diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000000..8b077fbd74 --- /dev/null +++ b/.gitattributes @@ -0,0 +1 @@ +changelog.txt merge=union diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md new file mode 100644 index 0000000000..e682b17afc --- /dev/null +++ b/.github/pull_request_template.md @@ -0,0 +1 @@ +If this PR makes an externally-visible change in behavior, please add an appropriate line to `changelog.txt`. diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml new file mode 100644 index 0000000000..4982b36b06 --- /dev/null +++ b/.github/workflows/build.yml @@ -0,0 +1,29 @@ +name: Build + +on: [push, pull_request] + +jobs: + test: + uses: DFHack/dfhack/.github/workflows/test.yml@develop + with: + scripts_repo: ${{ github.repository }} + scripts_ref: ${{ github.ref }} + secrets: inherit + + docs: + uses: DFHack/dfhack/.github/workflows/build-linux.yml@develop + with: + scripts_repo: ${{ github.repository }} + scripts_ref: ${{ github.ref }} + artifact-name: docs + platform-files: false + common-files: false + docs: true + secrets: inherit + + lint: + uses: DFHack/dfhack/.github/workflows/lint.yml@develop + with: + scripts_repo: ${{ github.repository }} + scripts_ref: ${{ github.ref }} + secrets: inherit diff --git a/.github/workflows/clean-cache.yml b/.github/workflows/clean-cache.yml new file mode 100644 index 0000000000..3439019d01 --- /dev/null +++ b/.github/workflows/clean-cache.yml @@ -0,0 +1,11 @@ +name: Clean up PR caches + +on: + pull_request_target: + types: + - closed + +jobs: + cleanup: + uses: DFHack/dfhack/.github/workflows/clean-cache.yml@develop + secrets: inherit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000000..e5993ffdcb --- /dev/null +++ b/.gitignore @@ -0,0 +1,13 @@ +# kdevelop +*.kdev4 +.kdev4 + +# vim files +*.swp +.vimrc + +# VSCode files +.vscode + +# CLion +.idea diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000000..c5694a6f43 --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,39 @@ +ci: + autofix_prs: false + autoupdate_schedule: monthly +repos: +# shared across repos: +- repo: https://github.com/pre-commit/pre-commit-hooks + rev: v6.0.0 + hooks: + - id: check-added-large-files + - id: check-case-conflict + - id: check-executables-have-shebangs + - id: check-merge-conflict + - id: check-shebang-scripts-are-executable + - id: check-symlinks + - id: check-xml + - id: check-yaml + - id: destroyed-symlinks + - id: end-of-file-fixer + - id: mixed-line-ending + args: ['--fix=lf'] + - id: trailing-whitespace +- repo: https://github.com/python-jsonschema/check-jsonschema + rev: 0.37.4 + hooks: + - id: check-github-workflows +- repo: https://github.com/Lucas-C/pre-commit-hooks + rev: v1.5.6 + hooks: + - id: forbid-tabs + exclude_types: + - json + - id: remove-tabs + exclude_types: + - json +# specific to scripts: +- repo: https://github.com/pre-commit/pre-commit-hooks + rev: v6.0.0 + hooks: + - id: forbid-new-submodules diff --git a/.travis.yml b/.travis.yml deleted file mode 100644 index 9ff36d224f..0000000000 --- a/.travis.yml +++ /dev/null @@ -1,32 +0,0 @@ -sudo: false -language: generic -cache: - pip: true - directories: - - $HOME/lua53 -before_install: -- pip install --user sphinx==1.4 -- mkdir -p $HOME/dfhack -- wget https://github.com/DFHack/dfhack/archive/develop.tar.gz -O $HOME/dfhack.tar.gz -- tar xzf $HOME/dfhack.tar.gz -C $HOME/dfhack --strip-components 1 -- wget https://github.com/DFHack/df-structures/archive/master.tar.gz -O $HOME/dfhack/library/xml/xml.tar.gz -- tar xzf $HOME/dfhack/library/xml/xml.tar.gz -C $HOME/dfhack/library/xml --strip-components 1 -- wget https://github.com/DFHack/stonesense/archive/master.tar.gz -O $HOME/dfhack/plugins/stonesense/stonesense.tar.gz -- tar xzf $HOME/dfhack/plugins/stonesense/stonesense.tar.gz -C $HOME/dfhack/plugins/stonesense --strip-components 1 -- rmdir $HOME/dfhack/scripts -- ln -s "$(pwd)" $HOME/dfhack/scripts -- sh $HOME/dfhack/travis/build-lua.sh -script: -- export PATH="$PATH:$HOME/lua53/bin" -- sphinx-build -qW -j3 $HOME/dfhack $HOME/dfhack/docs/html -- python $HOME/dfhack/travis/lint.py -- python $HOME/dfhack/travis/script-docs.py . -- python $HOME/dfhack/travis/script-syntax.py --ext=lua --cmd="luac5.3 -p" -- python $HOME/dfhack/travis/script-syntax.py --ext=rb --cmd="ruby -c" -notifications: - email: false - irc: - channels: - - "chat.freenode.net#dfhack" - on_success: change - on_failure: always diff --git a/CMakeLists.txt b/CMakeLists.txt index ca5b92a792..a673d8298a 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -1,6 +1,15 @@ install(DIRECTORY ${CMAKE_CURRENT_SOURCE_DIR} DESTINATION ${DFHACK_DATA_DESTINATION} FILES_MATCHING PATTERN "*.lua" - PATTERN "*.rb" - PATTERN "3rdparty" EXCLUDE + PATTERN "*.json" + PATTERN "scripts/docs" EXCLUDE + PATTERN "scripts/test" EXCLUDE + PATTERN ".github" EXCLUDE + PATTERN ".vscode" EXCLUDE ) + +if(BUILD_TESTS) + install(DIRECTORY ${CMAKE_CURRENT_SOURCE_DIR}/test/ + DESTINATION ${DFHACK_DATA_DESTINATION}/scripts/test/scripts + ) +endif() diff --git a/adaptation.lua b/adaptation.lua new file mode 100644 index 0000000000..221fa7e317 --- /dev/null +++ b/adaptation.lua @@ -0,0 +1,64 @@ +local argparse = require('argparse') + +local function print_color(color, s) + dfhack.color(color) + dfhack.print(s) + dfhack.color(COLOR_RESET) +end + +local function show_one(unit) + local t = dfhack.units.getMiscTrait(unit, df.misc_trait_type.CaveAdapt) + local val = t and t.value or 0 + print_color(COLOR_RESET, ('%s has an adaptation level of '): + format(dfhack.units.getReadableName(unit))) + if val <= 399999 then + print_color(COLOR_GREEN, ('%d\n'):format(val)) + elseif val <= 599999 then + print_color(COLOR_YELLOW, ('%d\n'):format(val)) + else + print_color(COLOR_RED, ('%d\n'):format(val)) + end +end + +local function set_one(unit, value) + local t = dfhack.units.getMiscTrait(unit, df.misc_trait_type.CaveAdapt, true) + print(('%s has changed from an adaptation level of %d to %d'): + format(dfhack.units.getReadableName(unit), t.value, value)) + t.value = value +end + +local function get_units(all) + local units = all and dfhack.units.getCitizens() or {dfhack.gui.getSelectedUnit(true)} + if #units == 0 then + qerror('Please select a unit or specify the --all option') + end + return units +end + +local help, all = false, false +local positionals = argparse.processArgsGetopt({...}, { + {'a', 'all', handler=function() all = true end}, + {'h', 'help', handler=function() help = true end} +}) + +if help then + print(dfhack.script_help()) + return +end + +if not positionals[1] or positionals[1] == 'show' then + for _, unit in ipairs(get_units(all)) do + show_one(unit) + end +elseif positionals[1] == 'set' then + local value = argparse.nonnegativeInt(positionals[2], 'value') + if value > 800000 then + dfhack.printerr('clamping value to 800,000') + value = 800000 + end + for _, unit in ipairs(get_units(all)) do + set_one(unit, value) + end +else + qerror('unknown command: ' .. positionals[1]) +end diff --git a/adaptation.rb b/adaptation.rb deleted file mode 100644 index 23bb7ab88c..0000000000 --- a/adaptation.rb +++ /dev/null @@ -1,104 +0,0 @@ -# View or set cavern adaptation levels -# based on removebadthoughts.rb -=begin - -adaptation -========== -View or set level of cavern adaptation for the selected unit or the whole fort. -Usage: ``adaptation [value]``. The ``value`` must be -between 0 and 800,000 inclusive. - -=end - -# Color constants, values mapped to color_value enum in include/ColorText.h -COLOR_GREEN = 2 -COLOR_RED = 4 -COLOR_YELLOW = 14 -COLOR_WHITE = 15 - -def usage(s) - if nil != s - puts(s) - end - puts "Usage: adaptation [value]" - throw :script_finished -end - -mode = $script_args[0] || 'help' -who = $script_args[1] -value = $script_args[2] - -if 'help' == mode - usage(nil) -elsif 'show' != mode && 'set' != mode - usage("Invalid mode '#{mode}': must be either 'show' or 'set'") -end - -if nil == who - usage("Target not specified") -elsif 'him' != who && 'all' != who - usage("Invalid target '#{who}'") -end - -if 'set' == mode - if nil == value - usage("Value not specified") - elsif !/[[:digit:]]/.match(value) - usage("Invalid value '#{value}'") - end - - if 0 > value.to_i || 800000 < value.to_i - usage("Value must be between 0 and 800000") - end - value = value.to_i -end - -num_set = 0 - -set_adaptation_value = lambda { |u,v| - next if !df.unit_iscitizen(u) - next if u.flags2.killed - u.status.misc_traits.each { |t| - if t.id == :CaveAdapt - # TBD: expose the color_ostream console and color values of - # t.value based on adaptation level - if mode == 'show' - if df.respond_to?(:print_color) - print "Unit #{u.id} (#{u.name}) has an adaptation of " - case t.value - when 0..399999 - #df.print_color(COLOR_GREEN, "#{t.value}\n") - print "#{t.value}\n" - when 400000..599999 - df.print_color(COLOR_YELLOW, "#{t.value}\n") - else - df.print_color(COLOR_RED, "#{t.value}\n") - end - else - puts "Unit #{u.id} (#{u.name}) has an adaptation of #{t.value}" - end - elsif mode == 'set' - puts "Unit #{u.id} (#{u.name}) changed from #{t.value} to #{v}" - t.value = v - num_set += 1 - end - end - } -} - -case who -when 'him' - if u = df.unit_find - set_adaptation_value[u,value] - else - puts 'Please select a dwarf ingame' - end -when 'all' - df.unit_citizens.each { |uu| - set_adaptation_value[uu,value] - } -end - -if 'set' == mode - puts "#{num_set} unit#{'s' if num_set != 1} updated." -end diff --git a/add-recipe.lua b/add-recipe.lua index a7ebfb28a9..fe7fd63576 100644 --- a/add-recipe.lua +++ b/add-recipe.lua @@ -1,27 +1,8 @@ -- Script to add unknown crafting recipes to the player's civ. ---[====[ -add-recipe -========== -Adds unknown weapon and armor crafting recipes to your civ. -E.g. some civilizations never learn to craft high boots. This script can -help with that, and more. Only weapons and armor are currently supported; -things such as instruments are not. Available options: - -* ``add-recipe all`` adds *all* available weapons and armor, including exotic items - like blowguns, two-handed swords, and capes. - -* ``add-recipe native`` adds only native (but unknown) crafting recipes. Civilizations - pick randomly from a pool of possible recipes, which means not all civs get - high boots, for instance. This command gives you all the recipes your - civilisation could have gotten. - -* ``add-recipe single `` adds a single item by the given - item token. For example, ``add-recipe single SHOES:ITEM_SHOES_BOOTS``. -]====] local itemDefs = df.global.world.raws.itemdefs -local resources = df.historical_entity.find(df.global.ui.civ_id).resources -local civ = df.historical_entity.find(df.global.ui.civ_id).entity_raw +local resources = df.historical_entity.find(df.global.plotinfo.civ_id).resources +local civ = df.historical_entity.find(df.global.plotinfo.civ_id).entity_raw if (resources == nil) then qerror("Could not find entity resources") @@ -36,7 +17,7 @@ end --1: the currently known recipes to the player civ --2: the native (non-exotic) recipes to the player civ --3: all possible recipes including exotic -local categories = { +local categories = { --as:{1:'number[]',2:'number[]',3:'df.itemdef[]'}[] weapon = {resources.weapon_type, civ.equipment.weapon_id, itemDefs.weapons }, shield = {resources.shield_type, civ.equipment.shield_id, itemDefs.shields }, ammo = {resources.ammo_type, civ.equipment.ammo_id, itemDefs.ammo }, @@ -44,7 +25,8 @@ local categories = { gloves = {resources.gloves_type, civ.equipment.gloves_id, itemDefs.gloves }, shoes = {resources.shoes_type, civ.equipment.shoes_id, itemDefs.shoes }, helm = {resources.helm_type, civ.equipment.helm_id, itemDefs.helms }, - pants = {resources.pants_type, civ.equipment.pants_id, itemDefs.pants } + pants = {resources.pants_type, civ.equipment.pants_id, itemDefs.pants }, + tool = {resources.tool_type, civ.equipment.tool_id, itemDefs.tools} } local diggers = resources.digger_type @@ -81,49 +63,56 @@ function addItems(category, exotic) --category: the category of items we're adding --exotic: whether to add exotic items --returns: list of item objects that were added - known = category[1] - native = category[2] - all = category[3] - added = {} - + local known_category = category[1] + local native = category[2] + local all = category[3] + local added = {} --as:df.itemdef[] + for _, item in ipairs(all) do - subtype = item.subtype - itemOk = false - + local item = item --as:df.itemdef_weaponst + local subtype = item.subtype + local itemOk = false + local known = known_category + -- digging implements are seperate from weapons in entity resources. + if (df.itemdef_weaponst:is_instance(item) and item.skill_melee == df.job_skill.MINING) then + known = diggers + end + --check if it's a training weapon - t1, t2 = pcall(function () return item.flags.TRAINING == false end) - training = not(not t1 or t2) - - --we don't want procedural items with adjectives such as "wavy spears" - --(because they don't seem to be craftable even if added) + local t1, t2 = pcall(function () return item.flags.TRAINING == false end) + local training = not(not t1 or t2) + + --excludes procedural items, eg: "wavy spears" for divine origins --nor do we want known items or training items (because adding training --items seems to allow them to be made out of metals) - if (item.adjective == "" and not training and not checkKnown(known, subtype)) then + if (not item.base_flags.GENERATED and not training and not checkKnown(known, subtype)) then itemOk = true end - + if (not exotic and not checkNative(native, subtype)) then itemOk = false end - - --check that the weapon we're adding is not already known to the civ as - --a digging implement so picks don't get duplicated - if (checkKnown(diggers, subtype)) then - itemOk = false + + --if the weapon we're adding is a digging implement, add to diggers instead of weapons + --prevents picks from being duplicated, and puts great picks in correct category + if (df.itemdef_weaponst:is_instance(item) and item.skill_melee == df.job_skill.MINING) then + if (checkKnown(diggers, subtype)) then + itemOk = false + end end - + if (itemOk) then known:insert('#', subtype) table.insert(added, item) end end - + return added end - function printItems(itemList) - for _, v in ipairs(added) do + for _, v in ipairs(itemList) do + local v = v --as:df.itemdef_weaponst print("Added recipe " .. v.id .. " (" .. v.name .. ")") end end @@ -137,17 +126,29 @@ function addAllItems(exotic) printItems(addItems(categories.shoes, exotic)) printItems(addItems(categories.helm, exotic)) printItems(addItems(categories.pants, exotic)) + printItems(addItems(categories.tool, exotic)) end function addSingleItem(itemstring) - itemType, itemId = string.match(itemstring, "(.*):(.*)") - if (itemType == nil or itemId == nil) then return end - category = categories[string.lower(itemType)] + local itemType = nil --The category of the item, the word before the ":" + local itemId = nil --The item within that category, what goes after the ":" + + if (itemstring ~= nil) then + itemType, itemId = string.match(itemstring, "(.*):(.*)") + else + print("Usage: add-recipe single | Example: add-recipe single SHOES:ITEM_SHOES_BOOTS") + return + end + if (itemType == nil or itemId == nil) then + print("Usage: add-recipe single | Example: add-recipe single SHOES:ITEM_SHOES_BOOTS") + return + end + local category = categories[string.lower(itemType)] if (category == nil) then return end - known = category[1] - all = category[3] - - addedItem = nil + local known = category[1] + local all = category[3] + + local addedItem = nil --assume the user knows what they're doing, so no need for sanity checks for _, item in ipairs(all) do if (item.id == itemId) then @@ -156,14 +157,18 @@ function addSingleItem(itemstring) break end end - + if (addedItem ~= nil) then + local addedItem = addedItem --as:df.itemdef_weaponst print("Added recipe " .. addedItem.id .. " (" .. addedItem.name .. ")") + else + print("Could not add recipe: invalid item name") end end local args = {...} local cmd = args[1] + if (cmd == "all") then addAllItems(true) elseif (cmd == "native") then @@ -171,10 +176,5 @@ elseif (cmd == "native") then elseif (cmd == "single") then addSingleItem(args[2]) else - print("Available options:\n" - .."all: adds all supported crafting recipes.\n" - .."native: adds only unknown native recipes (eg. high boots for " - .."some dwarves)\n" - .."single: adds a specific item by itemstring (eg. " - .."SHOES:ITEM_SHOES_BOOTS)") + print(dfhack.script_help()) end diff --git a/add-thought.lua b/add-thought.lua index ba4e19a500..b68916d30b 100644 --- a/add-thought.lua +++ b/add-thought.lua @@ -1,47 +1,41 @@ -- Adds emotions to creatures. --@ module = true ---[====[ - -add-thought -=========== -Adds a thought or emotion to the selected unit. Can be used by other scripts, -or the gui invoked by running ``add-thought gui`` with a unit selected. - -]====] - -local utils=require('utils') +local script = require('gui.script') +local utils = require('utils') function addEmotionToUnit(unit,thought,emotion,severity,strength,subthought) - local emotions=unit.status.current_soul.personality.emotions - if not (tonumber(emotion)) then - emotion=df.emotion_type[emotion] --luacheck: retype + local personality = unit.status.current_soul.personality + local emotions = personality.emotions + if not tonumber(emotion) then + emotion = df.emotion_type[emotion] --luacheck: retype end - local properThought = tonumber(thought) --as:df.unit_thought_type + severity = tonumber(severity) or 0 + local properThought = tonumber(thought) or df.unit_thought_type[thought] local properSubthought = tonumber(subthought) if not properThought or not df.unit_thought_type[properThought] then - for k,syn in ipairs(df.global.world.raws.syndromes.all) do - if syn.syn_name==thought then + for _,syn in ipairs(df.global.world.raws.mat_table.syndromes.all) do + if syn.syn_name == thought then properThought = df.unit_thought_type.Syndrome properSubthought = syn.id break end end end - emotions:insert('#',{new=df.unit_personality.T_emotions, - type=tonumber(emotion), - unk2=1, - strength=tonumber(strength), - thought=properThought, - subthought=properSubthought, - severity=tonumber(severity), - unk7=0, - year=df.global.cur_year, - year_tick=df.global.cur_year_tick + emotions:insert('#', { + new=df.personality_moodst, + type=emotion, + strength=1, + relative_strength=tonumber(strength), + thought=properThought, + subthought=properSubthought, + severity=severity, + year=df.global.cur_year, + year_tick=df.global.cur_year_tick }) local divider=df.emotion_type.attrs[emotion].divider - if divider~=0 then - unit.status.current_soul.personality.stress_level=unit.status.current_soul.personality.stress_level+math.ceil(severity/df.emotion_type.attrs[emotion].divider) + if divider ~= 0 then + personality.stress = personality.stress + math.ceil(severity/df.emotion_type.attrs[emotion].divider) end end @@ -56,48 +50,37 @@ local validArgs = utils.invert({ }) function tablify(iterableObject) - local t={} + local t = {} for k,v in ipairs(iterableObject) do t[k] = v~=nil and v or 'nil' end return t end -if moduleMode then - return +if dfhack_flags.module then + return end local args = utils.processArgs({...}, validArgs) local unit = args.unit and df.unit.find(tonumber(args.unit)) or dfhack.gui.getSelectedUnit(true) - if not unit then qerror('A unit must be specified or selected.') end + if args.gui then - local script=require('gui.script') - script.start(function() - local tok,thought=script.showListPrompt('emotions','Which thought?',COLOR_WHITE,tablify(df.unit_thought_type),10,true) - if tok then - local eok,emotion=script.showListPrompt('emotions','Which emotion?',COLOR_WHITE,tablify(df.emotion_type),10,true) - if eok then - local sok,severity=script.showInputPrompt('emotions','At what severity?',COLOR_WHITE,'0') - if sok then - local stok,strength=script.showInputPrompt('emotions','At what strength?',COLOR_WHITE,'0') - if stok then - addEmotionToUnit(unit,thought,emotion,severity,strength,0) - end - end - end - end - end) + script.start(function() + local tok,thought = script.showListPrompt('emotions','Which thought?',COLOR_WHITE,tablify(df.unit_thought_type),10,true) + if not tok then return end + local eok,emotion = script.showListPrompt('emotions','Which emotion?',COLOR_WHITE,tablify(df.emotion_type),10,true) + if not eok then return end + local stok,strength = script.showInputPrompt('emotions','At what strength? 1 (Slight), 2 (Moderate), 5 (Strong), 10 (Intense).',COLOR_WHITE,'0') + if not stok then return end + addEmotionToUnit(unit,thought,emotion,0,strength,0) + end) else - local thought = args.thought or 180 - + local thought = args.thought or df.unit_thought_type.NeedsUnfulfilled local emotion = args.emotion or -1 - local severity = args.severity or 0 - local subthought = args.subthought or 0 - local strength = args.strength or 0 addEmotionToUnit(unit,thought,emotion,severity,strength,subthought) diff --git a/adv-fix-sleepers.lua b/adv-fix-sleepers.lua deleted file mode 100644 index 40ab6a74d4..0000000000 --- a/adv-fix-sleepers.lua +++ /dev/null @@ -1,43 +0,0 @@ ---Fixes all local bugged sleepers in adventure mode. ---[====[ - -adv-fix-sleepers -================ -Fixes :bug:`6798`. This bug is characterized by sleeping units who refuse to - awaken in adventure mode regardless of talking to them, hitting them, or - waiting so long you die of thirst. - -Usage: If you come accross one or more bugged sleepers in adventure mode, - simply run the script (type adv-fix-sleepers into the dfhack console), - and all nearby sleepers will be cured. - -]====] - ---======================== --- Author: ArrowThunder on bay12 & reddit --- Version: 1.1 ---======================= - --- get the list of all the active units currently loaded -local active_units = df.global.world.units.active -- get all active units - --- check every active unit for the bug -local num_fixed = 0 -- this is the number of army controllers fixed, not units - -- I've found that often, multiple sleepers share a bugged army controller -for k, unit in pairs(active_units) do - if unit then - local army_controller = df.army_controller.find(unit.enemy.army_controller_id) - if army_controller and army_controller.type == 4 then -- sleeping code is possible - if army_controller.unk_64.t4.unk_2.not_sleeping == false then - army_controller.unk_64.t4.unk_2.not_sleeping = true -- fix bug - num_fixed = num_fixed + 1 - end - end - end -end - -if num_fixed == 0 then - print ("No sleepers with the fixable bug were found, sorry.") -else - print ("Fixed " .. num_fixed .. " bugged army_controller(s).") -end diff --git a/adv-max-skills.lua b/adv-max-skills.lua index 950bab27ff..f895933d34 100644 --- a/adv-max-skills.lua +++ b/adv-max-skills.lua @@ -11,7 +11,7 @@ if dfhack.gui.getCurFocus() ~= 'setupadventure' then qerror('Must be called on adventure mode setup screen') end -local adv = dfhack.gui.getCurViewscreen().adventurer --hint:df.viewscreen_setupadventurest +local adv = dfhack.gui.getCurViewscreen().party_members[0] --hint:df.viewscreen_setupadventurest for k in pairs(adv.skills) do adv.skills[k] = df.skill_rating.Legendary5 end diff --git a/adv-rumors.lua b/adv-rumors.lua deleted file mode 100644 index 1099df621b..0000000000 --- a/adv-rumors.lua +++ /dev/null @@ -1,83 +0,0 @@ --- Improve "Bring up specific incident or rumor" menu in Adventure mode ---@ module = true ---[====[ - -adv-rumors -========== -Improves the "Bring up specific incident or rumor" menu in Adventure mode. - -- Moves entries into one line -- Adds a "slew" keyword for filtering, making it easy to find your kills and not your companions' -- Trims repetitive words - -]====] - ---======================== --- Author : 1337G4mer on bay12 and reddit --- Version : 0.2 --- Description : A small utility based on dfhack to improve the rumor UI in adventure mode. --- --- In game when you want to boast about your kill to someone. Start conversation and choose --- the menu "Bring up specific incident or rumor" --- type rumors in dfhack window and hit enter. Or do the below keybind and use that directly from DF window. --- --- Prior Configuration: (you can skip this if you want) --- Set the three boolean values below and play around with the script as to how you like --- improveReadability = will move everything in one line --- addKeywordSlew = will add a keyword for filtering using slew, making it easy to find your kills and not your companion's --- shortenString = will further shorten the line to = slew "XYZ" ( "n time" ago in " Region") ---======================= - -local utils = require "utils" - -local names_blacklist = utils.invert{"a", "an", "you", "attacked", "slew", "was", "slain", "by"} - -function condenseChoiceTitle(choice) - while #choice.title > 1 do - choice.title[0].value = choice.title[0].value .. ' ' .. choice.title[1].value - choice.title:erase(1) - end -end - -function addKeyword(choice, keyword) - local keyword_ptr = df.new('string') - keyword_ptr.value = keyword - choice.keywords:insert('#', keyword_ptr) -end - -function rumorUpdate() - local improveReadability = true - local addKeywordSlew = true - local shortenString = true - local addKeywordNames = true - - for i, choice in ipairs(df.global.ui_advmode.conversation.choices) do - if choice.choice.type == df.talk_choice_type.SummarizeConflict then - if improveReadability then - condenseChoiceTitle(choice) - end - if shortenString then - condenseChoiceTitle(choice) - choice.title[0].value = choice.title[0].value - :gsub("Summarize the conflict in which +", "") - :gsub("This occurred +", "") - end - if addKeywordSlew then - if string.find(choice.title[0].value, "slew") then - addKeyword(choice, 'slew') - end - end - if addKeywordNames then - local title = choice.title[0].value - for keyword in title:sub(1, title:find('%(') - 1):gmatch('%w+') do - keyword = dfhack.utf2df(dfhack.df2utf(keyword):lower()) - if not names_blacklist[keyword] then - addKeyword(choice, keyword) - end - end - end - end - end -end - -rumorUpdate() diff --git a/advtools.lua b/advtools.lua new file mode 100644 index 0000000000..9f4d2ec3b3 --- /dev/null +++ b/advtools.lua @@ -0,0 +1,32 @@ +--@ module=true + +local convo = reqscript('internal/advtools/convo') +local fastcombat = reqscript('internal/advtools/fastcombat') +local party = reqscript('internal/advtools/party') + +OVERLAY_WIDGETS = { + conversation=convo.AdvRumorsOverlay, + fastcombat=fastcombat.AdvCombatOverlay, +} + +if dfhack_flags.module then + return +end + +local commands = { + party=party.run, +} + +local args = {...} +local command = table.remove(args, 1) + +if not command or command == 'help' or not commands[command] then + print(dfhack.script_help()) + return +end + +-- since these are "advtools", maybe don't let them run outside adventure mode. +if not dfhack.world.isAdventureMode() then + qerror("This script can only be used during adventure mode!") +end +commands[command](args) diff --git a/agitation-rebalance.lua b/agitation-rebalance.lua new file mode 100644 index 0000000000..ae22593a67 --- /dev/null +++ b/agitation-rebalance.lua @@ -0,0 +1,790 @@ +--@module = true +--@enable = true + +local eventful = require('plugins.eventful') +local exterminate = reqscript('exterminate') +local gui = require('gui') +local overlay = require('plugins.overlay') +local utils = require('utils') +local widgets = require('gui.widgets') + +local GLOBAL_KEY = 'agitation-rebalance' +local UNIT_EVENT_FREQ = 5 + +local presets = { + casual={ + wild_irritate_min=100000, + wild_sens=100000, + wild_irritate_decay=100000, + cavern_dweller_max_attackers=0, + }, + lenient={ + wild_irritate_min=10000, + wild_sens=10000, + wild_irritate_decay=5000, + cavern_dweller_max_attackers=20, + }, + strict={ + wild_irritate_min=2500, + wild_sens=500, + wild_irritate_decay=1000, + cavern_dweller_max_attackers=50, + }, + insane={ + wild_irritate_min=600, + wild_sens=200, + wild_irritate_decay=200, + cavern_dweller_max_attackers=100, + }, +} + +local vanilla_presets = { + casual={ + wild_irritate_min=2000, + wild_sens=10000, + wild_irritate_decay=500, + cavern_dweller_max_attackers=0, + }, + lenient={ + wild_irritate_min=2000, + wild_sens=10000, + wild_irritate_decay=500, + cavern_dweller_max_attackers=50, + }, + strict={ + wild_irritate_min=0, + wild_sens=10000, + wild_irritate_decay=100, + cavern_dweller_max_attackers=75, + }, +} + +local function get_default_state() + return { + enabled=false, + features={ + auto_preset=true, + surface=true, + cavern=true, + cap_invaders=true, + }, + caverns={ + last_invasion_id=-1, + last_year_roll=-1, + last_season_roll=-1, + baseline=0, + player_visible_baseline=0, + }, + stats={ + surface_attacks=0, + cavern_attacks=0, + invasions_diverted=0, + invaders_vaporized=0, + }, + } +end + +state = state or get_default_state() +new_unit_min_frame_counter = new_unit_min_frame_counter or -1 +num_cavern_invaders = num_cavern_invaders or 0 +num_cavern_invaders_frame_counter = num_cavern_invaders_frame_counter or -1 + +function isEnabled() + return state.enabled +end + +local function get_stat(stat) + return ensure_key(state, 'stats')[stat] or 0 +end + +local function inc_stat(stat) + local cur_val = get_stat(stat) + state.stats[stat] = cur_val + 1 +end + +local function persist_state() + dfhack.persistent.saveSiteData(GLOBAL_KEY, state) +end + +local world = df.global.world +local map_features = world.features.map_features +local plotinfo = df.global.plotinfo +local custom_difficulty = plotinfo.main.custom_difficulty + +local function on_surface_attack() + if plotinfo.outdoor_irritation > custom_difficulty.wild_irritate_min then + plotinfo.outdoor_irritation = custom_difficulty.wild_irritate_min + inc_stat('surface_attacks') + persist_state() + end +end + +local function get_cumulative_irritation() + local irritation = 0 + for _, map_feature in ipairs(map_features) do + if df.feature_init_subterranean_from_layerst:is_instance(map_feature) then + irritation = irritation + map_feature.feature.irritation_level + end + end + return irritation +end + +local function get_cavern_irritation(which) + for _,map_feature in ipairs(map_features) do + if not df.feature_init_subterranean_from_layerst:is_instance(map_feature) then + goto continue + end + if map_feature.start_depth == which then + return map_feature.feature.irritation_level + end + ::continue:: + end +end + +-- returns the minimum irritation level that will max out chances of +-- both cavern invasions and forgotten beasts +local function get_normalized_irritation(which) + local irritation = get_cavern_irritation(which) + if not irritation then return 0 end + local wealth_rating = plotinfo.tasks.wealth.total // custom_difficulty.forgotten_wealth_div + local irritation_min = custom_difficulty.forgotten_irritate_min + return math.max(10000, irritation_min - wealth_rating + custom_difficulty.forgotten_sens) +end + +local function get_cavern_sens() + return (custom_difficulty.wild_irritate_min + custom_difficulty.wild_sens)//2 +end + +local function on_cavern_attack(invasion_id) + state.caverns.last_invasion_id = invasion_id + for _,map_feature in ipairs(map_features) do + if not df.feature_init_subterranean_from_layerst:is_instance(map_feature) then + goto continue + end + local normalized_irritation = get_normalized_irritation(map_feature.start_depth) + map_feature.feature.irritation_level = math.min( + map_feature.feature.irritation_level, + 100000-get_cavern_sens(), -- values above this are too close to max limit + normalized_irritation) -- values above this are effectively the same + ::continue:: + end + state.caverns.baseline = get_cumulative_irritation() + inc_stat('cavern_attacks') + persist_state() +end + +local function is_unkilled(unit) + return not dfhack.units.isKilled(unit) and + unit.animal.vanish_countdown <= 0 -- not yet exterminated +end + +local function is_cavern_invader(unit) + local invasion = df.invasion_info.find(unit.invasion_id) + return invasion and + invasion.origin_master_army_controller_id == -1 and + not unit.flags1.caged and + not dfhack.units.isTame(unit) +end + +local function on_cavern_invader_over_max() + -- process units from the end of the active units first so we tend to + -- preserve animal person invaders over the war animals they bring + for i=#world.units.active-1,0,-1 do + local unit = world.units.active[i] + if not is_cavern_invader(unit) or not is_unkilled(unit) then + goto continue + end + exterminate.killUnit(unit, exterminate.killMethod.DISINTEGRATE) + num_cavern_invaders = num_cavern_invaders - 1 + inc_stat('invaders_vaporized') + if num_cavern_invaders <= custom_difficulty.cavern_dweller_max_attackers then + break + end + ::continue:: + end + persist_state() +end + +local function get_cavern_invaders() + local invaders = {} + for _, unit in ipairs(world.units.active) do + if is_unkilled(unit) and is_cavern_invader(unit) then + table.insert(invaders, unit) + end + end + return invaders +end + +local function get_num_cavern_invaders(slack) + slack = slack or 0 + if num_cavern_invaders_frame_counter + slack < world.frame_counter then + num_cavern_invaders = #get_cavern_invaders() + num_cavern_invaders_frame_counter = world.frame_counter + if num_cavern_invaders == 0 and + state.caverns.baseline ~= state.caverns.player_visible_baseline + then + state.caverns.player_visible_baseline = state.caverns.baseline + persist_state() + end + end + return num_cavern_invaders +end + +local function get_agitated_units() + local agitators = {} + for _, unit in ipairs(world.units.active) do + if is_unkilled(unit) and dfhack.units.isAgitated(unit) then + table.insert(agitators, unit) + end + end + return agitators +end + +local function check_new_unit(unit_id) + -- when just enabling, ignore the first batch of "new" units so we + -- don't react to existing agitated units or cavern invaders + if new_unit_min_frame_counter >= world.frame_counter then return end + local unit = df.unit.find(unit_id) + if not unit or not is_unkilled(unit) then return end + if state.features.surface and dfhack.units.isAgitated(unit) then + on_surface_attack() + return + end + if not state.features.cap_invaders or not is_cavern_invader(unit) then + return + end + if state.caverns.last_invasion_id ~= unit.invasion_id then + on_cavern_attack(unit.invasion_id) + end + if state.features.cap_invaders and + get_num_cavern_invaders() > custom_difficulty.cavern_dweller_max_attackers + then + on_cavern_invader_over_max() + end +end + +local function cull_invaders() + if not state.features.cap_invaders then return end + if get_num_cavern_invaders() > custom_difficulty.cavern_dweller_max_attackers then + on_cavern_invader_over_max() + end +end + +local function get_cavern_attack_independent_natural_chance(which) + return math.min(1, (get_cavern_irritation(which) or 0) / 10000) +end + +local function get_cavern_attack_natural_chances() + local cavern_1_chance = get_cavern_attack_independent_natural_chance(df.layer_type.Cavern1) + local cavern_2_chance = get_cavern_attack_independent_natural_chance(df.layer_type.Cavern2) + local cavern_3_chance = get_cavern_attack_independent_natural_chance(df.layer_type.Cavern3) + return cavern_1_chance, + (1-cavern_1_chance) * cavern_2_chance, + (1-cavern_1_chance) * (1-cavern_2_chance) * cavern_3_chance +end + +local function cavern_attack_passes_roll() + local irritation = get_cumulative_irritation() - state.caverns.baseline + local irr_max = get_cavern_sens() + if state.caverns.baseline == 0 then + -- normalize chances if irritation < 10000 + local c1, c2, c3 = get_cavern_attack_natural_chances() + irr_max = math.floor(irr_max * (c1 + c2 + c3)) + end + if irritation >= irr_max then return true end + return math.random(1, irr_max) <= irritation +end + +local function throttle_invasions() + if not state.features.cavern then return end + if state.caverns.last_year_roll == df.global.cur_year and + state.caverns.last_season_roll >= df.global.cur_season or + state.caverns.last_year_roll >= df.global.cur_year + then + -- only roll once per season + return + end + local over_cap = state.features.cap_invaders and + get_num_cavern_invaders() >= custom_difficulty.cavern_dweller_max_attackers + for idx=#df.global.timed_events-1,0,-1 do + local ev = df.global.timed_events[idx] + if ev.type ~= df.timed_event_type.FeatureAttack then goto continue end + local civ = ev.entity + if not civ then goto continue end + if over_cap or not cavern_attack_passes_roll() then + inc_stat('invasions_diverted') + df.global.timed_events:erase(idx) + ev:delete() + end + ::continue:: + end + state.caverns.last_year_roll = df.global.cur_year + state.caverns.last_season_roll = df.global.cur_season + persist_state() +end + +local function do_preset(preset_name) + local preset = presets[preset_name] + if not preset then + qerror(('preset not found: "%s"'):format(preset_name)) + end + utils.assign(custom_difficulty, preset) + print('agitation-rebalance: preset applied: ' .. preset_name) +end + +local TICKS_PER_DAY = 1200 +local TICKS_PER_MONTH = 28 * TICKS_PER_DAY +local TICKS_PER_SEASON = 3 * TICKS_PER_MONTH + +local function seasons_cleaning() + if not state.enabled then return end + cull_invaders() + throttle_invasions() + local ticks_until_next_season = TICKS_PER_SEASON - df.global.cur_season_tick + 1 + dfhack.timeout(ticks_until_next_season, 'ticks', seasons_cleaning) +end + +local function check_preset() + for preset_name,vanilla_settings in pairs(vanilla_presets) do + local matched = true + for k,v in pairs(vanilla_settings) do + if custom_difficulty[k] ~= v then + matched = false + break + end + end + if matched then + do_preset(preset_name) + break + end + end +end + +local function do_enable() + state.enabled = true + new_unit_min_frame_counter = world.frame_counter + UNIT_EVENT_FREQ + 1 + num_cavern_invaders_frame_counter = -(UNIT_EVENT_FREQ+1) + eventful.enableEvent(eventful.eventType.UNIT_NEW_ACTIVE, UNIT_EVENT_FREQ) + eventful.onUnitNewActive[GLOBAL_KEY] = check_new_unit + if state.features.auto_preset then check_preset() end + seasons_cleaning() +end + +local function do_disable() + state.enabled = false + eventful.onUnitNewActive[GLOBAL_KEY] = nil +end + +dfhack.onStateChange[GLOBAL_KEY] = function(sc) + if sc == SC_MAP_UNLOADED then + do_disable() + return + end + if sc ~= SC_MAP_LOADED or not dfhack.world.isFortressMode() then + return + end + state = get_default_state() + utils.assign(state, dfhack.persistent.getSiteData(GLOBAL_KEY, state)) + num_cavern_invaders = num_cavern_invaders or 0 + num_cavern_invaders_frame_counter = -(UNIT_EVENT_FREQ+1) + if state.enabled then + do_enable() + end +end + +----------------------------------- +-- IrritationOverlay +-- + +IrritationOverlay = defclass(IrritationOverlay, overlay.OverlayWidget) +IrritationOverlay.ATTRS{ + desc='Monitors irritation and shows chances of invasion.', + default_pos={x=-32,y=5}, + viewscreens='dwarfmode/Default', + overlay_onupdate_max_freq_seconds=5, + frame={w=24, h=13}, +} + +local function get_savagery() + -- need to check at (or about) ground level since biome data may be missing or incorrect + -- in the extreme top or bottom levels of the map + local ground_level = (world.map.z_count-2) - world.worldgen.worldgen_parms.levels_above_ground + local rgnX, rgnY + for z=ground_level,0,-1 do + rgnX, rgnY = dfhack.maps.getTileBiomeRgn(0, 0, z) + if rgnX then break end + end + local biome = dfhack.maps.getRegionBiome(rgnX, rgnY) + return biome and biome.savagery or 0 +end + +-- returns chance for next wildlife group +local function get_surface_attack_chance() + local adjusted_irritation = plotinfo.outdoor_irritation - custom_difficulty.wild_irritate_min + if adjusted_irritation <= 0 or get_savagery() <= 65 then return 0 end + return custom_difficulty.wild_sens <= 0 and 100 or + math.min(100, (adjusted_irritation*100)//custom_difficulty.wild_sens) +end + +-- returns chance for next season +local function get_fb_attack_chance(which) + local irritation = get_cavern_irritation(which) + if not irritation then return 0 end + local wealth_rating = plotinfo.tasks.wealth.total // custom_difficulty.forgotten_wealth_div + local irritation_min = custom_difficulty.forgotten_irritate_min + local adjusted_irritation = wealth_rating + irritation - irritation_min + if adjusted_irritation < 0 then return 0 end + return custom_difficulty.forgotten_sens <= 0 and 33 or + math.min(33, (adjusted_irritation*33)//custom_difficulty.forgotten_sens) +end + +local function get_cavern_attack_natural_chance(which) + local c1, c2, c3 = get_cavern_attack_natural_chances() + if which == df.layer_type.Cavern1 then + return math.floor(c1 * 100) + elseif which == df.layer_type.Cavern2 then + return math.floor(c2 * 100) + elseif which == df.layer_type.Cavern3 then + return math.floor(c3 * 100) + else + return math.floor((c1+c2+c3) * 100) + end +end + +local function get_cavern_invasion_chance(which) + if not state.enabled then + return get_cavern_attack_natural_chance(which) + end + + -- don't divilge new lowered chances until the current crop of invaders is gone + local baseline = num_cavern_invaders == 0 and + state.caverns.baseline or state.caverns.player_visible_baseline + local irritation = get_cumulative_irritation() - baseline + local irr_max = get_cavern_sens() + local c1, c2, c3 = get_cavern_attack_natural_chances() + local natural_chance = c1 + c2 + c3 + if state.caverns.baseline == 0 then + -- normalize chances if we've never had an attack + irr_max = math.floor(irr_max * natural_chance) + end + local overall_chance = math.min(1, irritation * natural_chance / irr_max) + + if which == df.layer_type.Cavern1 then + return math.floor(c1 * 100 * overall_chance) + elseif which == df.layer_type.Cavern2 then + return math.floor(c2 * 100 * overall_chance) + elseif which == df.layer_type.Cavern3 then + return math.floor(c3 * 100 * overall_chance) + else + return math.floor(natural_chance * 100 * overall_chance) + end +end + +local function get_chance_color(chance_fn, chance_arg) + local chance = chance_fn(chance_arg) + if chance < 1 then + return COLOR_GREEN + elseif chance < 33 then + return COLOR_YELLOW + elseif chance < 51 then + return COLOR_LIGHTRED + end + return COLOR_RED +end + +local function obfuscate_chance(chance_fn, chance_arg) + local chance = chance_fn(chance_arg) + if chance < 1 then + return 'None' + elseif chance < 33 then + return 'Low' + elseif chance < 51 then + return 'Med' + end + return 'High' +end + +local function get_invader_color() + if num_cavern_invaders <= 0 then + return COLOR_GREEN + elseif num_cavern_invaders < custom_difficulty.cavern_dweller_max_attackers then + return COLOR_YELLOW + else + return COLOR_RED + end +end + +-- set to true with :lua reqscript('agitation-rebalance').monitor_debug=true +-- to see more information on the monitor panel +monitor_debug = monitor_debug or false + +function IrritationOverlay:init() + local panel = widgets.Panel{ + frame_style=gui.FRAME_MEDIUM, + frame_background=gui.CLEAR_PEN, + frame={t=0, r=0, w=15, h=5}, + visible=function() return not monitor_debug end, + } + panel:addviews{ + widgets.Label{ + frame={t=0}, + text='Irrit. Threat', + auto_width=true, + }, + widgets.Label{ + frame={t=1, l=0}, + text={ + 'Surface:', + {gap=1, text=curry(obfuscate_chance, get_surface_attack_chance)}, + }, + text_pen=curry(get_chance_color, get_surface_attack_chance), + }, + widgets.Label{ + frame={t=2, l=0}, + text={ + 'Caverns:', + {gap=1, text=curry(obfuscate_chance, get_cavern_invasion_chance)}, + }, + text_pen=curry(get_chance_color, get_cavern_invasion_chance), + }, + } + + local debug_panel = widgets.Panel{ + frame_style=gui.FRAME_MEDIUM, + frame_background=gui.CLEAR_PEN, + visible=function() return monitor_debug end, + } + debug_panel:addviews{ + widgets.Label{ + frame={t=0, l=0}, + text='Attack chance', + }, + widgets.Label{ + frame={t=1, l=0}, + text={ + ' Surface:', + {gap=1, text=get_surface_attack_chance, width=3, rjustify=true}, + '%', + }, + text_pen=curry(get_chance_color, get_surface_attack_chance), + }, + widgets.Label{ + frame={t=2, l=0}, + text={ + 'Caverns:', + {gap=2, text='FBs:'}, + }, + }, + widgets.Label{ + frame={t=3, l=0}, + text={ + '1:', + {gap=2, text=curry(get_cavern_invasion_chance, df.layer_type.Cavern1), width=3, rjustify=true}, + '%', + }, + text_pen=curry(get_chance_color, get_cavern_invasion_chance, df.layer_type.Cavern1), + }, + widgets.Label{ + frame={t=3, l=10}, + text={ + {text=curry(get_fb_attack_chance, df.layer_type.Cavern1), width=3, rjustify=true}, + '%', + }, + text_pen=curry(get_chance_color, get_fb_attack_chance, df.layer_type.Cavern1), + }, + widgets.Label{ + frame={t=4, l=0}, + text={ + '2:', + {gap=2, text=curry(get_cavern_invasion_chance, df.layer_type.Cavern2), width=3, rjustify=true}, + '%', + }, + text_pen=curry(get_chance_color, get_cavern_invasion_chance, df.layer_type.Cavern2), + }, + widgets.Label{ + frame={t=4, l=10}, + text={ + {text=curry(get_fb_attack_chance, df.layer_type.Cavern2), width=3, rjustify=true}, + '%', + }, + text_pen=curry(get_chance_color, get_fb_attack_chance, df.layer_type.Cavern2), + }, + widgets.Label{ + frame={t=5, l=0}, + text={ + '3:', + {gap=2, text=curry(get_cavern_invasion_chance, df.layer_type.Cavern3), width=3, rjustify=true}, + '%', + }, + text_pen=curry(get_chance_color, get_cavern_invasion_chance, df.layer_type.Cavern3), + }, + widgets.Label{ + frame={t=5, l=10}, + text={ + {text=curry(get_fb_attack_chance, df.layer_type.Cavern3), width=3, rjustify=true}, + '%', + }, + text_pen=curry(get_chance_color, get_fb_attack_chance, df.layer_type.Cavern3), + }, + widgets.Label{ + frame={t=0, r=0}, + text='Irrit', + auto_width=true, + }, + widgets.Label{ + frame={t=1, r=0}, + text={{text=function() return plotinfo.outdoor_irritation end, width=6, rjustify=true}}, + text_pen=curry(get_chance_color, get_surface_attack_chance), + auto_width=true, + }, + widgets.Label{ + frame={t=3, r=0}, + text={{text=function() return get_cavern_irritation(df.layer_type.Cavern1) end, width=6, rjustify=true}}, + text_pen=curry(get_chance_color, get_cavern_invasion_chance, df.layer_type.Cavern1), + auto_width=true, + }, + widgets.Label{ + frame={t=4, r=0}, + text={{text=function() return get_cavern_irritation(df.layer_type.Cavern2) end, width=6, rjustify=true}}, + text_pen=curry(get_chance_color, get_cavern_invasion_chance, df.layer_type.Cavern2), + auto_width=true, + }, + widgets.Label{ + frame={t=5, r=0}, + text={{text=function() return get_cavern_irritation(df.layer_type.Cavern3) end, width=6, rjustify=true}}, + text_pen=curry(get_chance_color, get_cavern_invasion_chance, df.layer_type.Cavern3), + auto_width=true, + }, + widgets.Label{ + frame={t=6, l=0}, + text={ + 'Invaders:', + {gap=1, text=function() return num_cavern_invaders end, width=4, rjustify=true}, + '/', + {text=function() return custom_difficulty.cavern_dweller_max_attackers end}, + }, + text_pen=function() return get_invader_color() end, + }, + widgets.Label{ + frame={t=7, l=0}, + text={ + 'Surface attacks:', + {gap=1, text=function() return get_stat('surface_attacks') end, width=5, rjustify=true}, + }, + }, + widgets.Label{ + frame={t=8, l=0}, + text={ + ' Cavern attacks:', + {gap=1, text=function() return get_stat('cavern_attacks') end, width=5, rjustify=true}, + }, + }, + widgets.Label{ + frame={t=9, l=0}, + text={ + 'Invasions erased:', + {gap=1, text=function() return get_stat('invasions_diverted') end, width=4, rjustify=true}, + }, + }, + widgets.Label{ + frame={t=10, l=0}, + text={ + 'Invaders culled:', + {gap=1, text=function() return get_stat('invaders_vaporized') end, width=5, rjustify=true}, + }, + }, + } + + self:addviews{ + panel, + debug_panel, + widgets.HelpButton{command='agitation-rebalance'} + } +end + +function IrritationOverlay:overlay_onupdate() + get_num_cavern_invaders(UNIT_EVENT_FREQ) +end + +OVERLAY_WIDGETS = {monitor=IrritationOverlay} + +----------------------------------- +-- CLI +-- + +if dfhack_flags.module then + return +end + +if not dfhack.world.isFortressMode() or not dfhack.isMapLoaded() then + qerror('needs a loaded fortress map to work') +end + +local WIDGET_NAME = dfhack.current_script_name() .. '.monitor' + +local function print_status() + print(GLOBAL_KEY .. ' is ' .. (state.enabled and 'enabled' or 'not enabled')) + print() + print('features:') + for k,v in pairs(state.features) do + print((' %15s: %s'):format(k, v)) + end + print((' %15s: %s'):format('monitor', overlay.isOverlayEnabled(WIDGET_NAME) or 'false')) + print() + print('difficulty settings:') + print((' Wilderness irritation minimum: %d (about %d tree(s) until initial attacks are possible)'):format( + custom_difficulty.wild_irritate_min, custom_difficulty.wild_irritate_min // 100)) + print((' Wilderness sensitivity: %d (each tree past the miniumum makes an attack %.2f%% more likely)'):format( + custom_difficulty.wild_sens, 10000 / custom_difficulty.wild_sens)) + print((' Wilderness irritation decay: %d (about %d additional tree(s) allowed per year)'):format( + custom_difficulty.wild_irritate_decay, custom_difficulty.wild_irritate_decay // 100)) + print((' Cavern dweller maximum attackers: %d (maximum allowed across all caverns)'):format( + custom_difficulty.cavern_dweller_max_attackers)) + print() + local unhidden_invaders = {} + for _, unit in ipairs(get_cavern_invaders()) do + if not dfhack.units.isHidden(unit) then + table.insert(unhidden_invaders, unit) + end + end + print(('current agitated wildlife: %5d'):format(#get_agitated_units())) + print(('current known cavern invaders: %5d'):format(#unhidden_invaders)) + print() + print('current chances for an upcoming attack:') + print((' Surface: %s'):format(obfuscate_chance(get_surface_attack_chance))) + print((' Caverns: %s'):format(obfuscate_chance(get_cavern_invasion_chance))) +end + +local function enable_feature(which, enabled) + if which == 'monitor' then + dfhack.run_command('overlay', enabled and 'enable' or 'disable', WIDGET_NAME) + return + end + local feature = state.features[which] + if feature == nil then + qerror(('feature not found: "%s"'):format(which)) + end + state.features[which] = enabled + print(('feature %sabled: %s'):format(enabled and 'en' or 'dis', which)) +end + +local args = {...} +local command = table.remove(args, 1) + +if dfhack_flags and dfhack_flags.enable then + if dfhack_flags.enable_state then do_enable() + else do_disable() + end +elseif command == 'preset' then + do_preset(args[1] or '') +elseif command == 'enable' or command == 'disable' then + enable_feature(args[1] or '', command == 'enable') +elseif not command or command == 'status' then + print_status() + return +else + print(dfhack.script_help()) + return +end + +persist_state() diff --git a/allneeds.lua b/allneeds.lua new file mode 100644 index 0000000000..a3fa117cf1 --- /dev/null +++ b/allneeds.lua @@ -0,0 +1,84 @@ +-- Prints the sum of all citizens' needs. + +local argparse = require('argparse') + +local sorts = { + id=function(a,b) return a.id < b.id end, + strength=function(a,b) return a.strength > b.strength end, + focus=function(a,b) return a.focus < b.focus end, + freq=function(a,b) return a.freq > b.freq end, +} + +local sort = 'focus' + +argparse.processArgsGetopt({...}, { + {'s', 'sort', hasArg=true, handler=function(optarg) sort = optarg end} +}) + +if not sorts[sort] then + qerror(('unknown sort: "%s"'):format(sort)) +end + +local fulfillment_threshold = + { 300, 200, 100, -999, -9999, -99999 } + +local function getFulfillment(focus_level) + for i = 1, 6 do + if focus_level >= fulfillment_threshold[i] then + return i + end + end + return 7 +end + +local fort_needs = {} + +local units = dfhack.gui.getSelectedUnit(true) +if units then + print(('Summarizing needs for %s:'):format(dfhack.units.getReadableName(units))) + units = {units} +else + print('Summarizing needs for all (sane) citizens and residents:') + units = dfhack.units.getCitizens() +end +print() + +for _, unit in ipairs(units) do + local mind = unit.status.current_soul.personality.needs + -- sum need_level and focus_level for each need + for _,need in ipairs(mind) do + local needs = ensure_key(fort_needs, need.id) + needs.strength = (needs.strength or 0) + need.need_level + needs.focus = (needs.focus or 0) + need.focus_level + needs.freq = (needs.freq or 0) + 1 + + local level = getFulfillment(need.focus_level) + ensure_key(needs, 'fulfillment', {0, 0, 0, 0, 0, 0, 0}) + needs.fulfillment[level] = needs.fulfillment[level] + 1 + end +end + +local sorted_fort_needs = {} +for id, need in pairs(fort_needs) do + table.insert(sorted_fort_needs, { + id=df.need_type[id], + strength=need.strength, + focus=need.focus, + freq=need.freq, + fulfillment=need.fulfillment + }) +end + +table.sort(sorted_fort_needs, sorts[sort]) + +-- Print sorted output +local fmt = '%20s %8s %12s %9s %35s' +print(fmt:format("Need", "Strength", "Focus Impact", "Frequency", "Num. Unfettered -> Badly distracted")) +print(fmt:format("----", "--------", "------------", "---------", "-----------------------------------")) +for _, need in ipairs(sorted_fort_needs) do + local res = "" + for i = 1, 7 do + res = res..(('%5d'):format(need.fulfillment[i])) + end + print(fmt:format(need.id, need.strength, need.focus, need.freq, res)) +end diff --git a/animal-control.lua b/animal-control.lua new file mode 100644 index 0000000000..65c3364e81 --- /dev/null +++ b/animal-control.lua @@ -0,0 +1,135 @@ +-- helps manage the butchery and gelding of animals +-- Written by Josh Cooper(cppcooper) on 2020-02-18, last modified: 2020-03-01 +local utils=require('utils') +local validArgs = utils.invert({ + 'all', + 'id', + 'race', + 'markedfor', + 'notmarkedfor', + 'gelded', + 'notgelded', + 'male', + 'female', + + 'showstats', + 'markfor', + 'unmarkfor', + + 'help', +}) +local args = utils.processArgs({...}, validArgs) + +header_format = "%-20s %-9s %-9s %-5s %-22s %-8s %-25s" +row_format = "%-20s %-9d %-9d %-5s %-22s %-8s %-25s" +stats_header_format = "%-20s %-9s %-9s %-5s %-22s %-8s %-25s %-7s %-7s %-7s %-7s %-7s %-7s" +stats_row_format = "%-20s %-9d %-9d %-5s %-22s %-8s %-25s %-7d %-7d %-7d %-7d %-7d %-7d" + +header = header_format:format( + "animal type", "unit id", "race id", "sex", "marked for slaughter", + "gelded", "marked for gelding") +stats_header = stats_header_format:format( + "animal type", "unit id", "race id", "sex", "marked for slaughter", + "gelded", "marked for gelding", "str", "agi", "tgh", "endur", "recup", "disres") + +if args.race and not tonumber(args.race) then + args.race=string.upper(args.race) + local raceID + for i,c in ipairs(df.global.world.raws.creatures.all) do + if c.creature_id == args.race then + raceID = i + break + end + end + if not raceID then + qerror('Invalid race: ' .. args.race) + end + args.race = raceID +end + +bfilters = (args.id or args.race or args.markedfor or args.notmarkedfor or args.gelded or args.notgelded or args.male or args.female) +bcommands = (args.showstats or args.markfor or args.unmarkfor) +bvalid = (args.all and not bfilters) or (not args.all and (bfilters or bcommands)) + +if args.help or not bvalid then + print(dfhack.script_help()) +else + count=0 + if args.showstats then + print(stats_header) + else + print(header) + end + for _,v in ipairs(df.global.world.units.active) do + if v.civ_id == df.global.plotinfo.civ_id and v.flags1.tame then + if not (args.male or args.female) or args.male and v.sex == 1 or args.female and v.sex == 0 then + if not args.race or tonumber(args.race) == v.race then + if not args.markedfor or (args.markedfor == "slaughter" and v.flags2.slaughter) or (args.markedfor == "gelding" and v.flags3.marked_for_gelding) then + if not args.notmarkedfor or (args.notmarkedfor == "slaughter" and not v.flags2.slaughter) or (args.notmarkedfor == "gelding" and not v.flags3.marked_for_gelding) then + if not args.gelded or v.flags3.gelded then + if not args.notgelded or not v.flags3.gelded then + if not args.id or tonumber(args.id) == v.id then + count = count + 1 + name = dfhack.units.isAdult(v) and df.global.world.raws.creatures.all[v.race].name[0] or dfhack.units.getRaceChildName(v) + sex = v.sex == 1 and "M" or "F" + if args.markfor or args.unmarkfor then + if args.markfor then + mark = args.markfor + state = true + else + mark = args.unmarkfor + state = false + end + + if mark == "gelding" and sex == "M" then + --print("geld",state) + v.flags3.marked_for_gelding = state + elseif mark == "slaughter" then + --print("slaughter",state) + v.flags2.slaughter = state + end + end + attr = v.body.physical_attrs + if v.sex == 1 then + if args.showstats then + print(string.format(stats_row_format + ,name,v.id,v.race,sex + ,tostring(v.flags2.slaughter),tostring(v.flags3.gelded),tostring(v.flags3.marked_for_gelding) + ,attr.STRENGTH.value,attr.AGILITY.value,attr.TOUGHNESS.value,attr.ENDURANCE.value,attr.RECUPERATION.value,attr.DISEASE_RESISTANCE.value)) + else + print(string.format(row_format + ,name,v.id,v.race,sex + ,tostring(v.flags2.slaughter),tostring(v.flags3.gelded),tostring(v.flags3.marked_for_gelding))) + end + else + if args.showstats then + print(string.format(stats_row_format + ,name,v.id,v.race,sex + ,tostring(v.flags2.slaughter),"-","-" + ,attr.STRENGTH.value,attr.AGILITY.value,attr.TOUGHNESS.value,attr.ENDURANCE.value,attr.RECUPERATION.value,attr.DISEASE_RESISTANCE.value)) + else + print(string.format(row_format + ,name,v.id,v.race,sex + ,tostring(v.flags2.slaughter),"-","-")) + end + end + end + end + end + end + end + end + end + end + end + if not args.id then + if args.showstats then + print(stats_header) + else + print(header) + end + else + print("") + end + print(string.format("total: %d", count)) +end diff --git a/armoks-blessing.lua b/armoks-blessing.lua index 88a789aefb..6d57ab34eb 100644 --- a/armoks-blessing.lua +++ b/armoks-blessing.lua @@ -1,71 +1,120 @@ -- Adjust all attributes of all dwarves to an ideal -- by vjek ---[====[ -armoks-blessing -=============== -Runs the equivalent of `rejuvenate`, `elevate-physical`, `elevate-mental`, and -`brainwash` on all dwarves currently on the map. This is an extreme change, -which sets every stat and trait to an ideal easy-to-satisfy preference. +local rejuvenate = reqscript('rejuvenate') +local utils = require('utils') -Without providing arguments, only attributes, age, and personalities will be adjusted. -Adding arguments allows for skills or classes to be adjusted to legendary (maximum). - -Arguments: - -- ``list`` - Prints list of all skills - -- ``classes`` - Prints list of all classes - -- ``all`` - Set all skills, for all Dwarves, to legendary - -- ```` - Set a specific skill, for all Dwarves, to legendary - - example: ``armoks-blessing RANGED_COMBAT`` - - All Dwarves become a Legendary Archer - -- ```` - Set a specific class (group of skills), for all Dwarves, to legendary - - example: ``armoks-blessing Medical`` - - All Dwarves will have all medical related skills set to legendary - -]====] -local utils = require 'utils' -function rejuvenate(unit) - if unit==nil then - print ("No unit available! Aborting with extreme prejudice.") - return - end - - local current_year=df.global.cur_year - local newbirthyear=current_year - 20 - if unit.birth_year < newbirthyear then - unit.birth_year=newbirthyear - end - if unit.old_year < current_year+100 then - unit.old_year=current_year+100 - end - -end -- --------------------------------------------------------------------------- function brainwash_unit(unit) if unit==nil then print ("No unit available! Aborting with extreme prejudice.") return end +-- ~unit.status.current_soul.personality.traits + unit.status.current_soul.personality.traits.LOVE_PROPENSITY = 75 + unit.status.current_soul.personality.traits.HATE_PROPENSITY = 25 + unit.status.current_soul.personality.traits.ENVY_PROPENSITY = 25 + unit.status.current_soul.personality.traits.CHEER_PROPENSITY = 75 + unit.status.current_soul.personality.traits.DEPRESSION_PROPENSITY = 25 + unit.status.current_soul.personality.traits.ANGER_PROPENSITY = 25 + unit.status.current_soul.personality.traits.ANXIETY_PROPENSITY = 25 + unit.status.current_soul.personality.traits.LUST_PROPENSITY = 99 + unit.status.current_soul.personality.traits.STRESS_VULNERABILITY = 25 + unit.status.current_soul.personality.traits.GREED = 25 + unit.status.current_soul.personality.traits.IMMODERATION = 25 + unit.status.current_soul.personality.traits.VIOLENT = 50 + unit.status.current_soul.personality.traits.PERSEVERANCE = 75 + unit.status.current_soul.personality.traits.WASTEFULNESS = 50 + unit.status.current_soul.personality.traits.DISCORD = 25 + unit.status.current_soul.personality.traits.FRIENDLINESS = 75 + unit.status.current_soul.personality.traits.POLITENESS = 75 + unit.status.current_soul.personality.traits.DISDAIN_ADVICE = 50 + unit.status.current_soul.personality.traits.BRAVERY = 75 + unit.status.current_soul.personality.traits.CONFIDENCE = 75 + unit.status.current_soul.personality.traits.VANITY = 25 + unit.status.current_soul.personality.traits.AMBITION = 75 + unit.status.current_soul.personality.traits.GRATITUDE = 75 + unit.status.current_soul.personality.traits.IMMODESTY = 50 + unit.status.current_soul.personality.traits.HUMOR = 75 + unit.status.current_soul.personality.traits.VENGEFUL = 25 + unit.status.current_soul.personality.traits.PRIDE = 50 + unit.status.current_soul.personality.traits.CRUELTY = 25 + unit.status.current_soul.personality.traits.SINGLEMINDED = 75 + unit.status.current_soul.personality.traits.HOPEFUL = 75 + unit.status.current_soul.personality.traits.CURIOUS = 75 + unit.status.current_soul.personality.traits.BASHFUL = 25 + unit.status.current_soul.personality.traits.PRIVACY = 75 + unit.status.current_soul.personality.traits.PERFECTIONIST = 75 + unit.status.current_soul.personality.traits.CLOSEMINDED = 25 + unit.status.current_soul.personality.traits.TOLERANT = 75 + unit.status.current_soul.personality.traits.EMOTIONALLY_OBSESSIVE = 25 + unit.status.current_soul.personality.traits.SWAYED_BY_EMOTIONS = 25 + unit.status.current_soul.personality.traits.ALTRUISM = 75 + unit.status.current_soul.personality.traits.DUTIFULNESS = 75 + unit.status.current_soul.personality.traits.THOUGHTLESSNESS = 25 + unit.status.current_soul.personality.traits.ORDERLINESS = 75 + unit.status.current_soul.personality.traits.TRUST = 75 + unit.status.current_soul.personality.traits.GREGARIOUSNESS = 75 + unit.status.current_soul.personality.traits.ASSERTIVENESS = 25 + unit.status.current_soul.personality.traits.ACTIVITY_LEVEL = 75 + unit.status.current_soul.personality.traits.EXCITEMENT_SEEKING = 75 + unit.status.current_soul.personality.traits.IMAGINATION = 25 + unit.status.current_soul.personality.traits.ABSTRACT_INCLINED = 25 + unit.status.current_soul.personality.traits.ART_INCLINED = 50 - local profile ={75,25,25,75,25,25,25,99,25,25,25,50,75,50,25,75,75,50,75,75,25,75,75,50,75,25,50,25,75,75,75,25,75,75,25,75,25,25,75,75,25,75,75,75,25,75,75,25,25,50} - local i + unit.status.current_soul.personality.values:resize(0) + local list_of_values={ + [df.value_type.LAW]=-11, + [df.value_type.FAMILY]=11, + [df.value_type.FRIENDSHIP]=41, + [df.value_type.DECORUM]=11, + [df.value_type.TRADITION]=11, + [df.value_type.ARTWORK]=41, + [df.value_type.COOPERATION]=41, + [df.value_type.STOICISM]=11, + [df.value_type.INTROSPECTION]=41, + [df.value_type.SELF_CONTROL]=41, + [df.value_type.HARMONY]=11, + [df.value_type.MERRIMENT]=21, + [df.value_type.SKILL]=41, + [df.value_type.HARD_WORK]=41, + [df.value_type.SACRIFICE]=41, + [df.value_type.COMPETITION]=-41, + [df.value_type.PERSEVERANCE]=41, + [df.value_type.LEISURE_TIME]=-11, + [df.value_type.COMMERCE]=41, + [df.value_type.ROMANCE]=41, + [df.value_type.PEACE]=-11, + [df.value_type.KNOWLEDGE]=41, + } + for k,v in pairs(list_of_values) do + unit.status.current_soul.personality.values:insert("#",{new=true,type=k,strength=v}) + end - for i=1, #profile do - unit.status.current_soul.personality.traits[i-1]=profile[i] + unit.status.current_soul.personality.needs:resize(0) + local list_of_needs={ + [df.need_type.Socialize]=2, + [df.need_type.BeWithFriends]=2, + [df.need_type.TakeItEasy]=2, + [df.need_type.MakeMerry]=2, + [df.need_type.AdmireArt]=2, + -- [df.need_type.EatGoodMeal]=0, -- open bug 10262 as of 20200218 + [df.need_type.PrayOrMeditate]=0, + [df.need_type.DrinkAlcohol]=1, + } + for k,v in pairs(list_of_needs) do + if k == df.need_type.PrayOrMeditate then -- handle deity id, otherwise, the deity is set to -1 + if unit.hist_figure_id ~= -1 then + for index, link in ipairs(df.historical_figure.find(unit.hist_figure_id).histfig_links) do + if df.histfig_hf_link_deityst:is_instance(link) and link.target_hf ~= nil then + unit.status.current_soul.personality.needs:insert("#",{new=true,id=k,deity_id=link.target_hf,need_level=v}) + link.link_strength=1 -- casual worshipper + end + end + end + else + unit.status.current_soul.personality.needs:insert("#",{new=true,id=k,need_level=v}) + end end end @@ -140,22 +189,26 @@ function BreathOfArmok(unit) print ("The breath of Armok has engulfed "..unit.name.first_name) end -- --------------------------------------------------------------------------- -function LegendaryByClass(skilltype,v) - local unit=v - if unit==nil then +local function get_skill_desc(skill_idx) + return df.job_skill.attrs[skill_idx].caption or df.job_skill[skill_idx] or ("(unnamed skill %d)"):format(skill_idx) +end + +function LegendaryByClass(skilltype, unit) + if not unit then print ("No unit available! Aborting with extreme prejudice.") return end - local i - local skillclass local count_max = count_this(df.job_skill) for i=0, count_max do - skillclass = df.job_skill_class[df.job_skill.attrs[i].type] + if df.job_skill[i]:startswith('UNUSED') then goto continue end + local skillclass = df.job_skill_class[df.job_skill.attrs[i].type] if skilltype == skillclass then - print ("Skill "..df.job_skill.attrs[i].caption.." is type: "..skillclass.." and is now Legendary for "..unit.name.first_name) + local skillname = get_skill_desc(i) + print ("Skill "..skillname.." is type: "..skillclass.." and is now Legendary for "..unit.name.first_name) utils.insert_or_update(unit.status.current_soul.skills, { new = true, id = i, rating = 20 }, 'id') end + ::continue:: end end -- --------------------------------------------------------------------------- @@ -163,7 +216,7 @@ function PrintSkillList() local count_max = count_this(df.job_skill) local i for i=0, count_max do - print("'"..df.job_skill.attrs[i].caption.."' "..df.job_skill[i].." Type: "..df.job_skill_class[df.job_skill.attrs[i].type]) + print("'"..get_skill_desc(i).."' "..df.job_skill[i].." Type: "..df.job_skill_class[df.job_skill.attrs[i].type]) end print ("Provide the UPPER CASE argument, for example: PROCESSPLANTS rather than Threshing") end @@ -179,20 +232,18 @@ function PrintSkillClassList() end -- --------------------------------------------------------------------------- function adjust_all_dwarves(skillname) - for _,v in ipairs(df.global.world.units.all) do - if v.race == df.global.ui.race_id then - print("Adjusting "..dfhack.TranslateName(dfhack.units.getVisibleName(v))) - brainwash_unit(v) - elevate_attributes(v) - rejuvenate(v) - if skillname then - if df.job_skill_class[skillname] then - LegendaryByClass(skillname,v) - elseif skillname=="all" then - BreathOfArmok(v) - else - make_legendary(skillname,v) - end + for _,v in ipairs(dfhack.units.getCitizens()) do + print("Adjusting "..dfhack.df2console(dfhack.units.getReadableName(v))) + brainwash_unit(v) + elevate_attributes(v) + rejuvenate.rejuvenate(v, true) + if skillname then + if df.job_skill_class[skillname] then + LegendaryByClass(skillname,v) + elseif skillname=="all" then + BreathOfArmok(v) + else + make_legendary(skillname,v) end end end diff --git a/assign-attributes.lua b/assign-attributes.lua new file mode 100644 index 0000000000..2ba07b99f2 --- /dev/null +++ b/assign-attributes.lua @@ -0,0 +1,290 @@ +-- Change the attributes of a unit. +--@ module = true + +local help = [====[ + +assign-attributes +================= +A script to change the physical and mental attributes of a unit. + +Attributes are divided into tiers from -4 to 4. Tier 0 is the +standard level and represents the average values for that attribute, +tier 4 is the maximum level, and tier -4 is the minimum level. + +An example of the attribute "Strength": + +==== =================== +Tier Description +==== =================== +4 unbelievably strong +3 mighty +2 very strong +1 strong +0 (no description) +-1 weak +-2 very weak +-3 unquestionably weak +-4 unfathomably weak +==== =================== + +For more information: +https://dwarffortresswiki.org/index.php/DF2014:Attribute + +Usage: + +``-help``: + print the help page. + +``-unit ``: + the target unit ID. If not present, the + currently selected unit will be the target. + +``-attributes [ <...> ]``: + the list of the attributes to modify and their tiers. + The valid attribute names can be found in the wiki: + https://dwarffortresswiki.org/index.php/DF2014:Attribute + (substitute any space with underscores); tiers range from -4 + to 4. There must be a space before and after each square + bracket. + +``-reset``: + reset all attributes to the average level (tier 0). + If both this option and a list of attributes/tiers + are present, the unit attributes will be reset + and then the listed attributes will be modified. + +Example:: + + assign-attributes -reset -attributes [ STRENGTH 2 AGILITY -1 SPATIAL_SENSE -1 ] + +This will reset all attributes to a neutral value and will set the following +values (if the currently selected unit is a dwarf): + + * Strength: a random value between 1750 and 1999 (tier 2); + * Agility: a random value between 401 and 650 (tier -1); + * Spatial sense: a random value between 1043 and 1292 (tier -1). + +The final result will be: "She is very strong, but she is clumsy. +She has a questionable spatial sense." +]====] + +local utils = require("utils") + +local valid_args = utils.invert({ + 'help', + 'unit', + 'attributes', + 'reset', + }) + + +-- ----------------------------------------------- UTILITY FUNCTIONS ------------------------------------------------ -- +function print_yellow(text) + dfhack.color(COLOR_YELLOW) + print(text) + dfhack.color(-1) +end + +-- ----------------------------------------------- GENERATE MEDIANS ------------------------------------------------- -- +--- Retrieve and store the medians for the given race. Default race is DWARF. +local function generate_medians_table(race) + assert(not race or type(race) == "number" or type(race) == "string") + + race = race or "DWARF" + + local medians = { + PHYSICAL = {}, + MENTAL = {} + } + -- populate the medians table with the attributes and their standard "human" value + do + for _, physical_attribute in ipairs(df.physical_attribute_type) do + medians.PHYSICAL[physical_attribute] = 1000 + end + for _, mental_attribute in ipairs(df.mental_attribute_type) do + medians.MENTAL[mental_attribute] = 1000 + end + end + -- now update those medians that are different from the standard median (i.e. the ones listed in the creature raws) + do + local creatures = df.global.world.raws.creatures.all + local creature + if type(race) == "number" then + creature = creatures[race] + else + _, creature = utils.linear_index(creatures, race, "creature_id") + end + assert(creature ~= nil) + for _, raw in ipairs(creature.raws) do + if string.match(raw.value, "PHYS_ATT_RANGE") then + local token_parts = raw.value:split(":") + -- part 1 *2* 3 4 5 *6* 7 8 9 + -- [PHYS_ATT_RANGE:STRENGTH:450:950:1150:1250:1350:1550:2250] + medians.PHYSICAL[token_parts[2]] = tonumber(token_parts[6]) + elseif string.match(raw.value, "MENT_ATT_RANGE") then + local token_parts = raw.value:split(":") + -- part 1 *2* 3 4 5 *6* 7 8 9 + -- [MENT_ATT_RANGE:PATIENCE:450:950:1150:1250:1350:1550:2250] + medians.MENTAL[token_parts[2]] = tonumber(token_parts[6]) + end + end + end + return medians +end + +-- ----------------------------------------------- ASSIGN ATTRIBUTES ------------------------------------------------ -- +--- Assign the given attributes to a unit, resetting all the other attributes if requested. +--- :attributes: nil, or a table. The fields have the attribute token as key and the tier as value. +--- :unit: a valid unit id, a df.unit object, or nil. If nil, the currently selected unit will be targeted. +--- :reset: boolean, or nil. +function assign(attributes, unit, reset) + assert(not attributes or type(attributes) == "table") + assert(not unit or type(unit) == "number" or df.unit:is_instance(unit)) + assert(not reset or type(reset) == "boolean") + + attributes = attributes or {} + reset = reset or false + + if type(unit) == "number" then + unit = df.unit.find(tonumber(unit)) --luacheck:retype + else + unit = unit or dfhack.gui.getSelectedUnit(true) + end + if not unit then + qerror("No unit found.") + end + + local medians = generate_medians_table(unit.race) + + --- Given an attribute and a tier, calculate a random value within the limits of the tier and return it. + --- Tiers are normally 250 points apart, so they are 249 points wide, with the exception of tier 0 and tier 4: + --- tier 0 minimum value is 249 points below the median, and its maximum value is 249 points above the median; + --- tier 4 maximum value is theoretically 5000, but each unit has a maximum value equal to: + --- starting_value + max(starting_value, median) + --- where starting_value is the value of the attribute before any training. + --- The formula to calculate tier boundaries is: + --- median + (tier * 250). + --- For tiers 1, 2, 3 and 4, the formula returns the minimum value of the tier; for tiers -1, -2, -3, -4, the + --- formula returns the maximum value of the tier. Given that all tiers but tier 0 and tier 4 are 250 points + --- apart, for tiers -4, -3, -2, -1, 1, 2 and 3 we can add (for positive tiers) or subtract (for negative tiers) + --- up to 249 points to the formula result and still remain in the same tier. For tier 0, we can add or + --- subtract 249 points to the median value. For tier 4, we could add points up to the maximum value, but we won't + --- do it for simplicity's sake and we will treat tier 4 as any other tier. + local function convert_tier_to_value(attribute, tier) + assert(type(attribute) == "string") + assert(type(tier) == "number" and tier >= -4 or tier <= 4) + + -- decide if we need to add or subtract + local sign = 0 + if tier > 0 then + sign = 1 + elseif tier < 0 then + sign = -1 + else + -- tier 0: decide randomly if we'll add or subtract + local pos_or_neg = { 1, -1 } + sign = pos_or_neg[math.random(2)] + end + local random_offset = math.random(0, 249) * sign + local median = medians.PHYSICAL[attribute] or medians.MENTAL[attribute] + local value = math.max(0, median + tier * 250 + random_offset) + -- calculate the new max_value, using the new value as the starting value + local max_value = value + math.max(value, median) + return value, max_value + end + + -- reset attributes around the median value + if reset then + for attribute_token, attribute_obj in pairs(unit.body.physical_attrs) do + local v, max_v = convert_tier_to_value(attribute_token, 0) + attribute_obj.value = v + attribute_obj.max_value = max_v + end + for attribute_token, attribute_obj in pairs(unit.status.current_soul.mental_attrs) do + local v, max_v = convert_tier_to_value(attribute_token, 0) + attribute_obj.value = v + attribute_obj.max_value = max_v + end + end + + -- assign new attributes + for attribute, tier in pairs(attributes) do + attribute = attribute:upper() + + if medians.PHYSICAL[attribute] then + if tier >= -4 and tier <= 4 then + local v, max_v = convert_tier_to_value(attribute, tier) + unit.body.physical_attrs[attribute].value = v + unit.body.physical_attrs[attribute].max_value = max_v + else + print_yellow("WARNING: tier out of range for attribute '" .. attribute .. "'. Skipping...") + end + elseif medians.MENTAL[attribute] then + if tier >= -4 and tier <= 4 then -- code repetition, but I think the warning message it's clearer this way + local v, max_v = convert_tier_to_value(attribute, tier) + unit.status.current_soul.mental_attrs[attribute].value = v + unit.status.current_soul.mental_attrs[attribute].max_value = max_v + else + print_yellow("WARNING: tier out of range for attribute '" .. attribute .. "'. Skipping...") + end + else + print_yellow("WARNING: '" .. attribute .. "' is not a valid attribute. Skipping...") + end + end +end + +-- ------------------------------------------------------ MAIN ------------------------------------------------------ -- +local function main(...) + local args = utils.processArgs({ ... }, valid_args) + + if args.help then + print(help) + return + end + + local unit + if args.unit then + unit = tonumber(args.unit) + if not unit then + qerror("'" .. args.unit .. "' is not a valid unit ID.") + end + end + + local reset = false + if args.reset then + reset = true + end + + -- parse attributes list + local attributes = {} + if args.attributes then + local i = 1 + while i <= #args.attributes do + local v = args.attributes[i] + -- v can be an attribute name but it can also be a tier value, so we have to check + if not tonumber(v) then + -- assume it's a valid attribute name, for now + local attribute_name = tostring(v):upper() + -- then try to get the tier value + local tier_str = args.attributes[i + 1] + if not tier_str then + -- we reached the end of the attributes list + qerror("Missing tier value after '" .. v .. "'.") + end + local tier_int = tonumber(tier_str) + if not tier_int then + qerror("'" .. tier_str .. "' is not a valid number.") + end + -- assume the tier value is in range, for now + attributes[attribute_name] = tier_int + end + i = i + 1 -- skip next arg because we already consumed it + end + end + + assign(attributes, unit, reset) +end + +if not dfhack_flags.module then + main(...) +end diff --git a/assign-beliefs.lua b/assign-beliefs.lua new file mode 100644 index 0000000000..cc0de2c282 --- /dev/null +++ b/assign-beliefs.lua @@ -0,0 +1,207 @@ +-- Change the beliefs of a unit. +--@ module = true + +local help = [====[ + +assign-beliefs +============== +A script to change the beliefs (values) of a unit. + +Beliefs are defined with the belief token and a number from -3 to 3, +which describes the different levels of belief strength, as explained here: +https://dwarffortresswiki.org/index.php/DF2014:Personality_trait#Beliefs + +======== ========= +Strength Effect +======== ========= +3 Highest +2 Very High +1 High +0 Neutral +-1 Low +-2 Very Low +-3 Lowest +======== ========= + +Resetting a belief means setting it to a level that does not trigger a +report in the "Thoughts and preferences" screen. + +Usage: + +``-help``: + print the help page. + +``-unit ``: + set the target unit ID. If not present, the + currently selected unit will be the target. + +``-beliefs [ <...> ]``: + the beliefs to modify and their levels. The + valid belief tokens can be found in the wiki page + linked above; level values range from -3 to 3. + There must be a space before and after each square + bracket. + +``-reset``: + reset all beliefs to a neutral level. If the script is + called with both this option and a list of beliefs/levels, + first all the unit beliefs will be reset and then those + beliefs listed after ``-beliefs`` will be modified. + +Example:: + + assign-beliefs -reset -beliefs [ TRADITION 2 CRAFTSMANSHIP 3 POWER 0 CUNNING -1 ] + +Resets all the unit beliefs, then sets the listed beliefs to the following +values: + +* Tradition: a random value between 26 and 40 (level 2); +* Craftsmanship: a random value between 41 and 50 (level 3); +* Power: a random value between -10 and 10 (level 0); +* Cunning: a random value between -25 and -11 (level -1). + +The final result (for a dwarf) will be: "She personally is a firm believer in +the value of tradition and sees guile and cunning as indirect and somewhat +worthless." + +Note that the beliefs aligned with the cultural values of the unit have not +triggered a report. + +]====] + +local utils = require("utils") +local setneed = reqscript("modtools/set-need") + +local valid_args = utils.invert({ + 'help', + 'unit', + 'beliefs', + 'reset', + }) + +-- ----------------------------------------------- UTILITY FUNCTIONS ------------------------------------------------ -- +local function print_yellow(text) + dfhack.color(COLOR_YELLOW) + print(text) + dfhack.color(-1) +end + +-- ------------------------------------------------- ASSIGN BELIEFS ------------------------------------------------- -- +-- NOTE: in the game data, beliefs are called values. + +--- Assign the given beliefs to a unit, clearing all the other beliefs if requested. +--- :beliefs: nil, or a table. The fields have the belief name as key and its level as value. +--- :unit: a valid unit id, a df.unit object, or nil. If nil, the currently selected unit will be targeted. +--- :reset: boolean, or nil. +function assign(beliefs, unit, reset) + assert(not beliefs or type(beliefs) == "table") + assert(not unit or type(unit) == "number" or df.unit:is_instance(unit)) + assert(not reset or type(reset) == "boolean") + + beliefs = beliefs or {} + reset = reset or false + + if type(unit) == "number" then + unit = df.unit.find(tonumber(unit)) --luacheck:retype + end + unit = unit or dfhack.gui.getSelectedUnit(true) + if not unit then + qerror("No unit found.") + end + + --- Calculate a random value within the desired belief level + local function calculate_random_belief_value(belief_level) + local beliefs_levels = { + -- {minimum level value, maximum level value} + { -50, -41 }, -- -3: lowest + { -40, -26 }, -- -2: very low + { -25, -11 }, -- -1: low + { -10, 10 }, -- 0: neutral + { 11, 25 }, -- 1: high + { 26, 40 }, -- 2: very high + { 41, 51 } -- 3: highest + } + local lvl = beliefs_levels[belief_level + 4] + return math.random(lvl[1], lvl[2]) + end + + -- clear beliefs + if reset then + unit.status.current_soul.personality.values = {} + end + + --assign beliefs + for belief, level in pairs(beliefs) do + assert(type(level) == "number") + belief = belief:upper() + if df.value_type[belief] then + if level >= -3 and level <= 3 then + local belief_value = calculate_random_belief_value(level) + utils.insert_or_update(unit.status.current_soul.personality.values, + { new = true, type = df.value_type[belief], strength = belief_value }, + "type") + else + print_yellow("WARNING: level out of range for belief '" .. belief .. "'. Skipping...") + end + else + print_yellow("WARNING: '" .. belief .. "' is not a valid belief token. Skipping...") + end + end + setneed.rebuildNeeds(unit) +end + +-- ------------------------------------------------------ MAIN ------------------------------------------------------ -- +local function main(...) + local args = utils.processArgs({...}, valid_args) + + if args.help then + print(help) + return + end + + local unit + if args.unit then + unit = tonumber(args.unit) + if not unit then + qerror("'" .. args.unit .. "' is not a valid unit ID.") + end + end + + local reset = false + if args.reset then + reset = true + end + + -- parse beliefs list + local beliefs = {} + if args.beliefs then + local i = 1 + while i <= #args.beliefs do + local v = args.beliefs[i] + -- v can be a belief name but it can also be a level value, so we have to check + if not tonumber(v) then + -- assume it's a valid belief name, for now + local belief_name = tostring(v):upper() + -- then try to get the level value + local level_str = args.beliefs[i + 1] + if not level_str then + -- we reached the end of the beliefs list + qerror("Missing level value after '" .. v .. "'.") + end + local level_int = tonumber(level_str) + if not level_int then + qerror("'" .. level_str .. "' is not a valid number.") + end + -- assume the level value is in range, for now + beliefs[belief_name] = level_int + end + i = i + 1 -- skip next arg because we already consumed it + end + end + + assign(beliefs, unit, reset) +end + +if not dfhack_flags.module then + main(...) +end diff --git a/assign-facets.lua b/assign-facets.lua new file mode 100644 index 0000000000..c91670f07c --- /dev/null +++ b/assign-facets.lua @@ -0,0 +1,205 @@ +-- Change the facets of a unit. +--@ module = true + +local help = [====[ + +assign-facets +============= +A script to change the facets (traits) of a unit. + +Facets are defined with a token and a number from -3 to 3, which describes +the different levels of facets strength, as explained here: +https://dwarffortresswiki.org/index.php/DF2014:Personality_trait#Facets + +======== ========= +Strength Effect +======== ========= +3 Highest +2 Very High +1 High +0 Neutral +-1 Low +-2 Very Low +-3 Lowest +======== ========= + +Resetting a facet means setting it to a level that does not +trigger a report in the "Thoughts and preferences" screen. + +Usage: + +``-help``: + print the help page. + +``-unit ``: + set the target unit ID. If not present, the + currently selected unit will be the target. + +``-beliefs [ <...> ]``: + the facets to modify and their levels. The + valid facet tokens can be found in the wiki page + linked above; level values range from -3 to 3. + There must be a space before and after each square + bracket. + +``-reset``: + reset all facets to a neutral level. If the script is + called with both this option and a list of facets/levels, + first all the unit facets will be reset and then those + facets listed after ``-facets`` will be modified. + +Example:: + + assign-facets -reset -facets [ HATE_PROPENSITY -2 CHEER_PROPENSITY -1 ] + +Resets all the unit facets, then sets the listed facets to the following values: + +* Hate propensity: a value between 10 and 24 (level -2); +* Cheer propensity: a value between 25 and 39 (level -1). + +The final result (for a dwarf) will be: "She very rarely develops negative +feelings toward things. She is rarely happy or enthusiastic, and she is +conflicted by this as she values parties and merrymaking in the abstract." + +Note that the facets are compared to the beliefs, and if conflicts arise they +will be reported. + +]====] + +local utils = require("utils") +local setneed = reqscript("modtools/set-need") + +local valid_args = utils.invert({ + 'help', + 'unit', + 'facets', + 'reset', + }) + +-- ----------------------------------------------- UTILITY FUNCTIONS ------------------------------------------------ -- +local function print_yellow(text) + dfhack.color(COLOR_YELLOW) + print(text) + dfhack.color(-1) +end + +-- ------------------------------------------------- ASSIGN FACETS -------------------------------------------------- -- +-- -- NOTE: in the game data, facets are called traits. + +--- Assign the given facets to a unit, resetting all the other facets if requested. +--- :facets: nil, or a table. The fields have the facet name as key and its level as value. +--- :unit: a valid unit id, a df.unit object, or nil. If nil, the currently selected unit will be targeted. +--- :reset: boolean, or nil. +function assign(facets, unit, reset) + assert(not facets or type(facets) == "table") + assert(not unit or type(unit) == "number" or df.unit:is_instance(unit)) + assert(not reset or type(reset) == "boolean") + + facets = facets or {} + reset = reset or false + + if type(unit) == "number" then + unit = df.unit.find(tonumber(unit)) --luacheck:retype + end + unit = unit or dfhack.gui.getSelectedUnit(true) + if not unit then + qerror("No unit found.") + end + + --- Calculate a random value within the desired facet strength level + local function calculate_random_facet_strength(facet_level) + local facets_levels = { + -- {minimum level value, maximum level value} + { 0, 9 }, -- -3: lowest + { 10, 24 }, -- -2: very low + { 25, 39 }, -- -1: low + { 40, 60 }, -- 0: neutral + { 61, 75 }, -- 1: high + { 76, 90 }, -- 2: very high + { 91, 100 } -- 3: highest + } + local lvl = facets_levels[facet_level + 4] + return math.random(lvl[1], lvl[2]) + end + + -- reset facets + if reset then + for i, _ in pairs(unit.status.current_soul.personality.traits) do + local value = calculate_random_facet_strength(0) + unit.status.current_soul.personality.traits[i] = value + end + end + + --assign facets + for facet, level in pairs(facets) do + assert(type(level) == "number") + facet = facet:upper() + if df.personality_facet_type[facet] then + if level >= -3 and level <= 3 then + local facet_strength = calculate_random_facet_strength(level) + unit.status.current_soul.personality.traits[facet] = facet_strength + else + print_yellow("WARNING: level out of range for facet '" .. level .. "'. Skipping...") + end + else + print_yellow("WARNING: '" .. facet .. "' is not a valid facet token. Skipping...") + end + end + setneed.rebuildNeeds(unit) +end + +-- ------------------------------------------------------ MAIN ------------------------------------------------------ -- +local function main(...) + local args = utils.processArgs({...}, valid_args) + + if args.help then + print(help) + return + end + + local unit + if args.unit then + unit = tonumber(args.unit) + if not unit then + qerror("'" .. args.unit .. "' is not a valid unit ID.") + end + end + + local reset = false + if args.reset then + reset = true + end + + -- parse facets list + local facets = {} + if args.facets then + local i = 1 + while i <= #args.facets do + local v = args.facets[i] + -- v can be a facet name but it can also be a level value, so we have to check + if not tonumber(v) then + -- assume it's a valid facet name, for now + local facet_name = tostring(v):upper() + -- then try to get the level value + local level_str = args.facets[i + 1] + if not level_str then + -- we reached the end of the facets list + qerror("Missing level value after '" .. v .. "'.") + end + local level_int = tonumber(level_str) + if not level_int then + qerror("'" .. level_str .. "' is not a valid number.") + end + -- assume the level value is in range, for now + facets[facet_name] = level_int + end + i = i + 1 -- skip next arg because we already consumed it + end + end + + assign(facets, unit, reset) +end + +if not dfhack_flags.module then + main(...) +end diff --git a/assign-goals.lua b/assign-goals.lua new file mode 100644 index 0000000000..032fd309bd --- /dev/null +++ b/assign-goals.lua @@ -0,0 +1,173 @@ +-- Change the goals of a unit. +--@ module = true + +local help = [====[ + +assign-goals +============ +A script to change the goals (dreams) of a unit. + +Goals are defined with the goal token and a true/false value +that describes whether or not the goal has been accomplished. Be +advised that this last feature has not been properly tested and +might be potentially destructive: I suggest leaving it at false. + +For a list of possible goals: +https://dwarffortresswiki.org/index.php/DF2014:Personality_trait#Goals + +Bear in mind that nothing will stop you from assigning zero or +more than one goal, but it's not clear how it will affect the game. + +Usage: + +``-help``: + print the help page. + +``-unit ``: + set the target unit ID. If not present, the + currently selected unit will be the target. + +``-goals [ <...> ]``: + the goals to modify/add and whether they have + been realized or not. The valid goal tokens + can be found in the wiki page linked above. + The flag must be a true/false value. + There must be a space before and after each square + bracket. + +``-reset``: + clear all goals. If the script is called with + both this option and a list of goals, first all + the unit goals will be erased and then those + goals listed after ``-goals`` will be added. + +Example:: + + assign-goals -reset -goals [ MASTER_A_SKILL false ] + +Clears all the unit goals, then sets the "master a skill" goal. The final result +will be: "dreams of mastering a skill". + +]====] + +local utils = require("utils") + +local valid_args = utils.invert({ + 'help', + 'unit', + 'goals', + 'reset', + }) + +-- ----------------------------------------------- UTILITY FUNCTIONS ------------------------------------------------ -- +local function print_yellow(text) + dfhack.color(COLOR_YELLOW) + print(text) + dfhack.color(-1) +end + +-- ------------------------------------------------- ASSIGN GOALS ------------------------------------------------- -- +-- NOTE: in the game data, goals are called both dreams and goals. + +--- Assign the given goals to a unit, clearing all the other goals if requested. +--- :goals: nil, or a table. The fields have the goal name as key and true/false as value. +--- :unit: a valid unit id, a df.unit object, or nil. If nil, the currently selected unit will be targeted. +--- :reset: boolean, or nil. +function assign(goals, unit, reset) + assert(not goals or type(goals) == "table") + assert(not unit or type(unit) == "number" or df.unit:is_instance(unit)) + assert(not reset or type(reset) == "boolean") + + goals = goals or {} + reset = reset or false + + if type(unit) == "number" then + unit = df.unit.find(tonumber(unit)) --luacheck:retype + end + unit = unit or dfhack.gui.getSelectedUnit(true) + if not unit then + qerror("No unit found.") + end + + -- erase goals + if reset then + unit.status.current_soul.personality.dreams = {} + end + + --assign goals + for goal, realized in pairs(goals) do + assert(type(realized) == "boolean") + goal = goal:upper() + if df.goal_type[goal] then + utils.insert_or_update(unit.status.current_soul.personality.dreams, + { new = true, type = df.goal_type[goal], flags = {accomplished = realized} }, + "type") + else + print_yellow("WARNING: '" .. goal .. "' is not a valid goal token. Skipping...") + end + end +end + +-- ------------------------------------------------------ MAIN ------------------------------------------------------ -- +local function main(...) + local function is_flag(string) + return string:upper() == "TRUE" or string:upper() == "FALSE" + end + + local args = utils.processArgs({ ... }, valid_args) + + if args.help then + print(help) + return + end + + local unit + if args.unit then + unit = tonumber(args.unit) + if not unit then + qerror("'" .. args.unit .. "' is not a valid unit ID.") + end + end + + local reset = false + if args.reset then + reset = true + end + + -- parse goals list + local goals = {} + if args.goals then + local i = 1 + while i <= #args.goals do + local v = args.goals[i] + -- v can be a goal name but it can also be a boolean flag, so we have to check + if not is_flag(v) then + -- assume it's a valid goal name, for now + local goal_name = tostring(v):upper() + -- then try to get the boolean flag + local flag_str = args.goals[i + 1] + if not flag_str then + -- we reached the end of the goals list + qerror("Missing realized flag after '" .. v .. "'.") + end + local flag_bool + if flag_str:upper() == "TRUE" then + flag_bool = true + elseif flag_str:upper() == "FALSE" then + flag_bool = false + end + if flag_bool == nil then + qerror("'" .. flag_str .. "' is not a true or false value.") + end + goals[goal_name] = flag_bool + end + i = i + 1 -- skip next arg because we already consumed it + end + end + + assign(goals, unit, reset) +end + +if not dfhack_flags.module then + main(...) +end diff --git a/assign-minecarts.lua b/assign-minecarts.lua new file mode 100644 index 0000000000..53e498d2b7 --- /dev/null +++ b/assign-minecarts.lua @@ -0,0 +1,171 @@ +-- assigns minecarts to hauling routes +--@ module = true + +local argparse = require('argparse') +local utils = require('utils') + +function get_free_vehicles() + local free_vehicles = {} + for _,vehicle in ipairs(df.global.world.vehicles.active) do + if vehicle and vehicle.route_id == -1 then + table.insert(free_vehicles, vehicle) + end + end + return free_vehicles +end + +local function has_stops(route) + return #route.stops > 0 +end + +local function get_minecart(route) + if #route.vehicle_ids == 0 then return end + local vehicle = utils.binsearch(df.global.world.vehicles.active, route.vehicle_ids[0], 'id') + if not vehicle then return end + return df.item.find(vehicle.item_id) +end + +local function get_name(route) + return route.name and #route.name > 0 and route.name or ('Route '..route.id) +end + +local function get_id_and_name(route) + return ('%d (%s)'):format(route.id, get_name(route)) +end + +local function assign_minecart_to_route(route, quiet, minecart) + local assigned_minecart = get_minecart(route) + if assigned_minecart then + return assigned_minecart + end + if not has_stops(route) then + if not quiet then + dfhack.printerr( + ('Route %s has no stops defined. Cannot assign minecart.') + :format(get_id_and_name(route))) + end + return false + end + if not minecart then + minecart = get_free_vehicles()[1] + if not minecart then + if not quiet then + dfhack.printerr('No minecarts available! Please build some.') + end + return false + end + end + for _,vehicle_id in ipairs(route.vehicle_ids) do + local vehicle = utils.binsearch(df.global.world.vehicles.all, vehicle_id, 'id') + if vehicle then vehicle.route_id = -1 end + end + route.vehicle_ids:resize(0) + route.vehicle_stops:resize(0) + route.vehicle_ids:insert('#', minecart.id) + route.vehicle_stops:insert('#', 0) + minecart.route_id = route.id + if not quiet then + print(('Assigned a minecart to route %s.') + :format(get_id_and_name(route))) + end + return df.item.find(minecart.item_id) +end + +-- assign first free minecart to the most recently-created route +-- returns assigned minecart (or nil if assignment failed) +function assign_minecart_to_last_route(quiet) + local routes = df.global.plotinfo.hauling.routes + local route_idx = #routes - 1 + if route_idx < 0 then + return false + end + local route = routes[route_idx] + return assign_minecart_to_route(route, quiet) +end + +local function get_route_by_id(route_id) + for _,route in ipairs(df.global.plotinfo.hauling.routes) do + if route.id == route_id then + return route + end + end +end + +local function list() + local routes = df.global.plotinfo.hauling.routes + if 0 == #routes then + print('No hauling routes defined.') + else + print(('Found %d route%s:\n') + :format(#routes, #routes == 1 and '' or 's')) + print('route id minecart? has stops? route name') + print('-------- --------- ---------- ----------') + for _,route in ipairs(routes) do + print(('%-8d %-9s %-9s %s') + :format(route.id, + get_minecart(route) and 'yes' or 'NO', + has_stops(route) and 'yes' or 'NO', + get_name(route))) + end + end + local minecarts = get_free_vehicles() + print(('\nYou have %d unassigned minecart%s.') + :format(#minecarts, #minecarts == 1 and '' or 's')) +end + +local function all(quiet) + local minecarts, idx = get_free_vehicles(), 1 + local routes = df.global.plotinfo.hauling.routes + for _,route in ipairs(routes) do + if get_minecart(route) then + goto continue + end + if not assign_minecart_to_route(route, quiet, minecarts[idx]) then + return + end + idx = idx + 1 + ::continue:: + end +end + +local function do_help(_) + print(dfhack.script_help()) +end + +local command_switch = { + list=list, + all=all, +} + +local function main(args) + local help, quiet = false, false + local command = argparse.processArgsGetopt(args, { + {'h', 'help', handler=function() help = true end}, + {'q', 'quiet', handler=function() quiet = true end}})[1] + + if help then + command = nil + end + + local requested_route_id = tonumber(command) + if requested_route_id then + local route = get_route_by_id(requested_route_id) + if not route then + dfhack.printerr('route id not found: '..requested_route_id) + elseif get_minecart(route) then + if not quiet then + print(('Route %s already has a minecart assigned.') + :format(get_id_and_name(route))) + end + else + assign_minecart_to_route(route, quiet) + end + return + end + + (command_switch[command] or do_help)(quiet) +end + +if not dfhack_flags.module then + main({...}) +end diff --git a/assign-preferences.lua b/assign-preferences.lua new file mode 100644 index 0000000000..9a8c1bdc6a --- /dev/null +++ b/assign-preferences.lua @@ -0,0 +1,530 @@ +-- Change the preferences of a unit. +--@ module = true + +local utils = require("utils") + +local valid_args = utils.invert({ + 'help', + 'unit', + 'show', + 'likematerial', + 'likecreature', + 'likefood', + 'hatecreature', + 'likeitem', + 'likeplant', + 'liketree', + 'likecolor', + 'likeshape', + 'reset', + }) + +-- ----------------------------------------------- UTILITY FUNCTIONS ------------------------------------------------ -- +local function print_yellow(text) + dfhack.color(COLOR_YELLOW) + print(text) + dfhack.color(-1) +end + +local function format_preference(pref, index) + print(string.format("Preference #%d:", index)) + + local pref_type = df.unitpref_type[pref.type] + local description = "" + if pref_type == "LikeMaterial" then + description = "Likes material: " .. dfhack.matinfo.getToken(pref.mattype, pref.matindex) + elseif pref_type == "LikeFood" then + description = "Likes food: " .. dfhack.matinfo.getToken(pref.mattype, pref.matindex) + elseif pref_type == "LikeItem" then + description = "Likes item type: " .. tostring(pref.item_type) + elseif pref_type == "LikePlant" then + description = "Likes plant: " .. dfhack.matinfo.getToken(pref.mattype, pref.matindex) + elseif pref_type == "HateCreature" then + description = "Hates creature: " .. df.global.world.raws.creatures.all[pref.creature_id].creature_id + elseif pref_type == "LikeColor" then + description = "Likes color: " .. df.global.world.raws.descriptors.colors[pref.color_id].id + elseif pref_type == "LikeShape" then + description = "Likes shape: " .. df.global.world.raws.descriptors.shapes[pref.shape_id].id + elseif pref_type == "LikePoeticForm" then + description = "Likes poetic form: " .. dfhack.translation.translateName(df.global.world.poetic_forms.all[pref.poetic_form_id].name, true) + elseif pref_type == "LikeMusicalForm" then + description = "Likes musical form: " .. dfhack.translation.translateName(df.global.world.musical_forms.all[pref.musical_form_id].name, true) + elseif pref_type == "LikeDanceForm" then + description = "Likes dance form: " .. dfhack.translation.translateName(df.global.world.dance_forms.all[pref.dance_form_id].name, true) + else + description = "Unknown preference type: " .. tostring(pref.type) + end + + print(description) +end + +-- initialise random number generator +local rng = dfhack.random.new() + +-- ----------------------------------------------- ASSIGN PREFERENCES ----------------------------------------------- -- +--[[ +This table stores all the information needed to instantiate a new df.unit_preference object. +The keys are the names of the different types of preferences, the values are functions that +return a table with all the fields of a df.unit_preference object and their values. +I find it easier to just overwrite every single field instead of keeping track of the default +values assigned to the field at object creation. +--]] +local preference_functions = { + -- ---------------- LIKEMATERIAL ---------------- -- + LIKEMATERIAL = function(token) + local mat_info = dfhack.matinfo.find(token) + local ret = {} + if mat_info then + ret = { --luacheck:retype + type = df.unitpref_type.LikeMaterial, + item_type = -1, + creature_id = -1, + color_id = -1, + shape_id = -1, + plant_id = -1, + poetic_form_id = -1, + musical_form_id = -1, + dance_form_id = -1, + item_subtype = -1, + mattype = mat_info.type, + matindex = mat_info.index, + mat_state = 0, + flags = {visible = true}, + prefstring_seed = rng:random() + } + else + print_yellow("WARNING: '" .. token .. "' does not seem to be a valid material token. Skipping...") + end + return ret + end, + -- ---------------- LIKECREATURE ---------------- -- + LIKECREATURE = function(token) + local creature_id + local parts = token:split(":") + if #parts == 1 then + creature_id = parts[1] + else + creature_id = parts[2] + end + local index = utils.linear_index(df.global.world.raws.creatures.all, creature_id, "creature_id") + if index then + return { + type = df.unitpref_type.LikeCreature, + item_type = index, + creature_id = index, + color_id = index, + shape_id = index, + plant_id = index, + poetic_form_id = index, + musical_form_id = index, + dance_form_id = index, + item_subtype = -1, + mattype = -1, + matindex = -1, + mat_state = 0, + flags = {visible = true}, + prefstring_seed = rng:random() + } + else + print_yellow("WARNING: '" .. token .. "' does not seem to be a valid creature token. Skipping...") + return {} + end + end, + -- ---------------- LIKEFOOD ---------------- -- + LIKEFOOD = function(token) + local mat_info = dfhack.matinfo.find(token) + if not mat_info then + goto error + end + do + local item_type + local item_subtype = -1 + local food_mat_index = mat_info.material.food_mat_index + -- FIXME: is fish missing? Apparently it's considered meat... + -- FIXME: maybe automatize this process instead of using a chain of if-elseif statements? + if food_mat_index.Meat > -1 then + item_type = df.item_type.MEAT + elseif food_mat_index.EdibleCheese > -1 then + item_type = df.item_type.CHEESE + elseif food_mat_index.EdiblePlant > -1 then + item_type = df.item_type.PLANT + elseif food_mat_index.AnyDrink > -1 then + item_type = df.item_type.DRINK + elseif food_mat_index.CookableLiquid > -1 then + item_type = df.item_type.LIQUID_MISC + elseif food_mat_index.CookablePowder > -1 then + item_type = df.item_type.POWDER_MISC + elseif food_mat_index.CookableSeed > -1 then + item_type = df.item_type.SEEDS + elseif food_mat_index.CookablePlantGrowth > -1 then + --[[ + In case of plant growths, "mat_info" stores the item type as a specific subtype ("FLOWER", or "FRUIT", + etc.) instead of the generic "PLANT_GROWTH" item type. Also, the IDs of the different types of growths + are sometimes stored in the plural form and sometimes in the singular form, so we need to know what to + look for when we try to associate the item_type to the growth_id. + --]] + local growths = { + -- item_id = growth_id, as returned by dfhack.matinfo.find() + BULB = "BULBS", + FLOWER = "FLOWER", + LEAF = "LEAVES", + NUT = "NUT", + POD = "POD", + FRUIT = "FRUIT", + } + local item_type_str = mat_info.material.id + if growths[item_type_str] then + local growth_id = growths[item_type_str] + for _, growth in ipairs(mat_info.plant.growths) do + if growth_id == growth.id then + item_type = growth.item_type + item_subtype = growth.item_subtype + end + end + end + end + + if item_type then + return { + type = df.unitpref_type.LikeFood, + item_type = item_type, + creature_id = item_type, + color_id = item_type, + shape_id = item_type, + plant_id = item_type, + poetic_form_id = item_type, + musical_form_id = item_type, + dance_form_id = item_type, + item_subtype = item_subtype, + mattype = mat_info.type, + matindex = mat_info.index, + mat_state = 1, + flags = {visible = true}, + prefstring_seed = rng:random() + } + end + end + + :: error :: + print_yellow("WARNING: '" .. token .. "' does not seem to be a valid food token. Skipping...") + return {} + end, + -- ---------------- HATECREATURE ---------------- -- + HATECREATURE = function(token) + local creature_id + local parts = token:split(":") + if #parts == 1 then + creature_id = parts[1] + else + creature_id = parts[2] + end + local index = utils.linear_index(df.global.world.raws.creatures.all, creature_id, "creature_id") + if index then + return { + type = df.unitpref_type.HateCreature, + item_type = index, + creature_id = index, + color_id = index, + shape_id = index, + plant_id = index, + poetic_form_id = index, + musical_form_id = index, + dance_form_id = index, + item_subtype = -1, + mattype = -1, + matindex = -1, + mat_state = 0, + flags = {visible = true}, + prefstring_seed = rng:random() + } + else + print_yellow("WARNING: '" .. token .. "' does not seem to be a valid creature token. Skipping...") + return {} + end + end, + -- ---------------- LIKEITEM ---------------- -- + LIKEITEM = function(token) + local item_type + local item_subtype = -1 + local parts = token:split(":") + if #parts == 1 then + item_type = df.item_type[parts[1]] + else + -- the token is someting like ITEM_WEAPON:ITEM_WEAPON_AXE_BATTLE + item_type = df.item_type[string.gsub(parts[1], "^ITEM_", "")] + local _, item = utils.linear_index(df.global.world.raws.itemdefs.all, parts[2], "id") + if item then + item_subtype = item.subtype + else + goto error + end + end + do + if item_type then + return { + type = df.unitpref_type.LikeItem, + item_type = item_type, + creature_id = item_type, + color_id = item_type, + shape_id = item_type, + plant_id = item_type, + poetic_form_id = item_type, + musical_form_id = item_type, + dance_form_id = item_type, + item_subtype = item_subtype, + mattype = -1, + matindex = -1, + mat_state = 0, + flags = {visible = true}, + prefstring_seed = rng:random() + } + end + end + + :: error :: + print_yellow("WARNING: '" .. token .. "' does not seem to be a valid item token. Skipping...") + return {} + end, + -- ---------------- LIKEPLANT ---------------- -- + LIKEPLANT = function(token) + local plant_id + local parts = token:split(":") + if #parts == 1 then + plant_id = parts[1] + else + plant_id = parts[2] + end + local index = utils.linear_index(df.global.world.raws.plants.all, plant_id, "id") + if index then + return { + type = df.unitpref_type.LikePlant, + item_type = index, + creature_id = index, + color_id = index, + shape_id = index, + plant_id = index, + poetic_form_id = index, + musical_form_id = index, + dance_form_id = index, + item_subtype = -1, + mattype = -1, + matindex = -1, + mat_state = 0, + flags = {visible = true}, + prefstring_seed = rng:random() + } + else + print_yellow("WARNING: '" .. token .. "' does not seem to be a valid plant token. Skipping...") + return {} + end + end, + -- ---------------- LIKETREE ---------------- -- + LIKETREE = function(token) + local plant_id + local parts = token:split(":") + if #parts == 1 then + plant_id = parts[1] + else + plant_id = parts[2] + end + local index = utils.linear_index(df.global.world.raws.plants.all, plant_id, "id") + if index then + return { + type = df.unitpref_type.LikeTree, + item_type = index, + creature_id = index, + color_id = index, + shape_id = index, + plant_id = index, + poetic_form_id = index, + musical_form_id = index, + dance_form_id = index, + item_subtype = -1, + mattype = -1, + matindex = -1, + mat_state = 0, + flags = {visible = true}, + prefstring_seed = rng:random() + } + else + print_yellow("WARNING: '" .. token .. "' does not seem to be a valid plant token. Skipping...") + return {} + end + end, + -- ---------------- LIKECOLOR ---------------- -- + LIKECOLOR = function(token) + local color_name + local parts = token:split(":") + if #parts == 1 then + color_name = parts[1] + else + color_name = parts[2] + end + -- f.global.world.raws.descriptors.colors is ordered by id, we can use binary search + local _, found, index = utils.binsearch(df.global.world.raws.descriptors.colors, color_name, "id") + if found then + return { + type = df.unitpref_type.LikeColor, + item_type = index, + creature_id = index, + color_id = index, + shape_id = index, + plant_id = index, + poetic_form_id = index, + musical_form_id = index, + dance_form_id = index, + item_subtype = -1, + mattype = -1, + matindex = -1, + mat_state = 0, + flags = {visible = true}, + prefstring_seed = rng:random() + } + else + print_yellow("WARNING: '" .. token .. "' does not seem to be a valid color token. Skipping...") + return {} + end + end, + -- ---------------- LIKESHAPE ---------------- -- + LIKESHAPE = function(token) + local shape_name + local parts = token:split(":") + if #parts == 1 then + shape_name = parts[1] + else + shape_name = parts[2] + end + local index, _ = utils.linear_index(df.global.world.raws.descriptors.shapes, shape_name, "id") + if index then + return { + type = df.unitpref_type.LikeShape, + item_type = index, + creature_id = index, + color_id = index, + shape_id = index, + plant_id = index, + poetic_form_id = index, + musical_form_id = index, + dance_form_id = index, + item_subtype = -1, + mattype = -1, + matindex = -1, + mat_state = 0, + flags = {visible = true}, + prefstring_seed = rng:random() + } + else + print_yellow("WARNING: '" .. token .. "' does not seem to be a valid shape token. Skipping...") + return {} + end + end, +} + +--- Assign the given preferences to a unit, clearing all the other preferences if requested. +--- :preferences: nil, or a table. The fields have the preference type as key and an array of preference tokens +--- as value. If the token is just one, the field can be in a simple key=value format. +--- :unit: a valid unit id, a df.unit object, or nil. If nil, the currently selected unit will be targeted. +--- :reset: boolean, or nil. +function assign(preferences, unit, reset) + assert(not preferences or type(preferences) == "table") + assert(not unit or type(unit) == "number" or df.unit:is_instance(unit)) + assert(not reset or type(reset) == "boolean") + + local preferences = preferences or {} --as:string[][] + reset = reset or false + + if type(unit) == "number" then + unit = df.unit.find(tonumber(unit)) --luacheck:retype + end + unit = unit or dfhack.gui.getSelectedUnit(true) + if not unit then + qerror("No unit found.") + end + + -- clear preferences + if reset then + unit.status.current_soul.preferences = {} + end + + -- assign preferences + local unit_preferences = unit.status.current_soul.preferences -- store the userdata in a variable for convenience + + for preference_type, preference_tokens in pairs(preferences) do + assert(type(preference_tokens) == "table" or type(preference_tokens) == "string") + local funct = preference_functions[preference_type:upper()] + if type(preference_tokens) == "string" then --luacheck:skip + preference_tokens = {preference_tokens} + end + for _, token in ipairs(preference_tokens) do + assert(type(token) == "string") + local new_preference = df.unit_preference:new() + local values = funct(token:upper()) + for field, value in pairs(values) do + if value then + new_preference[field] = value + else + print_yellow("WARNING: unable to calculate a value for field '" .. field .. "'. Skipping...") + goto next_preference + end + end + -- TODO: should we insert preferences following the order in which DF presents them? + unit_preferences:insert(#unit_preferences, new_preference) + end + + :: next_preference :: + end +end + +-- ----------------------------------------------- SHOW PREF UTILITY ------------------------------------------------ -- +local function showPreferences(unit) + assert(not unit or type(unit) == "number" or df.unit:is_instance(unit)) + unit = unit or dfhack.gui.getSelectedUnit(true) + if not unit then + qerror("No unit found.") + end + + for i, pref in ipairs(unit.status.current_soul.preferences) do + format_preference(pref, i) + end +end + +-- ------------------------------------------------------ MAIN ------------------------------------------------------ -- +local function main(...) + local args = utils.processArgs({ ... }, valid_args) + + if args.help then + print(dfhack.script_help()) + return + end + + local unit + if args.unit then + unit = tonumber(args.unit) + if not unit then + qerror("'" .. args.unit .. "' is not a valid unit ID.") + end + end + + local reset = false + if args.reset then + reset = true + end + + if args.show then + showPreferences(unit) + return + end + + -- parse preferences + args.unit = nil -- remove from args table + args.reset = nil -- remove from args table + args.show = nil -- remove from args table + local preferences = {} + utils.assign(preferences, args) + + assign(preferences, unit, reset) +end + +if not dfhack_flags.module then + main(...) +end diff --git a/assign-profile.lua b/assign-profile.lua new file mode 100644 index 0000000000..70ce2ac600 --- /dev/null +++ b/assign-profile.lua @@ -0,0 +1,187 @@ +-- Set a dwarf's characteristics according to a predefined profile +--@ module = true + +local help = [====[ + +assign-profile +============== +A script to change the characteristics of a unit +according to a profile loaded from a json file. + +A profile can describe which attributes, skills, preferences, beliefs, +goals and facets a unit must have. The script relies on the presence +of the other ``assign-...`` modules in this collection: please refer +to the other modules documentation for more specific information. + +For information about the json schema, please see the +the "/hack/scripts/dwarf_profiles.json" file. + +Usage: + +``-help``: + print the help page. + +``-unit ``: + the target unit ID. If not present, the + target will be the currently selected unit. + +``-file ``: + the json file containing the profile to apply. + It's a relative path, starting from the DF + root directory and ending at the json file. + It must begin with a slash. Default value: + "/hack/scripts/dwarf_profiles.json". + +``-profile ``: + the profile to apply. It's the name of + the profile as stated in the json file. + +``-reset [ ]``: + the characteristics to be reset/cleared. If not present, + it will not clear or reset any characteristic, and it will + simply add what is described in the profile. If it's a + valid list of characteristic, those characteristics will + be reset, and then what is described in the profile + will be applied. If set to ``PROFILE``, it will reset + only the characteristics directly modified by the profile + (and then the new values described will be applied). + If set to ``ALL``, it will reset EVERY characteristic and + then it will apply the profile. + Accepted values: + ``ALL``, ``PROFILE``, ``ATTRIBUTES``, ``SKILLS``, + ``PREFERENCES``, ``BELIEFS``, ``GOALS``, ``FACETS``. + There must be a space before and after each square + bracket. If only one value is provided, the square brackets + can be omitted. + +Examples: + +* Loads and applies the profile called "DOCTOR" in the default json file, + resetting/clearing all the characteristics changed by the profile, leaving + the others unchanged, and then applies the new values:: + + assign-profile -profile DOCTOR -reset PROFILE + +* Loads and applies the profile called "ARCHER" in the provided (fictional) json, + keeping all the old characteristics but the attributes and the skills, which + will be reset (and then, if the profile provides some attributes or skills values, + those new values will be applied):: + + assign-profile -file /hack/scripts/military_profiles.json -profile ARCHER -reset [ ATTRIBUTES SKILLS ] + +]====] + +local json = require "json" +local utils = require("utils") + +local valid_args = utils.invert({ + "help", + "unit", + "file", + "profile", + "reset", + }) + +-- add a script here to include it in the profile. The key must be the same as written in the json. +local scripts = { + ATTRIBUTES = reqscript("assign-attributes"), + SKILLS = reqscript("assign-skills"), + PREFERENCES = reqscript("assign-preferences"), + BELIEFS = reqscript("assign-beliefs"), + GOALS = reqscript("assign-goals"), + FACETS = reqscript("assign-facets"), +} + +local default_filename = dfhack.getHackPath().."/scripts/dwarf_profiles.json" + +-- ------------------------------------------------- APPLY PROFILE -------------------------------------------------- -- +--- Apply the given profile to a unit, erasing or resetting the unit characteristics as requested. +--- :profile: a table. Each field has a characteristic name as key, and a table suitable to be passed as +--- an argument to the ``assign`` function of the script related to the characteristic. +--- See the called script documentation for more details. +--- :unit: a valid unit id, a df.unit object, or nil. The called script has the responsibility to validate this value. +--- :reset: nil, or a list of values. See this script documentation for accepted values. +--luacheck: in=string[],df.unit,bool[] +function apply_profile(profile, unit, reset) + assert(type(profile) == "table") + assert(not unit or type(unit) == "number" or df.unit:is_instance(unit)) + assert(not reset or type(reset) == "table") + + reset = reset or {} + + local function apply(characteristic_name, script) + local reset_flag = reset.ALL ~= nil or + reset[characteristic_name] ~= nil or + (reset.PROFILE and profile[characteristic_name] ~= nil) + script.assign(profile[characteristic_name], unit, reset_flag) + end + + for characteristic_name, script in pairs(scripts) do + apply(characteristic_name, script) + end +end + +-- --------------------------------------------------- LOAD PROFILE ------------------------------------------------- -- +--- Load the given profile, searching it inside the given JSON file (if not nil) or inside the default JSON file. +--- The filename must begin with a slash and must be a relative path starting from the root DF directory and ending +--- with the desired filename. +--- Return the parsed profile as a table. +--luacheck: in=string,string +function load_profile(profile_name, filename) + assert(profile_name ~= nil) + + local json_file = string.format("%s%s", dfhack.getDFPath(), filename or default_filename) + local profiles = {} --as:string[][] + if dfhack.filesystem.isfile(json_file) then + profiles = json.decode_file(json_file) + else + qerror(string.format("File '%s' not found.", json_file)) + end + if profiles[profile_name] then + return profiles[profile_name] + else + qerror(string.format("Profile '%s' not found", profile_name)) + end +end + +-- ------------------------------------------------------ MAIN ------------------------------------------------------ -- +local function main(...) + local args = utils.processArgs({...}, valid_args) + + if args.help then + print(help) + return + end + + local unit + if args.unit then + unit = tonumber(args.unit) + if not unit then + qerror("'" .. args.unit .. "' is not a valid unit ID.") + end + end + + local filename = args.file + + local profile_name + if args.profile then + profile_name = args.profile + else + qerror("Missing profile name.") + end + + local reset + if type(args.reset) == "string" then + reset = {} + reset[args.reset] = 1 + elseif type(args.reset) == "table" then + reset = utils.invert(args.reset) + end + + local profile = load_profile(profile_name, filename) + apply_profile(profile, unit, reset) +end + +if not dfhack_flags.module then + main(...) +end diff --git a/assign-skills.lua b/assign-skills.lua new file mode 100644 index 0000000000..a364004710 --- /dev/null +++ b/assign-skills.lua @@ -0,0 +1,181 @@ +-- Change the skills of a unit. +--@ module = true + +local help = [====[ + +assign-skills +============= +A script to change the skills of a unit. + +Skills are defined by their token and their rank. Skills tokens can be +found here: https://dwarffortresswiki.org/index.php/DF2014:Skill_token + +Below you can find a list of the first 16 ranks: + +==== ============ +Rank Skill name +==== ============ +0 Dabbling +1 Novice +2 Adequate +3 Competent +4 Skilled +5 Proficient +6 Talented +7 Adept +8 Expert +9 Professional +10 Accomplished +11 Great +12 Master +13 High Master +14 Grand Master +15+ Legendary +==== ============ + +For more information: +https://dwarffortresswiki.org/index.php/DF2014:Skill#Skill_level_names + +Usage: + +``-help``: + print the help page. + +``-unit ``: + the target unit ID. If not present, the + currently selected unit will be the target. + +``-skills [ <...> ]``: + the list of the skills to modify and their ranks. + Rank values range from -1 (the skill is not learned) + to normally 20 (legendary + 5). It is actually + possible to go beyond 20, no check is performed. + There must be a space before and after each square + bracket. + +``-reset``: + clear all skills. If the script is called with + both this option and a list of skills/ranks, + first all the unit skills will be cleared + and then the listed skills will be added. + +Example:: + + assign-skills -reset -skills [ WOODCUTTING 3 AXE 2 ] + +Clears all the unit skills, then adds the Wood cutter skill (competent level) +and the Axeman skill (adequate level). + +]====] + +local utils = require("utils") + +local valid_args = utils.invert({ + 'help', + 'unit', + 'skills', + 'reset', + }) + +-- ----------------------------------------------- UTILITY FUNCTIONS ------------------------------------------------ -- +local function print_yellow(text) + dfhack.color(COLOR_YELLOW) + print(text) + dfhack.color(-1) +end + +-- ------------------------------------------------- ASSIGN SKILLS -------------------------------------------------- -- +--- Assign the given skills to a unit, clearing all the other skills if requested. +--- :skills: nil, or a table. The fields have the skill token as key and its rank as value. +--- :unit: a valid unit id, a df.unit object, or nil. If nil, the currently selected unit will be targeted. +--- :reset: boolean, or nil. +function assign(skills, unit, reset) + assert(not skills or type(skills) == "table") + assert(not unit or type(unit) == "number" or df.unit:is_instance(unit)) + assert(not reset or type(reset) == "boolean") + + skills = skills or {} + reset = reset or false + + if type(unit) == "number" then + unit = df.unit.find(tonumber(unit)) --luacheck:retype + end + unit = unit or dfhack.gui.getSelectedUnit(true) + if not unit then + qerror("No unit found.") + end + + -- clear skills + if reset then + unit.status.current_soul.skills = {} + end + + -- assign skills + for skill, rank in pairs(skills) do + assert(type(rank) == "number") + skill = skill:upper() + if df.job_skill[skill] then + utils.insert_or_update(unit.status.current_soul.skills, + { new = true, id = df.job_skill[skill], rating = rank }, + "id") + else + print_yellow("WARNING: '" .. skill .. "' is not a valid skill. Skipping...") + end + end +end + +-- ------------------------------------------------------ MAIN ------------------------------------------------------ -- +local function main(...) + local args = utils.processArgs({ ... }, valid_args) + + if args.help then + print(help) + return + end + + local unit + if args.unit then + unit = tonumber(args.unit) + if not unit then + qerror("'" .. args.unit .. "' is not a valid unit ID.") + end + end + + local reset = false + if args.reset then + reset = true + end + + -- parse skills list + local skills = {} + if args.skills then + local i = 1 + while i <= #args.skills do + local v = args.skills[i] + -- v can be a skill name but it can also be a rank value, so we have to check + if not tonumber(v) then + -- assume it's a valid skill name, for now + local skill_name = tostring(v):upper() + -- then try to get the rank value + local rank_str = args.skills[i + 1] + if not rank_str then + -- we reached the end of the skills list + qerror("Missing rank value after '" .. v .. "'.") + end + local rank_int = tonumber(rank_str) + if not rank_int then + qerror("'" .. rank_str .. "' is not a valid number.") + end + -- assume the rank value is in range, for now + skills[skill_name] = rank_int + end + i = i + 1 -- skip next arg because we already consumed it + end + end + + assign(skills, unit, reset) +end + +if not dfhack_flags.module then + main(...) +end diff --git a/autocheese.lua b/autocheese.lua new file mode 100644 index 0000000000..e9bdc146ae --- /dev/null +++ b/autocheese.lua @@ -0,0 +1,173 @@ +--@module = true + +---make cheese using a specific barrel and workshop +---@param barrel df.item +---@param workshop df.building_workshopst +---@return df.job +function makeCheese(barrel, workshop) + ---@type df.job + local job = dfhack.job.createLinked() + job.job_type = df.job_type.MakeCheese + + local jitem = df.job_item:new() + jitem.quantity = 0 + jitem.vector_id = df.job_item_vector_id.ANY_COOKABLE + jitem.flags1.unrotten = true + jitem.flags1.milk = true + job.job_items.elements:insert('#', jitem) + + if not dfhack.job.attachJobItem(job, barrel, df.job_role_type.Reagent, 0, -1) then + dfhack.error('could not attach item') + end + + dfhack.job.assignToWorkshop(job, workshop) + return job +end + +---checks that unit can path to workshop +---@param unit df.unit +---@param workshop df.building_workshopst +---@return boolean +function canAccessWorkshop(unit, workshop) + local workshop_position = xyz2pos(workshop.centerx, workshop.centery, workshop.z) + return dfhack.maps.canWalkBetween(unit.pos, workshop_position) +end + +---check if unit can perform labor at workshop +---@param unit df.unit +---@param unit_labor df.unit_labor +---@param workshop df.building +---@return boolean +function availableLaborer(unit, unit_labor, workshop) + return unit.status.labors[unit_labor] + and dfhack.units.isJobAvailable(unit) + and canAccessWorkshop(unit, workshop) +end + +---find unit with a particular labor enabled +---@param unit_labor df.unit_labor +---@param job_skill df.job_skill +---@param workshop df.building +---@return df.unit|nil +---@return integer|nil + function findAvailableLaborer(unit_labor, job_skill, workshop) + local max_unit = nil + local max_skill = -1 + for _, unit in ipairs(dfhack.units.getCitizens(true, false)) do + if + availableLaborer(unit, unit_labor, workshop) + then + local unit_skill = dfhack.units.getNominalSkill(unit, job_skill, true) + if unit_skill > max_skill then + max_unit = unit + max_skill = unit_skill + end + end + end + return max_unit, max_skill +end + +local function findMilkBarrel(min_liquids) + for _, container in ipairs(df.global.world.items.other.FOOD_STORAGE) do + if + not (container.flags.in_job or container.flags.forbid) and + container.flags.container and #container.general_refs >= min_liquids + then + local content_reference = dfhack.items.getGeneralRef(container, df.general_ref_type.CONTAINS_ITEM) + local contained_item = df.item.find(content_reference and content_reference.item_id or -1) + if contained_item then + local mat_info = dfhack.matinfo.decode(contained_item) + if mat_info:matches { milk = true } then + return container + end + end + end + end +end + +---find a workshop to which the barrel can be brought +---if the workshop has a master, only return workshop and master if the master is available +---@param pos df.coord +---@return df.building_workshopst? +---@return df.unit? +function findWorkshop(pos) + for _,workshop in ipairs(df.global.world.buildings.other.WORKSHOP_FARMER) do + if + dfhack.maps.canWalkBetween(pos, xyz2pos(workshop.centerx, workshop.centery, workshop.z)) and + not workshop.profile.blocked_labors[df.unit_labor.MAKE_CHEESE] and + #workshop.jobs == 0 + then + if #workshop.profile.permitted_workers == 0 then + -- immediately return workshop without master + return workshop, nil + else + unit = df.unit.find(workshop.profile.permitted_workers[0]) + if + unit and availableLaborer(unit, df.unit_labor.MAKE_CHEESE, workshop) + then + -- return workshop and master, if master is available + return workshop, unit + else + print("autocheese: Skipping farmer's workshop with unavailable master") + end + end + end + end +end + +if dfhack_flags.module then + return +end + +-- actual script action + +local argparse = require('argparse') + +local min_number = 50 + +local _ = argparse.processArgsGetopt({...}, +{ + { 'm', 'min-milk', hasArg = true, + handler = function(min) + min_number = argparse.nonnegativeInt(min, 'min-milk') + end } +}) + + +local reagent = findMilkBarrel(min_number) + +if not reagent then + -- print('autocheese: no sufficiently full barrel found') + return +end + +local workshop, worker = findWorkshop(xyz2pos(dfhack.items.getPosition(reagent))) + +if not workshop then + print("autocheese: no Farmer's Workshop available") + return +end + +-- try to find laborer for workshop without master +if not worker then + worker, _ = findAvailableLaborer(df.unit_labor.MAKE_CHEESE, df.job_skill.CHEESEMAKING, workshop) +end + +if not worker then + print('autocheese: no cheesemaker available') + return +end +local job = makeCheese(reagent, workshop) + +print(('autocheese: dispatching cheesemaking job for %s (%d milk) to %s'):format( + dfhack.df2console(dfhack.items.getReadableDescription(reagent)), + #reagent.general_refs, + dfhack.df2console(dfhack.units.getReadableName(worker)) +)) + + +-- assign a worker and send it to fetch the barrel +dfhack.job.addWorker(job, worker) +dfhack.units.setPathGoal(worker, reagent.pos, df.unit_path_goal.GrabJobResources) +job.items[0].flags.is_fetching = true +job.flags.fetching = true diff --git a/autofarm.rb b/autofarm.rb deleted file mode 100644 index b41817362c..0000000000 --- a/autofarm.rb +++ /dev/null @@ -1,196 +0,0 @@ -# Select crops to plant based on current stocks -=begin - -autofarm -======== -Automatically handle crop selection in farm plots based on current plant stocks. -Selects a crop for planting if current stock is below a threshold. -Selected crops are dispatched on all farmplots. - -Usage:: - - autofarm start - autofarm default 30 - autofarm threshold 150 helmet_plump tail_pig - -=end - -class AutoFarm - - def initialize - @thresholds = Hash.new(50) - @lastcounts = Hash.new(0) - end - - def setthreshold(id, v) - list = df.world.raws.plants.all.find_all { |plt| plt.flags[:SEED] }.map { |plt| plt.id } - if tok = df.match_rawname(id, list) - @thresholds[tok] = v.to_i - else - puts "No plant with id #{id}, try one of " + - list.map { |w| w =~ /[^\w]/ ? w.inspect : w }.sort.join(' ') - end - end - - def setdefault(v) - @thresholds.default = v.to_i - end - - def is_plantable(plant) - has_seed = plant.flags[:SEED] - season = df.cur_season - harvest = df.cur_season_tick + plant.growdur * 10 - can_plant = has_seed && plant.flags[season] - while (harvest >= 10080) - season = (season + 1)%4 - harvest = harvest - 10080 - can_plant = can_plant && plant.flags[season] - end - can_plant - end - - def find_plantable_plants - plantable = {} - counts = Hash.new(0) - - df.world.items.other[:SEEDS].each { |i| - if (!i.flags.dump && !i.flags.forbid && !i.flags.garbage_collect && - !i.flags.hostile && !i.flags.on_fire && !i.flags.rotten && - !i.flags.trader && !i.flags.in_building && !i.flags.construction && - !i.flags.artifact) - counts[i.mat_index] += i.stack_size - end - } - - counts.keys.each { |i| - if df.ui.tasks.discovered_plants[i] - plant = df.world.raws.plants.all[i] - if is_plantable(plant) - plantable[i] = :Surface if (plant.underground_depth_min == 0 || plant.underground_depth_max == 0) - plantable[i] = :Underground if (plant.underground_depth_min > 0 || plant.underground_depth_max > 0) - end - end - } - - return plantable - end - - def set_farms(plants, farms) - return if farms.length == 0 - if plants.length == 0 - plants = [-1] - end - - season = df.cur_season - - farms.each_with_index { |f, idx| - f.plant_id[season] = plants[idx % plants.length] - } - end - - def process - plantable = find_plantable_plants - @lastcounts = Hash.new(0) - - df.world.items.other[:PLANT].each { |i| - if (!i.flags.dump && !i.flags.forbid && !i.flags.garbage_collect && - !i.flags.hostile && !i.flags.on_fire && !i.flags.rotten && - !i.flags.trader && !i.flags.in_building && !i.flags.construction && - !i.flags.artifact && plantable.has_key?(i.mat_index)) - id = df.world.raws.plants.all[i.mat_index].id - @lastcounts[id] += i.stack_size - end - } - - return unless @running - - plants_s = [] - plants_u = [] - - plantable.each_key { |k| - plant = df.world.raws.plants.all[k] - if (@lastcounts[plant.id] < @thresholds[plant.id]) - plants_s.push(k) if plantable[k] == :Surface - plants_u.push(k) if plantable[k] == :Underground - end - } - - farms_s = [] - farms_u = [] - df.world.buildings.other[:FARM_PLOT].each { |f| - if (f.flags.exists) - underground = df.map_designation_at(f.centerx,f.centery,f.z).subterranean - farms_s.push(f) unless underground - farms_u.push(f) if underground - end - } - - set_farms(plants_s, farms_s) - set_farms(plants_u, farms_u) - end - - def start - return if @running - @onupdate = df.onupdate_register('autofarm', 1200) { process } - @running = true - end - - def stop - df.onupdate_unregister(@onupdate) - @running = false - end - - def status - stat = @running ? "Running." : "Stopped." - @lastcounts.each { |k,v| - stat << "\n#{k} limit #{@thresholds.fetch(k, 'default')} current #{v}" - } - @thresholds.each { |k,v| - stat << "\n#{k} limit #{v} current 0" unless @lastcounts.has_key?(k) - } - stat << "\nDefault: #{@thresholds.default}" - stat - end - -end - -$AutoFarm ||= AutoFarm.new - -case $script_args[0] -when 'start', 'enable' - $AutoFarm.start - puts $AutoFarm.status - -when 'end', 'stop', 'disable' - $AutoFarm.stop - puts 'Stopped.' - -when 'default' - $AutoFarm.setdefault($script_args[1]) - -when 'threshold' - t = $script_args[1] - $script_args[2..-1].each {|i| - $AutoFarm.setthreshold(i, t) - } - -when 'delete' - $AutoFarm.stop - $AutoFarm = nil - -when 'help', '?' - puts < [min] [] + +--@ enable=true +--@ module=true + +local argparse = require("argparse") +local repeatutil = require("repeat-util") + +local GLOBAL_KEY = "autofish" + +-- set default enabled state +enabled = enabled or false +s_maxFish = s_maxFish or 100 +s_minFish = s_minFish or 75 +s_useRaw = s_useRaw or true +isFishing = isFishing or true + +--- Check if the script is enabled. +-- @return true if the script is enabled, otherwise false. +function isEnabled() + return enabled +end + +--- Save the current state of the script +local function persist_state() + dfhack.persistent.saveSiteData(GLOBAL_KEY, { + enabled=enabled, + s_maxFish=s_maxFish, + s_minFish=s_minFish, + s_useRaw=s_useRaw, + isFishing=isFishing, + }) +end + +--- Load the saved state of the script +local function load_state() + -- load persistent data + local persisted_data = dfhack.persistent.getSiteData(GLOBAL_KEY, {}) + enabled = persisted_data.enabled or false + s_maxFish = persisted_data.s_maxFish or 100 + s_minFish = persisted_data.s_minFish or 75 + s_useRaw = persisted_data.s_useRaw or (persisted_data.s_useRaw == nil) + isFishing = persisted_data.isFishing or (persisted_data.isFishing == nil) +end + +--- Set the maximum fish threshold. +-- @param val The value to set s_maxFish to. (number) +function set_minFish(val) + s_minFish = val + -- min fish cannot exceed max fish + if s_minFish >= s_maxFish then s_minFish = s_maxFish end + persist_state() +end + +--- Set the minimum fish threshold. +-- @param val The value to set s_minFish to. (number) +function set_maxFish(val) + s_maxFish = val + -- max fish cannot be lower than min fish + if s_maxFish <= s_minFish then s_minFish = s_maxFish end + persist_state() +end + +--- Set the raw fish toggle +-- @param val The value to set s_useRaw to. (boolean) +function set_useRaw(val) + s_useRaw = val + persist_state() +end + +--- Toggle all work details (and fishing labours) +-- @param state What state to toggle the labours to. +function toggle_fishing_labour(state) + -- pass true to state to turn on, otherwise disable + -- find all work details that have fishing enabled: + local work_details = df.global.plotinfo.labor_info.work_details + for _,v in pairs(work_details) do + if v.allowed_labors.FISH then + v.flags.mode = state and + df.work_detail_mode.OnlySelectedDoesThis or df.work_detail_mode.NobodyDoesThis + + -- since the work details are not actually applied unless a button + -- is clicked on the work details screen, we have to manually set + -- unit labours + for _,v2 in ipairs(v.assigned_units) do + -- find unit by ID and toggle fishing + local unit = df.unit.find(v2) + if unit then + unit.status.labors.FISH = state + end + end + end + end + isFishing = state -- save current state + + -- let the user know we've got enough, or run out of fish + if isFishing then + print("autofish: Re-enabling fishing, fallen below minimum.") + else + print("autofish: Disabling fishing, reached desired quota.") + end +end + +--- Checks several item flags to see if a given item should be considered good +-- @param item: a valid dwarf fortress item. +function isValidItem(item) + local flags = item.flags + if flags.rotten or flags.trader or flags.hostile or flags.forbid + or flags.dump or flags.on_fire or flags.garbage_collect or flags.owned + or flags.removed or flags.encased or flags.spider_web then + return false + end + return true +end + + +--- Counts the number of available fish in a fortress +-- @return prepared (number): count of prepared fish available. +-- @return raw (number): count of raw fish available. +function count_fish() + local world = df.global.world + + -- count the number of valid fish we have. (not rotten, forbidden, on fire, dumping...) + local prepared, raw = 0, 0 + for k,v in pairs(world.items.other[df.items_other_id.IN_PLAY]) do + if v:getType() == df.item_type.FISH and isValidItem(v) then + prepared = prepared + v:getStackSize() + end + if (v:getType() == df.item_type.FISH_RAW and isValidItem(v)) and s_useRaw then + raw = raw + v:getStackSize() + end + end + return prepared, raw +end + +--- The main event loop of the script. +function event_loop() + if not enabled then return end + + local prepared, raw = count_fish() + + -- handle pausing/resuming labour + local numFish = s_useRaw and (prepared+raw) or prepared + if isFishing and (numFish >= s_maxFish) then + toggle_fishing_labour(false) + elseif not isFishing and (numFish < s_minFish) then + toggle_fishing_labour(true) + end + + persist_state() + + -- check weekly + repeatutil.scheduleUnlessAlreadyScheduled(GLOBAL_KEY, 7, "days", event_loop) +end + + +--- Print a status output, showing the current state of the script and options. +local function print_status() + print(string.format("autofish is currently %s.\n", (enabled and "enabled" or "disabled"))) + if enabled then + local rfs + rfs = s_useRaw and "raw & prepared" or "prepared" + + print(string.format("Stopping at %s %s fish.", s_maxFish, rfs)) + print(string.format("Restarting at %s %s fish.", s_minFish, rfs)) + if isFishing then + print("\nCurrently allowing fishing.") + else + print("\nCurrently not allowing fishing.") + end + end +end + +--- Handles automatic loading +dfhack.onStateChange[GLOBAL_KEY] = function(sc) + -- unload with game + if sc == SC_MAP_UNLOADED then + enabled = false + return + end + + if sc ~= SC_MAP_LOADED or df.global.gamemode ~= df.game_mode.DWARF then + return + end + + load_state() + + -- run the main code + event_loop() +end + + +-- sanity checks? +if dfhack_flags.module then + return +end + +if df.global.gamemode ~= df.game_mode.DWARF or not dfhack.isMapLoaded() then + dfhack.printerr("autofish needs a loaded fortress to work") + return +end + +-- argument handling +local args = {...} +if dfhack_flags and dfhack_flags.enable then + args = {dfhack_flags.enable_state and "enable" or "disable"} +end + +-- handle options flags +local positionals = argparse.processArgsGetopt(args, + {{"r", "raw", hasArg=true, + handler=function(optArg) + local val = argparse.boolean(optArg, "raw") + set_useRaw(val) + end} +}) + +load_state() +-- handle the rest of the arguments +if positionals[1] == "enable" then + enabled = true + +elseif positionals[1] == "disable" then + enabled = false + persist_state() + repeatutil.cancel(GLOBAL_KEY) + return + +elseif positionals[1] == "status" then + print_status() + return + +-- positionals is an empty table if no positional arguments are set +elseif positionals ~= nil then + -- check to see if passed args are numbers + if positionals[1] and tonumber(positionals[1]) then + -- assume we're changing setting: + local newval = tonumber(positionals[1]) + set_maxFish(newval) + if not positionals[2] then + set_minFish(math.floor(newval * 0.75)) + end + end + + if positionals[2] and tonumber(positionals[2]) then + set_minFish(tonumber(positionals[2])) + end + + -- a setting probably changed, save & show the updated settings. + persist_state() + print_status() + return +end + +event_loop() +persist_state() diff --git a/autolabor-artisans.lua b/autolabor-artisans.lua index 069fe4a9c0..4bf95145e8 100644 --- a/autolabor-artisans.lua +++ b/autolabor-artisans.lua @@ -11,6 +11,7 @@ influences output quality. Examples:: autolabor-artisans disable ]====] + local artisan_labors = { "CARPENTER", "DETAIL", @@ -51,7 +52,7 @@ end function run() if #args == 0 or args[1] == "help" then - print(help) + print(dfhack.script_help()) return false end diff --git a/autonick.lua b/autonick.lua new file mode 100644 index 0000000000..ee1cd01326 --- /dev/null +++ b/autonick.lua @@ -0,0 +1,61 @@ +-- gives dwarves unique nicknames + +local options = {} + +local argparse = require('argparse') +local commands = argparse.processArgsGetopt({...}, { + {'h', 'help', handler=function() options.help = true end}, + {'q', 'quiet', handler=function() options.quiet = true end}, +}) + +if #commands ~= 1 or commands[1] ~= "all" then + options.help = true +end + +if options.help == true then + print(dfhack.script_help()) + return +end + +local seen = {} +--check current nicknames +for _,unit in ipairs(dfhack.units.getCitizens()) do + if unit.name.nickname ~= "" then + seen[unit.name.nickname] = true + end +end + +local names = {} +-- grab list, put in array +local path = dfhack.getDFPath () .. "/dfhack-config/autonick.txt"; +for line in io.lines(path) do + line = line:trim() + if (line ~= "") + and (not line:startswith('#')) + and (not seen[line]) then + table.insert(names, line) + seen[line] = true + end +end + +--assign names +local count = 0 +for _,unit in ipairs(dfhack.units.getCitizens()) do + if (#names == 0) then + if options.quiet ~= true then + print("not enough unique names in dfhack-config/autonick.txt") + end + break + end + --if there are any names left + if unit.name.nickname == "" then + newnameIndex = math.random (#names) + dfhack.units.setNickname(unit, names[newnameIndex]) + table.remove(names, newnameIndex) + count = count + 1 + end +end + +if options.quiet ~= true then + print(("gave nicknames to %s dwarves."):format(count)) +end diff --git a/autotraining.lua b/autotraining.lua new file mode 100644 index 0000000000..386ab6bb0a --- /dev/null +++ b/autotraining.lua @@ -0,0 +1,296 @@ +-- Based on the original code by RNGStrategist (who also got some help from Uncle Danny) +--@ enable = true +--@ module = true + +local repeatUtil = require('repeat-util') +local utils=require('utils') + +local GLOBAL_KEY = "autotraining" +local MartialTraining = df.need_type['MartialTraining'] +local ignore_count = 0 + +local function get_default_state() + return { + enabled=false, + threshold=-5000, + ignored={}, + ignored_nobles={}, + training_squads = {}, + } +end + +state = state or get_default_state() + +function isEnabled() + return state.enabled +end + +-- persisting a table with numeric keys results in a json array with a huge number of null entries +-- therefore, we convert the keys to strings for persistence +local function to_persist(persistable) + local persistable_ignored = {} + for k, v in pairs(persistable) do + persistable_ignored[tostring(k)] = v + end + return persistable_ignored +end + +-- loads both from the older array format and the new string table format +local function from_persist(persistable) + if not persistable then + return + end + local ret = {} + for k, v in pairs(persistable) do + ret[tonumber(k)] = v + end + return ret +end + +function persist_state() + dfhack.persistent.saveSiteData(GLOBAL_KEY, { + enabled=state.enabled, + threshold=state.threshold, + ignored=to_persist(state.ignored), + ignored_nobles=state.ignored_nobles, + training_squads=to_persist(state.training_squads) + }) +end + +--- Load the saved state of the script +local function load_state() + -- load persistent data + local persisted_data = dfhack.persistent.getSiteData(GLOBAL_KEY, {}) + state.enabled = persisted_data.enabled or state.enabled + state.threshold = persisted_data.threshold or state.threshold + state.ignored = from_persist(persisted_data.ignored) or state.ignored + state.ignored_nobles = persisted_data.ignored_nobles or state.ignored_nobles + state.training_squads = from_persist(persisted_data.training_squads) or state.training_squads + return state +end + +dfhack.onStateChange[GLOBAL_KEY] = function(sc) + if sc == SC_MAP_UNLOADED then + state.enabled = false + return + end + -- the state changed, is a map loaded and is that map in fort mode? + if sc ~= SC_MAP_LOADED or df.global.gamemode ~= df.game_mode.DWARF then + -- no its isnt, so bail + return + end + -- yes it was, so: + + -- retrieve state saved in game. merge with default state so config + -- saved from previous versions can pick up newer defaults. + load_state() + if state.enabled then + start() + end + persist_state() +end + + +--###### +--Functions +--###### +local function isIgnoredNoble(unit) + local noblePos = dfhack.units.getNoblePositions(unit) + if noblePos ~= nil then + for _, position in ipairs(noblePos) do + if state.ignored_nobles[position.position.code] then + return true + end + end + end + return false +end + +---@return table +function getTrainingCandidates() + local ret = {} + ignore_count = 0 + for _, unit in ipairs(dfhack.units.getCitizens(true)) do + if not dfhack.units.isAdult(unit) then + goto next_unit + end + local need = getTrainingNeed(unit) + if not need or need.focus_level >= state.threshold then + goto next_unit + end + -- ignored units are those that would like to train but are forbidden from doing so + if state.ignored[unit.id] then + ignore_count = ignore_count + 1 + goto next_unit + end + if isIgnoredNoble(unit) then + ignore_count = ignore_count + 1 + goto next_unit + end + if unit.military.squad_id ~= -1 then + goto next_unit + end + table.insert(ret, { unit = unit, need = need.focus_level }) + ::next_unit:: + end + table.sort(ret, function (a, b) return a.need < b.need end) + return ret +end + +function getTrainingSquads() + local squads = {} + for squad_id, active in pairs(state.training_squads) do + local squad = df.squad.find(squad_id) + if active and squad then + table.insert(squads, squad) + else + -- setting to nil during iteration is permitted by lua + state.training_squads[squad_id] = nil + end + end + return squads +end + +function getTrainingNeed(unit) + if unit == nil then return nil end + local needs = unit.status.current_soul.personality.needs + for _, need in ipairs(needs) do + if need.id == MartialTraining then + return need + end + end + return nil +end + +--###### +--Main +--###### + +-- Find all training squads +-- Abort if no squads found +function checkSquads() + local squads = {} + for _, squad in ipairs(getTrainingSquads()) do + if squad.entity_id == df.global.plotinfo.group_id then + local leader = squad.positions[0].occupant + if leader ~= -1 then + table.insert(squads,squad) + end + end + end + + if #squads == 0 then + return nil + end + + return squads +end + +function addTraining(unit,good_squads) + if unit.military.squad_id ~= -1 then + for _, squad in ipairs(good_squads) do + if unit.military.squad_id == squad.id then + return true + end + end + return false + end + for _, squad in ipairs(good_squads) do + for i=1,9,1 do + if squad.positions[i].occupant == -1 then + return dfhack.military.addToSquad(unit.id,squad.id,i) + end + end + end + + return false +end + +function removeAll() + if state.training_squads == nil then return end + for _, squad in ipairs(getTrainingSquads()) do + for i=1,9,1 do + local hf = df.historical_figure.find(squad.positions[i].occupant) + if hf ~= nil then + dfhack.military.removeFromSquad(hf.unit_id) + end + end + end +end + + +function check() + local squads = checkSquads() + local intraining_count = 0 + local inque_count = 0 + if squads == nil then return end + for _,squad in ipairs(squads) do + for i=1,9,1 do + if squad.positions[i].occupant ~= -1 then + local hf = df.historical_figure.find(squad.positions[i].occupant) + if hf ~= nil then + local unit = df.unit.find(hf.unit_id) + local training_need = getTrainingNeed(unit) + if not training_need or training_need.focus_level >= state.threshold then + dfhack.military.removeFromSquad(unit.id) + end + end + end + end + end + for _, p in ipairs(getTrainingCandidates()) do + local added = addTraining(p.unit, squads) + if added then + intraining_count = intraining_count +1 + else + inque_count = inque_count +1 + end + end + print(("%s: %d training, %d waiting, and %d excluded units with training needs"): + format(GLOBAL_KEY, intraining_count, inque_count, ignore_count)) +end + +function start() + repeatUtil.scheduleEvery(GLOBAL_KEY, 1, 'days', check) +end + +function stop() + repeatUtil.cancel(GLOBAL_KEY) +end + +function enable() + state.enabled = true + persist_state() + start() +end + +function disable() + state.enabled = false + persist_state() + stop() + removeAll() +end + +if dfhack_flags.module then + return +end + +validArgs = utils.invert({ + 't' +}) + +local args = utils.processArgs({...}, validArgs) + +if dfhack_flags.enable then + if dfhack_flags.enable_state then + enable() + else + disable() + end +else + -- called on the command-line + if args.t then + state.threshold = 0-tonumber(args.t) + end + print(("autotraining is %s"):format(state.enabled and "enabled" or "disabled")) +end diff --git a/autounsuspend.rb b/autounsuspend.rb deleted file mode 100644 index a3a0de2003..0000000000 --- a/autounsuspend.rb +++ /dev/null @@ -1,54 +0,0 @@ -# un-suspend construction jobs, on a recurring basis -=begin - -autounsuspend -============= -Automatically unsuspend construction jobs, on a recurring basis. - -Will not unsuspend jobs where water flow > 1. - -See `unsuspend` for one-off use, or `resume` ``all``. - -=end - -class AutoUnsuspend - attr_accessor :running - - def process - count = 0 - df.world.jobs.list.each { |job| - if job.job_type == :ConstructBuilding and job.flags.suspend and df.map_tile_at(job).designation.flow_size <= 1 - # skip planned buildings - next if job.job_items.length == 1 and job.job_items[0].item_type == :NONE - job.flags.suspend = false - count += 1 - end - } - if count > 0 - puts "Unsuspended #{count} job(s)." - df.process_jobs = true - end - end - - def start - @running = true - @onupdate = df.onupdate_register('autounsuspend', 100) { process if @running } - end - - def stop - @running = false - df.onupdate_unregister(@onupdate) - end -end - -case $script_args[0] -when 'start' - $AutoUnsuspend ||= AutoUnsuspend.new - $AutoUnsuspend.start - -when 'end', 'stop' - $AutoUnsuspend.stop - -else - puts $AutoUnsuspend && $AutoUnsuspend.running ? 'Running.' : 'Stopped.' -end diff --git a/ban-cooking.lua b/ban-cooking.lua new file mode 100644 index 0000000000..2254e116dd --- /dev/null +++ b/ban-cooking.lua @@ -0,0 +1,310 @@ +-- convenient way to ban cooking categories of food +-- based on ban-cooking.rb by Putnam: https://github.com/DFHack/scripts/pull/427/files +-- Putnams work completed by TBSTeun + +local argparse = require('argparse') + +local kitchen = df.global.plotinfo.kitchen + +local options = {} +local banned = {} +local count = 0 + +local function make_key(mat_type, mat_index, type, subtype) + return ('%s:%s:%s:%s'):format(mat_type, mat_index, type, subtype) +end + +local function ban_cooking(print_name, mat_type, mat_index, type, subtype) + local key = make_key(mat_type, mat_index, type, subtype) + -- Skip adding a new entry further below if there's nothing to do + if (banned[key] and not options.unban) or (not banned[key] and options.unban) then + return + end + -- The item hasn't already been (un)banned, so we do that here by appending/removing + -- its values to/from the various arrays + count = count + 1 + if options.verbose then + print(print_name .. ' has been ' .. (options.unban and 'un' or '') .. 'banned!') + end + + if options.unban then + dfhack.kitchen.removeExclusion({Cook=true}, type, subtype, mat_type, mat_index) + banned[key] = nil + else + dfhack.kitchen.addExclusion({Cook=true}, type, subtype, mat_type, mat_index) + banned[key] = { + mat_type=mat_type, + mat_index=mat_index, + type=type, + subtype=subtype, + } + end +end + +local function init_banned() + -- Iterate over the elements of the kitchen.item_types list + for i in ipairs(kitchen.item_types) do + if kitchen.exc_types[i].Cook then + local key = make_key(kitchen.mat_types[i], kitchen.mat_indices[i], kitchen.item_types[i], kitchen.item_subtypes[i]) + if not banned[key] then + banned[key] = { + mat_type=kitchen.mat_types[i], + mat_index=kitchen.mat_indices[i], + type=kitchen.item_types[i], + subtype=kitchen.item_subtypes[i], + } + end + end + end +end + +local funcs = {} + +funcs.booze = function() + for _, p in ipairs(df.global.world.raws.plants.all) do + for _, m in ipairs(p.material) do + if m.flags.ALCOHOL and m.flags.EDIBLE_COOKED then + local matinfo = dfhack.matinfo.find(p.id, m.id) + ban_cooking(p.name .. ' ' .. m.id, matinfo.type, matinfo.index, df.item_type.DRINK, -1) + end + end + end + for _, c in ipairs(df.global.world.raws.creatures.all) do + for _, m in ipairs(c.material) do + if m.flags.ALCOHOL and m.flags.EDIBLE_COOKED then + local matinfo = dfhack.matinfo.find(c.creature_id, m.id) + ban_cooking(c.name[2] .. ' ' .. m.id, matinfo.type, matinfo.index, df.item_type.DRINK, -1) + end + end + end +end + +funcs.honey = function() + for _, c in ipairs(df.global.world.raws.creatures.all) do + for _, m in ipairs(c.material) do + if m.flags.EDIBLE_COOKED then + for _, s in ipairs(m.reaction_product.id) do + if s.value == "DRINK_MAT" then + local matinfo = dfhack.matinfo.find(c.creature_id, m.id) + ban_cooking(c.name[2] .. ' ' .. m.id, matinfo.type, matinfo.index, df.item_type.LIQUID_MISC, -1) + break + end + end + end + end + end +end + +funcs.tallow = function() + for _, c in ipairs(df.global.world.raws.creatures.all) do + for _, m in ipairs(c.material) do + if m.flags.EDIBLE_COOKED then + for _, s in ipairs(m.reaction_product.id) do + if s.value == "SOAP_MAT" then + local matinfo = dfhack.matinfo.find(c.creature_id, m.id) + ban_cooking(c.name[2] .. ' ' .. m.id, matinfo.type, matinfo.index, df.item_type.GLOB, -1) + break + end + end + end + end + end +end + +funcs.milk = function() + for _, c in ipairs(df.global.world.raws.creatures.all) do + for _, m in ipairs(c.material) do + if m.flags.EDIBLE_COOKED then + for _, s in ipairs(m.reaction_product.id) do + if s.value == "CHEESE_MAT" then + local matinfo = dfhack.matinfo.find(c.creature_id, m.id) + ban_cooking(c.name[2] .. ' ' .. m.id, matinfo.type, matinfo.index, df.item_type.LIQUID_MISC, -1) + break + end + end + end + end + end +end + +funcs.oil = function() + for _, p in ipairs(df.global.world.raws.plants.all) do + for _, m in ipairs(p.material) do + if m.flags.EDIBLE_COOKED then + for _, s in ipairs(m.reaction_product.id) do + if s.value == "SOAP_MAT" then + local matinfo = dfhack.matinfo.find(p.id, m.id) + ban_cooking(p.name .. ' ' .. m.id, matinfo.type, matinfo.index, df.item_type.LIQUID_MISC, -1) + break + end + end + end + end + end +end + +funcs.seeds = function() + for _, p in ipairs(df.global.world.raws.plants.all) do + if p.material_defs.type.seed == -1 or p.material_defs.idx.seed == -1 or p.flags.TREE then goto continue end + ban_cooking(p.name .. ' seeds', p.material_defs.type.seed, p.material_defs.idx.seed, df.item_type.SEEDS, -1) + for _, m in ipairs(p.material) do + if m.id == "STRUCTURAL" then + if m.flags.EDIBLE_COOKED then + local has_seed = false + for _, s in ipairs(m.reaction_product.id) do + has_seed = has_seed or s.value == "SEED_MAT" + end + if has_seed then + local matinfo = dfhack.matinfo.find(p.id, m.id) + ban_cooking(p.name .. ' ' .. m.id, matinfo.type, matinfo.index, df.item_type.PLANT, -1) + end + end + break + end + end + for k, g in ipairs(p.growths) do + local matinfo = dfhack.matinfo.decode(g) + local m = matinfo.material + if m.flags.EDIBLE_COOKED then + local has_seed = false + for _, s in ipairs(m.reaction_product.id) do + has_seed = has_seed or s.value == "SEED_MAT" + end + if has_seed then + ban_cooking(p.name .. ' ' .. m.id, matinfo.type, matinfo.index, df.item_type.PLANT_GROWTH, k) + end + end + end + + ::continue:: + end +end + +funcs.brew = function() + for _, p in ipairs(df.global.world.raws.plants.all) do + if p.material_defs.type.drink == -1 or p.material_defs.idx.drink == -1 then goto continue end + for _, m in ipairs(p.material) do + if m.id == "STRUCTURAL" then + if m.flags.EDIBLE_COOKED then + for _, s in ipairs(m.reaction_product.id) do + if s.value == "DRINK_MAT" then + local matinfo = dfhack.matinfo.find(p.id, m.id) + ban_cooking(p.name .. ' ' .. m.id, matinfo.type, matinfo.index, df.item_type.PLANT, -1) + break + end + end + end + -- Stop iterating materials since there is only one STRUCTURAL + break + end + end + for k, g in ipairs(p.growths) do + local matinfo = dfhack.matinfo.decode(g) + local m = matinfo.material + if m.flags.EDIBLE_COOKED then + for _, s in ipairs(m.reaction_product.id) do + if s.value == "DRINK_MAT" then + ban_cooking(p.name .. ' ' .. m.id, matinfo.type, matinfo.index, df.item_type.PLANT_GROWTH, k) + break + end + end + end + end + + ::continue:: + end +end + +funcs.mill = function() + for _, p in ipairs(df.global.world.raws.plants.all) do + if p.material_defs.idx.mill ~= -1 then + for _, m in ipairs(p.material) do + if m.id == "STRUCTURAL" then + if m.flags.EDIBLE_COOKED then + local matinfo = dfhack.matinfo.find(p.id, m.id) + ban_cooking(p.name .. ' ' .. m.id, matinfo.type, matinfo.index, df.item_type.PLANT, -1) + end + break + end + end + end + end +end + +funcs.thread = function() + for _, p in ipairs(df.global.world.raws.plants.all) do + if p.material_defs.idx.thread == -1 then goto continue end + for _, m in ipairs(p.material) do + if m.id == "STRUCTURAL" then + if m.flags.EDIBLE_COOKED then + for _, s in ipairs(m.reaction_product.id) do + if s.value == "THREAD" then + local matinfo = dfhack.matinfo.find(p.id, m.id) + ban_cooking(p.name .. ' ' .. m.id, matinfo.type, matinfo.index, df.item_type.PLANT, -1) + break + end + end + end + break + end + end + for k, g in ipairs(p.growths) do + local matinfo = dfhack.matinfo.decode(g) + local m = matinfo.material + if m.flags.EDIBLE_COOKED then + for _, s in ipairs(m.reaction_product.id) do + if s.value == "THREAD" then + ban_cooking(p.name .. ' ' .. m.id, matinfo.type, matinfo.index, df.item_type.PLANT_GROWTH, k) + break + end + end + end + end + + ::continue:: + end +end + +funcs.fruit = function() + for _, p in ipairs(df.global.world.raws.plants.all) do + for k, g in ipairs(p.growths) do + local matinfo = dfhack.matinfo.decode(g) + local m = matinfo.material + if m.id == "FRUIT" and m.flags.EDIBLE_COOKED and m.flags.STOCKPILE_PLANT_GROWTH then + for _, s in ipairs(m.reaction_product.id) do + if s.value == "DRINK_MAT" then + ban_cooking(p.name .. ' ' .. m.id, matinfo.type, matinfo.index, df.item_type.PLANT_GROWTH, k) + break + end + end + end + end + end +end + +local classes = argparse.processArgsGetopt({...}, { + {'h', 'help', handler=function() options.help = true end}, + {'u', 'unban', handler=function() options.unban = true end}, + {'v', 'verbose', handler=function() options.verbose = true end}, +}) + +if options.help == true then + print(dfhack.script_help()) + return +end + +init_banned() + +if classes[1] == 'all' then + for _, func in pairs(funcs) do + func() + end +else + for _, v in ipairs(classes) do + if funcs[v] then + funcs[v]() + end + end +end + +print((options.unban and 'un' or '') .. 'banned ' .. count .. ' types.') diff --git a/ban-cooking.rb b/ban-cooking.rb deleted file mode 100644 index aa89eeb48f..0000000000 --- a/ban-cooking.rb +++ /dev/null @@ -1,358 +0,0 @@ -# convenient way to ban cooking categories of food -=begin - -ban-cooking -=========== -A more convenient way to ban cooking various categories of foods than the -kitchen interface. Usage: ``ban-cooking ``. Valid types are ``booze``, -``honey``, ``tallow``, ``oil``, ``seeds`` (non-tree plants with seeds), -``brew``, ``fruit``, ``mill``, ``thread``, and ``milk``. - -=end - -# Create a dictionary/hash table to store what items are already banned. -already_banned = {} - -# Just create a shorthand reference to the kitchen object -kitchen = df.ui.kitchen - -# Store our list of banned items in the dictionary/hash table -kitchen.item_types.length.times { |i| - if kitchen.exc_types[i] == :Cook - already_banned[[kitchen.mat_types[i], kitchen.mat_indices[i], kitchen.item_types[i], kitchen.item_subtypes[i]]] = true - end -} - -# The function for actually banning cooking of an item. -# -- subtype was added to the arguments list from the original script, as -# the original script defaulted subtype to -1, which doesn't support tree -# fruit items -# -- item names was added to the front of the arguments list, as the -# original script ran silently, and during debugging it was found to be -# more useful to print the banned item names than picking through the -# kitchen menu in game -ban_cooking = lambda { |print_name, mat_type, mat_index, type, subtype| - key = [mat_type, mat_index, type, subtype] - # Skip adding a new entry further below, if the item is already banned. - if already_banned[key] - return - end - # The item hasn't already been banned, so we do that here by appending its values to the various arrays - puts(print_name + ' has been banned!') - # grab the length of the array now, before it's appended to, so that we don't have to subtract one for the index value to be correct after appending is done. - length = df.ui.kitchen.mat_types.length - df.ui.kitchen.mat_types << mat_type - df.ui.kitchen.mat_indices << mat_index - df.ui.kitchen.item_types << type - df.ui.kitchen.item_subtypes << subtype - df.ui.kitchen.exc_types << :Cook - already_banned[key] = true -} - -$script_args.each do |arg| - case arg - # ban the cooking of plant based alcohol - # -- targets creature based alcohol, of which I'm not sure if any exists, but it should be banned too if it does, I guess (forgotten beasts maybe?) - when 'booze' - df.world.raws.plants.all.each_with_index do |p, i| - p.material.each_with_index do |m, j| - if m.flags[:ALCOHOL] and m.flags[:EDIBLE_COOKED] - ban_cooking[p.name + ' ' + m.id, j + DFHack::MaterialInfo::PLANT_BASE, i, :DRINK, -1] - end - end - end - df.world.raws.creatures.all.each_with_index do |c, i| - c.material.each_with_index do |m, j| - if m.flags[:ALCOHOL] and m.flags[:EDIBLE_COOKED] - ban_cooking[c.name[0] + ' ' + m.id, j + DFHack::MaterialInfo::CREATURE_BASE, i, :DRINK, -1] - end - end - end - - # Mmmm.... mead. For those days when you want to savor the labor of thousands of semi-willingly enslaved workers. - # Bans only honey bee honey... technically dwarves could collect bumble bee honey from wild nests, I think... - when 'honey' - # hard-coded in the raws of the mead reaction - honey = df.decode_mat('CREATURE:HONEY_BEE:HONEY') - ban_cooking['honey bee honey', honey.mat_type, honey.mat_index, :LIQUID_MISC, -1] - - # Gotta have that cat soap somehow... - # Just wait until explosives are implemented... - # Bans all tallow from creatures - when 'tallow' - df.world.raws.creatures.all.each_with_index do |c, i| - c.material.each_with_index do |m, j| - if m.flags[:EDIBLE_COOKED] and m.reaction_product.id.include?('SOAP_MAT') - ban_cooking[c.name[0] + ' ' + m.id, j + DFHack::MaterialInfo::CREATURE_BASE, i, :GLOB, -1] - end - end - end - - # Too bad adding this to meals doesn't alter the bone fracture mechanics (both healing and damage taking) - # Ban milk from cooking, so that cheese can be produced - # -- Not the best of ideas, as currently milk lasts forever, and cheese rots. - # -- Technically hard cheeses never go "bad", they just grow a nasty mold layer that can be cut off - when 'milk' - df.world.raws.creatures.all.each_with_index do |c, i| - c.material.each_with_index do |m, j| - if m.flags[:EDIBLE_COOKED] and m.reaction_product.id.include?('CHEESE_MAT') - ban_cooking[c.name[0] + ' ' + m.id, j + DFHack::MaterialInfo::CREATURE_BASE, i, :LIQUID_MISC, -1] - end - end - end - - # Don't be an elf... - # Ban all plant based oils from cooking - when 'oil' - df.world.raws.plants.all.each_with_index do |p, i| - p.material.each_with_index do |m, j| - if m.flags[:EDIBLE_COOKED] and m.reaction_product.id.include?('SOAP_MAT') - ban_cooking[p.name + ' ' + m.id, j + DFHack::MaterialInfo::PLANT_BASE, i, :LIQUID_MISC, -1] - end - end - end - - # Ban seeds, and the plant parts that produce the seeds from being cooked - # -- Doesn't ban seeds that can't be farmed (trees), as well as those that can't be brewed, - # as gaining seeds from dwarves eating the food raw is too time consuming. - when 'seeds' - df.world.raws.plants.all.each_with_index do |p, i| - # skip over plants without seeds and tree seeds (as you can't currently farm trees with their seeds) - if p.material_defs.type_seed != -1 and p.material_defs.idx_seed != -1 and not p.flags.inspect.include?('TREE') - # Bans the seeds themselves - ban_cooking[p.name + ' seeds', p.material_defs.type_seed, p.material_defs.idx_seed, :SEEDS, -1] - # This section handles banning the structural plant parts that produce seeds. - # -- There's no guarantee I can find that the STRUCTURAL material will be array item zero in the materials array - # thus I'm playing it safe with a possibly wasteful loop here - p.material.each_with_index do |m, j| - # only operate here on STRUCTURAL materials, as the rest will be :PLANT_GROWTH, instead of just :PLANT - # which then means that the subtype won't be -1 - if m.id == "STRUCTURAL" and m.flags[:EDIBLE_COOKED] and m.reaction_product.id.include?('SEED_MAT') and m.reaction_product.id.include?('DRINK_MAT') - ban_cooking[p.name + ' ' + m.id, j + DFHack::MaterialInfo::PLANT_BASE, i, :PLANT, -1] - end - end - # This section handles banning the plant growths that produce seeds - p.growths.each_with_index do |g, r| - m = df.decode_mat(g).material - if m.flags[:EDIBLE_COOKED] and m.reaction_product.id.include?('SEED_MAT') and m.reaction_product.id.include?('DRINK_MAT') - p.material.each_with_index do |s, j| - if m.id == s.id - ban_cooking[p.name + ' ' + m.id, j + DFHack::MaterialInfo::PLANT_BASE, i, :PLANT_GROWTH, r] - end - end - end - end - end - end - - # Bans cooking of alcohol producing plant parts - when 'brew' - df.world.raws.plants.all.each_with_index do |p, i| - # skip over any plants that don't have an alcohol listed - if p.material_defs.type_drink != -1 and p.material_defs.idx_drink != -1 - p.material.each_with_index do |m, j| - # only operate here on STRUCTURAL materials, as the rest will be :PLANT_GROWTH, instead of just :PLANT - # which then means that the subtype won't be -1 - if m.id == "STRUCTURAL" and m.flags[:EDIBLE_COOKED] and m.reaction_product.id.include?('DRINK_MAT') - ban_cooking[p.name + ' ' + m.id, j + DFHack::MaterialInfo::PLANT_BASE, i, :PLANT, -1] - end - end - # This section handles banning the plant growths that produce alcohol - p.growths.each_with_index do |g, r| - m = df.decode_mat(g).material - if m.flags[:EDIBLE_COOKED] and m.reaction_product.id.include?('DRINK_MAT') - p.material.each_with_index do |s, j| - if m.id == s.id - ban_cooking[p.name + ' ' + m.id, j + DFHack::MaterialInfo::PLANT_BASE, i, :PLANT_GROWTH, r] - end - end - end - end - end - end - - # Should work, but I don't think there are any millable plants that are cookable - when 'mill' - df.world.raws.plants.all.each_with_index do |p, i| - # skip over plants that don't have a millable part listed - if p.material_defs.idx_mill != -1 - p.material.each_with_index do |m, j| - if m.id == "STRUCTURAL" and m.flags[:EDIBLE_COOKED] - ban_cooking[p.name + ' ' + m.id, j + DFHack::MaterialInfo::PLANT_BASE, i, :PLANT, -1] - end - end - # No plant growths are targeted for milling, as I can't find a flag that would indicate that a growth - # was used for milling. Thus, I can only assume that only the STRUCTURAL plant object can be used - # in the milling process. - end - end - - # Should work, but I don't think there are any thread convertable plants that are cookable - when 'thread' - df.world.raws.plants.all.each_with_index do |p, i| - # skip over plants that don't have a threadable part listed - if p.material_defs.idx_thread != -1 - p.material.each_with_index do |m, j| - # only operate here on STRUCTURAL materials, as the rest will be :PLANT_GROWTH, instead of just :PLANT - # which then means that the subtype won't be -1 - if m.id == "STRUCTURAL" and m.flags[:EDIBLE_COOKED] and m.reaction_product.str.include?('THREAD') - ban_cooking[p.name + ' ' + m.id, j + DFHack::MaterialInfo::PLANT_BASE, i, :PLANT, -1] - end - end - # This section handles banning the plant growths that produce thread... not that there are any now, that I'm aware of... - p.growths.each_with_index do |g, r| - m = df.decode_mat(g).material - if m.flags[:EDIBLE_COOKED] and m.reaction_product.str.include?('THREAD') - p.material.each_with_index do |s, j| - if m.id == s.id - ban_cooking[p.name + ' ' + m.id, j + DFHack::MaterialInfo::PLANT_BASE, i, :PLANT_GROWTH, r] - end - end - end - end - end - end - - # Bans fruits that produce alcohol - when 'fruit' - df.world.raws.plants.all.each_with_index do |p, i| - p.growths.each_with_index do |g, r| - # Get the material item from the growth data - m = df.decode_mat(g).material - # ensure that we're only targetting fruits that can be cooked as solids (that's the :LEAF_MAT flag) - # in the kitchen, which can also be brewed into alcohol - if m.id == "FRUIT" and m.flags[:EDIBLE_COOKED] and m.flags[:LEAF_MAT] and m.reaction_product.id.include?('DRINK_MAT') - p.material.each_with_index do |s, j| - if m.id == s.id - ban_cooking[p.name + ' ' + m.id, j + DFHack::MaterialInfo::PLANT_BASE, i, :PLANT_GROWTH, r] - end - end - end - end - end - - # The below function outputs a pipe seperated list of the banned cooking ingredients - # The list isn't intended to be readable from the console, as I used it for validating - # the methods I was using to select, and ban cooking items. Mostly this was for the - # tree fruit items, as the item subtype number wasn't immediately obvious to be used - # as a reference pointer to the growths array. - when 'show' - # First put together a dictionary/hash table - type_list = {} - # cycle through all plants - df.world.raws.plants.all.each_with_index do |p, i| - # The below three if statements initialize the dictionary/hash tables for their respective (cookable) plant/drink/seed - # And yes, this will create and then overwrite an entry when there is no (cookable) plant/drink/seed item for a specific plant, - # but since the -1 type and -1 index can't be added to the ban list, it's inconsequential to check for non-existent (cookable) plant/drink/seed items here - if not type_list[[p.material_defs.type_basic_mat, p.material_defs.idx_basic_mat]] - type_list[[p.material_defs.type_basic_mat, p.material_defs.idx_basic_mat]] = {} - end - if not type_list[[p.material_defs.type_drink, p.material_defs.idx_drink]] - type_list[[p.material_defs.type_drink, p.material_defs.idx_drink]] = {} - end - if not type_list[[p.material_defs.type_seed, p.material_defs.idx_seed]] - type_list[[p.material_defs.type_seed, p.material_defs.idx_seed]] = {} - end - type_list[[p.material_defs.type_basic_mat, p.material_defs.idx_basic_mat]]['text'] = p.name + ' basic' - # basic materials for plants always appear to use the :PLANT item type tag - type_list[[p.material_defs.type_basic_mat, p.material_defs.idx_basic_mat]]['type'] = :PLANT - # item subtype of :PLANT types appears to always be -1, as there is no growth array entry for the :PLANT - type_list[[p.material_defs.type_basic_mat, p.material_defs.idx_basic_mat]]['subtype'] = -1 - type_list[[p.material_defs.type_drink, p.material_defs.idx_drink]]['text'] = p.name + ' drink' - # drink materials for plants always appear to use the :DRINK item type tag - type_list[[p.material_defs.type_drink, p.material_defs.idx_drink]]['type'] = :DRINK - # item subtype of :DRINK types appears to always be -1, as there is no growth array entry for the :DRINK - type_list[[p.material_defs.type_drink, p.material_defs.idx_drink]]['subtype'] = -1 - type_list[[p.material_defs.type_seed, p.material_defs.idx_seed]]['text'] = p.name + ' seed' - # seed materials for plants always appear to use the :SEEDS item type tag - type_list[[p.material_defs.type_seed, p.material_defs.idx_seed]]['type'] = :SEEDS - # item subtype of :SEEDS types appears to always be -1, as there is no growth array entry for the :SEEDS - type_list[[p.material_defs.type_seed, p.material_defs.idx_seed]]['subtype'] = -1 - p.growths.each_with_index do |g, r| - m = df.decode_mat(g).material - # Search only growths that are cookable (:EDIBLE_COOKED), and listed as :LEAF_MAT, - # as that appears to be the tag required to allow cooking as a solid/non-liquid item in the kitchen - if m.flags[:EDIBLE_COOKED] and m.flags[:LEAF_MAT] - # Sift through the materials array to find the matching entry for our growths array entry - p.material.each_with_index do |s, j| - if m.id == s.id - if not type_list[[j + DFHack::MaterialInfo::PLANT_BASE, i]] - type_list[[j + DFHack::MaterialInfo::PLANT_BASE, i]] = {} - end - type_list[[j + DFHack::MaterialInfo::PLANT_BASE, i]]['text'] = p.name + ' ' + m.id + ' growth' - # item type for plant materials listed in the growths array appear to always use the :PLANT_GROWTH item type tag - type_list[[j + DFHack::MaterialInfo::PLANT_BASE, i]]['type'] = :PLANT_GROWTH - # item subtype is equal to the array index of the cookable item in the growths table - type_list[[j + DFHack::MaterialInfo::PLANT_BASE, i]]['subtype'] = r - end - end - end - end - end - # cycle through all creatures - df.world.raws.creatures.all.each_with_index do |c, i| - c.material.each_with_index do |m, j| - if m.reaction_product and m.reaction_product.id and m.reaction_product.id.include?('CHEESE_MAT') - if not type_list[[j + DFHack::MaterialInfo::CREATURE_BASE, i]] - type_list[[j + DFHack::MaterialInfo::CREATURE_BASE, i]] = {} - end - type_list[[j + DFHack::MaterialInfo::CREATURE_BASE, i]]['text'] = c.name[0] + ' milk' - # item type for milk appears to use the :LIQUID_MISC tag - type_list[[j + DFHack::MaterialInfo::CREATURE_BASE, i]]['type'] = :LIQUID_MISC - type_list[[j + DFHack::MaterialInfo::CREATURE_BASE, i]]['subtype'] = -1 - end - if m.reaction_product and m.reaction_product.id and m.reaction_product.id.include?('SOAP_MAT') - if not type_list[[j + DFHack::MaterialInfo::CREATURE_BASE, i]] - type_list[[j + DFHack::MaterialInfo::CREATURE_BASE, i]] = {} - end - type_list[[j + DFHack::MaterialInfo::CREATURE_BASE, i]]['text'] = c.name[0] + ' tallow' - # item type for tallow appears to use the :GLOB tag - type_list[[j + DFHack::MaterialInfo::CREATURE_BASE, i]]['type'] = :GLOB - type_list[[j + DFHack::MaterialInfo::CREATURE_BASE, i]]['subtype'] = -1 - end - end - end - already_banned.each_with_index do |b, i| - # initialize our output string with the array entry position (largely stays the same for each item on successive runs, except when items are added/removed) - output = i.inspect + ': ' - # initialize our key for accessing our stored items info - key = [b[0][0], b[0][1]] - # It shouldn't be possible for there to not be a matching key entry by this point, but we'll be kinda safe here - if type_list[key] - # Add the item name to the first part of the string - output += '|' + type_list[key]['text'] + ' |type ' - if type_list[key]['type'] == b[0][2] - # item type expected vs. actual is a match, so we print that it's a match, as well as the item type - output += 'match: ' + type_list[key]['type'].inspect - else - # Aw crap. The item type we EXpected doesn't match up with the ACtual item type. - output += 'error: ex;' + type_list[key]['type'].inspect + '/ac;' + b[0][2].inspect - end - output += '|subtype ' - if type_list[key]['subtype'] == b[0][3] - # item sub type is a match, so we print that it's a match, as well as the item subtype index number (-1 means there is no subtype for this item) - output += 'match: ' + type_list[key]['subtype'].inspect - else - # Something went wrong, and the EXpected item subtype index value doesn't match the ACtual index value - output += 'error: ex;' + type_list[key]['subtype'].inspect + '/ac;' + b[0][3].inspect - end - else - # There's no entry for this item in our calculated list of cookable items. So, it's not a plant, alcohol, tallow, or milk. It's likely that it's a meat that has been banned. - output += '|"' + key.inspect + ' unknown banned material type (meat?) " ' + '|item type: "' + b[0][2].inspect + '"|item subtype: "' + b[0][3].inspect - end - puts output - end - else - puts "ban-cooking booze - bans cooking of drinks" - puts "ban-cooking honey - bans cooking of honey bee honey" - puts "ban-cooking tallow - bans cooking of tallow" - puts "ban-cooking milk - bans cooking of creature liquids that can be turned into cheese" - puts "ban-cooking oil - bans cooking of oil" - puts "ban-cooking seeds - bans cooking of plants that have farmable seeds and that can be brewed into alcohol (eating raw plants to get seeds is rather slow)" - puts "ban-cooking brew - bans cooking of all plants (fruits too) that can be brewed into alcohol" - puts "ban-cooking fruit - bans cooking of only fruits that can be brewed into alcohol" - puts "ban-cooking mill - bans cooking of plants that can be milled into powder -- should any actually exist" - puts "ban-cooking thread - bans cooking of plants that can be spun into thread -- should any actually exist" - puts "ban-cooking show - list known items that are banned in a pipe seperated format (if you ban meat(s) or fish(es) you'll get unknown listings!)" - end -end diff --git a/bodyswap.lua b/bodyswap.lua index 814656e326..e64f43f783 100644 --- a/bodyswap.lua +++ b/bodyswap.lua @@ -1,139 +1,144 @@ --- Shifts player control over to another unit in adventure mode. --- author: Atomic Chicken --- based on "assumecontrol.lua" by maxthyme, as well as the defunct advtools plugin "adv-bodyswap" --- calls "modtools/create-unit" for nemesis and histfig creation - --@ module = true local utils = require 'utils' local validArgs = utils.invert({ -'unit', -'help' + 'unit', + 'help' }) -local args = utils.processArgs({...}, validArgs) - -local usage = [====[ - -bodyswap -======== -This script allows the player to take direct control of any unit present in -adventure mode whilst giving up control of their current player character. - -To specify the target unit, simply select it in the user interface, -such as by opening the unit's status screen or viewing its description, -and enter "bodyswap" in the DFHack console. - -Alternatively, the target unit can be specified by its unit id as shown below. - -Arguments:: - - -unit id - replace "id" with the unit id of your target - example: - bodyswap -unit 42 - -]====] +local args = utils.processArgs({ ... }, validArgs) if args.help then - print(usage) - return -end - -if df.global.gamemode ~= df.game_mode.ADVENTURE then - qerror("This script can only be used in adventure mode!") + print(dfhack.script_help()) + return end function setNewAdvNemFlags(nem) - nem.flags.ACTIVE_ADVENTURER = true - nem.flags.RETIRED_ADVENTURER = false - nem.flags.ADVENTURER = true + nem.flags.ACTIVE_ADVENTURER = true + nem.flags.ADVENTURER = true end + function setOldAdvNemFlags(nem) - nem.flags.ACTIVE_ADVENTURER = false - nem.flags.RETIRED_ADVENTURER = true + nem.flags.ACTIVE_ADVENTURER = false end function clearNemesisFromLinkedSites(nem) --- this is a workaround for a bug which tends to cause duplication of the unit entry in df.global.world.units.active when the site to which a historical figure is linked is reloaded with the unit present --- appears to fix the problem without causing any noticeable issues - if not nem.figure then - return - end - for _,link in ipairs(nem.figure.site_links) do - local site = df.world_site.find(link.site) - for i = #site.unk_1.nemesis-1,0,-1 do - if site.unk_1.nemesis[i] == nem.id then - site.unk_1.nemesis:erase(i) - end + -- omitting this step results in duplication of the unit entry in df.global.world.units.active when the site to which the historical figure is linked is reloaded with said figure present as a member of the player party + -- this can be observed as part of the normal recruitment process when the player adds a site-linked historical figure to their party + if not nem.figure then + return + end + for _, link in ipairs(nem.figure.site_links) do + local site = df.world_site.find(link.site) + utils.erase_sorted(site.populace.nemesis, nem.id) end - end end function createNemesis(unit) - local nemesis = reqscript('modtools/create-unit').createNemesis(unit,unit.civ_id) - nemesis.figure.flags.never_cull = true - return nemesis + local nemesis = unit:create_nemesis(1, 1) + nemesis.figure.flags.never_cull = true + return nemesis end -function swapAdvUnit(newUnit) +function isPet(nemesis) + if nemesis.unit then + if nemesis.unit.relationship_ids.PetOwner ~= -1 then + return true + end + elseif nemesis.figure then -- in case the unit is offloaded + for _, link in ipairs(nemesis.figure.histfig_links) do + if link._type == df.histfig_hf_link_pet_ownerst then + return true + end + end + end + return false +end + +function processNemesisParty(nemesis, targetUnitID, alreadyProcessed) + -- configures the target and any leaders/companions to behave as cohesive adventure mode party members + local alreadyProcessed = alreadyProcessed or {} + alreadyProcessed[tostring(nemesis.id)] = true + + local nemUnit = nemesis.unit + if nemesis.unit_id == targetUnitID then -- the target you're bodyswapping into + df.global.adventure.interactions.party_core_members:insert('#', nemesis.figure.id) + nemUnit.relationship_ids.GroupLeader = -1 + elseif isPet(nemesis) then -- pets belonging to the target or to their companions + df.global.adventure.interactions.party_pets:insert('#', nemesis.figure.id) + else + df.global.adventure.interactions.party_core_members:insert('#', nemesis.figure.id) -- placing all non-pet companions into the core party list to enable tactical mode swapping + nemesis.flags.ADVENTURER = true + if nemUnit then -- check in case the companion is offloaded + nemUnit.relationship_ids.GroupLeader = targetUnitID + end + end + -- the hierarchy of nemesis-level leader/companion relationships appears to be left untouched when the player character is changed using the inbuilt "tactical mode" party system - if not newUnit then - qerror('Target unit not specified!') - end + clearNemesisFromLinkedSites(nemesis) - local oldNem = df.nemesis_record.find(df.global.ui_advmode.player_id) - local oldUnit = oldNem.unit - if newUnit == oldUnit then - return - end - - local activeUnits = df.global.world.units.active - local oldUnitIndex - if activeUnits[0] == oldUnit then - oldUnitIndex = 0 - else -- unlikely; this is just in case - for i,u in ipairs(activeUnits) do - if u == oldUnit then - oldUnitIndex = i - break - end + if nemesis.group_leader_id ~= -1 and not alreadyProcessed[tostring(nemesis.group_leader_id)] then + local leader = df.nemesis_record.find(nemesis.group_leader_id) + if leader then + processNemesisParty(leader, targetUnitID, alreadyProcessed) + end + end + for _, id in ipairs(nemesis.companions) do + if not alreadyProcessed[tostring(id)] then + local companion = df.nemesis_record.find(id) + if companion then + processNemesisParty(companion, targetUnitID, alreadyProcessed) + end + end + end +end + +function configureAdvParty(targetNemesis) + local party = df.global.adventure.interactions + party.party_core_members:resize(0) + party.party_pets:resize(0) + party.party_extra_members:resize(0) + processNemesisParty(targetNemesis, targetNemesis.unit_id) +end + +function swapAdvUnit(newUnit) + if not newUnit then + qerror('Target unit not specified!') end - end - local newUnitIndex - for i,u in ipairs(activeUnits) do - if u == newUnit then - newUnitIndex = i - break + + local oldNem = df.nemesis_record.find(df.global.adventure.player_id) + local oldUnit = oldNem.unit + if newUnit == oldUnit then + return end - end - - if not newUnitIndex then - qerror("Target unit index not found!") - end - - local newNem = dfhack.units.getNemesis(newUnit) or createNemesis(newUnit) - if not newNem then - qerror("Failed to obtain target nemesis!") - end - - setOldAdvNemFlags(oldNem) - setNewAdvNemFlags(newNem) - clearNemesisFromLinkedSites(newNem) - df.global.ui_advmode.player_id = newNem.id - activeUnits[newUnitIndex] = oldUnit - activeUnits[oldUnitIndex] = newUnit - oldUnit.idle_area:assign(oldUnit.pos) + + local newNem = dfhack.units.getNemesis(newUnit) or createNemesis(newUnit) + if not newNem then + qerror("Failed to obtain target nemesis!") + end + + setOldAdvNemFlags(oldNem) + setNewAdvNemFlags(newNem) + configureAdvParty(newNem) + df.global.adventure.player_id = newNem.id + df.global.world.units.adv_unit = newUnit + oldUnit.idle_area:assign(oldUnit.pos) + + dfhack.gui.revealInDwarfmodeMap(xyz2pos(dfhack.units.getPosition(newUnit)), true) end if not dfhack_flags.module then - local unit = args.unit and df.unit.find(tonumber(args.unit)) or dfhack.gui.getSelectedUnit() - if not unit then - print("Enter the following if you require assistance: bodyswap -help") - if args.unit then - qerror("Invalid unit id: "..args.unit) - else - qerror("Target unit not specified!") + if df.global.gamemode ~= df.game_mode.ADVENTURE then + qerror("This script can only be used in adventure mode!") + end + + local unit = args.unit and df.unit.find(tonumber(args.unit)) or dfhack.gui.getSelectedUnit() + if not unit then + print("Enter the following if you require assistance: help bodyswap") + if args.unit then + qerror("Invalid unit id: " .. args.unit) + else + qerror("Target unit not specified!") + end end - end - swapAdvUnit(unit) + swapAdvUnit(unit) end diff --git a/brainwash.lua b/brainwash.lua index 12776de283..1cbdc29355 100644 --- a/brainwash.lua +++ b/brainwash.lua @@ -27,7 +27,7 @@ function brainwash_unit(profile) return end - unit_name=dfhack.TranslateName(dfhack.units.getVisibleName(unit)) + unit_name = dfhack.df2console(dfhack.units.getReadableName(unit)) print("Previous personality values for "..unit_name) printall(unit.status.current_soul.personality.traits) diff --git a/break-dance.lua b/break-dance.lua index 3a992510ba..0d4de1c61c 100644 --- a/break-dance.lua +++ b/break-dance.lua @@ -9,7 +9,7 @@ can't find a partner. ]====] local unit if dfhack.world.isAdventureMode() then - unit = df.global.world.units.active[0] + unit = dfhack.world.getAdventurer() else unit = dfhack.gui.getSelectedUnit(true) or qerror('No unit selected') end diff --git a/build-now.lua b/build-now.lua new file mode 100644 index 0000000000..91e362aeac --- /dev/null +++ b/build-now.lua @@ -0,0 +1,377 @@ +-- instantly completes unsuspended building construction jobs + +local argparse = require('argparse') +local gui = require('gui') +local suspendmanager = require('plugins.suspendmanager') +local utils = require('utils') + +local ok, buildingplan = pcall(require, 'plugins.buildingplan') +if not ok then + buildingplan = nil +end + +local function min_to_max(...) + local args = {...} + table.sort(args, function(a, b) return a < b end) + return table.unpack(args) +end + +local function parse_commandline(args) + local opts = {} + local positionals = argparse.processArgsGetopt(args, { + {'h', 'help', handler=function() opts.help = true end}, + {'q', 'quiet', handler=function() opts.quiet = true end}, + {'z', 'zlevel', handler=function() opts.zlevel = true end}, + }) + + if positionals[1] == 'help' then opts.help = true end + if opts.help then return opts end + + if #positionals >= 1 then + opts.start = argparse.coords(positionals[1]) + if #positionals >= 2 then + opts['end'] = argparse.coords(positionals[2]) + opts.start.x, opts['end'].x = min_to_max(opts.start.x,opts['end'].x) + opts.start.y, opts['end'].y = min_to_max(opts.start.y,opts['end'].y) + opts.start.z, opts['end'].z = min_to_max(opts.start.z,opts['end'].z) + else + opts['end'] = opts.start + end + else + -- default to covering entire map + opts.start = xyz2pos(0, 0, 0) + local x, y, z = dfhack.maps.getTileSize() + opts['end'] = xyz2pos(x-1, y-1, z-1) + end + if opts.zlevel then + opts.start.z = df.global.window_z + opts['end'].z = df.global.window_z + end + return opts +end + +-- gets list of jobs that meet all of the following criteria: +-- is a building construction job +-- has all job_items attached +-- is not suspended +-- target building is within the processing area +local function get_jobs(opts) + local num_suspended, num_incomplete, num_clipped, jobs = 0, 0, 0, {} + for _,job in utils.listpairs(df.global.world.jobs.list) do + if job.job_type ~= df.job_type.ConstructBuilding then goto continue end + if job.flags.suspend then + num_suspended = num_suspended + 1 + goto continue + end + + -- job_items are not items, they're filters that describe the kinds of + -- items that need to be attached. + for _,job_item in ipairs(job.job_items.elements) do + -- we have to check for quantity != 0 instead of just the existence + -- of the job_item since buildingplan leaves 0-quantity job_items in + -- place to protect against persistence errors. + if job_item.quantity > 0 then + num_incomplete = num_incomplete + 1 + goto continue + end + end + + local bld = dfhack.job.getHolder(job) + if not bld then + dfhack.printerr( + 'skipping construction job without attached building') + goto continue + end + + -- accept building if any part is within the processing area + if bld.z < opts.start.z or bld.z > opts['end'].z + or bld.x2 < opts.start.x or bld.x1 > opts['end'].x + or bld.y2 < opts.start.y or bld.y1 > opts['end'].y then + num_clipped = num_clipped + 1 + goto continue + end + + table.insert(jobs, job) + ::continue:: + end + if not opts.quiet then + if num_suspended > 0 then + print(('Skipped %d suspended building%s') + :format(num_suspended, num_suspended ~= 1 and 's' or '')) + end + if num_incomplete > 0 then + print(('Skipped %d building%s with pending items') + :format(num_incomplete, num_incomplete ~= 1 and 's' or '')) + end + if num_clipped > 0 then + print(('Skipped %d building%s out of processing range') + :format(num_clipped, num_clipped ~= 1 and 's' or '')) + end + end + return jobs +end + +-- returns a list of map blocks that contain items that are in the footprint +local function get_map_blocks_with_items_in_footprint(bld) + local blockset = {} + for x = bld.x1,bld.x2 do + for y = bld.y1,bld.y2 do + local block = dfhack.maps.ensureTileBlock(x, y, bld.z) + if block.occupancy[x%16][y%16].item ~= 0 then + blockset[block] = true + end + end + end + local blocks = {} + for block in pairs(blockset) do table.insert(blocks, block) end + return blocks +end + +local function transform(tab, transform_fn) + local ret = {} + for k,v in pairs(tab) do + ret[k] = transform_fn(v) + end + return ret +end + +local function get_item_id(item) + return item.id +end + +local function get_items_within_footprint(blocks, bld, ignore_items) + -- can't compare userdata items directly, so we'll compare ids + local ignore_set = utils.invert(transform(ignore_items, get_item_id)) + local items = {} + for _,block in ipairs(blocks) do + for _,itemid in ipairs(block.items) do + local item = df.item.find(itemid) + local pos = item.pos + if item.flags.on_ground and gui.is_in_rect(bld, pos.x, pos.y) then + if item.flags.in_job then + -- if the job that the item is associated with is the one + -- for building this building, then that's ok. it will be + -- moved directly into the building later. + if not ignore_set[item.id] then + return false + end + else + table.insert(items, item) + end + end + end + end + return true, items +end + +-- returns whether this is a match and whether we should continue searching +-- beyond this tile +local function is_good_dump_pos(pos) + local tt = dfhack.maps.getTileType(pos) + -- reject bad coordinates (or map blocks that haven't been loaded) + if not tt then return false, false end + local flags, occupancy = dfhack.maps.getTileFlags(pos) + local attrs = df.tiletype.attrs[tt] + local shape_attrs = df.tiletype_shape.attrs[attrs.shape] + -- reject hidden tiles + if flags.hidden then return false, false end + -- reject unwalkable tiles + if not shape_attrs.walkable then return false, false end + -- reject footprints within other buildings. this could potentially be + -- relaxed a bit since we can technically dump items on passable tiles + -- within other buildings, but that would look messy. + if occupancy.building ~= df.tile_building_occ.None then + return false, true + end + -- success! + return true +end + +-- noop if pos is in the seen map. otherwise marks pos in the seen map and +-- enqueues pos in queue +local function enqueue_if_unseen(seen, queue, pos) + if seen[pos.x] and seen[pos.x][pos.y] then return end + seen[pos.x] = seen[pos.x] or {} + seen[pos.x][pos.y] = true + table.insert(queue, pos) +end + +local function check_and_flood(seen, queue, pos) + local is_match, should_flood = is_good_dump_pos(pos) + if is_match then return pos end + if not should_flood then return end + local x, y, z = pos.x, pos.y, pos.z + enqueue_if_unseen(seen, queue, xyz2pos(x-1, y-1, z)) + enqueue_if_unseen(seen, queue, xyz2pos(x, y-1, z)) + enqueue_if_unseen(seen, queue, xyz2pos(x+1, y-1, z)) + enqueue_if_unseen(seen, queue, xyz2pos(x-1, y, z)) + enqueue_if_unseen(seen, queue, xyz2pos(x+1, y, z)) + enqueue_if_unseen(seen, queue, xyz2pos(x-1, y+1, z)) + enqueue_if_unseen(seen, queue, xyz2pos(x, y+1, z)) + enqueue_if_unseen(seen, queue, xyz2pos(x+1, y+1, z)) +end + +-- does a flood search to find the nearest tile where we can freely dump items +local function search_dump_pos(bld) + local seen = {[bld.centerx]={[bld.centery]=true}} + local queue, i = {xyz2pos(bld.centerx, bld.centery, bld.z)}, 1 + while queue[i] do + local good_pos = check_and_flood(seen, queue, queue[i]) + if good_pos then return good_pos end + queue[i] = nil + i = i + 1 + end +end + +-- uses the flood search algorithm to find a free tile. if that fails, returns +-- the position of the first fort citizen. if that fails, returns the position +-- of the first active unit. +local function get_dump_pos(bld) + local dump_pos = search_dump_pos(bld) + if dump_pos then + return dump_pos + end + for _,unit in ipairs(dfhack.units.getCitizens(true)) do + return unit.pos + end + -- fall back to position of first active unit + return df.global.world.units.active[0].pos +end + +-- move items away from the construction site +local function clear_footprint(bld, ignore_items) + -- check building tiles for items. if none exist, exit early with success + local blocks = get_map_blocks_with_items_in_footprint(bld) + if #blocks == 0 then return true end + local ok, items = get_items_within_footprint(blocks, bld, ignore_items) + if not ok then return false end + local dump_pos = get_dump_pos(bld) + for _,item in ipairs(items) do + if not dfhack.items.moveToGround(item, dump_pos) then return false end + end + return true +end + +local function get_items(job) + local items = {} + for _,item_ref in ipairs(job.items) do + table.insert(items, item_ref.item) + end + return items +end + +-- teleport any items that are not already part of the building to the building +-- center and mark them as part of the building. this handles both partially- +-- built buildings and items that are being carried to the building correctly. +local function attach_items(bld, items) + for _,item in ipairs(items) do + -- skip items that have already been brought to the building + if item.flags.in_building then goto continue end + -- 2 means "make part of bld" (which causes constructions to crash on + -- deconstruct) + local use = bld:getType() == df.building_type.Construction and 0 or 2 + if not dfhack.items.moveToBuilding(item, bld, use) then return false end + ::continue:: + end + return true +end + +-- complete architecture, if required, and perform the adjustments the game +-- normally does when a building is built. this logic is reverse engineered from +-- observing game behavior and may be incomplete. +local function build_building(bld) + if bld:needsDesign() then + -- unlike "natural" builds, we don't set the architect or builder unit + -- id. however, this doesn't seem to have any in-game effect. + local design = bld.design + design.flags.built = true + design.hitpoints = 80640 + design.max_hitpoints = 80640 + end + bld:setBuildStage(bld:getMaxBuildStage()) + dfhack.buildings.completeBuild(bld) +end + +local function throw(bld, msg) + msg = msg .. ('; please remove and recreate the %s at (%d, %d, %d)') + :format(df.building_type[bld:getType()], + bld.centerx, bld.centery, bld.z) + qerror(msg) +end + +-- main script +local opts = parse_commandline({...}) +if opts.help then print(dfhack.script_help()) return end + +-- ensure buildingplan is up to date so we don't skip buildings just because +-- buildingplan hasn't scanned them yet +if buildingplan then + buildingplan.doCycle() +end + +if suspendmanager.isEnabled() then + dfhack.run_command('unsuspend') +end + +local num_jobs = 0 +for _,job in ipairs(get_jobs(opts)) do + local bld = dfhack.job.getHolder(job) + + -- retrieve the items attached to the job before we destroy the references + local items = get_items(job) + + local bld_type = bld:getType() + if #items == 0 and bld_type ~= df.building_type.RoadDirt + and bld_type ~= df.building_type.FarmPlot then + print(('skipping building with no items attached at'.. + ' (%d, %d, %d)'):format(bld.centerx, bld.centery, bld.z)) + goto continue + end + + -- skip jobs whose attached items are already owned by the target building + -- but are not already part of the building. They are actively being used to + -- construct the building and we can't safely change the building's state. + for _,item in ipairs(items) do + if not item.flags.in_building and + bld == dfhack.items.getHolderBuilding(item) then + if not opts.quiet then + print( + ('skipping building that is actively being constructed at'.. + ' (%d, %d, %d)'):format(bld.centerx, bld.centery, bld.z)) + end + goto continue + end + end + + -- clear non-job items from the planned building footprint + if not clear_footprint(bld, items) then + dfhack.printerr( + ('cannot move items blocking building site at (%d, %d, %d)') + :format(bld.centerx, bld.centery, bld.z)) + goto continue + end + + -- remove job data and attach items to building. + if not dfhack.job.removeJob(job) then + throw(bld, 'failed to remove job; job state may be inconsistent') + end + + if not attach_items(bld, items) then + throw(bld, + 'failed to attach items to building; state may be inconsistent') + end + + build_building(bld) + + num_jobs = num_jobs + 1 + ::continue:: +end + +if num_jobs > 0 then + df.global.world.reindex_pathfinding = true +end + +if not opts.quiet then + print(('Completed %d construction job%s') + :format(num_jobs, num_jobs ~= 1 and 's' or '')) +end diff --git a/burial.lua b/burial.lua index 3f75c50b46..d6ce884014 100644 --- a/burial.lua +++ b/burial.lua @@ -1,27 +1,31 @@ --- allows burial in unowned coffins --- by Putnam https://gist.github.com/Putnam3145/e7031588f4d9b24b9dda ---[====[ +-- Allows burial in unowned coffins. +-- Based on Putnam's work (https://gist.github.com/Putnam3145/e7031588f4d9b24b9dda) -burial -====== -Sets all unowned coffins to allow burial. ``burial -pets`` also allows burial -of pets. +local argparse = require('argparse') +local quickfort = reqscript('quickfort') -]====] - -local utils=require('utils') - -local validArgs = utils.invert({ - 'pets' +local cur_zlevel, citizens, pets = false, true, true +argparse.processArgsGetopt({...}, { + {'z', 'cur-zlevel', handler=function() cur_zlevel = true end}, + {'c', 'citizens-only', handler=function() pets = false end}, + {'p', 'pets-only', handler=function() citizens = false end}, }) +local tomb_blueprint = { + mode = 'zone', + pos = nil, + -- Don't pass properties with default values to avoid 'unhandled property' warning + data = ('T{%s %s}'):format(citizens and '' or 'citizens=false', pets and 'pets=true' or ''), +} -local args = utils.processArgs({...}, validArgs) - -for k,v in ipairs(df.global.world.buildings.other.COFFIN) do --as:df.building_coffinst - if v.owner_id==-1 then - v.burial_mode.allow_burial=true - if not args.pets then - v.burial_mode.no_pets=true - end +local tomb_count = 0 +for _, coffin in pairs(df.global.world.buildings.other.COFFIN) do + if #coffin.relations > 0 or cur_zlevel and coffin.z ~= df.global.window_z then + goto skip end + tomb_blueprint.pos = xyz2pos(coffin.x1, coffin.y1, coffin.z) + quickfort.apply_blueprint(tomb_blueprint) + tomb_count = tomb_count + 1 + ::skip:: end + +print(('Created %s tomb(s).'):format(tomb_count)) diff --git a/cannibalism.lua b/cannibalism.lua index bf5fd407d0..00ca47b1f3 100644 --- a/cannibalism.lua +++ b/cannibalism.lua @@ -1,13 +1,3 @@ ---Allows consumption of sapient corpses. ---[====[ - -cannibalism -=========== -Allows consumption of sapient corpses. Use from an adventurer's inventory screen -or an individual item's detail screen. - -]====] - function unmark_inventory(inventory) for _, entry in ipairs(inventory) do entry.item.flags.dead_dwarf = false @@ -19,8 +9,8 @@ if df.viewscreen_itemst:is_instance(scrn) then scrn.item.flags.dead_dwarf = false --hint:df.viewscreen_itemst elseif df.viewscreen_dungeon_monsterstatusst:is_instance(scrn) then unmark_inventory(scrn.inventory) --hint:df.viewscreen_dungeon_monsterstatusst -elseif df.global.ui_advmode.menu == df.ui_advmode_menu.Inventory then - unmark_inventory(df.global.world.units.active[0].inventory) +elseif df.global.adventure.menu == df.ui_advmode_menu.Inventory then + unmark_inventory(dfhack.world.getAdventurer().inventory) else qerror('Unsupported context') end diff --git a/caravan.lua b/caravan.lua index 721c6be812..0dd2cf6d89 100644 --- a/caravan.lua +++ b/caravan.lua @@ -1,29 +1,30 @@ --- Adjusts properties of caravans ---[====[ - -caravan -======= - -Adjusts properties of caravans on the map. See also `force` to create caravans. - -This script has multiple subcommands. Commands listed with the argument -``[IDS]`` can take multiple caravan IDs (see ``caravan list``). If no IDs are -specified, then the commands apply to all caravans on the map. - -**Subcommands:** - -- ``list``: lists IDs and information about all caravans on the map. -- ``extend [DAYS] [IDS]``: extends the time that caravans stay at the depot by - the specified number of days (defaults to 7 if not specified). Also causes - caravans to return to the depot if applicable. -- ``happy [IDS]``: makes caravans willing to trade again (after seizing goods, - annoying merchants, etc.). Also causes caravans to return to the depot if - applicable. -- ``leave [IDS]``: makes caravans pack up and leave immediately. +-- Adjusts properties of caravans and provides overlays for enhanced trading +--@ module = true -]====] +local movegoods = reqscript('internal/caravan/movegoods') +local pedestal = reqscript('internal/caravan/pedestal') +local trade = reqscript('internal/caravan/trade') +local tradeagreement = reqscript('internal/caravan/tradeagreement') + +dfhack.onStateChange.caravanTradeOverlay = function(code) + if code == SC_WORLD_UNLOADED then + trade.trader_selected_state = {} + trade.broker_selected_state = {} + trade.handle_ctrl_click_on_render = false + trade.handle_shift_click_on_render = false + end +end ---@ module = true +OVERLAY_WIDGETS = { + trade=trade.TradeOverlay, + tradebanner=trade.TradeBannerOverlay, + tradeethics=trade.TradeEthicsWarningOverlay, + tradeagreement=tradeagreement.TradeAgreementOverlay, + movegoods=movegoods.MoveGoodsOverlay, + movegoods_hider=movegoods.MoveGoodsHiderOverlay, + assigntrade=movegoods.AssignTradeOverlay, + displayitemselector=pedestal.PedestalOverlay, +} INTERESTING_FLAGS = { casualty = 'Casualty', @@ -31,7 +32,7 @@ INTERESTING_FLAGS = { seized = 'Goods seized', offended = 'Offended' } -local caravans = df.global.ui.caravans +local caravans = df.global.plotinfo.caravans local function caravans_from_ids(ids) if not ids or #ids == 0 then @@ -40,7 +41,7 @@ local function caravans_from_ids(ids) local c = {} --as:df.caravan_state[] for _,id in ipairs(ids) do - local id = tonumber(id) + id = tonumber(id) if id then c[id] = caravans[id] end @@ -54,14 +55,16 @@ function bring_back(car) end end -local function list() +local commands = {} + +function commands.list() for id, car in pairs(caravans) do print(dfhack.df2console(('%d: %s caravan from %s'):format( id, df.creature_raw.find(df.historical_entity.find(car.entity).race).name[2], -- adjective - dfhack.TranslateName(df.historical_entity.find(car.entity).name) + dfhack.translation.translateName(df.historical_entity.find(car.entity).name) ))) - print(' ' .. (df.caravan_state.T_trade_state[car.trade_state] or 'Unknown state: ' .. car.trade_state)) + print(' ' .. (df.caravan_state.T_trade_state[car.trade_state] or ('Unknown state: ' .. car.trade_state))) print((' %d day(s) remaining'):format(math.floor(car.time_remaining / 120))) for flag, msg in pairs(INTERESTING_FLAGS) do if car.flags[flag] then @@ -71,7 +74,7 @@ local function list() end end -local function extend(days, ...) +function commands.extend(days, ...) days = tonumber(days or 7) or qerror('invalid number of days: ' .. days) --luacheck: retype for id, car in pairs(caravans_from_ids{...}) do car.time_remaining = car.time_remaining + (days * 120) @@ -79,7 +82,7 @@ local function extend(days, ...) end end -local function happy(...) +function commands.happy(...) for id, car in pairs(caravans_from_ids{...}) do -- all flags default to false car.flags.whole = 0 @@ -87,28 +90,83 @@ local function happy(...) end end -local function leave(...) +function commands.leave(...) for id, car in pairs(caravans_from_ids{...}) do car.trade_state = df.caravan_state.T_trade_state.Leaving end + local still_needs_broker = false + for _,car in ipairs(caravans) do + if car.trade_state == df.caravan_state.T_trade_state.Approaching or + car.trade_state == df.caravan_state.T_trade_state.AtDepot + then + still_needs_broker = true + break + end + end + if not still_needs_broker then + for _,depot in ipairs(df.global.world.buildings.other.TRADE_DEPOT) do + depot.trade_flags.trader_requested = false + for _, job in ipairs(depot.jobs) do + if job.job_type == df.job_type.TradeAtDepot then + dfhack.job.removeJob(job) + break + end + end + end + end +end + +local function isDisconnectedPackAnimal(unit) + if unit.following then + local dragger = unit.following + return ( + unit.relationship_ids[ df.unit_relationship_type.Dragger ] == -1 and + dragger.relationship_ids[ df.unit_relationship_type.Draggee ] == -1 + ) + end +end + +local function rejoin_pack_animals() + print('Reconnecting disconnected pack animals...') + local found = false + for _, unit in ipairs(df.global.world.units.active) do + if unit.flags1.merchant and isDisconnectedPackAnimal(unit) then + local dragger = unit.following + print((' %s <-> %s'):format( + dfhack.df2console(dfhack.units.getReadableName(unit)), + dfhack.df2console(dfhack.units.getReadableName(dragger)) + )) + unit.relationship_ids[ df.unit_relationship_type.Dragger ] = dragger.id + dragger.relationship_ids[ df.unit_relationship_type.Draggee ] = unit.id + found = true + end + end + if (found) then + print('All pack animals reconnected.') + else + print('No disconnected pack animals found.') + end +end + +function commands.unload() + rejoin_pack_animals() +end + +function commands.help() + print(dfhack.script_help()) end -function main(...) - local args = {...} - local command = table.remove(args, 1) - if command == "list" then - list(table.unpack(args)) - elseif command == "extend" then - extend(table.unpack(args)) - elseif command == "happy" then - happy(table.unpack(args)) - elseif command == "leave" then - leave(table.unpack(args)) +function main(args) + local command = table.remove(args, 1) or 'list' + if commands[command] then + commands[command](table.unpack(args)) else - qerror("No such command: "..command) + commands.help() + print() + qerror("No such command: " .. command) end end if not dfhack_flags.module then - main(...) + main{...} end diff --git a/catsplosion.lua b/catsplosion.lua index cf6a4bd115..367798fd94 100644 --- a/catsplosion.lua +++ b/catsplosion.lua @@ -1,21 +1,3 @@ --- Make cats just /multiply/. ---[====[ - -catsplosion -=========== -Makes cats (and other animals) just *multiply*. It is not a good idea to run this -more than once or twice. - -Usage: - -:catsplosion: Make all cats pregnant -:catsplosion list: List IDs of all animals on the map -:catsplosion ID ...: Make animals with given ID(s) pregnant - -Animals will give birth within two in-game hours (100 ticks or fewer). - -]====] - local world = df.global.world if not dfhack.isWorldLoaded() then @@ -45,11 +27,19 @@ local total_created = 0 local males = {} --as:df.unit[][] local females = {} --as:df.unit[][] -for _, unit in pairs(world.units.all) do +for _, unit in pairs(world.units.active) do + if not dfhack.units.isActive(unit) or + dfhack.units.isDead(unit) or + dfhack.units.isBaby(unit) or + dfhack.units.isChild(unit) + then + goto continue + end local id = world.raws.creatures.all[unit.race].creature_id males[id] = males[id] or {} females[id] = females[id] or {} table.insert((dfhack.units.isFemale(unit) and females or males)[id], unit) + ::continue:: end if list_only then @@ -67,8 +57,9 @@ if list_only then end for id in pairs(creatures) do - total = total + #(females[id] or {}) - for _, female in pairs(females[id]) do + local female_list = females[id] or {} + total = total + #female_list + for _, female in pairs(female_list) do if female.pregnancy_timer ~= 0 then female.pregnancy_timer = math.random(1, 100) total_changed = total_changed + 1 @@ -78,7 +69,7 @@ for id in pairs(creatures) do preg.colors:assign(female.appearance.genes.colors) female.pregnancy_genes = preg female.pregnancy_timer = math.random(1, 100) - female.pregnancy_caste = 1 + female.pregnancy_caste = female.caste -- To handle female only species total_created = total_created + 1 end end diff --git a/changelog.txt b/changelog.txt new file mode 100644 index 0000000000..5b464cafee --- /dev/null +++ b/changelog.txt @@ -0,0 +1,1312 @@ +===[[[ +This file contains changes specific to the scripts repo. See docs/changelog.txt +in the dfhack repo for a full description, or +https://docs.dfhack.org/en/latest/docs/dev/Documentation.html#building-the-changelogs + +NOTE: currently, gen_changelog.py expects a "Future" section to exist at the +top of this file (even if no changes are listed under it), or you will get a +"Entry without section" error. Also, to maintain proper sorting in the generated +changelogs when making a new release, docs/changelog.txt in the dfhack repo must +have the new release listed in the right place, even if no changes were made in +that repo. + +Template for new versions: + +## New Tools + +## New Features + +## Fixes + +## Misc Improvements + +## Removed + +]]] + +# Future + +## New Tools + +## New Features + +## Fixes + +- `fix/loyaltycascade`: guard against citizens that are not historical figures and emit a warning. + +## Misc Improvements +- `caravan`: the ``Bring goods to depot``, ``Trade``, and ``Assign items for display`` overlays now allow searching for items with non-ASCII characters in their description +- `caravan`: the ``Trade`` overlay will use the trader's appraisal skill instead of the broker's to round/obfuscate the value of items + +## Removed + +# 53.15-r2 + +## New Tools + +## New Features + +## Fixes +- `combine`: stopgap fix for incorrectly combining dyes, no longer affects dyes +- `gui/rename`: fix script error that sometimes caused the script to malfunction when started from the Launcher. + +## Misc Improvements + +## Removed + +# 53.15-r1 + +## New Tools +- `machine-toggle`: interface for toggling gear assemblies, as well as pressure plates (previously in `trackstop`) (available only if ``armok`` tools are shown) + +## New Features + +## Fixes +- `add-recipe`: fix to include incorrectly excluded recipes +- `gui/control-panel`: fixed incorrect description of deteriorate commands +- `gui/petitions`: fix deity display + +## Misc Improvements +- `trackstop`: ``pressureplate`` overlay moved to `machine-toggle` as an ``armok`` tool + +## Removed + +# 53.14-r2 + +## New Tools + +## New Features + +## Fixes + +## Misc Improvements + +## Removed +- ``gui/logcleaner``: Removed + +# 53.14-r1 + +## New Tools + +## New Features + +## Fixes +- `caravan`: fix ethics warning for wooden weapons +- `gui/control-panel`: fixed inconsistent space in command help ("tweak" -> "tweak ", like with other commands) +- `gui/siegemanager`: consistently set the initial filter to "All" +- `immortal-cravings`: also take care of immortal units with alcohol dependence + +## Misc Improvements + +## Removed + +# 53.11-r2 + +## New Tools + +## New Features + +## Fixes +- `gui/rename`: skip ``NONE`` when iterating through language name options +- `quickfort`: work orders will no longer be created with a repetition frequency of ``NONE`` + +## Misc Improvements + +## Removed + +# 53.10-r2 + +## New Tools +- `gui/logcleaner`: graphical overlay for configuring the logcleaner plugin with enable and filter toggles. + +## New Features +- `trackstop`: can now modify pressure plates; permits minecart and creature triggers to be set beyond normal sensitivity + +## Fixes +- `gui/rename`: added check for entity_id input in get_target function +- `prioritize`: Fix the overlay appearing where it should not when following a unit + +## Misc Improvements +- `gui/notify`: reduced severity of the missing nemesis records warning if no units on the map are affected. clarified wording. + +## Removed + +# 53.10-r1 + +## New Tools + +## New Features +- `gui/quickcmd`: added custom command names and option to display command output +- `gui/notify`: new notification type: missing nemesis records; displays a warning message about game corruption. + +## Fixes + +## Misc Improvements + +## Removed + +# 53.07-r1 + +## New Tools +- `fix/codex-pages`: add pages to written content that have unspecified page counts. +- `gui/keybinds`: gui for managing and saving custom keybindings + +## New Features + +## Fixes +- `empty-bin`: renamed ``--liquids`` parameter to ``--force`` and made emptying of containers (bags) with powders contingent on that parameter. Previously powders would just always get disposed. + +## Misc Improvements +- `combine`: try harder to find the currently-selected stockpile + +## Removed + +# 53.06-r1 + +## New Tools + +## New Features + +## Fixes +- `gui/design`: designating a single-level stair construction now properly follows the selected stair type. +- `gui/design`: adjusted conflicting keybinds, diagonal line reverse becoming ``R`` and bottom stair type becoming ``g``. +- `modtools/set-personality`: use correct caste trait ranges; fixes `gui/gm-unit` being unable to correctly randomize traits or set traits to caste average + +## Misc Improvements + +## Removed + +# 53.04-r1 + +## New Tools +- `gui/siegemanager`: manage your siege engines at a glance. + +## New Features +- `item`: new ``--total-quality`` option for use in conjunction with ``--min-quality`` or ``--max-quality`` to filter items according to their total quality + +## Fixes + +## Misc Improvements +- `gui/design`: can now construct reinforced walls +- `quickfort`: support for reinforced walls and bolt throwers + +## Removed + +# 53.01-r1 + +## New Tools +- `fix/symbol-unstick`: unstick noble symbols that cannot be re-designated. +- `resize-armor`: resize armor or clothing item to any creature size. + +## New Features + +## Fixes +- `autotraining`: squads once used for training then disabled now properly are treated as disabled. + +## Misc Improvements + +## Removed +- `fix/archery-practice`: removed from the control panel's bug fixes tab. + +# 52.05-r2 + +## New Tools + +## New Features + +## Fixes +- `fix/archery-practice`: now splits instead of combining ammo items in quivers, and moves quivers to end of unit's inventory list + +## Misc Improvements + +## Removed + +# 52.05-r1 + +## New Tools +- `fix/archery-practice`: combine ammo items in units' quivers to fix 'Soldier (no item)' issue +- `gui/adv-finder`: UI for tracking historical figures and artifacts in adventure mode +- `store-owned`: task owned items to be stored in the owner's room furniture + +## New Features + +## Fixes +- `ban-cooking`: bans honey added by creatures other than vanilla honey bee +- `uniform-unstick`: added quivers, backpacks, and flasks/waterskins to uniform analysis +- `uniform-unstick`: the ``--drop`` option now only evaluates clothing as possible items to drop +- `uniform-unstick`: the ``--free`` option no longer redundantly reports an improperly assigned item when that item is removed from a uniform +- `uniform-unstick`: the ``--drop`` and ``--free`` options now only drop items which are actually in a unit's inventory +- `uniform-unstick`: the ``--all`` and ``--drop`` options, when used together, now print the separator line between each unit's report in the proper place + +## Misc Improvements +- adapt Lua tools to use new API functionality for creating and assigning jobs +- `idle-crafting`: properly interrupt interruptible (i.e. "green") social activities + +## Removed + +# 52.03-r2 + +## New Tools +- `autotraining`: new tool to assign citizens to a military squad when they need Martial Training +- `gui/autotraining`: configuration tool for autotraining +- `entomb`: allow any unit that has a corpse or body parts to be assigned a tomb zone +- `husbandry`: Automatically milk and shear animals at nearby farmer's workshops + +## New Features +- `deathcause`: added functionality to this script to fetch cause of death programatically + +## Fixes +- `ban-cooking`: will not fail trying to ban honey if the world has no honey +- `confirm`: only show pause option for pausable confirmations +- `confirm`: when editing a uniform, confirm discard of changes when exiting with Escape +- `confirm`: when removing a manager order, show correct order description when using non-100% interface setting +- `confirm`: when removing a manager order, show correct order description after prior order removal or window resize (when scrolled to bottom of order list) +- `confirm`: when removing a manager order, show specific item/job type for ammo, shield, helm, gloves, shoes, trap component, and meal orders +- `confirm`: the pause option now pauses individual confirmation types, allowing multiple different confirmations to be paused independently +- `immortal-cravings`: prioritize high-value meals, properly split of portions, and don't go eating or drinking on a full stomach +- `uniform-unstick`: no longer causes units to equip multiples of assigned items +- `caravan`: in the pedestal item assignment dialog, add new items at the end of the list of displayed items instead of at a random position +- `caravan`: in the pedestal item assignment dialog, consistently remove items from the list of displayed items + +## Misc Improvements +- `devel/hello-world`: updated to show off the new Slider widget + +## Removed + +# 52.03-r1 + +## New Tools + +## New Features + +## Fixes +- `make-legendary`: ``make-legendary all`` will no longer corrupt souls + +## Misc Improvements + +## Removed + +# 52.02-r2 + +## New Tools + +## New Features +- `gui/mod-manager`: now supports arena mode + +## Fixes +- `gui/mod-manager`: gracefully handle vanilla mods with different versions from the user's preset +- `gui/mod-manager`: hide other versions of loaded mods and unhides them when unloaded + +## Misc Improvements + +## Removed + +# 52.02-r1 + +## New Tools + +## New Features + +## Fixes +- ``embark-anyone``: validate viewscreen before using, avoids a crash + +## Misc Improvements + +## Removed + +# 52.01-r1 + +## New Tools + +## New Features + +## Fixes +- fixed references to removed ``unit.curse`` compound +- `gui/gm-unit`: remove reference to ``think_counter``, removed in v51.12 +- `gui/journal`: fix typo which caused the table of contents to always be regenerated even when not needed +- `gui/mod-manager`: gracefully handle mods with missing or broken ``info.txt`` files +- `uniform-unstick`: resolve overlap with new buttons in 51.13 + +## Misc Improvements + +## Removed + +# 51.12-r1 + +## New Tools +- `deteriorate`: (reinstated) allow corpses, body parts, food, and/or damaged clothes to rot away +- `modtools/moddable-gods`: (reinstated) create new deities from scratch + +## New Features +- `gui/spectate`: added "Prefer nicknamed" to the list of options +- `gui/mod-manager`: when run in a loaded world, shows a list of active mods -- click to export the list to the clipboard for easy sharing or posting +- `gui/blueprint`: now records zone designations +- `gui/design`: add option to draw N-point stars, hollow or filled or inverted, and change the main axis to orient in any direction + +## Fixes +- `starvingdead`: properly restore to correct enabled state when loading a new game that is different from the first game loaded in this session +- `starvingdead`: ensure undead decay does not happen faster than the declared decay rate when saving and loading the game +- `gui/design`: prevent line thickness from extending outside the map boundary + +## Misc Improvements +- `remove-stress`: also applied to long-term stress, immediately removing stressed and haggard statuses + +# 51.11-r1 + +## Fixes +- `list-agreements`: fix date math when determining petition age +- `gui/petitions`: fix date math when determining petition age +- `gui/rename`: fix commandline processing when manually specifying target ids +- `gui/sandbox`: restore metal equipment options when spawning units + +## Misc Improvements +- `fix/loyaltycascade`: now also breaks up brawls and other intra-fort conflicts that *look* like loyalty cascades +- `makeown`: remove selected unit from any current conflicts so they don't just start attacking other citizens when you make them a citizen of your fort + +# 51.09-r1 + +## New Features +- `gui/mass-remove`: add a button to the bottom toolbar when eraser mode is active for launching `gui/mass-remove` +- `idle-crafting`: default to only considering happy and ecstatic units for the highest need threshold +- `gui/sitemap`: add a button to the toolbar at the bottom left corner of the screen for launching `gui/sitemap` + +## Fixes +- `idle-crafting`: check that units still have crafting needs before creating a job for them +- `gui/journal`: prevent pause/unpause events from leaking through the UI when keys are mashed + +# 51.07-r1 + +## New Tools +- `devel/export-map`: export map tile data to a JSON file +- `autocheese`: automatically make cheese using barrels that have accumulated sufficient milk +- `gui/spectate`: interactive UI for configuring `spectate` +- `gui/notes`: UI for adding and managing notes attached to tiles on the map +- `launch`: (reinstated) new adventurer fighting move: thrash your enemies with a flying suplex +- `putontable`: (reinstated) make an item appear on a table + +## New Features +- `advtools`: ``advtools.fastcombat`` overlay (enabled by default) allows you to skip combat animations and the announcement "More" button by mashing the movement keys +- `gui/journal`: now working in adventure mode -- journal is per-adventurer, so if you unretire an adventurer, you get the same journal +- `emigration`: ``nobles`` command for sending freeloader barons back to the sites that they rule over +- `toggle-kbd-cursor`: support adventure mode (Alt-k keybinding now toggles Look mode) + +## Fixes +- `hfs-pit`: use correct wall types when making pits with walls +- `gui/liquids`: don't add liquids to wall tiles +- `gui/liquids`: using the remove tool with magma selected will no longer create unexpected unpathable tiles +- `idle-crafting`: do not assign crafting jobs to nobles holding meetings (avoids dangling jobs) +- `rejuvenate`: update unit portrait and sprite when aging up babies and children +- `rejuvenate`: recalculate labor assignments for unit when aging up babies and children (so they can start accepting jobs) + +## Misc Improvements +- `hide-tutorials`: handle tutorial popups for adventure mode +- `hide-tutorials`: new ``reset`` command that will re-enable popups in the current game (in case you hid them all and now want them back) +- `gui/notify`: moody dwarf notification turns red when they can't reach workshop or items +- `gui/notify`: save reminder now appears in adventure mode +- `gui/notify`: save reminder changes color to yellow at 30 minutes and to orange at 60 minutes +- `gui/confirm`: in the delete manager order confirmation dialog, show a description of which order you have selected to delete +- `gui/create-item`: now accepts a ``pos`` argument of where to spawn items +- `modtools/create-item`: exported ``hackWish`` function now supports ``opts.pos`` for determining spawn location +- `hfs-pit`: improve placement of stairs w/r/t eerie pits and ramp tops +- `position`: add adventurer tile position +- `position`: add global site position +- `position`: when a tile is selected, display relevant map block and intra-block offset +- `gui/sitemap`: shift click to start following the selected unit or artifact +- `prioritize`: when prioritizing jobs of a specified type, also output how many of those jobs were already prioritized before you ran the command +- `prioritize`: don't include already-prioritized jobs in the output of ``prioritize -j`` +- `gui/design`: only display vanilla dimensions tooltip if the DFHack dimensions tooltip is disabled +- `devel/query`: support adventure mode +- `devel/tree-info`: support adventure mode +- `hfs-pit`: support adventure mode +- `colonies`: support adventure mode +- `position`: report position of the adventure mode look cursor, if active + +# 51.04-r1.1 + +## Fixes +- `advtools`: fix dfhack-added conversation options not appearing in the ask whereabouts conversation tree +- `gui/rename`: fix error when changing the language of a unit's name + +## Misc Improvements +- `assign-preferences`: new ``--show`` option to display the preferences of the selected unit +- `pref-adjust`: new ``show`` command to display the preferences of the selected unit + +## Removed +- `gui/control-panel`: removed ``craft-age-wear`` tweak for Windows users; the tweak doesn't currently load on Windows + +# 51.02-r1 + +## Fixes +- `deathcause`: fix error when retrieving the name of a historical figure + +# 50.15-r2 + +## New Tools +- `fix/stuck-squad`: allow squads and messengers returning from missions to rescue squads that have gotten stuck on the world map +- `gui/rename`: (reinstated) give new in-game language-based names to anything that can be named (units, governments, fortresses, the world, etc.) + +## New Features +- `gui/settings-manager`: new overlay on the Labor -> Standing Orders tab for configuring the number of barrels to reserve for job use (so you can brew alcohol and not have all your barrels claimed by stockpiles for container storage) +- `gui/settings-manager`: standing orders save/load now includes the reserved barrels setting +- `gui/rename`: add overlay to worldgen screen allowing you to rename the world before the new world is saved +- `gui/rename`: add overlay to the "Prepare carefully" embark screen that transparently fixes a DF bug where you can't give units nicknames or custom professions +- `gui/notify`: new notification type: save reminder; appears if you have gone more than 15 minutes without saving; click to autosave + +## Fixes +- `fix/dry-buckets`: don't empty buckets for wells that are actively in use +- `gui/unit-info-viewer`: skill progress bars now show correct XP thresholds for skills past Legendary+5 +- `caravan`: no longer incorrectly identify wood-based plant items and plant-based soaps as being ethically unsuitable for trading with the elves +- `gui/design`: don't require an extra right click on the first cancel of building area designations +- `gui/gm-unit`: refresh unit sprite when profession is changed + +## Misc Improvements +- `immortal-cravings`: goblins and other naturally non-eating/non-drinking races will now also satisfy their needs for eating and drinking +- `caravan`: add filter for written works in display furniture assignment dialog +- `fix/wildlife`: don't vaporize stuck wildlife that is onscreen -- kill them instead (as if they died from old age) +- `gui/sitemap`: show primary group affiliation for visitors and invaders (e.g. civilization name or performance troupe) + +# 50.14-r2 + +## New Tools +- `fix/wildlife`: prevent wildlife from getting stuck when trying to exit the map. This fix needs to be enabled manually in `gui/control-panel` on the Bug Fixes tab since not all players want this bug to be fixed (you can intentionally stall wildlife incursions by trapping wildlife in an enclosed area so they are not caged but still cannot escape). +- `immortal-cravings`: allow immortals to satisfy their cravings for food and drink +- `justice`: pardon a criminal's prison sentence + +## New Features +- `force`: add support for a ``Wildlife`` event to allow additional wildlife to enter the map + +## Fixes +- `gui/quickfort`: only print a help blueprint's text once even if the repeat setting is enabled +- `makeown`: quell any active enemy or conflict relationships with converted creatures +- `makeown`: halt any hostile jobs the unit may be engaged in, like kidnapping +- `fix/loyaltycascade`: allow the fix to work on non-dwarven citizens +- `control-panel`: fix error when setting numeric preferences from the commandline +- `gui/quickfort`: fix build mode evaluation rules to allow placement of furniture and constructions on tiles with stair shapes or without orthagonal floors +- `emigration`: save-and-reload no longer resets the emigration cycle timeout +- `geld`, `ungeld`: save-and-reload no longer loses changes done by `geld` and `ungeld` for units who are historical figures +- `rejuvenate`: fix error when specifying ``--age`` parameter +- `gui/notify`: don't classify (peacefully) visiting night creatures as hostile +- `exportlegends`: ensure historical figure race filter is usable after re-entering legends mode with a different loaded world + +## Misc Improvements +- `idle-crafting`: also support making shell crafts for workshops with linked input stockpiles +- `gui/gm-editor`: automatically resolve and display names for ``language_name`` fields +- `fix/stuck-worship`: reduced console output by default. Added ``--verbose`` and ``--quiet`` options. +- `gui/design`: add dimensions tooltip to vanilla zone painting interface +- `necronomicon`: new ``--world`` option to list all secret-containing items in the entire world +- `gui/design`: new ``gui/design.rightclick`` overlay that allows you to cancel out of partially drawn box and minecart designations without canceling completely out of drawing mode + +## Removed +- `modtools/force`: merged into `force` + +# 50.13-r5 + +## New Tools +- `embark-anyone`: allows you to embark as any civilization, including dead and non-dwarven civs +- `idle-crafting`: allow dwarves to independently satisfy their need to craft objects +- `gui/family-affairs`: (reinstated) inspect or meddle with pregnancies, marriages, or lover relationships +- `notes`: attach notes to locations on a fort map + +## New Features +- `caravan`: DFHack dialogs for trade screens (both ``Bring goods to depot`` and the ``Trade`` barter screen) can now filter by item origins (foreign vs. fort-made) and can filter bins by whether they have a mix of ethically acceptable and unacceptable items in them +- `caravan`: If you have managed to select an item that is ethically unacceptable to the merchant, an "Ethics warning" badge will now appear next to the "Trade" button. Clicking on the badge will show you which items that you have selected are problematic. The dialog has a button that you can click to deselect the problematic items in the trade list. +- `confirm`: If you have ethically unacceptable items selected for trade, the "Are you sure you want to trade" confirmation will warn you about them +- `quickfort`: ``#zone`` blueprints now integrated with `preserve-rooms` so you can create a zone and automatically assign it to a noble or administrative role +- `exportlegends`: option to filter by race on historical figures page + +## Fixes +- `timestream`: ensure child growth events (that is, a child's transition to adulthood) are not skipped; existing "overage" children will be automatically fixed within a year +- `empty-bin`: ``--liquids`` option now correctly empties containers filled with LIQUID_MISC (like lye) +- `gui/design`: don't overcount "affected tiles" for Line & Freeform drawing tools +- `deep-embark`: fix error when embarking where there is no land to stand on (e.g. when embarking in the ocean with `gui/embark-anywhere`) +- `deep-embark`: fix failure to transport units and items when embarking where there is no room to spawn the starting wagon +- `gui/create-item`, `modtools/create-item`: items of type "VERMIN", "PET", "REMANS", "FISH", "RAW FISH", and "EGG" no longer spawn creature item "nothing" and will now stack correctly +- `rejuvenate`: don't set a lifespan limit for creatures that are immortal (e.g. elves, goblins) +- `rejuvenate`: properly disconnect babies from mothers when aging babies up to adults + +## Misc Improvements +- `gui/sitemap`: show whether a unit is friendly, hostile, or wild +- `gui/sitemap`: show whether a unit is caged +- `gui/control-panel`: include option for turning off dumping of old clothes for `tailor`, for players who have magma pit dumps and want to save old clothes from being dumped into the magma +- `position`: report current historical era (e.g., "Age of Myth"), site/adventurer world coords, and mouse map tile coords +- `position`: option to copy keyboard cursor position to the clipboard +- `assign-minecarts`: reassign vehicles to routes where the vehicle has been destroyed (or has otherwise gone missing) +- `fix/dry-buckets`: prompt DF to recheck requests for aid (e.g. "bring water" jobs) when a bucket is unclogged and becomes available for use +- `exterminate`: show descriptive names for the listed races in addition to their IDs +- `exterminate`: show actual names for unique creatures such as forgotten beasts and titans +- `fix/ownership`: now also checks and fixes room ownership links + +## Documentation +- `gui/embark-anywhere`: add information about how the game determines world tile pathability and instructions for bridging two landmasses + +# 50.13-r4 + +## New Features +- `gui/journal`: new automatic table of contents. add lines that start with "# ", like "# Entry for 502-04-02", to add hyperlinked headers to the table of contents + +## Fixes +- `full-heal`: fix ``-r --all_citizens`` option combination not resurrecting citizens +- `open-legends`: don't intercept text bound for vanilla legends mode search widgets +- `gui/unit-info-viewer`: correctly display skill levels when rust is involved +- `timestream`: fix dwarves spending too long eating and drinking +- `timestream`: fix jobs not being created at a sufficient rate, leading to dwarves standing around doing nothing +- `locate-ore`: fix sometimes selecting an incorrect tile when there are multiple mineral veins in a single map block +- `gui/settings-manager`: fix position of "settings restored" message on embark when the player has no saved embark profiles +- `build-now`: fix error when building buildings that (in previous DF versions) required the architecture labor +- `prioritize`: fix incorrect restoring of saved settings on Windows +- `list-waves`: no longer gets confused by units that leave the map and then return (e.g. squads who go out on raids) +- `fix/dead-units`: fix error when removing dead units from burrows and the unit with the greatest ID was dead +- `makeown`: ensure names given to adopted units (or units created with `gui/sandbox`) are respected later in legends mode +- `gui/autodump`: prevent dumping into walls or invalid map areas +- `gui/autodump`: properly turn items into projectiles when they are teleported into mid-air + +## Misc Improvements +- `build-now`: if `suspendmanager` is running, run an unsuspend cycle immediately before scanning for buildings to build +- `list-waves`: now outputs the names of the dwarves in each migration wave +- `list-waves`: can now display information about specific migration waves (e.g. ``list-waves 0`` to identify your starting 7 dwarves) +- `allneeds`: display distribution of needs by how severely they are affecting the dwarf + +# 50.13-r3 + +## New Tools +- `advtools`: collection of useful commands and overlays for adventure mode +- `advtools`: added an overlay that automatically fixes corrupt throwing/shooting state, preventing save/load crashes +- `advtools`: advtools party - promotes one of your companions to become a controllable adventurer +- `advtools`: advtools pets - fixes pets you gift in adventure mode. +- `pop-control`: (reinstated) limit the maximum size of migrant waves +- `bodyswap`: (reinstated) take control of another unit in adventure mode +- `gui/sitemap`: list and zoom to people, locations, and artifacts +- `devel/tree-info`: print a technical visualization of tree data +- `gui/tiletypes`: interface for modifying map tiles and tile properties +- `fix/population-cap`: fixes the situation where you continue to get migrant waves even when you are above your configured population cap +- `fix/occupancy`: fixes issues where you can't build somewhere because the game tells you an item/unit/building is in the way but there's nothing there +- `fix/sleepers`: (reinstated) fixes sleeping units belonging to a camp that never wake up. +- `timestream`: (reinstated) keep the game running quickly even when there are large numbers of units on the map +- `gui/journal`: fort journal with a multi-line text editor +- `devel/luacov`: (reinstated) add Lua script coverage reporting for use in testing and performance analysis + +## New Features +- `buildingplan`: dimension tooltip is now displayed for constructions and buildings that are designated over an area, like bridges and farm plots +- `gui/notify`: new notification type: injured citizens; click to zoom to injured units; also displays a warning if your hospital is not functional (or if you have no hospital) +- `gui/notify`: new notification type: drowning and suffocation progress bars for adventure mode +- `prioritize`: new info panel on under-construction buildings showing if the construction job has been taken and by whom. click to zoom to builder; toggle high priority status for job if it's not yet taken and you need it to be built ASAP +- `gui/unit-info-viewer`: new overlay for displaying progress bars for skills on the unit info sheet +- `gui/pathable`: new "Depot" mode that shows whether wagons can path to your trade depot +- `advtools`: automatically add a conversation option to "ask whereabouts of" for all your relationships (before, you could only ask whereabouts of people involved in rumors) +- `gui/design`: all-new visually-driven UI for much improved usability + +## Fixes +- `assign-profile`: fix handling of ``unit`` option for setting target unit id +- `gui/gm-unit`: correctly display skill levels above Legendary+5 +- `gui/gm-unit`: fix errors when editing/randomizing colors and body appearance +- `quickfort`: fix incorrect handling of stockpiles that are split into multiple separate areas but are given the same label (indicating that they should be part of the same stockpile) +- `makeown`: set animals to tame and domesticated +- `gui/sandbox`: spawned citizens can now be useful military squad members +- `gui/sandbox`: spawned undead now have a purple shade (only after save and reload, though) +- `caravan`: fix errors in trade dialog if all fort items are traded away while the trade dialog is showing fort items and the `confirm` trade confirmation is shown +- `control-panel`: restore non-default values of per-save enabled/disabled settings for repeat-based commands +- `confirm`: fix confirmation prompt behavior when overwriting a hotkey zoom location +- `quickfort`: allow farm plots to be built on muddy stone (as per vanilla behavior) +- `suspend`: remove broken ``--onlyblocking`` option; restore functionality to ``suspend all`` +- `gui/create-item`: allow creation of adamantine thread, wool, and yarn +- `gui/notify`: the notification panel no longer responds to the Enter key so Enter key is passed through to the vanilla UI +- `clear-smoke`: properly tag smoke flows for garbage collection to avoid memory leak +- `warn-stranded`: don't warn for babies carried by mothers who happen to be gathering fruit from trees +- `prioritize`: also boost priority of already-claimed jobs when boosting priority of a job type so those jobs are not interrupted +- `ban-cooking`: ban all seed producing items from being cooked when 'seeds' is chosen instead of just brewable seed producing items + +## Misc Improvements +- `item`: option for ignoring uncollected spider webs when you search for "silk" +- `gui/launcher`: "space space to toggle pause" behavior is skipped if the game was paused when `gui/launcher` came up to prevent accidental unpausing +- `gui/unit-info-viewer`: add precise unit size in cc (cubic centimeters) for comparison against the wiki values. you can set your preferred number format for large numbers like this in the preferences of `control-panel` or `gui/control-panel` +- `gui/unit-info-viewer`: now displays a unit's weight relative to a similarly-sized well-known creature (dwarves, elephants, or cats) +- `gui/unit-info-viewer`: shows a unit's size compared to the average for the unit's race +- `caravan`: optional overlay to hide vanilla "bring trade goods to depot" button (if you prefer to always use the DFHack version and don't want to accidentally click on the vanilla button). enable ``caravan.movegoods_hider`` in `gui/control-panel` UI Overlays tab to use. +- `caravan`: bring goods to depot screen now shows (approximate) distance from item to depot +- `gui/design`: circles are more circular (now matches more pleasing shape generated by ``digcircle``) +- `gui/quickfort`: you can now delete your blueprints from the blueprint load dialog +- `caravan`: remember filter settings for pedestal item assignment dialog +- `quickfort`: new ``delete`` command for deleting player-owned blueprints (library and mod-added blueprints cannot be deleted) +- `quickfort`: support enabling `logistics` features for autoforbid and autoclaim on stockpiles +- `gui/quickfort`: allow farm plots, dirt roads, and paved roads to be designated around partial obstructions without calling it an error, matching vanilla behavior +- `gui/launcher`: refresh default tag filter when mortal mode is toggled in `gui/control-panel` so changes to which tools autocomplete take effect immediately +- `gui/civ-alert`: you can now register multiple burrows as civilian alert safe spaces +- `exterminate`: add ``all`` target for convenient scorched earth tactics +- `empty-bin`: select a stockpile, tile, or building to empty all containers in the stockpile, tile, or building +- `exterminate`: add ``--limit`` option to limit number of exterminated creatures +- `exterminate`: add ``knockout`` and ``traumatize`` method for non-lethal incapacitation +- `caravan`: add shortcut to the trade request screen for selecting item types by value (e.g. so you can quickly select expensive gems or cheap leather) +- `gui/notify`: notification panel extended to apply to adventure mode +- `gui/control-panel`: highlight preferences that have been changed from the defaults +- `gui/quickfort`: buildings can now be constructed in a "high priority" state, giving them first dibs on `buildingplan` materials and setting their construction jobs to the highest priority +- `prioritize`: add ``ButcherAnimal`` to the default prioritization list (``SlaughterAnimal`` was already there, but ``ButcherAnimal`` -- which is different -- was missing) +- `prioritize`: list both unclaimed and total counts for current jobs when the --jobs option is specified +- `prioritize`: boost performance of script by not tracking number of times a job type was prioritized +- `gui/unit-syndromes`: make werecreature syndromes easier to search for + +## Removed +- `max-wave`: merged into `pop-control` +- `devel/find-offsets`, `devel/find-twbt`, `devel/prepare-save`: remove development scripts that are no longer useful +- `fix/item-occupancy`, `fix/tile-occupancy`: merged into `fix/occupancy` +- `adv-fix-sleepers`: renamed to `fix/sleepers` +- `adv-rumors`: merged into `advtools` + +# 50.13-r2 + +## New Tools +- Updated for adventure mode: `gui/sandbox`, `gui/create-item`, `gui/reveal` +- `ghostly`: (reinstated) allow your adventurer to phase through walls +- `markdown`: (reinstated) export description of selected unit or item to a text file +- `adaptation`: (reinstated) inspect or set unit cave adaptation levels +- `fix/engravings`: fix corrupt engraving tiles +- `unretire-anyone`: (reinstated) choose anybody in the world as an adventurer +- `reveal-adv-map`: (reinstated) reveal (or hide) the adventure map +- `resurrect-adv`: (reinstated) allow your adventurer to recover from death +- `flashstep`: (reinstated) teleport your adventurer to the mouse cursor + +## New Features +- `instruments`: new subcommand ``instruments order`` for creating instrument work orders + +## Fixes +- `modtools/create-item`: now functions properly when the ``reaction-gloves`` tweak is active +- `quickfort`: don't designate multiple tiles of the same tree for chopping when applying a tree chopping blueprint to a multi-tile tree +- `gui/quantum`: fix processing when creating a quantum dump instead of a quantum stockpile +- `caravan`: don't include undiscovered divine artifacts in the goods list +- `quickfort`: fix detection of valid tiles for wells +- `combine`: respect container volume limits + +## Misc Improvements +- `gui/autobutcher`: add shortcuts for butchering/unbutchering all animals +- `combine`: reduce combined drink sizes to 25 +- `gui/launcher`: add button for copying output to the system clipboard +- `deathcause`: automatically find and choose a corpse when a pile of mixed items is selected +- `gui/quantum`: add option for whether a minecart automatically gets ordered and/or attached +- `gui/quantum`: when attaching a minecart, show which minecart was attached +- `gui/quantum`: allow multiple feeder stockpiles to be linked to the minecart route +- `prioritize`: add PutItemOnDisplay jobs to the default prioritization list -- when these kinds of jobs are requested by the player, they generally want them done ASAP + +# 50.13-r1.1 + +## Fixes +- `gui/quantum`: accept all item types in the output stockpile as intended +- `deathcause`: fix error on run + +# 50.13-r1 + +## New Tools +- `gui/unit-info-viewer`: (reinstated) give detailed information on a unit, such as egg laying behavior, body size, birth date, age, and information about their afterlife behavior (if a ghost) +- `gui/quantum`: (reinstated) point and click interface for creating quantum stockpiles or quantum dumps + +## Fixes +- `open-legends`: don't interfere with the dragging of vanilla list scrollbars +- `gui/create-item`: properly restrict bags to bag materials by default +- `gui/create-item`: allow gloves and shoes to be made out of textiles by default +- `exterminate`: don't classify dangerous non-invader units as friendly (e.g. snatchers) + +## Misc Improvements +- `open-legends`: allow player to cancel the "DF will now exit" dialog and continue browsing +- `gui/gm-unit`: changes to unit appearance will now immediately be reflected in the unit portrait + +# 50.12-r3 + +## New Tools +- `gui/aquifer`: interactive aquifer visualization and editing +- `open-legends`: (reinstated) open legends mode directly from a loaded fort + +## New Features +- `quickfort`: add options for setting warm/damp dig markers when applying blueprints +- `gui/quickfort`: add options for setting warm/damp dig markers when applying blueprints +- `gui/reveal`: new "aquifer only" mode to only see hidden aquifers but not reveal any tiles +- `gui/notify`: optional notification for general wildlife (not on by default) + +## Fixes +- `fix/loyaltycascade`: fix edge case where loyalties of renegade units were not being fixed +- `quickfort`: reject tiles for building that contain magma or deep water +- `armoks-blessing`: fix error when making "Normal" attributes legendary +- `emigration`: remove units from burrows when they emigrate +- `agitation-rebalance`: fix calculated percent chance of cavern invasion +- `gui/launcher`: don't pop up a result dialog if a command run from minimal mode has no output + +## Misc Improvements +- `gui/reveal`: show aquifers even when not in mining mode +- `gui/control-panel`: add alternate "nodump" version for `cleanowned` that does not cause citizens to toss their old clothes in the dump. this is useful for players who would rather sell old clothes than incinerate them +- `agitation-rebalance`: when more than the maximum allowed cavern invaders are trying to enter the map, prefer keeping the animal people invaders instead of their war animals + +## Removed +- `drain-aquifer`: replaced by ``aquifer drain --all``; an alias now exists so ``drain-aquifer`` will automatically run the new command + +# 50.12-r2.1 + +## Fixes +- `fix/noexert-exhaustion`: fix typo in control panel registry entry which prevented the fix from being run when enabled +- `gui/suspendmanager`: fix script startup errors +- `control-panel`: properly auto-enable newly added bugfixes + +## Misc Improvements +- `gui/unit-syndromes`: make syndromes searchable by their display names (e.g. "necromancer") + +# 50.12-r2 + +## New Tools +- `agitation-rebalance`: alter mechanics of irritation-related attacks so they are less constant and are more responsive to recent player behavior +- `fix/ownership`: fix instances of multiple citizens claiming the same items, resulting in "Store owned item" job loops +- `fix/stuck-worship`: fix prayer so units don't get stuck in uninterruptible "Worship!" states +- `instruments`: provides information on how to craft the instruments used by the player civilization +- `modtools/item-trigger`: (reinstated) modder's resource for triggering scripted content when specific items are used +- `modtools/if-entity`: (reinstated) modder's resource for triggering scripted content depending on the race of the loaded fort +- `devel/block-borders`: (reinstated) highlights boundaries of map blocks or embark tile blocks +- `fix/noexert-exhaustion`: fix "Tired" NOEXERT units. Enabling via `gui/control-panel` prevents NOEXERT units from getting stuck in a "Tired" state + +## New Features +- `gui/settings-manager`: add import, export, and autoload for work details +- `exterminate`: new "disintegrate" kill method that additionally destroys carried items +- `quickfort`: allow setting of workshop profile properties (e.g. labor, skill restrictions) from build blueprints + +## Fixes +- `gui/launcher`: fix history scanning (Up/Down arrow keys) being slow to respond when in minimal mode +- `control-panel`: fix filtering not filtering when running the ``list`` command +- `gui/notify`: don't zoom to forbidden depots for merchants ready to trade notification +- `catsplosion`: only cause pregnancies in adults + +## Misc Improvements +- `gui/launcher`: add interface for browsing and filtering commands by tags +- `gui/launcher`: add support for history search (Alt-s hotkey) when in minimal mode +- `gui/launcher`: add support for the ``clear`` command and clearing the scrollback buffer +- `control-panel`: enable tweaks quietly on fort load so we don't spam the console +- `devel/tile-browser`: simplify interface now that SDL automatically normalizes texture scale +- `exterminate`: make race name matching case and space insensitive +- `gui/gm-editor`: support opening engraved art for inspection +- `gui/notify`: Shift click or Shift Enter on a zoomable notification to zoom to previous target +- `allneeds`: select a dwarf in the UI to see a summary of needs for just that dwarf +- `allneeds`: provide options for sorting the cumulative needs by different criteria +- `prioritize`: print out custom reaction and hauling jobs in the same format that is used for ``prioritize`` command arguments so the player can just copy and paste + +# 50.12-r1 + +## Fixes +- `gui/notify`: persist notification settings when toggled in the UI + +## Misc Improvements +- `gui/launcher`: developer mode hotkey restored to Ctrl-D + +# 50.11-r7 + +## New Tools +- `undump-buildings`: (reinstated) remove dump designation from in-use building materials +- `gui/petitions`: (reinstated) show outstanding (or all historical) petition agreements for guildhalls and temples +- `gui/notify`: display important notifications that vanilla doesn't support yet and provide quick zoom links to notification targets. +- `list-waves`: (reinstated) show migration wave information +- `make-legendary`: (reinstated) make a dwarf legendary in specified skills +- `combat-harden`: (reinstated) set a dwarf's resistance to being affected by visible corpses +- `add-thought`: (reinstated) add custom thoughts to a dwarf +- `devel/input-monitor`: interactive UI for debugging input issues + +## Fixes +- `gui/design`: clicking the center point when there is a design mark behind it will no longer simultaneously enter both mark dragging and center dragging modes. Now you can click once to move the shape, and click twice to move only the mark behind the center point. +- `fix/retrieve-units`: prevent pulling in duplicate units from offscreen +- `warn-stranded`: when there was at least one truly stuck unit and miners were actively mining, the miners were also confusingly shown in the stuck units list +- `source`: fix issue where removing sources would make some other sources inactive +- `caravan`: display book and scroll titles in the goods and trade dialogs instead of generic scroll descriptions +- `item`: avoid error when scanning items that have no quality rating (like bars and other construction materials) +- `gui/blueprint`: changed hotkey for setting blueprint origin tile so it doesn't conflict with default map movement keys +- `gui/control-panel`: fix error when toggling autostart settings + +## Misc Improvements +- `exportlegends`: make progress increase smoothly over the entire export and increase precision of progress percentage +- `gui/autobutcher`: ask for confirmation before zeroing out targets for all races +- `caravan`: move goods to trade depot dialog now allocates more space for the display of the value of very expensive items +- `extinguish`: allow selecting units/items/buildings in the UI to target them for extinguishing; keyboard cursor is only required for extinguishing map tiles that cannot be selected any other way +- `item`: change syntax so descriptions can be searched for without indicating the ``--description`` option. e.g. it's now ``item count royal`` instead of ``item count --description royal`` +- `item`: add ``--verbose`` option to print each item as it is matched +- `gui/mod-manager`: will automatically unmark the default mod profile from being the default if it fails to load (due to missing or incompatible mods) +- `gui/quickfort`: can now dynamically adjust the dig priority of tiles designated by dig blueprints +- `gui/quickfort`: can now opt to apply dig blueprints in marker mode + +## Removed +- `gui/manager-quantity`: the vanilla UI can now modify manager order quantities after creation +- `gui/create-tree`: replaced by `gui/sandbox` +- `warn-starving`: combined into `gui/notify` +- `warn-stealers`: combined into `gui/notify` + +# 50.11-r6 + +## Fixes +- `makeown`: fix error when adopting units that need a historical figure to be created +- `item`: fix missing item categories when using ``--by-type`` + +# 50.11-r5 + +## New Tools +- `control-panel`: new commandline interface for control panel functions +- `uniform-unstick`: (reinstated) force squad members to drop items that they picked up in the wrong order so they can get everything equipped properly +- `gui/reveal`: temporarily unhide terrain and then automatically hide it again when you're ready to unpause +- `gui/teleport`: mouse-driven interface for selecting and teleporting units +- `gui/biomes`: visualize and inspect biome regions on the map +- `gui/embark-anywhere`: bypass those pesky warnings and embark anywhere you want to +- `item`: perform bulk operations on groups of items. + +## New Features +- `uniform-unstick`: add overlay to the squad equipment screen to show a equipment conflict report and give you a one-click button to (attempt to) fix +- `gui/settings-manager`: save and load embark difficulty settings and standing orders; options for auto-load on new embark + +## Fixes +- `source`: water and magma sources and sinks now persist with fort across saves and loads +- `gui/design`: fix incorrect dimensions being shown when you're placing a stockpile, but a start coordinate hasn't been selected yet +- `warn-stranded`: don't warn for citizens who are only transiently stranded, like those on stepladders gathering plants or digging themselves out of a hole +- `ban-cooking`: fix banning creature alcohols resulting in error +- `confirm`: properly detect clicks on the remove zone button even when the unit selection screen is also open (e.g. the vanilla assign animal to pasture panel) +- `caravan`: ensure items are marked for trade when the move trade goods dialog is closed even when they were selected and then the list filters were changed such that the items were no longer actively shown +- `quickfort`: if a blueprint specifies an up/down stair, but the tile the blueprint is applied to cannot make an up stair (e.g. it has already been dug out), still designate a down stair if possible +- `suspendmanager`: correctly handle building collisions with smoothing designations when the building is on the edge of the map +- `empty-bin`: now correctly sends ammunition in carried quivers to the tile underneath the unit instead of teleporting them to an invalid (or possibly just far away) location + +## Misc Improvements +- `warn-stranded`: center the screen on the unit when you select one in the list +- `gui/control-panel`: reduce frequency for `warn-stranded` check to once every 2 days +- `gui/control-panel`: tools are now organized by type: automation, bugfix, and gameplay +- `confirm`: updated confirmation dialogs to use clickable widgets and draggable windows +- `confirm`: added confirmation prompt for right clicking out of the trade agreement screen (so your trade agreement selections aren't lost) +- `confirm`: added confirmation prompts for irreversible actions on the trade screen +- `confirm`: added confirmation prompt for deleting a uniform +- `confirm`: added confirmation prompt for convicting a criminal +- `confirm`: added confirmation prompt for re-running the embark site finder +- `confirm`: added confirmation prompt for reassigning or clearing zoom hotkeys +- `confirm`: added confirmation prompt for exiting the uniform customization page without saving +- `gui/autobutcher`: interface redesigned to better support mouse control +- `gui/launcher`: now persists the most recent 32KB of command output even if you close it and bring it back up +- `gui/quickcmd`: clickable buttons for command add/remove/edit operations +- `uniform-unstick`: warn if a unit belongs to a squad from a different site (can happen with migrants from previous forts) +- `gui/mass-remove`: can now differentiate planned constructions, stockpiles, and regular buildings +- `gui/mass-remove`: can now remove zones +- `gui/mass-remove`: can now cancel removal for buildings and constructions +- `fix/stuck-instruments`: now handles instruments that are left in the "in job" state but that don't have any actual jobs associated with them +- `gui/launcher`: make autocomplete case insensitive + +# 50.11-r4 + +## New Tools +- `build-now`: (reinstated) instantly complete unsuspended buildings that are ready to be built + +## Fixes +- `combine`: prevent stack sizes from growing beyond quantities that you would normally see in vanilla gameplay +- `gui/design`: Center dragging shapes now track the mouse correctly + +## Misc Improvements +- `caravan`: enable searching within containers in trade screen when in "trade bin with contents" mode + +# 50.11-r3 + +## New Tools +- `sync-windmills`: synchronize or randomize movement of active windmills +- `trackstop`: (reimplemented) integrated overlay for changing track stop and roller settings after construction + +## New Features +- `gui/design`: show selected dimensions next to the mouse cursor when designating with vanilla tools, for example when painting a burrow or designating digging +- `quickfort`: new ``burrow`` blueprint mode for designating or manipulating burrows +- `unforbid`: now ignores worn and tattered items by default (X/XX), use -X to bypass +- `fix/dead-units`: gained ability to scrub dead units from burrow membership lists + +## Fixes +- `gui/unit-syndromes`: show the syndrome names properly in the UI +- `emigration`: fix clearing of work details assigned to units that leave the fort + +## Misc Improvements +- `warn-stranded`: don't warn for units that are temporarily on unwalkable tiles (e.g. as they pass under a waterfall) + +## Removed +- `gui/control-panel`: removed always-on system services from the ``System`` tab: `buildingplan`, `confirm`, `logistics`, and `overlay`. The base services should not be turned off by the player. Individual confirmation prompts can be managed via `gui/confirm`, and overlays (including those for `buildingplan` and `logistics`) are managed on the control panel ``Overlays`` tab. +- `gui/control-panel`: removed `autolabor` from the ``Fort`` and ``Autostart`` tabs. The tool does not function correctly with the new labor types, and is causing confusion. You can still enable `autolabor` from the commandline with ``enable autolabor`` if you understand and accept its limitations. + +# 50.11-r2 + +## New Tools +- `add-recipe`: (reinstated) add reactions to your civ (e.g. for high boots if your civ didn't start with the ability to make high boots) +- `fix/corrupt-jobs`: prevents crashes by automatically removing corrupted jobs +- `burial`: (reinstated) create tomb zones for unzoned coffins + +## New Features +- `burial`: new options to configure automatic burial and limit scope to the current z-level +- `drain-aquifer`: gained ability to drain just above or below a certain z-level +- `drain-aquifer`: new option to drain all layers except for the first N aquifer layers, in case you want some aquifer layers but not too many +- `gui/control-panel`: ``drain-aquifer --top 2`` added as an autostart option + +## New Scripts +- `warn-stranded`: new repeatable maintenance script to check for stranded units, similar to `warn-starving` + +## Fixes +- `suspendmanager`: fix errors when constructing near the map edge +- `gui/sandbox`: fix scrollbar moving double distance on click +- `hide-tutorials`: fix the embark tutorial prompt sometimes not being skipped +- `full-heal`: fix removal of corpse after resurrection +- `toggle-kbd-cursor`: clear the cursor position when disabling, preventing the game from sometimes jumping the viewport around when cursor keys are hit + +## Misc Improvements +- `prioritize`: refuse to automatically prioritize dig and smooth/carve job types since it can break the DF job scheduler; instead, print a suggestion that the player use specialized units and vanilla designation priorities +- `gui/overlay`: filter overlays by current context so there are fewer on the screen at once and you can more easily click on the one you want to reposition +- `quickfort`: now allows constructions to be built on top of constructed floors and ramps, just like vanilla. however, to allow blueprints to be safely reapplied to the same area, for example to fill in buildings whose constructions were canceled due to lost items, floors will not be rebuilt on top of floors and ramps will not be rebuilt on top of ramps +- `gui/gm-editor`: for fields with primitive types, change from click to edit to click to select, double-click to edit. this should help prevent accidental modifications to the data and make hotkeys easier to use (since you have to click on a data item to use a hotkey on it) + +# 50.11-r1 + +## New Tools +- `startdwarf`: (reinstated) set number of starting dwarves + +## New Features +- `startdwarf`: overlay scrollbar so you can scroll through your starting dwarves if they don't all fit on the screen +- A new searchable, sortable, filterable dialog for selecting items for display on pedestals and display cases + +## Fixes +- `suspendmanager`: fixed a bug where floor grates, bars, bridges etc. wouldn't be recognised as walkable, leading to unnecessary suspensions in certain cases. + +## Misc Improvements +- `devel/inspect-screen`: display total grid size for UI and map layers +- `suspendmanager`: now suspends constructions that would cave-in immediately on completion + +# 50.10-r1 + +## Fixes +- 'fix/general-strike: fix issue where too many seeds were getting planted in farm plots + +# 50.09-r4 + +## Misc Improvements +- `autofish`: changed ``--raw`` argument format to allow explicit setting to on or off +- `caravan`: move goods to depot screen can now see/search/trade items inside of barrels and pots +- `gui/launcher`: show tagged tools in the autocomplete list when a tag name is typed + +# 50.09-r3 + +## New Tools +- `devel/scan-vtables`: scan and dump likely vtable addresses (for memory research) +- `hide-interface`: hide the vanilla UI elements for clean screenshots or laid-back fortress observing +- `hide-tutorials`: hide the DF tutorial popups; enable in the System tab of `gui/control-panel` +- `set-orientation`: tinker with romantic inclinations (reinstated from back catalog of tools) + +## New Features +- `exportlegends`: new overlay that integrates with the vanilla "Export XML" button. Now you can generate both the vanilla export and the extended data export with a single click! + +## Fixes +- `suspendmanager`: Fix the overlay enabling/disabling `suspendmanager` unexpectedly +- `caravan`: correct price adjustment values in trade agreement details screen +- `caravan`: apply both import and export trade agreement price adjustments to items being both bought or sold to align with how vanilla DF calculates prices +- `caravan`: cancel any active TradeAtDepot jobs if all caravans are instructed to leave +- `emigration`: fix errors loading forts after dwarves assigned to work details or workshops have emigrated +- `emigration`: fix citizens sometimes "emigrating" to the fortress site +- `suspendmanager`: improve the detection on "T" and "+" shaped high walls +- `starvingdead`: ensure sieges end properly when undead siegers starve +- `fix/retrieve-units`: fix retrieved units sometimes becoming duplicated on the map +- `quickfort`: cancel old dig jobs that point to a tile when a new designation is applied to the tile +- `gui/launcher`, `gui/gm-editor`: recover gracefully when the saved frame position is now offscreen +- `gui/sandbox`: correctly load equipment materials in modded games that categorize non-wood plants as wood + +## Misc Improvements +- `devel/lsmem`: added support for filtering by memory addresses and filenames +- `gui/gm-editor`: hold down shift and right click to exit, regardless of how many substructures deep you are +- `quickfort`: linked stockpiles and workshops can now be specified by ID instead of only by name. this is mostly useful when dynamically generating blueprints and applying them via the `quickfort` API +- `gui/quickfort`: blueprint details screen can now be closed with Ctrl-D (the same hotkey used to open the details) +- `suspendmanager`: display a different color for jobs suspended by suspendmanager +- `caravan`: optionally display items within bins in bring goods to depot screen +- `gui/gm-editor`: display in the title bar whether the editor window is scanning for live updates +- `gui/design`: change "auto commit" hotkey from ``c`` to ``Alt-c`` to avoid conflict with the default keybinding for z-level down +- `gui/liquids`: support removing river sources by converting them into stone floors + +# 50.09-r2 + +## New Scripts +- `caravan`: new trade screen UI replacements for bringing goods to trade depot and trading +- `fix/empty-wheelbarrows`: new script to empty stuck rocks from all wheelbarrows on the map + +## Fixes +- `gui/autodump`: when "include items claimed by jobs" is on, actually cancel the job so the item can be teleported +- `gui/gm-unit`: fix commandline processing when a unit id is specified +- `suspendmanager`: take in account already built blocking buildings +- `suspendmanager`: don't consider tree branches as a suitable access path to a building + +## Misc Improvements +- `gui/unit-syndromes`: make lists searchable +- `suspendmanager`: display the suspension reason when viewing a suspended building +- `quickfort`: blueprint libraries are now moddable -- add a ``blueprints/`` directory to your mod and they'll show up in `quickfort` and `gui/quickfort`! + +# 50.09-r1 + +## Misc Improvements +- `caravan`: new overlay for selecting all/none on trade request screen +- `suspendmanager`: don't suspend constructions that are built over open space + +# 50.08-r4 + +## Fixes +- `gui/create-item`: allow blocks to be made out of wood when using the restrictive filters +- `emigration`: reassign home site for emigrating units so they don't just come right back to the fort +- `gui/sandbox`: allow creatures that have separate caste-based graphics to be spawned (like ewes/rams) +- `gui/liquids`: ensure tile temperature is set correctly when painting water or magma +- `workorder`: prevent ``autoMilkCreature`` from over-counting milkable animals, which was leading to cancellation spam for the MilkCreature job +- `gui/quickfort`: allow traffic designations to be applied over buildings +- `gui/quickfort`: protect against meta blueprints recursing infinitely if they include themselves + +## Misc Improvements +- `gui/control-panel`: add some popular startup configuration commands for `autobutcher` and `autofarm` +- `gui/control-panel`: add option for running `fix/blood-del` on new forts (enabled by default) +- `gui/sandbox`: when creating citizens, give them names appropriate for their races +- `gui/autodump`: add option to clear the ``trader`` flag from teleported items, allowing you to reclaim items dropped by merchants +- `quickfort`: significant rewrite for DF v50! now handles zones, locations, stockpile configuration, hauling routes, and more +- `suspendmanager`: now suspends construction jobs on top of floor designations, protecting the designations from being erased +- `prioritize`: add wild animal management tasks and lever pulling to the default list of prioritized job types +- `suspendmanager`: suspend blocking jobs when building high walls or filling corridors +- `workorder`: reduce existing orders for automatic shearing and milking jobs when animals are no longer available +- `gui/quickfort`: adapt "cursor lock" to mouse controls so it's easier to see the full preview for multi-level blueprints before you apply them +- `gui/quickfort`: only display post-blueprint messages once when repeating the blueprint up or down z-levels +- `combine`: reduce max different stacks in containers to 30 to prevent containers from getting overfull + +## Removed +- `gui/automelt`: replaced by an overlay panel that appears when you click on a stockpile + +# 50.08-r2 + +## New Scripts +- `diplomacy`: view or alter diplomatic relationships +- `exportlegends`: (reinstated) export extended legends information for external browsing +- `modtools/create-item`: (reinstated) commandline and API interface for creating items +- `light-aquifers-only`: (reinstated) convert heavy aquifers to light +- `necronomicon`: search fort for items containing the secrets of life and death +- `fix/stuck-instruments`: fix instruments that are attached to invalid jobs, making them unusable. turn on automatic fixing in `gui/control-panel` in the ``Maintenance`` tab. +- `gui/mod-manager`: automatically restore your list of active mods when generating new worlds +- `gui/autodump`: point and click item teleportation and destruction interface (available only if ``armok`` tools are shown) +- `gui/sandbox`: creation interface for units, trees, and items (available only if ``armok`` tools are shown) +- `assign-minecarts`: (reinstated) quickly assign minecarts to hauling routes + +## Fixes +- `quickfort`: properly allow dwarves to smooth, engrave, and carve beneath walkable tiles of buildings +- `deathcause`: fix incorrect weapon sometimes being reported +- `gui/create-item`: allow armor to be made out of leather when using the restrictive filters +- `gui/design`: Fix building and stairs designation +- `quickfort`: fixed detection of tiles where machines are allowed (e.g. water wheels *can* be built on stairs if there is a machine support nearby) +- `quickfort`: fixed rotation of blueprints with carved track tiles + +## Misc Improvements +- `gui/quickfort`: blueprints that designate items for dumping/forbidding/etc. no longer show an error highlight for tiles that have no items on them +- `gui/quickfort`: place (stockpile layout) mode is now supported. note that detailed stockpile configurations were part of query mode and are not yet supported +- `gui/quickfort`: you can now generate manager orders for items required to complete blueprints +- `gui/create-item`: ask for number of items to spawn by default +- `light-aquifers-only`: now available as a fort Autostart option in `gui/control-panel`. note that it will only appear if "armok" tools are configured to be shown on the Preferences tab. +- `gui/gm-editor`: when passing the ``--freeze`` option, further ensure that the game is frozen by halting all rendering (other than for DFHack tool windows) +- `gui/gm-editor`: Alt-A now enables auto-update mode, where you can watch values change live when the game is unpaused + +# 50.08-r1 + +## Fixes +- `deteriorate`: ensure remains of enemy dwarves are properly deteriorated +- `suspendmanager`: Fix over-aggressive suspension of jobs that could still possibly be done (e.g. jobs that are partially submerged in water) + +## Misc Improvements +- `combine`: Now supports ammo, parts, powders, and seeds, and combines into containers +- `deteriorate`: add option to exclude useable parts from deterioration +- `gui/gm-editor`: press ``g`` to move the map to the currently selected item/unit/building +- `gui/gm-editor`: press ``Ctrl-D`` to toggle read-only mode to protect from accidental changes; this state persists across sessions +- `gui/gm-editor`: new ``--freeze`` option for ensuring the game doesn't change while you're inspecting it +- `gui/launcher`: DFHack version now shown in the default help text +- `gui/prerelease-warning`: widgets are now clickable + +# 50.07-r1 + +## Fixes +-@ `caravan`: fix trade good list sometimes disappearing when you collapse a bin +-@ `gui/gm-editor`: no longer nudges last open window when opening a new one +- `warn-starving`: no longer warns for dead units +-@ `gui/control-panel`: the config UI for `automelt` is no longer offered when not in fortress mode + +## Misc Improvements +- `gui/gm-editor`: can now jump to material info objects from a mat_type reference with a mat_index using ``i`` +- `gui/gm-editor`: the key column now auto-fits to the widest key +- `prioritize`: revise and simplify the default list of prioritized jobs -- be sure to tell us if your forts are running noticeably better (or worse!) +-@ `gui/control-panel`: add `faststart` to the system services + +# 50.07-beta2 + +## New Scripts +- `fix/general-strike`: fix known causes of the general strike bug (contributed by Putnam) +- `gui/seedwatch`: GUI config and status panel interface for `seedwatch` +- `gui/civ-alert`: configure and trigger civilian alerts + +## Fixes +-@ `caravan`: item list length now correct when expanding and collapsing containers +-@ `prioritize`: fixed all watched job type names showing as ``nil`` after a game load +-@ `suspendmanager`: does not suspend non-blocking jobs such as floor bars or bridges anymore +-@ `suspendmanager`: fix occasional bad identification of buildingplan jobs +- `warn-starving`: no longer warns for enemy and neutral units + +## Misc Improvements +- `gui/control-panel`: Now detects overlays from scripts named with capital letters +- `gui/cp437-table`: now has larger key buttons and clickable backspace/submit/cancel buttons, making it fully usable on the Steam Deck and other systems that don't have an accessible keyboard +-@ `gui/design`: Now supports placing constructions using 'Building' mode. Inner and Outer tile constructions are configurable. Uses buildingplan filters set up with the regular buildingplan interface. +- `exterminate`: add support for ``vaporize`` kill method for when you don't want to leave a corpse +- `combine`: you can select a target stockpile in the UI instead of having to use the keyboard cursor +- `combine`: added ``--quiet`` option for no output when there are no changes +- `stripcaged`: added ``--skip-forbidden`` option for greater control over which items are marked for dumping +- `stripcaged`: items that are marked for dumping are now automatically unforbidden (unless ``--skip-forbidden`` is set) +-@ `gui/control-panel`: added ``combine all`` maintenance option for automatic combining of partial stacks in stockpiles +-@ `gui/control-panel`: added ``general-strike`` maintenance option for automatic fixing of (at least one cause of) the general strike bug +- `gui/cp437-table`: dialog is now fully controllable with the mouse, including highlighting which key you are hovering over and adding a clickable backspace button + +## Removed +- `autounsuspend`: replaced by `suspendmanager` +-@ `gui/dig`: renamed to `gui/design` + +# 50.07-beta1 + +## New Scripts +- `suspendmanager`: automatic job suspension management (replaces `autounsuspend`) +- `gui/suspendmanager`: graphical configuration interface for `suspendmanager` +- `suspend`: suspends building construction jobs + +## Fixes +-@ `quicksave`: now reliably triggers an autosave, even if one has been performed recently +- `gui/launcher`: tab characters in command output now appear as a space instead of a code page 437 "blob" + +## Misc Improvements +- `quickfort`: now reads player-created blueprints from ``dfhack-config/blueprints/`` instead of the old ``blueprints/`` directory. Be sure to move over your personal blueprints to the new directory! +- `gui/gm-editor`: can now open the selected stockpile if run without parameters + +# 50.07-alpha3 + +## Fixes +-@ `gui/create-item`: fix generic corpsepiece spawning + +## Misc Improvements +- `gui/create-item`: added ability to spawn 'whole' corpsepieces (every layer of a part) +-@ `gui/dig`: Allow placing an extra point (curve) while still placing the second main point +-@ `gui/dig`: Allow placing n-point shapes, shape rotation/mirroring +-@ `gui/dig`: Allow second bezier point, mirror-mode for freeform shapes, symmetry mode + +# 50.07-alpha2 + +## New Scripts +- `combine`: combines stacks of food and plant items. + +## Fixes +-@ `troubleshoot-item`: fix printing of job details for chosen item +-@ `makeown`: fixes errors caused by using makeown on an invader +-@ `gui/blueprint`: correctly use setting presets passed on the commandline +-@ `gui/quickfort`: correctly use settings presets passed on the commandline +- `devel/query`: can now properly index vectors in the --table argument +-@ `forbid`: fix detection of unreachable items for items in containers +-@ `unforbid`: fix detection of unreachable items for items in containers + +## Misc Improvements +- `troubleshoot-item`: output as bullet point list with indenting, with item description and ID at top +- `troubleshoot-item`: reports on items that are hidden, artifacts, in containers, and held by a unit +- `troubleshoot-item`: reports on the contents of containers with counts for each contained item type +- `devel/visualize-structure`: now automatically inspects the contents of most pointer fields, rather than inspecting the pointers themselves +- `devel/query`: will now search for jobs at the map coordinate highlighted, if no explicit job is highlighted and there is a map tile highlighted +- `caravan`: add trade screen overlay that assists with selecting groups of items and collapsing groups in the UI +- `gui/gm-editor`: will now inspect a selected building itself if the building has no current jobs + +## Removed +- `combine-drinks`: replaced by `combine` +- `combine-plants`: replaced by `combine` + +# 50.07-alpha1 + +## New Scripts +- `gui/design`: digging and construction designation tool with shapes and patterns +- `makeown`: makes the selected unit a citizen of your fortress + +## Fixes +-@ `gui/unit-syndromes`: allow the window widgets to be interacted with +-@ `fix/protect-nicks`: now works by setting the historical figure nickname +-@ `gui/liquids`: fixed issues with unit pathing after adding/removing liquids +-@ `gui/dig`: Fix for 'continuing' auto-stair designation. Avoid nil index issue for tile_type + +## Misc Improvements +- `gui/gm-editor`: now supports multiple independent data inspection windows +- `gui/gm-editor`: now prints out contents of coordinate vars instead of just the type +- `rejuvenate`: now takes an --age parameter to choose a desired age. +-@ `gui/dig` : Added 'Line' shape that also can draw curves, added draggable center handle + +# 50.05-alpha3.1 + +## Fixes +-@ `gui/launcher`: no longer resets to the Help tab on every keystroke + +# 50.05-alpha3 + +## New Scripts +- `autofish`: auto-manage fishing labors to control your stock of fish +- `gui/autofish`: GUI config and status panel interface for autofish +- `gui/automelt`: GUI config and status panel interface for automelt +- `gui/control-panel`: quick access to DFHack configuration +- `fix/civil-war`: removes negative relations with own government +- `fix/protect-nicks`: restore nicknames when DF loses them +- `forbid`: forbid and list forbidden items on the map +- `gui/unit-syndromes`: browser for syndrome information + +## Fixes +- `build-now`: now correctly avoids adjusting non-empty tiles above constructions that it builds +- `catsplosion`: now only affects live, active units +- `quickfort`: allow floor bars, floor grates, and hatches to be placed over all stair types like vanilla allows + +## Misc Improvements +- `ban-cooking`: ban announcements are now hidden by default; use new option ``--verbose`` to show them. +- `ban-cooking`: report number of items banned. +- `build-now`: now handles dirt roads and initializes farm plots properly +- `devel/click-monitor`: report on middle mouse button actions +-@ `gui/autochop`: hide uninteresting burrows by default +-@ `gui/blueprint`: allow map movement with the keyboard while the UI is open +- `gui/create-item`: support spawning corpse pieces (e.g. shells) under "body part" +- `gui/create-item`: added search and filter capabilities to the selection lists +- `gui/launcher`: make command output scrollback separate from the help text so players can continue to see the output of the previous command as they type the next one +- `gui/launcher`: allow double spacebar to pause/unpause the game, even while typing a command +- `gui/launcher`: clarify what is being shown in the autocomplete list (all commands, autocompletion of partially typed command, or commands related to typed command) +- `gui/launcher`: support running commands directly from the autocomplete list via double-clicking +- `gui/liquids`: interface overhaul, also now allows spawning river sources, setting/adding/removing liquid levels, and cleaning water from being salty or stagnant +- `gui/overlay`: now focuses on repositioning overlay widgets; enabling, disabling, and getting help for overlay widgets has moved to the new `gui/control-panel` +-@ `gui/quickcmd`: now acts like a regular window instead of a modal dialog +- `gui/quickfort`: don't close the window when applying a blueprint so players can apply the same blueprint multiple times more easily +- `locate-ore`: now only searches revealed tiles by default +- `modtools/spawn-liquid`: sets tile temperature to stable levels when spawning water or magma +-@ `prioritize`: pushing minecarts is now included in the default prioritization list +- `prioritize`: now automatically starts boosting the default list of job types when enabled +- `unforbid`: avoids unforbidding unreachable and underwater items by default +- `gui/create-item`: added whole corpse spawning alongside corpsepieces. (under "corpse") + +## Removed +- `show-unit-syndromes`: replaced by `gui/unit-syndromes`; html export is no longer supported + +# 50.05-alpha2 + +## Fixes +-@ `gui/gm-editor`: fix errors displayed while viewing help screen +- `build-now`: don't error on constructions that do not have an item attached + +## Removed +- `create-items`: replaced by `gui/create-item` ``--multi`` + +# 50.05-alpha1 + +## New Scripts +- `gui/autochop`: configuration frontend and status monitor for the `autochop` plugin +- `devel/tile-browser`: page through available textures and see their texture ids +- `allneeds`: list all unmet needs sorted by how many dwarves suffer from them. + +## Fixes +- `make-legendary`: "MilitaryUnarmed" option now functional + +## Misc Improvements +- `autounsuspend`: now saves its state with your fort +- `emigration`: now saves its state with your fort +- `prioritize`: now saves its state with your fort +- `unsuspend`: overlay now displays different letters for different suspend states so they can be differentiated in graphics mode (P=planning, x=suspended, X=repeatedly suspended) +- `unsuspend`: overlay now shows a marker all the time when in graphics mode. ascii mode still only shows when paused so that you can see what's underneath. +- `gui/gm-editor`: converted to a movable, resizable, mouse-enabled window +- `gui/launcher`: now supports a smaller, minimal mode. click the toggle in the launcher UI or start in minimal mode via the ``Ctrl-Shift-P`` keybinding +- `gui/launcher`: can now be dragged from anywhere on the window body +- `gui/launcher`: now remembers its size and position between invocations +- `gui/gm-unit`: converted to a movable, resizable, mouse-enabled window +- `gui/cp437-table`: converted to a movable, mouse-enabled window +- `gui/quickcmd`: converted to a movable, resizable, mouse-enabled window +- `gui/quickcmd`: commands are now stored globally so you don't have to recreate commands for every fort +- `devel/inspect-screen`: updated for new rendering semantics and can now also inspect map textures +- `exterminate`: added drown method. magma and drown methods will now clean up liquids automatically. + +## Documentation +- `devel/hello-world`: updated to be a better example from which to start new gui scripts diff --git a/clear-smoke.lua b/clear-smoke.lua index 987ab40365..ee3b82c017 100644 --- a/clear-smoke.lua +++ b/clear-smoke.lua @@ -1,25 +1,39 @@ --- Removes all smoke from the map +--@module = true ---[====[ - -clear-smoke -=========== - -Removes all smoke from the map. Note that this can leak memory and should be -used sparingly. +function removeFlow(flow) --have DF remove the flow + if not flow then + return + end + flow.flags.DEAD = true -]====] + local block = dfhack.maps.getTileBlock(flow.pos) + if block then + block.flow_pool.flags.active = true + else + df.global.world.orphaned_flow_pool.flags.active = true + end +end -function clearSmoke(flows) - for i = #flows - 1, 0, -1 do - if flows[i].type == df.flow_type.Smoke then - flows:erase(i) +function removeFlows(flow_type) --remove all if flow_type is nil + local count = 0 + for _,flow in ipairs(df.global.flows) do + if not flow.flags.DEAD and (flow_type == nil or flow.type == flow_type) then + removeFlow(flow) + count = count + 1 end end + + return count end -clearSmoke(df.global.flows) +function clearSmoke() + if dfhack.isWorldLoaded() then + print(('%d smoke flows removed.'):format(removeFlows(df.flow_type.Smoke))) + else + qerror('World not loaded!') + end +end -for _, block in pairs(df.global.world.map.map_blocks) do - clearSmoke(block.flows) +if not dfhack_flags.module then + clearSmoke() end diff --git a/clear-webs.lua b/clear-webs.lua new file mode 100644 index 0000000000..bab8086da5 --- /dev/null +++ b/clear-webs.lua @@ -0,0 +1,53 @@ +-- Removes webs and frees webbed units. +-- Author: Atomic Chicken + +local utils = require('utils') +local validArgs = utils.invert({ + 'unitsOnly', + 'websOnly', + 'help' +}) +local args = utils.processArgs({...}, validArgs) + +if args.help then + print(dfhack.script_help()) + return +end + +if args.unitsOnly and args.websOnly then + qerror("You have specified both --unitsOnly and --websOnly. These cannot be used together.") +end + +local webCount = 0 +if not args.unitsOnly then + for i = #df.global.world.items.other.ANY_WEBS-1, 0, -1 do + dfhack.items.remove(df.global.world.items.other.ANY_WEBS[i]) + webCount = webCount + 1 + end +end + +local unitCount = 0 +if not args.websOnly then + for _, unit in ipairs(df.global.world.units.active) do + if unit.counters.webbed > 0 and not unit.flags2.killed and not unit.flags1.inactive then -- the webbed status is retained in death + unitCount = unitCount + 1 + unit.counters.webbed = 0 + end + end +end + +if not args.unitsOnly then + if webCount == 0 then + print("No webs detected!") + else + print("Removed " .. webCount .. " web" .. (webCount == 1 and "" or "s") .. "!") + end +end + +if not args.websOnly then + if unitCount == 0 then + print("No captured units detected!") + else + print("Freed " .. unitCount .. " unit" .. (unitCount == 1 and "" or "s") .. "!") + end +end diff --git a/colonies.lua b/colonies.lua index 93d7bff3e9..2672b66ab9 100644 --- a/colonies.lua +++ b/colonies.lua @@ -1,24 +1,7 @@ -- List, create, or change wild colonies (eg honey bees) -- By PeridexisErrant and Warmist -local help = [====[ - -colonies -======== -List vermin colonies, place honey bees, or convert all vermin -to honey bees. Usage: - -:colonies: List all vermin colonies on the map. -:colonies place: Place a honey bee colony under the cursor. -:colonies convert: Convert all existing colonies to honey bees. - -The ``place`` and ``convert`` subcommands by default create or -convert to honey bees, as this is the most commonly useful. -However both accept an optional flag to use a different vermin -type, for example ``colonies place ANT`` creates an ant colony -and ``colonies convert TERMITE`` ends your beekeeping industry. - -]====] +local guidm = require('gui.dwarfmode') function findVermin(target_verm) for k,v in ipairs(df.global.world.raws.creatures.all) do @@ -30,7 +13,7 @@ function findVermin(target_verm) end function list_colonies() - for idx, col in pairs(df.global.world.vermin.colonies) do + for idx, col in pairs(df.global.world.event.vermin_colonies) do local race = df.global.world.raws.creatures.all[col.race].creature_id print(race..' at '..col.pos.x..', '..col.pos.y..', '..col.pos.z) end @@ -39,7 +22,7 @@ end function convert_vermin_to(target_verm) local vermin_id = findVermin(target_verm) local changed = 0 - for _, verm in pairs(df.global.world.vermin.colonies) do + for _, verm in pairs(df.global.world.event.vermin_colonies) do verm.race = vermin_id verm.caste = -1 -- check for queen bee? verm.amount = 18826 @@ -50,8 +33,8 @@ function convert_vermin_to(target_verm) end function place_vermin(target_verm) - local pos = copyall(df.global.cursor) - if pos.x == -30000 then + local pos = guidm.getCursorPos() + if not pos then qerror("Cursor must be pointing somewhere") end local verm = df.vermin:new() @@ -61,21 +44,21 @@ function place_vermin(target_verm) verm.amount = 18826 verm.visible = true verm.pos:assign(pos) - df.global.world.vermin.colonies:insert("#", verm) - df.global.world.vermin.all:insert("#", verm) + df.global.world.event.vermin_colonies:insert("#", verm) + df.global.world.event.vermin:insert("#", verm) end local args = {...} local target_verm = args[2] or "HONEY_BEE" if args[1] == 'help' or args[1] == '?' then - print(help) + print(dfhack.script_help()) elseif args[1] == 'convert' then convert_vermin_to(target_verm) elseif args[1] == 'place' then place_vermin(target_verm) else - if #df.global.world.vermin.colonies < 1 then + if #df.global.world.event.vermin_colonies < 1 then dfhack.printerr('There are no colonies on the map.') end list_colonies() diff --git a/color-schemes.lua b/color-schemes.lua new file mode 100644 index 0000000000..7343bb6b34 --- /dev/null +++ b/color-schemes.lua @@ -0,0 +1,898 @@ +-- Color schemes manager. +--[====[ + +color-schemes +============= +A script to manage color schemes. + +Current features are : + * :Registration: + * :Loading: + * :Listing: + * :Default: Setting/Loading + +For detailed information and usage, type ``color-schemes`` in console. + +Loaded as a module, this script will export those methods : + * register(path, force) : Register colors schemes by path (file or directory), relative to DF main directory + * load(name) : Load a registered color scheme by name + * list() : Return a list of registered color schemes + * set_default(name) : Set the default color scheme + * load_default() : Load the default color scheme + +For more information about arguments and return values, see ``hack/scripts/color-schemes.lua``. + +Related scripts: + * `gui/color-schemes` is the in-game GUI for this script. + * `season-palette` swaps color schemes when the season changes. + +]====] + +--[[ +Copyright (c) 2020 Nicolas Ayala `https://github.com/nicolasayala` + +This software is provided 'as-is', without any express or implied +warranty. In no event will the authors be held liable for any damages +arising from the use of this software. + +Permission is granted to anyone to use this software for any purpose, +including commercial applications, and to alter it and redistribute it +freely, subject to the following restrictions: + +1. The origin of this software must not be misrepresented; you must not + claim that you wrote the original software. If you use this software + in a product, an acknowledgment in the product documentation would be + appreciated but is not required. +2. Altered source versions must be plainly marked as such, and must not be + misrepresented as being the original software. +3. This notice may not be removed or altered from any source distribution. +]] + +--@ module = true + +--+================+ +--| IMPORTS | +--+================+ + +local json = require "json" + +--+================+ +--| GLOBALS | +--+================+ + +-- `color-schemes` version +VERSION = "0.1.0" +-- Config file (default color-scheme) +CONFIG_FILE = "dfhack-config/color-scheme.json" +-- Table of registered color schemes (see Scheme class for template) +_registered_schemes = _registered_schemes or {} +-- The current loaded color scheme instance (loaded_scheme.loaded = true) +_loaded_scheme = _loaded_scheme or nil + +--+================+ +--| LOGGING | +--+================+ + +-- Log levels +local SUCCESS = 1 +local INFO = 2 +local WARN = 3 +local ERR = 4 +-- Log level threshold +local verbosity = 4 + +local log_colors = {COLOR_LIGHTGREEN, COLOR_LIGHTBLUE, COLOR_YELLOW, COLOR_LIGHTRED} +local function log(msg, level) + level = level or 1 + if verbosity - level >= 0 then + dfhack.color(log_colors[level]) + print("color-schemes : " .. msg) + dfhack.color() + end +end + +--+==================+ +--| FILESYSTEM | +--+==================+ + +--[[ + Define filesystem methods working with DF's root directory +]]-- +local ROOT = dfhack.getDFPath() .. "/" + +local function open(path, mode) + return io.open(ROOT .. path, mode) +end + +local function exists(path) + return dfhack.filesystem.exists(ROOT .. path) +end + +local function get_mtime(path) + return dfhack.filesystem.mtime(ROOT .. path) +end + +local function listdir(path) + local paths = dfhack.filesystem.listdir_recursive(ROOT .. path) + for _,v in ipairs(paths) do + if string.startswith(v.path, ROOT) then + v.path = string.sub(v.path, #ROOT + 1) + end + end + return paths +end + +local function isdir(path) + return dfhack.filesystem.isdir(ROOT .. path) +end + +local function read_file(path) + if not exists(path) then + return nil, path .. " does not exist" + end + local file = open(path, "rb") + if not file then + return nil, "Could not open " .. path + end + local data = file:read("*a") + file:close() + return data +end + +local function decode_file(path, ...) + return json.decode_file(ROOT .. path, ...) +end + +local function encode_file(data, path, ...) + json.encode_file(data, ROOT .. path, ...) +end + +local function sanitize_path(path) + path = string.gsub(path, "/+", "/") + path = string.gsub(path, "^/", "") + return path +end + +--+==========================+ +--| COLOR SCHEME PARSING | +--+==========================+ + +-- Returns the table flipped (keys and values flipped) +local function flip(table) + local T = {} + for k,v in pairs(table) do + T[v] = k + end + return T +end + +local COLORS = { + "BLACK", "BLUE", "GREEN", "CYAN", "RED", "MAGENTA", + "BROWN", "LGRAY", "DGRAY", "LBLUE", "LGREEN", + "LCYAN", "LRED", "LMAGENTA", "YELLOW", "WHITE" +} + +local CHANNELS = {"R", "G", "B"} + +--[[ + Parse colors' values from string with format `[_:]...` + in `COLORS`, in `CHANNELS`, in range [0,255] + Return + valid : Parsing was successful + values : + { + "" = { + "R" = , + "G" = , + "B" = + } + ... + } + ( = -1 when unspecified) +]] +local function parse_color_scheme(string) + local values = {} + local total = 0 + local colors = flip(COLORS) + local channels = flip(CHANNELS) + -- Initialize values table + for color,_ in pairs(colors) do + values[color] = {R = -1, G = -1, B = -1} + end + -- Parse color values + for color, channel, value in string.gmatch(string, "%[([A-Z]+)_([RGB]):([0-9]+)%]") do + value = tonumber(value) + if colors[color] and channels[channel] and value ~= nil and values[color][channel] == -1 then + values[color][channel] = math.max(0, math.min(255, value)) -- Clamp value in range [0,255] + total = total + 1 -- Keep count of parsed colors channels + end + end + local valid = (total == 48) -- #COLORS * #CHANNELS = 16 * 3 = 48 + return valid, values +end + +--+==================+ +--| SCHEME CLASS | +--+==================+ + +--[[ + Color scheme class, used to store colors' values, file path, modification time, loaded state + template : + { + name : + file : Relative to DF main directory + mtime : File modification time, used for reloading + valid : Specifies if that color scheme is valid (all values are defined) + values: Table of colors' values (see `parse_color_scheme` for template) + } +]] +Scheme = defclass(Scheme) +Scheme.ATTRS { + name = "", + file = "", + mtime = 0, + valid = false, + values = DEFAULT_NIL +} + +-- Create a color scheme from file +function Scheme.new(file) + file = sanitize_path(file) + -- Parse name from file path, //. (Replace `_` and `-` with spaces) + local name = string.gsub(string.match(file, "([^/]+)%.[^%.]*$"), "[_-]", " ") + -- Read file, get modification time and parse color values + local data, err = read_file(file) + if not data then qerror(err) end + local mtime = get_mtime(file) + local valid, values = parse_color_scheme(data) + return Scheme { + name = name, + file = file, + mtime = mtime, + valid = valid, + values = values + } +end + +-- Load a color scheme +function Scheme:load() + for c,color in ipairs(COLORS) do + for l,channel in ipairs(CHANNELS) do + -- Use `math.max` for -1 values (not parsed correctly) + local value = math.max(0, self.values[color][channel]) / 255 + df.global.enabler.ccolor[c - 1][l - 1] = value + end + end + df.global.gps.force_full_display_count = 1 +end + +--+===================+ +--| SCRIPT CONFIG | +--+===================+ + +local config = nil + +local function get_config() + if not exists(CONFIG_FILE) then + return {} + end + local success, config = pcall(decode_file, CONFIG_FILE) + if not success then qerror(string.format("Unable to parse config file `%s`", CONFIG_FILE)) end + return config +end + +local function config_set(params) + for param, value in pairs(params) do + config[param] = value + end + encode_file(config, CONFIG_FILE) +end + +config = get_config() + +--+========================+ +--| LOCAL CORE METHODS | +--+========================+ + +--[[ + Register color scheme from file (relative to DF main directory) + `force` argument forces registering invalid or incomplete color schemes + Return color scheme instance if success, nil otherwise +]] +local function register_file(file, force) + local new = Scheme.new(file) + local mtime = get_mtime(file) + local status + local log_level = INFO + local suffix = "" + local save = true + -- Check if that color scheme was already registered + local old = _registered_schemes[new.name] + if not old then + -- That color scheme isn't already registered + status = "Registered" + elseif new.file ~= old.file then + -- Duplicate color scheme name + status = "Couldn't register" + suffix = "duplicate name" + log_level = ERR + save = false + elseif old.mtime ~= new.mtime then + -- That color scheme was updated + status = "Updated" + else + -- Color scheme already up to date + status = "Up to date" + save = false + end + + if save then + -- Check color scheme validity + if new.valid then + _registered_schemes[new.name] = new + log_level = INFO + else + suffix = "invalid" + if force then + _registered_schemes[new.name] = new + log_level = WARN + else + status = "Couldn't register" + log_level = ERR + end + end + end + local action = string.format("%s `%s`", status, new.name) + local file = string.format("[%s]", new.file) + log(string.format("%-30s %-40s %s", action, file, suffix), log_level) + return save and new or nil; +end + +-- Register color schemes in directory via `register_file` +local function register_dir(dir, force) + for _,value in ipairs(listdir(dir)) do + if not value.isdir then + if string.match(value.path, ".txt$") then + register_file(value.path, force) + end + end + end +end + +--+======================+ +--| EXPORTED METHODS | +--+======================+ + +--[[ + Register color schemes by path (file or directory) + Registering color schemes by directory is recursive + `force` argument forces registering invalid or incomplete color schemes +]] +function register(path, force) + if not exists(path) then + log("Couldn't register file or directory `" .. path .. "` (does not exist)", ERR) + else + if (isdir(path)) then + register_dir(path, force) + else + register_file(path, force) + end + end +end + +--[[ + Load a registered color scheme by name + The color scheme must be first registered with the `register` method + Return the color scheme instance +]] +function load(name) + local scheme = _registered_schemes[name] + if not scheme then + log("Unregistered color scheme `" .. name .. "`", ERR) + return nil + end + if scheme ~= _loaded_scheme then + _loaded_scheme = scheme + scheme:load() + log("Loaded `" .. scheme.name .. "`") + else + log("Color scheme `" .. scheme.name .. "` already loaded !", WARN) + end + return scheme +end + +--[[ + Return the table of registered color schemes + template : + { + { + name : + file : + valid : + loaded : + } + ... + } +]] +function list() + local schemes = {} + for name,scheme in pairs(_registered_schemes) do + table.insert(schemes, { + name = name, + file = scheme.file, + valid = scheme.valid, + loaded = (scheme == _loaded_scheme), + default = (scheme.name == config.default) + }) + end + return schemes +end + +--[[ + Set the default color scheme name, stored in `CONFIG_FILE`, persistent between restarts. +]] +function set_default(name) + if name then + log(string.format("Default set to `%s`", name), SUCCESS) + config_set({default = name}) + end +end + +--[[ + Load the default color scheme, however it must be registered first like any other color scheme. + (see `dfhack*.init` files to register color schemes on start) +]] +function load_default() + local default = config.default + if not default then + log("No default color scheme", ERR) + return nil + end + return load(default) +end + +if moduleMode then return _ENV end + +--+==================+ +--| ARGS PARSING | +--+==================+ + +--[[ + Print a formated table with colors + <-----spacing_0----> <-----spacing_1-----> ... + | |... + ====================|=====================|... + .column_0 |.column_1 |... + .column_0 |.column_1 |... + + Args + T :
+ on_print : Function called before entry is printed + ... : Format ":" (column name and spacing) +]] +local function printSchemes(T, on_print, ...) + local format = {...} + local columns = {} + local spacings = {} + local entry_format = "" + local sep = "|" + local bar = "" + -- Parse columns' names and build entry format + for i,c in ipairs(format) do + if i == #format then sep = "" end + local name, spacing = string.match(c, "(%a+):(%d+)") + entry_format = entry_format .. "%-" .. spacing .. "s" .. sep + bar = bar .. string.rep("=", tonumber(spacing)) .. sep + table.insert(columns, name) + table.insert(spacings, tonumber(spacing)) + end + print(string.format(entry_format, table.unpack(columns))) + print(bar) + -- Build entry with specified columns + for name,item in pairs(T) do + local entry = {} + for i,c in ipairs(columns) do + -- Clamp string fields to match spacing + if type(item[c]) == "string" and #item[c] > spacings[i] then + table.insert(entry, string.sub(item[c], 0, spacings[i]-3) .. "...") + else + table.insert(entry, item[c]) + end + end + if on_print then on_print(item) end + print(string.format(entry_format, table.unpack(entry))) + dfhack.color() + end +end + +local function map(f, T, i, j) + local M = {} + for k = (i or 1),(j or #T) do + M[k] = f(k, T[k]) + end + return M +end + +local function extract(T, i, j) + return {table.unpack(T, i, j)} +end + +local function find(v, T) + for key,value in ipairs(T) do + if v == value then return key, value end + end + return nil +end + +local function concat(T, f, sep, i, j) + return table.concat(map(function(k, v) return f(v) end, T, i, j), sep) +end + +local function get(self, key) + if type(self[key]) == "function" then + return self[key](self) + else + return self[key] + end +end + +local class = function(class, parent, attrs) + local class = defclass(class, parent) + local ATTRS = {} + for attr,default in pairs(attrs or {}) do + local internal = string.format("_%s", attr) + ATTRS[internal] = default + class[attr] = function(self, v) self[internal] = v return self end + end + class.ATTRS(ATTRS) + return class +end + +--+=====================+ +--| PARSING CLASSES | +--+=====================+ + +--[[ PItem ]]-- + +PItem = class(PItem, nil, + { + name = DEFAULT_NIL, -- string + description = "", -- string + action = DEFAULT_NIL, -- boolean func(self, result, target, args) + } +) + +local actions = { + store = function(self, result, target, args) result[target] = args end, + store_true = function(self, result, target, args) result[target] = true end, + store_false = function(self, result, target, args) result[target] = false end, + count = function(self, result, target, args) result[target] = 1 or result[target] + 1 end +} + +function PItem:init(args) + local class = getmetatable(self) + class.__tostring = class.__tostring or class.super.__tostring +end + +function PItem:error(format, ...) + return qerror(string.format(format, ...)) +end + +function PItem:__tostring() + return string.format("%-20s %s", self._name, self._description) +end + +--[[ Argument ]]-- + +Argument = class(Argument, PItem, + { + action = actions.store, -- boolean func(self, result, target, args) + args = 1, -- number + choices = DEFAULT_NIL, -- table + convert = DEFAULT_NIL, -- function(arg) + } +) + +function Argument:usage() + return string.rep(string.format("<%s>", self._name), self._args, " ") +end + +function Argument:consume(args, result) + local A = {} + local consumed = 0 + for i = 1,self._args do + local arg = args[i] + if not arg then + self:error("argument `%s` requires %d argument(s)", self._name, self._args) + end + if self._choices then + if not find(arg, self._choices) then + local C = concat(self._choices, curry(string.format, "`%s`"), ", ") + self:error("argument `%s` must be one of %s", self._name, C) + end + end + consumed = consumed + 1 + if self._convert then + local res = self._convert(arg) + if not res then self:error("argument `%s` could not be converted", arg) end + table.insert(A, res) + else + table.insert(A, arg) + end + end + local sub_args = extract(args, consumed + 1) + if self:_action(result, self._target, (consumed == 1) and A[1] or A) then + return sub_args, true + end + return sub_args +end + +function Argument:name(name) + self._name = name + self._target = name + return self +end + +--[[ Option ]]-- + +Option = class(Option, Argument) + +function Option:name(name) + self._aliases = self._aliases or {} + for _,alias in ipairs(string.split(name, " ")) do + local short = alias:match("^-%a$") + local long = alias:match("^%-%-%a+$") + if short then self._short = short end + self._target = string.gsub(short or long, "%-", "") + table.insert(self._aliases, short or long) + end + self._name = name + return self +end + +function Option:usage() + if self._args == 0 then + return string.format("[%s]", self._short) + else + return string.format("[%s %s]", self._short, Argument.usage(self)) + end +end + +function Option:__tostring() + if self._args == 0 then + return string.format("%-20s %s", table.concat(self._aliases, ", "), self._description) + else + local arg = string.format("<%s>", self._target) + local args = string.rep(arg, self._args, " ") + local lines = map(function(k,v) return string.format("%s %s", v, args) end, self._names) + local width = 0 + for _,l in ipairs(lines) do width = math.max(width, #l) end + local help = concat(lines, curry(string.format, "%" .. width .. "s"), ",\n ") + return string.format("%s\n %20s %s", help, "", self._description) + end +end + +--[[ Parser ]]-- + +Parser = class(Parser, PItem, + { + action = actions.store, -- boolean func(self, result, target, args) + summary = DEFAULT_NIL, -- string + notes = DEFAULT_NIL, -- string + req_command = true, -- boolean + } +) + +Parser.help_items = { + {key = "_name", format = "%s\n"}, + {key = "_description", format = "\n%s\n"}, + {key = "usage", format = "\nUsage: %s\n"}, + {key = "_arguments", format = "\nArguments:\n%s\n"}, + {key = "_options", format = "\nOptions:\n%s\n"}, + {key = "_commands", format = "\nCommands:\n%s\n"}, + {key = "_notes", format = "\nNotes:\n%s\n"}, +} + +Parser.usage_items = { + {key = "_name", usage = function(value) return value end}, + {key = "_options", usage = function(value) return concat(value, Option.usage, " ") end}, + {key = "_arguments", usage = function(value) return concat(value, Argument.usage, " ") end}, + {key = "_commands", usage = function(value) return "..." end}, +} + +function Parser:command() + local command = Parser() + self._commands = self._commands or {} + table.insert(self._commands, command) + return command +end + +function Parser:argument() + local argument = Argument() + self._arguments = self._arguments or {} + table.insert(self._arguments, argument) + return argument +end + +function Parser:option() + local option = Option() + self._options = self._options or {} + table.insert(self._options, option) + return option +end + +function Parser:flag() + return self + :option() + :args(0) + :action(actions.store_true) +end + +function Parser:add_help() + self:flag() + :name("-h --help") + :description("Show this help message and exit") + :action(function() print(self:help()) return true end) + return self +end + +function Parser:__tostring() + return string.format("%-20s %s", self._name, self._summary or self._description) +end + +function Parser:usage() + local parts = {} + for _,item in ipairs(Parser.usage_items) do + local value = self[item.key] + if value then + table.insert(parts, item.usage(value)) + end + end + return table.concat(parts, " ") +end + +function Parser:help() + local help_format = "" + local help_args = {} + for _,item in ipairs(Parser.help_items) do + local value = get(self, item.key) + if value then + help_format = help_format .. item.format + local value_str + if type(value) == "table" then + value_str = " " .. concat(value, tostring, "\n ") + elseif type(value) == "string" then + value_str = value + end + table.insert(help_args, value_str) + end + end + return help_format:format(table.unpack(help_args)) +end + +function Parser:parse(args, result) + local result = result or {} + local arg = args[1] + if not arg then + if self._commands and self._req_command then + self:error("a command is required") + elseif self._arguments then + for _,argument in ipairs(self._arguments) do + if not result[argument._name] then + self:error("missing argument `%s`", argument._name) + end + end + end + self:_action(result, self._name) + return result + end + + -- Options + if arg:sub(1, 1) == "-" and self._options then + local o = arg + for _,option in ipairs(self._options) do + if find(o, option._aliases) then + local sub_args, exit = option:consume(extract(args, 2), result) + if exit then return result end + return self:parse(sub_args, result) + end + end + self:error("unknown option `%s`", o) + end + + -- Arguments + if self._arguments then + for _,argument in ipairs(self._arguments) do + if not result[argument._name] then + local sub_args, exit = argument:consume(args, result) + if exit then return result end + return self:parse(sub_args, result) + end + end + end + + -- Commands + local c = arg + if self._commands then + for _,command in ipairs(self._commands) do + if c == command._name then + result.command = command:parse(extract(args, 2)) + return result + end + end + self:error("unknown command `%s`", c) + end + self:error("too many arguments") +end + +--[[ Parser Config ]]-- + +local parser = Parser() +parser + :name("color-schemes"):description("Manage color schemes") + :add_help() + :notes(" Type `--help` after a command to get further information") +parser:flag() + :name("-V --version"):description("Show version number and exit") + :action(function() print(VERSION) return true end) +parser:flag() + :name("-q --quiet"):description("Do not write anything to output") + :action(function() verbosity = 0 end) + +local load_cmd = parser:command() +load_cmd + :name("load"):description("Load a registered color scheme") + :add_help() + :action(function(self, result) load(result.name) end) +load_cmd:argument() + :name("name") + :description("Color scheme name") + +local list_cmd = parser:command() +list_cmd + :name("list"):description("List registered color schemes") + :add_help() + :action( + function() + printSchemes( + list(), + function(item) + if not item.valid then return dfhack.color(COLOR_LIGHTRED) + elseif item.loaded then return dfhack.color(COLOR_LIGHTGREEN) end + end, + "name:30", "file:50" + ) + end + ) + +local register_cmd = parser:command() +register_cmd + :name("register"):description("Register color schemes") + :add_help() + :action(function(self, result) register(result.path, result.force) end) +register_cmd:flag() + :name("-f --force"):description("Also register syntaxically incorrect or partial color schemes") +register_cmd:argument() + :name("path"):description("Path to color scheme file or directory") + +local default_cmd = parser:command() +default_cmd + :name("default"):description("Load or set the default color scheme") + :add_help() +default_cmd:command() + :name("load"):description("Load the default color scheme") + :add_help() + :action(curry(load_default)) +default_cmd:command() + :name("set"):description("Set the default color scheme") + :add_help() + :action(function(self, result) set_default(result.name) end) + :argument() + :name("name"):description("Color scheme name") + +local args = {...} +if #args == 0 then print(parser:help()) return _ENV end +local result = parser:parse(args) +-- result table isn't used here since logic is inside parsing actions + +return _ENV diff --git a/combat-harden.lua b/combat-harden.lua new file mode 100644 index 0000000000..8010ae14b8 --- /dev/null +++ b/combat-harden.lua @@ -0,0 +1,94 @@ +--@ module = true + +local utils = require('utils') + +local validArgs = utils.invert({ + 'help', + 'all', + 'citizens', + 'tier', + 'unit', + 'value', +}) + +local tiers = {0, 33, 67, 100} + +function setUnitCombatHardened(unit, value) + if not unit.status.current_soul then return end + + -- Ensure value is in the bounds of 0-100 + value = math.max(0, math.min(100, value)) + unit.status.current_soul.personality.combat_hardened = value + + print(('set hardness value for %s to %d'):format( + dfhack.df2console(dfhack.units.getReadableName(unit)), + value)) +end + +function main(args) + local opts = utils.processArgs(args, validArgs) + + if opts.help then + print(dfhack.script_help()) + return + end + + local value + if not opts.tier and not opts.value then + -- Default to 100 + value = 100 + elseif opts.tier then + -- Bound between 1-4 + local tierNum = math.max(1, math.min(4, tonumber(opts.tier))) + value = tiers[tierNum] + elseif opts.value then + -- Function ensures value is bound, so no need to bother here + -- Will check it's a number, though + value = tonumber(opts.value) or 100 + end + + local unitsList = {} --as:df.unit[] + + if not opts.all and not opts.citizens then + -- Assume trying to target a unit + local unit + if opts.unit then + if tonumber(opts.unit) then + unit = df.unit.find(opts.unit) + end + end + + -- If unit ID wasn't provided / unit couldn't be found, + -- Try getting selected unit + if not unit then + unit = dfhack.gui.getSelectedUnit(true) + end + + if not unit then + qerror("Couldn't find unit. If you don't want to target a specific unit, use --all or --citizens.") + else + table.insert(unitsList, unit) + end + elseif opts.all then + for _, unit in pairs(df.global.world.units.active) do + table.insert(unitsList, unit) + end + elseif opts.citizens then + -- Abort if not in Fort mode + if not dfhack.world.isFortressMode() then + qerror('--citizens requires fortress mode') + end + + for _, unit in ipairs(dfhack.units.getCitizens()) do + table.insert(unitsList, unit) + end + end + + for _, unit in ipairs(unitsList) do + setUnitCombatHardened(unit, value) + end +end + +if not dfhack_flags.module then + main{...} +end diff --git a/combine-drinks.lua b/combine-drinks.lua deleted file mode 100644 index d7665655f0..0000000000 --- a/combine-drinks.lua +++ /dev/null @@ -1,118 +0,0 @@ --- Merge drink stacks in the selected stockpile ---[====[ - -combine-drinks -============== -Merge stacks of drinks in the selected stockpile. - -]====] -local utils = require 'utils' - -local validArgs = utils.invert({ 'max', 'stockpile' }) -local args = utils.processArgs({...}, validArgs) - -local max = 30; -if args.max then max = tonumber(args.max) end - -local stockpile = nil -if args.stockpile then stockpile = df.building.find(tonumber(args.stockpile)) end - -local function itemsCompatible(item0, item1) - return item0:getType() == item1:getType() - and item0.mat_type == item1.mat_type - and item0.mat_index == item1.mat_index -end - -local function getDrinks(items, drinks, index) - for i,d in ipairs(items) do - local foundDrink = nil - - -- Skip items currently tasked - if #d.specific_refs == 0 then - - if d:getType() == df.item_type.DRINK then - foundDrink = d - else - --print(d.id) - local containedItems = dfhack.items.getContainedItems(d) - -- Drink containers only contain one item - if #containedItems == 1 then - local possibleDrink = containedItems[1] - - if #possibleDrink.specific_refs == 0 and possibleDrink:getType() == df.item_type.DRINK then - foundDrink = possibleDrink - end - end - end - end - - if foundDrink ~= nil then - drinks[index] = foundDrink - index = index + 1 - end - end - - return index -end - -local building = stockpile or dfhack.gui.getSelectedBuilding(true) -if building ~= nil and building:getType() ~= 29 then building = nil end - -if building == nil then - error("Select a stockpile") - -else - local rootItems = dfhack.buildings.getStockpileContents(building); - - if #rootItems == 0 then - error("Select a non-empty stockpile") - - else - local drinks = { } --as:df.item_drinkst[] - local drinkCount = getDrinks(rootItems, drinks, 0) - - --for i,p in ipairs(drinks) do - -- print(i .. ': ' .. dfhack.items.getDescription(p, p:getType())) - --end - - local removedDrinks = {} --as:bool[] - - for i=0,(drinkCount-2) do - local currentDrink = drinks[i] - local itemsNeeded = max - currentDrink.stack_size - --print('processing ' .. dfhack.items.getDescription(currentDrink, currentDrink:getType()) .. ' needs ' .. itemsNeeded) - if removedDrinks[currentDrink.id] == nil and itemsNeeded > 0 then - for j=(i+1),(drinkCount-1) do - local sourceDrink = drinks[j] - --print('\ttrying ' .. dfhack.items.getDescription(sourceDrink, sourceDrink:getType())) - - if removedDrinks[sourceDrink.id] == nil and itemsCompatible(currentDrink, sourceDrink) then - local amountToMove = math.min(itemsNeeded, sourceDrink.stack_size) - --print('\tmoving ' .. amountToMove) - itemsNeeded = itemsNeeded - amountToMove - currentDrink.stack_size = currentDrink.stack_size + amountToMove - - if sourceDrink.stack_size == amountToMove then - --print('\tadding remove id ' .. sourceDrink.id) - removedDrinks[sourceDrink.id] = true - else - sourceDrink.stack_size = sourceDrink.stack_size - amountToMove - end - end - end - end - end - - for id,removed in pairs(removedDrinks) do - if removed then - local removedDrink = df.item.find(id) - --print('remove id=' .. id .. ' drink=' .. dfhack.items.getDescription(removedDrink, removedDrink:getType())) - dfhack.items.remove(removedDrink) - end - end - end ---elseif item:getType() == 68 then - --handleDrink(item) ---else --- error("Select a drink or a drink.") -end diff --git a/combine-plants.lua b/combine-plants.lua deleted file mode 100644 index 2483044c1d..0000000000 --- a/combine-plants.lua +++ /dev/null @@ -1,118 +0,0 @@ --- Merge plant stacks in the selected container or stockpile ---[====[ - -combine-plants -============== -Merge stacks of plants or plant growths in the selected container or stockpile. - -]====] -local utils = require 'utils' - -local validArgs = utils.invert({ 'max', 'stockpile', 'container' }) -local args = utils.processArgs({...}, validArgs) - -local max = 12 -if args.max then max = tonumber(args.max) end - -local stockpile = nil -if args.stockpile then stockpile = df.building.find(tonumber(args.stockpile)) end - -local container = nil -if args.container then container = df.item.find(tonumber(args.container)) end - -function itemsCompatible(item0, item1) - return item0:getType() == item1:getType() - and item0.mat_type == item1.mat_type --hint:df.item_plantst - and item0.mat_index == item1.mat_index --hint:df.item_plantst -end - -function getPlants(items, plants, index) - repeat - local nextBatch = {} - for _,v in pairs(items) do - -- Skip items currently tasked - if #v.specific_refs == 0 then - if v:getType() == df.item_type.PLANT or v:getType() == df.item_type.PLANT_GROWTH or v:getType() == df.item_type.CHEESE then - plants[index] = v - index = index + 1 - - else - local containedItems = dfhack.items.getContainedItems(v) - if #containedItems > 0 then - for _,w in pairs(containedItems) do - table.insert(nextBatch, w) - end - end - end - end - end - items = nextBatch - until #items == 0 - - return index -end - -local item = container or dfhack.gui.getSelectedItem(true) -local building = stockpile or dfhack.gui.getSelectedBuilding(true) -if building ~= nil and building:getType() ~= 29 then building = nil end -if item == nil and building == nil then - error("Select an item or building") - -else - local rootItems; - if building then - rootItems = dfhack.buildings.getStockpileContents(building) - else - rootItems = dfhack.items.getContainedItems(item) - end - - if #rootItems == 0 then - error("Select a non-empty container") - - else - local plants = { } --as:df.item_actual[] - local plantCount = getPlants(rootItems, plants, 0) - print("found " .. plantCount .. " plants") - - local removedPlants = { } --as:bool[] - - for i=0,(plantCount-2) do - local currentPlant = plants[i] --as:df.item_plantst - local itemsNeeded = max - currentPlant.stack_size - - if removedPlants[currentPlant.id] == nil and itemsNeeded > 0 then - local j = i+1 - local last = plantCount - repeat - local sourcePlant = plants[j] - - if removedPlants[sourcePlant.id] == nil and itemsCompatible(currentPlant, sourcePlant) then - local amountToMove = math.min(itemsNeeded, sourcePlant.stack_size) - itemsNeeded = itemsNeeded - amountToMove - currentPlant.stack_size = currentPlant.stack_size + amountToMove - - if sourcePlant.stack_size == amountToMove then - removedPlants[sourcePlant.id] = true - sourcePlant.stack_size = 1 - else - sourcePlant.stack_size = sourcePlant.stack_size - amountToMove - end --- else print("failed") - end - - j = j + 1 - until j == plantCount or itemsNeeded == 0 - end - end - - local removedCount = 0 - for id,removed in pairs(removedPlants) do - if removed then - removedCount = removedCount + 1 - local removedPlant = df.item.find(id) - dfhack.items.remove(removedPlant) - end - end - print("removed " .. removedCount .. " plants") - end -end diff --git a/combine.lua b/combine.lua new file mode 100644 index 0000000000..cd9e2522f0 --- /dev/null +++ b/combine.lua @@ -0,0 +1,867 @@ +local argparse = require('argparse') +local utils = require('utils') + +local opts, args = { + help = false, + all = nil, + here = nil, + dry_run = false, + types = nil, + quiet = false, + verbose = 0, + }, {...} + +-- TODO: +-- - Combine non-plantable seeds (seed combining currently commented out since we don't want to combine plantable seeds) +-- - Combine items inside built containers. +-- - Combine cloth, quality of cloth. +-- - Combine partial bars in smelters. +-- - Combine thread, quality of thread. +-- - Quality for food, currently ignoring. +-- - Override stack size; armok option. +-- - Override container limits; quantum containers armok option. + +-- list of types that use race and caste +local typesThatUseCreatures = utils.invert{'REMAINS', 'FISH', 'FISH_RAW', 'VERMIN', 'PET', 'EGG', 'CORPSE', 'CORPSEPIECE'} +local typesThatUseMaterial = utils.invert{'CORPSEPIECE'} + +-- list of valid item types for merging +-- Notes: 1. mergeable stacks are ones with the same type_id+race+caste or type_id+mat_type+mat_index +-- 2. even though powders are specified, sand and plaster types items are excluded from merging. +-- 3. seeds cannot be combined in stacks > 1. +local valid_types_map = { + all = { }, + ammo = {[df.item_type.AMMO] ={type_id=df.item_type.AMMO, max_stack_qty=25, max_mat_amt=1}}, + parts = {[df.item_type.CORPSEPIECE] ={type_id=df.item_type.CORPSEPIECE, max_stack_qty=1, max_mat_amt=30}}, + drink = {[df.item_type.DRINK] ={type_id=df.item_type.DRINK, max_stack_qty=25, max_mat_amt=1}}, + fat = {[df.item_type.GLOB] ={type_id=df.item_type.GLOB, max_stack_qty=5, max_mat_amt=1}, + [df.item_type.CHEESE] ={type_id=df.item_type.CHEESE, max_stack_qty=5, max_mat_amt=1}}, + fish = {[df.item_type.FISH] ={type_id=df.item_type.FISH, max_stack_qty=5, max_mat_amt=1}, + [df.item_type.FISH_RAW] ={type_id=df.item_type.FISH_RAW, max_stack_qty=5, max_mat_amt=1}, + [df.item_type.EGG] ={type_id=df.item_type.EGG, max_stack_qty=5, max_mat_amt=1}}, + food = {[df.item_type.FOOD] ={type_id=df.item_type.FOOD, max_stack_qty=20, max_mat_amt=1}}, + meat = {[df.item_type.MEAT] ={type_id=df.item_type.MEAT, max_stack_qty=5, max_mat_amt=1}}, + plant = {[df.item_type.PLANT] ={type_id=df.item_type.PLANT, max_stack_qty=5, max_mat_amt=1}, + [df.item_type.PLANT_GROWTH]={type_id=df.item_type.PLANT_GROWTH, max_stack_qty=5, max_mat_amt=1}}, + powder= {[df.item_type.POWDER_MISC] ={type_id=df.item_type.POWDER_MISC, max_stack_qty=10, max_mat_amt=1}}, +-- seed = {[df.item_type.SEEDS] ={type_id=df.item_type.SEEDS, max_stack_qty=1, max_mat_amt=1}}, +} + +-- populate all types entry +for k1,v1 in pairs(valid_types_map) do + if k1 == 'all' then goto continue end + for k2,v2 in pairs(v1) do + local elem = ensure_key(valid_types_map.all, k2) + for k3,v3 in pairs(v2) do + elem[k3] = v3 + end + end + ::continue:: +end + +local function log(level, ...) + -- if verbose is specified, then print the arguments, or don't. + if not opts.quiet and opts.verbose >= level then + print(dfhack.df2console(string.format(...))) + end +end + +-- CList class +-- generic list class used for key value pairs. +local CList = { } + +function CList:new(o) + -- key, value pair table structure. __len allows # to be used for table count. + o = o or { } + setmetatable(o, self) + self.__index = self + self.__len = function(t) local n = 0 for _ in pairs(t) do n = n + 1 end return n end + return o +end + +local function comp_item_new(comp_key, stack_type) + -- create a new comp_item entry to be added to a comp_items table. + local comp_item = {} + if not comp_key then qerror('new_comp_item: comp_key is nil') end + comp_item.comp_key = comp_key -- key used to index comparable items for merging + comp_item.description = '' -- description of the comp item for output + comp_item.max_stack_qty = stack_type.max_stack_qty -- how many of a comp item can be in one stack + -- item info + comp_item.items = CList:new() -- key:item.id, + -- val:{item, + -- before_size, after_size, before_cont_id, after_cont_id, + -- stockpile_id, stockpile_name, + -- before_mat_amt {Leather, Bone, Shell, Tooth, Horn, HairWool, Yarn} + -- after_mat_amt {Leather, Bone, Shell, Tooth, Horn, HairWool, Yarn} + -- } + comp_item.item_qty = 0 -- total quantity of items + comp_item.material_amt = 0 -- total amount of materials + comp_item.max_mat_amt = stack_type.max_mat_amt -- max amount of materials in one stack + + comp_item.before_stacks = 0 -- the number of stacks of the items before... + comp_item.after_stacks = 0 -- ...and after the merge + --container info + comp_item.before_cont_ids = CList:new() -- key:container.id, val:container.id + comp_item.after_cont_ids = CList:new() -- key:container.id, val:container.id + return comp_item +end + +local function comp_item_add_item(stockpile, stack_type, comp_item, item, container) + -- add an item into the comp_items table, setting the comp_item attributes. + if not comp_item.items[item.id] then + comp_item.item_qty = comp_item.item_qty + item.stack_size + comp_item.before_stacks = comp_item.before_stacks + 1 + comp_item.description = utils.getItemDescription(item, 1) + + if item.stack_size > comp_item.max_stack_qty then + comp_item.max_stack_qty = item.stack_size + end + + local new_item = {} + new_item.item = item + new_item.before_size = item.stack_size + + new_item.stockpile_id = stockpile.id + new_item.stockpile_name = stockpile.name + + -- material amount info + new_item.before_mat_amt = {} + new_item.before_mat_amt.Qty = 0 + new_item.after_mat_amt = {} + new_item.after_mat_amt.Qty = 0 + + -- material amount used? + if typesThatUseMaterial[df.item_type[stack_type.type_id]] then + new_item.before_mat_amt.Leather = item.material_amount.Leather + new_item.before_mat_amt.Bone = item.material_amount.Bone + new_item.before_mat_amt.Shell = item.material_amount.Shell + new_item.before_mat_amt.Tooth = item.material_amount.Tooth + new_item.before_mat_amt.Horn = item.material_amount.Horn + new_item.before_mat_amt.HairWool = item.material_amount.HairWool + new_item.before_mat_amt.Yarn = item.material_amount.Yarn + for _, v in pairs(new_item.before_mat_amt) do if new_item.before_mat_amt.Qty < v then new_item.before_mat_amt.Qty = v end end + + comp_item.material_amt = comp_item.material_amt + new_item.before_mat_amt.Qty + if new_item.before_mat_amt.Qty > comp_item.max_mat_amt then comp_item.max_mat_amt = new_item.before_mat_amt.Qty end + end + + -- item is in a container + if container then + new_item.before_cont_id = container.id + comp_item.before_cont_ids[container.id] = container.id + end + + comp_item.items[item.id] = new_item + return comp_item.items[item.id] + else + -- this case should not happen, unless an item id is duplicated. + -- in which case, only allow one instance for the merge. + return nil + end +end + +local function stack_type_new(type_vals) + -- create a new stack type entry to be added to the stacks table. + local stack_type = {} + + -- attributes from the type val table + for k,v in pairs(type_vals) do + stack_type[k] = v + end + + -- item info + stack_type.comp_items = CList:new() -- key:comp_key, val:comp_item + stack_type.item_qty = 0 -- total quantity of items types + stack_type.material_amt = 0 -- total amount of materials + stack_type.before_stacks = 0 -- the number of stacks of the item types before ... + stack_type.after_stacks = 0 -- ...and after the merge + + --container info + stack_type.before_cont_ids = CList:new() -- key:container.id, val:container.id + stack_type.after_cont_ids = CList:new() -- key:container.id, val:container.id + return stack_type +end + +local function isDye(item) + -- Dyes should not be combined as this will cause bugs when mixing them together + if item:getType() ~= df.item_type.POWDER_MISC then return false end + -- pcall guards items/materials that can't be decoded or lack the flag + local ok, is_dye = pcall(function() + local mat = dfhack.matinfo.decode(item.mat_type, item.mat_index) + return mat and mat.material.flags.IS_DYE or false + end) + return ok and is_dye or false +end + +local function stacks_add_item(stockpile, stacks, stack_type, item, container) + -- add an item to the matching comp_items table; based on comp_key. + local comp_key = '' + + if typesThatUseCreatures[df.item_type[stack_type.type_id]] then + if not typesThatUseMaterial[df.item_type[stack_type.type_id]] then + comp_key = ('%s+%s+%s'):format(stack_type.type_id, item.race, item.caste) + else + comp_key = ('%s+%s+%s+%s+%s'):format(stack_type.type_id, item.race, item.caste, item:getActualMaterial(), item:getActualMaterialIndex()) + end + elseif item:isCrafted() then + if item:getQuality() == df.item_quality.Masterful then + comp_key = ('%s+%s+%s+%s+%s'):format(stack_type.type_id, item.mat_type, item.mat_index, item:getQuality(), item:getMaker()) + else + comp_key = ('%s+%s+%s+%s'):format(stack_type.type_id, item.mat_type, item.mat_index, item:getQuality()) + end + else + comp_key = ('%s+%s+%s'):format(stack_type.type_id, item.mat_type, item.mat_index) + end + + if not stack_type.comp_items[comp_key] then + stack_type.comp_items[comp_key] = comp_item_new(comp_key, stack_type) + end + + local new_comp_item_item = comp_item_add_item(stockpile, stack_type, stack_type.comp_items[comp_key], item, container) + if new_comp_item_item then + stack_type.before_stacks = stack_type.before_stacks + 1 + stack_type.item_qty = stack_type.item_qty + item.stack_size + stack_type.material_amt = stack_type.material_amt + new_comp_item_item.before_mat_amt.Qty + + stacks.before_stacks = stacks.before_stacks + 1 + stacks.item_qty = stacks.item_qty + item.stack_size + stacks.material_amt = stacks.material_amt + new_comp_item_item.before_mat_amt.Qty + + if item.stack_size > stack_type.max_stack_qty then + stack_type.max_stack_qty = item.stack_size + end + + -- item is in a container + if container then + + -- add it to the stack type list + stack_type.before_cont_ids[container.id] = container.id + + -- add it to the before stacks container list + stacks.before_cont_ids[container.id] = container.id + end + end +end + +local function sorted_items_qty(tab) + -- used to sort the comp_items by contained, then size. Important for combining containers. + local sorted = {} + for id, val in pairs(tab) do + table.insert(sorted, { + id=id, + before_cont_id=val.before_cont_id, + before_size=val.before_size, + }) + end + + table.sort(sorted, + function(a, b) + if not a.before_cont_id and not b.before_cont_id or a.before_cont_id and b.before_cont_id then + return a.before_size > b.before_size + else + return a.before_cont_id and not b.before_cont_id + end + end + ) + + local i = 0 + local iter = + function() + i = i + 1 + if sorted[i] == nil then + return nil + else + return sorted[i].id, tab[sorted[i].id] + end + end + return iter +end + +local function sorted_items_mat(tab) + -- used to sort the comp_items by mat amt. + local sorted = {} + for id, val in pairs(tab) do + table.insert(sorted, { + id=id, + before_qty=val.before_mat_amt.Qty, + }) + end + + table.sort(sorted, + function(a, b) + return a.before_qty > b.before_qty + end + ) + + local i = 0 + local iter = + function() + i = i + 1 + if sorted[i] == nil then + return nil + else + return sorted[i].id, tab[sorted[i].id] + end + end + return iter +end + +local function sorted_desc(tab, ids) + -- used to sort the lists by description + local sorted = {} + for id, val in pairs(tab) do + if ids[id] then + table.insert(sorted, { + id=id, + description=val.description, + }) + end + end + + table.sort(sorted, function(a, b) return a.description < b.description end) + + local i = 0 + local iter = + function() + i = i + 1 + if sorted[i] == nil then + return nil + else + return sorted[i].id, tab[sorted[i].id] + end + end + return iter +end + +local function print_stacks_details(stacks, quiet) + -- print stacks details + if quiet then return end + if #stacks.containers > 0 then + log(1, 'Summary:') + log(1, 'Containers:%5d before:%5d after:%5d', #stacks.containers, #stacks.before_cont_ids, #stacks.after_cont_ids) + for cont_id, cont in sorted_desc(stacks.containers, stacks.before_cont_ids) do + log(2, (' Cont: %50s <%6d> bef:%5d aft:%5d'):format(cont.description, cont_id, cont.before_vol, cont.after_vol)) + end + end + if stacks.item_qty > 0 then + log(1, ('Items: #Qty: %6d sizes: bef:%5d aft:%5d Mat amt:%6d'):format(stacks.item_qty, stacks.before_stacks, stacks.after_stacks, stacks.material_amt)) + for key, stack_type in pairs(stacks.stack_types) do + if stack_type.item_qty > 0 then + log(1, (' Type: %12s <%d> #Qty:%6d sizes: max:%5d bef:%6d aft:%6d Cont: bef:%5d aft:%5d Mat amt:%6d'):format( + df.item_type[stack_type.type_id], stack_type.type_id, stack_type.item_qty, stack_type.max_stack_qty, stack_type.before_stacks, + stack_type.after_stacks, #stack_type.before_cont_ids, #stack_type.after_cont_ids, stack_type.material_amt)) + for _, comp_item in sorted_desc(stack_type.comp_items, stack_type.comp_items) do + if comp_item.item_qty > 0 then + log(2, (' Comp item:%40s <%12s> #Qty:%6d #stacks:%5d max:%5d bef:%6d aft:%6d Cont: bef:%5d aft:%5d Mat amt:%6d'):format( + comp_item.description, comp_item.comp_key, comp_item.item_qty, #comp_item.items, comp_item.max_stack_qty, comp_item.before_stacks, + comp_item.after_stacks, #comp_item.before_cont_ids, #comp_item.after_cont_ids, comp_item.material_amt)) + for _, item in sorted_items_qty(comp_item.items) do + log(3, (' Item:%40s <%6d> Qty: bef:%6d aft:%6d Cont: bef:<%5d> aft:<%5d> Mat Amt: bef: %6d aft:%6d stockpile:%s'):format( + utils.getItemDescription(item.item), item.item.id, item.before_size or 0, item.after_size or 0, item.before_cont_id or 0, + item.after_cont_id or 0, item.before_mat_amt.Qty or 0, item.after_mat_amt.Qty or 0, item.stockpile_name)) + log(4, (' stackable: %s'):format(df.item_type.attrs[stack_type.type_id].is_stackable)) + end + end + end + end + end + end +end + +local function print_stacks_summary(stacks, quiet, dry_run) + -- print stacks summary to the console + local printed = 0 + for _, s in pairs(stacks.stack_types) do + if s.before_stacks ~= s.after_stacks then + printed = printed + 1 + local str = '' + if dry_run then str = 'will combine' else str ='combined' end + print(('%s %d %s items from %d stacks into %d') + :format(str, s.item_qty, df.item_type[s.type_id], s.before_stacks, s.after_stacks)) + end + end + if printed == 0 and not quiet then + print('All stacks already optimally combined.') + end +end + +local function stacks_new() + local stacks = {} + + stacks.stack_types = CList:new() -- key=type_id, val=stack_type + stacks.containers = CList:new() -- key=container.id, val={container, description, before_vol, after_vol} + stacks.before_cont_ids = CList:new() -- key=container.id, val=container.id + stacks.after_cont_ids = CList:new() -- key=container.id, val=container.id + stacks.item_qty = 0 + stacks.material_amt = 0 -- total amount of materials - used for CORPSEPIECEs + stacks.before_stacks = 0 + stacks.after_stacks = 0 + + return stacks +end + +local function isRestrictedItem(item) + -- is the item restricted from merging? + local flags = item.flags + return flags.rotten or flags.trader or flags.hostile or flags.forbid + or flags.dump or flags.on_fire or flags.garbage_collect or flags.owned + or flags.removed or flags.encased or flags.spider_web or flags.melt + or #item.specific_refs > 0 +end + +local function isValidPart(item) + return item:getMaterial() >= 0 or + (not item.corpse_flags.unbutchered and ( + item.material_amount.Leather > 0 or + item.material_amount.Bone > 0 or + item.material_amount.Shell > 0 or + item.material_amount.Tooth > 0 or + item.material_amount.Horn > 0 or + item.material_amount.HairWool > 0 or + item.material_amount.Yarn > 0)) +end + +local function getCapacity(container, item) + if item:getType() == df.item_type.DRINK then + -- artificially reduce the capacity of barrels for drinks since 100 is just too many + return 60 * valid_types_map.drink[df.item_type.DRINK].max_stack_qty + end + return dfhack.items.getCapacity(container) +end + +local function getVolume(items) + local vol = 0 + for _, item in ipairs(items) do + vol = vol + item:getVolume() + end + return vol +end + +local function stacks_add_items(stockpile, stacks, items, container, ind) +-- loop through each item and add it to the matching stack[type_id].comp_items table +-- recursively calls itself to add contained items + if not ind then ind = '' end + + for _, item in pairs(items) do + local type_id = item:getType() + local stack_type = stacks.stack_types[type_id] + + -- item type in list of included types? + if stack_type and not item:isSand() and not item:isPlaster() and not isDye(item) and isValidPart(item) then + if not isRestrictedItem(item) and item.stack_size <= stack_type.max_stack_qty then + + stacks_add_item(stockpile, stacks, stack_type, item, container) + + if typesThatUseCreatures[df.item_type[type_id]] then + local raceRaw = df.global.world.raws.creatures.all[item.race] + local casteRaw = raceRaw.caste[item.caste] + log(4, (' %sitem:%40s <%6d> is incl, type:%d, race:%s, caste:%s'):format( + ind, utils.getItemDescription(item), item.id, type_id, raceRaw.creature_id, casteRaw.caste_id)) + elseif item:isCrafted() then + local mat_info = dfhack.matinfo.decode(item.mat_type, item.mat_index) + log(4, (' %sitem:%40s <%6d> is incl, type:%d, info:%s, quality:%d, maker:%d'):format( + ind, utils.getItemDescription(item), item.id, type_id, mat_info:toString(), item:getQuality(), item:getMaker())) + else + local mat_info = dfhack.matinfo.decode(item.mat_type, item.mat_index) + log(4, (' %sitem:%40s <%6d> is incl, type:%d, info:%s, sand:%s, plasterplaster:%s quality:%d ovl quality:%d'):format( + ind, utils.getItemDescription(item), item.id, type_id, mat_info:toString(), item:isSand(), item:isPlaster(), + item:getQuality(), item:getOverallQuality())) + end + + else + -- restricted; such as marked for action or dump. + log(4, (' %sitem:%40s <%6d> is restricted'):format(ind, utils.getItemDescription(item), item.id)) + end + + -- add contained items + elseif dfhack.items.getGeneralRef(item, df.general_ref_type.CONTAINS_ITEM) then + local contained_items = dfhack.items.getContainedItems(item) + local count = #contained_items + local volume = getVolume(contained_items) + stacks.containers[item.id] = {} + stacks.containers[item.id].container = item + stacks.containers[item.id].before_vol = volume + stacks.containers[item.id].description = utils.getItemDescription(item, 1) + log(4, (' %sContainer:%s <%6d> #items:%5d volume:%5d'):format( + ind, utils.getItemDescription(item), item.id, count, volume)) + stacks_add_items(stockpile, stacks, contained_items, item, ind .. ' ') + + -- excluded item types + else + log(5, (' %sitem:%40s <%6d> is excl, type %d, sand:%s plaster:%s'):format( + ind, utils.getItemDescription(item), item.id, type_id, item:isSand(), item:isPlaster())) + end + end +end + +local function populate_stacks(stacks, stockpiles, types) + -- 1. loop through the specified types and add them to the stacks table. stacks[type_id] + -- 2. loop through the table of stockpiles, get each item in the stockpile, then add them to stacks if the type_id matches + -- an item is stored at the bottom of the structure: stacks[type_id].comp_items[comp_key].item + -- comp_key is a compound key comprised of type_id+race+caste or type_id+mat_type+mat_index + log(4, 'Populating phase') + + -- iterate across the types + log(4, 'stack types') + for type_id, type_vals in pairs(types) do + if not stacks.stack_types[type_id] then + stacks.stack_types[type_id] = stack_type_new(type_vals) + local stack_type = stacks.stack_types[type_id] + log(4, (' type: <%12s> <%d> #item_qty:%5d stack sizes: max: %5d bef:%5d aft:%5d'):format( + df.item_type[stack_type.type_id], stack_type.type_id, stack_type.item_qty, stack_type.max_stack_qty, + stack_type.before_stacks, stack_type.after_stacks)) + end + end + + -- iterate across the stockpiles, get the list of items and call the add function to check/add as needed + log(4, ('stockpiles')) + for _, stockpile in ipairs(stockpiles) do + + local items = dfhack.buildings.getStockpileContents(stockpile) + log(4, (' stockpile:%30s <%6d> pos:(%3d,%3d,%3d) #items:%5d'):format( + stockpile.name, stockpile.id, stockpile.centerx, stockpile.centery, stockpile.z, #items)) + + if #items > 0 then + stacks_add_items(stockpile, stacks, items) + else + log(4, ' skipping stockpile: no items') + end + end +end + +local function preview_stacks(stacks) + -- calculate the stacks sizes and store in after_item_stack_size + -- the max stack size for each comp item is determined as the maximum stack size for its type + log(4, 'Preview phase') + + for _, stack_type in pairs(stacks.stack_types) do + log(4, (' type: <%12s> <%d> #item_qty:%5d stack sizes: max: %5d bef:%5d aft:%5d'):format( + df.item_type[stack_type.type_id], stack_type.type_id, stack_type.item_qty, stack_type.max_stack_qty, + stack_type.before_stacks, stack_type.after_stacks)) + + for _, comp_item in pairs(stack_type.comp_items) do + log(4, (' comp item:%40s <%12s> #qty:%5d #stacks:%5d sizes: max:%5d bef:%5d aft:%5d Cont: bef:%5d aft:%5d'):format( + comp_item.description, comp_item.comp_key, comp_item.item_qty, #comp_item.items, comp_item.max_stack_qty, + comp_item.before_stacks, comp_item.after_stacks, #comp_item.before_cont_ids, #comp_item.after_cont_ids)) + + -- item qty used? + if not typesThatUseMaterial[df.item_type[stack_type.type_id]] then + + -- max size comparison + if stack_type.max_stack_qty > comp_item.max_stack_qty then + comp_item.max_stack_qty = stack_type.max_stack_qty + end + + -- how many stacks are needed? + local stacks_needed = comp_item.item_qty // comp_item.max_stack_qty + + -- how many items are left over after the max stacks are allocated? + local stack_remainder = comp_item.item_qty - stacks_needed * comp_item.max_stack_qty + + if stack_remainder > 0 then + comp_item.after_stacks = stacks_needed + 1 + else + comp_item.after_stacks = stacks_needed + end + + stack_type.after_stacks = stack_type.after_stacks + comp_item.after_stacks + stacks.after_stacks = stacks.after_stacks + comp_item.after_stacks + + -- Update the after stack sizes. + for _, item in sorted_items_qty(comp_item.items) do + if stacks_needed > 0 then + stacks_needed = stacks_needed - 1 + item.after_size = comp_item.max_stack_qty + elseif stack_remainder > 0 then + item.after_size = stack_remainder + stack_remainder = 0 + else + item.after_size = 0 + end + end + + -- material amount used. + else + local stacks_needed = comp_item.material_amt // comp_item.max_mat_amt + local stack_remainder = comp_item.material_amt - stacks_needed * comp_item.max_mat_amt + + if stack_remainder > 0 then + comp_item.after_stacks = stacks_needed + 1 + else + comp_item.after_stacks = stacks_needed + end + + stack_type.after_stacks = stack_type.after_stacks + comp_item.after_stacks + stacks.after_stacks = stacks.after_stacks + comp_item.after_stacks + + for _, item in sorted_items_mat(comp_item.items) do + item.after_mat_amt = {} + if stacks_needed > 0 then + stacks_needed = stacks_needed - 1 + item.after_size = item.before_size + for k2, v in pairs(item.before_mat_amt) do + if v > 0 then + item.after_mat_amt[k2] = comp_item.max_mat_amt + else + item.after_mat_amt[k2] = 0 + end + end + elseif stack_remainder > 0 then + item.after_size = item.before_size + for k2, v in pairs(item.before_mat_amt) do + if v > 0 then + item.after_mat_amt[k2] = stack_remainder + else + item.after_mat_amt[k2] = 0 + end + end + stack_remainder = 0 + else + for k2, v in pairs(item.before_mat_amt) do + item.after_mat_amt[k2] = 0 + end + item.after_size = 0 + end + end + end + + -- Container loop; combine item stacks in containers. + local curr_cont = nil + local curr_cap = nil + local curr_vol = 0 + + for _, item in sorted_items_qty(comp_item.items) do + local vol = item.item:getVolume() + + -- non-zero quantity? + if item.after_size > 0 then + -- in a container before merge? + if item.before_cont_id then + local before_cont = stacks.containers[item.before_cont_id] + + -- first contained item or current container full? + if not curr_cont or curr_vol + vol > curr_cap then + curr_cont = before_cont + curr_cap = getCapacity(curr_cont.container, item.item) + curr_vol = curr_cont.before_vol + stacks.after_cont_ids[item.before_cont_id] = item.before_cont_id + stack_type.after_cont_ids[item.before_cont_id] = item.before_cont_id + comp_item.after_cont_ids[item.before_cont_id] = item.before_cont_id + + -- enough room in current container + else + curr_vol = curr_vol + vol + before_cont.after_vol = (before_cont.after_vol or before_cont.before_vol) - vol + end + + curr_cont.after_vol = curr_vol + item.after_cont_id = curr_cont.container.id + + -- not in a container before merge, container exists, and has space + elseif curr_cont and curr_vol + vol <= curr_cap then + curr_vol = curr_vol + vol + curr_cont.after_vol = curr_vol + item.after_cont_id = curr_cont.container.id + + -- not in a container, no container exists or no space in container + else + -- do nothing + end + + -- zero after size, reduce the number of stacks in the container + elseif item.before_cont_id then + local before_cont = stacks.containers[item.before_cont_id] + before_cont.after_vol = (before_cont.after_vol or before_cont.before_vol) - vol + end + end + log(4, (' comp item:%40s <%12s> #qty:%5d #stacks:%5d sizes: max:%5d bef:%5d aft:%5d cont: bef:%5d aft:%5d'):format( + comp_item.description, comp_item.comp_key, comp_item.item_qty, #comp_item.items, comp_item.max_stack_qty, + comp_item.before_stacks, comp_item.after_stacks, #comp_item.before_cont_ids, #comp_item.after_cont_ids)) + end + log(4, (' type: <%12s> <%d> #item_qty:%5d stack sizes: max: %5d bef:%5d aft:%5d'):format( + df.item_type[stack_type.type_id], stack_type.type_id, stack_type.item_qty, stack_type.max_stack_qty, + stack_type.before_stacks, stack_type.after_stacks)) + end +end + +local function merge_stacks(stacks) + -- apply the stack size changes in the after_item_stack_size + -- if the after_item_stack_size is zero, then remove the item + log(4, 'Merge phase') + for _, stack_type in pairs(stacks.stack_types) do + for comp_key, comp_item in pairs(stack_type.comp_items) do + + for item_id, item in pairs(comp_item.items) do + log(4, (' item amt:%40s <%6d> bef:%5d aft:%5d cont: bef:<%5d> aft:<%5d> mat: bef:%5d aft:%5d'):format( + comp_item.description, item.item.id, item.before_size or 0, item.after_size or 0, + item.before_cont_id or 0, item.after_cont_id or 0, item.before_mat_amt.Qty or 0, item.after_mat_amt.Qty or 0)) + + -- no items left in stack? + if item.after_size == 0 then + log(4, ' removing') + dfhack.items.remove(item.item) + + -- some items left in stack + elseif not typesThatUseMaterial[df.item_type[stack_type.type_id]] and item.before_size ~= item.after_size then + log(4, ' updating qty') + item.item.stack_size = item.after_size + + elseif typesThatUseMaterial[df.item_type[stack_type.type_id]] and item.before_mat_amt.Qty ~= item.after_mat_amt.Qty then + log(4, ' updating material') + item.item.material_amount.Leather = item.after_mat_amt.Leather + item.item.material_amount.Bone = item.after_mat_amt.Bone + item.item.material_amount.Shell = item.after_mat_amt.Shell + item.item.material_amount.Tooth = item.after_mat_amt.Tooth + item.item.material_amount.Horn = item.after_mat_amt.Horn + item.item.material_amount.HairWool = item.after_mat_amt.HairWool + item.item.material_amount.Yarn = item.after_mat_amt.Yarn + else + log(4, ' no change') + end + + -- move to a container? + if item.after_cont_id then + if (item.before_cont_id or 0) ~= item.after_cont_id then + log(4, (' moving item:%40s <%6d> bef:%5d aft:%5d cont: bef:<%5d> aft:<%5d>'):format( + comp_item.description, item.item.id, item.before_size or 0, item.after_size or 0, + item.before_cont_id or 0, item.after_cont_id or 0)) + dfhack.items.moveToContainer(item.item, stacks.containers[item.after_cont_id].container) + end + end + end + end + end +end + +local function get_stockpile_all() + -- returns the stockpiles vector + local stockpiles = df.global.world.buildings.other.STOCKPILE + if opts.verbose > 0 then + print(('Stockpile(all): %d found'):format(#stockpiles)) + end + return stockpiles +end + +local function get_stockpile_here() + -- attempt to get the selected stockpile, or exit with error + -- return the stockpile as a table + local stockpiles = {} + local building = dfhack.gui.getSelectedStockpile(true) + + -- try finding the stockpile by viewed item or first item in itemlist viewsheet. + if building == nil then + local item = nil + if dfhack.gui.getSelectedItem(true) ~= nil then + item = dfhack.gui.getSelectedItem(true) + elseif tonumber(dfhack.DF_VERSION:match("^0*%.*(%d+%.%d+)")) >= 50.07 -- matchFocusString() in Commit a770a4c + and dfhack.gui.matchFocusString("dwarfmode/ViewSheets/ITEM_LIST", dfhack.gui.getDFViewscreen()) + and df.global.game.main_interface.view_sheets.open == true + and df.global.game.main_interface.view_sheets.active_sheet == df.view_sheet_type.ITEM_LIST + and #df.global.game.main_interface.view_sheets.viewing_itid > 0 + then + local itemid = df.global.game.main_interface.view_sheets.viewing_itid[0] + item = df.item.find(itemid) + end + local pos = (item) and xyz2pos(dfhack.items.getPosition(item)) or nil + building = (pos) and dfhack.buildings.findAtTile(pos) or nil + building = (df.building_stockpilest:is_instance(building)) and building or nil + end + + if not building then qerror('Please select a stockpile.') end + table.insert(stockpiles, building) + if opts.verbose > 0 then + print(('Stockpile(here): %s <%d> #items:%d'):format(building.name, building.id, + #dfhack.buildings.getStockpileContents(building))) + end + return stockpiles +end + +local function parse_types_opts(arg) + -- check the types specified on the command line, or exit with error + -- return the selected types as a table + local types = {} + local div = '' + local types_output = '' + + if not arg then + qerror('Expected: comma separated list of types') + end + + for _, t in pairs(argparse.stringList(arg)) do + if not valid_types_map[t] then + qerror(('Unknown type: %s'):format(t)) + end + + for k2, v2 in pairs(valid_types_map[t]) do + if not types[k2] then + types[k2]={} + for k3, v3 in pairs(v2) do + types[k2][k3]=v3 + end + types_output = types_output .. div .. df.item_type[types[k2].type_id] + div=', ' + else + qerror(('Expected: only one value for %s'):format(t)) + end + end + end + return types +end + +local function parse_commandline(opts, args) + -- check the command line/exit on error, and set the defaults + local positionals = argparse.processArgsGetopt(args, { + {'h', 'help', handler=function() opts.help = true end}, + {'t', 'types', hasArg=true, handler=function(optarg) opts.types=parse_types_opts(optarg) end}, + {'d', 'dry-run', handler=function() opts.dry_run = true end}, + {'q', 'quiet', handler=function() opts.quiet = true end}, + {'v', 'verbose', hasArg=true, handler=function(optarg) opts.verbose = math.tointeger(optarg) or 0 end}, + }) + + -- if stockpile option is not specificed, then default to all + if positionals[1] == 'all' then + opts.all=get_stockpile_all() + elseif positionals[1] == 'here' then + opts.here=get_stockpile_here() + else + opts.help = true + end + + -- if types option is not specified, then default to all + if not opts.types then + opts.types = valid_types_map['all'] + end +end + +-- main program starts here +local function main() + + if df.global.gamemode ~= df.game_mode.DWARF or not dfhack.isMapLoaded() then + qerror('combine needs a loaded fortress map to work') + end + + parse_commandline(opts, args) + + if opts.help then + print(dfhack.script_help()) + return + end + + local stacks = stacks_new() + + populate_stacks(stacks, opts.all or opts.here, opts.types) + + preview_stacks(stacks) + + if not opts.dry_run then + merge_stacks(stacks) + end + + print_stacks_details(stacks) + print_stacks_summary(stacks, opts.quiet, opts.dry_run) + +end + +if not dfhack_flags.module then + main() +end diff --git a/confirm.lua b/confirm.lua new file mode 100644 index 0000000000..5142013d1f --- /dev/null +++ b/confirm.lua @@ -0,0 +1,210 @@ +--@ module = true + +local dialogs = require('gui.dialogs') +local gui = require('gui') +local overlay = require('plugins.overlay') +local specs = reqscript('internal/confirm/specs') +local utils = require('utils') +local widgets = require("gui.widgets") + +------------------------ +-- API + +function get_state() + return specs.config.data +end + +function set_enabled(id, enabled) + for _, conf in pairs(specs.config.data) do + if conf.id == id then + if conf.enabled ~= enabled then + conf.enabled = enabled + specs.config:write() + end + break + end + end +end + +------------------------ +-- Overlay + +local function get_contexts() + local contexts, contexts_set = {}, {} + for id, conf in pairs(specs.REGISTRY) do + if not contexts_set[id] then + contexts_set[id] = true + table.insert(contexts, conf.context) + end + end + return contexts +end + +ConfirmOverlay = defclass(ConfirmOverlay, overlay.OverlayWidget) +ConfirmOverlay.ATTRS{ + desc='Detects dangerous actions and prompts with confirmation dialogs.', + default_pos={x=1,y=1}, + default_enabled=true, + full_interface=true, -- not player-repositionable + hotspot=true, -- need to reset pause when we're not in target contexts + overlay_onupdate_max_freq_seconds=300, + viewscreens=get_contexts(), +} + +function ConfirmOverlay:init() + for id, conf in pairs(specs.REGISTRY) do + if conf.intercept_frame then + self:addviews{ + widgets.Panel{ + view_id=id, + frame=copyall(conf.intercept_frame), + frame_style=conf.debug_frame and gui.FRAME_INTERIOR or nil, + } + } + end + end + self.paused_confs = {} +end + +function ConfirmOverlay:preUpdateLayout() + local interface_rect = gui.get_interface_rect() + self.frame.w, self.frame.h = interface_rect.width, interface_rect.height + -- reset frames if any of them have been pushed out of position + for id, conf in pairs(specs.REGISTRY) do + if conf.intercept_frame then + self.subviews[id].frame = copyall(conf.intercept_frame) + end + end +end + +function ConfirmOverlay:overlay_onupdate() + for conf in pairs(self.paused_confs) do + if not dfhack.gui.matchFocusString(conf.context, + dfhack.gui.getDFViewscreen(true)) + then + self.paused_confs[conf] = nil + end + end + if not next(self.paused_confs) then + self.overlay_onupdate_max_freq_seconds = 300 + end +end + +function ConfirmOverlay:matches_conf(conf, keys, scr) + local matched_keys = false + for _, key in ipairs(conf.intercept_keys) do + if keys[key] then + matched_keys = true + break + end + end + if not matched_keys then return false end + local mouse_offset + if keys._MOUSE_L and conf.intercept_frame then + local mousex, mousey = self.subviews[conf.id]:getMouseFramePos() + if not mousex then + return false + end + mouse_offset = xy2pos(mousex, mousey) + end + if not dfhack.gui.matchFocusString(conf.context, scr) then return false end + return not conf.predicate or conf.predicate(keys, mouse_offset) +end + +function ConfirmOverlay:onInput(keys) + if self.simulating then + return false + end + local scr = dfhack.gui.getDFViewscreen(true) + for id, conf in pairs(specs.REGISTRY) do + if specs.config.data[id].enabled and self:matches_conf(conf, keys, scr) then + if self.paused_confs[conf] then + return false + end + local mouse_pos = xy2pos(dfhack.screen.getMousePos()) + local propagate_fn = function(pause) + if conf.on_propagate then + conf.on_propagate() + end + if pause then + self.paused_confs[conf] = true + self.overlay_onupdate_max_freq_seconds = 0 + end + if keys._MOUSE_L then + df.global.gps.mouse_x = mouse_pos.x + df.global.gps.mouse_y = mouse_pos.y + end + self.simulating = true + gui.simulateInput(scr, keys) + self.simulating = false + end + local pause_fn = conf.pausable and curry(propagate_fn, true) or nil + dialogs.showYesNoPrompt(conf.title, utils.getval(conf.message):wrap(45), COLOR_YELLOW, + propagate_fn, nil, pause_fn, curry(dfhack.run_script, 'gui/confirm', tostring(conf.id))) + return true + end + end +end + +function ConfirmOverlay:render(dc) + if gui.blink_visible(500) then + return + end + ConfirmOverlay.super.render(self, dc) +end + +OVERLAY_WIDGETS = { + overlay=ConfirmOverlay, +} + +------------------------ +-- CLI + +local function do_list() + print('Available confirmation prompts:') + local confs, max_len = {}, 10 + for id, conf in pairs(specs.REGISTRY) do + max_len = math.max(max_len, #id) + table.insert(confs, conf) + end + table.sort(confs, function(a,b) return a.id < b.id end) + for _, conf in ipairs(confs) do + local fmt = '%' .. tostring(max_len) .. 's: %s %s' + print((fmt):format(conf.id, + specs.config.data[conf.id].enabled and '(enabled) ' or '(disabled)', + conf.title)) + end +end + +local function do_enable_disable(args, enable) + if args[1] == 'all' then + for id in pairs(specs.REGISTRY) do + set_enabled(id, enable) + end + else + for _, id in ipairs(args) do + if not specs.REGISTRY[id] then + qerror('confirmation prompt id not found: ' .. tostring(id)) + end + set_enabled(id, enable) + end + end +end + +local function main(args) + local command = table.remove(args, 1) + + if not command or command == 'list' then + do_list() + elseif command == 'enable' or command == 'disable' then + do_enable_disable(args, command == 'enable') + elseif command == 'help' then + print(dfhack.script_help()) + else + dfhack.printerr('unknown command: ' .. tostring(command)) + end +end + +if not dfhack_flags.module then + main{...} +end diff --git a/control-panel.lua b/control-panel.lua new file mode 100644 index 0000000000..7579db0261 --- /dev/null +++ b/control-panel.lua @@ -0,0 +1,242 @@ +--@module = true + +local argparse = require('argparse') +local common = reqscript('internal/control-panel/common') +local registry = reqscript('internal/control-panel/registry') +local utils = require('utils') + +local GLOBAL_KEY = 'control-panel' + +-- state change hooks + +local function apply_system_config() + local enabled_map = common.get_enabled_map() + for _, data in ipairs(registry.COMMANDS_BY_IDX) do + if data.mode == 'system_enable' or data.mode == 'tweak' then + common.apply_command(data, enabled_map) + end + end + for _, data in ipairs(registry.PREFERENCES_BY_IDX) do + local value = safe_index(common.config.data.preferences, data.name, 'val') + if value ~= nil then + data.set_fn(value) + end + end +end + +local function apply_autostart_config() + local enabled_map =common.get_enabled_map() + for _, data in ipairs(registry.COMMANDS_BY_IDX) do + if data.mode == 'enable' or data.mode == 'run' or data.mode == 'repeat' then + common.apply_command(data, enabled_map) + end + end +end + +local function apply_fort_loaded_config() + local state = dfhack.persistent.getSiteData(GLOBAL_KEY, {}) + if not state.autostart_done then + apply_autostart_config() + dfhack.persistent.saveSiteData(GLOBAL_KEY, {autostart_done=true}) + end + local enabled_map = common.get_enabled_map() + local enabled_repeats = dfhack.persistent.getSiteData(common.REPEATS_GLOBAL_KEY, {}) + for _, data in ipairs(registry.COMMANDS_BY_IDX) do + if data.mode == 'repeat' then + common.apply_command(data, enabled_map, enabled_repeats[data.command]) + end + end +end + +dfhack.onStateChange[GLOBAL_KEY] = function(sc) + if sc == SC_CORE_INITIALIZED then + apply_system_config() + elseif sc == SC_MAP_LOADED and dfhack.world.isFortressMode() then + apply_fort_loaded_config() + end +end + +local function get_command_data(name_or_idx) + if type(name_or_idx) == 'number' then + return registry.COMMANDS_BY_IDX[name_or_idx] + end + return registry.COMMANDS_BY_NAME[name_or_idx] +end + +local function get_autostart_internal(data) + local default_value = not not data.default + local current_value = safe_index(common.config.data.commands, data.command, 'autostart') + if current_value == nil then + current_value = default_value + end + return current_value, default_value +end + +-- API + +-- returns current, default +function get_autostart(command) + local data = get_command_data(command) + if not data then return end + return get_autostart_internal(data) +end + +-- CLI + +local function print_header(header) + print() + print(header) + print(('-'):rep(#header)) +end + +local function list_command_group(group, filter_strs, enabled_map) + local header = ('Group: %s'):format(group) + for idx, data in ipairs(registry.COMMANDS_BY_IDX) do + if not common.command_passes_filters(data, group, filter_strs) then + goto continue + end + if header then + print_header(header) + ---@diagnostic disable-next-line: cast-local-type + header = nil + end + local extra = '' + if data.mode == 'system_enable' or data.mode == 'tweak' then + extra = ' (global)' + end + print(('%d) %s%s'):format(idx, data.command, extra)) + local desc = common.get_description(data) + if #desc > 0 then + print((' %s'):format(desc)) + end + print((' autostart enabled: %s (default: %s)'):format(get_autostart_internal(data))) + if enabled_map[data.command] ~= nil then + print((' currently enabled: %s'):format(enabled_map[data.command])) + end + print() + ::continue:: + end + if not header then + end +end + +local function list_preferences(filter_strs) + local header = 'Preferences' + for _, data in ipairs(registry.PREFERENCES_BY_IDX) do + local search_key = ('%s %s %s'):format(data.name, data.label, data.desc) + if not utils.search_text(search_key, filter_strs) then goto continue end + if header then + print_header(header) + ---@diagnostic disable-next-line: cast-local-type + header = nil + end + print(('%s) %s'):format(data.name, data.label)) + print((' %s'):format(data.desc)) + print((' current: %s (default: %s)'):format(data.get_fn(), data.default)) + if data.min then + print((' minimum: %s'):format(data.min)) + end + print() + ::continue:: + end +end + +local function do_list(filter_strs) + local enabled_map = common.get_enabled_map() + list_command_group('automation', filter_strs, enabled_map) + list_command_group('bugfix', filter_strs, enabled_map) + list_command_group('gameplay', filter_strs, enabled_map) + list_preferences(filter_strs) +end + +local function do_enable_disable(which, entries) + local enabled_map =common.get_enabled_map() + for _, entry in ipairs(entries) do + local data = get_command_data(entry) + if data.mode ~= 'system_enable' and not dfhack.world.isFortressMode() then + qerror('must have a loaded fortress to enable '..data.name) + end + if common.apply_command(data, enabled_map, which == 'en') then + print(('%sabled %s'):format(which, entry)) + end + end +end + +local function do_enable(entries) + do_enable_disable('en', entries) +end + +local function do_disable(entries) + do_enable_disable('dis', entries) +end + +local function do_autostart_noautostart(which, entries) + for _, entry in ipairs(entries) do + local data = get_command_data(entry) + if not data then + qerror(('autostart command or index not found: "%s"'):format(entry)) + else + common.set_autostart(data, which == 'en') + print(('%sabled autostart for: %s'):format(which, entry)) + end + end + common.config:write() +end + +local function do_autostart(entries) + do_autostart_noautostart('en', entries) +end + +local function do_noautostart(entries) + do_autostart_noautostart('dis', entries) +end + +local function do_set(params) + local name, value = params[1], params[2] + local data = registry.PREFERENCES_BY_NAME[name] + if not data then + qerror(('preference name not found: "%s"'):format(name)) + end + common.set_preference(data, value) + common.config:write() +end + +local function do_reset(params) + local name = params[1] + local data = registry.PREFERENCES_BY_NAME[name] + if not data then + qerror(('preference name not found: "%s"'):format(name)) + end + common.set_preference(data, data.default) + common.config:write() +end + +local command_switch = { + list=do_list, + enable=do_enable, + disable=do_disable, + autostart=do_autostart, + noautostart=do_noautostart, + set=do_set, + reset=do_reset, +} + +local function main(args) + local help = false + + local positionals = argparse.processArgsGetopt(args, { + {'h', 'help', handler=function() help = true end}, + }) + + local command = table.remove(positionals, 1) + if help or not command or not command_switch[command] then + print(dfhack.script_help()) + return + end + + command_switch[command](positionals) +end + +if not dfhack_flags.module then + main{...} +end diff --git a/create-items.rb b/create-items.rb deleted file mode 100644 index 544bd8ce0f..0000000000 --- a/create-items.rb +++ /dev/null @@ -1,207 +0,0 @@ -# create first necessity items under cursor -=begin - -create-items -============ -Spawn items under the cursor, to get your fortress started. - -The first argument gives the item category, the second gives the material, -and the optionnal third gives the number of items to create (defaults to 20). - -Currently supported item categories: ``boulder``, ``bar``, ``plant``, ``log``, -``web``. - -Instead of material, using ``list`` makes the script list eligible materials. - -The ``web`` item category will create an uncollected cobweb on the floor. - -Note that the script does not enforce anything, and will let you create -boulders of toad blood and stuff like that. -However the ``list`` mode will only show 'normal' materials. - -Examples:: - - create-items boulders COAL_BITUMINOUS 12 - create-items plant tail_pig - create-items log list - create-items web CREATURE:SPIDER_CAVE_GIANT:SILK - create-items bar CREATURE:CAT:SOAP - create-items bar adamantine - -=end - -category = $script_args[0] || 'help' -mat_raw = $script_args[1] || 'list' -count = $script_args[2] - - -category = df.match_rawname(category, ['help', 'bars', 'boulders', 'plants', 'logs', 'webs', 'anvils']) || 'help' - -if category == 'help' - puts < 5 - df.curview.feed_keys(:CURSOR_DOWN_Z) - df.curview.feed_keys(:CURSOR_UP_Z) -else - df.curview.feed_keys(:CURSOR_UP_Z) - df.curview.feed_keys(:CURSOR_DOWN_Z) -end diff --git a/deathcause.lua b/deathcause.lua new file mode 100644 index 0000000000..3fd62fd115 --- /dev/null +++ b/deathcause.lua @@ -0,0 +1,177 @@ +-- show death cause of a creature +--@ module = true + +local DEATH_TYPES = reqscript('gui/unit-info-viewer').DEATH_TYPES + +-- Gets the first corpse item at the given location +local function getItemAtPosition(pos) + for _, item in ipairs(df.global.world.items.other.ANY_CORPSE) do + -- could this maybe be `if same_xyz(pos, item.pos) then`? + if item.pos.x == pos.x and item.pos.y == pos.y and item.pos.z == pos.z then + print("Automatically chose first corpse at the selected location.") + return item + end + end +end + +local function getRaceNameSingular(race_id) + return df.creature_raw.find(race_id).name[0] +end + +local function getDeathStringFromCause(cause) + if cause == -1 then + return "died" + else + return DEATH_TYPES[cause]:trim() + end +end + +-- Returns a cause of death given a unit +local function getDeathCauseFromUnit(unit) + local str = unit.name.has_name and '' or 'The ' + str = str .. dfhack.units.getReadableName(unit) + + if not dfhack.units.isDead(unit) then + return str .. " is not dead yet!" + end + + str = str .. (" %s"):format(getDeathStringFromCause(unit.counters.death_cause)) + + local incident = df.incident.find(unit.counters.death_id) + if incident then + str = str .. (" in year %d"):format(incident.event_year) + + if incident.criminal then + local killer = df.unit.find(incident.criminal) + if killer then + str = str .. (", killed by the %s"):format(getRaceNameSingular(killer.race)) + if killer.name.has_name then + str = str .. (" %s"):format(dfhack.translation.translateName(dfhack.units.getVisibleName(killer))) + end + end + end + end + + return str .. '.' +end + +-- returns the item description if the item still exists; otherwise +-- returns the weapon name +local function getWeaponName(item_id, subtype) + local item = df.item.find(item_id) + if not item then + return df.global.world.raws.itemdefs.weapons[subtype].name + end + return dfhack.items.getDescription(item, 0, false) +end + +local function getDeathEventHistFigUnit(histfig_unit, event) + local str = ("The %s %s %s in year %d"):format( + getRaceNameSingular(histfig_unit.race), + dfhack.translation.translateName(dfhack.units.getVisibleName(histfig_unit)), + getDeathStringFromCause(event.death_cause), + event.year + ) + + local slayer_histfig = df.historical_figure.find(event.slayer_hf) + if slayer_histfig then + str = str .. (", killed by the %s %s"):format( + getRaceNameSingular(slayer_histfig.race), + dfhack.translation.translateName(dfhack.units.getVisibleName(slayer_histfig)) + ) + end + + if event.weapon then + if event.weapon.item_type == df.item_type.WEAPON then + str = str .. (", using a %s"):format(getWeaponName(event.weapon.item, event.weapon.item_subtype)) + elseif event.weapon.shooter_item_type == df.item_type.WEAPON then + str = str .. (", shot by a %s"):format(getWeaponName(event.weapon.shooter_item, event.weapon.shooter_item_subtype)) + end + end + + return str .. '.' +end + +-- Returns the death event for the given histfig or nil if not found +local function getDeathEventForHistFig(histfig_id) + for i = #df.global.world.history.events - 1, 0, -1 do + local event = df.global.world.history.events[i] + if event:getType() == df.history_event_type.HIST_FIGURE_DIED then + if event.victim_hf == histfig_id then + return event + end + end + end +end + +-- Returns the cause of death given a histfig +local function getDeathCauseFromHistFig(histfig) + local histfig_unit = df.unit.find(histfig.unit_id) + if not histfig_unit then + qerror("Cause of death not available") + end + + if not dfhack.units.isDead(histfig_unit) then + return ("%s is not dead yet!"):format(dfhack.units.getReadableName(histfig_unit)) + else + local death_event = getDeathEventForHistFig(histfig.id) + return getDeathEventHistFigUnit(histfig_unit, death_event) + end +end + +local function is_corpse_item(item) + if not item then return false end + local itype = item:getType() + return itype == df.item_type.CORPSE or itype == df.item_type.CORPSEPIECE +end + +local view_sheets = df.global.game.main_interface.view_sheets + +local function get_target() + local selected_unit = dfhack.gui.getSelectedUnit(true) + if selected_unit then + return selected_unit.hist_figure_id, selected_unit + end + local selected_item = dfhack.gui.getSelectedItem(true) + if not selected_item and + dfhack.gui.matchFocusString('dwarfmode/ViewSheets/ITEM_LIST', dfhack.gui.getDFViewscreen(true)) and + #view_sheets.viewing_itid > 0 + then + local pos = xyz2pos(dfhack.items.getPosition(df.item.find(view_sheets.viewing_itid[0]))) + selected_item = getItemAtPosition(pos) + end + if not is_corpse_item(selected_item) then + if df.item_remainsst:is_instance(selected_item) then + print(("The %s died."):format(getRaceNameSingular(selected_item.race))) + return + end + qerror("Please select a unit, a corpse, or a body part") + end + return selected_item.hist_figure_id, df.unit.find(selected_item.unit_id) +end + +-- wrapper function to take either a unit or a histfig and get the death cause +function getDeathCause(target) + if df.unit:is_instance(target) then + return getDeathCauseFromUnit(target) + else + return getDeathCauseFromHistFig(target) + end +end + +if dfhack_flags.module then + return +end + +local hist_figure_id, selected_unit = get_target() + +if not hist_figure_id then + qerror("Cause of death not available") +elseif hist_figure_id == -1 then + if not selected_unit then + qerror("Cause of death not available") + end + print(dfhack.df2console(getDeathCause(selected_unit))) +else + print(dfhack.df2console(getDeathCause(df.historical_figure.find(hist_figure_id)))) +end diff --git a/deathcause.rb b/deathcause.rb deleted file mode 100644 index 879c5fc44f..0000000000 --- a/deathcause.rb +++ /dev/null @@ -1,84 +0,0 @@ -# show death cause of a creature -=begin - -deathcause -========== -Select a body part ingame, or a unit from the :kbd:`u` unit list, and this -script will display the cause of death of the creature. - -=end - -def display_death_event(e) - str = "The #{e.victim_hf_tg.race_tg.name[0]} #{e.victim_hf_tg.name} died in year #{e.year}" - str << " (cause: #{e.death_cause.to_s.downcase})," - str << " killed by the #{e.slayer_race_tg.name[0]} #{e.slayer_hf_tg.name}" if e.slayer_hf != -1 - str << " using a #{df.world.raws.itemdefs.weapons[e.weapon.item_subtype].name}" if e.weapon.item_type == :WEAPON - str << ", shot by a #{df.world.raws.itemdefs.weapons[e.weapon.shooter_item_subtype].name}" if e.weapon.shooter_item_type == :WEAPON - - puts str.chomp(',') + '.' -end - -def display_death_unit(u) - str = "The #{u.race_tg.name[0]}" - str << " #{u.name}" if u.name.has_name - - if not u.flags2.killed and not u.flags3.ghostly - str << " is not dead yet !" - - puts str.chomp(',') - else - death_info = u.counters.death_tg - killer = death_info.killer_tg if death_info - - str << " died" if !u.flags2.slaughter - str << " was slaughtered" if u.flags2.slaughter - - str << " in year #{death_info.event_year}" if death_info - str << " (cause: #{u.counters.death_cause.to_s.downcase})," if u.counters.death_cause != -1 - str << " killed by the #{killer.race_tg.name[0]} #{killer.name}" if killer - - puts str.chomp(',') + '.' - end -end - -item = df.item_find(:selected) -unit = df.unit_find(:selected) - -if !unit and (!item or !item.kind_of?(DFHack::ItemBodyComponent)) - item = df.world.items.other[:ANY_CORPSE].find { |i| df.at_cursor?(i) } -end - -if item and item.kind_of?(DFHack::ItemBodyComponent) - hf = item.hist_figure_id -elsif unit - hf = unit.hist_figure_id -end - -if not hf - puts "Please select a corpse in the loo'k' menu, or an unit in the 'u'nitlist screen" - -elsif hf == -1 - if unit ||= item.unit_tg - display_death_unit(unit) - else - puts "Not a historical figure, cannot find death info" - end - -else - histfig = df.world.history.figures.binsearch(hf) - unit = histfig ? df.unit_find(histfig.unit_id) : nil - if unit and not unit.flags2.killed and not unit.flags3.ghostly - puts "#{unit.name} is not dead yet !" - - else - events = df.world.history.events - (0...events.length).reverse_each { |i| - e = events[i] - if e.kind_of?(DFHack::HistoryEventHistFigureDiedst) and e.victim_hf == hf - display_death_event(e) - break - end - } - end -end - diff --git a/deep-embark.lua b/deep-embark.lua new file mode 100644 index 0000000000..6738bd532f --- /dev/null +++ b/deep-embark.lua @@ -0,0 +1,360 @@ +--@ module = true + +local dlg = require('gui.dialogs') +local utils = require('utils') + +function getFeatureID(cavernType) + local features = df.global.world.features + local map_features = features.map_features + if cavernType == 'CAVERN_1' then + for i, feature in ipairs(map_features) do + if feature._type == df.feature_init_subterranean_from_layerst and feature.start_depth == 0 then + return features.feature_global_idx[i] + end + end + elseif cavernType == 'CAVERN_2' then + for i, feature in ipairs(map_features) do + if feature._type == df.feature_init_subterranean_from_layerst and feature.start_depth == 1 then + return features.feature_global_idx[i] + end + end + elseif cavernType == 'CAVERN_3' then + for i, feature in ipairs(map_features) do + if feature._type == df.feature_init_subterranean_from_layerst and feature.start_depth == 2 then + return features.feature_global_idx[i] + end + end + elseif cavernType == 'UNDERWORLD' then + for i, feature in ipairs(map_features) do + if feature._type == df.feature_init_underworld_from_layerst and feature.start_depth == 4 then + return features.feature_global_idx[i] + end + end + end +end + +function getFeatureBlocks(featureID) + local featureBlocks = {} --as:number[] + for i, block in ipairs(df.global.world.map.map_blocks) do + if block.global_feature == featureID and block.local_feature == -1 then + table.insert(featureBlocks, i) + end + end + return featureBlocks +end + +function isValidTiletype(tiletype) + local tt = df.tiletype[tiletype] + local tiletypeAttrs = df.tiletype.attrs[tt] + local material = tiletypeAttrs.material + local forbiddenMaterials = utils.invert{ + df.tiletype_material.TREE, -- so as not to embark stranded on top of a tree + df.tiletype_material.MUSHROOM, + df.tiletype_material.FIRE, + df.tiletype_material.CAMPFIRE + } + if forbiddenMaterials[material] then return false end + local shapeAttrs = df.tiletype_shape.attrs[tiletypeAttrs.shape] + return shapeAttrs.walkable +end + +function getValidEmbarkTiles(block) + local validTiles = {} --as:{_type:table,x:number,y:number,z:number}[] + for xi = 0, 15 do + for yi = 0, 15 do + if block.designation[xi][yi].flow_size == 0 + and isValidTiletype(block.tiletype[xi][yi]) + then + table.insert(validTiles, { x = block.map_pos.x + xi, y = block.map_pos.y + yi, z = block.map_pos.z }) + end + end + end + return validTiles +end + +function blockGlowingBarrierAnnouncements(recenter) + -- temporarily disables the "glowing barrier has disappeared" announcement + -- announcement settings are restored after 1 tick + -- setting recenter to true enables recentering of game view to the announcement position + -- glowing barrier disappearance announcement + local announcementFlags = df.global.d_init.announcements.flags.ENDGAME_EVENT_1 + local oldFlags = df.global.d_init.announcements.flags.ENDGAME_EVENT_1:new() -- backup announcement settings + announcementFlags.DO_MEGA = false + announcementFlags.PAUSE = false + announcementFlags.RECENTER = recenter and true or false + announcementFlags.A_DISPLAY = false + announcementFlags.D_DISPLAY = recenter and true or false -- an actual announcement is required for recentering to occur + dfhack.timeout(1, 'ticks', function() -- barrier disappears after 1 tick + announcementFlags:assign(oldFlags) -- restore announcement settings + if recenter then + -- Remove glowing barrier notifications: + local status = df.global.world.status + local announcements = status.announcements + for i = #announcements - 1, 0, -1 do + if string.find(announcements[i].text, "glowing barrier has disappeared") then + announcements:erase(i) + break + end + end + local reports = status.reports + for i = #reports - 1, 0, -1 do + if string.find(reports[i].text, "glowing barrier has disappeared") then + reports:erase(i) + break + end + end + status.display_timer = 0 -- to avoid displaying other announcements + end + end) +end + +function reveal(pos) + -- creates an unbound glowing barrier at the target location + -- so as to trigger tile revelation when it disappears 1 tick later (fortress mode only) + -- should be run in conjunction with blockGlowingBarrierAnnouncements() + local x, y, z = pos2xyz(pos) + local block = dfhack.maps.getTileBlock(x, y, z) + local tiletype = block.tiletype[x % 16][y % 16] + if tiletype == df.tiletype.GlowingBarrier then -- to avoid multiple instances + return + end + block.tiletype[x % 16][y % 16] = df.tiletype.GlowingBarrier + local barriers = df.global.world.event.glowing_barriers + local barrier = df.glowing_barrier:new() + barrier.buildings:insert('#', -1) -- being unbound to a building makes the barrier disappear immediately + barrier.pos:assign(pos) + barriers:insert('#', barrier) + local hfs = df.glowing_barrier:new() + hfs.triggered = 1 -- this prevents HFS events (which can otherwise be triggered by the barrier disappearing) + barriers:insert('#', hfs) + dfhack.timeout(1, 'ticks', function() -- barrier tiletype disappears after 1 tick + block.tiletype[x % 16][y % 16] = tiletype -- restore old tiletype + barriers:erase(#barriers - 1) -- remove hfs blocker + barriers:erase(#barriers - 1) -- remove revelation barrier + end) +end + +function moveEmbarkStuff(selectedBlock, embarkTiles) + local spawnPosCentre + for _, hotkey in ipairs(df.global.plotinfo.main.hotkeys) do + if hotkey.cmd == df.hotkey_type.Zoom then -- the preset hotkey is centred around the spawn point + spawnPosCentre = xyz2pos(hotkey.x, hotkey.y, hotkey.z) + hotkey:assign(embarkTiles[math.random(1, #embarkTiles)]) -- set the hotkey to the new spawn point + break + end + end + + if not spawnPosCentre then -- no place for the wagon; use the position of the first unit + spawnPosCentre = xyz2pos(dfhack.units.getPosition(dfhack.units.getCitizens()[1])) + end + + -- only target things within this zone to help avoid teleporting non-embark stuff: + -- the following values might need to be modified + local x1 = spawnPosCentre.x - 15 + local x2 = spawnPosCentre.x + 15 + local y1 = spawnPosCentre.y - 15 + local y2 = spawnPosCentre.y + 15 + local z1 = spawnPosCentre.z - 3 -- units can be spread across multiple z-levels when embarking on a mountain + local z2 = spawnPosCentre.z + 3 + + -- Move citizens and pets: + local unitsAtSpawn = dfhack.units.getUnitsInBox(x1, y1, z1, x2, y2, z2) + local movedUnit = false + for i, unit in ipairs(unitsAtSpawn) do + if unit.civ_id == df.global.plotinfo.civ_id and not unit.flags1.inactive and not unit.flags2.killed then + local pos = embarkTiles[math.random(1, #embarkTiles)] + dfhack.units.teleport(unit, pos) + reveal(pos) + movedUnit = true + end + end + if movedUnit then + blockGlowingBarrierAnnouncements(true) -- this is separate from the reveal() function as it only needs to be called once per tick, regardless of how many times reveal() has been run + end + + -- Move wagon contents: + local wagonFound = false + for _, wagon in ipairs(df.global.world.buildings.other.WAGON) do --as:df.building_wagonst + if wagon.age == 0 then -- just in case there's an older wagon present for some reason + local contained = wagon.contained_items + for i = #contained - 1, 0, -1 do + if contained[i].use_mode == df.building_item_role_type.TEMP then -- actual contents (as opposed to building components) + local item = contained[i].item + -- dfhack.items.moveToGround() does not handle items within buildings, so do this manually: + contained:erase(i) + for k = #item.general_refs - 1, 0, -1 do + if item.general_refs[k]._type == df.general_ref_building_holderst then + item.general_refs:erase(k) + end + end + item.flags.in_building = false + item.flags.on_ground = true + local pos = embarkTiles[math.random(1, #embarkTiles)] + item.pos:assign(pos) + selectedBlock.items:insert('#', item.id) + selectedBlock.occupancy[pos.x % 16][pos.y % 16].item = true + end + end + dfhack.buildings.deconstruct(wagon) + wagon.flags.almost_deleted = true -- wagon vanishes a tick later + wagonFound = true + break + end + end + + -- Move items scattered around the spawn point if there's no wagon: + if not wagonFound then + for _, item in ipairs(df.global.world.items.other.IN_PLAY) do + local flags = item.flags + local item_pos = xyz2pos(dfhack.items.getPosition(item)) + -- items spawned into mid-air incorrectly have the `in_job` flag set + if flags.in_job then + local job_ref = dfhack.items.getSpecificRef(item, df.specific_ref_type.JOB) + if job_ref then + dfhack.job.removeJob(job_ref.data.job) + end + flags.in_job = false + end + if item.age == 0 -- embark equipment consists of newly created items + and item_pos.x >= x1 and item_pos.x <= x2 + and item_pos.y >= y1 and item_pos.y <= y2 + and item_pos.z >= z1 and item_pos.z <= z2 + and not flags.in_inventory + and not flags.in_building + and not flags.construction + and not flags.spider_web + and not flags.encased + then + dfhack.items.moveToGround(item, embarkTiles[math.random(1, #embarkTiles)]) + end + end + end + + dlg.showMessage('deep-embark', 'Please unpause to zoom to your deep embark.', COLOR_WHITE) +end + +function deepEmbark(cavernType, blockDemons) + if not cavernType then + qerror('Cavern type not specified!') + end + + local cavernBlocks = getFeatureBlocks(getFeatureID(cavernType)) + if #cavernBlocks == 0 then + qerror(cavernType .. " not found!") + end + + local moved = false + for n = 1, #cavernBlocks do + local i = math.random(1, #cavernBlocks) + local selectedBlock = df.global.world.map.map_blocks[cavernBlocks[i]] + local embarkTiles = getValidEmbarkTiles(selectedBlock) + if #embarkTiles >= 20 then -- value chosen arbitrarily; might want to increase/decrease (determines how cramped the embark spot is allowed to be) + moveEmbarkStuff(selectedBlock, embarkTiles) + moved = true + break + end + table.remove(cavernBlocks, i) + end + if not moved then + qerror('Insufficient space at ' .. cavernType) + end + + if blockDemons then + disableSpireDemons() + end +end + +function disableSpireDemons() + -- marks underworld spires on the map as having been breached already, preventing HFS events + for _, spire in ipairs(df.global.world.event.deep_vein_hollows) do + spire.triggered = true + end +end + +function inEmbarkMode() + if df.global.gametype ~= df.game_type.DWARF_MAIN then -- is always set at fortress mode setup + return false + end + local embarkViewScreens = { + df.viewscreen_adopt_regionst, -- onLoad.init kicks in early; this is the viewscreen present at this stage (the 'loading world' viewscreen is also present at adventure mode setup and legends mode, hence the game_type check above) + df.viewscreen_choose_start_sitest, + df.viewscreen_setupdwarfgamest + } + local view = dfhack.gui.getCurViewscreen() + for _, valid in ipairs(embarkViewScreens) do + if view._type == valid then + return true + end + end + return false +end + +local validArgs = utils.invert({ + 'depth', + 'atReclaim', + 'blockDemons', + 'clear', + 'help' +}) +local args = utils.processArgs({ ... }, validArgs) + +if moduleMode then + return +end + +if args.help then + print(dfhack.script_help()) + return +end + +if args.clear then + dfhack.onStateChange.DeepEmbarkMonitor = nil + print("Cleared settings; now embarking normally.") + return +end + +if not args.depth then + qerror('Depth not specified! Enter "help deep-embark" for more information.') +end + +local validDepths = { + ["CAVERN_1"] = true, + ["CAVERN_2"] = true, + ["CAVERN_3"] = true, + ["UNDERWORLD"] = true +} + +if not validDepths[args.depth] then + qerror("Invalid depth: " .. args.depth) +end + +local consoleMode = dfhack.is_interactive() -- true if the script has been called directly from the DFHack console, false if called from onLoad.init + +if consoleMode and not inEmbarkMode() then + -- if running from the console (not onLoad.init), abort if not currently in an embark viewscreen. + qerror( + 'When run from the command line, this script should be run during the embark setup screens. Enter "help deep-embark" for more information.') +end + +if consoleMode then + print("Embarking at: " .. tostring(args.depth)) +end + +dfhack.onStateChange.DeepEmbarkMonitor = function(event) + if event == SC_VIEWSCREEN_CHANGED then -- I initially tried using SC_MAP_LOADED, but the map appears to be loaded too early when reclaiming sites + local view = dfhack.gui.getCurViewscreen() + if not consoleMode and not args.atReclaim and df.global.gametype == df.game_type.DWARF_RECLAIM then -- it's assumed that a player who chooses to run the script from console whilst reclaiming knows what they're doing, so there's no need to check for -atReclaim in this scenario + dfhack.onStateChange.DeepEmbarkMonitor = nil -- stop monitoring + return -- don't deepEmbark if running from onLoad.init and in reclaim mode without -atReclaim + elseif view._type == df.viewscreen_choose_start_sitest then -- on embark screen + if view.choosing_embark or view.choosing_reclaim then -- on a fresh embark, or on a reclaim + deepEmbark(args.depth, args.blockDemons) + dfhack.onStateChange.DeepEmbarkMonitor = nil + end + elseif view._type == df.viewscreen_dwarfmodest then -- we're in game. If we got here then we never got an embark screen, so this is loading a save and we abort. + dfhack.onStateChange.DeepEmbarkMonitor = nil + end + elseif event == SC_WORLD_UNLOADED then -- embark aborted + dfhack.onStateChange.DeepEmbarkMonitor = nil + end +end diff --git a/deteriorate.lua b/deteriorate.lua new file mode 100644 index 0000000000..42c0498aad --- /dev/null +++ b/deteriorate.lua @@ -0,0 +1,392 @@ +-- Cause selected item types to quickly rot away +--@module = true +--@enable = true + +local argparse = require('argparse') +local utils = require('utils') + +-------------------- +-- state + +local GLOBAL_KEY = 'deteriorate' + +local categories = { + 'clothes', + 'food', + 'corpses', + 'usable-parts', + 'unusable-parts', +} + +local aliases = { + parts={'usable-parts', 'unusable-parts'}, + all=categories, +} + +local function get_default_state() + local default_state = { + enabled=false, + categories={}, + } + for _,category in ipairs(categories) do + local default_enabled = category == 'corpses' or category == 'unusable-parts' + default_state.categories[category] = { + enabled=default_enabled, + frequency=1, + last_cycle_tick=0, + } + end + return default_state +end + +state = state or get_default_state() + +function isEnabled() + return state.enabled +end + +local function persist_state() + dfhack.persistent.saveSiteData(GLOBAL_KEY, state) +end + +----------------------- +-- deterioration logic + +local function get_clothes_vectors() + return { + df.global.world.items.other.GLOVES, + df.global.world.items.other.ARMOR, + df.global.world.items.other.SHOES, + df.global.world.items.other.PANTS, + df.global.world.items.other.HELM, + } +end + +local function get_food_vectors() + return { + df.global.world.items.other.FISH, + df.global.world.items.other.FISH_RAW, + df.global.world.items.other.EGG, + df.global.world.items.other.CHEESE, + df.global.world.items.other.PLANT, + df.global.world.items.other.PLANT_GROWTH, + df.global.world.items.other.FOOD, + df.global.world.items.other.MEAT, + df.global.world.items.other.LIQUID_MISC, + } +end + +local function get_corpse_vectors() + return { + df.global.world.items.other.CORPSE, + df.global.world.items.other.REMAINS, + } +end + +local function get_parts_vectors() + return { + df.global.world.items.other.CORPSEPIECE, + } +end + +local function is_valid_clothing(item) + -- includes discarded owned clothes + return item.subtype.armorlevel == 0 and item.flags.on_ground + and item.wear > 0 +end + +local function is_valid_food(item) + if not df.item_liquid_miscst:is_instance(item) then + return true + end + local mi = dfhack.matinfo.decode(item) + return mi:getToken():endswith(':MILK') +end + +-- TODO: is just checking in_building sufficient, or do we need to validate +-- that the building it is in is a coffin? +local function is_entombed(item) + return item.flags.in_building +end + +local function is_valid_corpse(item) + return not is_entombed(item) +end + +local usable_types = { + 'plant', + 'silk', + 'leather', + 'bone', + 'shell', + 'wood', + 'soap', + 'tooth', + 'horn', + 'pearl', + 'skull', + 'hair_wool', + 'yarn', +} + +local function is_usable_corpse_piece(item) + if item.flags.dead_dwarf or item.corpse_flags.unbutchered then + return false + end + for _,flag in ipairs(usable_types) do + if item.corpse_flags[flag] then return true end + end + return false +end + +local function is_valid_usable_corpse_piece(item) + return not is_entombed(item) and is_usable_corpse_piece(item) +end + +local function is_valid_unusable_corpse_piece(item) + return not is_entombed(item) and not is_usable_corpse_piece(item) +end + +-- different algorithm for clothes so they rot away when they become tattered +local function increment_clothes_wear(item) + item.wear_timer = math.ceil(item.wear_timer * (item.wear + 0.5)) + return item.wear > 2 +end + +local function increment_wear(threshold, item) + item.wear_timer = item.wear_timer + 1 + if item.wear_timer > threshold then + item.wear_timer = 0 + item.wear = item.wear + 1 + end + return item.wear > 3 +end + +local function deteriorate_items(now, get_item_vectors_fn, is_valid_fn, increment_wear_fn) + local items_to_remove = {} + for _,v in ipairs(get_item_vectors_fn()) do + for _,item in ipairs(v) do + if is_valid_fn(item) and (now or increment_wear_fn(item)) and not item.flags.garbage_collect then + table.insert(items_to_remove, item) + end + end + end + for _,item in ipairs(items_to_remove) do + print(('deteriorate: %s crumbles away to dust'):format(dfhack.items.getReadableDescription(item))) + dfhack.items.remove(item) + end + return #items_to_remove +end + +local function mk_deteriorate_fn(get_item_vectors_fn, is_valid_fn, increment_wear_fn) + return function(now) + return deteriorate_items(now, get_item_vectors_fn, is_valid_fn, increment_wear_fn) + end +end + +local category_fns = { + clothes=mk_deteriorate_fn(get_clothes_vectors, is_valid_clothing, increment_clothes_wear), + food=mk_deteriorate_fn(get_food_vectors, is_valid_food, curry(increment_wear, 24)), + corpses=mk_deteriorate_fn(get_corpse_vectors, is_valid_corpse, curry(increment_wear, 24)), + ['usable-parts']=mk_deteriorate_fn(get_parts_vectors, is_valid_usable_corpse_piece, curry(increment_wear, 24)), + ['unusable-parts']=mk_deteriorate_fn(get_parts_vectors, is_valid_unusable_corpse_piece, curry(increment_wear, 24)), +} + +---------------------------- +-- cycle and timer logic + +local TICKS_PER_DAY = 1200 +local TICKS_PER_MONTH = 28 * TICKS_PER_DAY +local TICKS_PER_YEAR = 12 * TICKS_PER_MONTH + +local function get_normalized_tick() + return dfhack.world.ReadCurrentTick() + TICKS_PER_YEAR * dfhack.world.ReadCurrentYear() +end + +timeout_ids = timeout_ids or {} + +local function event_loop(category) + local category_data = state.categories[category] + if not state.enabled or not category_data.enabled then return end + + local current_tick = get_normalized_tick() + local ticks_per_cycle = math.max(1, math.floor(TICKS_PER_DAY * category_data.frequency)) + local timeout_ticks = ticks_per_cycle + + if current_tick - category_data.last_cycle_tick < ticks_per_cycle then + timeout_ticks = category_data.last_cycle_tick - current_tick + ticks_per_cycle + else + category_fns[category](false) + category_data.last_cycle_tick = current_tick + persist_state() + end + timeout_ids[category] = dfhack.timeout(timeout_ticks, 'ticks', curry(event_loop, category)) +end + +-- launches timer. first cycle will be after the configured frequency +local function start_category(category, category_data, current_tick) + category_data = category_data or state.categories[category] + category_data.last_cycle_tick = current_tick or get_normalized_tick() + event_loop(category) +end + +local function stop_category(category) + local timeout_id = timeout_ids[category] + if timeout_id then + dfhack.timeout_active(timeout_id, nil) -- cancel callback + timeout_ids[category] = nil + end +end + +local function do_enable() + if state.enabled then return end + + state.enabled = true + local current_tick = get_normalized_tick() + for _,category in ipairs(categories) do + local category_data = state.categories[category] + if category_data.enabled then + start_category(category, category_data, current_tick) + end + end +end + +local function do_disable() + if not state.enabled then return end + + state.enabled = false + for _,category in ipairs(categories) do + stop_category(category) + end +end + +dfhack.onStateChange[GLOBAL_KEY] = function(sc) + if sc == SC_MAP_UNLOADED then + do_disable() + return + end + + if sc ~= SC_MAP_LOADED or not dfhack.world.isFortressMode() then + return + end + + state = get_default_state() + utils.assign(state, dfhack.persistent.getSiteData(GLOBAL_KEY, state)) + + for _,category in ipairs(categories) do + event_loop(category) + end +end + +--------------------- +-- CLI + +if dfhack_flags.module then + return +end + +if dfhack_flags.enable then + if dfhack_flags.enable_state then + do_enable() + else + do_disable() + end +end + +local function parse_categories(arg) + local list = {} + for _,v in ipairs(argparse.stringList(arg)) do + if aliases[v] then + for _,alias in ipairs(aliases[v]) do + table.insert(list, alias) + end + elseif category_fns[v] then + table.insert(list, v) + else + qerror(('unrecognized category: "%s"'):format(v)) + end + end + if #list == 0 then + qerror('no categories specified') + end + return list +end + +local function status() + local running_str = state.enabled and 'Running' or 'Would run' + print(('deteriorate is %s'):format(state.enabled and 'enabled' or 'disabled')) + print() + for _,category in ipairs(categories) do + local status_str = 'Stopped' + local category_data = state.categories[category] + if category_data.enabled then + status_str = ('%s every %s day%s') :format(running_str, + category_data.frequency, category_data.frequency == 1 and '' or 's') + end + print(('%18s: %s'):format(category, status_str)) + end +end + +local help = false + +local positionals = argparse.processArgsGetopt({...}, { + {'h', 'help', handler=function() help = true end}, +}) + +local command = table.remove(positionals, 1) +if command == 'help' or help then + print(dfhack.script_help()) + return +end + +if not command or command == 'status' then + status() +elseif command == 'enable' then + local cats = parse_categories(positionals[1]) + for _,v in ipairs(cats) do + if state.categories[v].enabled then + goto continue + end + state.categories[v].enabled = true + if state.enabled then + start_category(v) + end + ::continue:: + end +elseif command == 'disable' then + local cats = parse_categories(positionals[1]) + for _,v in ipairs(cats) do + if not state.categories[v].enabled then + goto continue + end + state.categories[v].enabled = false + if state.enabled then + stop_category(v) + end + ::continue:: + end +elseif command == 'frequency' or command == 'freq' then + local freq = tonumber(positionals[1]) + if not freq or freq <= 0 then + qerror('frequency must be greater than 0') + end + local cats = parse_categories(positionals[2]) + for _,v in ipairs(cats) do + state.categories[v].frequency = freq + if state.enabled then + stop_category(v) + start_category(v) + end + end +elseif command == 'now' then + local cats = parse_categories(positionals[1]) + local count = 0 + for _,v in ipairs(cats) do + count = count + category_fns[v](true) + end + print(('Deteriorated %d item%s'):format(count, count == 1 and '' or 's')) +else + qerror('unrecognized command: "' .. command .. '"') +end + +persist_state() diff --git a/deteriorateclothes.rb b/deteriorateclothes.rb deleted file mode 100644 index 33084185e7..0000000000 --- a/deteriorateclothes.rb +++ /dev/null @@ -1,81 +0,0 @@ -# Increase the rate at which clothes wear out -=begin - -deteriorateclothes -================== -Somewhere between a "mod" and a "fps booster", with a small impact on -vanilla gameplay. All of those slightly worn wool shoes that dwarves -scatter all over the place will deteriorate at a greatly increased rate, -and eventually just crumble into nothing. As warm and fuzzy as a dining -room full of used socks makes your dwarves feel, your FPS does not like it. - -Usage: ``deteriorateclothes (start|stop)`` - -=end - -class DeteriorateClothes - - def initialize - end - - def process - return false unless @running - - items = [df.world.items.other[:GLOVES], - df.world.items.other[:ARMOR], - df.world.items.other[:SHOES], - df.world.items.other[:PANTS], - df.world.items.other[:HELM]] - - items.each { |type| - type.each { |i| - if (i.subtype.armorlevel == 0 and i.flags.on_ground == true and i.wear > 0) - i.wear_timer *= i.wear + 0.5 - if (i.wear > 2) - i.flags.garbage_collect = true - end - end - } - } - - - end - - def start - @onupdate = df.onupdate_register('deteriorateclothes', 1200, 1200) { process } - @running = true - - puts "Deterioration of old clothes commencing..." - - end - - def stop - df.onupdate_unregister(@onupdate) - @running = false - end - - def status - @running ? 'Running.' : 'Stopped.' - end - -end - -case $script_args[0] -when 'start' - if ($DeteriorateClothes) - $DeteriorateClothes.stop - end - - $DeteriorateClothes = DeteriorateClothes.new - $DeteriorateClothes.start - -when 'end', 'stop' - $DeteriorateClothes.stop - -else - if $DeteriorateClothes - puts $DeteriorateClothes.status - else - puts 'Not loaded.' - end -end \ No newline at end of file diff --git a/deterioratecorpses.rb b/deterioratecorpses.rb deleted file mode 100644 index 56003f6d0e..0000000000 --- a/deterioratecorpses.rb +++ /dev/null @@ -1,106 +0,0 @@ -# Make corpse parts decay and vanish over time -=begin - -deterioratecorpses -================== -Somewhere between a "mod" and a "fps booster", with a small impact on -vanilla gameplay. - -In long running forts, especially evil biomes, you end up with a lot -of toes, teeth, fingers, and limbs scattered all over the place. -Various corpses from various sieges, stray kitten corpses, probably -some heads. Basically, your map will look like a giant pile of -assorted body parts, all of which individually eat up a small part -of your FPS, which collectively eat up quite a bit. - -In addition, this script also targets various butchery byproducts. -Enjoying your thriving animal industry? Your FPS does not. Those -thousands of skulls, bones, hooves, and wool eat up precious FPS -that could be used to kill goblins and elves. Whose corpses will -also get destroyed by the script to kill more goblins and elves. - -This script causes all of those to rot away into nothing after -several months. - -Usage: ``deterioratecorpses (start|stop)`` - -=end - -class DeteriorateCorpses - - def initialize - end - - def process - return false unless @running - - df.world.items.other[:ANY_CORPSE].each { |i| - if (i.flags.dead_dwarf == false) - i.wear_timer += 1 - if (i.wear_timer > 24 + rand(8)) - i.wear_timer = 0 - i.wear += 1 - end - if (i.wear > 3) - i.flags.garbage_collect = true - end - - end - - } - - df.world.items.other[:REMAINS].each { |i| - if (i.flags.dead_dwarf == false) - i.wear_timer += 1 - if (i.wear_timer > 6) - i.wear_timer = 0 - i.wear += 1 - end - if (i.wear > 3) - i.flags.garbage_collect = true - end - - end - - } - - end - - def start - @onupdate = df.onupdate_register('deterioratecorpses', 1200, 1200) { process } - @running = true - - puts "Deterioration of body parts commencing..." - - end - - def stop - df.onupdate_unregister(@onupdate) - @running = false - end - - def status - @running ? 'Running.' : 'Stopped.' - end - -end - -case $script_args[0] -when 'start' - if ($DeteriorateCorpses) - $DeteriorateCorpses.stop - end - - $DeteriorateCorpses = DeteriorateCorpses.new - $DeteriorateCorpses.start - -when 'end', 'stop' - $DeteriorateCorpses.stop - -else - if $DeteriorateCorpses - puts $DeteriorateCorpses.status - else - puts 'Not loaded.' - end -end \ No newline at end of file diff --git a/deterioratefood.rb b/deterioratefood.rb deleted file mode 100644 index c59171ea56..0000000000 --- a/deterioratefood.rb +++ /dev/null @@ -1,96 +0,0 @@ -# Food and plants decay, and vanish after a few months -=begin - -deterioratefood -=============== -Somewhere between a "mod" and a "fps booster", with a small impact on -vanilla gameplay. - -With this script running, all food and plants wear out and disappear -after several months. Barrels and stockpiles will keep them from -rotting, but it won't keep them from decaying. No more sitting on a -hundred years worth of food. No more keeping barrels of pig tails -sitting around until you decide to use them. Either use it, eat it, -or lose it. Seeds, are excluded from this, if you aren't planning on -using your pig tails, hold onto the seeds for a rainy day. - -This script is...pretty far reaching. However, almost all long -running forts I've had end up sitting on thousands and thousands of -food items. Several thousand cooked meals, three thousand plump -helmets, just as many fish and meat. It gets pretty absurd. And your -FPS doesn't like it. - -Usage: ``deterioratefood (start|stop)`` - -=end - -class DeteriorateFood - - def initialize - end - - def process - return false unless @running - - items = [df.world.items.other[:FISH], - df.world.items.other[:FISH_RAW], - df.world.items.other[:EGG], - df.world.items.other[:CHEESE], - df.world.items.other[:PLANT], - df.world.items.other[:PLANT_GROWTH], - df.world.items.other[:FOOD]] - - items.each { |type| - type.each { |i| - i.wear_timer += 1 - if (i.wear_timer > 24 + rand(8)) - i.wear_timer = 0 - i.wear += 1 - end - if (i.wear > 3) - i.flags.garbage_collect = true - end - } - } - - - end - - def start - @onupdate = df.onupdate_register('deterioratefood', 1200, 1200) { process } - @running = true - - puts "Deterioration of food commencing..." - - end - - def stop - df.onupdate_unregister(@onupdate) - @running = false - end - - def status - @running ? 'Running.' : 'Stopped.' - end - -end - -case $script_args[0] -when 'start' - if ($DeteriorateFood) - $DeteriorateFood.stop - end - - $DeteriorateFood = DeteriorateFood.new - $DeteriorateFood.start - -when 'end', 'stop' - $DeteriorateFood.stop - -else - if $DeteriorateFood - puts $DeteriorateFood.status - else - puts 'Not loaded.' - end -end \ No newline at end of file diff --git a/devel/about.txt b/devel/about.txt index 12b1f18539..77e916c466 100644 --- a/devel/about.txt +++ b/devel/about.txt @@ -1,5 +1,5 @@ ``devel/*`` scripts are intended for developer use, but many may -be of interest to anyone investigating odd phenomema or just messing +be of interest to anyone investigating odd phenomena or just messing around. They are documented to encourage such inquiry. Some can PERMANENTLY DAMAGE YOUR SAVE if misused, so please be careful. diff --git a/devel/all-bob.lua b/devel/all-bob.lua index ef4dc32d0a..59a7fe09d4 100644 --- a/devel/all-bob.lua +++ b/devel/all-bob.lua @@ -1,15 +1,6 @@ -- Changes the first name of all units to "Bob" --author expwnent --- ---[====[ -devel/all-bob -============= -Changes the first name of all units to "Bob". -Useful for testing `modtools/interaction-trigger` events. - -]====] - -for _,v in ipairs(df.global.world.units.all) do +for _,v in ipairs(df.global.world.units.active) do v.name.first_name = "Bob" end diff --git a/devel/annc-monitor.lua b/devel/annc-monitor.lua index 096289643f..2b91e0f4eb 100644 --- a/devel/annc-monitor.lua +++ b/devel/annc-monitor.lua @@ -8,28 +8,31 @@ Displays announcements and reports in the console. :enable|start: Begins monitoring :disable|stop: Stops monitoring -:interval X: Sets the delay between checks for - new announcements to ``X`` frames +:report enable: Show combat reports +:report disable: Only show announcements ]====] -VERSION = '0.2' +VERSION = '0.3' local eventful = require 'plugins.eventful' enabled = enabled or false +filter = filter or false function usage() print [[ Usage: annc-monitor start|enable: Begin monitoring annc-monitor stop|disable: End monitoring + annc-monitor report enable: Show combat reports + annc-monitor report disable: Only show announcements ]] end function log(s, color) dfhack.color(color) - print(dfhack.df2utf(s)) + print(dfhack.df2console(s)) dfhack.color(COLOR_RESET) end @@ -42,6 +45,14 @@ if #args >= 1 then enabled = true elseif args[1] == 'stop' or args[1] == 'disable' then enabled = false + elseif args[1] == 'report' and #args >= 2 then + if args[2] == 'enable' then + filter = false + elseif args[2] == 'disable' then + filter = true + else + usage() + end else usage() end @@ -53,8 +64,10 @@ eventful.enableEvent(eventful.eventType.REPORT, 1) function eventful.onReport.annc_monitor(id) if enabled and dfhack.isWorldLoaded() then local annc = df.report.find(id) + if filter and not annc.flags.announcement then + return + end local color = annc.color + (annc.bright and 8 or 0) log(annc.text, color) end end - diff --git a/devel/block-borders.lua b/devel/block-borders.lua new file mode 100644 index 0000000000..445bdcaab5 --- /dev/null +++ b/devel/block-borders.lua @@ -0,0 +1,92 @@ +-- overlay that displays map block borders + +local gui = require('gui') +local guidm = require "gui.dwarfmode" +local widgets = require('gui.widgets') + +local DRAW_CHARS = { + ns = string.char(179), + ew = string.char(196), + ne = string.char(192), + nw = string.char(217), + se = string.char(218), + sw = string.char(191), +} + +BlockBorders = defclass(BlockBorders, widgets.Window) +BlockBorders.ATTRS { + frame_title='Block Borders', + frame={t=20, r=3, w=29, h=7}, + autoarrange_subviews=true, + autoarrange_gap=1, +} + +function BlockBorders:init() + self:addviews{ + widgets.ToggleHotkeyLabel{ + view_id='draw', + key='CUSTOM_CTRL_D', + label='Draw borders:', + initial_option=true, + }, + widgets.CycleHotkeyLabel{ + view_id='size', + key='CUSTOM_CTRL_B', + label=' Block size:', + options={16, 48}, + }, + } +end + +function BlockBorders:render_overlay() + local block_size = self.subviews.size:getOptionValue() + local block_end = block_size - 1 + guidm.renderMapOverlay(function(pos, is_cursor) + if is_cursor then return end + local block_x = pos.x % block_size + local block_y = pos.y % block_size + local key + if block_x == 0 and block_y == 0 then + key = 'se' + elseif block_x == 0 and block_y == block_end then + key = 'ne' + elseif block_x == block_end and block_y == 0 then + key = 'sw' + elseif block_x == block_end and block_y == block_end then + key = 'nw' + elseif block_x == 0 or block_x == block_end then + key = 'ns' + elseif block_y == 0 or block_y == block_end then + key = 'ew' + end + if not key then return nil end + return COLOR_LIGHTCYAN, DRAW_CHARS[key] + end) +end + +function BlockBorders:onRenderFrame(dc, rect) + if self.subviews.draw:getOptionValue() then + self:render_overlay() + end + BlockBorders.super.onRenderFrame(self, dc, rect) +end + +BlockBordersScreen = defclass(BlockBordersScreen, gui.ZScreen) +BlockBordersScreen.ATTRS { + focus_path='block-borders', + pass_movement_keys=true, +} + +function BlockBordersScreen:init() + self:addviews{BlockBorders{}} +end + +function BlockBordersScreen:onDismiss() + view = nil +end + +if not dfhack.isMapLoaded() then + qerror('This script requires a fortress map to be loaded') +end + +view = view and view:raise() or BlockBordersScreen{}:show() diff --git a/devel/check-release.lua b/devel/check-release.lua index 96cf66838a..b690ac1a30 100644 --- a/devel/check-release.lua +++ b/devel/check-release.lua @@ -1,9 +1,4 @@ -- basic check for release readiness ---[====[ -devel/check-release -=================== -Basic checks for release readiness -]====] local ok = true function err(s) diff --git a/devel/click-monitor.lua b/devel/click-monitor.lua index 0bace7d436..685a21fb91 100644 --- a/devel/click-monitor.lua +++ b/devel/click-monitor.lua @@ -1,28 +1,7 @@ -- Displays the mouse (grid) coordinates when the mouse is clicked --@ enable = true ---[====[ - -devel/click-monitor -=================== -Displays the grid coordinates of mouse clicks in the console. -Useful for plugin/script development. - -Usage: ``devel/click-monitor start|stop`` - -]====] - -VERSION = '0.2' - active = active or false -function usage() - print [[ -Usage: - click-monitor enable|start: Begin monitoring - click-monitor disable|stop: End monitoring -]] -end - function set_timeout() dfhack.timeout(1, 'frames', check_click) end @@ -42,8 +21,8 @@ end function check_click() local s = '' local color = COLOR_RESET - for _, attr in pairs({'mouse_lbut', 'mouse_rbut', 'mouse_lbut_down', - 'mouse_rbut_down', 'mouse_lbut_lift', 'mouse_rbut_lift'}) do + for _, attr in pairs({'mouse_lbut', 'mouse_rbut', 'mouse_mbut', 'mouse_lbut_down', + 'mouse_rbut_down', 'mouse_mbut_down', 'mouse_lbut_lift', 'mouse_rbut_lift', 'mouse_mbut_lift'}) do local enabler = df.global.enabler --as:number[] if enabler[attr] ~= 0 then s = s .. '[' .. attr:sub(7) .. '] ' @@ -74,8 +53,8 @@ if #args == 1 then elseif args[1] == 'stop' or args[1] == 'disable' then active = false else - usage() + print(dfhack.script_help()) end else - usage() + print(dfhack.script_help()) end diff --git a/devel/dump-offsets.lua b/devel/dump-offsets.lua index ee668c2e41..5f49ce1662 100644 --- a/devel/dump-offsets.lua +++ b/devel/dump-offsets.lua @@ -1,171 +1,9 @@ -- Dump all global addresses ---[====[ - -devel/dump-offsets -================== - -WARNING: THIS SCRIPT IS STRICTLY FOR DFHACK DEVELOPERS. - -Running this script on a new DF version will NOT -MAKE IT RUN CORRECTLY if any data structures -changed, thus possibly leading to CRASHES AND/OR -PERMANENT SAVE CORRUPTION. - -This dumps the contents of the table of global addresses (new in 0.44.01). - -Passing global names as arguments calls setAddress() to set those globals' -addresses in-game. Passing "all" does this for all globals. - -]====] - -GLOBALS = { - version = "version", - min_load_version = "min_load_version", - movie_version = "movie_version", - basic_seed = "basic_seed", - enabler = "enabler", - cursor = "cursor", - point = "selection_rect", - gamemode = "gamemode", - gametype = "gametype", - menuposition = "ui_menu_width", - itemmade = "created_item_type", - itemmade_subtype = "created_item_subtype", - itemmade_subcat1 = "created_item_mattype", - itemmade_subcat2 = "created_item_matindex", - itemmade_number = "created_item_count", - mainview = "map_renderer", - d_init = "d_init", - title = "title", - title2 = "title_spaced", - event_flow = "flows", - gps = "gps", - gview = "gview", - init = "init", - texture = "texture", - plot_event = "timed_events", - plotinfo = "ui", - adventure = "ui_advmode", - buildreq = "ui_build_selector", - buildjob_type = "ui_building_assign_type", - buildjob_selected = "ui_building_assign_is_marked", - buildjob_unit = "ui_building_assign_units", - buildjob_item = "ui_building_assign_items", - looklist = "ui_look_list", - game = "ui_sidebar_menus", - world = "world", - year = "cur_year", - season_count = "cur_year_tick", - precise_phase = "cur_year_tick_advmode", - season_timer = "cur_season_tick", - season = "cur_season", - cur_weather = "current_weather", - assignbuildingjobs = "process_jobs", - assigndesjobs = "process_dig", - paused = "pause_state", - modeunit = "ui_selected_unit", - modeview = "ui_unit_view_mode", - modepage = "ui_look_cursor", - modeitem = "ui_building_item_cursor", - addingtask = "ui_workshop_in_add", - modejob = "ui_workshop_job_cursor", - buildjob_assignroom = "ui_building_in_assign", - buildjob_sizeroom = "ui_building_in_resize", - addingtask_sub = "ui_lever_target_type", - buildjob_sizerad = "ui_building_resize_radius", - scrollx = "window_x", - scrolly = "window_y", - scrollz = "window_z", - DEBUG_CONTINUOUS = "debug_nopause", - DEBUG_NOMOOD = "debug_nomoods", - DEBUG_SAFEDWARVES = "debug_combat", - DEBUG_NOANIMALS = "debug_wildlife", - DEBUG_NOTHIRST = "debug_nodrink", - DEBUG_NOHUNGER = "debug_noeat", - DEBUG_NOSLEEP = "debug_nosleep", - DEBUG_VISIBLEAMBUSHERS = "debug_showambush", - DEBUG_QUICKMODE_MINING = "debug_fastmining", - DEBUG_NEVERBERSERK = "debug_noberserk", - DEBUG_MEGAFAST = "debug_turbospeed", - gamemode_cansave = "save_on_exit", - standingorder_butcher = "standing_orders_auto_butcher", - standingorder_collect_web = "standing_orders_auto_collect_webs", - standingorder_fishery = "standing_orders_auto_fishery", - standingorder_kiln = "standing_orders_auto_kiln", - standingorder_kitchen = "standing_orders_auto_kitchen", - standingorder_loom = "standing_orders_auto_loom", - standingorder_other = "standing_orders_auto_other", - standingorder_slaughter = "standing_orders_auto_slaughter", - standingorder_smelter = "standing_orders_auto_smelter", - standingorder_tan = "standing_orders_auto_tan", - standingorder_gatherrefuse_chasm_bones = "standing_orders_dump_bones", - standingorder_gatherrefuse_chasm_corpses = "standing_orders_dump_corpses", - standingorder_gatherrefuse_chasm_strand_tissue = "standing_orders_dump_hair", - standingorder_gatherrefuse_chasm_othernonmetal = "standing_orders_dump_other", - standingorder_gatherrefuse_chasm_shell = "standing_orders_dump_shells", - standingorder_gatherrefuse_chasm_skins = "standing_orders_dump_skins", - standingorder_gatherrefuse_chasm_skulls = "standing_orders_dump_skulls", - standingorder_allharvest = "standing_orders_farmer_harvest", - standingorder_autoforbid_other_items = "standing_orders_forbid_other_dead_items", - standingorder_autoforbid_other_corpse = "standing_orders_forbid_other_nohunt", - standingorder_autoforbid_your_corpse = "standing_orders_forbid_own_dead", - standingorder_autoforbid_your_items = "standing_orders_forbid_own_dead_items", - standingorder_autoforbid_projectile = "standing_orders_forbid_used_ammo", - standingorder_gatheranimals = "standing_orders_gather_animals", - standingorder_gatherbodies = "standing_orders_gather_bodies", - standingorder_gatherfood = "standing_orders_gather_food", - standingorder_gatherfurniture = "standing_orders_gather_furniture", - standingorder_gatherstone = "standing_orders_gather_minerals", - standingorder_gatherrefuse = "standing_orders_gather_refuse", - standingorder_gatherrefuse_outside = "standing_orders_gather_refuse_outside", - standingorder_gatherrefuse_outside_vermin = "standing_orders_gather_vermin_remains", - standingorder_gatherwood = "standing_orders_gather_wood", - option_exceptions = "standing_orders_job_cancel_announce", - standingorder_mixfoods = "standing_orders_mix_food", - standingorder_dyed_clothes = "standing_orders_use_dyed_cloth", - standingorder_zone_drinking = "standing_orders_zoneonly_drink", - standingorder_zone_fishing = "standing_orders_zoneonly_fish", - next_activity_global_id = "activity_next_id", - next_agreement_global_id = "agreement_next_id", - next_army_controller_global_id = "army_controller_next_id", - next_army_global_id = "army_next_id", - next_army_tracking_info_global_id = "army_tracking_info_next_id", - next_art_imagechunk_global_id = "art_image_chunk_next_id", - next_artifact_global_id = "artifact_next_id", - next_belief_system_global_id = "belief_system_next_id", - next_building_global_id = "building_next_id", - next_crime_global_id = "crime_next_id", - next_cultural_identity_global_id = "cultural_identity_next_id", - next_dance_form_global_id = "dance_form_next_id", - next_civ_global_id = "entity_next_id", - next_flow_guide_global_id = "flow_guide_next_id", - next_formation_global_id = "formation_next_id", - next_histeventcol_global_id = "hist_event_collection_next_id", - next_histevent_global_id = "hist_event_next_id", - next_histfig_global_id = "hist_figure_next_id", - next_identity_global_id = "identity_next_id", - next_incident_global_id = "incident_next_id", - next_interaction_instance_global_id = "interaction_instance_next_id", - next_item_global_id = "item_next_id", - next_job_global_id = "job_next_id", - next_machine_global_id = "machine_next_id", - next_musical_form_global_id = "musical_form_next_id", - next_nem_global_id = "nemesis_next_id", - next_occupation_global_id = "occupation_next_id", - next_poetic_form_global_id = "poetic_form_next_id", - next_proj_global_id = "proj_next_id", - next_rhythm_global_id = "rhythm_next_id", - next_scale_global_id = "scale_next_id", - next_schedule_global_id = "schedule_next_id", - next_soul_global_id = "soul_next_id", - next_squad_global_id = "squad_next_id", - next_task_global_id = "task_next_id", - next_unitchunk_global_id = "unit_chunk_next_id", - next_unit_global_id = "unit_next_id", - next_vehicle_global_id = "vehicle_next_id", - next_written_content_global_id = "written_content_next_id", -} +GLOBALS = {} +for k, v in pairs(df.global._fields) do + GLOBALS[v.original_name] = k +end function read_cstr(addr) local s = '' @@ -186,29 +24,44 @@ local data = ms.get_data_segment() or qerror('Could not find data segment') local search if dfhack.getArchitecture() == 64 then - search = {0x1234567812345678, 0x8765432187654321} + search = {0x1234567812345678, 0x8765432187654321, 0x89abcdef89abcdef} else - search = {0x12345678, 0x87654321} + search = {0x12345678, 0x87654321, 0x89abcdef} end -local addrs = {} function save_addr(name, addr) print((""):format(name, addr)) if iargs[name] or iargs.all then ms.found_offset(name, addr) end - addrs[name] = addr end -local start = data.intptr_t:find_one(search) +local extended = false +local start, start_addr = data.intptr_t:find_one(search) +if start then + extended = true +else + -- try searching for a non-extended table + table.remove(search, #search) + start = data.intptr_t:find_one(search) +end +if not start then + qerror('Could not find global table header') +end + +if extended then + -- structures only has types for an extended global table + save_addr('global_table', start_addr + (#search * data.intptr_t.esize)) +end local index = 1 +local entry_size = (extended and 3 or 2) while true do - local p_name = data.intptr_t[start + (index * 2)] + local p_name = data.intptr_t[start + (index * entry_size)] if p_name == 0 then break end - local g_addr = data.intptr_t[start + (index * 2) + 1] + local g_addr = data.intptr_t[start + (index * entry_size) + 1] local df_name = read_cstr(p_name) local g_name = GLOBALS[df_name] if df_name:find('^index[12]_') then @@ -222,3 +75,5 @@ while true do end index = index + 1 end + +print('global table length:', index) diff --git a/devel/dump-tooltip-ids.lua b/devel/dump-tooltip-ids.lua new file mode 100644 index 0000000000..5a5c044d72 --- /dev/null +++ b/devel/dump-tooltip-ids.lua @@ -0,0 +1,49 @@ +df_captions = {} -- bimap: DF ID <> DF caption text +dfhack_captions = {} -- bimap: DFHack ID <> DF caption text +hover_instruction = df.global.game.main_interface.hover_instruction + +function xmlescape(s) + return s:gsub("'", '''):gsub('<', '<'):gsub('>', '>') +end + +for i, lines in ipairs(hover_instruction) do + local text = '' + for _, line in ipairs(lines) do + text = text .. ' ' .. line.value + end + text = text:trim():gsub('%s+', ' ') + df_captions[i] = text + df_captions[text] = i +end + +for i in ipairs(df.main_hover_instruction) do + local text = df.main_hover_instruction.attrs[i].caption + dfhack_captions[i] = text + dfhack_captions[text] = i +end + +print(" ") +print(" generated by devel/dump-tooltip-ids") +print(" ") +print("") +for i in ipairs(hover_instruction) do + if i % 10 == 0 then + print(" " .. i) + end + local dfhack_name = nil + if dfhack_captions[df_captions[i]] then + -- known caption, use the enum item name that DFHack has for it + dfhack_name = df.main_hover_instruction[dfhack_captions[df_captions[i]]] + end + + print((" "):format(dfhack_name and (" name='%s'"):format(xmlescape(dfhack_name)) or '')) + print((" "):format(xmlescape(df_captions[i]))) + print(" ") +end +print(" ") + +for k, id in pairs(dfhack_captions) do + if type(k) == 'string' and not df_captions[k] then + dfhack.printerr(('Unmatched caption: %s: was ID %d, key %s'):format(k, id, df.main_hover_instruction[id])) + end +end diff --git a/devel/eventful-client.lua b/devel/eventful-client.lua new file mode 100644 index 0000000000..0fd6b83c67 --- /dev/null +++ b/devel/eventful-client.lua @@ -0,0 +1,153 @@ +-- Testing script for eventful events + +--[====[ +devel/eventful-client +===================== + +Usage:: + + devel/eventful-client help + devel/eventful-client add + devel/eventful-client add all + devel/eventful-client list + devel/eventful-client clear + +:help: shows this help text and a list of valid event types +:add: add a handler for the named event type at the requested tick frequency +:list: lists active handlers and their metadata +:clear: unregisters all handlers + +Note this script does not handle the eventful reaction or workshop events. +]====] + +local eventful = require('plugins.eventful') + +local utils = require('utils') + +rng = rng or dfhack.random.new() + +-- maps handler name to {count, freq} +handlers = handlers or {} + +local function get_event_fn_name(event_type_name) + local fn_name = 'on' + for word in event_type_name:gmatch('[^_]+') do + fn_name = fn_name .. word:sub(1,1) .. word:sub(2):lower() + end + if eventful[fn_name] then return fn_name end + fn_name = fn_name .. 'CreatedDestroyed' + if eventful[fn_name] then return fn_name end + return nil +end + +local function get_event_registry() + local registry = {} + local event_list = utils.invert(eventful.eventType) + for i=0,#event_list do + local event_type_name = event_list[i] + if event_type_name == 'EVENT_MAX' then + return registry + end + registry[i+1] = {etype=event_type_name, + fn=get_event_fn_name(event_type_name)} + end +end + +local function help() + print(dfhack.script_help()) + print() + print('Event types (and the eventful event functions they map to):') + for _,v in ipairs(get_event_registry()) do + print((' %s -> %s'):format(v.etype, v.fn)) + end +end + +local function make_handler_fn(registry_entry, handler_name, freq) + return function(...) + local handler = handlers[handler_name] + handler.count = handler.count + 1 + print(('eventful-client: %s received %s event (freq=%d)') + :format(os.date("%X"), registry_entry.etype, freq)) + print(' params:', ...) + end +end + +-- adds a new handler for the specified event type. uses a unique handler name +-- each time so multiple handlers can be registered for the same event. +local function add_one(registry_entry, freq) + if not registry_entry.fn then + print(('no eventful event function for %s events; skipping') + :format(registry_entry.etype)) + return + end + eventful.enableEvent(eventful.eventType[registry_entry.etype], freq) + local handler_name = 'eventful-client.'..tostring(rng:random()) + local handler = make_handler_fn(registry_entry, handler_name, freq) + print(('eventful-client registering new %s handler at freq %d: %s') + :format(registry_entry.etype, freq, handler_name)) + eventful[registry_entry.fn][handler_name] = handler + handlers[handler_name] = {count=0, freq=freq} +end + +local function add(spec, freq) + for _,v in ipairs(get_event_registry()) do + if spec == 'all' or spec == v.etype then + add_one(v, freq) + end + end +end + +local function iterate_handlers(fn) + local count = 0 + for _,v in ipairs(get_event_registry()) do + if not eventful[v.fn] then goto continue end + for k in pairs(eventful[v.fn]) do + if k:startswith('eventful-client.') then + count = count + 1 + fn(v, k) + end + end + ::continue:: + end + return count +end + +local function list_fn(registry_entry, handler_name) + local hdata = handlers[handler_name] + local metadata = 'handler metadata not found' + if hdata then + metadata = ('freq=%s, call count=%s'):format(hdata.freq, hdata.count) + end + print((' %s -> %s (%s)') + :format(registry_entry.etype, handler_name, metadata)) +end + +local function list() + print('Active handlers:') + if 0 == iterate_handlers(list_fn) then + print(' None') + end +end + +local function clear_fn(registry_entry, handler_name) + list_fn(registry_entry, handler_name) + eventful[registry_entry.fn][handler_name] = nil +end + +local function clear() + print('Clearing handlers:') + iterate_handlers(clear_fn) + handlers = {} +end + +local action_switch = { + add=add, + list=list, + clear=clear, +} +setmetatable(action_switch, {__index=function() return help end}) + +local args = {...} +local action = table.remove(args, 1) or 'help' + +action_switch[action](table.unpack(args)) diff --git a/devel/export-dt-ini.lua b/devel/export-dt-ini.lua index 893ef9a4dc..5bf5eadd6c 100644 --- a/devel/export-dt-ini.lua +++ b/devel/export-dt-ini.lua @@ -1,18 +1,11 @@ -- Exports an ini file for Dwarf Therapist. ---[====[ -devel/export-dt-ini -=================== -Exports an ini file containing memory addresses for Dwarf Therapist. -]====] -local utils = require 'utils' local ms = require 'memscan' -- Utility functions local globals = df.global local global_addr = dfhack.internal.getAddress -local os_type = dfhack.getOSType() local rdelta = dfhack.internal.getRebaseDelta() local lines = {} --as:string[] local complete = true @@ -61,15 +54,20 @@ local function address(name,base,field,...) value(name, addr) end +local function vtable(name,class) + local addr = dfhack.internal.getVTable(class) + addr = addr - rdelta + value(name, addr) +end -- List of actual values header('addresses') address('cur_year_tick',globals,'cur_year_tick') address('current_year',globals,'cur_year') -address('dwarf_civ_index',globals,'ui','civ_id') -address('dwarf_race_index',globals,'ui','race_id') -address('fortress_entity',globals,'ui','main','fortress_entity') +address('dwarf_civ_index',globals,'plotinfo','civ_id') +address('dwarf_race_index',globals,'plotinfo','race_id') +address('fortress_entity',globals,'plotinfo','main','fortress_entity') address('historical_entities_vector',globals,'world','entities','all') address('creature_vector',globals,'world','units','all') address('active_creature_vector',globals,'world','units','active') @@ -94,7 +92,7 @@ address('musical_forms_vector',globals,'world','musical_forms','all') address('dance_forms_vector',globals,'world','dance_forms','all') address('occupations_vector',globals,'world','occupations','all') address('world_data',globals,'world','world_data') -address('material_templates_vector',globals,'world','raws','material_templates') +address('material_templates_vector',globals,'world','raws','material_templates','all') address('inorganics_vector',globals,'world','raws','inorganics') address('plants_vector',globals,'world','raws','plants','all') address('races_vector',globals,'world','raws','creatures','all') @@ -118,13 +116,15 @@ address('colors_vector',globals,'world','raws','descriptors','colors') address('shapes_vector',globals,'world','raws','descriptors','shapes') address('reactions_vector',globals,'world','raws','reactions') address('base_materials',globals,'world','raws','mat_table','builtin') -address('all_syndromes_vector',globals,'world','raws','syndromes','all') +address('all_syndromes_vector',globals,'world','raws','mat_table','syndromes','all') address('events_vector',globals,'world','history','events') address('historical_figures_vector',globals,'world','history','figures') address('world_site_type',df.world_site,'type') address('active_sites_vector',df.world_data,'active_site') address('gview',globals,'gview') -value('viewscreen_setupdwarfgame_vtable',dfhack.internal.getVTable('viewscreen_setupdwarfgamest')) +address('external_flag',globals,'game','external_flag') +address('global_equipment_update',globals,'plotinfo','equipment','update') +vtable('viewscreen_setupdwarfgame_vtable','viewscreen_setupdwarfgamest') header('offsets') address('word_table',df.language_translation,'words') @@ -169,7 +169,7 @@ address('tissues_vector',df.creature_raw,'tissue') header('caste_offsets') address('caste_name',df.caste_raw,'caste_name') address('caste_descr',df.caste_raw,'description') -address('caste_trait_ranges',df.caste_raw,'personality','a') +address('caste_trait_ranges',df.caste_raw,'personality','min') address('caste_phys_att_ranges',df.caste_raw,'attributes','phys_att_range') address('baby_age',df.caste_raw,'misc','baby_age') address('child_age',df.caste_raw,'misc','child_age') @@ -201,7 +201,7 @@ address('hist_name',df.historical_figure,'name') address('id',df.historical_figure,'id') address('hist_fig_info',df.historical_figure,'info') address('reputation',df.historical_figure_info,'reputation') -address('current_ident',df.historical_figure_info.T_reputation,'cur_identity') +address('current_ident',df.reputation_profilest,'cur_identity') address('fake_name',df.identity,'name') address('fake_birth_year',df.identity,'birth_year') address('fake_birth_time',df.identity,'birth_second') @@ -221,10 +221,12 @@ address('id',df.item,'id') address('general_refs',df.item,'general_refs') address('stack_size',df.item_actual,'stack_size') address('wear',df.item_actual,'wear') -address('mat_type',df.item_crafted,'mat_type') -address('mat_index',df.item_crafted,'mat_index') -address('maker_race',df.item_crafted,'maker_race') -address('quality',df.item_crafted,'quality') +address('mat_type',df.item_constructed,'mat_type') -- should be item_crafted +address('mat_index',df.item_constructed,'mat_index') -- should be item_crafted +address('maker_race',df.item_constructed,'maker_race') -- should be item_crafted +address('quality',df.item_constructed,'quality') -- should be item_crafted +address('artifact_id',df.artifact_record,'id') +address('artifact_name',df.artifact_record,'name') header('item_subtype_offsets') address('sub_type',df.itemdef,'subtype') @@ -235,12 +237,6 @@ address('adjective',df.itemdef_armorst,'name_preplural') address('tool_flags',df.itemdef_toolst,'flags') address('tool_adjective',df.itemdef_toolst,'adjective') -header('item_filter_offsets') -address('item_subtype',df.item_filter_spec,'item_subtype') -address('mat_class',df.item_filter_spec,'material_class') -address('mat_type',df.item_filter_spec,'mattype') -address('mat_index',df.item_filter_spec,'matindex') - header('weapon_subtype_offsets') address('single_size',df.itemdef_weaponst,'two_handed') address('multi_size',df.itemdef_weaponst,'minimum_size') @@ -259,14 +255,14 @@ address('pants_armor_properties',df.itemdef_pantsst,'props') address('other_armor_properties',df.itemdef_helmst,'props') header('material_offsets') -address('solid_name',df.material_common,'state_name','Solid') -address('liquid_name',df.material_common,'state_name','Liquid') -address('gas_name',df.material_common,'state_name','Gas') -address('powder_name',df.material_common,'state_name','Powder') -address('paste_name',df.material_common,'state_name','Paste') -address('pressed_name',df.material_common,'state_name','Pressed') -address('flags',df.material_common,'flags') -address('reaction_class',df.material_common,'reaction_class') +address('solid_name',df.material,'state_name','Solid') +address('liquid_name',df.material,'state_name','Liquid') +address('gas_name',df.material,'state_name','Gas') +address('powder_name',df.material,'state_name','Powder') +address('paste_name',df.material,'state_name','Paste') +address('pressed_name',df.material,'state_name','Pressed') +address('flags',df.material,'flags') +address('reaction_class',df.material,'reaction_class') address('prefix',df.material,'prefix') address('inorganic_materials_vector',df.inorganic_raw,'material') address('inorganic_flags',df.inorganic_raw,'flags') @@ -312,18 +308,19 @@ address('civ',df.unit,'civ_id') address('specific_refs',df.unit,'specific_refs') address('squad_id',df.unit,'military','squad_id') address('squad_position',df.unit,'military','squad_position') -address('recheck_equipment',df.unit,'military','pickup_flags') +address('recheck_equipment',df.unit,'uniform','pickup_flags') address('mood',df.unit,'mood') address('birth_year',df.unit,'birth_year') address('birth_time',df.unit,'birth_time') -address('pet_owner_id',df.unit,'relationship_ids',df.unit_relationship_type.Pet) +address('pet_owner_id',df.unit,'relationship_ids',df.unit_relationship_type.PetOwner) address('current_job',df.unit,'job','current_job') address('physical_attrs',df.unit,'body','physical_attrs') address('body_size',df.unit,'appearance','body_modifiers') -address('size_info',df.unit,'body','size_info') -address('curse',df.unit,'curse','name') -address('curse_add_flags1',df.unit,'curse','add_tags1') -address('turn_count',df.unit,'curse','time_on_site') +address('size_info',df.unit,'body','size_info','size_cur') +address('size_base',df.unit,'body','size_info','size_base') +address('curse',df.unit,'uwss_display_name_sing') +address('curse_add_flags1',df.unit,'uwss_add_caste_flag') +address('turn_count',df.unit,'usable_interaction','time_on_site') address('souls',df.unit,'status','souls') address('states',df.unit,'status','misc_traits') address('labors',df.unit,'status','labors') @@ -339,7 +336,7 @@ address('counters3',df.unit, 'counters2','paralysis') address('limb_counters',df.unit,'status2','limbs_stand_max') address('blood',df.unit,'body','blood_max') address('body_component_info',df.unit,'body','components') -address('layer_status_vector',df.body_component_info,'layer_status') +address('layer_status_vector',df.unit.T_body.T_components,'layer_status') address('wounds_vector',df.unit,'body','wounds') address('mood_skill',df.unit,'job','mood_skill') address('used_items_vector',df.unit,'used_items') @@ -355,20 +352,20 @@ address('cie_first_perc',df.creature_interaction_effect_phys_att_changest,'phys_ address('cie_phys',df.creature_interaction_effect_phys_att_changest,'phys_att_add') address('cie_ment',df.creature_interaction_effect_ment_att_changest,'ment_att_add') address('syn_classes_vector',df.syndrome,'syn_class') -address('trans_race_id',df.creature_interaction_effect_body_transformationst,'race') +address('trans_race_vec',df.creature_interaction_effect_body_transformationst,'race') header('unit_wound_offsets') address('parts',df.unit_wound,'parts') -address('id',df.unit_wound.T_parts,'body_part_id') -address('layer',df.unit_wound.T_parts,'layer_idx') +address('id',df.unit_wound_layerst,'body_part_id') +address('layer',df.unit_wound_layerst,'layer_idx') address('general_flags',df.unit_wound,'flags') -address('flags1',df.unit_wound.T_parts,'flags1') -address('flags2',df.unit_wound.T_parts,'flags2') -address('effects_vector',df.unit_wound.T_parts,'effect_type') -address('bleeding',df.unit_wound.T_parts,'bleeding') -address('pain',df.unit_wound.T_parts,'pain') -address('cur_pen',df.unit_wound.T_parts,'cur_penetration_perc') -address('max_pen',df.unit_wound.T_parts,'max_penetration_perc') +address('flags1',df.unit_wound_layerst,'flags1') +address('flags2',df.unit_wound_layerst,'flags2') +address('effects_vector',df.unit_wound_layerst,'effect_type') +address('bleeding',df.unit_wound_layerst,'bleeding') +address('pain',df.unit_wound_layerst,'pain') +address('cur_pen',df.unit_wound_layerst,'cur_penetration_perc') +address('max_pen',df.unit_wound_layerst,'max_penetration_perc') header('soul_details') address('name',df.unit_soul,'name') @@ -380,27 +377,29 @@ address('personality',df.unit_soul,'personality') address('beliefs',df.unit_personality,'values') address('emotions',df.unit_personality,'emotions') address('goals',df.unit_personality,'dreams') -address('goal_realized',df.unit_personality.T_dreams,'unk8') +address('goal_realized',df.personality_goalst,'flags') address('traits',df.unit_personality,'traits') -address('stress_level',df.unit_personality,'stress_level') +address('stress_level',df.unit_personality,'stress') address('needs',df.unit_personality,'needs') address('current_focus',df.unit_personality,'current_focus') address('undistracted_focus',df.unit_personality,'undistracted_focus') +address('combat_hardened',df.unit_personality,'combat_hardened') +address('likes_outdoors',df.unit_personality,'likes_outdoors') header('need_offsets') -address('id',df.unit_personality.T_needs,'id') -address('deity_id',df.unit_personality.T_needs,'deity_id') -address('focus_level',df.unit_personality.T_needs,'focus_level') -address('need_level',df.unit_personality.T_needs,'need_level') +address('id',df.personality_needst,'id') +address('deity_id',df.personality_needst,'deity_id') +address('focus_level',df.personality_needst,'focus_level') +address('need_level',df.personality_needst,'need_level') header('emotion_offsets') -address('emotion_type',df.unit_personality.T_emotions,'type') -address('strength',df.unit_personality.T_emotions,'strength') -address('thought_id',df.unit_personality.T_emotions,'thought') -address('sub_id',df.unit_personality.T_emotions,'subthought') -address('level',df.unit_personality.T_emotions,'severity') -address('year',df.unit_personality.T_emotions,'year') -address('year_tick',df.unit_personality.T_emotions,'year_tick') +address('emotion_type',df.personality_moodst,'type') +address('strength',df.personality_moodst,'relative_strength') +address('thought_id',df.personality_moodst,'thought') +address('sub_id',df.personality_moodst,'subthought') +address('level',df.personality_moodst,'severity') +address('year',df.personality_moodst,'year') +address('year_tick',df.personality_moodst,'year_tick') header('job_details') address('id',df.job,'job_type') @@ -421,23 +420,28 @@ address('schedules',df.squad,'schedule') value('sched_size',df.squad_schedule_entry:sizeof()) address('sched_orders',df.squad_schedule_entry,'orders') address('sched_assign',df.squad_schedule_entry,'order_assignments') -address('alert',df.squad,'cur_alert_idx') -address('carry_food',df.squad,'carry_food') -address('carry_water',df.squad,'carry_water') -address('ammunition',df.squad,'ammunition') +address('alert',df.squad,'cur_routine_idx') +address('carry_food',df.squad,'supplies','carry_food') +address('carry_water',df.squad,'supplies','carry_water') +address('ammunition',df.squad,'ammo','ammunition') address('ammunition_qty',df.squad_ammo_spec,'amount') -address('quiver',df.squad_position,'quiver') -address('backpack',df.squad_position,'backpack') -address('flask',df.squad_position,'flask') -address('armor_vector',df.squad_position,'uniform','body') -address('helm_vector',df.squad_position,'uniform','head') -address('pants_vector',df.squad_position,'uniform','pants') -address('gloves_vector',df.squad_position,'uniform','gloves') -address('shoes_vector',df.squad_position,'uniform','shoes') -address('shield_vector',df.squad_position,'uniform','shield') -address('weapon_vector',df.squad_position,'uniform','weapon') -address('uniform_item_filter',df.squad_uniform_spec,'item_filter') +address('quiver',df.squad_position,'equipment','quiver') +address('backpack',df.squad_position,'equipment','backpack') +address('flask',df.squad_position,'equipment','flask') +address('armor_vector',df.squad_position,'equipment','uniform','body') +address('helm_vector',df.squad_position,'equipment','uniform','head') +address('pants_vector',df.squad_position,'equipment','uniform','pants') +address('gloves_vector',df.squad_position,'equipment','uniform','gloves') +address('shoes_vector',df.squad_position,'equipment','uniform','shoes') +address('shield_vector',df.squad_position,'equipment','uniform','shield') +address('weapon_vector',df.squad_position,'equipment','uniform','weapon') +address('uniform_spec_item_type',df.squad_uniform_spec,'item_type') +address('uniform_spec_item_subtype',df.squad_uniform_spec,'item_subtype') +address('uniform_spec_mat_class',df.squad_uniform_spec,'material_class') +address('uniform_spec_mat_type',df.squad_uniform_spec,'mattype') +address('uniform_spec_mat_index',df.squad_uniform_spec,'matindex') address('uniform_indiv_choice',df.squad_uniform_spec,'indiv_choice') +address('equipment_update',df.squad,'ammo','update') header('activity_offsets') address('activity_type',df.activity_entry,'type') @@ -448,11 +452,11 @@ address('sq_skill',df.activity_event_skill_demonstrationst,'skill') address('sq_train_rounds',df.activity_event_skill_demonstrationst,'train_rounds') address('pray_deity',df.activity_event_prayerst,'histfig_id') address('pray_sphere',df.activity_event_prayerst,'topic') -address('knowledge_category',df.activity_event_ponder_topicst,'knowledge','category') -address('knowledge_flag',df.activity_event_ponder_topicst,'knowledge','flags') +address('knowledge_category',df.activity_event_ponder_topicst,'topic','research','flag_type') +address('knowledge_flag',df.activity_event_ponder_topicst,'topic','research','flag_data') address('perf_type',df.activity_event_performancest,'type') address('perf_participants',df.activity_event_performancest,'participant_actions') -address('perf_histfig',df.activity_event_performancest.T_participant_actions,'histfig_id') +address('perf_histfig',df.performance_rolest,'histfig_id') header('art_offsets') address('name',df.poetic_form,'name') @@ -460,7 +464,7 @@ address('name',df.poetic_form,'name') header('viewscreen_offsets') address('view',df.interfacest,'view') address('child',df.viewscreen,'child') -address('setupdwarfgame_units',df.viewscreen_setupdwarfgamest,'units') +address('setupdwarfgame_units',df.viewscreen_setupdwarfgamest,'s_unit') -- Final creation of the file @@ -499,7 +503,6 @@ end write_flags('valid_flags_2', {}) write_flags('invalid_flags_1', { - { 'a skeleton', { df.unit_flags1.skeleton } }, { 'a merchant', { df.unit_flags1.merchant } }, { 'outpost liaison, diplomat, or artifact requesting visitor', { df.unit_flags1.diplomat } }, { 'an invader or hostile', { df.unit_flags1.active_invader } }, diff --git a/devel/export-map.lua b/devel/export-map.lua new file mode 100644 index 0000000000..d01844a54b --- /dev/null +++ b/devel/export-map.lua @@ -0,0 +1,334 @@ +-- Export fortress map tile data to a JSON file +-- based on export-map.lua by mikerenfro: +-- https://github.com/mikerenfro/df-map-export/blob/main/export-map.lua +-- redux version by timothymtorres + +local tm = require('tile-material') +local utils = require('utils') +local json = require('json') +local argparse = require('argparse') + +local underworld_z +local underworld +local evilness + +-- the layer of the underworld +for _, feature in ipairs(df.global.world.features.map_features) do + if feature:getType() == df.feature_type.underworld_from_layer then + underworld_z = feature.layer + end +end + +-- right now the only tile_liquids are Water and Magma +local liquid_list = {} +for id, liquid in ipairs(df.tile_liquid) do + liquid_list[id] = string.upper(liquid) +end + +-- copied from agitation-rebalance.lua +-- check only one tile at the center of the map at ground lvl +-- (this ignores different biomes on the edges of the map) +local function get_evilness() + -- check around ground level + + local lvls_above + lvls_above = df.global.world.worldgen.worldgen_parms.levels_above_ground + local ground_z = (df.global.world.map.z_count - 2) - lvls_above + local xmax, ymax = dfhack.maps.getTileSize() + local center_x, center_y = math.floor(xmax/2), math.floor(ymax/2) + local rgnX, rgnY = dfhack.maps.getTileBiomeRgn(center_x, center_y, ground_z) + local biome = dfhack.maps.getRegionBiome(rgnX, rgnY) + + return biome and biome.evilness or 0 +end + +local function classify_tile(options, x, y, z) + -- if your map happens to cross a region boundary and different regions are + -- different depths, the last z-levels of hell MIGHT shrink their x/y size + -- so if your map is 190x190, the last hell z-levels can end up being 90x90 + + if dfhack.maps.getTileType(x, y, z) == nil then + return nil -- Designating the non-tiles of hell to be nil + end + + local tileattrs = df.tiletype.attrs[dfhack.maps.getTileType(x, y, z)] + local tileflags, tile_occupancy = dfhack.maps.getTileFlags(x, y, z) + + local tile_data = {} + + for map_option, position in pairs(options) do + if(map_option == "tiletype") then + tile_data[position] = tileattrs.material + elseif(map_option == "shape") then + tile_data[position] = tileattrs.shape + elseif(map_option == "special") then + tile_data[position] = tileattrs.special + elseif(map_option == "variant") then + tile_data[position] = tileattrs.variant + elseif(map_option == "hidden") then + tile_data[position] = tileflags.hidden + elseif(map_option == "light") then + tile_data[position] = tileflags.light + elseif(map_option == "subterranean") then + tile_data[position] = tileflags.subterranean + elseif(map_option == "outside") then + tile_data[position] = tileflags.outside + elseif(map_option == "liquid") then + if(tileflags.flow_size > 0) then + -- liquid_type is a boolean (true=Magma, false=Water) + -- converting it to a number for easy reference in key table + tile_data[position] = tileflags.liquid_type and 1 or 0 + else + tile_data[position] = nil + end + elseif(map_option == "flow") then + tile_data[position] = tileflags.flow_size + elseif(map_option == "aquifer") then + -- hardcoding these values bc they are not directly in a list + if(tileflags.water_table and tile_occupancy.heavy_aquifer) then + tile_data[position] = 2 + elseif(tileflags.water_table) then + tile_data[position] = 1 + else + tile_data[position] = 0 + end + elseif(map_option == "material") then + if(tileattrs.material >= 8 and tileattrs.material <= 11) then + -- grass material IDs [8-11] will throw an error so we skip them + tile_data[position] = nil + else + local material = tm.GetTileMat(x, y, z) + tile_data[position] = material and material.index or nil + end + end + end + + return tile_data +end + +local function setup_keys(options) + local KEYS = {} + + if(options.tiletype) then + KEYS.TILETYPE = {} + for id, material in ipairs(df.tiletype_material) do + KEYS.TILETYPE[id] = material + end + end + + if(options.shape) then + KEYS.SHAPE = {} + for id, shape in ipairs(df.tiletype_shape) do + KEYS.SHAPE[id] = shape + end + end + + if(options.special) then + KEYS.SPECIAL = {} + for id, special in ipairs(df.tiletype_special) do + KEYS.SPECIAL[id] = special + end + end + + if(options.variant) then + KEYS.VARIANT = {} + for id, variant in ipairs(df.tiletype_variant) do + KEYS.VARIANT[id] = variant + end + end + + if(options.aquifer) then + -- We are hardcoding since this info is not easily listed anywhere + KEYS.AQUIFER = { + [0] = "NONE", + [1] = "LIGHT", + [2] = "HEAVY", + } + end + + if(options.material) then + KEYS.MATERIAL = {} + KEYS.MATERIAL.PLANT = {} + for id, plant in ipairs(df.global.world.raws.plants.all) do + KEYS.MATERIAL.PLANT[id] = plant.id + end + + KEYS.MATERIAL.SOLID = {} -- everything but plants (stones, gems, metals) + KEYS.MATERIAL.METAL = {} + KEYS.MATERIAL.STONE = {} + KEYS.MATERIAL.GEM = {} + + for id, rock in ipairs(df.global.world.raws.inorganics.all) do + local material = rock.material + local name = material.state_adj.Solid + KEYS.MATERIAL.SOLID[id] = name +-- cant sort by key see +-- https://stackoverflow.com/questions/26160327/sorting-a-lua-table-by-key + KEYS.MATERIAL.STONE[id] = material.flags.IS_STONE and name or false + KEYS.MATERIAL.GEM[id] = material.flags.IS_GEM and name or false + KEYS.MATERIAL.METAL[id] = material.flags.IS_METAL and name or false + end + end + + if(options.liquid) then + KEYS.LIQUID = liquid_list + end + + if(options.flow) then + KEYS.FLOW = {} + for i=0, 7 do + KEYS.FLOW[i] = i + end + end + + return KEYS +end + +local function export_all_z_levels(fortress_name, folder, options) + local xmax, ymax, zmax = dfhack.maps.getTileSize() + local filename = string.format("%s/%s.json", folder, fortress_name) + + if dfhack.filesystem.exists(filename) then + qerror('Destination file ' .. filename .. ' already exists!') + return false + end + + local data = {} + + data.ARGUMENT_OPTION_ORDER = options + data.MAP_SIZE = { + x = xmax, + y = ymax, + -- subtract underworld levels if excluded from options + z = underworld and zmax or (zmax - underworld_z), + underworld_z_level = underworld and underworld_z or nil, + evilness = evilness and get_evilness() or nil, + } + data.KEYS = setup_keys(options) + + data.map = {} + + local zmin = 0 + if not underworld then -- skips all z-levels in the underworld + zmin = underworld_z + end + + -- start from bottom z-level (underworld) to top z-level (sky) + for z = zmin, zmax do + local level_data = {} + for y = 0, ymax - 1 do + local row_data = {} + for x = 0, xmax - 1 do + local classification = classify_tile(options, x, y, z) + table.insert(row_data, classification) + end + table.insert(level_data, row_data) + end + table.insert(data.map, level_data) + end + + local f = assert(io.open(filename, 'w')) + f:write(json.encode(data)) + f:close() + print("File created in Dwarf Fortress folder under " .. filename) +end + + +local function export_fortress_map(options) + local fortress_name = dfhack.TranslateName( + df.global.world.world_data.active_site[0].name + ) + local export_path = "map-exports/" .. fortress_name + dfhack.filesystem.mkdir_recursive(export_path) + export_all_z_levels(fortress_name, export_path, options) +end + +if not dfhack.isMapLoaded() then + qerror('This script requires a map to be loaded') +end + +local options, args = { + help = false, + tiletype = false, + shape = false, + special = false, + variant = false, + hidden = false, + light = false, + subterranean = false, + outside = false, + aquifer = false, + material = false, + flow = false, + liquid = false, + underworld = false, + evilness = false, +}, {...} + +local positionals = argparse.processArgsGetopt(args, { + {'', 'help', handler=function() options.help = true end}, + {'t', 'tiletype', handler=function() options.tiletype = true end}, + {'s', 'shape', handler=function() options.shape = true end}, + {'p', 'special', handler=function() options.special = true end}, + {'v', 'variant', handler=function() options.variant = true end}, + {'h', 'hidden', handler=function() options.hidden = true end}, + {'l', 'light', handler=function() options.light = true end}, + {'b', 'subterranean', handler=function() options.subterranean = true end}, + {'o', 'outside', handler=function() options.outside = true end}, + {'a', 'aquifer', handler=function() options.aquifer = true end}, + {'m', 'material', handler=function() options.material = true end}, + {'f', 'flow', handler=function() options.flow = true end}, + {'q', 'liquid', handler=function() options.liquid = true end}, + {'u', 'underworld', handler=function() options.underworld = true end}, + {'e', 'evilness', handler=function() options.evilness = true end}, +}) + +if positionals[1] == "help" or options.help then + print(dfhack.script_help()) + return false +elseif positionals[1] == "include" then + -- no need to change anything +elseif positionals[1] == "exclude" then + for setting in pairs(options) do + options[setting] = not options[setting] + end +else -- include everything + for setting in pairs(options) do + options[setting] = true + end +end + +local ordered_options = { + "tiletype", + "shape", + "special", + "variant", + "hidden", + "light", + "subterranean", + "outside", + "aquifer", + "material", + "flow", + "liquid", +} + +-- these get omitted from ordered_options since this data goes directly into the +-- JSON object for MAP_SIZE and doesn't need to be parsed into every tile +underworld = options.underworld +evilness = options.evilness + +-- reorganize ordered options based on selected options via argparse +-- this is so ARGUMENT_OPTION_ORDER has the correct order with no gaps +for setting in pairs(options) do + if not options[setting] then + for pos, json_setting in ipairs(ordered_options) do + if setting == json_setting then + table.remove(ordered_options, pos) + end + end + end +end + +ordered_options = utils.invert(ordered_options) +export_fortress_map(ordered_options) diff --git a/devel/find-offsets.lua b/devel/find-offsets.lua deleted file mode 100644 index 788cbb9363..0000000000 --- a/devel/find-offsets.lua +++ /dev/null @@ -1,1959 +0,0 @@ --- Find some global addresses ---[====[ - -devel/find-offsets -================== -WARNING: THIS SCRIPT IS STRICTLY FOR DFHACK DEVELOPERS. - -Running this script on a new DF version will NOT -MAKE IT RUN CORRECTLY if any data structures -changed, thus possibly leading to CRASHES AND/OR -PERMANENT SAVE CORRUPTION. - -Finding the first few globals requires this script to be -started immediately after loading the game, WITHOUT -first loading a world. The rest expect a loaded save, -not a fresh embark. Finding current_weather requires -a special save previously processed with `devel/prepare-save` -on a DF version with working dfhack. - -The script expects vanilla game configuration, without -any custom tilesets or init file changes. Never unpause -the game unless instructed. When done, quit the game -without saving using 'die'. - -Arguments: - -* global names to force finding them -* ``all`` to force all globals -* ``nofeed`` to block automated fake input searches -* ``nozoom`` to disable neighboring object heuristics - -]====] - ---luacheck-flags: strictsubtype - -local utils = require 'utils' -local ms = require 'memscan' -local gui = require 'gui' - -local is_known = dfhack.internal.getAddress - -local os_type = dfhack.getOSType() - -local force_scan = {} --as:bool[] -for _,v in ipairs({...}) do - force_scan[v] = true -end - -PTR_SIZE = (function() - local tmp = df.new('uintptr_t') - local size = tmp:sizeof() - tmp:delete() - return size -end)() - -collectgarbage() - -function prompt_proceed(indent) - if not indent then indent = 0 end - return utils.prompt_yes_no(string.rep(' ', indent) .. 'Proceed?', true) -end - -print[[ -WARNING: THIS SCRIPT IS STRICTLY FOR DFHACK DEVELOPERS. - -Running this script on a new DF version will NOT -MAKE IT RUN CORRECTLY if any data structures -changed, thus possibly leading to CRASHES AND/OR -PERMANENT SAVE CORRUPTION. - -Finding the first few globals requires this script to be -started immediately after loading the game, WITHOUT -first loading a world. The rest expect a loaded save, -not a fresh embark. Finding current_weather requires -a special save previously processed with devel/prepare-save -on a DF version with working dfhack. - -The script expects vanilla game configuration, without -any custom tilesets or init file changes. Never unpause -the game unless instructed. When done, quit the game -without saving using 'die'. -]] - -if not utils.prompt_yes_no('Proceed?') then - return -end - --- Data segment location - -local data = ms.get_data_segment() -if not data then - qerror('Could not find data segment') -end - -print('\nData section: '..tostring(data)) -if data.size < 5000000 then - qerror('Data segment too short.') -end - -local searcher = ms.DiffSearcher.new(data) - -local function get_screen(class, prompt) - if not is_known('gview') then - print('Please navigate to '..prompt) - if not prompt_proceed() then - return nil, false - end - return nil, true - end - - while true do - local cs = dfhack.gui.getCurViewscreen(true) - if not df.is_instance(class, cs) then - print('Please navigate to '..prompt) - if not prompt_proceed() then - return nil, false - end - else - return cs, true - end - end -end - -local function screen_title() - return get_screen(df.viewscreen_titlest, 'the title screen') -end -local function screen_dwarfmode() - return get_screen(df.viewscreen_dwarfmodest, 'the main dwarf mode screen') -end - -local function validate_offset(name,validator,addr,tname,...) - local obj = data:object_by_field(addr,tname,...) - if obj and not validator(obj) then - obj = nil - end - ms.found_offset(name,obj) -end - -local function zoomed_searcher(startn, end_or_sz, bidirectional) - if force_scan.nozoom then - return nil - end - local sv = is_known(startn) - if not sv then - return nil - end - local ev - if type(end_or_sz) == 'number' then - ev = sv + end_or_sz - if end_or_sz < 0 then - sv, ev = ev, sv - end - else - ev = is_known(end_or_sz) - if not ev then - return nil - end - end - if bidirectional then - sv = sv - (ev - sv) - end - sv = sv - (sv % 4) - ev = ev + 3 - ev = ev - (ev % 4) - if data:contains_range(sv, ev-sv) then - return ms.DiffSearcher.new(ms.MemoryArea.new(sv,ev)) - end -end - -local finder_searches = {} --as:string[] -local function exec_finder(finder, names, validators) - local validators = validators --as:{_type:function,_node:bool}[] - if type(names) ~= 'table' then - names = { names } --luacheck: retype - end - if type(validators) ~= 'table' then --luacheck: skip - validators = { validators } - end - local search = force_scan['all'] - for k,v in ipairs(names) do - if force_scan[v] or not is_known(v) then - table.insert(finder_searches, v) - search = true - elseif validators[k] then - if not validators[k](df.global[v]) then --luacheck: skip - dfhack.printerr('Validation failed for '..v..', will try to find again') - table.insert(finder_searches, v) - search = true - end - end - end - if search then - local ok, err = dfhack.safecall(finder) - if not ok then - if tostring(err):find('abort') or not utils.prompt_yes_no('Proceed with the rest of the script?') then - searcher:reset() - qerror('Quit') - end - end - else - print('Already known: '..table.concat(names,', ')) - end -end - -local ordinal_names = { - [0] = '1st entry', - [1] = '2nd entry', - [2] = '3rd entry' -} -setmetatable(ordinal_names, { - __index = function(self,idx) return (idx+1)..'th entry' end -}) - -local function list_index_choices(length_func) - return function(id) - if id > 0 then - local ok, len = pcall(length_func) - if not ok then - len = 5 - elseif len > 10 then - len = 10 - end - return id % len - else - return 0 - end - end -end - -local function can_feed() - return not force_scan['nofeed'] and is_known 'gview' -end - -local function dwarfmode_feed_input(...) - local screen = screen_dwarfmode() - if not df.isvalid(screen) then - qerror('could not retrieve dwarfmode screen') - end - try_save_cursor() - for _,v in ipairs({...}) do - gui.simulateInput(screen, v) - end -end - -local function dwarfmode_step_frames(count) - local screen = screen_dwarfmode() - if not df.isvalid(screen) then - qerror('could not retrieve dwarfmode screen') - end - - for i = 1,(count or 1) do - gui.simulateInput(screen, 'D_ONESTEP') - if screen.keyRepeat ~= 1 then - qerror('Could not step one frame: . did not work') - end - screen:logic() - end -end - -local function dwarfmode_to_top() - if not can_feed() then - return false - end - - local screen = screen_dwarfmode() - if not df.isvalid(screen) then - return false - end - - for i=0,10 do - if is_known 'ui' and df.global.ui.main.mode == df.ui_sidebar_mode.Default then - break - end - gui.simulateInput(screen, 'LEAVESCREEN') - end - - -- force pause just in case - screen.keyRepeat = 1 - return true -end - -local prev_cursor = df.global.T_cursor:new() -prev_cursor.x = -30000 -function try_save_cursor() - if not dfhack.internal.getAddress('cursor') then return end - for _, v in pairs(df.global.cursor) do - if v < 0 then - return - end - end - prev_cursor:assign(df.global.cursor) -end - -function try_restore_cursor() - if not dfhack.internal.getAddress('cursor') then return end - if prev_cursor.x >= 0 then - df.global.cursor:assign(prev_cursor) - dfhack.gui.refreshSidebar() - end -end - -local function feed_menu_choice(catnames,catkeys,enum,enter_seq,exit_seq,prompt) - return function (idx) - if idx == 0 and prompt and not prompt_proceed(2) then - return false - end - if idx > 0 then - dwarfmode_feed_input(table.unpack(exit_seq or {})) - end - idx = idx % #catnames + 1 - dwarfmode_feed_input(table.unpack(enter_seq or {})) - dwarfmode_feed_input(catkeys[idx]) - if enum then - return true, enum[catnames[idx]] - else - return true, catnames[idx] - end - end -end - -local function feed_list_choice(count,upkey,downkey) - return function(idx) - if idx > 0 then - local ok, len - if type(count) == 'number' then - ok, len = true, count - else - ok, len = pcall(count) - end - if not ok then - len = 5 - elseif len > 10 then - len = 10 - end - - local hcnt = len-1 - local rix = 1 + (idx-1) % (hcnt*2) - - if rix >= hcnt then - dwarfmode_feed_input(upkey or 'SECONDSCROLL_UP') - return true, hcnt*2 - rix - else - dwarfmode_feed_input(donwkey or 'SECONDSCROLL_DOWN') - return true, rix - end - else - print(' Please select the first list item.') - if not prompt_proceed(2) then - return false - end - return true, 0 - end - end -end - -local function feed_menu_bool(enter_seq, exit_seq) - return function(idx) - if idx == 0 then - if not prompt_proceed(2) then - return false - end - return true, 0 - end - if idx == 5 then - print(' Please resize the game window.') - if not prompt_proceed(2) then - return false - end - end - if idx%2 == 1 then - dwarfmode_feed_input(table.unpack(enter_seq)) - return true, 1 - else - dwarfmode_feed_input(table.unpack(exit_seq)) - return true, 0 - end - end -end - --- --- Cursor group --- - -local function find_cursor() - local _, ok = screen_title() - if not ok then - return false - end - - -- Unpadded version - local idx, addr = data.int32_t:find_one{ - -30000, -30000, -30000, - -30000, -30000, -30000, -30000, -30000, -30000, - df.game_mode.NONE, df.game_type.NONE - } - if idx then - ms.found_offset('cursor', addr) - ms.found_offset('selection_rect', addr + 12) - ms.found_offset('gamemode', addr + 12 + 24) - ms.found_offset('gametype', addr + 12 + 24 + 4) - return true - end - - -- Padded version - idx, addr = data.int32_t:find_one{ - -30000, -30000, -30000, 0, - -30000, -30000, -30000, -30000, -30000, -30000, 0, 0, - df.game_mode.NONE, 0, 0, 0, df.game_type.NONE - } - if idx then - ms.found_offset('cursor', addr) - ms.found_offset('selection_rect', addr + 0x10) - ms.found_offset('gamemode', addr + 0x30) - ms.found_offset('gametype', addr + 0x40) - return true - end - - -- New in 0.43.05 x64 - idx, addr = data.int32_t:find_one{ - -30000, -30000, -30000, 0, 0, - -30000, -30000, -30000, -30000, -30000, -30000, - df.game_mode.NONE, df.game_type.NONE - } - if idx then - ms.found_offset('cursor', addr) - ms.found_offset('selection_rect', addr + 0x14) - ms.found_offset('gamemode', addr + 0x2C) - ms.found_offset('gametype', addr + 0x30) - return true - end - - -- New in 0.43.05 x64 Linux - if os_type == 'linux' then - idx, addr = data.int32_t:find_one{ - df.game_type.NONE, 0, 0, 0, - df.game_mode.NONE, 0, 0, 0, - -30000, -30000, -30000, -30000, - -30000, -30000, 0, 0, - -30000, -30000, -30000 - } - if idx then - ms.found_offset('cursor', addr + 0x40) - ms.found_offset('selection_rect', addr + 0x20) - ms.found_offset('gamemode', addr + 0x10) - ms.found_offset('gametype', addr) - return true - end - end - - dfhack.printerr('Could not find cursor.') - return false -end - --- --- d_init --- - -local function is_valid_d_init(di) - if di.sky_tile ~= 178 then - print('Sky tile expected 178, found: '..di.sky_tile) - if not utils.prompt_yes_no('Ignore?') then - return false - end - end - - return true -end - -local function find_d_init() - local idx, addr = data.int16_t:find_one{ - 1,0, 2,0, 5,0, 25,0, -- path_cost - 4,4, -- embark_rect - 20,1000,1000,1000,1000 -- store_dist - } - if idx then - validate_offset('d_init', is_valid_d_init, addr, df.d_init, 'path_cost') - return - end - - dfhack.printerr('Could not find d_init') -end - --- --- gview --- - -local function find_gview() - local vs_vtable = dfhack.internal.getVTable('viewscreenst') - if not vs_vtable then - dfhack.printerr('Cannot search for gview - no viewscreenst vtable.') - return - end - - local idx, addr = data.uintptr_t:find_one{0, vs_vtable} - if idx then - ms.found_offset('gview', addr) - return - end - - idx, addr = data.uintptr_t:find_one{100, vs_vtable} - if idx then - ms.found_offset('gview', addr) - return - end - - dfhack.printerr('Could not find gview') -end - --- --- enabler --- - -local function lookup_colors() - local f = io.open('data/init/colors.txt', 'r') or error('failed to open file') - local text = f:read('*all') - f:close() - local colors = {} - for _, color in pairs({'BLACK', 'BLUE', 'GREEN', 'CYAN', 'RED', 'MAGENTA', - 'BROWN', 'LGRAY', 'DGRAY', 'LBLUE', 'LGREEN', 'LCYAN', 'LRED', - 'LMAGENTA', 'YELLOW', 'WHITE'}) do - for _, part in pairs({'R', 'G', 'B'}) do - local opt = color .. '_' .. part - table.insert(colors, tonumber(text:match(opt .. ':(%d+)') or error('missing from colors.txt: ' .. opt))) - end - end - return colors -end - -local function is_valid_enabler(e) - if not ms.is_valid_vector(e.textures.raws, PTR_SIZE) - or not ms.is_valid_vector(e.text_system, PTR_SIZE) - then - dfhack.printerr('Vector layout check failed.') - return false - end - - return true -end - -local function find_enabler() - -- Data from data/init/colors.txt - local default_colors = { - 0, 0, 0, 0, 0, 128, 0, 128, 0, - 0, 128, 128, 128, 0, 0, 128, 0, 128, - 128, 128, 0, 192, 192, 192, 128, 128, 128, - 0, 0, 255, 0, 255, 0, 0, 255, 255, - 255, 0, 0, 255, 0, 255, 255, 255, 0, - 255, 255, 255 - } - local colors - local ok, ret = pcall(lookup_colors) - if not ok then - dfhack.printerr('Failed to look up colors, using defaults: \n' .. ret) - colors = default_colors - else - colors = ret - end - - for i = 1,#colors do colors[i] = colors[i]/255 end - - local idx, addr = data.float:find_one(colors) - if not idx then - idx, addr = data.float:find_one(default_colors) - end - if idx then - validate_offset('enabler', is_valid_enabler, addr, df.enabler, 'ccolor') - return - end - - dfhack.printerr('Could not find enabler') -end - --- --- gps --- - -local function is_valid_gps(g) - if g.clipx[0] < 0 or g.clipx[0] > g.clipx[1] or g.clipx[1] >= g.dimx then - dfhack.printerr('Invalid clipx: ', g.clipx[0], g.clipx[1], g.dimx) - end - if g.clipy[0] < 0 or g.clipy[0] > g.clipy[1] or g.clipy[1] >= g.dimy then - dfhack.printerr('Invalid clipy: ', g.clipy[0], g.clipy[1], g.dimy) - end - - return true -end - -local function find_gps() - print('\nPlease ensure the mouse cursor is not over the game window.') - if not prompt_proceed() then - return - end - - local zone - if os_type == 'windows' or os_type == 'linux' then - zone = zoomed_searcher('cursor', 0x1000) - elseif os_type == 'darwin' then - zone = zoomed_searcher('enabler', 0x1000) - end - zone = zone or searcher - - local w,h = ms.get_screen_size() - - local idx, addr = zone.area.int32_t:find_one{w, h, -1, -1} - if not idx then - idx, addr = data.int32_t:find_one{w, h, -1, -1} - end - if idx then - validate_offset('gps', is_valid_gps, addr, df.graphic, 'dimx') - return - end - - dfhack.printerr('Could not find gps') -end - --- --- World --- - -local function is_valid_world(world) - if not ms.is_valid_vector(world.units.all, PTR_SIZE) - or not ms.is_valid_vector(world.units.active, PTR_SIZE) - or not ms.is_valid_vector(world.units.bad, PTR_SIZE) - or not ms.is_valid_vector(world.history.figures, PTR_SIZE) - or not ms.is_valid_vector(world.features.map_features, PTR_SIZE) - then - dfhack.printerr('Vector layout check failed.') - return false - end - - if #world.units.all == 0 or #world.units.all ~= #world.units.bad then - print('Different or zero size of units.all and units.bad:'..#world.units.all..' vs '..#world.units.bad) - if not utils.prompt_yes_no('Ignore?') then - return false - end - end - - return true -end - -local function find_world() - local catnames = { - 'Corpses', 'Refuse', 'Stone', 'Wood', 'Gems', 'Bars', 'Cloth', 'Leather', 'Ammo', 'Coins' - } - local catkeys = { - 'STOCKPILE_GRAVEYARD', 'STOCKPILE_REFUSE', 'STOCKPILE_STONE', 'STOCKPILE_WOOD', - 'STOCKPILE_GEM', 'STOCKPILE_BARBLOCK', 'STOCKPILE_CLOTH', 'STOCKPILE_LEATHER', - 'STOCKPILE_AMMO', 'STOCKPILE_COINS' - } - local addr - - if dwarfmode_to_top() then - dwarfmode_feed_input('D_STOCKPILES') - - addr = searcher:find_interactive( - 'Auto-searching for world.', - 'int32_t', - feed_menu_choice(catnames, catkeys, df.stockpile_category), - 20 - ) - end - - if not addr then - addr = searcher:find_menu_cursor([[ -Searching for world. Please open the stockpile creation -menu, and select different types as instructed below:]], - 'int32_t', catnames, df.stockpile_category - ) - end - - validate_offset('world', is_valid_world, addr, df.world, 'selected_stockpile_type') -end - --- --- UI --- - -local function is_valid_ui(ui) - if not ms.is_valid_vector(ui.economic_stone, 1) - or not ms.is_valid_vector(ui.dipscripts, PTR_SIZE) - then - dfhack.printerr('Vector layout check failed.') - return false - end - - if ui.follow_item ~= -1 or ui.follow_unit ~= -1 then - print('Invalid follow state: '..ui.follow_item..', '..ui.follow_unit) - return false - end - - return true -end - -local function find_ui() - local catnames = { - 'DesignateMine', 'DesignateChannel', 'DesignateRemoveRamps', - 'DesignateUpStair', 'DesignateDownStair', 'DesignateUpDownStair', - 'DesignateUpRamp', 'DesignateChopTrees' - } - local catkeys = { - 'DESIGNATE_DIG', 'DESIGNATE_CHANNEL', 'DESIGNATE_DIG_REMOVE_STAIRS_RAMPS', - 'DESIGNATE_STAIR_UP', 'DESIGNATE_STAIR_DOWN', 'DESIGNATE_STAIR_UPDOWN', - 'DESIGNATE_RAMP', 'DESIGNATE_CHOP' - } - local addr - - if dwarfmode_to_top() then - dwarfmode_feed_input('D_DESIGNATE') - - addr = searcher:find_interactive( - 'Auto-searching for ui.', - 'int16_t', - feed_menu_choice(catnames, catkeys, df.ui_sidebar_mode), - 20 - ) - end - - if not addr then - addr = searcher:find_menu_cursor([[ -Searching for ui. Please open the designation -menu, and switch modes as instructed below:]], - 'int16_t', catnames, df.ui_sidebar_mode - ) - end - - validate_offset('ui', is_valid_ui, addr, df.ui, 'main', 'mode') -end - --- --- ui_sidebar_menus --- - -local function is_valid_ui_sidebar_menus(usm) - if not ms.is_valid_vector(usm.workshop_job.choices_all, 4) - or not ms.is_valid_vector(usm.workshop_job.choices_visible, 4) - then - dfhack.printerr('Vector layout check failed.') - return false - end - - if #usm.workshop_job.choices_all == 0 - or #usm.workshop_job.choices_all ~= #usm.workshop_job.choices_visible then - print('Different or zero size of visible and all choices:'.. - #usm.workshop_job.choices_all..' vs '..#usm.workshop_job.choices_visible) - if not utils.prompt_yes_no('Ignore?') then - return false - end - end - - return true -end - -local function find_ui_sidebar_menus() - local addr - - if dwarfmode_to_top() then - dwarfmode_feed_input('D_BUILDJOB') - - addr = searcher:find_interactive([[ -Auto-searching for ui_sidebar_menus. Please select a Mason's, -Craftsdwarf's, or Carpenter's workshop:]], - 'int32_t', - function(idx) - if idx == 0 then - prompt_proceed(2) - -- ensure that the job list isn't full - dwarfmode_feed_input('BUILDJOB_CANCEL', 'BUILDJOB_ADD') - return true, 0 - end - return feed_list_choice(7)(idx) - end, - 20 - ) - end - - if not addr then - addr = searcher:find_menu_cursor([[ -Searching for ui_sidebar_menus. Please switch to 'q' mode, -select a Mason, Craftsdwarfs, or Carpenters workshop, open -the Add Job menu, and move the cursor within:]], - 'int32_t', - { 0, 1, 2, 3, 4, 5, 6 }, - ordinal_names - ) - end - - validate_offset('ui_sidebar_menus', is_valid_ui_sidebar_menus, - addr, df.ui_sidebar_menus, 'workshop_job', 'cursor') -end - --- --- ui_build_selector --- - -local function is_valid_ui_build_selector(ubs) - if not ms.is_valid_vector(ubs.requirements, 4) - or not ms.is_valid_vector(ubs.choices, 4) - then - dfhack.printerr('Vector layout check failed.') - return false - end - - if ubs.building_type ~= df.building_type.Trap - or ubs.building_subtype ~= df.trap_type.PressurePlate then - print('Invalid building type and subtype:'..ubs.building_type..','..ubs.building_subtype) - return false - end - - return true -end - -local function find_ui_build_selector() - local addr - - if dwarfmode_to_top() then - addr = searcher:find_interactive([[ -Auto-searching for ui_build_selector. This requires mechanisms.]], - 'int32_t', - function(idx) - if idx == 0 then - dwarfmode_to_top() - dwarfmode_feed_input( - 'D_BUILDING', - 'HOTKEY_BUILDING_TRAP', - 'HOTKEY_BUILDING_TRAP_TRIGGER', - 'BUILDING_TRIGGER_ENABLE_CREATURE' - ) - else - dwarfmode_feed_input('BUILDING_TRIGGER_MIN_SIZE_UP') - end - return true, 5000 + 1000*idx - end, - 20 - ) - end - - if not addr then - addr = searcher:find_menu_cursor([[ -Searching for ui_build_selector. Please start constructing -a pressure plate, and enable creatures. Then change the min -weight as requested, knowing that the ui shows 5000 as 5K:]], - 'int32_t', - { 5000, 6000, 7000, 8000, 9000, 10000, 11000 } - ) - end - - validate_offset('ui_build_selector', is_valid_ui_build_selector, - addr, df.ui_build_selector, 'plate_info', 'unit_min') -end - --- --- init --- - -local function is_valid_init(i) - -- derived from curses_*.png image sizes presumably - if i.font.small_font_dispx ~= 8 or i.font.small_font_dispy ~= 12 or - i.font.large_font_dispx ~= 10 or i.font.large_font_dispy ~= 12 then - print('Unexpected font sizes: ', - i.font.small_font_dispx, i.font.small_font_dispy, - i.font.large_font_dispx, i.font.large_font_dispy) - if not utils.prompt_yes_no('Ignore?') then - return false - end - end - - return true -end - -local function find_init() - local zone - --[[if os_type == 'windows' then - zone = zoomed_searcher('ui_build_selector', 0x3000) - elseif os_type == 'linux' or os_type == 'darwin' then - zone = zoomed_searcher('d_init', -0x2000) - end]] - zone = zone or searcher - - local idx, addr = zone.area.long:find_one{250, 150, 15, 0} - if idx then - validate_offset('init', is_valid_init, addr, df.init, 'input', 'hold_time') - return - end - - local w,h = ms.get_screen_size() - - local idx, addr = zone.area.int32_t:find_one{w, h} - if idx then - validate_offset('init', is_valid_init, addr, df.init, 'display', 'grid_x') - return - end - - dfhack.printerr('Could not find init') -end - --- --- current_weather --- - -local function find_current_weather() - local zone - if os_type == 'windows' then - zone = zoomed_searcher('crime_next_id', 512) - elseif os_type == 'darwin' then - zone = zoomed_searcher('cursor', 128, true) - elseif os_type == 'linux' then - zone = zoomed_searcher('ui_selected_unit', 512) - end - zone = zone or searcher - - local wbytes = { - 2, 1, 0, 2, 0, - 1, 2, 1, 0, 0, - 2, 0, 2, 1, 2, - 1, 2, 0, 1, 1, - 2, 0, 1, 0, 2 - } - - local idx, addr = zone.area.int8_t:find_one(wbytes) - if not idx then - idx, addr = data.int8_t:find_one(wbytes) - end - if idx then - ms.found_offset('current_weather', addr) - return - end - - dfhack.printerr('Could not find current_weather - must be a wrong save.') -end - --- --- ui_menu_width --- - -local function find_ui_menu_width() - local addr - - if dwarfmode_to_top() then - addr = searcher:find_interactive('Auto-searching for ui_menu_width', 'int8_t', function(idx) - local val = (idx % 3) + 1 - if idx == 0 then - print('Switch to the default [map][menu][map] layout (with Tab)') - if not prompt_proceed(2) then return false end - else - dwarfmode_feed_input('CHANGETAB', val ~= 3 and 'CHANGETAB') - end - return true, val - end) - end - - if not addr then - addr = searcher:find_menu_cursor([[ -Searching for ui_menu_width. Please exit to the main -dwarfmode menu, then use Tab to do as instructed below:]], - 'int8_t', - { 2, 3, 1 }, - { [2] = 'switch to the most usual [mapmap][menu] layout', - [3] = 'hide the menu completely', - [1] = 'switch to the default [map][menu][map] layout' } - ) - end - - ms.found_offset('ui_menu_width', addr) - - -- reset to make sure view is small enough for window_x/y scan on small maps - df.global.ui_menu_width[0] = 2 - df.global.ui_menu_width[1] = 3 -end - --- --- ui_selected_unit --- - -local function find_ui_selected_unit() - if not is_known 'world' then - dfhack.printerr('Cannot search for ui_selected_unit: no world') - return - end - - for i,unit in ipairs(df.global.world.units.active) do - -- This function does a lot of things and accesses histfigs, souls and so on: - --dfhack.units.setNickname(unit, i) - - -- Instead use just a simple bit of code that only requires the start of the - -- unit to be valid. It may not work properly with vampires or reset later - -- if unpaused, but is sufficient for this script and won't crash: - unit.name.nickname = tostring(i) - unit.name.has_name = true - end - - local addr = searcher:find_menu_cursor([[ -Searching for ui_selected_unit. Please activate the 'v' -mode, point it at units, and enter their numeric nickname -into the prompts below:]], - 'int32_t', - function() - return utils.prompt_input(' Enter index: ', utils.check_number) - end, - 'noprompt' - ) - ms.found_offset('ui_selected_unit', addr) -end - --- --- ui_unit_view_mode --- - -local function find_ui_unit_view_mode() - local catnames = { 'General', 'Inventory', 'Preferences', 'Wounds' } - local catkeys = { 'UNITVIEW_GEN', 'UNITVIEW_INV', 'UNITVIEW_PRF', 'UNITVIEW_WND' } - local addr - - if dwarfmode_to_top() and is_known('ui_selected_unit') then - dwarfmode_feed_input('D_VIEWUNIT') - - if df.global.ui_selected_unit < 0 then - df.global.ui_selected_unit = 0 - end - - addr = searcher:find_interactive( - 'Auto-searching for ui_unit_view_mode.', - 'int32_t', - feed_menu_choice(catnames, catkeys, df.ui_unit_view_mode.T_value), - 10 - ) - end - - if not addr then - addr = searcher:find_menu_cursor([[ -Searching for ui_unit_view_mode. Having selected a unit -with 'v', switch the pages as requested:]], - 'int32_t', catnames, df.ui_unit_view_mode.T_value - ) - end - - ms.found_offset('ui_unit_view_mode', addr) -end - --- --- ui_look_cursor --- - -local function look_item_list_count() - return #df.global.ui_look_list.items -end - -local function find_ui_look_cursor() - local addr - - if dwarfmode_to_top() then - dwarfmode_feed_input('D_LOOK') - - addr = searcher:find_interactive([[ -Auto-searching for ui_look_cursor. Please select a tile -with at least 5 items or units on the ground, and move -the cursor as instructed:]], - 'int32_t', - feed_list_choice(look_item_list_count), - 20 - ) - end - - if not addr then - addr = searcher:find_menu_cursor([[ -Searching for ui_look_cursor. Please activate the 'k' -mode, find a tile with many items or units on the ground, -and select list entries as instructed:]], - 'int32_t', - list_index_choices(look_item_list_count), - ordinal_names - ) - end - - ms.found_offset('ui_look_cursor', addr) -end - --- --- ui_building_item_cursor --- - -local function building_item_list_count() - return #df.global.world.selected_building.contained_items --hint:df.building_actual -end - -local function find_ui_building_item_cursor() - local addr - - if dwarfmode_to_top() then - dwarfmode_feed_input('D_BUILDITEM') - - addr = searcher:find_interactive([[ -Auto-searching for ui_building_item_cursor. Please highlight a -workshop, trade depot or other building with at least 5 contained -items, and select as instructed:]], - 'int32_t', - feed_list_choice(building_item_list_count), - 20 - ) - end - - if not addr then - addr = searcher:find_menu_cursor([[ -Searching for ui_building_item_cursor. Please activate the 't' -mode, find a cluttered workshop, trade depot, or other building -with many contained items, and select as instructed:]], - 'int32_t', - list_index_choices(building_item_list_count), - ordinal_names - ) - end - - ms.found_offset('ui_building_item_cursor', addr) -end - --- --- ui_workshop_in_add --- - -local function find_ui_workshop_in_add() - local addr - - if dwarfmode_to_top() then - dwarfmode_feed_input('D_BUILDJOB') - - addr = searcher:find_interactive([[ -Auto-searching for ui_workshop_in_add. Please select a -workshop, e.g. Carpenters or Masons.]], - 'int8_t', - feed_menu_bool( - { 'BUILDJOB_CANCEL', 'BUILDJOB_ADD' }, - { 'SELECT', 'SELECT', 'SELECT', 'SELECT', 'SELECT' } - ), - 20 - ) - end - - if not addr then - addr = searcher:find_menu_cursor([[ -Searching for ui_workshop_in_add. Please activate the 'q' -mode, find a workshop without jobs (or delete jobs), -and do as instructed below. - -NOTE: If not done after first 3-4 steps, resize the game window.]], - 'int8_t', - { 1, 0 }, - { [1] = 'enter the add job menu', - [0] = 'add job, thus exiting the menu' } - ) - end - - ms.found_offset('ui_workshop_in_add', addr) -end - --- --- ui_workshop_job_cursor --- - -local function workshop_job_list_count() - return #df.global.world.selected_building.jobs -end - -local function find_ui_workshop_job_cursor() - local addr - - if dwarfmode_to_top() then - dwarfmode_feed_input('D_BUILDJOB') - addr = searcher:find_interactive([[ -Auto-searching for ui_workshop_job_cursor. Please highlight a -Mason's or Carpenter's workshop, or any building with a job -selection interface navigable with just "Enter":]], - 'int32_t', - function(idx) - if idx == 0 then prompt_proceed(2) end - for i = 1, 10 - workshop_job_list_count() do - dwarfmode_feed_input('BUILDJOB_ADD', 'SELECT', 'SELECT', 'SELECT', 'SELECT', 'SELECT') - end - dwarfmode_feed_input('SECONDSCROLL_DOWN') - -- adding jobs resets the cursor position, so it is difficult to determine here - return true - end, - 20 - ) - end - - if not addr then - addr = searcher:find_menu_cursor([[ -Searching for ui_workshop_job_cursor. Please activate the 'q' -mode, find a workshop with many jobs, and select as instructed:]], - 'int32_t', - list_index_choices(workshop_job_list_count), - ordinal_names - ) - end - - ms.found_offset('ui_workshop_job_cursor', addr) -end - --- --- ui_building_in_assign --- - -local function find_ui_building_in_assign() - local addr - - if dwarfmode_to_top() then - dwarfmode_feed_input('D_BUILDJOB') - try_restore_cursor() - - addr = searcher:find_interactive([[ -Auto-searching for ui_building_in_assign. Please select a room, -i.e. a bedroom, tomb, office, dining room or statue garden.]], - 'int8_t', - feed_menu_bool( - { { 'BUILDJOB_STATUE_ASSIGN', 'BUILDJOB_COFFIN_ASSIGN', - 'BUILDJOB_CHAIR_ASSIGN', 'BUILDJOB_TABLE_ASSIGN', - 'BUILDJOB_BED_ASSIGN' } }, - { 'LEAVESCREEN' } - ), - 20 - ) - end - - if not addr then - addr = searcher:find_menu_cursor([[ -Searching for ui_building_in_assign. Please activate -the 'q' mode, select a room building (e.g. a bedroom) -and do as instructed below. - -NOTE: If not done after first 3-4 steps, resize the game window.]], - 'int8_t', - { 1, 0 }, - { [1] = 'enter the Assign owner menu', - [0] = 'press Esc to exit assign' } - ) - end - - ms.found_offset('ui_building_in_assign', addr) -end - --- --- ui_building_in_resize --- - -local function find_ui_building_in_resize() - local addr - - if dwarfmode_to_top() then - dwarfmode_feed_input('D_BUILDJOB') - try_restore_cursor() - - addr = searcher:find_interactive([[ -Auto-searching for ui_building_in_resize. Please select a room, -i.e. a bedroom, tomb, office, dining room or statue garden.]], - 'int8_t', - feed_menu_bool( - { { 'BUILDJOB_STATUE_SIZE', 'BUILDJOB_COFFIN_SIZE', - 'BUILDJOB_CHAIR_SIZE', 'BUILDJOB_TABLE_SIZE', - 'BUILDJOB_BED_SIZE' } }, - { 'LEAVESCREEN' } - ), - 20 - ) - end - - if not addr then - addr = searcher:find_menu_cursor([[ -Searching for ui_building_in_resize. Please activate -the 'q' mode, select a room building (e.g. a bedroom) -and do as instructed below. - -NOTE: If not done after first 3-4 steps, resize the game window.]], - 'int8_t', - { 1, 0 }, - { [1] = 'enter the Resize room mode', - [0] = 'press Esc to exit resize' } - ) - end - - ms.found_offset('ui_building_in_resize', addr) -end - --- --- ui_lever_target_type --- -local function find_ui_lever_target_type() - local catnames = { - 'Bridge', 'Door', 'Floodgate', - 'Cage', 'Chain', 'TrackStop', - 'GearAssembly', - } - local catkeys = { - 'HOTKEY_TRAP_BRIDGE', 'HOTKEY_TRAP_DOOR', 'HOTKEY_TRAP_FLOODGATE', - 'HOTKEY_TRAP_CAGE', 'HOTKEY_TRAP_CHAIN', 'HOTKEY_TRAP_TRACK_STOP', - 'HOTKEY_TRAP_GEAR_ASSEMBLY', - } - local addr - - if dwarfmode_to_top() then - dwarfmode_feed_input('D_BUILDJOB') - - addr = searcher:find_interactive( - 'Auto-searching for ui_lever_target_type. Please select a lever:', - 'int8_t', - feed_menu_choice(catnames, catkeys, df.lever_target_type, - {'BUILDJOB_ADD'}, - {'LEAVESCREEN', 'LEAVESCREEN'}, - true -- prompt - ), - 20 - ) - end - - if not addr then - addr = searcher:find_menu_cursor([[ -Searching for ui_lever_target_type. Please select a lever with -'q' and enter the "add task" menu with 'a':]], - 'int8_t', catnames, df.lever_target_type - ) - end - - ms.found_offset('ui_lever_target_type', addr) -end - --- --- window_x --- - -local function feed_window_xyz(dec,inc,step) - return function(idx) - if idx == 0 then - for i = 1,30 do dwarfmode_feed_input(dec) end - else - dwarfmode_feed_input(inc) - end - return true, nil, step - end -end - -local function find_window_x() - local addr - - if dwarfmode_to_top() then - addr = searcher:find_interactive( - 'Auto-searching for window_x.', - 'int32_t', - feed_window_xyz('CURSOR_LEFT_FAST', 'CURSOR_RIGHT', 10), - 20 - ) - - dwarfmode_feed_input('D_HOTKEY1') - end - - if not addr then - addr = searcher:find_counter([[ -Searching for window_x. Please exit to main dwarfmode menu, -scroll to the LEFT edge, then do as instructed:]], - 'int32_t', 10, - 'Please press Right to scroll right one step.' - ) - end - - ms.found_offset('window_x', addr) -end - --- --- window_y --- - -local function find_window_y() - local addr - - if dwarfmode_to_top() then - addr = searcher:find_interactive( - 'Auto-searching for window_y.', - 'int32_t', - feed_window_xyz('CURSOR_UP_FAST', 'CURSOR_DOWN', 10), - 20 - ) - - dwarfmode_feed_input('D_HOTKEY1') - end - - if not addr then - addr = searcher:find_counter([[ -Searching for window_y. Please exit to main dwarfmode menu, -scroll to the TOP edge, then do as instructed:]], - 'int32_t', 10, - 'Please press Down to scroll down one step.' - ) - end - - ms.found_offset('window_y', addr) -end - --- --- window_z --- - -local function find_window_z() - local addr - - if dwarfmode_to_top() then - addr = searcher:find_interactive( - 'Auto-searching for window_z.', - 'int32_t', - feed_window_xyz('CURSOR_UP_Z', 'CURSOR_DOWN_Z', -1), - 30 - ) - - dwarfmode_feed_input('D_HOTKEY1') - end - - if not addr then - addr = searcher:find_counter([[ -Searching for window_z. Please exit to main dwarfmode menu, -scroll to a Z level near surface, then do as instructed below. - -NOTE: If not done after first 3-4 steps, resize the game window.]], - 'int32_t', -1, - "Please press '>' to scroll one Z level down." - ) - end - - ms.found_offset('window_z', addr) -end - --- --- cur_year --- - -local function find_cur_year() - local zone - if os_type == 'windows' then - zone = zoomed_searcher('formation_next_id', 32) - elseif os_type == 'darwin' then - zone = zoomed_searcher('cursor', -32) - elseif os_type == 'linux' then - zone = zoomed_searcher('current_weather', -512) - end - if not zone then - dfhack.printerr('Cannot search for cur_year - prerequisites missing.') - return - end - - local yvalue = utils.prompt_input('Please enter current in-game year: ', utils.check_number) - local idx, addr = zone.area.int32_t:find_one{yvalue} - if idx then - ms.found_offset('cur_year', addr) - return - end - - dfhack.printerr('Could not find cur_year') -end - --- --- cur_year_tick --- - -function stop_autosave() - if is_known 'd_init' then - local f = df.global.d_init.flags4 - if f.AUTOSAVE_SEASONAL or f.AUTOSAVE_YEARLY then - f.AUTOSAVE_SEASONAL = false - f.AUTOSAVE_YEARLY = false - print('Disabled seasonal and yearly autosave.') - end - else - dfhack.printerr('Could not disable autosave!') - end -end - ---luacheck: in=number,df.viewscreen_dwarfmodest -function step_n_frames(cnt, feed) - local world = df.global.world - local ctick = world.frame_counter - - if feed then - print(" Auto-stepping "..cnt.." frames.") - dwarfmode_step_frames(cnt) - return world.frame_counter-ctick - end - - local more = '' - while world.frame_counter-ctick < cnt do - print(" Please step the game "..(cnt-world.frame_counter+ctick)..more.." frames.") - more = ' more' - if not prompt_proceed(2) then - return nil - end - end - return world.frame_counter-ctick -end - -local function find_cur_year_tick() - local zone - if os_type == 'windows' then - zone = zoomed_searcher('ui_unit_view_mode', 0x200) - else - zone = zoomed_searcher('cur_year', 128, true) - end - if not zone then - dfhack.printerr('Cannot search for cur_year_tick - prerequisites missing.') - return - end - - stop_autosave() - - local feed = dwarfmode_to_top() - local addr = zone:find_interactive( - 'Searching for cur_year_tick.', - 'int32_t', - function(idx) - if idx > 0 then - if not step_n_frames(1, feed) then - return false - end - end - return true, nil, 1 - end, - 20 - ) - - ms.found_offset('cur_year_tick', addr) -end - -local function find_cur_year_tick_advmode() - stop_autosave() - - local feed = dwarfmode_to_top() - local addr = searcher:find_interactive( - 'Searching for cur_year_tick_advmode.', - 'int32_t', - function(idx) - if idx > 0 then - if not step_n_frames(1, feed) then - return false - end - end - return true, nil, 144 - end, - 20 - ) - - ms.found_offset('cur_year_tick_advmode', addr) -end - --- --- cur_season_tick --- - -local function find_cur_season_tick() - if not (is_known 'cur_year_tick') then - dfhack.printerr('Cannot search for cur_season_tick - prerequisites missing.') - return - end - - stop_autosave() - - local feed = dwarfmode_to_top() - local addr = searcher:find_interactive([[ -Searching for cur_season_tick. Please exit to main dwarfmode -menu, then do as instructed below:]], - 'int32_t', - function(ccursor) - if ccursor > 0 then - if not step_n_frames(10, feed) then - return false - end - end - return true, math.floor((df.global.cur_year_tick%100800)/10) - end - ) - ms.found_offset('cur_season_tick', addr) -end - --- --- cur_season --- - -local function find_cur_season() - if not (is_known 'cur_year_tick' and is_known 'cur_season_tick') then - dfhack.printerr('Cannot search for cur_season - prerequisites missing.') - return - end - - stop_autosave() - - local feed = dwarfmode_to_top() - local addr = searcher:find_interactive([[ -Searching for cur_season. Please exit to main dwarfmode -menu, then do as instructed below:]], - 'int8_t', - function(ccursor) - if ccursor > 0 then - local cst = df.global.cur_season_tick - df.global.cur_season_tick = 10079 - df.global.cur_year_tick = df.global.cur_year_tick + (10079-cst)*10 - if not step_n_frames(10, feed) then - return false - end - end - return true, math.floor(df.global.cur_year_tick/100800)%4 - end - ) - ms.found_offset('cur_season', addr) -end - --- --- process_jobs --- - -local function get_process_zone() - if os_type == 'windows' then - return zoomed_searcher('ui_workshop_job_cursor', 'ui_building_in_resize') - elseif os_type == 'linux' or os_type == 'darwin' then - return zoomed_searcher('cur_year', 'cur_year_tick') - end -end - -local function find_process_jobs() - local zone = get_process_zone() or searcher - local addr - - stop_autosave() - - if dwarfmode_to_top() and dfhack.internal.getAddress('cursor') then - local cursor = df.global.T_cursor:new() - addr = zone:find_interactive([[ -Searching for process_jobs. Please position the cursor to the left -of at least 10 vacant natural floor tiles.]], - 'int8_t', - function(idx) - if idx == 0 then - dwarfmode_feed_input('D_LOOK') - if not prompt_proceed(2) then return false end - cursor:assign(df.global.cursor) - elseif idx == 6 then - print(' Please resize the game window.') - if not prompt_proceed(2) then return false end - end - dwarfmode_to_top() - dwarfmode_step_frames(1) - if idx % 2 == 0 then - dwarfmode_feed_input( - 'D_BUILDING', - 'HOTKEY_BUILDING_CONSTRUCTION', - 'HOTKEY_BUILDING_CONSTRUCTION_WALL' - ) - df.global.cursor:assign(cursor) - df.global.cursor.x = df.global.cursor.x + math.floor(idx / 2) - dwarfmode_feed_input('CURSOR_RIGHT', 'CURSOR_LEFT', 'SELECT', 'SELECT') - return true, 1 - else - return true, 0 - end - end, - 20) - end - - if not addr then - addr = zone:find_menu_cursor([[ -Searching for process_jobs. Please do as instructed below:]], - 'int8_t', - { 1, 0 }, - { [1] = 'designate a building to be constructed, e.g a bed or a wall', - [0] = 'step or unpause the game to reset the flag' } - ) - end - ms.found_offset('process_jobs', addr) -end - --- --- process_dig --- - -local function find_process_dig() - local zone = get_process_zone() or searcher - local addr - - stop_autosave() - - if dwarfmode_to_top() and dfhack.internal.getAddress('cursor') then - local cursor = df.global.T_cursor:new() - addr = zone:find_interactive([[ -Searching for process_dig. Please position the cursor to the left -of at least 10 unmined, unrevealed tiles.]], - 'int8_t', - function(idx) - if idx == 0 then - dwarfmode_feed_input('D_LOOK') - if not prompt_proceed(2) then return false end - cursor:assign(df.global.cursor) - elseif idx == 6 then - print(' Please resize the game window.') - if not prompt_proceed(2) then return false end - end - dwarfmode_to_top() - dwarfmode_step_frames(1) - if idx % 2 == 0 then - dwarfmode_feed_input('D_DESIGNATE', 'DESIGNATE_DIG') - df.global.cursor:assign(cursor) - df.global.cursor.x = df.global.cursor.x + math.floor(idx / 2) - dwarfmode_feed_input('SELECT', 'SELECT') - return true, 1 - else - return true, 0 - end - end, - 20) - end - - if not addr then - addr = zone:find_menu_cursor([[ -Searching for process_dig. Please do as instructed below:]], - 'int8_t', - { 1, 0 }, - { [1] = 'designate a tile to be mined out', - [0] = 'step or unpause the game to reset the flag' } - ) - end - ms.found_offset('process_dig', addr) -end - --- --- pause_state --- - -local function find_pause_state() - local zone, addr - if os_type == 'linux' or os_type == 'darwin' then - zone = zoomed_searcher('ui_look_cursor', 32, true) - elseif os_type == 'windows' then - zone = zoomed_searcher('ui_workshop_job_cursor', 80) - end - zone = zone or searcher - - stop_autosave() - - if dwarfmode_to_top() then - addr = zone:find_interactive( - 'Auto-searching for pause_state', - 'int8_t', - function(idx) - if idx%2 == 0 then - dwarfmode_feed_input('D_ONESTEP') - return true, 0 - else - screen_dwarfmode():logic() - return true, 1 - end - end, - 20 - ) - end - - if not addr then - addr = zone:find_menu_cursor([[ -Searching for pause_state. Please do as instructed below:]], - 'int8_t', - { 1, 0 }, - { [1] = 'PAUSE the game', - [0] = 'UNPAUSE the game' } - ) - end - - ms.found_offset('pause_state', addr) -end - --- --- standing orders --- - -local function find_standing_orders(gname, seq, depends) - if type(seq) ~= 'table' then seq = {seq} end - for k, v in pairs(depends) do - if not dfhack.internal.getAddress(k) then - dfhack.printerr(('Cannot locate %s: %s not found'):format(gname, k)) - return - end - df.global[k] = v - end - local addr - if dwarfmode_to_top() then - addr = searcher:find_interactive( - 'Auto-searching for ' .. gname, - 'uint8_t', - function(idx) - dwarfmode_feed_input('D_ORDERS') - dwarfmode_feed_input(table.unpack(seq)) - return true - end - ) - else - dfhack.printerr("Won't scan for standing orders global manually: " .. gname) - return - end - - ms.found_offset(gname, addr) -end - -local function exec_finder_so(gname, seq, _depends) - local depends = {} --as:number[] - local _depends = _depends or {} --as:number[] - for k, v in pairs(_depends) do - if k:find('standing_orders_') ~= 1 then - k = 'standing_orders_' .. k - end - depends[k] = v - end - if force_scan['standing_orders'] then - force_scan[gname] = true - end - exec_finder(function() - return find_standing_orders(gname, seq, depends) - end, gname) -end - --- --- MAIN FLOW --- - -print('\nInitial globals (need title screen):\n') - -exec_finder(find_gview, 'gview') -exec_finder(find_cursor, { 'cursor', 'selection_rect', 'gamemode', 'gametype' }) -exec_finder(find_d_init, 'd_init', is_valid_d_init) -exec_finder(find_enabler, 'enabler', is_valid_enabler) -exec_finder(find_gps, 'gps', is_valid_gps) - -print('\nCompound globals (need loaded world):\n') - -print('\nPlease load the save previously processed with prepare-save.') -if not prompt_proceed() then - searcher:reset() - return -end - -exec_finder(find_world, 'world', is_valid_world) -exec_finder(find_ui, 'ui', is_valid_ui) -exec_finder(find_ui_sidebar_menus, 'ui_sidebar_menus') -exec_finder(find_ui_build_selector, 'ui_build_selector') -exec_finder(find_init, 'init', is_valid_init) - -print('\nPrimitive globals:\n') - -exec_finder(find_ui_menu_width, 'ui_menu_width') -exec_finder(find_ui_selected_unit, 'ui_selected_unit') -exec_finder(find_ui_unit_view_mode, 'ui_unit_view_mode') -exec_finder(find_ui_look_cursor, 'ui_look_cursor') -exec_finder(find_ui_building_item_cursor, 'ui_building_item_cursor') -exec_finder(find_ui_workshop_in_add, 'ui_workshop_in_add') -exec_finder(find_ui_workshop_job_cursor, 'ui_workshop_job_cursor') -exec_finder(find_ui_building_in_assign, 'ui_building_in_assign') -exec_finder(find_ui_building_in_resize, 'ui_building_in_resize') -exec_finder(find_ui_lever_target_type, 'ui_lever_target_type') -exec_finder(find_window_x, 'window_x') -exec_finder(find_window_y, 'window_y') -exec_finder(find_window_z, 'window_z') -exec_finder(find_current_weather, 'current_weather') - -print('\nUnpausing globals:\n') - -exec_finder(find_cur_year, 'cur_year') -exec_finder(find_cur_year_tick, 'cur_year_tick') -exec_finder(find_cur_year_tick_advmode, 'cur_year_tick_advmode') -exec_finder(find_cur_season_tick, 'cur_season_tick') -exec_finder(find_cur_season, 'cur_season') -exec_finder(find_process_jobs, 'process_jobs') -exec_finder(find_process_dig, 'process_dig') -exec_finder(find_pause_state, 'pause_state') - -print('\nStanding orders:\n') - -exec_finder_so('standing_orders_gather_animals', 'ORDERS_GATHER_ANIMALS') -exec_finder_so('standing_orders_gather_bodies', 'ORDERS_GATHER_BODIES') -exec_finder_so('standing_orders_gather_food', 'ORDERS_GATHER_FOOD') -exec_finder_so('standing_orders_gather_furniture', 'ORDERS_GATHER_FURNITURE') -exec_finder_so('standing_orders_gather_minerals', 'ORDERS_GATHER_STONE') -exec_finder_so('standing_orders_gather_wood', 'ORDERS_GATHER_WOOD') - -exec_finder_so('standing_orders_gather_refuse', - {'ORDERS_REFUSE', 'ORDERS_REFUSE_GATHER'}) -exec_finder_so('standing_orders_gather_refuse_outside', - {'ORDERS_REFUSE', 'ORDERS_REFUSE_OUTSIDE'}, {gather_refuse=1}) -exec_finder_so('standing_orders_gather_vermin_remains', - {'ORDERS_REFUSE', 'ORDERS_REFUSE_OUTSIDE_VERMIN'}, {gather_refuse=1, gather_refuse_outside=1}) -exec_finder_so('standing_orders_dump_bones', - {'ORDERS_REFUSE', 'ORDERS_REFUSE_DUMP_BONE'}, {gather_refuse=1}) -exec_finder_so('standing_orders_dump_corpses', - {'ORDERS_REFUSE', 'ORDERS_REFUSE_DUMP_CORPSE'}, {gather_refuse=1}) -exec_finder_so('standing_orders_dump_hair', - {'ORDERS_REFUSE', 'ORDERS_REFUSE_DUMP_STRAND_TISSUE'}, {gather_refuse=1}) -exec_finder_so('standing_orders_dump_other', - {'ORDERS_REFUSE', 'ORDERS_REFUSE_DUMP_OTHER'}, {gather_refuse=1}) -exec_finder_so('standing_orders_dump_shells', - {'ORDERS_REFUSE', 'ORDERS_REFUSE_DUMP_SHELL'}, {gather_refuse=1}) -exec_finder_so('standing_orders_dump_skins', - {'ORDERS_REFUSE', 'ORDERS_REFUSE_DUMP_SKIN'}, {gather_refuse=1}) -exec_finder_so('standing_orders_dump_skulls', - {'ORDERS_REFUSE', 'ORDERS_REFUSE_DUMP_SKULL'}, {gather_refuse=1}) - - -exec_finder_so('standing_orders_auto_butcher', - {'ORDERS_WORKSHOP', 'ORDERS_BUTCHER'}) -exec_finder_so('standing_orders_auto_collect_webs', - {'ORDERS_WORKSHOP', 'ORDERS_COLLECT_WEB'}) -exec_finder_so('standing_orders_auto_fishery', - {'ORDERS_WORKSHOP', 'ORDERS_AUTO_FISHERY'}) -exec_finder_so('standing_orders_auto_kiln', - {'ORDERS_WORKSHOP', 'ORDERS_AUTO_KILN'}) -exec_finder_so('standing_orders_auto_kitchen', - {'ORDERS_WORKSHOP', 'ORDERS_AUTO_KITCHEN'}) -exec_finder_so('standing_orders_auto_loom', - {'ORDERS_WORKSHOP', 'ORDERS_LOOM'}) -exec_finder_so('standing_orders_auto_other', - {'ORDERS_WORKSHOP', 'ORDERS_AUTO_OTHER'}) -exec_finder_so('standing_orders_auto_slaughter', - {'ORDERS_WORKSHOP', 'ORDERS_SLAUGHTER'}) -exec_finder_so('standing_orders_auto_smelter', - {'ORDERS_WORKSHOP', 'ORDERS_AUTO_SMELTER'}) -exec_finder_so('standing_orders_auto_tan', - {'ORDERS_WORKSHOP', 'ORDERS_TAN'}) -exec_finder_so('standing_orders_use_dyed_cloth', - {'ORDERS_WORKSHOP', 'ORDERS_DYED_CLOTH'}) - -exec_finder_so('standing_orders_forbid_other_dead_items', - {'ORDERS_AUTOFORBID', 'ORDERS_FORBID_OTHER_ITEMS'}) -exec_finder_so('standing_orders_forbid_other_nohunt', - {'ORDERS_AUTOFORBID', 'ORDERS_FORBID_OTHER_CORPSE'}) -exec_finder_so('standing_orders_forbid_own_dead', - {'ORDERS_AUTOFORBID', 'ORDERS_FORBID_YOUR_CORPSE'}) -exec_finder_so('standing_orders_forbid_own_dead_items', - {'ORDERS_AUTOFORBID', 'ORDERS_FORBID_YOUR_ITEMS'}) -exec_finder_so('standing_orders_forbid_used_ammo', - {'ORDERS_AUTOFORBID', 'ORDERS_FORBID_PROJECTILE'}) - -exec_finder_so('standing_orders_farmer_harvest', 'ORDERS_ALL_HARVEST') -exec_finder_so('standing_orders_job_cancel_announce', 'ORDERS_EXCEPTIONS') -exec_finder_so('standing_orders_mix_food', 'ORDERS_MIXFOODS') - -exec_finder_so('standing_orders_zoneonly_drink', - {'ORDERS_ZONE', 'ORDERS_ZONE_DRINKING'}) -exec_finder_so('standing_orders_zoneonly_fish', - {'ORDERS_ZONE', 'ORDERS_ZONE_FISHING'}) - -dwarfmode_to_top() -print('\nDone. Now exit the game with the die command and add\n'.. - 'the newly-found globals to symbols.xml. You can find them\n'.. - 'in stdout.log or here:\n') - -for _, global in ipairs(finder_searches) do - local addr = dfhack.internal.getAddress(global) - if addr ~= nil then - local ival = addr - dfhack.internal.getRebaseDelta() - print(string.format("", global, ival)) - end -end - -searcher:reset() diff --git a/devel/hello-world.lua b/devel/hello-world.lua new file mode 100644 index 0000000000..2af576f815 --- /dev/null +++ b/devel/hello-world.lua @@ -0,0 +1,92 @@ +-- A basic example to start your own gui script from. +--@ module = true + +local gui = require('gui') +local widgets = require('gui.widgets') + +local HIGHLIGHT_PEN = dfhack.pen.parse{ + ch=string.byte(' '), + fg=COLOR_LIGHTGREEN, + bg=COLOR_LIGHTGREEN, +} + +HelloWorldWindow = defclass(HelloWorldWindow, widgets.Window) +HelloWorldWindow.ATTRS{ + frame={w=25, h=25}, + frame_title='Hello World', + autoarrange_subviews=true, + autoarrange_gap=2, + resizable=true, + resize_min={w=25, h=25}, +} + +function HelloWorldWindow:init() + local LEVEL_OPTIONS = { + {label='Low', value=1}, + {label='Medium', value=2}, + {label='High', value=3}, + {label='Pro', value=4}, + {label='Insane', value=5}, + } + + self:addviews{ + widgets.Label{text={{text='Hello, world!', pen=COLOR_LIGHTGREEN}}}, + widgets.HotkeyLabel{ + frame={l=0}, + label='Click me', + key='CUSTOM_CTRL_A', + on_activate=self:callback('toggleHighlight'), + }, + widgets.Panel{ + view_id='highlight', + frame={w=10, h=5}, + frame_style=gui.INTERIOR_FRAME, + }, + widgets.Divider{ + frame={h=1}, + frame_style_l=false, + frame_style_r=false, + }, + widgets.CycleHotkeyLabel{ + view_id='level', + frame={l=0, w=20}, + label='Level:', + key_back='CUSTOM_SHIFT_C', + key='CUSTOM_SHIFT_V', + options=LEVEL_OPTIONS, + initial_option=LEVEL_OPTIONS[1].value, + }, + widgets.Slider{ + frame={l=1}, + num_stops=#LEVEL_OPTIONS, + get_idx_fn=function() + return self.subviews.level:getOptionValue() + end, + on_change=function(idx) self.subviews.level:setOption(idx) end, + }, + } +end + +function HelloWorldWindow:toggleHighlight() + local panel = self.subviews.highlight + panel.frame_background = not panel.frame_background and HIGHLIGHT_PEN or nil +end + +HelloWorldScreen = defclass(HelloWorldScreen, gui.ZScreen) +HelloWorldScreen.ATTRS{ + focus_path='hello-world', +} + +function HelloWorldScreen:init() + self:addviews{HelloWorldWindow{}} +end + +function HelloWorldScreen:onDismiss() + view = nil +end + +if dfhack_flags.module then + return +end + +view = view and view:raise() or HelloWorldScreen{}:show() diff --git a/devel/inject-raws.lua b/devel/inject-raws.lua index 446baaadb3..62b4403c12 100644 --- a/devel/inject-raws.lua +++ b/devel/inject-raws.lua @@ -134,7 +134,7 @@ function add_to_dwarf_civs(btype, id) end for _,entity in ipairs(df.global.world.entities.all) do - if entity.race == df.global.ui.race_id then + if entity.race == df.global.plotinfo.race_id then local bvec = typeinfo[3] --as:string add_to_civ(entity, bvec, id) end diff --git a/devel/input-monitor.lua b/devel/input-monitor.lua new file mode 100644 index 0000000000..7b1c20fad6 --- /dev/null +++ b/devel/input-monitor.lua @@ -0,0 +1,146 @@ +local gui = require('gui') +local widgets = require('gui.widgets') + +----------------------- +-- InputMonitorWindow +-- + +InputMonitorWindow = defclass(InputMonitorWindow, widgets.Window) +InputMonitorWindow.ATTRS{ + frame={w=51, h=50}, + frame_title='Input Monitor', + resizable=true, + resize_min={h=20}, +} + +local function getModifierPen(which) + return dfhack.internal.getModifiers()[which] and + COLOR_WHITE or COLOR_GRAY +end + +local function getButtonPen(which) + which = ('mouse_%s_down'):format(which) + return df.global.enabler[which] == 1 and + COLOR_WHITE or COLOR_GRAY +end + +function InputMonitorWindow:init() + self:addviews{ + widgets.Label{ + frame={l=0, t=0}, + text={ + 'Modifier keys:', + {gap=1, text='Shift', pen=function() return getModifierPen('shift') end}, + {gap=1, text='Ctrl', pen=function() return getModifierPen('ctrl') end}, + {gap=1, text='Alt', pen=function() return getModifierPen('alt') end}, + }, + }, + widgets.Label{ + frame={l=0, t=2}, + text={ + 'Mouse buttons:', + {gap=1, text='Lbut', pen=function() return getButtonPen('lbut') end}, + {gap=1, text='Mbut', pen=function() return getButtonPen('mbut') end}, + {gap=1, text='Rbut', pen=function() return getButtonPen('rbut') end}, + }, + }, + widgets.Panel{ + view_id='streampanel', + frame={t=4, b=2, l=0, r=0}, + frame_style=gui.INTERIOR_FRAME, + subviews={ + widgets.Label{ + frame={t=0, l=0}, + text='Input stream (newest at bottom):', + }, + widgets.Label{ + view_id='streamlog', + frame={t=1, l=2, b=0}, + auto_height=false, + text={}, + }, + }, + }, + widgets.HotkeyLabel{ + frame={b=0}, + key='LEAVESCREEN', + label='Hit ESC twice or click here twice to close', + text_pen=function() + return self.escape_armed and COLOR_LIGHTRED or COLOR_WHITE + end, + auto_width=true, + on_activate=function() + if self.escape_armed then + self.parent_view:dismiss() + end + self.escape_armed = true + end, + }, + } +end + +function InputMonitorWindow:onInput(keys) + local streamlog = self.subviews.streamlog + local stream = streamlog.text + if #stream > 0 then + table.insert(stream, NEWLINE) + table.insert(stream, NEWLINE) + end + for key in pairs(keys) do + if key == '_STRING' then + table.insert(stream, + ('_STRING="%s" (%d)'):format(keys._STRING == 0 and '' or string.char(keys._STRING), keys._STRING)) + else + table.insert(stream, key) + end + print(stream[#stream]) + table.insert(stream, NEWLINE) + end + print() + local newstream = {} + local num_lines = self.subviews.streampanel.frame_rect.height - 2 + for idx=#stream,1,-1 do + local elem = stream[idx] + if elem == NEWLINE then + num_lines = num_lines - 1 + if num_lines <= 0 then + break + end + end + table.insert(newstream, elem) + end + for idx=1,#newstream//2 do + local mirror_idx = #newstream-idx+1 + newstream[idx], newstream[mirror_idx] = newstream[mirror_idx], newstream[idx] + end + streamlog:setText(newstream) + + InputMonitorWindow.super.onInput(self, keys) + if not keys._MOUSE_L and not keys._MOUSE_L_DOWN and not keys.LEAVESCREEN then + self.escape_armed = false + end + return true +end + +----------------------- +-- InputMonitorScreen +-- + +InputMonitorScreen = defclass(InputMonitorScreen, gui.ZScreen) +InputMonitorScreen.ATTRS{ + focus_path='input-monitor', +} + +function InputMonitorScreen:init() + self:addviews{InputMonitorWindow{}} +end + +function InputMonitorScreen:onDismiss() + view = nil +end + +if dfhack_flags.module then + return +end + +view = view and view:raise() or InputMonitorScreen{}:show() diff --git a/devel/inspect-screen.lua b/devel/inspect-screen.lua index 62b5b35798..c92f4b5f15 100644 --- a/devel/inspect-screen.lua +++ b/devel/inspect-screen.lua @@ -1,110 +1,354 @@ -- Read from the screen and display info about the tiles ---[====[ -devel/inspect-screen -==================== -Read the tiles from the screen and display info about them. +local gui = require('gui') +local guidm = require('gui.dwarfmode') +local widgets = require('gui.widgets') +local overlay = require('plugins.overlay') -]====] +Inspect = defclass(Inspect, widgets.Window) +Inspect.ATTRS{ + frame={w=40, h=20}, + resizable=true, + frame_title='Screen Inspector', +} -local utils = require 'utils' -local gui = require 'gui' +function Inspect:init() + local scr_name = overlay.simplify_viewscreen_name( + getmetatable(dfhack.gui.getDFViewscreen(true))) -InspectScreen = defclass(InspectScreen, gui.Screen) + self:addviews{ + widgets.Label{ + frame={t=0, l=0}, + text={'Current screen: ', {text=scr_name, pen=COLOR_CYAN}}, + }, + widgets.CycleHotkeyLabel{ + view_id='layer', + frame={t=2, l=0}, + key='CUSTOM_CTRL_A', + label='Inspect layer:', + options={{label='UI', value='ui'}, 'map'}, + enabled=self:callback('is_unfrozen'), + }, + widgets.CycleHotkeyLabel{ + view_id='empties', + frame={t=3, l=0}, + key='CUSTOM_CTRL_E', + label='Empty elements:', + options={'hide', 'show'}, + }, + widgets.ToggleHotkeyLabel{ + view_id='freeze', + frame={t=4, l=0}, + key='CUSTOM_CTRL_F', + label='Freeze current tile:', + initial_option=false, + }, + widgets.Label{ + frame={t=6}, + text={{text=self:callback('get_grid_size')}}, + }, + widgets.Label{ + frame={t=7}, + text={{text=self:callback('get_mouse_pos')}}, + }, + widgets.Label{ + view_id='report', + frame={t=9}, + }, + } +end -function InspectScreen:init(args) - local w,h = dfhack.screen.getWindowSize() - self.cursor_x = math.floor(w/2) - self.cursor_y = math.floor(h/2) +function Inspect:is_unfrozen() + return not self.subviews.freeze:getOptionValue() end -function InspectScreen:computeFrame(parent_rect) - local sw, sh = parent_rect.width, parent_rect.height - self.cursor_x = math.max(0, math.min(self.cursor_x, sw-1)) - self.cursor_y = math.max(0, math.min(self.cursor_y, sh-1)) +function Inspect:do_refresh() + return self:is_unfrozen() and not self:getMouseFramePos() +end - local frame = { w = 14, r = 1, h = 10, t = 1 } - if self.cursor_x > sw/2 then - frame = { w = 14, l = 1, h = 10, t = 1 } +function Inspect:get_grid_size() + if self.subviews.layer:getOptionValue() == 'ui' then + local width, height = dfhack.screen.getWindowSize() + return ('UI grid size: %d x %d'):format(width, height) + end + local layout = guidm.getPanelLayout() + return ('Map grid size: %d x %d'):format(layout.map.width, layout.map.height) +end + +local cur_mouse_pos = {x=-1, y=-1} +function Inspect:get_mouse_pos() + local pos, text = cur_mouse_pos, '' + if self.subviews.layer:getOptionValue() == 'ui' then + if self:do_refresh() then + pos = xy2pos(dfhack.screen.getMousePos()) + end + text = ('UI grid coords: %s, %s'):format(pos.x, pos.y) + else + if self:do_refresh() then + pos = dfhack.gui.getMousePos() + end + if pos then + text = ('Map coords: %s, %s, %s'):format(pos.x, pos.y, pos.z) + else + text = 'Mouse is not on the map' + end end + cur_mouse_pos = pos or cur_mouse_pos + return text +end - return gui.compute_frame_body(sw, sh, frame, 1, 0, false) +local function add_screen_report_line(report, base, vec, index, show_empty) + local ch = base[vec][index] + if ch <= 0 and not show_empty then return end + table.insert(report, '[' .. string.char(ch) .. ']') + table.insert(report, (' (%s) - %s'):format(ch, vec)) + table.insert(report, NEWLINE) + table.insert(report, (' fg: %d, %d, %d') + :format(base[vec][index+1], base[vec][index+2], base[vec][index+3])) + table.insert(report, NEWLINE) + table.insert(report, (' bg: %d, %d, %d') + :format(base[vec][index+4], base[vec][index+5], base[vec][index+6])) + table.insert(report, NEWLINE) end -function InspectScreen:onRenderFrame(dc, rect) - self:renderParent() - self.cursor_pen = dfhack.screen.readTile(self.cursor_x, self.cursor_y) - if gui.blink_visible(100) then - dfhack.screen.paintTile({ch='X',fg=COLOR_LIGHTGREEN}, self.cursor_x, self.cursor_y) +local function add_texpos_report_line(report, base, vec, index, show_empty) + local texpos = base[vec][index] + if texpos <= 0 and not show_empty then return end + table.insert(report, '[') + table.insert(report, {tile=texpos}) + table.insert(report, ']') + table.insert(report, (' (%s) - %s'):format(texpos, vec)) + table.insert(report, NEWLINE) +end + +local function pop_flag(report, flag, name, show_empty, dim) + dim = dim or 2 + local bit = flag % dim + if bit ~= 0 or show_empty then + table.insert(report, (' %s = %d'):format(name, bit)) + table.insert(report, NEWLINE) end - dc:fill(rect, {ch=' ',fg=COLOR_WHITE,bg=COLOR_CYAN}) + return flag // dim end -local FG_PEN = {fg=COLOR_WHITE,bg=COLOR_BLACK,tile_color=true} -local BG_PEN = {fg=COLOR_BLACK,bg=COLOR_WHITE,tile_color=true} -local TXT_PEN = {fg=COLOR_WHITE} - -function InspectScreen:onRenderBody(dc) - dc:pen(COLOR_WHITE, COLOR_CYAN) - if self.cursor_pen then - local info = self.cursor_pen - dc:string('CH: '):char(info.ch, FG_PEN):char(info.ch, BG_PEN):string(' '):string(''..info.ch,TXT_PEN):newline() - local fgcolor = info.fg - local fgstr = tostring(info.fg) - if info.bold then - fgcolor = (fgcolor+8)%16 - fgstr = fgstr..'+8' - end - dc:string('FG: '):string('NN',{fg=fgcolor}):string(' '):string(''..fgstr,TXT_PEN) - dc:seek(dc.width-1):char(info.ch,{fg=info.fg,bold=info.bold}):newline() - dc:string('BG: '):string('NN',{fg=info.bg}):string(' '):string(''..info.bg,TXT_PEN) - dc:seek(dc.width-1):char(info.ch,{fg=COLOR_BLACK,bg=info.bg}):newline() - local bstring = 'false' - if info.bold then bstring = 'true' end - dc:string('Bold: '..bstring):newline():newline() - - if info.tile and gui.USE_GRAPHICS then - dc:string('TL: '):tile(' ', info.tile, FG_PEN):tile(' ', info.tile, BG_PEN):string(' '..info.tile):newline() - if info.tile_color then - dc:string('Color: true') - elseif info.tile_fg then - dc:string('FG: '):string('NN',{fg=info.tile_fg}):string(' '):string(''..info.tile_fg,TXT_PEN):newline() - dc:string('BG: '):string('NN',{fg=info.tile_bg}):string(' '):string(''..info.tile_bg,TXT_PEN):newline() - end - end - else - dc:string('Invalid', COLOR_LIGHTRED) +local function add_texpos_flag_report_line(report, base, vec, index, flags, show_empty) + local val = base[vec][index] + if val == 0 and not show_empty then return end + table.insert(report, vec) + table.insert(report, NEWLINE) + for _,f in ipairs(flags or {}) do + val = pop_flag(report, val, f.name, show_empty, f.dim) end end -local MOVEMENT_KEYS = { - CURSOR_UP = { 0, -1, 0 }, CURSOR_DOWN = { 0, 1, 0 }, - CURSOR_LEFT = { -1, 0, 0 }, CURSOR_RIGHT = { 1, 0, 0 }, - CURSOR_UPLEFT = { -1, -1, 0 }, CURSOR_UPRIGHT = { 1, -1, 0 }, - CURSOR_DOWNLEFT = { -1, 1, 0 }, CURSOR_DOWNRIGHT = { 1, 1, 0 }, - CURSOR_UP_FAST = { 0, -1, 0, true }, CURSOR_DOWN_FAST = { 0, 1, 0, true }, - CURSOR_LEFT_FAST = { -1, 0, 0, true }, CURSOR_RIGHT_FAST = { 1, 0, 0, true }, - CURSOR_UPLEFT_FAST = { -1, -1, 0, true }, CURSOR_UPRIGHT_FAST = { 1, -1, 0, true }, - CURSOR_DOWNLEFT_FAST = { -1, 1, 0, true }, CURSOR_DOWNRIGHT_FAST = { 1, 1, 0, true }, +local gps = df.global.gps + +local screentexpos_flags = { + {name='grayscale'}, + {name='addcolor'}, + {name='anchor_subordinate'}, + {name='top_of_text'}, + {name='bottom_of_text'}, + {name='anchor_use_screen_color'}, + {name='anchor_x_coord', dim=64}, + {name='anchor_y_coord', dim=64}, } -function InspectScreen:onInput(keys) - if keys.LEAVESCREEN then - self:dismiss() - else - for k,v in pairs(MOVEMENT_KEYS) do - if keys[k] then - local delta = 1 - if v[4] then - delta = 10 - end - self.cursor_x = self.cursor_x + delta*v[1] - self.cursor_y = self.cursor_y + delta*v[2] - self:updateLayout() - return - end - end +local function get_ui_report(show_empty) + local report = {} + + local pos = cur_mouse_pos + if pos.x < 0 or pos.y < 0 then return report end + + local index = (pos.x * gps.dimy) + pos.y + + add_screen_report_line(report, gps, 'screen', index*8, show_empty) + add_texpos_report_line(report, gps, 'screentexpos_lower', index, show_empty) + add_texpos_report_line(report, gps, 'screentexpos', index, show_empty) + add_texpos_report_line(report, gps, 'screentexpos_anchored', index, show_empty) + add_texpos_report_line(report, gps, 'screentexpos_anchored_x', index, show_empty) + add_texpos_report_line(report, gps, 'screentexpos_anchored_y', index, show_empty) + add_texpos_flag_report_line(report, gps, 'screentexpos_flag', index, + screentexpos_flags, show_empty) + + if gps.top_in_use then + add_screen_report_line(report, gps, 'screen_top', index*8, show_empty) + add_texpos_report_line(report, gps, 'screentexpos_top_lower', index, show_empty) + add_texpos_report_line(report, gps, 'screentexpos_top', index, show_empty) + add_texpos_report_line(report, gps, 'screentexpos_top_anchored', index, show_empty) + add_texpos_report_line(report, gps, 'screentexpos_top_anchored_x', index, show_empty) + add_texpos_report_line(report, gps, 'screentexpos_top_anchored_y', index, show_empty) + add_texpos_flag_report_line(report, gps, 'screentexpos_top_flag', index, + screentexpos_flags, show_empty) + end + + return report +end + +local viewport_floor_flags = { + {name='s_edging', dim=256}, + {name='w_edging', dim=256}, + {name='e_edging', dim=256}, + {name='n_edging', dim=256}, + {name='special_texture', dim=8}, +} + +local viewport_liquid_flags = { + {name='center_animation', dim=4}, + {name='center_level', dim=8}, + {name='center_type', dim=16}, + {name='n_edge_type', dim=16}, + {name='s_edge_type', dim=16}, + {name='w_edge_type', dim=16}, + {name='e_edge_type', dim=16}, +} + +local viewport_spatter_flags = { + {name='shape_type', dim=64}, + {name='material_type', dim=32}, + {name='color_index', dim=256}, + {name='derived_plus', dim=512}, + {name='fire', dim=8}, + {name='accepts_spatter'}, +} + +local viewport_ramp_flags = { + {name='type', dim=256}, + {name='wall_n'}, + {name='wall_w'}, + {name='wall_e'}, + {name='wall_s'}, + {name='wall_nw'}, + {name='wall_ne'}, + {name='wall_sw'}, + {name='wall_se'}, + {name='n_is_dark_corner'}, + {name='s_is_dark_corner'}, + {name='w_is_dark_corner'}, + {name='e_is_dark_corner'}, + {name='n_is_open_air'}, + {name='s_is_open_air'}, + {name='w_is_open_air'}, + {name='e_is_open_air'}, + {name='show_up_arrow'}, + {name='show_down_arrow'}, + {name='color_index', dim=256}, +} + +local viewport_shadow_flags = { + {name='accepts_shadow'}, + {name='is_shadow_wall'}, + {name='shadow_wall_to_n'}, + {name='shadow_wall_to_s'}, + {name='shadow_wall_to_w'}, + {name='shadow_wall_to_e'}, + {name='shadow_wall_to_nw'}, + {name='shadow_wall_to_ne'}, + {name='shadow_wall_to_sw'}, + {name='shadow_wall_to_se'}, + {name='ramp_shadow_on_floor_nw_of_corner_se'}, + {name='ramp_shadow_on_floor_n_of_corner_se'}, + {name='ramp_shadow_on_floor_n_of_s'}, + {name='ramp_shadow_on_floor_n_of_corner_sw'}, + {name='ramp_shadow_on_floor_ne_of_corner_sw'}, + {name='ramp_shadow_on_floor_w_of_corner_se'}, + {name='ramp_shadow_on_floor_e_of_corner_sw'}, + {name='ramp_shadow_on_floor_w_of_e'}, + {name='ramp_shadow_on_floor_e_of_w'}, + {name='ramp_shadow_on_floor_w_of_corner_ne'}, + {name='ramp_shadow_on_floor_e_of_corner_nw'}, + {name='ramp_shadow_on_floor_sw_of_corner_ne'}, + {name='ramp_shadow_on_floor_s_of_corner_ne'}, + {name='ramp_shadow_on_floor_s_of_n'}, + {name='ramp_shadow_on_floor_s_of_corner_nw'}, + {name='ramp_shadow_on_floor_se_of_corner_nw'}, +} + +local function get_map_report(show_empty) + local report = {} + + local pos = cur_mouse_pos + if not pos or not pos.z then + return report + end + + if not dfhack.screen.inGraphicsMode() then + table.insert(report, 'Please inspect the UI layer') + table.insert(report, NEWLINE) + table.insert(report, 'when not in graphics mode') + return report + end + + local vp = gps.main_viewport + local index = ((pos.x - df.global.window_x) * vp.dim_y) + pos.y - df.global.window_y + + add_texpos_report_line(report, vp, 'screentexpos_background', index, show_empty) + add_texpos_flag_report_line(report, vp, 'screentexpos_floor_flag', index, + viewport_floor_flags, show_empty) + add_texpos_report_line(report, vp, 'screentexpos_background_two', index, show_empty) + add_texpos_flag_report_line(report, vp, 'screentexpos_liquid_flag', index, + viewport_liquid_flags, show_empty) + add_texpos_flag_report_line(report, vp, 'screentexpos_spatter_flag', index, + viewport_spatter_flags, show_empty) + add_texpos_report_line(report, vp, 'screentexpos_spatter', index, show_empty) + add_texpos_flag_report_line(report, vp, 'screentexpos_ramp_flag', index, + viewport_ramp_flags, show_empty) + add_texpos_flag_report_line(report, vp, 'screentexpos_shadow_flag', index, + viewport_shadow_flags, show_empty) + add_texpos_report_line(report, vp, 'screentexpos_building_one', index, show_empty) + add_texpos_report_line(report, vp, 'screentexpos_item', index, show_empty) + add_texpos_report_line(report, vp, 'screentexpos_vehicle', index, show_empty) + add_texpos_report_line(report, vp, 'screentexpos_vermin', index, show_empty) + add_texpos_report_line(report, vp, 'screentexpos_left_creature', index, show_empty) + add_texpos_report_line(report, vp, 'screentexpos', index, show_empty) + add_texpos_report_line(report, vp, 'screentexpos_right_creature', index, show_empty) + add_texpos_report_line(report, vp, 'screentexpos_building_two', index, show_empty) + add_texpos_report_line(report, vp, 'screentexpos_projectile', index, show_empty) + add_texpos_report_line(report, vp, 'screentexpos_high_flow', index, show_empty) + add_texpos_report_line(report, vp, 'screentexpos_top_shadow', index, show_empty) + add_texpos_report_line(report, vp, 'screentexpos_signpost', index, show_empty) + add_texpos_report_line(report, vp, 'screentexpos_upleft_creature', index, show_empty) + add_texpos_report_line(report, vp, 'screentexpos_up_creature', index, show_empty) + add_texpos_report_line(report, vp, 'screentexpos_upright_creature', index, show_empty) + add_texpos_report_line(report, vp, 'screentexpos_designation', index, show_empty) + add_texpos_report_line(report, vp, 'screentexpos_interface', index, show_empty) + + return report +end + +function Inspect:onRenderBody() + if not self:do_refresh() then return end + local show_empty = self.subviews.empties:getOptionValue() == 'show' + local report = self.subviews.layer:getOptionValue() == 'ui' and + get_ui_report(show_empty) or get_map_report(show_empty) + self.subviews.report:setText(report) + self:updateLayout() +end + +function Inspect:onInput(keys) + if Inspect.super.onInput(self, keys) then + return true + end + if keys._MOUSE_L and not self:getMouseFramePos() then + self.subviews.freeze:cycle() + return true end end -InspectScreen{}:show() +InspectScreen = defclass(InspectScreen, gui.ZScreenModal) +InspectScreen.ATTRS{ + focus_string='inspect-screen', +} + +function InspectScreen:init() + -- prevent hotspot widgets from reacting + overlay.register_trigger_lock_screen(self) + + self:addviews{Inspect{}} +end + +function InspectScreen:onDismiss() + view = nil +end + +view = view and view:raise() or InspectScreen{}:show() diff --git a/devel/kill-hf.lua b/devel/kill-hf.lua new file mode 100644 index 0000000000..63b8083988 --- /dev/null +++ b/devel/kill-hf.lua @@ -0,0 +1,39 @@ +-- Kills the specified historical figure + +local target_hf = -1 +local target_pregnancy = false +local dry_run = false + +for _, arg in ipairs({...}) do + if arg == '-p' or arg == '--pregnancy' then + target_pregnancy = true + elseif arg == '-n' or arg == '--dry-run' then + dry_run = true + elseif tonumber(arg) and target_hf == -1 then + target_hf = tonumber(arg) + else + qerror('unrecognized argument: ' .. arg) + end +end + +local hf = df.historical_figure.find(target_hf) + or qerror('histfig not found: ' .. target_hf) +local hf_name = dfhack.df2console(dfhack.translation.translateName(hf.name)) +local hf_desc = ('%i: %s (%s)'):format(target_hf, hf_name, dfhack.units.getRaceNameById(hf.race)) + +if dry_run then + print('Would target histfig ' .. hf_desc) + return +end + +if target_pregnancy then + hf.info.wounds.childbirth_year = -1 + hf.info.wounds.childbirth_tick = -1 + print('Terminated pregnancy of histfig ' .. hf_desc) +else + hf.old_year = df.global.cur_year + hf.old_seconds = df.global.cur_year_tick + 1 + hf.died_year = df.global.cur_year + hf.died_seconds = df.global.cur_year_tick + 1 + print('Killed histfig ' .. hf_desc) +end diff --git a/devel/light.lua b/devel/light.lua index aa741f4d07..ff56e34c30 100644 --- a/devel/light.lua +++ b/devel/light.lua @@ -1,16 +1,5 @@ -- an experimental lighting engine ---[====[ -devel/light -=========== -An experimental lighting engine for DF, using the `rendermax` plugin. - -Call ``devel/light static`` to not recalculate lighting when in game. -Press :kbd:`~` to recalculate lighting. Press :kbd:`\`` to exit. - -]====] - -local gui = require 'gui' local guidm = require 'gui.dwarfmode' local render = require 'plugins.rendermax' @@ -27,12 +16,6 @@ function setCell(x,y,cell) cell.bo=cell.bo or {r=0,g=0,b=0} render.setCell(x,y,cell) end -function getCursorPos() - local g_cursor=df.global.cursor - if g_cursor.x ~= -30000 then - return copyall(g_cursor) - end -end --luacheck: skip function falloff(color,sqDist,maxdist) local v1=1/(sqDist/maxdist+1) @@ -273,7 +256,7 @@ function LightOverlay:calculateLightSun() end end function LightOverlay:calculateLightCursor() - local c=getCursorPos() + local c=guidm.getCursorPos() if c then diff --git a/devel/list-filters.lua b/devel/list-filters.lua index 5fb1adaae1..c9aa9245aa 100644 --- a/devel/list-filters.lua +++ b/devel/list-filters.lua @@ -31,7 +31,7 @@ for i=df.job_item_vector_id._first_item,df.job_item_vector_id._last_item do else ptr = items.other[id] end - if ptr then + if ptr and df.job_item_vector_id[i] then local _,addr = df.sizeof(ptr) lookup[addr] = 'df.job_item_vector_id.'..df.job_item_vector_id[i] end @@ -54,7 +54,7 @@ end local function dump(name) local out = {} - for i,v in ipairs(df.global.ui_build_selector.requirements) do + for i,v in ipairs(df.global.buildreq.requirements) do out[#out+1] = clone_filter(v.filter, v.count_required) end @@ -64,8 +64,8 @@ local function dump(name) print(fmt) end -local itype = df.global.ui_build_selector.building_type -local stype = df.global.ui_build_selector.building_subtype +local itype = df.global.buildreq.building_type +local stype = df.global.buildreq.building_subtype if itype == df.building_type.Workshop then dump(' [df.workshop_type.'..df.workshop_type[stype]..'] = ') diff --git a/devel/lsmem.lua b/devel/lsmem.lua index 012cf68e56..286a5b3f2b 100644 --- a/devel/lsmem.lua +++ b/devel/lsmem.lua @@ -1,12 +1,46 @@ --- Prints memory ranges of the process. +-- Prints memory ranges of the DF process. --[====[ devel/lsmem =========== -Prints memory ranges of the process. +Prints memory ranges of the DF process. Useful for checking whether a pointer is +valid, whether a certain library/plugin is loaded, etc. ]====] +function range_contains_any(range, addrs) + for _, a in ipairs(addrs) do + if a >= range.start_addr and a < range.end_addr then + return true + end + end + return false +end + +function range_name_match_any(range, names) + for _, n in ipairs(names) do + if range.name:lower():find(n, 1, true) or range.name:lower():find(n) then + return true + end + end + return false +end + +local args = {...} +local filter_addrs = {} +local filter_names = {} +for _, arg in ipairs(args) do + if arg:lower():startswith('0x') then + arg = arg:sub(3) + end + local addr = tonumber(arg, 16) + if addr then + table.insert(filter_addrs, addr) + else + table.insert(filter_names, arg:lower()) + end +end + for _,v in ipairs(dfhack.internal.getMemRanges()) do local access = { '-', '-', '-', 'p' } if v.read then access[1] = 'r' end @@ -17,5 +51,9 @@ for _,v in ipairs(dfhack.internal.getMemRanges()) do elseif v.shared then access[4] = 's' end - print(string.format('%08x-%08x %s %s', v.start_addr, v.end_addr, table.concat(access), v.name)) + if (#filter_addrs == 0 or range_contains_any(v, filter_addrs)) and + (#filter_names == 0 or range_name_match_any(v, filter_names)) + then + print(string.format('%08x-%08x %s %s', v.start_addr, v.end_addr, table.concat(access), v.name)) + end end diff --git a/devel/lua-example.lua b/devel/lua-example.lua index 32427475b7..81bcfb4058 100644 --- a/devel/lua-example.lua +++ b/devel/lua-example.lua @@ -3,8 +3,8 @@ devel/lua-example ================= -An example lua script, which reports the number of times it has -been called. Useful for testing environment persistence. +An example Lua script which reports the number of times it has been called. +Useful for testing environment persistence. ]====] diff --git a/devel/luacov.lua b/devel/luacov.lua new file mode 100644 index 0000000000..fd62f2c029 --- /dev/null +++ b/devel/luacov.lua @@ -0,0 +1,129 @@ +-- Lua script coverage report generator + +local help_message = +[====[ + +devel/luacov +============ + +Lua script coverage report generator + +Usage: + + luacov [options] [pattern...] + +This script generates a coverage report from collected statistics. By default it +reports on every Lua file in all of DFHack. To filter filenames, specify one or +more Lua patterns matching files or directories to be included. Alternately, you +can configure reporting parameters in the .luacov file in your DF directory. See +https://keplerproject.github.io/luacov/doc/modules/luacov.defaults.html for +details. + +Statistics are cumulative across reports. That is, if you run a report, run a +lua script, and then run another report, the report will include all activity +from the first report plus the recently run lua script. Restarting DFHack will +clear the statistics. You can also clear statistics after running a report by +passing the --clear flag to this script. + +Note that the coverage report will be empty unless you have started DFHack with +the "DFHACK_ENABLE_LUACOV=1" environment variable defined, which enables the +coverage monitoring. + +Also note that enabling both coverage monitoring and lua profiling via the +"profiler" module can produce strange results. Their interceptor hooks override +each other. Usage of the "kill-lua" command will likewise override the luacov +interceptor hook and may prevent coverage statistics from being collected. + +Options: + +-c, --clear + Remove accumulated metrics after generating the report, ensuring the next + report starts from a clean slate. +-h, --help + Show this help message and exit. + +Examples: + +devel/luacov + Report on all DFHack lua scripts. +devel/luacov -c quickfort + Report only on quickfort source files and clear stats. +devel/luacov quickfort hack/lua + Report only on quickfort and DFHack library lua source files. +]====] + +local utils = require('utils') +local runner = require('luacov.runner') + +local clear, show_help = false, false +local other_args = utils.processArgsGetopt({...}, { + {'c', 'clear', handler=function() clear = true end}, + {'h', 'help', handler=function() show_help = true end}, + }) +if show_help then + print(help_message) + return +end + +if not runner.initialized then + dfhack.printerr( + 'Warning: Coverage stats are not being collected so the generated' .. + ' report will be empty. Please start dfhack with the' .. + ' DFHACK_ENABLE_LUACOV=1 environment variable defined to enable' .. + ' coverage monitoring. Keep in mind that using the "kill-lua"' .. + ' command or using a Lua profiler may interfere with coverage' .. + ' monitoring.') +end + +-- gets the active luacov configuration from when runner.init() was called. +-- note that this does *not* reload .luacov if that file was changed. +local config = runner.load_config() + +-- save the original configuration values since this script can mutate the +-- config when run with parameters. we need to restore the original values when +-- this script is subsequently run without parameters. +default_include = default_include or config.include or {} +if default_clear == nil then default_clear = config.deletestats or false end + +-- override 'include' table if patterns were explicitly specified +config.include = #other_args == 0 and default_include or {} +for _,pattern in ipairs(other_args) do + table.insert(config.include, + (pattern:gsub("\\", "/"):gsub("%.lua$", ""))) +end + +-- always exclude test files +config.exclude = config.exclude or {} +local test_pattern = '/test/' -- those are path slashes and not regex delimiters +if not utils.invert(config.exclude)[test_pattern] then + table.insert(config.exclude, test_pattern) +end + +-- remove stats after generating report if requested; otherwise restore default +config.deletestats = clear or default_clear + +-- pause the runner while we're generating a report. otherwise we can end up +-- conflicting with autosaved stats if the user has that enabled in their +-- .luacov config. +runner.pause() + +dfhack.with_finalize( + function() runner.resume() end, + function() + print('flushing coverage stats') + runner.save_stats() + + print(('generating report in "%s" for files matching:'):format( + config.reportfile)) + if #config.include == 0 then + print(' .*') + else + for _,pattern in ipairs(config.include) do + print((' %s'):format(pattern)) + end + end + print(('and %s accumulated coverage stats in "%s"'):format( + config.deletestats and 'removing' or 'keeping', + config.statsfile)) + runner.run_report(config) + end) diff --git a/devel/make-dt.pl b/devel/make-dt.pl old mode 100644 new mode 100755 index e1198ae9c1..9d8ba2c433 --- a/devel/make-dt.pl +++ b/devel/make-dt.pl @@ -84,17 +84,17 @@ ($$$$) emit_addr 'language_vector',%globals,'world','world.raws.language.words'; emit_addr 'creature_vector',%globals,'world','world.units.all'; emit_addr 'active_creature_vector',%globals,'world','world.units.active'; - emit_addr 'dwarf_race_index',%globals,'ui','ui.race_id'; + emit_addr 'dwarf_race_index',%globals,'plotinfo','plotinfo.race_id'; emit_addr 'squad_vector',%globals,'world','world.squads.all'; emit_addr 'current_year',%globals,'cur_year','cur_year'; emit_addr 'cur_year_tick',%globals,'cur_year_tick','cur_year_tick'; - emit_addr 'dwarf_civ_index',%globals,'ui','ui.civ_id'; + emit_addr 'dwarf_civ_index',%globals,'plotinfo','plotinfo.civ_id'; emit_addr 'races_vector',%globals,'world','world.raws.creatures.all'; emit_addr 'reactions_vector',%globals,'world','world.raws.reactions'; emit_addr 'events_vector',%globals,'world','world.history.events'; emit_addr 'historical_figures_vector',%globals,'world','world.history.figures'; emit_addr 'fake_identities_vector',%globals,'world','world.identities.all'; - emit_addr 'fortress_entity',%globals,'ui','ui.main.fortress_entity'; + emit_addr 'fortress_entity',%globals,'plotinfo','plotinfo.main.fortress_entity'; emit_addr 'historical_entities_vector',%globals,'world','world.entities.all'; emit_addr 'itemdef_weapons_vector',%globals,'world','world.raws.itemdefs.weapons'; emit_addr 'itemdef_trap_vector',%globals,'world','world.raws.itemdefs.trapcomps'; @@ -316,7 +316,7 @@ ($$$$) emit_addr 'specific_refs',%all,'unit','specific_refs'; emit_addr 'squad_id',%all,'unit','military.squad_id'; emit_addr 'squad_position',%all,'unit','military.squad_position'; - emit_addr 'recheck_equipment',%all,'unit','military.pickup_flags'; + emit_addr 'recheck_equipment',%all,'unit','uniform.pickup_flags'; emit_addr 'mood',%all,'unit','mood'; emit_addr 'birth_year',%all,'unit','birth_year'; emit_addr 'birth_time',%all,'unit','birth_time'; @@ -325,9 +325,9 @@ ($$$$) emit_addr 'physical_attrs',%all,'unit','body.physical_attrs'; emit_addr 'body_size',%all,'unit','appearance.body_modifiers'; emit_addr 'size_info',%all,'unit','body.size_info'; - emit_addr 'curse',%all,'unit','curse.name'; - emit_addr 'curse_add_flags1',%all,'unit','curse.add_tags1'; - emit_addr 'turn_count',%all,'unit','curse.time_on_site'; + emit_addr 'curse',%all,'unit','uwss_display_name_sing'; + emit_addr 'curse_add_flags1',%all,'unit','uwss_add_caste_flag'; + emit_addr 'turn_count',%all,'unit','usable_interaction.time_on_site'; emit_addr 'souls',%all,'unit','status.souls'; emit_addr 'states',%all,'unit','status.misc_traits'; emit_addr 'labors',%all,'unit','status.labors'; @@ -386,7 +386,7 @@ ($$$$) emit_addr 'goals',%all,'unit_personality','dreams'; emit_addr 'goal_realized',%all,'unit_personality::anon5','unk8'; emit_addr 'traits',%all,'unit_personality','traits'; - emit_addr 'stress_level',%all,'unit_personality','stress_level'; + emit_addr 'stress',%all,'unit_personality','stress'; emit_header 'emotion_offsets'; emit_addr 'emotion_type',%all,'unit_personality::anon4','type'; diff --git a/devel/modstate-monitor.lua b/devel/modstate-monitor.lua index 5e5986be25..11961f35c0 100644 --- a/devel/modstate-monitor.lua +++ b/devel/modstate-monitor.lua @@ -1,17 +1,5 @@ -- Displays changes in the key modifier state --@ enable = true ---[====[ - -devel/modstate-monitor -====================== -Display changes in key modifier state, ie Ctrl/Alt/Shift. - -:enable|start: Begin monitoring -:disable|stop: End monitoring - -]====] - -VERSION = '0.1' active = active or false @@ -20,11 +8,7 @@ if dfhack.internal.getModstate == nil or dfhack.internal.getModifiers == nil the end function usage() - print [[ -Usage: - modstate-monitor enable|start: Begin monitoring - modstate-monitor disable|stop: End monitoring -]] + print(dfhack.script_help()) end function set_timeout() diff --git a/devel/nuke-items.lua b/devel/nuke-items.lua index 36d623b8a6..82ce37977a 100644 --- a/devel/nuke-items.lua +++ b/devel/nuke-items.lua @@ -1,21 +1,12 @@ -- Delete ALL items not held by units, buildings or jobs ---[====[ - -devel/nuke-items -================ -Deletes ALL items not held by units, buildings or jobs. -Intended solely for lag investigation. - -]====] local count = 0 for _,v in ipairs(df.global.world.items.all) do if not (v.flags.in_building or v.flags.construction or v.flags.in_job - or dfhack.items.getGeneralRef(v,df.general_ref_type.UNIT_HOLDER)) then + or dfhack.items.getGeneralRef(v,df.general_ref_type.UNIT_HOLDER)) then count = count + 1 - v.flags.forbid = true - v.flags.garbage_collect = true + dfhack.items.remove(v) end end diff --git a/devel/pop-screen.lua b/devel/pop-screen.lua index 38bf5ff3fa..8b90620854 100644 --- a/devel/pop-screen.lua +++ b/devel/pop-screen.lua @@ -1,10 +1,3 @@ --- For killing bugged out gui script screens. ---[====[ - -devel/pop-screen -================ -For killing bugged out gui script screens. - -]====] +-- Forcibly closes the current screen dfhack.screen.dismiss(dfhack.gui.getCurViewscreen()) diff --git a/devel/prepare-save.lua b/devel/prepare-save.lua deleted file mode 100644 index fc4f045570..0000000000 --- a/devel/prepare-save.lua +++ /dev/null @@ -1,98 +0,0 @@ --- Prepare the current save for devel/find-offsets ---[====[ - -devel/prepare-save -================== -WARNING: THIS SCRIPT IS STRICTLY FOR DFHACK DEVELOPERS. - -This script prepares the current savegame to be used -with `devel/find-offsets`. It CHANGES THE GAME STATE -to predefined values, and initiates an immediate -`quicksave`, thus PERMANENTLY MODIFYING the save. - -]====] - -local utils = require 'utils' - -df.global.pause_state = true - -print[[ -WARNING: THIS SCRIPT IS STRICTLY FOR DFHACK DEVELOPERS. - -This script prepares the current savegame to be used -with devel/find-offsets. It CHANGES THE GAME STATE -to predefined values, and initiates an immediate -quicksave, thus PERMANENTLY MODIFYING the save. -]] - -if not utils.prompt_yes_no('Proceed?') then - return -end - ---[[print('Placing anchor...') - -do - local wp = df.global.ui.waypoints - - for _,pt in ipairs(wp.points) do - if pt.name == 'dfhack_anchor' then - print('Already placed.') - goto found - end - end - - local x,y,z = pos2xyz(df.global.cursor) - - if not x then - error("Place cursor at your preferred anchor point.") - end - - local id = wp.next_point_id - wp.next_point_id = id + 1 - - wp.points:insert('#',{ - new = true, id = id, name = 'dfhack_anchor', - comment=(x..','..y..','..z), - tile = string.byte('!'), fg_color = COLOR_LIGHTRED, bg_color = COLOR_BLUE, - pos = xyz2pos(x,y,z) - }) - -::found:: -end]] - -print('Nicknaming units...') - -for i,unit in ipairs(df.global.world.units.active) do - dfhack.units.setNickname(unit, i..':'..unit.id) -end - -print('Setting weather...') - -local wbytes = { - 2, 1, 0, 2, 0, - 1, 2, 1, 0, 0, - 2, 0, 2, 1, 2, - 1, 2, 0, 1, 1, - 2, 0, 1, 0, 2 -} - -for i=0,4 do - for j = 0,4 do - df.global.current_weather[i][j] = (wbytes[i*5+j+1] or 2) - end -end - -local yearstr = df.global.cur_year..','..df.global.cur_year_tick - -print('Cur year and tick: '..yearstr) - -dfhack.persistent.save{ - key='prepare-save/cur_year', - value=yearstr, - ints={df.global.cur_year, df.global.cur_year_tick} -} - --- Save - -dfhack.run_script('quicksave') - diff --git a/devel/print-args2.lua b/devel/print-args2.lua index 72b77ae283..74aa3f42c7 100644 --- a/devel/print-args2.lua +++ b/devel/print-args2.lua @@ -14,4 +14,3 @@ print("print-args") for _,arg in ipairs(args) do print("'"..arg.."'") end - diff --git a/devel/print-event.lua b/devel/print-event.lua new file mode 100644 index 0000000000..771ac55299 --- /dev/null +++ b/devel/print-event.lua @@ -0,0 +1,54 @@ +-- Prints the description of an event by ID or index. +--author BenLubar +--[====[ + +devel/print-event +================= +Prints the description of an event by ID or index. + +]====] +local utils = require "utils" + +local validArgs = utils.invert({ + "id", + "index" +}) + +local args = utils.processArgs({...}) + +if type(args.id) == "string" then + args.id = {args.id} +elseif args.id == nil then + args.id = {} +end +if type(args.index) == "string" then + args.index = {args.index} +elseif args.index == nil then + args.index = {} +end + +if #args.id == 0 and #args.index == 0 then + qerror("requires at least one event ID or index") +end + +local function print_event(event) + local str = df.new("string") + local ctx = df.history_event_context:new() + event:getSentence(str, ctx, true, false) + ctx:delete() + print(str.value) + str:delete() +end + +for _, id in ipairs(args.id) do + local event = df.history_event.find(tonumber(id)) + if not event then + qerror("event ID not found: " .. id) + end + print_event(event) +end + +for _, idx in ipairs(args.index) do + local event = df.global.world.history.events[tonumber(idx)] + print_event(event) +end diff --git a/devel/query.lua b/devel/query.lua index f380c209d5..610003873c 100644 --- a/devel/query.lua +++ b/devel/query.lua @@ -1,132 +1,653 @@ -- Query is a script useful for finding and reading values of data structure fields. Purposes will likely be exclusive to writing lua script code. --- Written by Josh Cooper(cppcooper) on 2017-12-21, last modified: 2018-10-19 -local utils=require('utils') +-- Written by Josh Cooper(cppcooper) on 2017-12-21, last modified: 2021-06-13 +-- Version: 3.2 +--luacheck:skip-entirely +local guidm = require('gui.dwarfmode') +local utils = require('utils') local validArgs = utils.invert({ + 'help', + 'unit', + 'item', + 'plant', + 'building', + 'job', + 'tile', + 'block', + 'script', + 'json', 'table', - 'query', - 'help', + 'getfield', + + 'search', + 'findvalue', + 'maxdepth', + 'maxlength', + 'excludetypes', + 'excludekinds', + + 'dumb', + + 'showpaths', + 'setvalue', + 'alignto', + 'oneline', + '1', + 'nopointers', + 'disableprint', + 'debug', + 'debugdata' }) local args = utils.processArgs({...}, validArgs) -local help = [====[ -devel/query -=========== -Query is a script useful for finding and reading values of data structure fields. -Purposes will likely be exclusive to writing lua script code. - -This script will recursively search an input for fields matching your query. -Matching fields will be printed along with their values. When a match is a -table the entire table will not be printed, only the reference to it. - -When performing table queries, use dot notation to denote sub-tables. -The script has to parse the input string and separate each table. - -example: "query -unit -query profession" will print all fields matching -'*profession*' inside the highlighted unit. - -The values input to -unit or -table must be global identifiers -(ie. _G[identifier] must exist). - -Usage: ``query -unit -query `` -or ``query -table -query `` - -]====] - ---thanks goes mostly to the internet for this function. thanks internet you da real mvp -function safe_pairs(item, keys_only) - if keys_only then - local mt = debug.getmetatable(item) - if mt and mt._index_table then - local idx = 0 - return function() - idx = idx + 1 - if mt._index_table[idx] then - return mt._index_table[idx] +local selection = nil +local path_info = nil +local path_info_pattern = nil +local new_value = nil +local find_value = nil +local maxdepth = nil +local cur_depth = -1 +local tilex = nil +local tiley = nil +local bToggle = true +local bool_flags = {} + +--Section: core logic +function query(table, name, search_term, path, bprinted_parent, parent_table) + -- Is the search depth too high? (Default: 7) + -- Is the data capable of being iterated, or does it only have a value? + -- Is the data recursively indexing (eg. A.B.C.A.*)? + + if bprinted_parent == nil then + bprinted_parent = false + end + if parent_table ~= nil then + if is_tiledata(parent_table) then + if setValue(parent_table[tilex], path, tiley, table, new_value) then + table = new_value + end + elseif setValue(parent_table, path, name, table, new_value) then + table = new_value + end + end + local bprinted = printField(path, name, table, bprinted_parent) + --print("table/field printed") + cur_depth = cur_depth + 1 + if cur_depth <= maxdepth then + -- check that we can search + if is_searchable(name, table) then + -- iterate over search space + function recurse(field, value) + -- Is the data recursively indexing (eg. A.B.C.A.*)? + + local new_tname = tostring(makeName(name, field)) + if is_tiledata(value) then + local indexed_name = string.format("%s[%d][%d]", field,tilex,tiley) + query(value[tilex][tiley], indexed_name, search_term, appendField(path, indexed_name), bprinted, value) + elseif not is_looping(path, new_tname) then + query(value, new_tname, search_term, appendField(path, field), bprinted, table) + else + -- I don't know when this prints (if ever) + printField(path, field, value, bprinted) end end + foreach(table, name, recurse) end end - local ret = table.pack(pcall(function() return pairs(item) end)) - local ok = ret[1] - table.remove(ret, 1) - if ok then - return table.unpack(ret) + cur_depth = cur_depth - 1 + return bprinted +end + +function foreach(table, name, callback) + -- How can the data be iterated? + -- Is the iteration count for the data too high? (Default: 257) + + function print_truncation_msg(name,index) + print("") + end + local index = 0 + if getmetatable(table) and table._kind and (table._kind == "enum-type" or table._kind == "bitfield-type") then + for idx, value in ipairs(table) do + if is_exceeding_maxlength(index) then + print_truncation_msg(name, index) + return + end + callback(idx, value) + index = index + 1 + end + elseif table._type and string.find(tostring(table._type), "_list_link") then + for field, value in utils.listpairs(table) do + local m = tostring(field):gsub("<.*: ",""):gsub(">.*",""):gsub("%x%x%x%x%x%x","%1 ",1) + local s = string.format("next{%d}->item", index) + if is_exceeding_maxlength(index) then + print_truncation_msg(name, index) + return + end + callback(s, value) + index = index + 1 + end else - return function() end + for field, value in safe_pairs(table) do + if is_exceeding_maxlength(index) then + print_truncation_msg(name, index) + return + end + callback(field, value) + index = index + 1 + end end end -function Query(table, query, parent) - if not parent then - parent = "" - end - for k,v in safe_pairs(table) do - -- avoid infinite recursion - if not tonumber(k) and type(k) ~= "table" and not string.find(tostring(k), 'script') then - if string.find(tostring(k), query) then - print(parent .. "." .. k .. ":", v) +function setValue(table, path, field, value, new_value) + -- Does the data match the search pattern? + + if args.setvalue then + if not args.search or is_match(path, field, value) then + if type(table[field]) == type(value) and type(value) == type(new_value) then + table[field] = new_value + print(string.format("Set %s value to %s, from %s", path, new_value, value)) --will call tostring + return true end - --print(parent .. "." .. k) - if not string.find(parent, tostring(k)) then - if parent then - Query(v, query, parent .. "." .. k) - else - Query(v, query, k) + end + end + return false +end + +--Section: entry/initialization +function main() + if args.help then + print(dfhack.script_help()) + return + end + processArguments() + selection, path_info = table.unpack{getSelectionData()} + debugf(0, tostring(selection), path_info) + + if selection == nil then + qerror(string.format("Selected %s is null. Invalid selection.", path_info)) + return + end + debugf(0, string.format("query(%s, %s, %s, %s)", selection, path_info, args.search, path_info)) + query(selection, path_info, args.search, path_info) +end + +local eval_env = utils.df_shortcut_env() +function eval(s) + local f, err = load("return " .. s, "expression", "t", eval_env) + if err then qerror(err) end + return f() +end + +function getSelectionData() + local selection = nil + local path_info = nil + if args.table then + debugf(0,"table selection") + selection = eval(args.table) + path_info = args.table + path_info_pattern = escapeSpecialChars(path_info) + elseif args.json then + local json = require("json") + local json_file = string.format("%s/%s", dfhack.getDFPath(), args.json) + if dfhack.filesystem.isfile(json_file) then + selection = json.decode_file(json_file) + path_info = args.json + path_info_pattern = path_info + else + qerror(string.format("File '%s' not found.", json_file)) + end + elseif args.script then + selection = reqscript(args.script) + path_info = args.script + path_info_pattern = path_info + elseif args.unit then + debugf(0,"unit selection") + selection = dfhack.gui.getSelectedUnit() + path_info = "unit" + path_info_pattern = path_info + elseif args.item then + debugf(0,"item selection") + selection = dfhack.gui.getSelectedItem() + path_info = "item" + path_info_pattern = path_info + elseif args.plant then + debugf(0,"plant selection") + selection = dfhack.gui.getSelectedPlant() + path_info = "plant" + path_info_pattern = path_info + elseif args.building then + debugf(0,"item selection") + selection = dfhack.gui.getSelectedBuilding() + path_info = "building" + path_info_pattern = path_info + elseif args.job then + debugf(0,"job selection") + selection = dfhack.gui.getSelectedJob(true) + local pos = guidm.getCursorPos() + if selection == nil and pos then + print("searching for a job at the cursor") + for _link, job in utils.listpairs(df.global.world.jobs.list) do + if same_xyz(job.pos, pos) then + if selection == nil then + selection = {} + end + table.insert(selection, job) end end end + path_info = "job" + path_info_pattern = path_info + elseif args.tile then + debugf(0,"tile selection") + local pos = guidm.getCursorPos() + selection = dfhack.maps.ensureTileBlock(pos.x,pos.y,pos.z) + bpos = selection.map_pos + path_info = string.format("tile[%d][%d][%d]",pos.x,pos.y,pos.z) + path_info_pattern = string.format("tile%%[%d%%]%%[%d%%]%%[%d%%]",pos.x,pos.y,pos.z) + tilex = pos.x%16 + tiley = pos.y%16 + elseif args.block then + debugf(0,"block selection") + local pos = guidm.getCursorPos() + selection = dfhack.maps.ensureTileBlock(pos.x,pos.y,pos.z) + bpos = selection.map_pos + path_info = string.format("blocks[%d][%d][%d]",bpos.x,bpos.y,bpos.z) + path_info_pattern = string.format("blocks%%[%d%%]%%[%d%%]%%[%d%%]",bpos.x,bpos.y,bpos.z) + else + print(help) end + if args.getfield then + selection = findPath(selection,args.getfield) + path_info = path_info .. "." .. args.getfield + path_info_pattern = path_info_pattern .. "." .. args.getfield + end + --print(selection, path_info) + return selection, path_info end -function parseTableString(str) - tableParts = {} - for word in string.gmatch(str, '([^.]+)') do --thanks stack overflow - table.insert(tableParts, word) +function processArguments() + if args["1"] then + args.oneline = true + end + --Table Recursion + if args.maxdepth then + maxdepth = tonumber(args.maxdepth) + if not maxdepth then + qerror(string.format("Must provide a number with -depth")) + end + elseif args.dumb then + maxdepth = 25 + else + maxdepth = 7 + end + args.maxdepth = maxdepth + + --Table Length + if args.maxlength then + args.maxlength = tonumber(args.maxlength) + elseif args.dumb then + args.maxlength = 10000 + else + --2048 was chosen as it was the smallest power of 2 that can query the `df` table entirely, and it seems likely it'll be able to query most structures fully + args.maxlength = 2048 + end + + new_value = toType(args.setvalue) + find_value = toType(args.findvalue) + + -- singular or plural + args.excludetypes = args.excludetypes and args.excludetypes or args.excludetype + args.excludekinds = args.excludekinds and args.excludekinds or args.excludekind + -- must be a string + args.excludetypes = args.excludetypes and args.excludetypes or "" + args.excludekinds = args.excludekinds and args.excludekinds or "" + if string.find(args.excludetypes, 'a') then + bool_flags["boolean"] = true + bool_flags["function"] = true + bool_flags["number"] = true + bool_flags["string"] = true + bool_flags["table"] = true + bool_flags["userdata"] = true + else + bool_flags["boolean"] = string.find(args.excludetypes, 'b') and true or false + bool_flags["function"] = string.find(args.excludetypes, 'f') and true or false + bool_flags["number"] = string.find(args.excludetypes, 'n') and true or false + bool_flags["string"] = string.find(args.excludetypes, 's') and true or false + bool_flags["table"] = string.find(args.excludetypes, 't') and true or false + bool_flags["userdata"] = string.find(args.excludetypes, 'u') and true or false end - curTable = nil - for k,v in pairs(tableParts) do - if curTable == nil then - if _G[v] ~= nil then - curTable = _G[v] + + if string.find(args.excludekinds, 'a') then + bool_flags["bitfield-type"] = true + bool_flags["class-type"] = true + bool_flags["enum-type"] = true + bool_flags["struct-type"] = true + else + bool_flags["bitfield-type"] = string.find(args.excludekinds, 'b') and true or false + bool_flags["class-type"] = string.find(args.excludekinds, 'c') and true or false + bool_flags["enum-type"] = string.find(args.excludekinds, 'e') and true or false + bool_flags["struct-type"] = string.find(args.excludekinds, 's') and true or false + end +end + +local bRunOnce={} +function runOnce(caller) + if bRunOnce[caller] == true then + return false + end + bRunOnce[caller] = true + return true +end + +function toType(str) + if str ~= nil then + if string.find(str,"true") then + return true + elseif string.find(str,"false") then + return false + elseif string.find(str,"nil") then + return nil + elseif tonumber(str) then + return tonumber(str) else - qerror("Table" .. v .. " does not exist.") + return tostring(str) end - else - if curTable[v] ~= nil then + end + return nil +end + +--Section: filters +function is_searchable(field, value) + -- Is the data capable of being iterated, or does it only have a value? + + if not df.isnull(value) then + debugf(3,string.format("is_searchable( %s ): type: %s, length: %s, count: %s", value,type(value),getTableLength(value), countTableLength(value))) + if not isEmpty(value) then + if getmetatable(value) then + if value._kind == "primitive" then + return false + elseif value._kind == "struct" then + if args.safer then + return false + else + return true + end + end + debugf(3,string.format("_kind: %s, _type: %s", value._kind, value._type)) + end + for _,_ in safe_pairs(value) do + return true + end + end + end + return false +end + +function match_searcharg(path,field) + if type(args.search) == "table" then + for _,pattern in pairs(args.search) do + if string.find(tostring(field),pattern) or string.find(path,pattern) then + return true + end + end + elseif string.find(tostring(field),args.search) or string.find(path,args.search) then + return true + end + return false +end + +function is_match(path, field, value) + debugf(3, string.format("path: %s\nfield: %s\nvalue: %s", path, field, value)) + if not args.search or match_searcharg(path,field) then + if not args.findvalue or (type(value) == type(find_value) and (value == find_value or (type(value) == "string" and string.find(value,find_value)))) then + return true + end + end + return false +end + +function is_looping(path, field) + return not args.dumb and string.find(path, tostring(field)) +end + +function is_tiledata(value) + if args.tile and string.find(tostring(value),"%[16%]") then + if type(value) and string.find(tostring(value[tilex]),"%[16%]") then + return true + end + end + return false +end + +function is_excluded(value) + return bool_flags[type(value)] or not isEmpty(value) and getmetatable(value) and bool_flags[value._kind] +end + +function is_exceeding_maxlength(index) + return args.maxlength and not (index < args.maxlength) +end + +--Section: table helpers +function isEmpty(t) + for _,_ in safe_pairs(t) do + return false + end + return true +end + +function countTableLength(t) + local count = 0 + for _,_ in safe_pairs(t) do + count = count + 1 + end + debugf(1,string.format("countTableEntries( %s ) = %d",t,count)) + return count +end + +function getTableLength(t) + if type(t) == "table" then + local count=#t + debugf(1,string.format("----getTableLength( %s ) = %d",t,count)) + return count + end + return 0 +end + +function findPath(t, path) + debugf(0,string.format("findPath(%s, %s)",t, path)) + local curTable = t + if not curTable then + error("no table to search") + end + local keyParts = string.split(path, '.', true) + for _,v in pairs(keyParts) do + if v and curTable[v] ~= nil then + debugf(1,"found something",v,curTable,curTable[v]) curTable = curTable[v] else - qerror("Table" .. v .. " does not exist.") + qerror("Key not found: " .. v) end - end end + --debugf(1,"returning",curTable) return curTable end -local selection = nil -if args.help then - print(help) -elseif args.unit then - if _G[args.unit] ~= nil then - selection = _G[args.unit] - else - selection = dfhack.gui.getSelectedUnit() +function hasMetadata(value) + if not isEmpty(value) then + if getmetatable(value) and value._kind and value._kind ~= nil then + return true + end end - if selection == nil then - qerror("Selected unit is null. Invalid selection.") - elseif args.query ~= nil then - Query(selection, args.query, 'selected-unit') + return false +end + +--Section: output helpers +function makeName(tname, field) + if tonumber(field) then + return string.format("%s[%s]", tname, field) + end + return field +end + +function appendField(parent, field) + newParent="" + if tonumber(field) then + newParent=string.format("%s[%s]",parent,field) else - print("The query is empty, the output is probably gonna be large. Start your engines.") - Query(selection, '', 'selected-unit') + newParent=string.format("%s.%s",parent,field) end -elseif args.table then - local t = parseTableString(args.table) - if args.query ~= nil then - Query(_G[args.table], args.query, args.table) + debugf(2, string.format("new parent: %s", newParent)) + return newParent +end + +function makeIndentation() + local base="| " + local indent="" + for i=0,(cur_depth) do + indent=indent .. string.format("%s",base) + end + --indent=string.format("%s ",base) + return indent +end + +function presentField(path, field, value) + local output = tostring(args.showpaths and path or field) + local leading_indent = not args.showpaths and makeIndentation() or "" + output = leading_indent .. output + local align = 40 + if tonumber(args.alignto) then + align = tonumber(args.alignto) + end + output = string.format("%-"..align.."s ", output .. ":") + if args.debugdata or not args.oneline or bToggle then + if not args.nopointers or not string.find(tostring(value), "0x%x%x%x%x%x%x") then + output = string.gsub(output," "," ~") + end + bToggle = false else - print("The query is empty, the output is probably gonna be large. Start your engines.") - Query(_G[args.table], '', args.table) + bToggle = true + end + return output +end + +function presentValue(field, value) + local output = "" + if not args.nopointers or not string.find(tostring(value), "0x%x%x%x%x%x%x") then + if not args.debugdata then + output = string.format(" %s", value) + elseif hasMetadata(value) then + if args.oneline then + output = string.format(" %s [%s]", value, value._kind) + else + local N = math.min(90, string.len(indented_field)) + local newline_indent = string.format("%" .. N .. "s", "") + output = string.format("%s %s\n%s [has metatable; _kind: %s]", output, value, newline_indent, value._kind) + end + else + output = string.format("%s %s; type(%s) = %s", output, value, field, type(value)) + end + end + return output +end + +function presentDebugData(presentedField, field, value) + local output = "" + if args.debugdata then + local N = math.min(90, string.len(presentedField)) + local newline_indent = string.format("%" .. N .. "s", "") + if hasMetadata(value) then + -- why not search? + if not args.search and args.oneline then + output = output .. string.format("\n%s type(%s): %s, _kind: %s, _type: %s", + newline_indent, field, type(value), value._kind, value._type) + else + output = output .. string.format("\n%s type(%s): %s\n%s _kind: %s\n%s _type: %s", + newline_indent, field, type(value), newline_indent, value._kind, newline_indent, value._type) + end + end + end + return output +end + +function printOnce(key, msg) + if runOnce(key) then + print(msg) + end +end + +--sometimes used to print fields, always used to print parents of fields +function printParents(path, field, value) + local value_printed = false + path = string.gsub(path, path_info_pattern, "") + field = string.gsub(field, path_info_pattern, "") + local cd = cur_depth + cur_depth = 0 + local cur_path = path_info + local words = {} + local index = 1 + for word in string.gmatch(path, '([^.]+)') do + words[index] = word + index = index + 1 + end + local last_index = index - 1 + for i,word in ipairs(words) do + if i ~= last_index then + cur_path = appendField(cur_path, word) + printOnce(cur_path, string.format("%s%s", makeIndentation(),word)) + elseif string.find(word,"%a+%[%d+%]%[%d+%]") then + value_printed = true + cur_path = appendField(cur_path, word) + local f = presentField(path, field, value) + local v = presentValue(field, value) + print(f .. v .. presentDebugData(f, field, value)) + end + cur_depth = cur_depth + 1 end -else - print(help) + cur_depth = cd + return value_printed end + +function printField(path, field, value, bprinted_parent) + -- Does the data match the search pattern? + + if runOnce(printField) then + -- print selection + printOnce(path,string.format("%s: %s", path, value)) + return + end + if not args.disableprint and not is_excluded(value) then + if not args.search and not args.findvalue or is_match(path, field, value) then + local bprinted_field = false + if not args.showpaths and not bprinted_parent then + bprinted_field = printParents(path, field, value) + end + if not bprinted_field then + if is_tiledata(value) then + value = value[tilex][tiley] + field = string.format("%s[%d][%d]", field,tilex,tiley) + end + local f = presentField(path, field, value) + local v = presentValue(field, value) + print(f .. v .. presentDebugData(f, field, value)) + end + return true + end + end + return false +end + +function debugf(level,...) + if args.debug and level <= tonumber(args.debug) then + local str=string.format(" # %s",select(1, ...)) + for i = 2, select('#', ...) do + str=string.format("%s\t%s",str,select(i, ...)) + end + print(str) + end +end + +function escapeSpecialChars(str) + return str:gsub('(['..("%^$()[].*+-?"):gsub("(.)", "%%%1")..'])', "%%%1") +end + +main() +print() diff --git a/devel/save-version.lua b/devel/save-version.lua index 7ba84a481b..8ac449eac5 100644 --- a/devel/save-version.lua +++ b/devel/save-version.lua @@ -109,6 +109,17 @@ versions = { [1620] = "0.44.10", [1623] = "0.44.11", [1625] = "0.44.12", + + [1710] = "0.47.01", + [1711] = "0.47.02", + [1713] = "0.47.03", + [1715] = "0.47.04", + [1716] = "0.47.05", + + [2078] = "50.01", + [2079] = "50.02", + [2080] = "50.03", -- and 50.04 + [2081] = "50.05", -- through 50.09 } --luacheck: global diff --git a/devel/sc.lua b/devel/sc.lua new file mode 100644 index 0000000000..085514fc9b --- /dev/null +++ b/devel/sc.lua @@ -0,0 +1,216 @@ +-- Size Check +--author mifki +--luacheck:skip-entirely + +local utils = require('utils') + +local guess_pointers = false +local check_vectors = true +local check_pointers = true +local check_enums = true + +-- not really a queue, I know +local queue + +local args = {...} +if args[1] == '-all' then + queue = {} + for k, v in pairs(df.global) do + if type(v) == 'userdata' then + table.insert(queue, { v, k }) + end + end +elseif #args == 1 then + queue = { { utils.df_expr_to_ref(args[1]), args[1] } } +else + queue = { { df.global.world, 'world' } } +end + +local checkedt = {} +local checkeda = {} +local checkedp = {} +local token = os.time() + +local sizetype = 'uintptr_t' + +local mem_ranges = {} +local mem_start, mem_end + +local function is_valid_address(ptr) + if ptr < mem_ranges[1].start_addr or ptr > mem_ranges[#mem_ranges].end_addr then + return false + end + + for k,mem in ipairs(mem_ranges) do + if ptr >= mem.start_addr and ptr < mem.end_addr then + return true + end + end + + return false +end + +local function is_valid_vector(ref) + local ints = df.reinterpret_cast(sizetype, ref) + + if ints[0] == 0 and ints[1] == 0 and ints[2] == 0 then + return true + end + + -- vector: ptr, int, ptr + if df.reinterpret_cast('int32_t', ints:_displace(1)).value == 0 then + return ints[0] <= ints[2] + end + + return ints[0] <= ints[1] and ints[1] <= ints[2] + --and is_valid_address(ints[0]) and is_valid_address(ints[1]) and is_valid_address(ints[2]) +end + +local function bold(s) + dfhack.color(8) + print(s) + dfhack.color(-1) +end + +local function warn(s) + dfhack.color(6) + print(s) + dfhack.color(-1) +end + +local function err(s) + dfhack.color(4) + print(s) + dfhack.color(-1) +end + +prog = '|/-\\' +local count = -1 +local function check_container(obj, path) + count = count + 1 + if dfhack.is_interactive() and count % 500 == 0 then + local i = ((count // 500) % 4) + 1 + dfhack.print(prog:sub(i, i) .. '\r') + dfhack.console.flush() + end + for k,v in pairs(obj) do + if df.isvalid(v) == 'ref' then + local s, a = v:sizeof() + + if v and v._kind == 'container' and k ~= 'bad' and k ~= 'temp_save' then + if tostring(v._type):sub(1,6) == 'vector' and check_vectors and not is_valid_vector(a) then + local key = tostring(obj._type) .. '.' .. k + if not checkedp[key] then + checkedp[key] = true + bold(path .. ' ' .. tostring(obj._type) .. ' -> ' .. k) + err(' INVALID VECTOR, SKIPPING REST OF THE TYPE') + end + return + end + + table.insert(queue, { v, path .. '.' .. k }) + + elseif v and v._kind == 'struct' then + local t = v._type + + if check_pointers and not is_valid_address(a) then + local key = tostring(obj._type) .. '.' .. k + if not checkedp[key] then + checkedp[key] = true + bold(path .. ' ' .. tostring(obj._type) .. ' -> ' .. k) + err(' INVALID ADDRESS, SKIPPING REST OF THE TYPE') + end + return + end + + -- the first check is to process pointers only, not nested structures + if obj:_field(k)._kind == 'primitive' and df.reinterpret_cast('uint32_t',a-8).value == 0xdfdf4ac8 then + if not checkedt[t] then + checkedt[t] = true + local s2 = df.reinterpret_cast(sizetype,a-16).value + if s2 == s then + --print (' OK') + else + bold (t) + err (' NOT OK '.. s .. ' ' .. s2 .. ' at ' .. path .. '.' .. k) + end + end + + if df.reinterpret_cast('uint32_t',a-4).value ~= token then + df.reinterpret_cast('uint32_t',a-4).value = token + table.insert(queue, { v, path .. '.' .. k }) + end + + elseif not checkeda[a] then + checkeda[a] = true + table.insert(queue, { v, path .. '.' .. k }) + end + end + + else + local field = obj:_field(k) + if field then + if guess_pointers then + if field._type == 'void*' + or field._type == 'int64_t' or field._type == 'uint64_t' + or field._type == 'int32_t' or field._type == 'uint32_t' then + local s,a = field:sizeof() + local val = df.reinterpret_cast(sizetype, a).value + + if is_valid_address(val) and df.reinterpret_cast('uint32_t',a-8).value == 0xdfdf4ac8 then + local key = tostring(obj._type) .. '.' .. k + if not checkedp[key] then + checkedp[key] = true + bold(path .. ' ' .. tostring(obj._type) .. ' -> ' .. k) + print(' POINTER TO STRUCT '..df.reinterpret_cast(sizetype,a-16).value) + end + end + end + end + + -- is there a better way to detect enums? + if check_enums then + if field._kind == 'primitive' and field._type[0] and not field._type[field.value] then + -- some checks to filter out uninitialised and conditional fields, enums in unions, etc. + if not (obj._type == df.unit_preference and k == 'item_type') + and not (obj._type == df.unit.T_job and k == 'mood_skill') + and not (obj._type == df.unit and k == 'idle_area_type') + and not (field._type == df.skill_rating) + and field.value >= -1 and field.value < 1024 then + local key = tostring(obj._type) .. '.' .. k .. tostring(field.value) + if not checkedp[key] then + checkedp[key] = true + bold(path .. ' ' .. tostring(obj._type) .. ' -> ' .. k) + warn(' INVALID ENUM VALUE ' .. tostring(field._type) .. ' ' .. field.value) + end + end + end + end + end + end + end +end + + +for i,mem in ipairs(dfhack.internal.getMemRanges()) do + if mem.read and mem.write then + -- concatename nearby regions, otherwise there are too many, and checks are very slow + if #mem_ranges > 0 and mem.start_addr - mem_ranges[#mem_ranges].end_addr < 1024*1024*10 then + mem_ranges[#mem_ranges].end_addr = mem.end_addr + else + table.insert(mem_ranges, mem) + end + + if not mem_start then + mem_start = mem.start_addr + end + mem_end = mem.end_addr + end +end + +while #queue > 0 do + local v = queue[#queue] + table.remove(queue, #queue) + check_container(v[1], v[2]) +end +print(count .. ' scanned') diff --git a/devel/scan-vtables.lua b/devel/scan-vtables.lua new file mode 100644 index 0000000000..069e43c0b0 --- /dev/null +++ b/devel/scan-vtables.lua @@ -0,0 +1,122 @@ +-- Scan and dump likely vtable addresses +local memscan = require('memscan') + +local osType = dfhack.getOSType() +if osType ~= 'linux' then + qerror('unsupported OS: ' .. osType) +end + +local function get_ranges() + local df_ranges, lib_names = {}, {} + + local raw_ranges = dfhack.internal.getMemRanges() + + -- add main binary mem ranges first + for _, range in ipairs(raw_ranges) do + if range.read and ( + string.match(range.name, '/dwarfort$') or + string.match(range.name, 'Dwarf Fortress%.exe') + ) + then + table.insert(df_ranges, range) + end + end + + for _, range in ipairs(raw_ranges) do + if range.read and string.match(range.name, '/libg_src_lib.so$') then + table.insert(df_ranges, range) + lib_names[range.name] = true + end + end + + return df_ranges, lib_names +end + +local df_ranges, lib_names = get_ranges() + +-- vtables that cross a range boundary can appear twice, a truncated version in the +-- lower memory range and a full version in the higher memory range +-- therefore, sort the memory ranges by start address, descending +-- but keep libraries last +local function sort_ranges(a, b) + if lib_names[a.name] == lib_names[b.name] then + return a.start_addr > b.start_addr + end + return lib_names[b.name] +end + +table.sort(df_ranges, sort_ranges) + +function is_df_addr(a) + for _, range in ipairs(df_ranges) do + if a >= range.start_addr and a < range.end_addr then + return true + end + end + return false +end + +local function is_vtable_range(range) + return not range.write and not range.execute +end + +function scan_ranges() + local vtables = {} + local seen = {} -- only record the first encountered vtable for each name + + for _, range in ipairs(df_ranges) do + if not is_vtable_range(range) then goto next_range end + local base = range.name:match('.*/(.*)$') + local area = memscan.MemoryArea.new(range.start_addr, range.end_addr) + local is_lib = lib_names[range.name] + for i = 1, area.uintptr_t.count - 1 do + -- take every pointer-aligned value in memory mapped to the DF executable, and see if it is a valid vtable + -- start by following the logic in Process::doReadClassName() and ensure it doesn't crash + local vtable = area.uintptr_t:idx2addr(i) + local typeinfo = area.uintptr_t[i - 1] + if not is_df_addr(typeinfo + 8) then goto next_ptr end + local typestring = df.reinterpret_cast('uintptr_t', typeinfo + 8)[0] + if not is_df_addr(typestring) then goto next_ptr end + -- rule out false positives by checking that the vtable points to a table of valid pointers + -- TODO: check that the pointers are actually function pointers + local vlen = 0 + while is_df_addr(vtable + (8*vlen)) and + is_df_addr(df.reinterpret_cast('uintptr_t', vtable + (8*vlen))[0]) + do + vlen = vlen + 1 + break -- for now, any vtable with one valid pointer is valid enough + end + if vlen <= 0 then goto next_ptr end + -- some false positives can be ruled out if the string.char() call in read_c_string() throws an error for invalid characters + local ok, name = pcall(function() + return memscan.read_c_string(df.reinterpret_cast('char', typestring)) + end) + if not ok then goto next_ptr end + -- GCC strips the "_Z" prefix from typeinfo names, so add it back + local demangled_name = dfhack.internal.cxxDemangle('_Z' .. name) + if demangled_name and + not demangled_name:match('[<>]') and + not demangled_name:match('^std::') and + not seen[demangled_name] and + (is_lib or demangled_name ~= 'widgets::widget') -- the widget in g_src takes precedence + then + local base_str = '' + if is_lib then + vtable = vtable - range.base_addr + base_str = (" base='%s'"):format(base) + end + vtables[demangled_name] = {value=vtable, base_str=base_str} + seen[demangled_name] = true + end + ::next_ptr:: + end + ::next_range:: + end + + return vtables +end + +local vtables = scan_ranges() +for name, data in pairs(vtables) do + print((""):format(name, data.value, data.base_str)) +end diff --git a/devel/scanitemother.lua b/devel/scanitemother.lua new file mode 100644 index 0000000000..949bf555fc --- /dev/null +++ b/devel/scanitemother.lua @@ -0,0 +1,20 @@ +-- Display the item lists that the selected item is part of. +local selected_item = dfhack.gui.getAnyItem() + +if not selected_item then + qerror("This script requires an item selected!") +end + +local indices = {} +for index, value in pairs(df.global.world.items.other) do + for _, item in pairs(value) do + if item == selected_item then + table.insert(indices, index) + end + end +end + +print("Found in the following indices:") +for _, index in pairs(indices) do + print(" " .. index) +end diff --git a/devel/scanitemother.rb b/devel/scanitemother.rb deleted file mode 100644 index 1bdf589464..0000000000 --- a/devel/scanitemother.rb +++ /dev/null @@ -1,16 +0,0 @@ -# list selected item's indices in world.item.other[] -=begin - -devel/scanitemother -=================== -List indices in ``world.item.other[]`` where current selected item appears. - -=end -tg = df.item_find -raise 'select an item' if not tg - -o = df.world.items.other -# discard ANY/BAD -o._indexenum::ENUM.sort.transpose[1][1..-2].each { |k| - puts k if o[k].find { |i| i == tg } -} diff --git a/devel/send-key.lua b/devel/send-key.lua index f10cc89375..1b3d4d6f9a 100644 --- a/devel/send-key.lua +++ b/devel/send-key.lua @@ -3,13 +3,22 @@ devel/send-key ============== -Send a key to the current screen or a parent +Send a key to the current screen or a parent. If you are trying to dismiss +a screen, `devel/pop-screen` may be more useful, particularly if the screen +is unresponsive to ``Esc``. -Usage: +Usage:: -:devel/send-key KEY_NAME: Send KEY_NAME -:devel/send-key KEY_NAME X: Send KEY_NAME to the screen ``X`` screens above - the current screen + devel/send-key KEY_NAME [X] + +* ``KEY_NAME`` is the name of an ``interface_key`` - a full list of valid + names can be obtained by running ``lua @df.interface_key``, looking in + ``data/init/interface.txt``, or by checking ``df.keybindings.xml`` in + the df-structures repository. + +* ``X`` (optional) - if specified, the key is sent to the screen ``X`` screens + above the current screen in the screen stack (e.g. ``1`` corresponds to the + current screen's parent) ]====] local args = {...} diff --git a/devel/spawn-unit-helper.lua b/devel/spawn-unit-helper.lua new file mode 100644 index 0000000000..f1ace946f7 --- /dev/null +++ b/devel/spawn-unit-helper.lua @@ -0,0 +1,18 @@ +-- Allow arena creature spawn after a mode change + +for key, _ in pairs(df.global.world.arena.race) do + df.global.world.arena.race[key] = nil +end + +for key, _ in pairs(df.global.world.arena.caste) do + df.global.world.arena.caste[key] = nil +end + +for creature_index, _ in pairs(df.global.world.raws.creatures.all) do + for caste_index, _ in pairs(df.global.world.raws.creatures.all[creature_index].caste) do + df.global.world.arena.race:insert('#', creature_index) + df.global.world.arena.caste:insert('#', caste_index) + end +end + +df.global.world.arena.creature_cnt[#df.global.world.arena.race - 1] = 0 diff --git a/devel/spawn-unit-helper.rb b/devel/spawn-unit-helper.rb deleted file mode 100644 index 77dadbcddd..0000000000 --- a/devel/spawn-unit-helper.rb +++ /dev/null @@ -1,35 +0,0 @@ -# Allow arena creature spawn after a mode change - -df.world.arena_spawn.race.clear -df.world.arena_spawn.caste.clear - -df.world.raws.creatures.all.length.times { |r_idx| - df.world.raws.creatures.all[r_idx].caste.length.times { |c_idx| - df.world.arena_spawn.race << r_idx - df.world.arena_spawn.caste << c_idx - } -} - -df.world.arena_spawn.creature_cnt[df.world.arena_spawn.race.length-1] = 0 - -puts < 0 then + chars = chars-2 + if dir == 1 then + print_color(' N', COLOR_DARKGREY) + elseif dir == 2 then + print_color(' S', COLOR_DARKGREY) + elseif dir == 3 then + print_color(' W', COLOR_DARKGREY) + elseif dir == 4 then + print_color(' E', COLOR_DARKGREY) + elseif dir == 5 then + print_color(' U', COLOR_DARKGREY) + elseif dir == 6 then + print_color(' D', COLOR_DARKGREY) + else + print_color(' ?', COLOR_DARKGREY) + end + end + + dfhack.print((' '):rep(chars)) +end + +function printRootTile(bits) + local chars = 8 --launcher doesn't like tab + local exists + + if bits.regular then + chars = chars-1 + exists = true + print_color(string.char(172), COLOR_BROWN) --1/4 + end + + if bits.blocked then + chars = chars-1 + print_color('x', COLOR_RED) + elseif not exists then + chars = chars-1 + dfhack.print('.') + end + + dfhack.print((' '):rep(chars)) +end + +function printTree(t) + local div = ('-'):rep(t.dim_x*8+1) + print(div) + + for z = t.body_height-1, 0, -1 do + for i = 0, t.dim_x*t.dim_y-1 do + printTreeTile(t.body[z]:_displace(i)) + + if i%t.dim_x == t.dim_x-1 then + print('|') --next line + end + end + + print(div) + end + + for z = 0, t.roots_depth-1 do + for i = 0, t.dim_x*t.dim_y-1 do + printRootTile(t.roots[z]:_displace(i)) + + if i%t.dim_x == t.dim_x-1 then + print('|') --next line + end + end + + print(div) + end +end + +if not dfhack_flags.module then + local pos = guidm.getCursorPos() + if not pos then qerror('No cursor!') end + local p = dfhack.maps.getPlantAtTile(pos) + if p and p.tree_info then + printTree(p.tree_info) + else + qerror('No tree!') + end +end diff --git a/devel/unforbidall.rb b/devel/unforbidall.rb deleted file mode 100644 index 176bff9c04..0000000000 --- a/devel/unforbidall.rb +++ /dev/null @@ -1,10 +0,0 @@ -# unforbid all items -=begin - -devel/unforbidall -================= -Unforbid all items. - -=end - -df.world.items.all.each { |i| i.flags.forbid = false } diff --git a/devel/unit-path.lua b/devel/unit-path.lua index 930f46a146..6f5e34d58e 100644 --- a/devel/unit-path.lua +++ b/devel/unit-path.lua @@ -24,24 +24,18 @@ UnitPathUI.ATTRS { unit = default_nil_unit, has_path = false, has_goal = false, + sidebar_mode=df.ui_sidebar_mode.LookAround, } function UnitPathUI:init() - self.saved_mode = df.global.ui.main.mode if self.unit then self.has_path = #self.unit.path.path.x > 0 self.has_goal = self.unit.path.dest.x >= 0 end end -function UnitPathUI:onShow() - -- with cursor, but without those ugly lines from native hauling mode - df.global.ui.main.mode = df.ui_sidebar_mode.Stockpiles -end - function UnitPathUI:onDestroy() self:moveCursorTo(copyall(self.unit.pos)) - df.global.ui.main.mode = self.saved_mode end local function getTileType(cursor) @@ -53,15 +47,6 @@ local function getTileType(cursor) end end -local function getTileWalkable(cursor) - local block = dfhack.maps.getTileBlock(cursor) - if block then - return block.walkable[cursor.x%16][cursor.y%16] - else - return 0 - end -end - local function paintMapTile(dc, vp, cursor, pos, ...) if not same_xyz(cursor, pos) then local stile = vp:tileToScreen(pos) @@ -121,7 +106,7 @@ function UnitPathUI:renderPath(dc,vp,cursor) end end local color = COLOR_LIGHTGREEN - if getTileWalkable(pt) == 0 then color = COLOR_LIGHTRED end + if dfhack.maps.getWalkableGroup(pt) == 0 then color = COLOR_LIGHTRED end paintMapTile(dc, vp, cursor, pt, char, color) end end @@ -142,7 +127,7 @@ function UnitPathUI:onRenderBody(dc) dc:seek(2,3):pen(COLOR_BLUE):string(prof) if name and name.has_name then - dc:seek(2,4):pen(COLOR_BLUE):string(dfhack.TranslateName(name)) + dc:seek(2,4):pen(COLOR_BLUE):string(dfhack.translation.translateName(name)) end local cursor = guidm.getCursorPos() @@ -221,7 +206,7 @@ end local unit = dfhack.gui.getSelectedUnit(true) if not unit or not string.match(dfhack.gui.getCurFocus(), '^dwarfmode/ViewUnits/Some/') then - qerror("This script requires the main dwarfmode view in 'v' mode with a unit selected") + qerror("This script requires 'v' mode with a unit selected") end UnitPathUI{ unit = unit }:show() diff --git a/devel/visualize-structure.lua b/devel/visualize-structure.lua new file mode 100644 index 0000000000..2db70e54f3 --- /dev/null +++ b/devel/visualize-structure.lua @@ -0,0 +1,136 @@ +-- Displays the raw memory of a structure, separated by field. +--author BenLubar +--luacheck:skip-entirely +-- +--[====[ + +devel/visualize-structure +========================= +Displays the raw memory of a structure, separated by field. +Useful for checking if structures are aligned. + +]====] + +local args = {...} + +if #args ~= 1 then + qerror([[Usage: devel/visualize-structure [path] + +Displays the raw memory of a structure, separated by field. +Useful for checking if structures are aligned.]]) +end + +local utils = require('utils') + +local ok, ref = pcall(function() return utils.df_expr_to_ref(args[1]) end) +if not ok then + qerror(ref) +end +if ref._kind == 'primitive' and type(ref._type) == 'string' and ref._type:endswith('*') and ref.value ~= nil then + -- dereference primitive pointer fields, since we usually care more about their contents + ref = ref.value +end + +local size, baseaddr = ref:sizeof() +local actual_size = -1 + +if ref._type == 'void*' then + baseaddr = df.reinterpret_cast('uintptr_t', ref).value + size = 0 +end + +local ptrsz = dfhack.getArchitecture() / 8 + +if baseaddr % 32 == 16 then + local intptr = df.reinterpret_cast('uint64_t', baseaddr) + if intptr:_displace(-1).value == 0xdfdf4ac8 then + actual_size = intptr:_displace(-2).value + if size < actual_size then + size = actual_size + end + end +end + +print(ref._type) + +local expected_padding_byte = 0xd2 + +for _, g in pairs(df.global) do + if type(g) == 'userdata' then + local global_size, global_start = g:sizeof() + if baseaddr >= global_start and baseaddr < global_start + global_size then + expected_padding_byte = 0 + break + end + end +end + +local byteptr = df.reinterpret_cast('uint8_t', baseaddr) +local offset = 0 +local function bytes_until(target, expect) + while offset < target do + if expect == 'padding' then + if byteptr:_displace(offset).value == expected_padding_byte then + dfhack.color(COLOR_DARKGREY) + else + dfhack.color(COLOR_LIGHTRED) + end + elseif expect == 'vtable' then + dfhack.color(COLOR_DARKGREY) + end + dfhack.print(string.format('%02x', byteptr:_displace(offset).value)) + dfhack.color(COLOR_RESET) + offset = offset + 1 + if offset % 4 == 0 then + dfhack.print(' ') + end + if offset % 8 == 0 then + dfhack.print(' ') + end + end + print() +end + +-- adapted from devel/query +local function key_pairs(item) + local mt = debug.getmetatable(item) + if mt and mt._index_table then + local idx = 0 + return function() + idx = idx + 1 + if mt._index_table[idx] then + return mt._index_table[idx] + end + end + end +end +if ref._field then + for k in key_pairs(ref) do + local field = ref:_field(k) + local fsize, faddr = field:sizeof() + local foff = faddr - baseaddr + if offset < foff then + print() + if offset == 0 and foff == ptrsz then + print('(vtable)') + bytes_until(foff, 'vtable') + else + print('(padding)') + bytes_until(foff, 'padding') + end + end + + print() + print(tostring(field._type) .. ' ' .. k) + bytes_until(foff + fsize) + if field._kind == 'container' then + print('length: ' .. #field) + end + end +end + +if offset < size then + print() + print('(padding)') + bytes_until(size, 'padding') +end diff --git a/devel/watch-minecarts.lua b/devel/watch-minecarts.lua index 64d0f5138c..386a55d8d3 100644 --- a/devel/watch-minecarts.lua +++ b/devel/watch-minecarts.lua @@ -81,4 +81,3 @@ if cmd == 'start' then elseif cmd == 'stop' then dfhack.timeout_active(timeout_id, nil) end - diff --git a/digfort.rb b/digfort.rb deleted file mode 100644 index 350245841c..0000000000 --- a/digfort.rb +++ /dev/null @@ -1,124 +0,0 @@ -# designate an area based on a '.csv' plan -=begin - -digfort -======= -A script to designate an area for digging according to a plan in csv format. - -This script, inspired from quickfort, can designate an area for digging. -Your plan should be stored in a .csv file like this:: - - # this is a comment - d;d;u;d;d;skip this tile;d - d;d;d;i - -Available tile shapes are named after the 'dig' menu shortcuts: -``d`` for dig, ``u`` for upstairs, ``j`` downstairs, ``i`` updown, -``h`` channel, ``r`` upward ramp, ``x`` remove designation. -Unrecognized characters are ignored (eg the 'skip this tile' in the sample). - -Empty lines and data after a ``#`` are ignored as comments. -To skip a row in your design, use a single ``;``. - -One comment in the file may contain the phrase ``start(3,5)``. It is interpreted -as an offset for the pattern: instead of starting at the cursor, it will start -3 tiles left and 5 tiles up from the cursor. - -additionally a comment can have a < for a rise in z level and a > for drop in z. - -The script takes the plan filename, starting from the root df folder (where -``Dwarf Fortress.exe`` is found). - -=end - -fname = $script_args[0].to_s - -if not $script_args[0] then - puts " Usage: digfort " - throw :script_finished -end -if not fname[-4..-1] == ".csv" then - puts " The plan file must be in .csv format." - throw :script_finished -end -if not File.file?(fname) then - puts " The specified file does not exist." - throw :script_finished -end - -planfile = File.read(fname) - -if df.cursor.x == -30000 - puts "place the game cursor to the top-left corner of the design and retry" - throw :script_finished -end - -offset = [0, 0] -tiles = [] -max_x = 0 -max_y = 0 -planfile.each_line { |l| - if l =~ /#.*start\s*\(\s*(-?\d+)\s*[,;]\s*(-?\d+)/ - raise "Error: multiple start() comments" if offset != [0, 0] - offset = [$1.to_i, $2.to_i] - end - if l.chomp == '#<' - l = '<' - end - - if l.chomp == '#>' - l = '>' - end - - l = l.chomp.sub(/#.*/, '') - next if l == '' - x = 0 - tiles << l.split(/[;,]/).map { |t| - t = t.strip - x = x + 1 - max_x = x if x > max_x and not t.empty? - (t[0] == '"') ? t[1..-2] : t - } - max_y = max_y + 1 -} - -x = df.cursor.x - offset[0] -y = df.cursor.y - offset[1] -z = df.cursor.z -starty = y - 1 - -map = df.world.map - -if x < 0 or y < 0 or x+max_x >= map.x_count or y+max_y >= map.y_count - max_x = max_x + x + 1 - max_y = max_y + y + 1 - raise "Position would designate outside map limits. Selected limits are from (#{x+1}, #{y+1}) to (#{max_x},#{max_y})" -end - -tiles.each { |line| - next if line.empty? or line == [''] - line.each { |tile| - if tile.empty? - x += 1 - next - end - t = df.map_tile_at(x, y, z) - s = t.shape_basic - case tile - when 'd'; t.dig(:Default) if s == :Wall - when 'u'; t.dig(:UpStair) if s == :Wall - when 'j'; t.dig(:DownStair) if s == :Wall or s == :Floor - when 'i'; t.dig(:UpDownStair) if s == :Wall - when 'h'; t.dig(:Channel) if s == :Wall or s == :Floor - when 'r'; t.dig(:Ramp) if s == :Wall - when 'x'; t.dig(:No) - when '<'; y=starty; z += 1 - when '>'; y=starty; z -= 1 - end - x += 1 - } - x = df.cursor.x - offset[0] - y += 1 -} - -puts ' done' diff --git a/diplomacy.lua b/diplomacy.lua new file mode 100644 index 0000000000..512d0df4d0 --- /dev/null +++ b/diplomacy.lua @@ -0,0 +1,108 @@ +local args = {...} + +-- player civ references: +local p_civ_id = df.global.plotinfo.civ_id +local p_civ = df.historical_entity.find(df.global.plotinfo.civ_id) + +-- get list of civs: +function get_civ_list() + local civ_list = {} + for _, entity in ipairs(p_civ.relations.diplomacy.state) do + local cur_civ_id = entity.group_id + local cur_civ = df.historical_entity.find(cur_civ_id) + if cur_civ.type == 0 then + -- if true then + rel_str = "" + if entity.relation == 0 then + rel_str = "Peace" + elseif entity.relation == 1 then + rel_str = "War" + end + matched = "No" + for _, entity2 in ipairs(cur_civ.relations.diplomacy.state) do + if entity2.group_id == p_civ_id and entity2.relation == entity.relation then + matched = "Yes" + end + end + table.insert(civ_list, { + cur_civ_id, + rel_str, + matched, + dfhack.translation.translateName(cur_civ.name, true) + }) + end + end + return civ_list +end + +-- output civ list: +function output_civ_list() + local civ_list = get_civ_list() + if not next(civ_list) then + print("Your civilisation has no diplomatic relations! This means something is going wrong, as it should have at least a relation to itself.") + else + print(([[%4s %12s %8s %30s]]):format("ID", "Relation", "Mutual", "Name")) + for _, civ in pairs(civ_list) do + print(([[%4s %12s %8s %30s]]):format(civ[1], civ[2], civ[3], civ[4])) + end + end +end + +-- change relation: +function change_relation(civ_id, relation) + print("Changing relation with " .. civ_id .. " to " .. (relation == 0 and "Peace" or "War")) + for _, entity in ipairs(p_civ.relations.diplomacy.state) do + local cur_civ_id = entity.group_id + local cur_civ = df.historical_entity.find(cur_civ_id) + if cur_civ.type == 0 and cur_civ_id == civ_id then + entity.relation = relation + for _, entity2 in pairs(cur_civ.relations.diplomacy.state) do + if entity2.group_id == p_civ_id then + entity2.relation = relation + end + end + end + end +end + +-- parse relation string args: +function relation_parse(rel_str) + if rel_str == "0" or rel_str:lower() == "peace" then + return 0 + elseif rel_str == "1" or rel_str:lower() == "war" then + return 1 + else + print(dfhack.script_help()) + qerror("Cannot parse relation: " .. rel_str) + end +end + +-- handle 'all' civ argument: +function handle_all(arg1, arg2) + if arg1:lower() == "all" then + local civ_list = get_civ_list() + for _, civ in pairs(civ_list) do + if civ[1] ~= p_civ_id then + change_relation(civ[1], arg2) + end + end + else + change_relation(tonumber(arg1), arg2) + end +end + +-- if no civ ID is entered, just output list of civs: +if not args[1] then + output_civ_list() + return +end + +-- make sure that there is a relation to change to: +if not args[2] then + print(dfhack.script_help()) + qerror("Please specify 'peace' or 'war'") +end + +-- change relation(s) according to args: +handle_all(args[1], relation_parse(args[2])) +output_civ_list() diff --git a/do-job-now.lua b/do-job-now.lua new file mode 100644 index 0000000000..3ef9585a9b --- /dev/null +++ b/do-job-now.lua @@ -0,0 +1,202 @@ +-- makes a job involving current selection high priority + +local utils = require('utils') + +local function print_help() + print(dfhack.script_help()) +end + +local function doJobNow(job) + local job_str = dfhack.job.getName(job) + if not job.flags.do_now then + job.flags.do_now = true + print("Made the job " .. job_str .. " top priority") + else + print("The job " .. job_str .. " is already top priority") + end + local building = dfhack.job.getHolder(job) + if building then + print("... at " .. utils.getBuildingName(building)) + end + local unit = dfhack.job.getWorker(job) + if unit then + print("... by " .. dfhack.df2console(dfhack.units.getReadableName(unit))) + end +end + +local function doItemJobNow(item) + if not item.flags.in_job then + qerror(dfhack.items.getDescription(item, 0) .. " must be in a job! (look for 'TSK')") + end + + local sref = dfhack.items.getSpecificRef(item, df.specific_ref_type.JOB) + if sref then + doJobNow(sref.data.job) + return + end + print("Couldn't find any job for " .. dfhack.items.getDescription(item, 0)) +end + +local function doBuildingJobNow(building) + --print('This will attempt to make a job of a building a top priority') + + if #building.jobs > 0 + and ( + building.jobs[0].job_type == df.job_type.ConstructBuilding + or building.jobs[0].job_type == df.job_type.DestroyBuilding + ) + then + doJobNow(building.jobs[0]) + return + end + print("Couldn't find either construct or destroy building job for " .. utils.getBuildingName(building)) +end + +local function doUnitJobNow(unit) + if dfhack.units.isCitizen(unit) then + local t_job = unit.job + if t_job then + local job = t_job.current_job + if job then + doJobNow(job) + return + end + end + print("Couldn't find any job for " .. dfhack.df2console(dfhack.units.getReadableName(unit))) + else + local needle = unit.id + for _link, job in utils.listpairs(df.global.world.jobs.list) do + if #job.general_refs > 0 then + for _, gref in ipairs(job.general_refs) do + --if gref:getType() == df.general_ref_type.UNIT then -- can't do this: there are many different types that can be units + local u = gref:getUnit() + if u and u.id == needle then + doJobNow(job) + return + end + --end + end + end + end + + print("Couldn't find any job involving " .. dfhack.df2console(dfhack.units.getReadableName(unit))) + end +end + +local function doPlantJobNow(plant) + --print('This will attempt to make a job with a plant a top priority') + + for _link, job in utils.listpairs(df.global.world.jobs.list) do + if plant.pos.x == job.pos.x + and plant.pos.y == job.pos.y + and plant.pos.z == job.pos.z + then + doJobNow(job) + return + end + end + + print("Couldn't find any job involving this plant.") +end + +local function doWorkOrderJobsNow(order) + local needle = order.id + local cnt = 0 + for _link, job in utils.listpairs(df.global.world.jobs.list) do + if job.order_id == needle then + doJobNow(job) + cnt = cnt + 1 + end + end + + if cnt > 0 then + print("Found " .. cnt .. " jobs for this work order.") + else + print("Couldn't find any job for this work order.") + end +end + +local function getSelectedWorkOrder() + local scr = dfhack.gui.getCurViewscreen() + local orders + local idx + if df.viewscreen_jobmanagementst:is_instance(scr) then + orders = df.global.world.manager_orders.all + idx = scr.sel_idx + elseif df.viewscreen_workshop_profilest:is_instance(scr) + and scr.tab == df.viewscreen_workshop_profilest.T_tab.Orders + then + orders = scr.orders + idx = scr.order_idx + end + if orders then + if idx < #orders then + return orders[idx] + else + qerror("Invalid work order selected") + end + end + + return nil +end + +local function doSelectedEntityJobNow() + -- do we have a job selected? + local job = dfhack.gui.getSelectedJob(true) + if job then + doJobNow(job) + return + end + + -- do we have an item selected? + local item = dfhack.gui.getSelectedItem(true) + if item then + doItemJobNow(item) + return + end + + -- do we have a building selected? + local building = dfhack.gui.getSelectedBuilding(true) + if building then + doBuildingJobNow(building) + return + end + + -- do we have a unit selected? + local unit = dfhack.gui.getSelectedUnit(true) + if unit then + doUnitJobNow(unit) + return + end + + -- do we have a plant selected? + local plant = dfhack.gui.getSelectedPlant(true) + if plant then + doPlantJobNow(plant) + return + end + + -- do we have a work order selected? + local order = getSelectedWorkOrder() + if order then + doWorkOrderJobsNow(order) + return + end + + qerror("Select something job-related in game.") +end + +local default_action = print_help + +local actions = { + -- help + ["-?"] = print_help, + ["?"] = print_help, + ["--help"] = print_help, + ["help"] = print_help, + -- action + ["default"] = doSelectedEntityJobNow, +} + +-- Lua is beautiful. +(actions[ (...) or "default" ] or default_action)(...) diff --git a/docs/adaptation.rst b/docs/adaptation.rst new file mode 100644 index 0000000000..ade35a2bc7 --- /dev/null +++ b/docs/adaptation.rst @@ -0,0 +1,35 @@ +adaptation +========== + +.. dfhack-tool:: + :summary: Adjust a unit's cave adaptation level. + :tags: fort armok units + +Cave adaptation (or adaption) increases for a unit when they spend time underground. If it reaches a high enough level, the unit will be affected when +View or set the level of cavern adaptation for the selected unit or the whole +fort. + +Usage +----- + +:: + + adaptation [show] [--all] + adaptation set [--all] + +The ``value`` must be between 0 and 800,000 (inclusive), with higher numbers +representing greater levels of cave adaptation. + +Examples +-------- + +``adaptation`` + Show the cave adaptation level for the selected unit. +``adaptation set --all 0`` + Clear the cave adaptation levels for all citizens and residents. + +Options +------- + +``-a``, ``--all`` + Apply to all citizens and residents. diff --git a/docs/add-recipe.rst b/docs/add-recipe.rst new file mode 100644 index 0000000000..c4e3f567dd --- /dev/null +++ b/docs/add-recipe.rst @@ -0,0 +1,31 @@ +add-recipe +========== + +.. dfhack-tool:: + :summary: Add crafting recipes to a civ. + :tags: adventure fort gameplay + +Civilizations pick randomly from a pool of possible recipes, which means, for +example, not all civs get high boots. This script can help fix that. Only +weapons, armor, and tools are currently supported; dynamically generated item +types like instruments are not. + +Usage +----- + +:: + + add-recipe (all|native) + add-recipe single + +Examples +-------- + +``add-recipe native`` + Add all crafting recipes that your civ could have chosen from its pool, but + did not. +``add-recipe all`` + Add *all* available weapons and armor, including exotic items like + blowguns, two-handed swords, and capes. +``add-recipe single SHOES:ITEM_SHOES_BOOTS`` + Allow your civ to craft high boots. diff --git a/docs/add-thought.rst b/docs/add-thought.rst new file mode 100644 index 0000000000..528fdd0e37 --- /dev/null +++ b/docs/add-thought.rst @@ -0,0 +1,50 @@ +add-thought +=========== + +.. dfhack-tool:: + :summary: Adds a thought to the selected unit. + :tags: fort armok units + +Usage +----- + +``add-thought --gui [--unit ]`` + Allows you to choose the thought to apply to the selected (or specified) + unit through a series of graphical prompts. +``add-thought [--unit ] []`` + Add a thought to the selected (or specified) unit. + +Examples +-------- + +``add-thought --gui`` + Add a thought to the unit currently selected in the UI. +``add-thought --unit 23142 --emotion GRATITUDE --thought GoodMeal --strength 1`` + Make unit 23142 feel a light sense of gratitude for eating a good meal. + +Options +------- + +``--emotion `` + Specifies an emotion for the unit to associate with the given thought. To + see a list of possible emotions, run ``:lua @df.emotion_type``. If not + specified, defaults to ``-1`` (i.e. no emotion). +``--thought `` + The thought. To see a list of possible thoughts, run + ``:lua @df.unit_thought_type``. If not specified, defaults to ``180``, or + ``NeedsUnfulfilled``. The id could also be the name of a syndrome. To see + a list of syndromes in your world, run + ``devel/query --table df.global.world.raws.syndromes.all --search syn_name --maxdepth 1``. + The id is the numerical index and the syn_name field is the name. +``--subthought `` + The subthought identifier. If the thought is the name of a syndrome, then + the subthought should be the syndrome id. If not specified, defaults to + ``0`` (which is what you want for most thought types). +``--strength `` + The strength of the emotion, corresponding to the strength of the need that + this emotion might cause or fulfill. Common values for this are ``1`` + (Slight need), ``2`` (Moderate need), ``5`` (Strong need), and ``10`` + (Intense need). If not specified, defaults to ``0``. +``--severity `` + If the thought is the name of a syndrome, then the severity will be used as + the severity of the syndrome. diff --git a/docs/adv-max-skills.rst b/docs/adv-max-skills.rst new file mode 100644 index 0000000000..75c201ed7b --- /dev/null +++ b/docs/adv-max-skills.rst @@ -0,0 +1,16 @@ +adv-max-skills +============== + +.. dfhack-tool:: + :summary: Raises adventurer stats to max. + :tags: unavailable + +When creating an adventurer, raises all changeable skills and attributes to +their maximum level. + +Usage +----- + +:: + + adv-max-skills diff --git a/docs/advtools.rst b/docs/advtools.rst new file mode 100644 index 0000000000..113d6c969a --- /dev/null +++ b/docs/advtools.rst @@ -0,0 +1,50 @@ +advtools +======== + +.. dfhack-tool:: + :summary: A collection of useful adventure mode tools. + :tags: adventure interface gameplay units + +Usage +----- + +:: + + advtools party + +``party`` command +----------------- + +When you run this command, you will get a list of your extra party members and +can choose who to promote into your "core party". This will let you control +them when in the tactics mode. + +Overlays +-------- + +This tool provides several functions that are managed by the overlay +framework. They can be repositioned via `gui/overlay` or toggled via +`gui/control-panel`. + +``advtools.conversation`` +~~~~~~~~~~~~~~~~~~~~~~~~~ + +When enabled, this overlay will automatically add additional searchable +keywords to conversation topics. In particular, topics that relate to slain +enemies will gain the ``slay`` and ``kill`` keywords. It will also add +additional conversation options for asking whereabouts of your relationships -- +in vanilla, you can only ask whereabouts of historical figures involved in +rumors you personally witnessed or heard about. + +``advtools.fastcombat`` +~~~~~~~~~~~~~~~~~~~~~~~ + +When enabled, this overlay will allow you to skip most combat animations, +including the whooshes and projectiles travelling through the screen. It will +also let you skip the announcements window when the "More" button is active, +scrolling you to the very bottom with the first press, and skipping the window +entirely with the second press. This drastically speeds up combat while still +giving you the option not to skip the announcements. Skip keys are left mouse click, +the SELECT button, the movement keys and combat-related keys that don't bring up a +menu (such as bump attack). If clicking to skip past combat, it will only skip the +announcements if you're clicking outside the announcements panel. diff --git a/docs/agitation-rebalance.rst b/docs/agitation-rebalance.rst new file mode 100644 index 0000000000..435d06225e --- /dev/null +++ b/docs/agitation-rebalance.rst @@ -0,0 +1,310 @@ +agitation-rebalance +=================== + +.. dfhack-tool:: + :summary: Make agitated wildlife and cavern invasions less persistent. + :tags: fort gameplay + +The DF agitation (or "irritation") system gives you challenges to face when +your dwarves impact the natural environment. It adds new depth to gameplay, but +it can also quickly drag the game down, both with constant incursions of +agitated animals and with FPS-killing massive buildups of hidden invaders in +the caverns. This mod changes how the agitation system behaves to ensure the +challenge remains fun and scales appropriately according to your dwarves' +current activities. + +In short, this mod changes irritation-based attacks from a constant flood to a +system that is responsive to your recent actions on the surface and in the +caverns. If you irritate the natural environment enough, you will be attacked +exactly once. You will not be attacked further unless you continue to +antagonize nature. This mod can be enabled (and auto-started for new forts, if +desired) on the "Gameplay" tab of `gui/control-panel`. + +Usage +----- + +:: + + enable agitation-rebalance + agitation-rebalance [status] + agitation-rebalance preset + agitation-rebalance enable|disable + +When run without arguments (or with the ``status`` argument), it will print out +whether it is enabled, the current configuration, how many agitated creatures +and (visible) cavern invaders are on the map, and your current chances of +suffering retaliation on the surface and in each of the cavern layers. + +The `Presets`_ allow you to quickly set the game irritation-related difficulty +settings to tested, balanced values. You can adjust them further (or set your +own values) on the DF difficulty settings screen. Note that +``agitation-rebalance preset`` can be used to set the difficulty settings even +if the mod is not enabled. Even with vanilla mechanics, the presets are still +handy. + +Finally, each feature of the mod can be individually enabled or disabled. More +details in the `Features`_ section below. + +Examples +-------- + +``agitation-rebalance preset lenient`` + Load the ``lenient`` preset, which allows for a fair amount of tree cutting + and other activity between attacks. This preset is loaded automatically if + the ``auto-preset`` feature enabled (it's enabled by default) and you have + the "Enemies" difficulty settings at their default "Normal" values. + +``enable agitation-rebalance`` + Manually enable the mod (not needed if you are using `gui/control-panel`) + +``agitation-rebalance enable monitor`` + Enables an overlay that shows the current chances of being attacked on the + surface or in the caverns. The danger ratings shown on the overlay are + accurate regardless of whether ``agitation-rebalance`` is enabled, so you + can use the monitor even if you're not using the mod. + +How the DF agitation system works +--------------------------------- + +The surface +~~~~~~~~~~~ + +For the surface wilderness in savage biomes (non-savage biomes will never see +agitated wildlife), DF maintains a counter. Your dwarves increase the counter +when they chop down trees or catch fish. Once it crosses a threshold, wildlife +that enters the map has a chance of becoming agitated and aggressively attacking +your units. This chance increases the higher the counter rises. Once a year, +the counter is decremented by a fixed amount. + +Only one group of wildlife can be on the surface at a time. When you kill all +the creatures in a group, or when they finally wander off on their own, the +game spawns a new group to replace them. Each new group rolls against the +current surface irritation counter for a chance to become agitated. + +Since agitated wildlife seeks out your units to attack, they are often quickly +destroyed -- if they don't quickly destroy *you* -- and a new wave will spawn. +The new wave will have a similar chance to the previous wave for becoming +agitated. This means that once you cross the threshold that starts the +agitation attacks, you may suffer near-constant retribution until you stop all +tree cutting and fishing on the surface and hide for sufficient time (years) +until the counter falls low enough again. + +The caverns +~~~~~~~~~~~ + +DF similarly maintains counters for each cavern layer, with chances of cavern +invasion and forgotten beast attack independently checked once per season. The +cavern irritation counter is increased for tree felling and fishing within the +cavern. Moreover, doing anything that makes :wiki:`noise` will increase the +irritation. For example, digging anywhere within the cavern's z-level range +(even if it is not in the open space of the cavern itself) will raise the +cavern's irritation level. + +The chance of cavern invasion increases linearly with respect to irritation +until it reaches 100% after about 100 cavern trees felled. While irritation +chances are calculated separately for each cavern layer, only one attack may +occur per season. The upper cavern layers get rolled first, so even if all +layers have the same irritation level, invasions will tend to happen in the +uppermost layer. There are no player-configurable settings to change the cavern +invasion thresholds. Regardless of irritation level, cavern invasions do not +spawn until the cavern layer is discovered by the current fort. + +The chance of forgotten beast attack in a particular layer is affected by the +cavern layer's irritation level, but your fortress's wealth has a much greater +impact. Even with an irritation level of zero, a wealthy fortress will +encourage forgotten beasts to attack at their maximum rate. The chance of +forgotten beast attack is capped at 33% per layer, but unlike cavern invasions, +you can have as many forgotten beast attacks in a season as you have layers. +With high irritation and/or high fortress wealth, forgotten beasts can invade a +cavern before you discover it. + +You can wall off the caverns to insulate your fort from the invasions, but +invaders will continue to spawn and build up over time. Cavern invaders spawn +hidden, so you will not be aware that they are there until you send a unit in +to investigate. Eventually, your FPS will be impacted by the large numbers of +cavern invaders. The irritation counters for the cavern layers do not decay over +time, so once attacks begin, cavern invasions will occur once a season +thereafter, regardless of the continued presence of previous invaders. + +Irritation counters are saved with the cavern layer in the world region, which +extends beyond the boundaries of your current fort. If you retire a fort and +start another one nearby, the caverns will retain any irritation added by the +first fort. This means that new forts may start with already-irritated caverns +and meet with immediate resistance. + +The settings +~~~~~~~~~~~~ + +There are several variables that affect the behavior of this system, all +customizable in the DF difficulty settings: + +``Wilderness irritation minimum`` + While the surface irritation counter is below this value, no agitated + wildlife will appear. +``Wilderness sensitivity`` + After the surface irritation counter rises above the minimum, this value + represents the range over which the chance of attack increases from 0% to + 100%. +``Wilderness irritation decay`` + This is the amount that the surface irritation counter decreases per year, + regardless of activity. Due to a bug in DF, the widget for this setting in + the difficulty settings panel always displays and controls the value for + ``Wilderness irritation minimum`` and thus the setting cannot be changed in + the vanilla interface from its default value of 500 (if initialized by the + "Normal" vanilla preset) or 100 (if initialized by the "Hard" vanilla + preset). +``Cavern dweller maximum attackers`` + This controls the maximum number of cavern invaders that can spawn in a + single invasion. If ``agitation-rebalance`` is not managing the invader + population, the number of invaders in the caverns can grow beyond this + number if the invaders from a previous invasion are still alive. +``Cavern dweller scale`` + Each time your civilization is attacked, the number of attackers in a + single cavern invasion increases by this value. The total number of + attackers is still capped by ``Cavern dweller maximum attackers``. +``Forgotten beast wealth divisor`` + Your fortress wealth is divided by this number and the result is added to a + cavern's "natural" irritation to get the effective irritation that a + forgotten beast rolls against for a chance to attack. +``Forgotten beast irritation minimum`` + While a cavern's effective irritation (see + ``Forgotten beast wealth divisor``) is below this value, no forgotten + beasts will invade that cavern. +``Forgotten beast sensitivity`` + After the cavern's effective irritation rises above the minimum, this value + represents the range over which the chance of forgotten beast attack + increases from 0% to 100%. + +What does this mod do? +---------------------- + +When enabled, this mod makes the following changes: + +When agitated wildlife enters the map on the surface, the surface irritation +counter is set to the value of ``Wilderness irritation minimum``, ensuring +that the *next* group of wildlife that enters the map will *not* be agitated. +This means that the incursions act more like a warning shot than an open +floodgate. You will not be attacked again unless you continue your activities +on the surface that raise the chance of a subsequent attack. + +The larger the value of ``Wilderness sensitivity``, the more you can irritate +the surface before you suffer another incursion. For reference, each tree +chopped adds 100 to the counter, so a ``Wilderness irritation minimum`` +value of 3500 and a ``Wilderness sensitivity`` value of 10000 will allow you to +initially chop 35 trees before having any chance of being attacked by agitated +creatures. Each tree you chop beyond those initial 35 raises the chance that +the next wave of wildlife will be agitated by 1%. + +If you cross a year boundary, then you will have additional leniency granted by +the ``Wilderness irritation decay`` value (if it is set to a value greater than +zero). + +For the caverns, we don't want to adjust the irritation counters directly since +that would negatively affect the chances of being attacked by (the much more +interesting) forgotten beasts. Instead, when a cavern invasion begins, we +record the current irritation counter value and effectively use that as the new +"minimum". A "sensitivity" value is synthesized from the average of the values +of ``Wilderness irritation minimum`` and ``Wilderness sensitivity``. This makes +cavern invasions behave similarly to surface agitation and lets it be +controlled by the same difficulty settings. The parameters for forgotten beast +attacks can still be controlled independently of this mod. + +Finally, if you have walled yourself off from the danger in the caverns, yet you +continue to irritate nature down there, this mod will ensure that the number of +active cavern invaders, cumulative across all cavern levels, never exceeds the +value set for ``Cavern dweller maximum attackers``. This prevents excessive FPS +loss during gameplay and keeps the number of creatures milling around outside +your gates (or hidden in the shadows) to a reasonable number. + +The monitor +~~~~~~~~~~~ + +You can optionally enable a small monitor panel that displays the current +threat rating for an upcoming attack. The chance of being attacked is shown for +the surface and for the caverns as a whole (so as not to spoil exactly where the +attack will happen). Moreover, to avoid spoiling when a cavern invasion has +begun, the displayed threat rating for the caverns is not reset to "None" (or, +more likely, "Low", since the act of fighting the invaders will have raised the +cavern's irritation a bit) until you have discovered and neutralized the +invaders. + +The ratings shown on the overlay are accurate regardless of whether +``agitation-rebalance`` is enabled. That is, if this mod is not enabled, then +the monitor will display ratings according to vanilla mechanics. + +Presets +------- + +The tree counts in these presets are only estimates. There are other actions +that contribute to irritation other than chopping trees, like fishing. +:wiki:`Noise` also contributes to irritation in the caverns. However, tree +chopping is the most important factor. + +``casual`` + - Trees until chance of invasion: 1000 + - Surface invasion chance increase per additional tree: 0.1% + - Additional allowed trees per year: 1000 + - Trees until risk of next cavern invasion: 1000 + - Max cavern invaders: 0 +``lenient`` + - Trees until chance of invasion: 100 + - Surface invasion chance increase per additional tree: 1% + - Additional allowed trees per year: 50 + - Trees until risk of next cavern invasion: 100 + - Max cavern invaders: 20 +``strict`` + - Trees until chance of invasion: 25 + - Surface invasion chance increase per additional tree: 20% + - Additional allowed trees per year: 10 + - Trees until risk of next cavern invasion: 15 + - Max cavern invaders: 50 +``insane`` + - Trees until chance of invasion: 6 + - Surface invasion chance increase per additional tree: 50% + - Additional allowed trees per year: 2 + - Trees until risk of next cavern invasion: 4 + - Max cavern invaders: 100 + +After using any of these presets, you can always to go the vanilla difficulty +settings and adjust them further to your liking. + +If the ``auto-preset`` feature is enabled and the difficulty settings exactly +match any of the vanilla "Enemies" presets when the mod is enabled, a +corresponding mod preset will be loaded. See the `Features`_ section below for +details. + +Features +-------- + +Features of the mod can be individually enabled or disabled. All features +except for ``monitor`` are enabled by default. Available features are: + +``auto-preset`` + Auto-load a preset based on which vanilla "Enemies" preset is active: + - "Off" loads the "casual" preset + - "Normal" loads the "lenient" preset + - "Hard" loads the "strict" preset + This feature takes effect at the time when the mod is enabled, so if you + don't want your default vanilla settings changed, be sure to disable this + feature before enabling ``agitation-rebalance``. +``surface`` + Manage surface agitated wildlife frequency. +``cavern`` + Manage cavern invasion frequency. +``cap-invaders`` + Ensure the number of live invaders in the caverns does not exceed the + configured maximum. +``monitor`` + Display a panel on the main map showing your chances of an + irritation-related attack on the surface and in the caverns. See + `The monitor`_ section above for details. The monitor overlay can also be + enabled and disabled via `gui/control-panel`, or repositioned with + `gui/overlay`. + +Caveat +------ + +If a cavern invasion causes the number of active attackers to exceed the +maximum, this mod will gently redirect the excess cavern invaders towards +oblivion as they enter the map. You may notice some billowing smoke near the +edge of the map as the surplus invaders are lovingly vaporized. diff --git a/docs/allneeds.rst b/docs/allneeds.rst new file mode 100644 index 0000000000..8155a29d90 --- /dev/null +++ b/docs/allneeds.rst @@ -0,0 +1,41 @@ +allneeds +======== + +.. dfhack-tool:: + :summary: Summarize the cumulative needs of a unit or the entire fort. + :tags: fort units + +Provides an overview of the needs of the selected unit, or, if no unit is +selected, the fort in general. By default, the list is sorted by which needs +are making your dwarves (or the selected dwarf) most unfocused right now. + +Usage +----- + +:: + + allneeds [] + +Examples +-------- + +``allneeds`` + Show the cumulative needs for the entire fort, or just for one unit if a + unit is selected in the UI. + +``allneeds --sort strength`` + Sort the list of needs by how strongly the people feel about them. + +Options +------- + +``-s``, ``--sort `` + Choose the sort order of the list. the criteria can be: + + - ``id``: sort the needs in alphabetical order. + - ``strength``: sort by how strongly units feel about the need. that is, if + left unmet, how quickly the focus will decline. + - ``focus``: sort by how unfocused the unmet needs are making your dwarves + feel right now. + - ``freq``: sort by how many times the need is seen (note that a single dwarf + can feel a need many times, e.g. when needing to pray to multiple gods). diff --git a/docs/animal-control.rst b/docs/animal-control.rst new file mode 100644 index 0000000000..79d237c50b --- /dev/null +++ b/docs/animal-control.rst @@ -0,0 +1,84 @@ +animal-control +============== + +.. dfhack-tool:: + :summary: Quickly view, butcher, or geld groups of animals. + :tags: fort productivity animals + +Animal control is useful for browsing through your animals and deciding which +to butcher or geld based on their stats. + +Usage +----- + +:: + + animal-control [] [] + +Examples +-------- + +``animal-control --all`` + View all your animals and whether they are marked for gelding or butchering. +``animal-control --race DOG --showstats`` + View extended info on your dogs. +``animal-control --markfor gelding --id 1988`` + Mark the specified unit for gelding. +``animal-control --gelded --markfor slaughter`` + Mark all gelded animals for slaughter. +``animal-control --gelded --markedfor slaughter --unmarkfor slaughter`` + Unmark all gelded animals for slaughter. + +Selection options +----------------- + +These options are used to specify what animals you want to select. If an option +calls for an ````, valid actions are ``slaughter`` and ``gelding``. + +``--all`` + Selects all units. This is the default if no selection options are + specified. +``--id `` + Selects the unit with the specified id. +``--race `` + Selects units which match the specified race. This can be the string name or + the numeric race id. Run ``animal-control --all`` to see which races you + have right now. +``--markedfor `` + Selects units which have been marked for the given action. +``--notmarkedfor `` + Selects units which have not been marked for the given action. +``--gelded`` + Selects units which have already been gelded. +``--notgelded`` + Selects units which have not been gelded. +``--male`` + Selects male units. +``--female`` + Selects female units. + +Command options +--------------- + +If no command option is specified, the default is to just list the matched +animals with some basic information. + +``--showstats`` + Displays physical attributes of the selected animals. +``--markfor `` + Marks selected animals for the given action. +``--unmarkfor `` + Unmarks selected animals for the given action. + +Column abbreviations +-------------------- + +Due to space constraints, the names of some output columns are abbreviated +as follows: + +- ``str``: strength +- ``agi``: agility +- ``tgh``: toughness +- ``endur``: endurance +- ``recup``: recuperation +- ``disres``: disease resistance diff --git a/docs/armoks-blessing.rst b/docs/armoks-blessing.rst new file mode 100644 index 0000000000..8151b161d2 --- /dev/null +++ b/docs/armoks-blessing.rst @@ -0,0 +1,36 @@ +armoks-blessing +=============== + +.. dfhack-tool:: + :summary: Bless units with superior stats and traits. + :tags: fort armok units + +Runs the equivalent of `rejuvenate`, `elevate-physical`, `elevate-mental`, and +`brainwash` on all dwarves currently on the map. This is an extreme change, +which sets every stat and trait to an ideal easy-to-satisfy preference. + +Usage +----- + +``armoks-blessing`` + Adjust stats and personalities to an ideal for all dwarves. No skills will + be modified. +``armoks-blessing all`` + In addition to the stat and personality adjustments, set all skills for all + dwarves to legendary. +``armoks-blessing list`` + Prints list of all skills. +``armoks-blessing classes`` + Prints list of all skill classes (i.e. named groups of skills). +``armoks-blessing `` + Set a specific skill for all dwarves to legendary. +``armoks-blessing `` + Set a specific class (group of skills) for all dwarves to legendary. + +Examples +-------- + +``armoks-blessing Medical`` + All dwarves will have all medical related skills set to legendary. +``armoks-blessing RANGED_COMBAT`` + All dwarves become legendary archers. diff --git a/docs/assign-attributes.rst b/docs/assign-attributes.rst new file mode 100644 index 0000000000..5d02f85076 --- /dev/null +++ b/docs/assign-attributes.rst @@ -0,0 +1,81 @@ +assign-attributes +================= + +.. dfhack-tool:: + :summary: Adjust physical and mental attributes. + :tags: fort armok units + +Attributes are divided into tiers from -4 to 4. Tier 0 is the standard level and +represents the average values for that attribute, tier 4 is the maximum level, +and tier -4 is the minimum level. + +For more information on attributes, please see the :wiki:`wiki `. + +Usage +----- + +:: + + assign-attributes [--unit ] + +Please run:: + + devel/query --table df --maxdepth 1 --search [ physical_attribute_type mental_attribute_type ] + +to see the list of valid attribute tokens. + +Example +------- + +:: + + assign-attributes --reset --attributes [ STRENGTH 2 AGILITY -1 SPATIAL_SENSE -1 ] + +This will reset all attributes to a neutral value and will then set the +following values (the ranges will differ based on race; this example assumes +a dwarf is selected in the UI): + + * Strength: a random value between 1750 and 1999 (tier 2); + * Agility: a random value between 401 and 650 (tier -1); + * Spatial sense: a random value between 1043 and 1292 (tier -1). + +The final result will be: "She is very strong, but she is clumsy. She has a +questionable spatial sense." + +Options +------- + + +``--unit `` + The target unit ID. If not present, the currently selected unit will be the + target. +``--attributes [ [ ...] ]`` + The list of the attributes to modify and their tiers. The valid attribute + names can be found in the :wiki:`Attribute` (substitute any space with + underscores). Tiers range from -4 to 4. There must be a space before and + after each square bracket. +``--reset`` + Reset all attributes to the average level (tier 0). If both this option and + ``attributes`` are specified, the unit attributes will be reset and then the + listed attributes will be modified. + +Tiers +----- + +The tier corresponds to the text that DF will use to describe a unit with +attributes in that tier's range. For example, here is the mapping for the +"Strength" attribute: + +==== =================== +Tier Description +==== =================== +4 unbelievably strong +3 mighty +2 very strong +1 strong +0 (no description) +-1 weak +-2 very weak +-3 unquestionably weak +-4 unfathomably weak +==== =================== diff --git a/docs/assign-beliefs.rst b/docs/assign-beliefs.rst new file mode 100644 index 0000000000..df1e369bfe --- /dev/null +++ b/docs/assign-beliefs.rst @@ -0,0 +1,80 @@ +assign-beliefs +============== + +.. dfhack-tool:: + :summary: Adjust a unit's beliefs and values. + :tags: fort armok units + +Beliefs are defined with the belief token and a number from -3 to 3, +which describes the different levels of belief strength, as explained on the +:wiki:`wiki `. + +Usage +----- + +:: + + assign-beliefs [--unit ] + +Please run ``devel/query --table df.value_type`` to see the list of valid belief +tokens. + +Example +------- + +:: + + assign-beliefs --reset --beliefs [ TRADITION 2 CRAFTSMANSHIP 3 POWER 0 CUNNING -1 ] + +Resets all the unit beliefs, then sets the listed beliefs to the following +values: + +* Tradition: a random value between 26 and 40 (level 2); +* Craftsmanship: a random value between 41 and 50 (level 3); +* Power: a random value between -10 and 10 (level 0); +* Cunning: a random value between -25 and -11 (level -1). + +The final result (for a dwarf) will be: "She personally is a firm believer in +the value of tradition and sees guile and cunning as indirect and somewhat +worthless." + +Note that the beliefs aligned with the cultural values of the unit have not +triggered a report. + +Options +------- + +``--unit `` + The target unit ID. If not present, the currently selected unit will be the + target. +``--beliefs [ [ ...] ]`` + The list of the beliefs to modify and their levels. The valid belief tokens + can be found in the :wiki:`Personality_trait` (substitute any space with + underscores). Levels range from -3 to 3. There must be a space before and + after each square bracket. +``--reset`` + Reset all beliefs to a neutral level, aligned with the unit's race's + cultural values. If both this option and ``--beliefs`` are specified, the + unit's beliefs will be reset and then the listed beliefs will be modified. + +Belief strengths +---------------- + +The belief strength corresponds to the text that DF will use to describe a +unit's beliefs: + +======== ========= +Strength Effect +======== ========= +3 Highest +2 Very High +1 High +0 Neutral +-1 Low +-2 Very Low +-3 Lowest +======== ========= + +Resetting a belief means setting it to a level that does not trigger a report in +the "Thoughts and preferences" screen, which is dependent on the race of the +unit. diff --git a/docs/assign-facets.rst b/docs/assign-facets.rst new file mode 100644 index 0000000000..4e70aabc6e --- /dev/null +++ b/docs/assign-facets.rst @@ -0,0 +1,77 @@ +assign-facets +============= + +.. dfhack-tool:: + :summary: Adjust a unit's facets and traits. + :tags: fort armok units + +Facets are defined with a token and a number from -3 to 3, which describes +the different levels of facet strength, as explained on the +:wiki:`wiki ` + +Usage +----- + +:: + + assign-facets [--unit ] + +Please run ``devel/query --table df.personality_facet_type`` to see a list of +valid facet tokens. + +Example +------- + +:: + + assign-facets --reset --facets [ HATE_PROPENSITY -2 CHEER_PROPENSITY -1 ] + +Resets all the unit facets, then sets the listed facets to the following values: + +* Hate propensity: a value between 10 and 24 (level -2); +* Cheer propensity: a value between 25 and 39 (level -1). + +The final result (for a dwarf) will be: "She very rarely develops negative +feelings toward things. She is rarely happy or enthusiastic, and she is +conflicted by this as she values parties and merrymaking in the abstract." + +Note that the facets are compared to the beliefs, and if conflicts arise they +will be reported. + +Options +------- + +``--unit `` + The target unit ID. If not present, the currently selected unit will be the + target. +``--facets [ [ ...] ]`` + The list of the facets to modify and their levels. The valid facet tokens + can be found in the :wiki:`Personality_trait` (substitute any space with + underscores). Levels range from -3 to 3. There must be a space before and + after each square bracket. +``--reset`` + Reset all facets to a neutral level, aligned with the unit's race's + cultural values. If both this option and ``--facets`` are specified, the + unit's facets will be reset and then the listed facets will be modified. + +Facet strengths +--------------- + +The facet strength corresponds to the text that DF will use to describe a unit's +facets: + +======== ========= +Strength Effect +======== ========= +3 Highest +2 Very High +1 High +0 Neutral +-1 Low +-2 Very Low +-3 Lowest +======== ========= + +Resetting a facet means setting it to a level that does not trigger a report in +the "Thoughts and preferences" screen, which is dependent on the race of the +unit. diff --git a/docs/assign-goals.rst b/docs/assign-goals.rst new file mode 100644 index 0000000000..356eb300f1 --- /dev/null +++ b/docs/assign-goals.rst @@ -0,0 +1,45 @@ +assign-goals +============ + +.. dfhack-tool:: + :summary: Adjust a unit's goals and dreams. + :tags: fort armok units + +Goals are defined with a goal token and a flag indicating whether the goal has +been achieved. For now, this flag should always be set to false. For a list of +possible goals, please run ``devel/query --table df.goal_type`` or see the +:wiki:`wiki `. + +Bear in mind that nothing will stop you from assigning zero or multiple goals, +but it's not clear how that will affect the game. + +Usage +----- + +:: + + assign-goals [--unit ] + +Example +------- + +:: + + assign-goals --reset --goals [ MASTER_A_SKILL false ] + +Clears all the selected unit goals, then sets the "master a skill" goal. The +final result will be: "dreams of mastering a skill". + +Options +------- + +``--unit `` + The target unit ID. If not present, the currently selected unit will be the + target. +``--goals [ false [ false ...] ]`` + The list of goals to add. The valid goal tokens can be found in the + :wiki:`Personality_trait` (substitute any space with underscores). There + must be a space before and after each square bracket. +``--reset`` + Clear all goals. If both this option and ``--goals`` are specified, the + unit's goals will be cleared and then the listed goals will be added. diff --git a/docs/assign-minecarts.rst b/docs/assign-minecarts.rst new file mode 100644 index 0000000000..6512e4f79d --- /dev/null +++ b/docs/assign-minecarts.rst @@ -0,0 +1,32 @@ +assign-minecarts +================ + +.. dfhack-tool:: + :summary: Assign minecarts to hauling routes. + :tags: fort productivity + +This script allows you to quickly assign minecarts to hauling routes without +having to go through the in-game interface. + +Note that a hauling route must have at least one stop defined before a minecart +can be assigned to it. + +Usage +----- + +``assign-minecarts list`` + Print information about your hauling routes, including whether they + currently have minecarts assigned to them. +``assign-minecarts all| [-q|--quiet]`` + Find and assign a free minecart to all hauling routes (or the specified + hauling route). Hauling routes that already have a minecart assigned to them + are skipped. + +Add ``-q`` or ``--quiet`` to suppress extra informational output. + +Example +------- + +:: + + assign-minecarts all diff --git a/docs/assign-preferences.rst b/docs/assign-preferences.rst new file mode 100644 index 0000000000..ee77a22757 --- /dev/null +++ b/docs/assign-preferences.rst @@ -0,0 +1,125 @@ +assign-preferences +================== + +.. dfhack-tool:: + :summary: View or adjust a unit's preferences. + :tags: fort armok units + +You will need to know the token of the object you want your dwarf to like. +You can find them in the wiki, otherwise in the folder "/raw/objects/" under +the main DF directory you will find all the raws defined in the game. + +For more information, please see the :wiki:`wiki `. + +Note the last three types of preferences ("like poetic form", "like musical +form", and "like dance form") are not supported by this script. + +Usage +----- + +:: + + assign-goals [--unit ] + +Examples +-------- + +* "likes alabaster and willow wood":: + + assign-preferences --reset --likematerial [ INORGANIC:ALABASTER PLANT:WILLOW:WOOD ] + +* "likes sparrows for their ...":: + + assign-preferences --reset --likecreature SPARROW + +* "prefers to consume dwarven wine, olives and yak":: + + assign-preferences --reset --likefood [ PLANT:MUSHROOM_HELMET_PLUMP:DRINK PLANT:OLIVE:FRUIT CREATURE_MAT:YAK:MUSCLE ] + +* "absolutely detests jumping spiders:: + + assign-preferences --reset --hatecreature SPIDER_JUMPING + +* "likes logs and battle axes":: + + assign-preferences --reset --likeitem [ WOOD ITEM_WEAPON:ITEM_WEAPON_AXE_BATTLE ] + +* "likes strawberry plants for their ...":: + + assign-preferences --reset --likeplant BERRIES_STRAW + +* "likes oaks for their ...":: + + assign-preferences --reset --liketree OAK + +* "likes the color aqua":: + + assign-preferences --reset --likecolor AQUA + +* "likes stars":: + + assign-preferences --reset --likeshape STAR + +Options +------- + +For each of the parameters that take lists of tokens, if there is a space in the +token name, please replace it with an underscore. Also, there must be a space +before and after each square bracket. If only one value is provided, the square +brackets can be omitted. + +``--unit `` + The target unit ID. If not present, the currently selected unit will be the + target. +``--show`` + Print the list of current likes/dislikes for the selected unit +``--likematerial [ [ ...] ]`` + This is usually set to three tokens: a type of stone, a type of metal, and a + type of gem. It can also be a type of wood, glass, leather, horn, pearl, + ivory, a decoration material - coral or amber, bone, shell, silk, yarn, or + cloth. Please include the full tokens, not just a part. +``--likecreature [ [ ...] ]`` + For this preference, you can just list the species as the token. For + example, a creature token can be something like ``CREATURE:SPARROW:SKIN``. + Here, you can just say ``SPARROW``. +``--likefood [ [ ...] ]`` + This usually contains at least a type of alcohol. It can also be a type of + meat, fish, cheese, edible plant, cookable plant/creature extract, cookable + mill powder, cookable plant seed, or cookable plant leaf. Please write the + full tokens. +``--hatecreature [ [ ...] ]`` + As in ``--likecreature`` above, you can just list the species in the token. + The creature should be a type of ``HATEABLE`` vermin which isn't already + explicitly liked, but no check is performed to enforce this. +``--likeitem [ [ ...] ]`` + This can be a kind of weapon, a kind of ammo, a piece of armor, a piece of + clothing (including backpacks or quivers), a type of furniture (doors, + floodgates, beds, chairs, windows, cages, barrels, tables, coffins, statues, + boxes, armor stands, weapon racks, cabinets, bins, hatch covers, grates, + querns, millstones, traction benches, or slabs), a kind of craft (figurines, + amulets, scepters, crowns, rings, earrings, bracelets, or large gems), or a + kind of miscellaneous item (catapult parts, ballista parts, a type of siege + ammo, a trap component, coins, anvils, totems, chains, flasks, goblets, + buckets, animal traps, an instrument, a toy, splints, crutches, or a tool). + The item tokens can be found here: + https://dwarffortresswiki.org/index.php/DF2014:Item_token + If you want to specify an item subtype, look into the files listed under the + column "Subtype" of the wiki page (they are in the "/raw/objects/" folder), + then specify the items using the full tokens found in those files (see + examples in this help). +``--likeplant [ [ ...] ]`` + As in ``--likecreature`` above, you can just list the tree or plant species + in the token. +``--likecolor [ [ ...] ]`` + You can find the color tokens here: + https://dwarffortresswiki.org/index.php/DF2014:Color#Color_tokens + or inside the "descriptor_color_standard.txt" file (in the "/raw/objects/" + folder). You can use the full token or just the color name. +``--likeshape [ [ ...] ]`` + I couldn't find a list of shape tokens in the wiki, but you can find them + inside the "descriptor_shape_standard.txt" file (in the "/raw/objects/" + folder). You can use the full token or just the shape name. +``--reset`` + Clear all preferences. If the script is called with both this option and one + or more preferences, first all the unit preferences will be cleared and then + the listed preferences will be added. diff --git a/docs/assign-profile.rst b/docs/assign-profile.rst new file mode 100644 index 0000000000..d7092360bf --- /dev/null +++ b/docs/assign-profile.rst @@ -0,0 +1,65 @@ +assign-profile +============== + +.. dfhack-tool:: + :summary: Adjust characteristics of a unit according to saved profiles. + :tags: unavailable + +This tool can load a profile stored in a JSON file and apply the +characteristics to a unit. + +A profile can describe which attributes, skills, preferences, beliefs, goals, +and facets a unit will have. The script relies on the presence of the other +``assign-...`` modules in this collection; please refer to the other modules' +documentation for more information on the kinds of characteristics you can set. + +See the "hack/scripts/dwarf_profiles.json" file for examples of the json schema. +Please make a new file with your own additions, though, since the example file +will get overwritten when you upgrade DFHack. + +Usage +----- + +:: + + assign-profile [--unit ] + +Examples +-------- + +* Loads and applies the profile called "DOCTOR" in the default json file, + resetting the characteristics that are changed by the profile:: + + assign-profile --profile DOCTOR --reset PROFILE + +* Loads and applies a profile called "ARCHER" in a file you (the player) wrote. + It keeps all the old characteristics except the attributes and the skills, + which will be reset (and then, if the profile provides some attributes or + skills values, those new values will be applied):: + + assign-profile --file /dfhack-config/assign-profile/military_profiles.json --profile ARCHER --reset [ ATTRIBUTES SKILLS ] + +Options +------- + +``--unit `` + The target unit ID. If not present, the currently selected unit will be the + target. +``--file `` + The json file containing the profile to apply. It's a relative path, + starting from the DF root directory and ending at the json file. It must + begin with a slash. Default value: "/hack/scripts/dwarf_profiles.json". +``--profile `` + The profile inside the json file to apply. +``--reset [ ]`` + The characteristics to be reset/cleared. If not present, it will not clear + or reset any characteristic, and it will simply add what is described in the + profile. If it's a valid list of characteristics, those characteristics will + be reset, and then what is described in the profile will be applied. If set + to the string ``PROFILE``, it will reset only the characteristics directly + modified by the profile (and then the new values described will be applied). + If set to ``ALL``, it will reset EVERY characteristic and then it will apply + the profile. Accepted values are: ``ALL``, ``PROFILE``, ``ATTRIBUTES``, + ``SKILLS``, ``PREFERENCES``, ``BELIEFS``, ``GOALS``, and ``FACETS``. There + must be a space before and after each square bracket. If only one value is + provided, the square brackets can be omitted. diff --git a/docs/assign-skills.rst b/docs/assign-skills.rst new file mode 100644 index 0000000000..ea957a7b96 --- /dev/null +++ b/docs/assign-skills.rst @@ -0,0 +1,72 @@ +assign-skills +============= + +.. dfhack-tool:: + :summary: Adjust a unit's skills. + :tags: fort armok units + +Skills are defined by their token and their rank. You can see a list of valid +skill tokens by running ``devel/query --table df.job_skill`` or by browsing the +:wiki:`wiki `. + +Usage +----- + +:: + + assign-skills [--unit ] + +Example +------- + +:: + + assign-skills --reset --skills [ WOODCUTTING 3 AXE 2 ] + +Clears all the unit skills, then adds the Wood cutter skill (competent level) +and the Axeman skill (adequate level). + +Options +------- + +``--unit `` + The target unit ID. If not present, the currently selected unit will be the + target. +``--skills [ [ ...] ]`` + The list of the skills to modify and their ranks. Rank values range from -1 + (the skill is not learned) to 20 (legendary + 5). It is actually possible to + go beyond 20 (no check is performed), but the effect on the game may not be + predictable. There must be a space before and after each square bracket. +``--reset`` + Clear all skills. If the script is called with both this option and + ``--skills``, first all the unit skills will be cleared and then the listed + skills will be added. + +Skill ranks +----------- + +Here is the mapping from rank value to description: + +==== ================ +Rank Rank description +==== ================ +0 Dabbling +1 Novice +2 Adequate +3 Competent +4 Skilled +5 Proficient +6 Talented +7 Adept +8 Expert +9 Professional +10 Accomplished +11 Great +12 Master +13 High Master +14 Grand Master +15+ Legendary +==== ================ + +For more information, please see: +https://dwarffortresswiki.org/index.php/DF2014:Skill#Skill_level_names diff --git a/docs/autocheese.rst b/docs/autocheese.rst new file mode 100644 index 0000000000..fb92874df5 --- /dev/null +++ b/docs/autocheese.rst @@ -0,0 +1,39 @@ +autocheese +========== + +.. dfhack-tool:: + :summary: Schedule cheese making jobs based on milk reserves. + :tags: fort auto + +Cheese making is difficult to automate using work orders. A single job +can consume anything from a bucket with a single unit of milk to a barrel +with 100 units of milk. This makes it hard to predict how much cheese will +actually be produced by an automated order. + +The script will scan your fort for barrels with a certain minimum amount of milk +(default: 50), create a cheese making job specifically for that barrel, and +assign this job to one of your idle dwarves (giving preference to skilled cheese +makers). + +When enabled using `gui/control-panel`, the script will run automatically, with +default options, twice a month. + +Usage +----- + +:: + + autocheese [] + +Examples +-------- + +``autocheese -m 100`` + Only create a job if there is a barrel that is filled to the maximum. + +Options +------- + +``-m``, ``--min-milk`` + Set the minimum number of milk items in a barrel for the barrel to be + considered for cheese making. diff --git a/docs/autofish.rst b/docs/autofish.rst new file mode 100644 index 0000000000..97c9f71ecf --- /dev/null +++ b/docs/autofish.rst @@ -0,0 +1,52 @@ +autofish +======== + +.. dfhack-tool:: + :summary: Auto-manage fishing labors to control your stock of fish. + :tags: fort auto labors + +This script makes managing how much fish you keep around automatic. It tries to +maintain a configured stock level of raw and/or prepared fish, partially to keep +item quantities from ballooning out of control, and partly to try and prevent +collecting too many rotten fish. + +Usage +----- + +``enable autofish`` + Enable the script +``disable autofish`` + Disable the script +``autofish status`` + Show the current status of the script, your configured values, and whether + or not fishing is currently enabled. +``autofish [min] []`` + Change autofish settings. + +Positional Parameters +--------------------- + +``max`` + (default: 100) controls the maximum amount of fish you want to keep on hand + in your fortress. Fishing will be disabled when the amount of fish goes + above this value. + +``min`` + (default: 75) controls the minimum fish you want before restarting fishing. + +Options +------- + +``r``, ``--raw (true | false)`` + (default: ``true``) Set whether or not raw fish should be counted in the running + total of fish in your fortress. + +Examples +-------- + +``enable autofish`` + Enables the script. +``autofish 150 -r true`` + Sets your maximum fish to 150, and enables counting raw fish. +``autofish 300 250`` + Sets your maximum fish to 300 and minimum to 250. diff --git a/docs/autolabor-artisans.rst b/docs/autolabor-artisans.rst new file mode 100644 index 0000000000..9c7b248b5b --- /dev/null +++ b/docs/autolabor-artisans.rst @@ -0,0 +1,26 @@ +autolabor-artisans +================== + +.. dfhack-tool:: + :summary: Configures autolabor to produce artisan dwarves. + :tags: unavailable + +This script runs an `autolabor` command for all labors where skill level +influences output quality (e.g. Carpentry, Stone detailing, Weaponsmithing, +etc.). It automatically enables autolabor if it is not already enabled. + +After running this tool, you can make further adjustments to autolabor +configuration by running autolabor commands directly. + +Usage +----- + +:: + + autolabor-artisans + +Examples: + +``autolabor-artisans 0 2 3`` + Only allows a maximum of 2 dwarves to have skill-dependent labors enabled + at once, chosen from the talent pool of the top 3 dwarves for that skill. diff --git a/docs/autonick.rst b/docs/autonick.rst new file mode 100644 index 0000000000..7a9d1302df --- /dev/null +++ b/docs/autonick.rst @@ -0,0 +1,43 @@ +autonick +======== + +.. dfhack-tool:: + :summary: Give dwarves random unique nicknames. + :tags: fort productivity units + +Names are chosen randomly from the ``dfhack-config/autonick.txt`` config file, +which you can edit with your own preferred names, if you like. + +Dwarves who already have nicknames will keep the nicknames they have, and no +other dwarf will be assigned that nickname. + +If there are fewer available nicknames than dwarves, the remaining +dwarves will go un-nicknamed. + +Usage +----- + +:: + + autonick all [] + +You may wish to use this script with the "repeat" command so that new migrants +automatically get nicknamed:: + + repeat -name autonick -time 3 -timeUnits months -command [ autonick all ] + +Options +------- + +``-q``, ``--quiet`` + Do not report how many dwarves were given nicknames. + +Config file format +------------------ + +The ``dfhack-config/autonick.txt`` config file has a simple format: + +- One nickname per line +- Empty lines, lines beginning with ``#``, and repeat entries are discarded + +You can add any nicknames you like! diff --git a/docs/autotraining.rst b/docs/autotraining.rst new file mode 100644 index 0000000000..360f4d08e5 --- /dev/null +++ b/docs/autotraining.rst @@ -0,0 +1,41 @@ +autotraining +============ + +.. dfhack-tool:: + :summary: Assigns citizens to a military squad until they have fulfilled their need for Martial Training + :tags: fort auto bugfix units + +This script automatically assigns citizens with the need for military training to designated training squads. + +You need to have at least one squad that is set up for training. The squad should be set to "Constant Training" in the military screen. The squad doesn't need months off. The members leave the squad once they have satisfied their need for military training. + +The configured uniform determines the skills that are acquired by the training dwarves. Providing "No Uniform" is a perfectly valid choice and will make your militarily inclined civilians become wrestlers over time. However, you can also provide weapons and armor to pre-train civilians for future drafts. + +Once you have made squads for training use `gui/autotraining` to select the squads and ignored units, as well as the needs threshhold. + +Usage +----- + + ``autotraining []`` + +Examples +-------- + +``autotraining`` + Current status of script + +``enable autotraining`` + Checks to see if you have fullfilled the creation of a training squad. + If there is no squad marked for training use, a clickable notification will appear letting you know to set one up/ + Searches your fort for dwarves with a need for military training, and begins assigning them to a training squad. + Once they have fulfilled their need they will be removed from their squad to be replaced by the next dwarf in the list. + +``disable autotraining`` + Stops adding new units to the squad. + +Options +------- + ``-t`` + Use integer values. (Default 5000) + The negative need threshhold to trigger for each citizen + The greater the number the longer before a dwarf is added to the waiting list. diff --git a/docs/ban-cooking.rst b/docs/ban-cooking.rst new file mode 100644 index 0000000000..38d2ba2b1f --- /dev/null +++ b/docs/ban-cooking.rst @@ -0,0 +1,72 @@ +ban-cooking +=========== + +.. dfhack-tool:: + :summary: Protect useful items from being cooked. + :tags: fort productivity items plants + +Some cookable ingredients have other important uses. For example, seeds can be +cooked, but if you cook them all, then your farmers will have nothing to plant +in the fields. Similarly, booze can be cooked, but if you do that, then your +dwarves will have nothing (good) to drink. + +If you open the Kitchen screen, you can select individual item types and choose +to ban them from cooking. To prevent all your booze from being cooked, for +example, you'd filter by "Drinks" and then click each of the visible types of +booze to prevent them from being cooked. Only types that you have in stock are +shown, so if you acquire a different type of booze in the future, you have to +come back to this screen and ban the new types. + +Instead of doing all that clicking, ``ban-cooking`` can ban entire classes of +items (e.g. all types of booze) in one go. It can even ban types that you don't +have in stock yet, so when you *do* get some in stock, they will already be +banned. It will never ban items that are only good for eating or cooking, like +meat or non-plantable nuts. It is usually a good idea to run +``ban-cooking all`` as one of your first actions in a new fort. You can add +this command to your Autostart list in `gui/control-panel`. + +If you want to re-enable cooking for a banned item type, you can go to the +Kitchen screen and un-ban whatever you like by clicking on the "cook" +icon. You can also un-ban an entire class of items with the ``--unban`` option. + +Usage +----- + +:: + + ban-cooking [ ...] [] + +Valid types are: + +- ``booze`` +- ``brew`` (brewable plants) +- ``fruit`` +- ``honey`` +- ``milk`` +- ``mill`` (millable plants) +- ``oil`` +- ``seeds`` (plantable seeds and items producing seeds) +- ``tallow`` +- ``thread`` + +Note that in the vanilla game, there are no items that can be milled or turned +into thread that can also be cooked, so these types are only useful when using +mods that add such items to the game. + +Examples +-------- + +``ban-cooking oil tallow`` + Ban all types of oil and tallow from cooking. +``ban-cooking all`` + Ban all otherwise useful types of foods from being cooked. This command can + be enabled for Autostart in `gui/control-panel`. + +Options +------- + +``-u``, ``--unban`` + Un-ban the indicated item types. + +``-v``, ``--verbose`` + Print each ban as it happens. diff --git a/docs/binpatch.rst b/docs/binpatch.rst new file mode 100644 index 0000000000..c5da27f4b8 --- /dev/null +++ b/docs/binpatch.rst @@ -0,0 +1,15 @@ +binpatch +======== + +.. dfhack-tool:: + :summary: Applies or removes binary patches. + :tags: unavailable + +See `binpatches` for more info. + +Usage +----- + +:: + + binpatch check|apply|remove diff --git a/docs/bodyswap.rst b/docs/bodyswap.rst new file mode 100644 index 0000000000..772c882de0 --- /dev/null +++ b/docs/bodyswap.rst @@ -0,0 +1,28 @@ +bodyswap +======== + +.. dfhack-tool:: + :summary: Take direct control of any visible unit. + :tags: adventure armok units + +This script allows the player to take direct control of any unit present in +adventure mode whilst giving up control of their current player character. + +Usage +----- + +:: + + bodyswap [--unit ] + +If no specific unit id is specified, the target unit is the one selected in the +user interface, such as by opening the unit's status screen or viewing its +description. + +Examples +-------- + +``bodyswap`` + Takes control of the selected unit. +``bodyswap --unit 42`` + Takes control of unit with id 42. diff --git a/docs/brainwash.rst b/docs/brainwash.rst new file mode 100644 index 0000000000..9c1eef4ed1 --- /dev/null +++ b/docs/brainwash.rst @@ -0,0 +1,32 @@ +brainwash +========= + +.. dfhack-tool:: + :summary: Set the personality of a dwarf to an ideal. + :tags: fort armok units + +Modify the traits of the selected dwarf to match an idealized personality: as +stable and reliable as possible to prevent tantrums even after months of misery. + +Usage +----- + +:: + + brainwash + +Examples +-------- + +``brainwash ideal`` + Sets the dwarf to have an ideal, stable personality + +Types +----- + +The type is one of the following: + +:ideal: Reliable, with generally positive personality traits +:baseline: Reset all personality traits to the average +:stepford: Amplifies all good qualities to an excessive degree +:wrecked: Amplifies all bad qualities to an excessive degree diff --git a/docs/break-dance.rst b/docs/break-dance.rst new file mode 100644 index 0000000000..048fa1a85f --- /dev/null +++ b/docs/break-dance.rst @@ -0,0 +1,16 @@ +break-dance +=========== + +.. dfhack-tool:: + :summary: Fixes buggy tavern dances. + :tags: unavailable + +Sometimes when a unit can't find a dance partner, the dance becomes stuck and +never stops. This tool can get them unstuck. + +Usage +----- + +:: + + break-dance diff --git a/docs/build-now.rst b/docs/build-now.rst new file mode 100644 index 0000000000..63bdb6a3e9 --- /dev/null +++ b/docs/build-now.rst @@ -0,0 +1,44 @@ +build-now +========= + +.. dfhack-tool:: + :summary: Instantly completes building construction jobs. + :tags: fort armok buildings + +By default, all unsuspended buildings on the map are completed, but the area of +effect is configurable. + +Note that no units will get architecture experience for any buildings that +require that skill to construct. + +Usage +----- + +:: + + build-now [ []] [] + +Where the optional ```` pair can be used to specify the coordinate bounds +within which ``build-now`` will operate. If they are not specified, +``build-now`` will scan the entire map. If only one ```` is specified, only +the building at that coordinate is built. + +The ```` parameters can either be an ``,,`` triple (e.g. +``35,12,150``) or the string ``here``, which means the position of the active +keyboard game cursor. + +Examples +-------- + +``build-now`` + Completes all unsuspended construction jobs on the map. +``build-now here`` + Builds the unsuspended, unconstructed building under the cursor. + +Options +------- + +``-q``, ``--quiet`` + Suppress informational output (error messages are still printed). +``-z``, ``--zlevel`` + Restrict operation to the currently visible z-level diff --git a/docs/burial.rst b/docs/burial.rst new file mode 100644 index 0000000000..1923384572 --- /dev/null +++ b/docs/burial.rst @@ -0,0 +1,45 @@ +burial +====== + +.. dfhack-tool:: + :summary: Create tomb zones for unzoned coffins. + :tags: fort productivity buildings + +Creates a 1x1 tomb zone for each built coffin that isn't already contained in a +zone. + +Usage +----- + + ``burial []`` + +Examples +-------- + +``burial`` + Create a general use tomb for every unzoned coffin on the map. + +``burial -z`` + Create tombs only on the current zlevel. + +``burial -c`` + Create tombs designated for burial of citizens only. + +``burial -p`` + Create tombs designated for burial of pets only. + +``burial -cp`` + Create tombs with automatic burial disabled for both citizens and pets, + requiring manual assignment of deceased units to each tomb. + +Options +------- + +``-z``, ``--cur-zlevel`` + Only create tombs on the current zlevel. + +``-c``, ``--citizens-only`` + Only automatically bury citizens. + +``-p``, ``--pets-only`` + Only automatically bury pets. diff --git a/docs/cannibalism.rst b/docs/cannibalism.rst new file mode 100644 index 0000000000..acb9d34237 --- /dev/null +++ b/docs/cannibalism.rst @@ -0,0 +1,17 @@ +cannibalism +=========== + +.. dfhack-tool:: + :summary: Allows an adventurer to consume sapient corpses. + :tags: unavailable + +This tool clears the flag from items that mark them as being from a sapient +creature. Use from an adventurer's inventory screen or an individual item's +detail screen. + +Usage +----- + +:: + + cannibalism diff --git a/docs/caravan.rst b/docs/caravan.rst new file mode 100644 index 0000000000..17cb2ed071 --- /dev/null +++ b/docs/caravan.rst @@ -0,0 +1,164 @@ +caravan +======= + +.. dfhack-tool:: + :summary: Adjust properties of caravans on the map. + :tags: fort armok bugfix + +This tool can help with caravans that are leaving too quickly, refuse to unload, +or are just plain unhappy that you are such a poor negotiator. + +Also see `force` for creating caravans. + +Usage +----- + +:: + + caravan [list] + caravan extend [ []] + caravan happy [] + caravan leave [] + caravan unload + +Commands listed with the argument ``[]`` can take multiple +(space-separated) caravan IDs (see ``caravan list`` to get the IDs). If no IDs +are specified, then the commands apply to all caravans on the map. + +Examples +-------- + +``caravan`` + List IDs and information about all caravans on the map. +``caravan extend`` + Force a caravan that is leaving to return to the depot and extend their + stay another 7 days. +``caravan extend 30 0 1`` + Extend the time that caravans 0 and 1 stay at the depot by 30 days. If the + caravans have already started leaving, they will return to the depot. +``caravan happy`` + Make the active caravans willing to trade again (after seizing goods, + annoying merchants, etc.). If the caravan has already started leaving in a + huff, they will return to the depot. +``caravan leave`` + Makes caravans pack up and leave immediately. +``caravan unload`` + Fix a caravan that got spooked by wildlife and refuses to fully unload. + +Overlays +-------- + +Additional functionality is provided on the various trade-related screens via +`overlay` widgets. You can turn the overlays on and off in `gui/control-panel`, +or you can reposition them to your liking with `gui/overlay`. + +Bring item to depot +``````````````````` + +**caravan.movegoods** + +When the trade depot is selected, a button appears to bring up the DFHack +enhanced move trade goods screen. You'll get a searchable, sortable list of all +your tradeable items, with hotkeys to quickly select or deselect all visible +items. + +There are filter sliders for selecting items of various condition levels and +quality. For example, you can quickly trade all your tattered, frayed, and worn +clothing by setting the condition slider to include from tattered to worn, then +hitting ``Ctrl-a`` to select all. + +Click on an item and shift-click on a second item to toggle all items between +the two that you clicked on. If the one that you shift-clicked on was selected, +the range of items will be deselected. If the one you shift-clicked on was not +selected, then the range of items will be selected. + +If any current merchants have ethical concerns, the list of goods that you can +bring to the depot is automatically filtered (by default) to only show +ethically acceptable items. Be aware that, again, by default, if you have items +in bins, and there are unethical items mixed into the bins, then the bins will +still be brought to the depot so you can trade the ethical items within those +bins. Please use the DFHack enhanced trade screen for the actual barter to +ensure the unethical items are not actually selected for sale. + +**caravan.movegoods_hider** + +This overlay simply hides the vanilla "Move trade goods" button, so if you +routinely prefer to use the enhanced DFHack "Move goods" dialog, you won't +accidentally click the vanilla button. + +**caravan.assigntrade** + +This overlay provides a button on the vanilla "Move trade goods" screen to +launch the DFHack enhanced dialog. + +Trade screen +```````````` + +**caravan.trade** + +This overlay enables some convenient gestures and keyboard shortcuts for working +with bins: + +- ``Shift-Click checkbox``: Select all items inside a bin without selecting the + bin itself +- ``Ctrl-Click checkbox``: Collapse or expand a single bin +- ``Ctrl-Shift-Click checkbox``: Select all items within the bin and collapse it +- ``Ctrl-c``: Collapse all bins +- ``Ctrl-x``: Collapse everything (all item categories and anything + collapsible within each category) + +There is also a reminder of the fast scroll functionality provided by the +vanilla game when you hold shift while scrolling (this works everywhere). + +**caravan.tradebanner** + +This overlay provides a button you can click to bring up the DFHack enhanced +trade dialog, which you can use to quickly search, filter, and select caravan +and fort goods for trade. + +For example, to select all steel items for purchase, search for ``steel`` and +hit ``Ctrl-a`` (or click the "Select all" button) to select them all. + +By default, the DFHack trade dialog will automatically filter out items that +the merchants you are trading with find ethically offensive. + +You can also bring up the DFHack trade dialog with the keyboard shortcut +``Ctrl-t``. + +**caravan.tradeethics** + +This overlay shows an "Ethics warning" badge next to the ``Trade`` button when +you have any items selected for sale that would offend the merchants that you +are trading with. Clicking on the badge will show a list of problematic items, +and you can click the button on the dialog to deselect all the problematic +items in your trade list. + +Trade agreements +```````````````` + +**caravan.tradeagreement** + +This adds a small panel with some useful shortcuts: + +* ``Ctrl-a`` for selecting all/none in the currently shown category. +* ``Ctrl-m`` for selecting items with specific base material price (only + enabled for item categories where this matters, like gems and leather). + +Display furniture +````````````````` + +**caravan.displayitemselector** + +A button is added to the screen when you are viewing display furniture +(pedestals and display cases) where you can launch a the extended DFhack item +assignment GUI. + +The dialog allows you to sort by name, value, or where the item is currently +assigned for display. + +You can search by name, and you can filter by: + +- item quality +- whether the item is forbidden +- whether the item is reachable from the display furniture +- whether the item is a written work (book or scroll) diff --git a/docs/catsplosion.rst b/docs/catsplosion.rst new file mode 100644 index 0000000000..a5c86b2c1a --- /dev/null +++ b/docs/catsplosion.rst @@ -0,0 +1,30 @@ +catsplosion +=========== + +.. dfhack-tool:: + :summary: Cause pregnancies. + :tags: fort armok animals + +This tool makes cats (or anything else) immediately pregnant. If you value your +fps, it is a good idea to use this tool sparingly. Only adult females of the +chosen race(s) will become pregnant. + +Usage +----- + +``catsplosion [ ...]`` + Makes animals with the given identifiers pregnant. Defaults to ``CAT``. +``catsplosion list`` + List IDs of all animals on the map. + +Units will give birth within two in-game hours (100 ticks or fewer). + +Examples +-------- + +``catsplosion`` + Make all cats pregnant. +``catsplosion PIG SHEEP ALPACA`` + Get some quick butcherable meat. +``catsplosion DWARF`` + Have a population boom in your fort. diff --git a/docs/clear-smoke.rst b/docs/clear-smoke.rst new file mode 100644 index 0000000000..403efd8ed3 --- /dev/null +++ b/docs/clear-smoke.rst @@ -0,0 +1,14 @@ +clear-smoke +=========== + +.. dfhack-tool:: + :summary: Removes all smoke from the map. + :tags: fort armok fps map + + +Usage +----- + +:: + + clear-smoke diff --git a/docs/clear-webs.rst b/docs/clear-webs.rst new file mode 100644 index 0000000000..00782eb3ba --- /dev/null +++ b/docs/clear-webs.rst @@ -0,0 +1,34 @@ +clear-webs +========== + +.. dfhack-tool:: + :summary: Removes all webs from the map. + :tags: adventure fort armok map units + +In addition to removing webs, this tool also frees any creatures who have been +caught in one. Usable in both fortress and adventurer mode. + +Note that it does not affect sprayed webs until they settle on the ground. + +See also `fix/drop-webs`. + +Usage +----- + +:: + + clear-webs [--unitsOnly|--websOnly] + +Examples +-------- + +``clear-webs`` + Remove all webs and free all webbed units. + +Options +------- + +``--unitsOnly`` + Free all units from webs without actually removing any webs +``--websOnly`` + Remove all webs without freeing any units. diff --git a/docs/colonies.rst b/docs/colonies.rst new file mode 100644 index 0000000000..cebdc69db6 --- /dev/null +++ b/docs/colonies.rst @@ -0,0 +1,29 @@ +colonies +======== + +.. dfhack-tool:: + :summary: Manipulate vermin colonies and hives. + :tags: adventure fort armok map + +Usage +----- + +``colonies`` + List all vermin colonies on the map. +``colonies place []`` + Place a colony under the cursor. +``colonies convert []`` + Convert all existing colonies to the specified type. + +The ``place`` and ``convert`` subcommands create or convert to honey bees by +default. + +Examples +-------- + +``colonies place`` + Place a honey bee colony. +``colonies place ANT`` + Place an ant hive. +``colonies convert TERMITE`` + End your beekeeping industry by converting all colonies to termite mounds. diff --git a/docs/color-schemes.rst b/docs/color-schemes.rst new file mode 100644 index 0000000000..07445b20fd --- /dev/null +++ b/docs/color-schemes.rst @@ -0,0 +1,73 @@ +color-schemes +============= + +.. dfhack-tool:: + :summary: Modify the colors used by the DF UI. + :tags: unavailable + +This tool allows you to set exactly which shades of colors should be used in the +DF interface color palette. + +To set up the colors, you must first create at least one file with color +definitions inside. These files must be in the same format as +:file:`data/init/colors.txt` and contain RGB values for each of the color names. +Just copy :file:`colors.txt` and edit the values for your custom color schemes. + +If you are interested in alternate color schemes, also see: + +- `gui/color-schemes`: the in-game GUI for this script +- `season-palette`: automatically swaps color schemes when the season changes + +Usage +----- + +``color-schemes register [-f] [-q]`` + Register the directory (relative to the main DF game directory) where your + color scheme files are stored. +``color-schemes list`` + List the color schemes from the registered directories. +``color-schemes default set [-q]`` + Set the named color scheme as the default. This value is stored so you only + have to set it once, even if you start a new adventure/fort. +``color-schemes default load [-q]`` + Load the default color scheme that you previously set with ``default set``. +``color-schemes load [-q]`` + Load the named color scheme. + +Examples +-------- + +Read your color scheme files from the :file:`colorschemes` directory (a +directory you created and populated with color scheme files) and set the +default to the scheme named ``mydefault``:: + + color-schemes register colorschemes + color-schemes default set mydefault + +Read your color scheme files from the :file:`colorschemes` directory (a +directory you created and populated with color scheme files) and load the saved +default. If you have a color scheme that you always want loaded, put these +commands in your :file:`dfhack-config/init/dfhack.init` file:: + + color-schemes -q register colorschemes + color-schemes default load + +Options +------- + +``-f``, ``--force`` + Register and read color schemes that are incomplete or are syntactically + incorrect. +``-q``, ``--quiet`` + Don't print any informational output. + +API +--- + +When loaded as a module, this script will export the following functions: + +- ``register(path, force)`` : Register colors schemes by path (file or directory), relative to DF main directory +- ``load(name)`` : Load a registered color scheme by name +- ``list()`` : Return a list of registered color schemes +- ``set_default(name)`` : Set the default color scheme +- ``load_default()`` : Load the default color scheme diff --git a/docs/combat-harden.rst b/docs/combat-harden.rst new file mode 100644 index 0000000000..78e01e9c68 --- /dev/null +++ b/docs/combat-harden.rst @@ -0,0 +1,51 @@ +combat-harden +============= + +.. dfhack-tool:: + :summary: Set the combat-hardened value on a unit. + :tags: fort armok units + +This tool can make a unit care more/less about seeing corpses. + +Usage +----- + +:: + + combat-harden [] [] + +Examples +-------- + +``combat-harden`` + Make the currently selected unit fully combat hardened +``combat-harden --citizens --tier 2`` + Make all fort citizens moderately combat hardened. + +Unit options +------------ + +``--all`` + All active units will be affected. +``--citizens`` + All citizens and residents of your fort will be affected. Will do nothing + in adventure mode. +``--unit `` + The given unit will be affected. + +If no option is given or the indicated unit can't be found, the script will use +the currently selected unit. + +Hardness options +---------------- + +``--value `` + A percent value (0 to 100, inclusive) to set combat hardened to. +``--tier `` + Choose a tier of hardenedness to set it to. + - 1 = No hardenedness. + - 2 = "is getting used to tragedy" + - 3 = "is a hardened individual" + - 4 = "doesn't really care about anything anymore" (max) + +If no option is given, the script defaults to using a value of 100. diff --git a/docs/combine.rst b/docs/combine.rst new file mode 100644 index 0000000000..7dea937672 --- /dev/null +++ b/docs/combine.rst @@ -0,0 +1,91 @@ +combine +======= + +.. dfhack-tool:: + :summary: Combine items that can be stacked together. + :tags: fort productivity items plants stockpiles + +This handy tool "defragments" your items without giving your fort the undue +advantage of unreasonably large stacks. Within each stockpile, similar items +will be combined into fewer, larger stacks for more compact and +easier-to-manage storage. Items outside of stockpiles will not be combined, and +items in separate stockpiles will not be combined together. + +Usage +----- + +:: + + combine (all|here) [] + +Examples +-------- + +``combine`` + Displays help +``combine all --dry-run`` + Preview what will be combined for all types in all stockpiles. +``combine all`` + Merge stacks in all stockpile for all types +``combine all --types=meat,plant`` + Merge ``meat`` and ``plant`` type stacks in all stockpiles. +``combine here`` + Merge stacks in the selected stockpile. + +Commands +-------- + +``all`` + Search all stockpiles. +``here`` + Search the currently selected stockpile, or the stockpile that the + currently-seelected item is in, or the stockpile that the currently- + displayed item-list is in. + +Options +------- + +``-d``, ``--dry-run`` + Display what would be combined instead of actually combining items. + +``-t``, ``--types `` + Specify which item types should be combined. Default is ``all``. Valid + types are: + + :all: all of the types listed here. + :ammo: stacks of ammunition. Qty max 25. + :drink: stacks of drinks in barrels/pots. Qty max 25. + :fat: cheese, fat, tallow, and other globs. Qty max 5. + :fish: raw and prepared fish. this category also includes all types of + eggs. Qty max 5. + :food: prepared food. Qty max 20. + :meat: meat. Qty max 5. + :parts: corpse pieces. Material max 30. + :plant: plant and plant growths. Qty max 5. + :powders: dye and other non-sand, non-plaster powders. Qty max 10. + +``-q``, ``--quiet`` + Don't print the final item distribution summary. + +``-v``, ``--verbose n`` + Print verbose output for debugging purposes, n from 1 to 4. + +Notes +----- + +The following conditions prevent an item from being combined: + +1. it is not in a stockpile. +2. it is sand or plaster. +3. it is rotten, forbidden, marked for dumping/melting, on fire, encased, owned + by a trader/hostile/dwarf or is in a spider web. +4. it is part of a corpse and has not been butchered. + +Moreover, if a stack is in a container associated with a stockpile, the stack +will not be able to grow past the volume limit of the container. + +An item can be combined with other similar items if it: + +1. has an associated race/caste and is of the same item type, race, and caste +2. has the same type, material, and quality. If it is a masterwork, it is also + grouped by who created it. diff --git a/docs/confirm.rst b/docs/confirm.rst new file mode 100644 index 0000000000..90408f0eb4 --- /dev/null +++ b/docs/confirm.rst @@ -0,0 +1,26 @@ +confirm +======= + +.. dfhack-tool:: + :summary: Adds confirmation dialogs for destructive actions. + :tags: fort interface + +In the base game, it is frighteningly easy to destroy hours of work with a single +misclick. Now you can avoid the consequences of accidentally disbanding a squad +(for example), or deleting a hauling route. + +See `gui/confirm` for a configuration GUI that controls which confirmation +prompts are enabled. + +Usage +----- + +:: + + confirm [list] + confirm enable|disable all + confirm enable|disable [ ...] + +Run without parameters (or with the ``list`` option) to see the available +confirmation dialogs and their IDs. You can enable or disable all dialogs or +set them individually by their IDs. diff --git a/docs/control-panel.rst b/docs/control-panel.rst new file mode 100644 index 0000000000..275a68db94 --- /dev/null +++ b/docs/control-panel.rst @@ -0,0 +1,78 @@ +control-panel +============= + +.. dfhack-tool:: + :summary: Configure DFHack and manage active DFHack tools. + :tags: dfhack + +This is the commandline interface for configuring DFHack behavior, toggling +which functionality is enabled right now, and setting up which tools are +enabled/run when starting new fortress games. For an in-game +graphical interface, please use `gui/control-panel`. For a commandline +interface for configuring which overlays are enabled, please use `overlay`. + +This interface controls three kinds of configuration: + +1. Tools that are enabled right now. These are DFHack tools that run in the +background, like `autofarm`, or tools that DFHack can run on a repeating +schedule, like the "autoMilk" functionality of `workorder`. Most tools that can +be enabled are saved with your fort, so you can have different tools enabled +for different forts. If a tool is marked "global", however, like +`hide-tutorials`, then enabling it will make it take effect for all games. + +2. Tools or commands that should be auto-enabled or auto-run when you start a +new fortress. In addition to tools that can be "enabled", this includes +commands that you might want to run once just after you embark, such as +commands to configure `autobutcher` or to drain portions of excessively deep +aquifers. + +3. DFHack system preferences, such as whether "Armok" (god-mode) tools are +shown in DFHack lists (including the lists of commands shown by the control +panel) or mouse configuration like how fast you have to click for it to count +as a double click (for example, when maximizing DFHack tool windows). +Preferences are "global" in that they apply to all games. + +Run ``control-panel list`` to see the current settings and what tools and +preferences are available for configuration. + +Usage +----- + +:: + + control-panel list + control-panel enable|disable + control-panel autostart|noautostart + control-panel set + control-panel reset + +Examples +-------- +``control-panel list butcher`` + Shows the current configuration of all commands related to `autobutcher` + (and anything else that includes the text "butcher" in it). +``control-panel enable fix/empty-wheelbarrows`` or ``control-panel enable 25`` + Starts to run `fix/empty-wheelbarrows` periodically to maintain the + usability of your wheelbarrows. In the second version of this command, the + number "25" is used as an example. You'll have to run + ``control-panel list`` to see what number this command is actually listed + as. +``control-panel autostart autofarm`` + Configures `autofarm` to become automatically enabled when you start a new + fort. +``control-panel autostart fix/blood-del`` + Configures `fix/blood-del` to run once when you start a new fort. +``control-panel set HIDE_ARMOK_TOOLS true`` + Enable "mortal mode" and hide "armok" tools in the DFHack UIs. Note that + this will also remove some entries from the ``control-panel list`` output. + Run ``control-panel list`` to see all preference options and their + descriptions. + +API +--- + +Other scripts can query whether a command is set for autostart via the script +API:: + + local control_panel = reqscript('control-panel') + local enabled, default = control_panel.get_autostart(command) diff --git a/docs/deathcause.rst b/docs/deathcause.rst new file mode 100644 index 0000000000..dac8a39ab9 --- /dev/null +++ b/docs/deathcause.rst @@ -0,0 +1,39 @@ +deathcause +========== + +.. dfhack-tool:: + :summary: Find out the cause of death for a creature. + :tags: fort inspection units + +Select a corpse or body part on the ground, and ``deathcause`` will detail the +cause of death of the creature. + +Usage +----- + +:: + + deathcause + +API +--- + +The ``deathcause`` script can be called programmatically by other scripts, either via the +commandline interface with ``dfhack.run_script()`` or via the API functions +defined in :source-scripts:`deathcause.lua`, available from the return value of +``reqscript('deathcause')``: + +* ``getDeathCause(unit or historical_figure)`` + +Returns a string with the unit or historical figure's cause of death. Note that using a historical +figure will sometimes provide more information than using a unit. + + + API usage example:: + + local dc = reqscript('deathcause') + + -- Note: this is an arguably bad example because this is the same as running deathcause + -- from the launcher, but this would theoretically still work. + local deathReason = dc.getDeathCauseFromUnit(dfhack.gui.getSelectedUnit()) + print(deathReason) diff --git a/docs/deep-embark.rst b/docs/deep-embark.rst new file mode 100644 index 0000000000..9ff54482be --- /dev/null +++ b/docs/deep-embark.rst @@ -0,0 +1,53 @@ +deep-embark +=========== + +.. dfhack-tool:: + :summary: Start a fort deep underground. + :tags: embark fort gameplay + +Moves the starting units and equipment to a specified underground region upon +embarking so you can start your fort from there. + +Run this script while setting up an embark, any time before the embark welcome +message appears. + +Usage +----- + +``deep-embark --depth []`` + Start monitoring the game for the welcome message. Once the embark welcome + message appears, your units and equipment will automatically be moved to the + specified layer. +``deep-embark --clear`` + Stop monitoring the game for the welcome message, effectively restoring + normal embarks on the surface. + +Example +------- + +``deep-embark --depth CAVERN_2`` + Embark in the second cavern layer +``deep-embark --depth UNDERWORLD --blockDemons`` + Embark in the underworld and disable the usual welcoming party. + +Options +------- + +``--depth `` + Embark at the specified layer. Valid layers are: ``CAVERN_1``, ``CAVERN_2``, + ``CAVERN_3``, and ``UNDERWORLD``. +``--blockDemons`` + Prevent the demon surge that is normally generated when you breach an + underworld spire. Use this with ``--depth UNDERWORLD`` to survive past the + first few minutes. Note that "wildlife" demon spawning will be unaffected. +``--atReclaim`` + Enable deep embarks when reclaiming sites. +``--clear`` + Re-enable normal surface embarks. + +Deep embarks for mods +--------------------- + +If you are creating a mod and you want to enable deep embarks by default, create +a file called "onLoad.init" in the DF raw folder (if one does not exist already) +and enter the ``deep-embark`` command within it. diff --git a/docs/deteriorate.rst b/docs/deteriorate.rst new file mode 100644 index 0000000000..f97cc3b089 --- /dev/null +++ b/docs/deteriorate.rst @@ -0,0 +1,78 @@ +deteriorate +=========== + +.. dfhack-tool:: + :summary: Cause corpses, clothes, and/or food to rot away over time. + :tags: fort fps gameplay items + +When enabled, this script will cause the specified item types to slowly rot +away. As they deteriorate, they will acquire the normal ``x``, ``X``, and +``XX`` markings. By default, items disappear after a few months, but you can +choose to slow this down or even make things rot away instantly! + +Now all those slightly worn wool shoes that dwarves scatter all over the place +or the toes, teeth, fingers, and limbs from the last undead siege will +deteriorate at a greatly increased rate, and eventually just crumble into +nothing. As warm and fuzzy as a dining room full of used socks makes your +dwarves feel, your FPS does not like it! + +By default (if you run ``enable deteriorate`` without changing any settings), +only non-entombed corpses and non-usable body parts will be affected. + +You can set other common options for new forts on the Gameplay / Autostart tab +of the DFHack control panel. + +Usage +----- + +:: + + enable deteriorate + deteriorate [status] + deteriorate (enable|disable) + deteriorate frequency + deteriorate now + +Where ```` is a comma-separated list of item types to affect. The +following categories are available: + +:clothes: All non-armor clothing pieces that are lying on the ground + that already have some damage. +:food: All food and plants. Milk is included, but seeds are left + untouched. +:corpses: All vermin remains and non-entombed corpses. This includes + former members of your fort, so if this category is enabled, + dwarves that have fallen down your well will rot away with + time. +:usable-parts: Non-entombed body parts that can be used for manufacturing, + crafting, or suturing (e.g. hair, wool, skulls, horns, etc.). +:unusable-parts: Non-entombed body parts that can't be used for manufacturing + or crafting. +:parts: Shorthand for the combination of the above two categories. +:all: Shorthand for all of the above categories. + +When setting a frequency, the number indicates the number of days between +adjustments of the deterioration counter. The default frequency of 1 day will +result in items disappearing after several months. The number does not need to +be a whole number. E.g. ``deteriorate frequency 0.5 all`` is perfectly valid. + +Examples +-------- + +``enable deteriorate`` + Start deteriorating items with current settings. +``deteriorate status`` + Show the current settings. +``deteriorate enable corpses,parts`` + Deteriorate corpses and body parts. This includes potentially useful parts + such as hair or wool, so use them quickly or lose them! +``deteriorate frequency 0.5 all`` + Deteriorate items of the enabled categories at twice the default rate. +``deteriorate frequency 14 clothes`` + Deteriorate clothes very slowly. +``deteriorate now corpses,unusable-parts`` + Deteriorate corpses and unusable body parts immediately. This is useful for + cleaning up after a siege or ambush (maybe after you have buried your own + casualties). +``deteriorate now food`` + Deteriorate all food items immediately. Instant famine! diff --git a/docs/devel/all-bob.rst b/docs/devel/all-bob.rst new file mode 100644 index 0000000000..a56ae8a28f --- /dev/null +++ b/docs/devel/all-bob.rst @@ -0,0 +1,15 @@ +devel/all-bob +============= + +.. dfhack-tool:: + :summary: Changes the first name of all units to "Bob".. + :tags: dev + +Useful for testing `modtools/interaction-trigger` events. + +Usage +----- + +:: + + devel/all-bob diff --git a/docs/devel/annc-monitor.rst b/docs/devel/annc-monitor.rst new file mode 100644 index 0000000000..72c1f24c03 --- /dev/null +++ b/docs/devel/annc-monitor.rst @@ -0,0 +1,19 @@ +devel/annc-monitor +================== + +.. dfhack-tool:: + :summary: Track announcements and reports and echo them to the console. + :tags: dev + +This tool monitors announcements and reports and echoes their contents to the +console. + +Usage +----- + +:: + + enable devel/annc-monitor + devel/annc-monitor report enable|disable + +Combat report monitoring is disabled by default. diff --git a/docs/devel/block-borders.rst b/docs/devel/block-borders.rst new file mode 100644 index 0000000000..a75293aa0b --- /dev/null +++ b/docs/devel/block-borders.rst @@ -0,0 +1,16 @@ +devel/block-borders +=================== + +.. dfhack-tool:: + :summary: Outline map blocks on the map screen. + :tags: dev map + +This tool displays an overlay that highlights the borders of map blocks. See +:doc:`/docs/api/Maps` for details on map blocks. + +Usage +----- + +:: + + devel/block-borders diff --git a/docs/devel/check-other-ids.rst b/docs/devel/check-other-ids.rst new file mode 100644 index 0000000000..4861217100 --- /dev/null +++ b/docs/devel/check-other-ids.rst @@ -0,0 +1,16 @@ +devel/check-other-ids +===================== + +.. dfhack-tool:: + :summary: Verify that game entities are referenced by the correct vectors. + :tags: dev + +This script runs through all ``world.items.other`` and ``world.buildings.other`` vectors +and verifies that the items contained in them have the expected types. + +Usage +----- + +:: + + devel/check-other-ids diff --git a/docs/devel/check-release.rst b/docs/devel/check-release.rst new file mode 100644 index 0000000000..91083a3646 --- /dev/null +++ b/docs/devel/check-release.rst @@ -0,0 +1,16 @@ +devel/check-release +=================== + +.. dfhack-tool:: + :summary: Perform basic checks for DFHack release readiness. + :tags: dev + +This script is run as part of the DFHack release process to check that release +flags are properly set. + +Usage +----- + +:: + + devel/check-release diff --git a/docs/devel/clear-script-env.rst b/docs/devel/clear-script-env.rst new file mode 100644 index 0000000000..ea3af0813f --- /dev/null +++ b/docs/devel/clear-script-env.rst @@ -0,0 +1,24 @@ +devel/clear-script-env +====================== + +.. dfhack-tool:: + :summary: Clear a lua script environment. + :tags: dev + +This tool can clear the environment of the specified lua script(s). This is +useful during development since if you remove a global function, an old version +of the function will stick around in the environment until it is cleared. + +Usage +----- + +:: + + devel/clear-script-env