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 index ea87b32ec6..4982b36b06 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -6,12 +6,14 @@ 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 @@ -22,5 +24,6 @@ jobs: lint: uses: DFHack/dfhack/.github/workflows/lint.yml@develop with: + scripts_repo: ${{ github.repository }} scripts_ref: ${{ github.ref }} secrets: inherit diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index e3dd5636d3..c5694a6f43 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -4,7 +4,7 @@ ci: repos: # shared across repos: - repo: https://github.com/pre-commit/pre-commit-hooks - rev: v4.5.0 + rev: v6.0.0 hooks: - id: check-added-large-files - id: check-case-conflict @@ -20,11 +20,11 @@ repos: args: ['--fix=lf'] - id: trailing-whitespace - repo: https://github.com/python-jsonschema/check-jsonschema - rev: 0.27.1 + rev: 0.37.4 hooks: - id: check-github-workflows - repo: https://github.com/Lucas-C/pre-commit-hooks - rev: v1.5.4 + rev: v1.5.6 hooks: - id: forbid-tabs exclude_types: @@ -34,6 +34,6 @@ repos: - json # specific to scripts: - repo: https://github.com/pre-commit/pre-commit-hooks - rev: v4.5.0 + rev: v6.0.0 hooks: - id: forbid-new-submodules diff --git a/CMakeLists.txt b/CMakeLists.txt index e9e5234b0d..a673d8298a 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -4,6 +4,8 @@ install(DIRECTORY ${CMAKE_CURRENT_SOURCE_DIR} PATTERN "*.json" PATTERN "scripts/docs" EXCLUDE PATTERN "scripts/test" EXCLUDE + PATTERN ".github" EXCLUDE + PATTERN ".vscode" EXCLUDE ) if(BUILD_TESTS) diff --git a/adaptation.lua b/adaptation.lua index 769f7d941a..221fa7e317 100644 --- a/adaptation.lua +++ b/adaptation.lua @@ -1,13 +1,4 @@ --- Original opening comment before lua adaptation --- View or set cavern adaptation levels --- based on removebadthoughts.rb --- Rewritten by TBSTeun using OpenAI GPT from adaptation.rb - -local args = {...} - -local mode = args[1] or 'help' -local who = args[2] -local value = args[3] +local argparse = require('argparse') local function print_color(color, s) dfhack.color(color) @@ -15,98 +6,59 @@ local function print_color(color, s) dfhack.color(COLOR_RESET) end -local function usage(s) - if s then - dfhack.printerr(s) +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 - print(dfhack.script_help()) end -local function set_adaptation_value(unit, v) - if not dfhack.units.isCitizen(unit) or not dfhack.units.isAlive(unit) then - return 0 - end - - for _, t in ipairs(unit.status.misc_traits) do - if t.id == df.misc_trait_type.CaveAdapt then - if mode == 'show' then - print_color(COLOR_RESET, ('Unit %s (%s) has an adaptation of '):format(unit.id, dfhack.TranslateName(dfhack.units.getVisibleName(unit)))) - if t.value <= 399999 then - print_color(COLOR_GREEN, ('%s\n'):format(t.value)) - elseif t.value <= 599999 then - print_color(COLOR_YELLOW, ('%s\n'):format(t.value)) - else - print_color(COLOR_RED, ('%s\n'):format(t.value)) - 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 - return 0 - elseif mode == 'set' then - print(('Unit %s (%s) changed from %s to %s'):format(unit.id, dfhack.TranslateName(dfhack.units.getVisibleName(unit)), t.value, v)) - t.value = v - return 1 - end - end - end - if mode == 'show' then - print_color(COLOR_RESET, ('Unit %s (%s) has an adaptation of '):format(unit.id, dfhack.TranslateName(dfhack.units.getVisibleName(unit)))) - print_color(COLOR_GREEN, '0\n') - elseif mode == 'set' then - local new_trait = dfhack.units.getMiscTrait(unit, df.misc_trait_type.CaveAdapt, true) - new_trait.id = df.misc_trait_type.CaveAdapt - new_trait.value = v - print(('Unit %s (%s) changed from 0 to %d'):format(unit.id, dfhack.TranslateName(dfhack.units.getVisibleName(unit)), v)) - return 1 +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 0 + return units end -if mode == 'help' then - usage() - return -elseif mode ~= 'show' and mode ~= 'set' then - usage(('Invalid mode %s: must be either "show" or "set"'):format(mode)) - return -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 not who then - usage('Target not specified') - return -elseif who ~= 'him' and who ~= 'all' then - usage(('Invalid target %s'):format(who)) +if help then + print(dfhack.script_help()) return end -if mode == 'set' then - if not value then - usage('Value not specified') - return - elseif not tonumber(value) then - usage(('Invalid value %s'):format(value)) - return +if not positionals[1] or positionals[1] == 'show' then + for _, unit in ipairs(get_units(all)) do + show_one(unit) end - - value = tonumber(value) - if value < 0 or value > 800000 then - usage(('Value must be between 0 and 800000 (inclusive), input value was %s'):format(value)) +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 -end - -if who == 'him' then - local u = dfhack.gui.getSelectedUnit(true) - if u then - set_adaptation_value(u, value) - else - dfhack.printerr('Please select a dwarf ingame') - end -elseif who == 'all' then - local num_set = 0 - - for _, uu in ipairs(df.global.world.units.all) do - num_set = num_set + set_adaptation_value(uu, value) - end - - if num_set > 0 then - print(('%s units changed'):format(num_set)) + for _, unit in ipairs(get_units(all)) do + set_one(unit, value) end +else + qerror('unknown command: ' .. positionals[1]) end diff --git a/add-recipe.lua b/add-recipe.lua index 222dc5511f..fe7fd63576 100644 --- a/add-recipe.lua +++ b/add-recipe.lua @@ -63,7 +63,7 @@ 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 - local known = category[1] + local known_category = category[1] local native = category[2] local all = category[3] local added = {} --as:df.itemdef[] @@ -72,16 +72,20 @@ function addItems(category, exotic) 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 local t1, t2 = pcall(function () return item.flags.TRAINING == false end) local 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) + --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 @@ -89,10 +93,12 @@ function addItems(category, exotic) 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 diff --git a/add-thought.lua b/add-thought.lua index e5e506c9bb..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 + 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=unit.status.current_soul.personality.stress+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 e92a01e0e7..0000000000 --- a/adv-fix-sleepers.lua +++ /dev/null @@ -1,45 +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. If you come accross one or more bugged sleepers in -adventure mode, simply run the script and all nearby sleepers will be cured. - -Usage:: - - adv-fix-sleepers - - -]====] - ---======================== --- 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-rumors.lua b/adv-rumors.lua deleted file mode 100644 index 7e03a8e326..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.adventure.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 index 8e1942ec2f..a3fa117cf1 100644 --- a/allneeds.lua +++ b/allneeds.lua @@ -1,39 +1,84 @@ -- Prints the sum of all citizens' needs. -local fort_needs = {} -for _, unit in pairs(df.global.world.units.all) do - if not dfhack.units.isCitizen(unit) or not dfhack.units.isAlive(unit) then - goto skipunit +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 pairs(mind) do + for _,need in ipairs(mind) do local needs = ensure_key(fort_needs, need.id) - needs.cumulative_need = (needs.cumulative_need or 0) + need.need_level - needs.cumulative_focus = (needs.cumulative_focus or 0) + need.focus_level - needs.citizen_count = (needs.citizen_count or 0) + 1 - end + 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 - :: skipunit :: + 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, { - df.need_type[id], - need.cumulative_need, - need.cumulative_focus, - need.citizen_count + id=df.need_type[id], + strength=need.strength, + focus=need.focus, + freq=need.freq, + fulfillment=need.fulfillment }) end -table.sort(sorted_fort_needs, function(a, b) - return a[2] > b[2] -end) +table.sort(sorted_fort_needs, sorts[sort]) -- Print sorted output -print(([[%20s %8s %8s %10s]]):format("Need", "Weight", "Focus", "# Dwarves")) -for _, need in pairs(sorted_fort_needs) do - print(([[%20s %8.f %8.f %10d]]):format(need[1], need[2], need[3], need[4])) +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 index f77f1c6b29..65c3364e81 100644 --- a/animal-control.lua +++ b/animal-control.lua @@ -19,79 +19,6 @@ local validArgs = utils.invert({ 'help', }) local args = utils.processArgs({...}, validArgs) -local help = [====[ - -animal-control -============== -Animal control is a script useful for deciding what animals to butcher and geld. - -While not as powerful as Dwarf Therapist in managing animals - in so far as -DT allows you to sort by various stats and flags - this script does provide -many options for filtering animals. Additionally you can mark animals for -slaughter or gelding, you can even do so enmasse if you so choose. - -Examples:: - - animal-control -race DOG - animal-control -race DOG -male -notgelded -showstats - animal-control -markfor gelding -id 1988 - animal-control -markfor slaughter -id 1988 - animal-control -gelded -markedfor slaughter -unmarkfor slaughter - -**Selection options:** - -These options are used to specify what animals you want or do not want to select. - -``-all``: Selects all units. - Note: cannot be used in conjunction with other - selection options. - -``-id ``: Selects the unit with the specified id value provided. - -``-race ``: Selects units which match the race value provided. - -``-markedfor ``: Selects units which have been marked for the action provided. - Valid actions: ``slaughter``, ``gelding`` - -``-notmarkedfor ``: Selects units which have not been marked for the action provided. - Valid actions: ``slaughter``, ``gelding`` - -``-gelded``: Selects units which have already been gelded. - -``-notgelded``: Selects units which have not been gelded. - -``-male``: Selects units which are male. - -``-female``: Selects units which are female. - -**Command options:** - -- ``-showstats``: Displays physical attributes of the selected animals. - -- ``-markfor ``: Marks selected animals for the action provided. - Valid actions: ``slaughter``, ``gelding`` - -- ``-unmarkfor ``: Unmarks selected animals for the action provided. - Valid actions: ``slaughter``, ``gelding`` - -**Other options:** - -- ``-help``: Displays this information - -**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 - -]====] - header_format = "%-20s %-9s %-9s %-5s %-22s %-8s %-25s" row_format = "%-20s %-9d %-9d %-5s %-22s %-8s %-25s" @@ -125,7 +52,7 @@ 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(help) + print(dfhack.script_help()) else count=0 if args.showstats then diff --git a/armoks-blessing.lua b/armoks-blessing.lua index 7dc2c62a6e..6d57ab34eb 100644 --- a/armoks-blessing.lua +++ b/armoks-blessing.lua @@ -1,59 +1,9 @@ -- 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 @@ -73,7 +23,7 @@ function brainwash_unit(unit) 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.PERSEVERENCE = 75 + 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 @@ -130,7 +80,7 @@ function brainwash_unit(unit) [df.value_type.HARD_WORK]=41, [df.value_type.SACRIFICE]=41, [df.value_type.COMPETITION]=-41, - [df.value_type.PERSEVERENCE]=41, + [df.value_type.PERSEVERANCE]=41, [df.value_type.LEISURE_TIME]=-11, [df.value_type.COMMERCE]=41, [df.value_type.ROMANCE]=41, @@ -239,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 -- --------------------------------------------------------------------------- @@ -262,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 @@ -278,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.plotinfo.race_id and v.status.current_soul then - print("Adjusting "..dfhack.df2console(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-beliefs.lua b/assign-beliefs.lua index 2a0d92cdb4..cc0de2c282 100644 --- a/assign-beliefs.lua +++ b/assign-beliefs.lua @@ -134,10 +134,6 @@ function assign(beliefs, unit, reset) for belief, level in pairs(beliefs) do assert(type(level) == "number") belief = belief:upper() - -- there's a typo in the game data - if belief == "PERSEVERANCE" then - belief = "PERSEVERENCE" - end if df.value_type[belief] then if level >= -3 and level <= 3 then local belief_value = calculate_random_belief_value(level) diff --git a/assign-facets.lua b/assign-facets.lua index 3e77b3e2e0..c91670f07c 100644 --- a/assign-facets.lua +++ b/assign-facets.lua @@ -134,10 +134,6 @@ function assign(facets, unit, reset) for facet, level in pairs(facets) do assert(type(level) == "number") facet = facet:upper() - -- there's a typo in the game data - if facet == "PERSEVERANCE" then - facet = "PERSEVERENCE" - end if df.personality_facet_type[facet] then if level >= -3 and level <= 3 then local facet_strength = calculate_random_facet_strength(level) diff --git a/assign-minecarts.lua b/assign-minecarts.lua index 5554e69022..53e498d2b7 100644 --- a/assign-minecarts.lua +++ b/assign-minecarts.lua @@ -2,6 +2,7 @@ --@ module = true local argparse = require('argparse') +local utils = require('utils') function get_free_vehicles() local free_vehicles = {} @@ -13,14 +14,17 @@ function get_free_vehicles() return free_vehicles end -local function has_minecart(route) - return #route.vehicle_ids > 0 -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 @@ -30,8 +34,9 @@ local function get_id_and_name(route) end local function assign_minecart_to_route(route, quiet, minecart) - if has_minecart(route) then - return true + local assigned_minecart = get_minecart(route) + if assigned_minecart then + return assigned_minecart end if not has_stops(route) then if not quiet then @@ -50,6 +55,12 @@ local function assign_minecart_to_route(route, quiet, minecart) 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 @@ -57,11 +68,11 @@ local function assign_minecart_to_route(route, quiet, minecart) print(('Assigned a minecart to route %s.') :format(get_id_and_name(route))) end - return true + return df.item.find(minecart.item_id) end -- assign first free minecart to the most recently-created route --- returns whether route now has a minecart assigned +-- 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 @@ -92,7 +103,7 @@ local function list() for _,route in ipairs(routes) do print(('%-8d %-9s %-9s %s') :format(route.id, - has_minecart(route) and 'yes' or 'NO', + get_minecart(route) and 'yes' or 'NO', has_stops(route) and 'yes' or 'NO', get_name(route))) end @@ -106,7 +117,7 @@ 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 has_minecart(route) then + if get_minecart(route) then goto continue end if not assign_minecart_to_route(route, quiet, minecarts[idx]) then @@ -141,7 +152,7 @@ local function main(args) local route = get_route_by_id(requested_route_id) if not route then dfhack.printerr('route id not found: '..requested_route_id) - elseif has_minecart(route) then + elseif get_minecart(route) then if not quiet then print(('Route %s already has a minecart assigned.') :format(get_id_and_name(route))) diff --git a/assign-preferences.lua b/assign-preferences.lua index 3eedaf30b6..9a8c1bdc6a 100644 --- a/assign-preferences.lua +++ b/assign-preferences.lua @@ -1,193 +1,12 @@ -- Change the preferences of a unit. --@ module = true -local help = [====[ - -assign-preferences -================== -A script to change the preferences of a unit. - -Preferences are classified into 12 types. The first 9 are: - -* like material; -* like creature; -* like food; -* hate creature; -* like item; -* like plant; -* like tree; -* like colour; -* like shape. - -These can be changed using this script. - -The remaining three are not currently managed by this script, -and are: like poetic form, like musical form, like dance form. - -To produce the correct description in the "thoughts and preferences" -page, you must specify the particular type of preference. For -each type, a description is provided in the section below. - -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: -https://dwarffortresswiki.org/index.php/DF2014:Preferences - -Usage: - -``-help``: - print the help page. - -``-unit ``: - set the target unit ID. If not present, the - currently selected unit will be the target. - -``-likematerial [ <...> ]``: - usually a type of stone, a type of metal and a type - of gem, plus it can also be a type of wood, a type of - glass, a type of leather, a type of horn, a type of - pearl, a type of ivory, a decoration material - coral - or amber, a type of bone, a type of shell, a type - of silk, a type of yarn, or a type of plant cloth. - Write the full tokens. - There must be a space before and after each square - bracket. - -``-likecreature [ <...> ]``: - one or more creatures liked by the unit. You can - just list the species: the creature token will be - something similar to ``CREATURE:SPARROW:SKIN``, - so the name of the species will be ``SPARROW``. Nothing - will stop you to write the full token, if you want: the - script will just ignore the first and the last parts. - There must be a space before and after each square - bracket. - -``-likefood [ <...> ]``: - usually a type of alcohol, plus it can be a type of - meat, a type of fish, a type of cheese, a type of edible - plant, a cookable plant/creature extract, a cookable - mill powder, a cookable plant seed or a cookable plant - leaf. Write the full tokens. - There must be a space before and after each square - bracket. - -``-hatecreature [ <...> ]``: - works the same way as ``-likecreature``, but this time - it's one or more creatures that the unit detests. They - should be a type of ``HATEABLE`` vermin which isn't already - explicitly liked, but no check is performed about this. - Like before, you can just list the creature species. - There must be a space before and after each square - bracket. - -``-likeitem [ <...> ]``: - a kind of weapon, a kind of ammo, a kind of 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/ojects/" folder), then specify the items using - the full tokens found in those files (see examples below). - There must be a space before and after each square - bracket. - -``-likeplant [ <...> ]``: - works in a similar way as ``-likecreature``, this time - with plants. You can just List the plant species (the - middle part of the token). - There must be a space before and after each square - bracket. - -``-liketree [ <...> ]``: - works exactly as ``-likeplant``. I think this - preference type is here for backward compatibility (?). - You can still use it, however. As before, - you can just list the tree (plant) species. - There must be a space before and after each square - bracket. - -``-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/ojects/" folder). You can use the full token or - just the color name. - There must be a space before and after each square - bracket. - -``-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/ojects/" folder). You can - use the full token or just the shape name. - There must be a space before and after each square - bracket. - -``-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. - -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 and olives":: - - assign-preferences -reset -likefood [ PLANT:MUSHROOM_HELMET_PLUMP:DRINK PLANT:OLIVE:FRUIT ] - -* "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 straberry 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 - -]====] - local utils = require("utils") local valid_args = utils.invert({ 'help', 'unit', + 'show', 'likematerial', 'likecreature', 'likefood', @@ -207,6 +26,38 @@ local function print_yellow(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() @@ -225,7 +76,7 @@ local preference_functions = { local ret = {} if mat_info then ret = { --luacheck:retype - type = df.unit_preference.T_type.LikeMaterial, + type = df.unitpref_type.LikeMaterial, item_type = -1, creature_id = -1, color_id = -1, @@ -238,7 +89,7 @@ local preference_functions = { mattype = mat_info.type, matindex = mat_info.index, mat_state = 0, - active = true, + flags = {visible = true}, prefstring_seed = rng:random() } else @@ -258,7 +109,7 @@ local preference_functions = { local index = utils.linear_index(df.global.world.raws.creatures.all, creature_id, "creature_id") if index then return { - type = df.unit_preference.T_type.LikeCreature, + type = df.unitpref_type.LikeCreature, item_type = index, creature_id = index, color_id = index, @@ -271,7 +122,7 @@ local preference_functions = { mattype = -1, matindex = -1, mat_state = 0, - active = true, + flags = {visible = true}, prefstring_seed = rng:random() } else @@ -305,7 +156,7 @@ local preference_functions = { item_type = df.item_type.POWDER_MISC elseif food_mat_index.CookableSeed > -1 then item_type = df.item_type.SEEDS - elseif food_mat_index.CookableLeaf > -1 then + 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 @@ -335,7 +186,7 @@ local preference_functions = { if item_type then return { - type = df.unit_preference.T_type.LikeFood, + type = df.unitpref_type.LikeFood, item_type = item_type, creature_id = item_type, color_id = item_type, @@ -348,7 +199,7 @@ local preference_functions = { mattype = mat_info.type, matindex = mat_info.index, mat_state = 1, - active = true, + flags = {visible = true}, prefstring_seed = rng:random() } end @@ -370,7 +221,7 @@ local preference_functions = { local index = utils.linear_index(df.global.world.raws.creatures.all, creature_id, "creature_id") if index then return { - type = df.unit_preference.T_type.HateCreature, + type = df.unitpref_type.HateCreature, item_type = index, creature_id = index, color_id = index, @@ -383,7 +234,7 @@ local preference_functions = { mattype = -1, matindex = -1, mat_state = 0, - active = true, + flags = {visible = true}, prefstring_seed = rng:random() } else @@ -411,7 +262,7 @@ local preference_functions = { do if item_type then return { - type = df.unit_preference.T_type.LikeItem, + type = df.unitpref_type.LikeItem, item_type = item_type, creature_id = item_type, color_id = item_type, @@ -424,7 +275,7 @@ local preference_functions = { mattype = -1, matindex = -1, mat_state = 0, - active = true, + flags = {visible = true}, prefstring_seed = rng:random() } end @@ -446,7 +297,7 @@ local preference_functions = { local index = utils.linear_index(df.global.world.raws.plants.all, plant_id, "id") if index then return { - type = df.unit_preference.T_type.LikePlant, + type = df.unitpref_type.LikePlant, item_type = index, creature_id = index, color_id = index, @@ -459,7 +310,7 @@ local preference_functions = { mattype = -1, matindex = -1, mat_state = 0, - active = true, + flags = {visible = true}, prefstring_seed = rng:random() } else @@ -479,7 +330,7 @@ local preference_functions = { local index = utils.linear_index(df.global.world.raws.plants.all, plant_id, "id") if index then return { - type = df.unit_preference.T_type.LikeTree, + type = df.unitpref_type.LikeTree, item_type = index, creature_id = index, color_id = index, @@ -492,7 +343,7 @@ local preference_functions = { mattype = -1, matindex = -1, mat_state = 0, - active = true, + flags = {visible = true}, prefstring_seed = rng:random() } else @@ -513,7 +364,7 @@ local preference_functions = { local _, found, index = utils.binsearch(df.global.world.raws.descriptors.colors, color_name, "id") if found then return { - type = df.unit_preference.T_type.LikeColor, + type = df.unitpref_type.LikeColor, item_type = index, creature_id = index, color_id = index, @@ -526,7 +377,7 @@ local preference_functions = { mattype = -1, matindex = -1, mat_state = 0, - active = true, + flags = {visible = true}, prefstring_seed = rng:random() } else @@ -546,7 +397,7 @@ local preference_functions = { local index, _ = utils.linear_index(df.global.world.raws.descriptors.shapes, shape_name, "id") if index then return { - type = df.unit_preference.T_type.LikeShape, + type = df.unitpref_type.LikeShape, item_type = index, creature_id = index, color_id = index, @@ -559,7 +410,7 @@ local preference_functions = { mattype = -1, matindex = -1, mat_state = 0, - active = true, + flags = {visible = true}, prefstring_seed = rng:random() } else @@ -624,12 +475,25 @@ function assign(preferences, unit, reset) 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(help) + print(dfhack.script_help()) return end @@ -646,9 +510,15 @@ local function main(...) 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) diff --git a/assign-profile.lua b/assign-profile.lua index a996c46135..70ce2ac600 100644 --- a/assign-profile.lua +++ b/assign-profile.lua @@ -92,7 +92,7 @@ local scripts = { FACETS = reqscript("assign-facets"), } -local default_filename = "/hack/scripts/dwarf_profiles.json" +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. @@ -179,7 +179,7 @@ local function main(...) end local profile = load_profile(profile_name, filename) - apply_profile(profile, unit_id, reset) + apply_profile(profile, unit, reset) end if not dfhack_flags.module then 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/autofish.lua b/autofish.lua index 8ceaeb3642..d743175ca8 100644 --- a/autofish.lua +++ b/autofish.lua @@ -4,8 +4,6 @@ --@ enable=true --@ module=true -local json = require("json") -local persist = require("persist-table") local argparse = require("argparse") local repeatutil = require("repeat-util") @@ -26,16 +24,19 @@ end --- Save the current state of the script local function persist_state() - persist.GlobalTable[GLOBAL_KEY] = json.encode({enabled=enabled, - s_maxFish=s_maxFish, s_minFish=s_minFish, s_useRaw=s_useRaw, - isFishing=isFishing + 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 = json.decode(persist.GlobalTable[GLOBAL_KEY] or "") or {} + 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 @@ -76,7 +77,7 @@ function toggle_fishing_labour(state) local work_details = df.global.plotinfo.labor_info.work_details for _,v in pairs(work_details) do if v.allowed_labors.FISH then - v.work_detail_flags.mode = state and + 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 diff --git a/autonick.lua b/autonick.lua index 2ea3cb4032..ee1cd01326 100644 --- a/autonick.lua +++ b/autonick.lua @@ -1,33 +1,4 @@ -- gives dwarves unique nicknames ---[====[ - -autonick -======== -Gives dwarves unique nicknames chosen randomly from ``dfhack-config/autonick.txt``. - -One nickname per line. -Empty lines, lines beginning with ``#`` and repeat entries are discarded. - -Dwarves with manually set nicknames are ignored. - -If there are fewer available nicknames than dwarves, the remaining -dwarves will go un-nicknamed. - -You may wish to use this script with the "repeat" command, e.g: -``repeat -name autonick -time 3 -timeUnits months -command [ autonick all ]`` - -Usage: - - autonick all [] - autonick help - -Options: - -:``-h``, ``--help``: - Show this text. -:``-q``, ``--quiet``: - Do not report how many dwarves were given nicknames. -]====] local options = {} @@ -48,9 +19,8 @@ end local seen = {} --check current nicknames -for _,unit in ipairs(df.global.world.units.active) do - if dfhack.units.isCitizen(unit) and - unit.name.nickname ~= "" then +for _,unit in ipairs(dfhack.units.getCitizens()) do + if unit.name.nickname ~= "" then seen[unit.name.nickname] = true end end @@ -70,16 +40,15 @@ end --assign names local count = 0 -for _,unit in ipairs(df.global.world.units.active) do +for _,unit in ipairs(dfhack.units.getCitizens()) do if (#names == 0) then if options.quiet ~= true then - print("no free names left in dfhack-config/autonick.txt") + print("not enough unique names in dfhack-config/autonick.txt") end break end --if there are any names left - if dfhack.units.isCitizen(unit) and - unit.name.nickname == "" then + if unit.name.nickname == "" then newnameIndex = math.random (#names) dfhack.units.setNickname(unit, names[newnameIndex]) table.remove(names, newnameIndex) 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/ban-cooking.lua b/ban-cooking.lua index eca6b63c57..2254e116dd 100644 --- a/ban-cooking.lua +++ b/ban-cooking.lua @@ -28,28 +28,10 @@ local function ban_cooking(print_name, mat_type, mat_index, type, subtype) end if options.unban then - for i, mtype in ipairs(kitchen.mat_types) do - if mtype == mat_type and - kitchen.mat_indices[i] == mat_index and - kitchen.item_types[i] == type and - kitchen.item_subtypes[i] == subtype and - kitchen.exc_types[i] == df.kitchen_exc_type.Cook - then - kitchen.mat_types:erase(i) - kitchen.mat_indices:erase(i) - kitchen.item_types:erase(i) - kitchen.item_subtypes:erase(i) - kitchen.exc_types:erase(i) - break - end - end + dfhack.kitchen.removeExclusion({Cook=true}, type, subtype, mat_type, mat_index) banned[key] = nil else - kitchen.mat_types:insert('#', mat_type) - kitchen.mat_indices:insert('#', mat_index) - kitchen.item_types:insert('#', type) - kitchen.item_subtypes:insert('#', subtype) - kitchen.exc_types:insert('#', df.kitchen_exc_type.Cook) + dfhack.kitchen.addExclusion({Cook=true}, type, subtype, mat_type, mat_index) banned[key] = { mat_type=mat_type, mat_index=mat_index, @@ -62,7 +44,7 @@ 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] == df.kitchen_exc_type.Cook then + 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] = { @@ -90,7 +72,7 @@ funcs.booze = function() 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(creature_id.id, m.id) + 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 @@ -98,8 +80,19 @@ funcs.booze = function() end funcs.honey = function() - local mat = dfhack.matinfo.find("CREATURE:HONEY_BEE:HONEY") - ban_cooking('honey bee honey', mat.type, mat.index, df.item_type.LIQUID_MISC, -1) + 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() @@ -155,30 +148,29 @@ funcs.seeds = function() 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" and m.flags.EDIBLE_COOKED then - local has_drink = false - local has_seed = false - for _, s in ipairs(m.reaction_product.id) do - has_seed = has_seed or s.value == "SEED_MAT" - has_drink = has_drink or s.value == "DRINK_MAT" - end - if has_seed and has_drink 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) + 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_drink = false local has_seed = false for _, s in ipairs(m.reaction_product.id) do has_seed = has_seed or s.value == "SEED_MAT" - has_drink = has_drink or s.value == "DRINK_MAT" end - if has_seed and has_drink then + if has_seed then ban_cooking(p.name .. ' ' .. m.id, matinfo.type, matinfo.index, df.item_type.PLANT_GROWTH, k) end end @@ -192,14 +184,18 @@ 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" and 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 + 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 @@ -223,9 +219,12 @@ 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" 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.PLANT, -1) + 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 @@ -236,14 +235,17 @@ 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" and 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 + 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 @@ -268,7 +270,7 @@ funcs.fruit = function() 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.LEAF_MAT then + 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) diff --git a/bodyswap.lua b/bodyswap.lua index cc6de93b58..e64f43f783 100644 --- a/bodyswap.lua +++ b/bodyswap.lua @@ -1,199 +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 + 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.ACTIVE_ADVENTURER = false end function clearNemesisFromLinkedSites(nem) --- 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) - 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 isPet(nemesis) - if nemesis.unit then - if nemesis.unit.relationship_ids.Pet ~= -1 then - return true + 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 - 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 + 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 + -- 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 - 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 + -- 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 - clearNemesisFromLinkedSites(nemesis) + clearNemesisFromLinkedSites(nemesis) - 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) + 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 - 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 + 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 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) + 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 - if not newUnit then - qerror('Target unit not specified!') - end - - local oldNem = df.nemesis_record.find(df.global.adventure.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 + local oldNem = df.nemesis_record.find(df.global.adventure.player_id) + local oldUnit = oldNem.unit + if newUnit == oldUnit then + return end - end - local newUnitIndex - for i,u in ipairs(activeUnits) do - if u == newUnit then - newUnitIndex = i - break + + local newNem = dfhack.units.getNemesis(newUnit) or createNemesis(newUnit) + if not newNem then + qerror("Failed to obtain target nemesis!") 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) - configureAdvParty(newNem) - df.global.adventure.player_id = newNem.id - activeUnits[newUnitIndex] = oldUnit - activeUnits[oldUnitIndex] = newUnit - oldUnit.idle_area:assign(oldUnit.pos) + + 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 - 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: 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 index f14e0d1bdc..91e362aeac 100644 --- a/build-now.lua +++ b/build-now.lua @@ -1,9 +1,8 @@ -- instantly completes unsuspended building construction jobs local argparse = require('argparse') -local dig_now = require('plugins.dig-now') local gui = require('gui') -local tiletypes = require('plugins.tiletypes') +local suspendmanager = require('plugins.suspendmanager') local utils = require('utils') local ok, buildingplan = pcall(require, 'plugins.buildingplan') @@ -22,16 +21,12 @@ local function parse_commandline(args) local positionals = argparse.processArgsGetopt(args, { {'h', 'help', handler=function() opts.help = true end}, {'q', 'quiet', handler=function() opts.quiet = true end}, - {nil, 'really', handler=function() opts.really = 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 not opts.really then - qerror('This script is known to cause corruption and crashes with some building types, and the DFHack team is still looking into solutions. To bypass this message, pass the "--really" option to the script.') - end - if #positionals >= 1 then opts.start = argparse.coords(positionals[1]) if #positionals >= 2 then @@ -48,6 +43,10 @@ local function parse_commandline(args) 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 @@ -67,7 +66,7 @@ local function get_jobs(opts) -- 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) do + 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. @@ -84,7 +83,7 @@ local function get_jobs(opts) goto continue end - -- accept building if if any part is within the processing area + -- 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 @@ -101,7 +100,7 @@ local function get_jobs(opts) :format(num_suspended, num_suspended ~= 1 and 's' or '')) end if num_incomplete > 0 then - print(('Skipped %d building%s with missing items') + print(('Skipped %d building%s with pending items') :format(num_incomplete, num_incomplete ~= 1 and 's' or '')) end if num_clipped > 0 then @@ -232,10 +231,8 @@ local function get_dump_pos(bld) if dump_pos then return dump_pos end - for _,unit in ipairs(df.global.world.units.active) do - if dfhack.units.isCitizen(unit) then - return unit.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 @@ -263,42 +260,6 @@ local function get_items(job) return items end --- disconnect item from the workshop that it is cluttering, if any -local function disconnect_clutter(item) - local bld = dfhack.items.getHolderBuilding(item) - if not bld then return true end - -- remove from contained items list, fail if not found - local found = false - for i,contained_item in ipairs(bld.contained_items) do - if contained_item.item == item then - bld.contained_items:erase(i) - found = true - break - end - end - if not found then - dfhack.printerr('failed to find clutter item in expected building') - return false - end - -- remove building ref from item and move item into containing map block - -- we do this manually instead of calling dfhack.items.moveToGround() - -- because that function will cowardly refuse to work with items with - -- BUILDING_HOLDER references (because it could crash the game). However, - -- we know that this particular setup is safe to work with. - for i,ref in ipairs(item.general_refs) do - if ref:getType() == df.general_ref_type.BUILDING_HOLDER then - item.general_refs:erase(i) - -- this call can return failure, but it always succeeds in setting - -- the required item flags and adding the item to the map block, - -- which is all we care about here. dfhack.items.moveToBuilding() - -- will fix things up later. - item:moveToGround(item.pos.x, item.pos.y, item.pos.z) - return true - end - end - return false -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. @@ -306,9 +267,6 @@ 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 - -- ensure we have no more holder building references so moveToBuilding - -- can succeed - if not disconnect_clutter(item) then return false 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 @@ -318,139 +276,6 @@ local function attach_items(bld, items) return true end --- from observation of vectors sorted by the DF, pos sorting seems to be by x, --- then by y, then by z -local function pos_cmp(a, b) - local xcmp = utils.compare(a.x, b.x) - if xcmp ~= 0 then return xcmp end - local ycmp = utils.compare(a.y, b.y) - if ycmp ~= 0 then return ycmp end - return utils.compare(a.z, b.z) -end - -local function get_original_tiletype(pos) - -- TODO: this is not always exactly the existing tile type. for example, - -- tracks are ignored - return dfhack.maps.getTileType(pos) -end - -local function reuse_construction(construction, item) - construction.item_type = item:getType() - construction.item_subtype = item:getSubtype() - construction.mat_type = item:getMaterial() - construction.mat_index = item:getMaterialIndex() - construction.flags.top_of_wall = false - construction.flags.no_build_item = true -end - -local function create_and_link_construction(pos, item, top_of_wall) - local construction = df.construction:new() - utils.assign(construction.pos, pos) - construction.item_type = item:getType() - construction.item_subtype = item:getSubtype() - construction.mat_type = item:getMaterial() - construction.mat_index = item:getMaterialIndex() - construction.flags.top_of_wall = top_of_wall - construction.flags.no_build_item = not top_of_wall - construction.original_tile = get_original_tiletype(pos) - utils.insert_sorted(df.global.world.constructions, construction, - 'pos', pos_cmp) -end - --- maps construction_type to the resulting tiletype -local const_to_tile = { - [df.construction_type.Fortification] = df.tiletype.ConstructedFortification, - [df.construction_type.Wall] = df.tiletype.ConstructedPillar, - [df.construction_type.Floor] = df.tiletype.ConstructedFloor, - [df.construction_type.UpStair] = df.tiletype.ConstructedStairU, - [df.construction_type.DownStair] = df.tiletype.ConstructedStairD, - [df.construction_type.UpDownStair] = df.tiletype.ConstructedStairUD, - [df.construction_type.Ramp] = df.tiletype.ConstructedRamp, -} --- fill in all the track mappings, which have nice consistent naming conventions -for i,v in ipairs(df.construction_type) do - if type(v) ~= 'string' then goto continue end - local _, _, base, dir = v:find('^(TrackR?a?m?p?)([NSEW]+)') - if base == 'Track' then - const_to_tile[i] = df.tiletype['ConstructedFloorTrack'..dir] - elseif base == 'TrackRamp' then - const_to_tile[i] = df.tiletype['ConstructedRampTrack'..dir] - end - ::continue:: -end - -local function set_tiletype(pos, tt) - local block = dfhack.maps.ensureTileBlock(pos) - block.tiletype[pos.x%16][pos.y%16] = tt - if tt == df.tiletype.ConstructedPillar then - block.designation[pos.x%16][pos.y%16].outside = 0 - end - -- all tiles below this one are now "inside" - for z = pos.z-1,0,-1 do - block = dfhack.maps.ensureTileBlock(pos.x, pos.y, z) - if not block or block.designation[pos.x%16][pos.y%16].outside == 0 then - return - end - block.designation[pos.x%16][pos.y%16].outside = 0 - end -end - -local function adjust_tile_above(pos_above, item, construction_type) - if not dfhack.maps.ensureTileBlock(pos_above) then return end - local tt_above = dfhack.maps.getTileType(pos_above) - local shape_above = df.tiletype.attrs[tt_above].shape - if shape_above ~= df.tiletype_shape.EMPTY - and shape_above ~= df.tiletype_shape.RAMP_TOP then - return - end - if construction_type == df.construction_type.Wall then - create_and_link_construction(pos_above, item, true) - set_tiletype(pos_above, df.tiletype.ConstructedFloor) - elseif df.construction_type[construction_type]:find('Ramp') then - set_tiletype(pos_above, df.tiletype.RampTop) - end -end - --- add new construction to the world list and manage tiletype conversion -local function build_construction(bld) - -- remember required metadata and get rid of building used for designation - local item = bld.contained_items[0].item - local pos = copyall(item.pos) - local construction_type = bld.type - dfhack.buildings.deconstruct(bld) - - -- check if we're building on a construction (i.e. building a construction on top of a wall) - local tiletype = dfhack.maps.getTileType(pos) - local tileattrs = df.tiletype.attrs[tiletype] - if tileattrs.material == df.tiletype_material.CONSTRUCTION then - -- modify the construction to the new type - local construction, found = utils.binsearch(df.global.world.constructions, pos, 'pos', pos_cmp) - if not found then - error('Could not find construction entry for construction tile at ' .. pos.x .. ', ' .. pos.y .. ', ' .. pos.z) - end - reuse_construction(construction, item) - else - -- add entry to df.global.world.constructions - create_and_link_construction(pos, item, false) - end - -- adjust tiletypes for the construction itself - set_tiletype(pos, const_to_tile[construction_type]) - if construction_type == df.construction_type.Wall then - dig_now.link_adjacent_smooth_walls(pos) - end - - -- for walls and ramps with empty space above, adjust the tile above - if construction_type == df.construction_type.Wall - or df.construction_type[construction_type]:find('Ramp') then - adjust_tile_above(xyz2pos(pos.x, pos.y, pos.z+1), item, - construction_type) - end - - -- a duplicate item will get created on deconstruction due to the - -- no_build_item flag set in create_and_link_construction; destroy this item - dfhack.items.remove(item) -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. @@ -459,37 +284,12 @@ local function build_building(bld) -- 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.designed = true design.flags.built = true design.hitpoints = 80640 design.max_hitpoints = 80640 end bld:setBuildStage(bld:getMaxBuildStage()) - bld.flags.exists = true - -- update occupancy flags and build dirt roads (they don't build themselves) - local bld_type = bld:getType() - local is_dirt_road = bld_type == df.building_type.RoadDirt - for x = bld.x1,bld.x2 do - for y = bld.y1,bld.y2 do - bld:updateOccupancy(x, y) - if is_dirt_road and dfhack.buildings.containsTile(bld, x, y) then - -- note that this does not clear shrubs. we need to figure out - -- how to do that - if not tiletypes.tiletypes_setTile(xyz2pos(x, y, bld.z), - -1, df.tiletype_material.SOIL, df.tiletype_special.NORMAL, -1) then - dfhack.printerr('failed to build tile of dirt road') - end - end - end - end - -- all buildings call this, though it only appears to have an effect for - -- farm plots - bld:initFarmSeasons() - -- doors and floodgates link to adjacent smooth walls - if bld_type == df.building_type.Door or - bld_type == df.building_type.Floodgate then - dig_now.link_adjacent_smooth_walls(bld.centerx, bld.centery, bld.z) - end + dfhack.buildings.completeBuild(bld) end local function throw(bld, msg) @@ -509,6 +309,10 @@ 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) @@ -547,9 +351,7 @@ for _,job in ipairs(get_jobs(opts)) do goto continue end - -- remove job data and clean up ref links. we do this first because - -- dfhack.items.moveToBuilding() refuses to work with items that already - -- hold references to buildings. + -- 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 @@ -559,11 +361,7 @@ for _,job in ipairs(get_jobs(opts)) do 'failed to attach items to building; state may be inconsistent') end - if bld_type == df.building_type.Construction then - build_construction(bld) - else - build_building(bld) - end + build_building(bld) num_jobs = num_jobs + 1 ::continue:: diff --git a/cannibalism.lua b/cannibalism.lua index f0ce69abf4..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 @@ -20,7 +10,7 @@ if df.viewscreen_itemst:is_instance(scrn) then elseif df.viewscreen_dungeon_monsterstatusst:is_instance(scrn) then unmark_inventory(scrn.inventory) --hint:df.viewscreen_dungeon_monsterstatusst elseif df.global.adventure.menu == df.ui_advmode_menu.Inventory then - unmark_inventory(df.global.world.units.active[0].inventory) + unmark_inventory(dfhack.world.getAdventurer().inventory) else qerror('Unsupported context') end diff --git a/caravan.lua b/caravan.lua index de8c4e32f9..0dd2cf6d89 100644 --- a/caravan.lua +++ b/caravan.lua @@ -18,8 +18,10 @@ end 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, } @@ -60,7 +62,7 @@ function commands.list() 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((' %d day(s) remaining'):format(math.floor(car.time_remaining / 120))) @@ -124,15 +126,6 @@ local function isDisconnectedPackAnimal(unit) end end -local function getPrintableUnitName(unit) - local visible_name = dfhack.units.getVisibleName(unit) - local profession_name = dfhack.units.getProfessionName(unit) - if visible_name.has_name then - return ('%s (%s)'):format(dfhack.TranslateName(visible_name), profession_name) - end - return profession_name -- for unnamed animals -end - local function rejoin_pack_animals() print('Reconnecting disconnected pack animals...') local found = false @@ -140,8 +133,8 @@ local function rejoin_pack_animals() if unit.flags1.merchant and isDisconnectedPackAnimal(unit) then local dragger = unit.following print((' %s <-> %s'):format( - dfhack.df2console(getPrintableUnitName(unit)), - dfhack.df2console(getPrintableUnitName(dragger)) + 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 diff --git a/catsplosion.lua b/catsplosion.lua index ccb9c5ec00..367798fd94 100644 --- a/catsplosion.lua +++ b/catsplosion.lua @@ -28,7 +28,11 @@ local males = {} --as:df.unit[][] local females = {} --as:df.unit[][] for _, unit in pairs(world.units.active) do - if dfhack.units.isDead(unit) then + 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 diff --git a/changelog.txt b/changelog.txt index 84329fa0c4..8876a3b0ed 100644 --- a/changelog.txt +++ b/changelog.txt @@ -33,9 +33,865 @@ Template for new versions: ## Fixes ## 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 @@ -198,13 +1054,11 @@ Template for new versions: - `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 contaners from getting overfull +- `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-r3 - # 50.08-r2 ## New Scripts @@ -230,7 +1084,7 @@ Template for new versions: ## 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 bluerpints +- `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) @@ -253,8 +1107,6 @@ Template for new versions: # 50.07-r1 -## New Scripts - ## 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 @@ -267,8 +1119,6 @@ Template for new versions: - `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 -## Removed - # 50.07-beta2 ## New Scripts @@ -346,7 +1196,7 @@ Template for new versions: - `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 seleting groups of items and collapsing groups in the UI +- `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 @@ -412,7 +1262,7 @@ Template for new versions: - `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 priortization list +-@ `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") @@ -458,415 +1308,3 @@ Template for new versions: ## Documentation - `devel/hello-world`: updated to be a better example from which to start new gui scripts - -# 0.47.05-r8 - -## New Scripts -- `gui/overlay`: configuration interface for the DFHack overlays and overlay widgets. includes a click-and-drag interface for repositioning widgets! - -## Fixes -- `dwarf-op`: fixed error when matching dwarves by name -- `gui/gm-unit`: fixed behavior of ``+`` and ``-`` to adjust skill values instead of populating the search field -- `warn-stealers`: register callback with correct event name so that units entering the map are detected -- `warn-stealers`: announce thieving creatures that spawn already revealed -- `warn-stealers`: cache unit IDs instead of unit objects to avoid referencing stale pointers -- `workorder`: fix interpretation of json-specified orders that set the ``item_type`` field - -## Misc Improvements -- `gui/blueprint`: support new blueprint phases and options -- `gui/create-item`: restrict materials to those normally allowed by the game by default, introduce new ``--unrestricted`` option for full freedom in choosing materials -- `gui/launcher`: show help for commands that start with ':' (like ``:lua``) -- `gui/quantum`: add option to allow corpses and refuse in your quantum stockpile -- `modtools/create-unit`: better unit naming, more argument checks, assign nemesis save data for units without civilization so they can be properly saved when offloaded -- `unsuspend`: new `overlay` for displaying status of suspended buildings (functionality migrated from removed `resume` plugin) - -## Removed -- `gui/create-item`: removed ``--restricted`` option. it is now the default behavior - -# 0.47.05-r7 - -## New Scripts -- `gui/kitchen-info`: adds more info to the Kitchen screen -- `gui/workorder-details`: adjusts work orders' input item, material, traits -- `warn-stealers`: warn when creatures that may steal your food, drinks, or items become visible -- `gui/launcher`: in-game command launcher with autocomplete, history, and context-sensitive help -- `max-wave`: dynamically limit the next immigration wave, can be set to repeat -- `pop-control`: persistent per fortress population cap, `hermit`, and `max-wave` management - -## Fixes -- `devel/query`: fixed error when --tile is specified -- `gui/unit-info-viewer`: fix logic for displaying undead creature names -- `gui/workflow`: restore functionality to the add/remove/order hotkeys on the workflow status screen -- `emigration`: fix emigrant logic so unhappy dwarves leave as designed -- `gui/gm-unit`: allow ``+`` and ``-`` to adjust skill values as intended instead of letting the filter intercept the characters -- `dwarf-op`: fixed error when applying the Miner job to dwarves -- `view-item-info`: fixed a couple errors when viewing items without materials -- `modtools/moddable-gods`: fixed an error when assigning spheres - -## Misc Improvements -- `devel/query`: inform the user when a query has been truncated due to ``--maxlength`` being hit. -- `devel/query`: increased default maxlength value from 257 to 2048 -- `gui/gm-unit`: don't clear the list filter when you adjust a skill value -- `gui/quickfort`: better formatting for the generated manager orders report -- `gui/quickfort`: you can now click on the map to move the blueprint anchor point to that tile instead of having to use the cursor movement keys -- `gui/quickfort`: display an error message when the blueprints directory cannot be found -- `prioritize`: new ``defaults`` keyword to prioritize the list of jobs that the community agrees should generally be prioritized. Run ``prioritize -a defaults`` to try it out in your fort! -- `quickfort`: library blueprints are now included by default in ``quickfort list`` output. Use the new ``--useronly`` (or just ``-u``) option to filter out library bluerpints. -- `quickfort`: better error message when the blueprints directory cannot be found -- `dwarf-op`: replaces [ a b c ] option lists with a,b,c option lists - -## Removed -- `fix/build-location`: The corresponding DF bug (5991) was fixed in DF 0.40.05 -- `fix/diplomats`: DF bug 3295 fixed in 0.40.05 -- `fix/fat-dwarves`: DF bug 5971 fixed in 0.40.05 -- `fix/feeding-timers`: DF bug 2606 is fixed in 0.40.12 -- `fix/merchants`: DF bug that prevents humans from making trade agreements has been fixed -- `gui/assign-rack`: No longer useful in current DF versions -- `gui/hack-wish`: Replaced by `gui/create-item` -- `gui/no-dfhack-init`: No longer useful since players don't have to create their own ``dfhack.init`` files anymore - -# 0.47.05-r6 - -## New Scripts - -- `assign-minecarts`: automatically assign minecarts to hauling routes that don't have one -- `deteriorate`: combines, replaces, and extends previous `deteriorateclothes`, `deterioratecorpses`, and `deterioratefood` scripts. -- `gui/petitions`: shows petitions. now you can see which guildhall/temple you agreed to build! -- `gui/quantum`: point-and-click tool for creating quantum stockpiles -- `gui/quickfort`: shows blueprint previews on the live map so you can apply them interactively -- `modtools/fire-rate`: allows modders to adjust the rate of fire for ranged attacks - -## Fixes -- `build-now`: walls built above other walls can now be deconstructed like regularly-built walls -- `gui/dfstatus`: no longer count items owned by traders -- `gui/unit-info-viewer`: fix calculation/labeling of unit size - -## Misc Improvements -- `build-now`: buildings that were just designated with `buildingplan` are now built immediately (as long as there are items available to build the buildings with) instead of being skipped until buildingplan gets around to doing its regular scan -- `caravan`: new ``unload`` command, fixes endless unloading at the depot by reconnecting merchant pack animals that were disconnected from their owners -- `deteriorate`: new ``now`` command immediately deteriorates items of the specified types -- `list-agreements`: now displays translated guild names, worshipped deities, petition age, and race-appropriate professions (e.g. "Craftsdwarf" instead of "Craftsman") -- `workorder`: a manager is no longer required for orders to be created (matching bevavior in the game itself) - -## Removed -- `devel/unforbidall`: please use `unforbid` instead. You can silence the output with ``unforbid all --quiet`` -- `deteriorateclothes`: please use ``deteriorate --types=clothes`` instead -- `deterioratecorpses`: please use ``deteriorate --types=corpses`` instead -- `deterioratefood`: please use ``deteriorate --types=food`` instead - -# 0.47.05-r5 - -## New Scripts -- `devel/eventful-client`: useful for testing eventful events - -## Fixes -- `devel/query`: fixed a problem printing parents when the starting path had lua pattern special characters in it -- `devel/query`: fixed a crash when trying to iterate over linked lists -- `gui/advfort`: encrust and stud jobs no longer consume reagents without actually improving the target item -- `quickfort`: contructions and bridges are now properly placed over natural ramps -- `setfps`: keep internal ratio of processing FPS to graphics FPS in sync when updating FPS - -## Misc Improvements -- `autonick`: now displays help instead of modifying dwarf nicknames when run without parameters. use ``autonick all`` to rename all dwarves. -- `autonick`: added ``--quiet`` and ``--help`` options -- `gui/blueprint`: support new `blueprint` options and phases -- `quickfort`: support transformations for blueprints that use expansion syntax -- `quickfort`: adjust direction affinity when transforming buildings (e.g. bridges that open to the north now open to the south when rotated 180 degrees) -- `quickfort`: automatically adjust cursor movements on the map screen in ``#query`` and ``#config`` modes when the blueprint is transformed. e.g. ``{Up}`` will be played back as ``{Right}`` when the blueprint is rotated clockwise and the direction key would move the map cursor -- `quickfort`: new blueprint mode: ``#config``; for playing back key sequences that don't involve the map cursor (like configuring hotkeys, changing standing orders, or modifying military uniforms) -- `quickfort`: API function ``apply_blueprint`` can now take ``data`` parameters that are simple strings instead of coordinate maps. This allows easier application of blueprints that are just one cell. - -# 0.47.05-r4 - -## Fixes -- `quickfort`: produce a useful error message instead of a code error when a bad query blueprint key sequence leaves the game in a mode that does not have an active cursor -- `quickfort`: restore functionality to the ``--verbose`` commandline flag -- `quickfort`: don't designate tiles for digging if they are within the bounds of a planned or constructed building -- `quickfort`: allow grates, bars, and hatches to be built on flat floor (like DF itself allows) -- `quickfort`: allow tracks to be built on hard, natural rock ramps -- `quickfort`: allow dig priority to be properly set for track designations -- `quickfort`: fix incorrect directions for tracks that extend south or east from a track segment pair specified with expansion syntax (e.g. T(4x4)) -- `quickfort`: fix parsing of multi-part extended zone configs (e.g. when you set custom supply limits for hospital zones AND set custom flags for a pond) -- `quickfort`: fix error when attempting to set a custom limit for plaster powder in a hospital zone -- `exportlegends`: fix issue where birth year was outputted as birth seconds - -## Misc Improvements -- `gui/blueprint`: support the new ``--splitby`` and ``--format`` options for `blueprint` -- `gui/blueprint`: hide help text when the screen is too short to display it -- `quickfort`: add ``quickfort.apply_blueprint()`` API function that can be called directly by other scripts -- `quickfort`: by default, don't designate tiles for digging that have masterwork engravings on them. quality level to preserve is configurable with the new ``--preserve-engravings`` param -- `quickfort`: implement single-tile track aliases so engraved tracks can be specified tile-by-tile just like constructed tracks -- `quickfort`: allow blueprints to jump up or down multiple z-levels with a single command (e.g. ``#>5`` goes down 5 levels) -- `quickfort`: blueprints can now be repeated up and down a specified number of z-levels via ``repeat`` markers in meta blueprints or the ``--repeat`` commandline option -- `quickfort`: blueprints can now be rotated, flipped, and shifted via ``transform`` and ``shift`` markers in meta blueprints or the corresponding commandline options - -## Removed -- `digfort`: please use `quickfort` instead - -# 0.47.05-r3 - -## New Scripts -- `autonick`: gives dwarves unique nicknames -- `build-now`: instantly completes planned building constructions -- `do-job-now`: makes a job involving current selection high priority -- `prioritize`: automatically boosts the priority of current and/or future jobs of specified types, such as hauling food, tanning hides, or pulling levers -- `reveal-adv-map`: exposes/hides all world map tiles in adventure mode - -## Fixes -- `devel/export-dt-ini`: fixed incorrect vtable address on Windows -- `quickfort`: allow machines (e.g. screw pumps) to be built on ramps just like DF allows -- `quickfort`: fix error message when the requested label is not found in the blueprint file - -## Misc Improvements -- `assign-beliefs`, `assign-facets`: now update needs of units that were changed -- `devel/query`: updated script to v3.2 (i.e. major rewrite for maintainability/readability) -- `devel/query`: merged options ``-query`` and ``-querykeys`` into ``-search`` -- `devel/query`: merged options ``-depth`` and ``-keydepth`` into ``-maxdepth`` -- `devel/query`: replaced option ``-safer`` with ``-excludetypes`` and ``-excludekinds`` -- `devel/query`: improved how tile data is dealt with identification, iteration, and searching -- `devel/query`: added option ``-findvalue`` -- `devel/query`: added option ``-showpaths`` to print full data paths instead of nested fields -- `devel/query`: added option ``-nopointers`` to disable printing values with memory addresses -- `devel/query`: added option ``-alignto`` to set the value column's alignment -- `devel/query`: added options ``-oneline`` and alias ``-1`` to avoid using two lines for fields with metadata -- `devel/query`: added support for matching multiple patterns -- `devel/query`: added support for selecting the highlighted job, plant, building, and map block data -- `devel/query`: added support for selecting a Lua script (e.g. `dorf_tables`) -- `devel/query`: added support for selecting a Json file (e.g. dwarf_profiles.json) -- `devel/query`: removed options ``-listall``, ``-listfields``, and ``-listkeys`` - these are now simply default behaviour -- `devel/query`: ``-table`` now accepts the same abbreviations (global names, ``unit``, ``screen``, etc.) as `lua` and `gui/gm-editor` -- `dorf_tables`: integrated `devel/query` to show the table definitions when requested with ``-list`` -- `geld`: fixed ``-help`` option -- `gui/gm-editor`: made search case-insensitive -- `quickfort`: accept multiple commands, list numbers, and/or blueprint lables on a single commandline -- `unretire-anyone`: replaced the 'undead' descriptor with 'reanimated' to make it more mod-friendly -- `warn-starving`: added an option to only check sane dwarves - -## Internals -- Install tests in the scripts repo into hack/scripts/test/scripts when the CMake variable BUILD_TESTS is defined - -# 0.47.05-r2 - -## New Scripts -- `clear-webs`: removes all webs on the map and/or frees any webbed creatures -- `devel/block-borders`: overlay that displays map block borders -- `devel/luacov`: generate code test coverage reports for script development. Define the ``DFHACK_ENABLE_LUACOV=1`` environment variable to start gathering coverage metrics. -- `fix/drop-webs`: causes floating webs to fall to the ground -- `gui/blueprint`: interactive frontend for the `blueprint` plugin (with mouse support!) -- `gui/mass-remove`: mass removal/suspension tool for buildings and constructions -- `reveal-hidden-sites`: exposes all undiscovered sites -- `set-timeskip-duration`: changes the duration of the "Updating World" process preceding the start of a new game, enabling you to jump in earlier or later than usual - -## Fixes -- `bodyswap`: stopped prior party members from tagging along after bodyswapping and reloading the map -- `bodyswap`: made companions of bodyswapping targets get added to the adventurer party - they can now be viewed using the in-game party system -- `color-schemes`: fixed an error in the ``register`` subcommand when the DF path contains certain punctuation characters -- `gui/advfort`: fixed an issue where starting a workshop job while not standing at the center of the workshop required advancing time manually -- `gui/unit-info-viewer`: fixed size description displaying unrelated values instead of size -- `quickfort`: comments in blueprint cells no longer prevent the rest of the row from being read. A cell with a single '#' marker in it, though, will still stop the parser from reading further in the row. -- `quickfort`: fixed an off-by-one line number accounting in blueprints with implicit ``#dig`` modelines -- `quickfort`: changed to properly detect and report an error on sub-alias params with no values instead of just failing to apply the alias later (if you really want an empty value, use ``{Empty}`` instead) -- `quickfort`: improved handling of non-rectangular and non-solid extent-based structures (like fancy-shaped stockpiles and farm plots) -- `quickfort`: fixed conversion of numbers to DF keycodes in ``#query`` blueprints -- `quickfort`: fixed various errors with cropping across the map edge -- `quickfort`: properly reset config to default values in ``quickfort reset`` even if if the ``dfhack-config/quickfort/quickfort.txt`` config file doesn't mention all config vars. Also now works even if the config file doesn't exist. - -## Misc Improvements -- `devel/annc-monitor`: added ``report enable|disable`` subcommand to filter combat reports -- `gui/advfort`: added workshop name to workshop UI -- `quickfort`: added the ``--cursor`` option for running a blueprint at specific coordinates instead of starting at the game cursor position -- `quickfort`: added more helpful error messages for invalid modeline markers -- `quickfort`: added support for extra space characters in blueprints -- `quickfort`: added a warning when an invalid alias is encountered instead of silently ignoring it -- `quickfort`: made more quiet when the ``--quiet`` parameter is specified -- `setfps`: improved error handling -- `unretire-anyone`: the historical figure selection list now includes the ``SYN_NAME`` (necromancer, vampire, etc) of figures where applicable - -# 0.47.05-r1 - -## Misc Improvements -- `gui/no-dfhack-init`: clarified how to dismiss dialog that displays when no ``dfhack.init`` file is found -- `quickfort`: an active cursor is no longer required for running #notes blueprints (like the dreamfort walkthrough) -- `quickfort`: you can now be in any mode with an active cursor when running ``#query`` blueprints (before you could only be in a few "approved" modes, like look, query, or place) -- `quickfort`: refined ``#query`` blueprint sanity checks: cursor should still be on target tile at end of configuration, and it's ok for the screen ID to change if you are destroying (or canceling destruction of) a building -- `quickfort`: now reports how many work orders were added when generating manager orders from blueprints in the gui dialog -- `quickfort`: added ``--dry-run`` option to process blueprints but not change any game state -- `quickfort`: you can now specify the number of desired barrels, bins, and wheelbarrows for individual stockpiles when placing them -- `quickfort`: ``quickfort orders`` on a ``#place`` blueprint will now enqueue manager orders for barrels, bins, or wheelbarrows that are explicitly set in the blueprint. -- `quickfort`: you can now add alias definitions directly to your blueprint files instead of having to put them in a separate aliases.txt file. makes sharing blueprints with custom alias definitions much easier. - -## Documentation -- `digfort`: added deprecation warnings - digfort has been replaced by `quickfort` - -# 0.47.05-beta1 - -## Fixes -- `quickfort`: raw numeric `quickfort-dig-priorities` (e.g. ``3``, which is a valid shorthand for ``d3``) now works when used in .xlsx blueprints - -## Misc Improvements -- `quickfort`: new commandline options for setting the initial state of the gui dialog. for example: ``quickfort gui -l dreamfort notes`` will start the dialog filtered for the dreamfort walkthrough blueprints - -# 0.47.04-r5 - -## New Scripts -- `gui/quickfort`: fast access to the quickfort interactive dialog -- `workorder-recheck`: resets the selected work order to the ``Checking`` state - -## Fixes -- `quickfort`: zones are now created in the active state by default -- `quickfort`: solve rare crash when changing UI modes - -## Misc Improvements -- `quickfort`: new blueprint mode: ``#ignore``, useful for scratch space or personal notes -- `quickfort`: implement ``{Empty}`` keycode for use in quickfort aliases; useful for defining blank-by-default alias values -- `quickfort`: more flexible commandline parsing allowing for more natural parameter ordering (e.g. where you used to have to write ``quickfort list dreamfort -l`` you can now write ``quickfort list -l dreamfort``) -- `quickfort`: print out blueprint names that a ``#meta`` blueprint is applying so it's easier to understand what meta blueprints are doing -- `quickfort`: whitespace is now allowed between a marker name and the opening parenthesis in blueprint modelines. for example, ``#dig start (5; 5)`` is now valid (you used to be required to write ``#dig start(5; 5)``) - -# 0.47.04-r4 - -## New Scripts -- `fix/corrupt-equipment`: fixes some military equipment-related corruption issues that can cause DF crashes - -## Fixes -- `adaptation`: fixed handling of units with no cave adaptation suffered yet -- `assign-goals`: fixed error preventing new goals from being created -- `assign-preferences`: fixed handling of preferences for flour -- `deathcause`: fixed an error when inspecting certain corpses -- `quickfort`: fixed handling of modifier keys (e.g. ``{Ctrl}`` or ``{Alt}``) in query blueprints -- `quickfort`: fixed misconfiguration of nest boxes, hives, and slabs that were preventing them from being built from build blueprints -- `quickfort`: fixed valid placement detection for floor hatches, floor grates, and floor bars (they were erroneously being rejected from open spaces and staircase tops) -- `quickfort`: fixed query blueprint statistics being added to the wrong metric when both a query and a zone blueprint are run by the same meta blueprint -- `quickfort`: added missing blueprint labels in gui dialog list -- `quickfort`: fixed occupancy settings for extent-based structures so that stockpiles can be placed within other stockpiles (e.g. in a checkerboard or bullseye pattern) -- `unsuspend`: now leaves buildingplan-managed buildings alone and doesn't unsuspend underwater tasks - -## Misc Improvements -- `devel/export-dt-ini`: updated for Dwarf Therapist 41.2.0 -- `gui/advfort`: added support for linking to hatches and pressure plates with mechanisms -- `modtools/add-syndrome`: added support for specifying syndrome IDs instead of names -- `quickfort`: query blueprint aliases can now accept parameters for dynamic expansion - see dfhack-config/quickfort/aliases.txt for details -- `quickfort`: alias names can now include dashes and underscores (in addition to letters and numbers) -- `quickfort`: improved speed of first call to ``quickfort list`` significantly, especially for large blueprint libraries -- `quickfort`: added ``query_unsafe`` setting to disable query blueprint error checking - useful for query blueprints that send unusual key sequences -- `quickfort`: added support for bookcases, display cases, and offering places (altars) -- `quickfort`: added configuration support for zone pit/pond, gather, and hospital sub-menus in zone blueprints -- `quickfort`: removed ``buildings_use_blocks`` setting and replaced it with more flexible functionality in `buildingplan` -- `quickfort`: added support for creating uninitialized stockpiles with :kbd:`c` - -# 0.47.04-r3 - -## New Scripts -- `quickfort`: DFHack-native implementation of quickfort with many new features and integrations - see the `quickfort-user-guide` for details -- `timestream`: controls the speed of the calendar and creatures -- `uniform-unstick`: prompts units to reevaluate their uniform, by removing/dropping potentially conflicting worn items - -## Fixes -- `ban-cooking`: fixed an error in several subcommands - -## Misc Improvements -- `unretire-anyone`: made undead creature names appear in the historical figure list - -# 0.47.04-r2 - -## New Scripts -- `animal-control`: helps manage the butchery and gelding of animals -- `devel/kill-hf`: kills a historical figure -- `geld`: gelds or ungelds animals -- `list-agreements`: lists all guildhall and temple agreements -- `list-waves`: displays migration wave information for citizens/units -- `ungeld`: ungelds animals (wrapper around `geld`) - -## Fixes -- `digfort`: fixed y-line tracking when .csv files contain lines with only commas -- `digfort`: fixed an issue causing blueprints touching the southern or eastern edges of the map to be rejected (northern and western edges were already allowed). This allows blueprints that span the entire embark area. -- `exportlegends`: fixed an issue where two different ```` tags could be included in a ```` -- `exportlegends`: stopped including some tags with ``-1`` values which don't provide useful information -- `gui/advfort`: fixed "operate pump" job -- `gui/load-screen`: fixed an issue causing longer timezones to be cut off -- `names`: fixed an error preventing the script from working -- `names`: fixed an issue causing renamed units to display their old name in legends mode and some other places -- `modtools/moddable-gods`: fixed an error when creating the historical figure -- `modtools/moddable-gods`: removed unused ``-domain`` and ``-description`` arguments -- `modtools/moddable-gods`: made ``-depictedAs`` argument work -- `pref-adjust`: fixed some compatibility issues and a potential crash - -## Misc Improvements -- `add-recipe`: added tool recipes (minecarts, wheelbarrows, stepladders, etc.) -- `add-recipe`: added a command explanation or error message when entering an invalid command -- `armoks-blessing`: added adjustments to values and needs -- `devel/query`: added many new query options -- `digfort`: handled double quotes (") at the start of a string, allowing .csv files exported from spreadsheets to work without manual modification -- `digfort`: documented that removing ramps, cutting trees, and gathering plants are indeed supported -- `digfort`: added a ``force`` option to truncate blueprints if the full blueprint would extend off the edge of the map -- `dwarf-op`: added ability to select dwarves based on migration wave -- `dwarf-op`: added ability to protect dwarves based on symbols in their custom professions -- `exportlegends`: changed some flags to be represented by self-closing tags instead of true/false strings (e.g. ````) - note that this may require changes to other XML-parsing utilities -- `exportlegends`: changed some enum values from numbers to their string representations -- `exportlegends`: added ability to save all files to a subfolder, named after the region folder and date by default -- `gui/advfort`: added support for specifying the entity used to determine available resources -- `gui/gm-editor`: added support for automatically following ref-targets when pressing the ``i`` key -- `modtools/moddable-gods`: added support for ``neuter`` gender -- `pref-adjust`: added support for adjusting just the selected dwarf -- `pref-adjust`: added a new ``goth`` profile -- `remove-stress`: added a ``-value`` argument to enable setting stress level directly -- `workorder`: changed default frequency from "Daily" to "OneTime" - -# 0.47.04-r1 - -## Fixes -- `catsplosion`: fixed error when handling races with only one caste (e.g. harpies) --@ `exportlegends`: fixed error when exporting maps -- `spawnunit`: fixed an error when forwarding some arguments but not a location to `modtools/create-unit` - -## Misc Improvements -- `exportlegends`: - - made interaction export more robust and human-readable - - removed empty ```` and ```` tags -- `modtools/create-unit`: - - added ``-equip`` option to equip created units - - added ``-skills`` option to give skills to units - - added ``-profession`` and ``-customProfession`` options to adjust unit professions - -# 0.47.04-beta1 - -## New scripts -- `color-schemes`: manages color schemes -- `devel/print-event`: prints the description of an event by ID or index -- `gui/color-schemes`: an in-game interface for `color-schemes` -- `light-aquifers-only`: changes heavy aquifers to light aquifers -- `on-new-fortress`: runs DFHack commands only in a new fortress -- `once-per-save`: runs DFHack commands unless already run in the current save -- `resurrect-adv`: brings your adventurer back to life -- `reveal-hidden-units`: exposes all sneaking units -- `workorder`: allows queuing manager jobs; smart about shear and milk creature jobs - -## Fixes -- `devel/visualize-structure`: fixed padding detection for globals -- `exportlegends`: - - added UTF-8 encoding and XML escaping for more fields - - added checking for unhandled structures to avoid generating invalid XML - - fixed missing fields in ``history_event_assume_identityst`` export -- `full-heal`: - - when resurrected by specifying a corpse, units now appear at the location of the corpse rather than their location of death - - resurrected units now have their tile occupancy set (and are placed in the prone position to facilitate this) - -## Misc Improvements -- `devel/export-dt-ini`: updated some field names for DT for 0.47 -- `devel/visualize-structure`: added human-readable lengths to containers -- `exportlegends`: - - added evilness and force IDs to regions - - added profession and weapon info to relevant entities - - added support for many new history events in 0.47 - - added historical event relationships and supplementary data -- `full-heal`: - - made resurrection produce a historical event viewable in Legends mode - - made error messages more explanatory -- `install-info`: added DFHack build ID to report -- `modtools/create-item`: added ``-matchingGloves`` and ``-matchingShoes`` arguments -- `modtools/create-unit`: - - added ``-duration`` argument to make the unit vanish after some time - - added ``-locationRange`` argument to allow spawning in a random position within a defined area - - added ``-locationType`` argument to specify the type of location to spawn in -- `unretire-anyone`: - - added ``-dead`` argument to revive and enable selection of a dead historical figure to use as an adventurer in adv mode 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 index 37f1939da3..bab8086da5 100644 --- a/clear-webs.lua +++ b/clear-webs.lua @@ -1,36 +1,7 @@ -- Removes webs and frees webbed units. -- Author: Atomic Chicken -local usage = [====[ - -clear-webs -========== -This script removes all webs that are currently on the map, -and also frees any creatures who have been caught in one. - -Note that it does not affect sprayed webs until -they settle on the ground. - -Usable in both fortress and adventurer mode. - -Web removal and unit release happen together by default. -The following may be used to isolate one of these actions: - -Arguments:: - - -unitsOnly - Include this if you want to free all units from webs - without removing any webs - - -websOnly - Include this if you want to remove all webs - without freeing any units - -See also `fix/drop-webs`. - -]====] - -local utils = require 'utils' +local utils = require('utils') local validArgs = utils.invert({ 'unitsOnly', 'websOnly', @@ -39,12 +10,12 @@ local validArgs = utils.invert({ local args = utils.processArgs({...}, validArgs) if args.help then - print(usage) + 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.") + qerror("You have specified both --unitsOnly and --websOnly. These cannot be used together.") end local webCount = 0 @@ -57,7 +28,7 @@ end local unitCount = 0 if not args.websOnly then - for _, unit in ipairs(df.global.world.units.all) do + 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 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/combat-harden.lua b/combat-harden.lua index 39cdef7a83..8010ae14b8 100644 --- a/combat-harden.lua +++ b/combat-harden.lua @@ -1,41 +1,6 @@ --- Sets a unit's combat-hardened value to a given percent --@ module = true -local help = [====[ - -combat-harden -============= -Sets the combat-hardened value on a unit, making them care more/less about seeing corpses. -Requires a value and a target. - -Valid values: - -:``-value <0-100>``: - A percent value to set combat hardened to. -:``-tier <1-4>``: - 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 neither are provided, the script defaults to using a value of 100. - -Valid targets: - -:``-all``: - All active units will be affected. -:``-citizens``: - All (sane) citizens of your fort will be affected. Will do nothing in adventure mode. -:``-unit ``: - The given unit will be affected. - -If no target is given, the provided unit can't be found, or no unit id is given with the unit -argument, the script will try and default to targeting the currently selected unit. - -]====] - -local utils = require 'utils' +local utils = require('utils') local validArgs = utils.invert({ 'help', @@ -49,83 +14,81 @@ local validArgs = utils.invert({ local tiers = {0, 33, 67, 100} function setUnitCombatHardened(unit, value) - if unit.status.current_soul ~= nil then - -- Ensure value is in the bounds of 0-100 - local value = math.max(0, math.min(100, value)) + if not unit.status.current_soul then return end - unit.status.current_soul.personality.combat_hardened = value - 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(...) - local args = utils.processArgs({...}, validArgs) +function main(args) + local opts = utils.processArgs(args, validArgs) - if args.help then - print(help) + if opts.help then + print(dfhack.script_help()) return end local value - if not args.tier and not args.value then + if not opts.tier and not opts.value then -- Default to 100 value = 100 - elseif args.tier then + elseif opts.tier then -- Bound between 1-4 - local tierNum = math.max(1, math.min(4, tonumber(args.tier))) + local tierNum = math.max(1, math.min(4, tonumber(opts.tier))) value = tiers[tierNum] - elseif args.value then + elseif opts.value then -- Function ensures value is bound, so no need to bother here -- Will check it's a number, though - value = tonumber(args.value) or 100 + value = tonumber(opts.value) or 100 end local unitsList = {} --as:df.unit[] - if not args.all and not args.citizens then + if not opts.all and not opts.citizens then -- Assume trying to target a unit local unit - if args.unit then - if tonumber(args.unit) then - unit = df.unit.find(args.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 unit == nil then + if not unit then unit = dfhack.gui.getSelectedUnit(true) end - if unit == nil then - qerror("Couldn't find unit. If you don't want to target a specific unit, use -all or -citizens.") + 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 args.all then + elseif opts.all then for _, unit in pairs(df.global.world.units.active) do table.insert(unitsList, unit) end - elseif args.citizens then - -- Technically this will exclude insane citizens, but this is the - -- easiest thing that dfhack provides - + elseif opts.citizens then -- Abort if not in Fort mode if not dfhack.world.isFortressMode() then - qerror('-citizens requires fortress mode') + qerror('--citizens requires fortress mode') end - for _, unit in pairs(df.global.world.units.active) do - if dfhack.units.isCitizen(unit) then - table.insert(unitsList, unit) - end + for _, unit in ipairs(dfhack.units.getCitizens()) do + table.insert(unitsList, unit) end end - for index, unit in ipairs(unitsList) do + for _, unit in ipairs(unitsList) do setUnitCombatHardened(unit, value) end end if not dfhack_flags.module then - main(...) + main{...} end diff --git a/combine.lua b/combine.lua index fa9b299ea6..cd9e2522f0 100644 --- a/combine.lua +++ b/combine.lua @@ -1,5 +1,3 @@ --- Combines items in a stockpile that could be stacked together - local argparse = require('argparse') local utils = require('utils') @@ -13,54 +11,59 @@ local opts, args = { verbose = 0, }, {...} --- default max stack size of 30 -local MAX_ITEM_STACK=30 -local MAX_AMMO_STACK=25 -local MAX_CONT_ITEMS=30 -local MAX_MAT_AMT=30 +-- 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'} +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. the maximum stack size is calcuated at run time: the highest value of MAX_ITEM_STACK or largest current stack size. --- 3. even though powders are specified, sand and plaster types items are excluded from merging. --- 4. seeds cannot be combined in stacks > 1. +-- 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_size=MAX_AMMO_STACK}}, - ['parts'] = {[df.item_type.CORPSEPIECE] ={type_id=df.item_type.CORPSEPIECE, max_size=1}}, - ['drink'] = {[df.item_type.DRINK] ={type_id=df.item_type.DRINK, max_size=MAX_ITEM_STACK}}, - ['fat'] = {[df.item_type.GLOB] ={type_id=df.item_type.GLOB, max_size=MAX_ITEM_STACK}, - [df.item_type.CHEESE] ={type_id=df.item_type.CHEESE, max_size=MAX_ITEM_STACK}}, - ['fish'] = {[df.item_type.FISH] ={type_id=df.item_type.FISH, max_size=MAX_ITEM_STACK}, - [df.item_type.FISH_RAW] ={type_id=df.item_type.FISH_RAW, max_size=MAX_ITEM_STACK}, - [df.item_type.EGG] ={type_id=df.item_type.EGG, max_size=MAX_ITEM_STACK}}, - ['food'] = {[df.item_type.FOOD] ={type_id=df.item_type.FOOD, max_size=MAX_ITEM_STACK}}, - ['meat'] = {[df.item_type.MEAT] ={type_id=df.item_type.MEAT, max_size=MAX_ITEM_STACK}}, - ['plant'] = {[df.item_type.PLANT] ={type_id=df.item_type.PLANT, max_size=MAX_ITEM_STACK}, - [df.item_type.PLANT_GROWTH]={type_id=df.item_type.PLANT_GROWTH, max_size=MAX_ITEM_STACK}}, - ['powder'] = {[df.item_type.POWDER_MISC] ={type_id=df.item_type.POWDER_MISC, max_size=MAX_ITEM_STACK}}, - ['seed'] = {[df.item_type.SEEDS] ={type_id=df.item_type.SEEDS, max_size=1}}, + 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 - for k2,v2 in pairs(v1) do - valid_types_map['all'][k2]={} - for k3,v3 in pairs (v2) do - valid_types_map['all'][k2][k3]=v3 - end + 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 dfhack.print(string.format(...)) end + if not opts.quiet and opts.verbose >= level then + print(dfhack.df2console(string.format(...))) + end end -- CList class @@ -72,19 +75,19 @@ function CList:new(o) 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 + 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, max_size) +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_size = max_size or 0 -- how many of a comp item can be in one stack + 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(nil) -- key:item.id, + comp_item.items = CList:new() -- key:item.id, -- val:{item, -- before_size, after_size, before_cont_id, after_cont_id, -- stockpile_id, stockpile_name, @@ -93,13 +96,13 @@ local function comp_item_new(comp_key, max_size) -- } comp_item.item_qty = 0 -- total quantity of items comp_item.material_amt = 0 -- total amount of materials - comp_item.max_mat_amt = MAX_MAT_AMT -- max amount of materials in one stack + 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(nil) -- key:container.id, val:container.id - comp_item.after_cont_ids = CList:new(nil) -- key:container.id, val:container.id + 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 @@ -110,8 +113,8 @@ local function comp_item_add_item(stockpile, stack_type, comp_item, item, contai comp_item.before_stacks = comp_item.before_stacks + 1 comp_item.description = utils.getItemDescription(item, 1) - if item.stack_size > comp_item.max_size then - comp_item.max_size = item.stack_size + if item.stack_size > comp_item.max_stack_qty then + comp_item.max_stack_qty = item.stack_size end local new_item = {} @@ -167,40 +170,51 @@ local function stack_type_new(type_vals) end -- item info - stack_type.comp_items = CList:new(nil) -- 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 + 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(nil) -- key:container.id, val:container.id - stack_type.after_cont_ids = CList:new(nil) -- key:container.id, val:container.id + 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 = tostring(stack_type.type_id) .. "+" .. tostring(item.race) .. "+" .. tostring(item.caste) + comp_key = ('%s+%s+%s'):format(stack_type.type_id, item.race, item.caste) else - comp_key = tostring(stack_type.type_id) .. "+" .. tostring(item.race) .. "+" .. tostring(item.caste) .. "+" .. tostring(item:getActualMaterial()) .. "+" .. tostring(item:getActualMaterialIndex()) + comp_key = ('%s+%s+%s+%s+%s'):format(stack_type.type_id, item.race, item.caste, item:getActualMaterial(), item:getActualMaterialIndex()) end - elseif item:isAmmo() then - if item:getQuality() == 5 then - comp_key = tostring(stack_type.type_id) .. "+" .. tostring(item.mat_type) .. "+" .. tostring(item.mat_index) .. "+" .. tostring(item:getQuality() .. "+" .. item:getMaker()) + 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 = tostring(stack_type.type_id) .. "+" .. tostring(item.mat_type) .. "+" .. tostring(item.mat_index) .. "+" .. tostring(item:getQuality()) + comp_key = ('%s+%s+%s+%s'):format(stack_type.type_id, item.mat_type, item.mat_index, item:getQuality()) end else - comp_key = tostring(stack_type.type_id) .. "+" .. tostring(item.mat_type) .. "+" .. tostring(item.mat_index) + 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.max_size) + 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) @@ -213,8 +227,8 @@ local function stacks_add_item(stockpile, stacks, stack_type, item, container) 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_size then - stack_type.max_size = item.stack_size + if item.stack_size > stack_type.max_stack_qty then + stack_type.max_stack_qty = item.stack_size end -- item is in a container @@ -231,13 +245,16 @@ end local function sorted_items_qty(tab) -- used to sort the comp_items by contained, then size. Important for combining containers. - local tmp = {} + local sorted = {} for id, val in pairs(tab) do - local val = {id=id, before_cont_id=val.before_cont_id, before_size=val.before_size} - table.insert(tmp, val) + table.insert(sorted, { + id=id, + before_cont_id=val.before_cont_id, + before_size=val.before_size, + }) end - table.sort(tmp, + 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 @@ -251,10 +268,10 @@ local function sorted_items_qty(tab) local iter = function() i = i + 1 - if tmp[i] == nil then + if sorted[i] == nil then return nil else - return tmp[i].id, tab[tmp[i].id] + return sorted[i].id, tab[sorted[i].id] end end return iter @@ -262,13 +279,15 @@ end local function sorted_items_mat(tab) -- used to sort the comp_items by mat amt. - local tmp = {} + local sorted = {} for id, val in pairs(tab) do - local val = {id=id, before_qty=val.before_mat_amt.Qty} - table.insert(tmp, val) + table.insert(sorted, { + id=id, + before_qty=val.before_mat_amt.Qty, + }) end - table.sort(tmp, + table.sort(sorted, function(a, b) return a.before_qty > b.before_qty end @@ -278,10 +297,10 @@ local function sorted_items_mat(tab) local iter = function() i = i + 1 - if tmp[i] == nil then + if sorted[i] == nil then return nil else - return tmp[i].id, tab[tmp[i].id] + return sorted[i].id, tab[sorted[i].id] end end return iter @@ -289,24 +308,26 @@ end local function sorted_desc(tab, ids) -- used to sort the lists by description - local tmp = {} + local sorted = {} for id, val in pairs(tab) do if ids[id] then - local val = {id=id, description=val.description} - table.insert(tmp, val) + table.insert(sorted, { + id=id, + description=val.description, + }) end end - table.sort(tmp, function(a, b) return a.description < b.description end) + table.sort(sorted, function(a, b) return a.description < b.description end) local i = 0 local iter = function() i = i + 1 - if tmp[i] == nil then + if sorted[i] == nil then return nil else - return tmp[i].id, tab[tmp[i].id] + return sorted[i].id, tab[sorted[i].id] end end return iter @@ -316,23 +337,29 @@ local function print_stacks_details(stacks, quiet) -- print stacks details if quiet then return end if #stacks.containers > 0 then - log(1, 'Summary:\nContainers:%5d before:%5d after:%5d\n', #stacks.containers, #stacks.before_cont_ids, #stacks.after_cont_ids) + 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\n'):format(cont.description, cont_id, cont.before_size, cont.after_size)) + 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\n'):format(stacks.item_qty, stacks.before_stacks, stacks.after_stacks, stacks.material_amt)) + 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\n'):format(df.item_type[stack_type.type_id], stack_type.type_id, stack_type.item_qty, stack_type.max_size, stack_type.before_stacks, stack_type.after_stacks, #stack_type.before_cont_ids, #stack_type.after_cont_ids, stack_type.material_amt)) + 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\n'):format(comp_item.description, comp_item.comp_key, comp_item.item_qty, #comp_item.items, comp_item.max_size, comp_item.before_stacks, comp_item.after_stacks, #comp_item.before_cont_ids, #comp_item.after_cont_ids, comp_item.material_amt)) + 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)) - log(3, ('\n')) + 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 @@ -361,12 +388,12 @@ end local function stacks_new() local stacks = {} - stacks.stack_types = CList:new(nil) -- key=type_id, val=stack_type - stacks.containers = CList:new(nil) -- key=container.id, val={container, description, before_size, after_size} - stacks.before_cont_ids = CList:new(nil) -- key=container.id, val=container.id - stacks.after_cont_ids = CList:new(nil) -- key=container.id, val=container.id + 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.material_amt = 0 -- total amount of materials - used for CORPSEPIECEs stacks.before_stacks = 0 stacks.after_stacks = 0 @@ -379,7 +406,7 @@ local function isRestrictedItem(item) 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 flags.hidden or #item.specific_refs > 0 + or #item.specific_refs > 0 end local function isValidPart(item) @@ -394,6 +421,22 @@ local function isValidPart(item) 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 @@ -401,46 +444,52 @@ local function stacks_add_items(stockpile, stacks, items, container, ind) for _, item in pairs(items) do local type_id = item:getType() - local subtype_id = item:getSubtype() 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 isValidPart(item) then - if not isRestrictedItem(item) then + 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\n'):format(ind, utils.getItemDescription(item), item.id, type_id, raceRaw.creature_id, casteRaw.caste_id)) - elseif item:isAmmo() then + 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\n'):format(ind, utils.getItemDescription(item), item.id, type_id, mat_info:toString(), item:getQuality(), item:getMaker())) + 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\n'):format(ind, utils.getItemDescription(item), item.id, type_id, mat_info:toString(), item:isSand(), item:isPlaster(), item:getQuality(), item:getOverallQuality())) + 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\n'):format(ind, utils.getItemDescription(item), item.id)) + 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_size = #contained_items + stacks.containers[item.id].before_vol = volume stacks.containers[item.id].description = utils.getItemDescription(item, 1) - log(4, (' %sContainer:%s <%6d> #items:%5d\n'):format(ind, utils.getItemDescription(item), item.id, count, item:isSandBearing())) + 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\n'):format(ind, utils.getItemDescription(item), item.id, type_id, item:isSand(), item:isPlaster())) + 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 @@ -450,57 +499,64 @@ local function populate_stacks(stacks, stockpiles, types) -- 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\n') + log(4, 'Populating phase') -- iterate across the types - log(4, 'stack types\n') + 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\n'):format(df.item_type[stack_type.type_id], stack_type.type_id, stack_type.item_qty, stack_type.max_size, stack_type.before_stacks, stack_type.after_stacks)) + 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\n')) - for _, stockpile in pairs(stockpiles) do + 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\n'):format(stockpile.name, stockpile.id, stockpile.centerx, stockpile.centery, stockpile.z, #items)) + 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\n') + 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 it's type - log(4, '\nPreview phase\n') + -- 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\n'):format(df.item_type[stack_type.type_id], stack_type.type_id, stack_type.item_qty, stack_type.max_size, stack_type.before_stacks, stack_type.after_stacks)) + 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_key, 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\n'):format(comp_item.description, comp_item.comp_key, comp_item.item_qty, #comp_item.items, comp_item.max_size, comp_item.before_stacks, comp_item.after_stacks, #comp_item.before_cont_ids, #comp_item.after_cont_ids)) + 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_size > comp_item.max_size then - comp_item.max_size = stack_type.max_size + 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 = math.floor(comp_item.item_qty / comp_item.max_size) + 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_size + 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 @@ -515,7 +571,7 @@ local function preview_stacks(stacks) 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_size + item.after_size = comp_item.max_stack_qty elseif stack_remainder > 0 then item.after_size = stack_remainder stack_remainder = 0 @@ -526,7 +582,7 @@ local function preview_stacks(stacks) -- material amount used. else - local stacks_needed = math.floor(comp_item.material_amt / comp_item.max_mat_amt) + 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 @@ -538,7 +594,7 @@ local function preview_stacks(stacks) stack_type.after_stacks = stack_type.after_stacks + comp_item.after_stacks stacks.after_stacks = stacks.after_stacks + comp_item.after_stacks - for k1, item in sorted_items_mat(comp_item.items) do + for _, item in sorted_items_mat(comp_item.items) do item.after_mat_amt = {} if stacks_needed > 0 then stacks_needed = stacks_needed - 1 @@ -571,41 +627,40 @@ local function preview_stacks(stacks) -- Container loop; combine item stacks in containers. local curr_cont = nil - local curr_size = 0 + local curr_cap = nil + local curr_vol = 0 - for item_id, item in sorted_items_qty(comp_item.items) do + 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_size >= MAX_CONT_ITEMS then - + if not curr_cont or curr_vol + vol > curr_cap then curr_cont = before_cont - curr_size = curr_cont.before_size + 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_size = curr_size + 1 - before_cont.after_size = (before_cont.after_size or before_cont.before_size) - 1 + curr_vol = curr_vol + vol + before_cont.after_vol = (before_cont.after_vol or before_cont.before_vol) - vol end - curr_cont.after_size = curr_size + 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_size < MAX_CONT_ITEMS then - - curr_size = curr_size + 1 - curr_cont.after_size = curr_size + 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 @@ -616,37 +671,43 @@ local function preview_stacks(stacks) -- 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_size = (before_cont.after_size or before_cont.before_size) - 1 + 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\n'):format(comp_item.description, comp_item.comp_key, comp_item.item_qty, #comp_item.items, comp_item.max_size, comp_item.before_stacks, comp_item.after_stacks, #comp_item.before_cont_ids, #comp_item.after_cont_ids)) + 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\n'):format(df.item_type[stack_type.type_id], stack_type.type_id, stack_type.item_qty, stack_type.max_size, stack_type.before_stacks, stack_type.after_stacks)) + 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\n') + 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)) + 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\n') + 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\n') + 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\n') + 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 @@ -655,13 +716,15 @@ local function merge_stacks(stacks) 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\n') + 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>\n'):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)) + 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 @@ -671,14 +734,8 @@ local function merge_stacks(stacks) end local function get_stockpile_all() - -- attempt to get all the stockpiles for the fort, or exit with error - -- return the stockpiles as a table - local stockpiles = {} - for _, building in pairs(df.global.world.buildings.all) do - if building:getType() == df.building_type.Stockpile then - table.insert(stockpiles, building) - end - end + -- 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 @@ -689,12 +746,32 @@ 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() + 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) - local items = dfhack.buildings.getStockpileContents(building) if opts.verbose > 0 then - print(('Stockpile(here): %s <%d> #items:%d'):format(building.name, building.id, #items)) + print(('Stockpile(here): %s <%d> #items:%d'):format(building.name, building.id, + #dfhack.buildings.getStockpileContents(building))) end return stockpiles end @@ -742,9 +819,9 @@ local function parse_commandline(opts, args) }) -- if stockpile option is not specificed, then default to all - if args[1] == 'all' then + if positionals[1] == 'all' then opts.all=get_stockpile_all() - elseif args[1] == 'here' then + elseif positionals[1] == 'here' then opts.here=get_stockpile_here() else opts.help = true @@ -760,7 +837,7 @@ end 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\n') + qerror('combine needs a loaded fortress map to work') end parse_commandline(opts, args) 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/deathcause.lua b/deathcause.lua index 7939898597..3fd62fd115 100644 --- a/deathcause.lua +++ b/deathcause.lua @@ -1,47 +1,24 @@ -- show death cause of a creature -local utils = require('utils') +--@ module = true + local DEATH_TYPES = reqscript('gui/unit-info-viewer').DEATH_TYPES --- Creates a table of all items at the given location optionally matching a given item type -function getItemsAtPosition(pos_x, pos_y, pos_z, item_type) - local items = {} - for _, item in ipairs(df.global.world.items.all) do - if item.pos.x == pos_x and item.pos.y == pos_y and item.pos.z == pos_z then - if not item_type or item:getType() == item_type then - table.insert(items, item) - end +-- 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 - return items end --- Finds a unit with the given id or nil if no unit is found -function findUnit(unit_id) - if not unit_id or unit_id == -1 then - return nil - end - - return utils.binsearch(df.global.world.units.all, unit_id, 'id') +local function getRaceNameSingular(race_id) + return df.creature_raw.find(race_id).name[0] end --- Find a histfig with the given id or nil if no unit is found -function findHistFig(histfig_id) - if not histfig_id or histfig_id == -1 then - return nil - end - - return utils.binsearch(df.global.world.history.figures, histfig_id, 'id') -end - -function getRace(race_id) - return df.global.world.raws.creatures.all[race_id] -end - -function getRaceNameSingular(race_id) - return getRace(race_id).name[0] -end - -function getDeathStringFromCause(cause) +local function getDeathStringFromCause(cause) if cause == -1 then return "died" else @@ -49,16 +26,13 @@ function getDeathStringFromCause(cause) end end -function displayDeathUnit(unit) - local str = ("The %s"):format(getRaceNameSingular(unit.race)) - if unit.name.has_name then - str = str .. (" %s"):format(dfhack.TranslateName(unit.name)) - 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 unit.flags2.killed and not unit.flags3.ghostly then - str = str .. " is not dead yet!" - print(str) - return + if not dfhack.units.isDead(unit) then + return str .. " is not dead yet!" end str = str .. (" %s"):format(getDeathStringFromCause(unit.counters.death_cause)) @@ -70,20 +44,20 @@ function displayDeathUnit(unit) if incident.criminal then local killer = df.unit.find(incident.criminal) if killer then - str = str .. (" killed by the %s"):format(getRaceNameSingular(killer.race)) + str = str .. (", killed by the %s"):format(getRaceNameSingular(killer.race)) if killer.name.has_name then - str = str .. (" %s"):format(dfhack.TranslateName(killer.name)) + str = str .. (" %s"):format(dfhack.translation.translateName(dfhack.units.getVisibleName(killer))) end end end end - print(str .. '.') + return str .. '.' end -- returns the item description if the item still exists; otherwise -- returns the weapon name -function getWeaponName(item_id, subtype) +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 @@ -91,19 +65,19 @@ function getWeaponName(item_id, subtype) return dfhack.items.getDescription(item, 0, false) end -function displayDeathEventHistFigUnit(histfig_unit, event) +local function getDeathEventHistFigUnit(histfig_unit, event) local str = ("The %s %s %s in year %d"):format( getRaceNameSingular(histfig_unit.race), - dfhack.TranslateName(histfig_unit.name), + dfhack.translation.translateName(dfhack.units.getVisibleName(histfig_unit)), getDeathStringFromCause(event.death_cause), event.year ) - local slayer_histfig = findHistFig(event.slayer_hf) + 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.TranslateName(slayer_histfig.name) + dfhack.translation.translateName(dfhack.units.getVisibleName(slayer_histfig)) ) end @@ -115,11 +89,11 @@ function displayDeathEventHistFigUnit(histfig_unit, event) end end - print(str .. '.') + return str .. '.' end -- Returns the death event for the given histfig or nil if not found -function getDeathEventForHistFig(histfig_id) +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 @@ -128,63 +102,76 @@ function getDeathEventForHistFig(histfig_id) end end end - - return nil end -function displayDeathHistFig(histfig) - local histfig_unit = findUnit(histfig.unit_id) +-- 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(("Failed to retrieve unit for histfig [histfig_id: %d, histfig_unit_id: %d"):format( - histfig.id, - tostring(histfig.unit_id) - )) + qerror("Cause of death not available") end - if not histfig_unit.flags2.killed and not histfig_unit.flags3.ghostly then - print(("%s is not dead yet!"):format(dfhack.TranslateName(histfig_unit.name))) + 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) - displayDeathEventHistFigUnit(histfig_unit, death_event) + return getDeathEventHistFigUnit(histfig_unit, death_event) end end -local selected_item = dfhack.gui.getSelectedItem(true) -local selected_unit = dfhack.gui.getSelectedUnit(true) -local hist_figure_id - -if not selected_unit and (not selected_item or selected_item:getType() ~= df.item_type.CORPSE) then - -- if there isn't a selected unit and we don't have a selected item or the selected item is not a corpse - -- let's try to look for corpses under the cursor because it's probably what the user wants - -- we will just grab the first one as it's the best we can do - local items = getItemsAtPosition(df.global.cursor.x, df.global.cursor.y, df.global.cursor.z, df.item_type.CORPSE) - if #items > 0 then - print("Automatically chose first corpse under cursor.") - selected_item = items[1] +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 -if not selected_unit and not selected_item then - qerror("Please select a corpse") +-- 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 selected_item then - hist_figure_id = selected_item.hist_figure_id -elseif selected_unit then - hist_figure_id = selected_unit.hist_figure_id +if dfhack_flags.module then + return end +local hist_figure_id, selected_unit = get_target() + if not hist_figure_id then - qerror("Failed to find hist_figure_id. This is not user error") + qerror("Cause of death not available") elseif hist_figure_id == -1 then if not selected_unit then - selected_unit = findUnit(selected_item.unit_id) - if not selected_unit then - qerror("Not a historical figure, cannot find death info") - end + qerror("Cause of death not available") end - - displayDeathUnit(selected_unit) + print(dfhack.df2console(getDeathCause(selected_unit))) else - displayDeathHistFig(findHistFig(hist_figure_id)) + print(dfhack.df2console(getDeathCause(df.historical_figure.find(hist_figure_id)))) end diff --git a/deep-embark.lua b/deep-embark.lua index 5953ae0dac..6738bd532f 100644 --- a/deep-embark.lua +++ b/deep-embark.lua @@ -1,352 +1,360 @@ --- Embark underground. --- author: Atomic Chicken - --@ module = true -local utils = require 'utils' +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 + 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 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) + 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 - end - return featureBlocks + return featureBlocks end function isValidTiletype(tiletype) - local tiletype = df.tiletype[tiletype] - local tiletypeAttrs = df.tiletype.attrs[tiletype] - local material = tiletypeAttrs.material - local forbiddenMaterials = { - 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 - } - for _,forbidden in ipairs(forbiddenMaterials) do - if material == forbidden then - return false - end - end - local shapeAttrs = df.tiletype_shape.attrs[tiletypeAttrs.shape] - return shapeAttrs.walkable + 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 + 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 - end - return validTiles + 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 - local announcementFlags = df.global.d_init.announcements.flags.ENDGAME_EVENT_1 -- glowing barrier disappearance announcement - 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 + -- 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 - status.display_timer = 0 -- to avoid displaying other announcements - end - 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 - block.tiletype[x%16][y%16] = df.tiletype.GlowingBarrier - local barriers = df.global.world.glowing_barriers + -- 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.buildings:insert('#', -1) -- being unbound to a building makes the barrier disappear immediately barrier.pos:assign(pos) - barriers:insert('#',barrier) + barriers:insert('#', barrier) local hfs = df.glowing_barrier:new() - hfs.triggered = true -- 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 + 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 end function moveEmbarkStuff(selectedBlock, embarkTiles) - local spawnPosCentre - for _, hotkey in ipairs(df.global.plotinfo.main.hotkeys) do - if hotkey.name == "Wagon arrival location" 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 + 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 - 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 + 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 --- 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 + -- 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 - 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 == 0 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) + -- 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 - 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 + dfhack.buildings.deconstruct(wagon) + wagon.flags.almost_deleted = true -- wagon vanishes a tick later + wagonFound = true + break 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 - 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 flags.on_ground - and not flags.in_inventory - and not flags.in_building - and not flags.in_chest - and not flags.construction - and not flags.spider_web - and not flags.encased then - dfhack.items.moveToGround(item, embarkTiles[math.random(1, #embarkTiles)]) - 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 - 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 + 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 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 + 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 - table.remove(cavernBlocks, i) - end - if not moved then - qerror('Insufficient space at ' .. cavernType) - end - if blockDemons then - disableSpireDemons() - 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.deep_vein_hollows) do - spire.triggered = true - end + -- 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 or view.parent._type == valid and view._type ~= df.viewscreen_textviewerst then -- df.viewscreen_textviewerst is present right after embarking (displays the embark message) and has .parent._type == df.viewscreen_setupdwarfgamest - return true + 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 - end - return false + return false end local validArgs = utils.invert({ - 'depth', - 'atReclaim', - 'blockDemons', - 'clear', - 'help' + 'depth', + 'atReclaim', + 'blockDemons', + 'clear', + 'help' }) -local args = utils.processArgs({...}, validArgs) +local args = utils.processArgs({ ... }, validArgs) if moduleMode then - return + return end if args.help then - print(dfhack.script_help()) - return + print(dfhack.script_help()) + return end if args.clear then - dfhack.onStateChange.DeepEmbarkMonitor = nil - print("Cleared settings; now embarking normally.") - return + 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.') + 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 + ["CAVERN_1"] = true, + ["CAVERN_2"] = true, + ["CAVERN_3"] = true, + ["UNDERWORLD"] = true } if not validDepths[args.depth] then - qerror("Invalid depth: " .. args.depth) + 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.') + -- 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)) + 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) + 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 - 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 index eb94dcd143..42c0498aad 100644 --- a/deteriorate.lua +++ b/deteriorate.lua @@ -1,88 +1,159 @@ -- Cause selected item types to quickly rot away --@module = true +--@enable = true local argparse = require('argparse') local utils = require('utils') -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} +-------------------- +-- 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 -local function get_corpse_vectors() - return {df.global.world.items.other.ANY_CORPSE} +state = state or get_default_state() + +function isEnabled() + return state.enabled end -local function get_remains_vectors() - return {df.global.world.items.other.REMAINS} +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} + 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 keep_usable(opts, item) - return opts.keep_usable and ( - not item.corpse_flags.unbutchered and ( - item.corpse_flags.bone or - item.corpse_flags.horn or - item.corpse_flags.leather or - item.corpse_flags.skull2 or - item.corpse_flags.tooth) or ( - item.corpse_flags.hair_wool or - item.corpse_flags.pearl or - item.corpse_flags.plant or - item.corpse_flags.shell or - item.corpse_flags.silk or - item.corpse_flags.yarn) ) +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 -local function is_valid_corpse(opts, item) - -- check if the corpse is a resident of the fortress and is not keep_usable - local unit = df.unit.find(item.unit_id) - if not unit then - return not keep_usable(opts, item) - end - local hf = df.historical_figure.find(unit.hist_figure_id) - if not hf then - return not keep_usable(opts, item) +-- 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 _,link in ipairs(hf.entity_links) do - if link.entity_id == df.global.plotinfo.group_id and df.histfig_entity_link_type[link:getType()] == 'MEMBER' then - return false - end + for _,flag in ipairs(usable_types) do + if item.corpse_flags[flag] then return true end end - return not keep_usable(opts, item) + return false end -local function is_valid_remains(opts, item) - return true +local function is_valid_usable_corpse_piece(item) + return not is_entombed(item) and is_usable_corpse_piece(item) end -local function is_valid_food(opts, item) - return true +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_generic_wear(item, threshold) +local function increment_wear(threshold, item) item.wear_timer = item.wear_timer + 1 if item.wear_timer > threshold then item.wear_timer = 0 @@ -91,212 +162,231 @@ local function increment_generic_wear(item, threshold) return item.wear > 3 end -local function increment_corpse_wear(item) - return increment_generic_wear(item, 24) -end - -local function increment_remains_wear(item) - return increment_generic_wear(item, 6) -end - -local function increment_food_wear(item) - return increment_generic_wear(item, 24) -end - -local function deteriorate(opts, get_item_vectors_fn, is_valid_fn, increment_wear_fn) - local count = 0 +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(opts, item) and increment_wear_fn(item) - and not item.flags.garbage_collect then - item.flags.garbage_collect = true - item.flags.hidden = true - count = count + 1 + 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 - return count + 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 always_worn() - return true +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 function deteriorate_clothes(opts, now) - return deteriorate(opts, get_clothes_vectors, is_valid_clothing, - now and always_worn or increment_clothes_wear) -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)), +} -local function deteriorate_corpses(opts, now) - return deteriorate(opts, get_corpse_vectors, is_valid_corpse, - now and always_worn or increment_corpse_wear) - + deteriorate(opts, get_remains_vectors, is_valid_remains, - now and always_worn or increment_remains_wear) -end +---------------------------- +-- cycle and timer logic -local function deteriorate_food(opts, now) - return deteriorate(opts, get_food_vectors, is_valid_food, - now and always_worn or increment_food_wear) +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 -local type_fns = { - clothes=deteriorate_clothes, - corpses=deteriorate_corpses, - food=deteriorate_food, -} +timeout_ids = timeout_ids or {} --- maps the type string to {id=int, time=int, timeunit=string} -timeout_ids = timeout_ids or { - clothes={}, - corpses={}, - food={}, -} +local function event_loop(category) + local category_data = state.categories[category] + if not state.enabled or not category_data.enabled then return end -local function _stop(item_type) - local timeout_id = timeout_ids[item_type].id + 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[item_type].id = nil - return true + timeout_ids[category] = nil end end -local function make_timeout_cb(item_type, opts) - local fn - fn = function(first_time) - local timeout_data = timeout_ids[item_type] - timeout_data.time, timeout_data.mode = opts.time, opts.mode - timeout_data.id = dfhack.timeout(opts.time, opts.mode, fn) - if not timeout_ids[item_type].id then - print('Map has been unloaded; stopping deteriorate') - for k in pairs(type_fns) do - _stop(k) - end - return - end - if not first_time then - local count = type_fns[item_type](opts) - if count > 0 then - print(('Deteriorated %d %s'):format(count, item_type)) - 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 - return fn end -local function start(opts) - for _,v in ipairs(opts.types) do - _stop(v) - if not opts.quiet then - print(('Deterioration of %s commencing...'):format(v)) - end - -- create a callback and call it to make it register itself - make_timeout_cb(v, opts)(true) +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 -local function stop(opts) - for _,v in ipairs(opts.types) do - if _stop(v) and not opts.quiet then - print('Stopped deteriorating ' .. v) - end +dfhack.onStateChange[GLOBAL_KEY] = function(sc) + if sc == SC_MAP_UNLOADED then + do_disable() + return end -end -local function status() - for k in pairs(type_fns) do - local timeout_data = timeout_ids[k] - local status_str = 'Stopped' - if timeout_data.id then - local time, mode = timeout_data.time, timeout_data.mode - if time == 1 then - mode = mode:sub(1, #mode - 1) -- make singular - end - status_str = ('Running (every %s %s)') :format(time, mode) - end - print(('%7s:\t%s'):format(k, status_str)) + if sc ~= SC_MAP_LOADED or not dfhack.world.isFortressMode() then + return end -end -local function now(opts) - for _,v in ipairs(opts.types) do - local count = type_fns[v](opts, true) - if not opts.quiet then - print(('Deteriorated %d %s'):format(count, v)) - 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 -local function help() - print(dfhack.script_help()) -end +--------------------- +-- CLI if dfhack_flags.module then return end -if not dfhack.isMapLoaded() then - qerror('deteriorate needs a fortress map to be loaded.') +if dfhack_flags.enable then + if dfhack_flags.enable_state then + do_enable() + else + do_disable() + end end -local command_switch = { - start=start, - stop=stop, - status=status, - now=now, -} - -local valid_timeunits = utils.invert{'days', 'months', 'years'} - -local function parse_freq(arg) - local elems = argparse.stringList(arg) - local num = tonumber(elems[1]) - if not num or num <= 0 then - qerror('number parameter for --freq option must be greater than 0') +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 #elems == 1 then - return num, 'days' + if #list == 0 then + qerror('no categories specified') end - local timeunit = elems[2]:lower() - if valid_timeunits[timeunit] then return num, timeunit end - timeunit = timeunit .. 's' -- it's ok if the user specified a singular - if valid_timeunits[timeunit] then return num, timeunit end - qerror(('invalid time unit: "%s"'):format(elems[2])) + return list end -local function parse_types(arg) - local types = argparse.stringList(arg) - for _,v in ipairs(types) do - if not type_fns[v] then - qerror(('unrecognized type: "%s"'):format(v)) +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 - return types end -local opts = { - time = 1, - mode = 'days', - quiet = false, - types = {}, - keep_usable = false, - help = false, -} +local help = false -local nonoptions = argparse.processArgsGetopt({...}, { - {'f', 'freq', 'frequency', hasArg=true, - handler=function(optarg) opts.time,opts.mode = parse_freq(optarg) end}, - {'h', 'help', handler=function() opts.help = true end}, - {'q', 'quiet', handler=function() opts.quiet = true end}, - {'k', 'keep-usable', handler=function() opts.keep_usable = true end}, - {'t', 'types', hasArg=true, - handler=function(optarg) opts.types = parse_types(optarg) end}}) +local positionals = argparse.processArgsGetopt({...}, { + {'h', 'help', handler=function() help = true end}, +}) -local command = nonoptions[1] -if not command or not command_switch[command] then opts.help = true end +local command = table.remove(positionals, 1) +if command == 'help' or help then + print(dfhack.script_help()) + return +end -if not opts.help and command ~= 'status' and #opts.types == 0 then - qerror('no item types specified! try adding a --types parameter.') +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 -(command_switch[command] or help)(opts) +persist_state() 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/block-borders.lua b/devel/block-borders.lua index 8d125d9b88..445bdcaab5 100644 --- a/devel/block-borders.lua +++ b/devel/block-borders.lua @@ -1,19 +1,8 @@ -- overlay that displays map block borders ---[====[ - -devel/block-borders -=================== - -An overlay that draws borders of map blocks. See :doc:`/docs/api/Maps` for -details on map blocks. - -]====] - -local gui = require "gui" +local gui = require('gui') local guidm = require "gui.dwarfmode" - -local ui = df.global.plotinfo +local widgets = require('gui.widgets') local DRAW_CHARS = { ns = string.char(179), @@ -23,64 +12,39 @@ local DRAW_CHARS = { se = string.char(218), sw = string.char(191), } --- persist across script runs -color = color or COLOR_LIGHTCYAN -BlockBordersOverlay = defclass(BlockBordersOverlay, guidm.MenuOverlay) -BlockBordersOverlay.ATTRS{ - block_size = 16, - draw_borders = true, +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 BlockBordersOverlay:onInput(keys) - if keys.LEAVESCREEN then - self:dismiss() - elseif keys.D_PAUSE then - self.draw_borders = not self.draw_borders - elseif keys.CUSTOM_B then - self.block_size = self.block_size == 16 and 48 or 16 - elseif keys.CUSTOM_C then - color = color + 1 - if color > 15 then - color = 1 - end - elseif keys.CUSTOM_SHIFT_C then - color = color - 1 - if color < 1 then - color = 15 - end - elseif keys.D_LOOK then - self:sendInputToParent(ui.main.mode == df.ui_sidebar_mode.LookAround and 'LEAVESCREEN' or 'D_LOOK') - else - self:propagateMoveKeys(keys) - end -end - -function BlockBordersOverlay:onRenderBody(dc) - dc = dc:viewport(1, 1, dc.width - 2, dc.height - 2) - dc:key_string('D_PAUSE', 'Toggle borders') - :newline() - dc:key_string('CUSTOM_B', self.block_size == 16 and '1 block (16 tiles)' or '3 blocks (48 tiles)') - :newline() - dc:key('CUSTOM_C') - :string(', ') - :key_string('CUSTOM_SHIFT_C', 'Color: ') - :string('Example', color) - :newline() - dc:key_string('D_LOOK', 'Toggle cursor') - :newline() - - self:renderOverlay() +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 BlockBordersOverlay:renderOverlay() - if not self.draw_borders then return end - - local block_end = self.block_size - 1 - self:renderMapOverlay(function(pos, is_cursor) +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 % self.block_size - local block_y = pos.y % self.block_size + 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' @@ -95,16 +59,34 @@ function BlockBordersOverlay:renderOverlay() elseif block_y == 0 or block_y == block_end then key = 'ew' end - return DRAW_CHARS[key], color or COLOR_LIGHTCYAN + 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 --- we can work both with a cursor and without one. start in a mode that mirrors --- the current game state -local is_cursor = not not guidm.getCursorPos() -local sidebar_mode = df.ui_sidebar_mode[is_cursor and 'LookAround' or 'Default'] -BlockBordersOverlay{sidebar_mode=sidebar_mode}:show() +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/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/export-dt-ini.lua b/devel/export-dt-ini.lua index 49d46c4bd7..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 @@ -99,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') @@ -123,7 +116,7 @@ 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') @@ -176,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') @@ -208,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') @@ -244,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') @@ -268,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') @@ -321,19 +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','size_cur') address('size_base',df.unit,'body','size_info','size_base') -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('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') @@ -349,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') @@ -369,16 +356,16 @@ address('trans_race_vec',df.creature_interaction_effect_body_transformationst,'r 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') @@ -390,7 +377,7 @@ 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,'flags') +address('goal_realized',df.personality_goalst,'flags') address('traits',df.unit_personality,'traits') address('stress_level',df.unit_personality,'stress') address('needs',df.unit_personality,'needs') @@ -400,19 +387,19 @@ 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') @@ -434,21 +421,25 @@ 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_routine_idx') -address('carry_food',df.squad,'carry_food') -address('carry_water',df.squad,'carry_water') +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') @@ -461,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','flag_type') -address('knowledge_flag',df.activity_event_ponder_topicst,'knowledge','flag_data') +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') @@ -512,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 26d2100738..0000000000 --- a/devel/find-offsets.lua +++ /dev/null @@ -1,1963 +0,0 @@ --- Find some global addresses ---luacheck:skip-entirely ---[====[ - -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 'plotinfo' and df.global.plotinfo.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.temp_save, 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.temp_save then - print('Different or zero size of units.all and units.temp_save:'..#world.units.all..' vs '..#world.units.temp_save) - 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 - --- --- plotinfo --- - -local function is_valid_plotinfo(plotinfo) - if not ms.is_valid_vector(plotinfo.economic_stone, 1) - or not ms.is_valid_vector(plotinfo.dipscripts, PTR_SIZE) - then - dfhack.printerr('Vector layout check failed.') - return false - end - - if plotinfo.follow_item ~= -1 or plotinfo.follow_unit ~= -1 then - print('Invalid follow state: '..plotinfo.follow_item..', '..plotinfo.follow_unit) - return false - end - - return true -end - -local function find_plotinfo() - 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 plotinfo.', - 'int16_t', - feed_menu_choice(catnames, catkeys, df.ui_sidebar_mode), - 20 - ) - end - - if not addr then - addr = searcher:find_menu_cursor([[ -Searching for plotinfo. Please open the designation -menu, and switch modes as instructed below:]], - 'int16_t', catnames, df.ui_sidebar_mode - ) - end - - validate_offset('plotinfo', is_valid_plotinfo, addr, df.plotinfost, 'main', 'mode') -end - --- --- game --- - -local function is_valid_game(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_game() - local addr - - if dwarfmode_to_top() then - dwarfmode_feed_input('D_BUILDJOB') - - addr = searcher:find_interactive([[ -Auto-searching for game. 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 game. 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('game', is_valid_game, - addr, df.game, 'workshop_job', 'cursor') -end - --- --- buildreq --- - -local function is_valid_buildreq(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_buildreq() - local addr - - if dwarfmode_to_top() then - addr = searcher:find_interactive([[ -Auto-searching for buildreq. 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 buildreq. 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('buildreq', is_valid_buildreq, - addr, df.buildreq, '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('buildreq', 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_plotinfo, 'plotinfo', is_valid_plotinfo) -exec_finder(find_game, 'game') -exec_finder(find_buildreq, 'buildreq') -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/find-twbt.lua b/devel/find-twbt.lua deleted file mode 100644 index 61c75b3787..0000000000 --- a/devel/find-twbt.lua +++ /dev/null @@ -1,83 +0,0 @@ --- Find some TWBT-related offsets ---luacheck:skip-entirely ---[====[ - -devel/find-twbt -=============== - -Finds some TWBT-related offsets - currently just ``twbt_render_map``. - -]====] -local ms = require('memscan') -local cs = ms.get_code_segment() - -function get_ptr_size() - local v = df.new('uintptr_t') - local ret = df.sizeof(v) - df.delete(v) - return ret -end - -function print_off(name, off) - print(string.format("", name, off - dfhack.internal.getRebaseDelta())) -end - -local ptr_size = get_ptr_size() - -local vtoff = dfhack.internal.getVTable('viewscreen_dwarfmodest') --- print_off("Vtable:", vtoff) - -local vtable = ms.CheckedArray.new('uintptr_t', vtoff, vtoff + 3 * ptr_size) - -local render_method = vtable[2] --third method aka render --- print_off("render", render_method) - -function list_all_possible_calls(start, len) - local func = ms.CheckedArray.new('uint8_t', start, start + len) -- should be near - local possible_calls = {} - - for i = 0, #func - 1 do - if func[i] == 0xe8 then - table.insert(possible_calls, i) - end - end - return possible_calls -end -function get_call_target(offset) - local call_offset = df.reinterpret_cast('int32_t', offset + 1) - local ret = call_offset.value + offset + 5 - if cs:contains_range(ret, 1) then - return ret - else - return nil - end -end -function list_all_valid_calls(start, len) - local all_calls = list_all_possible_calls(start, len) - local ret = {} - for i, v in ipairs(all_calls) do - local ct = get_call_target(start + v) - if ct then - table.insert(ret, ct) - end - end - return ret -end --- TODO(warmist): this is probably stupid, but without disassembler ( sad face ) i can't say for sure --- if it's part of instruction or an arg, so we just hope to find only one? - -local possible_calls = list_all_valid_calls(render_method, 50) - -if #possible_calls == 0 then - qerror("Failed to find call instruction in render vmethod") -elseif #possible_calls > 1 then - qerror("Found multiple 0xe8 (call) in render vmethod start") -end - -local dwarfmode_render_main = possible_calls[1] -print_off("twbt_render_map", dwarfmode_render_main) - ---[[ other not used offset(s) -local dfrender = list_all_valid_calls(dwarfmode_render_main, 200)[2] --this could be further -print_off("A_RENDER_MAP", dfrender) -]] diff --git a/devel/hello-world.lua b/devel/hello-world.lua index 679bf1d52e..2af576f815 100644 --- a/devel/hello-world.lua +++ b/devel/hello-world.lua @@ -12,17 +12,27 @@ local HIGHLIGHT_PEN = dfhack.pen.parse{ HelloWorldWindow = defclass(HelloWorldWindow, widgets.Window) HelloWorldWindow.ATTRS{ - frame={w=20, h=14}, + frame={w=25, h=25}, frame_title='Hello World', autoarrange_subviews=true, - autoarrange_gap=1, + 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, t=0}, + frame={l=0}, label='Click me', key='CUSTOM_CTRL_A', on_activate=self:callback('toggleHighlight'), @@ -32,6 +42,28 @@ function HelloWorldWindow:init() 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 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 36d42564be..c92f4b5f15 100644 --- a/devel/inspect-screen.lua +++ b/devel/inspect-screen.lua @@ -166,6 +166,8 @@ local function get_ui_report(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) @@ -174,6 +176,8 @@ local function get_ui_report(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 diff --git a/devel/kill-hf.lua b/devel/kill-hf.lua index e2cf5f7215..63b8083988 100644 --- a/devel/kill-hf.lua +++ b/devel/kill-hf.lua @@ -1,31 +1,5 @@ -- Kills the specified historical figure ---[====[ - -devel/kill-hf -============= - -Kills the specified historical figure, even if off-site, or terminates a -pregnancy. Useful for working around :bug:`11549`. - -Usage:: - - devel/kill-hf [-p|--pregnancy] [-n|--dry-run] HISTFIG_ID - -Arguments: - -``histfig_id``: - the ID of the historical figure to target - -``-p``, ``--pregnancy``: - if specified, and if the historical figure is pregnant, terminate the - pregnancy instead of killing the historical figure - -``-n``, ``--dry-run``: - if specified, only print the name of the historical figure - -]====] - local target_hf = -1 local target_pregnancy = false local dry_run = false @@ -44,7 +18,7 @@ end local hf = df.historical_figure.find(target_hf) or qerror('histfig not found: ' .. target_hf) -local hf_name = dfhack.df2console(dfhack.TranslateName(hf.name)) +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 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/make-dt.pl b/devel/make-dt.pl index 4bde9e22c6..9d8ba2c433 100755 --- a/devel/make-dt.pl +++ b/devel/make-dt.pl @@ -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'; 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 ef320ab090..8b90620854 100644 --- a/devel/pop-screen.lua +++ b/devel/pop-screen.lua @@ -1,24 +1,3 @@ -- Forcibly closes the current screen ---[====[ - -devel/pop-screen -================ -Forcibly closes the current screen. This is usually equivalent to pressing -:kbd:`Esc` (``LEAVESCREEN``), but will bypass the screen's input handling. This is -intended primarily for development, if you have created a screen whose input -handling throws an error before it handles :kbd:`Esc` (or if you have forgotten -to handle :kbd:`Esc` entirely). - -.. warning:: - - If you run this script when the current screen does not have a parent, - this will cause DF to exit **immediately**. These screens include: - - * The main fortress mode screen (``viewscreen_dwarfmodest``) - * The main adventure mode screen (``viewscreen_dungeonmodest``) - * The main legends mode screen (``viewscreen_legendsst``) - * The title screen (``viewscreen_titlest``) - -]====] dfhack.screen.dismiss(dfhack.gui.getCurViewscreen()) diff --git a/devel/prepare-save.lua b/devel/prepare-save.lua deleted file mode 100644 index c5add069df..0000000000 --- a/devel/prepare-save.lua +++ /dev/null @@ -1,100 +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.plotinfo.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-event.lua b/devel/print-event.lua index f2d9a54aad..771ac55299 100644 --- a/devel/print-event.lua +++ b/devel/print-event.lua @@ -34,7 +34,7 @@ end local function print_event(event) local str = df.new("string") local ctx = df.history_event_context:new() - event:getSentence(str, ctx) + event:getSentence(str, ctx, true, false) ctx:delete() print(str.value) str:delete() diff --git a/devel/query.lua b/devel/query.lua index e18ba39450..610003873c 100644 --- a/devel/query.lua +++ b/devel/query.lua @@ -2,7 +2,8 @@ -- Written by Josh Cooper(cppcooper) on 2017-12-21, last modified: 2021-06-13 -- Version: 3.2 --luacheck:skip-entirely -local utils=require('utils') +local guidm = require('gui.dwarfmode') +local utils = require('utils') local validArgs = utils.invert({ 'help', @@ -220,15 +221,12 @@ function getSelectionData() path_info_pattern = path_info elseif args.job then debugf(0,"job selection") - selection = dfhack.gui.getSelectedJob() - if selection == nil and df.global.cursor.x >= 0 then - local pos = { x=df.global.cursor.x, - y=df.global.cursor.y, - z=df.global.cursor.z } + 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 - local jp = job.pos - if jp.x == pos.x and jp.y == pos.y and jp.z == pos.z then + if same_xyz(job.pos, pos) then if selection == nil then selection = {} end @@ -240,7 +238,7 @@ function getSelectionData() path_info_pattern = path_info elseif args.tile then debugf(0,"tile selection") - local pos = copyall(df.global.cursor) + 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) @@ -249,7 +247,7 @@ function getSelectionData() tiley = pos.y%16 elseif args.block then debugf(0,"block selection") - local pos = copyall(df.global.cursor) + 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) diff --git a/devel/sc.lua b/devel/sc.lua index 6cf8aab27b..085514fc9b 100644 --- a/devel/sc.lua +++ b/devel/sc.lua @@ -1,35 +1,6 @@ -- Size Check --author mifki --luacheck:skip-entirely --- ---[====[ - -devel/sc -======== -Size Check: scans structures for invalid vectors, misaligned structures, -and unidentified enum values. - -.. note:: - - This script can take a very long time to complete, and DF may be - unresponsive while it is running. You can use `kill-lua` to interrupt - this script. - -Examples: - -* scan world:: - - devel/sc - -* scan all globals:: - - devel/sc -all - -* scan result of expression:: - - devel/sc [expr] - -]====] local utils = require('utils') @@ -38,7 +9,6 @@ local check_vectors = true local check_pointers = true local check_enums = true - -- not really a queue, I know local queue @@ -119,7 +89,7 @@ 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 + local i = ((count // 500) % 4) + 1 dfhack.print(prog:sub(i, i) .. '\r') dfhack.console.flush() end @@ -205,7 +175,6 @@ local function check_container(obj, path) 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 (obj._type == df.history_event_body_abusedst.T_props) 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) diff --git a/devel/scan-vtables.lua b/devel/scan-vtables.lua index c64d549d16..069e43c0b0 100644 --- a/devel/scan-vtables.lua +++ b/devel/scan-vtables.lua @@ -1,71 +1,122 @@ -- Scan and dump likely vtable addresses -memscan = require('memscan') +local memscan = require('memscan') local osType = dfhack.getOSType() if osType ~= 'linux' then qerror('unsupported OS: ' .. osType) end -local df_ranges = {} -for i,mem in ipairs(dfhack.internal.getMemRanges()) do - if mem.read and ( - string.match(mem.name,'/dwarfort%.exe$') - or string.match(mem.name,'/dwarfort$') - or string.match(mem.name,'/Dwarf_Fortress$') - or string.match(mem.name,'Dwarf Fortress%.exe') - or string.match(mem.name,'/libg_src_lib.so$') - ) - then - table.insert(df_ranges, mem) +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 _, mem in ipairs(df_ranges) do - if a >= mem.start_addr and a < mem.end_addr then + 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 -for _, range in ipairs(df_ranges) do - if (not range.read) or range.write or range.execute or range.name:match('g_src') then - goto next_range - end +local function is_vtable_range(range) + return not range.write and not range.execute +end - local area = memscan.MemoryArea.new(range.start_addr, range.end_addr) - 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 is_df_addr(typeinfo + 8) then +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 is_df_addr(typestring) then - -- 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 - -- 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 ok then - -- 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::') then - print((""):format(demangled_name, vtable)) - end - end + 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 - ::next_range:: + + 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/tile-browser.lua b/devel/tile-browser.lua index 94c9718b05..f5071c02e6 100644 --- a/devel/tile-browser.lua +++ b/devel/tile-browser.lua @@ -1,77 +1,72 @@ local gui = require('gui') local widgets = require('gui.widgets') +local raws = df.global.enabler.textures.raws + TileBrowser = defclass(TileBrowser, gui.ZScreen) TileBrowser.ATTRS{ focus_string='tile-browser', } function TileBrowser:init() - self.max_texpos = #df.global.enabler.textures.raws - self.tile_size = 1 + self.dirty = true local main_panel = widgets.Window{ - view_id='main', - frame={w=32, h=30}, - drag_anchors={title=true, body=true}, + frame_title='Tile Browser', + frame={w=35, h=30}, resizable=true, resize_min={h=20}, - on_layout=self:callback('on_resize'), - frame_title='Tile Browser', } main_panel:addviews{ widgets.EditField{ view_id='start_index', frame={t=0, l=0}, - key='CUSTOM_CTRL_A', + key='CUSTOM_ALT_S', label_text='Start index: ', text='0', on_submit=self:callback('set_start_index'), - on_char=function(ch) return ch:match('%d') end}, + on_char=function(ch) return ch:match('%d') end, + }, + widgets.Label{ + view_id='header', + frame={t=2, l=8}, + text='0123456789 0123456789', + }, + widgets.Label{ + view_id='report', + frame={t=3, b=3, l=0}, + auto_height=false, + }, + widgets.Label{ + view_id='footer', + frame={b=2, l=8}, + text='0123456789 0123456789', + }, widgets.HotkeyLabel{ - frame={t=1, l=0}, + frame={b=0, l=0}, label='Prev', key='KEYBOARD_CURSOR_UP_FAST', - on_activate=self:callback('shift_start_index', -1000)}, + auto_width=true, + on_activate=self:callback('shift_start_index', -1000), + }, widgets.Label{ - frame={t=1, l=6, w=1}, - text={{text=string.char(24), pen=COLOR_LIGHTGREEN}}}, + frame={b=0, l=6, w=1}, + text={{text=string.char(24), pen=COLOR_LIGHTGREEN}}, + }, widgets.HotkeyLabel{ - frame={t=1, l=15}, + frame={b=0, l=18}, label='Next', key='KEYBOARD_CURSOR_DOWN_FAST', - on_activate=self:callback('shift_start_index', 1000)}, - widgets.Label{ - frame={t=1, l=21, w=1}, - text={{text=string.char(25), pen=COLOR_LIGHTGREEN}}}, + auto_width=true, + on_activate=self:callback('shift_start_index', 1000), + }, widgets.Label{ - view_id='header', - frame={t=3, l=0, r=2}}, - widgets.Panel{ - view_id='transparent_area', - frame={t=4, b=4, l=5, r=2}, - frame_background=gui.TRANSPARENT_PEN}, - widgets.Label{ - view_id='report', - frame={t=4, b=4}, - scroll_keys={ - STANDARDSCROLL_UP = -1, - KEYBOARD_CURSOR_UP = -1, - STANDARDSCROLL_DOWN = 1, - KEYBOARD_CURSOR_DOWN = 1, - STANDARDSCROLL_PAGEUP = '-page', - STANDARDSCROLL_PAGEDOWN = '+page'}}, - widgets.Label{ - view_id='footer', - frame={b=3, l=0, r=2}}, - widgets.WrappedLabel{ - frame={b=0}, - text_to_wrap='Please resize window to change visible tile size.'}, + frame={b=0, l=24, w=1}, + text={{text=string.char(25), pen=COLOR_LIGHTGREEN}}, + }, } self:addviews{main_panel} - - self:set_start_index('0') end function TileBrowser:shift_start_index(amt) @@ -82,57 +77,32 @@ function TileBrowser:set_start_index(idx) idx = tonumber(idx) if not idx then return end - idx = math.max(0, math.min(self.max_texpos - 980, idx)) + idx = math.max(0, math.min(#raws - 980, idx)) idx = idx - (idx % 20) -- floor to nearest multiple of 20 self.subviews.start_index:setText(tostring(idx)) - self.subviews.transparent_area.frame.l = #tostring(idx) + 4 self.dirty = true end function TileBrowser:update_report() local idx = tonumber(self.subviews.start_index.text) - local end_idx = math.min(self.max_texpos, idx + 999) - local prefix_len = #tostring(idx) + 4 - - local guide = {} - table.insert(guide, {text='', width=prefix_len}) - for n=0,9 do - table.insert(guide, {text=tostring(n), width=self.tile_size}) - end - table.insert(guide, {text='', width=self.tile_size}) - for n=0,9 do - table.insert(guide, {text=tostring(n), width=self.tile_size}) - end - self.subviews.header:setText(guide) - self.subviews.footer:setText(guide) + local end_idx = math.min(#raws-1, idx+999) local report = {} for texpos=idx,end_idx do if texpos % 20 == 0 then - table.insert(report, {text=tostring(texpos), width=prefix_len}) + table.insert(report, {text=texpos, width=7, rjustify=true}) + table.insert(report, ' ') elseif texpos % 10 == 0 then - table.insert(report, {text='', pad_char=' ', width=self.tile_size}) + table.insert(report, ' ') end - table.insert(report, {tile=texpos, pen=gui.KEEP_LOWER_PEN, width=self.tile_size}) + table.insert(report, {tile=texpos, pen=gui.KEEP_LOWER_PEN, width=1}) if (texpos+1) % 20 == 0 then - for n=1,self.tile_size do - table.insert(report, NEWLINE) - end + table.insert(report, NEWLINE) end end self.subviews.report:setText(report) - self.subviews.main:updateLayout() -end - -function TileBrowser:on_resize(body) - local report_width = body.width - 15 - local prev_tile_size = self.tile_size - self.tile_size = (report_width // 21) + 1 - if prev_tile_size ~= self.tile_size then - self.dirty = true - end end function TileBrowser:onRenderFrame() diff --git a/devel/tree-info.lua b/devel/tree-info.lua new file mode 100644 index 0000000000..ec59563de0 --- /dev/null +++ b/devel/tree-info.lua @@ -0,0 +1,184 @@ +--Print a tree_info visualization of the tree at the cursor. +--@module = true +local guidm = require('gui.dwarfmode') + +-- [w][n][e][s] +local branch_chars = { + [true]={ + [true]={ + [true]={ + [true]=string.char(197), --WNES + [false]=string.char(193), --WNE + }, + [false]={ + [true]=string.char(180), --WNS + [false]=string.char(217), --WN + }, + }, + [false]={ + [true]={ + [true]=string.char(194), --WES + [false]=string.char(196), --WE + }, + [false]={ + [true]=string.char(191), --WS + [false]=string.char(26), --W + }, + }, + }, + [false]={ + [true]={ + [true]={ + [true]=string.char(195), --NES + [false]=string.char(192), --NE + }, + [false]={ + [true]=string.char(179), --NS + [false]=string.char(25), --N + }, + }, + [false]={ + [true]={ + [true]=string.char(218), --ES + [false]=string.char(27), --E + }, + [false]={ + [true]=string.char(24), --S + [false]=' ', + }, + }, + }, +} + +local function print_color(s, color) + dfhack.color(color) + dfhack.print(dfhack.df2console(s)) + dfhack.color(COLOR_RESET) +end + +function printTreeTile(bits) + local chars = 8 --launcher doesn't like tab + local exists + + if bits.trunk then + chars = chars-1 + exists = true + + if bits.trunk_is_thick then + print_color('@', COLOR_BROWN) + else + print_color('O', COLOR_BROWN) + end + end + + if bits.branches then + chars = chars-1 + exists = true + print_color(string.char(172), COLOR_GREEN) --1/4 + end + + if bits.trunk ~= bits.branches then --align properly + chars = chars-1 + dfhack.print(' ') + end + + if bits.leaves then + chars = chars-1 + exists = true + print_color(';', COLOR_GREEN) + end + + if bits.blocked then + chars = chars-1 + print_color('x', COLOR_RED) + elseif not exists then + chars = chars-1 + dfhack.print('.') + end + + chars = chars-2 + print_color(' '..(branch_chars[bits.branch_w][bits.branch_n][bits.branch_e][bits.branch_s] or '?'), COLOR_GREY) + + local dir = bits.parent_dir + if dir > 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/unit-path.lua b/devel/unit-path.lua index 459ff92159..6f5e34d58e 100644 --- a/devel/unit-path.lua +++ b/devel/unit-path.lua @@ -127,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() diff --git a/diplomacy.lua b/diplomacy.lua index 7baee75ab6..512d0df4d0 100644 --- a/diplomacy.lua +++ b/diplomacy.lua @@ -7,7 +7,7 @@ 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 pairs(p_civ.relations.diplomacy) do + 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 @@ -19,16 +19,16 @@ function get_civ_list() rel_str = "War" end matched = "No" - for _, entity2 in pairs(cur_civ.relations.diplomacy) do + 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.TranslateName(cur_civ.name, true) + cur_civ_id, + rel_str, + matched, + dfhack.translation.translateName(cur_civ.name, true) }) end end @@ -51,12 +51,12 @@ 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 pairs(p_civ.relations.diplomacy) do + 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) do + for _, entity2 in pairs(cur_civ.relations.diplomacy.state) do if entity2.group_id == p_civ_id then entity2.relation = relation end diff --git a/do-job-now.lua b/do-job-now.lua index c6994dc5f3..3ef9585a9b 100644 --- a/do-job-now.lua +++ b/do-job-now.lua @@ -1,35 +1,9 @@ -- makes a job involving current selection high priority -local function print_help() - print [====[ - -do-job-now -========== - -The script will try its best to find a job related to the selected entity -(which can be a job, dwarf, animal, item, building, plant or work order) and then -mark this job as high priority. There is no visual indicator, please look -at the dfhack console for output. If a work order is selected, every job -currently present job from this work order is affected, but not the future ones. - -For best experience add the following to your ``dfhack*.init``:: - - keybinding add Alt-N do-job-now - -Also see ``do-job-now`` `tweak` and `prioritize`. -]====] -end - -local utils = require 'utils' - -local function getUnitName(unit) - local language_name = dfhack.units.getVisibleName(unit) - if language_name.has_name then - return dfhack.df2console(dfhack.TranslateName( language_name )) - end +local utils = require('utils') - -- animals - return dfhack.units.getProfessionName(unit) +local function print_help() + print(dfhack.script_help()) end local function doJobNow(job) @@ -46,7 +20,7 @@ local function doJobNow(job) end local unit = dfhack.job.getWorker(job) if unit then - print("... by " .. getUnitName(unit) ) + print("... by " .. dfhack.df2console(dfhack.units.getReadableName(unit))) end end @@ -80,7 +54,6 @@ end local function doUnitJobNow(unit) if dfhack.units.isCitizen(unit) then - --print('This will attempt to make a job of ' .. getUnitName(unit) .. ' a top priority') local t_job = unit.job if t_job then local job = t_job.current_job @@ -89,10 +62,8 @@ local function doUnitJobNow(unit) return end end - print("Couldn't find any job for " .. getUnitName(unit) ) + print("Couldn't find any job for " .. dfhack.df2console(dfhack.units.getReadableName(unit))) else - --print('This will attempt to make a job with ' .. getUnitName(unit) .. ' a top priority') - local needle = unit.id for _link, job in utils.listpairs(df.global.world.jobs.list) do if #job.general_refs > 0 then @@ -108,7 +79,7 @@ local function doUnitJobNow(unit) end end - print("Couldn't find any job involving " .. getUnitName(unit) ) + print("Couldn't find any job involving " .. dfhack.df2console(dfhack.units.getReadableName(unit))) end end @@ -150,7 +121,7 @@ local function getSelectedWorkOrder() local orders local idx if df.viewscreen_jobmanagementst:is_instance(scr) then - orders = df.global.world.manager_orders + 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 diff --git a/docs/adaptation.rst b/docs/adaptation.rst index 9cf08ecd68..ade35a2bc7 100644 --- a/docs/adaptation.rst +++ b/docs/adaptation.rst @@ -3,17 +3,19 @@ adaptation .. dfhack-tool:: :summary: Adjust a unit's cave adaptation level. - :tags: unavailable + :tags: fort armok units -View or set level of cavern adaptation for the selected unit or the whole fort. +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 him|all - adaptation set him|all + 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. @@ -21,5 +23,13 @@ representing greater levels of cave adaptation. Examples -------- -``adaptation set all 0`` - Clear the cave adaptation levels for all dwarves. +``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-thought.rst b/docs/add-thought.rst index 9a1a24754d..528fdd0e37 100644 --- a/docs/add-thought.rst +++ b/docs/add-thought.rst @@ -3,7 +3,7 @@ add-thought .. dfhack-tool:: :summary: Adds a thought to the selected unit. - :tags: unavailable + :tags: fort armok units Usage ----- diff --git a/docs/adv-fix-sleepers.rst b/docs/adv-fix-sleepers.rst deleted file mode 100644 index 111a743f43..0000000000 --- a/docs/adv-fix-sleepers.rst +++ /dev/null @@ -1,18 +0,0 @@ -adv-fix-sleepers -================ - -.. dfhack-tool:: - :summary: Fix units who refuse to awaken in adventure mode. - :tags: unavailable - -Use this tool if you encounter sleeping units who refuse to awaken regardless of -talking to them, hitting them, or waiting so long you die of thirst -(:bug:`6798`). If you come across one or more bugged sleepers in adventure -mode, simply run the script and all nearby sleepers will be cured. - -Usage ------ - -:: - - adv-fix-sleepers diff --git a/docs/adv-rumors.rst b/docs/adv-rumors.rst deleted file mode 100644 index 563c22df36..0000000000 --- a/docs/adv-rumors.rst +++ /dev/null @@ -1,22 +0,0 @@ -adv-rumors -========== - -.. dfhack-tool:: - :summary: Improves the rumors menu in adventure mode. - :tags: unavailable - -In adventure mode, start a conversation with someone and then run this tool -to improve the "Bring up specific incident or rumor" menu. Specifically, this -tool will: - -- Move entries into a single line to improve readability -- Add a "slew" keyword for filtering, making it easy to find your kills and not - your companions' -- Trim repetitive words from the text - -Usage ------ - -:: - - adv-rumors 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 index f700826c79..8155a29d90 100644 --- a/docs/allneeds.rst +++ b/docs/allneeds.rst @@ -2,15 +2,40 @@ allneeds ======== .. dfhack-tool:: - :summary: Show the cumulative needs of all citizens. + :summary: Summarize the cumulative needs of a unit or the entire fort. :tags: fort units -Provides an overview of the needs of the fort in general, output is sorted to -show most unfullfiled needs. +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 + 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/assign-preferences.rst b/docs/assign-preferences.rst index 1345b1d262..ee77a22757 100644 --- a/docs/assign-preferences.rst +++ b/docs/assign-preferences.rst @@ -2,7 +2,7 @@ assign-preferences ================== .. dfhack-tool:: - :summary: Adjust a unit's preferences. + :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. @@ -71,6 +71,8 @@ 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, 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/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 index d096f72a19..38d2ba2b1f 100644 --- a/docs/ban-cooking.rst +++ b/docs/ban-cooking.rst @@ -7,13 +7,12 @@ ban-cooking 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, thread can be cooked, but if you do that, then your -weavers will have nothing to weave into cloth and your doctors will have -nothing to use for stitching up injured dwarves. +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 select the Booze tab and then click each of the visible types of +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. @@ -23,12 +22,12 @@ 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. +``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 -``ban-cooking --unban`` option. +icon. You can also un-ban an entire class of items with the ``--unban`` option. Usage ----- @@ -37,20 +36,31 @@ Usage ban-cooking [ ...] [] -Valid types are ``booze``, ``brew`` (brewable plants), ``fruit``, ``honey``, -``milk``, ``mill`` (millable plants), ``oil``, ``seeds`` (plantable seeds), -``tallow``, and ``thread``. It is possible to include multiple types or all -types in a single ban-cooking command: ``ban-cooking oil tallow`` will ban both -oil and tallow from cooking. ``ban-cooking all`` will ban all of the above -types. +Valid types are: -Examples:: +- ``booze`` +- ``brew`` (brewable plants) +- ``fruit`` +- ``honey`` +- ``milk`` +- ``mill`` (millable plants) +- ``oil`` +- ``seeds`` (plantable seeds and items producing seeds) +- ``tallow`` +- ``thread`` - on-new-fortress ban-cooking all +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. -Ban cooking all otherwise useful ingredients once when starting a new fortress. -Note that this exact command can be enabled via the ``Autostart`` tab of -`gui/control-panel`. +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 ------- diff --git a/docs/bodyswap.rst b/docs/bodyswap.rst index 51c0fdcfd1..772c882de0 100644 --- a/docs/bodyswap.rst +++ b/docs/bodyswap.rst @@ -3,7 +3,7 @@ bodyswap .. dfhack-tool:: :summary: Take direct control of any visible unit. - :tags: unavailable + :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. diff --git a/docs/brainwash.rst b/docs/brainwash.rst index cbd952d22f..9c1eef4ed1 100644 --- a/docs/brainwash.rst +++ b/docs/brainwash.rst @@ -12,6 +12,7 @@ Usage ----- :: + brainwash Examples diff --git a/docs/build-now.rst b/docs/build-now.rst index 873ecabfd5..63bdb6a3e9 100644 --- a/docs/build-now.rst +++ b/docs/build-now.rst @@ -3,7 +3,7 @@ build-now .. dfhack-tool:: :summary: Instantly completes building construction jobs. - :tags: unavailable + :tags: fort armok buildings By default, all unsuspended buildings on the map are completed, but the area of effect is configurable. @@ -25,7 +25,7 @@ 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 -game cursor. +keyboard game cursor. Examples -------- @@ -40,3 +40,5 @@ 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/cannibalism.rst b/docs/cannibalism.rst index 69d3973e24..acb9d34237 100644 --- a/docs/cannibalism.rst +++ b/docs/cannibalism.rst @@ -2,7 +2,7 @@ cannibalism =========== .. dfhack-tool:: - :summary: Allows a player character to consume sapient corpses. + :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 diff --git a/docs/caravan.rst b/docs/caravan.rst index d45432619d..17cb2ed071 100644 --- a/docs/caravan.rst +++ b/docs/caravan.rst @@ -49,31 +49,14 @@ Overlays -------- Additional functionality is provided on the various trade-related screens via -`overlay` widgets. - -Trade screen -```````````` - -- ``Shift+Click checkbox``: Select all items inside a bin without selecting the - bin itself -- ``Ctrl+Click checkbox``: Collapse or expand a single bin (as is possible in - the "Move goods to/from depot" screen) -- ``Ctrl+c``: Collapses all bins. The hotkey hint can also be clicked as though - it were a button. -- ``Ctrl+x``: Collapses everything (all item categories and anything - collapsible within each category). The hotkey hint can also be clicked as - though it were a button. - -There is also a reminder of the fast scroll functionality provided by the -vanilla game when you hold shift while scrolling (this works everywhere). - -You can turn the overlay on and off in `gui/control-panel`, or you can -reposition it to your liking with `gui/overlay`. The overlay is named -``caravan.tradeScreenExtension``. +`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 @@ -82,27 +65,100 @@ 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-V to select all. +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. -Trade agreement -``````````````` +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** -A small panel is shown with a hotkey (``Ctrl-A``) for selecting all/none in the -currently shown category. +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 an item assignment GUI. +(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 and by whether the -item is forbidden. +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 index 20e3e9ead5..a5c86b2c1a 100644 --- a/docs/catsplosion.rst +++ b/docs/catsplosion.rst @@ -6,7 +6,8 @@ catsplosion :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. +fps, it is a good idea to use this tool sparingly. Only adult females of the +chosen race(s) will become pregnant. Usage ----- diff --git a/docs/clear-smoke.rst b/docs/clear-smoke.rst index 58f49a8274..403efd8ed3 100644 --- a/docs/clear-smoke.rst +++ b/docs/clear-smoke.rst @@ -5,7 +5,6 @@ clear-smoke :summary: Removes all smoke from the map. :tags: fort armok fps map -Note that this can leak memory and should be used sparingly. Usage ----- diff --git a/docs/colonies.rst b/docs/colonies.rst index e778692162..cebdc69db6 100644 --- a/docs/colonies.rst +++ b/docs/colonies.rst @@ -3,7 +3,7 @@ colonies .. dfhack-tool:: :summary: Manipulate vermin colonies and hives. - :tags: fort armok map + :tags: adventure fort armok map Usage ----- diff --git a/docs/combat-harden.rst b/docs/combat-harden.rst index d8fa17ab84..78e01e9c68 100644 --- a/docs/combat-harden.rst +++ b/docs/combat-harden.rst @@ -3,7 +3,7 @@ combat-harden .. dfhack-tool:: :summary: Set the combat-hardened value on a unit. - :tags: unavailable + :tags: fort armok units This tool can make a unit care more/less about seeing corpses. @@ -12,7 +12,7 @@ Usage :: - combat-harden [] [] + combat-harden [] [] Examples -------- @@ -28,8 +28,8 @@ Unit options ``--all`` All active units will be affected. ``--citizens`` - All (sane) citizens of your fort will be affected. Will do nothing in - adventure mode. + All citizens and residents of your fort will be affected. Will do nothing + in adventure mode. ``--unit `` The given unit will be affected. diff --git a/docs/combine.rst b/docs/combine.rst index 8f8889090a..7dea937672 100644 --- a/docs/combine.rst +++ b/docs/combine.rst @@ -5,6 +5,12 @@ combine :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 ----- @@ -14,12 +20,13 @@ Usage Examples -------- + ``combine`` Displays help ``combine all --dry-run`` - Preview stack changes for all types in all stockpiles. + Preview what will be combined for all types in all stockpiles. ``combine all`` - Merge stacks for all stockpile and all types + Merge stacks in all stockpile for all types ``combine all --types=meat,plant`` Merge ``meat`` and ``plant`` type stacks in all stockpiles. ``combine here`` @@ -27,64 +34,58 @@ Examples Commands -------- + ``all`` Search all stockpiles. ``here`` - Search the currently selected stockpile. + 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 ------- -``-h``, ``--help`` - Prints help text. Default if no options are specified. ``-d``, ``--dry-run`` - Display the stack changes without applying them. + Display what would be combined instead of actually combining items. ``-t``, ``--types `` - Filter item types. Default is ``all``. Valid types are: - - ``all``: all of the types listed here. - - ``ammo``: AMMO - - ``drink``: DRINK - - ``fat``: GLOB and CHEESE - - ``fish``: FISH, FISH_RAW and EGG - - ``food``: FOOD - - ``meat``: MEAT - - ``parts``: CORPSEPIECE - - ``plant``: PLANT and PLANT_GROWTH - - ``powders``: POWDERS_MISC - - ``seed``: SEEDS + 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`` - Only print changes instead of a summary of all processed stockpiles. + Don't print the final item distribution summary. ``-v``, ``--verbose n`` - Print verbose output, n from 1 to 4. + Print verbose output for debugging purposes, n from 1 to 4. Notes ----- + The following conditions prevent an item from being combined: - 1. An item is not in a stockpile. - 2. An item is sand or plaster. - 3. An item is rotten, forbidden/hidden, marked for dumping/melting, on fire, encased, owned by a trader/hostile/dwarf or is in a spider web. - 4. An item is part of a corpse and not butchered. - -The following categories are defined: - 1. Corpse pieces, grouped by piece type and race - 2. Items that have an associated race/caste, grouped by item type, race, and caste - 3. Ammo, grouped by ammo type, material, and quality. If the ammo is a masterwork, it is also grouped by who created it. - 4. Anything else, grouped by item type and material - -Each category has a default stack size of 30 unless a larger stack already -exists "naturally" in your fort. In that case the largest existing stack size -is used. + +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 index c9a2ae0a06..dac8a39ab9 100644 --- a/docs/deathcause.rst +++ b/docs/deathcause.rst @@ -14,3 +14,26 @@ 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/deteriorate.rst b/docs/deteriorate.rst index 583150afdb..f97cc3b089 100644 --- a/docs/deteriorate.rst +++ b/docs/deteriorate.rst @@ -3,11 +3,12 @@ deteriorate .. dfhack-tool:: :summary: Cause corpses, clothes, and/or food to rot away over time. - :tags: unavailable + :tags: fort fps gameplay items When enabled, this script will cause the specified item types to slowly rot -away. By default, items disappear after a few months, but you can choose to slow -this down or even make things rot away instantly! +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 @@ -15,61 +16,63 @@ 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 ----- -``deteriorate start --types [--freq ] [--quiet] [--keep-usable]`` - Starts deteriorating the specified item types while you play. -``deteriorate stop --types `` - Stops deteriorating the specified item types. -``deteriorate status`` - Shows the item types that are currently being monitored and their - deterioration frequencies. -``deteriorate now --types [--quiet] [--keep-usable]`` - Causes all items (of the specified item types) to rot away within a few - ticks. - -You can have different types of items rotting away at different rates by running -``deteriorate start`` multiple times with different options. +:: + + 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 -------- -Start deteriorating corpses and body parts, keeping usable parts such as hair, wool:: - - deteriorate start --types corpses --keep-usable - -Start deteriorating corpses and food and do it at twice the default rate:: - - deteriorate start --types corpses,food --freq 0.5,days - -Deteriorate corpses quickly but clothes slowly:: - - deteriorate start -tcorpses -f0.1 - deteriorate start -tclothes -f3,months - -Options -------- - -``-f``, ``--freq``, ``--frequency [,]`` - How often to increment the wear counters. ```` can be one of - ``days``, ``months``, or ``years`` and defaults to ``days`` if not - specified. 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. - ``--freq=0.5,days`` is perfectly valid. -``-k``, ``--keep-usable`` - Keep usable body parts such as hair, wool, hooves, bones, and skulls. -``-q``, ``--quiet`` - Silence non-error output. -``-t``, ``--types `` - The comma-separated list of item types to affect. This option is required - for ``start``, ``stop``, and ``now`` commands. - -Types ------ - -:clothes: All clothing pieces that have an armor rating of 0 and are lying on - the ground. -:corpses: All resident corpses and body parts. -:food: All food and plants, regardless of whether they are in barrels or - stockpiles. Seeds are left untouched. +``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/block-borders.rst b/docs/devel/block-borders.rst index acc6f39877..a75293aa0b 100644 --- a/docs/devel/block-borders.rst +++ b/docs/devel/block-borders.rst @@ -3,7 +3,7 @@ devel/block-borders .. dfhack-tool:: :summary: Outline map blocks on the map screen. - :tags: unavailable + :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. diff --git a/docs/devel/dump-tooltip-ids.rst b/docs/devel/dump-tooltip-ids.rst new file mode 100644 index 0000000000..5f066b94b0 --- /dev/null +++ b/docs/devel/dump-tooltip-ids.rst @@ -0,0 +1,29 @@ +devel/dump-tooltip-ids +====================== + +.. dfhack-tool:: + :summary: Generate main_hover_instruction enum XML structures. + :tags: dev + +This script generates the contents of the ``main_hover_instruction`` enum and attrs, then cross-checks with the +currently-built enum attrs to determine the correct enum item names. This is intended to catch cases where items +move around. + +For example, if DFHack has:: + + + + + +as item 500, but we detect that caption at position 501 in ``main_interface.hover_instruction``, then the output +produced by the script will include the above element at position 501 instead of 500. + +Before running this script, the size of ``main_interface.hover_instruction`` must be aligned properly with the +loaded version of DF so the array of strings can be read. + +Usage +----- + +:: + + devel/dump-tooltip-ids diff --git a/docs/devel/export-map.rst b/docs/devel/export-map.rst new file mode 100644 index 0000000000..6beea7378b --- /dev/null +++ b/docs/devel/export-map.rst @@ -0,0 +1,159 @@ +devel/export-map +================ + +.. dfhack-tool:: + :summary: Export map tile data to a JSON file. + :tags: dev + +WARNING - This command will cause the game to freeze for minutes depending on +map size and options enabled. + +Exports the map tile data to a JSON file. The export does not include items, +characters, buildings, etc. Depending on options enabled, there will be a +``KEY`` table in the JSON with relevant [number ID] values that match a number +to their object type. + +Usage +----- + +:: + + devel/export-map + devel/export-map (include|exclude) + +Examples +-------- + +``devel/export-map`` + Export the map to JSON with ALL data included. + +``devel/export-map include -m -s -v`` + Export the map to JSON with only materials, shape, and variant + data included. + +``devel/export-map exclude --variant --hidden --light`` + Export the map to JSON with variant, hidden, and light data + excluded. + +Options +------- + +``-t``, ``--tiletype`` + The tile material classification. [number ID] (AIR/SOIL/STONE/RIVER/etc.) + +``-s``, ``--shape`` + The tile shape classification. [number ID] (EMPTY/FLOOR/WALL/STAIR/etc.) + +``-p``, ``--special`` + The tile surface special properties for smoothness. [number ID] + (NORMAL/SMOOTH/ROUGH/etc.) (used for engraving). + +``-v``, ``--variant`` + The specific variant of a tile that have visual variations. [number] (like + grass tiles in ASCII mode) + +``-h``, ``--hidden`` + Whether tile is revealed or unrevealed. [boolean] + +``-l``, ``--light`` + Whether tile is exposed to light. [boolean] + +``-b``, ``--subterranean`` + Whether the tile is considered underground. [boolean] (used to determine + crops that can be planted underground) + +``-o``, ``--outside`` + Whether the tile is considered “outside”. [boolean] (used by weather effects + to trigger on outside tiles) + +``-a``, ``--aquifer`` + Whether the tile is considered an aquifer. [number ID] (NONE/LIGHT/HEAVY) + +``-m``, ``--material`` + The material inside the tile. [number ID] (IRON/GRANITE/CLAY/ + TOPAZOLITE/BLACK_OPAL/etc.) (will return nil if the tile is empty) + +``-q``, ``--liquid`` + The type of liquid inside the tile. [number ID] (WATER/MAGMA) (will return + nil if the tile flow level is zero) + +``-f``, ``--flow`` + The level of liquids inside the tile. [number] (0-7) + +``-u``, ``--underworld`` + Whether the underworld z-levels will be included. + +``-e``, ``--evilness`` + Whether the evilness value will be included in MAP_SIZE table. This only + checks the value of the center map tile at ground level and will ignore + biomes at the edges of the map. + +JSON DATA +--------- + +``ARGUMENT_OPTION_ORDER`` + The order of the selected options for how data is arranged at a map + position. + + Example 1: + ``{"material": 1, "shape": 2, "hidden": 3}`` + + ``map[z][y][x] = {material_data, shape_data, hidden_data}`` + + Example 2: + ``{"variant": 3, "light": 1, "outside": 2, "aquifer": 4}`` + + ``map[z][y][x] = {light_data, outside_data, variant_data, aquifer_data}`` + +``MAP_SIZE`` + A table containing basic information about the map size for width, height, + depth. (x, y, z) The underworld_z_level is included if the underworld option + is enabled and the map depth (z) will be automatically adjusted. + +``KEYS`` + The tables containing the [number ID] values for different options. + + ``"SHAPE": {"-1": "NONE", "0": "EMPTY", "1": "FLOOR", "2": "BOULDERS", + "3": "PEBBLES", "4": "WALL", ..., "18": "ENDLESS_PIT"}`` + + ``"PLANT": {"0": "SINGLE-GRAIN_WHEAT", "1": "TWO-GRAIN_WHEAT", + "2": "SOFT_WHEAT", "3": "HARD_WHEAT", "4": "SPELT", "5": "BARLEY", ..., + "224": "PALM"}`` + + ``"AQUIFER": {"0": "NONE", "1": "LIGHT", "2": "HEAVY"}`` + + Note - when using the ``materials`` option, you need to pair the [number ID] + with the correct ``KEYS`` material table. Generally you use ``tiletype`` + option as a helper to sort tiles into different material types. I would + recommend consulting ``tile-material.lua`` to see how materials are sorted. + +``map`` + JSON map data is arranged as: ``map[z][y][x] = {tile_data}`` + + DF maps start at index [0]. (starts at map[0][0][0]) + + For most JSON libraries the index starts at [0] but some languages has the + index start at [1]. So to translate an actual DF map position from the JSON + map, you may need to add +1 to all x/y/z coordinates to get the correct tile + position. + + The ``ARGUMENT_OPTION_ORDER`` determines order of tile data. (see above) + I would recommend referencing the tile data like so: + + ``shape = json_data.map[z][x][y][json_data.ARGUMENT_OPTIONS_ORDER.shape]`` + + ``light = json_data.map[z][x][y][json_data.ARGUMENT_OPTIONS_ORDER.light]`` + + Note - some of the bottom z-levels for hell do not have the same + width/height as the default map. So if your map is 190x190, the last hell + z-levels are gonna be like 90x90. + + Instead of returning normal tile data like: + + ``map[0][90][90] = {tile_data}`` + + It will return nil instead: + + ``map[0][91][91] = nil`` + + So you need to account for this! diff --git a/docs/devel/find-offsets.rst b/docs/devel/find-offsets.rst deleted file mode 100644 index 72d9c869f3..0000000000 --- a/docs/devel/find-offsets.rst +++ /dev/null @@ -1,36 +0,0 @@ -devel/find-offsets -================== - -.. dfhack-tool:: - :summary: Find memory offsets of DF data structures. - :tags: unavailable - -.. 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. - -To find the first few globals, you must run this script 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`. - -Usage ------ - -:: - - devel/find-offsets all| [nofeed] [nozoom] - -- global names to force finding them -- ``all`` to force all globals -- ``nofeed`` to block automated fake input searches -- ``nozoom`` to disable neighboring object heuristics diff --git a/docs/devel/find-twbt.rst b/docs/devel/find-twbt.rst deleted file mode 100644 index 4a5e056945..0000000000 --- a/docs/devel/find-twbt.rst +++ /dev/null @@ -1,15 +0,0 @@ -devel/find-twbt -=============== - -.. dfhack-tool:: - :summary: Display the memory offsets of some important TWBT functions. - :tags: unavailable - -Finds some TWBT-related offsets - currently just ``twbt_render_map``. - -Usage ------ - -:: - - devel/find-twbt diff --git a/docs/devel/input-monitor.rst b/docs/devel/input-monitor.rst new file mode 100644 index 0000000000..e20947cc27 --- /dev/null +++ b/docs/devel/input-monitor.rst @@ -0,0 +1,30 @@ +devel/input-monitor +=================== + +.. dfhack-tool:: + :summary: Live monitor and logger for input events. + :tags: dev + +This UI allows you to discover how DF is interpreting input from your keyboard +and mouse device. + +The labels for Shift, Ctrl, and Alt light up when those modifier keys are being +held down. + +Similar labels for left, middle, and right mouse buttons light up when any of +those buttons are being held down. + +The input stream panel shows the keybindings that are being triggered. You can +resize the window to see more of the stream history. The events are also logged +to the `external console `, if you need a more permanent record of the +stream. + +Since right click is intercepted, it cannot be used to close this window. +Instead, hit :kbd:`Esc` twice in a row or click twice on the exit button. + +Usage +----- + +:: + + devel/input-monitor diff --git a/docs/devel/luacov.rst b/docs/devel/luacov.rst index 8b9667cfd4..f4fc1f4054 100644 --- a/docs/devel/luacov.rst +++ b/docs/devel/luacov.rst @@ -3,7 +3,7 @@ devel/luacov .. dfhack-tool:: :summary: Lua script coverage report generator. - :tags: unavailable + :tags: dev 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 @@ -16,16 +16,17 @@ 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. +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. +Also note that enabling coverage monitoring and lua profiling (via the +"profiler" module) at the same time can produce strange results. Their +interceptor hooks conflict with each other. Usage of the `kill-lua` command will +likewise override the luacov interceptor hook and may prevent coverage +statistics from being collected. Usage ----- diff --git a/docs/devel/prepare-save.rst b/docs/devel/prepare-save.rst deleted file mode 100644 index c11df0cd7f..0000000000 --- a/docs/devel/prepare-save.rst +++ /dev/null @@ -1,21 +0,0 @@ -devel/prepare-save -================== - -.. dfhack-tool:: - :summary: Set internal game state to known values for memory analysis. - :tags: unavailable - -.. 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. - -Usage ------ - -:: - - devel/prepare-save diff --git a/docs/devel/spawn-unit-helper.rst b/docs/devel/spawn-unit-helper.rst index c45dcf8e1e..fdf545d741 100644 --- a/docs/devel/spawn-unit-helper.rst +++ b/docs/devel/spawn-unit-helper.rst @@ -13,8 +13,8 @@ Usage 1. Enter the :kbd:`k` menu and change mode using ``rb_eval df.gametype = :DWARF_ARENA`` -2. Spawn creatures with the normal arena mode UI (:kbd:`c` ingame) -3. Revert to forgress mode using +2. Spawn creatures with the normal arena mode UI (:kbd:`c` in-game) +3. Revert to fortress mode using ``rb_eval df.gametype = #{df.gametype.inspect}`` 4. To convert spawned creatures to livestock, select each one with the :kbd:`v` menu, and enter ``rb_eval df.unit_find.civ_id = df.ui.civ_id`` diff --git a/docs/devel/tile-browser.rst b/docs/devel/tile-browser.rst index d0e3618e77..bc07479e7e 100644 --- a/docs/devel/tile-browser.rst +++ b/docs/devel/tile-browser.rst @@ -9,10 +9,9 @@ This script pops up a panel that shows a page of 1000 textures. You can change the starting texpos index with :kbd:`Ctrl`:kbd:`A` or scan forward or backwards in increments of 1000 with :kbd:`Shift` :kbd:`Up`/:kbd:`Down`. -Textures that take up more than one grid position may have only their upper-left -tiles shown. Increase the tile browser window (drag the bottom right corner to -resize) to see larger tiles Note there may be transparent space visible through -the window for tiles that don't take up the entire allotted space. +Textures are resized to the dimensions of the interface grid (8px by 12px), so +map textures (nominally 32px by 32px) will be squished. If no texture is +assigned to the texpos index, the window will be transparent at that spot. Usage ----- diff --git a/docs/devel/tree-info.rst b/docs/devel/tree-info.rst new file mode 100644 index 0000000000..7e804c450b --- /dev/null +++ b/docs/devel/tree-info.rst @@ -0,0 +1,17 @@ +devel/tree-info +=============== + +.. dfhack-tool:: + :summary: Print a tree_info visualization of the tree at the cursor. + :tags: dev + +Print a tree_info visualization of the tree at the cursor. For each tile it +prints a representation of trunk/branch/twig/blocked flags, then branch +direction, then parent direction. + +Usage +----- + +:: + + devel/tree-info diff --git a/docs/drain-aquifer.rst b/docs/drain-aquifer.rst deleted file mode 100644 index b614a36306..0000000000 --- a/docs/drain-aquifer.rst +++ /dev/null @@ -1,41 +0,0 @@ -drain-aquifer -============= - -.. dfhack-tool:: - :summary: Remove some or all aquifers on the map. - :tags: fort armok map - -This tool irreversibly removes 'aquifer' tags from map blocks. Also see -`prospect` for discovering the range of layers that currently have aquifers. - -Usage ------ - -:: - - drain-aquifer [] - -Examples --------- - -``drain-aquifer`` - Remove all aquifers on the map. -``drain-aquifer --top 2`` - Remove all aquifers on the map except for the top 2 levels of aquifer. -``drain-aquifer -d`` - Remove all aquifers on the current z-level and below. - -Options -------- - -``-t``, ``--top `` - Remove all aquifers on the map except for the top ```` levels, - starting from the first level that has an aquifer tile. Note that there may - be less than ```` levels of aquifer after the command is run if the - levels of aquifer are not contiguous. -``-d``, ``--zdown`` - Remove all aquifers on the current z-level and below. -``-u``, ``--zup`` - Remove all aquifers on the current z-level and above. -``-z``, ``--cur-zlevel`` - Remove all aquifers on the current z-level. diff --git a/docs/embark-anyone.rst b/docs/embark-anyone.rst new file mode 100644 index 0000000000..6c6fcff29a --- /dev/null +++ b/docs/embark-anyone.rst @@ -0,0 +1,24 @@ +embark-anyone +============= + +.. dfhack-tool:: + :summary: Allows you to embark as any civilisation, including dead and non-dwarven ones. + :tags: embark armok + +This script must be run on the embark screen when choosing an origin civilization. +When run, you can add any civilization, including dead or non-dwarven civilizations, +to the list of choices. + + +Usage +----- + +:: + + embark-anyone + +Note +----- +Non-dwarven civs have their own mechanics and are likely to lack recipes which can render fortress mode difficult +or unplayable. Preparing carefully is advised, and some crucial items may need to be +spawned in with other DFHack tools (e.g. `gui/create-item`). Additionally, missing crafting recipes can be added using `add-recipe`. diff --git a/docs/embark-skills.rst b/docs/embark-skills.rst index cdf54aa246..61f123f9b9 100644 --- a/docs/embark-skills.rst +++ b/docs/embark-skills.rst @@ -3,7 +3,7 @@ embark-skills .. dfhack-tool:: :summary: Adjust dwarves' skills when embarking. - :tags: unavailable + :tags: embark armok units When selecting starting skills for your dwarves on the embark screen, this tool can manipulate the skill values or adjust the number of points you have diff --git a/docs/emigration.rst b/docs/emigration.rst index 58a3d623e7..074c8ebc96 100644 --- a/docs/emigration.rst +++ b/docs/emigration.rst @@ -16,9 +16,44 @@ even in the company of a visiting elven bard! The check is made monthly. A happy dwarf (i.e. with negative stress) will never emigrate. +The tool also supports ``nobles``, a manually-invoked command that makes nobles +emigrate to their rightful land of rule. No more freeloaders making inane demands! +Nobles assigned to squads or to fort administrator positions will not be emigrated. +Remove their assignments before retrying. Nobles holding elected positions +(i.e. mayors) may be emigrated, but will be marked with a ``*`` icon when listed. + Usage ----- :: enable emigration + emigration nobles [--list] + emigration nobles [] + +Examples +-------- + +``emigration nobles`` + Emigrate the selected noble if it does not rule your fortress. + If no unit is selected, list all nobles that do not rule your fortress. +``emigration nobles --list`` + List all nobles that do not rule your fortress. Nobles that cannot be emigrated + (see above) will have a ``!`` indicator while nobles holding elected positions + will have a ``*`` indicator. +``emigration nobles --all`` + Emigrate all nobles that do not rule your fortress. +``emigration nobles --unit 34534`` + Emigrate a noble matching the specified unit ID that does not rule your fortress. + +Options +------- + +These options are exclusive to the ``emigration nobles`` command. + +``-l``, ``--list`` + List all nobles that do not rule your fortress +``-a``, ``--all`` + Emigrate all nobles do not rule your fortress +``-u``, ``--unit `` + Emigrate noble matching specified unit ID that does not rule your fortress diff --git a/docs/empty-bin.rst b/docs/empty-bin.rst index 2ac8138b47..e6e8bf4088 100644 --- a/docs/empty-bin.rst +++ b/docs/empty-bin.rst @@ -6,12 +6,39 @@ empty-bin :tags: fort productivity items This tool can quickly empty the contents of the selected container (bin, -barrel, or pot) onto the floor, allowing you to access individual items that -might otherwise be hard to get to. +barrel, pot, wineskin, quiver, etc.) onto the floor, allowing you to access +individual items that might otherwise be hard to get to. + +If you instead select a stockpile or building, running `empty-bin` will empty +*all* containers in the stockpile or building. Likewise, if you select a tile +that has many items and the UI is showing the list of items, all containers on +the tile will be dumped. Usage ----- :: - empty-bin + empty-bin [] + +Examples +-------- + +``empty-bin`` + Empty the contents of selected containers or all containers in the selected stockpile or building, except containers with liquids or powders, onto the floor. + +``empty-bin --force`` + Empty the contents of selected containers or all containers in the selected stockpile or building, including containers with liquids or powders, onto the floor. + +``empty-bin --recursive --force`` + Empty the contents of selected containers or all containers in the selected stockpile or building, including containers with liquids/powders and containers contents that are containers, such as a bags of seeds or filled waterskins, onto the floor. + + +Options +------- + +``-r``, ``--recursive`` + Recursively empty containers. + +``-f``, ``--force`` + Move contained liquid and powders (DRINK, LIQUID_MISC and POWDER_MISC) to the floor, making them unusable. diff --git a/docs/entomb.rst b/docs/entomb.rst new file mode 100644 index 0000000000..1352b990e4 --- /dev/null +++ b/docs/entomb.rst @@ -0,0 +1,66 @@ +entomb +====== + +.. dfhack-tool:: + :summary: Entomb any corpse into tomb zones. + :tags: fort items buildings + +Assign any unit regardless of citizenship, residency, pet status, +or affiliation to an unassigned tomb zone for burial. + +Usage +----- + +``entomb []`` + +Select a unit's corpse or body part, or specify the unit's ID +when executing this script to assign an unassigned tomb zone to +the unit, and flag the unit's corpse as well as any severed body +parts to become valid items for interment. + +Optionally, specify the tomb zone's ID to assign a specific tomb +zone to the unit. + +A non-citizen, non-resident, or non-pet unit that is still alive +may even be assigned a tomb zone if they have lost any body part +that can be placed inside a tomb, e.g. teeth or severed limbs. +New corpse items after a tomb has already been assigned will not +be properly interred until the script is executed again with the +unit ID specified, or the unit's corpse or any body part selected. + +If executed on slaughtered animals, all its butchering returns will +become valid burial items and no longer usable for cooking or crafting. + +Examples +-------- + +``entomb --unit `` + Assign an unassigned tomb zone to the unit with the specified ID. + +``entomb --tomb `` + Assign a tomb zone with the specified ID to the selected corpse + item's unit. + +``entomb -u -t -h`` + Assign a tomb zone with the specified ID to the unit with the + specified ID and task all its burial items for simultaneous + hauling into the coffin in the tomb zone. + +Options +------- + +``-u``, ``--unit `` + Specify the ID of the unit to be assigned to a tomb zone. + +``-t``, ``--tomb `` + Specify the ID of the zone into which a unit will be interred. + +``-a``, ``--add-item`` + Add a selected item, or multiple items at the keyboard cursor's + position to be interred together with a unit. A unit or tomb + zone ID must be specified when calling this option. + +``-n``, ``--haul-now`` + Task all of the unit's burial items for simultaneous hauling + into the coffin of its assigned tomb zone. This option can be + called even after a tomb zone is already assigned to the unit. diff --git a/docs/exportlegends.rst b/docs/exportlegends.rst index 2d7c4702d6..5d262a853c 100644 --- a/docs/exportlegends.rst +++ b/docs/exportlegends.rst @@ -11,7 +11,10 @@ about your world so that it can be browsed with external programs like get with vanilla export functionality, and many external tools depend on this extra information. -By default, ``exportlegends`` hooks into the standard vanilla ``Export XML`` button and runs in the background when you click it, allowing both the vanilla export and the extended data export to execute simultaneously. You can continue to browse legends mode via the vanilla UI while the export is running. +By default, ``exportlegends`` hooks into the standard vanilla ``Export XML`` +button and runs in the background when you click it, allowing both the vanilla +export and the extended data export to execute simultaneously. You can continue +to browse legends mode via the vanilla UI while the export is running. To use: @@ -35,7 +38,11 @@ Usage Overlay ------- -This script also provides an overlay that is managed by the `overlay` framework. +This script also provides several overlays that are managed by the `overlay` +framework. + +**exportlegends.export** + When the overlay is enabled, a toggle for exporting extended legends data will appear below the vanilla "Export XML" button. If the toggle is enabled when the "Export XML" button is clicked, then ``exportlegends`` will run alongside the @@ -45,6 +52,13 @@ While the extended data is being exported, a status line will appear in place of the toggle, reporting the current export target and the overall percent complete. -There is an additional overlay that masks out the "Done" button while the -extended export is running. This prevents the player from exiting legends mode -before the export is complete. +**exportlegends.mask** + +This overlay masks out the "Done" button while the extended export is running. +This prevents the player from accidentally exiting legends mode before the +export is complete. + +**exportlegends.histfigfilter** + +This overlay adds a filter widget to the Historical Figures legends page. +Clicking the widget allows you to filter the list of historical figures by race. diff --git a/docs/exterminate.rst b/docs/exterminate.rst index 1e2609a81c..9ce66b9791 100644 --- a/docs/exterminate.rst +++ b/docs/exterminate.rst @@ -5,31 +5,37 @@ exterminate :summary: Kill things. :tags: fort armok units -Kills any unit, or all undead, or all units of a given race. You can target any -unit on a revealed tile of the map, including ambushers, but caged/chained -creatures cannot be killed with this tool. +Kills any individual unit, or all undead, or all units of a given race. Caged +and chained creatures are ignored. Usage ----- :: - exterminate + exterminate [list] exterminate this [] exterminate undead [] + exterminate all[:] [] exterminate [:] [] +Race and caste names are case insensitive. + Examples -------- -``exterminate this`` - Kill the selected unit. ``exterminate`` List the targets on your map. +``exterminate this`` + Kill the selected unit. ``exterminate BIRD_RAVEN:MALE`` Kill the ravens flying around the map (but only the male ones). -``exterminate GOBLIN --method magma --only-visible`` +``exterminate goblin --method magma --only-visible`` Kill all visible, hostile goblins on the map by boiling them in magma. +``exterminate all`` + Kill all non-friendly creatures. +``exterminate all:MALE`` + Kill all non-friendly male creatures. Options ------- @@ -37,10 +43,12 @@ Options ``-m``, ``--method `` Specifies the "method" of killing units. See below for details. ``-o``, ``--only-visible`` - Specifies the tool should only kill units visible to the player. + Specifies the tool should only kill units visible to the player on the map. ``-f``, ``--include-friendly`` Specifies the tool should also kill units friendly to the player. +``-l``, ``--limit `` + Set the maximum number of units to exterminate. Methods ------- @@ -52,19 +60,25 @@ Methods :vaporize: Make the unit disappear in a puff of smoke. Note that units killed this way will not leave a corpse behind, but any items they were carrying will still drop. +:disintegrate: Vaporize the unit and destroy any items they were carrying. :drown: Drown the unit in water. :magma: Boil the unit in magma (not recommended for magma-safe creatures). :butcher: Will mark the units for butchering instead of killing them. This is - more useful for pets than armed enemies. + useful for pets and not useful for armed enemies. +:knockout: Will put units into an unconscious state for 30k ticks (about a + month in fort mode). +:traumatize: Traumatizes units, forcing them to stare off into space (catatonic + state). Technical details ----------------- -This tool kills by setting a unit's ``blood_count`` to 0, which means -immediate death at the next game tick. For creatures where this is not enough, -such as vampires, it also sets ``animal.vanish_countdown``, allowing the unit -to vanish in a puff of smoke if the blood loss doesn't kill them. +For the ``instant`` method, this tool kills by setting a unit's ``blood_count`` +to 0, which means immediate death at the next game tick. For creatures where +this is not enough, such as vampires, it also sets ``animal.vanish_countdown``, +allowing the unit to vanish in a puff of smoke if the blood loss doesn't kill +them. If the method of choice involves liquids, the tile is filled with a liquid level of 7 every tick. If the target unit moves, the liquid moves along with -it, leaving the vacated tiles clean. +it, leaving the vacated tiles clean (though possibly scorched). diff --git a/docs/extinguish.rst b/docs/extinguish.rst index 23774cbb04..67ba8c7797 100644 --- a/docs/extinguish.rst +++ b/docs/extinguish.rst @@ -3,12 +3,13 @@ extinguish .. dfhack-tool:: :summary: Put out fires. - :tags: fort armok buildings items map units + :tags: fort adventure armok buildings items map units With this tool, you can put out fires affecting map tiles, plants, units, items, and buildings. -To select a target, place the cursor over it before running the script. +Select a target in the UI or enable the keyboard cursor place it over the +target tile before running the script. If your FPS is unplayably low because of the generated smoke, see `clear-smoke`. @@ -16,15 +17,6 @@ Usage ----- ``extinguish`` - Put out the fire under the cursor. + Put out the fire affecting the selected unit/item/map tile. ``extinguish --all`` Put out all fires on the map. -``extinguish --location [ ]`` - Put out the fire at the specified map coordinates. You can use the - `position` tool to find out what the coordinates under the cursor are. - -Examples --------- - -``extinguish --location [ 33 41 128 ]`` - Put out the fire burning on the surface at position x=33, y=41, z=128. diff --git a/docs/fillneeds.rst b/docs/fillneeds.rst index b3d389a9f8..3eedaa1135 100644 --- a/docs/fillneeds.rst +++ b/docs/fillneeds.rst @@ -15,4 +15,4 @@ Usage ``fillneeds --unit `` Make the specified unit focused and unstressed. ``fillneeds --all`` - Make all units focused and unstressed. + Make all citizens and residents focused and unstressed. diff --git a/docs/firestarter.rst b/docs/firestarter.rst index 5e610d2e92..08e16e0626 100644 --- a/docs/firestarter.rst +++ b/docs/firestarter.rst @@ -3,7 +3,7 @@ firestarter .. dfhack-tool:: :summary: Lights things on fire. - :tags: fort armok items map units + :tags: fort adventure armok items map units Feel the need to burn something? Set items, locations, or even entire inventories on fire! Use while viewing an item, with the cursor over a map tile, diff --git a/docs/fix/archery-practice.rst b/docs/fix/archery-practice.rst new file mode 100644 index 0000000000..89cf6e0d81 --- /dev/null +++ b/docs/fix/archery-practice.rst @@ -0,0 +1,75 @@ +fix/archery-practice +==================== + +.. dfhack-tool:: + :summary: Fix quivers and training ammo items to allow archery practice to take place. + :tags: fort bugfix items + +Make quivers the last item in the inventory of every ranged unit currently +training and split stacks of ammo items assigned for training inside the +quivers to ensure each training unit can have more than one stack to allow +archery practice to take place. + +Note +---- + +The bug preventing units from initiating archery practice was fixed in +DF version 53.01. See below for more information about the issue and how +this tool works to mitigate it. Running this tool for any other archery +related issues will not yield useful results. + +Usage +----- + +``fix/archery-practice`` + Move quivers to the end of units' inventory list and split stacks of + training ammo items inside the quivers. + +``fix/archery-practice -q``, ``fix/archery-practice --quiet`` + Move quivers to the end of units' inventory list and split stacks of + training ammo items inside the quivers. Do not print to console. + +This tool will set quivers as the last item in the inventory of units in +squads that are currently set to train as well as split ammo items inside +their quivers into multiple stacks if a quiver contains only ammo item +with a stack size of 25 or larger assigned for training. The original +training ammo item with a reduced stack size will remain in the quiver +while new ammo items split from it will be placed on the ground where +the unit is located to be picked up later. + +Why are archers not practicing archery? +--------------------------------------- + +Due to a bug in the game, a unit that is scheduled to train will not be +able to practice archery at the archery range when their quiver contains +only one stack of ammo item assigned for training. This is sometimes +indicated on the unit by the 'Soldier (no item)' status. + +During versions 52.03 and 52.04, the issue was the complete reverse; +units would not practice when their quivers contained more than one +stack of ammo items assigned for training. + +Another issue in 52.05 is that units will not practice archery if their +quiver is not the last item in their inventory. + +This tool provides an interim remedy by moving quivers to the end of +every training unit's inventory list and splitting stacks of ammo items +inside their quivers to prompt the game to give them multiple stacks +of training ammo items. + +Limitations +----------- + +The game has a tendency to reshuffle the squad's ammo/unit pairings if +the newly split ammo items are force paired to the units holding the +original ammo item. As a compromise, the new items are placed on the +ground instead and added to the squad's training ammo assignment pool, +so that the game can distribute the items normally without causing the +pairing for ammo items already in quivers to be reshuffled. + +Although this tool would allow units to practice archery, the activity +will still be aborted once they have only one stack of training ammo +item remaining in their quivers. Practicing units will gain skill from +practice, but not the positive thought they would have gained from +having completed the activity. Once the game assigns more training +ammo items to them, they can continue practicing archery. diff --git a/docs/fix/codex-pages.rst b/docs/fix/codex-pages.rst new file mode 100644 index 0000000000..b5378622bf --- /dev/null +++ b/docs/fix/codex-pages.rst @@ -0,0 +1,44 @@ +fix/codex-pages +=============== + +.. dfhack-tool:: + :summary: Add pages to written content that have no pages. + :tags: fort bugfix items + +Add pages to codices, quires, and scrolls that do not have specified page counts. + +Usage +----- + +``fix/codex-pages [this|site|all]`` + +Pages will be added to written works that do not have properly specified page +counts. The number of pages to be added will be determined mainly by the type +of the written content, modified by its writing style and the strength of the +style, with weighted randomization. + +Options +------- + +``this`` + Add pages to the selected codex, quire, or scroll item. + +``site`` + Add pages to all written works that are currently in the player's fortress. + +``all`` + Add pages to all written works to have ever existed in the world. + +Note +---- + +This tool mitigates :bug:`9268` by generating new, randomized information for +written content that do not have the start and end pages specified in their +data structure. It cannot retrieve page count from written content that was +already missing the page count information. + +Also, unbound quires and scrolls do not display the number of pages they contain +in their item description even if the data structure of their written content +holds the information. However, once a quire that has written content with +appropriately specified page count information is bound into a codex, its page +count will be properly displayed in the resulting codex's item description. diff --git a/docs/fix/dead-units.rst b/docs/fix/dead-units.rst index 49156be5f7..4f194262ee 100644 --- a/docs/fix/dead-units.rst +++ b/docs/fix/dead-units.rst @@ -32,4 +32,4 @@ Options ``--burrow`` Scrub dead units from burrow membership lists. ``-q``, ``--quiet`` - Surpress console output (final status update is still printed if at least one item was affected). + Suppress console output (final status update is still printed if at least one item was affected). diff --git a/docs/fix/dry-buckets.rst b/docs/fix/dry-buckets.rst index 311e3e6c2e..65740e309d 100644 --- a/docs/fix/dry-buckets.rst +++ b/docs/fix/dry-buckets.rst @@ -12,9 +12,14 @@ water from them. This tool also fixes over-full buckets that are blocking well operations. +If enabled in `gui/control-panel` (it is enabled by default), this fix is +periodically run automaticaly, so you should not normally need to run it +manually. + Usage ----- -:: - - fix/dry-buckets +``fix/dry-buckets`` + Empty water buckets not currently used in jobs. +``fix/dry-buckets -q``, ``fix/dry-buckets --quiet`` + Empty water buckets not currently used in jobs. Don't print to the console. diff --git a/docs/fix/empty-wheelbarrows.rst b/docs/fix/empty-wheelbarrows.rst index a6070d3091..9fcbe15c70 100644 --- a/docs/fix/empty-wheelbarrows.rst +++ b/docs/fix/empty-wheelbarrows.rst @@ -9,8 +9,8 @@ Empties all wheelbarrows which contain rocks that have become 'stuck' in them. This works around the issue encountered with :bug:`6074`, and should be run if you notice wheelbarrows lying around with rocks in them that aren't -being used in a task. This script can also be set to run periodically in -the background by toggling the Maintenance task in `gui/control-panel`. +being used in a task. This script is set to run periodically by default in +`gui/control-panel`. Usage ----- @@ -26,12 +26,12 @@ Examples ``fix/empty-wheelbarrows --dry-run`` Lists all wheelbarrows that would be emptied and their contents without performing the action. ``fix/empty-wheelbarrows --quiet`` - Does the action while surpressing output to console. + Does the action while suppressing output to console. Options ------- ``-q``, ``--quiet`` - Surpress console output (final status update is still printed if at least one item was affected). + Suppress console output (final status update is still printed if at least one item was affected). ``-d``, ``--dry-run`` Dry run, don't commit changes. diff --git a/docs/fix/engravings.rst b/docs/fix/engravings.rst new file mode 100644 index 0000000000..841647c091 --- /dev/null +++ b/docs/fix/engravings.rst @@ -0,0 +1,22 @@ +fix/engravings +============== + +.. dfhack-tool:: + :summary: Fixes unengravable corrupted tiles so they are able to be engraved. + :tags: fort bugfix + +When constructing a new wall or new floor over a previously engraved tile, the tile may become corrupted and unengravable. +This fix detects the problem and resets the state of those tiles so they may be engraved again. + +Usage +----- + +:: + + fix/engravings [] + +Options +------- + +``-q``, ``--quiet`` + Only output status when something was actually fixed. diff --git a/docs/fix/general-strike.rst b/docs/fix/general-strike.rst index 7bdd5ccf2c..71f4c5985f 100644 --- a/docs/fix/general-strike.rst +++ b/docs/fix/general-strike.rst @@ -8,8 +8,7 @@ fix/general-strike This script attempts to fix known causes of the "general strike bug", where dwarves just stop accepting work and stand around with "No job". -You can enable automatic running of this fix in the "Maintenance" tab of -`gui/control-panel`. +This script is set to run periodically by default in `gui/control-panel`. Usage ----- diff --git a/docs/fix/item-occupancy.rst b/docs/fix/item-occupancy.rst deleted file mode 100644 index b039cc73f8..0000000000 --- a/docs/fix/item-occupancy.rst +++ /dev/null @@ -1,25 +0,0 @@ -fix/item-occupancy -================== - -.. dfhack-tool:: - :summary: Fixes errors with phantom items occupying site. - :tags: unavailable - -This tool diagnoses and fixes issues with nonexistent 'items occupying site', -usually caused by hacking mishaps with items being improperly moved about. - -Usage ------ - -:: - - fix/item-occupancy - -Technical details ------------------ - -This tool checks that: - -#. Item has ``flags.on_ground`` <=> it is in the correct block item list -#. A tile has items in block item list <=> it has ``occupancy.item`` -#. The block item lists are sorted diff --git a/docs/fix/loyaltycascade.rst b/docs/fix/loyaltycascade.rst index 7a1eaad620..d4448f8c56 100644 --- a/docs/fix/loyaltycascade.rst +++ b/docs/fix/loyaltycascade.rst @@ -5,8 +5,10 @@ fix/loyaltycascade :summary: Halts loyalty cascades where dwarves are fighting dwarves. :tags: fort bugfix units -This tool aborts loyalty cascades by fixing units who consider their own -civilization to be the enemy. +This tool neutralizes loyalty cascades by fixing units who consider their own +civilization to be the enemy. It will also halt all fighting on the map that +involves your citizens, though "real" enemies will re-engage in combat after a +short delay. Usage ----- diff --git a/docs/fix/noexert-exhaustion.rst b/docs/fix/noexert-exhaustion.rst new file mode 100644 index 0000000000..1fae883635 --- /dev/null +++ b/docs/fix/noexert-exhaustion.rst @@ -0,0 +1,29 @@ +fix/noexert-exhaustion +====================== + +.. dfhack-tool:: + :summary: Prevents NOEXERT units from getting tired when training. + :tags: fort bugfix units + +This tool zeroes the "exhaustion" counter of all NOEXERT units (e.g. Vampires, Necromancers, and Intelligent Undead), +fixing any that are stuck 'Tired' from an activity that doesn't respect NOEXERT. This is not a permanent fix - +the issue will reoccur next time they partake in an activity that does not respect the NOEXERT tag. + +Running this regularly works around :bug:`8389`, which permanently debuffs NOEXERT units and prevents them from +properly partaking in military training. It should be run if you notice Vampires, Necromancers, or Intelligent +Undead becoming 'Tired'. Enabling this script via `control-panel` or `gui/control-panel` will run it often enough to +prevent NOEXERT units from becoming 'Tired'. + +Usage +----- +:: + + fix/noexert-exhaustion + +Technical details +----------------- + +Units with the NOEXERT tag ignore most sources of physical exertion, and have no means to recover from it. +Individual Combat Drill seems to add approximately 50 'Exhaustion' every 9 ticks, ignoring NOEXERT. +Units become Tired at 2000 Exhaustion, and switch from Individual Combat Drill to Individual Combat Drill/Resting at 3000. +Setting the Exhaustion counter of every NOEXERT-tagged unit to 0 every 350 ticks should prevent them from becoming Tired from Individual Combat Drill. diff --git a/docs/fix/occupancy.rst b/docs/fix/occupancy.rst new file mode 100644 index 0000000000..f94fd81a0a --- /dev/null +++ b/docs/fix/occupancy.rst @@ -0,0 +1,36 @@ +fix/occupancy +============= + +.. dfhack-tool:: + :summary: Fix phantom occupancy issues. + :tags: fort bugfix map + +If you see "unit blocking tile", "building present", or "items occupying site" +messages that you can't account for (:bug:`3499`), this tool can help. + +Usage +----- + +:: + + fix/occupancy [] [] + +The optional position can be map coordinates in the form ``0,0,0``, without +spaces, or the string ``here`` to use the position of the keyboard cursor, if +active. + +Examples +-------- + +``fix/occupancy`` + Fix all occupancy issues on the map. +``fix/occupancy here`` + Fix all occupancy issues on the tile selected by the keyboard cursor. +``fix-unit-occupancy -n`` + Report on, but do not fix, all occupancy issues on the map. + +Options +------- + +``-n``, ``--dry-run`` + Report issues, but do not write any changes to the map. diff --git a/docs/fix/ownership.rst b/docs/fix/ownership.rst new file mode 100644 index 0000000000..44c412db84 --- /dev/null +++ b/docs/fix/ownership.rst @@ -0,0 +1,30 @@ +fix/ownership +============= + +.. dfhack-tool:: + :summary: Fixes ownership links. + :tags: fort bugfix items units + +Due to a bug, a unit can believe they own an item when they actually do not. +Additionally, a room can remember that it is owned by a unit, but the unit can +forget that they own the room. + +Invalid item ownership links result in units getting stuck in a "Store owned +item" job. Missing room ownership links result in rooms becoming unused by the +nominal owner and unclaimable by any other unit. In particular, nobles and +administrators will not recognize that their room requirements are met. + +When enabled in `gui/control-panel`, `fix/ownership` will run once a day to +validate and fix ownership links for items and rooms. + +Usage +----- + +:: + + fix/ownership + +Links +----- + +Among other issues, this tool fixes :bug:`6578`. diff --git a/docs/fix/population-cap.rst b/docs/fix/population-cap.rst index e259a83044..a2eed10bdd 100644 --- a/docs/fix/population-cap.rst +++ b/docs/fix/population-cap.rst @@ -3,9 +3,10 @@ fix/population-cap .. dfhack-tool:: :summary: Ensure the population cap is respected. - :tags: unavailable + :tags: fort bugfix -Run this after every migrant wave to ensure your population cap is not exceeded. +Run this if you continue to get migrant waves even after you have exceeded your +set population cap. The reason this tool is needed is that the game only updates the records of your current population when a dwarven caravan successfully leaves for the @@ -26,10 +27,3 @@ Usage :: fix/population-cap - -Examples --------- - -``repeat --time 1 --timeUnits months --command [ fix/population-cap ]`` - Automatically run this fix after every migrant wave to keep the population - values up to date. diff --git a/docs/fix/protect-nicks.rst b/docs/fix/protect-nicks.rst index 0b00e94e7c..ead51cc42a 100644 --- a/docs/fix/protect-nicks.rst +++ b/docs/fix/protect-nicks.rst @@ -2,7 +2,7 @@ fix/protect-nicks ================= .. dfhack-tool:: - :summary: Fix nicknames being erased or not displayed + :summary: Fix nicknames being erased or not displayed. :tags: fort bugfix units Due to a bug, units nicknames are not displayed everywhere and are occasionally diff --git a/docs/fix/sleepers.rst b/docs/fix/sleepers.rst new file mode 100644 index 0000000000..bb316ad535 --- /dev/null +++ b/docs/fix/sleepers.rst @@ -0,0 +1,18 @@ +fix/sleepers +============ + +.. dfhack-tool:: + :summary: Fixes sleeping units belonging to a camp that never wake up. + :tags: adventure bugfix + +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. If you come across one or more bugged sleepers in +adventure mode, simply run the script and all nearby sleepers will be cured. + +Usage +----- + +:: + + fix/sleepers diff --git a/docs/fix/stuck-squad.rst b/docs/fix/stuck-squad.rst new file mode 100644 index 0000000000..21a2d5048d --- /dev/null +++ b/docs/fix/stuck-squad.rst @@ -0,0 +1,34 @@ +fix/stuck-squad +=============== + +.. dfhack-tool:: + :summary: Allow squads and messengers to rescue lost squads. + :tags: fort bugfix military + +Occasionally, squads that you send out on a mission get stuck on the world map. +They lose their ability to navigate and are unable to return to your fortress. +This tool allows a messenger that is returning from a holding or any other of +your squads that is returning from a mission to rescue the lost squad along the +way and bring them home. + +This fix is enabled by default in the DFHack +`control panel `, or you can run it as needed. However, it +is still up to you to send out a messenger or squad that can be tasked with the +rescue. If you have a holding that is linked to your fort, you can send out a +messenger -- you don't have to actually request any workers. Otherwise, you can +send a squad out on a mission with minimal risk, like "Demand one-time tribute". + +This tool is integrated with `gui/notify`, so you will get a notification in +the DFHack notification panel when a squad is stuck and there are no squads or +messengers currently out traveling that can rescue them. + +Note that there might be other reasons why your squad appears missing -- if it +got wiped out in combat and nobody survived to report back, for example -- but +this tool should allow you to recover from the cases that are actual bugs. + +Usage +----- + +:: + + fix/stuck-squad diff --git a/docs/fix/stuck-worship.rst b/docs/fix/stuck-worship.rst new file mode 100644 index 0000000000..04c4129364 --- /dev/null +++ b/docs/fix/stuck-worship.rst @@ -0,0 +1,50 @@ +fix/stuck-worship +================= + +.. dfhack-tool:: + :summary: Prevent dwarves from getting stuck in Worship! states. + :tags: fort bugfix units + +Dwarves that need to pray to multiple deities sometimes get stuck in a state +where they repeatedly fulfill the need to pray/worship one deity but ignore the +others. The intense need to pray to the other deities causes the dwarf to start +a purple (uninterruptible) ``Worship!`` activity, but since those needs are +never satisfied, the dwarf becomes stuck and effectively useless. More info on +this problem at :bug:`10918`. + +This fix analyzes all units that are actively praying/worshipping and detects +when they have become stuck (or are on the path to becoming stuck). It then +adjusts the distribution of need so when the dwarf finishes their current +prayer, a different prayer need will be fulfilled. + +This fix will run automatically if it is enabled in the ``Bugfixes`` tab in +`gui/control-panel`. If it is disabled there, you can still run it as needed. +You'll know it worked if your units go from ``Worship!`` to a prayer to some +specific deity or if the unit just stops praying altogether and picks up +another task. + +Usage +----- + +:: + + fix/stuck-worship [] + +Reshuffle prayer needs of units in the fort. + +Examples +-------- + +``fix/stuck-worship`` + Rebalance prayer needs and print the total number of affected units. +``fix/stuck-worship -v`` + Same as above, but also print the names of all affected units. + +Options +------- + +``-v``, ``--verbose`` + Print the names of all affected units. +``-q``, ``--quiet`` + Don't print the number of affected units if it's zero. Intended for + automatic use. diff --git a/docs/fix/symbol-unstick.rst b/docs/fix/symbol-unstick.rst new file mode 100644 index 0000000000..82b3f77db7 --- /dev/null +++ b/docs/fix/symbol-unstick.rst @@ -0,0 +1,18 @@ +fix/symbol-unstick +================== + +.. dfhack-tool:: + :summary: Unstick noble symbols that cannot be re-designated. + :tags: fort bugfix items + +Remove symbol designation from artifacts that cannot be re-designated +after the noble's promotion to a higher position. + +Usage +----- + +``fix/symbol-unstick`` + +Select an artifact that was designated as a noble's symbol and run the +command to remove its designation as a symbol. The operation will only +be performed if the symbol is claimed by a vacated noble position. diff --git a/docs/fix/tile-occupancy.rst b/docs/fix/tile-occupancy.rst deleted file mode 100644 index fbcfaa5ca0..0000000000 --- a/docs/fix/tile-occupancy.rst +++ /dev/null @@ -1,19 +0,0 @@ -fix/tile-occupancy -================== - -.. dfhack-tool:: - :summary: Fix tile occupancy flags. - :tags: unavailable - -This tool clears bad occupancy flags at the selected tile. It is useful for -getting rid of phantom "building present" messages when trying to build -something in an obviously empty tile. - -To use, select a tile with the in-game "look" (:kbd:`k` mode) cursor. - -Usage ------ - -:: - - fix/tile-occupancy diff --git a/docs/fix/wildlife.rst b/docs/fix/wildlife.rst new file mode 100644 index 0000000000..648f1f4dde --- /dev/null +++ b/docs/fix/wildlife.rst @@ -0,0 +1,70 @@ +fix/wildlife +============ + +.. dfhack-tool:: + :summary: Moves stuck wildlife off the map so new waves can enter. + :tags: fort bugfix animals + +This tool identifies wildlife that is trying to leave the map but has gotten +stuck. The stuck creatures will be moved off the map so that new waves of +wildlife can enter. When removing stuck wildlife, their regional population +counters are correctly incremented, just as if they had successfully left the +map on their own. + +Dwarf Fortress manages wildlife in "waves". A small group of creatures of a +species that has population associated with a local region enters the map, +wanders around for a while (or aggressively attacks you if it is an agitated +group), and then leaves the map. Any members of the group that successfully +leave the map will get added back to the regional population. + +The trouble, though, is that the group sometimes gets stuck when attempting to +leave. A new wave cannot enter until the previous group has been destroyed or +has left the map, so wildlife activity effectively completely halts. This is DF +:bug:`12921`. + +You can run this script without parameters to immediately remove stuck +wildlife, or you can enable it in the `gui/control-panel` on the Bug Fixes tab +to monitor and manage wildlife in the background. When enabled from the control +panel, it will monitor for stuck wildlife and remove wildlife after it has been +stuck for 7 days. + +Unlike most bugfixes, this one is not enabled by default since some players +like to keep wildlife around for creative purposes (e.g. for intentionally +stalling wildlife waves or for controlled startling of friendly necromancers). +These players can selectively ignore the wildlife they want to keep captive +before they enable `fix/wildlife`. + +Usage +----- +:: + + fix/wildlife [] + fix/wildlife ignore [unit ID] + +Examples +-------- + +``fix/wildlife`` + Remove any wildlife that is currently trying to leave the map but has not + yet succeeded. +``fix/wildlife --week`` + Remove wildlife that has been stuck for at least a week. The command must + be run periodically with this option so it can discover newly stuck + wildlife and remove wildlife when timeouts expire. +``fix/wildlife ignore`` + Disconnect the selected unit from its wildlife population so it doesn't + block new wildlife from entering the map, but keep the unit on the map. + This unit will not be touched by future invocations of this tool. + +Options +------- + +``-n``, ``--dry-run`` + Print out which creatures are stuck but take no action. +``-w``, ``--week`` + Discover newly stuck units and associate the current in-game time with + them. Units that were discovered on a previous invocation where this + parameter was specified will be removed if that time was at least a week + ago. +``-q``, ``--quiet`` + Don't print the number of affected units if no units were affected. diff --git a/docs/flashstep.rst b/docs/flashstep.rst index 5dcf37fe62..494d0e15d7 100644 --- a/docs/flashstep.rst +++ b/docs/flashstep.rst @@ -2,11 +2,12 @@ flashstep ========= .. dfhack-tool:: - :summary: Teleport your adventurer to the cursor. - :tags: unavailable + :summary: Teleport your adventurer to the mouse cursor. + :tags: adventure armok units ``flashstep`` is a hotkey-friendly teleport that places your adventurer where -your cursor is. +your mouse cursor is. A small area around the target tile is revealed. If you +take a step, then the normal vision cone area around the unit is revealed. Usage ----- diff --git a/docs/force.rst b/docs/force.rst index 915863a75f..fb52a15803 100644 --- a/docs/force.rst +++ b/docs/force.rst @@ -15,6 +15,7 @@ Usage :: force [] + force Wildlife [all] The civ id is only used for ``Diplomat`` and ``Caravan`` events, and defaults to the player civilization if not specified. @@ -27,18 +28,36 @@ The default civ IDs that you are likely to be interested in are: But to see IDs for all civilizations in your current game, run this command:: - devel/query --table df.global.world.entities.all --search code --maxdepth 2 + :lua ids={} for _,en in ipairs(world.entities.all) do ids[en.entity_raw.code] = true end for id in pairs(ids) do print(id) end + +Examples +-------- + +``force Caravan`` + Spawn a caravan from your parent civilization. +``force Diplomat FOREST`` + Spawn an elven diplomat. +``force Megabeast`` + Call in a megabeast to attack your fort. The megabeast will enter the map + on the surface. +``force Wildlife`` + Allow additional wildlife to enter the map. Only affects areas that you can + see, so if you haven't opened the caverns, cavern wildlife won't be + affected. +``force Wildlife all`` + Allow additional wildlife to enter the map, even in areas you haven't + explored yet. Event types ----------- -The recognized event types are: +The supported event types are: - ``Caravan`` - ``Migrants`` - ``Diplomat`` - ``Megabeast`` -- ``WildlifeCurious`` -- ``WildlifeMischievous`` -- ``WildlifeFlier`` -- ``NightCreature`` +- ``Wildlife`` + +Most events happen on the next tick. The ``Wildlife`` event may take up to 100 +ticks to take effect. diff --git a/docs/full-heal.rst b/docs/full-heal.rst index 0680efb03b..226761d0f9 100644 --- a/docs/full-heal.rst +++ b/docs/full-heal.rst @@ -22,7 +22,7 @@ Usage ``full-heal --all [-r] [--keep_corpse]`` Heal all units on the map, optionally resurrecting them if dead. ``full-heal --all_citizens [-r] [--keep_corpse]`` - Heal all fortress citizens on the map. Does not include pets. + Heal all fortress citizens and residents on the map. Does not include pets. ``full-heal --all_civ [-r] [--keep_corpse]`` Heal all units belonging to your parent civilization, including pets and visitors. @@ -35,3 +35,13 @@ Examples ``full-heal -r --keep_corpse --unit 23273`` Fully heal unit 23273. If this unit was dead, it will be resurrected without removing the corpse - creepy! + +Notes +----- + +If you have to repeatedly use `full-heal` on a dwarf only to have that dwarf's +syndrome return seconds later, then it's likely because said dwarf still has a +syndrome-causing residue on their body. To deal with this, either use +``clean units`` to decontaminate the dwarf or let a hospital worker wash the +residue off the dwarf and THEN do a `full-heal`. Syndromes like Beast Sickness +and Demon Sickness can by VERY NASTY, causing maladies like tissue necrosis. diff --git a/docs/gaydar.rst b/docs/gaydar.rst index ee40541155..7a2375ea9f 100644 --- a/docs/gaydar.rst +++ b/docs/gaydar.rst @@ -21,7 +21,7 @@ Examples ``gaydar`` Show sexual orientation of the selected unit. ``gaydar --citizens --asexual`` - Identify asexual citizens. + Identify asexual citizens and residents. Target options -------------- @@ -29,7 +29,7 @@ Target options ``--all`` Selects every creature on the map. ``--citizens`` - Selects fort citizens. + Selects fort citizens and residents. ``--named`` Selects all named units on the map. diff --git a/docs/ghostly.rst b/docs/ghostly.rst index 999125a28d..35dc96d3d1 100644 --- a/docs/ghostly.rst +++ b/docs/ghostly.rst @@ -3,7 +3,7 @@ ghostly .. dfhack-tool:: :summary: Toggles an adventurer's ghost status. - :tags: unavailable + :tags: adventure armok units This is useful for walking through walls, avoiding attacks, or recovering after a death. diff --git a/docs/gui/adv-finder.rst b/docs/gui/adv-finder.rst new file mode 100644 index 0000000000..a1d12128e7 --- /dev/null +++ b/docs/gui/adv-finder.rst @@ -0,0 +1,112 @@ +gui/adv-finder +============== + +.. dfhack-tool:: + :summary: Find and track historical figures and artifacts + :tags: adventure armok inspection items units + +A real-time tracker for historical figures and artifacts. Select a target by +clicking the settings icon [☼] and selecting an entry from the list in the +relevant tab. The list can be filtered by search string, as well as by +excluding dead figures (displayed in red text). Artifacts can exclude books, +and the "dead" option excludes artifacts held by dead figures (which are +generally unrecoverable). Dismissing the screen (e.g., right-click) will +close the target search window first. A second dismissal will close the +finder window, but target settings will be preserved until the world is +unloaded. + +Your coordinates will be kept up to date alongside your target's. There are +two types of coordinates, and they will be displayed as long as they can be +determined. + +========== ========== +Coord Type Meaning +========== ========== +Global Distance in map blocks from the world origin (northwest corner). + The adventurer usually moves by 3 blocks during fast travel, but + slows to 1 when the zoomed site map is displayed. Equivalent to + 16 local tiles. Always available except for targets with an + indeterminate location. +Local Tile coordinates, available outside of fast travel and sleeping. + Your target's local coordinates are displayed when nearby and + loaded. Local coordinates will remain consistent within a site, but + may jump around in the wilderness as areas of the world are loaded. +========== ========== + +For global coordinates, the Z component will only be displayed if it can be +specifically determined by the location type. This represents an underground +layer depth, so the surface is indicated by ``Z0`` and the first cavern layer +is ``Z-1``. + +A compass and relative coordinates will be displayed. The relative coordinate +display uses the most precise coordinate type shared between you and your +target. + +There are six types of location types displayed for targets: + +============= ========== +Location Type Meaning +============= ========== +Nearby The target is loaded into the map area and the local + coordinates will be displayed. If you don't see this when you're + in the correct area and outside fast travel, then the target + isn't loading for some reason and you'll never be able to find + them. +Site The target is located within a site. The text displays + "At " and the global coords will represent the center + of the site if the target doesn't track its own precise + coordinates (e.g., worldgen being vague). +Traveling The target is traveling around the world map like an army. +Wilderness The target is somewhere on the surface not in a site. +Underground The target is somewhere in the caverns not in a site. +None The target's location isn't defined in the game world. + Maybe they're a deity. Maybe they got dropped off in limbo + after their army disbanded. If they're dead, the location + wasn't recorded properly in history. The text displays "Missing" + if they're dead or can die of old age, else "Transcendent" + because nothing can touch them. +============= ========== + +Dead figures generally can't be encountered at all, and they take their items +with them if they weren't separated properly by worldgen. The coord given is +usually a death or abstract burial location, but the corpse isn't guaranteed to +exist. Generally, wilderness and underground locations only have coords if you +left something there in adventure mode. Anything lost there during worldgen or a +fort mode mission likely can't be located. Anything in a site is usually a safe +bet, but sometimes items won't load. (Fort missions can be used to acquire these +for later retrieval, however.) Traveling targets are always valid. + +Usage +----- + +:: + + gui/adv-finder [] + +Examples +-------- + +``gui/adv-finder`` + Open the finder window (unless already open). Target will be blank on first + use, but maintained on future invocations. +``gui/adv-finder --histfig 1234`` + Track the historical figure with ID #1234. Finder will be opened if not + already. +``gui/adv-finder -h -1 -a -1`` + Clear any target so it's just the adventurer. Finder will be opened if not + already. +``gui/adv-finder --debug`` + Display selected target IDs in the finder window title bar. Finder will be + opened if not already. This setting isn't saved, so it can be disabled by + invoking ``gui/adv-finder`` again without the option. + +Options +------- + +``-h``, ``--histfig `` + Set the target to the historical figure with the given ID. +``-a``, ``--artifact `` + Set the target to the artifact record with the given ID. (Not an item ID!) +``-d``, ``--debug`` + Display selected target IDs in the finder window title bar. Doesn't persist + between invocations. diff --git a/docs/gui/aquifer.rst b/docs/gui/aquifer.rst new file mode 100644 index 0000000000..a595e197d1 --- /dev/null +++ b/docs/gui/aquifer.rst @@ -0,0 +1,27 @@ +gui/aquifer +=========== + +.. dfhack-tool:: + :summary: View, add, remove, or modify aquifers. + :tags: fort armok map + +This is the interactive GUI for the `aquifer` tool. While `gui/aquifer` is +open, aquifer tiles will be highlighted as per the `dig.warmdamp ` overlay +(but unlike that overlay, only aquifer tiles are highlighted, not "just damp" +tiles or warm tiles). Note that "just damp" tiles will still be highlighted if +they are otherwise already visible. + +You can draw boxes around areas of tiles to alter their aquifer properties, or +you can use the :kbd:`Ctrl`:kbd:`A` shortcut to affect entire layers at a time. + +If you want to see where the aquifer tiles are so you can designate digging, +please run `gui/reveal`. If you only want to see the aquifer tiles and not +reveal the caverns or other tiles, please run +`gui/reveal --aquifers-only ` instead. + +Usage +----- + +:: + + gui/aquifer diff --git a/docs/gui/autobutcher.rst b/docs/gui/autobutcher.rst index f2120ad99a..98713e5893 100644 --- a/docs/gui/autobutcher.rst +++ b/docs/gui/autobutcher.rst @@ -7,8 +7,8 @@ gui/autobutcher :tags: fort auto fps animals This is an in-game interface for `autobutcher`, which allows you to set -thresholds for how many animals you want to keep and will automatically butcher -the extras. +thresholds for how many animals you want to keep and the extras will be +automatically butchered. Usage ----- diff --git a/docs/gui/autodump.rst b/docs/gui/autodump.rst index d20ca0021f..cad8c4f4eb 100644 --- a/docs/gui/autodump.rst +++ b/docs/gui/autodump.rst @@ -9,8 +9,9 @@ This is a general point and click interface for teleporting or destroying items. By default, it will teleport items you have marked for dumping, but if you draw boxes around items on the map, it will act on the selected items instead. Double-click anywhere on the map to teleport the items there. Be wary -(or excited) that if you teleport the items into an unsupported position (e.g. -mid-air), then they will become projectiles and fall. +(or excited) that if you teleport the items into an unsupported position +(e.g., mid-air), then they will become projectiles and fall. Items may not be +teleported into walls or hidden tiles. There are options to include or exclude forbidden items, items that are currently tagged as being used by an active job, and items dropped by traders. diff --git a/docs/gui/autotraining.rst b/docs/gui/autotraining.rst new file mode 100644 index 0000000000..a86b28adf9 --- /dev/null +++ b/docs/gui/autotraining.rst @@ -0,0 +1,15 @@ +gui/autotraining +================ + +.. dfhack-tool:: + :summary: GUI interface for ``autotraining`` + :tags: fort auto interface + +This is an in-game configuration interface for `autotraining`. You can pick squads for training, select ignored units, and set the needs threshold. + +Usage +----- + +:: + + gui/autotraining diff --git a/docs/gui/biomes.rst b/docs/gui/biomes.rst new file mode 100644 index 0000000000..957527f1f8 --- /dev/null +++ b/docs/gui/biomes.rst @@ -0,0 +1,26 @@ +gui/biomes +========== + +.. dfhack-tool:: + :summary: Visualize and inspect biome regions on the map. + :tags: fort inspection map + +This script shows the boundaries of the biome regions on the map. +Hover over a biome entry in the list to get detailed info about it. + +Note that up in mid-air, there may be additional biomes inherited from +neighboring embark squares due to DF :bug:`8781`. This does not usually affect +the player unless: + +- You build up into the sky, cast obsidian to make natural flooring, muddy it, + and designate a farm plot +- The inherited sky biome is evil and has an effect on fliers that happen to + enter its space (e.g. avian wildlife can unexpectedly get zombified or drop + dead from syndromes) + +Usage +----- + +:: + + gui/biomes diff --git a/docs/gui/civ-alert.rst b/docs/gui/civ-alert.rst index cc87518550..7cfcfed720 100644 --- a/docs/gui/civ-alert.rst +++ b/docs/gui/civ-alert.rst @@ -32,9 +32,10 @@ activate the civ alert right away with the button in the upper right corner. You can also access this button at any time from the squads panel. When danger appears, open up the squads menu and click on the new "Activate -civilian alert" button in the lower left corner. It's big and red; you can't -miss it. Your civilians will rush off to safety and you can concentrate on -dealing with the incursion without Urist McArmorsmith getting in the way. +civilian alert" button in the lower left corner of the panel. It's big and red; +you can't miss it. Your civilians will rush off to safety and you can +concentrate on dealing with the incursion without Urist McArmorsmith getting in +the way. When the civ alert is active, the civilian alert button will stay on the screen, even if the squads menu is closed. After the danger has passed, @@ -47,8 +48,8 @@ Overlay The position of the "Activate civilian alert" button that appears when the squads panel is open is configurable via `gui/overlay`. The overlay panel also -gives you a way to launch `gui/civ-alert` if you need to change which burrow -civilians should be gathering at. +gives you a way to launch `gui/civ-alert` if you need to change which burrow(s) +civilians should be gathering in. Technical notes --------------- diff --git a/docs/gui/confirm.rst b/docs/gui/confirm.rst index 644581cd88..a932e9c7c5 100644 --- a/docs/gui/confirm.rst +++ b/docs/gui/confirm.rst @@ -5,12 +5,16 @@ gui/confirm :summary: Configure which confirmation dialogs are enabled. :tags: fort interface -This tool is a basic configuration interface for the `confirm` plugin. You can -see and modify which confirmation dialogs are enabled. +This tool is a basic configuration interface for `confirm`. You can see and +modify which confirmation dialogs are enabled. Your selections will be saved as +personal preferences, and will apply to all forts going forward. Usage ----- :: - gui/confirm + gui/confirm [] + +You can optionally pass the ID of a confirmation dialog to have it initially +selected in the list. diff --git a/docs/gui/control-panel.rst b/docs/gui/control-panel.rst index a8933087fd..e3b1de92db 100644 --- a/docs/gui/control-panel.rst +++ b/docs/gui/control-panel.rst @@ -2,83 +2,94 @@ gui/control-panel ================= .. dfhack-tool:: - :summary: Configure DFHack. + :summary: Configure DFHack and manage active DFHack tools. :tags: dfhack The DFHack control panel allows you to quickly see and change what DFHack tools -are enabled now, which tools will run when you start a new fort, and how global -DFHack configuration options are set. It also provides convenient links to -relevant help pages and GUI configuration frontends. The control panel has -several pages that you can switch among by clicking on the tabs at the top of -the window. Each page has a search filter so you can quickly find the tools and -options that you're looking for. - -Fort Services -------------- - -The fort services page shows tools that you can enable in fort mode. You can -select the tool name to see a short description at the bottom of the list. Hit +are enabled, which tools will run when you start a new fort, which UI overlays +are enabled, and how global DFHack configuration options are set. It also +provides convenient links to relevant help pages and GUI configuration +frontends (where available). The control panel has several sections that you +can access by clicking on the tabs at the top of the window. Each tab has a +search filter so you can quickly find the tools and options that you're looking +for. + +The tabs can also be navigated with the keyboard, with the :kbd:`Ctrl`:kbd:`T` +and :kbd:`Ctrl`:kbd:`Y` hotkeys. These are the default hotkeys for navigating +DFHack tab bars. + +The "Automation", "Bug Fixes", and "Gameplay" tabs +-------------------------------------------------- + +These three tabs provide access to the three main subcategories of DFHack tools. +In general, you'll probably want to start with only the "Bugfix" tools enabled. +As you become more comfortable with vanilla systems, and some of them start to +become less fun and more toilsome, you can enable more of the "Automation" +tools to manage them for you. Finally, you can examine the tools on the +"Gameplay" tab and enable whatever you think sounds like fun :). + +Under each of these tabs, there are two subtabs: "Enabled" and "Autostart". The +subtabs can be navigated with the keyboard, using the :kbd:`Ctrl`:kbd:`N` and +:kbd:`Ctrl`:kbd:`M` hotkeys. + +The "Enabled" subtab +~~~~~~~~~~~~~~~~~~~~ + +The "Enabled" tab allows you to toggle which tools are enabled right now. You +can select the tool in the list to see a short description at the bottom. Hit :kbd:`Enter`, double click on the tool name, or click on the toggle on the far left to enable or disable that tool. -Note that the fort services displayed on this page can only be enabled when a -fort is loaded. They will be disabled in the list and cannot be enabled or have -their GUI config screens shown until you have loaded a fortress. Once you do -enable them (after you've loaded a fort), they will save their state with your -fort and automatically re-enable themselves when you load your fort again. +Note that before a fort is loaded, there will be very few tools listed here. +Come back when a fort is loaded to see much more. + +Once tools are enabled, they will save their state with your fort and +automatically re-enable themselves when you load that same fort again. You can hit :kbd:`Ctrl`:kbd:`H` or click on the help icon to show the help page for the selected tool in `gui/launcher`. You can also use this as shortcut to run custom commandline commands to configure that tool manually. If the tool has an associated GUI config screen, a gear icon will also appear next to the help -icon. Hit :kbd:`Ctrl`:kbd:`G` or click on that icon to launch the relevant -configuration interface. +icon. Hit :kbd:`Ctrl`:kbd:`G`, click on the gear icon, or Shift-double click +the tool name to launch the relevant configuration interface. .. _dfhack-examples-guide: -New Fort Autostart Commands ---------------------------- - -This page shows the tools that you can configure DFHack to auto-enable or -auto-run when you start a new fort. You'll recognize many tools from the -previous page here, but there are also useful one-time commands that you might -want to run at the start of a fort, like `ban-cooking all `. - -Periodic Maintenance Operations -------------------------------- - -This page shows commands that DFHack can regularly run for you in order to keep -your fort (and the game) running smoothly. For example, there are commands to -periodically enqueue orders for shearing animals that are ready to be shorn or -sort your manager orders so slow-moving daily orders won't prevent your -high-volume one-time orders from ever being completed. - -System Services ---------------- - -The system services page shows "core" DFHack tools that provide background -services to other tools. It is generally not advisable to turn these tools -off. If you do toggle them off in the control panel, they will be re-enabled -when you restart the game. If you really need to turn these tools off -permanently, add a line like ``disable toolname`` to your -``dfhack-config/init/dfhack.init`` file. - -Overlays --------- - -The overlays page allows you to easily see which overlays are enabled and lets -you toggle them on and off and see the help for the owning tools. If you want to -reposition any of the overlay widgets, hit :kbd:`Ctrl`:kbd:`G` or click on -the the hotkey hint to launch `gui/overlay`. - -Preferences ------------ - -The preferences page allows you to change DFHack's internal settings and -defaults, like whether DFHack tools pause the game when they come up, or how -long you can wait between clicks and still have it count as a double-click. Hit -:kbd:`Ctrl`:kbd:`G` or click on the hotkey hint at the bottom of the page to -restore all preferences to defaults. +The "Autostart" subtab +~~~~~~~~~~~~~~~~~~~~~~ + +This subtab is organized similarly to the "Enabled" subtab, but instead of +tools you can enable now, it shows the tools that you can configure DFHack to +auto-enable or auto-run when you start the game or a new fort. You'll recognize +many tools from the "Enabled" subtab here, but there are also useful one-time +commands that you might want to run at the start of a fort, like +`ban-cooking all ` or (if you have "mortal mode" disabled in the +"Preferences" tab) god-mode tools like `light-aquifers-only`. + +The "UI Overlays" tab +--------------------- + +DFHack overlays add information and additional functionality to the vanilla DF +screens. For example, the popular DFHack `Building Planner ` is +an overlay named ``buildingplan.planner`` that appears when you are building +something. + +The "Overlays" tab allows you to easily see which overlays are enabled, gives +you a short description of what each one does, lets you toggle them on and off, +and gives you links for the related help text (which is normally added at the +bottom of the help page for the tool that provides the overlay). If you want to +reposition any of the overlay widgets, hit :kbd:`Ctrl`:kbd:`G` or click on the +the hotkey hint to launch `gui/overlay`. + +The "Preferences" tab +--------------------- + +The "Preferences" tab allows you to change DFHack's internal settings and +defaults, like whether DFHack's "mortal mode" is enabled -- hiding the god-mode +tools from the UI, whether DFHack tools pause the game when they come up, or how +long you can take between clicks and still have it count as a double-click. +Click on the gear icon or hit :kbd:`Enter` to toggle or edit the selected +preference. Usage ----- diff --git a/docs/gui/create-item.rst b/docs/gui/create-item.rst index 3358d3ab91..580dec5f1d 100644 --- a/docs/gui/create-item.rst +++ b/docs/gui/create-item.rst @@ -3,15 +3,16 @@ gui/create-item .. dfhack-tool:: :summary: Summon items from the aether. - :tags: fort armok items + :tags: adventure fort armok items This tool provides a graphical interface for creating items of your choice. It walks you through the creation process with a series of prompts, asking you for the type of item, the material, the quality, and the quantity. If a unit is selected, that unit will be designated the creator of the summoned -items. The items will appear at that unit's feet. If no unit is selected, the -first citizen unit will be used as the creator. +items. Any item with a "sized for" property, like armor, will be created for +that unit's race, and the items will appear at that unit's feet. If no unit is +selected, the first citizen unit will be used as the creator. Usage ----- @@ -41,6 +42,10 @@ Options ``-f``, ``--unrestricted`` Don't restrict the material options to only those that are normally appropriate for the selected item type. +``-p``, ``--pos ,,`` + If specified, items will be spawned at the given coordinates instead of at + the creator unit's feet. ``here`` can be used in place of ``,,`` + to use the active keyboard cursor. ``--startup`` Instead of showing the item creation interface, start monitoring for a modded reaction with a code of ``DFHACK_WISH``. When a reaction with that diff --git a/docs/gui/create-tree.rst b/docs/gui/create-tree.rst deleted file mode 100644 index c1a3819522..0000000000 --- a/docs/gui/create-tree.rst +++ /dev/null @@ -1,23 +0,0 @@ -gui/create-tree -=============== - -.. dfhack-tool:: - :summary: Create a tree. - :tags: unavailable - -This tool provides a graphical interface for creating trees. - -Place the cursor wherever you want the tree to appear before you run the script. - -Then, select the desired tree type from the list, and then the age. If you just -hit :kbd:`Enter` for the age, it will default to 1 (the youngest age for a -non-sapling tree). - -For full-grown trees, try an age of 100. - -Usage ------ - -:: - - gui/create-tree diff --git a/docs/gui/design.rst b/docs/gui/design.rst index 29d46dfebb..1c182ad262 100644 --- a/docs/gui/design.rst +++ b/docs/gui/design.rst @@ -4,10 +4,11 @@ gui/design .. dfhack-tool:: :summary: Design designation utility with shapes. - :tags: fort design productivity map + :tags: fort design productivity interface map This tool provides a point and click interface to make designating shapes -and patterns easier. Supports both digging designations and placing constructions. +and patterns easier. Supports both digging designations and placing +constructions. Usage ----- @@ -16,10 +17,64 @@ Usage gui/design +Shapes +------ + +- Rectangle + - They can be hollow or filled using 'h'. + - When hollow line thickness can be increased/decreased using 'T'/'t'. + - They can be inverted using 'i'. +- Ellipse + - They can be hollow or filled using 'h'. + - When hollow line thickness can be increased/decreased using 'T'/'t'. + - They can be inverted using 'i'. +- Star + - The default has 5 points, use 'B'/'b' to increase/decrease points. + - They can be hollow or filled using 'h'. + - When hollow line thickness can be increased/decreased using 'T'/'t'. + - They can be inverted using 'i'. + - The next-point offset can be increased/decreased using 'N'/'n' which + particularly affects 7 point stars and above to make them spikier or + smoother, but can also be used to decrease to 1 to make symmetrical + polygons or increase to N which only paints the vertexes. + - The orientation can be changed by adding a main axis point using 'v' and + moving this to point in the desired direction. +- Rows + - Vertical rows can be toggled using 'v'. + - Horizontal rows can be toggled using 'h'. + - Spacing can be increased/decreased using 'T'/'t'. + - They can be inverted using 'i'. +- Diagonal + - Direction can be reversed using 'R'. + - Spacing can be increased/decreased using 'T'/'t'. + - They can be inverted using 'i'. +- Line + - Line thickness can be increased/decreased using 'T'/'t'. + - Can be curved by adding one or more control points using 'v'. +- FreeForm + - Can be toggled open multi-line sequence or closed polygon using 'y' + - Line thickness can be increased/decreased using 'T'/'t'. + Overlay ------- -This script provides an overlay that shows the selected dimensions when -designating something with vanilla tools, for example when painting a burrow or +This tool also provides two overlays that are managed by the `overlay` +framework. + +dimensions +~~~~~~~~~~ + +The ``gui/design.dimensions`` overlay shows the selected dimensions when +designating with vanilla tools, for example when painting a burrow or designating digging. The dimensions show up in a tooltip that follows the mouse cursor. + +When this overlay is enabled, the vanilla dimensions display will be hidden. +When this overlay is disabled, the vanilla dimensions display will be unhidden. + +rightclick +~~~~~~~~~~ + +The ``gui/design.rightclick`` overlay prevents the right mouse button and other +keys bound to "Leave screen" from exiting out of designation mode when drawing +a box with vanilla tools, instead making it cancel the designation first. diff --git a/docs/gui/embark-anywhere.rst b/docs/gui/embark-anywhere.rst new file mode 100644 index 0000000000..db5c06fc34 --- /dev/null +++ b/docs/gui/embark-anywhere.rst @@ -0,0 +1,62 @@ +gui/embark-anywhere +=================== + +.. dfhack-tool:: + :summary: Embark wherever you want. + :tags: embark armok interface + +If you run this command when you're choosing a site for embark, you can bypass +any warnings the game gives you about potential embark locations. Want to +embark in an inaccessible location on top of a mountain range? Go for it! Want +to try a brief existence in the middle of the ocean? Nobody can stop you! Want +to tempt fate by embarking *inside of* a necromancer tower? !!FUN!! + +If you are using this tool to create a fort that will bridge two disconnected +areas of land, see `So you want to bridge a gap?`_ below for tips and caveats. + +Any and all consequences of embarking in strange locations are up to you to +handle (possibly with other `armok ` tools). In particular, +embarking in inaccessible locations will prevent migrants, caravans, and +visitors from arriving. + +Usage +----- + +:: + + gui/embark-anywhere + +The command will only work when you are on the screen where you can choose the +embark site for your fort. + +So you want to bridge a gap? +---------------------------- + +A popular use case for this tool is to create a fort (or a series of forts) that +bridges two disconnected landmasses so sites on the two landmasses can reach +each other (that is, they can send raiding parties and/or engage in trade). + +However, the way this works is not entirely intuitive. + +A single large embark is not necessarily going to functionally connect the two +shores so that armies can cross the gap. You could still choose to use this +approach to build a continuous constructed bridge in fort mode for later use as +an *adventurer* in adventure mode, but it will not be usable by the other +characters/armies in the world. + +The DF world map is divided into blocks of 16x16 tiles. When you are choosing +where to embark and you move the mouse so that your embark area "shadow" moves +over a little bit -- that's one "tile". An embark area can span block +boundaries, and there is no indication on the map where those boundaries are. + +The way DF determines world pathability is to check if the ground is continuous +**or** if the enclosing 16x16 block contains the upper left tile of a fort +embark area. + +In order for a connection to be formed for armies, one fort upper left corner +must exist in each 16x16 block that contains part of the gap. + +Therefore, the simplest solution for making a "bridge" that armies can use (but +walking adventurers cannot) is to make a 1x1 fort every 16 tiles across the +water gap, starting on land on one shore and finishing on land on the opposite +shore. That will ensure that every 16x16 block in the gap is covered by a fort. diff --git a/docs/gui/family-affairs.rst b/docs/gui/family-affairs.rst index 7a1e0a4bfc..a6d0a039fd 100644 --- a/docs/gui/family-affairs.rst +++ b/docs/gui/family-affairs.rst @@ -2,28 +2,46 @@ gui/family-affairs ================== .. dfhack-tool:: - :summary: Inspect or meddle with romantic relationships. - :tags: unavailable + :summary: Manage romantic relationships and generate pregnancies. + :tags: adventure fort armok animals units -This tool provides a user-friendly interface to view romantic relationships, -with the ability to add, remove, or otherwise change them at your whim - -fantastic for depressed dwarves with a dead spouse (or matchmaking players...). +This tool provides an interface for inspecting (or meddling with) romantic +relationships and for producing pregnancies with specific mothers and fathers. +Perfect for matchmaking players! -The target/s must be alive, sane, and in fortress mode. +If a unit is selected when you run `gui/family-affairs`, they will be +pre-loaded as a romantic partner (or prospective parent). While the window is +up, you can click on units on the map and assign them roles in the +`gui/family-affairs` UI. + +You can click on unit names in the `gui/family-affairs` UI to zoom the map to +their location. + +Whereas you can choose any historical figure (of the same race) to serve as a +spouse or lover, a unit must be on the map to participate in a pregnancy. For +example, you cannot generate a pregnancy with a father that is not on-site, +even if they are the selected mother's spouse. + +Children and units that are insane cannot be selected for participation in a +pregnancy, and, due to game limitations, cross-species pregnancies are not +supported. Usage ----- -``gui/family-affairs [unitID]`` - Show GUI for the selected unit, or the unit with the specified unit ID. -``gui/family-affairs divorce [unitID]`` - Remove all spouse and lover information from the unit and their partner. -``gui/family-affairs [unitID] [unitID]`` - Divorce the two specified units and their partners, then arrange for the two - units to marry. +:: + + gui/family-affairs + gui/family-affairs --pregnancy + +Passing the ``--pregnancy`` option will start the `gui/family-affairs` UI on +the "Pregnancies" tab, and any (adult, sane) unit you have selected at the time +will be pre-selected as a parent. -Screenshot ----------- +Technical notes +--------------- -.. image:: /docs/images/family-affairs.png - :align: center +The reason for the requirement that a father must be on the map to contribute +to a pregnancy is that the genes used for the pregnancy are associated with the +physical unit. They are not stored with the "historical figure" that represents +the father when he is off-map. diff --git a/docs/gui/gm-editor.rst b/docs/gui/gm-editor.rst index 080c49b98e..e2a34a488b 100644 --- a/docs/gui/gm-editor.rst +++ b/docs/gui/gm-editor.rst @@ -16,22 +16,39 @@ Hold down :kbd:`Shift` and right click to exit, even if you are inspecting a substructure, no matter how deep. If you just want to browse without fear of accidentally changing anything, hit -:kbd:`Ctrl`:kbd:`D` to toggle read-only mode. If you want `gui/gm-editor` to -automatically pick up changes to game data in realtime, hit :kbd:`Alt`:kbd:`A` -to switch to auto update mode. +:kbd:`Ctrl`:kbd:`D` to toggle read-only mode. + +If you want `gui/gm-editor` to automatically pick up changes to game data in +realtime, hit :kbd:`Alt`:kbd:`A` to switch to auto update mode. + +.. warning:: + + Note that data structures can be created and deleted while the game is + running. If you happen to be inspecting a dynamically allocated data + structure when it is deleted by the game, the game may crash. Please save + your game before poking around in `gui/gm-editor`, especially if you are + examining data while the game is unpaused. + +.. warning:: + + Union data structures contain fields that occupy the same memory space. + When you see the ``[union structure]`` badge at the top of the screen, be + aware that only one of the fields in the structure is likely to make sense. + The "correct" field is usually indicated by some context in the parent + structure. If there are any pointers to substructures in the union, + inspecting the pointer when it is not the "correct" field may crash the + game. Usage ----- -``gui/gm-editor [-f]`` - Open the editor on whatever is selected or viewed (e.g. unit/item - description screen) -``gui/gm-editor [-f] `` - Evaluate a lua expression and opens the editor on its results. Field - prefixes of ``df.global`` can be omitted. -``gui/gm-editor [-f] dialog`` - Show an in-game dialog to input the lua expression to evaluate. Works the - same as the version above. +:: + + gui/gm-editor [] [] + gui/gm-editor [] dialog + +When specifying a lua expression, field prefixes of ``df.global`` can be +omitted. Examples -------- @@ -40,15 +57,22 @@ Examples Opens the editor on the selected unit/item/job/workorder/stockpile etc. ``gui/gm-editor world.items.all`` Opens the editor on the items list. +``gui/gm-editor df.unit.find(12345)`` + Opens the editor on the unit with id 12345. +``gui/gm-editor reqscript('gui/quickfort').view`` + Opens the editor on a running instance of `gui/quickfort`. Useful for + debugging GUI tool state during development. ``gui/gm-editor --freeze scr`` Opens the editor on the current DF viewscreen data (bypassing any DFHack - layers) and prevents the underlying viewscreen from getting updates while - you have the editor open. + tools that may be open) and prevents the underlying viewscreen from getting + updates while you are inspecting the data. +``gui/gm-editor dialog`` + Show an in-game dialog to input the lua expression to evaluate. Options ------- -``-f``, ``--freeze`` +``-f``, ``--freeze``, ``--safe-mode`` Freeze the underlying viewscreen so that it does not receive any updates. This allows you to be sure that whatever you are inspecting or modifying will not be read or changed by the game until you are done with it. Note @@ -57,6 +81,12 @@ Options `gui/gm-editor` as usual when the game is frozen. The black background will disappear when the last `gui/gm-editor` window that was opened with the ``--freeze`` option is dismissed. +``--no-stringification`` + Don't attempt to provide helpful string representations of potentially + unsafe fields like language_name when browsing the data structures. Specify + this option when you know you will be browsing garbage data that could lead + to crashes if accessed for stringification. Note that fields in union data + structures are never stringified. Screenshot ---------- diff --git a/docs/gui/journal.rst b/docs/gui/journal.rst new file mode 100644 index 0000000000..5240002c5c --- /dev/null +++ b/docs/gui/journal.rst @@ -0,0 +1,58 @@ +gui/journal +=========== + +.. dfhack-tool:: + :summary: Fort journal with a multi-line text editor. + :tags: adventure fort interface + +The `gui/journal` interface makes it easy to take notes and document +important details for your fortresses and adventurers. + +With this multi-line text editor, +you can keep track of your fortress's/adventurer's background story, goals, +notable events, and both short-term and long-term plans. + +This is particularly useful when you need to take a longer break from the game. +Having detailed notes makes it much easier to resume your game after +a few weeks or months, without losing track of your progress and objectives. + +Supported Features +------------------ + +- Cursor Control: Navigate through text using arrow keys (left, right, up, down) for precise cursor placement. +- Fast Rewind: Use :kbd:`Ctrl` + :kbd:`Left` / :kbd:`Ctrl` + :kbd:`B` and :kbd:`Ctrl` + :kbd:`Right` / :kbd:`Ctrl` + :kbd:`F` to move the cursor one word back or forward. +- Longest X Position Memory: The cursor remembers the longest x position when moving up or down, making vertical navigation more intuitive. +- Mouse Control: Use the mouse to position the cursor within the text, providing an alternative to keyboard navigation. +- New Lines: Easily insert new lines using the :kbd:`Enter` key, supporting multiline text input. +- Text Wrapping: Text automatically wraps within the editor, ensuring lines fit within the display without manual adjustments. +- Backspace Support: Use the backspace key to delete characters to the left of the cursor. +- Delete Character: :kbd:`Delete` deletes the character under the cursor. +- Line Navigation: :kbd:`Home` moves the cursor to the beginning of the current line, and :kbd:`End` moves it to the end. +- Delete Current Line: :kbd:`Ctrl` + :kbd:`U` deletes the entire current line where the cursor is located. +- Delete Rest of Line: :kbd:`Ctrl` + :kbd:`K` deletes text from the cursor to the end of the line. +- Delete Last Word: :kbd:`Ctrl` + :kbd:`W` removes the word immediately before the cursor. +- Text Selection: Select text with the mouse, with support for replacing or removing selected text. +- Jump to Beginning/End: Quickly move the cursor to the beginning or end of the text using :kbd:`Shift` + :kbd:`Up` and :kbd:`Shift` + :kbd:`Down`. +- Select Word/Line: Use double click to select current word, or triple click to select current line +- Select All: Select entire text by :kbd:`Ctrl` + :kbd:`A` +- Undo/Redo: Undo/Redo changes by :kbd:`Ctrl` + :kbd:`Z` / :kbd:`Ctrl` + :kbd:`Y` +- Clipboard Operations: Perform OS clipboard cut, copy, and paste operations on selected text, allowing you to paste the copied content into other applications. +- Copy Text: Use :kbd:`Ctrl` + :kbd:`C` to copy selected text. + - copy selected text, if available + - If no text is selected it copy the entire current line, including the terminating newline if present. +- Cut Text: Use :kbd:`Ctrl` + :kbd:`X` to cut selected text. + - cut selected text, if available + - If no text is selected it will cut the entire current line, including the terminating newline if present +- Paste Text: Use :kbd:`Ctrl` + :kbd:`V` to paste text from the clipboard into the editor. + - replace selected text, if available + - If no text is selected, paste text in the cursor position +- Scrolling behaviour for long text build-in +- Table of contents (:kbd:`Ctrl` + :kbd:`O`), with headers line prefixed by '#', e.g. '# Fort history', '## Year 1' +- Table of contents navigation: jump to previous/next section by :kbd:`Ctrl` + :kbd:`Up` / :kbd:`Ctrl` + :kbd:`Down` + +Usage +----- + +:: + + gui/journal diff --git a/docs/gui/keybinds.rst b/docs/gui/keybinds.rst new file mode 100644 index 0000000000..f6031a27ce --- /dev/null +++ b/docs/gui/keybinds.rst @@ -0,0 +1,30 @@ +gui/keybinds +============ + +.. dfhack-tool:: + :summary: Manage your dfhack keybinds visually. + :tags: dfhack + +This tool allows you to create, edit, save, and delete custom keybinds that +run dfhack commands. + +Usage +----- + +:: + + gui/keybinds + +Focus Strings +------------- + +Keybinds may have a focus filter applied, enabling or disabling the keybind +based on the current open menu or gamemode. More information on the percise +format can be found in `keybinding`. + +Saved Keybinds +-------------- + +If saved, all currently active keybinds are stored in a dfhack init script at +``dfhack-config/init/dfhack.auto.keybinds.init``. The save does not remove any +keybinds set in other init scripts, nor created in-game. diff --git a/docs/gui/launcher.rst b/docs/gui/launcher.rst index 04d81b41f3..f5e39579bc 100644 --- a/docs/gui/launcher.rst +++ b/docs/gui/launcher.rst @@ -28,15 +28,15 @@ Examples Open the launcher dialog in minimal mode with a blank initial commandline. ``gui/launcher prospect --show ores,veins`` Open the launcher dialog with the edit area pre-populated with the given - command, ready for modification or running. Tools related to ``prospect`` - will appear in the autocomplete list, and help text for ``prospect`` will be - displayed in the lower panel. + command, ready for modification or running. Tools related to + `prospect ` will appear in the autocomplete list, and help text + for ``prospect`` will be displayed in the lower panel. Editing and running commands ---------------------------- Enter the command you want to run by typing its name. If you want to start over, -:kbd:`Ctrl`:kbd:`C` will clear the line. When you are happy with the command, +:kbd:`Ctrl`:kbd:`X` will clear the line. When you are happy with the command, hit :kbd:`Enter` or click on the ``run`` button to run it. Any output from the command will appear in the lower panel after you run it. If you want to run the command but close the dialog immediately so you can get back to the game, hold @@ -46,63 +46,80 @@ In any case, the command output will also be written to the DFHack terminal console (the separate window that comes up when you start DF) if you need to find it later. -To pause or unpause the game while `gui/launcher` is open, hit the spacebar once -or twice. If you are typing a command, the first space will go into the edit box -for your commandline. If the commandline is empty or if it already ends in a -space, space key will be passed through to the game to affect the pause button. +If the game was unpaused when you started `gui/launcher`, you can toggle pause +by hit the spacebar once or twice. If you are already typing a command, the +first space will go into the edit box for your commandline. If the commandline +is empty or already has a space just before the cursor, the space key will be +passed through to the game to affect the pause button. If the game was paused +when you started `gui/launcher`, then this behavior is disabled to prevent +accidental unpausing. If your keyboard layout makes any key impossible to type (such as :kbd:`[` and :kbd:`]` on German QWERTZ keyboards), use :kbd:`Ctrl`:kbd:`Shift`:kbd:`K` to bring up the on-screen keyboard. You can "type" the text you need by clicking -on the characters with the mouse. +on the characters with the mouse and then clicking the ``Enter`` button to +send the text to the launcher editor. Autocomplete ------------ As you type, autocomplete options for DFHack commands appear in the right -column. If the first word of what you've typed matches a valid command, then the -autocomplete options will also include commands that have similar functionality -to the one that you've named. Click on an autocomplete list option to select it -or cycle through them with :kbd:`Shift`:kbd:`Left` and :kbd:`Shift`:kbd:`Right`. -You can run a command quickly without parameters by double-clicking on the tool -name in the list. Holding down shift while you double-click allows you to -run the command and close `gui/launcher` at the same time. +column. You can restrict which commands are shown in the autocomplete list by +setting the tag filter with :kbd:`Ctrl`:kbd:`W` or by clicking on the ``Tags`` +button. If the first word of what you've typed matches a valid command, then the +autocomplete options switch to showing commands that have similar functionality +to the one that you've typed. Click on an autocomplete list option to select it +or cycle through them with :kbd:`Tab` and :kbd:`Shift`:kbd:`Tab`. You can run a +command quickly without parameters by double-clicking on the tool name in the +list. Holding down shift while you double-click allows you to run the command +and close `gui/launcher` at the same time. Context-sensitive help and command output ----------------------------------------- When you start ``gui/launcher`` without parameters, it shows some useful -information in the lower panel about how to get started with browsing DFHack -tools by their category `tags`. +information in the lower panel about how to get started with DFHack. Once you have typed (or autocompleted) a word that matches a valid command, the lower panel shows the help for that command, including usage instructions and -examples. You can scroll the help text with the mouse or with :kbd:`PgUp` and -:kbd:`PgDn`. You can also scroll line by line with :kbd:`Shift`:kbd:`Up` and +examples. You can scroll the help text with the mouse wheel or with :kbd:`PgUp` +and :kbd:`PgDn`. You can also scroll line by line with :kbd:`Shift`:kbd:`Up` and :kbd:`Shift`:kbd:`Down`. Once you run a command, the lower panel will switch to command output mode, where you can see any text the command printed to the screen. If you want to -see more help text as you run further commands, you can switch the lower panel -back to help mode with :kbd:`Ctrl`:kbd:`T`. The output text is kept for all the -commands you run while the launcher window is open, but is cleared if you -dismiss the launcher window and bring it back up. +see more help text as you browse further commands, you can switch the lower +panel back to help mode with :kbd:`Ctrl`:kbd:`T`. The output text is kept for +all the commands you run while the launcher window is open (up to 256KB of +text), but only the most recent 32KB of text is saved if you dismiss the +launcher window and bring it back up. Command output is also printed to the +external DFHack console (the one you can show with `show` on Windows) or the +parent terminal on Unix-based systems if you need a longer history of the +output. + +You can run the `clear ` command or click the ``Clear output`` button to +clear the output scrollback buffer. Command history --------------- ``gui/launcher`` keeps a history of commands you have run to let you quickly run those commands again. You can scroll through your command history with the -:kbd:`Up` and :kbd:`Down` arrow keys, or you can search your history for +:kbd:`Up` and :kbd:`Down` arrow keys. You can also search your history for something specific with the :kbd:`Alt`:kbd:`S` hotkey. When you hit :kbd:`Alt`:kbd:`S`, start typing to search your history for a match. To find the next match for what you've already typed, hit :kbd:`Alt`:kbd:`S` again. You can run the matched command immediately with :kbd:`Enter`, or hit :kbd:`Esc` to edit the command before running it. -Dev mode --------- +Default tag filters +------------------- By default, commands intended for developers and modders are filtered out of the -autocomplete list. This includes any tools tagged with ``unavailable``. You can -toggle this filtering by hitting :kbd:`Ctrl`:kbd:`D` at any time. +autocomplete list. This includes any tools tagged with ``unavailable``. If you +have "mortal mode" enabled in the `gui/control-panel` preferences, any tools +with the ``armok`` tag are filtered out as well. + +You can toggle this default filtering by hitting :kbd:`Ctrl`:kbd:`D` to switch +into "Dev mode" at any time. You can also adjust your command filters in the +``Tags`` filter list. diff --git a/docs/gui/manager-quantity.rst b/docs/gui/manager-quantity.rst deleted file mode 100644 index 4005565e09..0000000000 --- a/docs/gui/manager-quantity.rst +++ /dev/null @@ -1,18 +0,0 @@ -gui/manager-quantity -==================== - -.. dfhack-tool:: - :summary: Set the quantity of the selected manager workorder. - :tags: unavailable - -There is no way in the base DF game to change the quantity for an existing -manager workorder. Select a workorder on the j-m or u-m screens and run this -tool to set a new total requested quantity. The number of items remaining in the -workorder will be modified accordingly. - -Usage ------ - -:: - - gui/manager-quantity diff --git a/docs/gui/manipulator.rst b/docs/gui/manipulator.rst new file mode 100644 index 0000000000..353d894c77 --- /dev/null +++ b/docs/gui/manipulator.rst @@ -0,0 +1,18 @@ +gui/manipulator +=============== + +.. dfhack-tool:: + :summary: Multi-function unit management interface. + :tags: unavailable + +This spreadsheet-like UI allows you to see all your units, their skills, +properties, assignments, etc. at a glance. You can search, sort, and filter to +see exactly the information that you're looking for. Moreover, you can assign +units to burrows, squads, work details, and workshops. + +Usage +----- + +:: + + gui/manipulator diff --git a/docs/gui/mass-remove.rst b/docs/gui/mass-remove.rst index 135b5678b2..3829d3b2c2 100644 --- a/docs/gui/mass-remove.rst +++ b/docs/gui/mass-remove.rst @@ -2,29 +2,16 @@ gui/mass-remove =============== .. dfhack-tool:: - :summary: Mass select buildings and constructions to suspend or remove. + :summary: Mass select things to remove. :tags: fort design productivity buildings stockpiles -This tool lets you remove buildings/constructions or suspend/unsuspend -construction jobs using a mouse-driven box selection. +This tool lets you remove buildings, constructions, stockpiles, and/or zones +using a mouse-driven box selection. You can choose which you want to remove +with the given filters, then box select to apply to the map. -The following marking modes are available. - -:Suspend: Suspend the construction of a planned building/construction. -:Unsuspend: Resume the construction of a suspended planned - building/construction. Note that buildings planned with `buildingplan` - that are waiting for items cannot be unsuspended until all pending items - are attached. -:Remove Construction: Designate a construction (wall, floor, etc.) for removal. -:Unremove Construction: Cancel removal of a construction (wall, floor, etc.). -:Remove Building: Designate a building (door, workshop, etc) for removal. -:Unremove Building: Cancel removal of a building (door, workshop, etc.). -:Remove All: Designate both constructions and buildings for removal, and deletes - planned buildings/constructions. -:Unremove All: Cancel removal designations for both constructions and buildings. - -Note: ``Unremove Construction`` and ``Unremove Building`` are not yet available -for the latest release of Dwarf Fortress. +Planned buildings, constructions, stockpiles, and zones will be removed +immediately. Built buildings and constructions will be designated for +deconstruction. Usage ----- @@ -32,3 +19,16 @@ Usage :: gui/mass-remove + +Overlay +------- + +This tool also provides one overlay that is managed by the `overlay` +framework. + +gui/mass-remove.toolbar +~~~~~~~~~~~~~~~~~~~~~~~ + +The ``gui/mass-remove.toolbar`` overlay adds a button to the toolbar at the +bottom of the screen when eraser mode is active. It allows you to conveniently +open the ``gui/mass-remove`` interface. diff --git a/docs/gui/mod-manager.rst b/docs/gui/mod-manager.rst index 3d88415dc7..56b1538f29 100644 --- a/docs/gui/mod-manager.rst +++ b/docs/gui/mod-manager.rst @@ -2,12 +2,11 @@ gui/mod-manager =============== .. dfhack-tool:: - :summary: Save and restore lists of active mods. + :summary: Manange your active mods. :tags: dfhack interface -Adds an optional overlay to the mod list screen that -allows you to save and load mod list presets, as well -as set a default mod list preset for new worlds. +When run with a world loaded, shows a list of active mods. You can copy the +list to the system clipboard for easy sharing or posting. Usage ----- @@ -15,3 +14,21 @@ Usage :: gui/mod-manager + +Overlay +------- + +This tool also provides two overlays that are managed by the `overlay` +framework. + +gui/mod-manager.button +~~~~~~~~~~~~~~~~~~~~~~ + +Adds a widget to the mod list screen that allows you to save and load mod list +presets. You can also set a default mod list preset for new worlds so you don't +have to manualy re-select the same mods every time you generate a world. + +gui/mod-manager.notification +~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +Displays a message when a mod preset has been auto-applied. diff --git a/docs/gui/notes.rst b/docs/gui/notes.rst new file mode 100644 index 0000000000..d10b5b6d57 --- /dev/null +++ b/docs/gui/notes.rst @@ -0,0 +1,33 @@ +gui/notes +========= + +.. dfhack-tool:: + :summary: Interactive panel for managing map-specific notes. + :tags: fort interface map + +The `gui/notes` tool provides a comprehensive interface for interacting +with map-specific notes. It is designed to streamline the process +of note management in Dwarf Fortress, making it simpler +and more intuitive to keep track of important map-specific information. + +This tool builds upon the functionality of the `notes` tool, +enhancing it by providing a user-friendly panel for easier management +and visibility of notes across the Dwarf Fortress game map. + +Usage +----- + +:: + + gui/notes + +Launch the notes management panel. + +Supported Features +------------------ + +- Interactive Panel: Manage all aspects of notes through a centralized graphical interface. +- Search (:kbd:`Alt` + :kbd:`S`): Quickly find notes +- Direct Map Interaction (:kbd:`Alt` + :kbd:`N`): Add new notes by clicking on the map. +- Edit (:kbd:`Alt` + :kbd:`U`): Easily modify existing notes. +- Delete (:kbd:`Alt` + :kbd:`D`): Easily remove existing notes. diff --git a/docs/gui/notify.rst b/docs/gui/notify.rst new file mode 100644 index 0000000000..17264010de --- /dev/null +++ b/docs/gui/notify.rst @@ -0,0 +1,30 @@ +gui/notify +========== + +.. dfhack-tool:: + :summary: Show notifications for important events. + :tags: adventure fort interface + +This tool is the configuration interface for the provided overlay. It allows +you to select which notifications to enable for the overlay display. See the +descriptions in the `gui/notify` list for more details on what each +notification is for. Adventure mode and fort mode have different notifications +that can be enabled. + +Usage +----- + +:: + + gui/notify + +Overlay +------- + +This script provides an overlay that shows the currently enabled notifications +(when applicable). If you click on an active notification in the list, it will +bring up a screen where you can do something about it or will zoom the map to +the target. If there are multiple targets, each successive click on the +notification (or press of the :kbd:`Enter` key) will zoom to the next target. +You can also shift click (or press :kbd:`Shift`:kbd:`Enter`) to zoom to the +previous target. diff --git a/docs/gui/pathable.rst b/docs/gui/pathable.rst index 65301750f5..6f21079243 100644 --- a/docs/gui/pathable.rst +++ b/docs/gui/pathable.rst @@ -2,40 +2,28 @@ gui/pathable ============ .. dfhack-tool:: - :summary: Highlights tiles reachable from the selected tile. + :summary: Highlights tiles reachable from the cursor or a trade depot. :tags: fort inspection map This tool highlights each visible map tile to indicate whether it is possible to -path to that tile from the tile under the mouse cursor. You can move the mouse -(and the map) around and the highlight will change dynamically. +path to that tile. It has two modes: Follow mouse, which shows which tiles are +pathable from the tile that the mouse cursor is hovering over, and Depot, which +shows which tiles a wagon can traverse on the way to your trade depot. -If graphics are enabled, then tiles show a small yellow box if they are pathable -and a small black box if not. +If graphics are enabled, then tiles show a yellow box if they are pathable and +a red X if not, and the target tiles (the tile under the mouse or the map edge +tiles where wagons can enter the map, depending on which mode you're in) show a +yellow box with a dot in them. In ASCII mode, the tiles are highlighted in green if pathing is possible and red -if not. - -While the UI is active, you can use the following hotkeys to change the -behavior: - -- :kbd:`Ctrl`:kbd:`t`: Lock target: when enabled, you can move the map around - and the target tile will not change. This is useful to check whether parts of - the map far away from the target tile can be pathed to from the target tile. -- :kbd:`Ctrl`:kbd:`d`: Draw: allows temporarily disabling the highlighting - entirely. This allows you to see the map without the highlights, if desired. -- :kbd:`Ctrl`:kbd:`u`: Skip unrevealed: when enabled, unrevealed tiles will not - be highlighted at all instead of being highlighted as not pathable. This might - be useful to turn off if you want to see the pathability of unrevealed cavern - sections. - -You can drag the informational panel around while it is visible if it's in the -way. +if not. Target tiles are highlighted in cyan. .. note:: This tool uses a cache used by DF, which currently does *not* account for climbing or flying. If an area of the map is only accessible by climbing or - flying, this tool may report it as inaccessible. Care should be taken when - digging into the upper levels of caverns, for example. + flying, this tool may report it as inaccessible. For example, this tool + will not highlight where flying cavern creatures can fly up through holes + in cavern ceilings. Usage ----- diff --git a/docs/gui/petitions.rst b/docs/gui/petitions.rst index 84c5f5e18b..6772f1a2cb 100644 --- a/docs/gui/petitions.rst +++ b/docs/gui/petitions.rst @@ -2,8 +2,8 @@ gui/petitions ============= .. dfhack-tool:: - :summary: Show information about your fort's petitions. - :tags: unavailable + :summary: List guildhall and temple agreements. + :tags: fort inspection Show your fort's petitions, both pending and fulfilled. diff --git a/docs/gui/quantum.rst b/docs/gui/quantum.rst index b3d4071716..fb321c8013 100644 --- a/docs/gui/quantum.rst +++ b/docs/gui/quantum.rst @@ -3,51 +3,42 @@ gui/quantum .. dfhack-tool:: :summary: Quickly and easily create quantum stockpiles. - :tags: unavailable + :tags: fort productivity map stockpiles This tool provides a visual, interactive interface for creating quantum stockpiles. Quantum stockpiles simplify fort management by allowing a small stockpile to -contain an infinite number of items. This reduces the complexity of your storage +contain a large number of items. This reduces the complexity of your storage design, lets your dwarves be more efficient, and increases FPS. -Quantum stockpiles work by linking a "feeder" stockpile to a one-tile minecart -hauling route. As soon as an item from the feeder stockpile is placed in the -minecart, the minecart is tipped and all items land on an adjacent tile. The -single-tile stockpile in that adjacent tile that holds all the items is your -quantum stockpile. +Quantum stockpiles work by linking on or more "feeder" stockpiles to a one-tile +minecart hauling route. As soon as an item from the feeder stockpile(s) is +placed in the minecart, the minecart is tipped and all items land on an +adjacent tile. The single-tile stockpile in that adjacent tile holds all the +items and is your quantum stockpile. You can also choose to not have a +receiving stockpile and instead have the minecart dump into a pit (perhaps a +pit filled with magma). -Before you run this tool, create and configure your "feeder" stockpile. The -size of the stockpile determines how many dwarves can be tasked with bringing +Before you run this tool, create and configure your "feeder" stockpile(s). The +size of the feeders determine how many dwarves can be tasked with bringing items to this quantum stockpile. Somewhere between 1x3 and 5x5 is usually a good size. Make sure to assign an appropriate number of wheelbarrows to feeder stockpiles that will contain heavy items like corpses, furniture, or boulders. The UI will walk you through the steps: -1) Select the feeder stockpile by clicking on it or selecting it with the cursor - and hitting Enter. -2) Configure the orientation of your quantum stockpile and select whether to - allow refuse and corpses with the onscreen options. -3) Select a spot on the map to build the quantum stockpile by clicking on it or - moving the cursor so the preview "shadow" is in the desired location. Then - hit :kbd:`Enter`. +1. Select a feeder stockpile by clicking on it. If you want to select multiple + feeder stockpiles, switch the feeder selection toggle into multi mode. +2. Set configuration with the onscreen options. +3. Click on the map to build the quantum stockpile there. If there are any minecarts available, one will be automatically assigned to the hauling route. If you don't have a free minecart, ``gui/quantum`` will enqueue a -manager order to make one for you. Once it is built, run -`assign-minecarts all ` to assign it to the route, or enter -the (h)auling menu and assign one manually. The quantum stockpile needs a -minecart to function. - -Quantum stockpiles work much more efficiently if you add the following line to -your ``dfhack-config/init/onMapLoad.init`` file:: - - prioritize -a StoreItemInVehicle - -This prioritizes moving of items from the feeder stockpile to the minecart. -Otherwise, the feeder stockpile can get full and block the quantum pipeline. +manager order to make a wooden one for you. Once it is built, you'll have to run +`assign-minecarts all ` to assign it to the route or open +the (H)auling menu and assign it manually. The quantum stockpile will not +function until the minecart is in place. See :wiki:`the wiki ` for more information on quantum stockpiles. @@ -58,3 +49,21 @@ Usage :: gui/quantum + +Tips +---- + +Loading items into minecarts is a low priority task. If you find that your +feeder stockpiles are filling up because your dwarves aren't loading the items +into the minecarts, there are a few things you could change to get things +moving along: + +- Make your dwarves less busy overall by reducing the number of other jobs they + have to do +- Assign a few dwarves the Hauling work detail and specialize them so they + focus on those tasks. Note that there is no specific labor for loading items + into vehicles, it's just "hauling" in general. +- Run ``prioritize -a StoreItemInVehicle``, which causes the game to prioritize + the minecart loading tasks. Note that this can pull artisans away from their + workshops to go load minecarts. You can protect against this by specializing + your artisans who are assigned to workshops. diff --git a/docs/gui/quickfort.rst b/docs/gui/quickfort.rst index 1ca58304e5..d658ea30a5 100644 --- a/docs/gui/quickfort.rst +++ b/docs/gui/quickfort.rst @@ -14,9 +14,9 @@ click the mouse or hit :kbd:`Enter` to apply the blueprint to the map. You can apply the blueprint as many times as you wish to different spots on the map. If a blueprint that you designated was only partially applied (due to job cancellations, incomplete dig area, or any other reason) you can apply the -blueprint a second time to fill in any gaps. Any part of the blueprint that has -already been completed will be harmlessly skipped. Right click or hit -:kbd:`Esc` to close the `gui/quickfort` UI. +blueprint a second time in the same spot to fill in any gaps. Any part of the +blueprint that has already been completed will be harmlessly skipped. Right +click or hit :kbd:`Esc` to close the `gui/quickfort` UI. Note that `quickfort` blueprints will use the DFHack building planner (`buildingplan`) material filter settings. If you want specific materials to be diff --git a/docs/gui/rename.rst b/docs/gui/rename.rst index 5688354bb7..56705b57c3 100644 --- a/docs/gui/rename.rst +++ b/docs/gui/rename.rst @@ -2,28 +2,109 @@ gui/rename ========== .. dfhack-tool:: - :summary: Give buildings and units new names, optionally with special chars. - :tags: unavailable + :summary: Edit in-game language-based names. + :tags: adventure fort productivity animals items units -Once you select a target on the game map, this tool allows you to rename it. It -is more powerful than the in-game rename functionality since it allows you to -use special characters (like diamond symbols), and it also allows you to rename -enemies and overwrite animal species strings. +Once you select a target (by clicking on something on the game map, by passing +a commandline parameter, or by using the selection dialog) this tool allows you +change the language of the name, generate a new random name, or replace +components of the name with your preferred words. -This tool supports renaming units, zones, stockpiles, workshops, furnaces, -traps, and siege engines. +`gui/rename` provides an interface similar to the in-game naming panel that you +can use to customize your fortress name at embark. That is, it allows you to +choose words from an in-game language to assemble a name, just like the default +names that the game generates. You will be able to assign units new given and +last names, or even rename the world itself. + +You can run `gui/rename` while on the "prepare carefully" embark screen to +rename your starting dwarves. + +Start typing to search for a word. You can search in English, in the selected +native language, or by the part of speech. Click on a word to assign it to the +selected name component slot. You can also clear or randomize each individual +name component slot. + +When giving a name to a unit that didn't previously have a name, you must +assign a word to the First Name slot. Otherwise, the game will not display the +name for the unit. + +When you change the language of a unit's name, the unit's existing first name +will be translated into the new language. Usage ----- +:: + + gui/rename [] + +The selection dialog will appear if no options are provided. You can +interactively choose one of the following to rename: + +- An artifact on the current map +- A location (e.g. tavern, hospital, guildhall, temple) on the current map +- The current fortress (or adventurer site) +- A squad belonging to the current fortress +- The civilization of the current fortress +- The government of the current fortress +- A unit on the current map +- The world + +Some of the options above will not show up if there is nothing of the given +type to rename (e.g. you won't get the option to rename a squad if there are no +squads, and you won't get an option to rename the government of the fortress if +you aren't in fortress mode). + +Examples +-------- + ``gui/rename`` - Renames the selected building, zone, or unit. -``gui/rename unit-profession`` - Set the unit profession or the animal species string. + Load the selected artifact, location, or unit for renaming. If nothing is + selected, you can select a target from a list. +``gui/rename -u 123 --no-target-selector`` + Load the unit with id ``123`` for renaming and remove the widget that + allows selecting a different target. +``gui/rename --location 2 --site 456`` + Load the location with "abstract building" ID ``2`` attached to the site + with id ``456`` for renaming. + +Options +------- + +Targets specified via these options do not need to be on the local map. + +``-a``, ``--artifact `` + Rename the artifact with the given item ID. +``-e``, ``--entity `` + Rename the historical entity (e.g. site government, world religion, etc) + with the given ID. +``-f``, ``--histfig `` + Rename the historical figure with the given ID. +``-l``, ``--location `` + Rename the location (e.g. tavern, hospital, guildhall, temple) with the + given ID. If this option is used, ``--site`` can be specified to indicate + locations attached to a specific site. If ``--site`` is not specified, the + location will be loaded from the current site. +``-q``, ``--squad `` + Rename the squad with the given ID. +``-s``, ``--site `` + Rename the site with the given ID. +``-u``, ``--unit `` + Rename the unit with the given ID. Renaming a unit also renames the + associated historical figure. +``-w``, ``--world`` + Rename the current world. +``--no-target-selector`` + Do not allow the player to switch naming targets. An option that sets the + initial target is required when using this option. -Screenshots ------------ +Overlays +-------- -.. image:: /docs/images/rename-bld.png +This tool supports the following overlays: -.. image:: /docs/images/rename-prof.png +``gui/rename.world`` + Adds a widget to the world generation screen for renaming the world. +``gui/rename.unit_embark`` + Transparently fixes DF :bug:`12060` on the "Prepare carefully" embark screen + where the player is unable to give units nicknames or custom professions. diff --git a/docs/gui/reveal.rst b/docs/gui/reveal.rst new file mode 100644 index 0000000000..80360ae930 --- /dev/null +++ b/docs/gui/reveal.rst @@ -0,0 +1,59 @@ +gui/reveal +========== + +.. dfhack-tool:: + :summary: Reveal map tiles. + :tags: adventure fort armok map + +This script provides a means for you to safely glimpse at unexplored areas of +the map, such as aquifers or the caverns, so you can plan your fort with full +knowledge of the terrain. When you open `gui/reveal`, the map will be revealed. +You can see where the caverns are, and you can designate what you want for +digging. When you close `gui/reveal`, the map will automatically be unrevealed +so you can continue normal gameplay. If you want the reveal to be permanent, +you can toggle the setting before you close `gui/reveal`. + +You can choose to only reveal the aquifers and not other tiles by toggling the +settings in the UI or by specifying the appropriate commandline parameter when +starting `gui/reveal`. + +Areas with event triggers, such as gem boxes and adamantine spires, are not +revealed by default. This allows you to choose to keep the map unrevealed when +you close the `gui/reveal` UI without being immediately inundated with +thousands of event message popups. + +In graphics mode, solid tiles that are not adjacent to open space will not be +rendered, but they can still be examined by hovering over them with the mouse. +Switching to ASCII mode (in the game settings) will allow the display of the +revealed tiles, allowing you to quickly determine where the ores and gem +clusters are. + +Usage +----- + +:: + + gui/reveal [hell] [] + +Pass the ``hell`` keyword to fully reveal adamantine spires, gemstone pillars, +and the underworld. The game cannot be unpaused with these features revealed, +so the choice to keep the map unrevealed when you close `gui/reveal` is +disabled when this option is specified. + +Examples +-------- + +``gui/reveal`` + Reveal all "normal" terrain, but keep areas with late-game surprises hidden. +``gui/reveal hell`` + Fully reveal adamantine spires, gemstone pillars, and the underworld. The + game cannot be unpaused with these features revealed, so the choice to keep + the map unrevealed when you close `gui/reveal` is disabled when this option + is specified. + +Options +------- + +``-o``, ``--aquifers-only`` + Don't reveal any map tiles, but continue to display markers to identify + aquifers and damp tiles as per the `dig.warmdamp ` overlay. diff --git a/docs/gui/sandbox.rst b/docs/gui/sandbox.rst index 50f45c10b4..4d93ad8435 100644 --- a/docs/gui/sandbox.rst +++ b/docs/gui/sandbox.rst @@ -3,7 +3,7 @@ gui/sandbox .. dfhack-tool:: :summary: Create units, trees, or items. - :tags: fort armok animals items map plants units + :tags: adventure fort armok animals items map plants units This tool provides a spawning interface for units, trees, and/or items. Units can be created with arbitrary skillsets, and trees can be created either as @@ -16,7 +16,7 @@ You can choose whether spawned units are: - hostile undead - independent/wild - friendly -- citizens/pets +- citizens/pets (only available when launching from fort mode) Note that if you create new citizens and you're not using `autolabor`, you'll have to got into the labors screen and make at least one change (any change) to @@ -28,3 +28,13 @@ Usage :: gui/sandbox + +Caveats +------- + +If running from adventure mode, the map will show fort-mode "dig" markers on +tiles that were within the code of vision of your adventurers. This is visually +distracting, but it is not an error and can be ignored. + +When spawning undead, you'll need to save and reload before they gain their +distinctive purple cast. diff --git a/docs/gui/seedwatch.rst b/docs/gui/seedwatch.rst index 95ae306c20..6ee1723dee 100644 --- a/docs/gui/seedwatch.rst +++ b/docs/gui/seedwatch.rst @@ -5,11 +5,11 @@ gui/seedwatch :summary: Manages seed and plant cooking based on seed stock levels. :tags: fort auto plants -This is the configuration interface for the `seedwatch` plugin. You can configure -a target stock amount for each seed type. If the number of seeds of that type falls -below the target, then the plants and seeds of that type will be protected from -cookery. If the number rises above the target + 20, then cooking will be allowed -again. +This is the configuration interface for the `seedwatch` plugin. You can +configure a target stock amount for each seed type. If the number of seeds of +that type falls below the target, then the plants and seeds of that type will +be protected from cookery. If the number rises above the target + 20, then +cooking will be allowed again. Usage ----- diff --git a/docs/gui/settings-manager.rst b/docs/gui/settings-manager.rst index e0cdc355d4..fd5bec8a51 100644 --- a/docs/gui/settings-manager.rst +++ b/docs/gui/settings-manager.rst @@ -2,21 +2,56 @@ gui/settings-manager ==================== .. dfhack-tool:: - :summary: Dynamically adjust global DF settings. - :tags: unavailable + :summary: Import and export DF settings. + :tags: embark interface -This tool is an in-game editor for settings defined in -:file:`data/init/init.txt` and :file:`data/init/d_init.txt`. Changes are written -back to the init files so they will be loaded the next time you start DF. For -settings that can be dynamically adjusted, such as the population cap, the -active value used by the game is updated immediately. - -Editing the population caps will override any modifications made by scripts such -as `max-wave`. +This tool allows you to save and load DF settings. Usage ----- :: - gui/settings-manager + gui/settings-manager save-difficulty + gui/settings-manager load-difficulty + gui/settings-manager save-standing-orders + gui/settings-manager load-standing-orders + gui/settings-manager save-work-details + gui/settings-manager load-work-details + +Difficulty can be saved and loaded on the embark "preparation" screen or in an +active fort. Standing orders and work details can only be saved and loaded in +an active fort. + +If auto-restoring of difficulty settings is turned on, it happens when the +embark "preparation" screen is loaded. If auto-restoring of standing orders or +work details definitions is turned on, it happens when the fort is loaded for +the first time (just like all other Autostart commands configured in +`gui/control-panel`). + +Overlays +-------- + +When embarking or when a fort is loaded, if you click on the +``Custom settings`` button for game difficulty, you will see a new panel at the +top. You can save the current difficulty settings and load the saved settings +back. You can also toggle an option to automatically load the saved settings +for new embarks. + +When a fort is loaded, you can also go to the Labor -> Standing Orders page. +You will see tow new panels. The first allows you to configure how many barrels +to reserve for use by workshop jobs (instead of being claimed by stockpiles for +container storage). The second panel allows you to save and restore your +settings for standing orders. You can also toggle whether the saved standing +orders are automatically restored when you embark on a new fort. This will +toggle the relevant command in `gui/control-panel` on the Automation -> +Autostart page. + +There is a similar panel on the Labor -> Work Details page that allows for +saving and restoring of work detail definitions. Be aware that work detail +assignments to units cannot be saved, so you have to assign the work details to +individual units after you restore the definitions. Another caveat is that DF +doesn't evaluate work detail definitions until a change (any change) is made on +the work details screen. Therefore, after importing work detail definitions, +including auto-loading them for new embarks, you have to go to the work details +page and make a change before your imported work details will take effect. diff --git a/docs/gui/siegemanager.rst b/docs/gui/siegemanager.rst new file mode 100644 index 0000000000..45ae1f3d69 --- /dev/null +++ b/docs/gui/siegemanager.rst @@ -0,0 +1,16 @@ +gui/siegemanager +================ + +.. dfhack-tool:: + :summary: Manage siege engines at a glance + :tags: buildings interface productivity + +This interface provides a list of siege engines, their ammo count, and current active +jobs whilst providing shortcuts to configure their firing/standy mode and view them in-world. + +Usage +----- + +:: + + gui/siegemanager diff --git a/docs/gui/sitemap.rst b/docs/gui/sitemap.rst new file mode 100644 index 0000000000..493f3ba5f1 --- /dev/null +++ b/docs/gui/sitemap.rst @@ -0,0 +1,39 @@ +gui/sitemap +=========== + +.. dfhack-tool:: + :summary: List and zoom to people, locations, or artifacts. + :tags: adventure fort inspection + +This simple UI gives you searchable lists of people, locations (temples, +guildhalls, hospitals, taverns, and libraries), and artifacts in the local area. +Clicking on a list item will zoom the map to the target. In fort mode, +shift-clicking will zoom to the unit or artifact and lock the camera to the +target with follow mode. If you are zooming to a location and the location has +multiple zones attached to it, clicking again will zoom to each component zone +in turn. + +Locations are attached to a site, so if you're in adventure mode, you must +enter a site before searching for locations. For worldgen sites, many locations +are not attached to a zone, so it does not have a specific map location and +click to zoom will have no effect. + +Usage +----- + +:: + + gui/sitemap + +Overlay +------- + +This tool also provides one overlay that is managed by the `overlay` +framework. + +gui/sitemap.toolbar +~~~~~~~~~~~~~~~~~~~ + +The ``gui/sitemap.toolbar`` overlay adds a button to the toolbar at the bottom left corner of the +screen with the other menu buttons. It allows you to conveniently open the ``gui/sitemap`` +interface. diff --git a/docs/gui/spectate.rst b/docs/gui/spectate.rst new file mode 100644 index 0000000000..802d4d3dac --- /dev/null +++ b/docs/gui/spectate.rst @@ -0,0 +1,19 @@ +gui/spectate +============ + +.. dfhack-tool:: + :summary: Automated spectator mode. + :tags: fort inspection interface + +This is an in-game configuration interface for `spectate`, which automatically +sets the camera to follow interesting units. + +You can configure the overlay tooltip settings as well as the follow mode +settings. + +Usage +----- + +:: + + gui/spectate diff --git a/docs/gui/teleport.rst b/docs/gui/teleport.rst index 82d15abeb0..513f26bbb0 100644 --- a/docs/gui/teleport.rst +++ b/docs/gui/teleport.rst @@ -2,11 +2,19 @@ gui/teleport ============ .. dfhack-tool:: - :summary: Teleport a unit anywhere. - :tags: unavailable + :summary: Teleport units anywhere. + :tags: fort armok units -This tool is a front-end for the `teleport` tool. It allows you to interactively -choose a unit to teleport and a destination tile using the in-game cursor. +This tool allows you to interactively select units to teleport by drawing boxes +around them on the map. Double clicking on a destination tile will teleport the selected units there. + +If a unit is already selected in the UI when you run `gui/teleport`, it will be +pre-selected for teleport. + +Note that you *can* select enemies that are lying in ambush and are not visible +on the map yet, so you if you select an area and see a marker that indicates +that a unit is selected, but you don't see the unit itself, this is likely what +it is. You can still teleport these units while they are hidden. Usage ----- diff --git a/docs/gui/tiletypes.rst b/docs/gui/tiletypes.rst new file mode 100644 index 0000000000..62b2fdbe15 --- /dev/null +++ b/docs/gui/tiletypes.rst @@ -0,0 +1,122 @@ +gui/tiletypes +============= + +.. dfhack-tool:: + :summary: Interactively shape the map. + :tags: fort armok map + +This tool is a gui for placing and modifying tiles and tile properties, +allowing you to click and paint the specified options onto the map. + +.. warning:: + + There is **no undo support**. This tool can confuse the game if you paint + yourself into an "impossible" situation (like removing the original surface + layer). Be sure to save your game before making any changes. + +Usage +----- + +:: + + gui/tiletypes [] + +Examples +-------- + +``gui/tiletypes`` + Start the tiletypes GUI. +``gui/tiletypes --unrestricted`` + Start the tiletypes GUI with non-standard stone types available. + +Options +------- + +``-f``, ``--unrestricted`` + Include non-standard stone types in the selection list. + +Selectors +--------- + +The UI provides a variety of selectors for choosing which tiles to create and +which properties to give them. + +Mode +~~~~ + +:Paint: Overwrite whatever tile is currently on the map. +:Replace: Only affect tiles that are not open air. +:Fill: Only affect tiles that *are* open air. +:Remove: Restores tiles to a form they would have if they were just dug out (if + the ``Autocorrect`` option is enabled -- see below) or with empty air + (if ``Autocorrect`` is not enabled). + +Shape +~~~~~ + +A shape of ``NONE`` will keep the shape that is already on the map. You can +cycle through common shapes by clicking on the ``Shape`` selector, or you can +click on the gear button to the right of the selector to choose from the full +list of available shapes, like ramps, fortifications, or stairs. + +Material +~~~~~~~~ + +Again, ``NONE`` will keep the material of the existing tiles, and you can cycle +through the common options by clicking on the ``Material`` selector. Extended +material selections, like grass, are available via the gear button. If you want +to paint an empty, open tile, use a material of ``AIR``. + +To paint a particular type of stone, mineral, or gem, select ``STONE`` as the +material, then choose the type from the ``Stone`` selector. If you leave it at +``NONE``, then it will choose the stone associated with the geological layer. + +Special +~~~~~~~ + +You can choose special properties of the tile, like whether it is rough or +smooth. Note that when creating walls, they will inherit the smoothness +property of whatever was there before unless you specifically set the Special +selector to ``NORMAL`` (for rough walls) or ``SMOOTH`` (for smooth walls). + +Extended special properties are available via the gear button. + +Variant +~~~~~~~ + +For tiles that have visual variations (like grass tiles in ASCII mode), you can +choose a specific variant with the selector. + +More options +~~~~~~~~~~~~ + +These options are mostly three-state selectors. An empty box means that the +property will be left untouched. A green check (or plus symbol in ASCII mode) +indicates that the property will be set (enabled). Red Xs indicate that the +property will be cleared (disabled). + +:Hidden: Sets whether the tile is revealed or unrevealed. If you are + filling up space with solid rock, for example, you might want to + enable this to make the now non-exposed tiles hidden. +:Light: Sets whether the tile is exposed to light. Dark tiles increase + cave adaption in dwarves that cross the tile. Light tiles that + are not also outside (see Skyview below) will neither increase + nor decrease cave adaption in dwarves that cross the tile. +:Subterranean: Sets whether the tile is considered underground. This affects + what crops you can plant in farm plots on this tile. +:Skyview: Sets whether the tile is considered "outside". Weather affects + things that are outside (e.g. by producing a grumpy thought + about being caught in the rain). Outside tiles may also cause + nausea in dwarves that are cave adapted, and will reduce the + cave adaption level in dwarves that cross the tile. +:Aquifer: Sets whether the tile is an aquifer. Two drops (in graphics + mode) or one light blue ≈ (in ascii mode) indicates a light + aquifer. Three drops (or ≈≈ in ascii mode) indicates a heavy + aquifer. +:Autocorrect: When you modify a tile, automatically fix adjacent tiles to fit + your changes. For example, when you place a ramp, it will + automatically place a corresponding "ramp top" in the z-level + above (which otherwise must be done manually for the ramp to be + displayed correctly and function). Most players should leave + this on, but you can turn this off if you need precise control + over each changed tile. diff --git a/docs/gui/unit-info-viewer.rst b/docs/gui/unit-info-viewer.rst index 856a93257e..ed9d29a2b2 100644 --- a/docs/gui/unit-info-viewer.rst +++ b/docs/gui/unit-info-viewer.rst @@ -3,10 +3,13 @@ gui/unit-info-viewer .. dfhack-tool:: :summary: Display detailed information about a unit. - :tags: unavailable + :tags: adventure fort interface inspection units -Displays information about age, birth, maxage, shearing, milking, grazing, egg -laying, body size, and death for the selected unit. +When run, it displays information about age, birth, maxage, shearing, milking, +grazing, egg laying, body size, and death for the selected unit. + +You can click on different units while the tool window is open and the +displayed information will refresh for the selected unit. Usage ----- @@ -14,3 +17,13 @@ Usage :: gui/unit-info-viewer + +Overlays +-------- + +This tool adds progress bars, experience points and levels in the unit skill +panels, color-coded to highlight rust and the highest skill levels: + +- If a skill is rusty, then the level marker is colored light red +- If a skill is at Legendary level or higher, it is colored light cyan +- Other skills are colored plain white diff --git a/docs/hide-tutorials.rst b/docs/hide-tutorials.rst index 417d5e278c..94b2a27a28 100644 --- a/docs/hide-tutorials.rst +++ b/docs/hide-tutorials.rst @@ -3,12 +3,12 @@ hide-tutorials .. dfhack-tool:: :summary: Hide new fort tutorial popups. - :tags: fort interface + :tags: adventure fort interface If you've played the game before and don't need to see the tutorial popups that show up on every new fort, ``hide-tutorials`` can hide them for you. You can enable this tool as a system service in the "Services" tab of -`gui/control-panel` so it takes effect for all new or loaded forts. +`gui/control-panel` so it takes effect for all forts and adventures. Specifically, this tool hides: @@ -16,6 +16,8 @@ Specifically, this tool hides: - The "Do you want to start a tutorial embark" popup - Popups displayed the first time you open the labor, burrows, justice, and other similar screens in a new fort +- Popups displayed when you perform certain actions for the first time in an + adventure Note that only unsolicited tutorial popups are hidden. If you directly request a tutorial page from the help, then it will still function normally. @@ -27,6 +29,10 @@ Usage enable hide-tutorials hide-tutorials + hide-tutorials reset -If you haven't enabled the tool, but you run the command while a fort is -loaded, all future popups for the loaded fort will be hidden. +If you haven't enabled the tool, but you run the command while a fort or +adventure is loaded, all future popups for the loaded game will be hidden. + +If you run the command with the ``reset`` option, all popups will be re-enabled +as if they had never been seen or dismissed. diff --git a/docs/husbandry.rst b/docs/husbandry.rst new file mode 100644 index 0000000000..27fa459cfe --- /dev/null +++ b/docs/husbandry.rst @@ -0,0 +1,61 @@ +husbandry +========= + +.. dfhack-tool:: + :summary: Automatically milk and shear animals. + :tags: fort auto + +This tool will automatically create milking and shearing orders at farmer's +workshops. Unlike the ``automilk`` and ``autoshear`` options from the control +panel, which create general work orders for milking and shearing jobs, +``husbandry`` will directly create jobs for individual animals at specific +workshops. This allows milking and shearing jobs to reliably be created at +nearby workshops (e.g. inside the pasture that an animal is assigned to), +minimizing the labor required to re-pasture animals after milking or shearing, +in particular in the case of multiple pastures that are far apart. + + +Usage +----- + +:: + + enable husbandry + husbandry [status] + husbandry now + husbandry [set|unset] [shearing|milking|roaming|pasture]+ + +Flags can be set or unset using the command ``husbandry set`` or ``husbandry +unset``. The ``shearing`` and ``milking`` flags (both enabled by default) +control whether shearing or milking jobs are created at all. + +Further, ``husbandry`` distinguishes between animals that are assigned to +pastures and those that are "roaming". + +If an animal is pastured and the pasture contains at least one workshop with the +appropriate labour (i.e. milking or shearing) enabled, jobs will be created +exclusively at those workshops. If the pasture does not contain a workshop with +the appropriate labor enabled the behavior depends on the ``pasture`` flag +(disabled by default): if set, no jobs will be created at workshops outside of +pastures, otherwise jobs may be created at the closest workshop in your fort. + +For animals that are roaming, jobs will only be created if the ``roaming`` flag +is set, which is the default. In this case, jobs are created at the closest +workshop with the appropriate labours enabled. + +Examples +-------- + +``enable husbandry`` + Start generating milking and shearing orders for animals. + +``husbandry now`` + Run a single cycle, detecting animals that can be milked/sheared an creating + jobs. Does not require the tool to be enabled. + +``husbandry unset roaming`` + Disable the creation of jobs for roaming animals. + +``husbandry set milking shearing pasture`` + Create milking and shearing jobs for pastured animals, but only at workshops + inside their pastures. diff --git a/docs/idle-crafting.rst b/docs/idle-crafting.rst new file mode 100644 index 0000000000..6eeb571e7f --- /dev/null +++ b/docs/idle-crafting.rst @@ -0,0 +1,69 @@ +idle-crafting +============= + +.. dfhack-tool:: + :summary: Allow dwarves to independently craft objects when they have the need. + :tags: fort gameplay + +This script allows you to mark specific Craftsdwarf's Workshops for use as +"recreational crafting" stations. Dwarves who feel the need to craft objects +will be able to go there to independently satisfy their crafting needs without +manual intervention from the player. + +There will be a toggle on the workshop info sheet ("Tasks") tab when you +select a Craftsdwarf's Workshop in the UI. More details below. + +Usage +----- + +``idle-crafting [status]`` + Print statistics about the status of the tool and the satisfaction of + "craft item" needs in your fort. + +``idle-crafting thresholds `` + Set the threshold(s) for the "craft item" need (i.e. the negated + ``focus_level``) at which the tool starts to generate crafting jobs for a + given unit. Units meeting higher thresholds will be prioritized. Defaults + to ``500,1000,10000``. + +``idle-crafting happy [yes|no]`` + If set to ``no``, "happy" and "ecstatic" dwarves not suffering from + long-term stress will only satisfy their crafting needs when meeting the + highest configured threshold (eg. ``10000`` at default settings). Defaults + to ``no``. + +``disable idle-crafting`` + Disallow idle crafting at all workshops. You can re-enable idle crafting + at individual Craftsdwarf's workshops. + +Examples +-------- + +``idle-crafting thresholds 500,1000,10000`` + Reset thresholds to defaults. + +``idle-crafting happy yes`` + Treat happy and ecstatic dwarves the same as everyone else. + +Overlay +------- + +This script provides an overlay on the "Workers" tab of Craftsdwarf's workshops, +allowing you to designate that workshop for use by idle dwarves to satisfy their +needs to craft objects. Workshops that have a master assigned cannot be used in +this way. + +When a workshop is designated for idle crafting, this tool will create crafting +jobs and assign them to idle dwarves who have a need for crafting objects. This +script respects the setting for permitted general work order labors from the "Workers" +tab. + +For workshops without input stockpile links, bone carving and stonecrafting are +supported, with stonecrafting being the default option. Thus, to designate a +workshop for bone carving, disable the stonecrafting labor while keeping the +bone carving labor enabled. + +For workshops with input stockpile links, the creation of totems, shell crafts, +and horn crafts are supported as well. In this case, the choice of job is made +randomly based on the resources available in the input stockpiles (respecting +the permitted labors from the workshop profile). diff --git a/docs/immortal-cravings.rst b/docs/immortal-cravings.rst new file mode 100644 index 0000000000..cd8b09e6b3 --- /dev/null +++ b/docs/immortal-cravings.rst @@ -0,0 +1,19 @@ +immortal-cravings +================= + +.. dfhack-tool:: + :summary: Allow immortals to satisfy their cravings for food and drink. + :tags: fort gameplay + +When enabled, this script watches your fort for units that have no physiological +need to eat or drink but are still alcohol dependent or have personality needs +that can only be satisfied by eating or drinking (e.g. necromancers or goblins). +This enables those units to help themselves to a drink or a meal when they crave +one and are not otherwise occupied. + +Usage +----- + +:: + + enable immortal-cravings diff --git a/docs/instruments.rst b/docs/instruments.rst new file mode 100644 index 0000000000..5165433b1f --- /dev/null +++ b/docs/instruments.rst @@ -0,0 +1,51 @@ +instruments +=========== + +.. dfhack-tool:: + :summary: Show how to craft instruments or create work orders for them. + :tags: fort inspection workorders + +This tool is used to query information about instruments or to create work +orders to produce them. + +The ``list`` subcommand provides information on how to craft the instruments +used by the player civilization. For single-piece instruments, it shows the +skill and material needed to craft it. For multi-piece instruments, it displays +the skill used in its assembly as well as information on how to craft the +necessary pieces. It also shows whether the instrument is handheld or placed as +a building. + +The ``order`` subcommand is used to create work orders for an instrument and +all of it's parts. The final assemble instrument order waits for the part +orders to complete before starting. + +Usage +----- + +:: + + instruments [list] + instruments order [] [] + +When ordering, the default is to order one of the specified instrument +(including all of its components). + +Examples +-------- + +``instruments`` + List instruments and their recipes. +``instruments order givel 10`` + If the instrument named ``givel`` in your world has four components, this + will create a total of 5 work orders: one for assembling 10 givels, and an + order of 10 for each of the givel's parts. Instruments are randomly + generated, so your instrument names and components will vary. +``instruments order ilul`` + Creates work orders to assemble one ïlul. Spelling does not need to include + the special ï character. + +Options +------- + +``-q``, ``--quiet`` + Suppress non-error console output. diff --git a/docs/item.rst b/docs/item.rst new file mode 100644 index 0000000000..403dadcafe --- /dev/null +++ b/docs/item.rst @@ -0,0 +1,233 @@ +item +==== + +.. dfhack-tool:: + :summary: Perform bulk operations on groups of items. + :tags: fort productivity items + +Filter items in you fort by various properties (e.g., item type, material, +wear-level, quality, ...), and perform bulk operations like forbid, dump, melt, +and their inverses. By default, the tool does not consider artifacts and owned +items. Outputs the number of items that matched the filters and were modified. + +Usage +----- + +``item [count|[un]forbid|[un]dump|[un]hide|[un]melt] [] []`` + +The ``count`` action counts up the items that are matched by the given filter +options. Otherwise, the named property is set (or unset) on all the items +matched by the filter options. The counts reported when you actually apply a +property might differ from those reported by ``count``, because applying a +property skips over all items that already have the property set (see +``--dry-run``) + +The (optional) search string will filter by the item description. It will be +interpreted as a Lua pattern, so any special regular expression characters +(like ``-``) will need to be escaped with ``%`` (e.g. ``%-``) to be interpreted +as a literal string. See https://www.lua.org/manual/5.3/manual.html#6.4.1 for +details. For example "cave spider silk" will match both "cave spider silk web" +and "cave spider silk cloth". Use ``^pattern$`` to match the entire description. + +Examples +-------- + +``item count steel -v`` + Scan your stocks for (unowned) items that contain the word "steel" and + print out the counts for each steel item found. + +``item count --verbose --by-type steel`` + Scan your stocks for (unowned) items that contain the word "steel" and + print out the descriptions of the matched items and the counts, grouped by + item type. + +``item forbid --unreachable`` + Forbid all items that cannot be reached by any of your citizens. + +``item unforbid --inside Cavern1 --type wood`` + Unforbid/reclaim all logs inside the burrow named "Cavern1" (Hint: use 3D + flood-fill to create a burrow covering an entire cavern layer). + +``item melt -t weapon -m steel --max-quality 3`` + Designate all steel weapons whose core quality is at most superior for + melting. + +``item hide -t boulder --scattered`` + Hide all scattered boulders, i.e. those that are not in stockpiles. + +``item unhide`` + Makes all hidden items visible again. + +Options +------- + +``-n``, ``--dry-run`` + Get a count of the items that would be modified by an operation, which will + be the number returned by the ``count`` action minus the number of items + with the desired property already set. + +``--by-type`` + Only applies to the ``count`` action. Outputs, in addition to the total + count, a table of item counts grouped by item type. + +``-a``, ``--include-artifacts`` + Include artifacts in the item list. Regardless of this setting, artifacts + are never dumped or melted. + +``--include-owned`` + Include items owned by units (e.g., your dwarves or visitors) + +``--ignore-webs`` + Ignore undisturbed spider webs. + +``-i``, ``--inside `` + Only include items inside the given burrow. + +``-o``, ``--outside `` + Only include items outside the given burrow. + +``-r``, ``--reachable`` + Only include items reachable by one of your citizens. + +``-u``, ``--unreachable`` + Only include items not reachable by any of your citizens. + +``-t``, ``--type `` + Filter by item type (e.g., BOULDER, CORPSE, ...). Also accepts lower case + spelling (e.g. "corpse"). Use ``:lua @df.item_type`` to get the list of all + item types. + +``-m``, ``--material `` + Filter by material the item is made out of (e.g., "iron"). + +``-c``, ``--mat-category `` + Filter by material category of the material item is made out of (e.g., + "metal"). Use ``:lua @df.dfhack_material_category`` to get a list of all + material categories. + +``-w``, ``--min-wear `` + Only include items whose wear/damage level is at least ``integer``. Useful + values are 0 (pristine) to 3 (XX). + +``-W``, ``--max-wear `` + Only include items whose wear/damage level is at most ``integer``. Useful + values are 0 (pristine) to 3 (XX). + +``-q``, ``--min-quality `` + Only include items whose quality level is at least ``integer``. Useful + values are 0 (ordinary) to 5 (masterwork). Use ``:lua @df.item_quality`` to + get the mapping between numbers and adjectives. + +``-Q``, ``--max-quality `` + Only include items whose quality level is at most ``integer``. Useful + values are 0 (ordinary) to 5 (masterwork). + +``--total-quality`` + Only applies to ``--min-quality`` and ``--max-quality`` options. Filter items + according to their total quality (to include improvements) of instead of + their core quality. + +``--stockpiled`` + Only include items that are in stockpiles. Does not include empty bins, + barrels, and wheelbarrows assigned as storage and transport for stockpiles. + +``--scattered`` + Opposite of ``--stockpiled`` + +``--marked=,,...`` + Only include items that have all provided flag set to true. Valid flags are: + ``forbid`` (or ``forbidden``), ``dump``, ``hidden``, ``melt``, and + ``owned``. + +``--not-marked=,,...`` + Only include items that have all provided flag set to false. Valid flags the + same as for ``--marked``. + +``--visible`` + Same as ``--not-marked=hidden`` + +``-v``, ``--verbose`` + Print out a description of each matched item. + +API +--- + +The item 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:`item.lua`, available from the return value of +``reqscript('item')``: + +* ``execute(action, conditions, options [, return_items])`` + +Performs ``action`` (``forbid``, ``melt``, etc.) on all items satisfying +``conditions`` (a table containing functions from item to boolean). ``options`` +is a table containing the boolean flags ``artifact``, ``dryrun``, ``bytype``, +and ``owned`` which correspond to the (filter) options described above. + +The function ``execute`` performs no output, but returns three values: + +1. the number of matching items +2. a table containing all matched items, if ``return_items`` is provided and true. +3. a table containing a mapping from numeric item types to their occurrence + count, if ``options.bytype=true`` + +* ``executeWithPrinting(action, conditions, options)`` + +Performs the same action as ``execute`` and performs the same output as the +``item`` tool, but returns nothing. + +The API provides a number of helper functions to aid in the construction of the +filter table. The first argument ``tab`` is always the table to which the filter +should be added. The final ``negate`` argument is optional, passing ``{ negate = +true }`` negates the added filter condition. Below, only the positive version of +the filter is described. + +* ``condition_burrow(tab, burrow, negate)`` + Corresponds to ``--inside``. The ``burrow`` argument must be a burrow + object, not a string. + +* ``condition_type(tab, match, negate)`` + If ``match`` is a string, this corresponds to ``--type ``. Also + accepts numbers, matching against ``item:getType()``. + +* ``condition_reachable(tab, negate)`` + Corresponds to ``--reachable``. + +* ``condition_description(tab, pattern, negate)`` + Corresponds to the search string passed on the commandline. + +* ``condition_material(tab, match, negate)`` + Corresponds to ``--material ``. + +* ``condition_matcat(tab, match, negate)`` + Corresponds to ``--mat-category ``. + +* ``condition_wear(tab, lower, upper, negate)`` + Selects items with wear level between ``lower`` and ``upper`` (Range 0-3, + see above). + +* ``condition_quality(tab, lower, upper, negate)`` + Selects items with core quality between ``lower`` and ``upper`` (Range 0-5, + see above). + +* ``condition_overall_quality(tab, lower, upper, negate)`` + Selects items with total quality between ``lower`` and ``upper`` (Range 0-5, + see above). + +* ``condition_stockpiled(tab, negate)`` + Corresponds to ``--stockpiled``. + +* ``condition_[forbid|melt|dump|hidden|owned](tab, negate)`` + Selects items with the respective flag set to ``true`` (e.g., + ``condition_forbid`` checks for ``item.flags.forbid``). + + API usage example:: + + local itemtools = reqscript('item') + local cond = {} + + itemtools.condition_type(cond, "BOULDER") + itemtools.execute('unhide', cond, {}) -- reveal all boulders + + itemtools.condition_stockpiled(cond, { negate = true }) + itemtools.execute('hide', cond, {}) -- hide all boulders not in stockpiles diff --git a/docs/justice.rst b/docs/justice.rst new file mode 100644 index 0000000000..99ca893873 --- /dev/null +++ b/docs/justice.rst @@ -0,0 +1,35 @@ +justice +======= + +.. dfhack-tool:: + :summary: Mess with the justice system. + :tags: fort armok units + +This tool allows control over aspects of the justice system, such as the +ability to pardon criminals. + +Usage +----- + +:: + + justice [list] + justice pardon [--unit ] + +Pardon the selected unit or the one specified by unit id (if provided). +Currently only applies to prison time and doesn't cancel beatings or +hammerings. + +Examples +-------- + +``justice`` + List the convicts currently serving sentences. +``justice pardon`` + Commutes the sentence of the currently selected convict. + +Options +------- + +``-u``, ``--unit `` + Specifies a specific unit instead of using a selected unit. diff --git a/docs/launch.rst b/docs/launch.rst index 6b884fcfb7..9f3648b88f 100644 --- a/docs/launch.rst +++ b/docs/launch.rst @@ -3,7 +3,7 @@ launch .. dfhack-tool:: :summary: Thrash your enemies with a flying suplex. - :tags: unavailable + :tags: adventure armok units Attack another unit and then run this command to grab them and fly in a glorious parabolic arc to where you have placed the cursor. You'll land safely and your diff --git a/docs/light-aquifers-only.rst b/docs/light-aquifers-only.rst index f8780cd63d..9715efb40e 100644 --- a/docs/light-aquifers-only.rst +++ b/docs/light-aquifers-only.rst @@ -10,6 +10,8 @@ post-embark. Pre-embark, it changes all aquifers in the world to light ones, while post-embark it only modifies the active map tiles, leaving the rest of the world unchanged. +For more powerful aquifer editing, please see `aquifer` and `gui/aquifer`. + Usage ----- diff --git a/docs/list-waves.rst b/docs/list-waves.rst index 784fca4a4d..c86e63a391 100644 --- a/docs/list-waves.rst +++ b/docs/list-waves.rst @@ -2,41 +2,54 @@ list-waves ========== .. dfhack-tool:: - :summary: Show migration wave information for your dwarves. - :tags: unavailable + :summary: Show migration wave membership and history. + :tags: fort inspection units -This script displays information about migration waves or identifies which wave -a particular dwarf came from. +This script displays information about past migration waves: when they arrived +and which dwarves arrived in them. If you have a citizen selected in the UI or +if you have passed the ``--unit`` option with a unit id, that citizen's name +and wave will be highlighted in the output. + +Residents that became citizens via petitions will be grouped with any other +dwarves that immigrated/joined at the same time. Usage ----- :: - list-waves --all [--showarrival] [--granularity ] - list-waves --unit [--granularity ] + list-waves [ ...] [] + +You can show only information about specific waves by specifying the wave +numbers on the commandline. Otherwise, all waves are shown. The first migration +wave that normally arrives in a fort's second season is wave number 1. The +founding dwarves arrive in wave 0. Examples -------- -``list-waves --all`` - Show how many dwarves came in each migration wave. -``list-waves --all --showarrival`` - Show how many dwarves came in each migration wave and when that migration - wave arrived. -``list-waves --unit`` - Show which migration wave the selected dwarf arrived with. +``list-waves`` + Show how many of your current dwarves came in each migration wave, when + the waves arrived, and the names of the dwarves in each wave. +``list-waves --no-names`` + Only show how many dwarves came in each seasonal migration wave and when + the waves arrived. Don't show the list of dwarves that came in each wave. +``list-waves 0`` + Identify your founding dwarves. Options ------- -``--unit`` - Displays the highlighted unit's arrival wave information. -``--all`` - Displays information about each arrival wave. -``--granularity `` +``-d``, ``--no-dead`` + Exclude residents and citizens who have died. +``-g``, ``--granularity `` Specifies the granularity of wave enumeration: ``years``, ``seasons``, ``months``, or ``days``. If omitted, the default granularity is ``seasons``, the same as Dwarf Therapist. -``--showarrival``: - Shows the arrival date for each wave. +``-n``, ``--no-names`` + Don't output the names of the members of each migration wave. +``-p``, ``--no-petitioners`` + Exclude citizens who joined via petition. That is, only show dwarves who + came in an actual migration wave. +``-u``, ``--unit `` + Highlight the specified unit's arrival wave information. diff --git a/docs/locate-ore.rst b/docs/locate-ore.rst index c25a1c880f..d2fbf814b2 100644 --- a/docs/locate-ore.rst +++ b/docs/locate-ore.rst @@ -6,17 +6,21 @@ locate-ore :tags: fort armok productivity map This tool finds and designates for digging one tile of a specific metal ore. If -you want to dig **all** tiles of that kind of ore, select that tile with the -cursor and run `digtype `. +you want to dig **all** tiles of that kind of ore, highlight that tile with the +keyboard cursor and run `digtype `. -By default, the tool only searches for visible ore veins. +By default, the tool only searches ore veins that your dwarves have discovered. + +Note that looking for a particular metal might find an ore that contains that +metal along with other metals. For example, locating silver may find +tetrahedrite, which contains silver and copper. Usage ----- -``locate-ore list`` +``locate-ore [list] []`` List metal ores available on the map. -``locate-ore `` +``locate-ore []`` Finds a tile of the specified ore type, zooms the screen so that tile is visible, and designates that tile for digging. @@ -24,17 +28,16 @@ Options ------- ``-a``, ``--all`` - Allow undiscovered ore veins to be marked. + Also search undiscovered ore veins. Examples -------- +``locate-ore`` + List discovered + :: locate-ore hematite locate-ore iron locate-ore silver --all - -Note that looking for a particular metal might find an ore that contains that -metal along with other metals. For example, locating silver may find -tetrahedrite, which contains silver and copper. diff --git a/docs/machine-toggle.rst b/docs/machine-toggle.rst new file mode 100644 index 0000000000..020c2bb4c7 --- /dev/null +++ b/docs/machine-toggle.rst @@ -0,0 +1,21 @@ +machine-toggle +============== + +.. dfhack-tool:: + :summary: Overlay to modify pressure plates and gear assemblies after construction. + :tags: fort armok buildings interface + +This script provides 2 overlays that are managed by the `overlay` framework. +The script does nothing when executed. +Track stops and rollers are handled by `trackstop`. + +The ``pressureplate`` overlay allows the player to change the trigger settings +of a selected pressure plate after it has been constructed. Manual value entry +of ranges for minecart and creature triggers is provided, allowing greater +precision than the game interface normally permits. Incrementing or decrementing +values always restricts them to the usual intervals. + +The ``gearassembly`` overlay allows the player to toggle the state of a selected +gear assembly without linking it to a lever first. This is useful for dwarfputing +and other applications where it may be desirable to default to the disengaged +state until triggered. diff --git a/docs/make-legendary.rst b/docs/make-legendary.rst index c4c431b8cf..7a345bcdce 100644 --- a/docs/make-legendary.rst +++ b/docs/make-legendary.rst @@ -3,7 +3,7 @@ make-legendary .. dfhack-tool:: :summary: Boost skills of the selected dwarf. - :tags: unavailable + :tags: fort armok units This tool can make the selected dwarf legendary in one skill, a group of skills, or all skills. @@ -15,13 +15,17 @@ Usage List the individual skills that you can boost. ``make-legendary classes`` List the skill groups that you can boost. -``make-legendary |all`` - Make the selected dwarf legendary in the specified skill (or all skills) +``make-legendary ||all`` + Make the selected dwarf legendary in the specified skill or class of skills. +``make-legendary all`` + Make the selected dwarf legendary in all skills. Examples -------- ``make-legendary MINING`` Make the selected dwarf a legendary miner. +``make-legendary Medical`` + Make the selected dwarf legendary in all medical skills. ``make-legendary all`` Make the selected dwarf legendary in all skills. Only perfection will do. diff --git a/docs/makeown.rst b/docs/makeown.rst index 7e2a53ba8a..136e8f5100 100644 --- a/docs/makeown.rst +++ b/docs/makeown.rst @@ -6,7 +6,8 @@ makeown :tags: fort armok units Select a unit in the UI and run this tool to converts that unit to be a fortress -citizen (if sentient). It also removes their foreign affiliation, if any. +citizen (if sentient). It also removes their foreign affiliation, if any, and +removes the unit from any current conflict they are engaged in. This tool also fixes :bug:`10921`, where you request workers from your holdings, but they come with the "Merchant" profession and are unable to diff --git a/docs/markdown.rst b/docs/markdown.rst index bffc5cfba6..87b18c48c5 100644 --- a/docs/markdown.rst +++ b/docs/markdown.rst @@ -2,56 +2,124 @@ markdown ======== .. dfhack-tool:: - :summary: Exports the text you see on the screen for posting online. - :tags: unavailable - -This tool saves a copy of a text screen, formatted in markdown, for posting to -Reddit (among other places). See `forum-dwarves` if you want to export BBCode -for posting to the Bay 12 forums. - -This script will attempt to read the current screen, and if it is a text -viewscreen (such as the dwarf 'thoughts' screen or an item 'description') then -append a marked-down version of this text to the output file. Previous entries -in the file are not overwritten, so you may use the ``markdown`` command -multiple times to create a single document containing the text from multiple -screens, like thoughts from several dwarves or descriptions from multiple -artifacts. - -The screens which have been tested and known to function properly with this -script are: - -#. dwarf/unit 'thoughts' screen -#. item/art 'description' screen -#. individual 'historical item/figure' screens -#. manual pages -#. announcements screen -#. combat reports screen -#. latest news (when meeting with liaison) - -There may be other screens to which the script applies. It should be safe to -attempt running the script with any screen active. An error message will inform -you when the selected screen is not appropriate for this script. + :summary: Export displayed text to a Markdown file. + :tags: adventure fort items units + +Saves the description of a selected unit or item to a Markdown file encoded in +UTF-8. + +By default, data is stored in the ``markdown_{YourWorldName}.md`` file in the +root of the game directory. + +For units, the script exports: + +#. Name, race, age, profession +#. Description from the unit's Health -> Description screen +#. Traits from the unit's Personality -> Traits screen + +For items, it exports: + +#. Decorated name (e.g., "☼«☼Chalk Statue of Dakas☼»☼") +#. Full description from the item's view sheet + +The script works for most items with in-game descriptions and names, including +those in storage, on the ground, installed as a building, or worn/carried by +units. + +By default, entries are appended, not overwritten, allowing the ``markdown`` +command to compile descriptions of multiple items & units in a single document. +You can quickly export text for the currently selected unit or item by tapping +the Ctrl-t keybinding or selecting `markdown` from the DFHack logo menu. Usage ----- :: - markdown [-n] [] + markdown [] [] -The output is appended to the ``md_export.md`` file by default. If an alternate -name is specified, then a file named like ``md_{name}.md`` is used instead. +Specifying a name will append to ``markdown_{name}.md``, which can be useful +for organizing data by category or topic. If ``name`` includes whitespace, +quote it in double quotes. + +If no ``name`` is given, the name of the loaded world is used by default. Examples -------- -``markdown`` - Appends the contents of the current screen to the ``md_export.md`` file. -``markdown artifacts`` - Appends the contents of the current screen to the ``md_artifacts.md`` file. +- ``markdown`` + +Example output for a selected chalk statue in the world "Orid Tamun", appended +to the default ``markdown_Orid_Tamun.md`` file:: + + [...previous entries...] + + ### ☼Chalk Statue of Bìlalo Bandbeach☼ + + #### Description: + This is a well-crafted chalk statue of Bìlalo Bandbeach. The item is an + image of Bìlalo Bandbeach the elf and Lani Lyricmonks the Learned the ettin + in chalk by Domas Uthmiklikot. Lani Lyricmonks the Learned is striking down + Bìlalo Bandbeach. + The artwork relates to the killing of the elf Bìlalo Bandbeach by the + ettin Lani Lyricmonks the Learned with Hailbite in The Forest of + Indignation in 147. + + --- + +- ``markdown -o descriptions`` + +Example output for a selected unit Lokum Alnisendok, written to the newly +overwritten ``markdown_descriptions.md`` file:: + + ### Lokum Alnisendok, dwarf, 27 years old Presser. + + #### Description: + A short, sturdy creature fond of drink and industry. + + He is very quick to tire. + + His very long beard is neatly combed. His very long sideburns are braided. + His very long moustache is neatly combed. His hair is clean-shaven. He is + average in size. His nose is sharply hooked. His nose bridge is convex. + His gold eyes are slightly wide-set. His somewhat tall ears are somewhat + narrow. His hair is copper. His skin is copper. + + #### Personality: + He has an amazing memory, but he has a questionable spatial sense and poor + focus. + + He doesn't generally think before acting. He feels a strong need to + reciprocate any favor done for him. He enjoys the company of others. He + does not easily hate or develop negative feelings. He generally finds + himself quite hopeful about the future. He tends to be swayed by the + emotions of others. He finds obligations confining, though he is + conflicted by this for more than one reason. He doesn't tend to hold on to + grievances. He has an active imagination. + + He needs alcohol to get through the working day. + + --- Options ------- -``-n`` - Overwrite the contents of output file instead of appending. +``-o``, ``--overwrite`` + Overwrite the output file, deleting previous entries. + +Setting up custom keybindings +----------------------------- + +If you want to use custom filenames, you can create your own keybinding so +you don't have to type out the full command each time. You can run a command +like this in `gui/launcher` to make it active for the current session, or add +it to ``dfhack-config/init/dfhack.init`` to register it at startup for future +game sessions:: + + keybinding add Ctrl-Shift-S@dwarfmode/ViewSheets/UNIT|dwarfmode/ViewSheets/ITEM "markdown descriptions" + +You can use a different key combination and output name, of course. See the +`keybinding` docs for more details. + +Alternately, you can register commandlines with the `gui/quickcmd` tool and run +them from the popup menu. diff --git a/docs/max-wave.rst b/docs/max-wave.rst deleted file mode 100644 index 386453647e..0000000000 --- a/docs/max-wave.rst +++ /dev/null @@ -1,34 +0,0 @@ -max-wave -======== - -.. dfhack-tool:: - :summary: Dynamically limit the next immigration wave. - :tags: unavailable - -Limit the number of migrants that can arrive in the next wave by -overriding the population cap value from data/init/d_init.txt. -Use with the `repeat` command to set a rolling immigration limit. -Original credit was for Loci. - -If you edit the population caps using `gui/settings-manager` after -running this script, your population caps will be reset and you may -get more migrants than you expected. - -Usage ------ - -:: - - max-wave [max_pop] - -Examples --------- - -:: - - max-wave 5 - repeat -time 1 -timeUnits months -command [ max-wave 10 200 ] - -The first example ensures the next migration wave has 5 or fewer -immigrants. The second example ensures all future seasons have a -maximum of 10 immigrants per wave, up to a total population of 200. diff --git a/docs/modtools/add-syndrome.rst b/docs/modtools/add-syndrome.rst index 12fa435715..93ac44d13a 100644 --- a/docs/modtools/add-syndrome.rst +++ b/docs/modtools/add-syndrome.rst @@ -31,7 +31,8 @@ Options ``--target `` The unit id of the target unit. ``--syndrome |`` - The syndrome to work with. + The syndrome to work with. You can browse syndromes and look up their ids + with `gui/unit-syndromes`. Click on ``All syndromes`` to the the full list. ``--resetPolicy `` Specify a policy of what to do if the unit already has an instance of the syndrome, one of: ``NewInstance``, ``DoNothing``, diff --git a/docs/modtools/create-item.rst b/docs/modtools/create-item.rst index fe96abdd47..a7fbd6ef65 100644 --- a/docs/modtools/create-item.rst +++ b/docs/modtools/create-item.rst @@ -48,4 +48,5 @@ Options caste associated with it. ``-p``, ``--pos ,,`` If specified, items will be spawned at the given coordinates instead of at - the creator unit's feet. + the creator unit's feet. ``here`` can be used in place of ``,,`` + to use the active keyboard cursor. diff --git a/docs/modtools/force.rst b/docs/modtools/force.rst deleted file mode 100644 index 9f71183fa1..0000000000 --- a/docs/modtools/force.rst +++ /dev/null @@ -1,32 +0,0 @@ -modtools/force -============== - -.. dfhack-tool:: - :summary: Trigger game events. - :tags: dev - -This tool triggers events like megabeasts, caravans, and migrants. - -Usage ------ - -:: - - -eventType event - specify the type of the event to trigger - examples: - Megabeast - Migrants - Caravan - Diplomat - WildlifeCurious - WildlifeMischievous - WildlifeFlier - NightCreature - -civ entity - specify the civ of the event, if applicable - examples: - player - MOUNTAIN - EVIL - 28 diff --git a/docs/modtools/if-entity.rst b/docs/modtools/if-entity.rst index 5f54620828..7228295502 100644 --- a/docs/modtools/if-entity.rst +++ b/docs/modtools/if-entity.rst @@ -2,28 +2,33 @@ modtools/if-entity ================== .. dfhack-tool:: - :summary: Run DFHack commands based on current civ id. - :tags: unavailable + :summary: Run DFHack commands based on the the civ id of the current fort. + :tags: dev -Run a command if the current entity matches a given ID. +Run a command if the current fort entity matches a given ID. -To use this script effectively it needs to be called from "raw/onload.init". -Calling this from the main dfhack.init file will do nothing, as no world has -been loaded yet. +This script can only be called when a fort is loaded. To run it immediately +when a matching fort is loaded, call it from a registered +``dfhack.onStateChange`` `state change handler `. See the +`modding-guide` for an example of how to set up a state change handler. Usage ----- -``id`` - Specify the entity ID to match -``cmd [ commandStrs ]`` - Specify the command to be run if the current entity matches the entity - given via -id +:: -All arguments are required. + modtools/if-entity --id --cmd [ ] + +Options +------- + +``--id `` + Specify the entity ID to match. +``--cmd [ ]`` + Specify the command to be run when the given id is matched. Example ------- -``if-entity -id "FOREST" -cmd [ lua "print('Dirty hippies.')" ]`` +``modtools/if-entity --id FOREST --cmd [ lua "print('Dirty hippies.')" ]`` Print a message if you load an elf fort, but not a dwarf, human, etc. fort. diff --git a/docs/modtools/item-trigger.rst b/docs/modtools/item-trigger.rst index 3eacd5924d..32110339b9 100644 --- a/docs/modtools/item-trigger.rst +++ b/docs/modtools/item-trigger.rst @@ -3,73 +3,98 @@ modtools/item-trigger .. dfhack-tool:: :summary: Run DFHack commands when a unit uses an item. - :tags: unavailable - -This powerful tool triggers DFHack commands when a unit equips, unequips, or -attacks another unit with specified item types, specified item materials, or -specified item contaminants. - -Arguments:: - - -clear - clear all registered triggers - -checkAttackEvery n - check the attack event at least every n ticks - -checkInventoryEvery n - check inventory event at least every n ticks - -itemType type - trigger the command for items of this type - examples: - ITEM_WEAPON_PICK - RING - -onStrike - trigger the command on appropriate weapon strikes - -onEquip mode - trigger the command when someone equips an appropriate item - Optionally, the equipment mode can be specified - Possible values for mode: - Hauled - Weapon - Worn - Piercing - Flask - WrappedAround - StuckIn - InMouth - Pet - SewnInto - Strapped - multiple values can be specified simultaneously - example: -onEquip [ Weapon Worn Hauled ] - -onUnequip mode - trigger the command when someone unequips an appropriate item - see above note regarding 'mode' values - -material mat - trigger the command on items with the given material - examples - INORGANIC:IRON - CREATURE:DWARF:BRAIN - PLANT:OAK:WOOD - -contaminant mat - trigger the command for items with a given material contaminant - examples - INORGANIC:GOLD - CREATURE:HUMAN:BLOOD - PLANT:MUSHROOM_HELMET_PLUMP:DRINK - WATER - -command [ commandStrs ] - specify the command to be executed - commandStrs - \\ATTACKER_ID - \\DEFENDER_ID - \\ITEM_MATERIAL - \\ITEM_MATERIAL_TYPE - \\ITEM_ID - \\ITEM_TYPE - \\CONTAMINANT_MATERIAL - \\CONTAMINANT_MATERIAL_TYPE - \\CONTAMINANT_MATERIAL_INDEX - \\MODE - \\UNIT_ID - \\anything -> \anything - anything -> anything + :tags: dev + +This powerful tool triggers DFHack commands when a unit equips or unequips +items or attacks another unit with specified item types, specified item +materials, or specified item contaminants. + +Usage +----- + +:: + + modtools/item-trigger [] --command [ ] + +At least one of the following options must be specified when registering a +trigger: ``--itemType``, ``--material``, or ``--contaminant``. + +Options +------- + +``--clear`` + Clear existing registered triggers before adding the specified trigger. If + no new trigger is specified, this option just clears existing triggers. + +``--checkAttackEvery `` + Check for attack events at least once every n ticks. + +``--checkInventoryEvery `` + Check for inventory events at least once every n ticks. + +``--itemType `` + Trigger the command for items of this type (as specified in the raws). + Examples:: + + ITEM_WEAPON_PICK + RING + +``--material `` + Trigger the command on items with the given material. Examples:: + + INORGANIC:IRON + CREATURE:DWARF:BRAIN + PLANT:OAK:WOOD + +``--contaminant `` + Trigger the command for items with a given material contaminant. Examples:: + + INORGANIC:GOLD + CREATURE:HUMAN:BLOOD + PLANT:MUSHROOM_HELMET_PLUMP:DRINK + WATER + +``--onStrike`` + Trigger the command on appropriate weapon strikes. + +``--onEquip `` + Trigger the command when someone equips an appropriate item. Optionally, + the equipment mode can be specified. Possible values for mode:: + + Hauled + Weapon + Worn + Piercing + Flask + WrappedAround + StuckIn + InMouth + Pet + SewnInto + Strapped + + Multiple values can be specified simultaneously. Example:: + + -onEquip [ Weapon Worn Hauled ] + +``--onUnequip `` + Trigger the command when someone unequips an appropriate item. Same mode + values as ``--onEquip``. + +``--command [ ]`` + Specify the command to be executed. The following tokens can be used in the + command and they will be replaced with appropriate values:: + + \\ATTACKER_ID + \\DEFENDER_ID + \\ITEM_MATERIAL + \\ITEM_MATERIAL_TYPE + \\ITEM_ID + \\ITEM_TYPE + \\CONTAMINANT_MATERIAL + \\CONTAMINANT_MATERIAL_TYPE + \\CONTAMINANT_MATERIAL_INDEX + \\MODE + \\UNIT_ID + \\anything -> \anything + anything -> anything diff --git a/docs/modtools/moddable-gods.rst b/docs/modtools/moddable-gods.rst index 1a575c8325..22b890e6c5 100644 --- a/docs/modtools/moddable-gods.rst +++ b/docs/modtools/moddable-gods.rst @@ -3,21 +3,47 @@ modtools/moddable-gods .. dfhack-tool:: :summary: Create deities. - :tags: unavailable - -This is a standardized version of Putnam's moddableGods script. It allows you -to create gods on the command-line. - -Arguments:: - - -name godName - sets the name of the god to godName - if there's already a god of that name, the script halts - -spheres [ sphereList ] - define a space-separated list of spheres of influence of the god - -gender male|female|neuter - sets the gender of the god - -depictedAs str - often depicted as a str - -verbose - if specified, prints details about the created god + :tags: dev + +This script allows you to create new gods in an existing world. + +Usage +----- + +:: + + moddable-gods --name --spheres [] + +Examples +-------- + +``modtools/moddable-gods --name "Slarty Bog" --spheres FATE,WEATHER`` + Create a new god named "Slarty Bog" with spheres of influence of FATE and + WEATHER. The god will have a random gender and will be depicted as a dwarf. + +``modtools/moddable-gods -n Og -s SPEECH,SALT,SACRIFICE -g neuter -d emu`` + Create a new god named "Og" with spheres of influence of SPEECH, SALT, and + SACRIFICE. The god will be genderless and will be depicted as an emu. + +Options +------- + +``-n``, ``--name `` + The name of the god to create. This is a required argument. The name must + be unique in the world. If the name is already taken, the script will exit + without action. +``-s``, ``--spheres `` + A comma-separated list of spheres of influence for the god. This is a + required argument. To see the available spheres, run this command:: + + lua @df.sphere_type + +``-g``, ``--gender (male|female|neuter)`` + The gender of the god. If not specified, a random gender will be chosen. +``-d``, ``--depicted-as `` + When the deity is referenced in-game, it will be described as "often + depicted as a ". The string must match the token ID or descriptive + name of a race that exists in the world. You can also specify a numeric + race ID. If not specified, it defaults to "dwarf". +``-q``, ``--quiet`` + If specified, suppresses all non-error output. diff --git a/docs/modtools/pref-edit.rst b/docs/modtools/pref-edit.rst index c305c97a0a..cc154e117c 100644 --- a/docs/modtools/pref-edit.rst +++ b/docs/modtools/pref-edit.rst @@ -34,7 +34,7 @@ Valid filters: Include one of these to describe what the id argument represents. - ``-type ``: This describes the type of the preference. Can be entered either using the numerical ID or text id. - Run ``lua @df.unit_preference.T_type`` for a full list of valid values. + Run ``lua @df.unitpref_type`` for a full list of valid values. - ``-subtype ``: The value for an item's subtype - ``-material ``: diff --git a/docs/modtools/set-need.rst b/docs/modtools/set-need.rst index e2199553c3..da794939bc 100644 --- a/docs/modtools/set-need.rst +++ b/docs/modtools/set-need.rst @@ -28,9 +28,9 @@ Valid need targets: :need : ID of the need to target. For example 0 or DrinkAlcohol. - If the need is PrayOrMedidate, a -deity argument is also required. + If the need is PrayOrMeditate, a -deity argument is also required. :deity : - Required when using PrayOrMedidate needs. This value should be the historical figure ID of the deity in question. + Required when using PrayOrMeditate needs. This value should be the historical figure ID of the deity in question. :all: All of the target's needs will be affected. diff --git a/docs/modtools/set-personality.rst b/docs/modtools/set-personality.rst index 9c8d1d45fe..d661b82cb0 100644 --- a/docs/modtools/set-personality.rst +++ b/docs/modtools/set-personality.rst @@ -21,7 +21,7 @@ Target options -------------- ``--citizens`` - All (sane) citizens of your fort will be affected. Will do nothing in + All citizens and residents of your fort will be affected. Will do nothing in adventure mode. ``--unit `` The given unit will be affected. diff --git a/docs/modtools/syndrome-trigger.rst b/docs/modtools/syndrome-trigger.rst index 7e915fda1a..dccf02e610 100644 --- a/docs/modtools/syndrome-trigger.rst +++ b/docs/modtools/syndrome-trigger.rst @@ -13,9 +13,9 @@ Usage :: - modutils/syndrome-trigger --clear - modutils/syndrome-trigger --syndrome --command [ ] - modutils/syndrome-trigger --synclass --command [ ] + modtools/syndrome-trigger --clear + modtools/syndrome-trigger --syndrome --command [ ] + modtools/syndrome-trigger --synclass --command [ ] Options ------- @@ -41,4 +41,4 @@ Examples :: - modutils/syndrome-trigger --synclass VAMPCURSE --command [ modtools/spawn-flow -flowType Dragonfire -location [ \\LOCATION ] ] + modtools/syndrome-trigger --synclass VAMPCURSE --command [ modtools/spawn-flow -flowType Dragonfire -location [ \\LOCATION ] ] diff --git a/docs/necronomicon.rst b/docs/necronomicon.rst index 0e784443a0..274501327d 100644 --- a/docs/necronomicon.rst +++ b/docs/necronomicon.rst @@ -3,12 +3,12 @@ necronomicon .. dfhack-tool:: :summary: Find books that contain the secrets of life and death. - :tags: fort inspection productivity items + :tags: fort inspection items -Lists all books in the fortress that contain the secrets to life and death. -To find the books in fortress mode, go to the Written content submenu in -Objects (O). Slabs are not shown by default since dwarves cannot read secrets -from a slab in fort mode. +Lists all books in the fortress (or world) that contain the secrets to life and +death. To zoom to the books in fortress mode, go to the ``Artifacts`` tab in +`gui/sitemap` and click on their names. Slabs are not listed by default since +dwarves cannot read secrets from a slab in fort mode. Usage ----- @@ -23,3 +23,7 @@ Options ``-s``, ``--include-slabs`` Also list slabs that contain the secrets of life and death. Note that dwarves cannot read the secrets from a slab in fort mode. + +``-w``, ``--world`` + Lists ALL secret-containing items across the entire world, not just your + fortress. diff --git a/docs/notes.rst b/docs/notes.rst new file mode 100644 index 0000000000..a67e05b37c --- /dev/null +++ b/docs/notes.rst @@ -0,0 +1,42 @@ +notes +===== + +.. dfhack-tool:: + :summary: Manage map-specific notes. + :tags: fort interface map + +The `notes` tool enables players to annotate specific tiles +on the Dwarf Fortress game map with customizable notes. + +Each note is displayed as a green pin on the map and includes a one-line title and a detailed comment. + +It can be used to e.g.: + - marking plans for future constructions + - explaining mechanisms or traps + - noting historical events + +Usage +----- + +:: + + notes add + +Add new note in the current position of the keyboard cursor. + +Creating a Note +--------------- +1. Use the keyboard cursor to select the desired map tile where you want to place a note. +2. Execute ``notes add`` via the DFHack console. +3. In the pop-up dialog, fill in the note's title and detailed comment. +4. Press :kbd:`Ctrl` + :kbd:`Enter` to create the note. + +Editing or Deleting a Note +-------------------------- +- Click on the green pin representing the note directly on the map. +- A dialog will appear, offering options to edit the title or comment, or to delete the note entirely. + +Managing Notes Visibility +------------------------- +- Access the `gui/control-panel` / ``UI Overlays`` tab. +- Toggle the ``notes.map-notes`` overlay to show or hide the notes on the map. diff --git a/docs/open-legends.rst b/docs/open-legends.rst index d1ecc310b6..0f7196c31e 100644 --- a/docs/open-legends.rst +++ b/docs/open-legends.rst @@ -3,32 +3,43 @@ open-legends .. dfhack-tool:: :summary: Open a legends screen from fort or adventure mode. - :tags: unavailable + :tags: legends inspection You can use this tool to open legends mode from a world loaded in fortress or -adventure mode. You can browse around, or even run `exportlegends` while you're -on the legends screen. - -Note that this script carries a significant risk of save corruption if the game -is saved after exiting legends mode. To avoid this: - -1. Pause DF **before** running ``open-legends`` -2. Run `quicksave` to save the game -3. Run ``open-legends`` (this script) and browse legends mode as usual -4. Immediately after exiting legends mode, run `die` to quit DF without saving - (saving at this point instead may corrupt your game!) - -Note that it should be safe to run ``open-legends`` itself multiple times in the -same DF session, as long as DF is killed immediately after the last run. -Unpausing DF or running other commands risks accidentally autosaving the game, -which can lead to save corruption. +adventure mode. You can browse around as normal, and even export legends data +while you're on the legends screen. + +However, entering and leaving legends mode from a fort or adventure mode game +will leave Dwarf Fortress in an inconsistent state. Therefore, entering legends +mode via this tool is a **ONE WAY TRIP**. If you care about your savegame, you +*MUST* save your game before entering legends mode. `open-legends` will pop up +a dialog to remind you of this and (in fort mode) will give you a link that you +can use to trigger an Autosave. You can also close the dialog, do a manual save +with a name of your choice, and run `open-legends` again to continue to legends +mode. Usage ----- :: - open-legends [force] + open-legends + open-legends --no-autoquit + +Options +------- + +The ``--no-autoquit`` option is provided for bypassing the auto-quit in case +you are doing testing where you want to switch into legends mode, switch back, +make a few changes, and then hop back into legends mode. However, please note +that while the game appears playable once you are back in the original mode, +your world data **is corrupted** in subtle ways that are not easy to detect +from the UI. Once you are done with your legends browsing, you *must* quit to +desktop and restart the game to be sure to avoid save corruption issues. + +If you are returning to fort mode, autosaves will be disabled to avoid +accidental overwriting of good savegames. -The optional ``force`` argument will bypass all safety checks, as well as the -save corruption warning. +If the ``--no-autoquit`` option has previously been passed and the savegame is +already "tainted" by previous trips into legends mode, the warning dialog +prompting you to save your game will be skipped. diff --git a/docs/pop-control.rst b/docs/pop-control.rst index 3d894688a0..3bd96ac06f 100644 --- a/docs/pop-control.rst +++ b/docs/pop-control.rst @@ -2,24 +2,55 @@ pop-control =========== .. dfhack-tool:: - :summary: Controls population and migration caps persistently per-fort. - :tags: unavailable + :summary: Limit the maximum size of migrant waves. + :tags: fort gameplay -This script controls `hermit` and the various population caps per-fortress. -It is intended to be run from ``dfhack-config/init/onMapLoad.init`` as -``pop-control on-load``. +This tool dynamically adjusts the game population caps to limit the number of +migrants that can arrive in a single wave. This prevents migration waves from +getting too large and overwhelming a fort's infrastructure. -If you edit the population caps using `gui/settings-manager` after -running this script, your population caps will be reset and you may -get more migrants than you expect. +.. warning:: + + This tool will change the population cap in the game settings. If you exit + out of a fort that has this tool enabled and then load a fort that doesn't + have this tool enabled, or if you start a new fort and `pop-control` is not + set for autostart, your population cap will be set to whatever value was + currently used for the previously loaded fort. + +If you want to more severely limit immigration and other "people" events, see +`hermit`. Usage ----- -``pop-control on-load`` - Load population settings for this site or prompt the user for settings - if not present. -``pop-control reenter-settings`` - Revise settings for this site. -``pop-control view-settings`` - Show the current settings for this site. +:: + + enable pop-control + pop-control [status] + pop-control set wave-size + pop-control set max-pop + pop-control reset + +By default, migration waves are capped at 10 migrants and the fort max +population is set at 200. + +When `pop-control` is disabled, the game population cap is set to the value +configured for ``max-pop``. If you have manually adjusted the population caps +outside of this tool, the value that is restored may differ from what you had +originally set. + +Likewise, if you manually adjust the population caps while this tool is +enabled, your manual settings will be overwritten when `pop-control` next +compares your fort population to the settings configured for `pop-control`. + +Examples +-------- + +``enable pop-control`` + Dynamically adjust the population cap for this fort so all future migrant waves are no larger than the configured ``wave-size``. +``pop-control`` + Show currently configured settings. +``pop-control set wave-size 5`` + Ensure future migration waves have 5 or fewer members. +``pop-control reset`` + Reset the wave size and max population to defaults. diff --git a/docs/position.rst b/docs/position.rst index fada2ec54c..56be5993c7 100644 --- a/docs/position.rst +++ b/docs/position.rst @@ -3,15 +3,34 @@ position .. dfhack-tool:: :summary: Report cursor and mouse position, along with other info. - :tags: fort inspection map + :tags: adventure dfhack fort inspection map -This tool reports the current date, clock time, month, and season. It also -reports the cursor position (or just the z-level if no cursor), window size, and -mouse location on the screen. +This tool reports the current date, clock time, month, season, and historical +era. It also reports the keyboard cursor position (or just the z-level if no +active cursor), window size, and mouse location on the screen. If a site is +loaded, it prints the world coordinates of the site. It also prints the world +coordinates of the adventurer (if applicable). + +Can also be used to copy the current keyboard cursor position for later use. Usage ----- :: - position + position [--copy] + +Examples +-------- + +``position`` + Print various information. +``position -c`` + Copy cursor position to system clipboard. + +Options +------- + +``-c``, ``--copy`` + Copy current keyboard cursor position to the clipboard in format ``0,0,0`` + instead of reporting info. For convenience with other tools. diff --git a/docs/pref-adjust.rst b/docs/pref-adjust.rst index a6794319eb..4a151cbb96 100644 --- a/docs/pref-adjust.rst +++ b/docs/pref-adjust.rst @@ -2,7 +2,7 @@ pref-adjust =========== .. dfhack-tool:: - :summary: Set the preferences of a dwarf to an ideal. + :summary: See the preferences of a dwarf or set them to a designated profile. :tags: fort armok units This tool replaces a dwarf's preferences with an "ideal" set which is easy to @@ -16,19 +16,21 @@ satisfy:: Usage ----- +``pref-adjust list`` + List all types of preferences. No changes will be made to any units. +``pref-adjust show`` + Show the preferences of the selected unit. ``pref-adjust all|goth_all|clear_all`` - Changes/clears preferences for all dwarves. + Changes/clears preferences for all citizens. ``pref-adjust one|goth|clear`` Changes/clears preferences for the currently selected dwarf. -``pref-adjust list`` - List all types of preferences. No changes will be made to any dwarves. Examples -------- ``pref-adjust all`` - Change preferences for all dwarves to an ideal. + Change preferences for all citizens to an ideal. Goth mode --------- diff --git a/docs/prioritize.rst b/docs/prioritize.rst index 852e4c1e37..bdfb5aca2a 100644 --- a/docs/prioritize.rst +++ b/docs/prioritize.rst @@ -2,26 +2,26 @@ prioritize ========== .. dfhack-tool:: - :summary: Automatically boost the priority of selected job types. + :summary: Automatically boost the priority of important job types. :tags: fort auto jobs This tool encourages specified types of jobs to get assigned and completed as soon as possible. Finally, you can be sure your food will be hauled before -rotting, your hides will be tanned before going bad, and the corpses of your -enemies will be cleared from your entranceway expediently. +it rots, your hides will be tanned before they go bad, and the corpses of your +enemies will be cleared expediently from your entranceway. You can prioritize a bunch of active jobs that you need done *right now*, or you -can mark certain job types as high priority, and ``prioritize`` will watch for +can register types of jobs as high priority, and ``prioritize`` will watch for and boost the priority of those types of jobs as they are created. This is especially useful for ensuring important (but low-priority -- according to DF) jobs don't get ignored indefinitely in busy forts. -It is important to automatically prioritize only the *most* important job types. -If you add too many job types, or if there are simply too many jobs of those -types in your fort, the *other* tasks in your fort can get ignored. This causes -the same problem that ``prioritize`` is designed to solve. The script provides -a good default set of job types to prioritize that have been suggested and -playtested by the DF community. +When registering job types, choose only the *most* important job types. If you +add too many job types, or if there are simply too many jobs of those types in +your fort, the *other* tasks in your fort can get ignored. This causes the same +problem that ``prioritize`` is designed to solve. The script provides a good +default set of job types to prioritize that have been suggested and playtested +by the DF community. Usage ----- @@ -29,22 +29,19 @@ Usage :: enable prioritize - disable prioritize prioritize [] [defaults| ...] Examples -------- ``prioritize`` - Print out which job types are being automatically prioritized and how many - jobs of each type we have prioritized since we started watching them. The - counts are saved with your game, so they will be accurate even if the game - has been saved and reloaded since ``prioritize`` was started. + Print out which job types are being automatically prioritized. ``enable prioritize``, ``prioritize -a defaults`` Watch for and prioritize the default set of job types that the community has suggested and playtested (see below for details). ``prioritize -j`` - Print out the list of active jobs that you can prioritize right now. + Print out the list of not-yet prioritized jobs that you can prioritize + right now. ``prioritize ConstructBuilding DestroyBuilding`` Prioritize all current building construction and destruction jobs. ``prioritize -a --haul-labor=Food,Body StoreItemInStockpile`` @@ -60,9 +57,8 @@ Options ``-d``, ``--delete`` Stop automatically prioritizing new jobs of the specified job types. ``-j``, ``--jobs`` - Print out how many unassigned jobs of each type there are. This is useful - for discovering the types of the jobs that you can prioritize right now. If - any job types are specified, only jobs of those types are listed. + Print out how many current unprioritized jobs of each type there are. If any + job types are specified, only jobs of those types are listed. ``-l``, ``--haul-labor [,...]`` For StoreItemInStockpile jobs, match only the specified hauling labor(s). Valid ``labor`` strings are: "Stone", "Wood", "Body", "Food", "Refuse", @@ -96,28 +92,38 @@ It is also convenient to prioritize tasks that block you (the player) from doing other things. When you designate a group of trees for chopping, it's often because you want to *do* something with those logs and/or that free space. Prioritizing tree chopping will get your dwarves on the task and keep you from -staring at the screen too long. +staring at the screen in annoyance for too long. You may be tempted to automatically prioritize ``ConstructBuilding`` jobs, but beware that if you engage in megaprojects where many constructions must be built, these jobs can consume your entire fortress if prioritized. It is often -better to run ``prioritize ConstructBuilding`` by itself (i.e. without the +better to run ``prioritize ConstructBuilding`` by itself (that is, without the ``-a`` parameter) as needed to just prioritize the construction jobs that you -have ready at the time. +have ready at the time if you need to "clear the queue". Default list of job types to prioritize --------------------------------------- The community has assembled a good default list of job types that most players -will benefit from. They have been playtested across a wide variety of fort -types. It is a good idea to enable `prioritize` with at least these defaults -for all your forts. +will benefit from. They have been playtested across a wide variety of forts. It +is a good idea to enable `prioritize` with at least these defaults for all your +forts. The default prioritize list includes: - Handling items that can rot - Medical, hygiene, and hospice tasks - Interactions with animals and prisoners -- Noble-specific tasks (like managing workorders) +- Noble-specific tasks (like managing work orders) - Dumping items, felling trees, and other tasks that you, as a player, might stare at and internally scream "why why why isn't this getting done??". + +Overlay +------- + +This script also provides an overlay that is managed by the `overlay` +framework. A panel is added to the info sheet for buildings that are queued for +construction or destruction. If a unit has taken the job, their name will be +listed. Click on the name to zoom to the unit. There is also a toggle button +for the high priority status for the job. Toggle it on if the job is not being +taken and you need it to be completed quickly. diff --git a/docs/putontable.rst b/docs/putontable.rst index e880ac896d..10d94705a6 100644 --- a/docs/putontable.rst +++ b/docs/putontable.rst @@ -3,7 +3,7 @@ putontable .. dfhack-tool:: :summary: Make an item appear on a table. - :tags: unavailable + :tags: adventure fort armok buildings items To use this tool, move an item to the ground on the same tile as a built table. Then, place the cursor over the table and item and run this command. The item diff --git a/docs/quickfort.rst b/docs/quickfort.rst index 7f5d6d1107..dfca6c0c15 100644 --- a/docs/quickfort.rst +++ b/docs/quickfort.rst @@ -7,9 +7,8 @@ quickfort Quickfort reads stored blueprint files and applies them to the game map. You can apply blueprints that designate digging, build buildings, place -stockpiles, mark zones, and/or configure settings. If you find yourself spending -time doing similar or repetitive things in your forts, this tool can be an -immense help. +stockpiles, mark zones, and more. If you find yourself spending time doing +similar or repetitive designs in your forts, this tool can be an immense help. Note that this is the commandline tool. Please see `gui/quickfort` if you'd like a graphical in-game UI for selecting, previewing, and applying blueprints. @@ -17,7 +16,8 @@ a graphical in-game UI for selecting, previewing, and applying blueprints. You can create the blueprints by hand (see the `quickfort-blueprint-guide` for details) or you can build your plan "for real" in Dwarf Fortress, and then export your map using `gui/blueprint`. This way you can effectively copy and -paste sections of your fort if you need to. +paste sections of your fort. Player-created blueprints are stored in the +``dfhack-config/blueprints`` directory. There are many ready-to-use blueprints in the `blueprint library ` that is distributed with DFHack, @@ -29,17 +29,21 @@ Usage ----- ``quickfort list [-m|--mode ] [-u|--useronly] [-h|--hidden] []`` - Lists blueprints in the ``blueprints`` folder. Blueprints are ``.csv`` files - or sheets within ``.xlsx`` files that contain a ``#`` comment in the - upper-left cell (please see `quickfort-blueprint-guide` for more information - on modes). By default, library blueprints in the ``hack/data/blueprints/`` subfolder - are included and blueprints that contain a ``hidden()`` marker in their - modeline are excluded from the returned list. Specify ``-u`` or ``-h`` to - exclude library or include hidden blueprints, respectively. The list can - additionally be filtered by a specified mode (e.g. ``-m build``) and/or - strings to search for in a path, filename, mode, or comment. The id numbers - in the reported list may not be contiguous if there are hidden or filtered - blueprints that are not being shown. + Lists available blueprints. Blueprints are ``.csv`` files or sheets within + ``.xlsx`` files that contain a ``#`` comment in the upper-left cell + (please see `quickfort-blueprint-guide` for more information on modes). By + default, library blueprints are included and blueprints that contain a + ``hidden()`` marker in their modeline are excluded from the returned list. + Specify ``-u`` or ``-h`` to exclude library or include hidden blueprints, + respectively. The list can additionally be filtered by a specified mode + (e.g. ``-m build``) and/or strings to search for in a path, filename, mode, + or comment. The id numbers in the reported list may not be contiguous if + there are hidden or filtered blueprints that are not being shown. +``quickfort delete [ ...]`` + Delete the specified blueprint file(s). Only player-owned blueprints (stored + in the ``dfhack-config/blueprints`` directory) can be deleted. Blueprints + provided by mods or the standard DFHack library cannot be deleted by this + command. ``quickfort gui []`` Invokes the quickfort UI with the specified parameters, giving you an interactive blueprint preview to work with before you apply it to the map. @@ -93,9 +97,9 @@ Examples z-levels). Also transform the blueprint by rotating counterclockwise and flipping vertically in order to fit the pump stack through some tricky-shaped caverns 50 z-levels above. Note that this kind of careful - positioning is much easier to do with `gui/quickfort`, but it can be done - via the commandline as well if you know exactly what transformations and - positioning you need. + positioning is much easier to do interactively with `gui/quickfort`, but it + can be done via the commandline as well if you know exactly what + transformations and positioning you need. ``quickfort orders 10,11,12 --dry-run`` Process the blueprints with ids ``10``, ``11``, and ``12`` (run ``quickfort list`` to see which blueprints these are for you) and calculate @@ -116,6 +120,18 @@ Command options ``-d``, ``--dry-run`` Go through all the motions and print statistics on what would be done, but don't actually change any game state. +``-m``, ``--marker [,...]`` + Apply the given marker(s) to the tiles designated by the ``#dig`` blueprint + that you are applying. Valid marker types are: ``blueprint`` (designate but + don't dig), ``warm`` (dig even if the tiles are warm), and ``damp`` (dig + even if the tiles are damp). ``warm`` and ``damp`` markers are interpreted + by the `dig` tool for interruption-free digging through warm and damp tiles. +``-p``, ``--priority `` + Set the priority to the given number (1-7) for tiles designated by the + ``#dig`` blueprint that you are applying. That is, tiles that normally have + a priority of ``4`` will instead have the priority you specify. If the + blueprint uses other explicit priorities, they will be shifted up or down + accordingly. ``--preserve-engravings `` Don't designate tiles for digging/carving if they have an engraving with at least the specified quality. Valid values for ``quality`` are: ``None``, @@ -174,9 +190,9 @@ for the current session and will be reset when you restart DF. ``blueprints_library_dir`` (default: ``hack/data/blueprints``) Directory tree to search for library blueprints. ``force_marker_mode`` (default: ``false``) - If true, will designate all dig blueprints in marker mode. If false, only - cells with dig codes explicitly prefixed with ``m`` will be designated in - marker mode. + If true, will designate all dig blueprints in marker=blueprint mode. If + false, only cells with dig codes explicitly prefixed with ``mb`` in the + blueprint cell will be designated in marker mode. ``stockpiles_max_barrels``, ``stockpiles_max_bins``, and ``stockpiles_max_wheelbarrows`` (defaults: ``-1``, ``-1``, ``0``) Set to the maximum number of resources you want assigned to stockpiles of the relevant types. Set to ``-1`` for DF defaults (number of stockpile tiles @@ -202,7 +218,7 @@ statistics structure is a map of stat ids to ``{label=string, value=number}``. ``params`` is a table with the following fields: ``mode`` (required) - The name of the blueprint mode, e.g. ``dig``, ``build``, etc. + The blueprint mode, e.g. ``dig``, ``build``, etc. ``data`` (required) A sparse map populated such that ``data[z][y][x]`` yields the blueprint text that should be applied to the tile at map coordinate ``(x, y, z)``. You can @@ -220,6 +236,13 @@ statistics structure is a map of stat ids to ``{label=string, value=number}``. ``aliases`` A map of blueprint alias names to their expansions. If not specified, defaults to ``{}``. +``marker`` + A map of strings to booleans indicating which markers should be applied to + this ``dig`` mode blueprint. See `Command options`_ above for details. If + not specified, defaults to ``{blueprint=false, warm=false, damp=false}``. +``priority`` + An integer between ``1`` and ``7``, inclusive, indicating the base priority + for this ``dig`` blueprint. If not specified, defaults to ``4``. ``preserve_engravings`` Don't designate tiles for digging or carving if they have an engraving with at least the specified quality. Value is a ``df.item_quality`` enum name or diff --git a/docs/rejuvenate.rst b/docs/rejuvenate.rst index 754510921c..aaa2216ca3 100644 --- a/docs/rejuvenate.rst +++ b/docs/rejuvenate.rst @@ -6,8 +6,9 @@ rejuvenate :tags: fort armok units If your most valuable citizens are getting old, this tool can save them. It -decreases the age of the selected dwarf to 20 years, or to the age specified. -Age is only increased using the --force option. +decreases the age of the selected dwarf to the minimum adult age, or to the age +specified. Age can only be increased (e.g. when this tool is run on babies or +children) if the ``--force`` option is specified. Usage ----- @@ -20,11 +21,15 @@ Examples -------- ``rejuvenate`` - Set the age of the selected dwarf to 20 (if they're older). + Set the age of the selected dwarf to 18 (if they're older than 18). The + target age may be different if you have modded dwarves to become an adult + at a different age, or if you have selected a unit that is not a dwarf. ``rejuvenate --all`` - Set the age of all dwarves over 20 to 20. + Set the ages of all adult citizens and residents to their minimum adult + ages. ``rejuvenate --all --force`` - Set the age of all dwarves (including babies) to 20. + Set the ages of all citizens and residents (including children and babies) + to their minimum adult ages. ``rejuvenate --age 149 --force`` Set the age of the selected dwarf to 149, even if they are younger. @@ -32,11 +37,11 @@ Options ------- ``--all`` - Rejuvenate all citizens, not just the selected one. + Rejuvenate all citizens and residents instead of a selected unit. ``--age `` - Sets the target to the age specified. If this is not set, the target age is 20. + Sets the target to the age specified. If this is not set, the target age defaults to the minimum adult age for the unit. ``--force`` - Set age for units under the specified age to the specified age. Useful if there are too - many babies around... + Set age for units under the specified age to the specified age. Useful if + there are too many babies around... ``--dry-run`` Only list units that would be changed; don't actually change ages. diff --git a/docs/resize-armor.rst b/docs/resize-armor.rst new file mode 100644 index 0000000000..1ea1e0c094 --- /dev/null +++ b/docs/resize-armor.rst @@ -0,0 +1,26 @@ +resize-armor +============ + +.. dfhack-tool:: + :summary: Resize armor and clothing. + :tags: adventure fort armok gameplay items + +Resize any armor or clothing item to suit any creature size. + +Usage +----- + +``resize-armor [\n") end end - end}) + end)) - table.insert(chunks, {name='historical_event_relationships', fn=function() - for ID, set in progress_ipairs(df.global.world.history.relationship_events, 'relationship_event') do + table.insert(chunks, make_chunk('historical_event_relationships', world.history.relationship_events, function(vector) + for ID, set in progress_ipairs(vector, 'historical event relationships') do for k = 0, set.next_element - 1 do file:write("\t\n") file:write("\t\t"..set.event[k].."\n") @@ -898,25 +906,25 @@ local function export_more_legends_xml() file:write("\t\n") end end - end}) + end)) - table.insert(chunks, {name='historical_event_relationship_supplements', fn=function() - for ID, event in progress_ipairs(df.global.world.history.relationship_event_supplements, 'relationship_event_supplement') do + table.insert(chunks, make_chunk('historical_event_relationship_supplements', world.history.relationship_event_supplements, function(vector) + for ID, event in progress_ipairs(vector, 'historical event relationship supplements') do file:write("\t\n") file:write("\t\t"..event.event.."\n") - file:write("\t\t"..event.occasion_type.."\n") - file:write("\t\t"..event.site.."\n") - file:write("\t\t"..event.unk_1.."\n") + file:write("\t\t"..event.circumstance.."\n") + file:write("\t\t"..event.circumstance_id.Death.."\n") -- FIXME: Death has been chosen arbitrarily here + file:write("\t\t"..event.reason.."\n") file:write("\t\n") end - end}) + end)) - table.insert(chunks, {name='historical_event_collections', fn=function() end}) + table.insert(chunks, make_chunk('historical_event_collections', {}, function() end)) - table.insert(chunks, {name='historical_eras', fn=function() end}) + table.insert(chunks, make_chunk('historical_eras', {}, function() end)) - table.insert(chunks, {name='written_contents', fn=function() - for wcK, wcV in progress_ipairs(df.global.world.written_contents.all) do + table.insert(chunks, make_chunk('written_contents', world.written_contents.all, function(vector) + for wcK, wcV in progress_ipairs(vector, 'written contents') do file:write("\t\n") file:write("\t\t"..wcV.id.."\n") file:write("\t\t"..escape_xml(dfhack.df2utf(wcV.title)).."\n") @@ -953,40 +961,37 @@ local function export_more_legends_xml() file:write("\t\t"..wcV.author.."\n") file:write("\t\n") end - end}) + end)) - table.insert(chunks, {name='poetic_forms', fn=function() - for formK, formV in progress_ipairs(df.global.world.poetic_forms.all, 'poetic form') do + table.insert(chunks, make_chunk('poetic_forms', world.poetic_forms.all, function(vector) + for formK, formV in progress_ipairs(vector, 'poetic forms') do file:write("\t\n") file:write("\t\t"..formV.id.."\n") - file:write("\t\t"..escape_xml(dfhack.df2utf(dfhack.TranslateName(formV.name,1))).."\n") + file:write("\t\t"..escape_xml(dfhack.df2utf(dfhack.translation.translateName(formV.name,1))).."\n") file:write("\t\n") end - end}) + end)) - table.insert(chunks, {name='musical_forms', fn=function() - for formK, formV in progress_ipairs(df.global.world.musical_forms.all, 'musical form') do + table.insert(chunks, make_chunk('musical_forms', world.musical_forms.all, function(vector) + for formK, formV in progress_ipairs(vector, 'musical forms') do file:write("\t\n") file:write("\t\t"..formV.id.."\n") - file:write("\t\t"..escape_xml(dfhack.df2utf(dfhack.TranslateName(formV.name,1))).."\n") + file:write("\t\t"..escape_xml(dfhack.df2utf(dfhack.translation.translateName(formV.name,1))).."\n") file:write("\t\n") end - end}) + end)) - table.insert(chunks, {name='dance_forms', fn=function() - for formK, formV in progress_ipairs(df.global.world.dance_forms.all, 'dance form') do + table.insert(chunks, make_chunk('dance_forms', world.dance_forms.all, function(vector) + for formK, formV in progress_ipairs(vector, 'dance forms') do file:write("\t\n") file:write("\t\t"..formV.id.."\n") - file:write("\t\t"..escape_xml(dfhack.df2utf(dfhack.TranslateName(formV.name,1))).."\n") + file:write("\t\t"..escape_xml(dfhack.df2utf(dfhack.translation.translateName(formV.name,1))).."\n") file:write("\t\n") end - end}) + end)) - step_size = math.max(1, 100 // #chunks) - for k, chunk in ipairs(chunks) do - progress_percent = math.max(progress_percent, (100 * k) // #chunks) - step_percent = progress_percent - write_chunk(chunk.name, chunk.fn) + for _, chunk in ipairs(chunks) do + write_chunk(file, chunk) end file:write("\n") @@ -1009,111 +1014,37 @@ local function export_more_legends_xml() end local function wrap_export() - if progress_percent >= 0 then + if asyncexport.num_total >= 0 then qerror('exportlegends already in progress') end - progress_percent = 0 - step_size = 1 - progress_item = 'basic info' + asyncexport.num_total = 0 + asyncexport.num_done = 0 + asyncexport.progress_item = 'basic info' yield_if_timeout() local ok, err = pcall(export_more_legends_xml) if not ok then dfhack.printerr(err) end - progress_percent = -1 - step_size = 1 - progress_item = '' + asyncexport.reset_state() end --- ------------------- --- LegendsOverlay --- - -LegendsOverlay = defclass(LegendsOverlay, overlay.OverlayWidget) -LegendsOverlay.ATTRS{ - default_pos={x=2, y=2}, - default_enabled=true, - viewscreens='legends/Default', - frame={w=55, h=5}, +OVERLAY_WIDGETS = { + export=asyncexport.LegendsOverlay, + mask=asyncexport.DoneMaskOverlay, + histfigfilter=racefilter.RaceFilterOverlay, } -function LegendsOverlay:init() - self:addviews{ - widgets.Panel{ - view_id='button_mask', - frame={t=0, l=0, w=15, h=3}, - }, - widgets.BannerPanel{ - frame={b=0, l=0, r=0, h=1}, - subviews={ - widgets.ToggleHotkeyLabel{ - view_id='do_export', - frame={t=0, l=1, w=53}, - label='Also export DFHack extended legends data:', - key='CUSTOM_CTRL_D', - visible=function() return progress_percent < 0 end, - }, - widgets.Label{ - frame={t=0, l=1}, - text={ - 'Exporting ', - {text=function() return progress_item end}, - ' (', - {text=function() return progress_percent end, pen=COLOR_YELLOW}, - '% complete)' - }, - visible=function() return progress_percent >= 0 end, - }, - }, - }, - } -end - -function LegendsOverlay:onInput(keys) - if keys._MOUSE_L and progress_percent < 0 and - self.subviews.button_mask:getMousePos() and - self.subviews.do_export:getOptionValue() - then - script.start(wrap_export) +dfhack.onStateChange[GLOBAL_KEY] = function(sc) + if sc == SC_VIEWSCREEN_CHANGED and df.viewscreen_choose_game_typest:is_instance(dfhack.gui.getDFViewscreen(true)) then + asyncexport.reset_state() end - return LegendsOverlay.super.onInput(self, keys) -end - --- ------------------- --- DoneMaskOverlay --- - -DoneMaskOverlay = defclass(DoneMaskOverlay, overlay.OverlayWidget) -DoneMaskOverlay.ATTRS{ - default_pos={x=-2, y=2}, - default_enabled=true, - viewscreens='legends', - frame={w=9, h=3}, -} - -function DoneMaskOverlay:init() - self:addviews{ - widgets.Panel{ - frame_background=gui.CLEAR_PEN, - visible=function() return progress_percent >= 0 end, - } - } -end -function DoneMaskOverlay:onInput(keys) - if progress_percent >= 0 then - if keys.LEAVESCREEN or (keys._MOUSE_L and self:getMousePos()) then - return true - end + -- Reset state when a world or map is loaded to ensure data remains current + if sc == SC_WORLD_LOADED then + racefilter.reset_state() end - return DoneMaskOverlay.super.onInput(self, keys) end -OVERLAY_WIDGETS = { - export=LegendsOverlay, - mask=DoneMaskOverlay, -} - if dfhack_flags.module then return end diff --git a/exterminate.lua b/exterminate.lua index f179b28648..0263fbe483 100644 --- a/exterminate.lua +++ b/exterminate.lua @@ -1,3 +1,5 @@ +--@module = true + local argparse = require('argparse') local function spawnLiquid(position, liquid_level, liquid_type, update_liquids) @@ -12,31 +14,35 @@ local function spawnLiquid(position, liquid_level, liquid_type, update_liquids) map_block.flags.update_liquid_twice = update_liquids end -local function checkUnit(unit) - return (unit.body.blood_count ~= 0 or unit.body.blood_max == 0) and - not unit.flags1.inactive and - not unit.flags1.caged and - not unit.flags1.chained -end - -local function isUnitFriendly(unit) - return dfhack.units.isCitizen(unit) or - dfhack.units.isOwnCiv(unit) or - dfhack.units.isOwnGroup(unit) or - dfhack.units.isVisiting(unit) or - dfhack.units.isTame(unit) or - dfhack.units.isDomesticated(unit) or - dfhack.units.isVisitor(unit) or - dfhack.units.isDiplomat(unit) or - dfhack.units.isMerchant(unit) +local function checkUnit(opts, unit) + if not dfhack.units.isActive(unit) or + (unit.body.blood_max ~= 0 and unit.body.blood_count == 0) or + unit.flags1.caged or + unit.flags1.chained + then + return false + end + if opts.only_visible and not dfhack.units.isVisible(unit) then + return false + end + if not opts.include_friendly and not dfhack.units.isDanger(unit) and not dfhack.units.isWildlife(unit) then + return false + end + if opts.selected_caste and opts.selected_caste ~= df.creature_raw.find(unit.race).caste[unit.caste].caste_id then + return false + end + return true end -local killMethod = { +killMethod = { INSTANT = 0, BUTCHER = 1, MAGMA = 2, DROWN = 3, VAPORIZE = 4, + DISINTEGRATE = 5, + KNOCKOUT = 6, + TRAUMATIZE = 7, } -- removes the unit from existence, leaving no corpse if the unit hasn't died @@ -57,6 +63,18 @@ local function butcherUnit(unit) unit.flags2.slaughter = true end +-- Knocks a unit out for 30k ticks or the target value +local function knockoutUnit(unit, target_value) + target_value = target_value or 30000 + unit.counters.unconscious = target_value +end + +-- Traumatizes the unit, forcing them to stare off into space. Cuts down on pathfinding +local function traumatizeUnit(unit) + unit.mood = df.mood_type.Traumatized +end + + local function drownUnit(unit, liquid_type) previousPositions = previousPositions or {} previousPositions[unit.id] = copyall(unit.pos) @@ -76,7 +94,14 @@ local function drownUnit(unit, liquid_type) createLiquid() end -local function killUnit(unit, method) +local function destroyInventory(unit) + for index = #unit.inventory-1, 0, -1 do + local item = unit.inventory[index].item + dfhack.items.remove(item) + end +end + +function killUnit(unit, method) if method == killMethod.BUTCHER then butcherUnit(unit) elseif method == killMethod.MAGMA then @@ -85,6 +110,13 @@ local function killUnit(unit, method) drownUnit(unit, df.tile_liquid.Water) elseif method == killMethod.VAPORIZE then vaporizeUnit(unit) + elseif method == killMethod.DISINTEGRATE then + vaporizeUnit(unit) + destroyInventory(unit) + elseif method == killMethod.KNOCKOUT then + knockoutUnit(unit) + elseif method == killMethod.TRAUMATIZE then + traumatizeUnit(unit) else destroyUnit(unit) end @@ -98,34 +130,43 @@ local function getRaceCastes(race_id) return unit_castes end -local function getMapRaces(only_visible, include_friendly) +local function getMapRaces(opts) local map_races = {} for _, unit in pairs(df.global.world.units.active) do - if only_visible and not dfhack.units.isVisible(unit) then - goto skipunit - end - if not include_friendly and isUnitFriendly(unit) then - goto skipunit - end - if dfhack.units.isActive(unit) and checkUnit(unit) then - local unit_race_name = dfhack.units.isUndead(unit) and "UNDEAD" or df.creature_raw.find(unit.race).creature_id - - local race = ensure_key(map_races, unit_race_name) - race.id = unit.race - race.name = unit_race_name - race.count = (race.count or 0) + 1 + if not checkUnit(opts, unit) then goto continue end + local race_name, display_name + if dfhack.units.isUndead(unit) then + race_name = 'UNDEAD' + display_name = 'UNDEAD' + else + local craw = df.creature_raw.find(unit.race) + race_name = craw.creature_id + if race_name:match('^FORGOTTEN_BEAST_[0-9]+$') or race_name:match('^TITAN_[0-9]+$') then + display_name = dfhack.units.getReadableName(unit) + else + display_name = craw.name[0] + end end - :: skipunit :: + local race = ensure_key(map_races, race_name) + race.id = unit.race + race.name = race_name + race.display_name = display_name + race.count = (race.count or 0) + 1 + ::continue:: end - return map_races end +if dfhack_flags.module then + return +end + local options, args = { help = false, method = killMethod.INSTANT, only_visible = false, include_friendly = false, + limit = -1, }, {...} local positionals = argparse.processArgsGetopt(args, { @@ -133,6 +174,7 @@ local positionals = argparse.processArgsGetopt(args, { {'m', 'method', handler = function(arg) options.method = killMethod[arg:upper()] end, hasArg = true}, {'o', 'only-visible', handler = function() options.only_visible = true end}, {'f', 'include-friendly', handler = function() options.include_friendly = true end}, + {'l', 'limit', handler = function(arg) options.limit = argparse.positiveInt(arg, 'limit') end, hasArg = true}, }) if not dfhack.isMapLoaded() then @@ -154,62 +196,106 @@ if positionals[1] == "this" then return end -local map_races = getMapRaces(options.only_visible, options.include_friendly) +local map_races = getMapRaces(options) -if not positionals[1] then +if not positionals[1] or positionals[1] == 'list' then local sorted_races = {} - for race, value in pairs(map_races) do - table.insert(sorted_races, { name = race, count = value.count }) + local max_width = 10 + for _,v in pairs(map_races) do + max_width = math.max(max_width, #v.name) + table.insert(sorted_races, v) end table.sort(sorted_races, function(a, b) + if a.count == b.count then + local asuffix, bsuffix = a.name:match('([0-9]+)$'), b.name:match('([0-9]+)$') + if asuffix and bsuffix then + local aname, bname = a.name:match('(.*)_[0-9]+$'), b.name:match('(.*)_[0-9]+$') + local anum, bnum = tonumber(asuffix), tonumber(bsuffix) + if aname == bname and anum and bnum then + return anum < bnum + end + end + return a.name < b.name + end return a.count > b.count end) - for _, race in ipairs(sorted_races) do - print(([[%4s %s]]):format(race.count, race.name)) + for _,v in ipairs(sorted_races) do + local name_str = v.name + if name_str ~= 'UNDEAD' and v.display_name ~= string.lower(name_str):gsub('_', ' ') then + name_str = ('%-'..tostring(max_width)..'s (%s)'):format(name_str, v.display_name) + end + print(('%4s %s'):format(v.count, name_str)) end return end -local count = 0 -if positionals[1]:lower() == 'undead' then +local count, target = 0, 'creature(s)' +local race_name = table.concat(positionals, ' ') +if race_name:lower() == 'undead' then + target = 'undead' if not map_races.UNDEAD then qerror("No undead found on the map.") end for _, unit in pairs(df.global.world.units.active) do - if dfhack.units.isUndead(unit) and checkUnit(unit) then + if dfhack.units.isUndead(unit) and checkUnit(options, unit) then killUnit(unit, options.method) count = count + 1 end end +elseif positionals[1]:split(':')[1] == "all" then + options.selected_caste = positionals[1]:split(':')[2] + + for _, unit in ipairs(df.global.world.units.active) do + if options.limit > 0 and count >= options.limit then + break + end + if not checkUnit(options, unit) then + goto skipunit + end + + killUnit(unit, options.method) + count = count + 1 + :: skipunit :: + end else - local selected_race, selected_caste = positionals[1], nil + local selected_race, selected_caste = race_name, nil if string.find(selected_race, ':') then - local tokens = positionals[1]:split(':') + local tokens = selected_race:split(':') selected_race, selected_caste = tokens[1], tokens[2] end if not map_races[selected_race] then - qerror("No creatures of this race on the map.") + local selected_race_upper = selected_race:upper() + local selected_race_under = selected_race_upper:gsub(' ', '_') + if map_races[selected_race_upper] then + selected_race = selected_race_upper + elseif map_races[selected_race_under] then + selected_race = selected_race_under + else + qerror("No creatures of this race on the map (" .. selected_race .. ").") + end end local race_castes = getRaceCastes(map_races[selected_race].id) if selected_caste and not race_castes[selected_caste] then - qerror("Invalid caste.") + local selected_caste_upper = selected_caste:upper() + if race_castes[selected_caste_upper] then + selected_caste = selected_caste_upper + else + qerror("Invalid caste: " .. selected_caste) + end end + target = selected_race + options.selected_caste = selected_caste + for _, unit in pairs(df.global.world.units.active) do - if not checkUnit(unit) then - goto skipunit - end - if options.only_visible and not dfhack.units.isVisible(unit) then - goto skipunit - end - if not options.include_friendly and isUnitFriendly(unit) then - goto skipunit + if options.limit > 0 and count >= options.limit then + break end - if selected_caste and selected_caste ~= df.creature_raw.find(unit.race).caste[unit.caste].caste_id then + if not checkUnit(options, unit) then goto skipunit end @@ -222,4 +308,4 @@ else end end -print(([[Exterminated %d creatures.]]):format(count)) +print(([[Exterminated %d %s.]]):format(count, target)) diff --git a/extinguish.lua b/extinguish.lua index 226f211ce7..986291ad3f 100644 --- a/extinguish.lua +++ b/extinguish.lua @@ -1,187 +1,173 @@ -- Puts out fires. --- author: Atomic Chicken --@ module = true -local utils = require 'utils' +local guidm = require('gui.dwarfmode') +local utils = require('utils') + local validArgs = utils.invert({ - 'all', - 'location', - 'help' + 'all', + 'help' }) -local args = utils.processArgs({...}, validArgs) - -local usage = [====[ - -extinguish -========== -This script allows the user to put out fires affecting -map tiles, plants, units, items and buildings. - -To select a target, place the cursor over it before running the script. -Alternatively, use one of the arguments below. - -Arguments:: - - -all - extinguish everything on the map - - -location [ x y z ] - extinguish only at the specified coordinates -]====] +local args = utils.processArgs({ ... }, validArgs) if args.help then - print(usage) - return -end - -if args.all and args.location then - qerror('-all and -location cannot be specified together.') + print(dfhack.script_help()) + return end function extinguishTiletype(tiletype) - if tiletype == df.tiletype['Fire'] or tiletype == df.tiletype['Campfire'] then - return df.tiletype['Ashes'..math.random(1,3)] - elseif tiletype == df.tiletype['BurningTreeTrunk'] then - return df.tiletype['TreeDeadTrunkPillar'] - elseif tiletype == df.tiletype['BurningTreeBranches'] then - return df.tiletype['TreeDeadBranches'] - elseif tiletype == df.tiletype['BurningTreeTwigs'] then - return df.tiletype['TreeDeadTwigs'] - elseif tiletype == df.tiletype['BurningTreeCapWall'] then - return df.tiletype['TreeDeadCapPillar'] - elseif tiletype == df.tiletype['BurningTreeCapRamp'] then - return df.tiletype['TreeDeadCapRamp'] - elseif tiletype == df.tiletype['BurningTreeCapFloor'] then - return df.tiletype['TreeDeadCapFloor'..math.random(1,4)] - else - return tiletype - end + if tiletype == df.tiletype['Fire'] or tiletype == df.tiletype['Campfire'] then + return df.tiletype['Ashes' .. math.random(1, 3)] + elseif tiletype == df.tiletype['BurningTreeTrunk'] then + return df.tiletype['TreeDeadTrunkPillar'] + elseif tiletype == df.tiletype['BurningTreeBranches'] then + return df.tiletype['TreeDeadBranches'] + elseif tiletype == df.tiletype['BurningTreeTwigs'] then + return df.tiletype['TreeDeadTwigs'] + elseif tiletype == df.tiletype['BurningTreeCapWall'] then + return df.tiletype['TreeDeadCapPillar'] + elseif tiletype == df.tiletype['BurningTreeCapRamp'] then + return df.tiletype['TreeDeadCapRamp'] + elseif tiletype == df.tiletype['BurningTreeCapFloor'] then + return df.tiletype['TreeDeadCapFloor' .. math.random(1, 4)] + else + return tiletype + end end -function extinguishTile(x,y,z) - local tileBlock = dfhack.maps.getTileBlock(x,y,z) - tileBlock.tiletype[x%16][y%16] = extinguishTiletype(tileBlock.tiletype[x%16][y%16]) - tileBlock.temperature_1[x%16][y%16] = 10050 -- chosen as a 'standard' value; it'd be more ideal to calculate it with respect to the region, season, undergound status, etc (but DF does this for us when updating temperatures) - tileBlock.temperature_2[x%16][y%16] = 10050 - tileBlock.flags.update_temperature = true - tileBlock.flags.update_liquid = true - tileBlock.flags.update_liquid_twice = true +function extinguishTile(x, y, z) + local tileBlock = dfhack.maps.getTileBlock(x, y, z) + tileBlock.tiletype[x % 16][y % 16] = extinguishTiletype(tileBlock.tiletype[x % 16][y % 16]) + -- chosen as a 'standard' value; it'd be more ideal to calculate it with respect to the region, + -- season, undergound status, etc (but DF does this for us when updating temperatures) + tileBlock.temperature_1[x % 16][y % 16] = 10050 + tileBlock.temperature_2[x % 16][y % 16] = 10050 + tileBlock.flags.update_temperature = true + tileBlock.flags.update_liquid = true + tileBlock.flags.update_liquid_twice = true end function extinguishContaminant(spatter) --- reset temperature of any contaminants to prevent them from causing reignition --- (just in case anyone decides to play around with molten gold or whatnot) - spatter.temperature.whole = 10050 - spatter.temperature.fraction = 0 + -- reset temperature of any contaminants to prevent them from causing reignition + -- (just in case anyone decides to play around with molten gold or whatnot) + spatter.base.temperature.whole = 10050 + spatter.base.temperature.fraction = 0 end +---@param item df.item function extinguishItem(item) - local item = item --as:df.item_actual - if item.flags.on_fire then - item.flags.on_fire = false - item.temperature.whole = 10050 - item.temperature.fraction = 0 - item.flags.temps_computed = false - if item.contaminants then - for _,spatter in ipairs(item.contaminants) do - extinguishContaminant(spatter) - end + if item.flags.on_fire then + item.flags.on_fire = false + item.temperature.whole = 10050 + item.temperature.fraction = 0 + item.flags.temps_computed = false + if item.contaminants then + for _, spatter in ipairs(item.contaminants) do + extinguishContaminant(spatter) + end + end end - end end function extinguishUnit(unit) --- based on full-heal.lua - local burning = false - for _, status in ipairs(unit.body.components.body_part_status) do - if not burning then - if status.on_fire then - burning = true - status.on_fire = false - end - else - status.on_fire = false - end - end - if burning then - for i = #unit.status2.body_part_temperature-1,0,-1 do - unit.status2.body_part_temperature:erase(i) + local burning = false + for _, status in ipairs(unit.body.components.body_part_status) do + if not burning then + if status.on_fire then + burning = true + status.on_fire = false + end + else + status.on_fire = false + end end - unit.flags2.calculated_nerves = false - unit.flags2.calculated_bodyparts = false - unit.flags2.calculated_insulation = false - unit.flags3.body_temp_in_range = false - unit.flags3.compute_health = true - unit.flags3.dangerous_terrain = false - for _,spatter in ipairs(unit.body.spatters) do - extinguishContaminant(spatter) + if burning then + for i = #unit.status2.body_part_temperature - 1, 0, -1 do + unit.status2.body_part_temperature:erase(i) + end + unit.flags2.calculated_nerves = false + unit.flags2.calculated_bodyparts = false + unit.flags2.calculated_insulation = false + unit.flags3.body_temp_in_range = false + unit.flags3.compute_health = true + unit.flags3.dangerous_terrain = false + for _, spatter in ipairs(unit.body.spatters) do + extinguishContaminant(spatter) + end end - end end function extinguishAll() - local fires = df.global.world.fires - for i = #fires-1,0,-1 do - extinguishTile(pos2xyz(fires[i].pos)) - fires:erase(i) - end - local campfires = df.global.world.campfires - for i = #campfires-1,0,-1 do - extinguishTile(pos2xyz(campfires[i].pos)) - campfires:erase(i) - end - for _,plant in ipairs(df.global.world.plants.all) do - plant.damage_flags.is_burning = false - end - for _,item in ipairs(df.global.world.items.all) do - extinguishItem(item) - end - for _,unit in ipairs(df.global.world.units.active) do - extinguishUnit(unit) - end + local fires = df.global.world.event.fires + for i = #fires - 1, 0, -1 do + extinguishTile(pos2xyz(fires[i].pos)) + fires:erase(i) + end + local campfires = df.global.world.event.campfires + for i = #campfires - 1, 0, -1 do + extinguishTile(pos2xyz(campfires[i].pos)) + campfires:erase(i) + end + for _, item in ipairs(df.global.world.items.other.IN_PLAY) do + extinguishItem(item) + end + for _, unit in ipairs(df.global.world.units.active) do + extinguishUnit(unit) + end end -function extinguishLocation(x,y,z) - local pos = xyz2pos(x,y,z) - local fires = df.global.world.fires - for i = #fires-1,0,-1 do - if same_xyz(pos, fires[i].pos) then - extinguishTile(x,y,z) - fires:erase(i) +function extinguishLocation(x, y, z) + local pos = xyz2pos(x, y, z) + local fires = df.global.world.event.fires + for i = #fires - 1, 0, -1 do + if same_xyz(pos, fires[i].pos) then + extinguishTile(x, y, z) + fires:erase(i) + end + end + local campfires = df.global.world.event.campfires + for i = #campfires - 1, 0, -1 do + if same_xyz(pos, campfires[i].pos) then + extinguishTile(x, y, z) + campfires:erase(i) + end end - end - local campfires = df.global.world.campfires - for i = #campfires-1,0,-1 do - if same_xyz(pos, campfires[i].pos) then - extinguishTile(x,y,z) - campfires:erase(i) + local units = dfhack.units.getUnitsInBox(x, y, z, x, y, z) + for _, unit in ipairs(units) do + extinguishUnit(unit) end - end - local units = dfhack.units.getUnitsInBox(x,y,z,x,y,z) - for _,unit in ipairs(units) do - extinguishUnit(unit) - end - for _,item in ipairs(df.global.world.items.all) do - if same_xyz(pos, item.pos) then - extinguishItem(item) + for _, item in ipairs(df.global.world.items.other.IN_PLAY) do + if same_xyz(pos, xyz2pos(dfhack.items.getPosition(item))) then + extinguishItem(item) + end end - end end -if not dfhack_flags.module then - if args.all then +if dfhack_flags.module then + return +end + +if args.all then extinguishAll() - elseif args.location then - if dfhack.maps.isValidTilePos(args.location[1],args.location[2],args.location[3]) then - extinguishLocation(tonumber(args.location[1]), tonumber(args.location[2]), tonumber(args.location[3])) +else + local unit = dfhack.gui.getSelectedUnit(true) + local item = dfhack.gui.getSelectedItem(true) + local bld = dfhack.gui.getSelectedBuilding(true) + if unit then + extinguishLocation(dfhack.units.getPosition(unit)) + elseif item then + extinguishLocation(dfhack.items.getPosition(item)) + elseif bld then + for y = bld.y1, bld.y2 do + for x = bld.x1, bld.x2 do + extinguishLocation(x, y, bld.z) + end + end else - qerror("Invalid location coordinates!") - end - else - local x,y,z = pos2xyz(df.global.cursor) - if not x then - qerror("Choose a target via the cursor or the -location argument, or specify -all to extinguish everything on the map.") + local pos = guidm.getCursorPos() + if not pos then + qerror("Select a target, place the keyboard cursor, or specify --all to extinguish everything on the map.") + end + extinguishLocation(pos.x, pos.y, pos.z) end - extinguishLocation(x,y,z) - end end diff --git a/feature.lua b/feature.lua index 0e14f41627..c94f27ed6a 100644 --- a/feature.lua +++ b/feature.lua @@ -45,8 +45,8 @@ function list_features() if feat:isChasm() then tags = tags .. ' [chasm]' end - if feat:isLayer() then - tags = tags .. ' [layer]' + if feat:isUnderworld() then + tags = tags .. ' [underworld]' end feat:getName(name) print(('Feature #%i is %s: "%s", type %s%s'):format( diff --git a/fillneeds.lua b/fillneeds.lua index 10cce1ccd9..6f3b001fef 100644 --- a/fillneeds.lua +++ b/fillneeds.lua @@ -1,15 +1,3 @@ --- Use with a unit selected to make them focused and unstressed. ---[====[ - -fillneeds -========= -Use with a unit selected to make them focused and unstressed. - -Alternatively, a unit can be specified by passing ``-unit UNIT_ID`` - -Use ``-all`` to apply to all units on the map. - -]====] local utils = require('utils') local validArgs = utils.invert({'all', 'unit'}) local args = utils.processArgs({...}, validArgs) @@ -26,7 +14,7 @@ function satisfyNeeds(unit) end if args.all then - for _, unit in ipairs(df.global.world.units.all) do + for _, unit in ipairs(dfhack.units.getCitizens()) do satisfyNeeds(unit) end else diff --git a/firestarter.lua b/firestarter.lua index 566560128b..80859afdc2 100644 --- a/firestarter.lua +++ b/firestarter.lua @@ -1,24 +1,18 @@ --Lights things on fire: items, locations, entire inventories even! ---[====[ -firestarter -=========== -Lights things on fire: items, locations, entire inventories even! Use while -viewing an item, unit inventory, or tile to start fires. +local guidm = require('gui.dwarfmode') -]====] if dfhack.gui.getSelectedItem(true) then dfhack.gui.getSelectedItem(true).flags.on_fire = true elseif dfhack.gui.getSelectedUnit(true) then for _, entry in ipairs(dfhack.gui.getSelectedUnit(true).inventory) do entry.item.flags.on_fire = true end -elseif df.global.cursor.x ~= -30000 then - local curpos = xyz2pos(pos2xyz(df.global.cursor)) - df.global.world.fires:insert('#', { +elseif guidm.getCursorPos() then + df.global.world.event.fires:insert('#', { new=df.fire, timer=1000, - pos=curpos, + pos=guidm.getCursorPos(), inner_temp_cur=60000, outer_temp_cur=60000, inner_temp_max=60000, diff --git a/fix/archery-practice.lua b/fix/archery-practice.lua new file mode 100644 index 0000000000..ee14c2e322 --- /dev/null +++ b/fix/archery-practice.lua @@ -0,0 +1,288 @@ +-- Fix quivers and training ammo items to allow archery practice to take place. + +local argparse = require("argparse") +local utils = require('utils') + +local function GetTrainingSquads() + local trainingSquads = {} + for _, squad in ipairs(df.global.world.squads.all) do + if squad.entity_id == df.global.plotinfo.group_id then + if #squad.ammo.ammunition > 0 and squad.activity ~= -1 then + table.insert(trainingSquads, squad) + end + end + end + return trainingSquads +end + +local function isTrainingAmmo(ammoItem, squad) + for _, ammoSpec in ipairs(squad.ammo.ammunition) do + if ammoSpec.flags.use_training then + for _, id in ipairs(ammoSpec.assigned) do + if ammoItem.id == id then return true end + end + end + end + return false +end + +local function GetTrainingAmmo(quiver, squad) + local trainingAmmo = {} + for _, generalRef in ipairs(quiver.general_refs) do + if df.general_ref_contains_itemst:is_instance(generalRef) then + local containedAmmo = generalRef + local ammoItem = containedAmmo and df.item.find(containedAmmo.item_id) + if ammoItem and + df.item_ammost:is_instance(ammoItem) and + isTrainingAmmo(ammoItem, squad) + then + table.insert(trainingAmmo, ammoItem) + end + end + end + return trainingAmmo +end + +--[[ The following functions are commented out because the nature +-- of the bug has changed, requiring a different solution. +-- More info: https://discord.com/channels/793331351645323264/873014631315148840/1423311023254933576 +local function UnassignAmmo(trainingAmmo, itemToKeep, itemsToRemove, squad, unit) + local plotEqAssignedAmmo = df.global.plotinfo.equipment.items_assigned.AMMO + local plotEqUnassignedAmmo = df.global.plotinfo.equipment.items_unassigned.AMMO + local uniforms = { + unit.uniform.uniforms.CLOTHING, + unit.uniform.uniforms.REGULAR, + unit.uniform.uniforms.TRAINING, + unit.uniform.uniforms.TRAINING_RANGED + } + for _, ammoItem in ipairs(trainingAmmo) do + if ammoItem ~= itemToKeep then + local idx + local assignedAmmo + for _, ammoSpec in ipairs(squad.ammo.ammunition) do + if ammoSpec.flags.use_training then + idx = utils.linear_index(ammoSpec.assigned, ammoItem.id) + if idx then + assignedAmmo = ammoSpec.assigned + goto unassignAmmo + end + end + end + ::unassignAmmo:: + if assignedAmmo and idx then + -- Unassign ammo item from squad. + assignedAmmo:erase(idx) + idx = utils.linear_index(squad.ammo.ammo_items, ammoItem.id) + if idx then + -- Remove item/unit pairings. + squad.ammo.ammo_items:erase(idx) + squad.ammo.ammo_units:erase(idx) + end + idx = utils.linear_index(plotEqAssignedAmmo, ammoItem.id) + if idx then + -- Move ammo item from assigned ammo list to unassigned ammo list. + plotEqAssignedAmmo:erase(idx) + plotEqUnassignedAmmo:insert('#', ammoItem.id) + utils.sort_vector(plotEqUnassignedAmmo) + end + end + for _, uniform in ipairs(uniforms) do + -- Remove ammo item from uniform. + idx = utils.linear_index(uniform, ammoItem.id) + if idx then uniform:erase(idx) end + end + if not utils.linear_index(itemsToRemove, ammoItem) then + -- Force drop ammo item to avoid issue recurring if game reassigns the ammo item to squad. + -- unit.uniform.uniform_drop:insert('#', ammoItem.id) + -- Units that choose to haul the surplus ammo items to stockpiles instead of just dropping them + -- on the ground will cancel their archery practice and put away the ammo item they were supposed + -- to train with as well. Force dropping the surplus item with moveToGround circumvents this. + local pos = unit and xyz2pos(dfhack.units.getPosition(unit)) + dfhack.items.moveToGround(ammoItem, pos) + end + end + end + -- Prompt unit to drop item. + -- unit.uniform.pickup_flags.update = true +end + +-- For practicality, item material, quality, and its creator (for masterworks), is ignored +-- for the purpose of combining the limited number of ammo items inside a quiver. +local function ConsolidateAmmo(trainingAmmo, squad, unit) + local itemToKeep + local itemsToRemove = {} + -- Check first if any training ammo item already has a stack size of 25 or higher. + for _, ammoItem in ipairs(trainingAmmo) do + if ammoItem.stack_size >= 25 then + itemToKeep = ammoItem + goto unassignAmmo + end + end + for _, ammoItem in ipairs(trainingAmmo) do + if not itemToKeep then + -- Keep the first item. + itemToKeep = ammoItem + goto nextItem + end + if itemToKeep and ammoItem ~= itemToKeep and itemToKeep.stack_size < 25 then + local combineSize = 25 - itemToKeep.stack_size + if ammoItem.stack_size > combineSize then + itemToKeep.stack_size = itemToKeep.stack_size + combineSize + ammoItem.stack_size = ammoItem.stack_size - combineSize + else + itemToKeep.stack_size = itemToKeep.stack_size + ammoItem.stack_size + itemsToRemove[#itemsToRemove + 1] = ammoItem + end + end + ::nextItem:: + end + ::unassignAmmo:: + -- Unassign surplus ammo items first before removing any from the game. + UnassignAmmo(trainingAmmo, itemToKeep, itemsToRemove, squad, unit) + if #itemsToRemove > 0 then + for _, item in ipairs(itemsToRemove) do + dfhack.items.remove(item) + end + end +end +-- End of commented out functions ]] + +-- Currently, only assignment to squad is practical, as pairing ammo items with +-- units directly has a tendency to cause the game to reshuffle the pairings. +local function AssignAmmoToSquad(newItems, item, squad) + local plotEqAssignedAmmo = df.global.plotinfo.equipment.items_assigned.AMMO + local assignedAmmo + for _, ammoSpec in ipairs(squad.ammo.ammunition) do + if utils.linear_index(ammoSpec.assigned, item.id) then + assignedAmmo = ammoSpec + break + end + end + for _, newItem in ipairs(newItems) do + assignedAmmo.assigned:insert('#', newItem.id) + utils.sort_vector(assignedAmmo.assigned) + plotEqAssignedAmmo:insert('#', newItem.id) + utils.sort_vector(plotEqAssignedAmmo) + end +end + +local function SplitAmmo(item, squad, unit) + local pos = unit and xyz2pos(dfhack.units.getPosition(unit)) + local newItems = {} + repeat + -- Create in quiver first, in case moving to ground fails. + newItem = item:splitStack(5, true) + newItem:categorize(true) + dfhack.items.moveToGround(newItem, pos) + table.insert(newItems, newItem) + until item:getStackSize() <= 5 + AssignAmmoToSquad(newItems, item, squad) +end + +local function RemoveQuiverFromInv(unit, quiver) + local equippedQuiver + for i, v in ipairs (unit.inventory) do + -- Remove quiver only if it's not the last item in the inventory. + if v.item == quiver and i ~= (#unit.inventory - 1) then + equippedQuiver = v + end + end + if equippedQuiver then + local idx = utils.linear_index(unit.inventory, equippedQuiver) + unit.inventory:erase(idx) + return true + end + return false +end + +local function MoveQuiverToEnd(unit, quiver) + local caste = dfhack.units.getCasteRaw(unit) + -- Re-add quiver to inventory regardless of whether there is a valid body part. + local bodyPart = -1 + for i, v in ipairs(caste.body_info.body_parts) do + if v.category == 'BODY_UPPER' then + bodyPart = i + break + end + end + dfhack.items.moveToInventory(quiver, unit, df.inv_item_role_type.Worn, bodyPart) +end + +local function FixTrainingUnits(trainingSquads, options) + local splitAmmoCount = 0 + local fixQuiverCount = 0 + for _, squad in ipairs(trainingSquads) do + for _, position in ipairs(squad.positions) do + if position.occupant ~= -1 then + local unit = df.unit.find(df.historical_figure.find(position.occupant).unit_id) + local quiver = unit and df.item.find(position.equipment.quiver) + if quiver then + local trainingAmmo = GetTrainingAmmo(quiver, squad) + local unitName = unit and dfhack.units.getReadableName(unit) + if #trainingAmmo == 1 then + local item = trainingAmmo[1] + -- Split ammo if it's the only training ammo item and its stack size is 25 or larger. + if item:getStackSize() >= 25 then + if not options.quiet then + print(('Splitting training ammo for %s...'):format(unitName)) + end + SplitAmmo(item, squad, unit) + splitAmmoCount = splitAmmoCount + 1 + end + end + if RemoveQuiverFromInv(unit, quiver) then + if not options.quiet then + print(('Moving quiver to the end for %s...'):format(unitName)) + end + MoveQuiverToEnd(unit, quiver) + fixQuiverCount = fixQuiverCount + 1 + end + end + end + end + end + if not options.quiet then + if splitAmmoCount > 0 then + print(('%d stack(s) of ammo item(s) split into stacks of 5.'):format(splitAmmoCount)) + else + print('No ammo items require splitting.') + end + if fixQuiverCount > 0 then + print(('%d quiver(s) moved to the end of each unit\'s inventory list.'):format(fixQuiverCount)) + else + print('No inventories require sorting.') + end + end +end + +local function ParseCommandLine(args) + local options = { + help = false, + quiet = false + } + local positionals = argparse.processArgsGetopt(args, { + {'h', 'help', handler = function() options.help = true end}, + {'q', 'quiet', handler=function() options.quiet = true end} + }) + return options +end + +local function Main(args) + local options = ParseCommandLine(args) + if args[1] == 'help' or options.help then + print(dfhack.script_help()) + return + end + local trainingSquads = GetTrainingSquads() + if #trainingSquads < 1 then + if not options.quiet then print('No ranged squads are currently training.') end + return + end + FixTrainingUnits(trainingSquads, options) +end + +if not dfhack.isSiteLoaded() and not dfhack.world.isFortressMode() then + qerror('This script requires the game to be in fortress mode.') +end + +Main({...}) diff --git a/fix/civil-war.lua b/fix/civil-war.lua index 80086bc1b1..2f05773071 100644 --- a/fix/civil-war.lua +++ b/fix/civil-war.lua @@ -3,7 +3,7 @@ local civ = df.historical_entity.find(df.global.plotinfo.civ_id) local fixed = false -for _, entity in pairs(civ.relations.diplomacy) do +for _, entity in ipairs(civ.relations.diplomacy.state) do if entity.group_id == civ.id and entity.relation > 0 then entity.relation = 0 fixed = true diff --git a/fix/codex-pages.lua b/fix/codex-pages.lua new file mode 100644 index 0000000000..e66296dfb0 --- /dev/null +++ b/fix/codex-pages.lua @@ -0,0 +1,267 @@ +-- Add pages to written content that have no pages. + +local function isBook(item) + if item and + df.item_bookst:is_instance(item) or + df.item_toolst:is_instance(item) and + (item:getSubtype() == dfhack.items.findSubtype('TOOL:ITEM_TOOL_QUIRE') or + item:getSubtype() == dfhack.items.findSubtype('TOOL:ITEM_TOOL_SCROLL')) and + item:hasWriting() + then + return true + end + return false +end + +local function GetBooks(target) + local books = {} + local item + if target.selected then + local item = dfhack.gui.getSelectedItem(true) + if item and isBook(item) then table.insert(books, item) end + elseif target.site then + local siteTools = df.global.world.items.other.TOOL + for _, item in ipairs(siteTools) do + if isBook(item) then table.insert(books, item) end + end + local siteBooks = df.global.world.items.other.BOOK + for _, item in ipairs(siteBooks) do + if isBook(item) then table.insert(books, item) end + end + end + return books +end + +local function GetWrittenContent(book) + for _, improvement in ipairs(book.improvements) do + if df.itemimprovement_pagesst:is_instance(improvement) or + df.itemimprovement_writingst:is_instance(improvement) + then + for _, content in ipairs(improvement.contents) do + return df.written_content.find(content) + end + end + end + return nil +end + +local function GetPageCount(targetWcType) + -- These values are based on polling page counts from various saves and may not be accurate. + local types = { + ['NONE'] = {upperCount = 1, lowerCount = 1, mode = 1}, + ['Manual'] = {upperCount = 250, lowerCount = 20, mode = 80}, + ['Guide'] = {upperCount = 250, lowerCount = 20, mode = 100}, + ['Chronicle'] = {upperCount = 450, lowerCount = 100, mode = nil}, + ['ShortStory'] = {upperCount = 50, lowerCount = 10, mode = nil}, + ['Novel'] = {upperCount = 450, lowerCount = 100, mode = 200}, + ['Biography'] = {upperCount = 400, lowerCount = 100, mode = 250}, + ['Autobiography'] = {upperCount = 450, lowerCount = 100, mode = 250}, + ['Poem'] = {upperCount = 10, lowerCount = 1, mode = 1}, + ['Play'] = {upperCount = 50, lowerCount = 20, mode = 30}, + ['Letter'] = {upperCount = 10, lowerCount = 1, mode = nil}, + ['Essay'] = {upperCount = 50, lowerCount = 10, mode = nil}, + ['Dialog'] = {upperCount = 30, lowerCount = 5, mode = nil}, + ['MusicalComposition'] = {upperCount = 20, lowerCount = 1, mode = 1}, + ['Choreography'] = {upperCount = 1, lowerCount = 1, mode = 1}, + ['ComparativeBiography'] = {upperCount = 300, lowerCount = 150, mode = nil}, + ['BiographicalDictionary'] = { + upperCount = math.max(300, math.min(500, math.ceil(df.global.hist_figure_next_id / 1000))), + lowerCount = math.max(100, math.min(150, math.floor(df.global.hist_figure_next_id / 10000))), + mode = nil}, -- Very few samples were available, so this one is mostly arbitrary. + ['Genealogy'] = {upperCount = 5, lowerCount = 1, mode = 4}, + ['Encyclopedia'] = {upperCount = 150, lowerCount = 50, mode = nil}, + ['CulturalHistory'] = {upperCount = 450, lowerCount = 100, mode = 200}, + ['CulturalComparison'] = {upperCount = 400, lowerCount = 100, mode = 200}, + ['AlternateHistory'] = {upperCount = 250, lowerCount = 100, mode = 150}, + ['TreatiseOnTechnologicalEvolution'] = {upperCount = 300, lowerCount = 100, mode = nil}, + ['Dictionary'] = {upperCount = 450, lowerCount = 100, mode = 250}, + ['StarChart'] = {upperCount = 1, lowerCount = 1, mode = 1}, + ['StarCatalogue'] = {upperCount = 150, lowerCount = 10, mode = 100}, + ['Atlas'] = {upperCount = 30, lowerCount = 10, mode = 25}, + } + local upperCount, lowerCount = 1, 1 + local mode + for wcType, tab in pairs(types) do + if df.written_content_type[wcType] == targetWcType then + upperCount = tab.upperCount + lowerCount = tab.lowerCount + mode = tab.mode + end + end + return upperCount, lowerCount, mode +end + +local function GetPageCountModifier(targetStyle, targetStrength) + -- These values are arbitrary and may not even have any effect on page count in vanilla DF. + local styles = { + ['NONE'] = 0, + ['Meandering'] = 0.5, + ['Cheerful'] = 0, + ['Depressing'] = 0.1, + ['Rigid'] = 0, + ['Serious'] = 0, + ['Disjointed'] = 0.2, + ['Ornate'] = 0.2, + ['Forceful'] = 0, + ['Humorous'] = 0, + ['Immature'] = 0.3, + ['SelfIndulgent'] = 0.5, + ['Touching'] = 0, + ['Compassionate'] = 0, + ['Vicious'] = 0, + ['Concise'] = -0.2, + ['Scornful'] = 0, + ['Witty'] = 0, + ['Ranting'] = 1, + } + local strength = { + ['NONE'] = 1, + ['Thorough'] = 1.5, + ['Somewhat'] = 1, + ['Hint'] = 0.5, + } + local pageCountModifier = 0 + for style, modifier in pairs(styles) do + if df.written_content_style[style] == targetStyle then + pageCountModifier = modifier + break + end + end + for strength, addModifier in pairs(strength) do + if df.writing_style_modifier_type[strength] == targetStrength then + if pageCountModifier ~= 0 then + pageCountModifier = pageCountModifier * addModifier + break + end + end + end + return pageCountModifier +end + +local rng = dfhack.random.new(nil, 10) +local seed = dfhack.world.ReadCurrentTick() + +local function SetPageCount(upperCount, lowerCount, mode) + if upperCount > 1 then + local range = upperCount - lowerCount + local increment = 1 + math.floor(range ^ 2) + local weightedTable = {} + local weight = 0 + for i = lowerCount, upperCount, 1 do + weight = weight + increment - math.floor(math.abs(i - mode) ^ 2) + if i == mode and mode == 1 then + -- Set heavy bias for very short written forms with mostly 1 page long works. + weight = weight + increment ^ 2 + end + table.insert(weightedTable, weight) + end + local limit = weight + rng:init(seed, 10) + local result = rng:random(limit) + for i, weight in ipairs(weightedTable) do + if result <= weight then + return i + lowerCount - 1 + end + end + end + return 1 +end + +local function AddPages(wc) + local pages = 0 + if wc.page_start == -1 and wc.page_end == -1 then + local wcType = wc.type + local upperCount, lowerCount, mode = GetPageCount(wcType) + if upperCount and lowerCount then + local modifier = 1 + for i, style in ipairs(wc.styles) do + if wc.style_strength[i] then + modifier = modifier + GetPageCountModifier(style, wc.style_strength[i]) + end + end + upperCount = math.max(1, math.ceil(upperCount * modifier)) + lowerCount = math.max(1, math.floor(lowerCount * modifier)) + if mode and mode ~= 1 then + mode = math.max(1, math.floor(mode * modifier)) + end + else + upperCount, lowerCount = 1, 1 + end + mode = mode or math.ceil((lowerCount + upperCount) / 2) + wc.page_start = 1 + wc.page_end = SetPageCount(upperCount, lowerCount, mode) + pages = wc.page_end + end + return pages +end + +local function FixPageCount(target) + local writtenContents = {} + if not target.all then + local books = GetBooks(target) + if #books == 0 then + if target.selected then + print('No book with written content selected.') + elseif target.site then + print('No books available in site.') + end + return + end + for _, book in ipairs(books) do + table.insert(writtenContents, GetWrittenContent(book)) + end + else + writtenContents = df.global.world.written_contents.all + end + local booksModified = 0 + local pagesAdded = 0 + for _, wc in ipairs(writtenContents) do + local pages = 0 + pages = AddPages(wc) + if pages > 0 then + local title + if wc.title == '' then + title = 'an untitled work' + else + title = ('"%s"'):format(wc.title) + end + print(('%d pages added to %s.'):format(pages, title)) + pagesAdded = pagesAdded + pages + seed = seed + pages + booksModified = booksModified + 1 + end + end + if booksModified > 0 then + local plural = '' + if booksModified > 1 then plural = 's' end + print(('\nA total of %d pages were added to %d book%s.'):format(pagesAdded, booksModified, plural)) + elseif target.selected then + print('Selected book already has pages in it.') + else + print('No written content with unspecified page counts were found; no pages were added to any books.') + end +end + +local function Main(args) + local target = { + selected = false, + site = false, + all = false, + } + if #args > 0 then + if args[1] == 'help' then + print(dfhack.script_help()) + return + end + if args[1] == 'this' then target.selected = true end + if args[1] == 'site' then target.site = true end + if args[1] == 'all' then target.all = true end + FixPageCount(target) + end +end + +if not dfhack.isSiteLoaded() and not dfhack.world.isFortressMode() then + qerror('This script requires the game to be in fortress mode.') +end + +Main({...}) diff --git a/fix/corrupt-equipment.lua b/fix/corrupt-equipment.lua index e509b8d9ec..a11723ee06 100644 --- a/fix/corrupt-equipment.lua +++ b/fix/corrupt-equipment.lua @@ -85,7 +85,7 @@ function fix_equipment () for i, squad in ipairs (df.global.world.squads.all) do if squad.entity_id == df.global.plotinfo.group_id then - local squad_name = dfhack.TranslateName(squad.name, true) + local squad_name = dfhack.translation.translateName(squad.name, true) if squad.alias ~= "" then squad_name = squad.alias end diff --git a/fix/corrupt-jobs.lua b/fix/corrupt-jobs.lua index de6eed2e7b..556e067aee 100644 --- a/fix/corrupt-jobs.lua +++ b/fix/corrupt-jobs.lua @@ -6,7 +6,7 @@ local GLOBAL_KEY = 'corrupt-jobs' function remove_bad_jobs() local count = 0 - for _, unit in ipairs(df.global.world.units.all) do + for _, unit in ipairs(df.global.world.units.active) do if unit.job.current_job and unit.job.current_job.id == -1 then unit.job.current_job = nil count = count + 1 diff --git a/fix/dead-units.lua b/fix/dead-units.lua index ab794b4589..16cf6d6f39 100644 --- a/fix/dead-units.lua +++ b/fix/dead-units.lua @@ -46,13 +46,17 @@ end local function scrub_burrows() for _, burrow in ipairs(df.global.plotinfo.burrows.list) do + local units_to_remove = {} for _, unit_id in ipairs(burrow.units) do local unit = df.unit.find(unit_id) if unit and dfhack.units.isDead(unit) then - count = count + 1 - dfhack.burrows.setAssignedUnit(burrow, unit, false) + table.insert(units_to_remove, unit) end end + count = count + #units_to_remove + for _, unit in ipairs(units_to_remove) do + dfhack.burrows.setAssignedUnit(burrow, unit, false) + end end end diff --git a/fix/drop-webs.lua b/fix/drop-webs.lua index c0f506e3c6..362d88e5c6 100644 --- a/fix/drop-webs.lua +++ b/fix/drop-webs.lua @@ -32,7 +32,7 @@ function drop_webs(all_webs) proj.flags.bouncing = true proj.flags.piercing = true proj.flags.parabolic = true - proj.flags.unk9 = true + proj.flags.no_adv_pause = true proj.flags.no_collide = true count = count+1 end diff --git a/fix/dry-buckets.lua b/fix/dry-buckets.lua index 1b463b6499..24a62a90f0 100644 --- a/fix/dry-buckets.lua +++ b/fix/dry-buckets.lua @@ -1,26 +1,49 @@ +local argparse = require("argparse") - -local emptied = 0 -local in_building = 0 local water_type = dfhack.matinfo.find('WATER').type -for _,item in ipairs(df.global.world.items.all) do - local container = dfhack.items.getContainer(item) - if container ~= nil - and container:getType() == df.item_type.BUCKET - and not (container.flags.in_job) - and item:getMaterial() == water_type - and item:getType() == df.item_type.LIQUID_MISC - and not (item.flags.in_job) then - if container.flags.in_building or item.flags.in_building then - in_building = in_building + 1 +local quiet = false +argparse.processArgsGetopt({...}, { + {'q', 'quiet', handler=function() quiet = true end}, +}) + +local emptied = 0 +local in_building = 0 +for _,item in ipairs(df.global.world.items.other.BUCKET) do + if item.flags.in_job then goto continue end + local well = dfhack.items.getHolderBuilding(item) + if well and well:getType() == df.building_type.Well and well.well_tag.whole ~= 0 then + -- bucket is in a well and the well is actively being used + goto continue + end + local emptied_bucket = false + local freed_in_building = false + for _,contained_item in ipairs(dfhack.items.getContainedItems(item)) do + if not contained_item.flags.in_job and + contained_item:getMaterial() == water_type and + contained_item:getType() == df.item_type.LIQUID_MISC + then + if item.flags.in_building or contained_item.flags.in_building then + freed_in_building = true + end + -- ok to remove item while iterating since we're iterating through copy of the vector + dfhack.items.remove(contained_item) + emptied_bucket = true end - dfhack.items.remove(item) + end + if emptied_bucket then emptied = emptied + 1 + df.global.plotinfo.flags.recheck_aid_requests = true end + if freed_in_building then + in_building = in_building + 1 + end + ::continue:: end -print('Emptied '..emptied..' buckets.') -if emptied > 0 then - print(('Unclogged %d wells.'):format(in_building)) +if not quiet then + print(('Emptied %d buckets.'):format(emptied)) + if in_building > 0 then + print(('Unclogged %d wells.'):format(in_building)) + end end diff --git a/fix/empty-wheelbarrows.lua b/fix/empty-wheelbarrows.lua index b3c0234832..00c00a9bfe 100644 --- a/fix/empty-wheelbarrows.lua +++ b/fix/empty-wheelbarrows.lua @@ -1,15 +1,12 @@ ---checks all wheelbarrows on map for rocks stuck in them. If a wheelbarrow isn't in use for a job (hauling) then there should be no rocks in them ---rocks will occasionally get stuck in wheelbarrows, and accumulate if the wheelbarrow gets used. ---this script empties all wheelbarrows which have rocks stuck in them. +-- checks all wheelbarrows on map for rocks stuck in them and empties such rocks onto the ground. If a wheelbarrow +-- isn't in use for a job (hauling) then there should be no rocks in them. local argparse = require("argparse") -local args = {...} - local quiet = false local dryrun = false -local cmds = argparse.processArgsGetopt(args, { +argparse.processArgsGetopt({...}, { {'q', 'quiet', handler=function() quiet = true end}, {'d', 'dry-run', handler=function() dryrun = true end}, }) @@ -17,24 +14,31 @@ local cmds = argparse.processArgsGetopt(args, { local i_count = 0 local e_count = 0 -local function emptyContainedItems(e, outputCallback) - local items = dfhack.items.getContainedItems(e) - if #items > 0 then - outputCallback('Emptying wheelbarrow: ' .. dfhack.items.getDescription(e, 0)) - e_count = e_count + 1 - for _,i in ipairs(items) do - outputCallback(' ' .. dfhack.items.getDescription(i, 0)) - if (not dryrun) then dfhack.items.moveToGround(i, e.pos) end - i_count = i_count + 1 +local function emptyContainedItems(wheelbarrow, outputCallback) + local items = dfhack.items.getContainedItems(wheelbarrow) + if #items == 0 then return end + outputCallback('Emptying wheelbarrow: ' .. dfhack.items.getReadableDescription(wheelbarrow)) + e_count = e_count + 1 + for _,item in ipairs(items) do + outputCallback(' ' .. dfhack.items.getReadableDescription(item)) + if not dryrun then + if item.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 + end + dfhack.items.moveToGround(item, wheelbarrow.pos) end + i_count = i_count + 1 end end local function emptyWheelbarrows(outputCallback) - for _,e in ipairs(df.global.world.items.other.TOOL) do + for _,item in ipairs(df.global.world.items.other.TOOL) do -- wheelbarrow must be on ground and not in a job - if ((not e.flags.in_job) and e.flags.on_ground and e:isWheelbarrow()) then - emptyContainedItems(e, outputCallback) + if ((not item.flags.in_job) and item.flags.on_ground and item:isWheelbarrow()) then + emptyContainedItems(item, outputCallback) end end end @@ -44,6 +48,7 @@ if (quiet) then output = (function(...) end) else output = print end emptyWheelbarrows(output) -if (i_count > 0 or (not quiet)) then - print(("fix/empty-wheelbarrows - removed %d items from %d wheelbarrows."):format(i_count, e_count)) +if i_count > 0 or not quiet then + local action = dryrun and 'would remove' or 'removed' + print(("fix/empty-wheelbarrows - %s %d item(s) from %d wheelbarrow(s)."):format(action, i_count, e_count)) end diff --git a/fix/engravings.lua b/fix/engravings.lua new file mode 100644 index 0000000000..7e93bd4461 --- /dev/null +++ b/fix/engravings.lua @@ -0,0 +1,47 @@ +local argparse = require('argparse') + +--function checks tiletype for attributes and returns true or false depending on if its engraving location is correct +local function is_good_engraving(engraving) + local ttype = dfhack.maps.getTileType(engraving.pos) + if not ttype then return end + local tileattrs = df.tiletype.attrs[ttype] + + if tileattrs.special ~= df.tiletype_special.SMOOTH then + return false + end + + if tileattrs.shape == df.tiletype_shape.FLOOR then + return engraving.flags.floor + end + + if tileattrs.shape == df.tiletype_shape.WALL then + return not engraving.flags.floor + end +end + +local help = false +local quiet = false +local positionals = argparse.processArgsGetopt({...}, { + {'h', 'help', handler=function() help = true end}, + {'q', 'quiet', handler=function() quiet = true end}, +}) + +if help or positionals[1] == 'help' then + print(dfhack.script_help()) + return +end + +--loop runs through list of all engravings checking each using is_good_engraving and if bad gets deleted +local cleanup = 0 +local engravings = df.global.world.event.engravings +for index = #engravings-1,0,-1 do + local engraving = engravings[index] + if not is_good_engraving(engraving) then + engravings:erase(index) + engraving:delete() + cleanup = cleanup + 1 + end +end +if not quiet or cleanup > 0 then + print(('%d bad engraving(s) fixed.'):format(cleanup)) +end diff --git a/fix/general-strike.lua b/fix/general-strike.lua index 5081251c2d..8a2be3b603 100644 --- a/fix/general-strike.lua +++ b/fix/general-strike.lua @@ -12,7 +12,7 @@ local function fix_seeds(quiet) v.flags.in_building = true for _,i in ipairs(bld.contained_items) do if i.item.id == v.id then - i.use_mode = 2 + i.use_mode = df.building_item_role_type.PERM end end count = count + 1 diff --git a/fix/item-occupancy.lua b/fix/item-occupancy.lua deleted file mode 100644 index b066e4d879..0000000000 --- a/fix/item-occupancy.lua +++ /dev/null @@ -1,131 +0,0 @@ --- Verify item occupancy and block item list integrity. - ---[====[ - -fix/item-occupancy -================== -Diagnoses and fixes issues with nonexistant 'items occupying site', usually -caused by `autodump` bugs or other hacking mishaps. Checks that: - -#. Item has ``flags.on_ground`` <=> it is in the correct block item list -#. A tile has items in block item list <=> it has ``occupancy.item`` -#. The block item lists are sorted - -]====] -local utils = require 'utils' - -function check_block_items(fix) - local cnt = 0 - local icnt = 0 - local found = {} - local found_somewhere = {} - - local should_fix = false - local can_fix = true - - for _,block in ipairs(df.global.world.map.map_blocks) do - local itable = {} - local bx,by,bz = pos2xyz(block.map_pos) - - -- Scan the block item vector - local last_id = nil - local resort = false - - for _,id in ipairs(block.items) do - local item = df.item.find(id) - local ix,iy,iz = pos2xyz(item.pos) - local dx,dy,dz = ix-bx,iy-by,iz-bz - - -- Check sorted order - if last_id and last_id >= id then - print(bx,by,bz,last_id,id,'block items not sorted') - resort = true - else - last_id = id - end - - -- Check valid coordinates and flags - if not item.flags.on_ground then - print(bx,by,bz,id,dx,dy,'in block & not on ground') - elseif dx < 0 or dx >= 16 or dy < 0 or dy >= 16 or dz ~= 0 then - found_somewhere[id] = true - print(bx,by,bz,id,dx,dy,dz,'invalid pos') - can_fix = false - else - found[id] = true - itable[dx + dy*16] = true; - - -- Check missing occupancy - if not block.occupancy[dx][dy].item then - print(bx,by,bz,dx,dy,'item & not occupied') - if fix then - block.occupancy[dx][dy].item = true - else - should_fix = true - end - end - end - end - - -- Sort the vector if needed - if resort then - if fix then - utils.sort_vector(block.items) - else - should_fix = true - end - end - - icnt = icnt + #block.items - - -- Scan occupancy for spurious marks - for x=0,15 do - local ocx = block.occupancy[x] - for y=0,15 do - if ocx[y].item and not itable[x + y*16] then - print(bx,by,bz,x,y,'occupied & no item') - if fix then - ocx[y].item = false - else - should_fix = true - end - end - end - end - - cnt = cnt + 256 - end - - -- Check if any items are missing from blocks - for _,item in ipairs(df.global.world.items.all) do - if item.flags.on_ground and not found[item.id] then - can_fix = false - if not found_somewhere[item.id] then - print(item.id,item.pos.x,item.pos.y,item.pos.z,'on ground & not in block') - end - end - end - - -- Report - print(cnt.." tiles and "..icnt.." items checked.") - - if should_fix and can_fix then - print("Use 'fix/item-occupancy --fix' to fix the listed problems.") - elseif should_fix then - print("The problems are too severe to be fixed by this script.") - end -end - -local opt = ... -local fix = false - -if opt then - if opt == '--fix' then - fix = true - else - qerror('Invalid option: '..opt) - end -end - -print("Checking item occupancy - this will take a few seconds.") -check_block_items(fix) diff --git a/fix/loyaltycascade.lua b/fix/loyaltycascade.lua index a943a5aa6d..3409750cb4 100644 --- a/fix/loyaltycascade.lua +++ b/fix/loyaltycascade.lua @@ -1,9 +1,12 @@ --- Prevents a "loyalty cascade" (civil war) when a citizen is killed. +-- Prevents a "loyalty cascade" (intra-fort civil war) when a citizen is killed. +-- Also breaks up brawls and other conflicts. + +local makeown = reqscript('makeown') -- Checks if a unit is a former member of a given entity as well as it's -- current enemy. local function getUnitRenegade(unit, entity_id) - local unit_entity_links = df.global.world.history.figures[unit.hist_figure_id].entity_links + local unit_entity_links = df.historical_figure.find(unit.hist_figure_id).entity_links local former_index = nil local enemy_index = nil @@ -14,9 +17,9 @@ local function getUnitRenegade(unit, entity_id) goto skipentity end - if link_type == df.histfig_entity_link_type.FORMER_MEMBER then + if link_type == df.histfig_entity_link_type.FORMER_MEMBER then former_index = index - elseif link_type == df.histfig_entity_link_type.ENEMY then + elseif link_type == df.histfig_entity_link_type.ENEMY then enemy_index = index end @@ -27,7 +30,7 @@ local function getUnitRenegade(unit, entity_id) end local function convertUnit(unit, entity_id, former_index, enemy_index) - local unit_entity_links = df.global.world.history.figures[unit.hist_figure_id].entity_links + local unit_entity_links = df.historical_figure.find(unit.hist_figure_id).entity_links unit_entity_links:erase(math.max(former_index, enemy_index)) unit_entity_links:erase(math.min(former_index, enemy_index)) @@ -42,70 +45,50 @@ end local function fixUnit(unit) local fixed = false - if not dfhack.units.isOwnCiv(unit) or not dfhack.units.isDwarf(unit) then - return fixed - end - - local unit_name = dfhack.TranslateName(dfhack.units.getVisibleName(unit)) + local unit_name = dfhack.units.getReadableName(unit) local former_civ_index, enemy_civ_index = getUnitRenegade(unit, df.global.plotinfo.civ_id) local former_group_index, enemy_group_index = getUnitRenegade(unit, df.global.plotinfo.group_id) -- If the unit is a former member of your civilization, as well as now an -- enemy of it, we make it become a member again. if former_civ_index and enemy_civ_index then - local civ_name = dfhack.TranslateName(df.global.world.entities.all[df.global.plotinfo.civ_id].name) + local civ_name = dfhack.translation.translateName(df.historical_entity.find(df.global.plotinfo.civ_id).name) convertUnit(unit, df.global.plotinfo.civ_id, former_civ_index, enemy_civ_index) - dfhack.gui.showAnnouncement(([[loyaltycascade: %s is now a member of %s again]]):format(unit_name, civ_name), COLOR_WHITE) + dfhack.gui.showAnnouncement( + ('loyaltycascade: %s is now a happy member of %s again'):format(unit_name, civ_name), COLOR_WHITE) fixed = true end if former_group_index and enemy_group_index then - local group_name = dfhack.TranslateName(df.global.world.entities.all[df.global.plotinfo.group_id].name) + local group_name = dfhack.translation.translateName(df.historical_entity.find(df.global.plotinfo.group_id).name) convertUnit(unit, df.global.plotinfo.group_id, former_group_index, enemy_group_index) - dfhack.gui.showAnnouncement(([[loyaltycascade: %s is now a member of %s again]]):format(unit_name, group_name), COLOR_WHITE) + dfhack.gui.showAnnouncement( + ('loyaltycascade: %s is now a happy member of %s again'):format(unit_name, group_name), COLOR_WHITE) fixed = true end - if fixed and unit.enemy.enemy_status_slot ~= -1 then - local status_cache = unit.enemy.enemy_status_cache - local status_slot = unit.enemy.enemy_status_slot - - unit.enemy.enemy_status_slot = -1 - status_cache.slot_used[status_slot] = false - - for index, _ in pairs(status_cache.rel_map[status_slot]) do - status_cache.rel_map[status_slot][index] = -1 - end - - for index, _ in pairs(status_cache.rel_map) do - status_cache.rel_map[index] = -1 - end - - -- TODO: what if there were status slots taken above status_slot? - -- does everything need to be moved down by one? - if cache.next_slot > status_slot then - cache.next_slot = status_slot - end + if fixed then + makeown.clear_enemy_status(unit) end - return false + return makeown.remove_from_conflict(unit) or fixed end local count = 0 -for _, unit in pairs(df.global.world.units.all) do - if dfhack.units.isCitizen(unit) and fixUnit(unit) then +for _, unit in pairs(dfhack.units.getCitizens()) do + if fixUnit(unit) then count = count + 1 end end if count > 0 then - print(([[Fixed %s units from a loyalty cascade.]]):format(count)) + print(('Fixed %s units with loyalty issues.'):format(count)) else - print("No loyalty cascade found.") + print('No loyalty cascade found.') end diff --git a/fix/noexert-exhaustion.lua b/fix/noexert-exhaustion.lua new file mode 100644 index 0000000000..eabfd965ef --- /dev/null +++ b/fix/noexert-exhaustion.lua @@ -0,0 +1,27 @@ +--checks all units for the NOEXERT tag, and sets their Exhaustion counter to zero +--NOEXERT units (including Vampires, Necromancers, and Intelligent Undead) are unable to lower their Exhaustion, and are not supposed to gain Exhaustion. +--at least one activity (Individual Combat Drill) doesn't respect NOEXERT, and will leave NOEXERT units permanently Tired. This script will fix 'Tired' NOEXERT units. +--Individual Combat Drill seems to add 50 Exhaustion approximately every 9 ticks. 'Tired' appears at 2000 Exhaustion, and dwarves switch to Individual Combat Drill/Resting at 3000 Exhaustion. +--Running this script on repeat approximately at least every 350 ticks should prevent NOEXERT units from becoming Tired as a result of Individual Combat Drill. + +function isNoExert(u) + if(u.uwss_remove_caste_flag.NOEXERT) then --tag removal overrides tag addition, so if the NOEXERT tag is removed the unit cannot be NOEXERT. + return false + end + if(u.uwss_add_caste_flag.NOEXERT) then--if the tag hasn't been removed, and the unit has a curse that adds it, they must be NOEXERT. + return true + end + if(dfhack.units.casteFlagSet(u.race,u.caste, df.caste_raw_flags.NOEXERT)) then --if the tag hasn't been added or removed, but their race and caste has the tag, they're NOEXERT. + return true + end +end + +function fixNoExertExhaustion() + for _, unit in ipairs(dfhack.units.getCitizens()) do + if(isNoExert(unit)) then + unit.counters2.exhaustion = 0 -- 0 represents no Exhaustion. NOEXERT units should never have Exhaustion above 0. + end + end +end + +fixNoExertExhaustion() diff --git a/fix/occupancy.lua b/fix/occupancy.lua new file mode 100644 index 0000000000..882f38548c --- /dev/null +++ b/fix/occupancy.lua @@ -0,0 +1,22 @@ +local argparse = require('argparse') +local plugin = require('plugins.fix-occupancy') + +local opts = { + dry_run=false, +} + +local positionals = argparse.processArgsGetopt({...}, { + { 'h', 'help', handler = function() opts.help = true end }, + { 'n', 'dry-run', handler = function() opts.dry_run = true end }, +}) + +if positionals[1] == 'help' or opts.help then + print(dfhack.script_help()) + return +end + +if not positionals[1] then + plugin.fix_map(opts.dry_run) +else + plugin.fix_tile(argparse.coords(positionals[1], 'pos'), opts.dry_run) +end diff --git a/fix/ownership.lua b/fix/ownership.lua new file mode 100644 index 0000000000..b3efd612bf --- /dev/null +++ b/fix/ownership.lua @@ -0,0 +1,57 @@ +local utils = require('utils') + +-- unit thinks they own the item but the item doesn't hold the proper +-- ref that actually makes this true +local function clean_item_ownership() + for _,unit in ipairs(dfhack.units.getCitizens()) do + for index = #unit.owned_items-1, 0, -1 do + local item_id = unit.owned_items[index] + local item = df.item.find(item_id) + if item then + for _, ref in ipairs(item.general_refs) do + if df.general_ref_unit_itemownerst:is_instance(ref) then + -- make sure the ref belongs to unit + if ref.unit_id == unit.id then goto continue end + end + end + end + print(('fix/ownership: Erasing invalid claim on item #%d for %s'):format( + item_id, dfhack.df2console(dfhack.units.getReadableName(unit)))) + unit.owned_items:erase(index) + ::continue:: + end + end +end + +local other = df.global.world.buildings.other +local zone_vecs = { + other.ZONE_BEDROOM, + other.ZONE_OFFICE, + other.ZONE_DINING_HALL, + other.ZONE_TOMB, +} +local function relink_zones() + for _,zones in ipairs(zone_vecs) do + for _,zone in ipairs(zones) do + local unit = dfhack.buildings.getOwner(zone) + if not unit then goto continue end + if not utils.linear_index(unit.owned_buildings, zone.id, 'id') then + print(('fix/ownership: Restoring %s ownership link for %s'):format( + df.civzone_type[zone:getSubtype()], dfhack.df2console(dfhack.units.getReadableName(unit)))) + dfhack.buildings.setOwner(zone, nil) + dfhack.buildings.setOwner(zone, unit) + end + ::continue:: + end + end +end + +local args = {...} + +if args[1] == "help" then + print(dfhack.script_help()) + return +end + +clean_item_ownership() +relink_zones() diff --git a/fix/population-cap.lua b/fix/population-cap.lua index bf910bac1e..afa5ead765 100644 --- a/fix/population-cap.lua +++ b/fix/population-cap.lua @@ -1,47 +1,32 @@ --- Tells mountainhomes your pop. to avoid overshoot +local plotinfo = df.global.plotinfo +local tasks = plotinfo.tasks +local knowledge = tasks.knowledge ---[====[ +local civ = df.historical_entity.find(plotinfo.civ_id) -fix/population-cap -================== -Run this after every migrant wave to ensure your population cap is not exceeded. - -The reason for population cap problems is that the population value it -is compared against comes from the last dwarven caravan that successfully -left for mountainhomes. This script instantly updates it. -Note that a migration wave can still overshoot the limit by 1-2 dwarves because -of the last migrant bringing his family. Likewise, king arrival ignores cap. - -]====] -local args = {...} - -local ui = df.global.plotinfo -local ui_stats = ui.tasks -local civ = df.historical_entity.find(ui.civ_id) - -if not civ then +if not dfhack.isMapLoaded() or not dfhack.world.isFortressMode() or not civ then qerror('No active fortress.') end local civ_stats = civ.activity_stats -if not civ_stats then - if args[1] ~= 'force' then - qerror('No caravan report object; use "fix/population-cap force" to create one') +local function upsize(civ_vec, tasks_vec) + local tasks_vec_size = #tasks_vec + if #civ_vec < tasks_vec_size then + civ_vec:resize(tasks_vec_size) end - print('Creating an empty statistics structure...') - civ.activity_stats = { - new = true, - created_weapons = { resize = #ui_stats.created_weapons }, - discovered_creature_foods = { resize = #ui_stats.discovered_creature_foods }, - discovered_creatures = { resize = #ui_stats.discovered_creatures }, - discovered_plant_foods = { resize = #ui_stats.discovered_plant_foods }, - discovered_plants = { resize = #ui_stats.discovered_plants }, - } - civ_stats = civ.activity_stats end +upsize(civ_stats.created_weapons, tasks.created_weapons) +upsize(civ_stats.knowledge.discovered_creature_foods, knowledge.discovered_creature_foods) +upsize(civ_stats.knowledge.discovered_creatures, knowledge.discovered_creatures) +upsize(civ_stats.knowledge.discovered_plant_foods, knowledge.discovered_plant_foods) +upsize(civ_stats.knowledge.discovered_plants, knowledge.discovered_plants) + -- Use max to keep at least some of the original caravan communication idea -civ_stats.population = math.max(civ_stats.population, ui_stats.population) +local new_pop = math.max(civ_stats.population, tasks.population) -print('Home civ notified about current population.') +if civ_stats.population ~= new_pop then + civ_stats.population = new_pop + print('Home civ notified about current population.') +end diff --git a/fix/protect-nicks.lua b/fix/protect-nicks.lua index f31b539b44..e5f89168ce 100644 --- a/fix/protect-nicks.lua +++ b/fix/protect-nicks.lua @@ -3,9 +3,6 @@ --@ enable = true --@ module = true -local json = require('json') -local persist = require('persist-table') - local GLOBAL_KEY = 'fix-protect-nicks' enabled = enabled or false @@ -15,12 +12,12 @@ function isEnabled() end local function persist_state() - persist.GlobalTable[GLOBAL_KEY] = json.encode({enabled=enabled}) + dfhack.persistent.saveSiteData(GLOBAL_KEY, {enabled=enabled}) end -- Reassign all the units nicknames with "dfhack.units.setNickname" local function save_nicks() - for _,unit in pairs(df.global.world.units.active) do + for _,unit in ipairs(df.global.world.units.active) do dfhack.units.setNickname(unit, unit.name.nickname) end end @@ -42,8 +39,8 @@ dfhack.onStateChange[GLOBAL_KEY] = function(sc) return end - local persisted_data = json.decode(persist.GlobalTable[GLOBAL_KEY] or '') - enabled = (persisted_data or {enabled=false})['enabled'] + local persisted_data = dfhack.persistent.getSiteData(GLOBAL_KEY, {enabled=false}) + enabled = persisted_data.enabled event_loop() end diff --git a/fix/retrieve-units.lua b/fix/retrieve-units.lua index e851fdc0a1..ae9fc88abc 100644 --- a/fix/retrieve-units.lua +++ b/fix/retrieve-units.lua @@ -17,19 +17,17 @@ function shouldRetrieve(unit) end function retrieveUnits() - for _, unit in pairs(df.global.world.units.all) do + for _, unit in ipairs(df.global.world.units.all) do if unit.flags1.inactive and shouldRetrieve(unit) then - print(("Retrieving from the abyss: %s (%s)"):format( - dfhack.df2console(dfhack.TranslateName(dfhack.units.getVisibleName(unit))), - df.creature_raw.find(unit.race).name[0] - )) + print(("Retrieving from the abyss: %s (%d)"):format( + dfhack.df2console(dfhack.units.getReadableName(unit)), unit.id)) unit.flags1.move_state = true unit.flags1.inactive = false unit.flags1.incoming = false unit.flags1.can_swap = true unit.flags1.hidden_in_ambush = false -- add to active if missing - if not utils.linear_index(df.global.world.units.active, unit, 'id') then + if not utils.linear_index(df.global.world.units.active, unit.id, 'id') then df.global.world.units.active:insert('#', unit) end end diff --git a/fix/sleepers.lua b/fix/sleepers.lua new file mode 100644 index 0000000000..1b7ed75fa8 --- /dev/null +++ b/fix/sleepers.lua @@ -0,0 +1,21 @@ +-- Number of fixed army controller(s) that may be shared by multiple units +local num_fixed = 0 + +-- Loop through all the active units currently loaded +for _, unit in ipairs(df.global.world.units.active) do + local army_controller = unit.enemy.army_controller + -- Only Campers have been observed to sleep + if army_controller and army_controller.goal == df.army_controller_goal_type.CAMP then + if not army_controller.data.goal_camp.camp_flag.ALARM_INTRUDER then + -- Intruder alert! Bloodthirsty adventurer is in the camp! + army_controller.data.goal_camp.camp_flag.ALARM_INTRUDER = true + num_fixed = num_fixed + 1 + end + end +end + +if num_fixed == 0 then + print ("No sleepers with the fixable bug were found, sorry.") +else + print ("Fixed " .. num_fixed .. " group(s) of sleeping units.") +end diff --git a/fix/stable-temp.lua b/fix/stable-temp.lua index 24d50e6958..a629d394a5 100644 --- a/fix/stable-temp.lua +++ b/fix/stable-temp.lua @@ -1,22 +1,11 @@ --- Reset item temperature to the value of their tile. ---[====[ - -fix/stable-temp -=============== -Instantly sets the temperature of all free-lying items to be in equilibrium with -the environment, which stops temperature updates until something changes. -To maintain this efficient state, use `tweak fast-heat `. - -]====] -local args = {...} +local args = { ... } local apply = (args[1] == 'apply') local count = 0 local types = {} --as:number[] -local function update_temp(item,btemp) - local item = item --as:df.item_actual +local function update_temp(item, btemp) if item.temperature.whole ~= btemp then count = count + 1 local tid = item:getType() @@ -28,15 +17,15 @@ local function update_temp(item,btemp) item.temperature.fraction = 0 if item.contaminants then - for _,c in ipairs(item.contaminants) do - c.temperature.whole = btemp - c.temperature.fraction = 0 + for _, c in ipairs(item.contaminants) do + c.base.temperature.whole = btemp + c.base.temperature.fraction = 0 end end end - for _,sub in ipairs(dfhack.items.getContainedItems(item)) do --as:df.item_actual - update_temp(sub,btemp) + for _, sub in ipairs(dfhack.items.getContainedItems(item)) do --as:df.item_actual + update_temp(sub, btemp) end if apply then @@ -44,30 +33,33 @@ local function update_temp(item,btemp) end end -local last_frame = df.global.world.frame_counter-1 +local last_frame = df.global.world.frame_counter - 1 -for _,item in ipairs(df.global.world.items.all) do - local item = item --as:df.item_actual - if item.flags.on_ground and df.item_actual:is_instance(item) and - item.temp_updated_frame == last_frame then - local pos = item.pos - local block = dfhack.maps.getTileBlock(pos) - if block then - update_temp(item, block.temperature_1[pos.x%16][pos.y%16]) - end +for _, item in ipairs(df.global.world.items.other.IN_PLAY) do + if not item.flags.on_ground or + not df.item_actual:is_instance(item) or + item.temp_updated_frame ~= last_frame + then + goto continue + end + local pos = item.pos + local block = dfhack.maps.getTileBlock(pos) + if block then + update_temp(item, block.temperature_1[pos.x % 16][pos.y % 16]) end + ::continue:: end if apply then - print('Items updated: '..count) + print('Items updated: ' .. count) else - print("Use 'fix/stable-temp apply' to force-change temperature.") - print('Items not in equilibrium: '..count) + print("Use 'fix/stable-temp apply' to normalize temperature.") + print('Items not in equilibrium: ' .. count) end local tlist = {} -for k,_ in pairs(types) do tlist[#tlist+1] = k end -table.sort(tlist, function(a,b) return types[a] > types[b] end) -for _,k in ipairs(tlist) do - print(' '..df.item_type[k]..':', types[k]) +for k, _ in pairs(types) do tlist[#tlist + 1] = k end +table.sort(tlist, function(a, b) return types[a] > types[b] end) +for _, k in ipairs(tlist) do + print(' ' .. df.item_type[k] .. ':', types[k]) end diff --git a/fix/stuck-instruments.lua b/fix/stuck-instruments.lua index 0526efb769..f859c5e799 100644 --- a/fix/stuck-instruments.lua +++ b/fix/stuck-instruments.lua @@ -10,7 +10,8 @@ function fixInstruments(opts) if ref:getType() == df.general_ref_type.ACTIVITY_EVENT then local activity = df.activity_entry.find(ref.activity_id) if not activity then - print(('Found stuck instrument: %s'):format(dfhack.items.getDescription(item, 0, true))) + print(dfhack.df2console(('Found stuck instrument: %s'):format( + dfhack.items.getDescription(item, 0, true)))) if not opts.dry_run then --remove dead activity reference item.general_refs[i]:delete() @@ -22,6 +23,16 @@ function fixInstruments(opts) end end end + -- instruments can also end up in the state where they are unreferenced, but + -- still have the in_job flag set + if item.flags.in_job and #item.general_refs == 0 then + print(dfhack.df2console(('Found stuck instrument: %s'):format( + dfhack.items.getDescription(item, 0, true)))) + if not opts.dry_run then + item.flags.in_job = false + end + fixed = fixed + 1 + end end if fixed > 0 or opts.dry_run then diff --git a/fix/stuck-merchants.lua b/fix/stuck-merchants.lua index ceddc205e1..fa9b5b77c0 100644 --- a/fix/stuck-merchants.lua +++ b/fix/stuck-merchants.lua @@ -1,26 +1,7 @@ --- Dismisses stuck merchants that haven't entered the map yet --- Based on "dismissmerchants" by PatrikLundell: --- http://www.bay12forums.com/smf/index.php?topic=159297.msg7257447#msg7257447 - -local help = [====[ - -fix/stuck-merchants -=================== - -Dismisses merchants that haven't entered the map yet. This can fix :bug:`9593`. -This script should probably not be run if any merchants are on the map, so using -it with `repeat` is not recommended. - -Run ``fix/stuck-merchants -n`` or ``fix/stuck-merchants --dry-run`` to list all -merchants that would be dismissed but make no changes. - -]====] - - function getEntityName(u) local civ = df.historical_entity.find(u.civ_id) if not civ then return 'unknown civ' end - return dfhack.TranslateName(civ.name) + return dfhack.translation.translateName(civ.name) end function getEntityRace(u) @@ -38,7 +19,7 @@ function dismissMerchants(args) local dry_run = false for _, arg in pairs(args) do if args[1]:match('-h') or args[1]:match('help') then - print(help) + print(dfhack.script_help()) return elseif args[1]:match('-n') or args[1]:match('dry') then dry_run = true @@ -49,7 +30,7 @@ function dismissMerchants(args) print(('%s unit %d: %s (%s), civ %d (%s, %s)'):format( dry_run and 'Would remove' or 'Removing', u.id, - dfhack.df2console(dfhack.TranslateName(dfhack.units.getVisibleName(u))), + dfhack.df2console(dfhack.units.getReadableName(u)), df.creature_raw.find(u.race).name[0], u.civ_id, dfhack.df2console(getEntityName(u)), @@ -62,6 +43,4 @@ function dismissMerchants(args) end end -if not dfhack_flags.module then - dismissMerchants{...} -end +dismissMerchants{...} diff --git a/fix/stuck-squad.lua b/fix/stuck-squad.lua new file mode 100644 index 0000000000..7bfc739b0d --- /dev/null +++ b/fix/stuck-squad.lua @@ -0,0 +1,126 @@ +--@ module=true + +local utils = require('utils') + +-- from observing bugged saves, this condition appears to be unique to stuck armies +local function is_army_stuck(army) + return army.controller_id ~= 0 and not army.controller +end + +-- if army is currently camping, we'll need to go up the chain +local function get_top_controller(controller) + if not controller then return end + if controller.master_id == controller.id then return controller end + return df.army_controller.find(controller.master_id) +end + +local function is_army_valid_and_returning(army) + local controller = get_top_controller(army.controller) + if not controller then return false, false end + if controller.goal == df.army_controller_goal_type.SITE_INVASION then + return true, controller.data.goal_site_invasion.flag.RETURNING_HOME + elseif controller.goal == df.army_controller_goal_type.MAKE_REQUEST then + return true, controller.data.goal_make_request.flag.RETURNING_HOME + end + return false, false +end + +local function get_hf_army(hf) + if not hf then return end + return df.army.find(hf.info and hf.info.whereabouts and hf.info.whereabouts.army_id or -1) +end + +-- need to check all squad positions since some members may have died +local function get_squad_army(squad) + if not squad then return end + for _,sp in ipairs(squad.positions) do + local hf = df.historical_figure.find(sp.occupant) + if not hf then goto continue end + local army = get_hf_army(hf) + if army then return army end + ::continue:: + end +end + +-- called by gui/notify notification +function scan_fort_armies() + local stuck_armies, outbound_army, returning_army = {}, nil, nil + local govt = df.historical_entity.find(df.global.plotinfo.group_id) + if not govt then return stuck_armies, outbound_army, returning_army end + + for _,squad_id in ipairs(govt.squads) do + local squad = df.squad.find(squad_id) + local army = get_squad_army(squad) + if not army then goto continue end + if is_army_stuck(army) then + table.insert(stuck_armies, {squad=squad, army=army}) + elseif not returning_army then + local valid, returning = is_army_valid_and_returning(army) + if valid then + if returning then + returning_army = {squad=squad, army=army} + else + outbound_army = {squad=squad, army=army} + end + end + end + ::continue:: + end + + if #stuck_armies == 0 then return stuck_armies, nil, nil end + + -- prefer returning with a messenger if one is readily available + for _,messenger in ipairs(dfhack.units.getUnitsByNobleRole('Messenger')) do + local army = get_hf_army(df.historical_figure.find(messenger.hist_figure_id)) + if not army then goto continue end + local valid, returning = is_army_valid_and_returning(army) + if valid then + if returning then + returning_army = {army=army} + else + outbound_army = {army=army} + end + end + ::continue:: + end + + return stuck_armies, outbound_army, returning_army +end + +local function unstick_armies() + local stuck_armies, outbound_army, returning_army = scan_fort_armies() + if #stuck_armies == 0 then return end + if not returning_army then + local instructions = outbound_army + and ('Please wait for %s to complete their objective and run this command again when they are on their way home.'):format( + outbound_army.squad and dfhack.df2console(dfhack.military.getSquadName(outbound_army.squad.id)) or 'the messenger') + or 'Please send a squad or a messenger out on a mission that will return to the fort, and'.. + ' run this command again when they are on the way home.' + qerror(('%d stuck squad%s found, but no returning squads or messengers are available to rescue them!\n%s'):format( + #stuck_armies, #stuck_armies == 1 and '' or 's', instructions)) + return + end + local returning_squad_name = returning_army.squad and dfhack.df2console(dfhack.military.getSquadName(returning_army.squad.id)) or 'the messenger' + for _,stuck in ipairs(stuck_armies) do + print(('fix/stuck-squad: Squad rescue operation underway! %s is rescuing %s'):format( + returning_squad_name, dfhack.military.getSquadName(stuck.squad.id))) + for _,member in ipairs(stuck.army.members) do + local nemesis = df.nemesis_record.find(member.nemesis_id) + if not nemesis or not nemesis.figure then goto continue end + local hf = nemesis.figure + if hf.info and hf.info.whereabouts then + hf.info.whereabouts.army_id = returning_army.army.id + end + utils.insert_sorted(returning_army.army.members, member, 'nemesis_id') + ::continue:: + end + stuck.army.members:resize(0) + utils.insert_sorted(get_top_controller(returning_army.army.controller).assigned_squads, stuck.squad.id) + end +end + +if dfhack_flags.module then + return +end + +unstick_armies() diff --git a/fix/stuck-worship.lua b/fix/stuck-worship.lua new file mode 100644 index 0000000000..d21df82717 --- /dev/null +++ b/fix/stuck-worship.lua @@ -0,0 +1,108 @@ +local argparse = require('argparse') + +local verbose, quiet = false, false +argparse.processArgsGetopt({...}, { + {'v', 'verbose', handler=function() verbose = true end}, + {'q', 'quiet', handler=function() quiet = true end}, +}) + +local function for_pray_need(needs, fn) + for idx, need in ipairs(needs) do + if need.id == df.need_type.PrayOrMeditate then + fn(idx, need) + end + end +end + +local function shuffle_prayer_needs(needs, prayer_targets) + local idx_of_prayer_target, max_focus_level + local idx_of_min_focus_level, min_focus_level + for_pray_need(needs, function(idx, need) + -- only shuffle if the need for one of the current prayer targets + -- is already met + if prayer_targets[need.deity_id] and need.focus_level > -1000 and + (not max_focus_level or need.focus_level > max_focus_level) + then + idx_of_prayer_target = idx + max_focus_level = need.focus_level + + -- find a need that hasn't been met outside of the current prayer targets + elseif not prayer_targets[need.deity_id] and + need.focus_level <= -1000 and + (not min_focus_level or need.focus_level < min_focus_level) + then + idx_of_min_focus_level = idx + min_focus_level = need.focus_level + end + end) + + -- if a need inside the prayer group is met and a need outside of the + -- prayer group is not met, transfer the credit outside of the prayer group + if idx_of_prayer_target and idx_of_min_focus_level then + needs[idx_of_min_focus_level].focus_level = needs[idx_of_prayer_target].focus_level + needs[idx_of_prayer_target].focus_level = min_focus_level + return true + end + + if not idx_of_prayer_target then return end + + -- otherwise, if the only unmet needs are inside the prayer group, + -- set the credit inside the prayer group to the level of the met need + -- we found earlier + local modified = false + for_pray_need(needs, function(_, need) + if prayer_targets[need.deity_id] and need.focus_level <= -1000 then + need.focus_level = needs[idx_of_prayer_target].focus_level + modified = true + end + end) + return modified +end + +local function get_prayer_targets(unit) + for _, sa in ipairs(unit.social_activities) do + local ae = df.activity_entry.find(sa) + if not ae or ae.type ~= df.activity_entry_type.Prayer then + goto next_activity + end + for _, ev in ipairs(ae.events) do + if not df.activity_event_worshipst:is_instance(ev) then + goto next_event + end + for _, hfid in ipairs(ev.participants.histfigs) do + local hf = df.historical_figure.find(hfid) + if not hf then goto next_hf end + local deity_set = {} + for _, hf_link in ipairs(hf.histfig_links) do + if df.histfig_hf_link_deityst:is_instance(hf_link) then + deity_set[hf_link.target_hf] = true + end + end + if next(deity_set) then return deity_set end + ::next_hf:: + end + ::next_event:: + end + ::next_activity:: + end +end + +local count = 0 +for _,unit in ipairs(dfhack.units.getCitizens(false, true)) do + local prayer_targets = get_prayer_targets(unit) + if not unit.status.current_soul or not prayer_targets then + goto next_unit + end + local needs = unit.status.current_soul.personality.needs + if shuffle_prayer_needs(needs, prayer_targets) then + count = count + 1 + if verbose then + print('Shuffled prayer target for '..dfhack.df2console(dfhack.units.getReadableName(unit))) + end + end + ::next_unit:: +end + +if not quiet or count > 0 then + print(('Rebalanced prayer needs for %d units.'):format(count)) +end diff --git a/fix/stuckdoors.lua b/fix/stuckdoors.lua index 085aa953f4..ec48a847b5 100644 --- a/fix/stuckdoors.lua +++ b/fix/stuckdoors.lua @@ -9,7 +9,7 @@ end -- Util function: find out if there are any units on the tile with coordinates x,y,z function unitOnTile(x, y, z) - local units = dfhack.units.getUnitsInBox(x,y,z,x,y,z,dfhack.units.isActive) + local units = dfhack.units.getUnitsInBox(x,y,z,x,y,z) return #(units) > 0 end @@ -22,7 +22,7 @@ end -- one above like dfhack.items.getItemsInBox(). function nonDoorItemOnTile(x, y, z) local count = 0 - for _,item in ipairs(df.global.world.items.all) do + for _,item in ipairs(df.global.world.items.other.IN_PLAY) do if item.pos.x == x and item.pos.y == y and item.pos.z == z then count = count + 1 if count > 1 then return true end diff --git a/fix/symbol-unstick.lua b/fix/symbol-unstick.lua new file mode 100644 index 0000000000..0e77131ba5 --- /dev/null +++ b/fix/symbol-unstick.lua @@ -0,0 +1,68 @@ +-- Unstick noble symbols that cannot be re-designated. + +local utils = require('utils') + +local function GetArtifact(item) + local artifact + for _, generalRef in ipairs(item.general_refs) do + if df.general_ref_is_artifactst:is_instance(generalRef) then + artifact = df.artifact_record.find(generalRef.artifact_id) + break + end + end + local civ_id = df.global.plotinfo.civ_id + -- Check if selected item is claimed by current site's civ. + local entClaim_idx = artifact and utils.linear_index(artifact.entity_claims, civ_id) + if artifact and entClaim_idx then + return artifact, entClaim_idx + end + return nil +end + +local function UnstickSymbol(artifact, entClaim_idx) + local civEntity = df.historical_entity.find(df.global.plotinfo.civ_id) + local idx = utils.linear_index(civEntity.artifact_claims, artifact.id, 'artifact_id') + -- Check if selected item is a symbol. + if idx and civEntity.artifact_claims[idx].claim_type == df.artifact_claim_type.Symbol then + local position_idx = civEntity.artifact_claims[idx].symbol_claim_id + -- Check if symbol's position has been vacated. + if civEntity.positions.assignments[position_idx].histfig == -1 then + -- Erase claim from the entity and its reference in the artifact's record. + -- Note: it's also possible to re-assign symbol by changing symbol_claim_id + -- to point to the appropriate idx in civEntity.positions.assignments + civEntity.artifact_claims:erase(idx) + artifact.entity_claims:erase(entClaim_idx) + return true + end + end + return false +end + +local function Main(args) + if args[1] == 'help' then + print(dfhack.script_help()) + return + end + local item = dfhack.gui.getSelectedItem(true) + if not item then + qerror('No item selected.') + end + local artifact, entClaim_idx = GetArtifact(item) + if artifact then + if UnstickSymbol(artifact, entClaim_idx) then + print('Symbol designation removed from selected item.') + local strItemName = item and dfhack.items.getReadableDescription(item) + print(('%s can now be re-designated as a symbol of nobility.'):format(strItemName)) + else + qerror('Selected item is not a defunct symbol of the current site\'s civilization.') + end + else + qerror('Selected item is not an artifact claimed by the current site\'s civilization.') + end +end + +if not dfhack.isSiteLoaded() and not dfhack.world.isFortressMode() then + qerror('This script requires the game to be in fortress mode.') +end + +Main({...}) diff --git a/fix/tile-occupancy.lua b/fix/tile-occupancy.lua deleted file mode 100644 index ce4e9820fc..0000000000 --- a/fix/tile-occupancy.lua +++ /dev/null @@ -1,56 +0,0 @@ --- Fix occupancy flags at a given tile - ---[====[ - -fix/tile-occupancy -================== -Clears bad occupancy flags at the selected tile. Useful for getting rid of -phantom "building present" messages. Currently only supports issues with -building and unit occupancy. Requires that a tile is selected with the in-game -cursor (``k``). - -Can be used to fix problematic tiles caused by :issue:`1047`. - -]====] - -if #{...} > 0 then - qerror('This script takes no arguments.') -end - -function findUnit(x, y, z) - for _, u in pairs(df.global.world.units.active) do - if u.pos.x == x and u.pos.y == y and u.pos.z == z then - return true - end - end - return false -end - -local cursor = df.global.cursor -local changed = false -function report(flag) - print('Cleared occupancy flag: ' .. flag) - changed = true -end - -if cursor.x == -30000 then - qerror('Cursor not active.') -end - -local occ = dfhack.maps.getTileBlock(pos2xyz(cursor)).occupancy[cursor.x % 16][cursor.y % 16] - -if occ.building ~= df.tile_building_occ.None and not dfhack.buildings.findAtTile(pos2xyz(cursor)) then - occ.building = df.tile_building_occ.None - report('building') -end - -for _, flag in pairs{'unit', 'unit_grounded'} do - if occ[flag] and not findUnit(pos2xyz(cursor)) then - occ[flag] = false - report(flag) - end -end - -if not changed then - print('No changes made at this tile.') -end diff --git a/fix/wildlife.lua b/fix/wildlife.lua new file mode 100644 index 0000000000..d5fc492815 --- /dev/null +++ b/fix/wildlife.lua @@ -0,0 +1,202 @@ +--@module = true + +local argparse = require('argparse') +local exterminate = reqscript('exterminate') +local guidm = require('gui.dwarfmode') + +local GLOBAL_KEY = 'fix/wildlife' + +DEBUG = DEBUG or false + +stuck_creatures = stuck_creatures or {} + +dfhack.onStateChange[GLOBAL_KEY] = function(sc) + if (sc == SC_MAP_UNLOADED or sc == SC_MAP_LOADED) and + dfhack.world.isFortressMode() + then + stuck_creatures = {} + end +end + +local function print_summary(opts, unstuck) + if not next(unstuck) then + if not opts.quiet then + print('No stuck wildlife found') + return + end + end + local prefix = opts.week and (GLOBAL_KEY .. ': ') or '' + local msg_txt = opts.dry_run and '' or 'no longer ' + for _,entry in pairs(unstuck) do + if entry.count == 1 then + print(('%s%d %s is %sblocking new waves of wildlife'):format( + prefix, + entry.count, + entry.known and dfhack.units.getRaceReadableNameById(entry.race) or 'hidden creature', + msg_txt)) + else + print(('%s%d %s are %sblocking new waves of wildlife'):format( + prefix, + entry.count, + entry.known and dfhack.units.getRaceNamePluralById(entry.race) or 'hidden creatures', + msg_txt)) + end + end +end + +local function refund_population(entry) + local epop = entry.pop + for _,population in ipairs(df.global.world.populations.all) do + local wpop = population.population + if population.quantity < 10000001 and + wpop.region_x == epop.region_x and + wpop.region_y == epop.region_y and + wpop.feature_idx == epop.feature_idx and + wpop.cave_id == epop.cave_id and + wpop.site_id == epop.site_id and + wpop.population_idx == epop.population_idx + then + population.quantity = math.min(population.quantity + entry.count, population.quantity_max) + break + end + end +end + +-- refund unit to population and ensure it doesn't get picked up by unstick_wildlife in the future +local function detach_unit(unit) + unit.flags2.roaming_wilderness_population_source = false + unit.flags2.roaming_wilderness_population_source_not_a_map_feature = false + refund_population{race=unit.race, pop=unit.animal.population, known=true, count=1} +end + +local TICKS_PER_DAY = 1200 +local TICKS_PER_WEEK = TICKS_PER_DAY * 7 +local TICKS_PER_MONTH = 28 * TICKS_PER_DAY +local TICKS_PER_SEASON = 3 * TICKS_PER_MONTH +local TICKS_PER_YEAR = 4 * TICKS_PER_SEASON + +-- time checks near year turnover is wishy-washy until we have a datetime API available +local WEEK_BEFORE_EOY_TICKS = TICKS_PER_YEAR - TICKS_PER_WEEK + +-- update stuck_creatures records and check timeout +-- we only enter this function if the unit's leave_countdown has already expired +-- returns true if the unit has timed out +local function check_timeout(opts, unit, week_ago_ticks) + if not opts.week then return true end + if not stuck_creatures[unit.id] then + stuck_creatures[unit.id] = df.global.cur_year_tick + return false + end + local timestamp = stuck_creatures[unit.id] + return timestamp < week_ago_ticks or + (timestamp > df.global.cur_year_tick and timestamp > WEEK_BEFORE_EOY_TICKS) +end + +local function to_key(pop) + return ('%d:%d:%d:%d:%d:%d'):format( + pop.region_x, pop.region_y, pop.feature_idx, pop.cave_id, pop.site_id, pop.population_idx) +end + +local function is_active_wildlife(unit) + return not dfhack.units.isDead(unit) and + dfhack.units.isActive(unit) and + dfhack.units.isWildlife(unit) and + unit.flags2.roaming_wilderness_population_source +end + +-- called by force for the "Wildlife" event +function free_all_wildlife(include_hidden) + for _,unit in ipairs(df.global.world.units.active) do + if is_active_wildlife(unit) and + (include_hidden or not dfhack.units.isHidden(unit)) + then + detach_unit(unit) + end + end +end + +local function is_onscreen(unit, viewport) + viewport = viewport or guidm.Viewport.get() + return viewport:isVisible(xyz2pos(dfhack.units.getPosition(unit))), viewport +end + +local function unstick_wildlife(opts) + local unstuck = {} + local week_ago_ticks = math.max(0, df.global.cur_year_tick - TICKS_PER_WEEK) + local viewport + for _,unit in ipairs(df.global.world.units.active) do + if not is_active_wildlife(unit) or unit.animal.leave_countdown > 0 then + goto skip + end + if unit.flags1.caged or unit.flags1.chained then + goto skip + end + if not check_timeout(opts, unit, week_ago_ticks) then + goto skip + end + local pop = unit.animal.population + local unstuck_entry = ensure_key(unstuck, to_key(pop), {race=unit.race, pop=pop, known=false, count=0}) + unstuck_entry.known = unstuck_entry.known or not dfhack.units.isHidden(unit) + unstuck_entry.count = unstuck_entry.count + 1 + if not opts.dry_run then + stuck_creatures[unit.id] = nil + local unit_is_visible + unit_is_visible, viewport = is_onscreen(unit, viewport) + exterminate.killUnit(unit, not unit_is_visible and exterminate.killMethod.DISINTEGRATE or nil) + end + ::skip:: + end + for _,entry in pairs(unstuck) do + refund_population(entry) + end + print_summary(opts, unstuck) +end + +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 opts = { + dry_run=false, + help=false, + quiet=false, + week=false, +} + +local positionals = argparse.processArgsGetopt({...}, { + {'h', 'help', handler = function() opts.help = true end}, + {'n', 'dry-run', handler = function() opts.dry_run = true end}, + {'w', 'week', handler = function() opts.week = true end}, + {'q', 'quiet', handler = function() opts.quiet = true end}, +}) + +if positionals[1] == 'help' or opts.help then + print(dfhack.script_help()) + return +end + +if positionals[1] == 'ignore' then + local unit + local unit_id = positionals[2] and argparse.nonnegativeInt(positionals[2], 'unit_id') + if unit_id then + unit = df.unit.find(unit_id) + else + unit = dfhack.gui.getSelectedUnit(true) + end + if not unit then + qerror('please select a unit or pass a unit ID on the commandline') + end + if not is_active_wildlife(unit) then + qerror('selected unit is not blocking new waves of wildlife; nothing to do') + end + detach_unit(unit) + if not opts.quiet then + print(('%s will now be ignored by fix/wildlife'):format(dfhack.units.getReadableName(unit))) + end +else + unstick_wildlife(opts) +end diff --git a/fixnaked.lua b/fixnaked.lua index 725ab6f9c1..02dbc794f3 100644 --- a/fixnaked.lua +++ b/fixnaked.lua @@ -1,18 +1,11 @@ --removes unhappy thoughts due to lack of clothing ---[====[ - -fixnaked -======== -Removes all unhappy thoughts due to lack of clothing. - -]====] function fixnaked() local total_fixed = 0 local total_removed = 0 -for fnUnitCount,fnUnit in ipairs(df.global.world.units.all) do - if fnUnit.race == df.global.plotinfo.race_id and fnUnit.status.current_soul then +for fnUnitCount,fnUnit in ipairs(dfhack.units.getCitizens()) do + if fnUnit.status.current_soul then local found = true local fixed = false while found do @@ -36,7 +29,7 @@ for fnUnitCount,fnUnit in ipairs(df.global.world.units.all) do end if fixed then total_fixed = total_fixed + 1 - print(total_fixed, total_removed, dfhack.TranslateName(dfhack.units.getVisibleName(fnUnit))) + print(total_fixed, total_removed, dfhack.df2console(dfhack.units.getReadableName(fnUnit))) end end end diff --git a/flashstep.lua b/flashstep.lua index 2176ecabf8..4f841424b6 100644 --- a/flashstep.lua +++ b/flashstep.lua @@ -1,19 +1,26 @@ ---Teleports adventurer to cursor ---[====[ - -flashstep -========= -A hotkey-friendly teleport that places your adventurer where your cursor is. - -]====] +local function reveal_tile(pos) + local block = dfhack.maps.getTileBlock(pos) + local des = block.designation[pos.x%16][pos.y%16] + des.hidden = false + des.pile = true -- reveal the tile on the map +end -function flashstep() - local unit = df.global.world.units.active[0] - if df.global.adventure.menu ~= df.ui_advmode_menu.Look then - qerror("No [l] cursor located! You kinda need it for this script.") +local function flashstep() + local unit = dfhack.world.getAdventurer() + if not unit then return end + local pos = dfhack.gui.getMousePos() + if not pos then return end + if dfhack.units.teleport(unit, pos) then + reveal_tile(xyz2pos(pos.x-1, pos.y-1, pos.z)) + reveal_tile(xyz2pos(pos.x, pos.y-1, pos.z)) + reveal_tile(xyz2pos(pos.x+1, pos.y-1, pos.z)) + reveal_tile(xyz2pos(pos.x-1, pos.y, pos.z)) + reveal_tile(pos) + reveal_tile(xyz2pos(pos.x+1, pos.y, pos.z)) + reveal_tile(xyz2pos(pos.x-1, pos.y+1, pos.z)) + reveal_tile(xyz2pos(pos.x, pos.y+1, pos.z)) + reveal_tile(xyz2pos(pos.x+1, pos.y+1, pos.z)) end - dfhack.units.teleport(unit, xyz2pos(pos2xyz(df.global.cursor))) - dfhack.maps.getTileBlock(unit.pos).designation[unit.pos.x%16][unit.pos.y%16].hidden = false end flashstep() diff --git a/force.lua b/force.lua index 9360ca8b95..afb3c4febd 100644 --- a/force.lua +++ b/force.lua @@ -1,30 +1,58 @@ --- Forces an event (wrapper for modtools/force) +local wildlife = reqscript('fix/wildlife') -local utils = require 'utils' -local args = {...} +local function findCiv(civ) + if civ == 'player' then return df.historical_entity.find(df.global.plotinfo.civ_id) end + if tonumber(civ) then return df.historical_entity.find(tonumber(civ)) end + civ = string.lower(tostring(civ)) + for _,entity in ipairs(df.global.world.entities.all) do + if string.lower(entity.entity_raw.code) == civ then return entity end + end +end + +local args = { ... } if #args < 1 then qerror('missing event type') end if args[1]:find('help') then print(dfhack.script_help()) return end -local eventType = nil + +local eventType = args[1]:upper() + +-- handle synthetic events +if eventType == 'WILDLIFE' then + wildlife.free_all_wildlife(args[2] == 'all') + return +end + +-- handle native events for _, type in ipairs(df.timed_event_type) do if type:lower() == args[1]:lower() then eventType = type end end -if not eventType then +if not df.timed_event_type[eventType] then qerror('unknown event type: ' .. args[1]) end +if eventType == 'FeatureAttack' then + qerror('Event type: FeatureAttack is not currently supported') +end + +local civ -local newArgs = {'--eventType', eventType} if eventType == 'Caravan' or eventType == 'Diplomat' then - table.insert(newArgs, '--civ') - if not args[2] then - table.insert(newArgs, 'player') - else - table.insert(newArgs, args[2]) + civ = findCiv(args[2] or 'player') + if not civ then + qerror('unable to find civilization: '..tostring(civ)) end +elseif eventType == 'Migrants' then + civ = findCiv('player') end -dfhack.run_script('modtools/force', table.unpack(newArgs)) +df.global.timed_events:insert('#', { + new=true, + type=df.timed_event_type[eventType], + season=df.global.cur_season, + season_ticks=df.global.cur_season_tick, + entity=civ, + feature_ind=-1, +}) diff --git a/forget-dead-body.lua b/forget-dead-body.lua index 1166e75ef7..0275097321 100644 --- a/forget-dead-body.lua +++ b/forget-dead-body.lua @@ -1,14 +1,6 @@ -- Removes emotions associated with seeing a dead body --@ module = true -local help = [====[ - -forget-dead-body -================ -Removes emotions associated with seeing a dead body. - -]====] - local utils = require 'utils' local validArgs = utils.invert({ @@ -28,22 +20,20 @@ function main(...) local args = utils.processArgs({...}, validArgs) if args.help then - print(help) + print(dfhack.script_help()) return end if args.all then - for _, unit in ipairs(df.global.world.units.active) do - if dfhack.units.isCitizen(unit) then - forgetDeadBody(unit) - end + for _, unit in ipairs(dfhack.units.getCitizens()) do + forgetDeadBody(unit) end else local unit = dfhack.gui.getSelectedUnit() if unit then forgetDeadBody(unit) else - qerror('Invalid usage: No unit selected and -all argument not given.') + qerror('Invalid usage: No unit selected and --all argument not given.') end end end diff --git a/full-heal.lua b/full-heal.lua index 2dc71c3dca..a1daa0fa89 100644 --- a/full-heal.lua +++ b/full-heal.lua @@ -1,6 +1,3 @@ --- Attempts to fully heal the selected unit ---author Kurik Amudnil, Urist DaVinci ---edited by expwnent and AtomicChicken --@ module = true local utils = require('utils') @@ -22,25 +19,6 @@ if args.help then return end -function isCitizen(unit) --- required as dfhack.units.isCitizen() returns false for dead units - local hf = df.historical_figure.find(unit.hist_figure_id) - if not hf then - return false - end - for _,link in ipairs(hf.entity_links) do - if link.entity_id == df.global.plotinfo.group_id and df.histfig_entity_link_type[link:getType()] == 'MEMBER' then - return true - end - end -end - -function isFortCivMember(unit) - if unit.civ_id == df.global.plotinfo.civ_id then - return true - end -end - function addResurrectionEvent(histFigID) local event = df.history_event_hist_figure_revivedst:new() event.histfig = histFigID @@ -51,13 +29,13 @@ function addResurrectionEvent(histFigID) df.global.hist_event_next_id = df.global.hist_event_next_id + 1 end -function heal(unit,resurrect,keep_corpse) +function heal(unit, resurrect, keep_corpse) if not unit then return end if resurrect then if unit.flags2.killed and not unit.flags3.scuttle then -- scuttle appears to be applicable to just wagons, which probably shouldn't be resurrected - --print("Resurrecting...") + print(('Resurrecting %s'):format(dfhack.units.getReadableName(unit))) unit.flags1.inactive = false unit.flags2.slaughter = false unit.flags2.killed = false @@ -76,7 +54,7 @@ function heal(unit,resurrect,keep_corpse) addResurrectionEvent(hf.id) end - if dfhack.world.isFortressMode() and isFortCivMember(unit) then + if dfhack.world.isFortressMode() and dfhack.units.isOwnCiv(unit) then unit.flags2.resident = false -- appears to be set to true for dead citizens in a reclaimed fortress, which causes them to be marked as hostile when resurrected local deadCitizens = df.global.plotinfo.main.dead_citizens @@ -92,9 +70,7 @@ function heal(unit,resurrect,keep_corpse) for i = #corpses-1,0,-1 do local corpse = corpses[i] --as:df.item_body_component if corpse.unit_id == unit.id then - corpse.flags.garbage_collect = true - corpse.flags.forbid = true - corpse.flags.hidden = true + dfhack.items.remove(corpse) end end end @@ -119,7 +95,6 @@ function heal(unit,resurrect,keep_corpse) --print("Resetting status flags...") unit.flags2.has_breaks = false unit.flags2.gutted = false - unit.flags2.circulatory_spray = false unit.flags2.vision_good = true unit.flags2.vision_damaged = false unit.flags2.vision_missing = false @@ -194,17 +169,17 @@ function heal(unit,resurrect,keep_corpse) unit.status2.body_part_temperature:resize(0) -- attempting to rewrite temperature was causing body parts to melt for some reason; forcing repopulation in this manner appears to be safer - for i = 0,#unit.enemy.body_part_8a8-1,1 do - unit.enemy.body_part_8a8[i] = 1 -- not sure what this does, but values appear to change following injuries + for i = 0,#unit.enemy.body_part_useable-1,1 do + unit.enemy.body_part_useable[i] = 1 -- not sure what this does, but values appear to change following injuries end - for i = 0,#unit.enemy.body_part_8d8-1,1 do - unit.enemy.body_part_8d8[i] = 0 -- same as above + for i = 0,#unit.enemy.invorder_bp_start-1,1 do + unit.enemy.invorder_bp_start[i] = 0 -- same as above end - for i = 0,#unit.enemy.body_part_878-1,1 do - unit.enemy.body_part_878[i] = 3 -- as above + for i = 0,#unit.enemy.motor_nervenet-1,1 do + unit.enemy.motor_nervenet[i] = 3 -- as above end - for i = 0,#unit.enemy.body_part_888-1,1 do - unit.enemy.body_part_888[i] = 3 -- as above + for i = 0,#unit.enemy.sensory_nervenet-1,1 do + unit.enemy.sensory_nervenet[i] = 3 -- as above end local histFig = df.historical_figure.find(unit.hist_figure_id) @@ -227,7 +202,7 @@ function heal(unit,resurrect,keep_corpse) health.dressing_cntdn = 0 health.suture_cntdn = 0 health.crutch_cntdn = 0 - health.unk_18_cntdn = 0 + health.try_for_cast_cntdn = 0 end local job = unit.job.current_job @@ -260,14 +235,15 @@ if args.all then heal(unit,args.r,args.keep_corpse) end elseif args.all_citizens then + -- can't use dfhack.units.getCitizens since we want dead ones too for _,unit in ipairs(df.global.world.units.active) do - if isCitizen(unit) then + if dfhack.units.isCitizen(unit, true) or dfhack.units.isResident(unit) then heal(unit,args.r,args.keep_corpse) end end elseif args.all_civ then for _,unit in ipairs(df.global.world.units.active) do - if isFortCivMember(unit) then + if dfhack.units.isOwnCiv(unit) then heal(unit,args.r,args.keep_corpse) end end diff --git a/gaydar.lua b/gaydar.lua index 21cc643b9d..5dfb5a1828 100644 --- a/gaydar.lua +++ b/gaydar.lua @@ -1,27 +1,5 @@ -- Shows the sexual orientation of units -local help = [====[ -gaydar -====== -Shows the sexual orientation of units, useful for social engineering or checking -the viability of livestock breeding programs. - -Targets: - -:-all: shows orientation of every creature -:-citizens: shows only orientation of citizens in fort mode -:-named: shows orientation of all named units on map -:(no target): shows orientation of the unit under the cursor - -Orientation filters: - -:-notStraight: only creatures who are not strictly straight -:-gayOnly: only creatures who are strictly gay -:-biOnly: only creatures who can get into romances with both sexes -:-straightOnly: only creatures who are strictly straight -:-asexualOnly: only creatures who are strictly asexual - -]====] local utils = require('utils') local validArgs = utils.invert({ @@ -40,7 +18,7 @@ local validArgs = utils.invert({ local args = utils.processArgs({...}, validArgs) if args.help then - print(help) + print(dfhack.script_help()) return end @@ -99,7 +77,7 @@ end local function nameOrSpeciesAndNumber(unit) if unit.name.has_name then - return dfhack.TranslateName(dfhack.units.getVisibleName(unit))..' '..getSexString(unit.sex),true + return dfhack.translation.translateName(dfhack.units.getVisibleName(unit))..' '..getSexString(unit.sex),true else return 'Unit #'..unit.id..' ('..df.creature_raw.find(unit.race).caste[unit.caste].caste_name[0]..' '..getSexString(unit.sex)..')',false end @@ -108,10 +86,8 @@ end local orientations={} --as:string[] if args.citizens then - for k,v in ipairs(df.global.world.units.active) do - if dfhack.units.isCitizen(v) then - table.insert(orientations,nameOrSpeciesAndNumber(v) .. determineorientation(v)) - end + for k,v in ipairs(dfhack.units.getCitizens()) do + table.insert(orientations,nameOrSpeciesAndNumber(v) .. determineorientation(v)) end elseif args.all then for k,v in ipairs(df.global.world.units.active) do @@ -126,6 +102,7 @@ elseif args.named then end else local unit=dfhack.gui.getSelectedUnit(true) + if not unit then qerror('Please select a unit in the UI') end local name,ok=nameOrSpeciesAndNumber(unit) dfprint(name..determineorientation(unit)) return diff --git a/geld.lua b/geld.lua index 690d6469df..7b55cdb50f 100644 --- a/geld.lua +++ b/geld.lua @@ -1,39 +1,17 @@ --- Gelds or ungelds animals --- Written by Josh Cooper(cppcooper) on 2019-12-10, last modified: 2020-02-23 - -utils = require('utils') +local utils = require('utils') local validArgs = utils.invert({ 'unit', 'toggle', 'ungeld', 'help', - 'find', }) local args = utils.processArgs({...}, validArgs) -local help = [====[ - -geld -==== -Geld allows the user to geld and ungeld animals. - -Valid options: - -``-unit ``: Gelds the unit with the specified ID. - This is optional; if not specified, the selected unit is used instead. - -``-ungeld``: Ungelds the specified unit instead (see also `ungeld`). - -``-toggle``: Toggles the gelded status of the specified unit. -``-help``: Shows this help information - -]====] - -unit=nil +local unit = nil if args.help then - print(help) + print(dfhack.script_help()) return end @@ -42,7 +20,7 @@ if args.unit then if id then unit = df.unit.find(id) else - qerror("Invalid ID provided.") + qerror("Invalid unit ID provided.") end else unit = dfhack.gui.getSelectedUnit() @@ -53,55 +31,75 @@ if not unit then end if unit.sex == df.pronoun_type.she then - qerror("Cannot geld female animals") - return + qerror("Cannot geld female animals.") end -function FindBodyPart(unit,newstate) - bfound = false - for i,wound in ipairs(unit.body.wounds) do - for j,part in ipairs(wound.parts) do - if unit.body.wounds[i].parts[j].flags2.gelded ~= newstate then - bfound = true - if newstate ~= nil then - unit.body.wounds[i].parts[j].flags2.gelded = newstate - end - end +-- Find the geldable body part id, returns -1 on failure +local function FindBodyPartId(unit) + for i,part in ipairs(unit.body.body_plan.body_parts) do + if part.flags.GELDABLE then + return i end end - return bfound + return -1 end -function AddParts(unit) - for i,wound in ipairs(unit.body.wounds) do - if wound.id == 1 and #wound.parts == 0 then - utils.insert_or_update(unit.body.wounds[i].parts,{ new = true, body_part_id = 1 }, 'body_part_id') - end +-- Sets the gelded status of a unit, returns false on failure +local function SetGelded(unit, state) + -- Gelded status is set in a number of places: + -- unit.flags3 + -- unit.body.wounds + -- unit.body.components.body_part_status + + local part_id = FindBodyPartId(unit) + if part_id == -1 then + print("Could not find a geldable body part.") + return false end -end -function Geld(unit) - unit.flags3.gelded = true - if not FindBodyPart(unit,true) then - utils.insert_or_update(unit.body.wounds,{ new = true, id = unit.body.wound_next_id }, 'id') + unit.flags3.gelded = state + + if state then + -- Create new wound + local _,wound,_ = utils.insert_or_update(unit.body.wounds, { new = true, id = unit.body.wound_next_id }, 'id') unit.body.wound_next_id = unit.body.wound_next_id + 1 - AddParts(unit) - if not FindBodyPart(unit,true) then - error("could not find body part") + local _,part,_ = utils.insert_or_update(wound.parts, { new = true, body_part_id = part_id}, 'body_part_id') + part.flags2.gelded = true + else + -- Remove gelding from any existing wounds + for _,wound in ipairs(unit.body.wounds) do + for _,part in ipairs(wound.parts) do + part.flags2.gelded = false + end + end + end + + if state then + -- Set part status to gelded + unit.body.components.body_part_status[part_id].gelded = true + else + -- Remove gelded status from all parts + for _,part in ipairs(unit.body.components.body_part_status) do + part.gelded = false end end - print(string.format("unit %s gelded.",unit.id)) + return true end -function Ungeld(unit) - unit.flags3.gelded = false - FindBodyPart(unit,false) - print(string.format("unit %s ungelded.",unit.id)) +local function Geld(unit) + if SetGelded(unit, true) then + print(string.format("Unit %s gelded.", unit.id)) + else + print(string.format("Failed to geld unit %s.", unit.id)) + end end -if args.find then - print(FindBodyPart(unit) and "found" or "not found") - return +local function Ungeld(unit) + if SetGelded(unit, false) then + print(string.format("Unit %s ungelded.", unit.id)) + else + print(string.format("Failed to ungeld unit %s.", unit.id)) + end end local oldstate = dfhack.units.isGelded(unit) @@ -122,5 +120,5 @@ if newstate ~= oldstate then Ungeld(unit) end else - qerror(string.format("unit %s is already %s", unit.id, oldstate and "gelded" or "ungelded")) + qerror(string.format("Unit %s is already %s.", unit.id, oldstate and "gelded" or "ungelded")) end diff --git a/ghostly.lua b/ghostly.lua index 900a8d6a47..2813d8aec3 100644 --- a/ghostly.lua +++ b/ghostly.lua @@ -1,18 +1,8 @@ --- Turns an adventurer into a ghost or back ---[====[ - -ghostly -======= -Toggles an adventurer's ghost status. Useful for walking through walls, avoiding -attacks, or recovering after a death. - -]====] - -if df.global.gamemode ~= df.game_mode.ADVENTURE then +if not dfhack.world.isAdventureMode() then qerror('This script must be used in adventure mode') end -local unit = df.global.world.units.active[0] +local unit = dfhack.world.getAdventurer() if unit then if unit.flags1.inactive then unit.flags1.inactive = false diff --git a/gui/adv-finder.lua b/gui/adv-finder.lua new file mode 100644 index 0000000000..4c5fddfdfd --- /dev/null +++ b/gui/adv-finder.lua @@ -0,0 +1,776 @@ +-- Find and track historical figures and artifacts +--@module = true + +local argparse = require('argparse') +local gui = require('gui') +local widgets = require('gui.widgets') +local utils = require('utils') + +local world = df.global.world +local transName = dfhack.translation.translateName +local findHF = df.historical_figure.find +local toSearch = dfhack.toSearchNormalized + +LType = utils.invert{'None','Local','Site','Wild','Under','Army'} --Location type + +filter_text = filter_text --Stored filter between lists; for setting only! +-- Use AdvSelWindow:get_filter_text() instead for getting current filter +cur_tab = cur_tab or 1 -- 1: HF, 2: Artifact +show_dead = show_dead or false --Exclude dead HFs +show_books = show_books or false --Exclude books +sel_hf = sel_hf or -1 --Selected historical_figure.id +sel_art = sel_art or -1 --Selected artifact_record.id +debug_id = false --Show target ID in window title; reopening without -d option resets + +---- Fns for target names ---- + +local function get_race_name(hf) --E.g., 'Plump Helmet Man' + return dfhack.capitalizeStringWords(dfhack.units.getRaceReadableNameById(hf.race)) +end + +function get_hf_name(hf) --'Native Name "Translated Name", Race' + local full_name = transName(hf.name, false) + if full_name == '' then --Improve searchability + full_name = 'Unnamed' + else --Add the translation + local t_name = transName(hf.name, true) + if full_name ~= t_name then --Don't repeat + full_name = full_name..' "'..t_name..'"' + end + end + local race_name = get_race_name(hf) + if race_name == '' then --Elf deities don't have a race + full_name = full_name..', Force' + else --Add the race + full_name = full_name..', '..race_name + end + return full_name +end + +function get_art_name(ar) --'Native Name "Translated Name", Item' + local full_name = transName(ar.name, false) + if full_name == '' then --Improve searchability + full_name = 'Unnamed' + else --Add the translation + local t_name = transName(ar.name, true) + if full_name ~= t_name then --Don't repeat + full_name = full_name..' "'..t_name..'"' + end + end + return full_name..', '..dfhack.items.getDescription(ar.item, 1, true) +end + +local function build_hf_list() --Build alphabetized HF list + local t = {} + for _,hf in ipairs(world.history.figures) do + if show_dead or hf.died_year == -1 then --Filter dead + local name = get_hf_name(hf) + local str = toSearch(name) + + if hf.died_year ~= -1 then + name = {{text=name, pen=COLOR_RED}} --Dead + elseif not hf.info or not hf.info.whereabouts then + name = {{text=name, pen=COLOR_YELLOW}} --Deity (usually) + end + table.insert(t, {text=name, id=hf.id, search_key=str}) + end + end + table.sort(t, function(a, b) return a.search_key < b.search_key end) + return t +end + +local function get_id(first, second) --Try to get a numeric id or -1 + return (first >= 0 and first) or (second >= 0 and second) or -1 +end + +local function dead_holder(ar) --Return true if has holder and they're dead + local holder = df.historical_figure.find(get_id(ar.holder_hf, ar.owner_hf)) + return holder and holder.died_year ~= -1 +end + +local function is_book(ar) --Return true if codex/scroll/quire + local item = ar.item + return item._type == df.item_bookst or --We'll ignore slabs, despite legends mode behaviour + (item._type == df.item_toolst and item:hasToolUse(df.tool_uses.CONTAIN_WRITING)) +end + +local function build_art_list() --Build alphabetized artifact list + local t = {} + for _,ar in ipairs(world.artifacts.all) do + local dead = dead_holder(ar) + if (show_dead or not dead) and (show_books or not is_book(ar)) then + local name = get_art_name(ar) + local str = toSearch(name) + + if dead then + name = {{text=name, pen=COLOR_RED}} + end + table.insert(t, {text=name, id=ar.id, search_key=str}) + end + end + table.sort(t, function(a, b) return a.search_key < b.search_key end) + return t +end + +------------------ +-- AdvSelWindow -- +------------------ + +AdvSelWindow = defclass(AdvSelWindow, widgets.Window) +AdvSelWindow.ATTRS{ + frame_title = 'Find Target', + frame = {w=42, h=24, t=22, r=34}, + resizable = true, + visible = false, +} + +function AdvSelWindow:init() + self:addviews{ + widgets.TabBar{ + frame = {t=0}, + labels = { + 'Historical Figures', + 'Artifacts', + }, + on_select = self:callback('swap_tab'), + get_cur_page = function() return cur_tab end, + }, + widgets.FilteredList{ + view_id = 'sel_hf_list', + frame = {t=2, b=2}, + not_found_label = 'No results', + edit_key = 'CUSTOM_ALT_S', + on_submit = self:callback('select_entry'), + visible = false, --Handled in sel_list + }, + widgets.FilteredList{ --setChoices is too slow, don't reuse HF list + view_id = 'sel_art_list', + frame = {t=2, b=2}, + not_found_label = 'No results', + edit_key = 'CUSTOM_ALT_S', + on_submit = self:callback('select_entry'), + visible = false, + }, + widgets.ToggleHotkeyLabel + { + view_id = 'dead_toggle', + frame = {b=0, l=0, w=17, h=1}, + label = 'Show dead:', + key = 'CUSTOM_SHIFT_D', + initial_option = show_dead, + on_change = self:callback('set_show_dead'), + }, + widgets.ToggleHotkeyLabel + { + view_id = 'book_toggle', + frame = {b=0, r=0, w=18, h=1}, + label = 'Show books:', + key = 'CUSTOM_SHIFT_B', + initial_option = show_books, + on_change = self:callback('set_show_books'), + visible = function() return cur_tab ~= 1 end, + }, + } +end + +function AdvSelWindow:get_filter_text() --Get current filter from tab + if cur_tab == 1 then --HF + return self.subviews.sel_hf_list:getFilter() + else --Artifact + return self.subviews.sel_art_list:getFilter() + end +end + +function AdvSelWindow:swap_tab(idx) --Persist filter and swap list + if cur_tab ~= idx then + filter_text = self:get_filter_text() + cur_tab = idx + self:sel_list() + end +end + +function AdvSelWindow:sel_list() --Set correct list for tab + local new, old, build_fn + if cur_tab == 1 then --HF + new = self.subviews.sel_hf_list + old = self.subviews.sel_art_list + build_fn = build_hf_list + else --Artifact + new = self.subviews.sel_art_list + old = self.subviews.sel_hf_list + build_fn = build_art_list + end + + old.visible = false + new.visible = true + if not next(new:getChoices()) then --Empty, build list + new:setChoices(build_fn()) + end + new:setFilter(filter_text) --Restore filter + new.edit:setFocus(old.edit.focus) --Inherit search focus + old.edit:setFocus(false) +end + +function AdvSelWindow:select_entry(sel, obj) --Set correct target for tab + local id = obj and obj.id or -1 + if cur_tab == 1 then --HF + sel_hf, sel_art = id, -1 + else --Artifact + sel_hf, sel_art = -1, id + end +end + +function AdvSelWindow:set_show_dead(show) --Set filtering of dead HFs, rebuild list + show = not not show --To bool + if show == show_dead then + return --No change + end + show_dead = show + filter_text = self:get_filter_text() + self.subviews.sel_hf_list:setChoices() + self.subviews.sel_art_list:setChoices() --Held by HF + self:sel_list() +end + +function AdvSelWindow:set_show_books(show) --Set filtering of books, rebuild list + show = not not show + if show == show_books then + return + end + show_books = show + filter_text = self:get_filter_text() + self.subviews.sel_art_list:setChoices() + self:sel_list() +end + +function AdvSelWindow:onInput(keys) --Close only this window + if keys.LEAVESCREEN or keys._MOUSE_R then + self.visible = false + filter_text = self:get_filter_text() + self.subviews.sel_hf_list:setChoices() + self.subviews.sel_art_list:setChoices() + return true + end + return self.super.onInput(self, keys) +end + +---- Fns for getting adventurer data ---- + +function global_from_local(pos) --Calc global coords (blocks from world origin) from local map pos + return pos and {x = world.map.region_x*3 + pos.x//16, y = world.map.region_y*3 + pos.y//16} or nil +end + +function get_adv_data() --All the coords we can get + local adv = dfhack.world.getAdventurer() + if not adv then --Army exists when unit doesn't + local army = df.army.find(df.global.adventure.player_army_id) + if army then --Should always exist if unit doesn't + return {g_pos = army.pos} + end + return nil --Error + end + return {g_pos = global_from_local(adv.pos), pos = adv.pos} +end + +---- Fns for getting target data ---- + +local function div(n, d) return n//d, n%d end +--We can get the MLT coords of a CZ from its ID (e.g., hf.info.whereabouts.cz_id) +--The g_pos will represent the center of the 3x3 MLT +--In testing, the HF of interest remained in limbo, but it might be of use to someone +function cz_g_pos(cz_id) --Creation zone center in global coords + if not cz_id or cz_id < 0 then return nil end + local w, t, rem = world.world_data.world_width, {}, nil + t.reg_y, rem = div(cz_id, 16*16*w) + t.mlt_y, rem = div(rem, 16*w) + t.reg_x, t.mlt_x = div(rem, 16) + return {x = (t.reg_x*16 + t.mlt_x)*3+1, y = (t.reg_y*16 + t.mlt_y)*3+1} +end + +function site_g_pos(site) --Site center in global coords (blocks from world origin) + local x, y = site.global_min_x, site.global_min_y + x, y = (x + (site.global_max_x - x)//2)*3+1, (y + (site.global_max_y - y)//2)*3+1 + return {x = x, y = y} +end + +local function apply_site_z(site, g_pos) --Improve Z coord using site + local pos = g_pos or site_g_pos(site) --Fall back on site center + pos.z = site.min_depth == site.max_depth and site.min_depth or nil --Single layer site + return pos --Return new table +end + +local function death_at_idx(idx) --Return death location data + if idx then --Dead + local event = world.history.events_death[idx] + return {site = event.site, sr = event.subregion, layer = event.feature_layer} + end + return {site = -1, sr = -1, layer = -1} --Alive +end + +local death_hfid, death_found_idx, death_last_idx --Cache history.events_death data +function get_death_data(hf) --Try to get death location data + if hf.died_year == -1 then --Alive (or undead) + return death_at_idx() + elseif hf.id ~= death_hfid then --Wrong HF, clear cache + death_hfid, death_found_idx, death_last_idx = hf.id, nil, nil + end + local deaths = world.history.events_death + local deaths_end = #deaths-1 + + if death_last_idx and death_last_idx == deaths_end then --No new entries + return death_at_idx(death_found_idx) --Use cached death + end + death_last_idx = death_last_idx or 0 --First time search entire vector + + for i=deaths_end, death_last_idx, -1 do --Iterate new entries backwards + local event = deaths[i] + if event._type == df.history_event_hist_figure_diedst then + if event.victim_hf == hf.id then + death_found_idx = i --Cache HF's most recent death + break + end + elseif event._type == df.history_event_hist_figure_revivest then + if event.histfig == hf.id then --Just in case died_year check failed somehow + death_found_idx = nil --Clear death state + break + end + end + end + death_last_idx = deaths_end --Cache latest index + return death_at_idx(death_found_idx) +end + +local function get_whereabouts(hf) --Return state profile data + local w = hf and hf.info and hf.info.whereabouts + if w then + local g_pos = w.abs_smm_x >= 0 and {x = w.abs_smm_x, y = w.abs_smm_y} or nil + return {site = w.site_id, sr = w.subregion_id, layer = w.feature_layer_id, army = w.army_id, g_pos = g_pos} + end + return {site = -1, sr = -1, layer = -1, army = -1} +end + +function get_hf_data(hf) --Locational data and coords + if not hf then --No target + return nil + end + + local where = get_whereabouts(hf) + for _,unit in ipairs(world.units.active) do + if unit.id == hf.unit_id then --Unit is loaded and active (i.e., player not traveling) + local pos = xyz2pos(dfhack.units.getPosition(unit)) + pos = pos.x >= 0 and pos or nil --Avoid bad coords + local g_pos = global_from_local(pos) or where.g_pos + return {loc_type = LType.Local, g_pos = g_pos, pos = pos} + end + end + local death = get_death_data(hf) + + local site = df.world_site.find(get_id(where.site, death.site)) + if site then --Site + return {loc_type = LType.Site, site = site, g_pos = apply_site_z(site, where.g_pos)} + end + + local sr = df.world_region.find(get_id(where.sr, death.sr)) + if sr then --Surface biome + if where.g_pos then + where.g_pos.z = 0 --Must be surface + end + return {loc_type = LType.Wild, sr = sr, g_pos = where.g_pos} + end + + local layer = df.world_underground_region.find(get_id(where.layer, death.layer)) + if layer then --Cavern layer + if where.g_pos then + where.g_pos.z = layer.layer_depth + end + return {loc_type = LType.Under, g_pos = where.g_pos} + end + + local army = df.army.find(where.army) + if army then --Traveling + return {loc_type = LType.Army, g_pos = army.pos} + end + + if #hf.site_links > 0 then --Try to grab site from links + local site = df.world_site.find(hf.site_links[#hf.site_links-1].site) --Only try last link + if site and utils.binsearch(site.populace.nemesis, hf.nemesis_id) then --HF is present + return {loc_type = LType.Site, site = site, g_pos = apply_site_z(site, where.g_pos)} + end + end + --We'd try cz_g_pos here if it actually helped + return {loc_type = LType.None, g_pos = where.g_pos} --Probably in limbo +end + +function get_art_data(ar) --Locational data and coords + if not ar then --No target + return nil + end + local holder = findHF(get_id(ar.holder_hf, ar.owner_hf)) + local data = get_hf_data(holder) or {loc_type = LType.None} + data.holder = holder + + local g_pos = ar.abs_tile_x >= 0 and {x = ar.abs_tile_x//16, y = ar.abs_tile_y//16} or nil + + for _,item in ipairs(world.items.other.ANY_ARTIFACT) do + if item == ar.item then --Item is nearby if categorized + local pos = xyz2pos(dfhack.items.getPosition(item)) + pos = pos.x >= 0 and pos or nil --Avoid bad coords + g_pos = global_from_local(pos) or g_pos + return {loc_type = LType.Local, holder = holder, g_pos = g_pos, pos = pos} + end + end + + local site = df.world_site.find(get_id(ar.site, ar.storage_site)) + if site then --Site + return {loc_type = LType.Site, site = site, holder = holder, g_pos = apply_site_z(site, g_pos)} + end + + if data.loc_type ~= LType.None then --Inherit from holder (seems lower priority than site) + return data + end + + local sr = df.world_region.find(get_id(ar.subregion, ar.loss_region)) + if sr then --Surface biome + if g_pos then + g_pos.z = 0 --Must be surface + end + return {loc_type = LType.Wild, holder = holder, sr = sr, g_pos = g_pos} + end + + local layer = df.world_underground_region.find(get_id(ar.feature_layer, ar.last_layer)) + if layer then --Cavern layer + if g_pos then + g_pos.z = layer.layer_depth + end + return {loc_type = LType.Under, holder = holder, g_pos = g_pos} + end + + data.g_pos = data.g_pos or g_pos or nil --Try our own if no holder g_pos + return data --Probably in limbo +end + +---- Fns for adventurer info panel ---- + +local compass_dir = { + 'E','ENE','NE','NNE', + 'N','NNW','NW','WNW', + 'W','WSW','SW','SSW', + 'S','SSE','SE','ESE', +} +local compass_pointer = { --Same chars as movement indicators + '>',string.char(191),string.char(191),string.char(191), + '^',string.char(218),string.char(218),string.char(218), + '<',string.char(192),string.char(192),string.char(192), + 'v',string.char(217),string.char(217),string.char(217), +} + +local idx_div_two_pi = 16/(2*math.pi) --16 indices / 2*Pi radians +function compass(dx, dy) --Handy compass strings + if dx*dx + dy*dy == 0 then --On target + return '***', string.char(249) --Char 249 is centered dot + end + local angle = math.atan(-dy, dx) --North is -Y + local index = math.floor(angle*idx_div_two_pi + 16.5)%16 --0.5 helps rounding + return compass_dir[index + 1], compass_pointer[index + 1] +end + +local function insert_text(t, text) --Insert newline before text + if text and text ~= '' then + table.insert(t, NEWLINE) + table.insert(t, text) + end +end + +local function relative_text(t, adv_data, target_data) --Add relative coords and compass + if not target_data then --No target + return + end + if target_data.pos and adv_data.pos then --Use local + local dx = target_data.pos.x - adv_data.pos.x + local dy = target_data.pos.y - adv_data.pos.y + local dir, point = compass(dx, dy) + table.insert(t, NEWLINE) --Improve visibility + insert_text(t, 'Target (local):') + insert_text(t, point..' '..dir) + insert_text(t, ('X%+d Y%+d Z%+d'):format(dx, dy, target_data.pos.z - adv_data.pos.z)) + elseif target_data.g_pos and adv_data.g_pos then --Use global + local dx = target_data.g_pos.x - adv_data.g_pos.x + local dy = target_data.g_pos.y - adv_data.g_pos.y + local dir, point = compass(dx, dy) + table.insert(t, NEWLINE) + insert_text(t, {text='Target (global):', pen=COLOR_GREY}) + insert_text(t, {text=point..' '..dir, pen=COLOR_GREY}) + + local str = ('X%+d Y%+d'):format(dx, dy) + if target_data.g_pos.z and adv_data.g_pos.z then --Use Z if we have it + str = str..(' Z%+d'):format(adv_data.g_pos.z - target_data.g_pos.z) --Negate because it's depth + end + insert_text(t, {text=str, pen=COLOR_GREY}) + end --else insufficient data +end + +local function pos_text(t, g_pos, pos) --Add available coords + if g_pos then + local str = g_pos.z and (' Z'..-g_pos.z) or '' --Use Z if we have it, negate because it's depth + insert_text(t, {text='Global: X'..g_pos.x..' Y'..g_pos.y..str, pen=COLOR_GREY}) + else --Keep compass in consistent spot + table.insert(t, NEWLINE) + end + if pos then + insert_text(t, ('Local: X%d Y%d Z%d'):format(pos.x, pos.y, pos.z)) + else + table.insert(t, NEWLINE) + end +end + +local function adv_text(adv_data, target_data) --Text for adv info panel + if not adv_data then + return 'Error' + end + local t = {'You'} --You, global, local, relative + pos_text(t, adv_data.g_pos, adv_data.pos) + + relative_text(t, adv_data, target_data) + return t +end + +---- Fns for target info panel ---- + +local function insert_name_text(t, name) --HF or artifact name; Return true if both lines + local str = transName(name, false) + if str == '' then + table.insert(t, 'Unnamed') + else --Both native and translation + table.insert(t, str) --Native + local t_name = transName(name, true) + if str ~= t_name then --Don't repeat + insert_text(t, '"'..t_name..'"') + return true + end + end +end + +local function hf_text(hf, target_data) --HF text for target info panel + if not hf or not target_data then --No target + return '' + end + local t = {} --Native, [translated], race, alive, location, global, local + + local both_lines = insert_name_text(t, hf.name) + local str = get_race_name(hf) + insert_text(t, str ~= '' and str or 'Force') + if not both_lines then --Consistent spacing + table.insert(t, NEWLINE) + end + + local eternal --Can't reasonably die + if hf.died_year ~= -1 then + insert_text(t, {text='DEAD', pen=COLOR_RED}) + elseif hf.old_year == -1 and target_data.loc_type == LType.None then + eternal = true --In limbo and can't reasonably die + insert_text(t, {text='ETERNAL', pen=COLOR_LIGHTBLUE}) + else + insert_text(t, {text='ALIVE', pen=COLOR_LIGHTGREEN}) + end + + if target_data.loc_type == LType.None then --Everywhere or nowhere + if eternal then + insert_text(t, {text='Transcendent', pen=COLOR_YELLOW}) + else + insert_text(t, {text='Missing', pen=COLOR_MAGENTA}) + end + else --Physical location + if target_data.loc_type == LType.Local then + insert_text(t, 'Nearby') + elseif target_data.loc_type == LType.Site then + insert_text(t, {text='At '..transName(target_data.site.name, true), pen=COLOR_LIGHTBLUE}) + elseif target_data.loc_type == LType.Army then + insert_text(t, {text='Traveling', pen=COLOR_LIGHTBLUE}) + elseif target_data.loc_type == LType.Wild then + insert_text(t, {text='Wilderness ('..transName(target_data.sr.name, true)..')', pen=COLOR_LIGHTRED}) + elseif target_data.loc_type == LType.Under then + insert_text(t, {text='Underground', pen=COLOR_LIGHTRED}) + else --Undefined loc_type + insert_text(t, {text='Error', pen=COLOR_MAGENTA}) + end + end + pos_text(t, target_data.g_pos, target_data.pos) + return t +end + +local function art_text(art, target_data) --Artifact text for target info panel + if not art or not target_data then --No target + return '' + end + local t = {} --Native, [translated], item_type, [held,] location, global, local + + local both_lines = insert_name_text(t, art.name) + insert_text(t, dfhack.items.getDescription(art.item, 1, true)) + if not both_lines then --Consistent spacing + table.insert(t, NEWLINE) + end + + if target_data.holder then + local str = 'Held by '..transName(target_data.holder.name, false) + insert_text(t, {text=str, pen=(target_data.holder.died_year == -1 and COLOR_LIGHTGREEN or COLOR_RED)}) + else --Consistent spacing + table.insert(t, NEWLINE) + end + + if target_data.loc_type == LType.None then + insert_text(t, {text='Missing', pen=COLOR_MAGENTA}) + elseif target_data.loc_type == LType.Local then + insert_text(t, 'Nearby') + elseif target_data.loc_type == LType.Site then + insert_text(t, {text='At '..transName(target_data.site.name, true), pen=COLOR_LIGHTBLUE}) + elseif target_data.loc_type == LType.Army then + insert_text(t, {text='Traveling', pen=COLOR_LIGHTBLUE}) + elseif target_data.loc_type == LType.Wild then + insert_text(t, {text='Wilderness ('..transName(target_data.sr.name, true)..')', pen=COLOR_LIGHTRED}) + elseif target_data.loc_type == LType.Under then + insert_text(t, {text='Underground', pen=COLOR_LIGHTRED}) + else --Undefined loc_type + insert_text(t, {text='Error', pen=COLOR_MAGENTA}) + end + pos_text(t, target_data.g_pos, target_data.pos) + return t +end + +------------------- +-- AdvFindWindow -- +------------------- + +AdvFindWindow = defclass(AdvFindWindow, widgets.Window) +AdvFindWindow.ATTRS{ + frame_title = 'Finder', + frame = {w=30, h=24, t=22, r=2}, + resizable = true, +} + +function AdvFindWindow:init() + self:addviews{ + widgets.Panel{ + view_id = 'adv_panel', + frame = {t=1, h=9}, + frame_style = gui.FRAME_INTERIOR, + subviews = { + widgets.Label{ + view_id = 'adv_label', + text = '', + frame = {t=0}, + }, + }, + }, + widgets.Panel{ + view_id = 'target_panel', + frame = {t=11}, + frame_style = gui.FRAME_INTERIOR, + subviews = { + widgets.Label{ + view_id = 'target_label', + text = '', + frame = {t=0}, + }, + }, + }, + widgets.ConfigureButton{ + frame = {t=0, r=0}, + on_click = function() + local sel_window = view.subviews.sel_window --AdvSelWindow + sel_window.visible = true + sel_window:sel_list() + end, + } + } +end + +local function set_title(self) --Display target ID in title + if debug_id then + local id = get_id(sel_hf, sel_art) + self.frame_title = 'Finder'..(id ~= -1 and ' (#'..id..')' or '') + else + self.frame_title = 'Finder' + end +end + +function AdvFindWindow:onRenderFrame(dc, rect) + if not dfhack.world.isAdventureMode() then --Could be advfort, etc. + view:dismiss() + print('gui/adv-finder: lost adv mode, dismissing view') + end + self.super.onRenderFrame(self, dc, rect) + + local adv_panel = self.subviews.adv_panel + local target_panel = self.subviews.target_panel + + local target_data + if sel_hf >= 0 then --HF + local target_hf = findHF(sel_hf) + target_data = get_hf_data(target_hf) + target_panel.subviews.target_label:setText(hf_text(target_hf, target_data)) + elseif sel_art >= 0 then --Artifact + local target_art = df.artifact_record.find(sel_art) + target_data = get_art_data(target_art) + target_panel.subviews.target_label:setText(art_text(target_art, target_data)) + else --None + target_panel.subviews.target_label:setText() + end + adv_panel.subviews.adv_label:setText(adv_text(get_adv_data(), target_data)) + + adv_panel:updateLayout() + target_panel:updateLayout() + set_title(self) +end + +------------------- +-- AdvFindScreen -- +------------------- + +AdvFindScreen = defclass(AdvFindScreen, gui.ZScreen) +AdvFindScreen.ATTRS{ + focus_path = 'advfinder', +} + +function AdvFindScreen:init() + self:addviews{ + AdvFindWindow{view_id='find_window'}, + AdvSelWindow{view_id='sel_window'}, + } +end + +function AdvFindScreen:onDismiss() + view = nil +end + +if dfhack_flags.module then + return +end + +if not dfhack.world.isAdventureMode() then + qerror('Adventure mode only!') +end + +dfhack.onStateChange['adv-finder'] = function(sc) + if sc == SC_WORLD_UNLOADED then --Data is world-specific + sel_hf = -1 --Invalidate IDs + sel_art = -1 + filter_text = nil --Probably unwanted + cur_tab = 1 --Reset to first tab, but keep other settings + print('gui/adv-finder: cleared target') + dfhack.onStateChange['adv-finder'] = nil --Do once + end +end + +argparse.processArgsGetopt({...}, { + {'h', 'histfig', handler = function(arg) + sel_hf = math.tointeger(arg) or -1 + sel_art = -1 + end, hasArg = true}, + {'a', 'artifact', handler = function(arg) + sel_art = math.tointeger(arg) or -1 + sel_hf = -1 + end, hasArg = true}, + {'d', 'debug', handler = function() debug_id = true end}, +}) + +view = view and view:raise() or AdvFindScreen{}:show() diff --git a/gui/advfort.lua b/gui/advfort.lua index 9de46a9cf4..1ce1e3f5c6 100644 --- a/gui/advfort.lua +++ b/gui/advfort.lua @@ -69,14 +69,15 @@ build_filter.HUMANish={ --[[ FIXME: maybe let player select which to disable?]] for k,v in ipairs(df.global.plotinfo.economic_stone) do df.global.plotinfo.economic_stone[k]=0 end -local gui = require 'gui' -local wid=require 'gui.widgets' -local dialog=require 'gui.dialogs' -local buildings=require 'dfhack.buildings' -local bdialog=require 'gui.buildings' -local workshopJobs=require 'dfhack.workshops' -local utils=require 'utils' -local gscript=require 'gui.script' +local gui = require('gui') +local guidm = require('gui.dwarfmode') +local wid = require('gui.widgets') +local dialog = require('gui.dialogs') +local buildings = require('dfhack.buildings') +local bdialog = require('gui.buildings') +local workshopJobs = require('dfhack.workshops') +local utils = require('utils') +local gscript = require('gui.script') local advfort_items = reqscript('internal/advfort/advfort_items') @@ -97,7 +98,7 @@ function reverseRaceLookup(id) end function deon_filter(name,type_id,subtype_id,custom_id, parent) --print(name) - local adv=df.global.world.units.active[0] + local adv=dfhack.world.getAdventurer() local race_filter=build_filter[reverseRaceLookup(adv.race)] if race_filter then if race_filter.forbid_all then @@ -195,12 +196,12 @@ end function advGlobalPos() local map=df.global.world.map local wd=df.global.world.world_data - local adv=df.global.world.units.active[0] - --wd.adv_region_x*16+wd.adv_emb_x,wd.adv_region_y*16+wd.adv_emb_y - --return wd.adv_region_x*16+wd.adv_emb_x,wd.adv_region_y*16+wd.adv_emb_y - --return wd.adv_region_x*16+wd.adv_emb_x+adv.pos.x/16,wd.adv_region_y*16+wd.adv_emb_y+adv.pos.y/16 + local adv=dfhack.world.getAdventurer() + --wd.midmap_data.adv_region_x*16+wd.midmap_data.adv_emb_x,wd.midmap_data.adv_region_y*16+wd.midmap_data.adv_emb_y + --return wd.midmap_data.adv_region_x*16+wd.midmap_data.adv_emb_x,wd.midmap_data.adv_region_y*16+wd.midmap_data.adv_emb_y + --return wd.midmap_data.adv_region_x*16+wd.midmap_data.adv_emb_x+adv.pos.x/16,wd.midmap_data.adv_region_y*16+wd.midmap_data.adv_emb_y+adv.pos.y/16 --print(map.region_x,map.region_y,adv.pos.x,adv.pos.y) - --print(map.region_x+adv.pos.x/48, map.region_y+adv.pos.y/48,wd.adv_region_x*16+wd.adv_emb_x,wd.adv_region_y*16+wd.adv_emb_y) + --print(map.region_x+adv.pos.x/48, map.region_y+adv.pos.y/48,wd.midmap_data.adv_region_x*16+wd.midmap_data.adv_emb_x,wd.midmap_data.adv_region_y*16+wd.midmap_data.adv_emb_y) return math.floor(map.region_x+adv.pos.x/48), math.floor(map.region_y+adv.pos.y/48) end function inSite() @@ -242,7 +243,7 @@ function addJobAction(job,unit) --what about job2? if job==nil then error("invalid job") end - if findAction(unit,df.unit_action_type.Job) or findAction(unit,df.unit_action_type.Job2) then + if findAction(unit,df.unit_action_type.Job) or findAction(unit,df.unit_action_type.JobRecover) then print("Already has job action") return end @@ -253,7 +254,7 @@ function addJobAction(job,unit) --what about job2? --job local data={type=df.unit_action_type.Job,data={job={x=pos.x,y=pos.y,z=pos.z,timer=10}}} --job2: - --local data={type=df.unit_action_type.Job2,data={job2={timer=10}}} + --local data={type=df.unit_action_type.JobRecover,data={job2={timer=10}}} add_action(unit,data) --add_action(unit,{type=df.unit_action_type.Unsteady,data={unsteady={timer=5}}}) end @@ -375,7 +376,7 @@ end function AssignUnitToJob(job,unit,unit_pos) job.general_refs:insert("#",{new=df.general_ref_unit_workerst,unit_id=unit.id}) unit.job.current_job=job - unit_pos=unit_pos or {x=job.pos.x,y=job.pos.y,z=job.pos.z} + unit_pos=unit_pos or copyall(job.pos) unit.path.dest:assign(unit_pos) return true end @@ -383,7 +384,7 @@ function SetCreatureRef(args) local job=args.job local pos=args.pos for k,v in pairs(df.global.world.units.active) do - if v.pos.x==pos.x and v.pos.y==pos.y and v.pos.z==pos.z then + if same_xyz(v.pos, pos) then job.general_refs:insert("#",{new=df.general_ref_unit_cageest,unit_id=v.id}) return end @@ -393,7 +394,7 @@ end function SetWebRef(args) local pos=args.pos for k,v in pairs(df.global.world.items.other.ANY_WEBS) do - if v.pos.x==pos.x and v.pos.y==pos.y and v.pos.z==pos.z then + if same_xyz(v.pos, pos) then args.job.general_refs:insert("#",{new=df.general_ref_item,item_id=v.id}) return end @@ -403,7 +404,7 @@ function SetPatientRef(args) local job=args.job local pos=args.pos for k,v in pairs(df.global.world.units.active) do - if v.pos.x==pos.x and v.pos.y==pos.y and v.pos.z==pos.z then + if same_xyz(v.pos, pos) then job.general_refs:insert("#",{new=df.general_ref_unit_patientst,unit_id=v.id}) return end @@ -413,15 +414,14 @@ function SetCarveDir(args) local job=args.job local pos=args.pos local from_pos=args.from_pos - local dirs={up=18,down=19,right=20,left=21} if pos.x>from_pos.x then - job.item_category[dirs.right]=true + job.specflag.carve_track_flags.carve_track_east=true elseif pos.xfrom_pos.y then - job.item_category[dirs.down]=true + job.specflag.carve_track_flags.carve_track_south=true elseif pos.y0 and item_suitable) or settings.build_by_items then --cur_item.flags.in_job=true - job.items:insert("#",{new=true,item=cur_item,role=df.job_item_ref.T_role.Reagent,job_item_idx=job_id}) + job.items:insert("#",{new=true,item=cur_item,role=df.job_role_type.Reagent,job_item_idx=job_id}) item_counts[job_id]=item_counts[job_id]-cur_item:getTotalDimension() --print(string.format("item added, job_item_id=%d, item %s, quantity left=%d",job_id,tostring(cur_item),item_counts[job_id])) used_item_id[cur_item.id]=true @@ -899,7 +899,7 @@ function AssignJobItems(args) job.items:erase(#job.items-1) end]] - if settings.gui_item_select and #job.job_items>0 then + if settings.gui_item_select and #job.job_items.elements>0 then if settings.quick then --TODO not so nice hack. instead of rewriting logic for job item filling i'm using one in gui dialog... local item_editor=advfort_items.jobitemEditor{ job = job, @@ -925,7 +925,7 @@ function AssignJobItems(args) end else if not settings.build_by_items then - for job_id, trg_job_item in ipairs(job.job_items) do + for job_id, trg_job_item in ipairs(job.job_items.elements) do if item_counts[job_id]>0 then print("Not enough items for this job") return false, "Not enough items for this job" @@ -1001,7 +1001,7 @@ function ContinueJob(unit) --reset suspends... c_job.flags.suspend=false for k,v in pairs(c_job.items) do --try fetching missing items - if v.is_fetching==1 then + if v.flags.is_fetching then unit.path.dest:assign(v.item.pos) return end @@ -1022,8 +1022,8 @@ end -- print("AAA FAILED!") -- return false -- end --- args.job.items[0].role=df.job_item_ref.T_role.LinkToTarget --- args.job.items[1].role=df.job_item_ref.T_role.LinkToTrigger +-- args.job.items[0].role=df.job_role_type.LinkToTarget +-- args.job.items[1].role=df.job_role_type.LinkToTrigger -- end function fake_linking(lever,building,slots) local item1=slots[1].items[1] @@ -1179,7 +1179,7 @@ usetool=defclass(usetool,gui.Screen) usetool.focus_path = 'advfort' --luacheck: out=string function usetool:getModeName() - local adv=df.global.world.units.active[0] + local adv=dfhack.world.getAdventurer() local ret if adv.job.current_job then ret= string.format("%s working(%d) ",(actions[(mode or 0)+1][1] or ""),adv.job.current_job.completion_timer) @@ -1198,7 +1198,7 @@ function usetool:update_site() local site_label=self.subviews.siteLabel --as:wid.Label if site then - site_label:itemById("site").text=dfhack.TranslateName(site.name) + site_label:itemById("site").text=dfhack.translation.translateName(site.name) else if settings.safe then site_label:itemById("site").text="" @@ -1233,7 +1233,7 @@ function usetool:init(args) } } } - local labors=df.global.world.units.active[0].status.labors + local labors=dfhack.world.getAdventurer().status.labors for i,v in ipairs(labors) do labors[i]=true end @@ -1277,7 +1277,7 @@ function setFiltersUp(specific,args) --printall(v) local filter=v filter.new=true - job.job_items:insert("#",filter) + job.job_items.elements:insert("#",filter) end return true end @@ -1303,8 +1303,8 @@ function siegeWeaponActionChosen(args,actionid) args.pre_actions={dfhack.curry(setFiltersUp,{items={{quantity=1,item_type=df.SIEGEAMMO}}}),AssignJobItems} end args.job_type=action - args.unit=df.global.world.units.active[0] - local from_pos={x=args.unit.pos.x,y=args.unit.pos.y, z=args.unit.pos.z} + args.unit=dfhack.world.getAdventurer() + local from_pos=copyall(args.unit.pos) args.from_pos=from_pos args.pos=from_pos elseif actionid==3 then --Fire @@ -1313,8 +1313,8 @@ function siegeWeaponActionChosen(args,actionid) action=df.job_type.FireCatapult end args.job_type=action - args.unit=df.global.world.units.active[0] - local from_pos={x=args.unit.pos.x,y=args.unit.pos.y, z=args.unit.pos.z} + args.unit=dfhack.world.getAdventurer() + local from_pos=copyall(args.unit.pos) args.from_pos=from_pos args.pos=from_pos end @@ -1330,10 +1330,10 @@ function putItemToBuilding(building,item) end end function usetool:openPutWindow(building) - local adv=df.global.world.units.active[0] + local adv=dfhack.world.getAdventurer() local items=EnumItems{pos=adv.pos,unit=adv, - inv={[df.unit_inventory_item.T_mode.Hauled]=true,--[df.unit_inventory_item.T_mode.Worn]=true, - [df.unit_inventory_item.T_mode.Weapon]=true,},deep=true} + inv={[df.inv_item_role_type.Hauled]=true,--[df.inv_item_role_type.Worn]=true, + [df.inv_item_role_type.Weapon]=true,},deep=true} local choices={} for k,v in pairs(items) do table.insert(choices,{text=dfhack.items.getDescription(v,0),item=v}) @@ -1346,7 +1346,7 @@ function usetool:openSiegeWindow(building) dfhack.curry(siegeWeaponActionChosen,args)) end function usetool:onWorkShopButtonClicked(building,index,choice) - local adv=df.global.world.units.active[0] + local adv=dfhack.world.getAdventurer() local args={unit=adv,building=building} if df.interface_button_building_new_jobst:is_instance(choice.button) then choice.button:click() @@ -1394,11 +1394,11 @@ function usetool:openShopWindowButtoned(building,no_reset) ,nil, nil,true) end function usetool:openShopWindow(building) - local adv=df.global.world.units.active[0] + local adv=dfhack.world.getAdventurer() local filter_pile=workshopJobs.getJobs(building:getType(),building:getSubtype(),building:getCustomType()) if filter_pile then - local state={unit=adv,from_pos={x=adv.pos.x,y=adv.pos.y, z=adv.pos.z},building=building,screen=self,bld=building} + local state={unit=adv,from_pos=copyall(adv.pos),building=building,screen=self,bld=building} local choices={} for k,v in pairs(filter_pile) do table.insert(choices,{job_id=0,text=v.name:lower(),filter=v}) @@ -1421,20 +1421,20 @@ function track_stop_configure(bld) --TODO: dedicated widget with nice interface local choices={"Friction","Dumping"} local function chosen(index,choice) if choice.text=="Friction" then - dialog.showInputPrompt("Choose friction","Friction",nil,tostring(bld.friction),function ( txt ) + dialog.showInputPrompt("Choose friction","Friction",nil,tostring(bld.track_stop_info.friction),function ( txt ) local num=tonumber(txt) --TODO allow only vanilla friction settings if num then - bld.friction=num + bld.track_stop_info.friction=num end end) else dialog.showListPrompt("Dumping direction", "Choose dumping:",COLOR_WHITE,dump_choices,function ( index,choice) if choice.x then - bld.use_dump=1 --?? - bld.dump_x_shift=choice.x - bld.dump_y_shift=choice.y + bld.track_stop_info.track_flags.use_dump=true + bld.track_stop_info.dump_x_shift=choice.x + bld.track_stop_info.dump_y_shift=choice.y else - bld.use_dump=0 + bld.track_stop_info.track_flags.use_dump=false end end) end @@ -1442,7 +1442,7 @@ function track_stop_configure(bld) --TODO: dedicated widget with nice interface dialog.showListPrompt("Track stop configure", "Choose what to change:",COLOR_WHITE,choices,chosen) end function usetool:armCleanTrap(building) - local adv=df.global.world.units.active[0] + local adv=dfhack.world.getAdventurer() --[[ Lever, PressurePlate, @@ -1488,7 +1488,7 @@ function usetool:armCleanTrap(building) end --luacheck: in=df.building_hivest out=none function usetool:hiveActions(building) - local adv=df.global.world.units.active[0] + local adv=dfhack.world.getAdventurer() local args={unit=adv,post_actions={AssignBuildingRef},pos=adv.pos, from_pos=adv.pos,job_type=df.job_type.InstallColonyInHive,building=building,screen=self} local job_filter={items={{quantity=1,item_type=df.item_type.VERMIN}} } @@ -1499,14 +1499,14 @@ function usetool:hiveActions(building) end function usetool:operatePump(building) --TODO: low priotity, but would be nice to have the job auto cleanup (i.e. one work would only pump and then you could press it again) - local adv=df.global.world.units.active[0] + local adv=dfhack.world.getAdventurer() local set_operate=function ( args ) args.building.pump_manually=true end makeJob{unit=adv,building=building,post_actions={AssignBuildingRef,set_operate},pos=adv.pos,from_pos=adv.pos,job_type=df.job_type.OperatePump,screen=self} end function usetool:farmPlot(building) - local adv=df.global.world.units.active[0] + local adv=dfhack.world.getAdventurer() local do_harvest=false for id, con_item in pairs(building.contained_items) do if con_item.use_mode==2 and con_item.item:getType()==df.item_type.PLANT then @@ -1532,14 +1532,14 @@ function usetool:farmPlot(building) end --luacheck: in=df.building_bedst out=none function usetool:bedActions(building) - local adv=df.global.world.units.active[0] + local adv=dfhack.world.getAdventurer() local args={unit=adv,pos=adv.pos,from_pos=adv.pos,screen=self,building=building, job_type=df.job_type.Sleep,post_actions={AssignBuildingRef}} makeJob(args) end --luacheck: in=df.building_chairst out=none function usetool:chairActions(building) - local adv=df.global.world.units.active[0] + local adv=dfhack.world.getAdventurer() local eatjob={items={{quantity=1,item_type=df.item_type.FOOD}}} local args={unit=adv,pos=adv.pos,from_pos=adv.pos,screen=self,job_type=df.job_type.Eat,building=building, pre_actions={dfhack.curry(setFiltersUp,eatjob),AssignJobItems},post_actions={AssignBuildingRef}} @@ -1641,7 +1641,7 @@ end function usetool:setupFields() local ui=df.global.plotinfo - local adv=df.global.world.units.active[0] + local adv=dfhack.world.getAdventurer() if settings.set_civ==true then ui.civ_id=adv.civ_id ui.race_id=adv.race @@ -1677,7 +1677,7 @@ function usetool:siteCheck() end --movement and co... Also passes on allowed keys function usetool:fieldInput(keys) - local adv=df.global.world.units.active[0] + local adv=dfhack.world.getAdventurer() local cur_mode=actions[(mode or 0)+1] local failed=false for code,_ in pairs(keys) do @@ -1688,15 +1688,16 @@ function usetool:fieldInput(keys) unit=adv, pos=moddedpos(adv.pos,MOVEMENT_KEYS[code]), dir=MOVEMENT_KEYS[code], - from_pos={x=adv.pos.x,y=adv.pos.y, z=adv.pos.z}, + from_pos=copyall(adv.pos), post_actions=cur_mode[4], pre_actions=cur_mode[5], job_type=cur_mode[2], screen=self} if code=="SELECT" then --do job in the distance, TODO: check if you can still cheat-mine (and co.) remotely - if df.global.cursor.x~=-30000 then - state.pos={x=df.global.cursor.x,y=df.global.cursor.y,z=df.global.cursor.z} + local cursor=guidm.getCursorPos() + if cursor then + state.pos=cursor else break end @@ -1745,10 +1746,10 @@ function usetool:onInput(keys) self:update_site() - local adv=df.global.world.units.active[0] + local adv=dfhack.world.getAdventurer() if keys.LEAVESCREEN then - if df.global.cursor.x~=-30000 then --if not poiting at anything + if guidm.getCursorPos() then --if not poiting at anything self:sendInputToParent("LEAVESCREEN") --leave poiting else self:dismiss() --leave the adv-tools all together @@ -1787,7 +1788,7 @@ function usetool:cancel_wait() self.long_wait=false end function usetool:onIdle() - local adv=df.global.world.units.active[0] + local adv=dfhack.world.getAdventurer() local job_ptr=adv.job.current_job local job_action=findAction(adv,df.unit_action_type.Job) @@ -1826,7 +1827,7 @@ function usetool:onIdle() self._native.parent:logic() end function usetool:isOnBuilding() - local adv=df.global.world.units.active[0] + local adv=dfhack.world.getAdventurer() local bld=dfhack.buildings.findAtTile(adv.pos) if bld and MODES[bld:getType()]~=nil and bld:getBuildStage()==bld:getMaxBuildStage() then return true,MODES[bld:getType()],bld @@ -1838,7 +1839,7 @@ function usetool:onRenderBody(dc) self:shopMode(self:isOnBuilding()) self:renderParent() end -if not (dfhack.gui.getCurFocus()=="dungeonmode/Look" or dfhack.gui.getCurFocus()=="dungeonmode/Default") then +if not (dfhack.gui.matchFocusString('dungeonmode/Look') or dfhack.gui.matchFocusString('dungeonmode/Default')) then qerror("This script requires an adventurer mode with (l)ook or default mode.") end usetool():show() diff --git a/gui/aquifer.lua b/gui/aquifer.lua new file mode 100644 index 0000000000..5685a750a7 --- /dev/null +++ b/gui/aquifer.lua @@ -0,0 +1,185 @@ +local dig = require('plugins.dig') +local gui = require('gui') +local widgets = require('gui.widgets') + +local selection_rect = df.global.selection_rect + +local function reset_selection_rect() + selection_rect.start_x = -30000 + selection_rect.start_y = -30000 + selection_rect.start_z = -30000 +end + +-- +-- Aquifer +-- + +Aquifer = defclass(Aquifer, widgets.Window) +Aquifer.ATTRS { + frame_title='Aquifer', + frame={w=38, h=15, r=2, t=18}, + autoarrange_subviews=true, + autoarrange_gap=1, + resizable=true, +} + +function Aquifer:init() + self:addviews{ + widgets.Label{ + frame={t=0, l=0}, + text='Select map area to modify', + }, + widgets.CycleHotkeyLabel{ + view_id='action', + key='CUSTOM_CTRL_E', + label='Action:', + options={ + {label='Drain', value='drain', pen=COLOR_LIGHTRED}, + {label='Convert', value='convert', pen=COLOR_YELLOW}, + {label='Add', value='add', pen=COLOR_BLUE}, + }, + on_change=function(val) + if val ~= 'drain' and self.subviews.aq_type:getOptionValue() == 'all' then + self.subviews.aq_type:cycle() + end + end, + }, + widgets.CycleHotkeyLabel{ + view_id='aq_type', + key='CUSTOM_CTRL_T', + label=function() + if self.subviews.action:getOptionValue() == 'convert' then + return 'To aquifer type:' + end + return 'Aquifer type:' + end, + options={ + {label='All', value='all', pen=COLOR_LIGHTRED}, + {label='Light', value='light', pen=COLOR_LIGHTBLUE}, + {label='Heavy', value='heavy', pen=COLOR_BLUE}, + }, + initial_option='all', + on_change=function(val) + if val == 'all' and self.subviews.action:getOptionValue() ~= 'drain' then + self.subviews.aq_type:cycle() + end + end, + }, + widgets.ToggleHotkeyLabel{ + view_id='leaky', + key='CUSTOM_CTRL_K', + label=function() + if self.subviews.action:getOptionValue() == 'add' then + return 'Allow immediate leaks:' + end + return 'Affect only leaks:' + end, + initial_option=false, + }, + widgets.Divider{ + frame={h=1}, + frame_style=gui.FRAME_THIN, + frame_style_l=false, + frame_style_r=false, + }, + widgets.HotkeyLabel{ + key='CUSTOM_CTRL_A', + label='Apply to entire level now', + on_activate=self:callback('action_level'), + }, + } +end + +function Aquifer:onRenderFrame(dc, rect) + dig.paintScreenWarmDamp(true, false) + Aquifer.super.onRenderFrame(self, dc, rect) +end + +function Aquifer:onInput(keys) + if Aquifer.super.onInput(self, keys) then return true end + + if keys.LEAVESCREEN or keys._MOUSE_R then + if selection_rect.start_x >= 0 then + reset_selection_rect() + return true + end + return false + end + + local pos = nil + if keys._MOUSE_L and not self:getMouseFramePos() then + pos = dfhack.gui.getMousePos() + end + + if pos then + if selection_rect.start_x >= 0 then + self:action_box(pos) + reset_selection_rect() + else + -- set this again just in case it got unset somehow + df.global.game.main_interface.main_designation_selected = df.main_designation_type.TOGGLE_ENGRAVING + -- use selection_rect so gui/design can display the dimensions overlay + selection_rect.start_x = pos.x + selection_rect.start_y = pos.y + selection_rect.start_z = pos.z + end + return true + end +end + +function Aquifer:get_base_command() + local command = {'aquifer', '-q'} + table.insert(command, self.subviews.action:getOptionValue()) + local aq_type = self.subviews.aq_type:getOptionValue() + if aq_type ~= 'all' then + table.insert(command, aq_type) + end + if self.subviews.leaky:getOptionValue() then + table.insert(command, '--leaky') + end + return command +end + +function Aquifer:action_level() + local command = self:get_base_command() + table.insert(command, '-z') + dfhack.run_command(command) +end + +function Aquifer:action_box(pos) + local command = self:get_base_command() + table.insert(command, ('%d,%d,%d'): + format(selection_rect.start_x, selection_rect.start_y, selection_rect.start_z)) + table.insert(command, ('%d,%d,%d'):format(pos.x, pos.y, pos.z)) + dfhack.run_command(command) +end + +-- +-- AquiferScreen +-- + +AquiferScreen = defclass(AquiferScreen, gui.ZScreen) +AquiferScreen.ATTRS { + focus_path='aquifer', + pass_movement_keys=true, + pass_mouse_clicks=false, +} + +function AquiferScreen:init() + self.saved_designation_type = df.global.game.main_interface.main_designation_selected + df.global.game.main_interface.main_designation_selected = df.main_designation_type.TOGGLE_ENGRAVING + + self:addviews{Aquifer{}} +end + +function AquiferScreen:onDismiss() + reset_selection_rect() + df.global.game.main_interface.main_designation_selected = self.saved_designation_type + view = nil +end + +if not dfhack.isMapLoaded() then + qerror('This script requires a map to be loaded') +end + +view = view and view:raise() or AquiferScreen{}:show() diff --git a/gui/autobutcher.lua b/gui/autobutcher.lua index 51c4b1b60f..b5f4bb7857 100644 --- a/gui/autobutcher.lua +++ b/gui/autobutcher.lua @@ -1,270 +1,597 @@ --- A GUI front-end for the autobutcher plugin. -local gui = require 'gui' -local utils = require 'utils' -local widgets = require 'gui.widgets' -local dlg = require 'gui.dialogs' +local dlg = require('gui.dialogs') +local gui = require('gui') +local plugin = require('plugins.autobutcher') +local widgets = require('gui.widgets') -local plugin = require 'plugins.autobutcher' +local CH_UP = string.char(30) +local CH_DN = string.char(31) + +local racewidth = 25 -- width of the race name column in the UI WatchList = defclass(WatchList, gui.ZScreen) WatchList.ATTRS{ focus_string='autobutcher', } --- width of the race name column in the UI -local racewidth = 25 +local function sort_noop(a, b) + -- this function is used as a marker and never actually gets called + error('sort_noop should not be called') +end + +local function either_are_special(a, b) + return type(a.race) == 'number' or type(b.race) == 'number' +end + +local function sort_by_race_desc(a, b) + if type(a.race) == 'number' then + if type(b.race) == 'number' then + return a.race < b.race + end + return true + elseif type(b.race) == 'number' then + return false + end + return a.race < b.race +end + +local function sort_by_race_asc(a, b) + if type(a.race) == 'number' then + if type(b.race) == 'number' then + return a.race < b.race + end + return true + elseif type(b.race) == 'number' then + return false + end + return a.race > b.race +end + +local function sort_by_total_desc(a, b) + if either_are_special(a, b) or a.total == b.total then + return sort_by_race_desc(a, b) + end + return a.total > b.total +end + +local function sort_by_total_asc(a, b) + if either_are_special(a, b) or a.total == b.total then + return sort_by_race_desc(a, b) + end + return a.total < b.total +end + +local function sort_by_fk_desc(a, b) + if either_are_special(a, b) or a.data.fk_total == b.data.fk_total then + return sort_by_race_desc(a, b) + end + return a.data.fk_total > b.data.fk_total +end + +local function sort_by_fk_asc(a, b) + if either_are_special(a, b) or a.data.fk_total == b.data.fk_total then + return sort_by_race_desc(a, b) + end + return a.data.fk_total < b.data.fk_total +end + +local function sort_by_fa_desc(a, b) + if either_are_special(a, b) or a.data.fa_total == b.data.fa_total then + return sort_by_race_desc(a, b) + end + return a.data.fa_total > b.data.fa_total +end + +local function sort_by_fa_asc(a, b) + if either_are_special(a, b) or a.data.fa_total == b.data.fa_total then + return sort_by_race_desc(a, b) + end + return a.data.fa_total < b.data.fa_total +end + +local function sort_by_mk_desc(a, b) + if either_are_special(a, b) or a.data.mk_total == b.data.mk_total then + return sort_by_race_desc(a, b) + end + return a.data.mk_total > b.data.mk_total +end -function nextAutowatchState() - if(plugin.autowatch_isEnabled()) then - return 'Stop ' +local function sort_by_mk_asc(a, b) + if either_are_special(a, b) or a.data.mk_total == b.data.mk_total then + return sort_by_race_desc(a, b) end - return 'Start' + return a.data.mk_total < b.data.mk_total end -function nextAutobutcherState() - if(plugin.isEnabled()) then - return 'Stop ' +local function sort_by_ma_desc(a, b) + if either_are_special(a, b) or a.data.ma_total == b.data.ma_total then + return sort_by_race_desc(a, b) end - return 'Start' -end - -function getSleepTimer() - return plugin.autobutcher_getSleep() -end - -function setSleepTimer(ticks) - plugin.autobutcher_setSleep(ticks) -end - -function WatchList:init(args) - local colwidth = 7 - self:addviews{ - widgets.Window{ - view_id = 'main', - frame_title = 'Autobutcher Watchlist', - frame = { w=84, h=30 }, - resizable = true, - subviews = { - widgets.Label{ - frame = { l = 0, t = 0 }, - text_pen = COLOR_CYAN, - text = { - { text = 'Race', width = racewidth }, ' ', - { text = 'female', width = colwidth }, ' ', - { text = ' male', width = colwidth }, ' ', - { text = 'Female', width = colwidth }, ' ', - { text = ' Male', width = colwidth }, ' ', - { text = 'watch? ' }, - { text = ' butchering' }, - NEWLINE, - { text = '', width = racewidth }, ' ', - { text = ' kids', width = colwidth }, ' ', - { text = ' kids', width = colwidth }, ' ', - { text = 'adults', width = colwidth }, ' ', - { text = 'adults', width = colwidth }, ' ', - { text = ' ' }, - { text = ' ordered' }, - } + return a.data.ma_total > b.data.ma_total +end + +local function sort_by_ma_asc(a, b) + if either_are_special(a, b) or a.data.ma_total == b.data.ma_total then + return sort_by_race_desc(a, b) + end + return a.data.ma_total < b.data.ma_total +end + +local function sort_by_watched_desc(a, b) + if either_are_special(a, b) or a.data.watched == b.data.watched then + return sort_by_race_desc(a, b) + end + return a.data.watched +end + +local function sort_by_watched_asc(a, b) + if either_are_special(a, b) or a.data.watched == b.data.watched then + return sort_by_race_desc(a, b) + end + return b.data.watched +end + +local function sort_by_ordered_desc(a, b) + if either_are_special(a, b) or a.ordered == b.ordered then + return sort_by_race_desc(a, b) + end + return a.ordered > b.ordered +end + +local function sort_by_ordered_asc(a, b) + if either_are_special(a, b) or a.ordered == b.ordered then + return sort_by_race_desc(a, b) + end + return a.ordered < b.ordered +end + +function WatchList:init() + if plugin.isEnabled() then + -- ensure slaughter counts and autowatch are up to date + dfhack.run_command('autobutcher', 'now') + end + + local window = widgets.Window{ + frame_title='Autobutcher', + frame={w=97, h=31}, + resizable=true, + subviews={ + widgets.CycleHotkeyLabel{ + view_id='sort', + frame={l=0, t=0, w=31}, + label='Sort by:', + key='CUSTOM_SHIFT_S', + options={ + {label='Total stock'..CH_DN, value=sort_by_total_desc}, + {label='Total stock'..CH_UP, value=sort_by_total_asc}, + {label='Race'..CH_DN, value=sort_by_race_desc}, + {label='Race'..CH_UP, value=sort_by_race_asc}, + {label='female kids'..CH_DN, value=sort_by_fk_desc}, + {label='female kids'..CH_UP, value=sort_by_fk_asc}, + {label='male kids'..CH_DN, value=sort_by_mk_desc}, + {label='make kids'..CH_UP, value=sort_by_mk_asc}, + {label='Female adults'..CH_DN, value=sort_by_fa_desc}, + {label='Female adults'..CH_UP, value=sort_by_fa_asc}, + {label='Male adults'..CH_DN, value=sort_by_ma_desc}, + {label='Male adults'..CH_UP, value=sort_by_ma_asc}, + {label='Watch?'..CH_DN, value=sort_by_watched_desc}, + {label='Watch?'..CH_UP, value=sort_by_watched_asc}, + {label='Butchering ordered'..CH_DN, value=sort_by_ordered_desc}, + {label='Butchering ordered'..CH_UP, value=sort_by_ordered_asc}, + }, + initial_option=sort_by_total_desc, + on_change=self:callback('refresh', 'sort'), + }, + widgets.ToggleHotkeyLabel{ + view_id='hide_zero', + frame={t=0, l=35, w=49}, + key='CUSTOM_CTRL_H', + label='Show only rows with non-zero targets', + on_change=self:callback('refresh', 'sort'), + }, + widgets.Panel{ + view_id='list_panel', + frame={t=2, l=0, r=0, b=8}, + frame_style=gui.FRAME_INTERIOR, + subviews={ + widgets.CycleHotkeyLabel{ + view_id='sort_total', + frame={t=0, l=0, w=6}, + options={ + {label='Total', value=sort_noop}, + {label='Total'..CH_DN, value=sort_by_total_desc}, + {label='Total'..CH_UP, value=sort_by_total_asc}, + }, + initial_option=sort_by_total_desc, + option_gap=0, + on_change=self:callback('refresh', 'sort_total'), + }, + widgets.Label{ + frame={t=1, l=0}, + text='stock' + }, + widgets.CycleHotkeyLabel{ + view_id='sort_race', + frame={t=0, l=8, w=5}, + options={ + {label='Race', value=sort_noop}, + {label='Race'..CH_DN, value=sort_by_race_desc}, + {label='Race'..CH_UP, value=sort_by_race_asc}, + }, + option_gap=0, + on_change=self:callback('refresh', 'sort_race'), + }, + widgets.CycleHotkeyLabel{ + view_id='sort_fk', + frame={t=0, l=37, w=7}, + options={ + {label='female', value=sort_noop}, + {label='female'..CH_DN, value=sort_by_fk_desc}, + {label='female'..CH_UP, value=sort_by_fk_asc}, + }, + option_gap=0, + on_change=self:callback('refresh', 'sort_fk'), + }, + widgets.Label{ + frame={t=1, l=38}, + text='kids' + }, + widgets.CycleHotkeyLabel{ + view_id='sort_mk', + frame={t=0, l=47, w=5}, + options={ + {label='male', value=sort_noop}, + {label='male'..CH_DN, value=sort_by_mk_desc}, + {label='male'..CH_UP, value=sort_by_mk_asc}, + }, + option_gap=0, + on_change=self:callback('refresh', 'sort_mk'), + }, + widgets.Label{ + frame={t=1, l=47}, + text='kids' + }, + widgets.CycleHotkeyLabel{ + view_id='sort_fa', + frame={t=0, l=55, w=7}, + options={ + {label='Female', value=sort_noop}, + {label='Female'..CH_DN, value=sort_by_fa_desc}, + {label='Female'..CH_UP, value=sort_by_fa_asc}, + }, + option_gap=0, + on_change=self:callback('refresh', 'sort_fa'), + }, + widgets.Label{ + frame={t=1, l=55}, + text='adults' + }, + widgets.CycleHotkeyLabel{ + view_id='sort_ma', + frame={t=0, l=65, w=5}, + options={ + {label='Male', value=sort_noop}, + {label='Male'..CH_DN, value=sort_by_ma_desc}, + {label='Male'..CH_UP, value=sort_by_ma_asc}, + }, + option_gap=0, + on_change=self:callback('refresh', 'sort_ma'), + }, + widgets.Label{ + frame={t=1, l=64}, + text='adults' + }, + widgets.CycleHotkeyLabel{ + view_id='sort_watched', + frame={t=0, l=72, w=7}, + options={ + {label='Watch?', value=sort_noop}, + {label='Watch?'..CH_DN, value=sort_by_watched_desc}, + {label='Watch?'..CH_UP, value=sort_by_watched_asc}, + }, + option_gap=0, + on_change=self:callback('refresh', 'sort_watched'), + }, + widgets.CycleHotkeyLabel{ + view_id='sort_ordered', + frame={t=0, l=81, w=11}, + options={ + {label='Butchering', value=sort_noop}, + {label='Butchering'..CH_DN, value=sort_by_ordered_desc}, + {label='Butchering'..CH_UP, value=sort_by_ordered_asc}, + }, + option_gap=0, + on_change=self:callback('refresh', 'sort_ordered'), + }, + widgets.Label{ + frame={t=1, l=82}, + text='ordered' + }, + widgets.Label{ + view_id='disabled_warning', + visible=function() return not plugin.isEnabled() end, + frame={t=3, l=8, h=1}, + text={"Enable autobutcher to change settings"}, + text_pen=COLOR_YELLOW + }, + widgets.List{ + view_id='list', + frame={t=3, b=0}, + visible=plugin.isEnabled, + on_double_click = self:callback('onDoubleClick'), + on_double_click2 = self:callback('zeroOut'), + }, }, - widgets.List{ - view_id = 'list', - frame = { t = 3, b = 5 }, - not_found_label = 'Watchlist is empty.', - text_pen = { fg = COLOR_GREY, bg = COLOR_BLACK }, - cursor_pen = { fg = COLOR_BLACK, bg = COLOR_GREEN }, - --on_select = self:callback('onSelectEntry'), + }, + widgets.Panel{ + view_id='footer', + frame={l=0, r=0, b=0, h=7}, + subviews={ + widgets.Label{ + frame={t=0, l=0}, + text={ + 'Columns show butcherable stock (+ protected stock, if any) / target: ', NEWLINE, + 'Double click on a value to edit/toggle or use the hotkeys listed below.' + } + }, + widgets.HotkeyLabel{ + view_id='fk', + frame={t=3, l=0}, + key='CUSTOM_F', + label='f kids', + auto_width=true, + on_activate=self:callback('editVal', 'female kids', 'fk'), + }, + widgets.HotkeyLabel{ + view_id='mk', + frame={t=4, l=0}, + key='CUSTOM_M', + label='m kids', + auto_width=true, + on_activate=self:callback('editVal', 'male kids', 'mk'), + }, + widgets.HotkeyLabel{ + view_id='fa', + frame={t=3, l=17}, + key='CUSTOM_SHIFT_F', + label='F adults', + auto_width=true, + on_activate=self:callback('editVal', 'female adults', 'fa'), + }, + widgets.HotkeyLabel{ + view_id='ma', + frame={t=4, l=17}, + key='CUSTOM_SHIFT_M', + label='M adults', + auto_width=true, + on_activate=self:callback('editVal', 'male adults', 'ma'), + }, + widgets.HotkeyLabel{ + view_id='butcher', + frame={t=5, l=0}, + key='CUSTOM_B', + label='Butcher race', + auto_width=true, + on_activate=self:callback('onButcherRace'), + }, + widgets.HotkeyLabel{ + view_id='unbutcher', + frame={t=5, l=17}, + key='CUSTOM_SHIFT_B', + label='Unbutcher race', + auto_width=true, + on_activate=self:callback('onUnbutcherRace'), + }, + widgets.HotkeyLabel{ + view_id='watch', + frame={t=3, l=36}, + key='CUSTOM_W', + label='Toggle watch', + auto_width=true, + on_activate=self:callback('onToggleWatching'), + }, + widgets.HotkeyLabel{ + frame={t=4, l=36}, + key='CUSTOM_X', + label='Delete row', + auto_width=true, + on_activate=self:callback('onDeleteEntry'), + }, + widgets.HotkeyLabel{ + frame={t=3, l=54}, + key='CUSTOM_R', + label='Set row targets to 0', + auto_width=true, + on_activate=self:callback('zeroOut'), + }, + widgets.HotkeyLabel{ + frame={t=4, l=54}, + key='CUSTOM_SHIFT_R', + label='Set row targets to N', + auto_width=true, + on_activate=self:callback('onSetRow'), + }, + widgets.HotkeyLabel{ + view_id='butcher_all', + frame={t=5, l=36}, + key='CUSTOM_E', + label='Butcher all', + auto_width=true, + on_activate=self:callback('onButcherAll'), + }, + widgets.HotkeyLabel{ + view_id='unbutcher_all', + frame={t=5, l=54}, + key='CUSTOM_SHIFT_E', + label='Unbutcher all', + auto_width=true, + on_activate=self:callback('onUnbutcherAll'), + }, + widgets.ToggleHotkeyLabel{ + view_id='enable_toggle', + frame={t=6, l=0, w=26}, + label='Autobutcher is', + key='CUSTOM_SHIFT_A', + options={{value=true, label='Enabled', pen=COLOR_GREEN}, + {value=false, label='Disabled', pen=COLOR_RED}}, + on_change=function(val) + plugin.setEnabled(val) + self:refresh() + end, + }, + widgets.ToggleHotkeyLabel{ + view_id='autowatch_toggle', + frame={t=6, l=36, w=24}, + label='Autowatch is', + key='CUSTOM_SHIFT_W', + options={{value=true, label='Enabled', pen=COLOR_GREEN}, + {value=false, label='Disabled', pen=COLOR_RED}}, + on_change=function(val) + plugin.autowatch_setEnabled(val) + self:refresh() + end, + }, }, - widgets.Label{ - view_id = 'bottom_ui', - frame = { b = 0, h = 1 }, - text = 'filled by updateBottom()' - } - } + }, }, } - self:initListChoices() - self:updateBottom() + self:addviews{window} + self:refresh() + self.subviews.list:setSelected(#self.subviews.list:getChoices() > 2 and 3 or 2) end --- change the viewmode for stock data displayed in left section of columns -local viewmodes = { 'total stock', 'protected stock', 'butcherable', 'butchering ordered' } -local viewmode = 1 -function WatchList:onToggleView() - if viewmode < #viewmodes then - viewmode = viewmode + 1 - else - viewmode = 1 - end - self:initListChoices() - self:updateBottom() -end - --- update the bottom part of the UI (after sleep timer changed etc) -function WatchList:updateBottom() - self.subviews.bottom_ui:setText( - { - { key = 'CUSTOM_SHIFT_V', text = ': View in colums shows: '..viewmodes[viewmode]..' / target max', - on_activate = self:callback('onToggleView') }, NEWLINE, - { key = 'CUSTOM_F', text = ': f kids', - on_activate = self:callback('onEditFK') }, ', ', - { key = 'CUSTOM_M', text = ': m kids', - on_activate = self:callback('onEditMK') }, ', ', - { key = 'CUSTOM_SHIFT_F', text = ': f adults', - on_activate = self:callback('onEditFA') }, ', ', - { key = 'CUSTOM_SHIFT_M', text = ': m adults', - on_activate = self:callback('onEditMA') }, '. ', - { key = 'CUSTOM_W', text = ': Toggle watch', - on_activate = self:callback('onToggleWatching') }, '. ', - { key = 'CUSTOM_X', text = ': Delete', - on_activate = self:callback('onDeleteEntry') }, '. ', NEWLINE, - --{ key = 'CUSTOM_A', text = ': Add race', - -- on_activate = self:callback('onAddRace') }, ', ', - { key = 'CUSTOM_SHIFT_R', text = ': Set whole row', - on_activate = self:callback('onSetRow') }, '. ', - { key = 'CUSTOM_B', text = ': Remove butcher orders', - on_activate = self:callback('onUnbutcherRace') }, '. ', - { key = 'CUSTOM_SHIFT_B', text = ': Butcher race', - on_activate = self:callback('onButcherRace') }, '. ', NEWLINE, - { key = 'CUSTOM_SHIFT_A', text = ': '..nextAutobutcherState()..' Autobutcher', - on_activate = self:callback('onToggleAutobutcher') }, '. ', - { key = 'CUSTOM_SHIFT_W', text = ': '..nextAutowatchState()..' Autowatch', - on_activate = self:callback('onToggleAutowatch') }, '. ', - { key = 'CUSTOM_SHIFT_S', text = ': Sleep ('..getSleepTimer()..' ticks)', - on_activate = self:callback('onEditSleepTimer') }, '. ', - }) +function WatchList:onRenderFrame(dc, rect) + self.subviews.enable_toggle:setOption(plugin.isEnabled()) + self.subviews.autowatch_toggle:setOption(plugin.autowatch_isEnabled()) + WatchList.super.onRenderFrame(self, dc, rect) end function stringify(number) - -- cap displayed number to 3 digits - -- after population of 50 per race is reached pets stop breeding anyways - -- so probably this could safely be reduced to 99 - local max = 999 - if number > max then number = max end + if not number then return '' end + -- cap displayed number to 2 characters to fit in the column width + if number > 99 then + return '++' + end return tostring(number) end -function WatchList:initListChoices() +local SORT_WIDGETS = { + 'sort', + 'sort_total', + 'sort_race', + 'sort_fk', + 'sort_mk', + 'sort_fa', + 'sort_ma', + 'sort_watched', + 'sort_ordered' +} + +local function make_count_text(butcherable, protected) + local str = protected and protected > 0 and ('+%s'):format(stringify(protected)) or '' + str = stringify(butcherable) .. str + return str .. (#str > 0 and '/' or ' ') +end + +local function make_row_text(race, data, total, ordered) + -- highlight entries where the target quota can't be met because too many are protected + local fk_pen = (data.fk_protected or 0) > data.fk and COLOR_LIGHTRED or nil + local fa_pen = (data.fa_protected or 0) > data.fa and COLOR_LIGHTRED or nil + local mk_pen = (data.mk_protected or 0) > data.mk and COLOR_LIGHTRED or nil + local ma_pen = (data.ma_protected or 0) > data.ma and COLOR_LIGHTRED or nil + + local watched = data.watched == nil and '' or (data.watched and 'yes' or 'no') + + return { + {text=total or '', width=5, rjustify=true, pad_char=' '}, ' ', + {text=race, width=racewidth, pad_char=' '}, ' ', + {text=make_count_text(data.fk_butcherable, data.fk_protected), width=6, rjustify=true, pad_char=' '}, + {text=data.fk, width=2, pen=fk_pen, pad_char=' '}, ' ', + {text=make_count_text(data.mk_butcherable, data.mk_protected), width=6, rjustify=true, pad_char=' '}, + {text=data.mk, width=2, pen=mk_pen, pad_char=' '}, ' ', + {text=make_count_text(data.fa_butcherable, data.fa_protected), width=6, rjustify=true, pad_char=' '}, + {text=data.fa, width=2, pen=fa_pen, pad_char=' '}, ' ', + {text=make_count_text(data.ma_butcherable, data.ma_protected), width=6, rjustify=true, pad_char=' '}, + {text=data.ma, width=2, pen=ma_pen, pad_char=' '}, ' ', + {text=watched, width=7, rjustify=true, pad_char=' '}, ' ', + {text=ordered or '', width=8, rjustify=true, pad_char=' '}, + } +end + +function WatchList:refresh(sort_widget, sort_fn) + sort_widget = sort_widget or 'sort' + sort_fn = sort_fn or self.subviews.sort:getOptionValue() + if sort_fn == sort_noop then + self.subviews[sort_widget]:cycle() + return + end + for _,widget_name in ipairs(SORT_WIDGETS) do + self.subviews[widget_name]:setOption(sort_fn) + end local choices = {} - -- first two rows are for "edit all races" and "edit new races" local settings = plugin.autobutcher_getSettings() - local fk = stringify(settings.fk) - local fa = stringify(settings.fa) - local mk = stringify(settings.mk) - local ma = stringify(settings.ma) - - local watched = '' - - local colwidth = 7 - - table.insert (choices, { - text = { - { text = '!! ALL RACES PLUS NEW', width = racewidth, pad_char = ' ' }, --' ', - { text = ' ', width = 3, rjustify = true, pad_char = ' ' }, ' ', - { text = fk, width = 3, rjustify = false, pad_char = ' ' }, ' ', - { text = ' ', width = 3, rjustify = true, pad_char = ' ' }, ' ', - { text = mk, width = 3, rjustify = false, pad_char = ' ' }, ' ', - { text = ' ', width = 3, rjustify = true, pad_char = ' ' }, ' ', - { text = fa, width = 3, rjustify = false, pad_char = ' ' }, ' ', - { text = ' ', width = 3, rjustify = true, pad_char = ' ' }, ' ', - { text = ma, width = 3, rjustify = false, pad_char = ' ' }, ' ', - { text = watched, width = 6, rjustify = true } - } - }) - table.insert (choices, { - text = { - { text = '!! ONLY NEW RACES', width = racewidth, pad_char = ' ' }, --' ', - { text = ' ', width = 3, rjustify = true, pad_char = ' ' }, ' ', - { text = fk, width = 3, rjustify = false, pad_char = ' ' }, ' ', - { text = ' ', width = 3, rjustify = true, pad_char = ' ' }, ' ', - { text = mk, width = 3, rjustify = false, pad_char = ' ' }, ' ', - { text = ' ', width = 3, rjustify = true, pad_char = ' ' }, ' ', - { text = fa, width = 3, rjustify = false, pad_char = ' ' }, ' ', - { text = ' ', width = 3, rjustify = true, pad_char = ' ' }, ' ', - { text = ma, width = 3, rjustify = false, pad_char = ' ' }, ' ', - { text = watched, width = 6, rjustify = true } - } + -- first two rows are for "edit all races" and "edit new races" + table.insert(choices, { + text=make_row_text('!! ALL RACES PLUS NEW', settings), + race=1, + data=settings, + }) + table.insert(choices, { + text=make_row_text('!! ONLY NEW RACES', settings), + race=2, + data=settings, }) - local watchlist = plugin.autobutcher_getWatchList() - - for i,entry in ipairs(watchlist) do - fk = stringify(entry.fk) - fa = stringify(entry.fa) - mk = stringify(entry.mk) - ma = stringify(entry.ma) - if viewmode == 1 then - fkc = stringify(entry.fk_total) - fac = stringify(entry.fa_total) - mkc = stringify(entry.mk_total) - mac = stringify(entry.ma_total) - end - if viewmode == 2 then - fkc = stringify(entry.fk_protected) - fac = stringify(entry.fa_protected) - mkc = stringify(entry.mk_protected) - mac = stringify(entry.ma_protected) - end - if viewmode == 3 then - fkc = stringify(entry.fk_butcherable) - fac = stringify(entry.fa_butcherable) - mkc = stringify(entry.mk_butcherable) - mac = stringify(entry.ma_butcherable) - end - if viewmode == 4 then - fkc = stringify(entry.fk_butcherflag) - fac = stringify(entry.fa_butcherflag) - mkc = stringify(entry.mk_butcherflag) - mac = stringify(entry.ma_butcherflag) + local hide_zero = self.subviews.hide_zero:getOptionValue() + + for _, data in ipairs(plugin.autobutcher_getWatchList()) do + if hide_zero then + local target = data.fk + data.mk + data.fa + data.ma + if target == 0 then goto continue end end - local butcher_ordered = entry.fk_butcherflag + entry.fa_butcherflag + entry.mk_butcherflag + entry.ma_butcherflag - local bo = ' ' - if butcher_ordered > 0 then bo = stringify(butcher_ordered) end - - local watched = 'no' - if entry.watched then watched = 'yes' end - - local racestr = entry.name - - -- highlight entries where the target quota can't be met because too many are protected - bad_pen = COLOR_LIGHTRED - good_pen = NONE -- this is stupid, but it works. sue me - fk_pen = good_pen - fa_pen = good_pen - mk_pen = good_pen - ma_pen = good_pen - if entry.fk_protected > entry.fk then fk_pen = bad_pen end - if entry.fa_protected > entry.fa then fa_pen = bad_pen end - if entry.mk_protected > entry.mk then mk_pen = bad_pen end - if entry.ma_protected > entry.ma then ma_pen = bad_pen end - - table.insert (choices, { - text = { - { text = racestr, width = racewidth, pad_char = ' ' }, --' ', - { text = fkc, width = 3, rjustify = true, pad_char = ' ' }, '/', - { text = fk, width = 3, rjustify = false, pad_char = ' ', pen = fk_pen }, ' ', - { text = mkc, width = 3, rjustify = true, pad_char = ' ' }, '/', - { text = mk, width = 3, rjustify = false, pad_char = ' ', pen = mk_pen }, ' ', - { text = fac, width = 3, rjustify = true, pad_char = ' ' }, '/', - { text = fa, width = 3, rjustify = false, pad_char = ' ', pen = fa_pen }, ' ', - { text = mac, width = 3, rjustify = true, pad_char = ' ' }, '/', - { text = ma, width = 3, rjustify = false, pad_char = ' ', pen = ma_pen }, ' ', - { text = watched, width = 6, rjustify = true, pad_char = ' ' }, ' ', - { text = bo, width = 8, rjustify = true, pad_char = ' ' } - }, - obj = entry, + local total = data.fk_total + data.mk_total + data.fa_total + data.ma_total + local ordered = data.fk_butcherflag + data.fa_butcherflag + data.mk_butcherflag + data.ma_butcherflag + table.insert(choices, { + text=make_row_text(data.name, data, total, ordered ~= 0 and ordered or nil), + race=data.name, + total=total, + ordered=ordered, + data=data, }) + ::continue:: end - local list = self.subviews.list - list:setChoices(choices) + table.sort(choices, self.subviews.sort:getOptionValue()) + self.subviews.list:setChoices(choices) +end + +function WatchList:onDoubleClick(_, choice) + local x = self.subviews.list:getMousePos() + if x <= 32 then return + elseif x <= 41 then self.subviews.fk.on_activate() + elseif x <= 42 then return + elseif x <= 50 then self.subviews.mk.on_activate() + elseif x <= 51 then return + elseif x <= 59 then self.subviews.fa.on_activate() + elseif x <= 60 then return + elseif x <= 69 then self.subviews.ma.on_activate() + elseif x <= 70 then return + elseif x <= 76 then self.subviews.watch.on_activate() + elseif x <= 77 then return + elseif x <= 90 and choice.ordered then + if choice.ordered == 0 then + self.subviews.butcher.on_activate() + else + self.subviews.unbutcher.on_activate() + end + end end -- check the user input for target population values -function WatchList:checkUserInput(count, text) +local function checkUserInput(count, text) if count == nil then dlg.showMessage('Invalid Number', 'This is not a number: '..text..NEWLINE..'(for zero enter a 0)', COLOR_LIGHTRED) return false @@ -276,251 +603,105 @@ function WatchList:checkUserInput(count, text) return true end --- check the user input for sleep timer -function WatchList:checkUserInputSleep(count, text) - if count == nil then - dlg.showMessage('Invalid Number', 'This is not a number: '..text..NEWLINE..'(for zero enter a 0)', COLOR_LIGHTRED) - return false - end - if count < 1000 then - dlg.showMessage('Invalid Number', - 'Minimum allowed timer value is 1000!'..NEWLINE..'Too low values could decrease performance'..NEWLINE..'and are not necessary!', - COLOR_LIGHTRED) - return false +local function get_race(choice) + if choice.race == 1 then + return 'ALL RACES PLUS NEW' + elseif choice.race == 2 then + return 'ONLY NEW RACES' end - return true + return choice.race end -function WatchList:onEditFK() - local selidx,selobj = self.subviews.list:getSelected() - local settings = plugin.autobutcher_getSettings() - local fk = settings.fk - local mk = settings.mk - local fa = settings.fa - local ma = settings.ma - local race = 'ALL RACES PLUS NEW' - local id = -1 - local watched = false - - if selidx == 2 then - race = 'ONLY NEW RACES' - end - - if selidx > 2 then - local entry = selobj.obj - fk = entry.fk - mk = entry.mk - fa = entry.fa - ma = entry.ma - race = entry.name - id = entry.id - watched = entry.watched - end +function WatchList:editVal(desc, var) + local _, choice = self.subviews.list:getSelected() + local race = get_race(choice) + local data = choice.data dlg.showInputPrompt( 'Race: '..race, - 'Enter desired maximum of female kids:', + ('Enter desired target for %s:'):format(desc), COLOR_WHITE, - ' '..fk, + ' '..data[var], function(text) local count = tonumber(text) - if self:checkUserInput(count, text) then - fk = count - if selidx == 1 then - plugin.autobutcher_setDefaultTargetAll( fk, mk, fa, ma ) - end - if selidx == 2 then - plugin.autobutcher_setDefaultTargetNew( fk, mk, fa, ma ) + if checkUserInput(count, text) then + data[var] = count + if choice.race == 1 then + plugin.autobutcher_setDefaultTargetAll(data.fk, data.mk, data.fa, data.ma) + elseif choice.race == 2 then + plugin.autobutcher_setDefaultTargetNew(data.fk, data.mk, data.fa, data.ma) + else + plugin.autobutcher_setWatchListRace(data.id, data.fk, data.mk, data.fa, data.ma, data.watched) end - if selidx > 2 then - plugin.autobutcher_setWatchListRace(id, fk, mk, fa, ma, watched) - end - self:initListChoices() + self:refresh() end end ) end -function WatchList:onEditMK() - local selidx,selobj = self.subviews.list:getSelected() - local settings = plugin.autobutcher_getSettings() - local fk = settings.fk - local mk = settings.mk - local fa = settings.fa - local ma = settings.ma - local race = 'ALL RACES PLUS NEW' - local id = -1 - local watched = false - - if selidx == 2 then - race = 'ONLY NEW RACES' - end - - if selidx > 2 then - local entry = selobj.obj - fk = entry.fk - mk = entry.mk - fa = entry.fa - ma = entry.ma - race = entry.name - id = entry.id - watched = entry.watched - end +-- set whole row (fk, mk, fa, ma) to one value +function WatchList:onSetRow() + local _, choice = self.subviews.list:getSelected() + local race = get_race(choice) + local data = choice.data dlg.showInputPrompt( - 'Race: '..race, - 'Enter desired maximum of male kids:', + 'Set whole row for '..race, + 'Enter desired value for all targets:', COLOR_WHITE, - ' '..mk, + ' ', function(text) local count = tonumber(text) - if self:checkUserInput(count, text) then - mk = count - if selidx == 1 then - plugin.autobutcher_setDefaultTargetAll( fk, mk, fa, ma ) - end - if selidx == 2 then - plugin.autobutcher_setDefaultTargetNew( fk, mk, fa, ma ) - end - if selidx > 2 then - plugin.autobutcher_setWatchListRace(id, fk, mk, fa, ma, watched) + if checkUserInput(count, text) then + if choice.race == 1 then + plugin.autobutcher_setDefaultTargetAll(count, count, count, count) + elseif choice.race == 2 then + plugin.autobutcher_setDefaultTargetNew(count, count, count, count) + else + plugin.autobutcher_setWatchListRace(data.id, count, count, count, count, data.watched) end - self:initListChoices() + self:refresh() end end ) end -function WatchList:onEditFA() - local selidx,selobj = self.subviews.list:getSelected() - local settings = plugin.autobutcher_getSettings() - local fk = settings.fk - local mk = settings.mk - local fa = settings.fa - local ma = settings.ma - local race = 'ALL RACES PLUS NEW' - local id = -1 - local watched = false - - if selidx == 2 then - race = 'ONLY NEW RACES' - end - - if selidx > 2 then - local entry = selobj.obj - fk = entry.fk - mk = entry.mk - fa = entry.fa - ma = entry.ma - race = entry.name - id = entry.id - watched = entry.watched - end - - dlg.showInputPrompt( - 'Race: '..race, - 'Enter desired maximum of female adults:', - COLOR_WHITE, - ' '..fa, - function(text) - local count = tonumber(text) - if self:checkUserInput(count, text) then - fa = count - if selidx == 1 then - plugin.autobutcher_setDefaultTargetAll( fk, mk, fa, ma ) - end - if selidx == 2 then - plugin.autobutcher_setDefaultTargetNew( fk, mk, fa, ma ) - end - if selidx > 2 then - plugin.autobutcher_setWatchListRace(id, fk, mk, fa, ma, watched) - end - self:initListChoices() +function WatchList:zeroOut() + local _, choice = self.subviews.list:getSelected() + local data = choice.data + + local count = 0 + if choice.race == 1 then + dlg.showYesNoPrompt( + 'Are you sure?', + 'Really set all targets for all races to 0?', + COLOR_YELLOW, + function() + plugin.autobutcher_setDefaultTargetAll(count, count, count, count) + self:refresh() end - end - ) -end - -function WatchList:onEditMA() - local selidx,selobj = self.subviews.list:getSelected() - local settings = plugin.autobutcher_getSettings() - local fk = settings.fk - local mk = settings.mk - local fa = settings.fa - local ma = settings.ma - local race = 'ALL RACES PLUS NEW' - local id = -1 - local watched = false - - if selidx == 2 then - race = 'ONLY NEW RACES' - end - - if selidx > 2 then - local entry = selobj.obj - fk = entry.fk - mk = entry.mk - fa = entry.fa - ma = entry.ma - race = entry.name - id = entry.id - watched = entry.watched + ) + elseif choice.race == 2 then + plugin.autobutcher_setDefaultTargetNew(count, count, count, count) + self:refresh() + else + plugin.autobutcher_setWatchListRace(data.id, count, count, count, count, data.watched) + self:refresh() end - - dlg.showInputPrompt( - 'Race: '..race, - 'Enter desired maximum of male adults:', - COLOR_WHITE, - ' '..ma, - function(text) - local count = tonumber(text) - if self:checkUserInput(count, text) then - ma = count - if selidx == 1 then - plugin.autobutcher_setDefaultTargetAll( fk, mk, fa, ma ) - end - if selidx == 2 then - plugin.autobutcher_setDefaultTargetNew( fk, mk, fa, ma ) - end - if selidx > 2 then - plugin.autobutcher_setWatchListRace(id, fk, mk, fa, ma, watched) - end - self:initListChoices() - end - end - ) -end - -function WatchList:onEditSleepTimer() - local sleep = getSleepTimer() - dlg.showInputPrompt( - 'Edit Sleep Timer', - 'Enter new sleep timer in ticks:'..NEWLINE..'(1 ingame day equals 1200 ticks)', - COLOR_WHITE, - ' '..sleep, - function(text) - local count = tonumber(text) - if self:checkUserInputSleep(count, text) then - sleep = count - setSleepTimer(sleep) - self:updateBottom() - end - end - ) end function WatchList:onToggleWatching() - local selidx,selobj = self.subviews.list:getSelected() - if selidx > 2 then - local entry = selobj.obj - plugin.autobutcher_setWatchListRace(entry.id, entry.fk, entry.mk, entry.fa, entry.ma, not entry.watched) + local _, choice = self.subviews.list:getSelected() + if type(choice.race) == 'string' then + local data = choice.data + plugin.autobutcher_setWatchListRace(data.id, data.fk, data.mk, data.fa, data.ma, not data.watched) end - self:initListChoices() + self:refresh() end function WatchList:onDeleteEntry() - local selidx,selobj = self.subviews.list:getSelected() - if(selidx < 3 or selobj == nil) then + local _, choice = self.subviews.list:getSelected() + if type(choice.race) ~= 'string' then return end dlg.showYesNoPrompt( @@ -528,102 +709,55 @@ function WatchList:onDeleteEntry() 'Really delete the selected entry?'..NEWLINE..'(you could just toggle watch instead)', COLOR_YELLOW, function() - plugin.autobutcher_removeFromWatchList(selobj.obj.id) - self:initListChoices() + plugin.autobutcher_removeFromWatchList(choice.data.id) + self:refresh() end ) end -function WatchList:onAddRace() - print('onAddRace - not implemented yet') -end - function WatchList:onUnbutcherRace() - local selidx,selobj = self.subviews.list:getSelected() - if selidx < 3 then dlg.showMessage('Error', 'Select a specific race.', COLOR_LIGHTRED) end - if selidx > 2 then - local entry = selobj.obj - local race = entry.name - plugin.autobutcher_unbutcherRace(entry.id) - self:initListChoices() - self:updateBottom() + local _, choice = self.subviews.list:getSelected() + if type(choice.race) ~= 'string' then + dlg.showMessage('Error', 'Please select a specific race.', COLOR_LIGHTRED) + return end + plugin.autobutcher_unbutcherRace(choice.data.id) + self:refresh() end function WatchList:onButcherRace() - local selidx,selobj = self.subviews.list:getSelected() - if selidx < 3 then dlg.showMessage('Error', 'Select a specific race.', COLOR_LIGHTRED) end - if selidx > 2 then - local entry = selobj.obj - local race = entry.name - plugin.autobutcher_butcherRace(entry.id) - self:initListChoices() - self:updateBottom() + local _, choice = self.subviews.list:getSelected() + if type(choice.race) ~= 'string' then + dlg.showMessage('Error', 'Please select a specific race.', COLOR_LIGHTRED) + return end + plugin.autobutcher_butcherRace(choice.data.id) + self:refresh() end --- set whole row (fk, mk, fa, ma) to one value -function WatchList:onSetRow() - local selidx,selobj = self.subviews.list:getSelected() - local race = 'ALL RACES PLUS NEW' - local id = -1 - local watched = false - - if selidx == 2 then - race = 'ONLY NEW RACES' +function WatchList:onUnbutcherAll() + for _, data in ipairs(plugin.autobutcher_getWatchList()) do + plugin.autobutcher_unbutcherRace(data.id) end - local watchindex = selidx - 3 - if selidx > 2 then - local entry = selobj.obj - race = entry.name - id = entry.id - watched = entry.watched - end + self:refresh() +end - dlg.showInputPrompt( - 'Set whole row for '..race, - 'Enter desired maximum for all subtypes:', - COLOR_WHITE, - ' ', - function(text) - local count = tonumber(text) - if self:checkUserInput(count, text) then - if selidx == 1 then - plugin.autobutcher_setDefaultTargetAll( count, count, count, count ) - end - if selidx == 2 then - plugin.autobutcher_setDefaultTargetNew( count, count, count, count ) - end - if selidx > 2 then - plugin.autobutcher_setWatchListRace(id, count, count, count, count, watched) - end - self:initListChoices() +function WatchList:onButcherAll() + dlg.showYesNoPrompt( + 'Butcher all animals', + 'Warning! This will mark ALL animals for butchering.'..NEWLINE..'Proceed with caution.', + COLOR_YELLOW, + function() + for _, data in ipairs(plugin.autobutcher_getWatchList()) do + plugin.autobutcher_butcherRace(data.id) end + + self:refresh() end ) end -function WatchList:onToggleAutobutcher() - if(plugin.isEnabled()) then - plugin.setEnabled(false) - else - plugin.setEnabled(true) - end - self:initListChoices() - self:updateBottom() -end - -function WatchList:onToggleAutowatch() - if(plugin.autowatch_isEnabled()) then - plugin.autowatch_setEnabled(false) - else - plugin.autowatch_setEnabled(true) - end - self:initListChoices() - self:updateBottom() -end - function WatchList:onDismiss() view = nil end diff --git a/gui/autodump.lua b/gui/autodump.lua index bb81432b0b..7bfa6c8702 100644 --- a/gui/autodump.lua +++ b/gui/autodump.lua @@ -2,13 +2,6 @@ local gui = require('gui') local guidm = require('gui.dwarfmode') local widgets = require('gui.widgets') -local function get_dims(pos1, pos2) - local width, height, depth = math.abs(pos1.x - pos2.x) + 1, - math.abs(pos1.y - pos2.y) + 1, - math.abs(pos1.z - pos2.z) + 1 - return width, height, depth -end - local function is_good_item(item, include) if not item then return false end if not item.flags.on_ground or item.flags.garbage_collect or @@ -29,7 +22,7 @@ end Autodump = defclass(Autodump, widgets.Window) Autodump.ATTRS { frame_title='Autodump', - frame={w=48, h=18, r=2, t=18}, + frame={w=48, h=16, r=2, t=18}, resizable=true, resize_min={h=10}, autoarrange_subviews=true, @@ -49,19 +42,6 @@ function Autodump:init() text_to_wrap=self:callback('get_help_text'), }, widgets.Panel{frame={h=1}}, - widgets.Panel{ - frame={h=2}, - subviews={ - widgets.Label{ - frame={l=0, t=0}, - text={ - 'Selected area: ', - {text=self:callback('get_selection_area_text')} - }, - }, - }, - visible=function() return self.mark end, - }, widgets.HotkeyLabel{ frame={l=0}, label='Dump to tile under mouse cursor', @@ -174,7 +154,7 @@ end function Autodump:refresh_dump_items() local dump_items = {} local include = self:get_include() - for _,item in ipairs(df.global.world.items.all) do + for _,item in ipairs(df.global.world.items.other.IN_PLAY) do if not is_good_item(item, include) then goto continue end if item.flags.dump then table.insert(dump_items, item) @@ -200,13 +180,6 @@ function Autodump:get_help_text() return ret end -function Autodump:get_selection_area_text() - local mark = self.mark - if not mark then return '' end - local cursor = dfhack.gui.getMousePos() or {x=mark.x, y=mark.y, z=df.global.window_z} - return ('%dx%dx%d'):format(get_dims(mark, cursor)) -end - function Autodump:get_bounds(cursor, mark) cursor = cursor or self.mark mark = mark or self.mark or cursor @@ -256,7 +229,7 @@ function Autodump:select_box(bounds) for x=bounds.x1,bounds.x2 do local block = dfhack.maps.getTileBlock(xyz2pos(x, y, z)) local block_str = tostring(block) - if not seen_blocks[block_str] then + if block and not seen_blocks[block_str] then seen_blocks[block_str] = true self:select_items_in_block(block, bounds) end @@ -280,7 +253,7 @@ function Autodump:onInput(keys) end local now_ms = dfhack.getTickCount() if same_xyz(pos, self.last_map_click_pos) and - now_ms - self.last_map_click_ms <= widgets.DOUBLE_CLICK_MS then + now_ms - self.last_map_click_ms <= widgets.getDoubleClickMs() then self:reset_double_click() self:do_dump(pos) self.mark = nil @@ -335,12 +308,26 @@ end function Autodump:do_dump(pos) pos = pos or dfhack.gui.getMousePos() - if not pos then return end - local tileattrs = df.tiletype.attrs[dfhack.maps.getTileType(pos)] - local basic_shape = df.tiletype_shape.attrs[tileattrs.shape].basic_shape - local on_ground = basic_shape == df.tiletype_shape_basic.Floor or - basic_shape == df.tiletype_shape_basic.Stair or - basic_shape == df.tiletype_shape_basic.Ramp + if not pos then --We check this before calling + qerror('Autodump:do_dump called with bad pos!') + end + + local tt = dfhack.maps.getTileType(pos) + if not (tt and dfhack.maps.isTileVisible(pos)) then + dfhack.printerr('Dump tile not visible! Must be in a revealed area of map.') + return + end + + local on_ground + local shape = df.tiletype.attrs[tt].shape + local shape_attrs = df.tiletype_shape.attrs[shape] + if shape_attrs.walkable or shape == df.tiletype_shape.FORTIFICATION then + on_ground = true --Floor, stair, ramp, or fortification + elseif shape_attrs.basic_shape == df.tiletype_shape_basic.Wall then + dfhack.printerr('Dump tile blocked! Can\'t dump inside walls.') --Wall or brook bed + return + end + local items = #self.selected_items.list > 0 and self.selected_items.list or self.dump_items local mark_as_forbidden = self.subviews.mark_as_forbidden:getOptionValue() print(('teleporting %d items'):format(#items)) @@ -358,7 +345,15 @@ function Autodump:do_dump(pos) item.flags.forbid = true end if not on_ground then - dfhack.items.makeProjectile(item) + local proj = dfhack.items.makeProjectile(item) + if proj then + proj.flags.no_impact_destroy = true + proj.flags.bouncing = true + proj.flags.piercing = true + proj.flags.parabolic = true + proj.flags.no_adv_pause = true + proj.flags.no_collide = true + end end else print(('Could not move item: %s from (%d, %d, %d)'):format( @@ -377,7 +372,6 @@ function Autodump:do_destroy() print(('destroying %d items'):format(#items)) for _,item in ipairs(items) do table.insert(self.destroyed_items, {item=item, flags=copyall(item.flags)}) - item.flags.garbage_collect = true item.flags.forbid = true item.flags.hidden = true end @@ -390,7 +384,6 @@ function Autodump:undo_destroy() print(('undestroying %d items'):format(#self.destroyed_items)) for _,item_spec in ipairs(self.destroyed_items) do local item = item_spec.item - item.flags.garbage_collect = false item.flags.forbid = item_spec.flags.forbid item.flags.hidden = item_spec.flags.hidden end @@ -411,10 +404,19 @@ AutodumpScreen.ATTRS { } function AutodumpScreen:init() - self:addviews{Autodump{}} + self.window = Autodump{} + self:addviews{ + self.window, + widgets.DimensionsTooltip{ + get_anchor_pos_fn=function() return self.window.mark end, + }, + } end function AutodumpScreen:onDismiss() + for _,item_spec in ipairs(self.window.destroyed_items) do + dfhack.items.remove(item_spec.item) + end view = nil end diff --git a/gui/autofish.lua b/gui/autofish.lua index bb1b87372c..75189686ec 100644 --- a/gui/autofish.lua +++ b/gui/autofish.lua @@ -14,7 +14,6 @@ Autofish = defclass(Autofish, widgets.Window) Autofish.ATTRS{ frame_title = "Autofish", frame = {w=35, h=11}, - resizable = false } function Autofish:init() @@ -108,10 +107,11 @@ function Autofish:refresh_data() self.next_refresh_ms = dfhack.getTickCount() + REFRESH_MS end -function Autofish:onRenderBody() +function Autofish:render(dc) if self.next_refresh_ms <= dfhack.getTickCount() then self:refresh_data() end + Autofish.super.render(self, dc) end diff --git a/gui/autogems.lua b/gui/autogems.lua index b0665c994c..3d84c6545d 100644 --- a/gui/autogems.lua +++ b/gui/autogems.lua @@ -39,7 +39,7 @@ CONFIG_KEY = "autogems/config" blacklist = {} gems = {} -for id, raw in pairs(df.global.world.raws.inorganics) do +for id, raw in pairs(df.global.world.raws.inorganics.all) do if raw.material.flags.IS_GEM then if blacklist[id] == nil then blacklist[id] = false @@ -97,7 +97,7 @@ end function save() local save_blacklist = {} - for id in ipairs(df.global.world.raws.inorganics) do + for id in ipairs(df.global.world.raws.inorganics.all) do if blacklist[id] then table.insert(save_blacklist, id) end diff --git a/gui/autotraining.lua b/gui/autotraining.lua new file mode 100644 index 0000000000..a878f5bb95 --- /dev/null +++ b/gui/autotraining.lua @@ -0,0 +1,264 @@ +---@diagnostic disable: missing-fields + +local gui = require('gui') +local widgets = require('gui.widgets') + +local autotraining = reqscript('autotraining') + +local training_squads = autotraining.state.training_squads +local ignored_units = autotraining.state.ignored +local ignored_nobles = autotraining.state.ignored_nobles + +AutoTrain = defclass(AutoTrain, widgets.Window) +AutoTrain.ATTRS { + frame_title='Training Setup', + frame={w=55, h=45}, + resizable=true, -- if resizing makes sense for your dialog + resize_min={w=55, h=20}, -- try to allow users to shrink your windows +} + +local SELECTED_ICON = dfhack.pen.parse{ch=string.char(251), fg=COLOR_LIGHTGREEN} +function AutoTrain:getSquadIcon(squad_id) + if training_squads[squad_id] then + return SELECTED_ICON + end + return nil +end + +function AutoTrain:getSquads() + local squads = {} + for _, squad in ipairs(df.global.world.squads.all) do + if not (squad.entity_id == df.global.plotinfo.group_id) then + goto continue + end + table.insert(squads, { + text = dfhack.translation.translateName(squad.name, true)..(squad.alias ~= '' and ' ('..squad.alias..')' or ''), + icon = self:callback("getSquadIcon", squad.id ), + id = squad.id + }) + + ::continue:: + end + return squads +end + +function AutoTrain:toggleSquad(_, choice) + training_squads[choice.id] = not training_squads[choice.id] + autotraining.persist_state() + self:updateLayout() +end + +local IGNORED_ICON = dfhack.pen.parse{ch='x', fg=COLOR_RED} +function AutoTrain:getUnitIcon(unit_id) + if ignored_units[unit_id] then + return IGNORED_ICON + end + return nil +end + +function AutoTrain:getNobleIcon(noble_code) + if ignored_nobles[noble_code] then + return IGNORED_ICON + end + return nil +end + +function AutoTrain:getUnits() + local unit_choices = {} + for _, unit in ipairs(dfhack.units.getCitizens(true,false)) do + if not dfhack.units.isAdult(unit) then + goto continue + end + + table.insert(unit_choices, { + text = dfhack.units.getReadableName(unit), + icon = self:callback("getUnitIcon", unit.id ), + id = unit.id + }) + ::continue:: + end + return unit_choices +end + +function AutoTrain:toggleUnit(_, choice) + ignored_units[choice.id] = not ignored_units[choice.id] + autotraining.persist_state() + self:updateLayout() +end + +local function to_title_case(str) + return dfhack.capitalizeStringWords(dfhack.lowerCp437(str:gsub('_', ' '))) +end + +function toSet(list) + local set = {} + for _, v in ipairs(list) do + set[v] = true + end + return set +end + +local function add_positions(positions, entity) + if not entity then return end + for _,position in pairs(entity.positions.own) do + positions[position.id] = { + id=position.id+1, + code=position.code, + } + end +end + +function AutoTrain:getPositions() + local positions = {} + local excludedPositions = toSet({ + 'MILITIA_CAPTAIN', + 'MILITIA_COMMANDER', + 'OUTPOST_LIAISON', + 'CAPTAIN_OF_THE_GUARD', + }) + + add_positions(positions, df.historical_entity.find(df.global.plotinfo.civ_id)) + add_positions(positions, df.historical_entity.find(df.global.plotinfo.group_id)) + + -- Step 1: Extract values into a sortable array + local sortedPositions = {} + for _, val in pairs(positions) do + if val and not excludedPositions[val.code] then + table.insert(sortedPositions, val) + end + end + + -- Step 2: Sort the positions (optional, adjust sorting criteria) + table.sort(sortedPositions, function(a, b) + return a.id < b.id -- Sort alphabetically by code + end) + + -- Step 3: Rebuild the table without gaps + positions = {} -- Reset positions table + for i, val in ipairs(sortedPositions) do + positions[i] = { + text = to_title_case(val.code), + value = val.code, + pen = COLOR_LIGHTCYAN, + icon = self:callback("getNobleIcon", val.code), + id = val.id + } + end + + return positions +end + + + +function AutoTrain:toggleNoble(_, choice) + ignored_nobles[choice.value] = not ignored_nobles[choice.value] + autotraining.persist_state() + self:updateLayout() +end + +function AutoTrain:init() + self:addviews{ + widgets.Label{ + frame={ t = 0 , h = 1 }, + text = "Select squads for automatic training:", + }, + widgets.List{ + view_id = "squad_list", + icon_width = 2, + frame = { t = 1, h = 5 }, + choices = self:getSquads(), + on_submit=self:callback("toggleSquad") + }, + widgets.Divider{ frame={t=6, h=1}, frame_style_l = false, frame_style_r = false}, + widgets.Label{ + frame={ t = 7 , h = 1 }, + text = "General options:", + }, + widgets.EditField { + view_id = "threshold", + frame={ t = 8 , h = 1 }, + key = "CUSTOM_T", + label_text = "Need threshold for training: ", + text = tostring(-autotraining.state.threshold), + on_char = function (char, _) + return tonumber(char,10) + end, + on_submit = function (text) + -- still necessary, because on_char does not check pasted text + local entered_number = tonumber(text,10) or 5000 + autotraining.state.threshold = -entered_number + autotraining.persist_state() + -- make sure that the auto correction is reflected in the EditField + self.subviews.threshold:setText(tostring(entered_number)) + end + }, + widgets.ToggleHotkeyLabel { + view_id = 'enable_toggle', + frame = { t = 9, h = 1 }, + label = 'Autotraining is', + key = 'CUSTOM_E', + options = { { value = true, label = 'Enabled', pen = COLOR_GREEN }, + { value = false, label = 'Disabled', pen = COLOR_RED } }, + on_change = function(val) + if val then + autotraining.enable() + else + autotraining.disable() + end + end, + }, + widgets.Divider{ frame={t=10, h=1}, frame_style_l = false, frame_style_r = false}, + widgets.Label{ + frame={ t = 11 , h = 1 }, + text = "Ignored noble positions:", + }, + widgets.List{ + frame = { t = 12 , h = 11}, + view_id = "nobles_list", + icon_width = 2, + choices = self:getPositions(), + on_submit=self:callback("toggleNoble") + }, + widgets.Divider{ frame={t=23, h=1}, frame_style_l = false, frame_style_r = false}, + widgets.Label{ + frame={ t = 24 , h = 1 }, + text = "Select units to exclude from automatic training:" + }, + widgets.FilteredList{ + frame = { t = 25 }, + view_id = "unit_list", + edit_key = "CUSTOM_CTRL_F", + icon_width = 2, + choices = self:getUnits(), + on_submit=self:callback("toggleUnit") + } + } + --self.subviews.unit_list:setChoices(unit_choices) +end + +function AutoTrain:onRenderBody(painter) + self.subviews.enable_toggle:setOption(autotraining.state.enabled) +end + +function AutoTrain:onDismiss() + view = nil +end + +AutoTrainScreen = defclass(AutoTrainScreen, gui.ZScreen) +AutoTrainScreen.ATTRS { + focus_path='autotrain', +} + +function AutoTrainScreen:init() + self:addviews{AutoTrain{}} +end + +function AutoTrainScreen:onDismiss() + view = nil +end + +if not dfhack.world.isFortressMode() or not dfhack.isMapLoaded() then + qerror('gui/autotraining requires a fortress map to be loaded') +end + +view = view and view:raise() or AutoTrainScreen{}:show() diff --git a/gui/biomes.lua b/gui/biomes.lua new file mode 100644 index 0000000000..7a2ff8478c --- /dev/null +++ b/gui/biomes.lua @@ -0,0 +1,371 @@ +-- Visualize and inspect biome regions on the map. + +local RELOAD = false -- set to true when actively working on this script + +local gui = require('gui') +local widgets = require('gui.widgets') +local guidm = require('gui.dwarfmode') + +local INITIAL_LIST_HEIGHT = 5 +local INITIAL_INFO_HEIGHT = 15 + +local texturesOnOff8x12 = dfhack.textures.loadTileset(dfhack.getHackPath()..'/data/art/on-off.png', 8, 12, true) +local LIST_ITEM_HIGHLIGHTED = dfhack.textures.getTexposByHandle(texturesOnOff8x12[1]) -- yellow-ish indicator + +local texturesOnOff = dfhack.textures.loadTileset(dfhack.getHackPath()..'/data/art/on-off_top-left.png', 32, 32, true) +local TILE_HIGHLIGHTED = dfhack.textures.getTexposByHandle(texturesOnOff[1]) -- yellow-ish indicator +if TILE_HIGHLIGHTED < 0 then -- use a fallback + TILE_HIGHLIGHTED = 88 -- `X` +end + +local texturesSmallLetters = dfhack.textures.loadTileset(dfhack.getHackPath()..'/data/art/curses-small-letters_top-left.png', 32, 32, true) +local TILE_STARTING_SYMBOL = dfhack.textures.getTexposByHandle(texturesSmallLetters[1]) +if TILE_STARTING_SYMBOL < 0 then -- use a fallback + TILE_STARTING_SYMBOL = 97 -- `a` +end + +local function find(t, predicate) + for k, item in pairs(t) do + if predicate(k, item) then + return k, item + end + end + return nil +end + +local regionBiomeMap = {} +local biomesMap = {} +local biomeList = {} +local function gatherBiomeInfo(z) + local maxX, maxY, maxZ = dfhack.maps.getTileSize() + maxX = maxX - 1; maxY = maxY - 1; maxZ = maxZ - 1 + + z = z or df.global.window_z + + --for z = 0, maxZ do + for y = 0, maxY do + for x = 0, maxX do + local rgnX, rgnY = dfhack.maps.getTileBiomeRgn(x,y,z) + if rgnX == nil then goto continue end + + local regionBiomesX = regionBiomeMap[rgnX] + if not regionBiomesX then + regionBiomesX = {} + regionBiomeMap[rgnX] = regionBiomesX + end + local regionBiomesXY = regionBiomesX[rgnY] + if not regionBiomesXY then + regionBiomesXY = { + biomeTypeId = dfhack.maps.getBiomeType(rgnX, rgnY), + biome = dfhack.maps.getRegionBiome(rgnX, rgnY), + } + regionBiomesX[rgnY] = regionBiomesXY + end + + local biomeTypeId = regionBiomesXY.biomeTypeId + local biome = regionBiomesXY.biome + + local biomesZ = biomesMap[z] + if not biomesZ then + biomesZ = {} + biomesMap[z] = biomesZ + end + local biomesZY = biomesZ[y] + if not biomesZY then + biomesZY = {} + biomesZ[y] = biomesZY + end + + local function currentBiome(_, item) + return item.biome == biome + end + local ix = find(biomeList, currentBiome) + if not ix then + local ch = string.char(string.byte('a') + #biomeList) + table.insert(biomeList, {biome = biome, char = ch, typeId = biomeTypeId}) + ix = #biomeList + end + + biomesZY[x] = ix + + ::continue:: + end + end + --end +end + +-- always gather info at the very bottom first: this ensures the important biomes are +-- always in the same order (high up in the air strange things happen) +gatherBiomeInfo(0) + +-------------------------------------------------------------------------------- + +local TITLE = "Biomes" + +if RELOAD then BiomeVisualizerLegend = nil end +BiomeVisualizerLegend = defclass(BiomeVisualizerLegend, widgets.Window) +BiomeVisualizerLegend.ATTRS { + frame_title=TITLE, + frame_inset=0, + resizable=true, + resize_min={w=25}, + frame = { + w = 47, + h = INITIAL_LIST_HEIGHT + 2 + INITIAL_INFO_HEIGHT, + -- just under the minimap: + r = 2, + t = 18, + }, +} + +local function GetBiomeName(biome, biomeTypeId) + -- based on probe.cpp + local sav = biome.savagery + local evi = biome.evilness; + local sindex = sav > 65 and 2 or sav < 33 and 0 or 1 + local eindex = evi > 65 and 2 or evi < 33 and 0 or 1 + local surr = sindex + eindex * 3 +1; --in Lua arrays are 1-based + + local surroundings = { + "Serene", "Mirthful", "Joyous Wilds", + "Calm", "Wilderness", "Untamed Wilds", + "Sinister", "Haunted", "Terrifying" + } + + return ([[%s %s]]):format(surroundings[surr], df.biome_type.attrs[biomeTypeId].caption) +end + +function BiomeVisualizerLegend:init() + local list = widgets.List{ + view_id = 'list', + frame = { t = 1, b = INITIAL_INFO_HEIGHT + 1 }, + icon_width = 1, + text_pen = { fg = COLOR_GREY, bg = COLOR_BLACK }, -- this makes selection stand out more + on_select = self:callback('onSelectEntry'), + } + local tooltip_panel = widgets.Panel{ + view_id='tooltip_panel', + autoarrange_subviews=true, + frame = { b = 0, h = INITIAL_INFO_HEIGHT }, + frame_style=gui.INTERIOR_FRAME, + frame_background=gui.CLEAR_PEN, + subviews={ + widgets.Label{ + view_id='label', + auto_height=false, + scroll_keys={}, + }, + }, + } + self:addviews{ + list, + tooltip_panel, + } + + self.list = list + self.tooltip_panel = tooltip_panel + + self:UpdateChoices() +end + +local PEN_ACTIVE_ICON = dfhack.pen.parse{tile=LIST_ITEM_HIGHLIGHTED} +local PEN_NO_ICON = nil + +function BiomeVisualizerLegend:get_icon_pen_callback(ix) + return function () + if self.SelectedIndex == ix then + return PEN_ACTIVE_ICON + else + return PEN_NO_ICON + end + end +end + +function BiomeVisualizerLegend:get_text_pen_callback(ix) + return function () + if self.MapHoverIndex == ix then + return self.SelectedIndex == ix + and { fg = COLOR_BLACK, bg = COLOR_LIGHTCYAN } + or { fg = COLOR_BLACK, bg = COLOR_GREY } + else + return nil + end + end +end + +function BiomeVisualizerLegend:onSelectEntry(idx, option) + self.SelectedIndex = idx + self.SelectedOption = option + + self:ShowTooltip(option) +end + +function BiomeVisualizerLegend:UpdateChoices() + local choices = self.list:getChoices() or {} + for i = #choices + 1, #biomeList do + local biomeExt = biomeList[i] + table.insert(choices, { + text = {{ + pen = self:get_text_pen_callback(#choices+1), + text = ([[%s: %s]]):format(biomeExt.char, GetBiomeName(biomeExt.biome, biomeExt.typeId)), + }}, + icon = self:get_icon_pen_callback(#choices+1), + biomeTypeId = biomeExt.typeId, + biome = biomeExt.biome, + }) + end + self.list:setChoices(choices) +end + +function BiomeVisualizerLegend:onRenderFrame(dc, rect) + BiomeVisualizerLegend.super.onRenderFrame(self, dc, rect) + + local list = self.list + local currentHoverIx = list:getIdxUnderMouse() + local oldIx = self.HoverIndex + if currentHoverIx ~= oldIx then + self.HoverIndex = currentHoverIx + if self.onMouseHoverEntry then + local choices = list:getChoices() + self:onMouseHoverEntry(currentHoverIx, choices[currentHoverIx]) + end + end +end + +local function add_field_text(lines, biome, field_name) + lines[#lines+1] = ("%s: %s"):format(field_name, biome[field_name]) + lines[#lines+1] = NEWLINE +end + +local function get_tooltip_text(option) + if not option then + return "" + end + + local text = {} + text[#text+1] = ("type: %s"):format(df.biome_type[option.biomeTypeId]) + text[#text+1] = NEWLINE + + local biome = option.biome + + add_field_text(text, biome, "savagery") + add_field_text(text, biome, "evilness") + table.insert(text, NEWLINE) + + add_field_text(text, biome, "elevation") + add_field_text(text, biome, "rainfall") + add_field_text(text, biome, "drainage") + add_field_text(text, biome, "vegetation") + add_field_text(text, biome, "temperature") + add_field_text(text, biome, "volcanism") + table.insert(text, NEWLINE) + + local flags = biome.flags + if flags.is_lake then + text[#text+1] = "lake" + text[#text+1] = NEWLINE + end + if flags.is_brook then + text[#text+1] = "brook" + text[#text+1] = NEWLINE + end + + return text +end + +function BiomeVisualizerLegend:onMouseHoverEntry(idx, option) + self:ShowTooltip(option or self.SelectedOption) +end + +function BiomeVisualizerLegend:ShowTooltip(option) + local text = get_tooltip_text(option) + + local tooltip_panel = self.tooltip_panel + local lbl = tooltip_panel.subviews.label + + lbl:setText(text) +end + +function BiomeVisualizerLegend:onRenderBody(painter) + local thisPos = self:getMouseFramePos() + local pos = dfhack.gui.getMousePos() + + if not thisPos and pos then + local N = safe_index(biomesMap, pos.z, pos.y, pos.x) + if N then + local choices = self.list:getChoices() + local option = choices[N] + + self.MapHoverIndex = N + self:ShowTooltip(option) + end + else + self.MapHoverIndex = nil + end + + BiomeVisualizerLegend.super.onRenderBody(self, painter) +end + +-------------------------------------------------------------------------------- + +if RELOAD then BiomeVisualizer = nil end +BiomeVisualizer = defclass(BiomeVisualizer, gui.ZScreen) +BiomeVisualizer.ATTRS{ + focus_path='BiomeVisualizer', + pass_movement_keys=true, +} + +function BiomeVisualizer:init() + local legend = BiomeVisualizerLegend{view_id = 'legend'} + self:addviews{legend} +end + +function BiomeVisualizer:onRenderFrame(dc, rect) + BiomeVisualizer.super.onRenderFrame(self, dc, rect) + + if not dfhack.screen.inGraphicsMode() and not gui.blink_visible(500) then + return + end + + local z = df.global.window_z + if not biomesMap[z] then + gatherBiomeInfo(z) + self.subviews.legend:UpdateChoices() + end + + local function get_overlay_pen(pos) + local self = self + local safe_index = safe_index + local biomes = biomesMap + + local N = safe_index(biomes, pos.z, pos.y, pos.x) + if not N then return end + + local idxSelected = self.subviews.legend.SelectedIndex + local idxTile = (N == idxSelected) + and TILE_HIGHLIGHTED + or TILE_STARTING_SYMBOL + (N-1) + local color = (N == idxSelected) + and COLOR_CYAN + or COLOR_GREY + local ch = string.char(string.byte('a') + (N-1)) + return color, ch, idxTile + end + + guidm.renderMapOverlay(get_overlay_pen, nil) -- nil for bounds means entire viewport +end + +function BiomeVisualizer:onDismiss() + view = nil +end + +if not dfhack.isMapLoaded() then + qerror('gui/biomes requires a map to be loaded') +end + +if RELOAD and view then + view:dismiss() + -- view is nil now +end + +view = view and view:raise() or BiomeVisualizer{}:show() diff --git a/gui/blueprint.lua b/gui/blueprint.lua index f05d4bada5..cde79b10ba 100644 --- a/gui/blueprint.lua +++ b/gui/blueprint.lua @@ -25,12 +25,9 @@ function ActionPanel:init() self:addviews{ widgets.WrappedLabel{ view_id='action_label', - text_to_wrap=self:callback('get_action_text')}, - widgets.TooltipLabel{ - view_id='selected_area', - indent=1, - text={{text=self:callback('get_area_text')}}, - show_tooltip=self.get_mark_fn}} + text_to_wrap=self:callback('get_action_text') + } + } end function ActionPanel:get_action_text() local text = 'Select the ' @@ -43,16 +40,6 @@ function ActionPanel:get_action_text() end return text .. ' with the mouse.' end -function ActionPanel:get_area_text() - local mark = self.get_mark_fn() - if not mark then return '' end - local other = dfhack.gui.getMousePos() - or {x=mark.x, y=mark.y, z=df.global.window_z} - local width, height, depth = get_dims(mark, other) - local tiles = width * height * depth - local plural = tiles > 1 and 's' or '' - return ('%dx%dx%d (%d tile%s)'):format(width, height, depth, tiles, plural) -end NamePanel = defclass(NamePanel, widgets.ResizingPanel) NamePanel.ATTRS{ @@ -65,6 +52,7 @@ function NamePanel:init() widgets.EditField{ view_id='name', key='CUSTOM_N', + label_text='name: ', text=self.name, on_change=self:callback('update_tooltip'), on_focus=self:callback('on_edit_focus'), @@ -148,7 +136,7 @@ function PhasesPanel:init() widgets.CycleHotkeyLabel{ view_id='phases', key='CUSTOM_SHIFT_P', - label='phases', + label='phases:', options={{label='Autodetect', value='Autodetect', pen=COLOR_GREEN}, 'Custom'}, initial_option=self.phases.auto_phase and 'Autodetect' or 'Custom', @@ -183,25 +171,14 @@ function PhasesPanel:init() initial_option=self:get_default('build')}}}, widgets.Panel{frame={h=1}, subviews={widgets.ToggleHotkeyLabel{view_id='place_phase', - frame={t=0, l=0, w=19}, - key='CUSTOM_P', label='place', - initial_option=self:get_default('place')}, --- widgets.ToggleHotkeyLabel{view_id='zone_phase', --- frame={t=0, l=15, w=19}, --- key='CUSTOM_Z', label='zone', --- initial_option=self:get_default('zone'), --- label_width=5} + frame={t=0, l=0, w=19}, key='CUSTOM_P', label='place', + initial_option=self:get_default('place'), label_width=9}, + widgets.ToggleHotkeyLabel{view_id='zone_phase', + frame={t=0, l=19, w=19}, + key='CUSTOM_Z', label='zone', + initial_option=self:get_default('zone'), + label_width=5} }}, --- widgets.Panel{frame={h=1}, --- subviews={widgets.ToggleHotkeyLabel{view_id='query_phase', --- frame={t=0, l=0, w=19}, --- key='CUSTOM_Q', label='query', --- initial_option=self:get_default('query')}, --- widgets.ToggleHotkeyLabel{view_id='rooms_phase', --- frame={t=0, l=15, w=19}, --- key='CUSTOM_SHIFT_Q', label='rooms', --- initial_option=self:get_default('rooms')} --- }}, widgets.TooltipLabel{ text_to_wrap='Select blueprint phases to export.', show_tooltip=true}, @@ -241,8 +218,8 @@ function StartPosPanel:init() self:addviews{ widgets.CycleHotkeyLabel{ view_id='startpos', - key='CUSTOM_P', - label='playback start', + key='CUSTOM_O', + label='playback start:', options={'Unset', 'Setting', 'Set'}, initial_option=self.start_pos and 'Set' or 'Unset', on_change=self:callback('on_change'), @@ -293,7 +270,7 @@ end Blueprint = defclass(Blueprint, widgets.Window) Blueprint.ATTRS { frame_title='Blueprint', - frame={w=47, h=40, r=2, t=18}, + frame={w=47, h=38, r=2, t=18}, resizable=true, resize_min={h=10}, autoarrange_subviews=true, @@ -325,7 +302,7 @@ function Blueprint:init() widgets.ToggleHotkeyLabel{ view_id='engrave', key='CUSTOM_SHIFT_E', - label='engrave', + label='engrave:', options={{label='On', value=true}, {label='Off', value=false}}, initial_option=not not self.presets.engrave}, widgets.TooltipLabel{ @@ -335,7 +312,7 @@ function Blueprint:init() widgets.ToggleHotkeyLabel{ view_id='smooth', key='CUSTOM_SHIFT_S', - label='smooth', + label='smooth:', options={{label='On', value=true}, {label='Off', value=false}}, initial_option=not not self.presets.smooth}, widgets.TooltipLabel{ @@ -345,7 +322,7 @@ function Blueprint:init() widgets.CycleHotkeyLabel{ view_id='format', key='CUSTOM_F', - label='format', + label='format:', options={{label='Minimal text .csv', value='minimal', pen=COLOR_GREEN}, {label='Pretty text .csv', value='pretty'}}, initial_option=self.presets.format}, @@ -362,7 +339,7 @@ function Blueprint:init() widgets.ToggleHotkeyLabel{ view_id='meta', key='CUSTOM_M', - label='meta', + label='meta:', initial_option=not self.presets.nometa}, widgets.TooltipLabel{ text_to_wrap='Combine blueprints that can be replayed together.', @@ -371,7 +348,7 @@ function Blueprint:init() widgets.CycleHotkeyLabel{ view_id='splitby', key='CUSTOM_T', - label='split', + label='split:', options={{label='No', value='none', pen=COLOR_GREEN}, {label='By group', value='group'}, {label='By phase', value='phase'}}, @@ -394,7 +371,7 @@ function Blueprint:onShow() end function Blueprint:save_cursor_pos() - self.saved_cursor = copyall(df.global.cursor) + self.saved_cursor = guidm.getCursorPos() end function Blueprint:is_setting_start_pos() @@ -609,7 +586,13 @@ BlueprintScreen.ATTRS { } function BlueprintScreen:init() - self:addviews{Blueprint{presets=self.presets}} + local window = Blueprint{presets=self.presets} + self:addviews{ + window, + widgets.DimensionsTooltip{ + get_anchor_pos_fn=function() return window.mark end, + }, + } end function BlueprintScreen:onDismiss() diff --git a/gui/choose-weapons.lua b/gui/choose-weapons.lua index 379f76f33c..910d399264 100644 --- a/gui/choose-weapons.lua +++ b/gui/choose-weapons.lua @@ -71,7 +71,7 @@ function unassign_wrong_items(unit,position,spec,subtype) utils.insert_sorted(equipment.items_unassigned.WEAPON,item,'id') end equipment.update.weapon = true - unit.military.pickup_flags.update = true + unit.uniform.pickup_flags.update = true end end end diff --git a/gui/civ-alert.lua b/gui/civ-alert.lua index 1c6cb17519..45efacf4eb 100644 --- a/gui/civ-alert.lua +++ b/gui/civ-alert.lua @@ -1,14 +1,15 @@ --@ module=true local gui = require('gui') +local overlay = require('plugins.overlay') local textures = require('gui.textures') +local utils = require('utils') local widgets = require('gui.widgets') -local overlay = require('plugins.overlay') local function get_civ_alert() local list = df.global.plotinfo.alerts.list while #list < 2 do - local list_item = df.plotinfost.T_alerts.T_list:new() + local list_item = df.alert_statest:new() list_item.id = df.global.plotinfo.alerts.next_id df.global.plotinfo.alerts.next_id = df.global.plotinfo.alerts.next_id + 1 list_item.name = 'civ-alert' @@ -17,48 +18,44 @@ local function get_civ_alert() return list[1] end -local function can_sound_alarm() +-- public API section +function can_sound_alarm() return df.global.plotinfo.alerts.civ_alert_idx == 0 and #get_civ_alert().burrows > 0 end -local function sound_alarm() +function sound_alarm() if not can_sound_alarm() then return end df.global.plotinfo.alerts.civ_alert_idx = 1 end -local function can_clear_alarm() +function can_clear_alarm() return df.global.plotinfo.alerts.civ_alert_idx ~= 0 end -local function clear_alarm() +function clear_alarm() df.global.plotinfo.alerts.civ_alert_idx = 0 end -function set_civalert_burrow_if_unset(burrow) +function add_civalert_burrow(id) local burrows = get_civ_alert().burrows - if #burrows == 0 then - burrows:insert('#', burrow.id) - end + utils.insert_sorted(burrows, id) end -function unset_civalert_burrow_if_set(burrow) +function remove_civalert_burrow(id) local burrows = get_civ_alert().burrows - if #burrows > 0 and burrows[0] == burrow.id then - burrows:resize(0) + utils.erase_sorted(burrows, id) + if #burrows == 0 then clear_alarm() end end local function toggle_civalert_burrow(id) local burrows = get_civ_alert().burrows - if #burrows == 0 then - burrows:insert('#', id) - elseif burrows[0] == id then - burrows:resize(0) - clear_alarm() + if utils.binsearch(burrows, id) then + remove_civalert_burrow(id) else - burrows[0] = id + add_civalert_burrow(id) end end @@ -81,24 +78,28 @@ function BigRedButton:init() self:addviews{ widgets.Label{ - text={ - ' Activate ', NEWLINE, - ' civilian ', NEWLINE, - ' alert ', + text=widgets.makeButtonLabelText{ + chars={ + ' Activate ', + ' civilian ', + ' alert ', + }, + pens=BUTTON_TEXT_ON, + pens_hover=BUTTON_TEXT_OFF, }, - text_pen=BUTTON_TEXT_ON, - text_hpen=BUTTON_TEXT_OFF, visible=can_sound_alarm, on_click=sound_alarm, }, widgets.Label{ - text={ - ' Clear ', NEWLINE, - ' civilian ', NEWLINE, - ' alert ', + text=widgets.makeButtonLabelText{ + chars={ + ' Clear ', + ' civilian ', + ' alert ', + }, + pens=BUTTON_TEXT_OFF, + pens_hover=BUTTON_TEXT_ON, }, - text_pen=BUTTON_TEXT_OFF, - text_hpen=BUTTON_TEXT_ON, visible=can_clear_alarm, on_click=clear_alarm, }, @@ -111,20 +112,24 @@ end CivalertOverlay = defclass(CivalertOverlay, overlay.OverlayWidget) CivalertOverlay.ATTRS{ + desc='Adds a button for activating a civilian alert when the squads panel is open.', default_pos={x=-15,y=-1}, default_enabled=true, viewscreens='dwarfmode', frame={w=20, h=5}, } +local function is_squads_panel_open() + return dfhack.gui.matchFocusString('dwarfmode/Squads/Default', + dfhack.gui.getDFViewscreen(true)) +end + local function should_show_alert_button() - return can_clear_alarm() or - (df.global.game.main_interface.squads.open and can_sound_alarm()) + return can_clear_alarm() or (is_squads_panel_open() and can_sound_alarm()) end local function should_show_configure_button() - return df.global.game.main_interface.squads.open - and not can_sound_alarm() and not can_clear_alarm() + return is_squads_panel_open() and not can_sound_alarm() and not can_clear_alarm() end local function launch_config() @@ -209,11 +214,11 @@ function Civalert:init() subviews={ widgets.WrappedLabel{ frame={t=0, r=0, h=2}, - text_to_wrap='Choose a burrow where you want your civilians to hide during danger.', + text_to_wrap='Choose the burrow(s) where you want your civilians to hide during danger.', }, widgets.HotkeyLabel{ frame={t=3, l=0}, - key='CUSTOM_CTRL_W', + key='CUSTOM_CTRL_N', label='Sound alarm! Citizens run to safety!', on_activate=sound_alarm, enabled=can_sound_alarm, @@ -255,8 +260,7 @@ local SELECTED_ICON = to_pen{ch=string.char(251), fg=COLOR_LIGHTGREEN} function Civalert:get_burrow_icon(id) local burrows = get_civ_alert().burrows - if #burrows == 0 or burrows[0] ~= id then return nil end - return SELECTED_ICON + return utils.binsearch(burrows, id) and SELECTED_ICON or nil end function Civalert:get_burrow_choices() diff --git a/gui/companion-order.lua b/gui/companion-order.lua index ad650c0a90..310bf5d194 100644 --- a/gui/companion-order.lua +++ b/gui/companion-order.lua @@ -1,34 +1,11 @@ -- Issue orders to companions in Adventure mode ---[====[ - -gui/companion-order -=================== -A script to issue orders for companions. Select companions with lower case chars (green when selected), issue orders with upper -case. Must be in look or talk mode to issue command on tile (e.g. move/equip/pick-up). - -.. image:: /docs/images/companion-order.png - -* move - orders selected companions to move to location. If companions are following they will move no more than 3 tiles from you. -* equip - try to equip items on the ground. -* pick-up - try to take items into hand (also wield) -* unequip - remove and drop equipment -* unwield - drop held items -* wait - temporarily remove from party -* follow - rejoin the party after "wait" -* leave - remove from party (can be rejoined by talking) - -Can be called with '-c' flag to display "cheating" commands. - -* patch up - fully heals the companion -* get in - rides e.g. minecart at cursor. Bit buggy as unit will teleport to the item when e.g. pushing it. - -]====] local gui = require 'gui' +local guidm = require 'gui.dwarfmode' local dlg = require 'gui.dialogs' local args={...} local is_cheat=(#args>0 and args[1]=="-c") -local cursor=xyz2pos(df.global.cursor.x,df.global.cursor.y,df.global.cursor.z) +local cursor=guidm.getCursorPos() local permited_equips={} permited_equips[df.item_backpackst]="UPPERBODY" @@ -46,7 +23,7 @@ function DoesHaveSubtype(item) return true end function CheckCursor(p) - if p.x==-30000 then + if not p then dlg.showMessage( 'Companion orders', 'You must have a cursor on some tile!', COLOR_LIGHTRED @@ -55,20 +32,9 @@ function CheckCursor(p) end return true end -function getxyz() -- this will return pointers x,y and z coordinates. - local x=df.global.cursor.x - local y=df.global.cursor.y - local z=df.global.cursor.z - return x,y,z -- return the coords -end - -function GetCaste(race_id,caste_id) - local race=df.creature_raw.find(race_id) - return race.caste[caste_id] -end function EnumBodyEquipable(race_id,caste_id) - local caste=GetCaste(race_id,caste_id) + local caste=dfhack.units.getCasteRaw(race_id,caste_id) local bps=caste.body_info.body_parts local ret={} for k,v in pairs(bps) do @@ -149,7 +115,7 @@ function AddIfFits(body_equip,unit,item) return false end function EnumGrasps(race_id,caste_id) - local caste=GetCaste(race_id,caste_id) + local caste=dfhack.units.getCasteRaw(race_id,caste_id) local bps=caste.body_info.body_parts local ret={} for k,v in pairs(bps) do @@ -194,8 +160,8 @@ function AddBackpackItems(backpack,items) end function GetItemsAtPos(pos) local ret={} - for k,v in pairs(df.global.world.items.all) do - if v.flags.on_ground and v.pos.x==pos.x and v.pos.y==pos.y and v.pos.z==pos.z then + for k,v in pairs(df.global.world.items.other.IN_PLAY) do + if v.flags.on_ground and same_xyz(v.pos, pos) then table.insert(ret,v) end end @@ -237,7 +203,7 @@ function move_unit( unit,tx,ty,tz ) --copied from http/commands.lua with minor m unit.idle_area_threshold=0 unit.follow_distance=50 --invalidate old path - unit.path.dest={x=unit.idle_area.x,y=unit.idle_area.y,z=unit.idle_area.z} + unit.path.dest=copyall(unit.idle_area) unit.path.goal=df.unit_path_goal.SeekStation unit.path.path.x:resize(0) unit.path.path.y:resize(0) @@ -346,14 +312,14 @@ end}, return true end}, {name="follow",f=function (unit_list) - local adv=df.global.world.units.active[0] + local adv=dfhack.world.getAdventurer() for k,v in pairs(unit_list) do v.relationship_ids.GroupLeader=adv.id end return true end}, {name="leave",f=function (unit_list) - local adv=df.global.world.units.active[0] + local adv=dfhack.world.getAdventurer() local t_nem=dfhack.units.getNemesis(adv) for k,v in pairs(unit_list) do @@ -398,8 +364,8 @@ end}, if not CheckCursor(pos) then return false end - adv=df.global.world.units.active[0] - item=GetItemsAtPos(df.global.cursor)[1] + adv=dfhack.world.getAdventurer() + item=GetItemsAtPos(cursor)[1] print(item.id) for k,v in pairs(unit_list) do v.riding_item_id=item.id @@ -413,7 +379,7 @@ end}, } function getCompanions(unit) - unit=unit or df.global.world.units.active[0] + unit=unit or dfhack.world.getAdventurer() local t_nem=dfhack.units.getNemesis(unit) if t_nem==nil then qerror("Invalid unit! No nemesis record") @@ -499,7 +465,7 @@ function CompanionUi:onRenderBody( dc) else dc:pen(COLOR_GREY) end - dc:newline(1):string(string.char(k+char_a)..". "):string(dfhack.TranslateName(v.name)); + dc:newline(1):string(string.char(k+char_a)..". "):string(dfhack.translation.translateName(v.name)); end dc:pen(COLOR_GREY) local w,h=self:getWindowSize() diff --git a/gui/confirm.lua b/gui/confirm.lua index e96190aa65..12b5e7adbe 100644 --- a/gui/confirm.lua +++ b/gui/confirm.lua @@ -1,148 +1,101 @@ -- config ui for confirm ---@ module = true -local GLOBAL_KEY = 'confirm' -local CONFIRM_CONFIG_FILE = 'dfhack-config/confirm.json' -local json = require('json') -local confirm = require('plugins.confirm') + +local confirm = reqscript('confirm') local gui = require('gui') local widgets = require('gui.widgets') -dfhack.onStateChange[GLOBAL_KEY] = function(sc) - if sc ~= SC_CORE_INITIALIZED then return end - local ok, config = pcall(json.decode_file, CONFIRM_CONFIG_FILE) - local all_confirms = confirm.get_conf_data() - -- enable all confirms by default so new confirms are automatically enabled - for _, c in ipairs(all_confirms) do - confirm.set_conf_state(c.id, true) - end - - if not ok then return end - -- update confirm state based on config - for _, c in ipairs(config) do - confirm.set_conf_state(c.id, c.enabled) - end -end - -Opts = defclass(Opts, widgets.Window) -Opts.ATTRS = { - frame_title = 'Confirmation dialogs', - frame={w=36, h=17}, +Confirm = defclass(Confirm, widgets.Window) +Confirm.ATTRS{ + frame_title='Confirmation dialogs', + frame={w=42, h=17}, + initial_id=DEFAULT_NIL, } -function Opts:init() +function Confirm:init() self:addviews{ widgets.List{ - view_id = 'list', - frame = {t = 0, l = 0}, - text_pen = COLOR_GREY, - cursor_pen = COLOR_WHITE, - choices = {}, - on_submit = function(idx) self:toggle(idx) self:refresh() end, - }, - widgets.HotkeyLabel{ - frame = {b=2, l=0}, - label='Toggle all', - key='CUSTOM_ALT_E', - on_activate=function() self:toggle_all(self.subviews.list:getSelected()) self:refresh() end, - }, - widgets.HotkeyLabel{ - frame = {b=1, l=0}, - label='Resume paused confirmations', - key='CUSTOM_P', - on_activate=function() self:unpause_all() self:refresh() end, - enabled=function() return self.any_paused end + view_id='list', + frame={t=0, l=0, b=2}, + on_submit=self:callback('toggle'), }, widgets.HotkeyLabel{ - frame = {b=0, l=0}, + frame={b=0, l=0}, label='Toggle', key='SELECT', - on_activate=function() self:toggle(self.subviews.list:getSelected()) self:refresh() end, + auto_width=true, + on_activate=function() self:toggle(self.subviews.list:getSelected()) end, + }, + widgets.HotkeyLabel{ + frame={b=0, l=15}, + label='Toggle all', + key='CUSTOM_CTRL_A', + auto_width=true, + on_activate=self:callback('toggle_all'), }, } + self:refresh() - local active_id = confirm.get_active_id() - for i, choice in ipairs(self.subviews.list:getChoices()) do - if choice.id == active_id then - self.subviews.list:setSelected(i) - break + if self.initial_id then + for i, choice in ipairs(self.subviews.list:getChoices()) do + if choice.id == self.initial_id then + self.subviews.list:setSelected(i) + break + end end end end -function Opts:persist_data() - if not safecall(json.encode_file, self.data, CONFIRM_CONFIG_FILE) then - dfhack.printerr(('failed to save confirm config file: "%s"') - :format(path)) - end -end - -function Opts:refresh() - self.data = confirm.get_conf_data() - self.any_paused = false +function Confirm:refresh() local choices = {} - for i, c in ipairs(self.data) do - if c.paused then - self.any_paused = true - end - - local text = (c.enabled and 'Enabled' or 'Disabled') - if c.paused then - text = '[' .. text .. ']' - end - + for id, conf in pairs(confirm.get_state()) do table.insert(choices, { - id = c.id, - enabled = c.enabled, - paused = c.paused, - text = { - c.id .. ': ', + id=id, + enabled=conf.enabled, + text={ + id, + ': ', { - text = text, - pen = self:callback('choice_pen', i, c.enabled) + text=conf.enabled and 'Enabled' or 'Disabled', + pen=conf.enabled and COLOR_GREEN or COLOR_RED, } } }) end - self.subviews.list:setChoices(choices) -end - -function Opts:choice_pen(index, enabled) - return (enabled and COLOR_GREEN or COLOR_RED) + (index == self.subviews.list:getSelected() and 8 or 0) + table.sort(choices, function(a, b) return a.id < b.id end) + local list = self.subviews.list + local selected = list:getSelected() + list:setChoices(choices) + list:setSelected(selected) end -function Opts:toggle(idx) - local choice = self.data[idx] - confirm.set_conf_state(choice.id, not choice.enabled) - self:refresh() - self:persist_data() -end - -function Opts:toggle_all(choice) - for _, c in pairs(self.data) do - confirm.set_conf_state(c.id, not self.data[choice].enabled) - end +function Confirm:toggle(_, choice) + if not choice then return end + confirm.set_enabled(choice.id, not choice.enabled) self:refresh() - self:persist_data() end -function Opts:unpause_all() - for _, c in pairs(self.data) do - confirm.set_conf_paused(c.id, false) +function Confirm:toggle_all() + local choice = self.subviews.list:getChoices()[1] + if not choice then return end + local target_state = not choice.enabled + for id in pairs(confirm.get_state()) do + confirm.set_enabled(id, target_state) end self:refresh() end -OptsScreen = defclass(OptsScreen, gui.ZScreen) -OptsScreen.ATTRS { - focus_path='confirm/options', +ConfirmScreen = defclass(ConfirmScreen, gui.ZScreen) +ConfirmScreen.ATTRS { + focus_path='confirm/config', + initial_id=DEFAULT_NIL, } -function OptsScreen:init() - self:addviews{Opts{}} +function ConfirmScreen:init() + self:addviews{Confirm{initial_id=self.initial_id}} end -function OptsScreen:onDismiss() +function ConfirmScreen:onDismiss() view = nil end @@ -150,4 +103,5 @@ if dfhack_flags.module then return end -view = view and view:raise() or OptsScreen{}:show() +local initial_id = ({...})[1] -- set when called from confirm dialogs +view = view and view:raise() or ConfirmScreen{initial_id=initial_id}:show() diff --git a/gui/control-panel.lua b/gui/control-panel.lua index e92b004a84..2e5455d5b0 100644 --- a/gui/control-panel.lua +++ b/gui/control-panel.lua @@ -1,167 +1,12 @@ +local common = reqscript('internal/control-panel/common') local dialogs = require('gui.dialogs') local gui = require('gui') -local textures = require('gui.textures') local helpdb = require('helpdb') +local textures = require('gui.textures') local overlay = require('plugins.overlay') -local repeatUtil = require('repeat-util') -local utils = require('utils') +local registry = reqscript('internal/control-panel/registry') local widgets = require('gui.widgets') --- init files -local SYSTEM_INIT_FILE = 'dfhack-config/init/dfhack.control-panel-system.init' -local PREFERENCES_INIT_FILE = 'dfhack-config/init/dfhack.control-panel-preferences.init' -local AUTOSTART_FILE = 'dfhack-config/init/onMapLoad.control-panel-new-fort.init' -local REPEATS_FILE = 'dfhack-config/init/onMapLoad.control-panel-repeats.init' - --- service and command lists -local FORT_SERVICES = { - 'autobutcher', - 'autochop', - 'autoclothing', - 'autofarm', - 'autofish', - 'autonestbox', - 'autoslab', - 'dwarfvet', - 'emigration', - 'fastdwarf', - 'fix/protect-nicks', - 'hermit', - 'misery', - 'nestboxes', - 'preserve-tombs', - 'prioritize', - 'seedwatch', - 'starvingdead', - 'suspendmanager', - 'tailor', -} - -local FORT_AUTOSTART = { - 'autobutcher target 10 10 14 2 BIRD_GOOSE', - 'autobutcher target 10 10 14 2 BIRD_TURKEY', - 'autobutcher target 10 10 14 2 BIRD_CHICKEN', - 'autofarm threshold 150 grass_tail_pig', - 'ban-cooking all', - 'buildingplan set boulders false', - 'buildingplan set logs false', - 'drain-aquifer --top 2', - 'fix/blood-del fort', - 'light-aquifers-only fort', -} -for _,v in ipairs(FORT_SERVICES) do - table.insert(FORT_AUTOSTART, v) -end -table.sort(FORT_AUTOSTART) - --- these are re-enabled by the default DFHack init scripts -local SYSTEM_SERVICES = { -} --- these are fully controlled by the user -local SYSTEM_USER_SERVICES = { - 'faststart', - 'hide-tutorials', - 'work-now', -} -for _,v in ipairs(SYSTEM_USER_SERVICES) do - table.insert(SYSTEM_SERVICES, v) -end -table.sort(SYSTEM_SERVICES) - -local PREFERENCES = { - ['dfhack']={ - HIDE_CONSOLE_ON_STARTUP={label='Hide console on startup (MS Windows only)', type='bool', default=true, - desc='Hide the external DFHack terminal window on startup. Use the "show" command to unhide it.'}, - HIDE_ARMOK_TOOLS={label='Mortal mode: hide "armok" tools', type='bool', default=false, - desc='Don\'t show tools that give you god-like powers wherever DFHack tools are listed.'}, - }, - ['gui']={ - DEFAULT_INITIAL_PAUSE={label='DFHack tools autopause game', type='bool', default=true, - desc='Whether to pause the game when a DFHack tool window is shown.'}, - }, - ['gui.widgets']={ - DOUBLE_CLICK_MS={label='Mouse double click speed (ms)', type='int', default=500, min=50, - desc='How long to wait for the second click of a double click, in ms.'}, - SCROLL_INITIAL_DELAY_MS={label='Mouse initial scroll repeat delay (ms)', type='int', default=300, min=5, - desc='The delay before scrolling quickly when holding the mouse button down on a scrollbar, in ms.'}, - SCROLL_DELAY_MS={label='Mouse scroll repeat delay (ms)', type='int', default=20, min=5, - desc='The delay between events when holding the mouse button down on a scrollbar, in ms.'}, - }, - ['utils']={ - FILTER_FULL_TEXT={label='DFHack searches full text', type='bool', default=false, - desc='When searching, choose whether to match anywhere in the text (true) or just at the start of words (false).'}, - }, -} -local CPP_PREFERENCES = { - { - label='Prevent duplicate key events', - type='bool', - default=true, - desc='Whether to additionally pass key events through to DF when DFHack keybindings are triggered.', - init_fmt=':lua dfhack.internal.setSuppressDuplicateKeyboardEvents(%s)', - get_fn=dfhack.internal.getSuppressDuplicateKeyboardEvents, - set_fn=dfhack.internal.setSuppressDuplicateKeyboardEvents, - }, -} - -local REPEATS = { - ['autoMilkCreature']={ - desc='Automatically milk creatures that are ready for milking.', - command={'--time', '14', '--timeUnits', 'days', '--command', '[', 'workorder', '"{\\"job\\":\\"MilkCreature\\",\\"item_conditions\\":[{\\"condition\\":\\"AtLeast\\",\\"value\\":2,\\"flags\\":[\\"empty\\"],\\"item_type\\":\\"BUCKET\\"}]}"', ']'}}, - ['autoShearCreature']={ - desc='Automatically shear creatures that are ready for shearing.', - command={'--time', '14', '--timeUnits', 'days', '--command', '[', 'workorder', 'ShearCreature', ']'}}, - ['cleanowned']={ - desc='Encourage dwarves to drop tattered clothing and grab new ones.', - command={'--time', '1', '--timeUnits', 'months', '--command', '[', 'cleanowned', 'X', ']'}}, - ['combine']={ - desc='Combine partial stacks in stockpiles into full stacks.', - command={'--time', '7', '--timeUnits', 'days', '--command', '[', 'combine', 'all', '-q', ']'}}, - ['dead-units-burrow']={ - desc='Fix units still being assigned to burrows after death.', - command={'--time', '7', '--timeUnits', 'days', '--command', '[', 'fix/dead-units', '--burrow', '-q', ']'}}, - ['empty-wheelbarrows']={ - desc='Empties wheelbarrows which have rocks stuck in them.', - command={'--time', '1', '--timeUnits', 'days', '--command', '[', 'fix/empty-wheelbarrows', '-q', ']'}}, - ['general-strike']={ - desc='Prevent dwarves from getting stuck and refusing to work.', - command={'--time', '1', '--timeUnits', 'days', '--command', '[', 'fix/general-strike', '-q', ']'}}, - ['orders-sort']={ - desc='Sort manager orders by repeat frequency so one-time orders can be completed.', - command={'--time', '1', '--timeUnits', 'days', '--command', '[', 'orders', 'sort', ']'}}, - ['orders-reevaluate']={ - desc='Invalidates work orders once a month, allowing conditions to be rechecked.', - command={'--time', '1', '--timeUnits', 'months', '--command', '[', 'orders', 'recheck', ']'}}, - ['stuck-instruments']={ - desc='Fix activity references on stuck instruments to make them usable again.', - command={'--time', '1', '--timeUnits', 'days', '--command', '[', 'fix/stuck-instruments', ']'}}, - ['warn-starving']={ - desc='Show a warning dialog when units are starving or dehydrated.', - command={'--time', '10', '--timeUnits', 'days', '--command', '[', 'warn-starving', ']'}}, - ['warn-stranded']={ - desc='Show a warning dialog when units are stranded from all others.', - command={'--time', '0.25', '--timeUnits', 'days', '--command', '[', 'warn-stranded', ']'}}, -} -local REPEATS_LIST = {} -for k in pairs(REPEATS) do - table.insert(REPEATS_LIST, k) -end -table.sort(REPEATS_LIST) - --- save_fn takes the file as a param and should call f:write() to write data -local function save_file(path, save_fn) - local ok, f = pcall(io.open, path, 'w') - if not ok or not f then - dialogs.showMessage('Error', - ('Cannot open file for writing: "%s"'):format(path)) - return - end - f:write('# DO NOT EDIT THIS FILE\n') - f:write('# Please use gui/control-panel to edit this file\n\n') - save_fn(f) - f:close() -end - local function get_icon_pens() local enabled_pen_left = dfhack.pen.parse{fg=COLOR_CYAN, tile=curry(textures.tp_control_panel, 1), ch=string.byte('[')} @@ -197,455 +42,774 @@ local ENABLED_PEN_LEFT, ENABLED_PEN_CENTER, ENABLED_PEN_RIGHT, -- ConfigPanel -- +-- provides common structure across control panel tabs ConfigPanel = defclass(ConfigPanel, widgets.Panel) ConfigPanel.ATTRS{ intro_text=DEFAULT_NIL, - is_enableable=DEFAULT_NIL, - is_configurable=DEFAULT_NIL, - select_label='Toggle enabled', } function ConfigPanel:init() + local main_panel = widgets.Panel{ + frame={t=0, b=9}, + autoarrange_subviews=true, + autoarrange_gap=1, + subviews={ + widgets.WrappedLabel{ + frame={t=0}, + text_to_wrap=self.intro_text, + }, + -- extended by subclasses + }, + } + self:init_main_panel(main_panel) + + local footer = widgets.Panel{ + view_id='footer', + frame={b=0, h=3}, + subviews={ + widgets.HotkeyLabel{ + frame={t=2, l=0}, + label='Restore defaults', + key='CUSTOM_CTRL_D', + auto_width=true, + on_activate=self:callback('restore_defaults') + }, + -- extended by subclasses + } + } + self:init_footer(footer) + self:addviews{ + main_panel, widgets.Panel{ - frame={t=0, b=7}, - autoarrange_subviews=true, - autoarrange_gap=1, + frame={b=4, h=4}, + frame_style=gui.FRAME_INTERIOR, subviews={ widgets.WrappedLabel{ - frame={t=0}, - text_to_wrap=self.intro_text, - }, - widgets.FilteredList{ - frame={t=5}, - view_id='list', - on_select=self:callback('on_select'), - on_double_click=self:callback('on_submit'), - on_double_click2=self:callback('launch_config'), - row_height=2, + frame={l=0, h=2}, + view_id='desc', + auto_height=false, + text_to_wrap='', -- updated in on_select }, }, }, - widgets.WrappedLabel{ - view_id='desc', - frame={b=4, h=2}, - auto_height=false, - }, - widgets.HotkeyLabel{ - frame={b=2, l=0}, - label=self.select_label, - key='SELECT', - enabled=self.is_enableable, - on_activate=self:callback('on_submit') - }, - widgets.HotkeyLabel{ - view_id='show_help_label', - frame={b=1, l=0}, - label='Show tool help or run commands', - key='CUSTOM_CTRL_H', - on_activate=self:callback('show_help') - }, - widgets.HotkeyLabel{ - view_id='launch', - frame={b=0, l=0}, - label='Launch config UI', - key='CUSTOM_CTRL_G', - enabled=self.is_configurable, - on_activate=self:callback('launch_config'), - }, + footer, } end -function ConfigPanel:onInput(keys) - local handled = ConfigPanel.super.onInput(self, keys) - if keys._MOUSE_L then - local list = self.subviews.list.list - local idx = list:getIdxUnderMouse() - if idx then - local x = list:getMousePos() - if x <= 2 then - self:on_submit() - elseif x >= 4 and x <= 6 then - self:show_help() - elseif x >= 8 and x <= 10 then - self:launch_config() - end - end - end - return handled +-- overridden by subclasses +function ConfigPanel:init_main_panel(panel) end -local COMMAND_REGEX = '^([%w/_-]+)' +-- overridden by subclasses +function ConfigPanel:init_footer(panel) +end +-- overridden by subclasses function ConfigPanel:refresh() - local choices = {} - for _,choice in ipairs(self:get_choices()) do - local command = choice.target or choice.command - command = command:match(COMMAND_REGEX) - local gui_config = 'gui/' .. command - local want_gui_config = utils.getval(self.is_configurable, gui_config) - and helpdb.is_entry(gui_config) - local enabled = choice.enabled - local function get_enabled_pen(enabled_pen, disabled_pen) - if not utils.getval(self.is_enableable) then - return gui.CLEAR_PEN - end - return enabled and enabled_pen or disabled_pen - end - local text = { - {tile=get_enabled_pen(ENABLED_PEN_LEFT, DISABLED_PEN_LEFT)}, - {tile=get_enabled_pen(ENABLED_PEN_CENTER, DISABLED_PEN_CENTER)}, - {tile=get_enabled_pen(ENABLED_PEN_RIGHT, DISABLED_PEN_RIGHT)}, - ' ', - {tile=BUTTON_PEN_LEFT}, - {tile=HELP_PEN_CENTER}, - {tile=BUTTON_PEN_RIGHT}, - ' ', - {tile=want_gui_config and BUTTON_PEN_LEFT or gui.CLEAR_PEN}, - {tile=want_gui_config and CONFIGURE_PEN_CENTER or gui.CLEAR_PEN}, - {tile=want_gui_config and BUTTON_PEN_RIGHT or gui.CLEAR_PEN}, - ' ', - choice.target, - } - local desc = helpdb.is_entry(command) and - helpdb.get_entry_short_help(command) or '' - table.insert(choices, - {text=text, command=choice.command, target=choice.target, desc=desc, - search_key=choice.target, enabled=enabled, - gui_config=want_gui_config and gui_config}) - end - local list = self.subviews.list - local filter = list:getFilter() - local selected = list:getSelected() - list:setChoices(choices) - list:setFilter(filter, selected) - list.edit:setFocus(true) end -function ConfigPanel:on_select(idx, choice) +-- overridden by subclasses +function ConfigPanel:restore_defaults() +end + +-- attach to lists in subclasses +-- choice.data is an entry from one of the registry tables +function ConfigPanel:on_select(_, choice) local desc = self.subviews.desc - desc.text_to_wrap = choice and choice.desc or '' + desc.text_to_wrap = choice and common.get_description(choice.data) or '' if desc.frame_body then desc:updateLayout() end - if choice then - self.subviews.launch.enabled = utils.getval(self.is_configurable) - and not not choice.gui_config - end end -function ConfigPanel:on_submit() - if not utils.getval(self.is_enableable) then return false end - _,choice = self.subviews.list:getSelected() - if not choice then return end - local tokens = {} - table.insert(tokens, choice.command) - table.insert(tokens, choice.enabled and 'disable' or 'enable') - table.insert(tokens, choice.target) - dfhack.run_command(tokens) - self:refresh() +-- +-- Enabled subtab functions +-- + +local function get_gui_config(command) + command = common.get_first_word(command) + local gui_config = 'gui/' .. command + if helpdb.is_entry(gui_config) then + return gui_config + end end -function ConfigPanel:show_help() - _,choice = self.subviews.list:getSelected() - if not choice then return end - local command = choice.target:match(COMMAND_REGEX) - dfhack.run_command('gui/launcher', command .. ' ') +local function make_enabled_text(self, command, mode, gui_config) + local label = command + if mode == 'system_enable' or mode == 'tweak' then + label = label .. ' (global)' + end + + local function get_enabled_button_token(enabled_tile, disabled_tile) + return { + tile=function() return self.enabled_map[command] and enabled_tile or disabled_tile end, + } + end + + local function get_config_button_token(tile) + return { + tile=gui_config and tile or nil, + text=not gui_config and ' ' or nil, + } + end + + return { + get_enabled_button_token(ENABLED_PEN_LEFT, DISABLED_PEN_LEFT), + get_enabled_button_token(ENABLED_PEN_CENTER, DISABLED_PEN_CENTER), + get_enabled_button_token(ENABLED_PEN_RIGHT, DISABLED_PEN_RIGHT), + ' ', + {tile=BUTTON_PEN_LEFT}, + {tile=HELP_PEN_CENTER}, + {tile=BUTTON_PEN_RIGHT}, + ' ', + get_config_button_token(BUTTON_PEN_LEFT), + get_config_button_token(CONFIGURE_PEN_CENTER), + get_config_button_token(BUTTON_PEN_RIGHT), + ' ', + label, + } end -function ConfigPanel:launch_config() - if not utils.getval(self.is_configurable) then return false end - _,choice = self.subviews.list:getSelected() - if not choice or not choice.gui_config then return end - dfhack.run_command(choice.gui_config) +local function get_enabled_choices(self) + local choices = {} + self.enabled_map = common.get_enabled_map() + for _,data in ipairs(registry.COMMANDS_BY_IDX) do + if data.mode == 'run' then goto continue end + if data.mode ~= 'system_enable' and + data.mode ~= 'tweak' and + not dfhack.world.isFortressMode() + then + goto continue + end + if not common.command_passes_filters(data, self.group) then goto continue end + local gui_config = get_gui_config(data.command) + table.insert(choices, { + text=make_enabled_text(self, data.command, data.mode, gui_config), + search_key=data.command, + data=data, + gui_config=gui_config, + }) + ::continue:: + end + return choices end --- --- Services --- +local function enabled_onInput(self, keys) + if not keys._MOUSE_L then return end + local list = self.subviews.list.list + local idx = list:getIdxUnderMouse() + if idx then + local x = list:getMousePos() + if x <= 2 then + self:on_submit() + elseif x >= 4 and x <= 6 then + self:show_help() + elseif x >= 8 and x <= 10 then + self:launch_config() + end + end +end -Services = defclass(Services, ConfigPanel) -Services.ATTRS{ - services_list=DEFAULT_NIL, -} +local function enabled_on_submit(self, data) + common.apply_command(data, self.enabled_map, not self.enabled_map[data.command]) +end -function Services:get_enabled_map() - local enabled_map = {} - local output = dfhack.run_command_silent('enable'):split('\n+') - for _,line in ipairs(output) do - local _,_,command,enabled_str,extra = line:find('%s*(%S+):%s+(%S+)%s*(.*)') - if enabled_str then - enabled_map[command] = enabled_str == 'on' +local function enabled_restore_defaults(self) + for _,data in ipairs(registry.COMMANDS_BY_IDX) do + if data.mode == 'run' then goto continue end + if (data.mode == 'enable' or data.mode == 'repeat') + and not dfhack.world.isFortressMode() + then + goto continue end + if not common.command_passes_filters(data, self.group) then goto continue end + common.apply_command(data, self.enabled_map, not not data.default) + ::continue:: end - return enabled_map end -local function get_first_word(text) - local word = text:trim():split(' +')[1] - if word:startswith(':') then word = word:sub(2) end - return word + +-- +-- Autostart subtab functions +-- + +local function make_autostart_text(label, mode, enabled) + if mode == 'system_enable' or mode == 'tweak' then + label = label .. ' (global)' + end + return { + {tile=enabled and ENABLED_PEN_LEFT or DISABLED_PEN_LEFT}, + {tile=enabled and ENABLED_PEN_CENTER or DISABLED_PEN_CENTER}, + {tile=enabled and ENABLED_PEN_RIGHT or DISABLED_PEN_RIGHT}, + ' ', + {tile=BUTTON_PEN_LEFT}, + {tile=HELP_PEN_CENTER}, + {tile=BUTTON_PEN_RIGHT}, + ' ', + label, + } end -function Services:get_choices() - local enabled_map = self:get_enabled_map() +local function get_autostart_choices(self) local choices = {} - local hide_armok = dfhack.getHideArmokTools() - for _,service in ipairs(self.services_list) do - local entry_name = get_first_word(service) - if not hide_armok or not helpdb.is_entry(entry_name) - or not helpdb.get_entry_tags(entry_name).armok then - table.insert(choices, {target=service, enabled=enabled_map[service]}) + for _,data in ipairs(registry.COMMANDS_BY_IDX) do + if not common.command_passes_filters(data, self.group) then goto continue end + local enabled = safe_index(common.config.data.commands, data.command, 'autostart') + if enabled == nil then + enabled = data.default end + table.insert(choices, { + text=make_autostart_text(data.command, data.mode, enabled), + search_key=data.command, + data=data, + enabled=enabled, + }) + ::continue:: end return choices end --- --- FortServices --- +local function autostart_onInput(self, keys) + if keys._MOUSE_L then + local list = self.subviews.list.list + local idx = list:getIdxUnderMouse() + if idx then + local x = list:getMousePos() + if x <= 2 then + self:on_submit() + elseif x >= 4 and x <= 6 then + self:show_help() + end + end + end +end + +local function autostart_on_submit(choice) + common.set_autostart(choice.data, not choice.enabled) + common.config:write() +end + +local function autostart_restore_defaults(self) + for _,data in ipairs(registry.COMMANDS_BY_IDX) do + if not common.command_passes_filters(data, self.group) then goto continue end + common.set_autostart(data, data.default) + ::continue:: + end + common.config:write() +end -FortServices = defclass(FortServices, Services) -FortServices.ATTRS{ - is_enableable=dfhack.world.isFortressMode, - is_configurable=function() return dfhack.world.isFortressMode() end, - intro_text='These tools can only be enabled when you have a fort loaded,'.. - ' but once you enable them, they will stay enabled when you'.. - ' save and reload your fort. If you want them to be'.. - ' auto-enabled for new forts, please see the "Autostart" tab.', - services_list=FORT_SERVICES, -} -- --- FortServicesAutostart +-- CommandTab -- -FortServicesAutostart = defclass(FortServicesAutostart, Services) -FortServicesAutostart.ATTRS{ - is_enableable=true, - is_configurable=false, - intro_text='Tools that are enabled on this page will be auto-enabled for'.. - ' you when you start a new fort, using the default'.. - ' configuration. To see tools that are enabled right now in'.. - ' an active fort, please see the "Fort" tab.', - services_list=FORT_AUTOSTART, +CommandTab = defclass(CommandTab, ConfigPanel) +CommandTab.ATTRS { + group=DEFAULT_NIL, } -function FortServicesAutostart:init() - local enabled_map = {} - local ok, f = pcall(io.open, AUTOSTART_FILE) - if ok and f then - local services_set = utils.invert(FORT_AUTOSTART) - for line in f:lines() do - line = line:trim() - if #line == 0 or line:startswith('#') then goto continue end - local service = line:match('^on%-new%-fortress enable ([%S]+)$') - or line:match('^on%-new%-fortress (.+)') - if service and services_set[service] then - enabled_map[service] = true - end - ::continue:: - end - end - self.enabled_map = enabled_map +local Subtabs = { + enabled=1, + autostart=2, +} + +local subtab = Subtabs.enabled + +function CommandTab:init() + self.blurbs = { + [Subtabs.enabled]='These are the tools that can be enabled right now.'.. + ' Most tools can only be enabled when you have a fort loaded.'.. + ' Once enabled, tools will stay enabled when you save and reload'.. + ' your fort. If you want them to be auto-enabled for new forts,'.. + ' please see the "Autostart" tab.', + [Subtabs.autostart]='Tools that are enabled on this page will be'.. + ' auto-run or auto-enabled for you when you start a new fort (or,'.. + ' for "global" tools, when you start the game). To see tools that'.. + ' are enabled right now, please click on the "Enabled" tab.', + } end -function FortServicesAutostart:get_enabled_map() - return self.enabled_map +function CommandTab:init_main_panel(panel) + panel:addviews{ + widgets.TabBar{ + view_id='subtabbar', + frame={t=5}, + key='CUSTOM_CTRL_N', + key_back='CUSTOM_CTRL_M', + labels={ + 'Enabled', + 'Autostart', + }, + on_select=function(val) + subtab = val + self:updateLayout() + self:refresh() + end, + get_cur_page=function() return subtab end, + }, + widgets.WrappedLabel{ + frame={t=7}, + text_to_wrap=function() return self.blurbs[subtab] end, + }, + widgets.FilteredList{ + frame={t=9}, + view_id='list', + on_select=self:callback('on_select'), + on_double_click=self:callback('on_submit'), + on_double_click2=self:callback('launch_config'), + row_height=2, + visible=function() return #self.subviews.list:getChoices() > 0 end, + }, + widgets.Label{ + frame={t=9, l=0}, + text={ + 'Please load a fort to see the fort-mode tools. Alternately,', NEWLINE, + 'please switch to the "Autostart" tab to configure which', NEWLINE, + 'tools should be run or enabled on embark.', + }, + text_pen=COLOR_LIGHTRED, + visible=function() return #self.subviews.list:getChoices() == 0 end, + }, + } end -function FortServicesAutostart:on_submit() +function CommandTab:init_footer(panel) + panel:addviews{ + widgets.HotkeyLabel{ + frame={t=0, l=0}, + label='Toggle enabled', + key='SELECT', + auto_width=true, + on_activate=self:callback('on_submit') + }, + widgets.HotkeyLabel{ + frame={t=1, l=0}, + label='Show full tool help or run custom command', + auto_width=true, + key='CUSTOM_CTRL_H', + on_activate=self:callback('show_help'), + }, + widgets.HotkeyLabel{ + frame={t=2, l=26}, + label='Launch tool-specific config UI', + key='CUSTOM_CTRL_G', + auto_width=true, + enabled=self:callback('has_config'), + visible=function() return subtab == Subtabs.enabled end, + on_activate=self:callback('launch_config'), + }, + } +end + +local function launch_help(data) + dfhack.run_command('gui/launcher', (data.help_command or data.command) .. ' ') +end + +function CommandTab:show_help() _,choice = self.subviews.list:getSelected() if not choice then return end - self.enabled_map[choice.target] = not choice.enabled + launch_help(choice.data) +end - local save_fn = function(f) - for service,enabled in pairs(self.enabled_map) do - if enabled then - if service:match(' ') then - f:write(('on-new-fortress %s\n'):format(service)) - else - f:write(('on-new-fortress enable %s\n'):format(service)) - end - end - end +function CommandTab:has_config() + _,choice = self.subviews.list:getSelected() + return choice and choice.gui_config +end + +function CommandTab:launch_config() + if subtab ~= Subtabs.enabled then return end + _,choice = self.subviews.list:getSelected() + if not choice or not choice.gui_config then return end + dfhack.run_command(choice.gui_config) +end + +function CommandTab:refresh() + local choices = subtab == Subtabs.enabled and + get_enabled_choices(self) or get_autostart_choices(self) + local list = self.subviews.list + local filter = list:getFilter() + local selected = list:getSelected() + list:setChoices(choices) + list:setFilter(filter, selected) + list.edit:setFocus(true) +end + +function CommandTab:on_submit() + local _,choice = self.subviews.list:getSelected() + if not choice then return end + if subtab == Subtabs.enabled then + enabled_on_submit(self, choice.data) + else + autostart_on_submit(choice) end - save_file(AUTOSTART_FILE, save_fn) self:refresh() end +-- pick up enablement changes made from other sources (e.g. gui config tools) +function CommandTab:onRenderFrame(dc, rect) + if subtab == Subtabs.enabled then + self.enabled_map = common.get_enabled_map() + end + CommandTab.super.onRenderFrame(self, dc, rect) +end + +function CommandTab:onInput(keys) + local handled = CommandTab.super.onInput(self, keys) + if subtab == Subtabs.enabled then + enabled_onInput(self, keys) + else + autostart_onInput(self, keys) + end + return handled +end + +function CommandTab:restore_defaults() + dialogs.showYesNoPrompt('Are you sure?', + ('Are you sure you want to restore %s\ndefaults for %s tools?'):format( + self.subviews.subtabbar.labels[subtab], self.group), + nil, function() + if subtab == Subtabs.enabled then + enabled_restore_defaults(self) + else + autostart_restore_defaults(self) + end + self:refresh() + dialogs.showMessage('Success', + ('%s defaults restored for %s tools.'):format( + self.subviews.subtabbar.labels[subtab], self.group)) + end) +end + + -- --- SystemServices +-- AutomationTab -- -local function system_service_is_configurable(gui_config) - return gui_config ~= 'gui/automelt' or dfhack.world.isFortressMode() -end +AutomationTab = defclass(AutomationTab, CommandTab) +AutomationTab.ATTRS{ + intro_text='These run in the background and help you manage your'.. + ' fort. They are always safe to enable, and allow you to concentrate'.. + ' on other aspects of gameplay that you find more enjoyable.', + group='automation', +} + + +-- +-- BugFixesTab +-- -SystemServices = defclass(SystemServices, Services) -SystemServices.ATTRS{ - title='System', - is_enableable=true, - is_configurable=system_service_is_configurable, - intro_text='These are DFHack system services that are not bound to' .. - ' a specific fort. Some of these are critical DFHack services' .. - ' that can be manually disabled, but will re-enable themselves' .. - ' when DF restarts.', - services_list=SYSTEM_SERVICES, +BugFixesTab = defclass(BugFixesTab, CommandTab) +BugFixesTab.ATTRS{ + intro_text='These automatically fix dangerous or annoying vanilla'.. + ' bugs. You should generally have all of these enabled.', + group='bugfix', } -function SystemServices:on_submit() - SystemServices.super.on_submit(self) - local enabled_map = self:get_enabled_map() - local save_fn = function(f) - for _,service in ipairs(SYSTEM_USER_SERVICES) do - if enabled_map[service] then - f:write(('enable %s\n'):format(service)) - end - end - end - save_file(SYSTEM_INIT_FILE, save_fn) -end +-- +-- GameplayTab +-- + +GameplayTab = defclass(GameplayTab, CommandTab) +GameplayTab.ATTRS{ + intro_text='These change or extend gameplay. Read their help docs to'.. + ' see what they do and enable the ones that appeal to you.', + group='gameplay' +} + -- --- Overlays +-- OverlaysTab -- -Overlays = defclass(Overlays, ConfigPanel) -Overlays.ATTRS{ - title='Overlays', - is_enableable=true, - is_configurable=false, +OverlaysTab = defclass(OverlaysTab, ConfigPanel) +OverlaysTab.ATTRS{ intro_text='These are DFHack overlays that add information and'.. - ' functionality to various DF screens.', + ' functionality to native DF screens. You can toggle whether'.. + ' they are enabled here, or you can reposition them with'.. + ' gui/overlay.', } -function Overlays:init() - self.subviews.launch.visible = false - self:addviews{ +function OverlaysTab:init_main_panel(panel) + panel:addviews{ + widgets.FilteredList{ + frame={t=5}, + view_id='list', + on_select=self:callback('on_select'), + on_double_click=self:callback('on_submit'), + row_height=2, + }, + } +end + +function OverlaysTab:init_footer(panel) + panel:addviews{ widgets.HotkeyLabel{ - frame={b=0, l=0}, - label='Launch overlay widget repositioning UI', + frame={t=0, l=0}, + label='Toggle overlay', + key='SELECT', + auto_width=true, + on_activate=self:callback('on_submit') + }, + widgets.HotkeyLabel{ + frame={t=1, l=0}, + label='Show overlay help', + auto_width=true, + key='CUSTOM_CTRL_H', + on_activate=self:callback('show_help'), + }, + widgets.HotkeyLabel{ + frame={t=2, l=26}, + label='Launch widget position adjustment UI', key='CUSTOM_CTRL_G', + auto_width=true, on_activate=function() dfhack.run_script('gui/overlay') end, }, } end -function Overlays:get_choices() +function OverlaysTab:onInput(keys) + local handled = OverlaysTab.super.onInput(self, keys) + if keys._MOUSE_L then + local list = self.subviews.list.list + local idx = list:getIdxUnderMouse() + if idx then + local x = list:getMousePos() + if x <= 2 then + self:on_submit() + elseif x >= 4 and x <= 6 then + self:show_help() + end + end + end + return handled +end + +local function make_overlay_text(label, enabled) + return { + {tile=enabled and ENABLED_PEN_LEFT or DISABLED_PEN_LEFT}, + {tile=enabled and ENABLED_PEN_CENTER or DISABLED_PEN_CENTER}, + {tile=enabled and ENABLED_PEN_RIGHT or DISABLED_PEN_RIGHT}, + ' ', + {tile=BUTTON_PEN_LEFT}, + {tile=HELP_PEN_CENTER}, + {tile=BUTTON_PEN_RIGHT}, + ' ', + label, + } +end + +function OverlaysTab:refresh() local choices = {} local state = overlay.get_state() for _,name in ipairs(state.index) do - table.insert(choices, {command='overlay', - target=name, - enabled=state.config[name].enabled}) + enabled = state.config[name].enabled + local text = make_overlay_text(name, enabled) + table.insert(choices, { + text=text, + search_key=name, + data={ + name=name, + command=name:match('^(.-)%.') or 'overlay', + desc=state.db[name].widget.desc, + }, + enabled=enabled, + }) end - return choices + local list = self.subviews.list + local filter = list:getFilter() + local selected = list:getSelected() + list:setChoices(choices) + list:setFilter(filter, selected) + list.edit:setFocus(true) +end + +local function enable_overlay(name, enabled) + local tokens = {'overlay'} + table.insert(tokens, enabled and 'enable' or 'disable') + table.insert(tokens, name) + dfhack.run_command(tokens) end +function OverlaysTab:on_submit() + _,choice = self.subviews.list:getSelected() + if not choice then return end + local data = choice.data + enable_overlay(data.name, not choice.enabled) + self:refresh() +end + +function OverlaysTab:restore_defaults() + dialogs.showYesNoPrompt('Are you sure?', + 'Are you sure you want to restore overlay defaults?', + nil, function() + local state = overlay.get_state() + for name, db_entry in pairs(state.db) do + enable_overlay(name, db_entry.widget.default_enabled) + end + self:refresh() + dialogs.showMessage('Success', 'Overlay defaults restored.') + end) +end + +function OverlaysTab:show_help() + _,choice = self.subviews.list:getSelected() + if not choice then return end + launch_help(choice.data) +end + + -- --- Preferences +-- PreferencesTab -- -IntegerInputDialog = defclass(IntegerInputDialog, widgets.Window) -IntegerInputDialog.ATTRS{ +PrefEditDialog = defclass(PrefEditDialog, widgets.Window) +PrefEditDialog.ATTRS{ visible=false, - frame={w=50, h=8}, + frame={w=50, h=11}, frame_title='Edit setting', frame_style=gui.PANEL_FRAME, on_hide=DEFAULT_NIL, } -function IntegerInputDialog:init() +function PrefEditDialog:init() self:addviews{ widgets.Label{ frame={t=0, l=0}, text={ - 'Please enter a new value for ', - {text=function() return self.id or '' end}, + 'Please set a new value for ', NEWLINE, + { + gap=4, + text=function() return self.id or '' end, + }, NEWLINE, {text=self:callback('get_spec_str')}, }, }, widgets.EditField{ view_id='input_edit', - frame={t=3, l=0}, + frame={t=4, l=0}, on_char=function(ch) return ch:match('%d') end, + visible=function() return not self.data.options end, + }, + widgets.CycleHotkeyLabel{ + view_id='input_cycle', + key='CUSTOM_CTRL_N', + frame={t=4, l=0}, + options={'dummy'}, -- options set dynamically by show function + visible=function() return self.data.options end, + }, + widgets.HotkeyLabel{ + frame={b=0, l=0}, + label='Save', + key='SELECT', + auto_width=true, + on_activate=function() + self:hide(self.data.options and + self.subviews.input_cycle:getOptionValue() or + self.subviews.input_edit.text) + end, + }, + widgets.HotkeyLabel{ + frame={b=0, r=0}, + label='Reset to default', + key='CUSTOM_CTRL_D', + auto_width=true, + on_activate=function() + if self.data.options then + self.subviews.input_cycle:setOption(self.data.default) + else + self.subviews.input_edit:setText(tostring(self.data.default)) + end + end, }, } end -function IntegerInputDialog:get_spec_str() - if not self.spec or (not self.spec.min and not self.spec.max) then - return '' - end - local strs = {} - if self.spec.min then - table.insert(strs, ('at least %d'):format(self.spec.min)) +function PrefEditDialog:get_spec_str() + local data = self.data + local strs = { + ('default: %d'):format(data.default), + } + if data.min then + table.insert(strs, ('at least %d'):format(data.min)) end - if self.spec.max then - table.insert(strs, ('at most %d'):format(self.spec.max)) + if data.max then + table.insert(strs, ('at most %d'):format(data.max)) end return ('(%s)'):format(table.concat(strs, ', ')) end -function IntegerInputDialog:show(id, spec, initial) +function PrefEditDialog:show(id, data, initial) self.visible = true - self.id, self.spec = id, spec - local edit = self.subviews.input_edit - edit:setText(tostring(initial)) - edit:setFocus(true) + self.id, self.data = id, data + if data.options then + local cycle = self.subviews.input_cycle + cycle.options = data.options + cycle:setOption(initial) + else + local edit = self.subviews.input_edit + edit:setText(tostring(initial)) + edit:setFocus(true) + end self:updateLayout() end -function IntegerInputDialog:hide(val) +function PrefEditDialog:hide(val) self.visible = false self.on_hide(tonumber(val)) end -function IntegerInputDialog:onInput(keys) - if IntegerInputDialog.super.onInput(self, keys) then +function PrefEditDialog:onInput(keys) + if PrefEditDialog.super.onInput(self, keys) then return true end - if keys.SELECT then - self:hide(self.subviews.input_edit.text) - return true - elseif keys.LEAVESCREEN or keys._MOUSE_R then + if keys.LEAVESCREEN or keys._MOUSE_R then self:hide() return true end end -Preferences = defclass(Preferences, ConfigPanel) -Preferences.ATTRS{ - title='Preferences', - is_enableable=true, - is_configurable=true, +PreferencesTab = defclass(PreferencesTab, ConfigPanel) +PreferencesTab.ATTRS{ intro_text='These are the customizable DFHack system settings.', - select_label='Edit setting', } -function Preferences:init() - self.subviews.show_help_label.visible = false - self.subviews.launch.visible = false - self:addviews{ - widgets.HotkeyLabel{ - frame={b=0, l=0}, - label='Restore defaults', - key='CUSTOM_CTRL_G', - on_activate=self:callback('restore_defaults') +function PreferencesTab:init_main_panel(panel) + panel:addviews{ + widgets.FilteredList{ + frame={t=5}, + view_id='list', + on_select=self:callback('on_select'), + on_double_click=self:callback('on_submit'), + row_height=3, }, - IntegerInputDialog{ + PrefEditDialog{ view_id='input_dlg', on_hide=self:callback('set_val'), }, } end -function Preferences:onInput(keys) - -- call grandparent's onInput since we don't want ConfigPanel's processing - local handled = Preferences.super.super.onInput(self, keys) +function PreferencesTab:init_footer(panel) + panel:addviews{ + widgets.HotkeyLabel{ + frame={t=0, l=0}, + label='Toggle/edit setting', + key='SELECT', + auto_width=true, + on_activate=self:callback('on_submit') + }, + } +end + +function PreferencesTab:onInput(keys) + if self.subviews.input_dlg.visible then + self.subviews.input_dlg:onInput(keys) + return true + end + local handled = PreferencesTab.super.onInput(self, keys) if keys._MOUSE_L then local list = self.subviews.list.list local idx = list:getIdxUnderMouse() @@ -659,34 +823,42 @@ function Preferences:onInput(keys) return handled end -local function make_preference_text(label, value) +local function make_preference_text(label, default, value) return { {tile=BUTTON_PEN_LEFT}, {tile=CONFIGURE_PEN_CENTER}, {tile=BUTTON_PEN_RIGHT}, ' ', - ('%s (%s)'):format(label, value), + label, + NEWLINE, + {gap=4, text=('(default: %s, '):format(default)}, + {text=('current: %s'):format(value), pen=default ~= value and COLOR_YELLOW or nil}, + ')', } end -function Preferences:refresh() - if self.subviews.input_dlg.visible then return end - local choices = {} - for ctx_name,settings in pairs(PREFERENCES) do - local ctx_env = require(ctx_name) - for id,spec in pairs(settings) do - local text = make_preference_text(spec.label, ctx_env[id]) - table.insert(choices, - {text=text, desc=spec.desc, search_key=text[#text], - ctx_env=ctx_env, id=id, spec=spec}) +local function format_val(data, val) + if not data.options then return val end + for _, opt in ipairs(data.options) do + if opt.value == val then + return opt.label end end - for _,spec in ipairs(CPP_PREFERENCES) do - local text = make_preference_text(spec.label, spec.get_fn()) - table.insert(choices, - {text=text, desc=spec.desc, search_key=text[#text], spec=spec}) + return val +end + +function PreferencesTab:refresh() + if self.subviews.input_dlg.visible then return end + local choices = {} + for _, data in ipairs(registry.PREFERENCES_BY_IDX) do + local def, cur = data.default, data.get_fn() + local text = make_preference_text(data.label, format_val(data, def), format_val(data, cur)) + table.insert(choices, { + text=text, + search_key=data.label, + data=data + }) end - table.sort(choices, function(a, b) return a.spec.label < b.spec.label end) local list = self.subviews.list local filter = list:getFilter() local selected = list:getSelected() @@ -695,165 +867,46 @@ function Preferences:refresh() list.edit:setFocus(true) end -local function preferences_set_and_save(self, choice, val) - if choice.spec.set_fn then - choice.spec.set_fn(val) - else - choice.ctx_env[choice.id] = val - end - self:do_save() +local function preferences_set_and_save(self, data, val) + common.set_preference(data, val) + common.config:write() self:refresh() end -function Preferences:on_submit() +function PreferencesTab:on_submit() _,choice = self.subviews.list:getSelected() if not choice then return end - local cur_val - if choice.spec.get_fn then - cur_val = choice.spec.get_fn() - else - cur_val = choice.ctx_env[choice.id] - end - if choice.spec.type == 'bool' then - preferences_set_and_save(self, choice, not cur_val) - elseif choice.spec.type == 'int' then - self.subviews.input_dlg:show(choice.id or choice.spec.label, choice.spec, cur_val) + local data = choice.data + local cur_val = data.get_fn() + local data_type = type(data.default) + if data.options then + self.subviews.input_dlg:show(data.label, data, cur_val) + elseif data_type == 'boolean' then + preferences_set_and_save(self, data, not cur_val) + elseif data_type == 'number' then + self.subviews.input_dlg:show(data.label, data, cur_val) end end -function Preferences:set_val(val) +function PreferencesTab:set_val(val) _,choice = self.subviews.list:getSelected() if not choice or not val then return end - preferences_set_and_save(self, choice, val) -end - -function Preferences:do_save() - local save_fn = function(f) - for ctx_name,settings in pairs(PREFERENCES) do - local ctx_env = require(ctx_name) - for id in pairs(settings) do - f:write((':lua require("%s").%s=%s\n'):format( - ctx_name, id, tostring(ctx_env[id]))) - end - end - for _,spec in ipairs(CPP_PREFERENCES) do - local line = spec.init_fmt:format(spec.get_fn()) - f:write(('%s\n'):format(line)) - end - end - save_file(PREFERENCES_INIT_FILE, save_fn) -end - -function Preferences:restore_defaults() - for ctx_name,settings in pairs(PREFERENCES) do - local ctx_env = require(ctx_name) - for id,spec in pairs(settings) do - ctx_env[id] = spec.default - end - end - for _,spec in ipairs(CPP_PREFERENCES) do - spec.set_fn(spec.default) - end - os.remove(PREFERENCES_INIT_FILE) - self:refresh() - dialogs.showMessage('Success', 'Default preferences restored.') + preferences_set_and_save(self, choice.data, val) end --- --- RepeatAutostart --- - -RepeatAutostart = defclass(RepeatAutostart, ConfigPanel) -RepeatAutostart.ATTRS{ - title='Periodic', - is_enableable=true, - is_configurable=false, - intro_text='Tools that can run periodically to fix bugs or warn you of'.. - ' dangers that are otherwise difficult to detect (like'.. - ' starving caged animals).', -} - -function RepeatAutostart:init() - self.subviews.show_help_label.visible = false - self.subviews.launch.visible = false - local enabled_map = {} - local ok, f = pcall(io.open, REPEATS_FILE) - if ok and f then - for line in f:lines() do - line = line:trim() - if #line == 0 or line:startswith('#') then goto continue end - local service = line:match('^repeat %-%-name ([%S]+)') - if service then - enabled_map[service] = true - end - ::continue:: +function PreferencesTab:restore_defaults() + dialogs.showYesNoPrompt('Are you sure?', + 'Are you sure you want to restore default preferences?', + nil, function() + for _,data in ipairs(registry.PREFERENCES_BY_IDX) do + common.set_preference(data, data.default) end - end - self.enabled_map = enabled_map + common.config:write() + self:refresh() + dialogs.showMessage('Success', 'Default preferences restored.') + end) end -function RepeatAutostart:onInput(keys) - -- call grandparent's onInput since we don't want ConfigPanel's processing - local handled = RepeatAutostart.super.super.onInput(self, keys) - if keys._MOUSE_L then - local list = self.subviews.list.list - local idx = list:getIdxUnderMouse() - if idx then - local x = list:getMousePos() - if x <= 2 then - self:on_submit() - end - end - end - return handled -end - -function RepeatAutostart:refresh() - local choices = {} - for _,name in ipairs(REPEATS_LIST) do - local enabled = self.enabled_map[name] - local text = { - {tile=enabled and ENABLED_PEN_LEFT or DISABLED_PEN_LEFT}, - {tile=enabled and ENABLED_PEN_CENTER or DISABLED_PEN_CENTER}, - {tile=enabled and ENABLED_PEN_RIGHT or DISABLED_PEN_RIGHT}, - ' ', - name, - } - table.insert(choices, - {text=text, desc=REPEATS[name].desc, search_key=name, - name=name, enabled=enabled}) - end - local list = self.subviews.list - local filter = list:getFilter() - local selected = list:getSelected() - list:setChoices(choices) - list:setFilter(filter, selected) - list.edit:setFocus(true) -end - -function RepeatAutostart:on_submit() - _,choice = self.subviews.list:getSelected() - if not choice then return end - self.enabled_map[choice.name] = not choice.enabled - local run_commands = dfhack.isMapLoaded() - - local save_fn = function(f) - for name,enabled in pairs(self.enabled_map) do - if enabled then - local command_str = ('repeat --name %s %s\n'): - format(name, table.concat(REPEATS[name].command, ' ')) - f:write(command_str) - if run_commands then - dfhack.run_command(command_str) -- actually start it up too - end - elseif run_commands then - repeatUtil.cancel(name) - end - end - end - save_file(REPEATS_FILE, save_fn) - self:refresh() -end -- -- ControlPanel @@ -862,9 +915,9 @@ end ControlPanel = defclass(ControlPanel, widgets.Window) ControlPanel.ATTRS { frame_title='DFHack Control Panel', - frame={w=61, h=36}, + frame={w=74, h=45}, resizable=true, - resize_min={h=28}, + resize_min={h=39}, autoarrange_subviews=true, autoarrange_gap=1, } @@ -874,12 +927,11 @@ function ControlPanel:init() widgets.TabBar{ frame={t=0}, labels={ - 'Fort', - 'Maintenance', - 'System', - 'Overlays', + 'Automation', + 'Bug Fixes', + 'Gameplay', + 'UI Overlays', 'Preferences', - 'Autostart', }, on_select=self:callback('set_page'), get_cur_page=function() return self.subviews.pages:getSelected() end, @@ -888,16 +940,19 @@ function ControlPanel:init() view_id='pages', frame={t=5, l=0, b=0, r=0}, subviews={ - FortServices{}, - RepeatAutostart{}, - SystemServices{}, - Overlays{}, - Preferences{}, - FortServicesAutostart{}, + AutomationTab{}, + BugFixesTab{}, + GameplayTab{}, + OverlaysTab{}, + PreferencesTab{}, }, }, } + if not dfhack.world.isFortressMode() then + self.subviews.pages:setSelected(3) + end + self:refresh_page() end @@ -911,6 +966,7 @@ function ControlPanel:set_page(val) self:updateLayout() end + -- -- ControlPanelScreen -- diff --git a/gui/create-item.lua b/gui/create-item.lua index 58e9ede789..cf5c12a25d 100644 --- a/gui/create-item.lua +++ b/gui/create-item.lua @@ -99,13 +99,21 @@ local function getRestrictiveMatFilter(itemType, opts) BLOCKS = function(mat, parent, typ, idx) return mat.flags.IS_STONE or mat.flags.IS_METAL or mat.flags.IS_GLASS or mat.flags.WOOD end, + BAG = function(mat, parent, typ, idx) + return mat.flags.SILK or mat.flags.THREAD_PLANT or mat.flags.YARN + end, } for k, v in ipairs { 'GOBLET', 'FLASK', 'TOY', 'RING', 'CROWN', 'SCEPTER', 'FIGURINE', 'TOOL' } do itemTypes[v] = itemTypes.INSTRUMENT end - for k, v in ipairs { 'SHOES', 'SHIELD', 'HELM', 'GLOVES' } do + for k, v in ipairs { 'SHIELD', 'HELM' } do itemTypes[v] = itemTypes.ARMOR end + for k, v in ipairs { 'SHOES', 'GLOVES' } do + itemTypes[v] = function(mat, parent, typ, idx) + return itemTypes.ARMOR(mat, parent, typ, idx) or itemTypes.BAG(mat, parent, typ, idx) + end + end for k, v in ipairs { 'EARRING', 'BRACELET' } do itemTypes[v] = itemTypes.AMULET end @@ -122,7 +130,7 @@ local function getMatFilter(itemtype, opts) return mat.flags.STRUCTURAL_PLANT_MAT end, LEAVES = function(mat, parent, typ, idx) - return mat.flags.LEAF_MAT + return mat.flags.STOCKPILE_PLANT_GROWTH end, MEAT = function(mat, parent, typ, idx) return mat.flags.MEAT @@ -147,7 +155,7 @@ local function getMatFilter(itemtype, opts) return (mat.flags.WOOD) end, THREAD = function(mat, parent, typ, idx) - return (mat.flags.THREAD_PLANT) + return (mat.flags.THREAD_PLANT or mat.flags.SILK or mat.flags.YARN or mat.flags.STOCKPILE_THREAD_METAL) end, LEATHER = function(mat, parent, typ, idx) return (mat.flags.LEATHER) @@ -224,7 +232,8 @@ local default_accessors = { partlayerok, partlayerID = script.showListPrompt('Wish', 'What creature material should it be?', COLOR_LIGHTGREEN, getCreatureMaterialList(raceId, casteId), 1, true) else - --the offsets here are because indexes in lua are wonky (some start at 0, some start at 1), so we adjust for that, as well as the index offset created by inserting the "generic" option at the start of the body part selection prompt + --the offsets here are because indexes in lua are wonky (some start at 0, some start at 1), so we adjust for that, + --as well as the index offset created by inserting the "generic" option at the start of the body part selection prompt bodypart = bodypart - 2 partlayerok, partlayerID = script.showListPrompt('Wish', 'What tissue layer should it be?', COLOR_LIGHTGREEN, getCreaturePartLayerList(raceId, casteId, bodypart), 1, true) @@ -267,6 +276,12 @@ local positionals = argparse.processArgsGetopt({ ... }, { hasArg = true, handler = function(arg) opts.count = argparse.nonnegativeInt(arg, 'count') end, }, + { + 'p', + 'pos', + hasArg = true, + handler = function(arg) opts.pos = argparse.coords(arg, 'pos') end, + }, }) if positionals[1] == 'help' then opts.help = true end diff --git a/gui/create-tree.lua b/gui/create-tree.lua deleted file mode 100644 index e56031cce7..0000000000 --- a/gui/create-tree.lua +++ /dev/null @@ -1,49 +0,0 @@ --- Create a tree at the cursor position. --- Intended to act as a user-friendly front for modtools/create-tree --- Author: Atomic Chicken - ---[====[ - -gui/create-tree -=============== -A graphical interface for creating trees. - -Place the cursor wherever you want the tree to appear and run the script. -Then select the desired tree type from the list. -You will then be asked to input the desired age of the tree in years. -If omitted, the age will default to 1. - -]====] - -function spawnTree() - local dialogs = require 'gui.dialogs' - local createTree = reqscript('modtools/create-tree') - - local x, y, z = pos2xyz(df.global.cursor) - if not x then - qerror("First select a spawn location using the cursor.") - end - local treePos = {x, y, z} - - local treeRaws = {} - for _, treeRaw in ipairs(df.global.world.raws.plants.trees) do - table.insert(treeRaws, {text = treeRaw.name, treeRaw = treeRaw, search_key = treeRaw.name:lower()}) - end - dialogs.showListPrompt('Spawn Tree', 'Select a plant:', COLOR_LIGHTGREEN, treeRaws, function(id, choice) - local treeAge - dialogs.showInputPrompt('Age', 'Enter the age of the tree (in years):', COLOR_LIGHTGREEN, nil, function(input) - if not input then - return - elseif input == '' then - treeAge = 1 - elseif tonumber(input) and tonumber(input) >= 0 then - treeAge = tonumber(input) - else - dialogs.showMessage('Error', 'Invalid age: ' .. input, COLOR_LIGHTRED) - end - createTree.createTree(choice.treeRaw, treeAge, treePos) - end) - end, nil, nil, true) -end - -spawnTree() diff --git a/gui/design.lua b/gui/design.lua index 4ccb85697e..47eaf197c6 100644 --- a/gui/design.lua +++ b/gui/design.lua @@ -3,19 +3,12 @@ -- TODOS ==================== --- Must Haves ------------------------------ --- Better UI, it's starting to get really crowded - --- Should Haves ------------------------------ -- Refactor duplicated code into functions -- File is getting long... might be time to consider creating additional modules -- All the various states are getting hard to keep track of, e.g. placing extra/mirror/mark/etc... -- Should consolidate the states into a single state attribute with enum values -- Keyboard support --- As the number of shapes and designations grow it might be better to have list menus for them instead of cycle --- Grid view without slowness (can ignore if next TODO is done, since nrmal mining mode has grid view) +-- Grid view without slowness (can ignore if next TODO is done, since normal mining mode has grid view) -- Lags when drawing the full screen grid on each frame render -- Integrate with default mining mode for designation type, priority, etc... (possible?) -- Figure out how to remove dug stairs with mode (nothing seems to work, include 'dig ramp') @@ -34,145 +27,207 @@ -- END TODOS ================ -local gui = require("gui") -local textures = require("gui.textures") -local guidm = require("gui.dwarfmode") -local widgets = require("gui.widgets") +local gui = require('gui') +local guidm = require('gui.dwarfmode') local overlay = require('plugins.overlay') -local quickfort = reqscript("quickfort") -local shapes = reqscript("internal/design/shapes") -local util = reqscript("internal/design/util") -local plugin = require("plugins.design") +local plugin = require('plugins.design') +local quickfort = reqscript('quickfort') +local shapes = reqscript('internal/design/shapes') +local textures = require('gui.textures') +local util = reqscript('internal/design/util') +local utils = require('utils') +local widgets = require('gui.widgets') local Point = util.Point local getMousePoint = util.getMousePoint -local tile_attrs = df.tiletype.attrs - local to_pen = dfhack.pen.parse -local guide_tile_pen = to_pen { - ch = "+", - fg = COLOR_YELLOW, - tile = dfhack.screen.findGraphicsTile( - "CURSORS", - 0, - 22 - ), +local guide_tile_pen = to_pen{ + ch='+', + fg=COLOR_YELLOW, + tile=dfhack.screen.findGraphicsTile('CURSORS', 0, 22), +} +local mirror_guide_pen = to_pen{ + ch='+', + fg=COLOR_YELLOW, + tile=dfhack.screen.findGraphicsTile('CURSORS', 1, 22), } -local mirror_guide_pen = to_pen { - ch = "+", - fg = COLOR_YELLOW, - tile = dfhack.screen.findGraphicsTile( - "CURSORS", - 1, - 22 - ), +-- ----------------- -- +-- DimensionsOverlay -- +-- ----------------- -- + +DimensionsOverlay = defclass(DimensionsOverlay, overlay.OverlayWidget) +DimensionsOverlay.ATTRS{ + desc='Adds a tooltip that shows the selected dimensions when drawing boxes.', + default_pos={x=1,y=1}, + default_enabled=true, + fullscreen=true, -- not player-repositionable + viewscreens={ + 'dwarfmode/Designate', + 'dwarfmode/Burrow/Paint', + 'dwarfmode/Stockpile/Paint', + 'dwarfmode/Zone/Paint', + 'dwarfmode/Building/Placement', + }, } +local main_interface = df.global.game.main_interface +local selection_rect = df.global.selection_rect +local uibs = df.global.buildreq + +local function get_selection_rect_pos() + if selection_rect.start_x < 0 then return end + return xyz2pos(selection_rect.start_x, selection_rect.start_y, selection_rect.start_z) +end + +local function get_uibs_pos() + if uibs.selection_pos.x < 0 then return end + return uibs.selection_pos +end + +function DimensionsOverlay:init() + self:addviews{ + widgets.DimensionsTooltip{ + get_anchor_pos_fn=function() + if dfhack.gui.matchFocusString('dwarfmode/Building/Placement', + dfhack.gui.getDFViewscreen(true)) + then + return get_uibs_pos() + else + return get_selection_rect_pos() + end + end, + }, + } +end + +-- don't imply that stockpiles or zones will be 3d +local function check_stockpile_dims() + if selection_rect.start_x > 0 and + (main_interface.bottom_mode_selected == df.main_bottom_mode_type.STOCKPILE_PAINT or + main_interface.bottom_mode_selected == df.main_bottom_mode_type.ZONE_PAINT) + then + selection_rect.start_z = df.global.window_z + end +end + +function DimensionsOverlay:render(dc) + check_stockpile_dims() + DimensionsOverlay.super.render(self, dc) +end + +function DimensionsOverlay:preUpdateLayout(parent_rect) + self.frame.w = parent_rect.width + self.frame.h = parent_rect.height +end + +function DimensionsOverlay:overlay_onenable() + df.global.d_init.display.flags.SHOW_RECTANGLE_DIMENSIONS = false +end + +function DimensionsOverlay:overlay_ondisable() + df.global.d_init.display.flags.SHOW_RECTANGLE_DIMENSIONS = true +end + --- ---- HelpWindow +--- RightClickOverlay --- -DESIGN_HELP_DEFAULT = { - "gui/design Help", - "============", - NEWLINE, - "This is a default help text." +RightClickOverlay = defclass(RightClickOverlay, overlay.OverlayWidget) +RightClickOverlay.ATTRS{ + desc='When drawing boxes, makes right click cancel selection instead of exiting.', + default_enabled=true, + fullscreen=true, + viewscreens={ + 'dwarfmode/Designate', + 'dwarfmode/Burrow/Paint', + 'dwarfmode/Stockpile/Paint', + 'dwarfmode/Zone/Paint', + 'dwarfmode/Building/Placement' + }, +} + +function RightClickOverlay:onInput(keys) + if keys._MOUSE_R or keys.LEAVESCREEN then + -- building mode + if dfhack.gui.matchFocusString('dwarfmode/Building/Placement', + dfhack.gui.getDFViewscreen(true)) + then + if uibs.selection_pos.x >= 0 then + uibs.selection_pos:clear() + return true + end + -- all other modes + elseif selection_rect.start_x >= 0 then + selection_rect.start_x = -30000 + selection_rect.start_y = -30000 + selection_rect.start_z = -30000 + return true + end + end +end + +OVERLAY_WIDGETS = { + dimensions=DimensionsOverlay, + rightclick=RightClickOverlay, } +--- +--- HelpWindow +--- + CONSTRUCTION_HELP = { - "gui/design Help: Building Filters", - "===============================", + 'Building filters', + '================', NEWLINE, - "Adding material filters to this tool is planned but not implemented at this time.", - NEWLINE, - "Use `buildingplan` to configure filters for the desired construction types. This tool will use the current buildingplan filters for an building type." + 'Use the DFHack building planner to configure filters for the desired construction types. This tool will use the current buildingplan filters for a building type.' } HelpWindow = defclass(HelpWindow, widgets.Window) -HelpWindow.ATTRS { - frame_title = 'gui/design Help', - frame = { w = 43, h = 20, t = 10, l = 10 }, - resizable = true, - resize_min = { w = 43, h = 20 }, - message = DESIGN_HELP_DEFAULT +HelpWindow.ATTRS{ + frame_title='gui/design Help', + frame={w=43, h=20, t=10, l=10}, + resizable=true, + resize_min={h=10}, + message='', } function HelpWindow:init() - self:addviews { - widgets.ResizingPanel { autoarrange_subviews = true, - frame = { t = 0, l = 0 }, - subviews = { - widgets.WrappedLabel { - view_id = 'help_text', - frame = { t = 0, l = 0 }, - text_to_wrap = function() return self.message end, - } - } - } + self:addviews{ + widgets.WrappedLabel{ + auto_height=false, + text_to_wrap=function() return self.message end, + }, } end -- Utilities -local function get_icon_pens() - local enabled_pen_left = dfhack.pen.parse { fg = COLOR_CYAN, - tile = curry(textures.tp_control_panel, 1) or nil, ch = string.byte('[') } - local enabled_pen_center = dfhack.pen.parse { fg = COLOR_LIGHTGREEN, - tile = curry(textures.tp_control_panel, 2) or nil, ch = 251 } -- check - local enabled_pen_right = dfhack.pen.parse { fg = COLOR_CYAN, - tile = curry(textures.tp_control_panel, 3) or nil, ch = string.byte(']') } - local disabled_pen_left = dfhack.pen.parse { fg = COLOR_CYAN, - tile = curry(textures.tp_control_panel, 4) or nil, ch = string.byte('[') } - local disabled_pen_center = dfhack.pen.parse { fg = COLOR_RED, - tile = curry(textures.tp_control_panel, 5) or nil, ch = string.byte('x') } - local disabled_pen_right = dfhack.pen.parse { fg = COLOR_CYAN, - tile = curry(textures.tp_control_panel, 6) or nil, ch = string.byte(']') } - local button_pen_left = dfhack.pen.parse { fg = COLOR_CYAN, - tile = curry(textures.tp_control_panel, 7) or nil, ch = string.byte('[') } - local button_pen_right = dfhack.pen.parse { fg = COLOR_CYAN, - tile = curry(textures.tp_control_panel, 8) or nil, ch = string.byte(']') } - local help_pen_center = dfhack.pen.parse { - tile = curry(textures.tp_control_panel, 9) or nil, ch = string.byte('?') - } - local configure_pen_center = dfhack.pen.parse { - tile = curry(textures.tp_control_panel, 10) or nil, ch = 15 - } -- gear/masterwork symbol - return enabled_pen_left, enabled_pen_center, enabled_pen_right, - disabled_pen_left, disabled_pen_center, disabled_pen_right, - button_pen_left, button_pen_right, - help_pen_center, configure_pen_center -end - -local ENABLED_PEN_LEFT, ENABLED_PEN_CENTER, ENABLED_PEN_RIGHT, -DISABLED_PEN_LEFT, DISABLED_PEN_CENTER, DISABLED_PEN_RIGHT, -BUTTON_PEN_LEFT, BUTTON_PEN_RIGHT, -HELP_PEN_CENTER, CONFIGURE_PEN_CENTER = get_icon_pens() +local BUTTON_PEN_LEFT = to_pen{fg=COLOR_CYAN, tile=curry(textures.tp_control_panel, 7) or nil, ch=string.byte('[')} +local HELP_PEN_CENTER = to_pen{tile=curry(textures.tp_control_panel, 9) or nil, ch=string.byte('?')} +local BUTTON_PEN_RIGHT = to_pen{fg=COLOR_CYAN, tile=curry(textures.tp_control_panel, 8) or nil, ch=string.byte(']')} -- Debug window -SHOW_DEBUG_WINDOW = false +SHOW_DEBUG_WINDOW = SHOW_DEBUG_WINDOW or false local function table_to_string(tbl, indent) - indent = indent or "" + indent = indent or '' local result = {} for k, v in pairs(tbl) do - local key = type(k) == "number" and "[" .. tostring(k) .. "]" or tostring(k) - if type(v) == "table" then - table.insert(result, indent .. key .. " = {") - local subTable = table_to_string(v, indent .. " ") + local key = type(k) == 'number' and ('[%d]'):format(k) or tostring(k) + if type(v) == 'table' then + table.insert(result, indent .. key .. ' = {') + local subTable = table_to_string(v, indent .. ' ') for _, line in ipairs(subTable) do table.insert(result, line) end - table.insert(result, indent .. "},") - elseif type(v) == "function" then - local res = v() - local value = type(res) == "number" and tostring(res) or "\"" .. tostring(res) .. "\"" - table.insert(result, indent .. key .. " = " .. value .. ",") + table.insert(result, indent .. '},') else - local value = type(v) == "number" and tostring(v) or "\"" .. tostring(v) .. "\"" - table.insert(result, indent .. key .. " = " .. value .. ",") + local val = utils.getval(v) + local value = type(val) == 'string' and ('"%s"'):format(val) or tostring(val) + table.insert(result, indent .. key .. ' = ' .. value .. ',') end end return result @@ -180,816 +235,693 @@ end DesignDebugWindow = defclass(DesignDebugWindow, widgets.Window) DesignDebugWindow.ATTRS { - frame_title = "Debug", - frame = { - w = 47, - h = 40, - l = 10, - t = 8, - }, - resizable = true, - resize_min = { h = 30 }, - autoarrange_subviews = true, - autoarrange_gap = 1, - design_window = DEFAULT_NIL + frame_title='Debug', + frame={w=47, h=40, l=10, t=8}, + resizable=true, + resize_min={w=20, h=30}, + autoarrange_subviews=true, + autoarrange_gap=1, + design_window=DEFAULT_NIL, } + function DesignDebugWindow:init() local attrs = { - -- "shape", -- prints a lot of lines due to the self.arr, best to disable unless needed, TODO add a 'get debug string' function - "prio", - "autocommit", - "cur_shape", - "placing_extra", - "placing_mark", - "prev_center", - "start_center", - "extra_points", - "last_mouse_point", - "needs_update", - "#marks", - "placing_mirror", - "mirror_point", - "mirror", - "show_guides" + 'needs_update', + 'placing_mark', + '#marks', + 'placing_mirror', + 'mirror', + 'mirror_point', + 'placing_extra', + 'extra_points', + 'last_mouse_point', + 'prev_center', + 'start_center', } - if not self.design_window then - return - end - - for i, a in pairs(attrs) do - local attr = a - local sizeOnly = string.sub(attr, 1, 1) == "#" - - if (sizeOnly) then - attr = string.sub(attr, 2) - end - - self:addviews { widgets.WrappedLabel { - view_id = "debug_label_" .. attr, - text_to_wrap = function() - if type(self.design_window[attr]) ~= "table" then - return tostring(attr) .. ": " .. tostring(self.design_window[attr]) - end + for _, attr in ipairs(attrs) do + self:addviews{ + widgets.WrappedLabel{ + text_to_wrap=function() + local want_size = attr:startswith('#') + local field = want_size and attr:sub(2) or attr + if type(self.design_window[field]) ~= 'table' then + return ('%s: %s'):format(field, self.design_window[field]) + end - if sizeOnly then - return '#' .. tostring(attr) .. ": " .. tostring(#self.design_window[attr]) - else - return { tostring(attr) .. ": ", table.unpack(table_to_string(self.design_window[attr], " ")) } - end - end, - } } + if want_size then + return ('%s: %d'):format(attr, #self.design_window[field]) + else + return ('%s: %s'):format(attr, + table.concat(table_to_string(self.design_window[attr], ' '))) + end + end, + }, + } end end ---Show mark point coordinates -MarksPanel = defclass(MarksPanel, widgets.ResizingPanel) -MarksPanel.ATTRS { - autoarrange_subviews = true, - design_panel = DEFAULT_NIL -} - -function MarksPanel:init() -end - -function MarksPanel:update_mark_labels() - self.subviews = {} - local label_text = {} - if #self.design_panel.marks >= 1 then - local first_mark = self.design_panel.marks[1] - if first_mark then - table.insert(label_text, - string.format("First Mark (%d): %d, %d, %d ", 1, first_mark.x, first_mark.y, first_mark.z)) - end - end - - if #self.design_panel.marks > 1 then - local last_index = #self.design_panel.marks - (self.design_panel.placing_mark.active and 1 or 0) - local last_mark = self.design_panel.marks[last_index] - if last_mark then - table.insert(label_text, - string.format("Last Mark (%d): %d, %d, %d ", last_index, last_mark.x, last_mark.y, - last_mark.z)) - end - end - - local mouse_pos = getMousePoint() - if mouse_pos then - table.insert(label_text, string.format("Mouse: %d, %d, %d", mouse_pos.x, mouse_pos.y, mouse_pos.z)) - end - - local mirror = self.design_panel.mirror_point - if mirror then - table.insert(label_text, string.format("Mirror Point: %d, %d, %d", mirror.x, mirror.y, mirror.z)) - end - - self:addviews { - widgets.WrappedLabel { - view_id = "mark_labels", - text_to_wrap = label_text, - } - } - +function DesignDebugWindow:render(dc) self:updateLayout() + DesignDebugWindow.super.render(self, dc) end --- Panel to show the Mouse position/dimensions/etc -ActionPanel = defclass(ActionPanel, widgets.ResizingPanel) -ActionPanel.ATTRS { - autoarrange_subviews = true, - design_panel = DEFAULT_NIL +-- +-- Design +-- + +Design = defclass(Design, widgets.Window) +Design.ATTRS { + frame_title = 'Design', + frame={w=40, h=48, r=2, t=18}, + resizable=true, + autoarrange_subviews=true, + autoarrange_gap=1, } -function ActionPanel:init() - self:addviews { - widgets.WrappedLabel { - view_id = "action_label", - text_to_wrap = self:callback("get_action_text"), - }, - widgets.WrappedLabel { - view_id = "selected_area", - text_to_wrap = self:callback("get_area_text"), - }, - self:get_mark_labels() +local function make_mode_option(desig, mode, ch1, ch2, ch1_color, ch2_color, x, y, x_selected, y_selected) + y_selected = y_selected or y + return { + desig=desig, + mode=mode, + button_spec=util.make_button_spec(ch1, ch2, ch1_color, ch2_color, COLOR_GRAY, COLOR_WHITE, x, y), + button_selected_spec=util.make_button_spec(ch1, ch2, ch1_color, ch2_color, COLOR_YELLOW, COLOR_YELLOW, x_selected, y_selected), } end -function ActionPanel:get_mark_labels() -end - -function ActionPanel:get_action_text() - local text = "" - if self.design_panel.marks[1] and self.design_panel.placing_mark.active then - text = "Place the next point" - elseif not self.design_panel.marks[1] then - text = "Place the first point" - elseif not self.parent_view.placing_extra.active and not self.parent_view.prev_center then - text = "Select any draggable points" - elseif self.parent_view.placing_extra.active then - text = "Place any extra points" - elseif self.parent_view.prev_center then - text = "Place the center point" - else - text = "Select any draggable points" - end - return text .. " with the mouse. Use right-click to dismiss points in order." -end - -function ActionPanel:get_area_text() - local label = "Area: " - - local bounds = self.design_panel:get_view_bounds() - if not bounds then return label .. "N/A" end - local width = math.abs(bounds.x2 - bounds.x1) + 1 - local height = math.abs(bounds.y2 - bounds.y1) + 1 - local depth = math.abs(bounds.z2 - bounds.z1) + 1 - local tiles = self.design_panel.shape.num_tiles * depth - local plural = tiles > 1 and "s" or "" - return label .. ("%dx%dx%d (%d tile%s)"):format( - width, - height, - depth, - tiles, - plural - ) -end - -function ActionPanel:get_mark_text(num) - local mark = self.design_panel.marks[num] - - local label = string.format("Mark %d: ", num) - - if not mark then - return label .. "Not set" +function Design:init() + self.needs_update = true + self.marks = {} + self.extra_points = {} + self.placing_extra = {active=false, index=nil} + self.placing_mark = {active=true, index=1, continue=true} + self.placing_mirror = false + self.mirror = {horizontal=false, vertical=false} + + local mode_options = { + {label='Dig', value=make_mode_option('d', 'dig', '-', ')', COLOR_BROWN, COLOR_GRAY, 0, 22, 4)}, + {label='Stairs', value=make_mode_option('i', 'dig', '>', '<', COLOR_GRAY, COLOR_GRAY, 8, 22, 12)}, + {label='Ramp', value=make_mode_option('r', 'dig', 30, 30, COLOR_GRAY, COLOR_GRAY, 0, 25, 4)}, + {label='Channel', value=make_mode_option('h', 'dig', 31, 31, COLOR_GRAY, COLOR_GRAY, 8, 25, 12)}, + {label='Smooth', value=make_mode_option('s', 'dig', 177, 219, COLOR_GRAY, COLOR_WHITE, 0, 55, 4)}, + {label='Engrave', value=make_mode_option('e', 'dig', 219, 1, COLOR_GRAY, {fg=COLOR_WHITE, bg=COLOR_GRAY}, 0, 58, 4)}, + -- TODO: get matching selected version of erase icon + {label='Remove Designation', value=make_mode_option('x', 'dig', 'X', 'X', COLOR_LIGHTRED, COLOR_LIGHTRED, 24, 28, 12)}, + {label='Building', value=make_mode_option('b', 'build', 210, 229, COLOR_BROWN, COLOR_DARKGRAY, 16, 31, 20)}, + } + local mode_button_specs, mode_button_specs_selected = {}, {} + for _, mode_option in ipairs(mode_options) do + table.insert(mode_button_specs, mode_option.value.button_spec) + table.insert(mode_button_specs_selected, mode_option.value.button_selected_spec) end - return label .. tostring(mark) -end - --- Generic options not specific to shapes -GenericOptionsPanel = defclass(GenericOptionsPanel, widgets.ResizingPanel) -GenericOptionsPanel.ATTRS { - name = DEFAULT_NIL, - autoarrange_subviews = true, - design_panel = DEFAULT_NIL, - on_layout_change = DEFAULT_NIL, -} - -function GenericOptionsPanel:init() - local options = {} - for i, shape in ipairs(shapes.all_shapes) do - options[#options + 1] = { - label = shape.name, - value = i, - } + local DESIGN_ICONS_WIDTH = 224 -- Must match design.png image width + local DESIGN_ICONS_HEIGHT = 72 -- Must match design.png image height + local DESIGN_ICON_ROW_COUNT = 2 + local DESIGN_CHAR_WIDTH = 8 + local DESIGN_CHAR_HEIGHT = 12 + local shape_tileset = dfhack.textures.loadTileset(dfhack.getHackPath()..'/data/art/design.png', DESIGN_CHAR_WIDTH, DESIGN_CHAR_HEIGHT, true) + + local STRIDE = DESIGN_ICONS_WIDTH / DESIGN_CHAR_WIDTH + local CHARS_PER_ROW = DESIGN_ICONS_HEIGHT / (DESIGN_ICON_ROW_COUNT * DESIGN_CHAR_HEIGHT) + + local shape_options, shape_button_specs, shape_button_specs_selected = {}, {}, {} + for _, shape in ipairs(shapes.all_shapes) do + table.insert(shape_options, {label=shape.name, value=shape}) + table.insert(shape_button_specs, { + chars=shape.button_chars, + tileset=shape_tileset, + tileset_offset=shape.texture_offset, + tileset_stride=STRIDE, + }) + table.insert(shape_button_specs_selected, { + chars=shape.button_chars, + pens=COLOR_YELLOW, + tileset=shape_tileset, + tileset_offset=shape.texture_offset+(STRIDE*CHARS_PER_ROW), + tileset_stride=STRIDE, + }) end - local stair_options = { - { - label = "Auto", - value = "auto", - }, - { - label = "Up/Down", - value = "i", - }, - { - label = "Up", - value = "u", - }, - { - label = "Down", - value = "j", - }, - } - local build_options = { - { - label = "Walls", - value = "Cw", - }, - { - label = "Floor", - value = "Cf", - }, - { - label = "Fortification", - value = "CF", - }, - { - label = "Ramps", - value = "Cr", - }, - { - label = "None", - value = "`", - }, + {label='Walls', value='Cw'}, + {label='Reinforced Walls', value='CW'}, + {label='Floor', value='Cf'}, + {label='Fortification', value='CF'}, + {label='Ramps', value='Cr'}, + {label='None', value='`'}, } - self:addviews { - widgets.WrappedLabel { - view_id = "settings_label", - text_to_wrap = "General Settings:\n", - }, - widgets.CycleHotkeyLabel { - view_id = "shape_name", - key = "CUSTOM_Z", - key_back = "CUSTOM_SHIFT_Z", - label = "Shape: ", - label_width = 8, - active = true, - enabled = true, - options = options, - disabled = false, - show_tooltip = true, - on_change = self:callback("change_shape"), + self:addviews{ + widgets.ButtonGroup{ + view_id='mode', + key='CUSTOM_F', + key_back='CUSTOM_SHIFT_F', + label='Designation:', + options=mode_options, + on_change=function() self.needs_update = true end, + button_specs=mode_button_specs, + button_specs_selected=mode_button_specs_selected, }, - - widgets.ResizingPanel { autoarrange_subviews = true, - subviews = { - widgets.ToggleHotkeyLabel { - key = 'CUSTOM_SHIFT_Y', - view_id = 'transform', - label = 'Transform', - active = true, - enabled = true, - initial_option = false, - on_change = function() self.design_panel.needs_update = true end - }, - widgets.ResizingPanel { - view_id = 'transform_panel_rotate', - visible = function() return self.design_panel.subviews.transform:getOptionValue() end, - subviews = { - widgets.HotkeyLabel { - key = 'STRING_A040', - frame = { t = 1, l = 1 }, key_sep = '', - on_activate = self.design_panel:callback('on_transform', 'ccw'), - }, - widgets.HotkeyLabel { - key = 'STRING_A041', - frame = { t = 1, l = 2 }, key_sep = ':', - on_activate = self.design_panel:callback('on_transform', 'cw'), - }, - widgets.WrappedLabel { - frame = { t = 1, l = 5 }, - text_to_wrap = 'Rotate' + widgets.ResizingPanel{ + autoarrange_subviews=true, + subviews={ + widgets.Panel{ + frame={h=2}, + visible=function() return self.subviews.mode:getOptionValue().desig == 'i' end, + subviews={ + widgets.CycleHotkeyLabel{ + view_id='stairs_top_subtype', + frame={t=0, l=0}, + key='CUSTOM_R', + label=' Top stair type:', + visible=function() + local bounds = self:get_view_bounds() + return bounds and bounds.z1 ~= bounds.z2 or false + end, + options={ + {label='Auto', value='auto'}, + {label='UpDown', value='i'}, + {label='Down', value='j'}, + }, }, - widgets.HotkeyLabel { - key = 'STRING_A095', - frame = { t = 2, l = 1 }, key_sep = '', - on_activate = self.design_panel:callback('on_transform', 'flipv'), + widgets.CycleHotkeyLabel { + view_id='stairs_bottom_subtype', + frame={t=1, l=0}, + key='CUSTOM_G', + label='Bottom Stair Type:', + visible=function() + local bounds = self:get_view_bounds() + return bounds and bounds.z1 ~= bounds.z2 or false + end, + options={ + {label='Auto', value='auto'}, + {label='UpDown', value='i'}, + {label='Up', value='u'}, + }, }, - widgets.HotkeyLabel { - key = 'STRING_A061', - frame = { t = 2, l = 2 }, key_sep = ':', - on_activate = self.design_panel:callback('on_transform', 'fliph'), + widgets.CycleHotkeyLabel{ + view_id='stairs_only_subtype', + frame={t=0, l=0}, + key='CUSTOM_R', + label='Single level stair:', + visible=function() + local bounds = self:get_view_bounds() + return not bounds or bounds.z1 == bounds.z2 + end, + options={ + {label='Up', value='u'}, + {label='UpDown', value='i'}, + {label='Down', value='j'}, + }, }, - widgets.WrappedLabel { - frame = { t = 2, l = 5 }, - text_to_wrap = 'Flip' - } } - } - } - }, - widgets.ResizingPanel { autoarrange_subviews = true, - subviews = { - widgets.HotkeyLabel { - key = 'CUSTOM_M', - view_id = 'mirror_point_panel', - visible = function() return self.design_panel.shape.can_mirror end, - label = function() if not self.design_panel.mirror_point then return 'Place Mirror Point' else return 'Delete Mirror Point' end end, - active = true, - enabled = function() return not self.design_panel.placing_extra.active and - not self.design_panel.placing_mark.active and not self.prev_center - end, - on_activate = function() - if not self.design_panel.mirror_point then - self.design_panel.placing_mark.active = false - self.design_panel.placing_extra.active = false - self.design_panel.placing_extra.active = false - self.design_panel.placing_mirror = true - else - self.design_panel.placing_mirror = false - self.design_panel.mirror_point = nil - end - self.design_panel.needs_update = true - self.design_panel:updateLayout() - end }, - widgets.ResizingPanel { - view_id = 'transform_panel_rotate', - visible = function() return self.design_panel.mirror_point end, - subviews = { - widgets.CycleHotkeyLabel { - view_id = "mirror_horiz_label", - key = "CUSTOM_SHIFT_J", - label = "Mirror Horizontal: ", - active = true, - enabled = true, - show_tooltip = true, - initial_option = 1, - options = { { label = "Off", value = 1 }, { label = "On (odd)", value = 2 }, - { label = "On (even)", value = 3 } }, - frame = { t = 1, l = 1 }, key_sep = '', - on_change = function() self.design_panel.needs_update = true end + widgets.Panel{ + frame={h=2}, + visible=function() return self.subviews.mode:getOptionValue().mode == 'build' end, + subviews={ + widgets.Label{ + frame={t=0, l=0}, + text={{tile=BUTTON_PEN_LEFT}, {tile=HELP_PEN_CENTER}, {tile=BUTTON_PEN_RIGHT}}, + on_click=self:callback('show_help', CONSTRUCTION_HELP), }, - widgets.CycleHotkeyLabel { - view_id = "mirror_diag_label", - key = "CUSTOM_SHIFT_O", - label = "Mirror Diagonal: ", - active = true, - enabled = true, - show_tooltip = true, - initial_option = 1, - options = { { label = "Off", value = 1 }, { label = "On (odd)", value = 2 }, - { label = "On (even)", value = 3 } }, - frame = { t = 2, l = 1 }, key_sep = '', - on_change = function() self.design_panel.needs_update = true end + widgets.CycleHotkeyLabel{ + view_id='building_outer_tiles', + frame={t=0, l=4}, + key='CUSTOM_R', + label='Outer Tiles:', + initial_option='Cw', + options=build_options, }, widgets.CycleHotkeyLabel { - view_id = "mirror_vert_label", - key = "CUSTOM_SHIFT_K", - label = "Mirror Vertical: ", - active = true, - enabled = true, - show_tooltip = true, - initial_option = 1, - options = { { label = "Off", value = 1 }, { label = "On (odd)", value = 2 }, - { label = "On (even)", value = 3 } }, - frame = { t = 3, l = 1 }, key_sep = '', - on_change = function() self.design_panel.needs_update = true end + view_id='building_inner_tiles', + frame={t=1, l=4}, + key='CUSTOM_G', + label='Inner Tiles:', + initial_option='Cf', + options=build_options, }, - widgets.HotkeyLabel { - view_id = "mirror_vert_label", - key = "CUSTOM_SHIFT_M", - label = "Save Mirrored Points", - active = true, - enabled = true, - show_tooltip = true, - initial_option = 1, - frame = { t = 4, l = 1 }, key_sep = ': ', - on_activate = function() - local points = self.design_panel:get_mirrored_points(self.design_panel.marks) - self.design_panel.marks = points - self.design_panel.mirror_point = nil - end - }, - } - } - } + }, + }, + widgets.CycleHotkeyLabel{ + view_id='priority', + key='CUSTOM_SHIFT_P', + key_back='CUSTOM_P', + label='Priority:', + options={1, 2, 3, 4, 5, 6, 7}, + initial_option=4, + visible=function() + local mode = self.subviews.mode:getOptionValue() + return mode.mode == 'dig' and mode.desig ~= 'x' + end, + }, + widgets.HotkeyLabel{ + key='CUSTOM_CTRL_X', + label='Clear entire z-level', + on_activate=function() + local map = df.global.world.map + quickfort.apply_blueprint{ + mode='dig', + data=('x(%dx%d)'):format(map.x_count, map.y_count), + pos=xyz2pos(0, 0, df.global.window_z), + } + end, + visible=function() + local mode = self.subviews.mode:getOptionValue() + return mode.mode == 'dig' and mode.desig == 'x' + end, + }, + }, }, - widgets.ToggleHotkeyLabel { - view_id = "invert_designation_label", - key = "CUSTOM_I", - label = "Invert: ", - label_width = 8, - active = true, - enabled = function() - return self.design_panel.shape.invertable == true - end, - show_tooltip = true, - initial_option = false, - on_change = function(new, old) - self.design_panel.shape.invert = new - self.design_panel.needs_update = true - end, + widgets.Divider{ + frame={h=1}, + frame_style=gui.FRAME_THIN, + frame_style_l=false, + frame_style_r=false, }, - widgets.HotkeyLabel { - view_id = "shape_place_extra_point", - key = "CUSTOM_V", - label = function() - local msg = "Place extra point: " - if #self.design_panel.extra_points < #self.design_panel.shape.extra_points then - return msg .. self.design_panel.shape.extra_points[#self.design_panel.extra_points + 1].label - end - - return msg .. "N/A" - end, - active = true, - visible = function() return self.design_panel.shape and #self.design_panel.shape.extra_points > 0 end, - enabled = function() - if self.design_panel.shape then - return #self.design_panel.extra_points < #self.design_panel.shape.extra_points - end - - return false - end, - show_tooltip = true, - on_activate = function() - if not self.design_panel.placing_mark.active then - self.design_panel.placing_extra.active = true - self.design_panel.placing_extra.index = #self.design_panel.extra_points + 1 - elseif #self.design_panel.marks then - local mouse_pos = getMousePoint() - if mouse_pos then table.insert(self.design_panel.extra_points, - mouse_pos) - end + widgets.ButtonGroup{ + view_id='shape', + key='CUSTOM_Z', + key_back='CUSTOM_SHIFT_Z', + label='Shape:', + options=shape_options, + on_change=function(shape, prev_shape) + self.needs_update = true + if shape.max_points ~= prev_shape.max_points then + self:reset() end - self.design_panel.needs_update = true end, + button_specs=shape_button_specs, + button_specs_selected=shape_button_specs_selected, }, - widgets.HotkeyLabel { - view_id = "shape_toggle_placing_marks", - key = "CUSTOM_B", - label = function() - return (self.design_panel.placing_mark.active) and "Stop placing" or "Start placing" - end, - active = true, - visible = true, - enabled = function() - if not self.design_panel.placing_mark.active and not self.design_panel.prev_center then - return not self.design_panel.shape.max_points or - #self.design_panel.marks < self.design_panel.shape.max_points - elseif not self.design_panel.placing_extra.active and not self.design_panel.prev_centerl then - return true - end + } - return false - end, - show_tooltip = true, - on_activate = function() - self.design_panel.placing_mark.active = not self.design_panel.placing_mark.active - self.design_panel.placing_mark.index = (self.design_panel.placing_mark.active) and - #self.design_panel.marks + 1 or - nil - if not self.design_panel.placing_mark.active then - table.remove(self.design_panel.marks, #self.design_panel.marks) - else - self.design_panel.placing_mark.continue = true - end + -- Currently only supports "bool" aka toggle and "plusminus" which creates + -- a pair of HotKeyLabel's to increment/decrement a value + -- Will need to update as needed to add more option types + local shape_options_panel = widgets.ResizingPanel{ + autoarrange_subviews=true, + } - self.design_panel.needs_update = true - end, - }, - widgets.HotkeyLabel { - view_id = "shape_clear_all_points", - key = "CUSTOM_X", - label = "Clear all points", - active = true, - enabled = function() - if #self.design_panel.marks > 0 then return true - elseif self.design_panel.shape then - if #self.design_panel.extra_points < #self.design_panel.shape.extra_points then - return true - end - end + for _, shape in ipairs(shapes.all_shapes) do + for _, option in pairs(shape.options) do + if option.type ~= 'bool' then goto continue end + shape_options_panel:addviews{ + widgets.ToggleHotkeyLabel{ + frame={h=1}, + auto_height=false, + key=option.key, + label=option.name..':', + initial_option=option.value, + enabled=option.enabled and function() + return shape.options[option.enabled[1]].value == option.enabled[2] + end or nil, + on_change=function(val) + option.value = val + self.needs_update = true + end, + visible=function() return self.subviews.shape:getOptionValue() == shape end, + } + } + ::continue:: + end + for _, option in pairs(shape.options) do + if option.type ~= 'plusminus' then goto continue end + shape_options_panel:addviews{ + widgets.Panel{ + frame={h=1}, + visible=function() return self.subviews.shape:getOptionValue() == shape end, + subviews={ + widgets.HotkeyLabel{ + frame={t=0, l=0, w=1}, + key=option.keys[1], + key_sep='', + enabled=function() + if option.enabled then + if shape.options[option.enabled[1]].value ~= option.enabled[2] then + return false + end + end + local min = utils.getval(option.min, shape) + return not min or option.value > min + end, + on_activate=function() + option.value = option.value - 1 + self.needs_update = true + end, + }, + widgets.HotkeyLabel{ + frame={t=0, l=1}, + key=option.keys[2], + label=function() return ('%s: %d'):format(option.name, option.value) end, + enabled=function() + if option.enabled then + if shape.options[option.enabled[1]].value ~= option.enabled[2] then + return false + end + end + local max = utils.getval(option.max, shape) + return not max or option.value <= max + end, + on_activate=function() + option.value = option.value + 1 + self.needs_update = true + end, + } + }, + }, + } + ::continue:: + end + if shape.invertable then + shape_options_panel:addviews{ + widgets.ToggleHotkeyLabel{ + key='CUSTOM_I', + label='Invert:', + initial_option=shape.invert, + on_change=function(val) + shape.invert = val + self.needs_update = true + end, + visible=function() return self.subviews.shape:getOptionValue() == shape end, + }, + } + end + end - return false - end, - disabled = false, - show_tooltip = true, - on_activate = function() - self.design_panel.marks = {} - self.design_panel.placing_mark.active = true - self.design_panel.placing_mark.index = 1 - self.design_panel.extra_points = {} - self.design_panel.prev_center = nil - self.design_panel.start_center = nil - self.design_panel.needs_update = true - end, - }, - widgets.HotkeyLabel { - view_id = "shape_clear_extra_points", - key = "CUSTOM_SHIFT_X", - label = "Clear extra points", - active = true, - enabled = function() - if self.design_panel.shape then - if #self.design_panel.extra_points > 0 then - return true - end - end + local mirror_options = { + {label='Off', value=1}, + {label='On (odd)', value=2}, + {label='On (even)', value=3} + } - return false - end, - disabled = false, - visible = function() return self.design_panel.shape and #self.design_panel.shape.extra_points > 0 end, - show_tooltip = true, - on_activate = function() - if self.design_panel.shape then - self.design_panel.extra_points = {} - self.design_panel.prev_center = nil - self.design_panel.start_center = nil - self.design_panel.placing_extra = { active = false, index = 0 } - self.design_panel:updateLayout() - self.design_panel.needs_update = true - end - end, - }, - widgets.ToggleHotkeyLabel { - view_id = "shape_show_guides", - key = "CUSTOM_SHIFT_G", - label = "Show Cursor Guides", - active = true, - enabled = true, - visible = true, - show_tooltip = true, - initial_option = true, - on_change = function(new, old) - self.design_panel.show_guides = new - end, + self:addviews{ + shape_options_panel, + widgets.Divider{ + frame={h=1}, + frame_style=gui.FRAME_THIN, + frame_style_l=false, + frame_style_r=false, }, - widgets.CycleHotkeyLabel { - view_id = "mode_name", - key = "CUSTOM_F", - key_back = "CUSTOM_SHIFT_F", - label = "Mode: ", - label_width = 8, - active = true, - enabled = true, - options = { - { - label = "Dig", - value = { desig = "d", mode = "dig" }, - }, - { - label = "Channel", - value = { desig = "h", mode = "dig" }, - }, - { - label = "Remove Designation", - value = { desig = "x", mode = "dig" }, - }, - { - label = "Remove Ramps", - value = { desig = "z", mode = "dig" }, - }, - { - label = "Remove Constructions", - value = { desig = "n", mode = "dig" }, - }, - { - label = "Stairs", - value = { desig = "i", mode = "dig" }, + widgets.ResizingPanel{ + autoarrange_subviews=true, + subviews={ + widgets.HotkeyLabel { + key='CUSTOM_B', + label=function() + return self.placing_mark.active and 'Stop placing points' or 'Start placing more points' + end, + visible=function() return not self.subviews.shape:getOptionValue().max_points end, + enabled=function() return not self.prev_center end, + on_activate=function() + self.placing_mark.active = not self.placing_mark.active + self.placing_mark.index = self.placing_mark.active and #self.marks + 1 or nil + if not self.placing_mark.active then + table.remove(self.marks, #self.marks) + else + self.placing_mark.continue = true + end + self.needs_update=true + end, }, - { - label = "Ramp", - value = { desig = "r", mode = "dig" }, + widgets.HotkeyLabel { + key='CUSTOM_V', + label=function() + local msg='Add: ' + local shape = self.subviews.shape:getOptionValue() + if #self.extra_points < #shape.extra_points then + return msg .. shape.extra_points[#self.extra_points + 1].label + end + return msg .. 'N/A' + end, + enabled=function() + return #self.marks > 1 and + #self.extra_points < #self.subviews.shape:getOptionValue().extra_points + end, + visible=function() return #self.subviews.shape:getOptionValue().extra_points > 0 end, + on_activate=function() + if not self.placing_mark.active then + self.placing_extra.active=true + self.placing_extra.index=#self.extra_points + 1 + elseif #self.marks > 0 then + local mouse_pos = getMousePoint() + if mouse_pos then table.insert(self.extra_points, mouse_pos) end + end + self.needs_update = true + end, }, - { - label = "Smooth", - value = { desig = "s", mode = "dig" }, + widgets.Panel{ + frame={h=1}, + subviews={ + widgets.HotkeyLabel{ + frame={t=0, l=0, w=1}, + key='STRING_A040', + key_sep='', + enabled=function() return #self.marks > 1 end, + on_activate=self:callback('on_transform', 'ccw'), + }, + widgets.HotkeyLabel{ + frame={t=0, l=1}, + key='STRING_A041', + label='Rotate', + auto_width=true, + enabled=function() return #self.marks > 1 end, + on_activate=self:callback('on_transform', 'cw'), + }, + widgets.HotkeyLabel{ + frame={t=0, l=14, w=1}, + key='STRING_A095', + key_sep='', + enabled=function() return #self.marks > 1 end, + on_activate=self:callback('on_transform', 'flipv'), + }, + widgets.HotkeyLabel { + frame={t=0, l=15}, + key='STRING_A061', + label='Flip', + auto_width=true, + enabled=function() return #self.marks > 1 end, + on_activate=self:callback('on_transform', 'fliph'), + }, + } }, - { - label = "Engrave", - value = { desig = "e", mode = "dig" }, + widgets.HotkeyLabel{ + key='CUSTOM_M', + visible=function() return self.subviews.shape:getOptionValue().can_mirror end, + label=function() + if not self.placing_mirror and not self.mirror_point then + return 'Mirror across axis' + else + return 'Cancel mirror' + end + end, + enabled=function() + if #self.marks < 2 then return false end + return not self.placing_extra.active and + not self.placing_mark.active and not self.prev_center + end, + on_activate=function() + if not self.mirror_point then + self.placing_mark.active = false + self.placing_extra.active = false + self.placing_extra.active = false + self.placing_mirror = true + else + self.placing_mirror = false + self.mirror_point = nil + end + self.needs_update = true + end }, - { - label = "Building", - value = { desig = "b", mode = "build" }, - } - }, - disabled = false, - show_tooltip = true, - on_change = function(new, old) self.design_panel:updateLayout() end, - }, - widgets.ResizingPanel { - view_id = 'stairs_type_panel', - visible = self:callback("is_mode_selected", "i"), - subviews = { widgets.CycleHotkeyLabel { - view_id = "stairs_top_subtype", - key = "CUSTOM_R", - label = "Top Stair Type: ", - frame = { t = 0, l = 1 }, - active = true, - enabled = true, - options = stair_options, + view_id='mirror_horiz_label', + frame={l=1}, + key='CUSTOM_SHIFT_J', + label='Mirror horizontal: ', + options=mirror_options, + on_change=function() self.needs_update = true end, + visible=function() return self.placing_mirror or self.mirror_point end, }, widgets.CycleHotkeyLabel { - view_id = "stairs_middle_subtype", - key = "CUSTOM_G", - label = "Middle Stair Type: ", - frame = { t = 1, l = 1 }, - active = true, - enabled = true, - options = stair_options, + view_id='mirror_diag_label', + frame={l=1}, + key='CUSTOM_SHIFT_O', + label='Mirror diagonal: ', + options=mirror_options, + on_change=function() self.needs_update = true end, + visible=function() return self.placing_mirror or self.mirror_point end, }, widgets.CycleHotkeyLabel { - view_id = "stairs_bottom_subtype", - key = "CUSTOM_N", - label = "Bottom Stair Type: ", - frame = { t = 2, l = 1 }, - active = true, - enabled = true, - options = stair_options, - } + view_id='mirror_vert_label', + frame={l=1}, + key='CUSTOM_SHIFT_K', + label='Mirror vertical: ', + options=mirror_options, + on_change=function() self.needs_update = true end, + visible=function() return self.placing_mirror or self.mirror_point end, + }, + widgets.HotkeyLabel { + frame={l=1}, + key='CUSTOM_SHIFT_M', + label='Commit mirror changes', + on_activate=function() + self.marks = self:get_mirrored_points(self.marks) + self.mirror_point = nil + self.needs_update = true + end, + visible=function() return self.placing_mirror or self.mirror_point end, + }, + widgets.HotkeyLabel { + key='CUSTOM_X', + label='Reset', + enabled=function() return #self.marks > 0 or #self.extra_points > 0 end, + on_activate=self:callback('reset'), + }, } }, - widgets.ResizingPanel { - view_id = 'building_types_panel', - visible = self:callback("is_mode_selected", "b"), - subviews = { - widgets.Label { - view_id = "building_outer_config", - frame = { t = 0, l = 1 }, - text = { { tile = BUTTON_PEN_LEFT }, { tile = HELP_PEN_CENTER }, { tile = BUTTON_PEN_RIGHT } }, - on_click = self.design_panel:callback("show_help", CONSTRUCTION_HELP) - }, - widgets.CycleHotkeyLabel { - view_id = "building_outer_tiles", - key = "CUSTOM_R", - label = "Outer Tiles: ", - frame = { t = 0, l = 5 }, - active = true, - enabled = true, - initial_option = 1, - options = build_options, + widgets.Panel{ + frame={b=0}, + subviews={ + widgets.Panel{ + frame={t=0, b=3}, + frame_style=gui.FRAME_INTERIOR, + subviews={ + widgets.Panel{ + -- area expands with window + frame={t=0, b=2}, + autoarrange_subviews=true, + autoarrange_gap=1, + subviews={ + widgets.WrappedLabel{ + text_to_wrap=self:callback('get_action_text'), + text_pen=COLOR_YELLOW, + }, + widgets.WrappedLabel{ + text_to_wrap=self:callback('get_area_text'), + }, + widgets.WrappedLabel{ + view_id='mark_text', + text_to_wrap=self:callback('get_mark_text'), + }, + }, + }, + widgets.HotkeyLabel{ + frame={b=0, l=0}, + key='SELECT', + label='Commit shape to the map', + enabled=function() return #self.marks >= self.subviews.shape:getOptionValue().min_points end, + on_activate=function() + self:commit() + self.needs_update=true + end, + }, + }, }, - widgets.Label { - view_id = "building_inner_config", - frame = { t = 1, l = 1 }, - text = { { tile = BUTTON_PEN_LEFT }, { tile = HELP_PEN_CENTER }, { tile = BUTTON_PEN_RIGHT } }, - on_click = self.design_panel:callback("show_help", CONSTRUCTION_HELP) + widgets.ToggleHotkeyLabel{ + view_id='show_guides', + frame={b=1, l=0}, + key='CUSTOM_SHIFT_G', + label='Show alignment guides:', + initial_option=true, }, - widgets.CycleHotkeyLabel { - view_id = "building_inner_tiles", - key = "CUSTOM_G", - label = "Inner Tiles: ", - frame = { t = 1, l = 5 }, - active = true, - enabled = true, - initial_option = 2, - options = build_options, + widgets.ToggleHotkeyLabel{ + view_id='autocommit', + frame={b=0, l=0}, + key='CUSTOM_ALT_C', + label='Auto-commit on click:', + initial_option=false, }, }, }, - widgets.WrappedLabel { - view_id = "shape_prio_label", - text_to_wrap = function() - return "Priority: " .. tostring(self.design_panel.prio) - end, - }, - widgets.HotkeyLabel { - view_id = "shape_option_priority_minus", - key = "CUSTOM_P", - label = "Increase Priority", - active = true, - enabled = function() - return self.design_panel.prio > 1 - end, - disabled = false, - show_tooltip = true, - on_activate = function() - self.design_panel.prio = self.design_panel.prio - 1 - self.design_panel:updateLayout() - self.design_panel.needs_update = true - end, - }, - widgets.HotkeyLabel { - view_id = "shape_option_priority_plus", - key = "CUSTOM_SHIFT_P", - label = "Decrease Priority", - active = true, - enabled = function() - return self.design_panel.prio < 7 - end, - disabled = false, - show_tooltip = true, - on_activate = function() - self.design_panel.prio = self.design_panel.prio + 1 - self.design_panel:updateLayout() - self.design_panel.needs_update = true - end, - }, - widgets.ToggleHotkeyLabel { - view_id = "autocommit_designation_label", - key = "CUSTOM_ALT_C", - label = "Auto-Commit: ", - active = true, - enabled = function() return self.design_panel.shape.max_points end, - disabled = false, - show_tooltip = true, - initial_option = true, - on_change = function(new, old) - self.design_panel.autocommit = new - self.design_panel.needs_update = true - end, - }, - widgets.HotkeyLabel { - view_id = "commit_label", - key = "CUSTOM_CTRL_C", - label = "Commit Designation", - active = true, - enabled = function() - return #self.design_panel.marks >= self.design_panel.shape.min_points - end, - disabled = false, - show_tooltip = true, - on_activate = function() - self.design_panel:commit() - self.design_panel.needs_update = true - end, - }, } end -function GenericOptionsPanel:is_mode_selected(mode) - return self.design_panel.subviews.mode_name:getOptionValue().desig == mode +function Design:reset() + self.needs_update = true + self.marks = {} + self.extra_points = {} + self.placing_extra = {active=false, index=nil} + self.placing_mark = {active=true, index=1, continue=true} + self.placing_mirror = false + self.mirror = {horizontal=false, vertical=false} + self.prev_center = nil + self.start_center = nil end -function GenericOptionsPanel:change_shape(new, old) - self.design_panel.shape = shapes.all_shapes[new] - if self.design_panel.shape.max_points and #self.design_panel.marks > self.design_panel.shape.max_points then - -- pop marks until we're down to the max of the new shape - for i = #self.design_panel.marks, self.design_panel.shape.max_points, -1 do - table.remove(self.design_panel.marks, i) - end +function Design:get_action_text() + local text = '' + if self.marks[2] and self.placing_mark.active then + text = 'Click to place the point' + elseif not self.marks[2] then + text = 'Click to place the first point' + elseif not self.placing_extra.active and not self.prev_center then + text = 'Move any draggable points' + elseif self.placing_extra.active then + text = 'Place any extra points' + elseif self.prev_center then + text = 'Move the center point' + else + text = 'Move any draggable points' end - self.design_panel:add_shape_options() - self.design_panel.needs_update = true - self.design_panel:updateLayout() + return text .. ' with the mouse. Use right-click to dismiss points in order.' end --- --- Design --- +function Design:get_area_text() + local bounds = self:get_view_bounds() + local label = 'Area: ' + if not bounds then return label .. 'N/A' end + local width = math.abs(bounds.x2 - bounds.x1) + 1 + local height = math.abs(bounds.y2 - bounds.y1) + 1 + local depth = math.abs(bounds.z2 - bounds.z1) + 1 + local tiles = self.subviews.shape:getOptionValue().num_tiles * depth + local plural = tiles == 1 and '' or 's' + return label .. ('%dx%dx%d (%d tile%s)'):format(width, height, depth, tiles, plural) +end -Design = defclass(Design, widgets.Window) -Design.ATTRS { - name = "design_window", - frame_title = "Design", - frame = { - w = 40, - h = 45, - r = 2, - t = 18, - }, - resizable = true, - resize_min = { h = 30 }, - autoarrange_subviews = true, - autoarrange_gap = 1, - shape = DEFAULT_NIL, - prio = 4, - autocommit = true, - cur_shape = 1, - placing_extra = { active = false, index = nil }, - placing_mark = { active = true, index = 1, continue = true }, - prev_center = DEFAULT_NIL, - start_center = DEFAULT_NIL, - extra_points = {}, - last_mouse_point = DEFAULT_NIL, - needs_update = false, - marks = {}, - placing_mirror = false, - mirror_point = DEFAULT_NIL, - mirror = { horizontal = false, vertical = false }, - show_guides = true, -} +function Design:get_mark_text() + local label_text = {} + local marks = self.marks + local num_marks = #marks --- Check to see if we're moving a point, or some change was made that implise we need to update the shape --- This stop us needing to update the shape geometery every frame which can tank FPS -function Design:shape_needs_update() + if num_marks >= 1 then + local first_mark = marks[1] + table.insert(label_text, ('First Mark (%d): %d, %d, %d') + :format(1, first_mark.x, first_mark.y, first_mark.z)) + end + + if num_marks > 1 then + local last_index = num_marks - (self.placing_mark.active and 1 or 0) + local last_mark = marks[last_index] + if last_mark then + table.insert(label_text, ('Last Mark (%d): %d, %d, %d') + :format(last_index, last_mark.x, last_mark.y, last_mark.z)) + end + end + + local mouse_pos = getMousePoint() + if mouse_pos then + table.insert(label_text, ('Mouse: %d, %d, %d'):format(mouse_pos.x, mouse_pos.y, mouse_pos.z)) + end + + local mirror = self.mirror_point + if mirror then + table.insert(label_text, ('Mirror Point: %d, %d, %d'):format(mirror.x, mirror.y, mirror.z)) + end + return label_text +end + +-- Check to see if we're moving a point, or some change was made that implies we need to update the shape +-- This stops us needing to update the shape geometery every frame which can tank FPS +function Design:shape_needs_update() if self.needs_update then return true end local mouse_pos = getMousePoint() if mouse_pos then local mouse_moved = not self.last_mouse_point and mouse_pos or - ( - self.last_mouse_point ~= mouse_pos) + (self.last_mouse_point ~= mouse_pos) if self.placing_mark.active and mouse_moved then return true @@ -1003,144 +935,9 @@ function Design:shape_needs_update() return false end -function Design:init() - self:addviews { - ActionPanel { - view_id = "action_panel", - design_panel = self, - get_extra_pt_count = function() - return #self.extra_points - end, - }, - MarksPanel { - view_id = "marks_panel", - design_panel = self, - }, - GenericOptionsPanel { - view_id = "generic_panel", - design_panel = self, - } - } -end - -function Design:postinit() - self.shape = shapes.all_shapes[self.subviews.shape_name:getOptionValue()] - if self.shape then - self:add_shape_options() - end -end - --- Add shape specific options dynamically based on the shape.options table --- Currently only supports 'bool' aka toggle and 'plusminus' which creates --- a pair of HotKeyLabel's to increment/decrement a value --- Will need to update as needed to add more option types -function Design:add_shape_options() - local prefix = "shape_option_" - for i, view in ipairs(self.subviews or {}) do - if view.view_id:sub(1, #prefix) == prefix then - self.subviews[i] = nil - end - end - - if not self.shape or not self.shape.options then return end - - self:addviews { - widgets.WrappedLabel { - view_id = "shape_option_label", - text_to_wrap = "Shape Settings:\n", - } - } - - for key, option in pairs(self.shape.options) do - if option.type == "bool" then - self:addviews { - widgets.ToggleHotkeyLabel { - view_id = "shape_option_" .. option.name, - key = option.key, - label = option.name, - active = true, - enabled = function() - if not option.enabled then - return true - else - return self.shape.options[option.enabled[1]].value == option.enabled[2] - end - end, - disabled = false, - show_tooltip = true, - initial_option = option.value, - on_change = function(new, old) - self.shape.options[key].value = new - self.needs_update = true - end, - } - } - - elseif option.type == "plusminus" then - local min, max = nil, nil - if type(option['min']) == "number" then - min = option['min'] - elseif type(option['min']) == "function" then - min = option['min'](self.shape) - end - if type(option['max']) == "number" then - max = option['max'] - elseif type(option['max']) == "function" then - max = option['max'](self.shape) - end - - self:addviews { - widgets.HotkeyLabel { - view_id = "shape_option_" .. option.name .. "_minus", - key = option.keys[1], - label = "Decrease " .. option.name, - active = true, - enabled = function() - if option.enabled then - if self.shape.options[option.enabled[1]].value ~= option.enabled[2] then - return false - end - end - return not min or - (self.shape.options[key].value > min) - end, - disabled = false, - show_tooltip = true, - on_activate = function() - self.shape.options[key].value = - self.shape.options[key].value - 1 - self.needs_update = true - end, - }, - widgets.HotkeyLabel { - view_id = "shape_option_" .. option.name .. "_plus", - key = option.keys[2], - label = "Increase " .. option.name, - active = true, - enabled = function() - if option.enabled then - if self.shape.options[option.enabled[1]].value ~= option.enabled[2] then - return false - end - end - return not max or - (self.shape.options[key].value <= max) - end, - disabled = false, - show_tooltip = true, - on_activate = function() - self.shape.options[key].value = - self.shape.options[key].value + 1 - self.needs_update = true - end, - } - } - end - end -end - function Design:on_transform(val) - local center = self.shape:get_center() + local shape = self.subviews.shape:getOptionValue() + local center = shape:get_center() -- Save mirrored points first if self.mirror_point then @@ -1161,7 +958,7 @@ function Design:on_transform(val) elseif val == 'flipv' then y = center.y - (y - center.y) end - self.marks[i] = Point { x = math.floor(x + 0.5), y = math.floor(y + 0.5), z = self.marks[i].z } + self.marks[i] = Point{x=math.floor(x + 0.5), y=math.floor(y + 0.5), z=self.marks[i].z} end -- Transform extra points @@ -1176,26 +973,25 @@ function Design:on_transform(val) elseif val == 'flipv' then y = center.y - (y - center.y) end - self.extra_points[i] = Point { x = math.floor(x + 0.5), y = math.floor(y + 0.5), z = self.extra_points[i].z } + self.extra_points[i] = Point{x=math.floor(x + 0.5), y=math.floor(y + 0.5), z=self.extra_points[i].z} end -- Calculate center point after transformation - self.shape:update(self.marks, self.extra_points) - local new_center = self.shape:get_center() + shape:update(self.marks, self.extra_points) + local new_center = shape:get_center() -- Calculate delta between old and new center points local delta = center - new_center -- Adjust marks and extra points based on delta for i, mark in ipairs(self.marks) do - self.marks[i] = mark + Point { x = delta.x, y = delta.y, z = 0 } + self.marks[i] = mark + Point{x=delta.x, y=delta.y, z=0} end for i, point in ipairs(self.extra_points) do - self.extra_points[i] = point + Point { x = delta.x, y = delta.y, z = 0 } + self.extra_points[i] = point + Point{x=delta.x, y=delta.y, z=0} end - self:updateLayout() self.needs_update = true end @@ -1212,6 +1008,11 @@ function Design:get_view_bounds() local marks_plus_next = copyall(self.marks) local mouse_pos = getMousePoint() if mouse_pos then + if not self.placing_mark.active then + -- only get the z coord from the mouse position + mouse_pos.x = self.marks[1].x + mouse_pos.y = self.marks[1].y + end table.insert(marks_plus_next, mouse_pos) end @@ -1229,27 +1030,16 @@ end -- TODO Function is too long function Design:onRenderFrame(dc, rect) - if (SHOW_DEBUG_WINDOW) then - self.parent_view.debug_window:updateLayout() - end - + self.subviews.mark_text:updateLayout() Design.super.onRenderFrame(self, dc, rect) - if not self.shape then - self.shape = shapes.all_shapes[self.subviews.shape_name:getOptionValue()] - end - local mouse_pos = getMousePoint() - - self.subviews.marks_panel:update_mark_labels() + local shape = self.subviews.shape:getOptionValue() if self.placing_mark.active and self.placing_mark.index and mouse_pos then self.marks[self.placing_mark.index] = mouse_pos end - -- Set main points - local points = copyall(self.marks) - -- Set the pos of the currently moving extra point if self.placing_extra.active then self.extra_points[self.placing_extra.index] = mouse_pos @@ -1263,11 +1053,11 @@ function Design:onRenderFrame(dc, rect) end -- Check if moving center, if so shift the shape by the delta between the previous and current points - -- TODO clean this up if self.prev_center and - ((self.shape.basic_shape and #self.marks == self.shape.max_points) - or (not self.shape.basic_shape and not self.placing_mark.active)) - and mouse_pos and (self.prev_center ~= mouse_pos) then + ((shape.basic_shape and #self.marks == shape.max_points) + or (not shape.basic_shape and not self.placing_mark.active)) + and mouse_pos and (self.prev_center ~= mouse_pos) + then self.needs_update = true local transform = mouse_pos - self.prev_center @@ -1289,23 +1079,25 @@ function Design:onRenderFrame(dc, rect) self.prev_center = mouse_pos end + -- Set main points + local points = copyall(self.marks) + if self.mirror_point then points = self:get_mirrored_points(points) end if self:shape_needs_update() then - self.shape:update(points, self.extra_points) + shape:update(points, self.extra_points) self.last_mouse_point = mouse_pos self.needs_update = false - self:add_shape_options() self:updateLayout() - plugin.design_clear_shape(self.shape.arr) + plugin.design_clear_shape(shape.arr) end -- Generate bounds based on the shape's dimensions local bounds = self:get_view_bounds() - if self.shape and bounds then - local top_left, bot_right = self.shape:get_view_dims(self.extra_points, self.mirror_point) + if bounds then + local top_left, bot_right = shape:get_view_dims(self.extra_points, self.mirror_point) if not top_left or not bot_right then return end bounds.x1 = top_left.x bounds.x2 = bot_right.x @@ -1314,11 +1106,11 @@ function Design:onRenderFrame(dc, rect) end -- Show mouse guidelines - if self.show_guides and mouse_pos then - local map_x, map_y, map_z = dfhack.maps.getTileSize() - local horiz_bounds = { x1 = 0, x2 = map_x, y1 = mouse_pos.y, y2 = mouse_pos.y, z1 = mouse_pos.z, z2 = mouse_pos.z } + if self.subviews.show_guides:getOptionValue() and mouse_pos and not self:getMouseFramePos() then + local map_x, map_y = dfhack.maps.getTileSize() + local horiz_bounds = {x1=0, x2=map_x, y1=mouse_pos.y, y2=mouse_pos.y, z1=mouse_pos.z, z2=mouse_pos.z} guidm.renderMapOverlay(function() return guide_tile_pen end, horiz_bounds) - local vert_bounds = { x1 = mouse_pos.x, x2 = mouse_pos.x, y1 = 0, y2 = map_y, z1 = mouse_pos.z, z2 = mouse_pos.z } + local vert_bounds = {x1=mouse_pos.x, x2=mouse_pos.x, y1=0, y2=map_y, z1=mouse_pos.z, z2=mouse_pos.z} guidm.renderMapOverlay(function() return guide_tile_pen end, vert_bounds) end @@ -1341,57 +1133,55 @@ function Design:onRenderFrame(dc, rect) if mirror_vert_value ~= 1 or mirror_diag_value ~= 1 then local vert_bounds = { - x1 = self.mirror_point.x, x2 = self.mirror_point.x, - y1 = 0, y2 = map_y, - z1 = self.mirror_point.z, z2 = self.mirror_point.z + x1=self.mirror_point.x, x2=self.mirror_point.x, + y1=0, y2=map_y, + z1=self.mirror_point.z, z2=self.mirror_point.z, } guidm.renderMapOverlay(function() return mirror_guide_pen end, vert_bounds) end end - plugin.design_draw_shape(self.shape.arr) + plugin.design_draw_shape(shape.arr) - if #self.marks >= self.shape.min_points and self.shape.basic_shape then - local shape_top_left, shape_bot_right = self.shape:get_point_dims() + if #self.marks >= shape.min_points and shape.basic_shape then + local shape_top_left, shape_bot_right = shape:get_point_dims() local drag_points = { - Point { x = shape_top_left.x, y = shape_top_left.y }, - Point { x = shape_bot_right.x, y = shape_bot_right.y }, - Point { x = shape_top_left.x, y = shape_bot_right.y }, - Point { x = shape_bot_right.x, y = shape_top_left.y } + Point{x=shape_top_left.x, y=shape_top_left.y}, + Point{x=shape_bot_right.x, y=shape_bot_right.y}, + Point{x=shape_top_left.x, y=shape_bot_right.y}, + Point{x=shape_bot_right.x, y=shape_top_left.y} } - plugin.design_draw_points({ drag_points, "drag_point" }) + plugin.design_draw_points({drag_points, 'drag_point'}) else - plugin.design_draw_points({ self.marks, "drag_point" }) + plugin.design_draw_points({self.marks, 'drag_point'}) end - plugin.design_draw_points({ self.extra_points, "extra_point" }) + plugin.design_draw_points({self.extra_points, 'extra_point'}) - if (self.shape.basic_shape and #self.marks == self.shape.max_points) or - (not self.shape.basic_shape and not self.placing_mark.active and #self.marks > 0) then - plugin.design_draw_points({ { self.shape:get_center() }, "extra_point" }) + if (shape.basic_shape and #self.marks == shape.max_points) or + (not shape.basic_shape and not self.placing_mark.active and #self.marks > 0) then + plugin.design_draw_points({{shape:get_center()}, 'extra_point'}) end - plugin.design_draw_points({ { self.mirror_point }, "extra_point" }) - + plugin.design_draw_points({{self.mirror_point}, 'extra_point'}) end --- TODO function too long function Design:onInput(keys) if Design.super.onInput(self, keys) then return true end - -- Secret shortcut to kill the panel if it becomes - -- unresponsive during development, should not release - -- if keys.CUSTOM_SHIFT_Q then - -- plugin.getPen(self.shape.arr) - -- return - -- end + local shape = self.subviews.shape:getOptionValue() if keys.LEAVESCREEN or keys._MOUSE_R then + if dfhack.internal.getModifiers().shift then + -- shift right click always closes immediately + return false + end + -- Close help window if open if view.help_window.visible then self:dismiss_help() return true end - -- If center draggin, put the shape back to the original center + -- If center dragging, put the shape back to the original center if self.prev_center then local transform = self.start_center - self.prev_center @@ -1407,20 +1197,17 @@ function Design:onInput(keys) self.start_center = nil self.needs_update = true return true - end -- TODO + end -- If extra points, clear them and return - if self.shape then - if #self.extra_points > 0 or self.placing_extra.active then - self.extra_points = {} - self.placing_extra.active = false - self.prev_center = nil - self.start_center = nil - self.placing_extra.index = 0 - self.needs_update = true - self:updateLayout() - return true - end + if #self.extra_points > 0 or self.placing_extra.active then + self.extra_points = {} + self.placing_extra.active = false + self.prev_center = nil + self.start_center = nil + self.placing_extra.index = 0 + self.needs_update = true + return true end -- If marks are present, pop the last mark @@ -1437,7 +1224,6 @@ function Design:onInput(keys) return true end - local pos = nil if keys._MOUSE_L and not self:getMouseFramePos() then pos = getMousePoint() @@ -1448,14 +1234,15 @@ function Design:onInput(keys) end if keys._MOUSE_L and pos then + self.needs_update = true + -- TODO Refactor this a bit - if self.shape.max_points and #self.marks == self.shape.max_points and self.placing_mark.active then + if shape.max_points and #self.marks == shape.max_points and self.placing_mark.active then self.marks[self.placing_mark.index] = pos self.placing_mark.index = self.placing_mark.index + 1 self.placing_mark.active = false -- The statement after the or is to allow the 1x1 special case for easy doorways - self.needs_update = true - if self.autocommit or (self.marks[1] == self.marks[2]) then + if self.subviews.autocommit:getOptionValue() or (self.marks[1] == self.marks[2]) then self:commit() end elseif not self.placing_extra.active and self.placing_mark.active then @@ -1466,34 +1253,53 @@ function Design:onInput(keys) self.placing_mark.index = nil self.placing_mark.active = false end - self.needs_update = true elseif self.placing_extra.active then - self.needs_update = true self.placing_extra.active = false elseif self.placing_mirror then self.mirror_point = pos self.placing_mirror = false - self.needs_update = true else - if self.shape.basic_shape and #self.marks == self.shape.max_points then + -- Clicking center point + if #self.marks > 0 then + local center = shape:get_center() + if pos == center and not self.prev_center then + self.start_center = pos + self.prev_center = pos + return true + elseif self.prev_center then + --If there was no movement presume user wanted to click the mark underneath instead and let the flow through. + if pos == self.start_center then + self.start_center = nil + self.prev_center = nil + else + -- Since it moved let's just drop the shape here. + self.start_center = nil + self.prev_center = nil + return true + end + end + end + + if shape.basic_shape and #self.marks == shape.max_points then -- Clicking a corner of a basic shape - local shape_top_left, shape_bot_right = self.shape:get_point_dims() + local shape_top_left, shape_bot_right = shape:get_point_dims() local corner_drag_info = { { pos = shape_top_left, opposite_x = shape_bot_right.x, opposite_y = shape_bot_right.y, - corner = "nw" }, + corner = 'nw' }, { pos = Point { x = shape_bot_right.x, y = shape_top_left.y }, opposite_x = shape_top_left.x, - opposite_y = shape_bot_right.y, corner = "ne" }, + opposite_y = shape_bot_right.y, corner = 'ne' }, { pos = Point { x = shape_top_left.x, y = shape_bot_right.y }, opposite_x = shape_bot_right.x, - opposite_y = shape_top_left.y, corner = "sw" }, + opposite_y = shape_top_left.y, corner = 'sw' }, { pos = shape_bot_right, opposite_x = shape_top_left.x, opposite_y = shape_top_left.y, - corner = "se" } + corner = 'se' } } for _, info in ipairs(corner_drag_info) do - if pos == info.pos and self.shape.drag_corners[info.corner] then + if pos == info.pos and shape.drag_corners[info.corner] then self.marks[1] = Point { x = info.opposite_x, y = info.opposite_y, z = self.marks[1].z } table.remove(self.marks, 2) - self.placing_mark = { active = true, index = 2 } + self.placing_mark.active = true + self.placing_mark.index = 2 break end end @@ -1509,21 +1315,6 @@ function Design:onInput(keys) for i = 1, #self.extra_points do if pos == self.extra_points[i] then self.placing_extra = { active = true, index = i } - self.needs_update = true - return true - end - end - - -- Clicking center point - if #self.marks > 0 then - local center = self.shape:get_center() - if pos == center and not self.prev_center then - self.start_center = pos - self.prev_center = pos - return true - elseif self.prev_center then - self.start_center = nil - self.prev_center = nil return true end end @@ -1533,35 +1324,42 @@ function Design:onInput(keys) end end - self.needs_update = true return true end - -- send movement and pause keys through, but otherwise we're a modal dialog - return not (keys.D_PAUSE or guidm.getMapKey(keys)) + if guidm.getMapKey(keys) then + self.needs_update = true + end end -- Put any special logic for designation type here -- Right now it's setting the stair type based on the z-level -- Fell through, pass through the option directly from the options value function Design:get_designation(point) - local mode = self.subviews.mode_name:getOptionValue() + local mode = self.subviews.mode:getOptionValue() local view_bounds = self:get_view_bounds() - local top_left, bot_right = self.shape:get_true_dims() + local shape = self.subviews.shape:getOptionValue() + local top_left, bot_right = shape:get_true_dims() -- Stairs - if mode.desig == "i" then + if mode.desig == 'i' then local stairs_top_type = self.subviews.stairs_top_subtype:getOptionValue() - local stairs_middle_type = self.subviews.stairs_middle_subtype:getOptionValue() local stairs_bottom_type = self.subviews.stairs_bottom_subtype:getOptionValue() + local stairs_only_type = self.subviews.stairs_only_subtype:getOptionValue() if point.z == 0 then - return stairs_bottom_type == "auto" and "u" or stairs_bottom_type + if view_bounds.z1 == view_bounds.z2 then + -- Single layer staircase + return stairs_only_type + else + -- Bottom of multi-level staircase + return stairs_bottom_type == 'auto' and 'u' or stairs_bottom_type + end elseif view_bounds and point.z == math.abs(view_bounds.z1 - view_bounds.z2) then - local pos = Point { x = view_bounds.x1, y = view_bounds.y1, z = view_bounds.z1} + point - local tile_type = dfhack.maps.getTileType({x = pos.x, y = pos.y, z = pos.z}) - local tile_shape = tile_type and tile_attrs[tile_type].shape or nil - local designation = dfhack.maps.getTileFlags({x = pos.x, y = pos.y, z = pos.z}) + local pos = Point{x=view_bounds.x1, y=view_bounds.y1, z=view_bounds.z1} + point + local tile_type = dfhack.maps.getTileType(xyz2pos(pos.x, pos.y, pos.z)) + local tile_shape = tile_type and df.tiletype.attrs[tile_type].shape or nil + local designation = dfhack.maps.getTileFlags(xyz2pos(pos.x, pos.y, pos.z)) -- If top of the view_bounds is down stair, 'auto' should change it to up/down to match vanilla stair logic local up_or_updown_dug = ( @@ -1569,22 +1367,22 @@ function Design:get_designation(point) local up_or_updown_desig = designation and (designation.dig == df.tile_dig_designation.UpStair or designation.dig == df.tile_dig_designation.UpDownStair) - if stairs_top_type == "auto" then - return (up_or_updown_desig or up_or_updown_dug) and "i" or "j" + if stairs_top_type == 'auto' then + return (up_or_updown_desig or up_or_updown_dug) and 'i' or 'j' else return stairs_top_type end else - return stairs_middle_type == "auto" and 'i' or stairs_middle_type + return 'i' end - elseif mode.desig == "b" then + elseif mode.desig == 'b' then local building_outer_tiles = self.subviews.building_outer_tiles:getOptionValue() local building_inner_tiles = self.subviews.building_inner_tiles:getOptionValue() local darr = { { 1, 1 }, { 1, 0 }, { 0, 1 }, { 0, 0 }, { -1, 0 }, { -1, -1 }, { 0, -1 }, { 1, -1 }, { -1, 1 } } -- If not completed surrounded, then use outer tile for i, d in ipairs(darr) do - if not (self.shape:get_point(top_left.x + point.x + d[1], top_left.y + point.y + d[2])) then + if not (shape:get_point(top_left.x + point.x + d[1], top_left.y + point.y + d[2])) then return building_outer_tiles end end @@ -1599,26 +1397,28 @@ end -- Commit the shape using quickfort API function Design:commit() local data = {} - local top_left, bot_right = self.shape:get_true_dims() + local shape = self.subviews.shape:getOptionValue() + local prio = self.subviews.priority:getOptionValue() + local top_left, bot_right = shape:get_true_dims() local view_bounds = self:get_view_bounds() -- Means mo marks set if not view_bounds then return end - local mode = self.subviews.mode_name:getOptionValue().mode + local mode = self.subviews.mode:getOptionValue().mode -- Generates the params for quickfort API local function generate_params(grid, position) - -- local top_left, bot_right = self.shape:get_true_dims() + -- local top_left, bot_right = shape:get_true_dims() for zlevel = 0, math.abs(view_bounds.z1 - view_bounds.z2) do data[zlevel] = {} for row = 0, math.abs(bot_right.y - top_left.y) do data[zlevel][row] = {} for col = 0, math.abs(bot_right.x - top_left.x) do if grid[col] and grid[col][row] then - local desig = self:get_designation(Point{x = col, y = row, z = zlevel}) - if desig ~= "`" then + local desig = self:get_designation(Point{x=col, y=row, z=zlevel}) + if desig ~= '`' then data[zlevel][row][col] = - desig .. (mode ~= "build" and tostring(self.prio) or "") + desig .. (mode ~= 'build' and tostring(prio) or '') end end end @@ -1638,7 +1438,7 @@ function Design:commit() z = math.min(view_bounds.z1, view_bounds.z2), } - local grid = self.shape:transform(0, 0) + local grid = shape:transform(0, 0) -- Special case for 1x1 to ease doorway marking if top_left == bot_right then @@ -1651,9 +1451,10 @@ function Design:commit() quickfort.apply_blueprint(params) -- Only clear points if we're autocommit, or if we're doing a complex shape and still placing - if (self.autocommit and self.shape.basic_shape) or - (not self.shape.basic_shape and - (self.placing_mark.active or (self.autocommit and self.shape.max_points == #self.marks))) then + local autocommit = self.subviews.autocommit:getOptionValue() + if (autocommit and shape.basic_shape) or + (not shape.basic_shape and + (self.placing_mark.active or (autocommit and shape.max_points == #self.marks))) then self.marks = {} self.placing_mark = { active = true, index = 1, continue = true } self.placing_extra = { active = false, index = nil } @@ -1662,7 +1463,7 @@ function Design:commit() self.start_center = nil end - self:updateLayout() + self.needs_update = true end function Design:get_mirrored_points(points) @@ -1673,7 +1474,7 @@ function Design:get_mirrored_points(points) local mirrored_points = {} for i = #points, 1, -1 do local point = points[i] - -- 1 maps to "Off" + -- 1 maps to 'Off' if mirror_horiz_value ~= 1 then local mirrored_y = self.mirror_point.y + ((self.mirror_point.y - point.y)) @@ -1745,140 +1546,46 @@ function Design:dismiss_help() self.parent_view.help_window.visible = false end +function Design:get_anchor_pos() + -- TODO: return a pos when the player is actively drawing + return nil +end + -- -- DesignScreen -- DesignScreen = defclass(DesignScreen, gui.ZScreen) DesignScreen.ATTRS { - focus_path = "design", - pass_pause = true, - pass_movement_keys = true, + focus_path='design', + pass_movement_keys=true, + pass_mouse_clicks=false, } function DesignScreen:init() - - self.design_window = Design {} - self.help_window = HelpWindow {} - self.help_window.visible = false - self:addviews { self.design_window, self.help_window } - if SHOW_DEBUG_WINDOW then - self.debug_window = DesignDebugWindow { design_window = self.design_window } - self:addviews { self.debug_window } - end -end - -function DesignScreen:onDismiss() - view = nil -end - --- ----------------- -- --- DimensionsOverlay -- --- ----------------- -- - -local DEFAULT_DIMENSION_TOOLTIP_WIDTH = 17 -local DIMENSION_TOOLTIP_HEIGHT = 4 - -local DIMENSION_TOOLTIP_X_OFFSET = 3 -local DIMENSION_TOOLTIP_Y_OFFSET = 3 - -DimensionsOverlay = defclass(DimensionsOverlay, overlay.OverlayWidget) -DimensionsOverlay.ATTRS{ - default_pos={x=1,y=1}, - default_enabled=true, - overlay_only=true, -- not player-repositionable - viewscreens={ - 'dwarfmode/Designate', - 'dwarfmode/Burrow/Paint', - 'dwarfmode/Stockpile/Paint', - }, - frame={w=DEFAULT_DIMENSION_TOOLTIP_WIDTH, h=DIMENSION_TOOLTIP_HEIGHT}, -} - -local selection_rect = df.global.selection_rect - -local function is_choosing_area() - return selection_rect.start_z >= 0 and dfhack.gui.getMousePos(true) -end - -local function get_cur_area_dims() - local pos1 = dfhack.gui.getMousePos(true) - if not pos1 or selection_rect.start_z < 0 then return 1, 1, 1 end - - -- clamp to map edges (since you can start selection out of bounds) - pos1 = xyz2pos( - math.max(0, math.min(df.global.world.map.x_count-1, pos1.x)), - math.max(0, math.min(df.global.world.map.y_count-1, pos1.y)), - math.max(0, math.min(df.global.world.map.z_count-1, pos1.z))) - local pos2 = xyz2pos( - math.max(0, math.min(df.global.world.map.x_count-1, selection_rect.start_x)), - math.max(0, math.min(df.global.world.map.y_count-1, selection_rect.start_y)), - math.max(0, math.min(df.global.world.map.z_count-1, selection_rect.start_z))) - - return math.abs(pos1.x - pos2.x) + 1, - math.abs(pos1.y - pos2.y) + 1, - math.abs(pos1.z - pos2.z) + 1 -end - -local function format_dims() - return ('%dx%dx%d'):format(get_cur_area_dims()) -end - -function DimensionsOverlay:init() + self.design_window = Design{} + self.help_window = HelpWindow{visible=false} self:addviews{ - widgets.ResizingPanel{ - view_id='tooltip', - frame={b=0, r=0, w=DEFAULT_DIMENSION_TOOLTIP_WIDTH, h=DIMENSION_TOOLTIP_HEIGHT}, - frame_style=gui.FRAME_THIN, - frame_background=gui.CLEAR_PEN, - auto_width=true, - visible=is_choosing_area, - subviews={ - widgets.Panel{ - -- set minimum size for tooltip frame so DFHack label fits - frame={t=0, l=0, w=7, h=2}, - }, - widgets.Label{ - view_id='label', - frame={t=0}, - auto_width=true, - text={{text=format_dims}}, - }, - }, + self.design_window, + self.help_window, + widgets.DimensionsTooltip{ + get_anchor_pos_fn=self.design_window:callback('get_anchor_pos'), }, } -end - --- don't imply that stockpiles will be 3d -local main_interface = df.global.game.main_interface -local function check_stockpile_dims() - if main_interface.bottom_mode_selected == df.main_bottom_mode_type.STOCKPILE_PAINT then - selection_rect.start_z = df.global.window_z + if SHOW_DEBUG_WINDOW then + self.debug_window = DesignDebugWindow{design_window=self.design_window} + self:addviews{self.debug_window} end end -function DimensionsOverlay:render(dc) - check_stockpile_dims() - local x, y = dfhack.screen.getMousePos() - if not x then return end - local sw, sh = dfhack.screen.getWindowSize() - local frame_width = math.max(9, self.subviews.label:getTextWidth() + 2) - self:updateLayout() - x = math.min(x + DIMENSION_TOOLTIP_X_OFFSET, sw - frame_width) - y = math.min(y + DIMENSION_TOOLTIP_Y_OFFSET, sh - DIMENSION_TOOLTIP_HEIGHT) - self.frame.w = x + frame_width - self.frame.h = y + DIMENSION_TOOLTIP_HEIGHT - DimensionsOverlay.super.render(self, dc) +function DesignScreen:onDismiss() + view = nil end -OVERLAY_WIDGETS = { - dimensions=DimensionsOverlay, -} - if dfhack_flags.module then return end if not dfhack.isMapLoaded() then - qerror("This script requires a fortress map to be loaded") + qerror('This script requires a fortress map to be loaded') end -view = view and view:raise() or DesignScreen {}:show() +view = view and view:raise() or DesignScreen{}:show() diff --git a/gui/dfstatus.lua b/gui/dfstatus.lua index 4467664d85..25c073a51f 100644 --- a/gui/dfstatus.lua +++ b/gui/dfstatus.lua @@ -31,7 +31,7 @@ config = { function parse_config() local metal_map = {} - for id, raw in pairs(df.global.world.raws.inorganics) do + for id, raw in pairs(df.global.world.raws.inorganics.all) do if raw.material.flags.IS_METAL then metal_map[raw.id:upper()] = id metal_map[id] = raw.id:upper() @@ -125,7 +125,7 @@ function dfstatus:init() metals[id] = 0 end - for _, item in ipairs(df.global.world.items.all) do + for _, item in ipairs(df.global.world.items.other.IN_PLAY) do local flags = item.flags if not (flags.rotten or flags.dump or flags.forbid or flags.construction or flags.trader) then if item:getType() == df.item_type.WOOD then diff --git a/gui/embark-anywhere.lua b/gui/embark-anywhere.lua new file mode 100644 index 0000000000..31de21a317 --- /dev/null +++ b/gui/embark-anywhere.lua @@ -0,0 +1,136 @@ +local gui = require('gui') +local widgets = require('gui.widgets') + +EmbarkAnywhere = defclass(EmbarkAnywhere, widgets.Window) +EmbarkAnywhere.ATTRS { + frame_title='Embark Anywhere', + frame={w=32, h=15, l=0, b=0}, + autoarrange_subviews=true, + autoarrange_gap=1, +} + +function EmbarkAnywhere:init() + self:addviews{ + widgets.WrappedLabel{ + text_to_wrap='Click anywhere on the map to ignore warnings and embark wherever you want.', + }, + widgets.WrappedLabel{ + text_to_wrap='There may be unforeseen consequences when embarking where the game doesn\'t expect.', + text_pen=COLOR_YELLOW, + }, + widgets.WrappedLabel{ + text_to_wrap='Right click on this window to cancel.', + }, + } +end + +EmbarkAnywhereScreen = defclass(EmbarkAnywhereScreen, gui.ZScreen) +EmbarkAnywhereScreen.ATTRS { + focus_path='embark-anywhere', + pass_movement_keys=true, +} + +local function is_confirm_panel_visible() + local scr = dfhack.gui.getDFViewscreen(true) + if df.viewscreen_choose_start_sitest:is_instance(scr) then + return scr.zoomed_in and scr.choosing_embark and scr.warn_flags.GENERIC + end +end + +function EmbarkAnywhereScreen:init() + self:addviews{ + EmbarkAnywhere{view_id='main'}, + widgets.Panel{ + frame={l=20, t=1, w=22, h=6}, + frame_style=gui.FRAME_MEDIUM, + frame_background=gui.CLEAR_PEN, + subviews={ + widgets.Label{ + text={ + 'Any embark warnings', NEWLINE, + 'have been bypassed.', NEWLINE, + NEWLINE, + {text='Good luck!', pen=COLOR_GREEN}, + }, + }, + }, + visible=is_confirm_panel_visible, + }, + widgets.Panel{ + view_id='masks', + frame={t=0, b=0, l=0, r=0}, + subviews={ + widgets.Panel{ -- size selection panel + frame={l=0, t=0, w=61, h=11}, + }, + widgets.Panel{ -- abort button + frame={r=41, b=1, w=10, h=3}, + }, + widgets.Panel{ -- show elevation button + frame={r=22, b=1, w=18, h=3}, + }, + widgets.Panel{ -- show cliffs button + frame={r=0, b=1, w=21, h=3}, + }, + }, + }, + } +end + +function EmbarkAnywhereScreen:isMouseOver() + return self.subviews.main:getMouseFramePos() +end + +local function force_embark(scr) + -- causes selected embark area to be highlighted on the map + scr.warn_mm_startx = scr.neighbor_hover_mm_sx + scr.warn_mm_endx = scr.neighbor_hover_mm_ex + scr.warn_mm_starty = scr.neighbor_hover_mm_sy + scr.warn_mm_endy = scr.neighbor_hover_mm_ey + + -- setting any warn_flag will cause the accept embark panel to be shown + -- clicking accept on that panel will accept the embark, regardless of + -- how inappropriate it is + scr.warn_flags.GENERIC = true +end + +function EmbarkAnywhereScreen:clicked_on_panel_mask() + for _, sv in ipairs(self.subviews.masks.subviews) do + if sv:getMousePos() then return true end + end +end + +function EmbarkAnywhereScreen:onInput(keys) + local scr = dfhack.gui.getDFViewscreen(true) + if keys.LEAVESCREEN and not scr.zoomed_in then + -- we have to make sure we're off the stack when returning to the title screen + -- since the top viewscreen will get unceremoniously destroyed by DF + self.defocused = false + elseif keys._MOUSE_L and scr.choosing_embark and + not self.subviews.main:getMouseFramePos() and + not self:clicked_on_panel_mask() + then + -- clicked on the map -- time to do our thing + force_embark(scr) + end + + return EmbarkAnywhereScreen.super.onInput(self, keys) +end + +function EmbarkAnywhereScreen:onDismiss() + view = nil +end + +function EmbarkAnywhereScreen:onRenderFrame(dc, rect) + local scr = dfhack.gui.getDFViewscreen(true) + if not dfhack.gui.matchFocusString('choose_start_site', scr) then + self:dismiss() + end + EmbarkAnywhereScreen.super.onRenderFrame(self, dc, rect) +end + +if not dfhack.gui.matchFocusString('choose_start_site') then + qerror('This script can only be run when choosing an embark site') +end + +view = view and view:raise() or EmbarkAnywhereScreen{}:show() diff --git a/gui/extended-status.lua b/gui/extended-status.lua index 2cd7c34113..ac64badade 100644 --- a/gui/extended-status.lua +++ b/gui/extended-status.lua @@ -1,17 +1,5 @@ -- Adds more z-status subpages --@ enable = true ---[====[ - -gui/extended-status -=================== -Adds more subpages to the ``z`` status screen. - -Usage:: - - gui/extended-status enable|disable|help|subpage_names - enable|disable gui/extended-status - -]====] gui = require 'gui' dialogs = require 'gui.dialogs' @@ -59,13 +47,14 @@ function queue_beds(amount) end order = df.manager_order:new() - order.id = df.global.world.manager_order_next_id - df.global.world.manager_order_next_id = df.global.world.manager_order_next_id + 1 + order.id = df.global.world.manager_orders.manager_order_next_id + df.global.world.manager_orders.manager_order_next_id = df.global.world.manager_orders.manager_order_next_id + 1 + order.frequency = df.workquota_frequency_type.OneTime order.job_type = df.job_type.ConstructBed order.material_category.wood = true order.amount_left = amount order.amount_total = amount - df.global.world.manager_orders:insert('#', order) + df.global.world.manager_orders.all:insert('#', order) end status_overlay = defclass(status_overlay, gui.Screen) @@ -159,21 +148,19 @@ function bedroom_list:refresh() end end end - for _, u in pairs(world.units.active) do - if dfhack.units.isCitizen(u) then - add('units', u) - local has_bed = false - for _, b in pairs(u.owned_buildings) do - if df.building_bedst:is_instance(b) then - has_bed = true - end - end - if has_bed then - add('uwith', u) - else - add('uwithout', u) + for _, u in pairs(dfhack.units.getCitizens(true)) do + add('units', u) + local has_bed = false + for _, b in pairs(u.owned_buildings) do + if df.building_bedst:is_instance(b) then + has_bed = true end end + if has_bed then + add('uwith', u) + else + add('uwithout', u) + end end for _, bed in pairs(world.items.other.BED) do add('beds', bed) @@ -186,7 +173,7 @@ function bedroom_list:refresh() end end self.queued_beds = 0 - for _, order in pairs(df.global.world.manager_orders) do + for _, order in pairs(df.global.world.manager_orders.all) do if order.job_type == df.job_type.ConstructBed then self.queued_beds = self.queued_beds + order.amount_left end diff --git a/gui/family-affairs.lua b/gui/family-affairs.lua index b25d2de989..428e34d591 100644 --- a/gui/family-affairs.lua +++ b/gui/family-affairs.lua @@ -1,290 +1,852 @@ --- gui/family-affairs --- derived from v1.2 @ http://www.bay12forums.com/smf/index.php?topic=147779 -local helpstr = [====[ - -gui/family-affairs -================== -A user-friendly interface to view romantic relationships, -with the ability to add, remove, or otherwise change them at -your whim - fantastic for depressed dwarves with a dead spouse -(or matchmaking players...). - -The target/s must be alive, sane, and in fortress mode. - -.. image:: /docs/images/family-affairs.png - :align: center +local argparse = require('argparse') +local dlg = require('gui.dialogs') +local gui = require('gui') +local widgets = require('gui.widgets') + +---------------------- +-- Utility functions +-- + +local function zoom_to(unit) + if not unit then return end + local pos = xyz2pos(dfhack.units.getPosition(unit)) + dfhack.gui.revealInDwarfmodeMap(pos, true, true) +end -``gui/family-affairs [unitID]`` - shows GUI for the selected unit, or the specified unit ID +local function is_viable_parent(unit, required_pronoun) + return unit and unit.sex == required_pronoun and dfhack.units.isAdult(unit) and dfhack.units.isSane(unit) +end -``gui/family-affairs divorce [unitID]`` - removes all spouse and lover information from the unit - and it's partner, bypassing almost all checks. +local function can_have_spouse(unit) + if not unit then return end + local caste_flags = unit.enemy.caste_flags + return caste_flags.CAN_LEARN and not caste_flags.SLOW_LEARNER +end -``gui/family-affairs [unitID] [unitID]`` - divorces the two specified units and their partners, - then arranges for the two units to marry, bypassing - almost all checks. Use with caution. +-- clears other_hf's link to hf (will clear only one reverse link if there are multiple) +local function remove_hf_link(link_type, hfid, other_hfid) + local other_hf = df.historical_figure.find(other_hfid) + if not other_hf then return end + for i, link in ipairs(other_hf.histfig_links) do + if link._type == link_type and link.target_hf == hfid then + other_hf.histfig_links:erase(i) + link:delete() + break + end + end +end -]====] +local function clear_hf_links(link_type, hfid) + local hf = df.historical_figure.find(hfid) + if not hf then return end + for i = #hf.histfig_links-1,0,-1 do + local link = hf.histfig_links[i] + if link._type == link_type then + remove_hf_link(link_type, hfid, link.target_hf) + hf.histfig_links:erase(i) + link:delete() + end + end +end -local dlg = require ('gui.dialogs') +local function has_hf_links(link_type, hfid) + local hf = df.historical_figure.find(hfid) + if not hf then return false end + for i = #hf.histfig_links-1,0,-1 do + local link = hf.histfig_links[i] + if link._type == link_type then return true end + end + return false +end -function ErrorPopup (msg,color) - if not tostring(msg) then msg = "Error" end - if not color then color = COLOR_LIGHTRED end - dlg.showMessage("Dwarven Family Affairs", msg, color, nil) +local function has_spouse_or_lover(unit, target) + local hf = df.historical_figure.find(unit.hist_figure_id) + if not hf then return false end + for _, link in ipairs(hf.histfig_links) do + if (link._type == df.histfig_hf_link_spousest or link._type == df.histfig_hf_link_loverst) and + (not target or link.target_hf == target.hist_figure_id) + then + return true + end + end end -function AnnounceAndGamelog(text) - dfhack.gui.showAnnouncement(text, COLOR_LIGHTMAGENTA) +local function get_lovers(unit) + local lovers = {} + if not unit then return lovers end + local hf = df.historical_figure.find(unit.hist_figure_id) + if not hf then return lovers end + for _, link in ipairs(hf.histfig_links) do + if link._type == df.histfig_hf_link_loverst then + table.insert(lovers, link.target_hf) + end + end + return lovers end -function ListPrompt (msg, choicelist, bool, yes_func) -dlg.showListPrompt( - "Dwarven Family Affairs", - msg, - COLOR_WHITE, - choicelist, - --called if choice is yes - yes_func, - --called on cancel - function() end, - 15, - bool - ) +local function get_name(unit_or_hf) + return unit_or_hf and dfhack.units.getReadableName(unit_or_hf) or '' end -function GetMarriageSummary (source) - local familystate = "" - local source_name = dfhack.TranslateName(source.name) +local function get_spouse_hf(unit) + if not unit then return end + local hf = df.historical_figure.find(unit.hist_figure_id) + if not hf then return end + for _, link in ipairs(hf.histfig_links) do + if link._type == df.histfig_hf_link_spousest then + -- may be nil due to hf culling, but then we just treat it as not having a spouse + return df.historical_figure.find(link.target_hf) + end + end +end - local spouse = df.unit.find(source.relationship_ids.Spouse) - local lover = df.unit.find(source.relationship_ids.Lover) +local function get_spouse_unit(unit) + local spouse_hf = get_spouse_hf(unit) + if not spouse_hf then return end + return df.unit.find(spouse_hf.unit_id) +end - if spouse then - if dfhack.units.isSane(spouse) then - familystate = source_name.." has a spouse ("..dfhack.TranslateName(spouse.name)..")" - end - if dfhack.units.isSane(spouse) == false then - familystate = source_name.."'s spouse is dead or not sane, would you like to choose a new one?" - end - elseif lover then - if dfhack.units.isSane(df.unit.find(source.relationship_ids.Lover)) then - familystate = source_name.." already has a lover ("..dfhack.TranslateName(lover.name)..")" - end - if dfhack.units.isSane(df.unit.find(source.relationship_ids.Lover)) == false then - familystate = source_name.."'s lover is dead or not sane, would you like that love forgotten?" +local function clear_spouse(unit, accept_fn, noconfirm) + if not unit then return end + local function do_clear_spouse() + local spouse = get_spouse_unit(unit) + clear_hf_links(df.histfig_hf_link_spousest, unit.hist_figure_id) + if spouse then + spouse.relationship_ids.Spouse = -1 end + unit.relationship_ids.Spouse = -1 + accept_fn() + end + if noconfirm then + do_clear_spouse() else - familystate = source_name.." is not involved in romantic relationships with anyone" + dlg.showYesNoPrompt('Clear spouse', + ('Really clear spouse for %s?'):format(get_name(unit)), + COLOR_YELLOW, do_clear_spouse) end +end - if source.pregnancy_timer > 0 then - familystate = familystate.."\nShe is pregnant." - local father = df.historical_figure.find(source.pregnancy_spouse) - if father then - familystate = familystate.." The father is "..dfhack.TranslateName(father.name).."." - end +-- adds a link to hf pointing to other_hf +local function add_hf_link(link_type, hfid, other_hfid) + local hf = df.historical_figure.find(hfid) + if not hf then return end + local link = link_type:new() + link.target_hf = other_hfid + link.link_strength = 100 + hf.histfig_links:insert('#', link) +end + +local function set_spouse(unit1, unit2, accept_fn) + local function do_set_spouse() + clear_spouse(unit1, function() end, true) + clear_spouse(unit2, function() end, true) + unit1.relationship_ids.Spouse = unit2.id + unit2.relationship_ids.Spouse = unit1.id + add_hf_link(df.histfig_hf_link_spousest, unit1.hist_figure_id, unit2.hist_figure_id) + add_hf_link(df.histfig_hf_link_spousest, unit2.hist_figure_id, unit1.hist_figure_id) + dfhack.gui.showAutoAnnouncement(df.announcement_type.MARRIAGE, xyz2pos(dfhack.units.getPosition(unit1)), + ('%s and %s have married!'):format(dfhack.translation.translateName(unit1.name), + dfhack.translation.translateName(unit2.name)), + COLOR_LIGHTMAGENTA) + accept_fn() + end + local unit1_has_lovers = has_hf_links(df.histfig_hf_link_loverst, unit1.hist_figure_id) + local unit2_has_lovers = has_hf_links(df.histfig_hf_link_loverst, unit2.hist_figure_id) + if unit1_has_lovers or unit2_has_lovers then + dlg.showYesNoPrompt('Clear lovers', + 'New partners have existing lovers. Spurn them?', + COLOR_YELLOW, function() + clear_hf_links(df.histfig_hf_link_loverst, unit1.hist_figure_id) + clear_hf_links(df.histfig_hf_link_loverst, unit2.hist_figure_id) + do_set_spouse() + end) + else + do_set_spouse() end +end - return familystate +---------------------- +-- RelationshipsPage +-- + +RelationshipsPage = defclass(RelationshipsPage, widgets.Panel) + +function RelationshipsPage:init() + self.cache = {} + self.unit_id = -1 + self.dirty = 0 + + self:addviews{ + widgets.Label{ + frame={t=0, l=0}, + text='Name:', + }, + widgets.Label{ + frame={t=0, l=6}, + text='None (please select a unit)', + text_pen=COLOR_YELLOW, + visible=function() return not self:get_unit() end, + }, + widgets.Label{ + frame={t=0, l=6}, + text={{text=self:callback('get_name')}}, + text_pen=COLOR_LIGHTMAGENTA, + auto_width=true, + on_click=function() zoom_to(self:get_unit()) end, + visible=self:callback('get_unit'), + }, + widgets.HotkeyLabel{ + frame={t=2, l=0}, + label="Switch to selected unit", + key='CUSTOM_U', + auto_width=true, + on_activate=self:callback('set_unit'), + enabled=function() + local unit = dfhack.gui.getSelectedUnit(true) + return unit and unit.id ~= self.unit_id + end, + }, + widgets.Label{ + frame={t=4, l=0}, + text={{text='Spouse:', pen=function() return can_have_spouse(self:get_unit()) and COLOR_WHITE or COLOR_GRAY end}}, + }, + widgets.Label{ + frame={t=4, l=8}, + text={{text='None', pen=function() return can_have_spouse(self:get_unit()) and COLOR_WHITE or COLOR_GRAY end}}, + visible=function() return not self:get_spouse_unit() and not get_spouse_hf(self:get_unit()) end, + }, + widgets.Label{ + frame={t=4, l=8}, + text={{text=function() return get_name(self:get_spouse_unit()) end}}, + text_pen=COLOR_BLUE, + auto_width=true, + on_click=function() zoom_to(self:get_spouse_unit()) end, + visible=self:callback('get_spouse_unit'), + }, + widgets.Label{ + frame={t=4, l=8}, + text={ + {text=function() return get_name(get_spouse_hf(self:get_unit())) end, pen=COLOR_BLUE}, + {gap=1, text='(off-site)', pen=COLOR_YELLOW}, + }, + auto_width=true, + visible=function() return not self:get_spouse_unit() and get_spouse_hf(self:get_unit()) end, + }, + widgets.HotkeyLabel{ + frame={t=5, l=2}, + label="Dissolve spouse relationship", + key='CUSTOM_X', + auto_width=true, + on_activate=function() clear_spouse(self:get_unit(), function() self.dirty = 2 end) end, + visible=function() return get_spouse_hf(self:get_unit()) end, + }, + widgets.HotkeyLabel{ + frame={t=5, l=2}, + label="Set selected unit as spouse", + key='CUSTOM_X', + auto_width=true, + on_activate=function() + set_spouse(self:get_unit(), dfhack.gui.getSelectedUnit(true), function() self.dirty = 2 end) + end, + visible=function() return not get_spouse_hf(self:get_unit()) end, + enabled=function() + local unit = self:get_unit() + local selected = dfhack.gui.getSelectedUnit(true) + return unit and not get_spouse_hf(unit) and can_have_spouse(unit) and + selected and selected.race == unit.race and selected.id ~= unit.id and + (selected.sex == df.pronoun_type.she and unit.sex == df.pronoun_type.he or + selected.sex == df.pronoun_type.he and unit.sex == df.pronoun_type.she) + end, + }, + widgets.Panel{ + frame={t=7, b=0}, + frame_style=gui.FRAME_INTERIOR, + subviews={ + widgets.Label{ + frame={t=0, l=0}, + text={ + 'Lovers:', + {gap=1, text=function() return #self.subviews.lovers:getChoices() end, pen=COLOR_YELLOW}, + }, + }, + widgets.List{ + frame={t=2, b=3}, + view_id='lovers', + on_submit=function(_, choice) + local hf = df.historical_figure.find(choice.data.hfid) + if not hf then return end + local unit = df.unit.find(hf.unit_id) + if not unit then return end + zoom_to(unit) + end, + }, + widgets.HotkeyLabel{ + frame={b=1, l=0}, + label='Add selected unit as lover', + key='CUSTOM_L', + auto_width=true, + on_activate=function() + local selected = dfhack.gui.getSelectedUnit(true) + if not selected then return end + local unit = self:get_unit() + if not unit then return end + add_hf_link(df.histfig_hf_link_loverst, unit.hist_figure_id, selected.hist_figure_id) + add_hf_link(df.histfig_hf_link_loverst, selected.hist_figure_id, unit.hist_figure_id) + self.dirty = 1 + end, + enabled=function() + local unit = self:get_unit() + local selected = dfhack.gui.getSelectedUnit(true) + return unit and selected and selected.race == unit.race and selected.id ~= unit.id and + not has_spouse_or_lover(unit, selected) + end, + }, + widgets.HotkeyLabel{ + frame={b=0, l=0}, + label='Remove lover', + key='CUSTOM_SHIFT_L', + auto_width=true, + on_activate=function() + local _, selected = self.subviews.lovers:getSelected() + if not selected then return end + local unit = self:get_unit() + if not unit then return end + remove_hf_link(df.histfig_hf_link_loverst, unit.hist_figure_id, selected.data.hfid) + remove_hf_link(df.histfig_hf_link_loverst, selected.data.hfid, unit.hist_figure_id) + self.dirty = 1 + end, + enabled=function() + local _, selected = self.subviews.lovers:getSelected() + return selected + end, + }, + }, + }, + } end -function GetSpouseData (source) - local spouse = df.unit.find(source.relationship_ids.Spouse) - local spouse_hf - if spouse then - spouse_hf = df.historical_figure.find(spouse.hist_figure_id) +function RelationshipsPage:on_show() + local unit = dfhack.gui.getSelectedUnit(true) + if self.unit_id == -1 then + self:set_unit(unit) end - return spouse,spouse_hf end -function GetLoverData (source) - local lover = df.unit.find(source.relationship_ids.Spouse) - local lover_hf - if lover then - lover_hf = df.historical_figure.find (lover.hist_figure_id) +function RelationshipsPage:get_unit() + self.cache.unit = self.cache.unit or df.unit.find(self.unit_id) + return self.cache.unit +end + +function RelationshipsPage:set_unit(unit) + unit = unit or dfhack.gui.getSelectedUnit(true) + if not unit then return end + self.unit_id = unit.id + self.dirty = 2 +end + +function RelationshipsPage:refresh_lover_list() + local choices = {} + for _, lover_hfid in ipairs(get_lovers(self:get_unit())) do + local lover_hf = df.historical_figure.find(lover_hfid) + if lover_hf then + local lover_unit = df.unit.find(lover_hf.unit_id) + local text = { + {text=get_name(lover_hf)}, + {gap=1, text=lover_unit and '' or '(off-site)', pen=COLOR_YELLOW}, + } + table.insert(choices, { + text=text, + data={hfid=lover_hfid}, + }) + end end - return lover,lover_hf + local list = self.subviews.lovers + local selected = list:getSelected() + list:setChoices(choices) + list:setSelected(selected) end -function EraseHFLinksLoverSpouse (hf) - for i = #hf.histfig_links-1,0,-1 do - if hf.histfig_links[i]._type == df.histfig_hf_link_spousest or hf.histfig_links[i]._type == df.histfig_hf_link_loverst then - local todelete = hf.histfig_links[i] - hf.histfig_links:erase(i) - todelete:delete() +function RelationshipsPage:render(dc) + if self.dirty > 0 then + self:updateLayout() + self.dirty = self.dirty - 1 + if self.dirty <= 0 then + self:refresh_lover_list() end end + RelationshipsPage.super.render(self, dc) + self.cache = {} +end + +function RelationshipsPage:get_name() + return get_name(self:get_unit()) end -function Divorce (source) - local source_hf = df.historical_figure.find(source.hist_figure_id) - local spouse,spouse_hf = GetSpouseData (source) - local lover,lover_hf = GetLoverData (source) +function RelationshipsPage:get_spouse_unit() + return get_spouse_unit(self:get_unit()) +end - source.relationship_ids.Spouse = -1 - source.relationship_ids.Lover = -1 +---------------------- +-- PregnancyPage +-- + +PregnancyPage = defclass(PregnancyPage, widgets.Panel) + +function PregnancyPage:init() + self.cache = {} + self.mother_id, self.father_id = -1, -1 + self.dirty = 0 + + self:addviews{ + widgets.Panel{ + frame={t=0}, + subviews={ + widgets.Label{ + frame={t=0, l=0}, + text='Mother:', + }, + widgets.Label{ + frame={t=0, l=8}, + text='None (please select an adult female)', + text_pen=COLOR_YELLOW, + visible=function() return not self:get_mother() end, + }, + widgets.Label{ + frame={t=0, l=8}, + text={{text=self:callback('get_name', 'mother')}}, + text_pen=COLOR_LIGHTMAGENTA, + auto_width=true, + on_click=function() zoom_to(self:get_mother()) end, + visible=self:callback('get_mother'), + }, + widgets.Label{ + frame={t=1, l=0}, + text={{text=self:callback('get_pregnancy_desc')}}, + }, + widgets.HotkeyLabel{ + frame={t=3, l=0}, + label="Choose selected unit to be the mother", + key='CUSTOM_SHIFT_M', + auto_width=true, + on_activate=self:callback('set_mother'), + enabled=function() + local unit = dfhack.gui.getSelectedUnit(true) + return unit and unit.id ~= self.mother_id and is_viable_parent(unit, df.pronoun_type.she) + end, + }, + widgets.Label{ + frame={t=5, l=0}, + text={{text='Spouse:', pen=function() return can_have_spouse(self:get_mother()) and COLOR_WHITE or COLOR_GRAY end}}, + }, + widgets.Label{ + frame={t=5, l=8}, + text={{text='None', pen=function() return can_have_spouse(self:get_mother()) and COLOR_WHITE or COLOR_GRAY end}}, + visible=function() return not self:get_spouse_unit('mother') and not self:get_spouse_hf('mother') end, + }, + widgets.Label{ + frame={t=5, l=8}, + text={{text=self:callback('get_spouse_name', 'mother')}}, + text_pen=COLOR_BLUE, + auto_width=true, + on_click=function() zoom_to(self:get_spouse_unit('mother')) end, + visible=self:callback('get_spouse_unit', 'mother'), + }, + widgets.Label{ + frame={t=5, l=8}, + text={ + {text=self:callback('get_spouse_hf_name', 'mother'), pen=COLOR_BLUE}, + {gap=1, text='(off-site)', pen=COLOR_YELLOW}, + }, + auto_width=true, + visible=function() return not self:get_spouse_unit('mother') and self:get_spouse_hf('mother') end, + }, + widgets.HotkeyLabel{ + frame={t=6, l=2}, + label="Set mother's spouse as the father", + key='CUSTOM_F', + auto_width=true, + on_activate=function() self:set_father(self:get_spouse_unit('mother')) end, + enabled=function() + local spouse = self:get_spouse_unit('mother') + return spouse and spouse.id ~= self.father_id and is_viable_parent(spouse, df.pronoun_type.he) + end, + }, + }, + }, + widgets.Divider{ + frame={t=8, h=1}, + frame_style=gui.FRAME_THIN, + frame_style_l=false, + frame_style_r=false, + }, + widgets.Panel{ + frame={t=10}, + subviews={ + widgets.Label{ + frame={t=0, l=0}, + text='Father:', + }, + widgets.Label{ + frame={t=0, l=8}, + text={ + 'None ', + {text='(optionally select an adult male)', pen=COLOR_GRAY}, + }, + visible=function() return not self:get_father() end, + }, + widgets.Label{ + frame={t=0, l=8}, + text={{text=self:callback('get_name', 'father')}}, + text_pen=function() + local spouse = self:get_spouse_unit('mother') + if spouse and self.father_id == spouse.id then + return COLOR_BLUE + end + return COLOR_CYAN + end, + auto_width=true, + on_click=function() zoom_to(self:get_father()) end, + visible=self:callback('get_father'), + }, + widgets.HotkeyLabel{ + frame={t=2, l=0}, + label="Choose selected unit to be the father", + key='CUSTOM_SHIFT_F', + auto_width=true, + on_activate=self:callback('set_father'), + enabled=function() + local unit = dfhack.gui.getSelectedUnit(true) + return unit and unit.id ~= self.father_id and is_viable_parent(unit, df.pronoun_type.he) + end, + }, + widgets.Label{ + frame={t=4, l=0}, + text={{text='Spouse:', pen=function() return can_have_spouse(self:get_father()) and COLOR_WHITE or COLOR_GRAY end}}, + }, + widgets.Label{ + frame={t=4, l=8}, + text={{text='None', pen=function() return can_have_spouse(self:get_father()) and COLOR_WHITE or COLOR_GRAY end}}, + visible=function() return not self:get_spouse_unit('father') and not self:get_spouse_hf('father') end, + }, + widgets.Label{ + frame={t=4, l=8}, + text={{text=self:callback('get_spouse_name', 'father')}}, + text_pen=function() + local spouse = self:get_spouse_unit('father') + if spouse and self.mother_id == spouse.id then + return COLOR_LIGHTMAGENTA + end + return COLOR_CYAN + end, + auto_width=true, + on_click=function() zoom_to(self:get_spouse_unit('father')) end, + visible=self:callback('get_spouse_unit', 'father'), + }, + widgets.Label{ + frame={t=4, l=8}, + text={ + {text=self:callback('get_spouse_hf_name', 'father'), pen=COLOR_CYAN}, + {gap=1, text='(off-site)', pen=COLOR_YELLOW}, + }, + auto_width=true, + visible=function() return not self:get_spouse_unit('father') and self:get_spouse_hf('father') end, + }, + widgets.HotkeyLabel{ + frame={t=5, l=2}, + label="Set father's spouse as the mother", + key='CUSTOM_M', + auto_width=true, + on_activate=function() self:set_mother(self:get_spouse_unit('father')) end, + enabled=function() + local spouse = self:get_spouse_unit('father') + return spouse and spouse.id ~= self.mother_id and is_viable_parent(spouse, df.pronoun_type.she) + end, + }, + }, + }, + widgets.Divider{ + frame={t=17, h=1}, + frame_style=gui.FRAME_THIN, + frame_style_l=false, + frame_style_r=false, + }, + widgets.Panel{ + frame={t=19}, + subviews={ + widgets.CycleHotkeyLabel{ + view_id='term', + frame={t=0, l=0, w=40}, + label='Pregnancy term (in months):', + key_back='CUSTOM_SHIFT_Z', + key='CUSTOM_Z', + options={ + {label='Default', value='default', pen=COLOR_BROWN}, + {label='0', value=0, pen=COLOR_BROWN}, + {label='1', value=1, pen=COLOR_BROWN}, + {label='2', value=2, pen=COLOR_BROWN}, + {label='3', value=3, pen=COLOR_BROWN}, + {label='4', value=4, pen=COLOR_BROWN}, + {label='5', value=5, pen=COLOR_BROWN}, + {label='6', value=6, pen=COLOR_BROWN}, + {label='7', value=7, pen=COLOR_BROWN}, + {label='8', value=8, pen=COLOR_BROWN}, + {label='9', value=9, pen=COLOR_BROWN}, + {label='10', value=10, pen=COLOR_BROWN}, + }, + initial_option='default', + }, + widgets.Panel{ + frame={t=2, w=23, h=3}, + frame_style=gui.FRAME_INTERIOR, + subviews={ + widgets.HotkeyLabel{ + key='CUSTOM_SHIFT_P', + label="Generate pregnancy", + on_activate=self:callback('commit'), + enabled=function() return self:get_mother() end, + }, + } + }, + }, + }, + } +end - if source_hf then - EraseHFLinksLoverSpouse (source_hf) +function PregnancyPage:on_show() + local unit = dfhack.gui.getSelectedUnit(true) + if self.mother_id == -1 then + self:set_mother(unit) end - if spouse then - spouse.relationship_ids.Spouse = -1 - spouse.relationship_ids.Lover = -1 + if self.father_id == -1 then + self:set_father(unit) end - if lover then - spouse.relationship_ids.Spouse = -1 - spouse.relationship_ids.Lover = -1 - end - if spouse_hf then - EraseHFLinksLoverSpouse (spouse_hf) - end - if lover_hf then - EraseHFLinksLoverSpouse (lover_hf) +end + +function PregnancyPage:get_mother() + self.cache.mother = self.cache.mother or df.unit.find(self.mother_id) + return self.cache.mother +end + +function PregnancyPage:get_father() + self.cache.father = self.cache.father or df.unit.find(self.father_id) + return self.cache.father +end + +function PregnancyPage:render(dc) + if self.dirty > 0 then + -- needs multiple iterations of updateLayout because of multiple + -- layers of indirection in the text generation + self:updateLayout() + self.dirty = self.dirty - 1 end + PregnancyPage.super.render(self, dc) + self.cache = {} +end - local partner = spouse or lover - if not partner then - AnnounceAndGamelog(dfhack.TranslateName(source.name).." is now single") - else - AnnounceAndGamelog(dfhack.TranslateName(source.name).." and "..dfhack.TranslateName(partner.name).." are now single") +function PregnancyPage:get_name(who) + return get_name(self['get_'..who](self)) +end + +local TICKS_PER_DAY = 1200 +local TICKS_PER_MONTH = 28 * TICKS_PER_DAY + +function PregnancyPage:get_pregnancy_desc() + local mother = self:get_mother() + if not mother or not mother.pregnancy_genes or mother.pregnancy_timer <= 0 then + return 'Not currently pregnant' + end + local term_str = 'today' + if mother.pregnancy_timer > TICKS_PER_MONTH then + local num_months = (mother.pregnancy_timer + TICKS_PER_MONTH//2) // TICKS_PER_MONTH + term_str = ('in %d month%s'):format(num_months, num_months == 1 and '' or 's') + elseif mother.pregnancy_timer > TICKS_PER_DAY then + local num_days = (mother.pregnancy_timer + TICKS_PER_DAY//2) // TICKS_PER_DAY + term_str = ('in %d day%s'):format(num_days, num_days == 1 and '' or 's') end + return ('Currently pregnant: coming to term %s'):format(term_str) end -function Marriage (source,target) - local source_hf = df.historical_figure.find(source.hist_figure_id) - local target_hf = df.historical_figure.find(target.hist_figure_id) - source.relationship_ids.Spouse = target.id - target.relationship_ids.Spouse = source.id +function PregnancyPage:get_spouse_unit(who) + return get_spouse_unit(self['get_'..who](self)) +end - local new_link = df.histfig_hf_link_spousest:new() -- adding hf link to source - new_link.target_hf = target_hf.id - new_link.link_strength = 100 - source_hf.histfig_links:insert('#',new_link) +function PregnancyPage:get_spouse_hf(who) + return get_spouse_hf(self['get_'..who](self)) +end - new_link = df.histfig_hf_link_spousest:new() -- adding hf link to target - new_link.target_hf = source_hf.id - new_link.link_strength = 100 - target_hf.histfig_links:insert('#',new_link) +function PregnancyPage:get_spouse_name(who) + return get_name(self:get_spouse_unit(who)) end -function ChooseNewSpouse (source) +function PregnancyPage:get_spouse_hf_name(who) + return get_name(self:get_spouse_hf(who)) +end - if not source then - qerror("no unit") return - end - if not dfhack.units.isAdult(source) then - ErrorPopup("target is too young") return +function PregnancyPage:set_mother(unit) + unit = unit or dfhack.gui.getSelectedUnit(true) + if not is_viable_parent(unit, df.pronoun_type.she) then return end + local father = self:get_father() + local function do_set_mother() + if self.father_id ~= -1 then + if not father or father.race ~= unit.race then + self.father_id = -1 + end + end + self.mother_id = unit.id + if self.father_id == -1 then + self:set_father(self:get_spouse_unit('mother')) + end + self.dirty = 2 end - if not (source.relationship_ids.Spouse == -1 and source.relationship_ids.Lover == -1) then - ErrorPopup("target already has a spouse or a lover") - qerror("source already has a spouse or a lover") - return + if father and father.race ~= unit.race then + dlg.showYesNoPrompt('Race mismatch', + 'Are you sure you want to select this unit as the mother?\n' .. + 'The unit\'s race does not match the selected father.\n' .. + 'The choice for father will be reset.', + COLOR_YELLOW, do_set_mother) + else + do_set_mother() end +end - local choicelist = {} - targetlist = {} - - for k,v in pairs (df.global.world.units.active) do - if dfhack.units.isCitizen(v) - and v.race == source.race - and v.sex ~= source.sex - and v.relationship_ids.Spouse == -1 - and v.relationship_ids.Lover == -1 - and dfhack.units.isAdult(v) - then - table.insert(choicelist,dfhack.TranslateName(v.name)..', '..dfhack.units.getProfessionName(v)) - table.insert(targetlist,v) +function PregnancyPage:set_father(unit) + unit = unit or dfhack.gui.getSelectedUnit(true) + if not is_viable_parent(unit, df.pronoun_type.he) then return end + local mother = self:get_mother() + local function do_set_father() + if self.mother_id ~= -1 then + if not mother or mother.race ~= unit.race then + self.mother_id = -1 + end + end + self.father_id = unit.id + if self.mother_id == -1 then + self:set_mother(self:get_spouse_unit('father')) end + self.dirty = 2 end - - if #choicelist > 0 then - ListPrompt( - "Assign new spouse for "..dfhack.TranslateName(source.name), - choicelist, - true, - function(a,b) - local target = targetlist[a] - Marriage (source,target) - AnnounceAndGamelog(dfhack.TranslateName(source.name).." and "..dfhack.TranslateName(target.name).." have married!") - end) + if mother and mother.race ~= unit.race then + dlg.showYesNoPrompt('Race mismatch', + 'Are you sure you want to select this unit as the father?\n' .. + 'The unit\'s race does not match the selected mother.\n' .. + 'The choice for mother will be reset.', + COLOR_YELLOW, do_set_father) else - ErrorPopup("No suitable candidates") + do_set_father() end end -function MainDialog (source) - - local familystate = GetMarriageSummary(source) - - familystate = familystate.."\nSelect action:" - local choicelist = {} - local on_select = {} - - local adult = dfhack.units.isAdult(source) - local single = source.relationship_ids.Spouse == -1 and source.relationship_ids.Lover == -1 - local ready_for_marriage = single and adult +local function get_term_ticks(months) + local ticks = months * TICKS_PER_MONTH + -- subtract off a random amount between 0 and half a month + ticks = math.max(1, ticks - math.random(0, TICKS_PER_MONTH//2)) + return ticks +end - if adult then - table.insert(choicelist,"Remove romantic relationships (if any)") - table.insert(on_select, Divorce) - if ready_for_marriage then - table.insert(choicelist,"Assign a new spouse") - table.insert(on_select,ChooseNewSpouse) - end - if not ready_for_marriage then - table.insert(choicelist,"[Assign a new spouse]") - table.insert(on_select,function () ErrorPopup ("Existing relationships must be removed if you wish to assign a new spouse.") end) +function PregnancyPage:commit() + local mother = self:get_mother() + local father = self:get_father() or mother + + local term_months = self.subviews.term:getOptionValue() + if term_months == 'default' then + local caste_flags = mother.enemy.caste_flags + if caste_flags.CAN_SPEAK or caste_flags.CAN_LEARN then + term_months = 9 + else + term_months = 6 end + end + + if mother.pregnancy_genes then + mother.pregnancy_genes:assign(father.appearance.genes) else - table.insert(choicelist,"Leave this child alone") - table.insert(on_select,nil) + mother.pregnancy_genes = father.appearance.genes:new() end - ListPrompt(familystate, choicelist, false, - function(a,b) if on_select[a] then on_select[a](source) end end) + mother.pregnancy_timer = get_term_ticks(term_months) + mother.pregnancy_caste = father.caste + mother.pregnancy_spouse = father.hist_figure_id ~= mother.hist_figure_id and father.hist_figure_id or -1 + + self.dirty = 2 end +---------------------- +-- FamilyAffairs +-- + +FamilyAffairs = defclass(FamilyAffairs, widgets.Window) +FamilyAffairs.ATTRS { + frame_title='Family manager', + frame={w=65, h=30, r=2, t=18}, + frame_inset={t=1, l=1, r=1}, + resizable=true, + initial_tab=DEFAULT_NIL, +} + +function FamilyAffairs:init() + local function on_select(idx) + local pages = self.subviews.pages + pages:setSelected(idx) + local _, page = pages:getSelected() + page:on_show() + end -local args = {...} + self:addviews{ + widgets.TabBar{ + frame={t=0, l=0}, + labels={ + 'Relationships', + 'Pregnancy', + }, + on_select=on_select, + get_cur_page=function() return self.subviews.pages:getSelected() end, + }, + widgets.Pages{ + view_id='pages', + frame={t=3, l=0, b=0, r=0}, + subviews={ + RelationshipsPage{}, + PregnancyPage{}, + }, + }, + } + + on_select(self.initial_tab == 'pregnancy' and 2 or 1) +end -if args[1] == "help" or args[1] == "?" then print(helpstr) return end +---------------------- +-- FamilyAffairsScreen +-- -if not dfhack.world.isFortressMode() then - print (helpstr) qerror ("invalid game mode") return -end +FamilyAffairsScreen = defclass(FamilyAffairsScreen, gui.ZScreen) +FamilyAffairsScreen.ATTRS { + focus_path='pregnancy', + initial_tab='relationships', +} -if args[1] == "divorce" and tonumber(args[2]) then - local unit = df.unit.find(tonumber(args[2])) - if unit then Divorce (unit) return end +function FamilyAffairsScreen:init() + self:addviews{FamilyAffairs{initial_tab=self.initial_tab}} end -if tonumber(args[1]) and tonumber(args[2]) then - local unit1 = df.unit.find(tonumber(args[1])) - local unit2 = df.unit.find(tonumber(args[2])) - if unit1 and unit2 then - Divorce (unit1) - Divorce (unit2) - Marriage (unit1,unit2) - return - end +function FamilyAffairsScreen:onDismiss() + view = nil end -local selected = dfhack.gui.getSelectedUnit(true) -if tonumber(args[1]) then - selected = df.unit.find(tonumber(args[1])) or selected +if not dfhack.isMapLoaded() then + qerror('requires a map to be loaded') end -if selected then - if dfhack.units.isCitizen(selected) and dfhack.units.isSane(selected) then - MainDialog(selected) - else - qerror("You must select a sane fortress citizen.") - return - end -else - print (helpstr) - qerror("Select a sane fortress dwarf") +local help, initial_tab = false, 'relationships' + +local positionals = argparse.processArgsGetopt({...}, { + {'h', 'help', handler=function() help = true end}, + {nil, 'pregnancy', handler=function() initial_tab = 'pregnancy' end}, +}) + +if positionals[1] == 'help' then help = true end +if help then + print(dfhack.script_help()) + return end + +view = view and view:raise() or FamilyAffairsScreen{initial_tab=initial_tab}:show() diff --git a/gui/gm-editor.lua b/gui/gm-editor.lua index 1c0fe7a345..6902875b69 100644 --- a/gui/gm-editor.lua +++ b/gui/gm-editor.lua @@ -1,12 +1,13 @@ -- Interface powered memory object editor. --@module=true -local gui = require 'gui' -local json = require 'json' -local dialog = require 'gui.dialogs' -local widgets = require 'gui.widgets' -local guiScript = require 'gui.script' -local utils = require 'utils' +local argparse = require('argparse') +local gui = require('gui') +local json = require('json') +local dialog = require('gui.dialogs') +local widgets = require('gui.widgets') +local guiScript = require('gui.script') +local utils = require('utils') config = config or json.open('dfhack-config/gm-editor.json') @@ -53,10 +54,23 @@ end function getTypeName(type) return tostring(type):gmatch('')() or '' end + function getTargetFromScreens() local my_trg = dfhack.gui.getSelectedUnit(true) or dfhack.gui.getSelectedItem(true) or dfhack.gui.getSelectedJob(true) or dfhack.gui.getSelectedBuilding(true) or dfhack.gui.getSelectedStockpile(true) or dfhack.gui.getSelectedCivZone(true) + if not my_trg then + if dfhack.gui.matchFocusString('dwarfmode/ViewSheets/ENGRAVING', dfhack.gui.getDFViewscreen(true)) then + local sheet = df.global.game.main_interface.view_sheets + local pos = xyz2pos(sheet.viewing_x, sheet.viewing_y, sheet.viewing_z) + for _, engraving in ipairs(df.global.world.event.engravings) do + if same_xyz(engraving.pos, pos) then + my_trg = engraving + break + end + end + end + end if not my_trg then qerror("No valid target found") end @@ -80,15 +94,39 @@ function search_relevance(search, candidate) return ret end +local RESIZE_MIN = {w=30, h=20} + +local function sanitize_frame(frame) + local w, h = dfhack.screen.getWindowSize() + local min = RESIZE_MIN + if frame.t and h - frame.t - (frame.b or 0) < min.h then + frame.t = h - min.h + frame.b = 0 + end + if frame.b and h - frame.b - (frame.t or 0) < min.h then + frame.b = h - min.h + frame.t = 0 + end + if frame.l and w - frame.l - (frame.r or 0) < min.w then + frame.l = w - min.w + frame.r = 0 + end + if frame.r and w - frame.r - (frame.l or 0) < min.w then + frame.r = w - min.w + frame.l = 0 + end + return frame +end GmEditorUi = defclass(GmEditorUi, widgets.Window) GmEditorUi.ATTRS{ - frame=copyall(config.data.frame or {}), + frame=sanitize_frame(copyall(config.data.frame or {})), frame_title="GameMaster's editor", frame_inset=0, resizable=true, - resize_min={w=30, h=20}, - read_only=(config.data.read_only or false) + resize_min=RESIZE_MIN, + read_only=(config.data.read_only or false), + helpers=true, } function burning_red(input) -- todo does not work! bug angavrilov that so that he would add this, very important!! @@ -147,7 +185,7 @@ function GmEditorUi:init(args) local mainPage=widgets.Panel{ subviews={ mainList, - widgets.Label{text={{text="",id="name"},{gap=1,text="Help",key=keybindings.help.key,key_sep = '()'}}, view_id = 'lbl_current_item',frame = {l=1,t=1,yalign=0}}, + widgets.Label{text={{text="",id="name"},{text="",pen=COLOR_CYAN,id="union"},{gap=1,text="Help",key=keybindings.help.key,key_sep = '()'}}, view_id = 'lbl_current_item',frame = {l=1,t=1,yalign=0}}, widgets.EditField{frame={l=1,t=2,h=1},label_text="Search",key=keybindings.start_filter.key,key_sep='(): ',on_change=self:callback('text_input'),view_id="filter_input"}} ,view_id='page_main'} @@ -585,27 +623,32 @@ function GmEditorUi:onInput(keys) end end -function getStringValue(trg,field) - local obj=trg.target +function GmEditorUi:getStringValue(trg, field) + local obj = trg.target local text=tostring(obj[field]) pcall(function() - if obj._field ~= nil then + if obj._field == nil then return end local f = obj:_field(field) - if df.coord:is_instance(f) then - text=('(%d, %d, %d) '):format(f.x, f.y, f.z) .. text - elseif df.coord2d:is_instance(f) then - text=('(%d, %d) '):format(f.x, f.y) .. text + if self.helpers and not obj._type._union then + if df.coord:is_instance(f) then + text=('(%d, %d, %d) %s'):format(f.x, f.y, f.z, text) + elseif df.coord2d:is_instance(f) then + text=('(%d, %d) %s'):format(f.x, f.y, text) + elseif df.language_name:is_instance(f) then + text=('%s (%s) %s'):format(dfhack.translation.translateName(f, false), dfhack.translation.translateName(f, true), text) + end end - local enum=f._type + local enum = f._type if enum._kind=="enum-type" then text=text.." ("..tostring(enum[obj[field]])..")" end + -- this will throw for types that have no ref target; pcall will catch it, but make sure this bit stays + -- at the end of the pcall function body local ref_target=f.ref_target if ref_target then text=text.. " (ref-target: "..getmetatable(ref_target)..")" end - end end) return text end @@ -645,10 +688,11 @@ function GmEditorUi:updateTarget(preserve_pos,reindex) end end end + self.subviews.lbl_current_item:itemById('union').text = type(trg.target) == 'userdata' and trg.target._type._union and " [union structure]" or "" self.subviews.lbl_current_item:itemById('name').text=tostring(trg.target) local t={} for k,v in pairs(trg.keys) do - table.insert(t,{text={{text=string.format("%-"..trg.kw.."s",tostring(v))},{gap=2,text=getStringValue(trg,v)}}}) + table.insert(t,{text={{text=string.format("%-"..trg.kw.."s",tostring(v))},{gap=2,text=self:getStringValue(trg,v)}}}) end local last_selected, last_top if preserve_pos then @@ -788,7 +832,7 @@ function GmScreen:init(args) end end end - self:addviews{GmEditorUi{target=target}} + self:addviews{GmEditorUi{target=target, helpers=args.helpers}} views[self] = true end @@ -802,28 +846,26 @@ function GmScreen:onDismiss() end local function get_editor(args) - local freeze = false - if args[1] == '-f' or args[1] == '--freeze' then - freeze = true - table.remove(args, 1) - end - if #args~=0 then - if args[1]=="dialog" then - dialog.showInputPrompt("Gm Editor", "Object to edit:", COLOR_GRAY, + local freeze, helpers = false, true + local positionals = argparse.processArgsGetopt(args, { + {'f', 'freeze', 'safe-mode', handler=function() freeze = true end}, + {nil, 'no-stringification', handler=function() helpers = false end}, + }) + if #positionals == 0 then + GmScreen{freeze=freeze, helpers=helpers, target=getTargetFromScreens()}:show() + else + if positionals[1]=="dialog" then + dialog.showInputPrompt("GM Editor", "Object to edit:", COLOR_GRAY, "", function(entry) - GmScreen{freeze=freeze, target=eval(entry)}:show() + GmScreen{freeze=freeze, helpers=helpers, target=eval(entry)}:show() end) - elseif args[1]=="free" then - GmScreen{freeze=freeze, target=df.reinterpret_cast(df[args[2]],args[3])}:show() - elseif args[1]=="scr" then + elseif positionals[1]=="scr" then -- this will not work for more complicated expressions, like scr.fieldname, but -- it should capture the most common case - GmScreen{freeze=freeze, target=dfhack.gui.getDFViewscreen(true)}:show() + GmScreen{freeze=freeze, helpers=helpers, target=dfhack.gui.getDFViewscreen(true)}:show() else - GmScreen{freeze=freeze, target=eval(args[1])}:show() + GmScreen{freeze=freeze, helpers=helpers, target=eval(positionals[1])}:show() end - else - GmScreen{freeze=freeze, target=getTargetFromScreens()}:show() end end diff --git a/gui/journal.lua b/gui/journal.lua new file mode 100644 index 0000000000..03cf34b022 --- /dev/null +++ b/gui/journal.lua @@ -0,0 +1,335 @@ +-- Fort journal with a multi-line text editor +--@ module = true + +local gui = require 'gui' +local widgets = require 'gui.widgets' +local utils = require 'utils' +local json = require 'json' +local shifter = reqscript('internal/journal/shifter') +local table_of_contents = reqscript('internal/journal/table_of_contents') +local journal_context = reqscript('internal/journal/journal_context') + +local RESIZE_MIN = {w=54, h=20} +local TOC_RESIZE_MIN = {w=24} + +journal_config = journal_config or json.open('dfhack-config/journal.json') + +JOURNAL_CONTEXT_MODE = journal_context.JOURNAL_CONTEXT_MODE + +JournalWindow = defclass(JournalWindow, widgets.Window) +JournalWindow.ATTRS { + frame_title='DF Journal', + resizable=true, + resize_min=RESIZE_MIN, + frame_inset={l=0,r=0,t=0,b=0}, + init_text=DEFAULT_NIL, + init_cursor=1, + save_layout=true, + show_tutorial=false, + + toc_welcome_copy=DEFAULT_NIL, + journal_welcome_copy=DEFAULT_NIL, + + on_text_change=DEFAULT_NIL, + on_cursor_change=DEFAULT_NIL, + on_layout_change=DEFAULT_NIL +} + +function JournalWindow:init() + local frame, toc_visible, toc_width = self:loadConfig() + + self.frame = frame and self:sanitizeFrame(frame) or self.frame + + self:addviews{ + table_of_contents.TableOfContents{ + view_id='table_of_contents_panel', + frame={l=0, w=toc_width, t=0, b=1}, + visible=toc_visible, + frame_inset={l=1, t=0, b=1, r=1}, + + resize_min=TOC_RESIZE_MIN, + resizable=true, + resize_anchors={l=false, t=false, b=true, r=true}, + + on_resize_begin=self:callback('onPanelResizeBegin'), + on_resize_end=self:callback('onPanelResizeEnd'), + + on_submit=self:callback('onTableOfContentsSubmit'), + subviews={ + widgets.WrappedLabel{ + view_id='table_of_contents_tutorial', + frame={l=0,t=0,r=0,b=3}, + text_to_wrap=self.toc_welcome_copy or '', + visible=false + } + } + }, + shifter.Shifter{ + view_id='shifter', + frame={l=0, w=1, t=1, b=2}, + collapsed=not toc_visible, + on_changed = function (collapsed) + self.subviews.table_of_contents_panel.visible = not collapsed + self.subviews.table_of_contents_divider.visible = not collapsed + + if not collapsed then + self:reloadTableOfContents() + end + + self:ensurePanelsRelSize() + self:updateLayout() + end, + }, + widgets.Divider{ + frame={l=0,r=0,b=2,h=1}, + frame_style_l=false, + frame_style_r=false, + interior_l=true, + }, + widgets.Divider{ + view_id='table_of_contents_divider', + + frame={l=30,t=0,b=2,w=1}, + visible=toc_visible, + + interior_b=true, + frame_style_t=false, + }, + widgets.TextArea{ + view_id='journal_editor', + frame={t=1, b=3, l=25, r=0}, + resize_min={w=30, h=10}, + frame_inset={l=1,r=0}, + init_text=self.init_text, + init_cursor=self.init_cursor, + on_text_change=self:callback('onTextChange'), + on_cursor_change=self:callback('onCursorChange'), + }, + widgets.HelpButton{command="gui/journal", frame={r=0,t=1}}, + widgets.Panel{ + frame={l=0,r=0,b=1,h=1}, + frame_inset={l=1,r=1,t=0, w=100}, + subviews={ + widgets.HotkeyLabel{ + frame={l=0}, + key='CUSTOM_CTRL_O', + label='Toggle table of contents', + auto_width=true, + on_activate=function() self.subviews.shifter:toggle() end + } + } + } + } + + if self.show_tutorial then + self.subviews.journal_editor:addviews{ + widgets.WrappedLabel{ + view_id='journal_tutorial', + frame={l=0,t=1,r=0,b=0}, + text_to_wrap=self.journal_welcome_copy or '' + } + } + end + + self:reloadTableOfContents() +end + +function JournalWindow:reloadTableOfContents() + self.subviews.table_of_contents_panel:reload( + self.subviews.journal_editor:getText(), + self.subviews.journal_editor:getCursor() or self.init_cursor + ) + self.subviews.table_of_contents_panel.subviews.table_of_contents_tutorial.visible = + #self.subviews.table_of_contents_panel:sections() == 0 +end + +function JournalWindow:sanitizeFrame(frame) + local w, h = dfhack.screen.getWindowSize() + local min = RESIZE_MIN + if frame.t and h - frame.t - (frame.b or 0) < min.h then + frame.t = h - min.h + frame.b = 0 + end + if frame.b and h - frame.b - (frame.t or 0) < min.h then + frame.b = h - min.h + frame.t = 0 + end + if frame.l and w - frame.l - (frame.r or 0) < min.w then + frame.l = w - min.w + frame.r = 0 + end + if frame.r and w - frame.r - (frame.l or 0) < min.w then + frame.r = w - min.w + frame.l = 0 + end + return frame +end + +function JournalWindow:saveConfig() + if not self.save_layout then + return + end + + local toc = self.subviews.table_of_contents_panel + + utils.assign(journal_config.data, { + frame = self.frame, + toc = { + width = toc.frame.w, + visible = toc.visible + } + }) + journal_config:write() +end + +function JournalWindow:loadConfig() + if not self.save_layout then + return nil, false, 25 + end + + local window_frame = copyall(journal_config.data.frame or {}) + window_frame.w = window_frame.w or 80 + window_frame.h = window_frame.h or 50 + + local toc = copyall(journal_config.data.toc or {}) + toc.width = math.max(toc.width or 25, TOC_RESIZE_MIN.w) + toc.visible = toc.visible or false + + return window_frame, toc.visible, toc.width +end + +function JournalWindow:onPanelResizeBegin() + self.resizing_panels = true +end + +function JournalWindow:onPanelResizeEnd() + self.resizing_panels = false + self:ensurePanelsRelSize() + + self:updateLayout() +end + +function JournalWindow:onRenderBody(painter) + if self.resizing_panels then + self:ensurePanelsRelSize() + self:updateLayout() + end + + return JournalWindow.super.onRenderBody(self, painter) +end + +function JournalWindow:ensurePanelsRelSize() + local toc_panel = self.subviews.table_of_contents_panel + local editor = self.subviews.journal_editor + local divider = self.subviews.table_of_contents_divider + + toc_panel.frame.w = math.min( + math.max(toc_panel.frame.w, TOC_RESIZE_MIN.w), + self.frame.w - editor.resize_min.w + ) + editor.frame.l = toc_panel.visible and toc_panel.frame.w or 1 + divider.frame.l = editor.frame.l - 1 +end + +function JournalWindow:preUpdateLayout() + self:ensurePanelsRelSize() +end + +function JournalWindow:postUpdateLayout() + self:saveConfig() +end + +function JournalWindow:onCursorChange(cursor) + self.subviews.table_of_contents_panel:setCursor(cursor) + local section_index = self.subviews.table_of_contents_panel:currentSection() + self.subviews.table_of_contents_panel:setSelectedSection(section_index) + + if self.on_cursor_change ~= nil then + self.on_cursor_change(cursor) + end +end + +function JournalWindow:onTextChange(text) + if self.show_tutorial then + self.subviews.journal_editor.subviews.journal_tutorial.visible = false + end + self:reloadTableOfContents() + + if self.on_text_change ~= nil then + self.on_text_change(text) + end +end + +function JournalWindow:onTableOfContentsSubmit(ind, section) + self.subviews.journal_editor:setCursor(section.line_cursor) + self.subviews.journal_editor:scrollToCursor(section.line_cursor) +end + +JournalScreen = defclass(JournalScreen, gui.ZScreen) +JournalScreen.ATTRS { + focus_path='journal', + pass_pause=false, + context_mode=DEFAULT_NIL, + save_layout=true, + save_prefix='' +} + +function JournalScreen:init() + self.journal_context = journal_context.journal_context_factory( + self.context_mode, + self.save_prefix + ) + local content = self.journal_context:load_content() + + self:addviews{ + JournalWindow{ + view_id='journal_window', + frame={w=65, h=45}, + + save_layout=self.save_layout, + + init_text=content.text[1], + init_cursor=content.cursor[1], + show_tutorial=content.show_tutorial or false, + + toc_welcome_copy=self.journal_context:tocWelcomeCopy(), + journal_welcome_copy=self.journal_context:welcomeCopy(), + + on_text_change=self:callback('onTextChange'), + on_cursor_change=self:callback('onTextChange') + }, + } +end + +function JournalScreen:onTextChange() + local text = self.subviews.journal_editor:getText() + local cursor = self.subviews.journal_editor:getCursor() + + self.journal_context:save_content(text, cursor) +end + +function JournalScreen:onDismiss() + view = nil +end + +function main(options) + if not dfhack.isMapLoaded() or (not dfhack.world.isFortressMode() + and not dfhack.world.isAdventureMode()) then + qerror('journal requires a fortress/adventure map to be loaded') + end + + local save_layout = options and options.save_layout + local overrided_context_mode = options and options.context_mode + local context_mode = overrided_context_mode == nil and + journal_context.detect_journal_context_mode() or overrided_context_mode + + view = view and view:raise() or JournalScreen{ + save_prefix=options and options.save_prefix or '', + save_layout=save_layout == nil and true or save_layout, + context_mode=context_mode, + }:show() +end + +if not dfhack_flags.module then + main() +end diff --git a/gui/keybinds.lua b/gui/keybinds.lua new file mode 100644 index 0000000000..8ac0f1e3e8 --- /dev/null +++ b/gui/keybinds.lua @@ -0,0 +1,589 @@ +-- Graphically configure DFHack keybinds + +local gui = require('gui') +local widgets = require('gui.widgets') + +-- Constants +local initfile = "dfhack-config/init/dfhack.auto.keybinds.init" + +-- +-- Icons +-- +local function make_button(ascii, pens, x, y) + local out = {} + for i = 1, 3 do + local tmp = {} + for j = 1, 3 do + table.insert(tmp, { + tile = dfhack.pen.parse { + ch = ascii[i][j], + fg = pens[i][j], + keep_lower = true, + tile = dfhack.screen.findGraphicsTile('INTERFACE_BITS', x + j - 1, y + i - 1) + }, + }) + end + table.insert(out, tmp) + end + return out +end + +local trash_icon = make_button( + { + + { 218, 196, 191 }, + { 179, 'D', 179 }, + { 192, 196, 217 }, + }, + { + { COLOR_GRAY, COLOR_GRAY, COLOR_GRAY }, + { COLOR_GRAY, COLOR_RED, COLOR_GRAY }, + { COLOR_GRAY, COLOR_GRAY, COLOR_GRAY }, + }, + 41, 0 +) +local inspect_icon = make_button( + { + { 218, 196, 191 }, + { 26, 'E', 179 }, + { 192, 196, 217 }, + }, + { + { COLOR_GRAY, COLOR_GRAY, COLOR_GRAY }, + { COLOR_WHITE, COLOR_YELLOW, COLOR_GRAY }, + { COLOR_GRAY, COLOR_GRAY, COLOR_GRAY }, + }, + 35, 0 +) + +-- +-- SelectKeyDialog +-- +SelectKeyDialog = defclass(SelectKeyDialog, gui.ZScreenModal) +SelectKeyDialog.ATTRS = { + -- Callbacks + on_select = DEFAULT_NIL, + on_cancel = DEFAULT_NIL, +} + +function SelectKeyDialog:init() + self.warning = false + self.key = nil + + self:addviews({ + widgets.Window { + frame = { w = 65, h = 16 }, + frame_title = 'Select a Key Combination', + frame_style = gui.FRAME_BOLD, + resizable = false, + subviews = { + widgets.Label { + frame = { t = 0 }, + text = { { text = self:callback('getKeyLabelText') } }, + on_click = self:callback('startListeningForKey'), + auto_width = true, + xalign = 0.5, + }, + widgets.Label { + frame = { t = 2 }, + auto_width = true, + xalign = 0.5, + text_pen = COLOR_RED, + text = "WARNING: This keybind may be disruptive to gameplay.\nProceed cautiously.", + visible = function() return self.warning end, + }, + widgets.HotkeyLabel { + frame = { b = 0, l = 2 }, + key = 'SELECT', + label = 'Confirm', + auto_width = true, + enabled = function() return self.key ~= nil end, + on_activate = self:callback('onConfirm') + }, + widgets.HotkeyLabel { + frame = { b = 0, l = 18 }, + key = 'LEAVESCREEN', + label = 'Cancel', + auto_width = true, + on_click = self:callback('onCancel') + }, + }, + }, + }) + + self:startListeningForKey() +end + +function SelectKeyDialog:onIdle() + if not self.key then + -- Check to see if we have gotten a key input since issuing the request + self.key = dfhack.hotkey.getKeybindingInput() + if self.key then + self.needs_refresh = true + self.warning = dfhack.hotkey.isDisruptiveKeybind(self.key) + end + end + + -- Force the width of the keyspec label to update + if self.needs_refresh then + self.needs_refresh = false + self:updateLayout() + end +end + +function SelectKeyDialog:onConfirm() + -- Cancel ongoing input requests + dfhack.hotkey.requestKeybindingInput(true) + + self:dismiss() + if self.on_select then self.on_select(self.key) end +end + +function SelectKeyDialog:onCancel() + -- Cancel ongoing input requests + dfhack.hotkey.requestKeybindingInput(true) + + self:dismiss() + if self.on_cancel then self.on_cancel() end +end + +function SelectKeyDialog:startListeningForKey() + self.key = nil + self.needs_refresh = true + dfhack.hotkey.requestKeybindingInput() +end + +function SelectKeyDialog:getKeyLabelText() + if self.key then + return self.key + else + return "Listening..." + end +end + +function SelectKeyDialog:getWarningText() + return self.warning and "WARNING: This keybind may be disruptive to gameplay." or "" +end + +-- +-- EditKeybindWindow +-- +EditKeybindWindow = defclass(EditKeybindWindow, gui.ZScreenModal) +EditKeybindWindow.ATTRS = { + hotkey = '', + spec_focus = '', + command = '', + -- Callbacks + on_apply = DEFAULT_NIL, + on_cancel = DEFAULT_NIL, +} + +function EditKeybindWindow:init() + -- Specifically request the focus strings of the first *DF* viewscreen + local focus_list = dfhack.gui.getFocusStrings(dfhack.gui.getDFViewscreen()) + self.current_focus = "Current context:" + for i = 1, #focus_list do + self.current_focus = self.current_focus .. "\n" .. focus_list[i] + end + + self:addviews({ + widgets.Window { + frame = { w = 65, h = 16 }, + frame_title = 'Edit Keybind', + resizable = false, + subviews = { + widgets.HotkeyLabel { + key = 'CUSTOM_ALT_A', + frame = { t = 0, l = 0 }, + label = 'Hotkey: ', + text_pen = COLOR_WHITE, + on_activate = self:callback('changeHotkey'), + }, + widgets.Label { + frame = { t = 0, l = 15 }, + text = { { text = function() return self.hotkey or '' end } }, + text_pen = COLOR_CYAN, + on_click = self:callback('changeHotkey'), + }, + widgets.EditField { + frame = { t = 2 }, + key = 'CUSTOM_ALT_B', + label_text = 'Command to Execute: ', + text = self.command or '', + on_change = function(cmd, _) self.command = cmd end + }, + widgets.Label { + frame = { t = 4 }, + text = 'List of active contexts, separated by |.\n' + .. 'Blank for always active', + text_pen = COLOR_GRAY, + }, + widgets.EditField { + frame = { t = 6 }, + key = 'CUSTOM_ALT_C', + label_text = 'Focus Context: ', + text = self.spec_focus or '', + on_change = function(focus, _) self.spec_focus = focus end + }, + widgets.Label { + frame = { t = 8 }, + text = self.current_focus, + }, + widgets.HotkeyLabel { + frame = { t = 11, l = 0 }, + key = 'LEAVESCREEN', + label = 'Cancel', + auto_width = true, + on_activate = self:callback('onCancel'), + }, + widgets.HotkeyLabel { + frame = { t = 11, l = 14 }, + key = 'SELECT', + label = 'Apply', + auto_width = true, + enabled = function() return self.command ~= '' and self.hotkey ~= '' end, + on_activate = self:callback('onApply'), + } + }, + }, + }) +end + +function EditKeybindWindow:changeHotkey() + self._key_dialog = SelectKeyDialog { + on_select = function(key) self.hotkey = key end, + } + self._key_dialog:show() +end + +function EditKeybindWindow:onApply() + self:dismiss() + if self.on_apply then + self.on_apply(self.hotkey, self.spec_focus, self.command) + end +end + +function EditKeybindWindow:onCancel() + self:dismiss() + if self.on_cancel then self.on_cancel() end +end + +-- +-- SaveKeybindsWindow +-- +SaveKeybindsWindow = defclass(SaveKeybindsWindow, gui.ZScreenModal) +SaveKeybindsWindow.ATTRS = {} + +function SaveKeybindsWindow:init() + self:addviews({ + widgets.Window { + frame = { w = 65, h = 12 }, + frame_title = 'Save Keybinds', + resizable = false, + subviews = { + widgets.WrappedLabel { + frame = { t = 0 }, + text_to_wrap = 'Keybinds are saved to "' .. initfile .. '" and loaded on startup.\n', + }, + widgets.Label { + frame = { t = 4 }, + text = 'This will not remove any keybinds set by other init files.', + text_pen = COLOR_LIGHTRED, + }, + widgets.HotkeyLabel { + frame = { b = 0, l = 0 }, + key = 'LEAVESCREEN', + label = 'Cancel', + auto_width = true, + on_activate = function() self:dismiss() end + }, + widgets.HotkeyLabel { + frame = { b = 0, l = 14 }, + key = 'SELECT', + label = 'Save', + auto_width = true, + on_activate = self:callback('createSave') + }, + widgets.HotkeyLabel { + frame = { b = 0, l = 28 }, + key = 'CUSTOM_ALT_D', + label = 'Delete Saved Keybinds', + auto_width = true, + on_activate = self:callback('deleteSave') + }, + } + } + }) +end + +function SaveKeybindsWindow:createSave() + local file = io.open(initfile, "w") + if file then + file:write('# This file is generated by gui/keybinds.\n' + .. '# To manually remove saved keybinds delete this file, or\n' + .. '# remove the line pertaining to the keybind you wish to remove\n') + + local list = dfhack.hotkey.listAllKeybinds() + for _, bind in ipairs(list) do + local sanitized_command = string.gsub(bind.command, '"', '\\"') + file:write('keybinding add ' .. bind.spec .. ' "' .. sanitized_command .. '"\n') + end + file:close() + end + + self:dismiss() +end + +function SaveKeybindsWindow:deleteSave() + -- Only remove the file if we can verify it exists + local file = io.open(initfile, "r") + if file then + file:close() + os.remove(initfile) + end + + self:dismiss() +end + +-- +-- KeybindList +-- +KeybindList = defclass(KeybindList, widgets.FilteredList) +KeybindList.ATTRS = { + view_id = 'list', + frame_style = gui.FRAME_INTERIOR, + frame = { b = 3 }, +} + +function KeybindList:init() + self.list.row_height = 3 + self:refreshData() +end + +function KeybindList:deleteKeybind() + local idx, bind = self:getSelected() + + dfhack.hotkey.removeKeybind(bind.data.spec, true, bind.data.command) + self:refreshData() +end + +function KeybindList:editKeybind() + local idx, bind = self:getSelected() + + local focus = '' + local spec = bind.data.spec + local focus_start = string.find(spec, '@') + if focus_start then + focus = string.sub(spec, focus_start + 1) + spec = string.sub(spec, 1, focus_start - 1) + end + + self._edit_dialog = EditKeybindWindow { + hotkey = spec, + spec_focus = focus, + command = bind.data.command, + on_apply = function(spec, focus, command) + -- Remove old keybind + dfhack.hotkey.removeKeybind(bind.data.spec, true, bind.data.command) + local fullspec = spec + if focus ~= '' then + fullspec = fullspec .. '@' .. focus + end + dfhack.hotkey.addKeybind(fullspec, command) + self:refreshData() + end, + } + self._edit_dialog:show() +end + +function KeybindList:get_list_pen(base_color, selected_color, idx) + local sel_idx, _ = self:getSelected() + if sel_idx == idx then + return selected_color + else + return base_color + end +end + +local function concat_tables(to, from) + for _, val in ipairs(from) do + table.insert(to, val) + end +end + +local function concat_multiline(to, from) + for i = 1, 3 do + concat_tables(to[i], from[i]) + end +end + +function KeybindList:make_list_label(bind, idx, longest_command) + local multiline = { {}, {}, {} } + concat_multiline(multiline, trash_icon) + concat_multiline(multiline, inspect_icon) + concat_multiline(multiline, { { { text = '', width = 1 } }, { { text = '', width = 1 } }, { { text = '', width = 1 } } }) + + local spec = bind.spec + local focus = '' + local focus_start = string.find(spec, '@') + if focus_start then + focus = string.sub(spec, focus_start) + spec = string.sub(spec, 1, focus_start - 1) + end + concat_tables(multiline[2], { + { text = bind.command, width = longest_command + 2, pen = self:callback('get_list_pen', COLOR_GRAY, COLOR_CYAN, idx) }, + { text = spec, pen = COLOR_LIGHTGREEN }, + { text = focus, pen = COLOR_BROWN }, + }) + + local out = {} + + for i = 1, 3 do + concat_tables(out, multiline[i]) + table.insert(out, NEWLINE) + end + return out +end + +function KeybindList:refreshData() + local options = {} + + local list = dfhack.hotkey.listAllKeybinds() + + -- Determine longest command, displaying up to 40 characters + local longest_command = 0 + for _, bind in ipairs(list) do + if #bind.command > longest_command then + longest_command = #bind.command + end + end + longest_command = longest_command > 40 and 40 or longest_command + + for _, bind in ipairs(list) do + table.insert(options, { + data = bind, + text = self:make_list_label(bind, #options + 1, longest_command), + search_key = bind.command .. " " .. bind.spec, + }) + end + + -- Set choices, persisting filter + local filter = self:getFilter() + self:setChoices(options) + if filter then + self:setFilter(filter) + end +end + +function KeybindList:onInput(keys) + if not keys._MOUSE_L then + return KeybindList.super.onInput(self, keys) + end + + local idx = self.list:getIdxUnderMouse() + if not idx then + return KeybindList.super.onInput(self, keys) + end + + local x = self:getMousePos() + if x < 0 or x > 6 then + return KeybindList.super.onInput(self, keys) + end + + self.list:setSelected(idx) + if x <= 3 then + self:deleteKeybind() + else + self:editKeybind() + end + return true +end + +-- KeybindWindow +KeybindWindow = defclass(KeybindWindow, widgets.Window) +KeybindWindow.ATTRS = { + frame_title = "Keybinds", + frame = { w = 100 }, + resizable = true, + + _key_dialog = DEFAULT_NIL, + _edit_dialog = DEFAULT_NIL, +} + +function KeybindWindow:init() + self:addviews({ + KeybindList { + frame = { t = 0, b = 4 }, + }, + widgets.HotkeyLabel { + key = 'CUSTOM_ALT_N', + label = 'New Keybind', + on_activate = self:callback('createNewKeybind'), + auto_width = true, + frame = { b = 2, l = 0 } + }, + widgets.HotkeyLabel { + key = 'CUSTOM_ALT_D', + label = 'Delete Keybind', + auto_width = true, + on_activate = self:callback('deleteKeybind'), + frame = { b = 2, l = 25 } + }, + widgets.HotkeyLabel { + key = 'CUSTOM_ALT_E', + label = 'Edit Keybind', + auto_width = true, + on_activate = self:callback('editKeybind'), + frame = { b = 1, l = 0 }, + }, + widgets.HotkeyLabel { + key = 'CUSTOM_ALT_S', + label = 'Save Keybinds', + auto_width = true, + on_activate = self:callback('saveKeybinds'), + frame = { b = 1, l = 25 } + } + }) +end + +function KeybindWindow:createNewKeybind() + self._edit_dialog = EditKeybindWindow { + on_apply = function(spec, focus, command) + local fullspec = spec + if focus ~= '' then + fullspec = fullspec .. '@' .. focus + end + dfhack.hotkey.addKeybind(fullspec, command) + self.subviews.list:refreshData() + end + } + self._edit_dialog:show() +end + +function KeybindWindow:deleteKeybind() + self.subviews.list:deleteKeybind() +end + +function KeybindWindow:editKeybind() + self.subviews.list:editKeybind() +end + +function KeybindWindow:saveKeybinds() + self._save_dialog = SaveKeybindsWindow {} + self._save_dialog:show() +end + +-- KeybindScreen +KeybindScreen = defclass(KeybindScreen, gui.ZScreen) + +function KeybindScreen:init() + self:addviews({ + KeybindWindow {}, + }) +end + +function KeybindScreen:onDismiss() + view = nil +end + +view = view and view:raise() or KeybindScreen {}:show() diff --git a/gui/launcher.lua b/gui/launcher.lua index cfb8d38efc..70d85621de 100644 --- a/gui/launcher.lua +++ b/gui/launcher.lua @@ -3,13 +3,12 @@ local dialogs = require('gui.dialogs') local gui = require('gui') -local textures = require('gui.textures') local helpdb = require('helpdb') local json = require('json') local utils = require('utils') local widgets = require('gui.widgets') -local AUTOCOMPLETE_PANEL_WIDTH = 25 +local AUTOCOMPLETE_PANEL_WIDTH = 28 local EDIT_PANEL_HEIGHT = 4 local HISTORY_SIZE = 5000 @@ -23,39 +22,135 @@ local TITLE = 'DFHack Launcher' -- within 1s when adding text to a full scrollback buffer local SCROLLBACK_CHARS = 2^18 +-- smaller amount of scrollback persisted between gui/launcher invocations +local PERSISTED_SCROLLBACK_CHARS = 2^15 + config = config or json.open('dfhack-config/launcher.json') -base_freq = base_freq or json.open('hack/data/base_command_counts.json') +base_freq = base_freq or json.open(dfhack.getHackPath()..'/data/base_command_counts.json') user_freq = user_freq or json.open('dfhack-config/command_counts.json') -- track whether the user has enabled dev mode dev_mode = dev_mode or false +-- track the last value of mortal mode +prev_mortal_mode = prev_mortal_mode +if prev_mortal_mode == nil then + prev_mortal_mode = dfhack.getMortalMode() +end + +local function get_default_tag_filter_base(mortal_mode) + local ret = { + includes={}, + excludes={}, + } + if not dev_mode then + ret.excludes.dev = true + ret.excludes.unavailable = true + if mortal_mode then + ret.excludes.armok = true + end + end + return ret +end + +local function get_default_tag_filter() + return get_default_tag_filter_base(dfhack.getMortalMode()) +end + +_tag_filter = _tag_filter or nil +local selecting_filters = false + +local function get_tag_filter() + _tag_filter = _tag_filter or get_default_tag_filter() + return _tag_filter +end + +local function toggle_dev_mode() + local tag_filter = get_tag_filter() + tag_filter.excludes.dev = dev_mode or nil + tag_filter.excludes.unavailable = dev_mode or nil + if not dev_mode then + tag_filter.excludes.armok = nil + elseif dfhack.getMortalMode() then + tag_filter.excludes.armok = true + end + dev_mode = not dev_mode +end + +local function matches(a, b) + for k,v in pairs(a) do + if b[k] ~= v then return false end + end + for k,v in pairs(b) do + if a[k] ~= v then return false end + end + return true +end + +local function is_default_filter_base(mortal_mode) + local tag_filter = get_tag_filter() + local default_filter = get_default_tag_filter_base(mortal_mode) + return matches(tag_filter.includes, default_filter.includes) and + matches(tag_filter.excludes, default_filter.excludes) +end + +local function is_default_filter() + return is_default_filter_base(dfhack.getMortalMode()) +end + +local function get_filter_text() + local tag_filter = get_tag_filter() + if not next(tag_filter.includes) and not next(tag_filter.excludes) then + return 'Dev default' + elseif is_default_filter() then + return 'Default' + end + local ret + for tag in pairs(tag_filter.includes) do + if not ret then + ret = tag + else + return 'Custom' + end + end + return ret or 'Custom' +end + +local function get_filter_pen() + local text = get_filter_text() + if text == 'Default' then + return COLOR_GREEN + elseif text == 'Dev default' then + return COLOR_LIGHTRED + else + return COLOR_YELLOW + end +end + -- trims the history down to its maximum size, if needed -local function trim_history(hist, hist_set) - if #hist <= HISTORY_SIZE then return end - -- we can only ever go over by one, so no need to loop +local function trim_history(hist) + local hist_size = #hist + local overage = hist_size - HISTORY_SIZE + if overage <= 0 then return end -- This is O(N) in the HISTORY_SIZE. if we need to make this more efficient, -- we can use a ring buffer. - local line = table.remove(hist, 1) - -- since all lines are guaranteed to be unique, we can just remove the hash - -- from the set instead of, say, decrementing a counter - hist_set[line] = nil + for i=overage+1,hist_size do + hist[i-overage] = hist[i] + if i > HISTORY_SIZE then + hist[i] = nil + end + end end --- removes duplicate existing history lines and adds the given line to the front -local function add_history(hist, hist_set, line) +-- adds the given line to the front of the history as long as it is different from the previous command +local function add_history(hist, line, defer_trim) line = line:trim() - if hist_set[line] then - for i,v in ipairs(hist) do - if v == line then - table.remove(hist, i) - break - end - end - end + local hist_size = #hist + if line == hist[hist_size] then return end table.insert(hist, line) - hist_set[line] = true - trim_history(hist, hist_set) + if not defer_trim then + trim_history(hist) + end end local function file_exists(fname) @@ -64,13 +159,15 @@ end -- history files are written with the most recent entry on *top*, which the -- opposite of what we want. add the file contents to our history in reverse. -local function add_history_lines(lines, hist, hist_set) +-- you must manually call trim_history() after this function +local function add_history_lines(lines, hist) for i=#lines,1,-1 do - add_history(hist, hist_set, lines[i]) + add_history(hist, lines[i], true) end end -local function add_history_file(fname, hist, hist_set) +-- you must manually call trim_history() after this function +local function add_history_file(fname, hist) if not file_exists(fname) then return end @@ -78,32 +175,32 @@ local function add_history_file(fname, hist, hist_set) for line in io.lines(fname) do table.insert(lines, line) end - add_history_lines(lines, hist, hist_set) + add_history_lines(lines, hist) end local function init_history() - local hist, hist_set = {}, {} + local hist = {} -- snarf the console history into our active history. it would be better if -- both the launcher and the console were using the same history object so -- the sharing would be "live", but we can address that later. - add_history_file(CONSOLE_HISTORY_FILE_OLD, hist, hist_set) - add_history_file(CONSOLE_HISTORY_FILE, hist, hist_set) + add_history_file(CONSOLE_HISTORY_FILE_OLD, hist) + add_history_file(CONSOLE_HISTORY_FILE, hist) -- read in our own command history - add_history_lines(dfhack.getCommandHistory(HISTORY_ID, HISTORY_FILE), - hist, hist_set) + add_history_lines(dfhack.getCommandHistory(HISTORY_ID, HISTORY_FILE), hist) - return hist, hist_set -end + trim_history(hist) -if not history then - history, history_set = init_history() + return hist end +-- history is a list of previously run commands, most recent at history[#history] +history = history or init_history() + local function get_first_word(text) local word = text:trim():split(' +')[1] if word:startswith(':') then word = word:sub(2) end - return word + return word:lower() end local function get_command_count(command) @@ -111,23 +208,172 @@ local function get_command_count(command) end local function record_command(line) - add_history(history, history_set, line) + add_history(history, line) local firstword = get_first_word(line) user_freq.data[firstword] = (user_freq.data[firstword] or 0) + 1 user_freq:write() end +---------------------------------- +-- TagFilterPanel +-- + +TagFilterPanel = defclass(TagFilterPanel, widgets.Panel) +TagFilterPanel.ATTRS{ + frame={t=0, r=AUTOCOMPLETE_PANEL_WIDTH, w=46, h=#helpdb.get_tags()+15}, + frame_style=gui.FRAME_INTERIOR_MEDIUM, + frame_background=gui.CLEAR_PEN, +} + +function TagFilterPanel:init() + self:addviews{ + widgets.FilteredList{ + view_id='list', + frame={t=0, l=0, r=0, b=11}, + on_select=self:callback('on_select'), + on_double_click=self:callback('on_submit'), + }, + widgets.Divider{ + frame={l=0, r=0, b=9, h=1}, + frame_style=gui.FRAME_INTERIOR, + frame_style_l=false, + frame_style_r=false, + }, + widgets.WrappedLabel{ + view_id='desc', + frame={b=3, h=6}, + auto_height=false, + text_to_wrap='', -- updated in on_select + }, + widgets.Divider{ + frame={l=0, r=0, b=2, h=1}, + frame_style=gui.FRAME_INTERIOR, + frame_style_l=false, + frame_style_r=false, + }, + widgets.HotkeyLabel{ + frame={b=0, l=0}, + label='Cycle filter', + key='SELECT', + auto_width=true, + on_activate=self:callback('on_submit') + }, + widgets.HotkeyLabel{ + frame={b=0, r=0}, + label='Cycle all', + key='CUSTOM_CTRL_N', + auto_width=true, + on_activate=self:callback('toggle_all') + }, + } + self:refresh() +end + +function TagFilterPanel:on_select(_, choice) + local desc = self.subviews.desc + desc.text_to_wrap = choice and choice.desc or '' + if desc.frame_body then + desc:updateLayout() + end +end + +function TagFilterPanel:on_submit() + local _,choice = self.subviews.list:getSelected() + if not choice then return end + local tag_filter = get_tag_filter() + local tag = choice.tag + if tag_filter.includes[tag] then + tag_filter.includes[tag] = nil + tag_filter.excludes[tag] = true + elseif tag_filter.excludes[tag] then + tag_filter.excludes[tag] = nil + else + tag_filter.includes[tag] = true + tag_filter.excludes[tag] = nil + end + self:refresh() + self.parent_view:refresh_autocomplete() +end + +function TagFilterPanel:toggle_all() + local choices = self.subviews.list:getVisibleChoices() + if not choices or #choices == 0 then return end + local tag_filter = get_tag_filter() + local canonical_tag = choices[1].tag + if tag_filter.includes[canonical_tag] then + for _,choice in ipairs(choices) do + local tag = choice.tag + tag_filter.includes[tag] = nil + tag_filter.excludes[tag] = true + end + elseif tag_filter.excludes[canonical_tag] then + for _,choice in ipairs(choices) do + local tag = choice.tag + tag_filter.includes[tag] = nil + tag_filter.excludes[tag] = nil + end + else + for _,choice in ipairs(choices) do + local tag = choice.tag + tag_filter.includes[tag] = true + tag_filter.excludes[tag] = nil + end + end + self:refresh() + self.parent_view:refresh_autocomplete() +end + +local function get_tag_text(tag) + local status, pen = '', nil + local tag_filter = get_tag_filter() + if tag_filter.includes[tag] then + status, pen = '(included)', COLOR_GREEN + elseif tag_filter.excludes[tag] then + status, pen = '(excluded)', COLOR_LIGHTRED + end + return { + text={ + {text=tag, width=20, rjustify=true}, + {gap=1, text=status, pen=pen}, + }, + tag=tag, + desc=helpdb.get_tag_data(tag).description + } +end + +function TagFilterPanel:refresh() + local choices = {} + for _, tag in ipairs(helpdb.get_tags()) do + table.insert(choices, get_tag_text(tag)) + end + local list = self.subviews.list + local filter = list:getFilter() + local selected = list:getSelected() + list:setChoices(choices) + list:setFilter(filter, selected) +end + ---------------------------------- -- AutocompletePanel -- + AutocompletePanel = defclass(AutocompletePanel, widgets.Panel) AutocompletePanel.ATTRS{ + frame_background=gui.CLEAR_PEN, + frame_inset={l=1}, on_autocomplete=DEFAULT_NIL, + tag_filter_panel=DEFAULT_NIL, on_double_click=DEFAULT_NIL, on_double_click2=DEFAULT_NIL, } function AutocompletePanel:init() + local function open_filter_panel() + selecting_filters = true + self.tag_filter_panel.subviews.list.edit:setFocus(true) + self.tag_filter_panel:refresh() + end + self:addviews{ widgets.Label{ frame={l=0, t=0}, @@ -135,26 +381,48 @@ function AutocompletePanel:init() }, widgets.Label{ frame={l=1, t=1}, - text={{text='Shift+Left', pen=COLOR_LIGHTGREEN}, + text={{text='Tab', pen=COLOR_LIGHTGREEN}, {text='/'}, - {text='Shift+Right', pen=COLOR_LIGHTGREEN}} + {text='Shift+Tab', pen=COLOR_LIGHTGREEN}} }, widgets.Label{ frame={l=0, t=3}, + text={ + {key='CUSTOM_CTRL_F', key_sep=': ', on_activate=open_filter_panel, text='Tags:'}, + {gap=1, pen=get_filter_pen, text=get_filter_text}, + }, + on_click=open_filter_panel, + }, + widgets.HotkeyLabel{ + frame={l=0, t=4}, + key='CUSTOM_CTRL_G', + label='Reset tag filter', + disabled=is_default_filter, + on_activate=function() + _tag_filter = get_default_tag_filter() + if selecting_filters then + self.tag_filter_panel:refresh() + end + self.parent_view:refresh_autocomplete() + end, + }, + widgets.Label{ + frame={l=0, t=6}, text='Showing:', }, widgets.Label{ view_id="autocomplete_label", - frame={l=9, t=3}, - text='All scripts' + frame={l=9, t=6}, + text={{text='Matching tools', pen=COLOR_GREY}}, }, widgets.List{ view_id='autocomplete_list', + frame={l=0, r=0, t=8, b=0}, scroll_keys={}, on_select=self:callback('on_list_select'), on_double_click=self.on_double_click, on_double_click2=self.on_double_click2, - frame={l=0, r=0, t=5, b=0}}, + }, } end @@ -202,13 +470,16 @@ EditPanel.ATTRS{ function EditPanel:init() self.stack = {} + self.seen_search = {} self:reset_history_idx() self:addviews{ widgets.Label{ view_id='prefix', - frame={l=0, t=0}, + frame={l=0, t=0, r=0}, + frame_background=gui.CLEAR_PEN, text='[DFHack]#', + auto_width=false, visible=self.prefix_visible}, widgets.EditField{ view_id='editfield', @@ -218,7 +489,10 @@ function EditPanel:init() -- to the commandline ignore_keys={'STRING_A096'}, on_char=function(ch, text) - if ch == ' ' then return text:match('%S$') end + -- if game was not initially paused, then allow double-space to toggle pause + if ch == ' ' and not self.parent_view.parent_view.saved_pause_state then + return text:sub(1, self.subviews.editfield.cursor - 1):match('%S$') + end return true end, on_change=self.on_change, @@ -228,6 +502,7 @@ function EditPanel:init() frame={l=1, t=3, w=10}, key='SELECT', label='run', + disabled=self.prefix_visible, on_activate=function() if dfhack.internal.getModifiers().shift then self.on_submit2(self.subviews.editfield.text) @@ -239,12 +514,15 @@ function EditPanel:init() frame={r=0, t=0, w=10}, key='CUSTOM_ALT_M', label=string.char(31)..string.char(30), + disabled=function() return selecting_filters end, on_activate=self.on_toggle_minimal}, widgets.EditField{ view_id='search', - frame={l=13, t=3, r=1}, + frame={l=13, b=0, r=1}, + frame_background=gui.CLEAR_PEN, key='CUSTOM_ALT_S', label_text='history search: ', + disabled=function() return selecting_filters end, on_change=function(text) self:on_search_text(text) end, on_focus=function() local text = self.subviews.editfield.text @@ -254,7 +532,10 @@ function EditPanel:init() end end, on_unfocus=function() self.subviews.search:setText('') - self.subviews.editfield:setFocus(true) end, + self.subviews.editfield:setFocus(true) + self.subviews.search.visible = not self.prefix_visible() + gui.Screen.request_full_screen_refresh = true + end, on_submit=function() self.on_submit(self.subviews.editfield.text) end, on_submit2=function() @@ -299,20 +580,31 @@ function EditPanel:move_history(delta) end function EditPanel:on_search_text(search_str, next_match) + if not next_match then self.seen_search = {} end if not search_str or #search_str == 0 then return end local start_idx = math.min(self.history_idx - (next_match and 1 or 0), #history) for history_idx = start_idx, 1, -1 do - if history[history_idx]:find(search_str, 1, true) then + local line = history[history_idx] + if line:find(search_str, 1, true) then self:move_history(history_idx - self.history_idx) - return + if not self.seen_search[line] then + self.seen_search[line] = true + return + end end end -- no matches. restart at the saved input buffer for the next search. + self.seen_search = {} self:move_history(#history + 1 - self.history_idx) end function EditPanel:onInput(keys) + if self.prefix_visible() then + local search = self.subviews.search + search.visible = keys.CUSTOM_ALT_S or search.focus + end + if EditPanel.super.onInput(self, keys) then return true end if keys.STANDARDSCROLL_UP then @@ -329,25 +621,43 @@ function EditPanel:onInput(keys) end end +function EditPanel:preUpdateLayout() + local search = self.subviews.search + local minimized = self.prefix_visible() + if minimized then + self.frame_background = nil + search.frame.l = 0 + search.frame.r = 11 + else + self.frame_background = gui.CLEAR_PEN + search.frame.l = 13 + search.frame.r = 1 + end + search.visible = not minimized or search.focus +end + ---------------------------------- -- HelpPanel -- HelpPanel = defclass(HelpPanel, widgets.Panel) HelpPanel.ATTRS{ - autoarrange_subviews=true, - autoarrange_gap=1, + frame_background=gui.CLEAR_PEN, frame_inset={t=0, l=1, r=1, b=0}, } +persisted_scrollback = persisted_scrollback or '' + -- this text is intentionally unwrapped so the in-UI wrapping can do the job local DEFAULT_HELP_TEXT = [[Welcome to DFHack! -Type a command to see its help text here. Hit ENTER to run the command, or tap backtick (`) or hit ESC to close this dialog. This dialog also closes automatically if you run a command that shows a new GUI screen. +Type a command or click on it in the autocomplete panel to see its help text here. Hit Enter or click on the "run" button to run the command as typed. You can also run a command without parameters by double clicking on it in the autocomplete list. + +You can filter the autocomplete list by clicking on the "Tags" button. Tap backtick (`) or hit ESC to close this dialog. This dialog also closes automatically if you run a command that shows a new GUI screen. -Not sure what to do? First, try running "quickstart-guide" to get oriented with DFHack and its capabilities. Then maybe try the "tags" command to see the different categories of tools DFHack has to offer! Run "tags " (e.g. "tags design") to see the tools in that category. +Not sure what to do? You can browse and configure DFHack most important tools in "gui/control-panel". Please also run "quickstart-guide" to get oriented with DFHack and its capabilities. -To see help for this command launcher (including info on mouse controls), type "launcher" and click on "gui/launcher" to autocomplete. +To see more detailed help for this command launcher (including info on keyboard and mouse controls), type "gui/launcher". You're running DFHack ]] .. dfhack.getDFHackVersion() .. (dfhack.isRelease() and '' or (' (git: %s)'):format(dfhack.getGitCommit(true))) @@ -365,9 +675,50 @@ function HelpPanel:init() on_select=function(idx) self.subviews.pages:setSelected(idx) end, get_cur_page=function() return self.subviews.pages:getSelected() end, }, + widgets.HotkeyLabel{ + frame={t=0, r=1}, + label='Clear output', + text_pen=COLOR_YELLOW, + auto_width=true, + on_activate=function() self:add_output('', true) end, + visible=function() return self.subviews.pages:getSelected() == 2 end, + enabled=function() return #self.subviews.output_label.text_to_wrap > 0 end, + }, + widgets.HotkeyLabel{ + view_id='copy', + frame={t=1, r=1}, + label='Copy output to clipboard', + text_pen=COLOR_YELLOW, + auto_width=true, + on_activate=function() + dfhack.internal.setClipboardTextCp437Multiline(self.subviews.output_label.text_to_wrap) + self.subviews.copy.visible = false + self.subviews.copy_flash.visible = true + local end_ms = dfhack.getTickCount() + 5000 + local function label_reset() + if dfhack.getTickCount() < end_ms then + dfhack.timeout(10, 'frames', label_reset) + else + self.subviews.copy_flash.visible = false + self.subviews.copy.visible = true + end + end + label_reset() + end, + visible=function() return self.subviews.pages:getSelected() == 2 end, + enabled=function() return #self.subviews.output_label.text_to_wrap > 0 end, + }, + widgets.Label{ + view_id='copy_flash', + frame={t=1, r=12}, + text='Copied', + auto_width=true, + text_pen=COLOR_GREEN, + visible=false, + }, widgets.Pages{ view_id='pages', - frame={t=2, l=0, b=0, r=0}, + frame={t=3, l=0, b=0, r=0}, subviews={ widgets.WrappedLabel{ view_id='help_label', @@ -388,7 +739,7 @@ function HelpPanel:init() STANDARDSCROLL_PAGEUP='-halfpage', STANDARDSCROLL_PAGEDOWN='+halfpage', }, - text_to_wrap=''}, + text_to_wrap=persisted_scrollback}, }, }, } @@ -400,14 +751,14 @@ local function HelpPanel_update_label(label, text) label:updateLayout() -- update the scroll arrows after rewrapping text end -function HelpPanel:add_output(output) +function HelpPanel:add_output(output, clear) self.subviews.pages:setSelected('output_label') local label = self.subviews.output_label local text_height = label:getTextHeight() label:scroll('end') local line_num = label.start_line_num local text = output - if label.text_to_wrap ~= '' then + if not clear and label.text_to_wrap ~= '' then text = label.text_to_wrap .. NEWLINE .. output end local text_len = #text @@ -419,6 +770,7 @@ function HelpPanel:add_output(output) label:scroll('end') line_num = label.start_line_num end + persisted_scrollback = text:sub(-PERSISTED_SCROLLBACK_CHARS) HelpPanel_update_label(label, text) if line_num == 1 then label:scroll(text_height - 1) @@ -455,16 +807,24 @@ function HelpPanel:postComputeFrame() HelpPanel_update_label(self.subviews.help_label, wrapped_help) end +function HelpPanel:postUpdateLayout() + if not self.sentinel then + self.sentinel = true + self.subviews.output_label:scroll('end') + end +end + ---------------------------------- -- MainPanel -- -MainPanel = defclass(MainPanel, widgets.Window) +MainPanel = defclass(MainPanel, widgets.Panel) MainPanel.ATTRS{ frame_title=TITLE, frame_inset=0, + draggable=true, resizable=true, - resize_min={w=AUTOCOMPLETE_PANEL_WIDTH+49, h=EDIT_PANEL_HEIGHT+20}, + resize_min={w=AUTOCOMPLETE_PANEL_WIDTH+48, h=EDIT_PANEL_HEIGHT+20}, get_minimal=DEFAULT_NIL, update_autocomplete=DEFAULT_NIL, on_edit_input=DEFAULT_NIL, @@ -475,60 +835,30 @@ function MainPanel:postUpdateLayout() config:write(self.frame) end -local H_SPLIT_PEN = dfhack.pen.parse{tile=curry(textures.tp_border_thin, 6), ch=196, fg=COLOR_GREY, bg=COLOR_BLACK} -local V_SPLIT_PEN = dfhack.pen.parse{tile=curry(textures.tp_border_thin, 5), ch=179, fg=COLOR_GREY, bg=COLOR_BLACK} -local TOP_SPLIT_PEN = dfhack.pen.parse{tile=curry(textures.tp_border_window,2), ch=209, fg=COLOR_GREY, bg=COLOR_BLACK} -local BOTTOM_SPLIT_PEN = dfhack.pen.parse{tile=curry(textures.tp_border_window,16), ch=207, fg=COLOR_GREY, bg=COLOR_BLACK} -local LEFT_SPLIT_PEN = dfhack.pen.parse{tile=curry(textures.tp_border_window,8), ch=199, fg=COLOR_GREY, bg=COLOR_BLACK} -local RIGHT_SPLIT_PEN = dfhack.pen.parse{tile=curry(textures.tp_border_thin, 18), ch=180, fg=COLOR_GREY, bg=COLOR_BLACK} - --- paint autocomplete panel border -local function paint_vertical_border(rect) - local x = rect.x2 - (AUTOCOMPLETE_PANEL_WIDTH + 2) - local y1, y2 = rect.y1, rect.y2 - dfhack.screen.paintTile(TOP_SPLIT_PEN, x, y1) - dfhack.screen.paintTile(BOTTOM_SPLIT_PEN, x, y2) - for y=y1+1,y2-1 do - dfhack.screen.paintTile(V_SPLIT_PEN, x, y) - end -end - --- paint border between edit area and help area -local function paint_horizontal_border(rect) - local panel_height = EDIT_PANEL_HEIGHT + 1 - local x1, x2 = rect.x1, rect.x2 - local v_border_x = x2 - (AUTOCOMPLETE_PANEL_WIDTH + 2) - local y = rect.y1 + panel_height - dfhack.screen.paintTile(LEFT_SPLIT_PEN, x1, y) - dfhack.screen.paintTile(RIGHT_SPLIT_PEN, v_border_x, y) - for x=x1+1,v_border_x-1 do - dfhack.screen.paintTile(H_SPLIT_PEN, x, y) - end -end - -function MainPanel:onRenderFrame(dc, rect) - MainPanel.super.onRenderFrame(self, dc, rect) - if self.get_minimal() then return end - paint_vertical_border(rect) - paint_horizontal_border(rect) -end - function MainPanel:onInput(keys) if MainPanel.super.onInput(self, keys) then return true + end + + if selecting_filters and (keys.LEAVESCREEN or keys._MOUSE_R) then + selecting_filters = false + self.subviews.search.on_unfocus() elseif keys.CUSTOM_CTRL_D then - dev_mode = not dev_mode - self.update_autocomplete(get_first_word(self.subviews.editfield.text)) - return true - elseif keys.KEYBOARD_CURSOR_RIGHT_FAST then + toggle_dev_mode() + self:refresh_autocomplete() + elseif keys.CHANGETAB then self.subviews.autocomplete:advance(1) - return true - elseif keys.KEYBOARD_CURSOR_LEFT_FAST then + elseif keys.SEC_CHANGETAB then self.subviews.autocomplete:advance(-1) - return true + else + return false end + return true end +function MainPanel:refresh_autocomplete() + self.update_autocomplete(get_first_word(self.subviews.editfield.text)) +end ---------------------------------- -- LauncherUI @@ -556,7 +886,7 @@ local function get_frame_r() return 0 end -function LauncherUI:init(args) +function LauncherUI:init() self.firstword = "" local main_panel = MainPanel{ @@ -566,6 +896,8 @@ function LauncherUI:init(args) on_edit_input=self:callback('on_edit_input'), } + local function not_minimized() return not self.minimal end + local frame_r = get_frame_r() local update_frames = function() @@ -574,7 +906,7 @@ function LauncherUI:init(args) new_frame.l = 0 new_frame.r = frame_r new_frame.t = 0 - new_frame.h = 1 + new_frame.h = 2 else new_frame = config.data if not next(new_frame) then @@ -585,6 +917,24 @@ function LauncherUI:init(args) new_frame[k] = 0 end end + local w, h = dfhack.screen.getWindowSize() + local min = MainPanel.ATTRS.resize_min + if new_frame.t and h - new_frame.t - (new_frame.b or 0) < min.h then + new_frame.t = h - min.h + new_frame.b = 0 + end + if new_frame.b and h - new_frame.b - (new_frame.t or 0) < min.h then + new_frame.b = h - min.h + new_frame.t = 0 + end + if new_frame.l and w - new_frame.l - (new_frame.r or 0) < min.w then + new_frame.l = w - min.w + new_frame.r = 0 + end + if new_frame.r and w - new_frame.r - (new_frame.l or 0) < min.w then + new_frame.r = w - min.w + new_frame.l = 0 + end end end main_panel.frame = new_frame @@ -592,8 +942,8 @@ function LauncherUI:init(args) local edit_frame = self.subviews.edit.frame edit_frame.r = self.minimal and - 0 or AUTOCOMPLETE_PANEL_WIDTH+2 - edit_frame.h = self.minimal and 1 or EDIT_PANEL_HEIGHT + 0 or AUTOCOMPLETE_PANEL_WIDTH+1 + edit_frame.h = self.minimal and 2 or EDIT_PANEL_HEIGHT local editfield_frame = self.subviews.editfield.frame editfield_frame.t = self.minimal and 0 or 1 @@ -601,14 +951,20 @@ function LauncherUI:init(args) editfield_frame.r = self.minimal and 11 or 1 end + local tag_filter_panel = TagFilterPanel{ + visible=function() return not_minimized() and selecting_filters end, + } + main_panel:addviews{ AutocompletePanel{ view_id='autocomplete', frame={t=0, r=0, w=AUTOCOMPLETE_PANEL_WIDTH}, on_autocomplete=self:callback('on_autocomplete'), + tag_filter_panel=tag_filter_panel, on_double_click=function(_,c) self:run_command(true, c.text) end, on_double_click2=function(_,c) self:run_command(false, c.text) end, - visible=function() return not self.minimal end}, + visible=not_minimized, + }, EditPanel{ view_id='edit', frame={t=0, l=0}, @@ -620,11 +976,26 @@ function LauncherUI:init(args) update_frames() self:updateLayout() end, - prefix_visible=function() return self.minimal end}, + prefix_visible=function() return self.minimal end, + }, HelpPanel{ view_id='help', - frame={t=EDIT_PANEL_HEIGHT+1, l=0, r=AUTOCOMPLETE_PANEL_WIDTH+1}, - visible=function() return not self.minimal end}, + frame={t=EDIT_PANEL_HEIGHT+1, l=0, r=AUTOCOMPLETE_PANEL_WIDTH}, + visible=not_minimized, + }, + widgets.Divider{ + frame={t=0, b=0, r=AUTOCOMPLETE_PANEL_WIDTH, w=1}, + frame_style_t=false, + frame_style_b=false, + visible=not_minimized, + }, + widgets.Divider{ + frame={t=EDIT_PANEL_HEIGHT, l=0, r=AUTOCOMPLETE_PANEL_WIDTH, h=1}, + interior=true, + frame_style_l=false, + visible=not_minimized, + }, + tag_filter_panel, } self:addviews{main_panel} @@ -671,9 +1042,12 @@ local function add_top_related_entries(entries, entry, n) local dev_ok = dev_mode or helpdb.get_entry_tags(entry).dev local tags = helpdb.get_entry_tags(entry) local affinities, buckets = {}, {} + local skip_armok = dfhack.getMortalMode() for tag in pairs(tags) do for _,peer in ipairs(helpdb.get_tag_data(tag)) do - affinities[peer] = (affinities[peer] or 0) + 1 + if not skip_armok or not helpdb.get_entry_tags(peer).armok then + affinities[peer] = (affinities[peer] or 0) + 1 + end end buckets[#buckets + 1] = {} end @@ -699,15 +1073,21 @@ local function add_top_related_entries(entries, entry, n) end function LauncherUI:update_autocomplete(firstword) - local includes = {{str=firstword, types='command'}} - local excludes + local includes = {str=firstword, types='command'} + local excludes = {} if helpdb.is_tag(firstword) then - table.insert(includes, {tag=firstword, types='command'}) - end - if not dev_mode then - excludes = {tag={'dev', 'unavailable'}} - if dfhack.getHideArmokTools() and firstword ~= 'armok' then - table.insert(excludes.tag, 'armok') + includes = {tag=firstword, types='command'} + for tag in pairs(get_default_tag_filter().excludes) do + table.insert(ensure_key(excludes, 'tag'), tag) + end + else + includes = {includes} + local tag_filter = get_tag_filter() + for tag in pairs(tag_filter.includes) do + table.insert(includes, {tag=tag}) + end + for tag in pairs(tag_filter.excludes) do + table.insert(ensure_key(excludes, 'tag'), tag) end end local entries = helpdb.search_entries(includes, excludes) @@ -718,17 +1098,13 @@ function LauncherUI:update_autocomplete(firstword) local found = extract_entry(entries, firstword) or helpdb.is_entry(firstword) sort_by_freq(entries) if helpdb.is_tag(firstword) then - self.subviews.autocomplete_label:setText("Tagged tools") + self.subviews.autocomplete_label:setText{{text='Tagged tools', pen=COLOR_LIGHTMAGENTA}} elseif found then table.insert(entries, 1, firstword) - self.subviews.autocomplete_label:setText("Similar tools") + self.subviews.autocomplete_label:setText{{text='Similar tools', pen=COLOR_BROWN}} add_top_related_entries(entries, firstword, 20) else - self.subviews.autocomplete_label:setText("Suggestions") - end - - if #firstword == 0 then - self.subviews.autocomplete_label:setText("All tools") + self.subviews.autocomplete_label:setText{{text='Matching tools', pen=COLOR_GREY}} end self.subviews.autocomplete:set_options(entries, found) @@ -752,43 +1128,69 @@ function LauncherUI:run_command(reappear, command) if #command == 0 then return end dfhack.addCommandToHistory(HISTORY_ID, HISTORY_FILE, command) record_command(command) - -- remember the previous parent screen address so we can detect changes - local _,prev_parent_addr = self._native.parent:sizeof() - -- propagate saved unpaused status to the new ZScreen - local saved_pause_state = df.global.pause_state - if not self.saved_pause_state then - df.global.pause_state = false - end - -- remove our viewscreen from the stack while we run the command. this - -- allows hotkey guards and tools that interact with the top viewscreen - -- without checking whether it is active to work reliably. - local output = dfhack.screen.hideGuard(self, dfhack.run_command_silent, - command) - df.global.pause_state = saved_pause_state - if #output > 0 then - print('Output from command run from gui/launcher:') - print('> ' .. command) - print() - print(output) - end - -- if we displayed a different screen, don't come back up even if reappear - -- is true so the user can interact with the new screen. - local _,parent_addr = self._native.parent:sizeof() - if not reappear or self.minimal or parent_addr ~= prev_parent_addr then - self:dismiss() - if self.minimal and #output > 0 then - dialogs.showMessage(TITLE, output) + local output, clear + if command == 'clear' or command == 'cls' or + command:startswith('clear ') or command:startswith('cls ') + then + output = '' + clear = true + else + -- remember the previous parent screen address so we can detect changes + local _,prev_parent_addr = self._native.parent:sizeof() + -- propagate saved unpaused status to the new ZScreen + local saved_pause_state = df.global.pause_state + if not self.saved_pause_state then + df.global.pause_state = false end - return + -- remove our viewscreen from the stack while we run the command. this + -- allows hotkey guards and tools that interact with the top viewscreen + -- without checking whether it is active to work reliably. + output = dfhack.screen.hideGuard(self, dfhack.run_command_silent, + command) + df.global.pause_state = saved_pause_state + if #output > 0 then + print('Output from command run from gui/launcher:') + print('> ' .. command) + print() + print(output) + end + output = output:gsub('\t', ' ') + -- if we displayed a different screen, don't come back up, even if reappear + -- is true, so the user can interact with the new screen. + local _,parent_addr = self._native.parent:sizeof() + if self.minimal or parent_addr ~= prev_parent_addr then + reappear = false + if self.minimal and #output > 0 then + dialogs.showMessage(TITLE, output) + end + end + if #output == 0 then + output = 'Command finished successfully' + end + if not output:endswith('\n') then + output = output .. '\n' + end + output = ('> %s\n\n%s'):format(command, output) end - -- reappear and show the command output + self.subviews.edit:set_text('') - if #output == 0 then - output = 'Command finished successfully' - else - output = output:gsub('\t', ' ') + self.subviews.help:add_output(output, clear) + + if not reappear then + self:dismiss() end - self.subviews.help:add_output(('> %s\n\n%s'):format(command, output)) +end + +function LauncherUI:render(dc) + local mortal_mode = dfhack.getMortalMode() + if mortal_mode ~= prev_mortal_mode then + prev_mortal_mode = mortal_mode + if is_default_filter_base(not mortal_mode) then + _tag_filter = get_default_tag_filter_base(mortal_mode) + self.subviews.main:refresh_autocomplete() + end + end + LauncherUI.super.render(self, dc) end function LauncherUI:onDismiss() @@ -799,30 +1201,28 @@ if dfhack_flags.module then return end -if view then - if not view:hasFocus() then - view:raise() - else - -- running the launcher while it is open (e.g. from hitting the launcher - -- hotkey a second time) should close the dialog - view:dismiss() - return - end -end - local args = {...} -local minimal +local minimal = false if args[1] == '--minimal' or args[1] == '-m' then table.remove(args, 1) minimal = true end + if not view then view = LauncherUI{minimal=minimal}:show() -elseif minimal and not view.minimal then +elseif minimal ~= view.minimal then view.subviews.edit.on_toggle_minimal() +elseif not view:hasFocus() then + view:raise() +elseif #args == 0 then + -- running the launcher while it is open (e.g. from hitting the launcher + -- hotkey a second time) should close the dialog + view:dismiss() + return end -local initial_command = table.concat(args, ' ') -if #initial_command > 0 then + +if #args > 0 then + local initial_command = table.concat(args, ' ') view.subviews.edit:set_text(initial_command) view:on_edit_input(initial_command, true) end diff --git a/gui/liquids.lua b/gui/liquids.lua index f5d90483fb..858af75f20 100644 --- a/gui/liquids.lua +++ b/gui/liquids.lua @@ -50,6 +50,11 @@ function SpawnLiquid:init() on_activate = self:callback('decreaseLiquidLevel'), disabled = function() return self.level == 1 end }, + widgets.Label{ + frame = { l = 0, b = 1, w = 1}, + text_pen=COLOR_LIGHTGREEN, + text=string.char(27), + }, widgets.HotkeyLabel{ frame = { l = 19, b = 1}, label = 'Increase level', @@ -58,6 +63,11 @@ function SpawnLiquid:init() on_activate = self:callback('increaseLiquidLevel'), disabled = function() return self.level == 7 end }, + widgets.Label{ + frame = { l = 19, b = 1, w = 1}, + text_pen=COLOR_LIGHTGREEN, + text=string.char(26), + }, widgets.CycleHotkeyLabel{ frame = {l = 0, b = 2}, label = 'Brush:', @@ -153,39 +163,43 @@ function SpawnLiquid:decreaseLiquidLevel() self.level = math.max(self.level - 1, 1) end +local function isFlowPassable(pos) + local tt = dfhack.maps.getTileType(pos) + local tile = dfhack.maps.getTileFlags(pos) + return tt and tile and df.tiletype_shape.attrs[df.tiletype.attrs[tt].shape].passable_flow +end + function SpawnLiquid:spawn(pos) - if dfhack.maps.isValidTilePos(pos) and dfhack.maps.isTileVisible(pos) then - local map_block = dfhack.maps.getTileBlock(pos) - - if self.mode == SpawnLiquidMode.CLEAN then - local tile = dfhack.maps.getTileFlags(pos) - - tile.water_salt = false - tile.water_stagnant = false - elseif self.type == df.tiletype.RiverSource then - if self.mode == SpawnLiquidMode.REMOVE then - local commands = { - 'f', 'any', ';', - 'f', 'sp', 'river_source', ';', - 'p', 'any', ';', - 'p', 's', 'floor', ';', - 'p', 'sp', 'normal', ';', - 'p', 'm', 'stone', ';', - } - dfhack.run_command('tiletypes-command', table.unpack(commands)) - dfhack.run_command('tiletypes-here', '--quiet', ('--cursor=%d,%d,%d'):format(pos2xyz(pos))) - liquids.spawnLiquid(pos, 0, df.tile_liquid.Water) - else - map_block.tiletype[pos.x % 16][pos.y % 16] = df.tiletype.RiverSource - liquids.spawnLiquid(pos, 7, df.tile_liquid.Water) - end + if not dfhack.maps.isValidTilePos(pos) or not dfhack.maps.isTileVisible(pos) or not isFlowPassable(pos) then + return + end + + local map_block = dfhack.maps.getTileBlock(pos) + + if self.mode == SpawnLiquidMode.CLEAN then + local tile = dfhack.maps.getTileFlags(pos) + + tile.water_salt = false + tile.water_stagnant = false + elseif self.type == df.tiletype.RiverSource then + if self.mode == SpawnLiquidMode.REMOVE then + local commands = { + 'f', 'any', ';', + 'f', 'sp', 'river_source', ';', + 'p', 'any', ';', + 'p', 's', 'floor', ';', + 'p', 'sp', 'normal', ';', + 'p', 'm', 'stone', ';', + } + dfhack.run_command('tiletypes-command', table.unpack(commands)) + dfhack.run_command('tiletypes-here', '--quiet', ('--cursor=%d,%d,%d'):format(pos2xyz(pos))) + liquids.spawnLiquid(pos, 0, df.tile_liquid.Water) else - liquids.spawnLiquid(pos, self:getLiquidLevel(pos), self.type) + map_block.tiletype[pos.x % 16][pos.y % 16] = df.tiletype.RiverSource + liquids.spawnLiquid(pos, 7, df.tile_liquid.Water) end - - -- Regardless of spawning or removing liquids, we need to reindex to - -- ensure pathability is up to date. - df.global.world.reindex_pathfinding = true + else + liquids.spawnLiquid(pos, self:getLiquidLevel(pos), self.type) end end diff --git a/gui/manager-quantity.lua b/gui/manager-quantity.lua deleted file mode 100644 index decb9a7e54..0000000000 --- a/gui/manager-quantity.lua +++ /dev/null @@ -1,49 +0,0 @@ --- Sets the quantity of the selected manager job ---[====[ - -gui/manager-quantity -==================== - -Sets the quantity of the selected manager job (in the j-m or u-m screens). - -]====] - -local dialog = require 'gui.dialogs' -local args = {...} - -function show_error(text) - dialog.showMessage("Error", text, COLOR_LIGHTRED) -end - -local scr = dfhack.gui.getCurViewscreen() -if df.viewscreen_jobmanagementst:is_instance(scr) then - local orders = df.global.world.manager_orders - function set_quantity(value) - if tonumber(value) then - value = tonumber(value) - local i = scr.sel_idx - local old_total = orders[i].amount_total - orders[i].amount_total = math.max(0, value) - orders[i].amount_left = math.max(0, orders[i].amount_left + (value - old_total)) - else - show_error(value .. " is not a number!") - end - end - if scr.sel_idx < #orders then - if #args >= 1 then - set_quantity(args[1]) - else - dialog.showInputPrompt( - "Quantity", - "Quantity:", - COLOR_WHITE, - '', - set_quantity - ) - end - else - show_error("Invalid order selected") - end -else - dfhack.printerr('Must be called on the manager screen (j-m or u-m)') -end diff --git a/gui/manipulator.lua b/gui/manipulator.lua new file mode 100644 index 0000000000..82c699ef16 --- /dev/null +++ b/gui/manipulator.lua @@ -0,0 +1,1358 @@ +--@module = true + +local gui = require("gui") +local json = require('json') +local overlay = require('plugins.overlay') +local presets = reqscript('internal/manipulator/presets') +local textures = require('gui.textures') +local utils = require('utils') +local widgets = require("gui.widgets") + +------------------------ +-- persistent state +-- + +local GLOBAL_KEY = 'manipulator' +local CONFIG_FILE = 'dfhack-config/manipulator.json' + +-- persistent player (global) state schema +local function get_default_config() + return { + tags={}, + presets={}, + } +end + +-- persistent per-fort state schema +local function get_default_state() + return { + favorites={}, + tagged={}, + } +end + +-- preset schema +local function get_default_preset() + return { + cols={}, + pinned={}, + } +end + +local function get_config() + local data = get_default_config() + local cfg = json.open(CONFIG_FILE) + utils.assign(data, cfg.data) + cfg.data = data + return cfg +end + +config = config or get_config() +state = state or get_default_state() +preset = preset or get_default_preset() + +local function persist_state() + dfhack.persistent.saveSiteData(GLOBAL_KEY, state) +end + +dfhack.onStateChange[GLOBAL_KEY] = function(sc) + if sc == SC_MAP_UNLOADED then + state = get_default_state() + 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)) +end + +------------------------ +-- ColumnMenu +-- + +ColumnMenu = defclass(ColumnMenu, widgets.Panel) +ColumnMenu.ATTRS{ + frame_style=gui.FRAME_INTERIOR, + frame_background=gui.CLEAR_PEN, + visible=false, + col=DEFAULT_NIL, +} + +function ColumnMenu:init() + local choices = {} + + table.insert(choices, { + text='Sort', + fn=self.col:callback('sort', true), + }) + table.insert(choices, { + text='Hide column', + fn=self.col:callback('hide_column'), + }) + table.insert(choices, { + text='Hide group', + fn=self.col:callback('hide_group'), + }) + if self.col.zoom_fn then + table.insert(choices, { + text='Zoom to', + fn=self.col.zoom_fn, + }) + end + + self:addviews{ + widgets.List{ + choices=choices, + on_submit=function(_, choice) + choice.fn() + self:hide() + end, + }, + } +end + +function ColumnMenu:show() + self.prev_focus_owner = self.focus_group.cur + self.visible = true + self:setFocus(true) +end + +function ColumnMenu:hide() + self.visible = false + if self.prev_focus_owner then + self.prev_focus_owner:setFocus(true) + end +end + +function ColumnMenu:onInput(keys) + if ColumnMenu.super.onInput(self, keys) then + return true + end + if keys._MOUSE_R then + self:hide() + elseif keys._MOUSE_L and not self:getMouseFramePos() then + self:hide() + end + return true +end + +------------------------ +-- Column +-- + +local DEFAULT_DATA_WIDTH = 4 +local DEFAULT_COL_OVERSCAN = 14 + +Column = defclass(Column, widgets.Panel) +Column.ATTRS{ + label=DEFAULT_NIL, + group='', + label_inset=0, + data_width=DEFAULT_DATA_WIDTH, + hidden=DEFAULT_NIL, + shared=DEFAULT_NIL, + data_fn=DEFAULT_NIL, + count_fn=DEFAULT_NIL, + cmp_fn=DEFAULT_NIL, + choice_fn=DEFAULT_NIL, + zoom_fn=DEFAULT_NIL, +} + +local CH_DOT = string.char(15) +local CH_UP = string.char(30) +local CH_DN = string.char(31) + +function Column:init() + self.frame = utils.assign({t=0, b=0, l=0, w=DEFAULT_COL_OVERSCAN}, self.frame or {}) + + local function show_menu() + self.subviews.col_menu:show() + end + + self:addviews{ + widgets.TextButton{ + view_id='col_group', + frame={t=0, l=0, h=1, w=#self.group+2}, + label=self.group, + visible=#self.group > 0, + }, + widgets.Label{ + view_id='col_current', + frame={t=7, l=1+self.label_inset, w=4}, + auto_height=false, + }, + widgets.Label{ + view_id='col_total', + frame={t=8, l=1+self.label_inset, w=4}, + auto_height=false, + }, + widgets.List{ + view_id='col_list', + frame={t=10, l=0, w=self.data_width+2}, -- +2 for the invisible scrollbar + on_submit=self:callback('on_select'), + }, + widgets.Panel{ + frame={t=2, l=0, h=10}, + subviews={ + widgets.Divider{ + view_id='col_stem', + frame={l=self.label_inset, t=4, w=1, h=1}, + frame_style=gui.FRAME_INTERIOR, + frame_style_b=false, + }, + widgets.Panel{ + view_id='col_label', + frame={l=self.label_inset, t=4}, + subviews={ + widgets.HotkeyLabel{ + frame={l=0, t=0, w=1}, + label=CH_DN, + text_pen=COLOR_LIGHTGREEN, + on_activate=show_menu, + visible=function() + local sort_spec = self.shared.sort_stack[#self.shared.sort_stack] + return sort_spec.col == self and not sort_spec.rev + end, + }, + widgets.HotkeyLabel{ + frame={l=0, t=0, w=1}, + label=CH_UP, + text_pen=COLOR_LIGHTGREEN, + on_activate=show_menu, + visible=function() + local sort_spec = self.shared.sort_stack[#self.shared.sort_stack] + return sort_spec.col == self and sort_spec.rev + end, + }, + widgets.HotkeyLabel{ + frame={l=0, t=0, w=1}, + label=CH_DOT, + text_pen=COLOR_GRAY, + on_activate=show_menu, + visible=function() + local sort_spec = self.shared.sort_stack[#self.shared.sort_stack] + return sort_spec.col ~= self + end, + }, + widgets.HotkeyLabel{ + frame={l=1, t=0}, + label=self.label, + on_activate=self:callback('on_click'), + }, + ColumnMenu{ + view_id='col_menu', + frame={l=0, t=1, h=self.zoom_fn and 6 or 5}, + col=self, + }, + }, + }, + }, + }, + } + + self.subviews.col_list.scrollbar.visible = false + self.col_data = {} + self.dirty = true +end + +-- extended by subclasses +function Column:on_select(idx, choice) + -- conveniently, this will be nil for the namelist column itself, + -- avoiding an infinite loop + local namelist = self.parent_view.parent_view.namelist + if namelist then + self.shared.set_cursor_col_fn(self) + namelist:setSelected(idx) + end +end + +function Column:hide_column() + self.hidden = true + self.shared.refresh_headers = true +end + +function Column:unhide_column() + self.hidden = false + self.shared.refresh_headers = true +end + +function Column:hide_group() + for _,col in ipairs(self.parent_view.subviews) do + if col.group == self.group then + col.hidden = true + end + end + self.shared.refresh_headers = true +end + +function Column:on_click() + local modifiers = dfhack.internal.getModifiers() + if modifiers.shift then + self:hide_group() + elseif modifiers.ctrl then + self:hide_column() + else + self:sort(true) + end +end + +function Column:sort(make_primary) + local sort_stack = self.shared.sort_stack + if make_primary then + -- we are newly sorting by this column: reverse sort if we're already on top of the + -- stack; otherwise put us on top of the stack + local top = sort_stack[#sort_stack] + if top.col == self then + top.rev = not top.rev + else + for idx,sort_spec in ipairs(sort_stack) do + if sort_spec.col == self then + table.remove(sort_stack, idx) + break + end + end + table.insert(sort_stack, {col=self, rev=false}) + end + end + for _,sort_spec in ipairs(sort_stack) do + local col = sort_spec.col + if col.dirty then + col:refresh() + end + end + local compare = function(a, b) + for idx=#sort_stack,1,-1 do + local sort_spec = sort_stack[idx] + local col = sort_spec.col + local first, second + if sort_spec.rev then + first, second = col.col_data[b], col.col_data[a] + else + first, second = col.col_data[a], col.col_data[b] + end + if first == second then goto continue end + if not first then return 1 end + if not second then return -1 end + local ret = (col.cmp_fn or utils.compare)(first, second) + if ret ~= 0 then return ret end + ::continue:: + end + return 0 + end + local order = utils.tabulate(function(i) return i end, 1, #self.shared.filtered_unit_ids) + local spec = {compare=compare} + self.shared.sort_order = utils.make_sort_order(order, {spec}) +end + +function Column:get_units() + if self.shared.cache.units then return self.shared.cache.units end + local units = {} + for _, unit_id in ipairs(self.shared.unit_ids) do + local unit = df.unit.find(unit_id) + if unit then + table.insert(units, unit) + else + self.shared.fault = true + end + end + self.shared.cache.units = units + return units +end + +function Column:get_sorted_unit_id(idx) + return self.shared.filtered_unit_ids[self.shared.sort_order[idx]] +end + +function Column:get_sorted_data(idx) + return self.col_data[self.shared.sort_order[idx]] +end + +function Column:refresh() + local col_data, choices = {}, {} + local current, total = 0, 0 + local next_id_idx = 1 + for _, unit in ipairs(self:get_units()) do + local data = self.data_fn(unit) + local val = self.count_fn(data) + if unit.id == self.shared.filtered_unit_ids[next_id_idx] then + local idx = next_id_idx + table.insert(col_data, data) + table.insert(choices, self.choice_fn(function() return self:get_sorted_data(idx) end)) + current = current + val + next_id_idx = next_id_idx + 1 + end + total = total + val + end + + self.col_data = col_data + self.subviews.col_current:setText(tostring(current)) + self.subviews.col_total:setText(tostring(total)) + self.subviews.col_list:setChoices(choices) + + self.dirty = false +end + +function Column:render(dc) + if self.dirty then + self:refresh() + end + Column.super.render(self, dc) +end + +function Column:set_stem_height(h) + self.subviews.col_label.frame.t = 4 - h + self.subviews.col_stem.frame.t = 4 - h + self.subviews.col_stem.frame.h = h + 1 +end + +------------------------ +-- DataColumn +-- + +local function data_cmp(a, b) + if type(a) == 'number' then return -utils.compare(a, b) end + return utils.compare(a, b) +end + +local function data_count(data) + if not data then return 0 end + if type(data) == 'number' then return data > 0 and 1 or 0 end + return 1 +end + +local function data_choice(get_ordered_data_fn) + return { + text={ + { + text=function() + local ordered_data = get_ordered_data_fn() + return (not ordered_data or ordered_data == 0) and '-' or tostring(ordered_data) + end, + }, + }, + } +end + +DataColumn = defclass(DataColumn, Column) +DataColumn.ATTRS{ + cmp_fn=data_cmp, + count_fn=data_count, + choice_fn=data_choice, +} + +------------------------ +-- ToggleColumn +-- + +local ENABLED_PEN_LEFT = dfhack.pen.parse{fg=COLOR_CYAN, + tile=curry(textures.tp_control_panel, 1), ch=string.byte('[')} +local ENABLED_PEN_CENTER = dfhack.pen.parse{fg=COLOR_LIGHTGREEN, + tile=curry(textures.tp_control_panel, 2) or nil, ch=251} -- check +local ENABLED_PEN_RIGHT = dfhack.pen.parse{fg=COLOR_CYAN, + tile=curry(textures.tp_control_panel, 3) or nil, ch=string.byte(']')} +local DISABLED_PEN_LEFT = dfhack.pen.parse{fg=COLOR_CYAN, + tile=curry(textures.tp_control_panel, 4) or nil, ch=string.byte('[')} +local DISABLED_PEN_CENTER = dfhack.pen.parse{fg=COLOR_RED, + tile=curry(textures.tp_control_panel, 5) or nil, ch=string.byte('x')} +local DISABLED_PEN_RIGHT = dfhack.pen.parse{fg=COLOR_CYAN, + tile=curry(textures.tp_control_panel, 6) or nil, ch=string.byte(']')} + +local function toggle_count(data) + return data and 1 or 0 +end + +local function toggle_choice(get_ordered_data_fn) + local function get_enabled_button_token(enabled_tile, disabled_tile) + return { + tile=function() return get_ordered_data_fn() and enabled_tile or disabled_tile end, + } + end + return { + text={ + get_enabled_button_token(ENABLED_PEN_LEFT, DISABLED_PEN_LEFT), + get_enabled_button_token(ENABLED_PEN_CENTER, DISABLED_PEN_CENTER), + get_enabled_button_token(ENABLED_PEN_RIGHT, DISABLED_PEN_RIGHT), + ' ', + }, + } +end + +local function toggle_sorted_vec_data(vec, unit) + return utils.binsearch(vec, unit.id) and true or false +end + +local function toggle_sorted_vec(vec, unit_id, prev_val) + if prev_val then + utils.erase_sorted(vec, unit_id) + else + utils.insert_sorted(vec, unit_id) + end +end + +ToggleColumn = defclass(ToggleColumn, Column) +ToggleColumn.ATTRS{ + count_fn=toggle_count, + choice_fn=toggle_choice, + toggle_fn=DEFAULT_NIL, +} + +function ToggleColumn:on_select(idx, choice) + ToggleColumn.super.on_select(self, idx, choice) + if not self.toggle_fn then return end + local unit_id = self:get_sorted_unit_id(idx) + local prev_val = self:get_sorted_data(idx) + self.toggle_fn(unit_id, prev_val) + self.dirty = true +end + +------------------------ +-- Cols +-- + +Cols = defclass(Cols, widgets.Panel) + +function Cols:renderSubviews(dc) + -- allow labels of columns to the left to overwrite the stems of columns on the right + for idx=#self.subviews,1,-1 do + local child = self.subviews[idx] + if utils.getval(child.visible) then + child:render(dc) + end + end + -- but group labels and popup menus on the right should overwrite long group names on the left + for _,child in ipairs(self.subviews) do + if utils.getval(child.visible) then + if utils.getval(child.subviews.col_group.visible) then + child.subviews.col_group:render(dc) + end + if utils.getval(child.subviews.col_menu.visible) then + child:render(dc) + end + end + end +end + +------------------------ +-- Spreadsheet +-- + +Spreadsheet = defclass(Spreadsheet, widgets.Panel) +Spreadsheet.ATTRS{ + get_units_fn=DEFAULT_NIL, +} + +local function get_workshop_label(workshop, type_enum, bld_defs) + if #workshop.name > 0 then + return workshop.name + end + local type_name = type_enum[workshop.type] + if type_name == 'Custom' then + local bld_def = bld_defs[workshop.custom_type] + if bld_def then return bld_def.code end + end + return type_name +end + +function Spreadsheet:init() + self.left_col = 1 + self.prev_filter = '' + + self.shared = { + unit_ids={}, + filtered_unit_ids={}, + sort_stack={}, + sort_order={}, -- list of indices into filtered_unit_ids (or cache.filtered_units) + cache={}, -- cached pointers; reset at end of frame + cursor_col=nil, + set_cursor_col_fn=self:callback('set_cursor_col'), + refresh_units=true, + refresh_headers=true, + } + + local cols = Cols{} + self.cols = cols + + cols:addviews{ + ToggleColumn{ + view_id='favorites', + group='tags', + label='Favorites', + shared=self.shared, + data_fn=curry(toggle_sorted_vec_data, state.favorites), + toggle_fn=function(unit_id, prev_val) + toggle_sorted_vec(state.favorites, unit_id, prev_val) + persist_state() + end, + }, + DataColumn{ + group='summary', + label='Stress', + shared=self.shared, + data_fn=function(unit) return unit.status.current_soul.personality.stress end, + choice_fn=function(get_ordered_data_fn) + return { + text={ + { + text=function() + local ordered_data = get_ordered_data_fn() + if ordered_data > 99999 then + return '>99k' + elseif ordered_data > 9999 then + return ('%3dk'):format(ordered_data // 1000) + elseif ordered_data < -99999 then + return ' -' .. string.char(236) -- -∞ + elseif ordered_data < -999 then + return ('%3dk'):format(-(-ordered_data // 1000)) + end + return ('%4d'):format(ordered_data) + end, + pen=function() + local ordered_data = get_ordered_data_fn() + local level = dfhack.units.getStressCategoryRaw(ordered_data) + local is_graphics = dfhack.screen.inGraphicsMode() + -- match colors of stress faces depending on mode + if level == 0 then return COLOR_RED end + if level == 1 then return COLOR_LIGHTRED end + if level == 2 then return is_graphics and COLOR_BROWN or COLOR_YELLOW end + if level == 3 then return is_graphics and COLOR_YELLOW or COLOR_WHITE end + if level == 4 then return is_graphics and COLOR_CYAN or COLOR_GREEN end + if level == 5 then return is_graphics and COLOR_GREEN or COLOR_LIGHTGREEN end + return is_graphics and COLOR_LIGHTGREEN or COLOR_LIGHTCYAN + end, + }, + }, + } + end, + } + } + + for i in ipairs(df.job_skill) do + local caption = df.job_skill.attrs[i].caption + if caption then + cols:addviews{ + DataColumn{ + group='skills', + label=caption, + shared=self.shared, + data_fn=function(unit) + return (utils.binsearch(unit.status.current_soul.skills, i, 'id') or {rating=0}).rating + end, + } + } + end + end + + local work_details = df.global.plotinfo.labor_info.work_details + for _, wd in ipairs(work_details) do + cols:addviews{ + ToggleColumn{ + group='work details', + label=wd.name, + shared=self.shared, + data_fn=curry(toggle_sorted_vec_data, wd.assigned_units), + toggle_fn=function(unit_id, prev_val) + toggle_sorted_vec(wd.assigned_units, unit_id, prev_val) + local unit = df.unit.find(unit_id) + if unit then dfhack.units.setAutomaticProfessions(unit) end + end, + } + } + end + + local function add_workshops(vec, type_enum, type_defs) + for _, bld in ipairs(vec) do + cols:addviews{ + ToggleColumn{ + group='workshops', + label=get_workshop_label(bld, type_enum, type_defs), + shared=self.shared, + data_fn=curry(toggle_sorted_vec_data, bld.profile.permitted_workers), + toggle_fn=function(unit_id, prev_val) + if not prev_val then + -- there can be only one + bld.profile.permitted_workers:resize(0) + end + toggle_sorted_vec(bld.profile.permitted_workers, unit_id, prev_val) + end, + zoom_fn=function() + dfhack.gui.revealInDwarfmodeMap( + xyz2pos(bld.centerx, bld.centery, bld.z), true, true) + end, + } + } + end + end + add_workshops(df.global.world.buildings.other.FURNACE_ANY, df.furnace_type, df.global.world.raws.buildings.furnaces) + add_workshops(df.global.world.buildings.other.WORKSHOP_ANY, df.workshop_type, df.global.world.raws.buildings.workshops) + + self:addviews{ + widgets.TextButton{ + view_id='left_group', + frame={t=1, l=0, h=1}, + key='CUSTOM_CTRL_Y', + visible=false, + }, + widgets.TextButton{ + view_id='right_group', + frame={t=1, r=0, h=1}, + key='CUSTOM_CTRL_T', + visible=false, + }, + widgets.Label{ + frame={t=7, l=0}, + text='Shown:', + }, + widgets.Label{ + frame={t=8, l=0}, + text='Total:', + }, + DataColumn{ + view_id='name', + frame={w=45}, + label='Name', + label_inset=8, + data_fn=dfhack.units.getReadableName, + data_width=45, + shared=self.shared, + }, + cols, + } + + -- teach each column about its relative position so we can track cursor movement + for idx, col in ipairs(cols) do + col.idx = idx + end + + -- set up initial sort: primary favorites, secondary name + self.shared.sort_stack[1] = {col=self.subviews.name, rev=false} + self.shared.sort_stack[2] = {col=self.subviews.favorites, rev=false} + + -- set initial keyboard cursor position + self:set_cursor_col(self.subviews.favorites) + + self.namelist = self.subviews.name.subviews.col_list + self:addviews{ + widgets.Scrollbar{ + view_id='scrollbar', + frame={t=7, r=0}, + on_scroll=self.namelist:callback('on_scrollbar'), + } + } + self.namelist.scrollbar = self.subviews.scrollbar + self.namelist:setFocus(true) +end + +local CURSOR_PEN = dfhack.pen.parse{fg=COLOR_GREY, bg=COLOR_CYAN} + +function Spreadsheet:set_cursor_col(col) + if self.shared.cursor_col then + self.shared.cursor_col.subviews.col_list.cursor_pen = COLOR_LIGHTCYAN + end + self.shared.cursor_col = col + col.subviews.col_list.cursor_pen = CURSOR_PEN +end + +function Spreadsheet:zoom_to_unit() + local idx = self.namelist:getSelected() + if not idx then return end + local unit = df.unit.find(self.subviews.name:get_sorted_unit_id(idx)) + if not unit then return end + dfhack.gui.revealInDwarfmodeMap( + xyz2pos(dfhack.units.getPosition(unit)), true, true) +end + +function Spreadsheet:zoom_to_col_source() + if not self.shared.cursor_col or not self.shared.cursor_col.zoom_fn then return end + self.shared.cursor_col.zoom_fn() +end + +function Spreadsheet:sort_by_current_col() + if not self.shared.cursor_col then return end + self.shared.cursor_col:sort(true) +end + +function Spreadsheet:hide_current_col() + if not self.shared.cursor_col then return end + self.shared.cursor_col:hide_column() +end + +function Spreadsheet:hide_current_col_group() + if not self.shared.cursor_col then return end + self.shared.cursor_col:hide_group() +end + +-- utf8-ize and, if needed, quote and escape +local function make_csv_cell(fmt, ...) + local str = fmt:format(...) + str = dfhack.df2utf(str) + if str:find('[,"]') then + str = str:gsub('"', '""') + str = ('"%s"'):format(str) + end + return str +end + +-- exports visible data, in the current sort, to a .csv file +function Spreadsheet:export() + local file = io.open('manipulator.csv', 'a+') + if not file then + dfhack.printerr('could not open export file: manipulator.csv') + return + end + file:write(make_csv_cell('%s,', self.subviews.name.label)) + for _, col in ipairs(self.cols.subviews) do + if col.hidden then goto continue end + file:write(make_csv_cell('%s/%s,', col.group, col.label)) + ::continue:: + end + file:write(NEWLINE) + for row=1,#self.shared.filtered_unit_ids do + file:write(make_csv_cell('%s', self.subviews.name:get_sorted_data(row))) + file:write(',') + for _, col in ipairs(self.cols.subviews) do + if col.hidden then goto continue end + file:write(make_csv_cell('%s,', col:get_sorted_data(row) or '')) + ::continue:: + end + file:write(NEWLINE) + end + file:close() +end + +function Spreadsheet:jump_to_group(group) + for i, col in ipairs(self.cols.subviews) do + if not col.hidden and col.group == group then + self:jump_to_col(i) + break + end + end +end + +function Spreadsheet:jump_to_col(idx) + idx = math.min(idx, #self.cols.subviews) + idx = math.max(idx, 1) + self.left_col = idx + if self.cols.subviews[idx].hidden then + local found = false + for shifted_idx=self.left_col-1,1,-1 do + if not self.cols.subviews[shifted_idx].hidden then + self.left_col = shifted_idx + found = true + break + end + end + if not found then + for shifted_idx=self.left_col+1,#self.cols.subviews do + if not self.cols.subviews[shifted_idx].hidden then + self.left_col = shifted_idx + break + end + end + end + end + self:updateLayout() +end + +function Spreadsheet:update_headers() + local ord = 1 + for _, col in ipairs(self.cols.subviews) do + if not col.hidden then + col:set_stem_height((5-ord)%5) + ord = ord + 1 + end + end + self.shared.refresh_headers = false +end + +-- TODO: support column addressing for searching/filtering (e.g. "skills/Armoring:>10") +function Spreadsheet:refresh(filter, full_refresh) + local shared = self.shared + shared.fault = false + self.subviews.name.dirty = true + for _, col in ipairs(self.cols.subviews) do + col.dirty = true + end + local incremental = not full_refresh and self.prev_filter and filter:startswith(self.prev_filter) + if not incremental then + local units = self.get_units_fn() + shared.cache.units = units + shared.unit_ids = utils.tabulate(function(idx) return units[idx].id end, 1, #units) + end + shared.filtered_unit_ids = copyall(shared.unit_ids) + if #filter > 0 then + local col = self.subviews.name + col:refresh() + for idx=#col.col_data,1,-1 do + local data = col.col_data[idx] + if (not utils.search_text(data, filter)) then + table.remove(shared.filtered_unit_ids, idx) + end + end + if #col.col_data ~= #shared.filtered_unit_ids then + col.dirty = true + end + end + shared.sort_stack[#shared.sort_stack].col:sort() + self.shared.refresh_units = false +end + +function Spreadsheet:update_col_layout(idx, col, width, group, max_width) + col.visible = not col.hidden and idx >= self.left_col and width + col.frame.w <= max_width + col.frame.l = width + if not col.visible then + return width, group + end + local col_group = col.subviews.col_group + col_group.label.on_activate=self:callback('jump_to_group', col.group) + col_group.visible = group ~= col.group + return width + col.data_width + 1, col.group +end + +function Spreadsheet:preUpdateLayout(parent_rect) + local left_group, right_group = self.subviews.left_group, self.subviews.right_group + left_group.visible, right_group.visible = false, false + + local width, group, cur_col_group = self.subviews.name.data_width + 1, '', '' + local prev_col_group, next_col_group + for idx, col in ipairs(self.cols.subviews) do + local prev_group = group + width, group = self:update_col_layout(idx, col, width, group, parent_rect.width) + if col.hidden then goto continue end + if not next_col_group and group ~= '' and not col.visible and col.group ~= cur_col_group then + next_col_group = col.group + local str = next_col_group .. string.char(26) -- right arrow + right_group.frame.w = #str + 10 + right_group.label.on_activate=self:callback('jump_to_group', next_col_group) + right_group.visible = true + right_group:setLabel(str) + end + if cur_col_group ~= col.group then + prev_col_group = cur_col_group + end + cur_col_group = col.group + if prev_group == '' and group ~= '' and prev_col_group and prev_col_group ~= '' then + local str = string.char(27) .. prev_col_group -- left arrow + left_group.frame.w = #str + 10 + left_group.label.on_activate=self:callback('jump_to_group', prev_col_group) + left_group.visible = true + left_group:setLabel(str) + end + ::continue:: + end + self.shared.layout_changed = false +end + +function Spreadsheet:render(dc) + if self.shared.refresh_headers or self.shared.refresh_units then + if self.shared.refresh_units then + self:refresh(self.prev_filter, true) + end + if self.shared.refresh_headers then + self:update_headers() + end + if self.cols.subviews[self.left_col].hidden then + self:jump_to_col(self.left_col) + else + self:updateLayout() + end + end + local page_top = self.namelist.page_top + local selected = self.namelist:getSelected() + for _, col in ipairs(self.cols.subviews) do + col.subviews.col_list.page_top = page_top + col.subviews.col_list:setSelected(selected) + end + Spreadsheet.super.render(self, dc) + self.shared.cache = {} +end + +function Spreadsheet:get_num_visible_cols() + local rect = self.frame_rect + if not rect then return 1 end + local other_width = self.subviews.name.data_width + (DEFAULT_COL_OVERSCAN - DEFAULT_DATA_WIDTH) + local width = rect.width - other_width + return width // (DEFAULT_DATA_WIDTH + 1) +end + +function Spreadsheet:onInput(keys) + if keys.KEYBOARD_CURSOR_LEFT then + self:jump_to_col(self.left_col-1) + elseif keys.KEYBOARD_CURSOR_LEFT_FAST then + local remaining = self:get_num_visible_cols() + local target_col = self.left_col + for idx=self.left_col-1,1,-1 do + if not self.cols.subviews[idx].hidden then + remaining = remaining - 1 + target_col = idx + if remaining == 0 then + break + end + end + end + self:jump_to_col(target_col) + elseif keys.KEYBOARD_CURSOR_RIGHT then + for idx=self.left_col+1,#self.cols.subviews do + if not self.cols.subviews[idx].hidden then + self:jump_to_col(idx) + break + end + end + elseif keys.KEYBOARD_CURSOR_RIGHT_FAST then + local remaining = self:get_num_visible_cols() + local target_col = self.left_col + for idx=self.left_col+1,#self.cols.subviews do + if not self.cols.subviews[idx].hidden then + remaining = remaining - 1 + target_col = idx + if remaining == 0 then + break + end + end + end + self:jump_to_col(target_col) + end + return Spreadsheet.super.onInput(self, keys) +end + +------------------------ +-- QuickMenu +-- + +QuickMenu = defclass(QuickMenu, widgets.Panel) +QuickMenu.ATTRS{ + frame_style=gui.FRAME_INTERIOR, + frame_background=gui.CLEAR_PEN, + visible=false, + multiselect=false, + label=DEFAULT_NIL, + choices_fn=DEFAULT_NIL, +} + +function QuickMenu:init() + self:addviews{ + widgets.Label{ + frame={t=0, l=0}, + text=self.label, + }, + widgets.FilteredList{ + view_id='list', + frame={t=2, l=0, b=self.multiselect and 3 or 0}, + on_submit=function(_, choice) + choice.fn() + self:hide() + end, + on_submit2=self.multiselect and function(_, choice) + choice.fn() + local list = self.subviews.list + local filter = list:getFilter() + list:setChoices(self.choices_fn(), list:getSelected()) + list:setFilter(filter) + end or nil, + }, + widgets.Label{ + frame={b=1, l=0}, + text='Shift click to select multiple.', + visible=self.multiselect, + }, + widgets.HotkeyLabel{ + frame={b=0, l=0}, + key='CUSTOM_CTRL_N', + label='Select all', + visible=self.multiselect, + on_activate=function() + local list = self.subviews.list + for _,choice in ipairs(list:getVisibleChoices()) do + choice.fn() + end + local filter = list:getFilter() + list:setChoices(self.choices_fn(), list:getSelected()) + list:setFilter(filter) + end, + }, + } +end + +function QuickMenu:show() + self.prev_focus_owner = self.focus_group.cur + self.visible = true + local list = self.subviews.list + list.edit:setText('') + list.edit:setFocus(true) + list:setChoices(self.choices_fn()) +end + +function QuickMenu:hide() + self.visible = false + if self.prev_focus_owner then + self.prev_focus_owner:setFocus(true) + end +end + +function QuickMenu:onInput(keys) + if ColumnMenu.super.onInput(self, keys) then + return true + end + if keys._MOUSE_R then + self:hide() + elseif keys._MOUSE_L and not self:getMouseFramePos() then + self:hide() + end + return true +end + +------------------------ +-- Manipulator +-- + +local REFRESH_MS = 1000 + +Manipulator = defclass(Manipulator, widgets.Window) +Manipulator.ATTRS{ + frame_title='Unit Overview and Manipulator', + frame={w=110, h=40}, + frame_inset={t=1, l=1, r=1, b=0}, + resizable=true, + resize_min={w=70, h=30}, +} + +function Manipulator:init() + if dfhack.world.isFortressMode() then + self.get_units_fn = dfhack.units.getCitizens + elseif dfhack.world.isAdventureMode() then + self.get_units_fn = qerror('get party members') + else + self.get_units_fn = function() return utils.clone(df.global.world.units.active) end + end + + self.needs_refresh, self.prev_unit_count, self.prev_last_unit_id = false, 0, -1 + self:update_needs_refresh(true) + + self:addviews{ + widgets.EditField{ + view_id='search', + frame={l=0, t=0}, + key='FILTER', + label_text='Search: ', + on_change=function(text) self.subviews.sheet:refresh(text, false) end, + on_unfocus=function() self.subviews.sheet.namelist:setFocus(true) end, + }, + widgets.Divider{ + frame={l=0, r=0, t=2, h=1}, + frame_style=gui.FRAME_INTERIOR, + frame_style_l=false, + frame_style_r=false, + }, + Spreadsheet{ + view_id='sheet', + frame={l=0, t=3, r=0, b=7}, + get_units_fn=self.get_units_fn, + }, + widgets.Divider{ + frame={l=0, r=0, b=6, h=1}, + frame_style=gui.FRAME_INTERIOR, + frame_style_l=false, + frame_style_r=false, + }, + widgets.Panel{ + frame={l=0, r=0, b=0, h=5}, + subviews={ + widgets.WrappedLabel{ + frame={t=0, l=0}, + text_to_wrap='Use arrow keys or middle click drag to navigate cells. Left click or ENTER to toggle cell.', + }, + widgets.HotkeyLabel{ + frame={b=2, l=0}, + auto_width=true, + label='Sort/reverse sort', + key='CUSTOM_SHIFT_S', + on_activate=function() self.subviews.sheet:sort_by_current_col() end, + }, + widgets.HotkeyLabel{ + frame={b=2, l=22}, + auto_width=true, + label='Jump to column', + key='CUSTOM_CTRL_G', + on_activate=function() self.subviews.quick_jump_menu:show() end, + }, + widgets.HotkeyLabel{ + frame={b=2, l=46}, + auto_width=true, + label='Export to csv', + key='CUSTOM_SHIFT_E', + on_activate=function() self.subviews.sheet:export() end, + }, + widgets.HotkeyLabel{ + frame={b=1, l=0}, + auto_width=true, + label='Hide column', + key='CUSTOM_SHIFT_H', + on_activate=function() self.subviews.sheet:hide_current_col() end, + }, + widgets.HotkeyLabel{ + frame={b=1, l=22}, + auto_width=true, + label='Hide group', + key='CUSTOM_CTRL_H', + on_activate=function() self.subviews.sheet:hide_current_col_group() end, + }, + widgets.HotkeyLabel{ + frame={b=1, l=46}, + auto_width=true, + label='Unhide column', + key='CUSTOM_SHIFT_U', + on_activate=function() self.subviews.unhide_menu:show() end, + }, + widgets.HotkeyLabel{ + frame={b=0, l=0}, + auto_width=true, + label='Zoom to unit', + key='CUSTOM_SHIFT_Z', + on_activate=function() self.subviews.sheet:zoom_to_unit() end, + }, + widgets.HotkeyLabel{ + frame={b=0, l=22}, + auto_width=true, + label='Zoom to source', + key='CUSTOM_CTRL_Z', + on_activate=function() self.subviews.sheet:zoom_to_col_source() end, + }, + widgets.HotkeyLabel{ + frame={b=0, l=46}, + auto_width=true, + label=function() + return self.needs_refresh and 'Refresh units (new units have arrived)' or 'Refresh units' + end, + text_pen=function() + return self.needs_refresh and COLOR_LIGHTRED or COLOR_WHITE + end, + key='CUSTOM_SHIFT_R', + on_activate=function() + self.subviews.sheet:refresh(self.subviews.search.text, true) + end, + }, + }, + }, + QuickMenu{ + view_id='quick_jump_menu', + frame={b=0, w=35, h=25}, + label='Jump to column:', + choices_fn=function() + local choices = {} + for idx,col in ipairs(self.subviews.sheet.cols.subviews) do + if col.hidden then goto continue end + table.insert(choices, { + text=('%s/%s'):format(col.group, col.label), + fn=function() self.subviews.sheet:jump_to_col(idx) end, + }) + ::continue:: + end + return choices + end, + }, + QuickMenu{ + view_id='unhide_menu', + frame={b=0, w=35, h=25}, + multiselect=true, + label='Unhide column:', + choices_fn=function() + local choices = {} + for idx,col in ipairs(self.subviews.sheet.cols.subviews) do + if not col.hidden then goto continue end + table.insert(choices, { + text=('%s/%s'):format(col.group, col.label), + fn=function() self.subviews.sheet.cols.subviews[idx]:unhide_column() end, + }) + ::continue:: + end + return choices + end, + }, + } +end + +function Manipulator:update_needs_refresh(initialize) + self.next_refresh_ms = dfhack.getTickCount() + REFRESH_MS + + local units = self.get_units_fn() + local unit_count = #units + if unit_count ~= self.prev_unit_count then + self.needs_refresh = true + self.prev_unit_count = unit_count + end + if unit_count <= 0 then + self.prev_last_unit_id = -1 + else + local last_unit_id = units[#units] + if last_unit_id ~= self.prev_last_unit_id then + self.needs_refresh = true + self.prev_last_unit_id = last_unit_id + end + end + if initialize then + self.needs_refresh = false + end +end + +function Manipulator:render(dc) + if self.next_refresh_ms <= dfhack.getTickCount() then + self:update_needs_refresh() + end + Manipulator.super.render(self, dc) +end + +------------------------ +-- ManipulatorScreen +-- + +ManipulatorScreen = defclass(ManipulatorScreen, gui.ZScreen) +ManipulatorScreen.ATTRS{ + focus_path='manipulator', +} + +function ManipulatorScreen:init() + self:addviews{Manipulator{}} +end + +function ManipulatorScreen:onDismiss() + view = nil +end + +------------------------ +-- ManipulatorOverlay +-- + +ManipulatorOverlay = defclass(ManipulatorOverlay, overlay.OverlayWidget) +ManipulatorOverlay.ATTRS{ + desc='Adds a hotkey to the vanilla units screen to launch the DFHack units interface.', + default_pos={x=50, y=-6}, + default_enabled=true, + viewscreens='dwarfmode/Info/CREATURES/CITIZEN', + frame={w=35, h=1}, +} + +function ManipulatorOverlay:init() + self:addviews{ + widgets.TextButton{ + frame={t=0, l=0}, + label='DFHack citizen management', + key='CUSTOM_CTRL_N', + on_activate=function() dfhack.run_script('gui/manipulator') end, + }, + } +end + +-- +-- disable overlay widget while tool is still in dark launch mode +-- +-- OVERLAY_WIDGETS = { +-- launcher=ManipulatorOverlay, +-- } + +if dfhack_flags.module then return end + +if not dfhack.isMapLoaded() then + qerror("This script requires a map to be loaded") +end + +view = view and view:raise() or ManipulatorScreen{}:show() diff --git a/gui/mass-remove.lua b/gui/mass-remove.lua index 4be9fb338a..932c3067de 100644 --- a/gui/mass-remove.lua +++ b/gui/mass-remove.lua @@ -1,18 +1,22 @@ -- building/construction mass removal/suspension tool +--@ module = true + +local toolbar_textures = dfhack.textures.loadTileset(dfhack.getHackPath()..'/data/art/mass_remove_toolbar.png', 8, 12) + local gui = require('gui') local guidm = require('gui.dwarfmode') local utils = require('utils') local widgets = require('gui.widgets') -local suspendmanager = reqscript('suspendmanager') +local overlay = require('plugins.overlay') -local ok, buildingplan = pcall(require, 'plugins.buildingplan') -if not ok then - buildingplan = nil +local function noop() end -local function remove_building(bld) - dfhack.buildings.deconstruct(bld) +function launch_mass_remove() + local vs = dfhack.gui.getDFViewscreen(true) + gui.simulateInput(vs,'LEAVESCREEN') + dfhack.run_script('gui/mass-remove') end local function get_first_job(bld) @@ -21,68 +25,77 @@ local function get_first_job(bld) return bld.jobs[0] end -local function unremove_building(bld) - local job = get_first_job(bld) - if not job or job.job_type ~= df.job_type.DestroyBuilding then return end - dfhack.job.removeJob(job) +local function process_building(built, planned, remove, bld) + if (built and bld:getBuildStage() == bld:getMaxBuildStage()) or + (planned and bld:getBuildStage() ~= bld:getMaxBuildStage()) + then + if remove then + dfhack.buildings.deconstruct(bld) + else + local job = get_first_job(bld) + if not job or job.job_type ~= df.job_type.DestroyBuilding then return end + dfhack.job.removeJob(job) + end + end end -local function remove_construction(pos) - dfhack.constructions.designateRemove(pos) +local function process_construction(built, planned, remove, grid, pos, bld) + if planned and bld then + process_building(false, true, remove, bld) + elseif built and not bld then + if remove then + dfhack.constructions.designateRemove(pos) + else + local tileFlags = dfhack.maps.getTileFlags(pos) + tileFlags.dig = df.tile_dig_designation.No + dfhack.maps.getTileBlock(pos).flags.designated = true + local job = safe_index(grid, pos.z, pos.y, pos.x) + if job then dfhack.job.removeJob(job) end + end + end end -local function unremove_construction(pos, grid) - local tileFlags = dfhack.maps.getTileFlags(pos) - tileFlags.dig = df.tile_dig_designation.No - dfhack.maps.getTileBlock(pos).flags.designated = true - local job = safe_index(grid, pos.z, pos.y, pos.x) - if job then dfhack.job.removeJob(job) end +local function remove_stockpile(bld) + dfhack.buildings.deconstruct(bld) +end + +local function remove_zone(pos) + for _, bld in ipairs(dfhack.buildings.findCivzonesAt(pos) or {}) do + dfhack.buildings.deconstruct(bld) + end end -- --- ActionPanel +-- DimsPanel -- -local function get_dims(pos1, pos2) - local width, height, depth = math.abs(pos1.x - pos2.x) + 1, - math.abs(pos1.y - pos2.y) + 1, - math.abs(pos1.z - pos2.z) + 1 - return width, height, depth -end - -ActionPanel = defclass(ActionPanel, widgets.ResizingPanel) -ActionPanel.ATTRS{ +DimsPanel = defclass(DimsPanel, widgets.ResizingPanel) +DimsPanel.ATTRS{ get_mark_fn=DEFAULT_NIL, autoarrange_subviews=true, } -function ActionPanel:init() +function DimsPanel:init() self:addviews{ widgets.WrappedLabel{ text_to_wrap=self:callback('get_action_text') }, - widgets.TooltipLabel{ - indent=1, - text={{text=self:callback('get_area_text')}}, - show_tooltip=self.get_mark_fn - }, } end -function ActionPanel:get_action_text() +function DimsPanel:get_action_text() local str = self.get_mark_fn() and 'second' or 'first' return ('Select the %s corner with the mouse.'):format(str) end -function ActionPanel:get_area_text() - local mark = self.get_mark_fn() - if not mark then return '' end - local other = dfhack.gui.getMousePos() - or {x=mark.x, y=mark.y, z=df.global.window_z} - local width, height, depth = get_dims(mark, other) - local tiles = width * height * depth - local plural = tiles > 1 and 's' or '' - return ('%dx%dx%d (%d tile%s)'):format(width, height, depth, tiles, plural) +local function is_something_selected() + return dfhack.gui.getSelectedBuilding(true) or + dfhack.gui.getSelectedStockpile(true) or + dfhack.gui.getSelectedCivZone(true) +end + +local function not_is_something_selected() + return not is_something_selected() end -- @@ -92,9 +105,9 @@ end MassRemove = defclass(MassRemove, widgets.Window) MassRemove.ATTRS{ frame_title='Mass Remove', - frame={w=47, h=16, r=2, t=18}, + frame={w=47, h=18, r=2, t=18}, resizable=true, - resize_min={h=10}, + resize_min={h=9}, autoarrange_subviews=true, autoarrange_gap=1, } @@ -102,44 +115,106 @@ MassRemove.ATTRS{ function MassRemove:init() self:addviews{ widgets.WrappedLabel{ - text_to_wrap='Designate multiple buildings and/or constructions (built or planned) for removal.' + view_id='warning', + text_to_wrap='Please deselect any selected buildings, stockpiles or zones before attempting to remove them.', + text_pen=COLOR_RED, + visible=is_something_selected, }, - ActionPanel{ - get_mark_fn=function() return self.mark end + widgets.WrappedLabel{ + text_to_wrap='Designate buildings, constructions, stockpiles, and/or zones for removal.', + visible=function() return not_is_something_selected() and self.subviews.remove:getOptionValue() end, + }, + widgets.WrappedLabel{ + text_to_wrap='Designate buildings or constructions to cancel removal.', + visible=function() return not_is_something_selected() and not self.subviews.remove:getOptionValue() end, + }, + DimsPanel{ + get_mark_fn=function() return self.mark end, + visible=not_is_something_selected, }, widgets.CycleHotkeyLabel{ view_id='buildings', label='Buildings:', key='CUSTOM_B', key_back='CUSTOM_SHIFT_B', + option_gap=5, options={ - {label='Leave alone', value=function() end}, - {label='Remove', value=remove_building}, - -- {label='Unremove', value=unremove_building}, + {label='Leave alone', value=noop, pen=COLOR_BLUE}, + {label='Affect built and planned', value=curry(process_building, true, true), pen=COLOR_RED}, + {label='Affect built', value=curry(process_building, true, false), pen=COLOR_LIGHTRED}, + {label='Affect planned', value=curry(process_building, false, true), pen=COLOR_YELLOW}, }, - initial_option=remove_building, + initial_option=2, + enabled=not_is_something_selected, }, widgets.CycleHotkeyLabel{ view_id='constructions', label='Constructions:', key='CUSTOM_V', key_back='CUSTOM_SHIFT_V', + option_gap=1, + options={ + {label='Leave alone', value=noop, pen=COLOR_BLUE}, + {label='Affect built and planned', value=curry(process_construction, true, true), pen=COLOR_RED}, + {label='Affect built', value=curry(process_construction, true, false), pen=COLOR_LIGHTRED}, + {label='Affect planned', value=curry(process_construction, false, true), pen=COLOR_YELLOW}, + }, + enabled=not_is_something_selected, + }, + widgets.CycleHotkeyLabel{ + view_id='stockpiles', + label='Stockpiles:', + key='CUSTOM_T', + key_sep=': ', + option_gap=4, options={ - {label='Leave alone', value=function() end}, - {label='Remove', value=remove_construction}, - -- {label='Unremove', value=unremove_construction}, + {label='Leave alone', value=noop, pen=COLOR_BLUE}, + {label='Remove', value=remove_stockpile, pen=COLOR_RED}, }, + enabled=not_is_something_selected, + visible=function() return self.subviews.remove:getOptionValue() end, + }, + widgets.CycleHotkeyLabel{ + label='Stockpiles:', + key='CUSTOM_T', + key_sep=': ', + option_gap=4, + options={{label='Leave alone', value=noop}}, + enabled=false, + visible=function() return not self.subviews.remove:getOptionValue() end, + }, + widgets.CycleHotkeyLabel{ + view_id='zones', + label='Zones:', + key='CUSTOM_Z', + key_sep=': ', + option_gap=9, + options={ + {label='Leave alone', value=noop, pen=COLOR_BLUE}, + {label='Remove', value=remove_zone, pen=COLOR_RED}, + }, + enabled=not_is_something_selected, + visible=function() return self.subviews.remove:getOptionValue() end, + }, + widgets.CycleHotkeyLabel{ + label='Zones:', + key='CUSTOM_Z', + key_sep=': ', + option_gap=9, + options={{label='Leave alone', value=noop}}, + enabled=false, + visible=function() return not self.subviews.remove:getOptionValue() end, }, widgets.CycleHotkeyLabel{ - view_id='suspend', - label='Suspend:', - key='CUSTOM_X', - key_back='CUSTOM_SHIFT_X', + view_id='remove', + label='Mode:', + key='CUSTOM_R', options={ - {label='Leave alone', value=function() end}, - {label='Suspend', value=suspendmanager.suspend}, - {label='Unsuspend', value=suspendmanager.unsuspend}, + {label='Remove or schedule for removal', value=true, pen=COLOR_RED}, + {label='Cancel removal', value=false, pen=COLOR_GREEN}, }, + on_change=function() self:updateLayout() end, + enabled=not_is_something_selected, }, } @@ -190,6 +265,12 @@ function MassRemove:onInput(keys) end if not pos then return false end + if is_something_selected() then + self.mark = nil + self:updateLayout() + return true + end + if self.mark then self:commit(get_bounds(self.mark, pos)) self.mark = nil @@ -205,8 +286,6 @@ end local to_pen = dfhack.pen.parse local SELECTION_PEN = to_pen{ch='X', fg=COLOR_GREEN, tile=dfhack.screen.findGraphicsTile('CURSORS', 1, 2)} -local SUSPENDED_PEN = to_pen{ch='s', fg=COLOR_YELLOW, - tile=dfhack.screen.findGraphicsTile('CURSORS', 0, 0)} local DESTROYING_PEN = to_pen{ch='d', fg=COLOR_LIGHTRED, tile=dfhack.screen.findGraphicsTile('CURSORS', 3, 0)} @@ -222,10 +301,6 @@ local function is_destroying_construction(pos, grid) dfhack.maps.getTileFlags(pos).dig == df.tile_dig_designation.Default end -local function can_suspend(bld) - return not buildingplan or not buildingplan.isPlannedBuilding(bld) -end - local function get_job_pen(pos, grid) if is_destroying_construction(pos) then return DESTROYING_PEN @@ -237,9 +312,6 @@ local function get_job_pen(pos, grid) if jt == df.job_type.DestroyBuilding or jt == df.job_type.RemoveConstruction then return DESTROYING_PEN - elseif jt == df.job_type.ConstructBuilding and job.flags.suspend - and can_suspend(bld) then - return SUSPENDED_PEN end end @@ -268,25 +340,30 @@ end function MassRemove:commit(bounds) local bld_fn = self.subviews.buildings:getOptionValue() local constr_fn = self.subviews.constructions:getOptionValue() - local susp_fn = self.subviews.suspend:getOptionValue() + local stockpile_fn = self.subviews.stockpiles:getOptionValue() + local zones_fn = self.subviews.zones:getOptionValue() + local remove = self.subviews.remove:getOptionValue() self:refresh_grid() - local grid = self.grid for z=bounds.z1,bounds.z2 do for y=bounds.y1,bounds.y2 do for x=bounds.x1,bounds.x2 do local pos = xyz2pos(x, y, z) local bld = dfhack.buildings.findAtTile(pos) - if bld then bld_fn(bld) end - if is_construction(pos) then - constr_fn(pos, grid) + if bld then + if bld:getType() == df.building_type.Stockpile then + stockpile_fn(bld) + elseif bld:getType() == df.building_type.Construction then + constr_fn(remove, self.grid, pos, bld) + else + bld_fn(remove, bld) + end end - local job = get_first_job(bld) - if job and job.job_type == df.job_type.ConstructBuilding - and can_suspend(bld) then - susp_fn(job) + if not dfhack.buildings.findAtTile(pos) and is_construction(pos) then + constr_fn(remove, self.grid, pos) end + zones_fn(pos) end end end @@ -304,13 +381,112 @@ MassRemoveScreen.ATTRS { } function MassRemoveScreen:init() - self:addviews{MassRemove{}} + local window = MassRemove{} + self:addviews{ + window, + widgets.DimensionsTooltip{ + get_anchor_pos_fn=function() return window.mark end, + }, + } end function MassRemoveScreen:onDismiss() view = nil end + +-- -------------------------------- +-- MassRemoveToolbarOverlay +-- + +MassRemoveToolbarOverlay = defclass(MassRemoveToolbarOverlay, overlay.OverlayWidget) +MassRemoveToolbarOverlay.ATTRS{ + desc='Adds a button to the erase toolbar to open the mass removal tool.', + default_pos={x=42, y=-4}, + default_enabled=true, + viewscreens='dwarfmode/Designate/ERASE', + frame={w=26, h=10}, +} + +function MassRemoveToolbarOverlay:init() + local button_chars = { + {218, 196, 196, 191}, + {179, 'M', 'R', 179}, + {192, 196, 196, 217}, + } + + self:addviews{ + widgets.Panel{ + frame={t=0, r=0, w=26, h=6}, + frame_style=gui.FRAME_PANEL, + frame_background=gui.CLEAR_PEN, + frame_inset={l=1, r=1}, + visible=function() return self.subviews.icon:getMousePos() end, + subviews={ + widgets.Label{ + text={ + 'Open mass removal', NEWLINE, + 'interface.', NEWLINE, + NEWLINE, + {text='Hotkey: ', pen=COLOR_GRAY}, {key='CUSTOM_M'}, + }, + }, + }, + }, + widgets.Panel{ + view_id='icon', + frame={b=0, r=22, w=4, h=3}, + subviews={ + widgets.Label{ + text=widgets.makeButtonLabelText{ + chars=button_chars, + pens=COLOR_GRAY, + tileset=toolbar_textures, + tileset_offset=1, + tileset_stride=8, + }, + on_click=launch_mass_remove, + visible=function () return not self.subviews.icon:getMousePos() end, + }, + widgets.Label{ + text=widgets.makeButtonLabelText{ + chars=button_chars, + pens={ + {COLOR_WHITE, COLOR_WHITE, COLOR_WHITE, COLOR_WHITE}, + {COLOR_WHITE, COLOR_GRAY, COLOR_GRAY, COLOR_WHITE}, + {COLOR_WHITE, COLOR_WHITE, COLOR_WHITE, COLOR_WHITE}, + }, + tileset=toolbar_textures, + tileset_offset=5, + tileset_stride=8, + }, + on_click=launch_mass_remove, + visible=function() return self.subviews.icon:getMousePos() end, + }, + }, + }, + } +end + +function MassRemoveToolbarOverlay:preUpdateLayout(parent_rect) + local w = parent_rect.width + if w <= 130 then + self.frame.w = 50 + else + self.frame.w = (parent_rect.width+1)//2 - 15 + end +end + +function MassRemoveToolbarOverlay:onInput(keys) + if keys.CUSTOM_M then + launch_mass_remove() + return true + end + return MassRemoveToolbarOverlay.super.onInput(self, keys) +end + +OVERLAY_WIDGETS = {toolbar=MassRemoveToolbarOverlay} + if dfhack_flags.module then return end diff --git a/gui/masspit.lua b/gui/masspit.lua index f7d77176bb..e46712819a 100644 --- a/gui/masspit.lua +++ b/gui/masspit.lua @@ -73,16 +73,16 @@ function Masspit:showStockpiles() for _, zone in pairs(df.global.world.buildings.other.STOCKPILE) do if (#zone.settings.animals.enabled > 0) then if #getCagedUnits(zone) > 0 then - local zone_name = zone.name.length and dfhack.TranslateName(zone.name) or "Animal stockpile #" .. zone.stockpile_number + local zone_name = #zone.name > 0 and zone.name or ("Animal stockpile #" .. zone.stockpile_number) local zone_position = df.coord:new() zone_position.x = zone.centerx zone_position.y = zone.centery zone_position.z = zone.z table.insert(choices, { - text = ([[%s: %s x:%s y:%s]]):format(zone.id, zone_name, zone.centerx, zone.centery), + text = zone_name, zone_position = zone_position, - zone_id = zone.id + zone_id = zone.id, }) end end @@ -97,6 +97,10 @@ function Masspit:showStockpiles() end end +local function getElevation() + return df.global.world.map.region_z + df.global.window_z - 100 +end + function Masspit:showPondPits() self.subviews.pages:setSelected('pits') @@ -104,14 +108,14 @@ function Masspit:showPondPits() for _, zone in pairs(df.global.world.buildings.other.ACTIVITY_ZONE) do if (zone.type == df.civzone_type.Pond) then - local zone_name = zone.name.length and dfhack.TranslateName(zone.name) or "Unnamed pit/pond" + local zone_name = #zone.name > 0 and zone.name or ("Unnamed pit/pond on elevation "..getElevation()) local zone_position = df.coord:new() zone_position.x = zone.centerx zone_position.y = zone.centery zone_position.z = zone.z table.insert(choices, { - text = ([[%s: %s x:%s y:%s]]):format(zone.id, zone_name, zone.centerx, zone.centery), + text = zone_name, zone_position = zone_position, zone_id = zone.id }) @@ -160,8 +164,8 @@ function Masspit:setPit(_, choice) local choices = {} for _, unit_id in pairs(self.caged_units) do - local unit = utils.binsearch(df.global.world.units.all, unit_id, 'id') - local unit_name = unit.name.has_name and dfhack.TranslateName(unit.name) or dfhack.units.getRaceNameById(unit.race) + local unit = df.unit.find(unit_id) + local unit_name = dfhack.units.getReadableName(unit) -- Prevents duplicate units in assignments, which can cause crashes. local duplicate = false @@ -171,7 +175,7 @@ function Masspit:setPit(_, choice) end end - table.insert(choices, { text = ([[%s: %s %s]]):format(unit.id, unit_name, duplicate and "(ALREADY ASSIGNED)" or "") }) + table.insert(choices, { text = ([[%s: %s%s]]):format(unit.id, unit_name, duplicate and " (ALREADY ASSIGNED)" or "") }) if not duplicate then unit.general_refs:insert("#", { new = df.general_ref_building_civzone_assignedst, building_id=self.pit.id }) @@ -220,7 +224,7 @@ function MasspitScreen:onDismiss() view = nil end -if not dfhack.isMapLoaded() then +if not dfhack.world.isFortressMode() or not dfhack.isMapLoaded() then qerror('This script requires a fortress map to be loaded') end diff --git a/gui/mod-manager.lua b/gui/mod-manager.lua index 08e9ae5b03..a3a745963d 100644 --- a/gui/mod-manager.lua +++ b/gui/mod-manager.lua @@ -1,22 +1,42 @@ --- Save and restore lists of active mods. +-- Show, save, and restore lists of active mods. --@ module = true -local overlay = require('plugins.overlay') -local gui = require('gui') -local widgets = require('gui.widgets') local dialogs = require('gui.dialogs') +local gui = require('gui') local json = require('json') +local overlay = require('plugins.overlay') +local scriptmanager = require('script-manager') local utils = require('utils') +local widgets = require('gui.widgets') local presets_file = json.open("dfhack-config/mod-manager.json") local GLOBAL_KEY = 'mod-manager' -local function get_newregion_viewscreen() +local function vanilla(dir) + return dir:startswith('data/vanilla') +end + +-- get_moddable_viewscreen(), get_any_moddable_viewscreen() and get_modlist_fields are declared +-- as global functions so external tools can call them to get the DF mod list +function get_moddable_viewscreen(type) + local vs = nil + if type == 'region' then + vs = dfhack.gui.getViewscreenByType(df.viewscreen_new_regionst, 0) + elseif type == 'arena' then + vs = dfhack.gui.getViewscreenByType(df.viewscreen_new_arenast, 0) + end + return vs +end + +function get_any_moddable_viewscreen() local vs = dfhack.gui.getViewscreenByType(df.viewscreen_new_regionst, 0) + if not vs then + vs = dfhack.gui.getViewscreenByType(df.viewscreen_new_arenast, 0) + end return vs end -local function get_modlist_fields(kind, viewscreen) +function get_modlist_fields(kind, viewscreen) if kind == "available" then return { id = viewscreen.available_id, @@ -52,21 +72,30 @@ local function get_modlist_fields(kind, viewscreen) end end -local function move_mod_entry(viewscreen, to, from, mod_id, mod_version) +---@return boolean # true if the mod entry was copied over; false if the mod or mod version was not found. +---@return string|nil # loaded version - DISPLAYED_VERSION from the mod's info.txt +local function copy_mod_entry(viewscreen, to, from, mod_id, mod_version) local to_fields = get_modlist_fields(to, viewscreen) local from_fields = get_modlist_fields(from, viewscreen) local mod_index = nil + local loaded_version = nil for i, v in ipairs(from_fields.id) do local version = from_fields.numeric_version[i] - if v.value == mod_id and version == mod_version then + local src_dir = from_fields.src_dir[i] + local displayed_version = from_fields.displayed_version[i].value + -- assumes that vanilla mods will not have multiple possible indices. + if v.value == mod_id and (vanilla(src_dir) or version == mod_version) then + if version ~= mod_version then + loaded_version = displayed_version + end mod_index = i break end end if mod_index == nil then - return false + return false, nil end for k, v in pairs(to_fields) do @@ -77,19 +106,49 @@ local function move_mod_entry(viewscreen, to, from, mod_id, mod_version) end end - for k, v in pairs(from_fields) do - v:erase(mod_index) - end - - return true + return true, loaded_version end +---@return boolean # true if the mod entry was copied over; false if the mod or mod version was not found. +---@return string|nil # loaded version - DISPLAYED_VERSION from the mod's info.txt local function enable_mod(viewscreen, mod_id, mod_version) - return move_mod_entry(viewscreen, "object_load_order", "available", mod_id, mod_version) + return copy_mod_entry(viewscreen, "object_load_order", "base_available", mod_id, mod_version) +end + +---@return boolean # true if the mod entry was copied over; false if the mod or mod version was not found. +---@return string|nil # loaded version - DISPLAYED_VERSION from the mod's info.txt +local function make_available_mod(viewscreen, mod_id, mod_version) + return copy_mod_entry(viewscreen, "available", "base_available", mod_id, mod_version) end -local function disable_mod(viewscreen, mod_id, mod_version) - return move_mod_entry(viewscreen, "available", "object_load_order", mod_id, mod_version) +local function clear_mods(viewscreen) + local active_modlist = get_modlist_fields('object_load_order', viewscreen) + local avail_modlist = get_modlist_fields('available', viewscreen) + for _, modlist in ipairs({active_modlist, avail_modlist}) do + for _, v in pairs(modlist) do + for i = #v - 1, 0, -1 do + v:erase(i) + end + end + end +end + +local function set_available_mods(viewscreen, loaded) + local base_avail = get_modlist_fields('base_available', viewscreen) + local unused = {} + for i, id in ipairs(base_avail.id) do + if not loaded[id.value] then + local version = base_avail.numeric_version[i] + table.insert(unused, { id= id.value, version= version }) + end + end + + for _, v in ipairs(unused) do + local success, _ = make_available_mod(viewscreen, v.id, v.version) + if not success then + dfhack.printerr('failed to show '..v.id..' in available list') + end + end end local function get_active_modlist(viewscreen) @@ -102,21 +161,33 @@ local function get_active_modlist(viewscreen) return t end +--- @return string[] +--- @return { id: string, new: string }[] local function swap_modlist(viewscreen, modlist) - local current = get_active_modlist(viewscreen) - for _, v in ipairs(current) do - disable_mod(viewscreen, v.id, v.version) - end + clear_mods(viewscreen) local failures = {} + local changed = {} + local loaded = {} for _, v in ipairs(modlist) do - if not enable_mod(viewscreen, v.id, v.version) then + local success, version = enable_mod(viewscreen, v.id, v.version) + if not success then table.insert(failures, v.id) + else + if version then + table.insert(changed, { id= v.id, new= version }) + end + loaded[v.id] = true end end - return failures + + set_available_mods(viewscreen, loaded) + return failures, changed end +-------------------- +-- ModmanageMenu + ModmanageMenu = defclass(ModmanageMenu, widgets.Window) ModmanageMenu.ATTRS { view_id = "modman_menu", @@ -131,7 +202,7 @@ ModmanageMenu.ATTRS { } local function save_new_preset(preset_name) - local viewscreen = get_newregion_viewscreen() + local viewscreen = get_any_moddable_viewscreen() local modlist = get_active_modlist(viewscreen) table.insert(presets_file.data, { name = preset_name, modlist = modlist }) presets_file:write() @@ -151,29 +222,95 @@ local function overwrite_preset(idx) return end - local viewscreen = get_newregion_viewscreen() + local viewscreen = get_any_moddable_viewscreen() local modlist = get_active_modlist(viewscreen) presets_file.data[idx].modlist = modlist presets_file:write() end -local function load_preset(idx) +local function prepare_warning(text, failed, changed, unset_default_on_failure) + if not failed and not changed then return end + + if failed then + if unset_default_on_failure then + table.insert(text, { + text='Failed to load some mods from your default preset.', + pen=COLOR_LIGHTRED, + }) + table.insert(text, NEWLINE) + table.insert(text, { + text='Preset is being unmarked as the default for safety.', + pen=COLOR_LIGHTRED, + }) + else + table.insert(text, { + text='Failed to load some mods from the preset.', + pen=COLOR_LIGHTRED, + }) + end + end + + if failed and changed then + table.insert(text, NEWLINE) + end + + if changed then + table.insert(text, { + text='Some vanilla mods have been updated.', + pen=COLOR_LIGHTRED, + }) + end + table.insert(text, NEWLINE) + table.insert(text, 'Please re-create your preset with mods you currently have installed.') + table.insert(text, NEWLINE) + table.insert(text, NEWLINE) +end + +local function load_preset(idx, unset_default_on_failure) if idx > #presets_file.data then return end - local viewscreen = get_newregion_viewscreen() + local viewscreen = get_any_moddable_viewscreen() local modlist = presets_file.data[idx].modlist - local failures = swap_modlist(viewscreen, modlist) + local failures, changes = swap_modlist(viewscreen, modlist) + local text = {} + + local failed = #failures > 0 + local changed = #changes > 0 + + prepare_warning(text, failed, changed) + if failed and unset_default_on_failure then + presets_file.data[idx].default = false + presets_file:write() + end - if #failures > 0 then - local failures_str = "" + if failed then + table.insert(text, 'Here are the mods that failed to load:') + table.insert(text, NEWLINE) + table.insert(text, NEWLINE) for _, v in ipairs(failures) do - failures_str = failures_str .. v .. "\n" + table.insert(text, ('- %s'):format(v)) + table.insert(text, NEWLINE) end - dialogs.showMessage("Warning", - "Failed to load some mods. Please re-create your default preset.", - COLOR_LIGHTRED) + end + + if failed and changed then + table.insert(text, NEWLINE) -- just to separate the sections + end + + if changed then + table.insert(text, 'Here are the vanilla mods that have been updated:') + table.insert(text, NEWLINE) + table.insert(text, NEWLINE) + for _, v in ipairs(changes) do + table.insert(text, ('- %s to %s'):format(v.id, v.new)) + table.insert(text, NEWLINE) + end + end + + if failed or changed then + dialogs.showMessage("Warning", text) end end @@ -362,6 +499,9 @@ function ModmanageMenu:init() } end +-------------------- +-- ModmanageScreen + ModmanageScreen = defclass(ModmanageScreen, gui.ZScreen) ModmanageScreen.ATTRS { focus_path = "mod-manager", @@ -374,12 +514,172 @@ function ModmanageScreen:init() } end +-------------------- +-- ModlistWindow + +ModlistWindow = defclass(ModlistWindow, widgets.Window) +ModlistWindow.ATTRS{ + frame_title="Active Mods", + frame={w=55, h=20}, + resizable=true, +} + +local function get_num_vanilla_mods() + local count = 0 + for _,mod in ipairs(scriptmanager.get_active_mods()) do + if mod.vanilla then + count = count + 1 + end + end + return count +end + +local function get_num_non_vanilla_mods() + local count = 0 + for _,mod in ipairs(scriptmanager.get_active_mods()) do + if not mod.vanilla then + count = count + 1 + end + end + return count +end + +function ModlistWindow:init() + self:addviews{ + widgets.CycleHotkeyLabel{ + view_id='vanilla', + frame={l=0, t=0, w=24}, + key='CUSTOM_V', + label='Vanilla mods:', + options={ + {label='Include', value=true, pen=COLOR_LIGHTBLUE}, + {label='Exclude', value=false, pen=COLOR_LIGHTRED}, + }, + initial_option=false, + on_change=function() self:refresh_list() end, + }, + widgets.Divider{ + frame={t=2, h=1}, + frame_style=gui.FRAME_THIN, + frame_style_l=false, + frame_style_r=false, + }, + widgets.HotkeyLabel{ + frame={t=4, r=0}, + label='Export to clipboard (single line)', + text_pen=COLOR_YELLOW, + auto_width=true, + on_activate=function() + local text = {} + for _,choice in ipairs(self.subviews.list:getChoices()) do + table.insert(text, ('%s %s'):format(choice.data.name, choice.data.version)) + end + dfhack.internal.setClipboardTextCp437Multiline(table.concat(text, ', ')) + end, + enabled=function() return #self.subviews.list:getChoices() > 0 end, + }, + widgets.HotkeyLabel{ + frame={t=5, r=0}, + label='Export to clipboard (with links)', + text_pen=COLOR_YELLOW, + auto_width=true, + on_activate=function() + local text = {} + for _,choice in ipairs(self.subviews.list:getChoices()) do + table.insert(text, choice.export_text) + end + dfhack.internal.setClipboardTextCp437Multiline(table.concat(text, NEWLINE)) + end, + enabled=function() return #self.subviews.list:getChoices() > 0 end, + }, + widgets.Label{ + frame={l=0, t=4}, + text={ + 'Load', + NEWLINE, + 'order', + }, + }, + widgets.Label{ + frame={l=7, t=5}, + text='Mod', + }, + widgets.List{ + view_id='list', + frame={t=7, b=2}, + }, + widgets.Label{ + frame={l=0, b=0}, + text={ + {text=('%d'):format(get_num_vanilla_mods()), pen=COLOR_LIGHTBLUE}, + ' vanilla mods', + {text=function() return self.subviews.vanilla:getOptionValue() and '' or ' (hidden)' end}, + ', ', + {text=('%d'):format(get_num_non_vanilla_mods()), pen=COLOR_BROWN}, + ' non-vanilla mods', + }, + }, + } + + self:refresh_list() +end + +function ModlistWindow:refresh_list() + local include_vanilla = self.subviews.vanilla:getOptionValue() + + local choices = {} + for idx,mod in ipairs(scriptmanager.get_active_mods()) do + if not include_vanilla and mod.vanilla then goto continue end + local metadata = scriptmanager.get_mod_info_metadata(mod.path, 'STEAM_FILE_ID') + local steam_id = metadata and metadata.STEAM_FILE_ID or nil + local url = steam_id and (': https://steamcommunity.com/sharedfiles/filedetails/?id=%s'):format(steam_id) or '' + table.insert(choices, { + text={ + {text=idx, width=2, rjustify=true}, + ') ', + {text=mod.name, gap=3}, + ' (', + {text=mod.version, pen=COLOR_LIGHTGREEN}, + ')', + }, + data=mod, + export_text=('- %s (%s)%s'):format(mod.name, mod.version, url), + }) + ::continue:: + end + + self.subviews.list:setChoices(choices) +end + +-------------------- +-- ModlistScreen + +ModlistScreen = defclass(ModlistScreen, gui.ZScreen) +ModlistScreen.ATTRS{ + focus_path="mod-manager", +} + +function ModlistScreen:init() + self:addviews{ + ModlistWindow{} + } +end + +function ModlistScreen:onDismiss() + view = nil +end + +-------------------- +-- Overlays + ModmanageOverlay = defclass(ModmanageOverlay, overlay.OverlayWidget) ModmanageOverlay.ATTRS { frame = { w=16, h=3 }, frame_style = gui.MEDIUM_FRAME, - default_pos = { x=5, y=-5 }, - viewscreens = { "new_region/Mods" }, + desc = "Adds a link to the mod selection screen for accessing the mod manager.", + default_pos = { x=5, y=-6 }, + version = 2, + viewscreens = { "new_region/Mods", "new_arena/Mods" }, default_enabled=true, } @@ -400,7 +700,9 @@ end NotificationOverlay = defclass(NotificationOverlay, overlay.OverlayWidget) NotificationOverlay.ATTRS { frame = { w=60, h=1 }, - default_pos = { x=3, y=-2 }, + desc = "Displays a message when a mod preset has been automatically applied.", + default_pos = { x=3, y=-6 }, + version = 2, viewscreens = { "new_region" }, default_enabled=true, } @@ -440,12 +742,12 @@ notification_timer_fn() local default_applied = false dfhack.onStateChange[GLOBAL_KEY] = function(sc) if sc == SC_VIEWSCREEN_CHANGED then - local vs = get_newregion_viewscreen() + local vs = get_any_moddable_viewscreen() if vs and not default_applied then default_applied = true for i, v in ipairs(presets_file.data) do if v.default then - load_preset(i) + load_preset(i, true) notification_message = "*** Loaded mod list '" .. v.name .. "'!" notification_overlay_end = dfhack.getTickCount() + 5000 @@ -462,3 +764,9 @@ end if dfhack_flags.module then return end + +if not dfhack.isWorldLoaded() then + qerror("Please load a game before using the mod manager to see active mods.") +end + +view = view and view:raise() or ModlistScreen{}:show() diff --git a/gui/notes.lua b/gui/notes.lua new file mode 100644 index 0000000000..9ad4d8f822 --- /dev/null +++ b/gui/notes.lua @@ -0,0 +1,369 @@ +-- Map notes +--@ module = true + +local gui = require 'gui' +local widgets = require 'gui.widgets' +local guidm = require 'gui.dwarfmode' +local script = require 'gui.script' +local overlay = require 'plugins.overlay' +local utils = require 'utils' + +local note_manager = reqscript('internal/notes/note_manager') +local notes_textures = reqscript('notes').textures + +local map_points = df.global.plotinfo.waypoints.points + +local NOTE_LIST_RESIZE_MIN = {w=26} +local RESIZE_MIN = {w=65, h=30} +local NOTE_SEARCH_BATCH_SIZE = 25 +local OVERLAY_NAME = 'notes.map_notes' + +NotesWindow = defclass(NotesWindow, widgets.Window) +NotesWindow.ATTRS { + frame_title='DF Notes', + resizable=true, + resize_min=RESIZE_MIN, + frame_inset={l=0,r=0,t=0,b=0}, + on_note_add=DEFAULT_NIL +} + +function NotesWindow:init() + self.selected_note = nil + self.note_manager = nil + self.curr_search_phrase = nil + + local left_panel_content = { + widgets.Panel{ + frame={l=0,h=3}, + frame_style=gui.FRAME_INTERIOR, + subviews={ + widgets.EditField{ + view_id='search', + on_change=self:callback('loadFilteredNotes'), + on_submit=function() + self.subviews.note_list:submit() + end + }, + } + }, + widgets.List{ + view_id='note_list', + frame={l=0,b=2}, + frame_inset={t=1}, + row_height=1, + on_select=function (ind, note) + self:loadNote(note) + end, + on_submit=function (ind, note) + self:loadNote(note) + dfhack.gui.pauseRecenter(note.point.pos) + end + }, + } + + self:addviews{ + widgets.Panel{ + view_id='note_list_panel', + frame={l=0, w=NOTE_LIST_RESIZE_MIN.w, t=0, b=1}, + visible=true, + frame_inset={l=1,t=1,b=1,r=1}, + autoarrange_subviews=true, + subviews=left_panel_content, + }, + widgets.HotkeyLabel{ + view_id='create', + frame={l=1,b=1,h=1}, + auto_width=true, + label='New note', + key='CUSTOM_CTRL_N', + visible=true, + on_activate=function() + if self.on_note_add then + self:on_note_add() + end + end, + }, + widgets.Divider{ + frame={l=NOTE_LIST_RESIZE_MIN.w,t=0,b=0,w=1}, + + interior_b=false, + frame_style_t=false, + frame_style_b=false, + }, + widgets.Panel{ + view_id='note_details', + frame={l=NOTE_LIST_RESIZE_MIN.w + 1,t=0,b=0}, + frame_inset=1, + subviews={ + widgets.Panel{ + view_id="name_panel", + frame_title='Name', + frame_style=gui.FRAME_INTERIOR, + frame={l=0,r=0,t=0,h=4}, + frame_inset={l=1,r=1}, + subviews={ + widgets.WrappedLabel{ + view_id='name', + auto_height=false, + frame={l=0,r=0,t=0,b=0}, + }, + }, + }, + widgets.Panel{ + view_id="comment_panel", + frame_title='Comment', + frame_style=gui.FRAME_INTERIOR, + frame={l=0,r=0,t=4,b=2}, + frame_inset={l=1,r=1,t=1}, + subviews={ + widgets.WrappedLabel{ + view_id='comment', + auto_height=false, + frame={l=0,r=0,t=0,b=0}, + }, + } + }, + widgets.Panel{ + frame={l=0,r=0,b=0,h=2}, + frame_inset={l=1,r=1,t=1}, + subviews={ + widgets.HotkeyLabel{ + view_id='edit', + frame={l=0,t=0,h=1}, + auto_width=true, + label='Edit', + key='CUSTOM_CTRL_E', + on_activate=function() self:showNoteManager(self.selected_note) end, + }, + widgets.HotkeyLabel{ + view_id='delete', + frame={r=0,t=0,h=1}, + auto_width=true, + label='Delete', + key='CUSTOM_CTRL_D', + on_activate=function() self:deleteNote(self.selected_note) end, + }, + } + } + } + } + } +end + +function NotesWindow:postUpdateLayout() + if self.curr_search_phrase == nil then + self:loadFilteredNotes('', true) + end +end + +function NotesWindow:showNoteManager(note) + if self.note_manager ~= nil then + self.note_manager:dismiss() + end + + self.note_manager = note_manager.NoteManager{ + note=note, + on_update=function() + self:reloadFilteredNotes() + dfhack.run_command_silent('overlay trigger notes.map_notes') + end, + on_dismiss=function() self.visible = true end + } + + self.visible = false + return self.note_manager:show():raise() +end + +function NotesWindow:deleteNote(note) + for ind, map_point in pairs(map_points) do + if map_point.id == note.point.id then + map_points:erase(ind) + break + end + end + + self:reloadFilteredNotes() +end + +function NotesWindow:loadNote(note) + self.selected_note = note + + if note == nil then + return + end + + self.subviews.name.text_to_wrap = self.selected_note.point.name + self.subviews.comment.text_to_wrap = self.selected_note.point.comment + + self.subviews.note_details:updateLayout() +end + +function NotesWindow:reloadFilteredNotes() + self:loadFilteredNotes(self.curr_search_phrase, true) +end + +function NotesWindow:loadFilteredNotes(search_phrase, force) + local full_list_loaded = self.curr_search_phrase == '' + + search_phrase = search_phrase:lower() + + self.curr_search_phrase = search_phrase + + script.start(function () + if #search_phrase == 0 and full_list_loaded and not force then + return + end + + local choices = {} + + for ind, map_point in ipairs(map_points) do + if ind > 0 and ind % NOTE_SEARCH_BATCH_SIZE == 0 then + script.sleep(1, 'frames') + end + if self.curr_search_phrase ~= search_phrase then + -- stop the work if user provided new search phrase + return + end + + if ( + #map_point.name > 0 and + utils.search_text(map_point.name, search_phrase) + ) then + table.insert(choices, { + text=map_point.name, + point=map_point + }) + end + end + + self.subviews.note_list:setChoices(choices) + + local sel_ind, sel_note = self.subviews.note_list:getSelected() + self:loadNote(sel_note) + end) +end + +NotesScreen = defclass(NotesScreen, gui.ZScreen) +NotesScreen.ATTRS { + focus_path='gui/notes', + pass_movement_keys=true, + enable_selector_blink = true, +} + +function NotesScreen:init() + self.is_adding_note = false + self.adding_note_pos = nil + self:addviews{ + NotesWindow{ + view_id='notes_window', + frame={w=RESIZE_MIN.w, h=35}, + on_note_add=self:callback('startNoteAdd') + }, + } +end + +function NotesScreen:startNoteAdd() + self.adding_note_pos = nil + self.subviews.notes_window.visible = false + self.is_adding_note = true +end + +function NotesScreen:stopNoteAdd() + self.subviews.notes_window.visible = true + self.is_adding_note = false +end + +function NotesScreen:onInput(keys) + if self.is_adding_note then + if (keys.SELECT or keys._MOUSE_L) then + self.adding_note_pos = dfhack.gui.getMousePos() + + local note_manager = note_manager.NoteManager{ + note=nil, + on_update=function() + dfhack.run_command_silent('overlay trigger notes.map_notes') + self.subviews.notes_window:reloadFilteredNotes() + self:stopNoteAdd() + end, + on_dismiss=function() + self:stopNoteAdd() + end + }:show() + note_manager:setNotePos(self.adding_note_pos) + self.subviews.notes_window.note_manager = note_manager + + return true + elseif (keys.LEAVESCREEN or keys._MOUSE_R)then + self:stopNoteAdd() + return true + end + end + + return NotesScreen.super.onInput(self, keys) +end + +function NotesScreen:onRenderFrame(dc, rect) + NotesScreen.super.onRenderFrame(self, dc, rect) + + if self.enable_selector_blink and not gui.blink_visible(500) then + return + end + + if self.is_adding_note then + local curr_pos = self.adding_note_pos or dfhack.gui.getMousePos() + if not curr_pos then + return + end + + local function get_overlay_pen(pos) + if same_xy(curr_pos, pos) then + local texpos = dfhack.textures.getTexposByHandle( + notes_textures.green_pin[1] + ) + return dfhack.pen.parse{ + ch='X', + fg=COLOR_BLUE, + tile=texpos + } + end + end + + guidm.renderMapOverlay(get_overlay_pen, { + x1=curr_pos.x, + y1=curr_pos.y, + x2=curr_pos.x, + y2=curr_pos.y, + }) + end +end + +function NotesScreen:onAboutToShow() + if not overlay.isOverlayEnabled(OVERLAY_NAME) then + self.should_disable_overlay = true + overlay.overlay_command({'enable', 'notes.map_notes'}) + end +end + +function NotesScreen:onDismiss() + if self.should_disable_overlay then + overlay.overlay_command({'disable', 'notes.map_notes'}) + end + + if self.subviews.notes_window.note_manager then + self.subviews.notes_window.note_manager:dismiss() + end + + view = nil +end + +function main() + if not dfhack.isMapLoaded() or not dfhack.world.isFortressMode() then + qerror('notes requires a fortress map to be loaded') + end + + view = view and view:raise() or NotesScreen{}:show() +end + +if not dfhack_flags.module then + main() +end diff --git a/gui/notify.lua b/gui/notify.lua new file mode 100644 index 0000000000..d9b3eaa163 --- /dev/null +++ b/gui/notify.lua @@ -0,0 +1,313 @@ +--@module = true + +local gui = require('gui') +local notifications = reqscript('internal/notify/notifications') +local overlay = require('plugins.overlay') +local utils = require('utils') +local widgets = require('gui.widgets') + +-- +-- NotifyOverlay +-- + +local LIST_MAX_HEIGHT = 5 + +NotifyOverlay = defclass(NotifyOverlay, overlay.OverlayWidget) +NotifyOverlay.ATTRS{ + default_enabled=true, + frame={w=30, h=LIST_MAX_HEIGHT+2}, + right_offset=DEFAULT_NIL, +} + +function NotifyOverlay:init() + self.state = {} + + self:addviews{ + widgets.Panel{ + view_id='panel', + frame_style=gui.MEDIUM_FRAME, + frame_background=gui.CLEAR_PEN, + subviews={ + widgets.List{ + view_id='list', + frame={t=0, b=0, l=0, r=0}, + -- disable scrolling with the keyboard since some people + -- have wasd mapped to the arrow keys + scroll_keys={}, + on_submit=function(_, choice) + if not choice.data.on_click then return end + local prev_state = self.state[choice.data.name] + self.state[choice.data.name] = choice.data.on_click(prev_state, false) + end, + on_submit2=function(_, choice) + if not choice.data.on_click then return end + local prev_state = self.state[choice.data.name] + self.state[choice.data.name] = choice.data.on_click(prev_state, true) + end, + }, + }, + }, + widgets.ConfigureButton{ + frame={t=0, r=1}, + on_click=function() dfhack.run_script('gui/notify') end, + } + } +end + +function NotifyOverlay:onInput(keys) + if keys.SELECT then return false end + + return NotifyOverlay.super.onInput(self, keys) +end + +local function get_fn(notification, is_adv) + if not notification then return end + if is_adv then + return notification.adv_fn or notification.fn + end + return notification.dwarf_fn or notification.fn +end + +function NotifyOverlay:overlay_onupdate() + local choices = {} + local is_adv = dfhack.world.isAdventureMode() + self.critical = false + for _, notification in ipairs(notifications.NOTIFICATIONS_BY_IDX) do + if not notifications.config.data[notification.name].enabled then goto continue end + local fn = get_fn(notification, is_adv) + if not fn then goto continue end + local str = fn() + if str then + table.insert(choices, { + text=str, + data=notification, + }) + self.critical = self.critical or notification.critical + end + ::continue:: + end + local list = self.subviews.list + local idx = 1 + local _, selected = list:getSelected() + if selected then + for i, v in ipairs(choices) do + if v.data.name == selected.data.name then + idx = i + break + end + end + end + list:setChoices(choices, idx) + self.visible = #choices > 0 + if self.frame_parent_rect then + self:preUpdateLayout(self.frame_parent_rect) + end +end + +function NotifyOverlay:preUpdateLayout(parent_rect) + local frame_rect = self.frame_rect + if not frame_rect then return end + local list = self.subviews.list + local list_width, num_choices = list:getContentWidth(), #list:getChoices() + -- +2 for the frame + self.frame.w = math.min(list_width + 2, parent_rect.width - (frame_rect.x1 + self.right_offset)) + if num_choices <= LIST_MAX_HEIGHT then + self.frame.h = num_choices + 2 + else + self.frame.w = self.frame.w + 3 -- for the scrollbar + self.frame.h = LIST_MAX_HEIGHT + 2 + end +end + +-- +-- DwarfNotifyOverlay, AdvNotifyOverlay +-- + +DwarfNotifyOverlay = defclass(DwarfNotifyOverlay, NotifyOverlay) +DwarfNotifyOverlay.ATTRS{ + desc='Shows list of active notifications in fort mode.', + default_pos={x=1,y=-8}, + viewscreens='dwarfmode/Default', + right_offset=3, +} + +local DWARFMODE_CONFLICTING_TOOLTIPS = utils.invert{ + df.main_hover_instruction.MAIN_OPEN_CREATURES, + df.main_hover_instruction.MAIN_OPEN_TASKS, + df.main_hover_instruction.MAIN_OPEN_PLACES, + df.main_hover_instruction.MAIN_OPEN_LABOR, + df.main_hover_instruction.MAIN_OPEN_WORK_ORDERS, + df.main_hover_instruction.MAIN_OPEN_NOBLES, + df.main_hover_instruction.MAIN_OPEN_OBJECTS, + df.main_hover_instruction.MAIN_OPEN_JUSTICE, +} + +local mi = df.global.game.main_interface + +function DwarfNotifyOverlay:render(dc) + if not DWARFMODE_CONFLICTING_TOOLTIPS[mi.current_hover] then + NotifyOverlay.super.render(self, dc) + end +end + +AdvNotifyOverlay = defclass(AdvNotifyOverlay, NotifyOverlay) +AdvNotifyOverlay.ATTRS{ + desc='Shows list of active notifications in adventure mode.', + default_pos={x=18,y=-5}, + viewscreens='dungeonmode/Default', + overlay_onupdate_max_freq_seconds=1, + right_offset=13, +} + +function AdvNotifyOverlay:set_width() + local desired_width = 13 + if df.global.adventure.player_control_state ~= df.adventure_game_loop_type.TAKING_INPUT then + local offset = self.frame_parent_rect.width > 137 and 26 or + (self.frame_parent_rect.width+1) // 2 - 43 + desired_width = self.frame_parent_rect.width // 2 + offset + end + if self.right_offset ~= desired_width then + self.right_offset = desired_width + self:updateLayout() + end +end + +function AdvNotifyOverlay:render(dc) + if mi.current_hover > -1 then return end + self:set_width() + if self.critical and self.prev_tick_counter ~= df.global.adventure.tick_counter then + self.prev_tick_counter = df.global.adventure.tick_counter + self:overlay_onupdate() + end + AdvNotifyOverlay.super.render(self, dc) +end + +OVERLAY_WIDGETS = { + panel=DwarfNotifyOverlay, + advpanel=AdvNotifyOverlay, +} + +-- +-- Notify +-- + +Notify = defclass(Notify, widgets.Window) +Notify.ATTRS{ + frame_title='Notification settings', + frame={w=40, h=22}, +} + +function Notify:init() + self:addviews{ + widgets.Panel{ + frame={t=0, l=0, b=7}, + frame_style=gui.FRAME_INTERIOR, + subviews={ + widgets.List{ + view_id='list', + on_submit=self:callback('toggle'), + on_select=function(_, choice) + self.subviews.desc.text_to_wrap = choice and choice.desc or '' + if self.frame_parent_rect then + self:updateLayout() + end + end, + }, + }, + }, + widgets.Panel{ + frame={b=2, l=0, h=5}, + frame_style=gui.FRAME_INTERIOR, + subviews={ + widgets.WrappedLabel{ + view_id='desc', + auto_height=false, + }, + }, + }, + widgets.HotkeyLabel{ + frame={b=0, l=0}, + label='Toggle', + key='SELECT', + auto_width=true, + on_activate=function() self:toggle(self.subviews.list:getSelected()) end, + }, + widgets.HotkeyLabel{ + frame={b=0, l=15}, + label='Toggle all', + key='CUSTOM_CTRL_A', + auto_width=true, + on_activate=self:callback('toggle_all'), + }, + } + + self:refresh() +end + +function Notify:refresh() + local choices = {} + local is_adv = dfhack.world.isAdventureMode() + for name, conf in pairs(notifications.config.data) do + local notification = notifications.NOTIFICATIONS_BY_NAME[name] + if not get_fn(notification, is_adv) then goto continue end + table.insert(choices, { + name=name, + desc=notification.desc, + enabled=conf.enabled, + text={ + ('%20s: '):format(name), + { + text=conf.enabled and 'Enabled' or 'Disabled', + pen=conf.enabled and COLOR_GREEN or COLOR_RED, + } + } + }) + ::continue:: + end + table.sort(choices, function(a, b) return a.name < b.name end) + local list = self.subviews.list + local selected = list:getSelected() + list:setChoices(choices) + list:setSelected(selected) +end + +function Notify:toggle(_, choice) + if not choice then return end + notifications.config.data[choice.name].enabled = not choice.enabled + notifications.config:write() + self:refresh() +end + +function Notify:toggle_all() + local choice = self.subviews.list:getChoices()[1] + if not choice then return end + local target_state = not choice.enabled + for name in pairs(notifications.NOTIFICATIONS_BY_NAME) do + notifications.config.data[name].enabled = target_state + end + notifications.config:write() + self:refresh() +end + +-- +-- NotifyScreen +-- + +NotifyScreen = defclass(NotifyScreen, gui.ZScreen) +NotifyScreen.ATTRS { + focus_path='notify', +} + +function NotifyScreen:init() + self:addviews{Notify{}} +end + +function NotifyScreen:onDismiss() + view = nil +end + +if dfhack_flags.module then + return +end + +view = view and view:raise() or NotifyScreen{}:show() diff --git a/gui/overlay.lua b/gui/overlay.lua index 2a671a0b2c..0df5d842d9 100644 --- a/gui/overlay.lua +++ b/gui/overlay.lua @@ -54,15 +54,12 @@ DraggablePanel.ATTRS{ draggable=true, drag_anchors={frame=true, body=true}, drag_bound='body', + widget=DEFAULT_NIL, } function DraggablePanel:onInput(keys) - if keys._MOUSE_L then - local rect = self.frame_rect - local x,y = self:getMousePos(gui.ViewRect{rect=rect}) - if x then - self.on_click() - end + if keys._MOUSE_L and self:getMousePos() then + self.on_click() end return DraggablePanel.super.onInput(self, keys) end @@ -72,16 +69,16 @@ function DraggablePanel:postUpdateLayout() local frame = self.frame local matcher = {t=not not frame.t, b=not not frame.b, l=not not frame.l, r=not not frame.r} - local parent_rect, frame_rect = self.frame_parent_rect, self.frame_rect - if frame_rect.y1 <= parent_rect.y1 then - frame.t, frame.b = frame_rect.y1-parent_rect.y1, nil - elseif frame_rect.y2 >= parent_rect.y2 then - frame.t, frame.b = nil, parent_rect.y2-frame_rect.y2 + local parent_rect, frame_rect = self.widget.frame_parent_rect, self.frame_body + if frame_rect.y1-1 <= parent_rect.y1 then + frame.t, frame.b = frame_rect.y1-parent_rect.y1-1, nil + elseif frame_rect.y2+1 >= parent_rect.y2 then + frame.t, frame.b = nil, parent_rect.y2-frame_rect.y2-1 end - if frame_rect.x1 <= parent_rect.x1 then - frame.l, frame.r = frame_rect.x1-parent_rect.x1, nil - elseif frame_rect.x2 >= parent_rect.x2 then - frame.l, frame.r = nil, parent_rect.x2-frame_rect.x2 + if frame_rect.x1-1 <= parent_rect.x1 then + frame.l, frame.r = frame_rect.x1-parent_rect.x1-1, nil + elseif frame_rect.x2+1 >= parent_rect.x2 then + frame.l, frame.r = nil, parent_rect.x2-frame_rect.x2-1 end self.frame_style = make_highlight_frame_style(self.frame) if not not frame.t ~= matcher.t or not not frame.b ~= matcher.b @@ -92,7 +89,7 @@ function DraggablePanel:postUpdateLayout() end function DraggablePanel:onRenderFrame(dc, rect) - if self:getMousePos(gui.ViewRect{rect=self.frame_rect}) then + if self:getMousePos() then self.frame_background = to_pen{ ch=32, fg=COLOR_LIGHTGREEN, bg=COLOR_LIGHTGREEN} else @@ -105,13 +102,14 @@ end -- OverlayConfig -- ------------------- -OverlayConfig = defclass(OverlayConfig, gui.Screen) +OverlayConfig = defclass(OverlayConfig, gui.Screen) -- not a ZScreen since we want to freeze the underlying UI function OverlayConfig:init() -- prevent hotspot widgets from reacting overlay.register_trigger_lock_screen(self) local contexts = dfhack.gui.getFocusStrings(dfhack.gui.getDFViewscreen(true)) + local interface_width_pct = df.global.init.display.max_interface_percentage local main_panel = widgets.Window{ frame={w=DIALOG_WIDTH, h=LIST_HEIGHT+15}, @@ -124,19 +122,25 @@ function OverlayConfig:init() frame={t=0, l=0}, text={ 'Current contexts: ', - {text=table.concat(contexts, ', '), pen=COLOR_CYAN} + {text=table.concat(contexts, ', '), pen=COLOR_CYAN}, + }}, + widgets.Label{ + frame={t=2, l=0}, + text={ + 'Interface width percent: ', + {text=interface_width_pct, pen=COLOR_CYAN}, }}, widgets.CycleHotkeyLabel{ view_id='filter', - frame={t=2, l=0}, + frame={t=4, l=0}, key='CUSTOM_CTRL_O', - label='Showing:', + label='Showing', options={{label='overlays for the current contexts', value='cur'}, {label='all overlays', value='all'}}, on_change=self:callback('refresh_list')}, widgets.FilteredList{ view_id='list', - frame={t=4, b=7}, + frame={t=6, b=7}, on_select=self:callback('highlight_selected'), }, widgets.HotkeyLabel{ @@ -156,11 +160,25 @@ function OverlayConfig:init() widgets.WrappedLabel{ frame={b=0, l=0}, scroll_keys={}, - text_to_wrap='When repositioning a widget, touch an edge of the'.. - ' screen to anchor the widget to that edge.', + text_to_wrap='When repositioning a widget, touch a boundary edge'.. + ' to anchor the widget to that edge.', + }, + } + + self:addviews{ + widgets.Divider{ + view_id='left_border', + frame={l=0, w=1}, + frame_style=gui.FRAME_THIN, + }, + widgets.Divider{ + view_id='right_border', + frame={r=0, w=1}, + frame_style=gui.FRAME_THIN, }, + main_panel, } - self:addviews{main_panel} + self:refresh_list() end @@ -184,7 +202,11 @@ function OverlayConfig:refresh_list(filter) for _,name in ipairs(state.index) do local db_entry = state.db[name] local widget = db_entry.widget - if widget.overlay_only then goto continue end + if widget.fullscreen or widget.full_interface or + widget.frame.w == 0 or widget.frame.h == 0 + then + goto continue + end if (not widget.hotspot or #widget.viewscreens > 0) and filter ~= 'all' then for _,vs in ipairs(overlay.normalize_list(widget.viewscreens)) do if dfhack.gui.matchFocusString(overlay.simplify_viewscreen_name(vs), scr) then @@ -194,20 +216,23 @@ function OverlayConfig:refresh_list(filter) goto continue end ::matched:: - local panel = nil - panel = DraggablePanel{ - frame=make_highlight_frame(widget.frame), - frame_style=SHADOW_FRAME, - on_click=make_on_click_fn(#choices+1), - name=name} + local panel = DraggablePanel{ + frame=make_highlight_frame(widget.frame), + frame_style=SHADOW_FRAME, + on_click=make_on_click_fn(#choices+1), + name=name, + widget=widget, + } panel.on_drag_end = function(success) if (success) then local frame = panel.frame - local posx = frame.l and tostring(frame.l+2) - or tostring(-(frame.r+2)) - local posy = frame.t and tostring(frame.t+2) - or tostring(-(frame.b+2)) - overlay.overlay_command({'position', name, posx, posy},true) + local frame_rect = panel.frame_rect + local frame_parent_rect = panel.frame_parent_rect + local posx = frame.l and tostring(frame_rect.x1+2) + or tostring(frame_rect.x2-frame_parent_rect.width-1) + local posy = frame.t and tostring(frame_rect.y1+2) + or tostring(frame_rect.y2-frame_parent_rect.height-1) + overlay.overlay_command({'position', name, posx, posy}, true) end self.reposition_panel = nil end @@ -222,7 +247,7 @@ function OverlayConfig:refresh_list(filter) end}) table.insert(choices, {text=tokens, enabled=cfg.enabled, name=name, panel=panel, - search_key=name}) + widget=widget, search_key=name}) ::continue:: end local old_filter = list:getFilter() @@ -259,21 +284,31 @@ function OverlayConfig:reposition(_, obj) end function OverlayConfig:reset() - local idx,obj = self.subviews.list:getSelected() + local _,obj = self.subviews.list:getSelected() if not obj or not obj.panel then return end overlay.overlay_command({'position', obj.panel.name, 'default'}, true) self:refresh_list(self.subviews.filter:getOptionValue()) + self:updateLayout() end function OverlayConfig:onDismiss() view = nil end +function OverlayConfig:preUpdateLayout(parent_rect) + local interface_rect = gui.get_interface_rect() + local left, right = self.subviews.left_border, self.subviews.right_border + left.frame.l = interface_rect.x1 - 1 + left.visible = left.frame.l >= 0 + right.frame.r = nil + right.frame.l = interface_rect.x2 + 1 + right.visible = right.frame.l < parent_rect.width +end + function OverlayConfig:postUpdateLayout() + local rect = gui.ViewRect{rect=gui.get_interface_rect()} for _,choice in ipairs(self.subviews.list:getChoices()) do - if choice.panel then - choice.panel:updateLayout(self.frame_parent_rect) - end + choice.panel:updateLayout(rect) end end @@ -302,17 +337,18 @@ function OverlayConfig:onInput(keys) end end -function OverlayConfig:onRenderFrame(dc, rect) +function OverlayConfig:onRenderFrame() self:renderParent() + local interface_area_painter = gui.Painter.new(gui.ViewRect{rect=gui.get_interface_rect()}) for _,choice in ipairs(self.subviews.list:getVisibleChoices()) do local panel = choice.panel if panel and panel ~= self.selected_panel then - panel:render(dc) + panel:render(interface_area_painter) end end if self.selected_panel then self.render_selected_panel = function() - self.selected_panel:render(dc) + self.selected_panel:render(interface_area_painter) end else self.render_selected_panel = nil diff --git a/gui/pathable.lua b/gui/pathable.lua index 868ce5d7f6..8958b391bf 100644 --- a/gui/pathable.lua +++ b/gui/pathable.lua @@ -1,63 +1,54 @@ --- View whether tiles on the map can be pathed to +-- View whether tiles on the map can be pathed to. --@module=true local gui = require('gui') local plugin = require('plugins.pathable') local widgets = require('gui.widgets') -Pathable = defclass(Pathable, gui.ZScreen) -Pathable.ATTRS{ - focus_path='pathable', -} +-- ------------------------------ +-- FollowMousePage -function Pathable:init() - local window = widgets.Window{ - view_id='main', - frame={t=20, r=3, w=32, h=11}, - frame_title='Pathability Viewer', - drag_anchors={title=true, frame=true, body=true}, - } +FollowMousePage = defclass(FollowMousePage, widgets.Panel) - window:addviews{ +function FollowMousePage:init() + self:addviews{ widgets.ToggleHotkeyLabel{ - view_id='lock', + view_id='draw', frame={t=0, l=0}, - key='CUSTOM_CTRL_T', - label='Lock target', - initial_option=false, + key='CUSTOM_CTRL_D', + label='Draw:', + label_width=12, + initial_option=true, }, widgets.ToggleHotkeyLabel{ - view_id='draw', + view_id='lock', frame={t=1, l=0}, - key='CUSTOM_CTRL_D', - label='Draw', - initial_option=true, + key='CUSTOM_CTRL_M', + label='Lock target:', + initial_option=false, }, widgets.ToggleHotkeyLabel{ view_id='show', frame={t=2, l=0}, key='CUSTOM_CTRL_U', - label='Show hidden', + label='Show hidden:', initial_option=false, }, - widgets.EditField{ - view_id='group', + widgets.Label{ frame={t=4, l=0}, - label_text='Pathability group: ', - active=false, + text='Pathability group:', }, - widgets.HotkeyLabel{ - frame={t=6, l=0}, - key='LEAVESCREEN', - label='Close', - on_activate=self:callback('dismiss'), + widgets.Label{ + view_id='group', + frame={t=4, l=19, h=1}, + text='', + text_pen=COLOR_LIGHTCYAN, + auto_height=false, }, } - - self:addviews{window} end -function Pathable:onRenderBody() +function FollowMousePage:onRenderBody() local target = self.subviews.lock:getOptionValue() and self.saved_target or dfhack.gui.getMousePos() self.saved_target = target @@ -67,21 +58,140 @@ function Pathable:onRenderBody() if not target then group:setText('') - return elseif not show and not dfhack.maps.isTileVisible(target) then group:setText('Hidden') - return + else + local walk_group = dfhack.maps.getWalkableGroup(target) + group:setText(walk_group == 0 and 'None' or tostring(walk_group)) + + if self.subviews.draw:getOptionValue() then + plugin.paintScreenPathable(target, show) + end end +end - local walk_group = dfhack.maps.getWalkableGroup(target) - group:setText(walk_group == 0 and 'None' or tostring(walk_group)) +-- ------------------------------ +-- DepotPage - if self.subviews.draw:getOptionValue() then - plugin.paintScreenPathable(target, show) +DepotPage = defclass(DepotPage, widgets.Panel) +DepotPage.ATTRS { + force_pause=true, +} + +function DepotPage:init() + if dfhack.world.isAdventureMode() then + self:addviews{ + widgets.WrappedLabel{ + text_to_wrap='Not available in adventure mode.', + text_pen=COLOR_YELLOW, + }, + } + return end + + self.animals = false + self.wagons = false + + local TD = df.global.world.buildings.other.TRADE_DEPOT + local depot_idx = 0 + + self:addviews{ + widgets.Label{ + frame={t=0, l=0}, + text='Depot is reachable by:', + }, + widgets.Label{ + frame={t=1, l=2}, + text={ + 'Pack animals:', + {gap=1, text=function() return self.animals and 'Yes' or 'No' end, + pen=function() return self.animals and COLOR_GREEN or COLOR_RED end}, + }, + }, + widgets.Label{ + frame={t=2, l=8}, + text={ + 'Wagons:', + {gap=1, text=function() return self.wagons and 'Yes' or 'No' end, + pen=function() return self.wagons and COLOR_GREEN or COLOR_RED end}, + }, + }, + widgets.HotkeyLabel{ + frame={b=0, l=0}, + key='CUSTOM_CTRL_D', + label='Zoom to depot', + on_activate=function() + depot_idx = depot_idx + 1 + if depot_idx >= #TD then depot_idx = 0 end + local bld = TD[depot_idx] + dfhack.gui.revealInDwarfmodeMap(xyz2pos(bld.centerx, bld.centery, bld.z), true, true) + end, + enabled=#TD > 0, + }, + } +end + +function DepotPage:on_show() + self.animals = plugin.getDepotAccessibleByAnimals() + self.wagons = plugin.getDepotAccessibleByWagons(true) +end + +function DepotPage:onRenderBody() + plugin.paintScreenDepotAccess() +end + +-- ------------------------------ +-- Pathable + +Pathable = defclass(Pathable, widgets.Window) +Pathable.ATTRS { + frame_title='Pathability Viewer', + frame={t=18, r=2, w=30, h=12}, +} + +function Pathable:init() + self:addviews{ + widgets.TabBar{ + frame={t=0, l=0}, + labels={ + 'Follow mouse', + 'Depot', + }, + on_select=function(idx) + self.subviews.pages:setSelected(idx) + local _, page = self.subviews.pages:getSelected() + self.parent_view.force_pause = page.force_pause + if page.on_show then + page:on_show() + end + end, + get_cur_page=function() return self.subviews.pages:getSelected() end, + }, + widgets.Pages{ + view_id='pages', + frame={t=3, l=0, b=0, r=0}, + subviews={ + FollowMousePage{}, + DepotPage{}, + }, + }, + } end -function Pathable:onDismiss() +-- ------------------------------ +-- PathableScreen + +PathableScreen = defclass(PathableScreen, gui.ZScreen) +PathableScreen.ATTRS { + focus_path='pathable', + pass_movement_keys=true, +} + +function PathableScreen:init() + self:addviews{Pathable{}} +end + +function PathableScreen:onDismiss() view = nil end @@ -89,8 +199,11 @@ if dfhack_flags.module then return end +-- ------------------------------ +-- CLI + if not dfhack.isMapLoaded() then qerror('gui/pathable requires a map to be loaded') end -view = view and view:raise() or Pathable{}:show() +view = view and view:raise() or PathableScreen{}:show() diff --git a/gui/petitions.lua b/gui/petitions.lua index 0449ad247a..13dea999b0 100644 --- a/gui/petitions.lua +++ b/gui/petitions.lua @@ -1,322 +1,107 @@ -- Show fort's petitions, pending and fulfilled. ---[====[ - -gui/petitions -============= -Show fort's petitions, pending and fulfilled. - -For best experience add following to your ``dfhack*.init``:: - - keybinding add Alt-P@dwarfmode/Default gui/petitions - -]====] local gui = require 'gui' +local list_agreements = reqscript('list-agreements') local widgets = require 'gui.widgets' -local utils = require 'utils' - --- local args = utils.invert({...}) - ---[[ -[lua]# @ df.agreement_details_type - -0 = JoinParty -1 = DemonicBinding -2 = Residency -3 = Citizenship -4 = Parley -5 = PositionCorruption -6 = PlotStealArtifact -7 = PromisePosition -8 = PlotAssassination -9 = PlotAbduct -10 = PlotSabotage -11 = PlotConviction -12 = Location -13 = PlotInfiltrationCoup -14 = PlotFrameTreason -15 = PlotInduceWar -]] - -if not dfhack.world.isFortressMode() then return end - --- from gui/unit-info-viewer.lua -do -- for code folding --------------------------------------------------- ----------------------- Time ---------------------- --------------------------------------------------- -local TU_PER_DAY = 1200 ---[[ -if advmode then TU_PER_DAY = 86400 ? or only for cur_year_tick? -advmod_TU / 72 = ticks ---]] -local TU_PER_MONTH = TU_PER_DAY * 28 -local TU_PER_YEAR = TU_PER_MONTH * 12 - -local MONTHS = { - 'Granite', - 'Slate', - 'Felsite', - 'Hematite', - 'Malachite', - 'Galena', - 'Limestone', - 'Sandstone', - 'Timber', - 'Moonstone', - 'Opal', - 'Obsidian', -} -Time = defclass(Time) -function Time:init(args) - self.year = args.year or 0 - self.ticks = args.ticks or 0 -end -function Time:getDays() -- >>float<< Days as age (including years) - return self.year * 336 + (self.ticks / TU_PER_DAY) -end -function Time:getDayInMonth() - return math.floor ( (self.ticks % TU_PER_MONTH) / TU_PER_DAY ) + 1 -end -function Time:getMonths() -- >>int<< Months as age (not including years) - return math.floor (self.ticks / TU_PER_MONTH) -end -function Time:getYears() -- >>int<< - return self.year -end -function Time:getMonthStr() -- Month as date - return MONTHS[self:getMonths()+1] or 'error' -end -function Time:getDayStr() -- Day as date - local d = math.floor ( (self.ticks % TU_PER_MONTH) / TU_PER_DAY ) + 1 - if d == 11 or d == 12 or d == 13 then - d = tostring(d)..'th' - elseif d % 10 == 1 then - d = tostring(d)..'st' - elseif d % 10 == 2 then - d = tostring(d)..'nd' - elseif d % 10 == 3 then - d = tostring(d)..'rd' - else - d = tostring(d)..'th' - end - return d -end ---function Time:__add() ---end -function Time:__sub(other) - if DEBUG then print(self.year,self.ticks) end - if DEBUG then print(other.year,other.ticks) end - if self.ticks < other.ticks then - return Time{ year = (self.year - other.year - 1) , ticks = (TU_PER_YEAR + self.ticks - other.ticks) } - else - return Time{ year = (self.year - other.year) , ticks = (self.ticks - other.ticks) } - end -end --------------------------------------------------- --------------------------------------------------- -end - -local we = df.global.plotinfo.group_id - -local function getAgreementDetails(a) - local sb = {} -- StringBuilder - - sb[#sb+1] = {text = "Agreement #" ..a.id, pen = COLOR_RED} - sb[#sb+1] = NEWLINE - - local us = "Us" - local them = "Them" - for i, p in ipairs(a.parties) do - local e_descr = {} - local our = false - for _, e_id in ipairs(p.entity_ids) do - local e = df.global.world.entities.all[e_id] - e_descr[#e_descr+1] = table.concat{"The ", df.historical_entity_type[e.type], " ", dfhack.TranslateName(e.name, true)} - if we == e_id then our = true end - end - for _, hf_id in ipairs(p.histfig_ids) do - local hf = df.global.world.history.figures[hf_id] - local race = df.creature_raw.find(hf.race) - local civ = df.historical_entity.find(hf.civ_id) - e_descr[#e_descr+1] = table.concat{ - "The ", race.creature_id, - " ", df.profession[hf.profession], - " ", dfhack.TranslateName(hf.name, true), - NEWLINE, - "of ", dfhack.TranslateName(civ.name, true) - } - end - - if our then - us = table.concat(e_descr, ", ") - else - them = table.concat(e_descr, ", ") - end - end - sb[#sb+1] = them - sb[#sb+1] = NEWLINE - sb[#sb+1] = " petitioned" - sb[#sb+1] = NEWLINE - sb[#sb+1] = us - sb[#sb+1] = NEWLINE - local expired = false - for _, d in ipairs (a.details) do - local petition_date = Time{year = d.year, ticks = d.year_tick} - local petition_date_str = petition_date:getDayStr()..' of '..petition_date:getMonthStr()..' in the year '..tostring(petition_date.year) - local cur_date = Time{year = df.global.cur_year, ticks = df.global.cur_year_tick} - sb[#sb+1] = ("On " .. petition_date_str) - sb[#sb+1] = NEWLINE - local diff = (cur_date - petition_date) - expired = expired or diff:getYears() >= 1 - if diff:getDays() < 1.0 then - sb[#sb+1] = ("(this was today)") - elseif diff:getMonths() == 0 then - sb[#sb+1] = ("(this was " .. math.floor( diff:getDays() ) .. " days ago)" ) - elseif diff:getYears() == 0 then - sb[#sb+1] = ("(this was " .. diff:getMonths() .. " months and " .. diff:getDayInMonth() .. " days ago)" ) - elseif diff:getYears() == 1 then - sb[#sb+1] = ("(this was " .. diff:getYears() .. " year " .. diff:getMonths() .. " months and " .. diff:getDayInMonth() .. " days ago)" ) - else - sb[#sb+1] = ("(this was " .. diff:getYears() .. " years " .. diff:getMonths() .. " months and " .. diff:getDayInMonth() .. " days ago)" ) - end - sb[#sb+1] = NEWLINE - - sb[#sb+1] = ("Petition type: " .. df.agreement_details_type[d.type]) - sb[#sb+1] = NEWLINE - if d.type == df.agreement_details_type.Location then - local details = d.data.Location - sb[#sb+1] = "Provide a " - sb[#sb+1] = {text = df.abstract_building_type[details.type], pen = COLOR_LIGHTGREEN} - sb[#sb+1] = " of tier " - sb[#sb+1] = {text = details.tier, pen = COLOR_LIGHTGREEN} - if details.deity_type ~= -1 then - sb[#sb+1] = " of a " - -- None/Deity/Religion - sb[#sb+1] = {text = df.temple_deity_type[details.deity_type], pen = COLOR_LIGHTGREEN} - else - sb[#sb+1] = " for " - sb[#sb+1] = {text = df.profession[details.profession], pen = COLOR_LIGHTGREEN} - end - sb[#sb+1] = NEWLINE - elseif d.type == df.agreement_details_type.Residency then - local details = d.data.Residency - sb[#sb+1] = " to " - sb[#sb+1] = {text = df.history_event_reason[details.reason], pen = COLOR_LIGHTGREEN} - sb[#sb+1] = NEWLINE - end - end - - local petition = {} - - if a.flags.petition_not_accepted then - sb[#sb+1] = {text = "This petition wasn't accepted yet!", pen = COLOR_YELLOW} - petition.status = 'PENDING' - elseif a.flags.convicted_accepted then - sb[#sb+1] = {text = "This petition was fulfilled!", pen = COLOR_GREEN} - petition.status = 'FULFILLED' - elseif expired then - sb[#sb+1] = {text = "This petition expired!", pen = COLOR_LIGHTRED} - petition.status = 'EXPIRED' - else - petition.status = 'ACCEPTED' - end - - petition.text = sb - - return petition -end - -local getAgreements = function() - local list = {} - - local ags = df.global.world.agreements.all - for i, a in ipairs(ags) do - for _, p in ipairs(a.parties) do - for _, e in ipairs(p.entity_ids) do - if e == we then - list[#list+1] = getAgreementDetails(a) - end - end - end - end - - return list -end - -local petitions = defclass(petitions, gui.FramedScreen) -petitions.ATTRS = { - frame_style = gui.GREY_LINE_FRAME, - frame_title = 'Petitions', - frame_width = 21, -- is calculated in :refresh - min_frame_width = 21, - frame_height = 26, - frame_inset = 0, - focus_path = 'petitions', +Petitions = defclass(Petitions, widgets.Window) +Petitions.ATTRS { + frame_title='Petitions', + frame={w=110, h=30}, + resizable=true, + resize_min={w=70, h=15}, } -function petitions:init(args) - self.list = args.list - -- self.fulfilled = true +function Petitions:init() self:addviews{ - widgets.Label{ - view_id = 'text', - frame_inset = 0, + widgets.List{ + view_id='list', + frame={t=0, l=0, r=0, b=2}, + row_height=3, + }, + widgets.ToggleHotkeyLabel{ + view_id='show_fulfilled', + frame={b=0, l=0, w=33}, + key='CUSTOM_CTRL_A', + label='Show past agreements:', + initial_option=false, + on_change=function() self:refresh() end, }, } self:refresh() + local list = self.subviews.list + self.frame.w = math.max(list:getContentWidth() + 6, self.resize_min.w) + self.frame.h = math.max(list:getContentHeight() + 6, self.resize_min.h) end -function petitions:refresh() - local lines = {} - -- list of petitions - for _, p in ipairs(self.list) do - if not self.fulfilled and (p.status == 'FULFILLED' or p.status == 'EXPIRED') then goto continue end - -- each petition is a status and a text - for _, tok in ipairs(p.text) do - -- where text is a list of tokens - table.insert(lines, tok) - end - table.insert(lines, NEWLINE) - ::continue:: +local function get_choice_text(agr) + local loctype = list_agreements.get_location_type(agr) + local loc_name = list_agreements.get_location_name(agr.details[0].data.Location.tier, loctype) + local agr_age = list_agreements.get_petition_age(agr) + local resolved, resolution_string = list_agreements.is_resolved(agr) + + local details_pre, details_target, details_post + if loctype == df.abstract_building_type.TEMPLE then + details_pre = 'worshiping ' + details_target = list_agreements.get_deity_name(agr) + details_post = '' + else + details_pre = 'a ' + details_target = list_agreements.get_guildhall_profession(agr) + details_post = ' guild' end - table.remove(lines, #lines) -- remove last NEWLINE - local label = self.subviews.text - label:setText(lines) + return { + 'Establish a ', + {text=loc_name, pen=COLOR_WHITE}, + ' for ', + {text=list_agreements.get_agr_party_name(agr), pen=COLOR_BROWN}, + ', ', + details_pre, + {text=details_target, pen=COLOR_MAGENTA}, + details_post, + ',', + NEWLINE, + {gap=4, text='as agreed on '}, + list_agreements.get_petition_date(agr), + ', ', + ('%dy, %dm, %dd ago '):format(agr_age[1], agr_age[2], agr_age[3]), + {text=('(%s)'):format(resolution_string), pen=resolved and COLOR_GREEN or COLOR_YELLOW}, + } +end - -- changing text doesn't automatically change scroll position - if label.frame_body then - local last_visible_line = label.start_line_num + label.frame_body.height - 1 - if last_visible_line > label:getTextHeight() then - label.start_line_num = math.max(label:getTextHeight() - label.frame_body.height + 1, 1) - end +function Petitions:refresh() + local cull_resolved = not self.subviews.show_fulfilled:getOptionValue() + local t_agr, g_agr = list_agreements.get_fort_agreements(cull_resolved) + local choices = {} + for _, agr in ipairs(t_agr) do + table.insert(choices, {text=get_choice_text(agr)}) end - - self.frame_width = math.max(label:getTextWidth()+1, self.min_frame_width) - self.frame_width = math.min(df.global.gps.dimx - 2, self.frame_width) - self.frame_height = math.min(df.global.gps.dimy - 4, self.frame_height) - self:onResize(dfhack.screen.getWindowSize()) -- applies new frame_width + for _, agr in ipairs(g_agr) do + table.insert(choices, {text=get_choice_text(agr)}) + end + if #choices == 0 then + table.insert(choices, {text='No outstanding agreements'}) + end + self.subviews.list:setChoices(choices) end -function petitions:onRenderFrame(painter, frame) - petitions.super.onRenderFrame(self, painter, frame) +PetitionsScreen = defclass(PetitionsScreen, gui.ZScreen) +PetitionsScreen.ATTRS { + focus_path='petitions', +} - painter:seek(frame.x1+2, frame.y1 + frame.height-1):key_string('CUSTOM_F', "toggle fulfilled") +function PetitionsScreen:init() + self:addviews{Petitions{}} end -function petitions:onInput(keys) - if petitions.super.onInput(self, keys) then return end +function PetitionsScreen:onDismiss() + view = nil +end - if keys.LEAVESCREEN or keys.SELECT then - self:dismiss() - elseif keys.CUSTOM_F then - self.fulfilled = not self.fulfilled - self:refresh() - end +if not dfhack.world.isFortressMode() or not dfhack.isMapLoaded() then + qerror('gui/petitions requires a fortress map to be loaded') end -df.global.pause_state = true -petitions{list=getAgreements()}:show() +view = view and view:raise() or PetitionsScreen{}:show() diff --git a/gui/quantum.lua b/gui/quantum.lua index b8d93d2087..08062a18d0 100644 --- a/gui/quantum.lua +++ b/gui/quantum.lua @@ -1,4 +1,3 @@ --- interactively creates quantum stockpiles --@ module = true local dialogs = require('gui.dialogs') @@ -11,344 +10,368 @@ local quickfort = reqscript('quickfort') local quickfort_command = reqscript('internal/quickfort/command') local quickfort_orders = reqscript('internal/quickfort/orders') -QuantumUI = defclass(QuantumUI, guidm.MenuOverlay) -QuantumUI.ATTRS { - frame_inset=1, - focus_path='quantum', - sidebar_mode=df.ui_sidebar_mode.LookAround, -} +local function get_qsp_pos(cursor, offset) + return { + x=cursor.x+(offset.x or 0), + y=cursor.y+(offset.y or 0), + z=cursor.z + } +end -function QuantumUI:init() - local cart_count = #assign_minecarts.get_free_vehicles() +local function is_valid_pos(cursor, qsp_pos) + local stats = quickfort.apply_blueprint{mode='build', data='trackstop', pos=cursor, dry_run=true} + if stats.build_designated.value <= 0 then return false end - local main_panel = widgets.Panel{autoarrange_subviews=true, - autoarrange_gap=1} - main_panel:addviews{ - widgets.Label{text='Quantum'}, - widgets.WrappedLabel{ - text_to_wrap=self:callback('get_help_text'), - text_pen=COLOR_GREY}, - widgets.ResizingPanel{autoarrange_subviews=true, subviews={ - widgets.EditField{ - view_id='name', - key='CUSTOM_N', - on_char=self:callback('on_name_char'), - text=''}, - widgets.TooltipLabel{ - text_to_wrap='Give the quantum stockpile a custom name.', - show_tooltip=true}}}, - widgets.ResizingPanel{autoarrange_subviews=true, subviews={ - widgets.CycleHotkeyLabel{ - view_id='dir', - key='CUSTOM_D', - options={{label='North', value={y=-1}}, - {label='South', value={y=1}}, - {label='East', value={x=1}}, - {label='West', value={x=-1}}}}, - widgets.TooltipLabel{ - text_to_wrap='Set the dump direction of the quantum stop.', - show_tooltip=true}}}, - widgets.ResizingPanel{autoarrange_subviews=true, subviews={ - widgets.ToggleHotkeyLabel{ - view_id='refuse', - key='CUSTOM_R', - label='Allow refuse/corpses', - initial_option=false}, - widgets.TooltipLabel{ - text_to_wrap='Note that enabling refuse will cause clothes' .. - ' and armor in this stockpile to quickly rot away.', - show_tooltip=true}}}, - widgets.WrappedLabel{ - text_to_wrap=('%d minecart%s available: %s will be %s'):format( - cart_count, cart_count == 1 and '' or 's', - cart_count == 1 and 'it' or 'one', - cart_count > 0 and 'automatically assigned to the quantum route' - or 'ordered via the manager for you to assign later')}, - widgets.HotkeyLabel{ - key='LEAVESCREEN', - label=self:callback('get_back_text'), - on_activate=self:callback('on_back')} - } + if not qsp_pos then return true end - self:addviews{main_panel} + stats = quickfort.apply_blueprint{mode='place', data='c', pos=qsp_pos, dry_run=true} + return stats.place_designated.value > 0 end -function QuantumUI:get_help_text() - if not self.feeder then - return 'Please select the feeder stockpile with the cursor or mouse.' - end - return 'Please select the location of the new quantum stockpile with the' .. - ' cursor or mouse.' -end -function QuantumUI:get_back_text() - if self.feeder then - return 'Cancel selection' +local function get_quantumstop_data(feeders, name, trackstop_dir) + local stop_name, route_name + if name == '' then + local next_route_id = df.global.plotinfo.hauling.next_id + stop_name = ('Dumper %d'):format(next_route_id) + route_name = ('Quantum %d'):format(next_route_id) + else + stop_name = ('%s dumper'):format(name) + route_name = ('%s quantum'):format(name) end - return 'Back' -end -function QuantumUI:on_back() - if self.feeder then - self.feeder = nil - self:updateLayout() - else - self:dismiss() + local feeder_ids = {} + for _, feeder in ipairs(feeders) do + table.insert(feeder_ids, tostring(feeder.id)) end -end -function QuantumUI:on_name_char(char, text) - return #text < 12 + return ('trackstop%s{name="%s" take_from=%s route="%s"}') + :format(trackstop_dir, stop_name, table.concat(feeder_ids, ','), route_name) end -local function is_in_extents(bld, x, y) - local extents = bld.room.extents - if not extents then return true end -- building is solid - local yoff = (y - bld.y1) * (bld.x2 - bld.x1 + 1) - local xoff = x - bld.x1 - return extents[yoff+xoff] == 1 +local function get_quantumsp_data(name) + if name == '' then + local next_route_id = df.global.plotinfo.hauling.next_id + name = ('Quantum %d'):format(next_route_id-1) + end + return ('ry{name="%s" quantum=true}:+all'):format(name) end -function QuantumUI:select_stockpile(pos) - local flags, occupancy = dfhack.maps.getTileFlags(pos) - if not flags or occupancy.building == 0 then return end - local bld = dfhack.buildings.findAtTile(pos) - if not bld or bld:getType() ~= df.building_type.Stockpile then return end - - local tiles = {} +-- this function assumes that is_valid_pos() has already validated the positions +local function create_quantum(pos, qsp_pos, feeders, name, trackstop_dir) + local data = get_quantumstop_data(feeders, name, trackstop_dir) + local stats = quickfort.apply_blueprint{mode='build', pos=pos, data=data} + if stats.build_designated.value == 0 then + error(('failed to build trackstop at (%d, %d, %d)') + :format(pos.x, pos.y, pos.z)) + end - for x=bld.x1,bld.x2 do - for y=bld.y1,bld.y2 do - if is_in_extents(bld, x, y) then - ensure_key(ensure_key(tiles, bld.z), y)[x] = true - end + if qsp_pos then + data = get_quantumsp_data(name) + stats = quickfort.apply_blueprint{mode='place', pos=qsp_pos, data=data} + if stats.place_designated.value == 0 then + error(('failed to place stockpile at (%d, %d, %d)') + :format(qsp_pos.x, qsp_pos.y, qsp_pos.z)) end end - - self.feeder = bld - self.feeder_tiles = tiles - - self:updateLayout() end -function QuantumUI:render_feeder_overlay() - if not gui.blink_visible(1000) then return end - - local zlevel = self.feeder_tiles[df.global.window_z] - if not zlevel then return end - - local function get_feeder_overlay_char(pos) - return safe_index(zlevel, pos.y, pos.x) and 'X' - end - - self:renderMapOverlay(get_feeder_overlay_char, self.feeder) +local function order_minecart(pos) + local quickfort_ctx = quickfort_command.init_ctx{ + command='orders', blueprint_name='gui/quantum', cursor=pos} + quickfort_orders.enqueue_additional_order(quickfort_ctx, 'wooden minecart') + quickfort_orders.create_orders(quickfort_ctx) end -function QuantumUI:get_qsp_pos(cursor) - local offsets = self.subviews.dir:getOptionValue() - return { - x = cursor.x + (offsets.x or 0), - y = cursor.y + (offsets.y or 0), - z = cursor.z +if dfhack.internal.IN_TEST then + unit_test_hooks = { + is_valid_pos=is_valid_pos, + create_quantum=create_quantum, } end -local function is_valid_pos(cursor, qsp_pos) - local stats = quickfort.apply_blueprint{mode='place', data='c', pos=qsp_pos, - dry_run=true} - local ok = stats.place_designated.value > 0 - - if ok then - stats = quickfort.apply_blueprint{mode='build', data='trackstop', - pos=cursor, dry_run=true} - ok = stats.build_designated.value > 0 - end +---------------------- +-- Quantum +-- + +Quantum = defclass(Quantum, widgets.Window) +Quantum.ATTRS { + frame_title='Quantum', + frame={w=35, h=21, r=2, t=18}, + autoarrange_subviews=true, + autoarrange_gap=1, + feeders=DEFAULT_NIL, +} - return ok -end +function Quantum:init() + self:addviews{ + widgets.WrappedLabel{ + text_to_wrap=self:callback('get_help_text'), + text_pen=COLOR_GREY, + }, + widgets.EditField{ + view_id='name', + label_text='Name: ', + key='CUSTOM_CTRL_N', + key_sep=' ', + on_char=function(_, text) return #text < 12 end, -- TODO can be circumvented by pasting + text='', + }, + widgets.CycleHotkeyLabel{ + view_id='dump_dir', + key='CUSTOM_CTRL_D', + key_sep=' ', + label='Dump direction:', + options={ + {label='North', value={y=-1}, pen=COLOR_YELLOW}, + {label='South', value={y=1}, pen=COLOR_YELLOW}, + {label='East', value={x=1}, pen=COLOR_YELLOW}, + {label='West', value={x=-1}, pen=COLOR_YELLOW}, + }, + }, + widgets.ToggleHotkeyLabel{ + view_id='create_sp', + key='CUSTOM_CTRL_Z', + label='Create output pile:', + }, + widgets.CycleHotkeyLabel{ + view_id='feeder_mode', + key='CUSTOM_CTRL_F', + label='Feeder select:', + options={ + {label='Single', value='single', pen=COLOR_GREEN}, + {label='Multi', value='multi', pen=COLOR_YELLOW}, + }, + }, + widgets.CycleHotkeyLabel{ + view_id='minecart', + key='CUSTOM_CTRL_M', + label='Assign minecart:', + options={ + {label='Auto', value='auto', pen=COLOR_GREEN}, + {label='Order', value='order', pen=COLOR_YELLOW}, + {label='Manual', value='manual', pen=COLOR_LIGHTRED}, + }, + }, + widgets.Panel{ + frame={h=4}, + subviews={ + widgets.WrappedLabel{ + text_to_wrap=function() return ('%d minecart%s available: %s will be %s'):format( + self.cart_count, self.cart_count == 1 and '' or 's', + self.cart_count == 1 and 'it' or 'one', + self.cart_count > 0 and 'automatically assigned to the quantum route' + or 'ordered via the manager for you to assign to the quantum route later') + end, + visible=function() return self.subviews.minecart:getOptionValue() == 'auto' end, + }, + widgets.WrappedLabel{ + text_to_wrap=function() return ('%d minecart%s available: %s will be ordered for you to assign to the quantum route later'):format( + self.cart_count, self.cart_count == 1 and '' or 's', + self.cart_count >= 1 and 'an additional one' or 'one') + end, + visible=function() return self.subviews.minecart:getOptionValue() == 'order' end, + }, + widgets.WrappedLabel{ + text_to_wrap=function() return ('%d minecart%s available: please %s a minecart of your choice to the quantum route later'):format( + self.cart_count, self.cart_count == 1 and '' or 's', + self.cart_count == 0 and 'order and assign' or 'assign') + end, + visible=function() return self.subviews.minecart:getOptionValue() == 'manual' end, + }, + }, + }, + } -function QuantumUI:render_destination_overlay() - local cursor = guidm.getCursorPos() - local qsp_pos = self:get_qsp_pos(cursor) - local bounds = {x1=qsp_pos.x, x2=qsp_pos.x, y1=qsp_pos.y, y2=qsp_pos.y} + self:refresh() +end - local ok = is_valid_pos(cursor, qsp_pos) +function Quantum:refresh() + self.cart_count = #assign_minecarts.get_free_vehicles() +end - local function get_dest_overlay_char() - return 'X', ok and COLOR_GREEN or COLOR_RED +function Quantum:get_help_text() + if #self.feeders == 0 then + return 'Please select a feeder stockpile.' end - - self:renderMapOverlay(get_dest_overlay_char, bounds) + if self.subviews.feeder_mode:getOptionValue() == 'single' then + return 'Please select the location of the new quantum dumper.' + end + return 'Please select additional feeder stockpiles or the location of the new quantum dumper.' end -function QuantumUI:onRenderBody() - if not self.feeder then return end +local function get_hover_stockpile(pos) + pos = pos or dfhack.gui.getMousePos() + if not pos then return end + local bld = dfhack.buildings.findAtTile(pos) + if not bld or bld:getType() ~= df.building_type.Stockpile then return end + return bld +end - self:render_feeder_overlay() - self:render_destination_overlay() +function Quantum:get_pos_qsp_pos() + local pos = dfhack.gui.getMousePos() + if not pos then return end + local qsp_pos = self.subviews.create_sp:getOptionValue() and + get_qsp_pos(pos, self.subviews.dump_dir:getOptionValue()) + return pos, qsp_pos end -function QuantumUI:onInput(keys) - if self:inputToSubviews(keys) then return true end +local to_pen = dfhack.pen.parse +local SELECTED_SP_PEN = to_pen{ch='=', fg=COLOR_LIGHTGREEN, + tile=dfhack.screen.findGraphicsTile('ACTIVITY_ZONES', 3, 15)} +local HOVERED_SP_PEN = to_pen{ch='=', fg=COLOR_GREEN, + tile=dfhack.screen.findGraphicsTile('ACTIVITY_ZONES', 2, 15)} - self:propagateMoveKeys(keys) +function Quantum:render_sp_overlay(sp, pen) + if not sp or sp.z ~= df.global.window_z then return end - local pos = nil - if keys._MOUSE_L_DOWN then - pos = dfhack.gui.getMousePos() - if pos then - guidm.setCursorPos(pos) - end - elseif keys.SELECT then - pos = guidm.getCursorPos() + local function get_overlay_char(pos) + if dfhack.buildings.containsTile(sp, pos.x, pos.y) then return pen end end - if pos then - if not self.feeder then - self:select_stockpile(pos) - else - local qsp_pos = self:get_qsp_pos(pos) - if not is_valid_pos(pos, qsp_pos) then - return - end - - self:dismiss() - self:commit(pos, qsp_pos) - end - end + guidm.renderMapOverlay(get_overlay_char, sp) end -local function get_feeder_pos(feeder_tiles) - for z,rows in pairs(feeder_tiles) do - for y,row in pairs(rows) do - for x in pairs(row) do - return xyz2pos(x, y, z) - end - end - end -end +local CURSOR_PEN = to_pen{ch='o', fg=COLOR_BLUE, + tile=dfhack.screen.findGraphicsTile('CURSORS', 5, 22)} +local GOOD_PEN = to_pen{ch='x', fg=COLOR_GREEN, + tile=dfhack.screen.findGraphicsTile('CURSORS', 1, 2)} +local BAD_PEN = to_pen{ch='X', fg=COLOR_RED, + tile=dfhack.screen.findGraphicsTile('CURSORS', 3, 0)} -local function get_moves(move, move_back, start_pos, end_pos, - move_to_greater_token, move_to_less_token) - if start_pos == end_pos then - return move, move_back - end - local diff = math.abs(start_pos - end_pos) - local move_to_greater_pattern = ('{%s %%d}'):format(move_to_greater_token) - local move_to_greater = move_to_greater_pattern:format(diff) - local move_to_less_pattern = ('{%s %%d}'):format(move_to_less_token) - local move_to_less = move_to_less_pattern:format(diff) - if start_pos < end_pos then - return move..move_to_greater, move_back..move_to_less - end - return move..move_to_less, move_back..move_to_greater -end +function Quantum:render_placement_overlay() + if #self.feeders == 0 then return end + local stop_pos, qsp_pos = self:get_pos_qsp_pos() + + if not stop_pos then return end -local function get_quantumstop_data(dump_pos, feeder_pos, name) - local move, move_back = get_moves('', '', dump_pos.z, feeder_pos.z, '<','>') - move, move_back = get_moves(move, move_back, dump_pos.y, feeder_pos.y, - 'Down', 'Up') - move, move_back = get_moves(move, move_back, dump_pos.x, feeder_pos.x, - 'Right', 'Left') - - local quantumstop_name_part, quantum_name_part = '', '' - if name ~= '' then - quantumstop_name_part = (' name="%s quantum"'):format(name) - quantum_name_part = ('{givename name="%s dumper"}'):format(name) + local bounds = { + x1=stop_pos.x, + x2=stop_pos.x, + y1=stop_pos.y, + y2=stop_pos.y, + } + if qsp_pos then + bounds.x1 = math.min(bounds.x1, qsp_pos.x) + bounds.x2 = math.max(bounds.x2, qsp_pos.x) + bounds.y1 = math.min(bounds.y1, qsp_pos.y) + bounds.y2 = math.max(bounds.y2, qsp_pos.y) end - return ('{quantumstop%s move="%s" move_back="%s"}%s') - :format(quantumstop_name_part, move, move_back, quantum_name_part) -end + local ok = is_valid_pos(stop_pos, qsp_pos) -local function get_quantum_data(name) - local name_part = '' - if name ~= '' then - name_part = (' name="%s"'):format(name) + local function get_overlay_char(pos) + if not ok then return BAD_PEN end + return same_xy(pos, stop_pos) and CURSOR_PEN or GOOD_PEN end - return ('{quantum%s}'):format(name_part) -end -local function order_minecart(pos) - local quickfort_ctx = quickfort_command.init_ctx{ - command='orders', blueprint_name='gui/quantum', cursor=pos} - quickfort_orders.enqueue_additional_order(quickfort_ctx, 'wooden minecart') - quickfort_orders.create_orders(quickfort_ctx) + guidm.renderMapOverlay(get_overlay_char, bounds) end -local function create_quantum(pos, qsp_pos, feeder_tiles, name, trackstop_dir, - allow_refuse) - local dsg = allow_refuse and 'yr' or 'c' - local stats = quickfort.apply_blueprint{mode='place', data=dsg, pos=qsp_pos} - if stats.place_designated.value == 0 then - error(('failed to place quantum stockpile at (%d, %d, %d)') - :format(qsp_pos.x, qsp_pos.y, qsp_pos.z)) - end - - stats = quickfort.apply_blueprint{mode='build', - data='trackstop'..trackstop_dir, pos=pos} - if stats.build_designated.value == 0 then - error(('failed to build trackstop at (%d, %d, %d)') - :format(pos.x, pos.y, pos.z)) +function Quantum:render(dc) + self:render_sp_overlay(get_hover_stockpile(), HOVERED_SP_PEN) + for _, feeder in ipairs(self.feeders) do + self:render_sp_overlay(feeder, SELECTED_SP_PEN) end + self:render_placement_overlay() + Quantum.super.render(self, dc) +end - local feeder_pos = get_feeder_pos(feeder_tiles) - local quantumstop_data = get_quantumstop_data(pos, feeder_pos, name) - stats = quickfort.apply_blueprint{mode='query', data=quantumstop_data, - pos=pos} - if stats.query_skipped_tiles.value > 0 then - error(('failed to query trackstop at (%d, %d, %d)') - :format(pos.x, pos.y, pos.z)) +function Quantum:try_commit() + local pos, qsp_pos = self:get_pos_qsp_pos() + if not is_valid_pos(pos, qsp_pos) then + return end - local quantum_data = get_quantum_data(name) - stats = quickfort.apply_blueprint{mode='query', data=quantum_data, - pos=qsp_pos} - if stats.query_skipped_tiles.value > 0 then - error(('failed to query quantum stockpile at (%d, %d, %d)') - :format(qsp_pos.x, qsp_pos.y, qsp_pos.z)) + create_quantum(pos, qsp_pos, self.feeders, self.subviews.name.text, + self.subviews.dump_dir:getOptionLabel():sub(1,1)) + + local minecart, message = nil, nil + local minecart_option = self.subviews.minecart:getOptionValue() + if minecart_option == 'auto' then + minecart = assign_minecarts.assign_minecart_to_last_route(true) + if minecart then + message = 'An available minecart (' .. + dfhack.items.getReadableDescription(minecart) .. + ') was assigned to your new' .. + ' quantum stockpile. You\'re all done!' + else + message = 'There are no minecarts available to assign to the' .. + ' quantum stockpile, but a manager order to produce' .. + ' one was created for you. Once the minecart is' .. + ' built, please add it to the quantum stockpile route' .. + ' with the "assign-minecarts all" command or manually in' .. + ' the (H)auling menu.' + end end -end - --- this function assumes that is_valid_pos() has already validated the positions -function QuantumUI:commit(pos, qsp_pos) - local name = self.subviews.name.text - local trackstop_dir = self.subviews.dir:getOptionLabel():sub(1,1) - local allow_refuse = self.subviews.refuse:getOptionValue() - create_quantum(pos, qsp_pos, self.feeder_tiles, name, trackstop_dir, allow_refuse) - - local message = nil - if assign_minecarts.assign_minecart_to_last_route(true) then - message = 'An available minecart was assigned to your new' .. - ' quantum stockpile. You\'re all done!' - else + if minecart_option == 'order' then order_minecart(pos) - message = 'There are no minecarts available to assign to the' .. - ' quantum stockpile, but a manager order to produce' .. - ' one was created for you. Once the minecart is' .. + message = 'A manager order to produce a minecart has been' .. + ' created for you. Once the minecart is' .. ' built, please add it to the quantum stockpile route' .. ' with the "assign-minecarts all" command or manually in' .. - ' the (h)auling menu.' + ' the (H)auling menu.' + end + if not message then + message = 'Please add a minecart of your choice to the quantum' .. + ' stockpile route in the (H)auling menu.' end -- display a message box telling the user what we just did dialogs.MessageBox{text=message:wrap(70)}:show() + return true end -if dfhack.internal.IN_TEST then - unit_test_hooks = { - is_in_extents=is_in_extents, - is_valid_pos=is_valid_pos, - get_feeder_pos=get_feeder_pos, - get_moves=get_moves, - get_quantumstop_data=get_quantumstop_data, - get_quantum_data=get_quantum_data, - create_quantum=create_quantum, - } +function Quantum:onInput(keys) + if Quantum.super.onInput(self, keys) then return true end + + if not keys._MOUSE_L then return end + local sp = get_hover_stockpile() + if sp then + if self.subviews.feeder_mode:getOptionValue() == 'single' then + self.feeders = {sp} + else + local found = false + for idx, feeder in ipairs(self.feeders) do + if sp.id == feeder.id then + found = true + table.remove(self.feeders, idx) + break + end + end + if not found then + table.insert(self.feeders, sp) + end + end + self:updateLayout() + elseif #self.feeders > 0 then + self:try_commit() + self:refresh() + self:updateLayout() + end +end + +---------------------- +-- QuantumScreen +-- + +QuantumScreen = defclass(QuantumScreen, gui.ZScreen) +QuantumScreen.ATTRS { + focus_path='quantum', + pass_movement_keys=true, + pass_mouse_clicks=false, + feeder=DEFAULT_NIL, +} + +function QuantumScreen:init() + self:addviews{Quantum{feeders={self.feeder}}} +end + +function QuantumScreen:onDismiss() + view = nil end if dfhack_flags.module then return end -view = QuantumUI{} -view:show() +view = view and view:raise() or QuantumScreen{feeder=dfhack.gui.getSelectedStockpile(true)}:show() diff --git a/gui/quickcmd.lua b/gui/quickcmd.lua index b4d3dcdfa6..5da0232a7f 100644 --- a/gui/quickcmd.lua +++ b/gui/quickcmd.lua @@ -34,17 +34,45 @@ local json = require('json') local gui = require('gui') local widgets = require('gui.widgets') +local CONFIG_FILE_BACKUP = 'dfhack-config/quickcmd.json.bak' local CONFIG_FILE = 'dfhack-config/quickcmd.json' local HOTKEYWIDTH = 7 +local OUTWIDTH = 4 local HOTKEYS = 'asdfghjklqwertyuiopzxcvbnm' +local function save_commands(data) + json.encode_file({version=2, commands=data}, CONFIG_FILE) +end + +local function migrate_to_v2(data) + json.encode_file(data, CONFIG_FILE_BACKUP) + + local commands = {} + for i, cmd in ipairs(data) do + if type(cmd) == 'string' then + table.insert(commands, {command = cmd, name = nil, show_output = false}) + end + end + + save_commands(commands) + return commands +end + local function load_commands() local ok, data = pcall(json.decode_file, CONFIG_FILE) - return ok and data or {} -end + if ok then + if type(data) == 'table' and data.version then + if data.version == 2 then + return data.commands or {} + end + end + -- Old format: array of strings + if type(data) == 'table' and #data > 0 then + return migrate_to_v2(data) + end + end -local function save_commands(data) - json.encode_file(data, CONFIG_FILE) + return {} end QCMDDialog = defclass(QCMDDialog, widgets.Window) @@ -61,12 +89,12 @@ function QCMDDialog:init(info) self:addviews{ widgets.Label{ frame={t=0}, - text={{text='Hotkey', width=HOTKEYWIDTH}, ' Command'}, + text={{text='Hotkey', width=HOTKEYWIDTH}, {text='Out', width=OUTWIDTH}, 'Name/Command'}, visible=function() return #self.commands > 0 end, }, widgets.List{ view_id='list', - frame={t=2, b=3}, + frame={t=2, b=4}, on_submit=self:callback('submit'), }, widgets.Label{ @@ -74,16 +102,40 @@ function QCMDDialog:init(info) text={'Command list is empty.', NEWLINE, 'Hit "A" to add one!'}, visible=function() return #self.commands == 0 end, }, - widgets.Label{ - frame={b=0, h=2}, - text={ - {key='CUSTOM_SHIFT_A', text=': Add command', - on_activate=self:callback('onAddCommand')}, ' ', - {key='CUSTOM_SHIFT_D', text=': Delete command', - on_activate=self:callback('onDelCommand')}, NEWLINE, - {key='CUSTOM_SHIFT_E', text=': Edit command', - on_activate=self:callback('onEditCommand')}, - }, + widgets.HotkeyLabel{ + frame={b=2, l=0}, + key='CUSTOM_SHIFT_A', + label='Add command', + auto_width=true, + on_activate=self:callback('onAddCommand'), + }, + widgets.HotkeyLabel{ + frame={b=2, l=19}, + key='CUSTOM_SHIFT_D', + label='Delete command', + auto_width=true, + on_activate=self:callback('onDelCommand'), + }, + widgets.HotkeyLabel{ + frame={b=1, l=0}, + key='CUSTOM_SHIFT_E', + label='Edit command', + auto_width=true, + on_activate=self:callback('onEditCommand'), + }, + widgets.HotkeyLabel{ + frame={b=1, l=19}, + key='CUSTOM_SHIFT_N', + label='Edit name', + auto_width=true, + on_activate=self:callback('onSetName'), + }, + widgets.HotkeyLabel{ + frame={b=0, l=0}, + key='CUSTOM_SHIFT_O', + label='Capture output', + auto_width=true, + on_activate=self:callback('onToggleOutput'), }, } @@ -91,28 +143,88 @@ function QCMDDialog:init(info) end function QCMDDialog:submit(idx, choice) + local cmd_obj = self.commands[idx] + + if cmd_obj.show_output then + self:showCommandOutput(cmd_obj.command, cmd_obj.name) + else + local screen = self.parent_view + dfhack.screen.hideGuard(screen, function() + dfhack.run_command(cmd_obj.command) + end) + screen:dismiss() + end +end + +function QCMDDialog:showCommandOutput(command, name) + local output = dfhack.run_command_silent(command) + + -- Dismiss the quickcmd dialog before showing output local screen = self.parent_view - local parent = screen._native.parent - dfhack.screen.hideGuard(screen, function() - dfhack.run_command(choice.command) - end) screen:dismiss() + + local OutputDialog = defclass(OutputDialog, gui.ZScreen) + OutputDialog.ATTRS{ + focus_path='quickcmd_output', + command='', + name='', + output='', + } + + function OutputDialog:init() + local title = ('%s%s'):format(self.name ~= '' and self.name .. ': ' or '', self.command) + + self:addviews{ + widgets.Window{ + frame_title=title, + frame={w=80, h=25}, + resizable=true, + resize_min={h=10, w=40}, + subviews={ + widgets.WrappedLabel{ + view_id='output', + frame={t=0, l=0, r=0, b=2}, + text_to_wrap=self.output or 'No output', + scroll_keys=widgets.STANDARDSCROLL, + }, + widgets.HotkeyLabel{ + frame={b=0, l=0}, + key='LEAVESCREEN', + label='Close', + auto_width=true, + on_activate=self:callback('dismiss'), + }, + } + } + } + end + + if #output == 0 then + output = 'Command finished successfully' + end + + OutputDialog{command=command, name=name, output=output}:show() end function QCMDDialog:updateList() -- Build the list entries. local choices = {} - for i,command in ipairs(self.commands) do + for i,cmd_obj in ipairs(self.commands) do -- Get the hotkey for this entry. local hotkey = nil if i <= HOTKEYS:len() then hotkey = HOTKEYS:sub(i, i) end + -- Display name if set, otherwise display command + local display_text = cmd_obj.name or cmd_obj.command + -- Store the entry. table.insert(choices, { - text={{text=hotkey or '', width=HOTKEYWIDTH}, ' ', command}, - command=command, + text={{text=hotkey or '', width=HOTKEYWIDTH}, {text=cmd_obj.show_output and '[X]' or '[ ]', width=OUTWIDTH}, display_text}, + command=cmd_obj.command, + name=cmd_obj.name, + show_output=cmd_obj.show_output, hotkey=hotkey and ('CUSTOM_' .. hotkey:upper()) or '', }) end @@ -139,7 +251,7 @@ function QCMDDialog:onAddCommand() COLOR_GREEN, '', function(command) - table.insert(self.commands, command) + table.insert(self.commands, {command=command, name=nil, show_output=false}) save_commands(self.commands) self:updateList() end @@ -156,7 +268,7 @@ function QCMDDialog:onDelCommand() -- Prompt for confirmation. dlg.showYesNoPrompt( 'Delete command', - 'Are you sure you want to delete this command: ' .. NEWLINE .. item.command, + 'Are you sure you want to delete this command: ' .. NEWLINE .. self.commands[index].command, COLOR_GREEN, function() table.remove(self.commands, index) @@ -178,15 +290,49 @@ function QCMDDialog:onEditCommand() 'Edit command', 'Enter command:', COLOR_GREEN, - item.command, + self.commands[index].command, function(command) - self.commands[index] = command + self.commands[index].command = command + save_commands(self.commands) + self:updateList() + end + ) +end + +function QCMDDialog:onSetName() + -- Get the selected command. + local index, item = self.subviews.list:getSelected() + if not item then + return + end + + -- Prompt for new name. + dlg.showInputPrompt( + 'Set name', + 'Enter name:', + COLOR_GREEN, + self.commands[index].name or '', + function(name) + self.commands[index].name = name ~= '' and name or nil save_commands(self.commands) self:updateList() end ) end +function QCMDDialog:onToggleOutput() + -- Get the selected command. + local index, item = self.subviews.list:getSelected() + if not item then + return + end + + -- Toggle the show_output flag. + self.commands[index].show_output = not self.commands[index].show_output + save_commands(self.commands) + self:updateList() +end + QCMDScreen = defclass(QCMDScreen, gui.ZScreen) QCMDScreen.ATTRS { focus_path='quickcmd', diff --git a/gui/quickfort.lua b/gui/quickfort.lua index 60b4b0b4aa..3928cfb33c 100644 --- a/gui/quickfort.lua +++ b/gui/quickfort.lua @@ -25,80 +25,131 @@ show_library = show_library == nil and true or show_library show_hidden = show_hidden or false filter_text = filter_text or '' selected_id = selected_id or 1 +marker_expanded = marker_expanded or false +markers = markers or {blueprint=false, warm=false, damp=false} repeat_dir = repeat_dir or false repetitions = repetitions or 1 transform = transform or false transformations = transformations or {} --- --- BlueprintDetails --- - --- displays blueprint details, such as the full modeline and comment, that --- otherwise might be truncated for length in the blueprint selection list -BlueprintDetails = defclass(BlueprintDetails, dialogs.MessageBox) -BlueprintDetails.ATTRS{ - focus_path='quickfort/dialog/details', - frame_title='Details', - frame_width=28, -- minimum width required for the bottom frame text -} - --- adds hint about left arrow being a valid "exit" key for this dialog -function BlueprintDetails:onRenderFrame(dc, rect) - BlueprintDetails.super.onRenderFrame(self, dc, rect) - dc:seek(rect.x1+2, rect.y2):string('Ctrl+D', dc.cur_key_pen): - string(': Back', COLOR_GREY) -end - -function BlueprintDetails:onInput(keys) - if keys.CUSTOM_CTRL_D or keys.SELECT - or keys.LEAVESCREEN or keys._MOUSE_R then - self:dismiss() - end -end - -- -- BlueprintDialog -- -- blueprint selection dialog, shown when the script starts or when a user wants -- to load a new blueprint into the ui -BlueprintDialog = defclass(BlueprintDialog, dialogs.ListBox) +BlueprintDialog = defclass(BlueprintDialog, gui.ZScreenModal) BlueprintDialog.ATTRS{ focus_path='quickfort/dialog', - frame_title='Load quickfort blueprint', - with_filter=true, - frame_width=dialog_width, - row_height=2, - frame_inset={t=0,l=1,r=0,b=1}, - list_frame_inset={t=1}, + on_select=DEFAULT_NIL, + on_cancel=DEFAULT_NIL, } function BlueprintDialog:init() - self:addviews{ - widgets.Label{frame={t=0, l=1}, text='Filters:', text_pen=COLOR_GREY}, - widgets.ToggleHotkeyLabel{frame={t=0, l=12}, label='Library', - key='CUSTOM_ALT_L', initial_option=show_library, - text_pen=COLOR_GREY, - on_change=self:callback('update_setting', 'show_library')}, - widgets.ToggleHotkeyLabel{frame={t=0, l=34}, label='Hidden', - key='CUSTOM_ALT_H', initial_option=show_hidden, - text_pen=COLOR_GREY, - on_change=self:callback('update_setting', 'show_hidden')} + local options={ + {label='Show', value=true, pen=COLOR_GREEN}, + {label='Hide', value=false} } -end --- always keep our list big enough to display 10 items so we don't jarringly --- resize when the filter is being edited and it suddenly matches no blueprints -function BlueprintDialog:getWantedFrameSize() - local mw, mh = BlueprintDialog.super.getWantedFrameSize(self) - return mw, math.max(mh, 24) + self:addviews{ + widgets.Window{ + frame={w=80, h=35}, + frame_title='Load quickfort blueprint', + resizable=true, + subviews={ + widgets.Label{ + frame={t=0, l=1}, + text='Filters:', + text_pen=COLOR_GREY, + }, + widgets.ToggleHotkeyLabel{ + frame={t=0, l=12, w=20}, + key='CUSTOM_ALT_L', + label='Library:', + options=options, + initial_option=show_library, + text_pen=COLOR_GREY, + on_change=self:callback('update_setting', 'show_library') + }, + widgets.ToggleHotkeyLabel{ + frame={t=0, l=35, w=19}, + key='CUSTOM_ALT_H', + label='Hidden:', + options=options, + initial_option=show_hidden, + text_pen=COLOR_GREY, + on_change=self:callback('update_setting', 'show_hidden') + }, + widgets.FilteredList{ + view_id='list', + frame={t=2, b=9}, + row_height=2, + on_select=function() + local desc = self.subviews.desc + if desc.frame_body then desc:updateLayout() end + end, + on_double_click=self:callback('commit'), + on_submit2=self:callback('delete_blueprint'), + on_double_click2=self:callback('delete_blueprint'), + }, + widgets.Panel{ + frame={b=3, h=5}, + frame_style=gui.FRAME_INTERIOR, + subviews={ + widgets.WrappedLabel{ + frame={l=0, h=3}, + view_id='desc', + auto_height=false, + text_to_wrap=function() + local list = self.subviews.list + local _, choice = list:getSelected() + return choice and choice.desc or '' + end, + }, + }, + }, + widgets.Label{ + frame={b=1, l=0}, + text='Double click or', + }, + widgets.HotkeyLabel{ + frame={b=1, l=16}, + key='SELECT', + label='Load selected blueprint', + on_activate=self:callback('commit'), + enabled=function() + local list = self.subviews.list + local _, choice = list:getSelected() + return choice + end, + }, + widgets.Label{ + frame={b=0, l=0}, + text='Shift click or', + }, + widgets.HotkeyLabel{ + frame={b=0, l=15}, + key='SELECT_ALL', -- TODO: change to SEC_SELECT once 51.01 is stable + label='Delete selected blueprint', + on_activate=self:callback('delete_blueprint'), + enabled=function() + local list = self.subviews.list + local _, choice = list:getSelected() + return choice and not choice.library + end, + }, + }, + }, + } end -function BlueprintDialog:onRenderFrame(dc, rect) - BlueprintDialog.super.onRenderFrame(self, dc, rect) - dc:seek(rect.x1+2, rect.y2):string('Ctrl+D', dc.cur_key_pen): - string(': Show details', COLOR_GREY) +function BlueprintDialog:commit() + local list = self.subviews.list + local _, choice = list:getSelected() + if choice then + self:dismiss() + self.on_select(choice.id) + end end function BlueprintDialog:update_setting(setting, value) @@ -106,32 +157,10 @@ function BlueprintDialog:update_setting(setting, value) self:refresh() end --- ensures each newline-delimited sequence within text is no longer than --- width characters long. also ensures that no more than max_lines lines are --- returned in the truncated string. -local more_marker = '...' -local function truncate(text, width, max_lines) - local truncated_text = {} - for line in text:gmatch('[^'..NEWLINE..']*') do - if #line > width then - line = line:sub(1, width-#more_marker) .. more_marker - end - table.insert(truncated_text, line) - if #truncated_text >= max_lines then break end - end - return table.concat(truncated_text, NEWLINE) -end - --- extracts the blueprint list id from a dialog list entry -local function get_id(text) - local _, _, id = text:find('^(%d+)') - return tonumber(id) -end - local function save_selection(list) - local _, obj = list:getSelected() - if obj then - selected_id = get_id(obj.text) + local _, choice = list:getSelected() + if choice then + selected_id = choice.id end end @@ -140,7 +169,7 @@ end local function restore_selection(list) local best_idx = 1 for idx,v in ipairs(list:getVisibleChoices()) do - local cur_id = get_id(v.text) + local cur_id = v.id if selected_id >= cur_id then best_idx = idx end if selected_id <= cur_id then break end end @@ -162,55 +191,73 @@ function BlueprintDialog:refresh() return false end for _,v in ipairs(results) do - local start_comment = '' - if v.start_comment then - start_comment = string.format(' cursor start: %s', v.start_comment) - end local sheet_spec = '' if v.section_name then - sheet_spec = string.format( - ' -n %s', - quickfort_parse.quote_if_has_spaces(v.section_name)) + sheet_spec = (' -n %s'):format(quickfort_parse.quote_if_has_spaces(v.section_name)) end - local main = ('%d) %s%s (%s)') - :format(v.id, quickfort_parse.quote_if_has_spaces(v.path), - sheet_spec, v.mode) - local text = ('%s%s'):format(main, start_comment) + local main = ('%d) %s%s (%s)'):format( + v.id, quickfort_parse.quote_if_has_spaces(v.path), sheet_spec, v.mode) + + local comment = '' if v.comment then - text = text .. ('\n %s'):format(v.comment) + comment = ('%s'):format(v.comment) end - local full_text = main - if #start_comment > 0 then - full_text = full_text .. '\n\n' .. start_comment - end - if v.comment then - full_text = full_text .. '\n\n comment: ' .. v.comment + local start_comment = '' + if v.start_comment then + start_comment = ('%scursor on: %s'):format(#comment > 0 and '; ' or '', v.start_comment) end - local truncated_text = - truncate(text, self.frame_body.width - 2, self.row_height) + local extra = #comment == 0 and #start_comment == 0 and 'No description' or '' + local desc = ('%s%s%s'):format(comment, start_comment, extra) -- search for the extra syntax shown in the list items in case someone -- is typing exactly what they see - table.insert(choices, - {text=truncated_text, - full_text=full_text, - search_key=v.search_key .. main}) + table.insert(choices, { + text=main, + desc=desc, + id=v.id, + library=v.library, + name=v.path, + search_key=v.search_key .. main, + }) end - self.subviews.list:setChoices(choices) - self:updateLayout() -- allows the dialog to resize width to fit the content - self.subviews.list:setFilter(filter_text) - restore_selection(self.subviews.list) + local list = self.subviews.list + list:setFilter('') + list:setChoices(choices, list:getSelected()) + list:setFilter(filter_text) + restore_selection(list) return true end +function BlueprintDialog:delete_blueprint(idx, choice) + local list = self.subviews.list + if choice then + list.list:setSelected(idx) + save_selection(list) + else + _, choice = list:getSelected() + end + if not choice or choice.library then return end + + local function do_delete(pause_confirmations) + dfhack.run_script('quickfort', 'delete', choice.name) + self:refresh() + self.pause_confirmations = self.pause_confirmations or pause_confirmations + end + + if self.pause_confirmations then + do_delete() + else + dialogs.showYesNoPrompt('Delete blueprint', + 'Are you sure you want to delete this blueprint?\n'..choice.name, + COLOR_YELLOW, do_delete, nil, curry(do_delete, true)) + end +end + function BlueprintDialog:onInput(keys) - local _, obj = self.subviews.list:getSelected() - if keys.CUSTOM_CTRL_D and obj then - local details = BlueprintDetails{ - text=obj.full_text:wrap(self.frame_body.width)} - details:show() - -- for testing - self._details = details + if keys.LEAVESCREEN or keys._MOUSE_R then + self.on_cancel() + self:dismiss() + return true elseif BlueprintDialog.super.onInput(self, keys) then local prev_filter_text = filter_text -- save the filter if it was updated so we always have the most recent @@ -225,7 +272,6 @@ function BlueprintDialog:onInput(keys) end return true end - return true end -- @@ -238,9 +284,9 @@ end Quickfort = defclass(Quickfort, widgets.Window) Quickfort.ATTRS { frame_title='Quickfort', - frame={w=34, h=32, r=2, t=18}, + frame={w=34, h=42, r=2, t=18}, resizable=true, - resize_min={h=26}, + resize_min={h=32}, autoarrange_subviews=true, autoarrange_gap=1, filter='', @@ -268,7 +314,7 @@ function Quickfort:init() widgets.ResizingPanel{autoarrange_subviews=true, subviews={ widgets.Label{text='Current blueprint:'}, widgets.WrappedLabel{ - text_pen=COLOR_GREY, + text_pen=COLOR_CYAN, text_to_wrap=self:callback('get_blueprint_name')} }}, widgets.ResizingPanel{autoarrange_subviews=true, subviews={ @@ -285,10 +331,63 @@ function Quickfort:init() active=function() return self.blueprint_name end, enabled=function() return self.blueprint_name end, on_activate=self:callback('toggle_lock_cursor')}, + widgets.Divider{frame={h=1}, + frame_style=gui.FRAME_THIN, + frame_style_l=false, + frame_style_r=false}, + widgets.CycleHotkeyLabel{key='CUSTOM_SHIFT_P', + key_back='CUSTOM_P', + view_id='priority', + label='Baseline dig priority:', + options={1, 2, 3, 4, 5, 6, 7}, + initial_option=4, + active=function() return self.blueprint_name and self.has_dig end, + enabled=function() return self.blueprint_name and self.has_dig end}, + widgets.ResizingPanel{subviews={ + widgets.ToggleHotkeyLabel{key='CUSTOM_M', + frame={t=0, h=1}, + view_id='marker', + label='Add marker:', + initial_option=marker_expanded, + active=function() return self.blueprint_name and self.has_dig end, + enabled=function() return self.blueprint_name and self.has_dig end, + on_change=function(val) + marker_expanded = val + self:updateLayout() + end, + }, + widgets.Panel{ + frame={t=0, h=4}, + visible=function() return self.subviews.marker:getOptionValue() end, + subviews={ + widgets.ToggleHotkeyLabel{key='CUSTOM_CTRL_B', + frame={t=1, l=0}, + view_id='marker_blueprint', + label='Blueprint:', + initial_option=markers.blueprint, + on_change=function(val) markers.blueprint = val end, + }, + widgets.ToggleHotkeyLabel{key='CUSTOM_CTRL_D', + frame={t=2, l=0}, + view_id='marker_damp', + label='Damp dig:', + initial_option=markers.damp, + on_change=function(val) markers.damp = val end, + }, + widgets.ToggleHotkeyLabel{key='CUSTOM_CTRL_W', + frame={t=3, l=0}, + view_id='marker_warm', + label='Warm dig:', + initial_option=markers.warm, + on_change=function(val) markers.warm = val end, + }, + }, + }, + }}, widgets.ResizingPanel{autoarrange_subviews=true, subviews={ widgets.CycleHotkeyLabel{key='CUSTOM_R', view_id='repeat_cycle', - label='Repeat', + label='Repeat:', active=function() return self.blueprint_name end, enabled=function() return self.blueprint_name end, options={{label='No', value=false}, @@ -300,16 +399,16 @@ function Quickfort:init() visible=function() return repeat_dir and self.blueprint_name end, subviews={ widgets.HotkeyLabel{key='STRING_A045', - frame={t=1, l=2}, key_sep='', + frame={t=1, l=2, w=1}, key_sep='', on_activate=self:callback('on_adjust_repetitions', -1)}, widgets.HotkeyLabel{key='STRING_A043', - frame={t=1, l=3}, key_sep='', + frame={t=1, l=3, w=1}, key_sep='', on_activate=self:callback('on_adjust_repetitions', 1)}, widgets.HotkeyLabel{key='STRING_A047', - frame={t=1, l=4}, key_sep='', + frame={t=1, l=4, w=1}, key_sep='', on_activate=self:callback('on_adjust_repetitions', -10)}, widgets.HotkeyLabel{key='STRING_A042', - frame={t=1, l=5}, key_sep='', + frame={t=1, l=5, w=1}, key_sep='', on_activate=self:callback('on_adjust_repetitions', 10)}, widgets.EditField{key='CUSTOM_SHIFT_R', view_id='repeat_times', @@ -321,7 +420,7 @@ function Quickfort:init() widgets.ResizingPanel{autoarrange_subviews=true, subviews={ widgets.ToggleHotkeyLabel{key='CUSTOM_T', view_id='transform', - label='Transform', + label='Transform:', active=function() return self.blueprint_name end, enabled=function() return self.blueprint_name end, initial_option=transform, @@ -330,22 +429,26 @@ function Quickfort:init() visible=function() return transform and self.blueprint_name end, subviews={ widgets.HotkeyLabel{key='STRING_A040', - frame={t=1, l=2}, key_sep='', + frame={t=1, l=2, w=1}, key_sep='', on_activate=self:callback('on_transform', 'ccw')}, widgets.HotkeyLabel{key='STRING_A041', - frame={t=1, l=3}, key_sep='', + frame={t=1, l=3, w=1}, key_sep='', on_activate=self:callback('on_transform', 'cw')}, widgets.HotkeyLabel{key='STRING_A095', - frame={t=1, l=4}, key_sep='', + frame={t=1, l=4, w=1}, key_sep='', on_activate=self:callback('on_transform', 'flipv')}, widgets.HotkeyLabel{key='STRING_A061', - frame={t=1, l=5}, key_sep=':', + frame={t=1, l=5, w=1}, key_sep=':', on_activate=self:callback('on_transform', 'fliph')}, widgets.WrappedLabel{ frame={t=1, l=8}, text_to_wrap=function() return #transformations == 0 and 'No transform' or table.concat(transformations, ', ') end}}}}}, + widgets.Divider{frame={h=1}, + frame_style=gui.FRAME_THIN, + frame_style_l=false, + frame_style_r=false}, widgets.HotkeyLabel{key='CUSTOM_O', label='Generate manager orders', active=function() return self.blueprint_name end, enabled=function() return self.blueprint_name end, @@ -360,7 +463,7 @@ function Quickfort:init() enabled=function() return self.blueprint_name end, on_activate=self:callback('do_command', 'undo')}, widgets.WrappedLabel{ - text_to_wrap='Blueprints will use DFHack building planner material filter settings.', + text_to_wrap='Build mode blueprints will use DFHack building planner material filter settings.', }, } end @@ -475,8 +578,7 @@ function Quickfort:on_transform(val) self.dirty = true end -function Quickfort:dialog_cb(text) - local id = get_id(text) +function Quickfort:dialog_cb(id) local name, sec_name, mode = quickfort_list.get_blueprint_by_number(id) self.blueprint_name, self.section_name, self.mode = name, sec_name, mode self:updateLayout() @@ -502,7 +604,7 @@ function Quickfort:show_dialog(initial) end local file_dialog = BlueprintDialog{ - on_select=function(idx, obj) self:dialog_cb(obj.text) end, + on_select=self:callback('dialog_cb'), on_cancel=self:callback('dialog_cancel_cb') } @@ -513,7 +615,7 @@ function Quickfort:show_dialog(initial) if initial and #self.filter > 0 then local choices = file_dialog.subviews.list:getVisibleChoices() if #choices == 1 then - local selection = choices[1].text + local selection = choices[1].id file_dialog:dismiss() self:dialog_cb(selection) return @@ -526,13 +628,15 @@ function Quickfort:show_dialog(initial) self._dialog = file_dialog end -function Quickfort:run_quickfort_command(command, dry_run, preview) +function Quickfort:run_quickfort_command(command, marker, priority, dry_run, preview) local ctx = quickfort_command.init_ctx{ command=command, blueprint_name=self.blueprint_name, cursor=self.saved_cursor, aliases=quickfort_list.get_aliases(self.blueprint_name), quiet=true, + marker=marker, + priority=priority, dry_run=dry_run, preview=preview, } @@ -556,8 +660,9 @@ function Quickfort:run_quickfort_command(command, dry_run, preview) end function Quickfort:refresh_preview() - local ctx = self:run_quickfort_command('run', true, true) + local ctx = self:run_quickfort_command('run', false, 4, true, true) self.saved_preview = ctx.preview + self.has_dig = ctx.stats.dig_designated end local to_pen = dfhack.pen.parse @@ -625,7 +730,9 @@ function Quickfort:do_command(command, dry_run, post_fn) quickfort_parse.format_command( command, self.blueprint_name, self.section_name, dry_run), self.saved_cursor.x, self.saved_cursor.y, self.saved_cursor.z)) - local ctx = self:run_quickfort_command(command, dry_run, false) + local marker = marker_expanded and markers or {} + local priority = self.subviews.priority:getOptionValue() + local ctx = self:run_quickfort_command(command, marker, priority, dry_run, false) quickfort_command.finish_commands(ctx) if command == 'run' then if #ctx.messages > 0 then diff --git a/gui/rename.lua b/gui/rename.lua index 509b61ba0d..ef1d20bba8 100644 --- a/gui/rename.lua +++ b/gui/rename.lua @@ -1,127 +1,1145 @@ --- Rename various objects via gui. ---[====[ +--@module = true -gui/rename -========== -Backed by `rename`, this script allows entering the desired name -via a simple dialog in the game ui. +local argparse = require('argparse') +local dlg = require('gui.dialogs') +local gui = require('gui') +local overlay = require('plugins.overlay') +local sitemap = reqscript('gui/sitemap') +local utils = require('utils') +local widgets = require('gui.widgets') -* ``gui/rename [building]`` in :kbd:`q` mode changes the name of a building. +local CH_UP = string.char(30) +local CH_DN = string.char(31) +local ENGLISH_COL_WIDTH = 16 +local NATIVE_COL_WIDTH = 16 - .. image:: /docs/images/rename-bld.png +local language = df.global.world.raws.language +local translations = df.language_translation.get_vector() - The selected building must be one of stockpile, workshop, furnace, trap, or siege engine. - It is also possible to rename zones from the :kbd:`i` menu. +-- +-- target selection +-- -* ``gui/rename [unit]`` with a unit selected changes the nickname. +---@alias target_type { name:df.language_name, sync_names:(df.language_name|fun():nil)[]?, civ_id:df.historical_entity.id? } - Unlike the built-in interface, this works even on enemies and animals. +local entity_names = { ---@type table + [df.language_name_type.Figure]=df.entity_name_type.OTHER, + [df.language_name_type.FigureFirstOnly]=df.entity_name_type.OTHER, + [df.language_name_type.FigureNoFirst]=df.entity_name_type.OTHER, + [df.language_name_type.Civilization]=df.entity_name_type.CIV, + [df.language_name_type.EntitySite]=df.entity_name_type.SITE, + [df.language_name_type.Site]=df.entity_name_type.OTHER, + [df.language_name_type.Squad]=df.entity_name_type.OTHER, + [df.language_name_type.Temple]=df.entity_name_type.TEMPLE, + [df.language_name_type.Library]=df.entity_name_type.LIBRARY, + [df.language_name_type.Hospital]=df.entity_name_type.HOSPITAL, +} -* ``gui/rename unit-profession`` changes the selected unit's custom profession name. +local category_names = { ---@type table + [df.language_name_type.World]=df.language_name_category.Region, + [df.language_name_type.Region]=df.language_name_category.Region, + [df.language_name_type.LegendaryFigure]=df.language_name_category.Unit, + [df.language_name_type.FigureNoFirst]=df.language_name_category.Unit, + [df.language_name_type.FigureFirstOnly]=df.language_name_category.Unit, + [df.language_name_type.Figure]=df.language_name_category.Unit, + [df.language_name_type.Religion]=df.language_name_category.CommonReligion, + [df.language_name_type.Temple]=df.language_name_category.Temple, + [df.language_name_type.FoodStore]=df.language_name_category.FoodStore, + [df.language_name_type.Library]=df.language_name_category.Library, + [df.language_name_type.Guildhall]=df.language_name_category.Guildhall, + [df.language_name_type.Hospital]=df.language_name_category.Hospital, +} - .. image:: /docs/images/rename-prof.png +local wt = language.word_table - Likewise, this can be applied to any unit, and when used on animals it overrides - their species string. +---@param name_type df.language_name_type +---@param civ df.historical_entity +---@return df.language_word_table major, df.language_word_table minor +local function get_word_selectors(name_type, civ) + -- special cases + if name_type == df.language_name_type.Artifact then + -- The game normally only uses ArtifactEvil if it was created by a fell/macabre mood, but we don't know + -- at this point, so we'll randomize the choice + if math.random(5) == 1 then + return wt[0][df.language_name_category.ArtifactEvil], wt[1][df.language_name_category.ArtifactEvil] + else + return wt[0][df.language_name_category.Artifact], wt[1][df.language_name_category.Artifact] + end + end + + -- entity-based names + local etype = entity_names[name_type] + if civ and etype then + return civ.entity_raw.symbols.symbols_major[etype], civ.entity_raw.symbols.symbols_minor[etype] + end + + -- category-based names + local ctype = category_names[name_type] + if ctype then + return wt[0][ctype], wt[1][ctype] + end + + -- default to something generic with a lot of word choices + return wt[0][df.language_name_category.River], wt[1][df.language_name_category.River] +end + +---@param item df.item +---@return target_type? +local function get_artifact_target(item) + if not item or not item.flags.artifact then return end + local gref = dfhack.items.getGeneralRef(item, df.general_ref_type.IS_ARTIFACT) ---@cast gref df.general_ref_is_artifactst? + if not gref then return end + local rec = df.artifact_record.find(gref.artifact_id) + if not rec then return end + return {name=rec.name} +end + +---@param hf df.historical_figure +---@return target_type? +local function get_hf_target(hf) + if not hf then return end + local name = dfhack.units.getVisibleName(hf) + local unit = df.unit.find(hf.unit_id) + local sync_names = {} + if unit then + local unit_name = dfhack.units.getVisibleName(unit) + if unit_name ~= name then + table.insert(sync_names, unit_name) + end + end + return {name=name, sync_names=sync_names, civ_id=hf.civ_id} +end + +---@param unit df.unit +---@return target_type? +local function get_unit_target(unit) + if not unit then return end + local hf = df.historical_figure.find(unit.hist_figure_id) + if hf then + return get_hf_target(hf) + end + -- unit with no hf + return {name=dfhack.units.getVisibleName(unit), civ_id=unit.civ_id} +end + +---@param entity df.historical_entity? +---@return df.historical_entity.id? +local function get_civ_id_from_entity(entity) + if not entity then return end + if entity.type == df.historical_entity_type.Civilization then return entity.id end + for _,ee_link in ipairs(entity.entity_links) do + if ee_link.type ~= df.entity_entity_link_type.PARENT then goto continue end + local linked_he = df.historical_entity.find(ee_link.target) + if linked_he and linked_he.type == df.historical_entity_type.Civilization then + return ee_link.target + end + ::continue:: + end +end + +---@param site df.world_site? +---@return df.historical_entity.id? +local function get_civ_id_from_site(site) + if not site then return end + for _,he_link in ipairs(site.entity_links) do + if he_link.type ~= df.entity_site_link_type.All then goto continue end + local linked_he = df.historical_entity.find(he_link.entity_id) + if linked_he and linked_he.type == df.historical_entity_type.Civilization then + return he_link.entity_id + end + ::continue:: + end +end + +---@param entity df.historical_entity +---@return target_type? +local function get_entity_target(entity) + if not entity then return end + return {name=entity.name, civ_id=get_civ_id_from_entity(entity)} +end -The ``building`` or ``unit`` options are automatically assumed when in relevant UI state. +---@param site df.world_site +---@return target_type? +local function get_site_target(site) + if not site then return end + return {name=site.name, civ_id=get_civ_id_from_site(site)} +end + +---@param site df.world_site +---@param loc_id integer index into site.buildings +---@return target_type? +local function get_location_target(site, loc_id) + if not site or loc_id < 0 then return end + local loc = utils.binsearch(site.buildings, loc_id, 'id') + if not loc then return end + return {name=loc.name, civ_id=get_civ_id_from_site(site)} +end + +---@param fort df.historical_entity +---@param squad df.squad +---@return target_type? +local function get_squad_target(fort, squad) + return {name=squad.name, civ_id=get_civ_id_from_entity(fort)} +end + +---@return target_type? +local function get_world_target() + local name = df.global.world.world_data.name + local sync_names = { + function() + df.global.world.cur_savegame.world_header.world_name = + ('%s, "%s"'):format(dfhack.translation.translateName(name), + dfhack.translation.translateName(name, true)) + end + } + return {name=name, sync_names=sync_names} +end -]====] -local gui = require 'gui' -local dlg = require 'gui.dialogs' -local widgets = require 'gui.widgets' -local plugin = require 'plugins.rename' +local function select_artifact(cb) + local choices = {} + for _, item in ipairs(df.global.world.items.other.ANY_ARTIFACT) do + if item.flags.garbage_collect then goto continue end + local target = get_artifact_target(item) + if not target then goto continue end + table.insert(choices, { + text=dfhack.items.getReadableDescription(item), + data={target=target}, + }) + ::continue:: + end + dlg.showListPrompt('Rename', 'Select an artifact to rename:', COLOR_WHITE, + choices, function(_, choice) cb(choice.data.target) end, nil, nil, true) +end -local mode = ... -local focus = dfhack.gui.getCurFocus() +---@param site df.world_site +local function select_location(site, cb) + local choices = {} + for _,loc in ipairs(site.buildings) do + ---@diagnostic disable-next-line: need-check-nil + local desc, pen = sitemap.get_location_desc(loc) ---@type string, dfhack.colors|dfhack.pen + table.insert(choices, { + text={ -- note: the df.abstract_building base class doesn't have a name field, but all relevant subclasses do, so using the name field is ok. + ---@diagnostic disable-next-line: undefined-field + dfhack.translation.translateName(loc.name, true), + ' (', + {text=desc, pen=pen}, + ')', + }, + data={target=get_location_target(site, loc.id)}, + }) + end + dlg.showListPrompt('Rename', 'Select a location to rename:', COLOR_WHITE, + choices, function(_, choice) cb(choice.data.target) end, nil, nil, true) +end + +---@param entity df.historical_entity +---@param cb fun(target:target_type?):nil +local function select_entity(entity, cb) + cb(get_entity_target(entity)) +end + +---@param cb fun(target:target_type?):nil +local function select_site(site, cb) + cb(get_site_target(site)) +end + +---@param fort df.historical_entity +---@param cb fun(target:target_type?):nil +local function select_squad(fort, cb) + local choices = {} ---@type { text:string, data:{ target:target_type? } }[] + for _,squad_id in ipairs(fort.squads) do + local squad = df.squad.find(squad_id) + if squad then + table.insert(choices, { + text=dfhack.military.getSquadName(squad.id), + data={target=get_squad_target(fort, squad)}, + }) + end + end + dlg.showListPrompt('Rename', 'Select a squad to rename:', COLOR_WHITE, + choices, function(_, choice) cb(choice.data.target) end, nil, nil, true) +end + +---@param cb fun(target:target_type?):nil +local function select_unit(cb) + local choices = {} ---@type { text:string, data:{ target:target_type? } }[] + -- scan through units.all instead of units.active so we can choose starting dwarves on embark prep screen + for _,unit in ipairs(df.global.world.units.all) do + if not dfhack.units.isActive(unit) then goto continue end + local target = get_unit_target(unit) + if not target then goto continue end + table.insert(choices, { + text=dfhack.units.getReadableName(unit), + data={target=target}, + }) + ::continue:: + end + dlg.showListPrompt('Rename', 'Select a unit to rename:', COLOR_WHITE, + choices, function(_, choice) cb(choice.data.target) end, + nil, nil, true) +end + +---@param cb fun(target:target_type?):nil +local function select_world(cb) + cb(get_world_target()) +end + +local function select_new_target(cb) + local choices = {} + if #df.global.world.items.other.ANY_ARTIFACT > 0 then + table.insert(choices, {text='An artifact', data={fn=select_artifact}}) + end + if #df.global.world.units.all > 0 then + table.insert(choices, {text='A unit', data={fn=select_unit}}) + end + local site = dfhack.world.getCurrentSite() + local is_fort_mode = dfhack.world.isFortressMode() + local fort = is_fort_mode and df.historical_entity.find(df.global.plotinfo.group_id) + local civ = is_fort_mode and df.historical_entity.find(df.global.plotinfo.civ_id) + if site then + if fort and #fort.squads > 0 then + table.insert(choices, {text='A squad', data={fn=curry(select_squad, fort)}}) + end + if #site.buildings > 0 then + table.insert(choices, {text='A location', data={fn=curry(select_location, site)}}) + end + table.insert(choices, {text='This fortress/site', data={fn=curry(select_site, site)}}) + end + if fort then + table.insert(choices, {text='The government of this fortress', data={fn=curry(select_entity, fort)}}) + end + if civ then + table.insert(choices, {text='The civilization of this fortress', data={fn=curry(select_entity, civ)}}) + end + table.insert(choices, {text='The world', data={fn=select_world}}) + dlg.showListPrompt('Rename', 'What would you like to rename?', COLOR_WHITE, + choices, function(_, choice) choice.data.fn(cb) end) +end + +-- +-- Rename +-- + +Rename = defclass(Rename, widgets.Window) +Rename.ATTRS { + frame_title='Rename', + frame={w=89, h=43}, + resizable=true, + resize_min={w=61}, +} + +local function get_language_options() + local options, max_width = {}, 5 ---@type { label:string, value:integer, pen:dfhack.colors|dfhack.pen }[], integer + for idx, lang in ipairs(translations) do + max_width = math.max(max_width, #lang.name) + table.insert(options, {label=dfhack.capitalizeStringWords(dfhack.lowerCp437(lang.name)), value=idx, pen=COLOR_CYAN}) + end + return options, max_width +end + +local function pad_text(text, width) + return (' '):rep((width - #text)//2) .. text +end + +local function sort_by_english_desc(a, b) + if a.data.english ~= b.data.english then + return a.data.english < b.data.english + end + local a_native, b_native = a.data.native_fn(), b.data.native_fn() + if a_native ~= b_native then + return a_native < b_native + end + return a.data.part_of_speech < b.data.part_of_speech +end + +local function sort_by_english_asc(a, b) + if a.data.english ~= b.data.english then + return a.data.english > b.data.english + end + local a_native, b_native = a.data.native_fn(), b.data.native_fn() + if a_native ~= b_native then + return a_native < b_native + end + return a.data.part_of_speech < b.data.part_of_speech +end + +local function sort_by_native_desc(a, b) + local a_native, b_native = a.data.native_fn(), b.data.native_fn() + if a_native ~= b_native then + return a_native < b_native + end + if a.data.english ~= b.data.english then + return a.data.english < b.data.english + end + return a.data.part_of_speech < b.data.part_of_speech +end + +local function sort_by_native_asc(a, b) + local a_native, b_native = a.data.native_fn(), b.data.native_fn() + if a_native ~= b_native then + return a_native > b_native + end + if a.data.english ~= b.data.english then + return a.data.english < b.data.english + end + return a.data.part_of_speech < b.data.part_of_speech +end + +local function sort_by_part_of_speech_desc(a, b) + if a.data.part_of_speech ~= b.data.part_of_speech then + return a.data.part_of_speech < b.data.part_of_speech + end + if a.data.english ~= b.data.english then + return a.data.english < b.data.english + end + local a_native, b_native = a.data.native_fn(), b.data.native_fn() + return a_native < b_native +end + +local function sort_by_part_of_speech_asc(a, b) + if a.data.part_of_speech ~= b.data.part_of_speech then + return a.data.part_of_speech > b.data.part_of_speech + end + if a.data.english ~= b.data.english then + return a.data.english < b.data.english + end + local a_native, b_native = a.data.native_fn(), b.data.native_fn() + return a_native < b_native +end + +---@param info { target:target_type, show_selector:boolean? } +function Rename:init(info) + self.target = info.target + self.cache = {} ---@type table< df.language_name_component, { text:(string|table)[]|string, search_key:(fun():string), data:{idx:integer, english:string, native_fn:(fun():string), part_of_speech:df.part_of_speech} }[] > + + local function normalize_name() + if self.target.name.type == df.language_name_type.NONE then + self.target.name.type = df.language_name_type.Figure + end + self.target.sync_names = self.target.sync_names or {} + end + normalize_name() + + local language_options, max_lang_name_width = get_language_options() -RenameDialog = defclass(RenameDialog, dlg.InputBox) -function RenameDialog:init(info) self:addviews{ - widgets.Label{ - view_id = 'controls', - text = { - {key = 'CUSTOM_ALT_C', text = ': Clear, ', - on_activate = function() - self.subviews.edit.text = '' - end}, - {key = 'CUSTOM_ALT_S', text = ': Special chars', - on_activate = curry(dfhack.run_script, 'gui/cp437-table')}, + widgets.Panel{frame={t=0, h=7}, -- header + subviews={ + widgets.HotkeyLabel{ + frame={t=0, l=0}, + key='CUSTOM_CTRL_N', + label='Select new target', + auto_width=true, + on_activate=function() + select_new_target(function(target) + if not target then return end + self.target = target + normalize_name() + self.subviews.language:setOption(self.target.name.language) + self:refresh_list() + end) + end, + visible=info.show_selector, + }, + widgets.HotkeyLabel{ + frame={t=0, r=0}, + key='CUSTOM_CTRL_G', + label='Generate random name', + auto_width=true, + on_activate=self:callback('generate_random_name'), + }, + widgets.Label{ + frame={t=2}, + text={{pen=COLOR_YELLOW, text=function() return pad_text(dfhack.translation.translateName(self.target.name), self.frame_body.width) end}}, + }, + widgets.Label{ + frame={t=3}, + text={{pen=COLOR_LIGHTCYAN, text=function() return pad_text(('"%s"'):format(dfhack.translation.translateName(self.target.name, true)), self.frame_body.width) end}}, + }, + widgets.CycleHotkeyLabel{ + view_id='language', + frame={t=5, l=0, w=max_lang_name_width + 18}, + key='CUSTOM_CTRL_T', + label='Language:', + options=language_options, + initial_option=self.target.name.language, + on_change=self:callback('set_language'), + }, + widgets.Label{ + frame={t=6, l=7}, + text={'Name type: ', {pen=COLOR_CYAN, text=function() return df.language_name_type[self.target.name.type] end}}, + }, }, - frame = {b = 0, l = 0, r = 0, w = 70}, - } + }, + widgets.Divider{frame={t=8, l=29, w=1}, + frame_style=gui.FRAME_THIN, + frame_style_t=false, + frame_style_b=false, + }, + widgets.Panel{frame={t=8}, -- body + subviews={ + widgets.Panel{frame={t=0, l=0, w=30}, -- component selector + subviews={ + widgets.Label{ + frame={t=0, l=0}, + text='Name components:', + }, + widgets.List{ + frame={t=2, l=0, b=4, w=ENGLISH_COL_WIDTH+2}, + view_id='component_list', + on_select=function() self:refresh_list() end, + choices=self:get_component_choices(), + row_height=3, + scroll_keys={}, + }, + widgets.List{ + frame={t=2, l=ENGLISH_COL_WIDTH+4, b=4}, + on_submit=function(_, choice) choice.data.fn() end, + choices=self:get_component_action_choices(), + cursor_pen=COLOR_CYAN, + scroll_keys={}, + }, + widgets.HotkeyLabel{ + frame={b=3, l=0}, + --key='SECONDSCROLL_UP', -- use when this is available in mainline DF + key='STRING_A045', + label='Prev component', + on_activate=function() + local clist = self.subviews.component_list + local move = self.target.name.type ~= df.language_name_type.Figure and + clist:getSelected() == 2 and #clist:getChoices()-2 or -1 + self.subviews.component_list:moveCursor(move) + end, + }, + widgets.HotkeyLabel{ + frame={b=2, l=0}, + -- key='SECONDSCROLL_DOWN', -- use when this is available in mainline DF + key='STRING_A043', + label='Next component', + on_activate=function() + local clist = self.subviews.component_list + local move = self.target.name.type ~= df.language_name_type.Figure and + clist:getSelected() == #clist:getChoices() and -#clist:getChoices()+2 or 1 + self.subviews.component_list:moveCursor(move) + end, + }, + widgets.HotkeyLabel{ + frame={b=1, l=0}, + key='CUSTOM_CTRL_D', + label='Randomize component', + on_activate=function() + local _, comp_choice = self.subviews.component_list:getSelected() + if comp_choice.data.is_first_name then + self:randomize_first_name() + else + self:randomize_component_word(comp_choice.data.val) + end + end, + }, + widgets.HotkeyLabel{ + frame={b=0, l=0}, + key='CUSTOM_CTRL_H', + label='Clear component', + on_activate=function() + local _, comp_choice = self.subviews.component_list:getSelected() + self:clear_component_word(comp_choice.data.val) + end, + enabled=function() + local _, comp_choice = self.subviews.component_list:getSelected() + if comp_choice.data.is_first_name then return false end + if comp_choice.data.val == df.language_name_component.NONE then return false end + return self.target.name.words[comp_choice.data.val] >= 0 + end, + }, + }, + }, + widgets.Panel{frame={t=0, l=31, r=0}, -- words table + subviews={ + widgets.CycleHotkeyLabel{ + view_id='sort', + frame={t=0, l=0, w=19}, + label='Change sort', + key='CUSTOM_CTRL_O', + options={ + {label='', value=sort_by_english_desc}, + {label='', value=sort_by_english_asc}, + {label='', value=sort_by_native_desc}, + {label='', value=sort_by_native_asc}, + {label='', value=sort_by_part_of_speech_desc}, + {label='', value=sort_by_part_of_speech_asc}, + }, + initial_option=sort_by_english_desc, + on_change=self:callback('refresh_list', 'sort'), + }, + widgets.EditField{ + view_id='search', + frame={t=0, l=22}, + label_text='Search: ', + -- ignore_keys={'SECONDSCROLL_DOWN', 'SECONDSCROLL_UP'} + ignore_keys={'STRING_A043', 'STRING_A045'}, + }, + widgets.CycleHotkeyLabel{ + view_id='sort_english', + frame={t=2, l=0, w=8}, + options={ + {label='English', value=DEFAULT_NIL}, + {label='English'..CH_DN, value=sort_by_english_desc}, + {label='English'..CH_UP, value=sort_by_english_asc}, + }, + initial_option=sort_by_english_desc, + option_gap=0, + on_change=self:callback('refresh_list', 'sort_english'), + }, + widgets.CycleHotkeyLabel{ + view_id='sort_native', + frame={t=2, l=ENGLISH_COL_WIDTH+2, w=7}, + options={ + {label='native', value=DEFAULT_NIL}, + {label='native'..CH_DN, value=sort_by_native_desc}, + {label='native'..CH_UP, value=sort_by_native_asc}, + }, + option_gap=0, + on_change=self:callback('refresh_list', 'sort_native'), + }, + widgets.CycleHotkeyLabel{ + view_id='sort_part_of_speech', + frame={t=2, l=ENGLISH_COL_WIDTH+2+NATIVE_COL_WIDTH+2, w=15}, + options={ + {label='part of speech', value=DEFAULT_NIL}, + {label='part_of_speech'..CH_DN, value=sort_by_part_of_speech_desc}, + {label='part_of_speech'..CH_UP, value=sort_by_part_of_speech_asc}, + }, + option_gap=0, + on_change=self:callback('refresh_list', 'sort_part_of_speech'), + }, + widgets.FilteredList{ + view_id='words_list', + frame={t=4, l=0, b=0, r=0}, + on_submit=self:callback('set_component_word'), + }, + }, + }, + }, + }, } - -- calculate text_width once - self.subviews.controls:getTextWidth() -end - -function RenameDialog:getWantedFrameSize() - local x, y = self.super.getWantedFrameSize(self) - x = math.max(x, self.subviews.controls.text_width) - return x, y + 2 -end - -function showRenameDialog(title, text, input, on_input) - RenameDialog{ - frame_title = title, - text = text, - text_pen = COLOR_GREEN, - input = input, - on_input = on_input, - }:show() -end - -local function verify_mode(expected) - if mode ~= nil and mode ~= expected then - qerror('Invalid UI state for mode '..mode) - end -end - -local unit = dfhack.gui.getSelectedUnit(true) -local building = dfhack.gui.getSelectedBuilding(true) - -if building and (not unit or mode == 'building') then - verify_mode('building') - - if plugin.canRenameBuilding(building) then - showRenameDialog( - 'Rename Building', - 'Enter a new name for the building:', - building.name, - curry(plugin.renameBuilding, building) - ) - else - dlg.showMessage( - 'Rename Building', - 'Cannot rename this type of building.', COLOR_LIGHTRED - ) - end -elseif unit then - if mode == 'unit-profession' then - showRenameDialog( - 'Rename Unit', - 'Enter a new profession for the unit:', - unit.custom_profession, - function(newval) - unit.custom_profession = newval + + -- replace the FilteredList's built-in EditField with our own + self.subviews.words_list.list.frame.t = 0 + self.subviews.words_list.edit.visible = false + self.subviews.words_list.edit = self.subviews.search + self.subviews.search.on_change = self.subviews.words_list:callback('onFilterChange') + + self:refresh_list() +end + +function Rename:get_component_choices() + local choices = {} + table.insert(choices, { + text={ + {text='First Name', + pen=function() return self.target.name.type ~= df.language_name_type.Figure and COLOR_GRAY or nil end}, + NEWLINE, + {gap=2, pen=COLOR_YELLOW, text=function() return self.target.name.first_name end} + }, + data={val=df.language_name_component.TheX, is_first_name=true}}) + for val, comp in ipairs(df.language_name_component) do + if val == df.language_name_component.NONE then goto continue end + local text = { + {text=comp:gsub('(%l)(%u)', '%1 %2')}, NEWLINE, + {gap=2, pen=COLOR_YELLOW, text=function() + if val == df.language_name_component.NONE then return '' end + local word = self.target.name.words[val] + if word < 0 then return end + return ('%s'):format(language.words[word].forms[self.target.name.parts_of_speech[val]]) + end}, + } + table.insert(choices, {text=text, data={val=val}}) + + ::continue:: + end + return choices +end + +function Rename:get_component_action_choices() + local choices = {} + table.insert(choices, { + text={ + {text='[', pen=function() return self.target.name.type ~= df.language_name_type.Figure and COLOR_GRAY or COLOR_RED end}, + {text='Random', pen=function() return self.target.name.type ~= df.language_name_type.Figure and COLOR_GRAY or nil end}, + {text=']', pen=function() return self.target.name.type ~= df.language_name_type.Figure and COLOR_GRAY or COLOR_RED end} + }, + data={fn=self:callback('randomize_first_name')}, + }) + table.insert(choices, {text='', data={fn=function() end}}) -- shouldn't be able to clear a first name, only overwrite + table.insert(choices, {text='', data={fn=function() end}}) + + local randomize_text = {{text='[', pen=COLOR_RED}, 'Random', {text=']', pen=COLOR_RED}} + for val, comp in ipairs(df.language_name_component) do + if val == df.language_name_component.NONE then goto continue end + local randomize_fn = self:callback('randomize_component_word', comp) + table.insert(choices, {text=randomize_text, data={fn=randomize_fn}}) + local clear_text = { + {text=function() return self.target.name.words[comp] >= 0 and '[' or '' end, pen=COLOR_RED}, + {text=function() return self.target.name.words[comp] >= 0 and 'Clear' or '' end }, + {text=function() return self.target.name.words[comp] >= 0 and ']' or '' end, pen=COLOR_RED} + } + local clear_fn = self:callback('clear_component_word', comp) + table.insert(choices, {text=clear_text, data={fn=clear_fn}}) + table.insert(choices, {text='', data={fn=function() end}}) + + ::continue:: + end + return choices +end + +---@param comp df.language_name_component +function Rename:clear_component_word(comp) + self.target.name.words[comp] = -1 + for _, sync_name in ipairs(self.target.sync_names) do + if type(sync_name) == 'function' then + sync_name() + else + sync_name.words[comp] = -1 + end + end +end + +---@param word_idx integer +function Rename:set_first_name(word_idx) + -- support giving names to previously unnamed units + self.target.name.has_name = true + + self.target.name.first_name = translations[self.subviews.language:getOptionValue()].words[word_idx].value + for _, sync_name in ipairs(self.target.sync_names) do + if type(sync_name) == 'function' then + sync_name() + else + sync_name.has_name = true + sync_name.first_name = self.target.name.first_name + end + end +end + +---@param component df.language_name_component +---@param word_idx integer +---@param part_of_speech df.part_of_speech +function Rename:set_component_word_by_data(component, word_idx, part_of_speech) + self.target.name.words[component] = word_idx + self.target.name.parts_of_speech[component] = part_of_speech + for _, sync_name in ipairs(self.target.sync_names) do + if type(sync_name) == 'function' then + sync_name() + else + sync_name.words[component] = word_idx + sync_name.parts_of_speech[component] = part_of_speech + end + end +end + +function Rename:set_component_word(_, choice) + local _, comp_choice = self.subviews.component_list:getSelected() + if comp_choice.data.is_first_name then + self:set_first_name(choice.data.idx) + return + end + self:set_component_word_by_data(comp_choice.data.val, choice.data.idx, choice.data.part_of_speech) +end + +---@param val integer index into df.language_translation:get_vector() +---@param prev_val integer index into df.language_translation:get_vector() +function Rename:set_language(val, prev_val) + self.target.name.language = val + -- translate current first name into target language + local idx = utils.linear_index(translations[prev_val].words, self.target.name.first_name, 'value') + if idx then self.target.name.first_name = translations[val].words[idx].value end + for _, sync_name in ipairs(self.target.sync_names) do + if type(sync_name) == 'function' then + sync_name() + else + sync_name.language = val + sync_name.first_name = self.target.name.first_name + end + end +end + +function Rename:randomize_first_name() + if self.target.name.type ~= df.language_name_type.Figure then return end + local choices = self:get_word_choices(df.language_name_component.TheX) + self:set_first_name(choices[math.random(#choices)].data.idx) +end + +---@param comp df.language_name_component +function Rename:randomize_component_word(comp) + local choices = self:get_word_choices(df.language_name_component.TheX) + local choice = choices[math.random(#choices)] + self:set_component_word_by_data(comp, choice.data.idx, choice.data.part_of_speech) +end + +function Rename:generate_random_name() + local civ + if self.target.civ_id then + civ = df.historical_entity.find(self.target.civ_id) + end + local major_selector, minor_selector = get_word_selectors(self.target.name.type, civ) + dfhack.translation.generateName(self.target.name, self.target.name.language, + self.target.name.type, major_selector, minor_selector) + for _, sync_name in ipairs(self.target.sync_names) do + if type(sync_name) == 'function' then + sync_name() + else + df.assign(sync_name, self.target.name) + end + end +end + +local part_of_speech_to_display = { + [df.part_of_speech.Noun] = 'Singular Noun', + [df.part_of_speech.NounPlural] = 'Plural Noun', + [df.part_of_speech.Adjective] = 'Adjective', + [df.part_of_speech.Prefix] = 'Prefix', + [df.part_of_speech.Verb] = 'Present (1st)', + [df.part_of_speech.Verb3rdPerson] = 'Present (3rd)', + [df.part_of_speech.VerbPast] = 'Preterite', + [df.part_of_speech.VerbPassive] = 'Past Participle', + [df.part_of_speech.VerbGerund] = 'Present Participle', +} + +---@param comp df.language_name_component +---@param idx integer +---@param word df.language_word +---@param part_of_speech df.part_of_speech +function Rename:add_word_choice(choices, comp, idx, word, part_of_speech) + local english = word.forms[part_of_speech] + if #english == 0 then return end + local function get_native() + return translations[self.subviews.language:getOptionValue()].words[idx].value + end + local part = part_of_speech_to_display[part_of_speech] + local clist = self.subviews.component_list + local function get_pen() + local _, comp_choice = clist:getSelected() + if comp_choice.data.is_first_name then + return get_native() == self.target.name.first_name and COLOR_YELLOW or nil + end + if idx == self.target.name.words[comp] and part_of_speech == self.target.name.parts_of_speech[comp] then + return COLOR_YELLOW + end + end + table.insert(choices, { + text={ + {text=english, width=ENGLISH_COL_WIDTH, pen=get_pen}, + {gap=2, text=get_native, width=NATIVE_COL_WIDTH, pen=get_pen}, + {gap=2, text=part, width=15, pen=get_pen}, + }, + search_key=function() return ('%s %s %s'):format(english, get_native(), part) end, + data={idx=idx, english=english, native_fn=get_native, part_of_speech=part_of_speech}, + }) +end + +---@param comp df.language_name_component +function Rename:get_word_choices(comp) + if self.cache[comp] then + return self.cache[comp] + end + + local choices = {} ---@type {text:(string|table)[]|string, search_key:(fun():string), data:{idx:integer, english:string, native_fn:(fun():string), part_of_speech:df.part_of_speech}} + for idx, word in ipairs(language.words) do + local flags = word.flags + if comp == df.language_name_component.FrontCompound then + if flags.front_compound_noun_sing then self:add_word_choice(choices, comp, idx, word, df.part_of_speech.Noun) end + if flags.front_compound_noun_plur then self:add_word_choice(choices, comp, idx, word, df.part_of_speech.NounPlural) end + if flags.front_compound_adj then self:add_word_choice(choices, comp, idx, word, df.part_of_speech.Adjective) end + if flags.front_compound_prefix then self:add_word_choice(choices, comp, idx, word, df.part_of_speech.Prefix) end + if flags.standard_verb then + self:add_word_choice(choices, comp, idx, word, df.part_of_speech.Verb) + self:add_word_choice(choices, comp, idx, word, df.part_of_speech.VerbPassive) + end + elseif comp == df.language_name_component.RearCompound then + if flags.rear_compound_noun_sing then self:add_word_choice(choices, comp, idx, word, df.part_of_speech.Noun) end + if flags.rear_compound_noun_plur then self:add_word_choice(choices, comp, idx, word, df.part_of_speech.NounPlural) end + if flags.rear_compound_adj then self:add_word_choice(choices, comp, idx, word, df.part_of_speech.Adjective) end + if flags.standard_verb then + self:add_word_choice(choices, comp, idx, word, df.part_of_speech.Verb) + self:add_word_choice(choices, comp, idx, word, df.part_of_speech.Verb3rdPerson) + self:add_word_choice(choices, comp, idx, word, df.part_of_speech.VerbPast) + self:add_word_choice(choices, comp, idx, word, df.part_of_speech.VerbPassive) end - ) - else - verify_mode('unit') + elseif comp == df.language_name_component.FirstAdjective or comp == df.language_name_component.SecondAdjective then + self:add_word_choice(choices, comp, idx, word, df.part_of_speech.Adjective) + elseif comp == df.language_name_component.HyphenCompound then + if flags.the_compound_noun_sing then self:add_word_choice(choices, comp, idx, word, df.part_of_speech.Noun) end + if flags.the_compound_noun_plur then self:add_word_choice(choices, comp, idx, word, df.part_of_speech.NounPlural) end + if flags.the_compound_adj then self:add_word_choice(choices, comp, idx, word, df.part_of_speech.Adjective) end + if flags.the_compound_prefix then self:add_word_choice(choices, comp, idx, word, df.part_of_speech.Prefix) end + elseif comp == df.language_name_component.TheX then + if flags.the_noun_sing then self:add_word_choice(choices, comp, idx, word, df.part_of_speech.Noun) end + if flags.the_noun_plur then self:add_word_choice(choices, comp, idx, word, df.part_of_speech.NounPlural) end + elseif comp == df.language_name_component.OfX then + if flags.of_noun_sing then self:add_word_choice(choices, comp, idx, word, df.part_of_speech.Noun) end + if flags.of_noun_plur then self:add_word_choice(choices, comp, idx, word, df.part_of_speech.NounPlural) end + if flags.standard_verb then + self:add_word_choice(choices, comp, idx, word, df.part_of_speech.VerbGerund) + end + end + end - local vname = dfhack.units.getVisibleName(unit) - local vnick = '' - if vname and vname.has_name then - vnick = vname.nickname + self.cache[comp] = choices + return self.cache[comp] +end + +---@param sort_widget widgets.CycleHotkeyLabel? +---@param sort_fn function? +function Rename:refresh_list(sort_widget, sort_fn) + local clist = self.subviews.component_list + if not clist then return end + if self.target.name.type ~= df.language_name_type.Figure and clist:getSelected() == 1 then + clist:setSelected(self.prev_selected_component ~= 1 and self.prev_selected_component or 2) + end + self.prev_selected_component = clist:getSelected() + + sort_widget = sort_widget or 'sort' + sort_fn = sort_fn or self.subviews.sort:getOptionValue() + if sort_fn == DEFAULT_NIL then + self.subviews[sort_widget]:cycle() + return + end + for _,widget_name in ipairs{'sort', 'sort_english', 'sort_native', 'sort_part_of_speech'} do + self.subviews[widget_name]:setOption(sort_fn) + end + local list = self.subviews.words_list + local saved_filter = list:getFilter() + list:setFilter('') + local _, comp_choice = clist:getSelected() + local choices = self:get_word_choices(comp_choice.data.val) + table.sort(choices, sort_fn) + list:setChoices(choices) + list:setFilter(saved_filter) +end + +-- +-- RenameScreen +-- + +RenameScreen = defclass(RenameScreen, gui.ZScreen) +RenameScreen.ATTRS { + focus_path='rename', +} + +---@param info { target:target_type, show_selector:boolean? } +function RenameScreen:init(info) + self:addviews{ + Rename{ + target=info.target, + show_selector=info.show_selector, + } + } +end + +function RenameScreen:onDismiss() + view = nil +end + +-- +-- WorldRenameOverlay +-- + +WorldRenameOverlay = defclass(WorldRenameOverlay, overlay.OverlayWidget) +WorldRenameOverlay.ATTRS { + desc='Adds a button for renaming newly generated worlds.', + default_pos={x=57, y=3}, + default_enabled=true, + viewscreens='new_region', + frame={w=22, h=1}, + visible=function() return dfhack.isWorldLoaded() end, +} + +function WorldRenameOverlay:init() + self:addviews{ + widgets.TextButton{ + frame={t=0, l=0}, + label='Rename world', + key='CUSTOM_CTRL_T', + on_activate=function() dfhack.run_script('gui/rename', '--world', '--no-target-selector') end, + }, + } +end + +-- +-- UnitEmbarkRenameOverlay +-- + +local mi = df.global.game.main_interface + +UnitEmbarkRenameOverlay = defclass(UnitEmbarkRenameOverlay, overlay.OverlayWidget) +UnitEmbarkRenameOverlay.ATTRS { + desc='Allows editing of unit nicknames on the embark preparation screen.', + default_enabled=true, + viewscreens='setupdwarfgame/Dwarves', + fullscreen=true, + active=function() return mi.view_sheets.open end, +} + +local function get_selected_embark_unit() + local scr = dfhack.gui.getDFViewscreen(true) ---@cast scr df.viewscreen_setupdwarfgamest + return scr.s_unit[scr.selected_u] +end + +function UnitEmbarkRenameOverlay:onInput(keys) + if (keys.SELECT or keys._STRING) and mi.view_sheets.unit_overview_customizing then + if mi.view_sheets.unit_overview_entering_nickname then + if keys.SELECT then + mi.view_sheets.unit_overview_entering_nickname = false + return true + end + local unit = get_selected_embark_unit() + if unit then + if keys._STRING == 0 then + unit.name.nickname = string.sub(unit.name.nickname, 1, -2) + else + unit.name.nickname = unit.name.nickname .. string.char(keys._STRING) + end + local hf = df.historical_figure.find(unit.hist_figure_id) + if hf then + hf.name.nickname = unit.name.nickname + end + return true + end + elseif mi.view_sheets.unit_overview_entering_profession_nickname then + if keys.SELECT then + mi.view_sheets.unit_overview_entering_profession_nickname = false + return true + end + local unit = get_selected_embark_unit() + if unit then + if keys._STRING == 0 then + unit.custom_profession = string.sub(unit.custom_profession, 1, -2) + else + unit.custom_profession = unit.custom_profession .. string.char(keys._STRING) + end + return true + end end + end + return false +end + +OVERLAY_WIDGETS = { + unit_embark=UnitEmbarkRenameOverlay, + world=WorldRenameOverlay, +} + +-- +-- CLI +-- + +if dfhack_flags.module then + return +end + +if not dfhack.isWorldLoaded() then + qerror('This script requires a world to be loaded') +end + +---@return target_type? +local function get_target(opts) + local target + if opts.histfig_id then + target = get_hf_target(df.historical_figure.find(opts.histfig_id)) + if not target then qerror('Historical figure not found') end + elseif opts.entity_id then + target = get_entity_target(df.historical_entity.find(opts.entity_id)) + if not target then qerror('Entity not found') end + elseif opts.item_id then + target = get_artifact_target(df.item.find(opts.item_id)) + if not target then qerror('Artifact not found') end + elseif opts.location_id then + local site = opts.site_id and df.world_site.find(opts.site_id) or dfhack.world.getCurrentSite() + if not site then qerror('Site not found') end + target = get_location_target(site, opts.location_id) + if not target then qerror('Location not found') end + elseif opts.site_id then + local site = df.world_site.find(opts.site_id) + if not site then qerror('Site not found') end + target = site.name + elseif opts.squad_id then + local squad = df.squad.find(opts.squad_id) + if not squad then qerror('Squad not found') end + target = squad.name + elseif opts.unit_id then + ---@diagnostic disable-next-line: param-type-mismatch + target = get_unit_target(df.unit.find(opts.unit_id)) + if not target then qerror('Unit not found') end + elseif opts.world then + target = get_world_target() + end + return target +end - showRenameDialog( - 'Rename Unit', - 'Enter a new nickname for the unit:', - vnick, - curry(dfhack.units.setNickname, unit) - ) +local function main(args) + local opts = { + help=false, + entity_id=nil, + histfig_id=nil, + item_id=nil, + location_id=nil, + site_id=nil, + squad_id=nil, + unit_id=nil, + world=false, + show_selector=true, + } + local positionals = argparse.processArgsGetopt(args, { + { 'a', 'artifact', hasArg=true, handler=function(optarg) opts.item_id = argparse.nonnegativeInt(optarg, 'artifact') end }, + { 'e', 'entity', hasArg=true, handler=function(optarg) opts.entity_id = argparse.nonnegativeInt(optarg, 'entity') end }, + { 'f', 'histfig', hasArg=true, handler=function(optarg) opts.histfig_id = argparse.nonnegativeInt(optarg, 'histfig') end }, + { 'h', 'help', handler = function() opts.help = true end }, + { 'l', 'location', hasArg=true, handler=function(optarg) opts.location_id = argparse.nonnegativeInt(optarg, 'location') end }, + { 'q', 'squad', hasArg=true, handler=function(optarg) opts.squad_id = argparse.nonnegativeInt(optarg, 'squad') end }, + { 's', 'site', hasArg=true, handler=function(optarg) opts.site_id = argparse.nonnegativeInt(optarg, 'site') end }, + { 'u', 'unit', hasArg=true, handler=function(optarg) opts.unit_id = argparse.nonnegativeInt(optarg, 'unit') end }, + { 'w', 'world', handler=function() opts.world = true end }, + { '', 'no-target-selector', handler=function() opts.show_selector = false end }, + }) + + if opts.help or positionals[1] == 'help' then + print(dfhack.script_help()) + return + end + + ---@param target target_type + local function launch(target) + view = view and view:raise() or RenameScreen{ + target=target, + show_selector=opts.show_selector, + }:show() end -elseif mode then - verify_mode(nil) + + local target = get_target(opts) + if target then + launch(target) + return + end + + local unit = dfhack.gui.getSelectedUnit(true) + local item = dfhack.gui.getSelectedItem(true) + local zone = dfhack.gui.getSelectedCivZone(true) + if unit then + target = get_unit_target(unit) + elseif item then + target = get_artifact_target(item) + elseif zone then + target = get_location_target(df.world_site.find(zone.site_id), zone.location_id) + end + if target then + launch(target) + return + end + + if not opts.show_selector then + qerror('No target selected') + end + + select_new_target(launch) end + +main{...} diff --git a/gui/reveal.lua b/gui/reveal.lua new file mode 100644 index 0000000000..f9e636cebd --- /dev/null +++ b/gui/reveal.lua @@ -0,0 +1,141 @@ +local argparse = require('argparse') +local dig = require('plugins.dig') +local gui = require('gui') +local widgets = require('gui.widgets') + +-- +-- Reveal +-- + +Reveal = defclass(Reveal, widgets.ResizingPanel) +Reveal.ATTRS { + frame_title='Reveal', + frame={w=37, r=2, t=18}, + frame_style=gui.WINDOW_FRAME, + frame_background=gui.CLEAR_PEN, + frame_inset=1, + draggable=true, + resizable=false, + autoarrange_subviews=true, + autoarrange_gap=1, + opts=DEFAULT_NIL, +} + +function Reveal:init() + self.graphics = dfhack.screen.inGraphicsMode() + + local is_adv = dfhack.world.isAdventureMode() + + if is_adv then + self.frame.t = 0 + end + + self:addviews{ + widgets.ResizingPanel{ + autoarrange_subviews=true, + autoarrange_gap=1, + visible=not self.opts.aquifers_only, + subviews={ + widgets.WrappedLabel{ + text_to_wrap='The map is revealed.' .. (is_adv and '' or ' The game will be force paused until you close this window.'), + }, + widgets.WrappedLabel{ + text_to_wrap='Areas with event triggers are kept hidden to avoid spoilers.', + visible=not self.opts.hell, + }, + widgets.WrappedLabel{ + text_to_wrap='Areas with hidden secrets have been revealed.' .. (is_adv and '' or ' The map must be hidden again before unpausing.'), + text_pen=COLOR_RED, + visible=self.opts.hell, + }, + widgets.WrappedLabel{ + text_to_wrap='In graphics mode, solid tiles that are not adjacent to open space are not rendered. Switch to ASCII mode to see them.', + text_pen=COLOR_BROWN, + visible=dfhack.screen.inGraphicsMode and not is_adv, + }, + }, + }, + widgets.WrappedLabel{ + text_to_wrap='Aquifers and damp tiles are revealed.', + visible=self.opts.aquifers_only, + }, + widgets.Divider{ + frame={h=1}, + frame_style=gui.FRAME_THIN, + frame_style_l=false, + frame_style_r=false, + visible=not self.opts.aquifers_only and not is_adv, + }, + widgets.ToggleHotkeyLabel{ + view_id='unreveal', + key='CUSTOM_SHIFT_R', + label='Restore map on close:', + options={ + {label='Yes', value=true, pen=COLOR_GREEN}, + {label='No', value=false, pen=COLOR_RED}, + }, + enabled=not self.opts.hell, + visible=not self.opts.aquifers_only and not is_adv, + }, + } +end + +function Reveal:onRenderFrame(dc, rect) + local graphics = dfhack.screen.inGraphicsMode() + if graphics ~= self.graphics then + self.graphics = graphics + self:updateLayout() + end + dig.paintScreenWarmDamp(true, true) + Reveal.super.onRenderFrame(self, dc, rect) +end + +-- +-- RevealScreen +-- + +RevealScreen = defclass(RevealScreen, gui.ZScreen) +RevealScreen.ATTRS { + focus_path='reveal', + pass_movement_keys=true, + opts=DEFAULT_NIL, +} + +function RevealScreen:init() + if not self.opts.aquifers_only then + self.force_pause=true + local command = {'reveal'} + if self.opts.hell then + table.insert(command, 'hell') + end + dfhack.run_command(command) + end + + self:addviews{Reveal{opts=self.opts}} +end + +function RevealScreen:onDismiss() + view = nil + if not self.opts.aquifers_only and self.subviews.unreveal:getOptionValue() then + dfhack.run_command('unreveal') + end +end + +if not dfhack.isMapLoaded() then + qerror('This script requires a map to be loaded') +end + +local opts = {aquifers_only=false} +local positionals = argparse.processArgsGetopt({...}, { + { 'h', 'help', handler = function() opts.help = true end }, + { 'o', 'aquifers-only', handler = function() opts.aquifers_only = true end }, +}) + +if opts.help or positionals[1] == 'help' then + print(dfhack.script_help()) + return +end + +opts.hell = positionals[1] == 'hell' + +view = view and view:raise() or RevealScreen{opts=opts}:show() diff --git a/gui/room-list.lua b/gui/room-list.lua index 00de972a10..4a9a50e93d 100644 --- a/gui/room-list.lua +++ b/gui/room-list.lua @@ -120,7 +120,7 @@ function drawUnitName(dc, unit) local vname = dfhack.units.getVisibleName(unit) if vname and vname.has_name then - dc:string(dfhack.TranslateName(vname)..', ') + dc:string(dfhack.translation.translateName(vname)..', ') end dc:string(dfhack.units.getProfessionName(unit)) else diff --git a/gui/sandbox.lua b/gui/sandbox.lua index 3866fd3096..64fa1ae763 100644 --- a/gui/sandbox.lua +++ b/gui/sandbox.lua @@ -23,6 +23,7 @@ Sandbox.ATTRS { frame={r=2, t=18, w=26, h=20}, frame_inset={b=1}, interface_masks=DEFAULT_NIL, + creator_unit=DEFAULT_NIL, } local function is_sentient(unit) @@ -56,8 +57,6 @@ local function finalize_animal(unit, disposition) -- noop; units are created friendly by default elseif disposition == DISPOSITIONS.FORT then makeown.make_own(unit) - unit.flags1.tame = true - unit.training_level = df.animal_training_level.Domesticated end end @@ -81,7 +80,6 @@ local function finalize_units(first_created_unit_id, disposition, syndrome) unit.profession = df.profession.STANDARD if syndrome then syndrome_util.infectWithSyndrome(unit, syndrome) - unit.flags1.zombie = true; end unit.name.has_name = false if is_sentient(unit) then @@ -97,6 +95,17 @@ function Sandbox:init() self.spawn_group = 1 self.first_unit_id = df.global.unit_next_id + local disposition_options = { + {label='hostile', value=DISPOSITIONS.HOSTILE, pen=COLOR_LIGHTRED}, + {label='hostile (undead)', value=DISPOSITIONS.HOSTILE_UNDEAD, pen=COLOR_RED}, + {label='independent/wild', value=DISPOSITIONS.WILD, pen=COLOR_YELLOW}, + {label='friendly', value=DISPOSITIONS.FRIENDLY, pen=COLOR_GREEN}, + } + + if dfhack.world.isFortressMode() then + table.insert(disposition_options, {label='citizens/pets', value=DISPOSITIONS.FORT, pen=COLOR_BLUE}) + end + self:addviews{ widgets.ResizingPanel{ frame={t=0}, @@ -141,13 +150,7 @@ function Sandbox:init() key_back='CUSTOM_SHIFT_A', label='Group disposition', label_below=true, - options={ - {label='hostile', value=DISPOSITIONS.HOSTILE, pen=COLOR_LIGHTRED}, - {label='hostile (undead)', value=DISPOSITIONS.HOSTILE_UNDEAD, pen=COLOR_RED}, - {label='independent/wild', value=DISPOSITIONS.WILD, pen=COLOR_YELLOW}, - {label='friendly', value=DISPOSITIONS.FRIENDLY, pen=COLOR_GREEN}, - {label='citizens/pets', value=DISPOSITIONS.FORT, pen=COLOR_BLUE}, - }, + options=disposition_options, }, }, }, @@ -170,7 +173,15 @@ function Sandbox:init() frame={l=0}, key='CUSTOM_SHIFT_I', label="Create item", - on_activate=function() dfhack.run_script('gui/create-item') end + on_activate=function() + local cmd = {'gui/create-item'} + if self.creator_unit then + table.insert(cmd, '-u') + table.insert(cmd, tostring(self.creator_unit.id)) + end + printall(cmd) + dfhack.run_script(table.unpack(cmd)) + end }, }, }, @@ -203,7 +214,20 @@ function Sandbox:onInput(keys) return true end if keys._MOUSE_L then - if self:getMouseFramePos() then return true end + -- don't click "through" the gui/sandbox ui + if self:getMouseFramePos() then + return true + end + -- don't allow clicking on the "assume control" button of a unit + local scr = dfhack.gui.getDFViewscreen(true) + if dfhack.gui.matchFocusString('dwarfmode/ViewSheets/UNIT/Overview', scr) then + local interface_rect = gui.ViewRect{rect=gui.get_interface_rect()} + local button_rect = interface_rect:viewport(interface_rect.width-77, interface_rect.height-7, 20, 3) + local mouse_x, mouse_y = dfhack.screen.getMousePos() + if mouse_x and button_rect:inClipGlobalXY(mouse_x, mouse_y) then + return true + end + end for _,mask_panel in ipairs(self.interface_masks) do if mask_panel:getMousePos() then return true end end @@ -214,14 +238,27 @@ end function Sandbox:find_zombie_syndrome() if self.zombie_syndrome then return self.zombie_syndrome end - for _,syn in ipairs(df.global.world.raws.syndromes.all) do + for _,syn in ipairs(df.global.world.raws.mat_table.syndromes.all) do + local has_flags, has_flash = false, false for _,effect in ipairs(syn.ce) do + if df.creature_interaction_effect_display_namest:is_instance(effect) then + -- we don't want named zombie syndromes; they're usually necro experiments + goto continue + end if df.creature_interaction_effect_add_simple_flagst:is_instance(effect) and - effect.tags1.OPPOSED_TO_LIFE and effect['end'] == -1 then - self.zombie_syndrome = syn - return syn + effect.tags1.OPPOSED_TO_LIFE and + effect['end'] == -1 + then + has_flags = true + end + if df.creature_interaction_effect_flash_symbolst:is_instance(effect) then + has_flash = true end end + if has_flags and has_flash then + self.zombie_syndrome = syn + return syn + end ::continue:: end dfhack.printerr('permanent syndrome with OPPOSED_TO_LIFE not found; not marking as undead') @@ -258,7 +295,6 @@ SandboxScreen = defclass(SandboxScreen, gui.ZScreen) SandboxScreen.ATTRS { focus_path='sandbox', force_pause=true, - pass_pause=false, defocusable=false, } @@ -334,7 +370,8 @@ local function init_arena() arena.race:resize(0) arena.caste:resize(0) arena.creature_cnt:resize(0) - arena.type = -1 + arena.last_race = -1 + arena.last_caste = -1 arena_unit.race = 0 arena_unit.caste = 0 arena_unit.races_filtered:resize(0) @@ -407,11 +444,11 @@ local function init_arena() item_subtype=itemdef.subtype, mattype=mattype, matindex=matindex, - unk_c=1} + on=1} if #list > list_size then utils.assign(list[list_size], element) else - element.new = df.embark_item_choice.T_list + element.new = df.itinfost list:insert('#', element) end list_size = list_size + 1 @@ -421,15 +458,15 @@ local function init_arena() -- for i in ipairs(df.builtin_mats) do -- do_insert(MAT_TABLE.builtin[i], i, -1) -- end - for i, mat in ipairs(RAWS.inorganics) do + for i, mat in ipairs(RAWS.inorganics.all) do do_insert(mat.material, 0, i) -- stop at the first "special" metal. we don't need more than that if mat.flags.DEEP_SPECIAL then break end end if data.want_leather then - leather_index_hint = scan_organic(df.organic_mat_category.Leather, RAWS.creatures.all, leather_index_hint, materials.CREATURE_BASE, do_insert) + leather_index_hint = scan_organic(df.organic_mat_category.Leather, RAWS.creatures.all, leather_index_hint, df.builtin_mats.CREATURE_1, do_insert) end - plant_index_hint = scan_organic(df.organic_mat_category.Wood, RAWS.plants.all, plant_index_hint, materials.PLANT_BASE, do_insert) + plant_index_hint = scan_organic(df.organic_mat_category.Wood, RAWS.plants.all, plant_index_hint, df.builtin_mats.PLANT_1, do_insert) ::inner_continue:: end ::continue:: @@ -465,21 +502,32 @@ function SandboxScreen:init() Sandbox{ view_id='sandbox', interface_masks=mask_panel.subviews, + creator_unit=dfhack.world.getAdventurer(), }, } - self.prev_gametype = df.global.gametype - df.global.gametype = df.game_type.DWARF_ARENA + self.prev_gamemode, self.prev_gametype = df.global.gamemode, df.global.gametype + df.global.gamemode, df.global.gametype = df.game_mode.DWARF, df.game_type.DWARF_ARENA + if self.prev_gamemode ~= df.game_mode.DWARF then + local dwarf = df.viewscreen_dwarfmodest:new() + dfhack.screen.show(dwarf) + end end function SandboxScreen:onDismiss() - df.global.gametype = self.prev_gametype - view = nil self.subviews.sandbox:finalize_group() end -if not dfhack.isWorldLoaded() then - qerror('gui/sandbox must have a world loaded') +function SandboxScreen:onDestroy() + view = nil + df.global.gamemode, df.global.gametype = self.prev_gamemode, self.prev_gametype + if self.prev_gamemode ~= df.game_mode.DWARF then + dfhack.run_script('devel/pop-screen') + end +end + +if not dfhack.isMapLoaded() then + qerror('gui/sandbox must have a map loaded') end view = view and view:raise() or SandboxScreen{}:show() diff --git a/gui/seedwatch.lua b/gui/seedwatch.lua index 612b42ee48..ad40e175b6 100644 --- a/gui/seedwatch.lua +++ b/gui/seedwatch.lua @@ -1,270 +1,290 @@ --- config ui for seedwatch - +local dlg = require('gui.dialogs') local gui = require('gui') -local widgets = require('gui.widgets') local plugin = require('plugins.seedwatch') +local widgets = require('gui.widgets') -local PROPERTIES_HEADER = ' Quantity Target ' -local REFRESH_MS = 10000 -local MAX_TARGET = 2147483647 --- --- SeedSettings --- -SeedSettings = defclass(SeedSettings, widgets.Window) -SeedSettings.ATTRS{ - frame={l=5, t=5, w=35, h=9}, +local CH_UP = string.char(30) +local CH_DN = string.char(31) + +Seedwatch = defclass(Seedwatch, widgets.Window) +Seedwatch.ATTRS{ + frame_title='Seedwatch', + frame={w=58, h=25}, + frame_inset={t=1}, + resizable=true, } -function SeedSettings:init() - self:addviews{ - widgets.Label{ - frame={t=0, l=0}, - text='Seed: ', - }, - widgets.Label{ - view_id='name', - frame={t=0, l=6}, - text_pen=COLOR_GREEN, - }, - widgets.Label{ - frame={t=1, l=0}, - text='Quantity: ', - }, - widgets.Label{ - view_id='quantity', - frame={t=1, l=10}, - text_pen=COLOR_GREEN, - }, - widgets.EditField{ - view_id='target', - frame={t=2, l=0}, - label_text='Target: ', - key='CUSTOM_CTRL_T', - on_char=function(ch) return ch:match('%d') end, - on_submit=self:callback('commit'), - }, - widgets.HotkeyLabel{ - frame={t=4, l=0}, - key='SELECT', - label='Apply', - on_activate=self:callback('commit'), - }, - } +local function sort_noop(a, b) + -- this function is used as a marker and never actually gets called + error('sort_noop should not be called') end -function SeedSettings:show(choice, on_commit) - self.data = choice.data - self.on_commit = on_commit - self.subviews.name:setText(self.data.name) - self.subviews.quantity:setText(tostring(self.data.quantity)) - self.subviews.target:setText(tostring(self.data.target)) - self.visible = true - self:setFocus(true) - self:updateLayout() +local function sort_by_name_desc(a, b) + return a.data.name < b.data.name end -function SeedSettings:hide() - self:setFocus(false) - self.visible = false +local function sort_by_name_asc(a, b) + return a.data.name > b.data.name end -function SeedSettings:commit() - local target = math.tointeger(self.subviews.target.text) or 0 - target = math.min(MAX_TARGET, math.max(0, target)) - - plugin.seedwatch_setTarget(self.data.id, target) - self:hide() - self.on_commit() +local function sort_by_quantity_desc(a, b) + if a.data.quantity == b.data.quantity then + return sort_by_name_desc(a, b) + end + return a.data.quantity > b.data.quantity end -function SeedSettings:onInput(keys) - if keys.LEAVESCREEN or keys._MOUSE_R then - self:hide() - return true +local function sort_by_quantity_asc(a, b) + if a.data.quantity == b.data.quantity then + return sort_by_name_desc(a, b) end - SeedSettings.super.onInput(self, keys) - return true + return a.data.quantity < b.data.quantity end --- --- Seedwatch --- -Seedwatch = defclass(Seedwatch, widgets.Window) -Seedwatch.ATTRS { - frame_title='Seedwatch', - frame={w=60, h=27}, - resizable=true, - resize_min={h=25}, -} - -function Seedwatch:init() - local minimal = false - local saved_frame = {w=50, h=6, r=2, t=18} - local saved_resize_min = {w=saved_frame.w, h=saved_frame.h} - local function toggle_minimal() - minimal = not minimal - local swap = self.frame - self.frame = saved_frame - saved_frame = swap - swap = self.resize_min - self.resize_min = saved_resize_min - saved_resize_min = swap - self:updateLayout() - self:refresh_data() - end - local function is_minimal() - return minimal +local function sort_by_target_desc(a, b) + if a.data.target == b.data.target then + return sort_by_name_desc(a, b) end - local function is_not_minimal() - return not minimal + return a.data.target > b.data.target +end + +local function sort_by_target_asc(a, b) + if a.data.target == b.data.target then + return sort_by_name_desc(a, b) end + return a.data.target < b.data.target +end +function Seedwatch:init() self:addviews{ - widgets.ToggleHotkeyLabel{ - view_id='enable_toggle', - frame={t=0, l=0, w=31}, - label='Seedwatch is', - key='CUSTOM_CTRL_E', - options={{value=true, label='Enabled', pen=COLOR_GREEN}, - {value=false, label='Disabled', pen=COLOR_RED}}, - on_change=function(val) plugin.setEnabled(val) end, - }, - widgets.EditField{ - view_id='all', - frame={t=1, l=0}, - label_text='Target for all: ', - key='CUSTOM_CTRL_A', - on_char=function(ch) return ch:match('%d') end, - on_submit=function(text) - local target = math.tointeger(text) - if not target or target == '' then - target = 0 - elseif target > MAX_TARGET then - target = MAX_TARGET - end - plugin.seedwatch_setTarget('all', target) - self.subviews.list:setFilter('') - self:refresh_data() - self:update_choices() - end, - visible=is_not_minimal, - text='30', + widgets.CycleHotkeyLabel{ + view_id='sort', + frame={l=1, t=0, w=31}, + label='Sort by:', + key='CUSTOM_SHIFT_S', + options={ + {label='Name'..CH_DN, value=sort_by_name_desc}, + {label='Name'..CH_UP, value=sort_by_name_asc}, + {label='Quantity'..CH_DN, value=sort_by_quantity_desc}, + {label='Quantity'..CH_UP, value=sort_by_quantity_asc}, + {label='Target'..CH_DN, value=sort_by_target_desc}, + {label='Target'..CH_UP, value=sort_by_target_asc}, + }, + initial_option=sort_by_name_desc, + on_change=self:callback('refresh', 'sort'), }, - - widgets.HotkeyLabel{ - frame={r=0, t=0, w=10}, - key='CUSTOM_ALT_M', - label=string.char(31)..string.char(30), - on_activate=toggle_minimal}, - widgets.Label{ - view_id='minimal_summary', - frame={t=1, l=0, h=1}, - auto_height=false, - visible=is_minimal, - }, - widgets.Label{ - frame={t=3, l=0}, - text='Seed', - auto_width=true, - visible=is_not_minimal, - }, - widgets.Label{ - frame={t=3, r=0}, - text=PROPERTIES_HEADER, - auto_width=true, - visible=is_not_minimal, - }, - widgets.FilteredList{ - view_id='list', - frame={t=5, l=0, r=0, b=3}, - on_submit=self:callback('configure_seed'), - visible=is_not_minimal, - edit_key = 'CUSTOM_S', + widgets.ToggleHotkeyLabel{ + view_id='hide_nostock', + frame={t=0, l=24, w=31}, + key='CUSTOM_CTRL_H', + label='Show only in stock:', + on_change=self:callback('refresh', 'sort'), }, - widgets.Label{ - view_id='summary', - frame={b=0, l=0}, - visible=is_not_minimal, + widgets.Panel{ + view_id='list_panel', + frame={t=2, l=0, r=0, b=4}, + frame_style=gui.FRAME_INTERIOR, + subviews={ + widgets.CycleHotkeyLabel{ + view_id='sort_name', + frame={t=0, l=0, w=5}, + options={ + {label='Name', value=sort_noop}, + {label='Name'..CH_DN, value=sort_by_name_desc}, + {label='Name'..CH_UP, value=sort_by_name_asc}, + }, + initial_option=sort_by_name_desc, + option_gap=0, + on_change=self:callback('refresh', 'sort_name'), + }, + widgets.CycleHotkeyLabel{ + view_id='sort_quantity', + frame={t=0, r=12, w=9}, + options={ + {label='Quantity', value=sort_noop}, + {label='Quantity'..CH_DN, value=sort_by_quantity_desc}, + {label='Quantity'..CH_UP, value=sort_by_quantity_asc}, + }, + option_gap=0, + on_change=self:callback('refresh', 'sort_quantity'), + }, + widgets.CycleHotkeyLabel{ + view_id='sort_target', + frame={t=0, r=3, w=7}, + options={ + {label='Target', value=sort_noop}, + {label='Target'..CH_DN, value=sort_by_target_desc}, + {label='Target'..CH_UP, value=sort_by_target_asc}, + }, + option_gap=0, + on_change=self:callback('refresh', 'sort_target'), + }, + widgets.Label{ + view_id='disabled_warning', + visible=function() return not plugin.isEnabled() end, + frame={t=3, h=1}, + auto_width=true, + text={"Please enable seedwatch to change settings"}, + text_pen=COLOR_YELLOW + }, + widgets.List{ + view_id='list', + frame={t=2, b=0}, + visible=plugin.isEnabled, + on_double_click=self:callback('prompt_for_new_target'), + }, + }, }, - SeedSettings{ - view_id='seed_settings', - visible=false, + widgets.Panel{ + view_id='footer', + frame={l=1, r=1, b=0, h=3}, + subviews={ + widgets.Label{ + frame={t=0, l=0}, + text={ + 'Double click on a row or hit ', + {text='Enter', pen=COLOR_LIGHTGREEN}, + ' to set the target.' + }, + }, + widgets.ToggleHotkeyLabel{ + view_id='enable_toggle', + frame={t=2, l=0, w=29}, + label='Seedwatch is', + key='CUSTOM_CTRL_E', + options={{value=true, label='Enabled', pen=COLOR_GREEN}, + {value=false, label='Disabled', pen=COLOR_RED}}, + on_change=function(val) + plugin.setEnabled(val) + self:refresh() + end, + }, + widgets.HotkeyLabel{ + frame={t=2, l=31}, + label='Set all targets', + key='CUSTOM_CTRL_A', + auto_width=true, + on_activate=self:callback('prompt_for_all_targets'), + }, + }, }, - } - - self:refresh_data() end -function Seedwatch:configure_seed(idx, choice) - self.subviews.seed_settings:show(choice, function() - self:refresh_data() - self:update_choices() - end) +function Seedwatch:render(dc) + self.subviews.enable_toggle:setOption(plugin.isEnabled()) + Seedwatch.super.render(self, dc) end -function Seedwatch:update_choices() - local list = self.subviews.list - local name_width = list.frame_body.width - #PROPERTIES_HEADER - local fmt = '%-'..tostring(name_width)..'s %10d %10d ' - local choices = {} - local prior_search=self.subviews.list.edit.text - for k, v in pairs(self.data.seeds) do - local text = (fmt):format(v.name:sub(1,name_width), v.quantity or 0, v.target or 0) - table.insert(choices, {text=text, data=v}) +function Seedwatch:onInput(keys) + if keys.SELECT then + self:prompt_for_new_target(self.subviews.list:getSelected()) end + return Seedwatch.super.onInput(self, keys) +end - self.subviews.list:setChoices(choices) - if prior_search then self.subviews.list:setFilter(prior_search) end - self.subviews.list:updateLayout() +function Seedwatch:postUpdateLayout() + self:refresh() end -function Seedwatch:refresh_data() - self.subviews.enable_toggle:setOption(plugin.isEnabled()) - local watch_map, seed_counts = plugin.seedwatch_getData() - self.data = {} - self.data.sum = 0 - self.data.seeds_qty = 0 - self.data.seeds_watched = 0 - self.data.seeds = {} - for k,v in pairs(seed_counts) do - local seed = {} - seed.id = df.global.world.raws.plants.all[k].id - seed.name = df.global.world.raws.plants.all[k].seed_singular - seed.quantity = v - seed.target = watch_map[k] or 0 - self.data.seeds[k] = seed - if self.data.seeds[k].target > 0 then - self.data.seeds_watched = self.data.seeds_watched + 1 - end - self.data.seeds_qty = self.data.seeds_qty + v +local SORT_WIDGETS = { + 'sort', + 'sort_name', + 'sort_quantity', + 'sort_target', +} + +local function make_row_text(name, quantity, target, row_width) + return { + {text=name, width=row_width-22, pad_char=' '}, + ' ', {text=quantity, width=7, rjustify=true, pad_char=' '}, + ' ', {text=target, width=7, rjustify=true, pad_char=' '}, + } +end + +local plants_all = df.global.world.raws.plants.all + +function Seedwatch:refresh(sort_widget, sort_fn) + sort_widget = sort_widget or 'sort' + sort_fn = sort_fn or self.subviews.sort:getOptionValue() + if sort_fn == sort_noop then + self.subviews[sort_widget]:cycle() + return end - if self.subviews.all.text == '' then - self.subviews.all:setText('0') + for _,widget_name in ipairs(SORT_WIDGETS) do + self.subviews[widget_name]:setOption(sort_fn) end - local summary_text = ('Seeds quantity: %d watched: %d\n'):format(tostring(self.data.seeds_qty),tostring(self.data.seeds_watched)) - self.subviews.summary:setText(summary_text) - local minimal_summary_text = summary_text - self.subviews.minimal_summary:setText(minimal_summary_text) - self.next_refresh_ms = dfhack.getTickCount() + REFRESH_MS + local watch_map, seed_counts = plugin.seedwatch_getData() + local hide_nostock = self.subviews.hide_nostock:getOptionValue() -end + local list = self.subviews.list + local row_width = list.frame_body.width + local choices = {} + for idx,target in pairs(watch_map) do + if hide_nostock and not seed_counts[idx] then goto continue end + local name = plants_all[idx].seed_singular + local quantity = seed_counts[idx] or 0 + table.insert(choices, { + text=make_row_text(name, quantity, target, row_width), + data={ + id=plants_all[idx].id, + name=name, + quantity=quantity, + target=target, + }, + }) + ::continue:: + end -function Seedwatch:postUpdateLayout() - self:update_choices() + table.sort(choices, self.subviews.sort:getOptionValue()) + local selected = list:getSelected() + list:setChoices(choices, selected) end --- refreshes data every 10 seconds or so -function Seedwatch:onRenderBody() - if self.next_refresh_ms <= dfhack.getTickCount() - and self.subviews.seed_settings.visible == false - and not self.subviews.all.focus - and not self.subviews.list.edit.focus then - self:refresh_data() - self:update_choices() +local function check_number(target, text) + if not target then + dlg.showMessage('Invalid Number', 'This is not a number: '..text..NEWLINE..'(for zero enter a 0)', COLOR_LIGHTRED) + return false end + if target < 0 then + dlg.showMessage('Invalid Number', 'Negative numbers make no sense!', COLOR_LIGHTRED) + return false + end + return true +end + +function Seedwatch:prompt_for_new_target(_, choice) + dlg.showInputPrompt( + 'Set target', + ('Enter desired target for %s:'):format(choice.data.name), + COLOR_WHITE, + tostring(choice.data.target), + function(text) + local target = tonumber(text) + if check_number(target, text) then + plugin.seedwatch_setTarget(choice.data.id, target) + self:refresh() + end + end + ) +end + +function Seedwatch:prompt_for_all_targets() + dlg.showInputPrompt( + 'Set all targets', + 'Enter desired target for all seed types', + COLOR_WHITE, + '', + function(text) + local target = tonumber(text) + if check_number(target, text) then + plugin.seedwatch_setTarget('all', target) + self:refresh() + end + end + ) end -- @@ -272,7 +292,7 @@ end -- SeedwatchScreen = defclass(SeedwatchScreen, gui.ZScreen) -SeedwatchScreen.ATTRS { +SeedwatchScreen.ATTRS{ focus_path='seedwatch', } @@ -284,8 +304,8 @@ function SeedwatchScreen:onDismiss() view = nil end -if not dfhack.isMapLoaded() then - qerror('seedwatch requires a map to be loaded') +if not dfhack.isMapLoaded() or not dfhack.world.isFortressMode() then + qerror('seedwatch requires a fort map to be loaded') end view = view and view:raise() or SeedwatchScreen{}:show() diff --git a/gui/settings-manager.lua b/gui/settings-manager.lua index 6fe62b252d..140310c2e4 100644 --- a/gui/settings-manager.lua +++ b/gui/settings-manager.lua @@ -1,11 +1,658 @@ --- An in-game init file editor ---[====[ +--@ module = true + +local argparse = require('argparse') +local control_panel = reqscript('control-panel') +local dialogs = require('gui.dialogs') +local gui = require('gui') +local json = require('json') +local overlay = require('plugins.overlay') +local utils = require('utils') +local widgets = require('gui.widgets') + +local GLOBAL_KEY = 'settings-manager' + +config = config or json.open("dfhack-config/settings-manager.json") + +-------------------------- +-- DifficultyOverlayBase +-- + +DifficultyOverlayBase = defclass(DifficultyOverlayBase, overlay.OverlayWidget) +DifficultyOverlayBase.ATTRS { + frame={w=46, h=5}, + frame_style=gui.MEDIUM_FRAME, + frame_background=gui.CLEAR_PEN, +} + +local function save_difficulty(df_difficulty) + local difficulty = utils.clone(df_difficulty, true) + for _, v in pairs(difficulty) do + if type(v) == 'table' and not v[1] then + for name in pairs(v) do + if tonumber(name) then + -- remove numeric "filler" vals from bitflag records + v[name] = nil + end + end + end + end + -- replace top-level button states to say "Custom" + -- one of the vanilla presets might actually apply, but we don't know that + -- unless we do some diffing + difficulty.difficulty_enemies = df.setting_difficulty_enemies_type.Custom + difficulty.difficulty_economy = df.setting_difficulty_economy_type.Custom + config.data.difficulty = difficulty + config:write() +end + +local function load_difficulty(df_difficulty) + local difficulty = utils.clone(config.data.difficulty or {}, true) + for _, v in pairs(difficulty) do + if type(v) == 'table' and v[1] then + -- restore 0-based index for static arrays and prevent resizing + for i, elem in ipairs(v) do + v[i-1] = elem + v[i] = nil + end + v.resize = false + end + end + df_difficulty:assign(difficulty) +end + +local function save_auto(val) + config.data.auto = val + config:write() +end + +function DifficultyOverlayBase:init() + self:addviews{ + widgets.HotkeyLabel{ + view_id='save', + frame={l=0, t=0, w=16}, + key='CUSTOM_SHIFT_S', + label='Save settings', + on_activate=self:callback('do_save'), + }, + widgets.Label{ + view_id='save_flash', + frame={l=6, t=0}, + text='Saved', + text_pen=COLOR_GREEN, + visible=false, + }, + widgets.HotkeyLabel{ + view_id='load', + frame={l=22, t=0, w=22}, + key='CUSTOM_SHIFT_L', + label='Load saved settings', + on_activate=self:callback('do_load'), + enabled=function() return next(config.data.difficulty or {}) end, + }, + widgets.Label{ + view_id='load_flash', + frame={l=28, t=0}, + text='Loaded', + text_pen=COLOR_GREEN, + visible=false, + }, + widgets.ToggleHotkeyLabel{ + frame={l=0, t=2}, + key='CUSTOM_SHIFT_A', + label='Apply saved settings for new embarks:', + on_change=save_auto, + initial_option=not not config.data.auto, + enabled=function() return next(config.data.difficulty or {}) end, + }, + } +end + +local function flash(self, which) + self.subviews[which].visible = false + self.subviews[which..'_flash'].visible = true + local end_ms = dfhack.getTickCount() + 5000 + local function label_reset() + if dfhack.getTickCount() < end_ms then + dfhack.timeout(10, 'frames', label_reset) + else + self.subviews[which..'_flash'].visible = false + self.subviews[which].visible = true + end + end + label_reset() +end + +-- overridden by subclasses +function DifficultyOverlayBase:get_df_struct() +end + +function DifficultyOverlayBase:do_save() + flash(self, 'save') + save_difficulty(self:get_df_struct().difficulty) +end + +function DifficultyOverlayBase:do_load() + flash(self, 'load') + load_difficulty(self:get_df_struct().difficulty) +end + +function DifficultyOverlayBase:onInput(keys) + if self:get_df_struct().entering_value_str then return false end + return DifficultyOverlayBase.super.onInput(self, keys) +end + +---------------------------- +-- DifficultyEmbarkOverlay +-- + +DifficultyEmbarkOverlay = defclass(DifficultyEmbarkOverlay, DifficultyOverlayBase) +DifficultyEmbarkOverlay.ATTRS { + desc='Adds buttons to the embark difficulty screen for saving and restoring settings.', + default_pos={x=-20, y=5}, + viewscreens='setupdwarfgame/CustomSettings', + default_enabled=true, +} + +show_notification = show_notification or false + +function DifficultyEmbarkOverlay:get_df_struct() + return dfhack.gui.getDFViewscreen(true) +end + +function DifficultyEmbarkOverlay:onInput(keys) + show_notification = false + return DifficultyEmbarkOverlay.super.onInput(self, keys) +end + +---------------------------------------- +-- DifficultyEmbarkNotificationOverlay +-- + +DifficultyEmbarkNotificationOverlay = defclass(DifficultyEmbarkNotificationOverlay, overlay.OverlayWidget) +DifficultyEmbarkNotificationOverlay.ATTRS { + desc='Displays a message when saved difficulty settings have been automatically applied.', + default_pos={x=75, y=18}, + viewscreens='setupdwarfgame/Default', + default_enabled=true, + frame={w=25, h=3}, +} + +function DifficultyEmbarkNotificationOverlay:init() + self:addviews{ + widgets.Panel{ + frame={h=3, b=0, w=25}, + frame_style=gui.FRAME_MEDIUM, + frame_background=gui.CLEAR_PEN, + subviews={ + widgets.Label{ + text='Saved settings restored', + text_pen=COLOR_LIGHTGREEN, + }, + }, + visible=function() return show_notification end, + }, + } +end + +function DifficultyEmbarkNotificationOverlay:preUpdateLayout(parent_rect) + self.frame.w = parent_rect.width - (self.frame.l or (self.default_pos.x - 1)) +end + +function DifficultyEmbarkNotificationOverlay:render(dc) + local scr = dfhack.gui.getDFViewscreen(true) + self.frame.h = #scr.embark_profile == 0 and 11 or 3 + DifficultyEmbarkNotificationOverlay.super.render(self, dc) +end + +local last_scr_type +dfhack.onStateChange[GLOBAL_KEY] = function(sc) + if sc ~= SC_VIEWSCREEN_CHANGED then return end + local scr = dfhack.gui.getDFViewscreen(true) + if last_scr_type == scr._type then return end + last_scr_type = scr._type + show_notification = false + if not df.viewscreen_setupdwarfgamest:is_instance(scr) then return end + if not config.data.auto then return end + load_difficulty(scr.difficulty) + show_notification = true +end + +------------------------------ +-- DifficultySettingsOverlay +-- + +DifficultySettingsOverlay = defclass(DifficultySettingsOverlay, DifficultyOverlayBase) +DifficultySettingsOverlay.ATTRS { + desc='Adds buttons to the fort difficulty screen for saving and restoring settings.', + default_pos={x=-42, y=8}, + viewscreens='dwarfmode/Settings/DIFFICULTY/CustomSettings', + default_enabled=true, +} + +function DifficultySettingsOverlay:get_df_struct() + return df.global.game.main_interface.settings +end + +------------------------------ +-- ImportExportAutoOverlay +-- + +ImportExportAutoOverlay = defclass(ImportExportAutoOverlay, overlay.OverlayWidget) +ImportExportAutoOverlay.ATTRS { + default_enabled=true, + frame_style=gui.MEDIUM_FRAME, + frame_background=gui.CLEAR_PEN, + save_label=DEFAULT_NIL, + load_label=DEFAULT_NIL, + auto_label=DEFAULT_NIL, + save_fn=DEFAULT_NIL, + load_fn=DEFAULT_NIL, + has_data_fn=DEFAULT_NIL, + autostart_command=DEFAULT_NIL, +} + +function ImportExportAutoOverlay:init() + self:addviews{ + widgets.HotkeyLabel{ + view_id='save', + frame={l=0, t=0, w=39}, + key='CUSTOM_CTRL_E', + label=self.save_label, + on_activate=self:callback('do_save'), + }, + widgets.Label{ + view_id='save_flash', + frame={l=18, t=0}, + text='Saved', + text_pen=COLOR_GREEN, + visible=false, + }, + widgets.HotkeyLabel{ + view_id='load', + frame={l=42, t=0, w=34}, + key='CUSTOM_CTRL_I', + label=self.load_label, + on_activate=self:callback('do_load'), + enabled=self.has_data_fn, + }, + widgets.Label{ + view_id='load_flash', + frame={l=51, t=0}, + text='Loaded', + text_pen=COLOR_GREEN, + visible=false, + }, + widgets.ToggleHotkeyLabel{ + view_id='auto', + frame={l=0, t=2}, + key='CUSTOM_CTRL_A', + label=self.auto_label, + on_change=self:callback('do_auto'), + enabled=self.has_data_fn, + }, + } +end + +function ImportExportAutoOverlay:do_save() + flash(self, 'save') + self.save_fn() +end + +function ImportExportAutoOverlay:do_load() + flash(self, 'load') + self.load_fn() +end + +AutoMessage = defclass(AutoMessage, widgets.Window) +AutoMessage.ATTRS { + frame={w=61, h=9}, + autostart_command=DEFAULT_NIL, + enabled=DEFAULT_NIL, +} + +function AutoMessage:init() + self:addviews{ + widgets.Label{ + view_id='label', + frame={t=0, l=0}, + text={ + 'The "', self.autostart_command, '" command', NEWLINE, + 'has been ', + {text=self.enabled and 'enabled' or 'disabled', pen=self.enabled and COLOR_GREEN or COLOR_LIGHTRED}, + ' in the ', + {text='Automation', pen=COLOR_YELLOW}, ' -> ', + {text='Autostart', pen=COLOR_YELLOW}, ' tab of ', NEWLINE, + {text='.', gap=25}, + }, + }, + widgets.HotkeyLabel{ + frame={t=2, l=0}, + label='gui/control-panel', + key='CUSTOM_CTRL_G', + auto_width=true, + on_activate=function() + self.parent_view:dismiss() + dfhack.run_script('gui/control-panel') + end, + }, + widgets.HotkeyLabel{ + frame={b=0, l=0, r=0}, + label='Ok', + key='SELECT', + auto_width=true, + on_activate=function() self.parent_view:dismiss() end, + }, + } +end + +AutoMessageScreen = defclass(AutoMessageScreen, gui.ZScreenModal) +AutoMessageScreen.ATTRS { + focus_path='settings-manager/prompt', + autostart_command=DEFAULT_NIL, + enabled=DEFAULT_NIL, +} + +function AutoMessageScreen:init() + self:addviews{ + AutoMessage{ + frame_title=(self.enabled and 'Enabled' or 'Disabled')..' auto-restore', + autostart_command=self.autostart_command, + enabled=self.enabled, + }, + } +end + +function ImportExportAutoOverlay:do_auto(val) + dfhack.run_script('control-panel', (val and '' or 'no') .. 'autostart', self.autostart_command) + AutoMessageScreen{autostart_command=self.autostart_command, enabled=val}:show() +end + +function ImportExportAutoOverlay:onRenderFrame(dc, rect) + ImportExportAutoOverlay.super.onRenderFrame(self, dc, rect) + local enabled = control_panel.get_autostart(self.autostart_command) + self.subviews.auto:setOption(enabled) +end + +------------------------------ +-- StandingOrdersOverlay +-- + +local plotinfo = df.global.plotinfo +local li = plotinfo.labor_info +local ps = plotinfo.stockpile + +local function save_standing_orders() + local standing_orders = {} + for name, val in pairs(df.global) do + if name:startswith('standing_orders_') then + standing_orders[name] = val + end + end + config.data.standing_orders = standing_orders + local chores = {} + chores.enabled = li.flags.children_do_chores + chores.labors = utils.clone(li.chores) + config.data.chores = chores + config.data.stockpile = {reserved_barrels=ps.reserved_barrels} + config:write() +end + +local function load_standing_orders() + for name, val in pairs(config.data.standing_orders or {}) do + df.global[name] = val + end + li.flags.children_do_chores = not not safe_index(config.data.chores, 'enabled') + for i, val in ipairs(safe_index(config.data.chores, 'labors') or {}) do + li.chores[i-1] = val + end + local reserved_barrels = safe_index(config.data.stockpile, 'reserved_barrels') + if reserved_barrels then + ps.reserved_barrels = reserved_barrels + end +end + +local function has_saved_standing_orders() + return next(config.data.standing_orders or {}) +end + +StandingOrdersOverlay = defclass(StandingOrdersOverlay, ImportExportAutoOverlay) +StandingOrdersOverlay.ATTRS { + desc='Adds buttons to the standing orders screen for saving and restoring settings.', + default_pos={x=6, y=-5}, + viewscreens='dwarfmode/Info/LABOR/STANDING_ORDERS/AUTOMATED_WORKSHOPS', + frame={w=78, h=5}, + save_label='Save standing orders (all tabs)', + load_label='Load saved standing orders', + auto_label='Apply saved settings for new embarks:', + save_fn=save_standing_orders, + load_fn=load_standing_orders, + has_data_fn=has_saved_standing_orders, + autostart_command='gui/settings-manager load-standing-orders', +} + +------------------------------ +-- ReservedBarrelsOverlay +-- + +ReservedBarrelsOverlay = defclass(ReservedBarrelsOverlay, overlay.OverlayWidget) +ReservedBarrelsOverlay.ATTRS { + desc='Exposes the setting for reserved barrels on the standing orders screen.', + default_pos={x=59, y=18}, + default_enabled=true, + viewscreens='dwarfmode/Info/LABOR/STANDING_ORDERS/AUTOMATED_WORKSHOPS', + frame={w=26, h=6}, + frame_style=gui.MEDIUM_FRAME, + frame_background=gui.CLEAR_PEN, +} + +function ReservedBarrelsOverlay:init() + self:addviews{ + widgets.Label{ + frame={t=0, l=0}, + text={ + 'Barrels reserved for use', NEWLINE, + 'by workshop jobs: ', + { + text=function() return ps.reserved_barrels end, + pen=function() return ps.reserved_barrels == 0 and COLOR_YELLOW or COLOR_GREEN end, + }, + }, + }, + widgets.HotkeyLabel{ + frame={t=3, l=0}, + key='CUSTOM_CTRL_B', + label='Set num reserved', + on_activate=function() + dialogs.InputBox{ + frame_title='Set reserved barrels', + text={ + 'You can ensure a number of barrels are reserved, preventing', NEWLINE, + 'them from being claimed by stockpiles as container storage.', NEWLINE, + 'For example, if there are 5 reserved barrels, no stockpile', NEWLINE, + 'will claim an empty barrel for storing items until you have', NEWLINE, + 'at least 6 barrels lying around.', NEWLINE, NEWLINE, + 'This feature is most often used to ensure that a fortress has', NEWLINE, + 'ample empty barrels for the production of alcohol, although', NEWLINE, + 'empty barrels are also necessary for other jobs.', + }, + text_pen=COLOR_YELLOW, + label_text='Number of barrels to reserve: ', + input=tostring(ps.reserved_barrels), + on_input=function(input) + input = tonumber(input) + if not input or input < 0 then return end + ps.reserved_barrels = math.floor(input) + end, + }:show() + end, + }, + } +end + +------------------------------ +-- WorkDetailsOverlay +-- + +local function clone_wd_flags(flags) + return { + cannot_be_everybody=flags.cannot_be_everybody, + no_modify=flags.no_modify, + mode=flags.mode, + } +end + +local function save_work_details() + local details = {} + for idx, wd in ipairs(li.work_details) do + local detail = { + name=wd.name, + icon=wd.icon, + flags=clone_wd_flags(wd.flags), + allowed_labors=utils.clone(wd.allowed_labors), + } + details[idx+1] = detail + end + config.data.work_details = details + config:write() +end -gui/settings-manager -==================== -An in-game manager for settings defined in ``init.txt`` and ``d_init.txt``. +local function load_work_details() + if not config.data.work_details or #config.data.work_details < 10 then + -- not enough data to cover built-in work details + return + end + li.work_details:resize(#config.data.work_details) + -- keep unit assignments for overwritten indices + for idx, wd in ipairs(config.data.work_details) do + local detail = { + new=df.work_detail, + name=wd.name, + icon=wd.icon, + flags=wd.flags or wd.work_detail_flags, -- compat for old name + } + li.work_details[idx-1] = detail + local al = li.work_details[idx-1].allowed_labors + for i,v in ipairs(wd.allowed_labors) do + al[i-1] = v + end + end + local scr = dfhack.gui.getDFViewscreen(true) + if dfhack.gui.matchFocusString('dwarfmode/Info/LABOR/WORK_DETAILS', scr) then + gui.simulateInput(scr, 'LEAVESCREEN') + gui.simulateInput(scr, 'D_LABOR') + end +end + +local function has_saved_work_details() + return next(config.data.work_details or {}) +end + +WorkDetailsOverlay = defclass(WorkDetailsOverlay, ImportExportAutoOverlay) +WorkDetailsOverlay.ATTRS { + desc='Adds buttons to the work details screen for saving and restoring settings.', + default_pos={x=80, y=-5}, + viewscreens='dwarfmode/Info/LABOR/WORK_DETAILS/Default', + frame={w=35, h=5}, + save_label='Save work details', + load_label='Load work details', + auto_label='Load for new embarks:', + save_fn=save_work_details, + load_fn=load_work_details, + has_data_fn=has_saved_work_details, + autostart_command='gui/settings-manager load-work-details', +} + +function WorkDetailsOverlay:init() + self.subviews.save.frame.w = 25 + self.subviews.save_flash.frame.l = 10 + self.subviews.load.frame.t = 1 + self.subviews.load.frame.l = 0 + self.subviews.load.frame.w = 25 + self.subviews.load_flash.frame.t = 1 + self.subviews.load_flash.frame.l = 10 +end -]====] +OVERLAY_WIDGETS = { + embark_difficulty=DifficultyEmbarkOverlay, + embark_notification=DifficultyEmbarkNotificationOverlay, + settings_difficulty=DifficultySettingsOverlay, + standing_orders=StandingOrdersOverlay, + reserved_barrels=ReservedBarrelsOverlay, + work_details=WorkDetailsOverlay, +} + +if dfhack_flags.module then + return +end + +------------------------------ +-- CLI processing +-- + +local help = false + +local positionals = argparse.processArgsGetopt({...}, { + {'h', 'help', handler=function() help = true end}, + }) + +local command = (positionals or {})[1] + +if help then + print(dfhack.script_help()) + return +end + +local scr = dfhack.gui.getDFViewscreen(true) +local is_embark = df.viewscreen_setupdwarfgamest:is_instance(scr) +local is_fort = df.viewscreen_dwarfmodest:is_instance(scr) + +if command == 'save-difficulty' then + if is_embark then save_difficulty(scr.difficulty) + elseif is_fort then + save_difficulty(df.global.game.main_interface.settings.difficulty) + else + qerror('must be on the embark preparation screen or in a loaded fort') + end +elseif command == 'load-difficulty' then + if is_embark then + load_difficulty(scr.difficulty) + show_notification = true + elseif is_fort then + load_difficulty(df.global.game.main_interface.settings.difficulty) + else + qerror('must be on the embark preparation screen or in a loaded fort') + end +elseif command == 'save-standing-orders' then + if is_fort then save_standing_orders() + else + qerror('must be in a loaded fort') + end +elseif command == 'load-standing-orders' then + if is_fort then load_standing_orders() + else + qerror('must be in a loaded fort') + end +elseif command == 'save-work-details' then + if is_fort then save_work_details() + else + qerror('must be in a loaded fort') + end +elseif command == 'load-work-details' then + if is_fort then load_work_details() + else + qerror('must be in a loaded fort') + end +else + print(dfhack.script_help()) +end + +return + +--[[ + +TODO: reinstate color editor + +-- An in-game init file editor VERSION = '0.6.0' @@ -67,7 +714,6 @@ if dfhack.getOSType() == 'linux' or dfhack.getOSType() == 'darwin' then table.insert(print_modes, {'TEXT', 'TEXT (ncurses)'}) end ---[[ Setting descriptions Settings listed MUST exist, but settings not listed will be ignored @@ -98,7 +744,7 @@ Fields: Reserved field names: - value (set to current setting value when settings are loaded) -]] + SETTINGS = { init = { {id = 'SOUND', type = 'bool', desc = 'Enable sound'}, @@ -892,3 +1538,5 @@ if dfhack.gui.getCurFocus() == 'dfhack/lua/settings_manager' then dfhack.screen.dismiss(dfhack.gui.getCurViewscreen()) end settings_manager():show() + +]] diff --git a/gui/siegemanager.lua b/gui/siegemanager.lua new file mode 100644 index 0000000000..aa239041c4 --- /dev/null +++ b/gui/siegemanager.lua @@ -0,0 +1,556 @@ +local gui = require('gui') +local widgets = require('gui.widgets') +local textures = require('gui.textures') + +-- +-- Button label definitions +-- +local function make_button(ascii, pens, x, y) + local out = {} + -- Grid of 3x3 tiles + for i=1,3 do + local tmp = {} + for j=1,3 do + table.insert(tmp, { + tile=dfhack.pen.parse{ + ch=ascii[i][j], + fg=pens[i][j], + keep_lower=true, + tile=dfhack.screen.findGraphicsTile('INTERFACE_BITS',x+j-1,y+i-1) + }, + }) + end + table.insert(out, tmp) + end + return out +end + +local function make_activity_button(ch, color, border_color, border_acolor, x, y, ax, ay) + local ascii = { + {218, 196, 191}, + {179, ch, 179}, + {192, 196, 217}, + } + + local function make_pens(border, main) + return { + {border, border, border}, + {border, main, border}, + {border, border, border}, + } + end + + return { + inactive = make_button(ascii, make_pens(border_color, color), x, y), + active = make_button(ascii, make_pens(border_acolor, color), ax, ay) + } +end + +local activity_buttons = { + -- NotInUse + [0] = make_activity_button('-', COLOR_LIGHTRED, COLOR_DARKGREY, COLOR_YELLOW, 59, 18, 59, 15), + -- KeepLoaded + [1] = make_activity_button('L', COLOR_LIGHTCYAN, COLOR_DARKGREY, COLOR_YELLOW, 59, 24, 59, 21), + -- PrepareToFire + [2] = make_activity_button('P', COLOR_YELLOW, COLOR_DARKGREY, COLOR_YELLOW, 56, 18, 56, 15), + -- FireAtWill + [3] = make_activity_button('F', COLOR_LIGHTRED, COLOR_DARKGREY, COLOR_YELLOW, 53, 18, 53, 15), + -- PracticeFire + [4] = make_activity_button('T', COLOR_LIGHTGREEN, COLOR_DARKGREY, COLOR_YELLOW, 44, 42, 44, 39), +} + +local goto_button_ascii = { + {218, 196, 191}, + {26, 'X', 179}, + {192, 196, 217}, +} +local goto_button_color = { + {COLOR_LIGHTCYAN, COLOR_LIGHTCYAN, COLOR_LIGHTCYAN}, + {COLOR_LIGHTCYAN, COLOR_LIGHTRED, COLOR_LIGHTCYAN}, + {COLOR_LIGHTCYAN, COLOR_LIGHTCYAN, COLOR_LIGHTCYAN}, +} +local goto_button = make_button(goto_button_ascii, goto_button_color, 32, 0) + + +-- TODO: The usage of these icons requires the ability to adjust screentexpos_flag +-- from a pen. Specifically anchor_subordinate, anchor_x_coord, and anchor_y_coord in order +-- to stretch a singular tile to fit the 5x5 area that vanilla displays portraits at. +-- +-- local function make_engine_icon(ascii, color, tile_x, tile_y) +-- local icon = {} +-- local tile = dfhack.screen.findGraphicsTile('BUILDING_ICONS',tile_x, tile_y) +-- +-- for y=1,5 do +-- icon[y] = {} +-- for x=1,5 do +-- local ch = 32 +-- local fg = nil +-- -- Adapt indices for a 3x3 icon into 5x5 like the graphics icon +-- if x >= 2 and x <= 4 and y >= 2 and y <= 4 then +-- ch = ascii[y-1][x-1] +-- fg = color[y-1][x-1] +-- end +-- icon[y][x] = { +-- tile = dfhack.pen.parse{ +-- ch=ch, +-- fg=fg, +-- keep_lower=true, +-- tile=subtile, +-- }, +-- } +-- end +-- end +-- +-- return icon +-- end +-- +-- local catapult_icon_ascii = { +-- {177, 210, 177}, +-- {177, 186, 177}, +-- {177, 8, 177}, +-- } +-- local catapult_icon_color = { +-- {COLOR_YELLOW, COLOR_BROWN, COLOR_YELLOW}, +-- {COLOR_YELLOW, COLOR_BROWN, COLOR_YELLOW}, +-- {COLOR_YELLOW, COLOR_BROWN, COLOR_YELLOW}, +-- } +-- +-- local ballista_icon_ascii = { +-- {220, 30, 220}, +-- {221, 179, 222}, +-- {92, 207, 47}, +-- } +-- local ballista_icon_color = { +-- {COLOR_YELLOW, COLOR_BROWN, COLOR_YELLOW}, +-- {COLOR_YELLOW, COLOR_BROWN, COLOR_YELLOW}, +-- {COLOR_BROWN, COLOR_YELLOW, COLOR_BROWN}, +-- } +-- +-- local boltthrower_icon_ascii = { +-- {32, 32, 32}, +-- {32, 147, 32}, +-- {32, 32, 32}, +-- } +-- local boltthrower_icon_color = { +-- {COLOR_BLACK, COLOR_BLACK, COLOR_BLACK}, +-- {COLOR_BLACK, COLOR_YELLOW, COLOR_BLACK}, +-- {COLOR_BLACK, COLOR_BLACK, COLOR_BLACK}, +-- } +-- +-- local siegeengine_icons = { +-- -- Catapult +-- [0] = make_engine_icon(catapult_icon_ascii, catapult_icon_color, 7, 11), +-- -- Ballista +-- [1] = make_engine_icon(ballista_icon_ascii, ballista_icon_color, 6, 11), +-- -- Bolt Thrower +-- [2] = make_engine_icon(boltthrower_icon_ascii, boltthrower_icon_color, 3, 12), +-- } + +local function is_siege_ammo(item) + return df.item_ammost:is_instance(item) + or df.item_siegeammost:is_instance(item) + or df.item_boulderst:is_instance(item) +end + +-- Obtain a list of siege engine buildings on the map with specific information +local function get_siege_engines() + local siege_list = {} + for _, building in ipairs(df.global.world.buildings.other.IN_PLAY) do + if not df.building_siegeenginest:is_instance(building) then goto continue end + if not building.flags.exists then goto continue end + + -- Calculate amount of ammo stored in the building + local loaded_ammo = 0 + for _, item in ipairs(building.contained_items) do + if item.use_mode == df.building_item_role_type.TEMP and is_siege_ammo(item.item) then + loaded_ammo = loaded_ammo + item.item.stack_size + end + end + + -- Display information on active jobs involving this building. + local active_job = nil + for _, job in ipairs(building.jobs) do + if job.job_type == df.job_type.LoadCatapult + or job.job_type == df.job_type.LoadBallista + or job.job_type == df.job_type.LoadBoltThrower then + active_job = 'Loading' + elseif job.job_type == df.job_type.FireCatapult + or job.job_type == df.job_type.FireBallista + or job.job_type == df.job_type.FireBoltThrower then + -- Display `Ready` instead of firing when in standby mode + -- as the same job_type is used when actively firing and waiting. + -- This is to reduce confusion as no projectiles are fired + active_job = building.action == 2 and 'Ready' or 'Firing' + end + end + + siege_list[building.id] = { + id = building.id, + type = building.type, + action = building.action, + loaded_ammo = loaded_ammo, + name = building.name, + active_job=active_job, + pos = { + x=building.centerx, + y=building.centery, + z=building.z, + }, + } + ::continue:: + end + return siege_list +end + +local function item_in_list(item, list) + for _, v in ipairs(list) do + if v == item then + return true + end + end + return false +end + +-- Set siegeengine action, returning false if the building isn't found +local function set_siege_engine_action(id_list, action) + local count = 0 + for _, building in ipairs(df.global.world.buildings.other.IN_PLAY) do + if item_in_list(building.id, id_list) then + if not df.building_siegeenginest:is_instance(building) then return false end + building.action = action + count = count + 1 + if count == #id_list then + return count + end + end + end + return count +end + +-- SiegeEngineList +SiegeEngineList = defclass(SiegeEngineList, widgets.Panel) +SiegeEngineList.ATTRS = { + view_id='list', + frame={l=0, r=0, t=1, b=7}, + frame_style=gui.FRAME_INTERIOR, + + -- Filters by siegeengine_type, -1 being all + type_filter=-1, +} + +function SiegeEngineList:init() + self:refresh_data() + + self.refresh_rate = 30 + self.refresh_timer = 0 + + self.button_start_x = 24 + + self:addviews({ + widgets.List { + view_id='list', + frame={l=0,t=0,b=0,r=0}, + row_height=3, + } + }) + + self:refresh_view(true) +end + +-- Used to manage how often the ui data refreshes +function SiegeEngineList:onRenderBody() + self.refresh_timer = self.refresh_timer + 1 + if (self.refresh_timer > self.refresh_rate) then + self.refresh_timer = 0 + self:refresh_data() + end +end + +local siegeengine_type_string = { + [0] = 'Catapult', + [1] = 'Ballista', + [2] = 'Thrower' +} + +local function concat_tables(to, from) + for _, val in ipairs(from) do + table.insert(to, val) + end +end + +-- TODO: Replace constants with df.siegeengine_action enum once structures merged +local action_button_order={3, 4, 2, 1, 0} +local action_button_keybinds = {'CUSTOM_SHIFT_F', 'CUSTOM_SHIFT_T', 'CUSTOM_SHIFT_P', 'CUSTOM_SHIFT_L', 'CUSTOM_SHIFT_N'} + +-- Add a multiline label definition from `from` to `to` starting at y=y_start or 0 +local function add_multiline(to, from, y_start) + for i, item in pairs(from) do + concat_tables(to[i + (y_start or 0)], from[i]) + end +end + +-- Label string callbacks, used to update the display without resetting the scrolling List +local action_text_pen = dfhack.pen.parse({ fg=COLOR_GREEN }) +function SiegeEngineList:get_action_text(id) + return self.engines[id].active_job or '' +end + +local ammo_text_pen = dfhack.pen.parse({ fg=COLOR_GREY }) +function SiegeEngineList:get_ammo_text(id) + return (self.engines[id].loaded_ammo or '?') +end + +local ammo_out_pen = dfhack.pen.parse({ fg=COLOR_RED }) +local ammo_pen = dfhack.pen.parse({ fg=COLOR_YELLOW }) +function SiegeEngineList:get_ammo_pen(id) + return self.engines[id].loaded_ammo == 0 and ammo_out_pen or ammo_pen +end + +function SiegeEngineList:get_name_text(id) + local engine = self.engines[id] + return siegeengine_type_string[engine.type]..' '..(#engine.name ==0 and 'Unnamed' or engine.name) +end + +function SiegeEngineList:get_activity_button_tile(id, action, x, y) + return activity_buttons[action][self.engines[id].action == action and 'active' or 'inactive'][y][x].tile +end + +-- Generate the multiline Label display for an engine +function SiegeEngineList:make_entry_text(engine) + local lines = { + {{text=self:callback('get_name_text', engine.id), width=self.button_start_x}}, + { + { text=self:callback('get_action_text', engine.id), pen=action_text_pen, width=9 }, + { text='ammo=', pen=ammo_text_pen }, + { text=self:callback('get_ammo_text', engine.id), pen=self:callback('get_ammo_pen', engine.id), width=self.button_start_x - 14}, + }, + {{text='', width=self.button_start_x}}, + } + + -- Goto Position Button + add_multiline(lines, goto_button) + + -- Padding following goto button + add_multiline(lines, {{{text='', width=3}},{{text='', width=3}},{{text='', width=3}}}) + + -- Siege Engine activity selection buttons + for _, button_action in ipairs(action_button_order) do + for y=1,3 do + for x=1,3 do + table.insert(lines[y], { tile = self:callback('get_activity_button_tile', engine.id, button_action, x, y)}) + end + end + end + + -- Transform multiline label into a single label with newlines + local out_tokens = {} + for i=1,3 do + concat_tables(out_tokens, lines[i]) + table.insert(out_tokens, NEWLINE) + end + + return out_tokens +end + +-- Refresh the engine information being displayed, but not the list. +-- Updating data here *does not* add or remove new/deleted engines. +function SiegeEngineList:refresh_data() + local old_engines = self.engines + self.engines = get_siege_engines() + if self.type_filter ~= -1 then + for id, eng in pairs(self.engines) do + if eng.type ~= self.type_filter then + self.engines[id] = nil + end + end + end + + if not old_engines then return end + -- Determine if a listed engine was removed, if so refresh ui + for id, _ in pairs(old_engines) do + if self.engines[id] == nil then + self:refresh_view(false) + return + end + end +end + +-- Refresh the engine list, updating to display new/deleted engines correctly. +function SiegeEngineList:refresh_view(refresh_data) + if refresh_data then self:refresh_data() end + local choices = {} + for _, data in pairs(self.engines) do + table.insert(choices, { + text=self:make_entry_text(data), + search_key="", + data=data.id + }); + end + self.subviews.list:setChoices(choices) +end + +function SiegeEngineList:reveal_selected() + local _, selected = self.subviews.list:getSelected() + if selected ~= nil then + dfhack.gui.revealInDwarfmodeMap(self.engines[selected.data].pos, true, true) + end +end + +function SiegeEngineList:set_all_action(action) + local listed = {} + for key, _ in pairs(self.engines) do + listed[#listed+1] = key + end + + local count = set_siege_engine_action(listed, action) + if count ~= #listed then + self:refresh_view(true) + return + end + + for _, engine in ipairs(self.engines) do + engine.action = action + end +end + +function SiegeEngineList:set_selected_action(action) + local _, selected = self.subviews.list:getSelected() + + local successful = set_siege_engine_action({selected.data}, action) + if not successful then + self:refresh_view(true) + return + end + + -- Successfully updated, just update the cached state + self.engines[selected.data].action = action +end + +function SiegeEngineList:onInput(keys) + if not keys._MOUSE_L then + SiegeEngineList.super.onInput(self, keys) + return + end + + local list = self.subviews.list + local idx = list:getIdxUnderMouse() + if not idx then + SiegeEngineList.super.onInput(self, keys) + return + end + + local x = list:getMousePos() + if x < self.button_start_x or x > self.button_start_x+(3*7) then + SiegeEngineList.super.onInput(self, keys) + return + end + + list:setSelected(idx) + + -- 0 is goto, 1 is blank, following are action buttons + local button_pressed = math.ceil((x-self.button_start_x+1)/3)-1 + + if button_pressed == 0 then + self:reveal_selected() + return + end + + if button_pressed == 1 then + -- Blank space + return + end + + local action = action_button_order[button_pressed-1] + self:set_selected_action(action) +end + +-- SiegeManager +SiegeManager = defclass(SiegeManager, widgets.Window) +SiegeManager.ATTRS = { + frame_title = 'Siege Manager', + frame = {w=54,h=48,r=2,t=18}, + resizable=true, + drag_anchors = {title=true}, + + engines=DEFAULT_NIL, +} + +function SiegeManager:init() + self:addviews({ + SiegeEngineList {}, + widgets.CycleHotkeyLabel { + frame={b=6}, + key='CUSTOM_T', + on_change=self:callback('set_type_filter'), + label='Show Types:', + options={ + {label='All', value=-1}, + {label='Ballista', value=df.siegeengine_type.Ballista}, + {label='Bolt Thrower', value=df.siegeengine_type.BoltThrower}, + {label='Catapult', value=df.siegeengine_type.Catapult}, + }, + initial_option=-1, + }, + widgets.ToggleHotkeyLabel { + view_id = 'configure_all', + frame={b=2}, + key = 'CUSTOM_SHIFT_A', + key_sep = ': ', + label = 'Configure All', + initial_option=2, + }, + widgets.HotkeyLabel { + frame={b=0}, + key='CUSTOM_CTRL_C', + label='Reveal in World', + on_activate=self:callback('reveal_selected') + }, + }) + + for i, action_button in ipairs(action_button_order) do + self:addviews({ + widgets.HotkeyLabel { + frame = {b=3, l=(i - 1)*2}, + key = action_button_keybinds[i], + key_sep = i == #action_button_order and ': ' or '', + label = i == #action_button_order and 'Set Action' or '', + on_activate = self:callback('set_action', action_button) + } + }) + end +end + +function SiegeManager:set_type_filter(new) + self.subviews.list.type_filter = new + self.subviews.list:refresh_view(true) +end + +function SiegeManager:reveal_selected() + self.subviews.list:reveal_selected() +end + +function SiegeManager:set_action(action) + if self.subviews.configure_all:getOptionValue() then + self.subviews.list:set_all_action(action) + else + self.subviews.list:set_selected_action(action) + end +end + +-- SiegeManagerScreen +SiegeManagerScreen = defclass(SiegeManagerScreen, gui.ZScreen) +SiegeManagerScreen.ATTRS = {} + +function SiegeManagerScreen:init() + self:addviews({SiegeManager{}}) +end + +function SiegeManagerScreen:onDismiss() + view = nil +end + +if not dfhack.isMapLoaded() then + qerror('requires a map to be loaded') +end + +view = view and view:raise() or SiegeManagerScreen{}:show() diff --git a/gui/sitemap.lua b/gui/sitemap.lua new file mode 100644 index 0000000000..0a72977c53 --- /dev/null +++ b/gui/sitemap.lua @@ -0,0 +1,451 @@ +--@ module = true + +local gui = require('gui') +local utils = require('utils') +local widgets = require('gui.widgets') +local overlay = require('plugins.overlay') + +local toolbar_textures = dfhack.textures.loadTileset(dfhack.getHackPath()..'/data/art/sitemap_toolbar.png', 8, 12) + +function launch_sitemap() + dfhack.run_script('gui/sitemap') +end + +-- +-- Sitemap +-- + +Sitemap = defclass(Sitemap, widgets.Window) +Sitemap.ATTRS { + frame_title='Sitemap', + frame={w=67, r=2, t=18, h=26}, + resizable=true, + resize_min={w=44, h=20}, + frame_inset={l=1, t=1, r=0, b=0}, +} + +local function to_title_case(str) + return dfhack.capitalizeStringWords(dfhack.lowerCp437(str:gsub('_', ' '))) +end + +-- also called by gui/rename +function get_location_desc(loc) + if df.abstract_building_hospitalst:is_instance(loc) then + return 'Hospital', COLOR_WHITE + elseif df.abstract_building_inn_tavernst:is_instance(loc) then + return 'Tavern', COLOR_LIGHTRED + elseif df.abstract_building_libraryst:is_instance(loc) then + return 'Library', COLOR_BLUE + elseif df.abstract_building_guildhallst:is_instance(loc) then + local prof = df.profession[loc.contents.profession] + if not prof then return 'Guildhall', COLOR_MAGENTA end + return ('%s guildhall'):format(to_title_case(prof)), COLOR_MAGENTA + elseif df.abstract_building_templest:is_instance(loc) then + local is_deity = loc.deity_type == df.religious_practice_type.WORSHIP_HFID + local id = loc.deity_data[is_deity and 'Deity' or 'Religion'] + local entity = is_deity and df.historical_figure.find(id) or df.historical_entity.find(id) + local desc = 'Temple' + if not entity then return desc, COLOR_YELLOW end + local name = dfhack.translation.translateName(entity.name, true) + if #name > 0 then + desc = ('%s to %s'):format(desc, name) + end + return desc, COLOR_YELLOW + end + local type_name = df.abstract_building_type[loc:getType()] or 'unknown' + return to_title_case(type_name), COLOR_GREY +end + +local function get_location_label(loc, zones) + local tokens = {} + table.insert(tokens, dfhack.translation.translateName(loc.name, true)) + local desc, pen = get_location_desc(loc) + if desc then + table.insert(tokens, ' (') + table.insert(tokens, { + text=desc, + pen=pen, + }) + table.insert(tokens, ')') + end + if #zones == 0 then + if loc.flags.DOES_NOT_EXIST then + table.insert(tokens, ' [retired]') + else + table.insert(tokens, ' [no zone]') + end + elseif #zones > 1 then + table.insert(tokens, (' [%d zones]'):format(#zones)) + end + return tokens +end + +local function get_location_choices(site) + local choices = {} + if not site then return choices end + for _,loc in ipairs(site.buildings) do + local contents = loc:getContents() + local zones = contents and contents.building_ids or {} + table.insert(choices, { + text=get_location_label(loc, zones), + data={ + -- clone since an adventurer might wander off the site + -- and the vector gets deallocated + zones=utils.clone(zones), + next_idx=1, + }, + }) + end + return choices +end + +local function zoom_to_next_zone(_, choice) + local data = choice.data + if #data.zones == 0 then return end + if data.next_idx > #data.zones then data.next_idx = 1 end + local bld = df.building.find(data.zones[data.next_idx]) + if bld then + dfhack.gui.revealInDwarfmodeMap( + xyz2pos(bld.centerx, bld.centery, bld.z), true, true) + end + data.next_idx = data.next_idx % #data.zones + 1 +end + +local function get_affiliation(unit) + local he = df.historical_entity.find(unit.civ_id) + if not he then return 'Unknown affiliation' end + local et_name = dfhack.translation.translateName(he.name, true) + local et_type = df.historical_entity_type[he.type]:gsub('(%l)(%u)', '%1 %2') + return ('%s%s %s'):format(#et_name > 0 and et_name or 'Unknown', #et_name > 0 and ',' or '', et_type) +end + +local function get_unit_disposition_and_pen_and_affiliation(unit) + local prefix = unit.flags1.caged and 'caged ' or '' + if dfhack.units.isDanger(unit) then + if dfhack.units.isInvader(unit) then + return prefix..'invader', COLOR_RED, get_affiliation(unit) + end + return prefix..'hostile', COLOR_LIGHTRED + elseif dfhack.units.isFortControlled(unit) then + return prefix..'fort '..(dfhack.units.isAnimal(unit) and 'animal' or 'member'), COLOR_LIGHTBLUE + elseif dfhack.units.isWildlife(unit) then + return prefix..'wildlife', COLOR_GREEN + elseif dfhack.units.isVisitor(unit) or dfhack.units.isDiplomat(unit) then + return prefix..'visitor', COLOR_MAGENTA, get_affiliation(unit) + elseif dfhack.units.isMerchant(unit) or dfhack.units.isForest(unit) then + return prefix..'merchant'..(dfhack.units.isAnimal(unit) and ' animal' or ''), COLOR_BLUE, get_affiliation(unit) + end + return prefix..'friendly', COLOR_LIGHTGREEN +end + +local function get_unit_choice_text(unit) + local disposition, disposition_pen, affiliation = get_unit_disposition_and_pen_and_affiliation(unit) + return { + dfhack.units.getReadableName(unit), NEWLINE, + {gap=2, text=disposition, pen=disposition_pen}, + affiliation and ': ' or '', + {text=affiliation, pen=COLOR_BROWN}, + } +end + +local function get_unit_choices() + local is_fort = dfhack.world.isFortressMode() + local choices = {} + for _, unit in ipairs(df.global.world.units.active) do + if not dfhack.units.isActive(unit) or + dfhack.units.isHidden(unit) or + (is_fort and not dfhack.maps.isTileVisible(dfhack.units.getPosition(unit))) + then + goto continue + end + table.insert(choices, { + text=get_unit_choice_text(unit), + data={ + unit_id=unit.id, + }, + }) + ::continue:: + end + return choices +end + +local function zoom_to_unit(_, choice) + local data = choice.data + local unit = df.unit.find(data.unit_id) + if not unit then return end + dfhack.gui.revealInDwarfmodeMap( + xyz2pos(dfhack.units.getPosition(unit)), true, true) + return unit.id +end + +local function follow_unit(idx, choice) + local unit_id = zoom_to_unit(idx, choice) + if not unit_id or not dfhack.world.isFortressMode() then return end + df.global.plotinfo.follow_item = -1 + df.global.plotinfo.follow_unit = unit_id + pcall(function() + -- if spectate is available, add the unit to the follow history + local spectate = require('plugins.spectate') + spectate.spectate_addToHistory(unit_id) + end) +end + +local function get_artifact_choices() + local choices = {} + for _, item in ipairs(df.global.world.items.other.ANY_ARTIFACT) do + if item.flags.garbage_collect then goto continue end + table.insert(choices, { + text=dfhack.items.getReadableDescription(item), + data={ + item_id=item.id, + }, + }) + ::continue:: + end + return choices +end + +local function zoom_to_item(_, choice) + local data = choice.data + local item = df.item.find(data.item_id) + if not item then return end + dfhack.gui.revealInDwarfmodeMap( + xyz2pos(dfhack.items.getPosition(item)), true, true) + return item.id +end + +local function follow_item(idx, choice) + local item_id = zoom_to_item(idx, choice) + if not item_id or not dfhack.world.isFortressMode() then return end + df.global.plotinfo.follow_item = item_id + df.global.plotinfo.follow_unit = -1 +end + +local function get_bottom_text() + local text = { + 'Click on a name or hit ', {text='Enter', pen=COLOR_LIGHTGREEN}, ' to zoom to', NEWLINE, + 'the selected target.', + } + + if not dfhack.world.isFortressMode() then + table.insert(text, NEWLINE) + table.insert(text, NEWLINE) + return text + end + + table.insert(text, ' Shift-click or') + table.insert(text, NEWLINE) + table.insert(text, {text='Shift-Enter', pen=COLOR_LIGHTGREEN}) + table.insert(text, ' to zoom and follow unit/item.') + return text +end + +function Sitemap:init() + local site = dfhack.world.getCurrentSite() or false + local location_choices = get_location_choices(site) + local unit_choices = get_unit_choices() + local artifact_choices = get_artifact_choices() + + self:addviews{ + widgets.TabBar{ + frame={t=0, l=0}, + labels={ + 'Creatures', + 'Locations', + 'Artifacts', + }, + on_select=function(idx) + self.subviews.pages:setSelected(idx) + local _, page = self.subviews.pages:getSelected() + page.subviews.list.edit:setFocus(true) + end, + get_cur_page=function() return self.subviews.pages:getSelected() end, + }, + widgets.Pages{ + view_id='pages', + frame={t=3, l=0, b=6, r=0}, + subviews={ + widgets.Panel{ + subviews={ + widgets.Label{ + frame={t=0, l=0}, + text='Nobody around. Spooky!', + text_pen=COLOR_LIGHTRED, + visible=#unit_choices == 0, + }, + widgets.FilteredList{ + view_id='list', + row_height=2, + on_submit=zoom_to_unit, + on_submit2=follow_unit, + choices=unit_choices, + visible=#unit_choices > 0, + }, + }, + }, + widgets.Panel{ + subviews={ + widgets.Label{ + frame={t=0, l=0}, + text='Please enter a site to see locations.', + text_pen=COLOR_LIGHTRED, + visible=not site, + }, + widgets.Label{ + frame={t=0, l=0}, + text={ + 'No temples, guildhalls, hospitals, taverns,', NEWLINE, + 'or libraries found at this site.' + }, + text_pen=COLOR_LIGHTRED, + visible=site and #location_choices == 0, + }, + widgets.FilteredList{ + view_id='list', + on_submit=zoom_to_next_zone, + on_submit2=zoom_to_next_zone, + choices=location_choices, + visible=#location_choices > 0, + }, + }, + }, + widgets.Panel{ + subviews={ + widgets.Label{ + frame={t=0, l=0}, + text='No artifacts around here.', + text_pen=COLOR_LIGHTRED, + visible=#artifact_choices == 0, + }, + widgets.FilteredList{ + view_id='list', + on_submit=zoom_to_item, + on_submit2=follow_item, + choices=artifact_choices, + visible=#artifact_choices > 0, + }, + }, + }, + }, + }, + widgets.Divider{ + frame={b=4, h=1, l=0, r=1}, + frame_style=gui.FRAME_THIN, + frame_style_l=false, + frame_style_r=false, + }, + widgets.Label{ + frame={b=0, l=0}, + text=get_bottom_text(), + }, + } +end + +-- +-- SitemapScreen +-- + +SitemapScreen = defclass(SitemapScreen, gui.ZScreen) +SitemapScreen.ATTRS { + focus_path='sitemap', + pass_movement_keys=true, +} + +function SitemapScreen:init() + self:addviews{Sitemap{}} +end + +function SitemapScreen:onDismiss() + view = nil +end + + +-- -------------------------------- +-- SitemapToolbarOverlay +-- + +SitemapToolbarOverlay = defclass(SitemapToolbarOverlay, overlay.OverlayWidget) +SitemapToolbarOverlay.ATTRS{ + desc='Adds a button to the toolbar at the bottom left corner of the screen for launching gui/sitemap.', + default_pos={x=34, y=-1}, + default_enabled=true, + viewscreens='dwarfmode', + frame={w=28, h=10}, +} + +function SitemapToolbarOverlay:init() + local button_chars = { + {218, 196, 196, 191}, + {179, '-', 'O', 179}, + {192, 196, 196, 217}, + } + + self:addviews{ + widgets.Panel{ + frame={t=0, l=0, w=27, h=6}, + frame_style=gui.FRAME_PANEL, + frame_background=gui.CLEAR_PEN, + frame_inset={l=1, r=1}, + visible=function() return self.subviews.icon:getMousePos() end, + subviews={ + widgets.Label{ + text={ + 'Open the general search', NEWLINE, + 'and zoom interface.', NEWLINE, + NEWLINE, + {text='Hotkey: ', pen=COLOR_GRAY}, {key='CUSTOM_CTRL_G'}, + }, + }, + }, + }, + widgets.Panel{ + view_id='icon', + frame={b=0, l=0, w=4, h=3}, + subviews={ + widgets.Label{ + text=widgets.makeButtonLabelText{ + chars=button_chars, + pens=COLOR_GRAY, + tileset=toolbar_textures, + tileset_offset=1, + tileset_stride=8, + }, + on_click=launch_sitemap, + visible=function () return not self.subviews.icon:getMousePos() end, + }, + widgets.Label{ + text=widgets.makeButtonLabelText{ + chars=button_chars, + pens={ + {COLOR_WHITE, COLOR_WHITE, COLOR_WHITE, COLOR_WHITE}, + {COLOR_WHITE, COLOR_GRAY, COLOR_GRAY, COLOR_WHITE}, + {COLOR_WHITE, COLOR_WHITE, COLOR_WHITE, COLOR_WHITE}, + }, + tileset=toolbar_textures, + tileset_offset=5, + tileset_stride=8, + }, + on_click=launch_sitemap, + visible=function() return not not self.subviews.icon:getMousePos() end, + }, + }, + }, + } +end + +function SitemapToolbarOverlay:onInput(keys) + return SitemapToolbarOverlay.super.onInput(self, keys) +end + +OVERLAY_WIDGETS = {toolbar=SitemapToolbarOverlay} + + +if dfhack_flags.module then + return +end + +if not dfhack.isMapLoaded() then + qerror('This script requires a map to be loaded') +end + +view = view and view:raise() or SitemapScreen{}:show() diff --git a/gui/spectate.lua b/gui/spectate.lua new file mode 100644 index 0000000000..558e281b5f --- /dev/null +++ b/gui/spectate.lua @@ -0,0 +1,271 @@ +local gui = require('gui') +local overlay = require('plugins.overlay') +local spectate = require('plugins.spectate') +local textures = require('gui.textures') +local utils = require('utils') +local widgets = require('gui.widgets') + +local OVERLAY_NAME = 'spectate.tooltip' + +-------------------------------------------------------------------------------- +--- ToggleLabel + +-- pens are the same as gui/control-panel.lua +local function get_icon_pens() + local enabled_pen_left = dfhack.pen.parse{fg=COLOR_CYAN, + tile=curry(textures.tp_control_panel, 1), ch=string.byte('[')} + local enabled_pen_center = dfhack.pen.parse{fg=COLOR_LIGHTGREEN, + tile=curry(textures.tp_control_panel, 2) or nil, ch=251} -- check + local enabled_pen_right = dfhack.pen.parse{fg=COLOR_CYAN, + tile=curry(textures.tp_control_panel, 3) or nil, ch=string.byte(']')} + local disabled_pen_left = dfhack.pen.parse{fg=COLOR_CYAN, + tile=curry(textures.tp_control_panel, 4) or nil, ch=string.byte('[')} + local disabled_pen_center = dfhack.pen.parse{fg=COLOR_RED, + tile=curry(textures.tp_control_panel, 5) or nil, ch=string.byte('x')} + local disabled_pen_right = dfhack.pen.parse{fg=COLOR_CYAN, + tile=curry(textures.tp_control_panel, 6) or nil, ch=string.byte(']')} + return enabled_pen_left, enabled_pen_center, enabled_pen_right, + disabled_pen_left, disabled_pen_center, disabled_pen_right +end +local ENABLED_PEN_LEFT, ENABLED_PEN_CENTER, ENABLED_PEN_RIGHT, + DISABLED_PEN_LEFT, DISABLED_PEN_CENTER, DISABLED_PEN_RIGHT = get_icon_pens() + +ToggleLabel = defclass(ToggleLabel, widgets.ToggleHotkeyLabel) + +function ToggleLabel:init() + local text = self.text + -- the very last token is the On/Off text -- we'll repurpose it as an indicator + text[#text] = { tile = function() return self:getOptionValue() and ENABLED_PEN_LEFT or DISABLED_PEN_LEFT end } + text[#text + 1] = { tile = function() return self:getOptionValue() and ENABLED_PEN_CENTER or DISABLED_PEN_CENTER end } + text[#text + 1] = { tile = function() return self:getOptionValue() and ENABLED_PEN_RIGHT or DISABLED_PEN_RIGHT end } + self:setText(text) +end + +-------------------------------------------------------------------------------- +--- Spectate config window +Spectate = defclass(Spectate, widgets.Window) +Spectate.ATTRS { + frame_title='Spectate', + frame={l=5, t=5, w=36, h=42}, +} + +local function create_toggle_button(frame, cfg_elem, hotkey, label, cfg_elem_key) + return ToggleLabel{ + frame=frame, + initial_option=spectate.get_config_elem(cfg_elem, cfg_elem_key), + on_change=function(val) dfhack.run_command('spectate', 'set', cfg_elem, cfg_elem_key, tostring(val)) end, + key=hotkey, + label=label, + } +end + +local function create_numeric_edit_field(frame, cfg_elem, hotkey, label) + local editOnSubmit + local ef = widgets.EditField{ + frame=frame, + label_text = label, + text = tostring(spectate.get_config_elem(cfg_elem)), + modal = true, + key = hotkey, + on_char = function(ch) return ch:match('%d') end, + on_submit = function(text) editOnSubmit(text) end, + } + editOnSubmit = function(text) + if text == '' then + ef:setText(tostring(spectate.get_config_elem(cfg_elem))) + else + dfhack.run_command('spectate', 'set', cfg_elem, text) + end + end + + return ef +end + +local function create_row_toggle_buttons(keyFollow, keyHover, colFollow, colHover, cfg_elem_key) + local tlFollow = create_toggle_button({l=colFollow+2}, keyFollow, nil, nil, cfg_elem_key) + local tlHover = create_toggle_button({l=colHover+1}, keyHover, nil, nil, cfg_elem_key) + return tlFollow, tlHover +end + +local function create_row(frame, label, hotkey, suffix, colFollow, colHover) + suffix = suffix or '' + if suffix ~= '' then suffix = '-'..suffix end + + local keyFollow = 'tooltip-follow'..suffix + local keyHover = 'tooltip-hover'..suffix + + local tlFollow, tlHover = create_row_toggle_buttons(keyFollow, keyHover, colFollow, colHover) + + return widgets.Panel{ + frame=utils.assign({h=1}, frame), + subviews={ + widgets.HotkeyLabel{ + frame={l=0,w=1}, + key='CUSTOM_' .. hotkey, + key_sep='', + on_activate=function() tlFollow:cycle() end, + }, + widgets.HotkeyLabel{ + frame={l=1,w=1}, + key='CUSTOM_SHIFT_' .. hotkey, + key_sep='', + on_activate=function() tlHover:cycle() end, + }, + widgets.Label{ + frame={l=2}, + text = ': ' .. label, + }, + tlFollow, + tlHover, + }, + } +end + +local function make_choice(text, tlFollow, tlHover) + return { + text=text, + data={tlFollow=tlFollow, tlHover=tlHover}, + } +end + +-- individual stress levels +-- a list on the left to select one, individual buttons in two columns to be able to click on them +local function create_stress_list(frame, colFollow, colHover) + local levelsKey = 'tooltip-stress-levels' + local stressFollowKey = 'tooltip-follow-stress-levels' + local stressHoverKey = 'tooltip-hover-stress-levels' + + local choices, subviews = {}, {} + for idx=0,6 do + local cfgElemKey = tostring(idx) + local tlFollow, tlHover = create_row_toggle_buttons(stressFollowKey, stressHoverKey, colFollow, colHover, cfgElemKey) + table.insert(subviews, widgets.Panel{ + frame={t=idx, h=1}, + subviews={ + tlFollow, + tlHover, + } + }) + + local elem = spectate.get_config_elem(levelsKey, cfgElemKey) + table.insert(choices, make_choice({{text=elem.text, pen=elem.pen}, ' ', elem.name}, tlFollow, tlHover)) + end + + table.insert(subviews, 1, widgets.List{ + frame={l=2}, + on_submit=function(_, choice) choice.data.tlFollow:cycle() end, + on_submit2=function(_, choice) choice.data.tlHover:cycle() end, + choices=choices, + }) + + return widgets.Panel{ + frame=frame, + subviews=subviews, + } +end + +local function rpad(s, i) + return string.format("%-"..i.."s", s) +end + +function Spectate:init() + local lWidth = 21 + local colFollow, colHover = 15, 25 + + self:addviews{ + widgets.Label{ + frame={t=0, l=0}, + text='See help for option details:', + }, + widgets.HelpButton{ + frame={t=0, r=0}, + command = 'spectate', + }, + ToggleLabel{ + frame={t=2}, + view_id='spectate_mode', + initial_option=spectate.isEnabled(), + on_change=function(val) dfhack.run_command(val and 'enable' or 'disable', 'spectate') end, + key='CUSTOM_ALT_E', + label='Spectate mode ', + enabled=dfhack.world.isFortressMode, + }, + create_numeric_edit_field({t=4}, 'follow-seconds', 'CUSTOM_ALT_F', 'Switch target (sec): '), + create_toggle_button({t=6}, 'auto-unpause', 'CUSTOM_ALT_U', rpad('Auto unpause', lWidth)), + create_toggle_button({t=7}, 'cinematic-action', 'CUSTOM_ALT_C', rpad('Cinematic action', lWidth)), + create_toggle_button({t=8}, 'include-animals', 'CUSTOM_ALT_A', rpad('Include animals', lWidth)), + create_toggle_button({t=9}, 'include-hostiles', 'CUSTOM_ALT_H', rpad('Include hostiles', lWidth)), + create_toggle_button({t=10}, 'include-visitors', 'CUSTOM_ALT_V', rpad('Include visitors', lWidth)), + create_toggle_button({t=11}, 'include-wildlife', 'CUSTOM_ALT_W', rpad('Include wildlife', lWidth)), + create_toggle_button({t=12}, 'prefer-conflict', 'CUSTOM_ALT_B', rpad('Prefer conflict', lWidth)), + create_toggle_button({t=13}, 'prefer-new-arrivals', 'CUSTOM_ALT_N', rpad('Prefer new arrivals', lWidth)), + create_toggle_button({t=14}, 'prefer-nicknamed', 'CUSTOM_ALT_I', rpad('Prefer nicknamed', lWidth)), + widgets.Divider{ + frame={t=16, h=1}, + frame_style=gui.FRAME_THIN, + frame_style_l=false, + frame_style_r=false, + }, + widgets.Label{ + frame={t=18, l=0}, + text="Tooltips:" + }, + ToggleLabel{ + frame={t=18, l=12}, + initial_option=overlay.isOverlayEnabled(OVERLAY_NAME), + on_change=function(val) dfhack.run_command('overlay', val and 'enable' or 'disable', OVERLAY_NAME) end, + key='CUSTOM_ALT_O', + label="Overlay ", + }, + widgets.Label{ + frame={t=20, l=colFollow}, + text='Follow', + }, + widgets.Label{ + frame={t=20, l=colHover}, + text='Hover', + }, + create_row({t=22}, 'Enabled', 'E', '', colFollow, colHover), + + create_numeric_edit_field({t=24}, 'tooltip-follow-blink-milliseconds', 'CUSTOM_B', 'Blink period (ms): '), + widgets.CycleHotkeyLabel{ + frame={t=25}, + key='CUSTOM_C', + label="Hold to show:", + options={ + {label="None", value="none", pen=COLOR_GREY}, + {label="Ctrl", value="ctrl", pen=COLOR_LIGHTCYAN}, + {label="Alt", value="alt", pen=COLOR_LIGHTCYAN}, + {label="Shift", value="shift", pen=COLOR_LIGHTCYAN}, + }, + initial_option=spectate.get_config_elem('tooltip-follow-hold-to-show'), + on_change=function(new, _) dfhack.run_command('spectate', 'set', 'tooltip-follow-hold-to-show', new) end + }, + + create_row({t=27}, 'Job', 'J', 'job', colFollow, colHover), + create_row({t=28}, 'Activity', 'A', 'activity', colFollow, colHover), + create_row({t=29}, 'Name', 'N', 'name', colFollow, colHover), + create_row({t=30}, 'Stress', 'S', 'stress', colFollow, colHover), + create_stress_list({t=31}, colFollow, colHover), + } +end + +function Spectate:render(dc) + self.subviews.spectate_mode:setOption(spectate.isEnabled()) + Spectate.super.render(self, dc) +end + +SpectateScreen = defclass(SpectateScreen, gui.ZScreen) +SpectateScreen.ATTRS { + focus_path='spectate', +} + +function SpectateScreen:init() + self:addviews{Spectate{}} +end + +function SpectateScreen:onDismiss() + view = nil +end + +view = view and view:raise() or SpectateScreen{}:show() diff --git a/gui/suspendmanager.lua b/gui/suspendmanager.lua index 25b666a726..03cb0af675 100644 --- a/gui/suspendmanager.lua +++ b/gui/suspendmanager.lua @@ -2,7 +2,7 @@ local gui = require("gui") local widgets = require("gui.widgets") -local suspendmanager = reqscript("suspendmanager") +local suspendmanager = require("plugins.suspendmanager") --- -- Suspendmanager @@ -31,12 +31,11 @@ function SuspendmanagerWindow:init() key="CUSTOM_ALT_B", options={{value=true, label="Yes", pen=COLOR_GREEN}, {value=false, label="No", pen=COLOR_RED}}, - initial_option = suspendmanager.preventBlockingEnabled(), + initial_option = suspendmanager.suspendmanager_preventBlockingEnabled(), on_change=function(val) - suspendmanager.update_setting("preventblocking", val) + dfhack.run_command('suspendmanager', 'set', 'preventblocking', tostring(val)) end }, - } end diff --git a/gui/teleport.lua b/gui/teleport.lua index 0a29d32f04..cf9bf30d50 100644 --- a/gui/teleport.lua +++ b/gui/teleport.lua @@ -1,116 +1,392 @@ --- A front-end for the teleport script +local gui = require('gui') +local guidm = require('gui.dwarfmode') +local utils = require('utils') +local widgets = require('gui.widgets') ---[====[ +saved_citizens = saved_citizens or (saved_citizens == nil and true) +saved_friendly = saved_friendly or (saved_friendly == nil and true) +saved_hostile = saved_hostile or (saved_hostile == nil and true) -gui/teleport -============ +local indicator = df.global.game.main_interface.recenter_indicator_m -A front-end for the `teleport` script that allows choosing a unit and destination -using the in-game cursor. - -]====] - -guidm = require 'gui.dwarfmode' -widgets = require 'gui.widgets' - -function uiMultipleUnits() - return #df.global.game.unit_cursor.list > 1 +local function is_good_unit(include, unit) + if not unit then return false end + if dfhack.units.isDead(unit) or + not dfhack.units.isActive(unit) or + unit.flags1.caged + then + return false + end + if dfhack.units.isCitizen(unit) or dfhack.units.isResident(unit) then + return include.citizens + end + local dangerous = dfhack.units.isDanger(unit) + if not dangerous then return include.friendly end + return include.hostile end -TeleportSidebar = defclass(TeleportSidebar, guidm.MenuOverlay) +----------------- +-- Teleport +-- -TeleportSidebar.ATTRS = { - sidebar_mode=df.ui_sidebar_mode.ViewUnits, +Teleport = defclass(Teleport, widgets.Window) +Teleport.ATTRS { + frame_title='Teleport', + frame={w=45, h=26, r=2, t=18}, + resizable=true, + resize_min={h=20}, + autoarrange_subviews=true, + autoarrange_gap=1, } -function TeleportSidebar:init() +function Teleport:init() + self.mark = nil + self.prev_help_text = '' + self:reset_selected_state() -- sets self.selected_* + self:reset_double_click() -- sets self.last_map_click_ms and self.last_map_click_pos + + -- pre-add UI selected unit, if any + local initial_unit = dfhack.gui.getSelectedUnit(true) + if initial_unit then + self:add_unit(initial_unit) + end + -- close the view sheet panel (if it's open) so the player can see the map + df.global.game.main_interface.view_sheets.open = false + self:addviews{ - widgets.Label{ - frame = {b=1, l=1}, - text = { - {key = 'UNITJOB_ZOOM_CRE', - text = ': Zoom to unit, ', - on_activate = self:callback('zoom_unit'), - enabled = function() return self.unit end}, - {key = 'UNITVIEW_NEXT', text = ': Next', - on_activate = self:callback('next_unit'), - enabled = uiMultipleUnits}, - NEWLINE, - NEWLINE, - {key = 'SELECT', text = ': Choose, ', on_activate = self:callback('choose')}, - {key = 'LEAVESCREEN', text = ': Back', on_activate = self:callback('back')}, - NEWLINE, - {key = 'LEAVESCREEN_ALL', text = ': Exit to map', on_activate = self:callback('dismiss')}, + widgets.WrappedLabel{ + frame={l=0}, + text_to_wrap=self:callback('get_help_text'), + }, + widgets.HotkeyLabel{ + frame={l=0}, + label='Teleport units to mouse cursor', + key='CUSTOM_CTRL_T', + auto_width=true, + on_activate=self:callback('do_teleport'), + enabled=function() + return dfhack.gui.getMousePos() and #self.selected_units.list > 0 + end, + }, + widgets.ResizingPanel{ + autoarrange_subviews=true, + subviews={ + widgets.ToggleHotkeyLabel{ + view_id='include_citizens', + frame={l=0, w=29}, + label='Include citizen units ', + key='CUSTOM_SHIFT_U', + initial_option=saved_citizens, + on_change=function(val) saved_citizens = val end, + }, + widgets.ToggleHotkeyLabel{ + view_id='include_friendly', + frame={l=0, w=29}, + label='Include friendly units', + key='CUSTOM_SHIFT_F', + initial_option=saved_friendly, + on_change=function(val) saved_friendly = val end, + }, + widgets.ToggleHotkeyLabel{ + view_id='include_hostile', + frame={l=0, w=29}, + label='Include hostile units ', + key='CUSTOM_SHIFT_H', + initial_option=saved_hostile, + on_change=function(val) saved_hostile = val end, + }, }, }, + widgets.Panel{ + frame={t=10, b=0, l=0, r=0}, + frame_style=gui.FRAME_INTERIOR, + subviews={ + widgets.Label{ + frame={t=0, l=0}, + text='No selected units', + visible=function() return #self.selected_units.list == 0 end, + }, + widgets.Label{ + frame={t=0, l=0}, + text='Selected units:', + visible=function() return #self.selected_units.list > 0 end, + }, + widgets.List{ + view_id='list', + frame={t=2, l=0, r=0, b=4}, + on_select=function(_, choice) + if choice then + df.assign(indicator, xyz2pos(dfhack.units.getPosition(choice.unit))) + end + end, + on_submit=function(_, choice) + if choice then + local pos = xyz2pos(dfhack.units.getPosition(choice.unit)) + dfhack.gui.revealInDwarfmodeMap(pos, true, true) + end + end, + }, + widgets.HotkeyLabel{ + frame={l=0, b=1}, + key='CUSTOM_SHIFT_R', + label='Deselect unit', + auto_width=true, + on_activate=self:callback('remove_unit'), + enabled=function() return #self.selected_units.list > 0 end, + }, + widgets.HotkeyLabel{ + frame={l=26, b=1}, + key='CUSTOM_SHIFT_X', + label='Clear list', + auto_width=true, + on_activate=self:callback('reset_selected_state'), + enabled=function() return #self.selected_units.list > 0 end, + }, + widgets.Label{ + frame={l=0, b=0}, + text={ + 'Click name or hit ', + {text='Enter', pen=COLOR_LIGHTGREEN}, + ' to zoom to unit', + }, + }, + } + } } - self.in_pick_pos = false + + self:refresh_choices() +end + +function Teleport:reset_double_click() + self.last_map_click_ms = 0 + self.last_map_click_pos = {} end -function TeleportSidebar:choose() - if not self.in_pick_pos then - self.in_pick_pos = true - else - dfhack.units.teleport(self.unit, xyz2pos(pos2xyz(df.global.cursor))) - self:dismiss() +function Teleport:update_coords(x, y, z) + ensure_keys(self.selected_coords, z, y)[x] = true + local selected_bounds = ensure_key(self.selected_bounds, z, + {x1=x, x2=x, y1=y, y2=y}) + selected_bounds.x1 = math.min(selected_bounds.x1, x) + selected_bounds.x2 = math.max(selected_bounds.x2, x) + selected_bounds.y1 = math.min(selected_bounds.y1, y) + selected_bounds.y2 = math.max(selected_bounds.y2, y) +end + +function Teleport:add_unit(unit) + if not unit then return end + local x, y, z = dfhack.units.getPosition(unit) + if not x then return end + if not self.selected_units.set[unit.id] then + self.selected_units.set[unit.id] = true + utils.insert_sorted(self.selected_units.list, unit, 'id') + self:update_coords(x, y, z) end end -function TeleportSidebar:back() - if self.in_pick_pos then - self.in_pick_pos = false - else - self:dismiss() +function Teleport:reset_selected_state(keep_units) + if not keep_units then + self.selected_units = {list={}, set={}} + end + self.selected_coords = {} -- z -> y -> x -> true + self.selected_bounds = {} -- z -> bounds rect + for _, unit in ipairs(self.selected_units.list) do + self:update_coords(dfhack.units.getPosition(unit)) + end + if next(self.subviews) then + self:updateLayout() + self:refresh_choices() end end -function TeleportSidebar:zoom_unit() - df.global.cursor:assign(xyz2pos(pos2xyz(self.unit.pos))) - self:getViewport():centerOn(self.unit.pos):set() +function Teleport:refresh_choices() + local choices = {} + for _, unit in ipairs(self.selected_units.list) do + local suffix = '' + if dfhack.units.isCitizen(unit) then suffix = ' (citizen)' + elseif dfhack.units.isResident(unit) then suffix = ' (resident)' + elseif dfhack.units.isDanger(unit) then suffix = ' (hostile)' + elseif dfhack.units.isMerchant(unit) or dfhack.units.isForest(unit) then + suffix = ' (merchant)' + elseif dfhack.units.isAnimal(unit) then + -- tame units will already have an annotation in the readable name + if not dfhack.units.isTame(unit) then + suffix = ' (wild)' + end + else + suffix = ' (friendly)' + end + table.insert(choices, { + text=dfhack.units.getReadableName(unit)..suffix, + unit=unit + }) + end + table.sort(choices, function(a, b) return a.text < b.text end) + self.subviews.list:setChoices(choices) end -function TeleportSidebar:next_unit() - self:sendInputToParent('UNITVIEW_NEXT') +function Teleport:remove_unit() + local _, choice = self.subviews.list:getSelected() + if not choice then return end + self.selected_units.set[choice.unit.id] = nil + utils.erase_sorted_key(self.selected_units.list, choice.unit.id, 'id') + self:reset_selected_state(true) end -function TeleportSidebar:onRenderBody(p) - p:seek(1, 1):pen(COLOR_WHITE) - if self.in_pick_pos then - p:string('Select destination'):newline(1):newline(1) +function Teleport:get_include() + local include = {citizens=false, friendly=false, hostile=false} + if next(self.subviews) then + include.citizens = self.subviews.include_citizens:getOptionValue() + include.friendly = self.subviews.include_friendly:getOptionValue() + include.hostile = self.subviews.include_hostile:getOptionValue() + end + return include +end - local cursor = df.global.cursor - local block = dfhack.maps.getTileBlock(pos2xyz(cursor)) - if block then - p:string(df.tiletype[block.tiletype[cursor.x % 16][cursor.y % 16]], COLOR_CYAN) - else - p:string('Unknown tile', COLOR_RED) +function Teleport:get_help_text() + local help_text = 'Draw boxes around units to select' + local num_selected = #self.selected_units.list + if num_selected > 0 then + help_text = help_text .. + (', or double click on a tile to teleport %d selected unit(s).'):format(num_selected) + end + if help_text ~= self.prev_help_text then + self.prev_help_text = help_text + end + return help_text +end + +function Teleport:get_bounds(cursor, mark) + cursor = cursor or self.mark + mark = mark or self.mark or cursor + if not mark then return end + + return { + x1=math.min(cursor.x, mark.x), + x2=math.max(cursor.x, mark.x), + y1=math.min(cursor.y, mark.y), + y2=math.max(cursor.y, mark.y), + z1=math.min(cursor.z, mark.z), + z2=math.max(cursor.z, mark.z) + } +end + +function Teleport:select_box(bounds) + if not bounds then return end + local filter = curry(is_good_unit, self:get_include()) + local selected_units = dfhack.units.getUnitsInBox( + bounds.x1, bounds.y1, bounds.z1, bounds.x2, bounds.y2, bounds.z2, filter) + for _,unit in ipairs(selected_units) do + self:add_unit(unit) + end + self:refresh_choices() +end + +function Teleport:onInput(keys) + if Teleport.super.onInput(self, keys) then return true end + if keys._MOUSE_R and self.mark then + self.mark = nil + self:updateLayout() + return true + elseif keys._MOUSE_L then + if self:getMouseFramePos() then return true end + local pos = dfhack.gui.getMousePos() + if not pos then + self:reset_double_click() + return false end - else - self.unit = dfhack.gui.getAnyUnit(self._native.parent) - p:string('Select unit:'):newline(1):newline(1) - if self.unit then - local name = dfhack.TranslateName(dfhack.units.getVisibleName(self.unit)) - p:string(name) - if name ~= '' then p:newline(1) end - p:string(dfhack.units.getProfessionName(self.unit), dfhack.units.getProfessionColor(self.unit)) - p:newline(1) - else - p:string('No unit selected', COLOR_LIGHTRED) + local now_ms = dfhack.getTickCount() + if same_xyz(pos, self.last_map_click_pos) and + now_ms - self.last_map_click_ms <= widgets.getDoubleClickMs() then + self:reset_double_click() + self:do_teleport(pos) + self.mark = nil + self:updateLayout() + return true + end + self.last_map_click_ms = now_ms + self.last_map_click_pos = pos + if self.mark then + self:select_box(self:get_bounds(pos)) + self:reset_double_click() + self.mark = nil + self:updateLayout() + return true end + self.mark = pos + self:updateLayout() + return true end end -function TeleportSidebar:onInput(keys) - TeleportSidebar.super.onInput(self, keys) - TeleportSidebar.super.propagateMoveKeys(self, keys) +local to_pen = dfhack.pen.parse +local CURSOR_PEN = to_pen{ch='o', fg=COLOR_BLUE, + tile=dfhack.screen.findGraphicsTile('CURSORS', 5, 22)} +local BOX_PEN = to_pen{ch='X', fg=COLOR_GREEN, + tile=dfhack.screen.findGraphicsTile('CURSORS', 0, 0)} +local SELECTED_PEN = to_pen{ch='I', fg=COLOR_GREEN, + tile=dfhack.screen.findGraphicsTile('CURSORS', 1, 2)} + +function Teleport:onRenderFrame(dc, rect) + Teleport.super.onRenderFrame(self, dc, rect) + + local highlight_coords = self.selected_coords[df.global.window_z] + if highlight_coords then + local function get_overlay_pen(pos) + if same_xyz(indicator, pos) then return end + if safe_index(highlight_coords, pos.y, pos.x) then + return SELECTED_PEN + end + end + guidm.renderMapOverlay(get_overlay_pen, self.selected_bounds[df.global.window_z]) + end + + -- draw selection box and cursor (blinking when in ascii mode) + local cursor = dfhack.gui.getMousePos() + local selection_bounds = self:get_bounds(cursor) + if selection_bounds and (dfhack.screen.inGraphicsMode() or gui.blink_visible(500)) then + guidm.renderMapOverlay( + function() return self.mark and BOX_PEN or CURSOR_PEN end, + selection_bounds) + end +end + +function Teleport:do_teleport(pos) + pos = pos or dfhack.gui.getMousePos() + if not pos then return end + print(('teleporting %d unit(s)'):format(#self.selected_units.list)) + for _,unit in ipairs(self.selected_units.list) do + dfhack.units.teleport(unit, pos) + end + indicator.x = -30000 + self:reset_selected_state() + self:updateLayout() end -function TeleportSidebar:onGetSelectedUnit() - return self.unit +----------------- +-- TeleportScreen +-- + +TeleportScreen = defclass(TeleportScreen, gui.ZScreen) +TeleportScreen.ATTRS { + focus_path='teleport', + pass_movement_keys=true, + pass_mouse_clicks=false, + force_pause=true, +} + +function TeleportScreen:init() + local window = Teleport{} + self:addviews{ + window, + widgets.DimensionsTooltip{ + get_anchor_pos_fn=function() return window.mark end, + }, + } end -if not dfhack.isMapLoaded() then - qerror('This script requires a fortress map to be loaded') +function TeleportScreen:onDismiss() + indicator.x = -30000 + view = nil end -TeleportSidebar():show() +view = view and view:raise() or TeleportScreen{}:show() diff --git a/gui/tiletypes.lua b/gui/tiletypes.lua new file mode 100644 index 0000000000..9f671db731 --- /dev/null +++ b/gui/tiletypes.lua @@ -0,0 +1,1792 @@ +--@ module = true +local argparse = require('argparse') +local gui = require('gui') +local guidm = require('gui.dwarfmode') +local plugin = require('plugins.tiletypes') +local textures = require('gui.textures') +local utils = require('utils') +local widgets = require('gui.widgets') + +local UI_AREA = {r=2, t=18, w=38, h=31} +local POPUP_UI_AREA = {r=41, t=18, w=30, h=22} + +local CONFIG_BUTTON = { + { tile= dfhack.pen.parse{fg=COLOR_CYAN, tile=curry(textures.tp_control_panel, 7) or nil, ch=string.byte('[')} }, + { tile= dfhack.pen.parse{tile=curry(textures.tp_control_panel, 10) or nil, ch=15} }, -- gear/masterwork symbol + { tile= dfhack.pen.parse{fg=COLOR_CYAN, tile=curry(textures.tp_control_panel, 8) or nil, ch=string.byte(']')} } +} + +local OTHER_LABEL_FORMAT = { first= string.char(15).."(", last= ")"} + +local UI_COLORS = { + SELECTED= COLOR_GREEN, + SELECTED2= COLOR_CYAN, + SELECTED_BORDER= COLOR_LIGHTGREEN, + DESELECTED= COLOR_GRAY, + DESELECTED2= COLOR_DARKGRAY, + DESELECTED_BORDER= COLOR_GRAY, + HIGHLIGHTED= COLOR_WHITE, + HIGHLIGHTED2= COLOR_DARKGRAY, + HIGHLIGHTED_BORDER= COLOR_YELLOW, + VALUE_NONE= COLOR_GRAY, + VALUE= COLOR_YELLOW, + VALID_OPTION= COLOR_WHITE, + INVALID_OPTION= COLOR_RED, +} + +local TILESET = dfhack.textures.loadTileset(dfhack.getHackPath()..'/data/art/tiletypes.png', 8, 12, true) +local TILESET_STRIDE = 16 + +local DEFAULT_OPTIONS = { + { value= -1, char1= " ", char2= " ", offset= 97, pen = COLOR_GRAY }, + { value= 1, char1= "+", char2= "+", offset= 101, pen = COLOR_LIGHTGREEN }, + { value= 0, char1= "X", char2= "X", offset= 105, pen = COLOR_RED }, +} + +local MORE_OPTIONS = { + { key= "hidden", label= "Hidden", values= DEFAULT_OPTIONS }, + { key= "light", label= "Light", values= DEFAULT_OPTIONS }, + { key= "subterranean", label= "Subterranean", values= DEFAULT_OPTIONS }, + { key= "skyview", label= "Skyview", values= DEFAULT_OPTIONS }, + { key= "aquifer", label= "Aquifer", values= { + { value= -1, char1= " ", char2= " ", offset= 97, pen = COLOR_GRAY }, + { value= 1, char1= 247, char2= " ", offset= 109, pen = COLOR_LIGHTBLUE }, + { value= 2, char1= 247, char2= 247, offset= 157, pen = COLOR_BLUE }, + { value= 0, char1= "X", char2= "X", offset= 105, pen = COLOR_RED }, + } + }, + { key= "autocorrect", label= "Autocorrect", values= { + { value= 1, char1= "+", char2= "+", offset= 101, pen = COLOR_LIGHTGREEN }, + { value= 0, char1= "X", char2= "X", offset= 105, pen = COLOR_RED }, + } + }, +} + +local MODE_LIST = { + { label= "Paint" , value= "paint" , pen= COLOR_YELLOW }, + { label= "Replace" , value= "replace", pen= COLOR_LIGHTGREEN }, + { label= "Fill" , value= "fill" , pen= COLOR_GREEN }, + { label= "Remove" , value= "remove" , pen= COLOR_RED }, +} + +function isEmptyTile(pos) + if pos and dfhack.maps.isValidTilePos(pos) then + local tiletype = dfhack.maps.getTileType(pos) + return tiletype and ( + df.tiletype.attrs[tiletype].shape == df.tiletype_shape.NONE + or df.tiletype.attrs[tiletype].material == df.tiletype_material.AIR + ) + end + return true +end + +local MODE_SETTINGS = { + ["paint"] = { + idx= 1, config= true , char1= 219, char2= 219, offset= 1, selected_offset = 49, + description= "Paint tiles", + validator= function(pos) return true end + }, + ["replace"] = { + idx= 2, config= true , char1= 8, char2= 7, offset= 5, selected_offset = 53, + description= "Replace non-empty tiles", + validator= function(pos) return not isEmptyTile(pos) end + }, + ["fill"] = { + idx= 3, config= true , char1= 7, char2= 8, offset= 9, selected_offset = 57, + description= "Fill in empty tiles", + validator= function(pos) return isEmptyTile(pos) end + }, + ["remove"] = { + idx= 4, config= false, char1= 177, char2= 177, offset= 13, selected_offset = 61, + description= "Remove selected tiles", + validator= function(pos) return true end + }, +} + +CYCLE_VALUES = { + shape = { + [df.tiletype_shape.NONE] = true, + [df.tiletype_shape.EMPTY] = true, + [df.tiletype_shape.FLOOR] = true, + [df.tiletype_shape.WALL] = true, + other = df.tiletype_shape.STAIR_UPDOWN + }, + material = { + [df.tiletype_material.NONE] = true, + [df.tiletype_material.AIR] = true, + [df.tiletype_material.SOIL] = true, + [df.tiletype_material.STONE] = true, + other = df.tiletype_material.LAVA_STONE + }, + special = { + [df.tiletype_special.NONE] = true, + [df.tiletype_special.NORMAL] = true, + [df.tiletype_special.SMOOTH] = true, + other = df.tiletype_special.WORN_1 + }, +} + +HIDDEN_VALUES = { + shape = { + [df.tiletype_shape.BRANCH] = true, + [df.tiletype_shape.TRUNK_BRANCH] = true, + [df.tiletype_shape.TWIG] = true, + [df.tiletype_shape.SAPLING] = true, + [df.tiletype_shape.SHRUB] = true, + [df.tiletype_shape.ENDLESS_PIT] = true + }, + material = { + [df.tiletype_material.FEATURE] = true, + [df.tiletype_material.MINERAL] = true, + [df.tiletype_material.CONSTRUCTION] = true, + [df.tiletype_material.PLANT] = true, + [df.tiletype_material.TREE] = true, + [df.tiletype_material.MUSHROOM] = true, + [df.tiletype_material.ROOT] = true, + [df.tiletype_material.CAMPFIRE] = true, + [df.tiletype_material.DRIFTWOOD] = true, + [df.tiletype_material.UNDERWORLD_GATE] = true, + [df.tiletype_material.HFS] = true + }, + special = { + [df.tiletype_special.DEAD] = true, + [df.tiletype_special.SMOOTH_DEAD] = true + } +} + +---@class TileType +---@field shape? df.tiletype_shape +---@field material? df.tiletype_material +---@field special? df.tiletype_special +---@field variant? df.tiletype_variant +---@field dig? integer Only for filters +---@field hidden? integer +---@field light? integer +---@field subterranean? integer +---@field skyview? integer +---@field aquifer? integer +---@field autocorrect? integer +---@field stone_material? integer +---@field vein_type? df.inclusion_type + +---@param pos df.coord +---@param target TileType +---@return boolean +function setTile(pos, target) + local toValidEnumValue = function(value, enum, default) + return value ~= nil and enum[value] and value or default + end + local toValidOptionValue = function(value) + return value == nil and -1 or value + end + + local tiletype = { + shape = toValidEnumValue(target.shape, df.tiletype_shape, df.tiletype_shape.NONE), + material = toValidEnumValue(target.material, df.tiletype_material, df.tiletype_material.NONE), + special = toValidEnumValue(target.special, df.tiletype_special, df.tiletype_special.NONE), + variant = toValidEnumValue(target.variant, df.tiletype_variant, df.tiletype_variant.NONE), + hidden = toValidOptionValue(target.hidden), + light = toValidOptionValue(target.light), + subterranean = toValidOptionValue(target.subterranean), + skyview = toValidOptionValue(target.skyview), + aquifer = toValidOptionValue(target.aquifer), + autocorrect = target.autocorrect == nil and 0 or target.autocorrect, + } + tiletype.stone_material = tiletype.material == df.tiletype_material.STONE and target.stone_material or -1 + tiletype.vein_type = tiletype.material ~= df.tiletype_material.STONE and -1 or toValidEnumValue(target.vein_type, df.inclusion_type, df.inclusion_type.CLUSTER) + + return plugin.tiletypes_setTile(pos, tiletype) +end + +--#region GUI + +--#region UI Utilities + +---@type widgets.LabelToken +local EMPTY_TOKEN = { text=' ', hpen=dfhack.pen.make(COLOR_RESET), width=1 } + +---@class InlineButtonLabelSpec +---@field left_specs? widgets.ButtonLabelSpec +---@field right_specs? widgets.ButtonLabelSpec +---@field width? integer +---@field height? integer +---@field spacing? integer + +---@nodiscard +---@param spec InlineButtonLabelSpec +---@return widgets.LabelToken[] +function makeInlineButtonLabelText(spec) + spec.left_specs = safe_index(spec, "left_specs", "chars") and spec.left_specs or {chars={}} + spec.right_specs = safe_index(spec, "right_specs", "chars") and spec.right_specs or {chars={}} + spec.width = spec.width or -1 + spec.height = spec.height or -1 + spec.spacing = spec.spacing or -1 + + local getSpecWidth = function(value) + local width = 0 + for _,v in pairs(value.chars) do + width = math.max(width, #v) + end + return width + end + + local left_width = getSpecWidth(spec.left_specs) + local right_width = getSpecWidth(spec.right_specs) + spec.width = spec.width >= 0 and spec.width or (left_width + right_width + math.max(spec.spacing, 0)) + + local left_height = #spec.left_specs.chars + local right_height = #spec.right_specs.chars + spec.height = spec.height >= 0 and spec.height or math.max(left_height, right_height) + + local left_tokens = widgets.makeButtonLabelText(spec.left_specs) + local right_tokens = widgets.makeButtonLabelText(spec.right_specs) + + local centerHeight = function(tokens, height) + local height_spacing = (spec.height - height) // 2 + for i=1, height_spacing do + table.insert(tokens, 1, NEWLINE) + end + height_spacing = spec.height - height - height_spacing + for i=1, height_spacing do + table.insert(tokens, NEWLINE) + end + end + + centerHeight(left_tokens, left_height) + centerHeight(right_tokens, right_height) + + local right_start = spec.spacing >= 0 and (left_width + spec.spacing + 1) or math.max(left_width, spec.width - right_width) + + local label_tokens = {} + local left_cursor = 1 + local right_cursor = 1 + for y=1, spec.height do + for x=1, spec.width do + local token = nil + if x <= left_width then + token = left_tokens[left_cursor] + token = token ~= NEWLINE and token or nil + if token then + left_cursor = left_cursor + 1 + end + elseif x >= right_start then + token = right_tokens[right_cursor] + token = token ~= NEWLINE and token or nil + if token then + right_cursor = right_cursor + 1 + end + end + table.insert(label_tokens, token or EMPTY_TOKEN) + end + + if y ~= spec.height then + -- Move the cursors to the token following the next NEWLINE + while left_tokens[left_cursor - 1] ~= NEWLINE do + left_cursor = left_cursor + 1 + end + while right_tokens[right_cursor - 1] ~= NEWLINE do + right_cursor = right_cursor + 1 + end + end + + table.insert(label_tokens, NEWLINE) + end + + return label_tokens +end + +-- Rect data class + +---@class Rect.attrs +---@field x1 number +---@field y1 number +---@field x2 number +---@field y2 number + +---@class Rect.attrs.partial: Rect.attrs + +---@class Rect: Rect.attrs +---@field ATTRS Rect.attrs|fun(attributes: Rect.attrs.partial) +---@overload fun(init_table: Rect.attrs.partial): self +Rect = defclass(Rect) +Rect.ATTRS { + x1 = -1, + y1 = -1, + x2 = -1, + y2 = -1, +} + +---@param pos df.coord2d +---@return boolean +function Rect:contains(pos) + return pos.x <= self.x2 + and pos.x >= self.x1 + and pos.y <= self.y2 + and pos.y >= self.y1 +end + +---@param overlap_rect Rect +---@return boolean +function Rect:isOverlapping(overlap_rect) + return overlap_rect.x1 <= self.x2 + and overlap_rect.x2 >= self.x1 + and overlap_rect.y1 <= self.y2 + and overlap_rect.y2 >= self.y1 +end + +---@param clip_rect Rect +---@return Rect[] +function Rect:clip(clip_rect) + local output = {} + + -- If there is any overlap with the screen rect + if self:isOverlapping(clip_rect) then + local temp_rect = Rect(self) + -- Get rect to the left of the clip rect + if temp_rect.x1 <= clip_rect.x1 then + table.insert(output, Rect{ + x1= temp_rect.x1, + x2= math.min(temp_rect.x2, clip_rect.x1), + y1= temp_rect.y1, + y2= temp_rect.y2 + }) + temp_rect.x1 = clip_rect.x1 + end + -- Get rect to the right of the clip rect + if temp_rect.x2 >= clip_rect.x2 then + table.insert(output, Rect{ + x1= math.max(temp_rect.x1, clip_rect.x2), + x2= temp_rect.x2, + y1= temp_rect.y1, + y2= temp_rect.y2 + }) + temp_rect.x2 = clip_rect.x2 + end + -- Get rect above the clip rect + if temp_rect.y1 <= clip_rect.y1 then + table.insert(output, Rect{ + x1= temp_rect.x1, + x2= temp_rect.x2, + y1= temp_rect.y1, + y2= math.min(temp_rect.y2, clip_rect.y1) + }) + temp_rect.y1 = clip_rect.y1 + end + -- Get rect below the clip rect + if temp_rect.y2 >= clip_rect.y2 then + table.insert(output, Rect{ + x1= temp_rect.x1, + x2= temp_rect.x2, + y1= math.max(temp_rect.y1, clip_rect.y2), + y2= temp_rect.y2 + }) + temp_rect.y2 = clip_rect.y2 + end + else + -- No overlap + table.insert(output, self) + end + + return output +end + +---@return Rect +function Rect:screenToTile() + local view_dims = dfhack.gui.getDwarfmodeViewDims() + local tile_view_size = xy2pos(view_dims.map_x2 - view_dims.map_x1 + 1, view_dims.map_y2 - view_dims.map_y1 + 1) + local display_view_size = xy2pos(df.global.init.display.grid_x, df.global.init.display.grid_y) + local display_to_tile_ratio = xy2pos(tile_view_size.x / display_view_size.x, tile_view_size.y / display_view_size.y) + + return Rect{ + x1= self.x1 * display_to_tile_ratio.x - 1, + x2= self.x2 * display_to_tile_ratio.x + 1, + y1= self.y1 * display_to_tile_ratio.y - 1, + y2= self.y2 * display_to_tile_ratio.y + 1 + } +end + +---@return Rect +function Rect:tileToScreen() + local view_dims = dfhack.gui.getDwarfmodeViewDims() + local tile_view_size = xy2pos(view_dims.map_x2 - view_dims.map_x1 + 1, view_dims.map_y2 - view_dims.map_y1 + 1) + local display_view_size = xy2pos(df.global.init.display.grid_x, df.global.init.display.grid_y) + local display_to_tile_ratio = xy2pos(tile_view_size.x / display_view_size.x, tile_view_size.y / display_view_size.y) + + return Rect{ + x1= (self.x1 + 1) / display_to_tile_ratio.x, + x2= (self.x2 - 1) / display_to_tile_ratio.x, + y1= (self.y1 + 1) / display_to_tile_ratio.y, + y2= (self.y2 - 1) / display_to_tile_ratio.y + } +end + +-- Draws a list of rects with associated pens, without overlapping any of the given screen rects +---@param draw_queue { pen: dfhack.pen, rect: Rect }[] +---@param screen_rect_list Rect[] +function drawOutsideOfScreenRectList(draw_queue, screen_rect_list) + local cur_draw_queue = draw_queue + for _,screen_rect in pairs(screen_rect_list) do + local screen_tile_rect = screen_rect:screenToTile() + local new_draw_queue = {} + for _,draw_rect in pairs(cur_draw_queue) do + for _,clipped in pairs(draw_rect.rect:clip(screen_tile_rect)) do + table.insert(new_draw_queue, { pen= draw_rect.pen, rect= clipped }) + end + end + cur_draw_queue = new_draw_queue + end + + for _,draw_rect in pairs(cur_draw_queue) do + dfhack.screen.fillRect(draw_rect.pen, draw_rect.rect.x1, draw_rect.rect.y1, draw_rect.rect.x2, draw_rect.rect.y2, true) + end +end + +-- Box data class + +---@class Box.attrs + +---@class Box.attrs.partial: Box.attrs + +---@class Box: Box.attrs +---@field ATTRS Box.attrs|fun(attributes: Box.attrs.partial) +---@field valid boolean +---@field min df.coord +---@field max df.coord +---@overload fun(init_table: Box.attrs.partial): self +Box = defclass(Box) +Box.ATTRS {} + +function Box:init(points) + self.valid = true + self.min = nil + self.max = nil + for _,value in pairs(points) do + if dfhack.maps.isValidTilePos(value) then + self.min = xyz2pos( + self.min and math.min(self.min.x, value.x) or value.x, + self.min and math.min(self.min.y, value.y) or value.y, + self.min and math.min(self.min.z, value.z) or value.z + ) + self.max = xyz2pos( + self.max and math.max(self.max.x, value.x) or value.x, + self.max and math.max(self.max.y, value.y) or value.y, + self.max and math.max(self.max.z, value.z) or value.z + ) + else + self.valid = false + break + end + end + + self.valid = self.valid and self.min and self.max + and dfhack.maps.isValidTilePos(self.min.x, self.min.y, self.min.z) + and dfhack.maps.isValidTilePos(self.max.x, self.max.y, self.max.z) + + if not self.valid then + self.min = xyz2pos(-1, -1, -1) + self.max = xyz2pos(-1, -1, -1) + end +end + +function Box:iterate(callback) + if not self.valid then return end + for z = self.min.z, self.max.z do + for y = self.min.y, self.max.y do + for x = self.min.x, self.max.x do + callback(xyz2pos(x, y, z)) + end + end + end +end + +function Box:draw(tile_map, avoid_rect, ascii_fill) + if not self.valid or df.global.window_z < self.min.z or df.global.window_z > self.max.z then return end + local screen_min = xy2pos( + self.min.x - df.global.window_x, + self.min.y - df.global.window_y + ) + local screen_max = xy2pos( + self.max.x - df.global.window_x, + self.max.y - df.global.window_y + ) + + local draw_queue = {} + + if self.min.x == self.max.x and self.min.y == self.max.y then + -- Single point + draw_queue = { + { + pen= tile_map.pens[tile_map.getPenKey{n=true, e=true, s=true, w=true}], + rect= Rect{ x1= screen_min.x, x2= screen_min.x, y1= screen_min.y, y2= screen_min.y } + } + } + + elseif self.min.x == self.max.x then + -- Vertical line + draw_queue = { + -- Line + { + pen= tile_map.pens[tile_map.getPenKey{n=false, e=true, s=false, w=true}], + rect= Rect{ x1= screen_min.x, x2= screen_min.x, y1= screen_min.y, y2= screen_max.y } + }, + -- Top nub + { + pen= tile_map.pens[tile_map.getPenKey{n=true, e=true, s=false, w=true}], + rect= Rect{ x1= screen_min.x, x2= screen_min.x, y1= screen_min.y, y2= screen_min.y } + }, + -- Bottom nub + { + pen= tile_map.pens[tile_map.getPenKey{n=false, e=true, s=true, w=true}], + rect= Rect{ x1= screen_min.x, x2= screen_min.x, y1= screen_max.y, y2= screen_max.y } + } + } + elseif self.min.y == self.max.y then + -- Horizontal line + draw_queue = { + -- Line + { + pen= tile_map.pens[tile_map.getPenKey{n=true, e=false, s=true, w=false}], + rect= Rect{ x1= screen_min.x, x2= screen_max.x, y1= screen_min.y, y2= screen_min.y } + }, + -- Left nub + { + pen= tile_map.pens[tile_map.getPenKey{n=true, e=false, s=true, w=true}], + rect= Rect{ x1= screen_min.x, x2= screen_min.x, y1= screen_min.y, y2= screen_min.y } + }, + -- Right nub + { + pen= tile_map.pens[tile_map.getPenKey{n=true, e=true, s=true, w=false}], + rect= Rect{ x1= screen_max.x, x2= screen_max.x, y1= screen_min.y, y2= screen_min.y } + } + } + else + -- Rectangle + draw_queue = { + -- North Edge + { + pen= tile_map.pens[tile_map.getPenKey{n=true, e=false, s=false, w=false}], + rect= Rect{ x1= screen_min.x, x2= screen_max.x, y1= screen_min.y, y2= screen_min.y } + }, + -- East Edge + { + pen= tile_map.pens[tile_map.getPenKey{n=false, e=true, s=false, w=false}], + rect= Rect{ x1= screen_max.x, x2= screen_max.x, y1= screen_min.y, y2= screen_max.y } + }, + -- South Edge + { + pen= tile_map.pens[tile_map.getPenKey{n=false, e=false, s=true, w=false}], + rect= Rect{ x1= screen_min.x, x2= screen_max.x, y1= screen_max.y, y2= screen_max.y } + }, + -- West Edge + { + pen= tile_map.pens[tile_map.getPenKey{n=false, e=false, s=false, w=true}], + rect= Rect{ x1= screen_min.x, x2= screen_min.x, y1= screen_min.y, y2= screen_max.y } + }, + -- NW Corner + { + pen= tile_map.pens[tile_map.getPenKey{n=true, e=false, s=false, w=true}], + rect= Rect{ x1= screen_min.x, x2= screen_min.x, y1= screen_min.y, y2= screen_min.y } + }, + -- NE Corner + { + pen= tile_map.pens[tile_map.getPenKey{n=true, e=true, s=false, w=false}], + rect= Rect{ x1= screen_max.x, x2= screen_max.x, y1= screen_min.y, y2= screen_min.y } + }, + -- SE Corner + { + pen= tile_map.pens[tile_map.getPenKey{n=false, e=true, s=true, w=false}], + rect= Rect{ x1= screen_max.x, x2= screen_max.x, y1= screen_max.y, y2= screen_max.y } + }, + -- SW Corner + { + pen= tile_map.pens[tile_map.getPenKey{n=false, e=false, s=true, w=true}], + rect= Rect{ x1= screen_min.x, x2= screen_min.x, y1= screen_max.y, y2= screen_max.y } + }, + } + + if dfhack.screen.inGraphicsMode() or ascii_fill then + -- Fill inside + table.insert(draw_queue, 1, { + pen= tile_map.pens[tile_map.getPenKey{n=false, e=false, s=false, w=false}], + rect= Rect{ x1= screen_min.x + 1, x2= screen_max.x - 1, y1= screen_min.y + 1, y2= screen_max.y - 1 } + }) + end + end + + if avoid_rect and not dfhack.screen.inGraphicsMode() then + -- If in ASCII and an avoid_rect was specified + -- Draw the queue, avoiding the avoid_rect + drawOutsideOfScreenRectList(draw_queue, { avoid_rect }) + else + -- Draw the queue + for _,draw_rect in pairs(draw_queue) do + dfhack.screen.fillRect(draw_rect.pen, math.floor(draw_rect.rect.x1), math.floor(draw_rect.rect.y1), math.floor(draw_rect.rect.x2), math.floor(draw_rect.rect.y2), true) + end + end +end + +--================================-- +--|| BoxSelection ||-- +--================================-- +-- Allows for selecting a box + +---@class BoxTileMap +---@field getPenKey fun(nesw: { n: boolean, e: boolean, s: boolean, w: boolean }): any +---@field createPens? fun(): { key: dfhack.pen } +---@field pens? { key: dfhack.pen } +local TILE_MAP = { + getPenKey= function(nesw) + local out = 0 + for _,v in ipairs({nesw.n, nesw.e, nesw.s, nesw.w}) do + out = (out << 1) | (v and 1 or 0) + end + return out + end +} +TILE_MAP.createPens= function() + return { + [TILE_MAP.getPenKey{ n=false, e=false, s=false, w=false }] = dfhack.pen.parse{tile=dfhack.screen.findGraphicsTile("CURSORS", 1, 2), fg=COLOR_GREEN, ch='X'}, -- INSIDE + [TILE_MAP.getPenKey{ n=true, e=false, s=false, w=true }] = dfhack.pen.parse{tile=dfhack.screen.findGraphicsTile("CURSORS", 0, 1), fg=COLOR_GREEN, ch='X'}, -- NW + [TILE_MAP.getPenKey{ n=true, e=false, s=false, w=false }] = dfhack.pen.parse{tile=dfhack.screen.findGraphicsTile("CURSORS", 1, 1), fg=COLOR_GREEN, ch='X'}, -- NORTH + [TILE_MAP.getPenKey{ n=true, e=true, s=false, w=false }] = dfhack.pen.parse{tile=dfhack.screen.findGraphicsTile("CURSORS", 2, 1), fg=COLOR_GREEN, ch='X'}, -- NE + [TILE_MAP.getPenKey{ n=false, e=false, s=false, w=true }] = dfhack.pen.parse{tile=dfhack.screen.findGraphicsTile("CURSORS", 0, 2), fg=COLOR_GREEN, ch='X'}, -- WEST + [TILE_MAP.getPenKey{ n=false, e=true, s=false, w=false }] = dfhack.pen.parse{tile=dfhack.screen.findGraphicsTile("CURSORS", 2, 2), fg=COLOR_GREEN, ch='X'}, -- EAST + [TILE_MAP.getPenKey{ n=false, e=false, s=true, w=true }] = dfhack.pen.parse{tile=dfhack.screen.findGraphicsTile("CURSORS", 0, 3), fg=COLOR_GREEN, ch='X'}, -- SW + [TILE_MAP.getPenKey{ n=false, e=false, s=true, w=false }] = dfhack.pen.parse{tile=dfhack.screen.findGraphicsTile("CURSORS", 1, 3), fg=COLOR_GREEN, ch='X'}, -- SOUTH + [TILE_MAP.getPenKey{ n=false, e=true, s=true, w=false }] = dfhack.pen.parse{tile=dfhack.screen.findGraphicsTile("CURSORS", 2, 3), fg=COLOR_GREEN, ch='X'}, -- SE + [TILE_MAP.getPenKey{ n=true, e=true, s=false, w=true }] = dfhack.pen.parse{tile=dfhack.screen.findGraphicsTile("CURSORS", 3, 2), fg=COLOR_GREEN, ch='X'}, -- N_NUB + [TILE_MAP.getPenKey{ n=true, e=true, s=true, w=false }] = dfhack.pen.parse{tile=dfhack.screen.findGraphicsTile("CURSORS", 5, 1), fg=COLOR_GREEN, ch='X'}, -- E_NUB + [TILE_MAP.getPenKey{ n=true, e=false, s=true, w=true }] = dfhack.pen.parse{tile=dfhack.screen.findGraphicsTile("CURSORS", 3, 1), fg=COLOR_GREEN, ch='X'}, -- W_NUB + [TILE_MAP.getPenKey{ n=false, e=true, s=true, w=true }] = dfhack.pen.parse{tile=dfhack.screen.findGraphicsTile("CURSORS", 4, 2), fg=COLOR_GREEN, ch='X'}, -- S_NUB + [TILE_MAP.getPenKey{ n=false, e=true, s=false, w=true }] = dfhack.pen.parse{tile=dfhack.screen.findGraphicsTile("CURSORS", 3, 3), fg=COLOR_GREEN, ch='X'}, -- VERT_NS + [TILE_MAP.getPenKey{ n=true, e=false, s=true, w=false }] = dfhack.pen.parse{tile=dfhack.screen.findGraphicsTile("CURSORS", 4, 1), fg=COLOR_GREEN, ch='X'}, -- VERT_EW + [TILE_MAP.getPenKey{ n=true, e=true, s=true, w=true }] = dfhack.pen.parse{tile=dfhack.screen.findGraphicsTile("CURSORS", 4, 3), fg=COLOR_GREEN, ch='X'}, -- POINT + } +end + +---@class BoxSelection.attrs: widgets.Window.attrs +---@field tooltip_enabled? boolean, +---@field screen? gui.Screen +---@field tile_map? BoxTileMap +---@field avoid_view? gui.View|fun():gui.View +---@field on_confirm? fun(box: Box) +---@field flat boolean +---@field ascii_fill boolean + +---@class BoxSelection.attrs.partial: BoxSelection.attrs + +---@class BoxSelection: widgets.Window, BoxSelection.attrs +---@field box? Box +---@field first_point? df.coord +---@field last_point? df.coord +BoxSelection = defclass(BoxSelection, widgets.Window) +BoxSelection.ATTRS { + tooltip_enabled=true, + screen=DEFAULT_NIL, + tile_map=TILE_MAP, + avoid_view=DEFAULT_NIL, + on_confirm=DEFAULT_NIL, + flat=false, + ascii_fill=false, +} + +function BoxSelection:init() + self.frame = { w=0, h=0 } + self.box=nil + self.first_point=nil + self.last_point=nil + + if self.tile_map then + if self.tile_map.createPens then + self.tile_map.pens = self.tile_map.createPens() + end + else + error("No tile map provided") + end + + -- Set the cursor to the center of the screen + guidm.setCursorPos(guidm.Viewport.get():getCenter()) + + -- Show cursor + df.global.game.main_interface.main_designation_selected = df.main_designation_type.TOGGLE_ENGRAVING + + if self.tooltip_enabled then + if self.screen then + self.dimensions_tooltip = widgets.DimensionsTooltip{ + get_anchor_pos_fn=function() + if self.first_point and self.flat then + return xyz2pos(self.first_point.x, self.first_point.y, df.global.window_z) + end + return self.first_point + end, + } + self.screen:addviews{ + self.dimensions_tooltip + } + else + error("No screen provided to BoxSelection, unable to display DimensionsTooltip") + end + end +end + +function BoxSelection:confirm() + if self.first_point and self.last_point + and dfhack.maps.isValidTilePos(self.first_point) + and dfhack.maps.isValidTilePos(self.last_point) + then + self.box = Box{ + self.first_point, + self.last_point + } + if self.on_confirm then + self.on_confirm(self.box) + end + end +end + +function BoxSelection:clear() + self.box = nil + self.first_point = nil + self.last_point = nil +end + +function BoxSelection:onInput(keys) + if BoxSelection.super.onInput(self, keys) then + return true + end + if keys.LEAVESCREEN or keys._MOUSE_R then + if self.last_point then + self.box = nil + self.last_point = nil + return true + elseif self.first_point then + self.first_point = nil + return true + end + return false + end + + local mousePos = dfhack.gui.getMousePos(true) + local cursorPos = guidm.getCursorPos() + + if cursorPos and keys.SELECT then + cursorPos.x = math.max(math.min(cursorPos.x, df.global.world.map.x_count - 1), 0) + cursorPos.y = math.max(math.min(cursorPos.y, df.global.world.map.y_count - 1), 0) + if self.first_point and not self.last_point then + if not self.flat or cursorPos.z == self.first_point.z then + self.last_point = cursorPos + self:confirm() + end + elseif dfhack.maps.isValidTilePos(cursorPos) then + self.first_point = self.first_point or cursorPos + end + + return true + end + + local avoid_view = utils.getval(self.avoid_view) + + -- Get the position of the mouse in coordinates local to avoid_view, if it's specified + local mouseFramePos = avoid_view and avoid_view:getMouseFramePos() + + if keys._MOUSE_L and not mouseFramePos then + -- If left click and the mouse is not in the avoid_view + self.useCursor = false + if self.first_point and not self.last_point then + if not self.flat or mousePos.z == self.first_point.z then + local inBoundsMouse = xyz2pos( + math.max(math.min(mousePos.x, df.global.world.map.x_count - 1), 0), + math.max(math.min(mousePos.y, df.global.world.map.y_count - 1), 0), + mousePos.z + ) + self.last_point = inBoundsMouse + self:confirm() + end + elseif dfhack.maps.isValidTilePos(mousePos) then + self.first_point = self.first_point or mousePos + end + + return true + end + + -- Switch to the cursor if the cursor was moved (Excluding up and down a Z level) + local filteredKeys = utils.clone(keys) + filteredKeys["CURSOR_DOWN_Z"] = nil + filteredKeys["CURSOR_UP_Z"] = nil + self.useCursor = (self.useCursor or guidm.getMapKey(filteredKeys)) and df.global.d_init.feature.flags.KEYBOARD_CURSOR + + return false +end + +function BoxSelection:onRenderFrame(dc, rect) + -- Switch to cursor if the mouse is offscreen, or if it hasn't moved + self.useCursor = (self.useCursor or (self.lastMousePos and (self.lastMousePos.x < 0 or self.lastMousePos.y < 0))) + and self.lastMousePos.x == df.global.gps.mouse_x and self.lastMousePos.y == df.global.gps.mouse_y + self.lastMousePos = xy2pos(df.global.gps.mouse_x, df.global.gps.mouse_y) + + if self.tooltip_enabled and self.screen and self.dimensions_tooltip then + self.dimensions_tooltip.visible = not self.useCursor + end + + if not self.tile_map then return end + + local box = self.box + if not box then + local selectedPos = dfhack.gui.getMousePos(true) + if self.useCursor or not selectedPos then + selectedPos = guidm.getCursorPos() or xyz2pos(nil) + selectedPos.x = math.max(math.min(selectedPos.x, df.global.world.map.x_count - 1), 0) + selectedPos.y = math.max(math.min(selectedPos.y, df.global.world.map.y_count - 1), 0) + end + + if self.flat and self.first_point then + selectedPos.z = self.first_point.z + end + + local inBoundsMouse = xyz2pos( + math.max(math.min(selectedPos.x, df.global.world.map.x_count - 1), 0), + math.max(math.min(selectedPos.y, df.global.world.map.y_count - 1), 0), + selectedPos.z + ) + + box = Box { + self.first_point or selectedPos, + self.last_point or (self.first_point and inBoundsMouse or selectedPos) + } + end + + if box then + local avoid_view = utils.getval(self.avoid_view) + box:draw(self.tile_map, avoid_view and Rect(avoid_view.frame_rect), self.ascii_fill) + end + + -- Don't call super.onRenderFrame, since this widget should not be drawn +end + +function BoxSelection:hideCursor() + -- Hide cursor + df.global.game.main_interface.main_designation_selected = df.main_designation_type.NONE +end + +--================================-- +--|| SelectDialog ||-- +--================================-- +-- Popup for selecting an item from a list, with a search bar + +---@class CategoryChoice +---@field text string|widgets.LabelToken[] +---@field category string? +---@field key string? +---@field item_list (widgets.ListChoice|CategoryChoice)[] + +local ARROW = string.char(26) + +SelectDialogWindow = defclass(SelectDialogWindow, widgets.Window) + +SelectDialogWindow.ATTRS{ + prompt = "Type or select a item from this list", + base_category = "Any item", + frame={w=40, h=28}, + frame_style = gui.FRAME_PANEL, + frame_inset = 1, + frame_title = "Select Item", + item_list = DEFAULT_NIL, ---@type (widgets.ListChoice|CategoryChoice)[] + on_select = DEFAULT_NIL, + on_cancel = DEFAULT_NIL, + on_close = DEFAULT_NIL, +} + +function SelectDialogWindow:init() + self.back = widgets.HotkeyLabel{ + frame = { r = 0, b = 0 }, + auto_width = true, + visible = false, + label = "Back", + key="LEAVESCREEN", + on_activate = self:callback('onGoBack') + } + self.list = widgets.FilteredList{ + not_found_label = 'No matching items', + frame = { l = 0, r = 0, t = 4, b = 2 }, + icon_width = 2, + on_submit = self:callback('onSubmitItem'), + } + self:addviews{ + widgets.Label{ + text = { + self.prompt, '\n\n', + 'Category: ', { text = self:cb_getfield('category_str'), pen = COLOR_CYAN } + }, + text_pen = COLOR_WHITE, + frame = { l = 0, t = 0 }, + }, + self.back, + self.list, + widgets.HotkeyLabel{ + frame = { l = 0, b = 0 }, + auto_width = true, + label = "Select", + key="SELECT", + disabled = function() return not self.list:canSubmit() end, + on_activate = function() self.list:submit() end + } + } + + self:initCategory(self.base_category, self.item_list) +end + +function SelectDialogWindow:onDismiss() + if self.on_close then + self.on_close() + end +end + +function SelectDialogWindow:initCategory(name, item_list) + local choices = {} + + for _,value in pairs(item_list) do + self:addItem(choices, value) + end + + self:pushCategory(name, choices) +end + +function SelectDialogWindow:addItem(choices, item) + if not item or not item.text then return end + + if item.item_list then + table.insert(choices, { + icon = ARROW, text = item.text, key = item.key, + cb = function() self:initCategory(item.category or item.text, item.item_list) end + }) + else + table.insert(choices, { + text = item.text, + value = item.value or item.text + }) + end +end + +function SelectDialogWindow:pushCategory(name, choices) + if not self.back_stack then + self.back_stack = {} + self.back.visible = false + else + table.insert(self.back_stack, { + category_str = self.category_str, + all_choices = self.list:getChoices(), + edit_text = self.list:getFilter(), + selected = self.list:getSelected(), + }) + self.back.visible = true + end + + self.category_str = name + self.list:setChoices(choices, 1) +end + +function SelectDialogWindow:onGoBack() + local save = table.remove(self.back_stack) + self.back.visible = (#self.back_stack > 0) + + self.category_str = save.category_str + self.list:setChoices(save.all_choices) + self.list:setFilter(save.edit_text, save.selected) +end + +function SelectDialogWindow:submitItem(value) + self.parent_view:dismiss() + + if self.on_select then + self.on_select(value) + end +end + +function SelectDialogWindow:onSubmitItem(idx, item) + if item.cb then + item:cb(idx) + else + self:submitItem(item.value) + end +end + +function SelectDialogWindow:onInput(keys) + if SelectDialogWindow.super.onInput(self, keys) then + return true + end + if keys.LEAVESCREEN or keys._MOUSE_R then + self.parent_view:dismiss() + if self.on_cancel then + self.on_cancel() + end + end + return true +end + +-- Screen for the popup +SelectDialog = defclass(SelectDialog, gui.ZScreenModal) +SelectDialog.ATTRS{ + focus_path='selectdialog' +} + +function SelectDialog:init(attrs) + self.window = SelectDialogWindow(attrs) + self:addviews{ + self.window + } +end + +function SelectDialog:onDismiss() + self.window:onDismiss() +end + +--#endregion + + +--================================-- +--|| CycleLabel ||-- +--================================-- +-- More customizable CycleHotkeyLabel + +CycleLabel = defclass(CycleLabel, widgets.CycleHotkeyLabel) +CycleLabel.ATTRS{ + base_label=DEFAULT_NIL, + key_pen=DEFAULT_NIL +} + +function CycleLabel:init() + self:setOption(self.initial_option) + self:updateOptionLabel() + + local on_change_fn = self.on_change + self.on_change = function(value) + self:updateOptionLabel() + if on_change_fn then + on_change_fn(value) + end + end +end + +function CycleLabel:updateOptionLabel() + local label = self:getOptionLabel() + if type(label) ~= "table" then + label = {{ text= label }} + else + label = copyall(label) + end + if self.base_label then + if label[1] then + label[1].gap=self.option_gap + end + table.insert(label, 1, type(self.base_label) == "string" and { text= self.base_label } or self.base_label) + end + table.insert(label, 1, self.key ~= nil and {key=self.key, key_pen=self.key_pen, key_sep=self.key_sep, on_activate=self:callback('cycle')} or {}) + table.insert(label, 1, self.key_back ~= nil and {key=self.key_back, key_sep='', width=0, on_activate=self:callback('cycle', true)} or {}) + self:setText(label) +end + +--================================-- +--|| OptionsPopup ||-- +--================================-- +-- Popup for more detailed options + +OptionsPopup = defclass(OptionsPopup, widgets.Window) +OptionsPopup.ATTRS { + name = "options_popup", + frame_title = 'More Options', + frame = POPUP_UI_AREA, + frame_inset={b=1, t=1}, +} + +function OptionsPopup:init() + local optionViews = {} + local width = self.frame.w - 3 + + local makeUIChars = function(center_char1, center_char2) + return { + {218, 196, 196, 191}, + {179, center_char1, center_char2, 179}, + {192, 196, 196, 217}, + } + end + local makeUIPen = function(border_pen, center_pen) + return { + border_pen, + {border_pen, center_pen, center_pen, border_pen}, + border_pen, + } + end + + self.values = {} + local height_offset = 0 + for _,option in pairs(MORE_OPTIONS) do + self.values[option.key] = option.values[1].value + local options = {} + + local addOption = function(value, pen, char1, char2, offset) + table.insert(options, { + value=value, + label= makeInlineButtonLabelText{ + left_specs={ + chars={option.label}, + pens=pen, + pens_hover=UI_COLORS.HIGHLIGHTED, + }, + right_specs= { + chars=makeUIChars(char1, char2), + pens=makeUIPen(UI_COLORS.DESELECTED, pen), + pens_hover=makeUIPen(UI_COLORS.HIGHLIGHTED_BORDER, pen), + tileset=TILESET, + tileset_offset=offset, + tileset_stride=TILESET_STRIDE, + }, + width=width + }, + }) + end + + for _, value in pairs(option.values) do + addOption(value.value, value.pen, value.char1, value.char2, value.offset) + end + + table.insert(optionViews, + CycleLabel { + frame={l=1,t=height_offset}, + initial_option=option.values[1].value, + options=options, + on_change=function(value) self.values[option.key] = value end + } + ) + height_offset = height_offset + 3 + end + + self:addviews(optionViews) +end + +--================================-- +--|| TileConfig ||-- +--================================-- +-- Tile config options + +TileConfig = defclass(TileConfig, widgets.Widget) +TileConfig.ATTRS { + data_lists=DEFAULT_NIL, + on_change_shape=DEFAULT_NIL, + on_change_mat=DEFAULT_NIL, + on_change_stone=DEFAULT_NIL, + on_change_special=DEFAULT_NIL, + on_change_variant=DEFAULT_NIL, +} + +function TileConfig:init() + self.stone_enabled = false + self.valid_combination = true + + local function getTextPen() + return self.valid_combination and UI_COLORS.VALID_OPTION or UI_COLORS.INVALID_OPTION + end + + local function getListOther(short_list) + for i=1, #short_list do + if short_list[i].value == short_list.other.value then + return short_list.other + end + end + table.insert(short_list, short_list.other) + short_list.other.label = OTHER_LABEL_FORMAT.first..short_list.other.label..OTHER_LABEL_FORMAT.last + return short_list.other + end + + self.other_shape = getListOther(self.data_lists.short_shape_list) + self.other_mat = getListOther(self.data_lists.short_mat_list) + self.other_special = getListOther(self.data_lists.short_special_list) + + local config_btn_width = #CONFIG_BUTTON + local config_btn_l = UI_AREA.w - 2 - config_btn_width + + self:addviews { + widgets.CycleHotkeyLabel { + view_id="shape_cycle", + frame={l=1, r=config_btn_width, t=0}, + key_back='CUSTOM_SHIFT_H', + key='CUSTOM_H', + label='Shape:', + options=self.data_lists.short_shape_list, + initial_option=-1, + on_change=function(value) + self:updateValidity() + if self.on_change_shape then + self.on_change_shape(value) + end + end, + text_pen=getTextPen + }, + widgets.Divider { frame={t=2}, frame_style_l=false, frame_style_r=false, }, + widgets.CycleHotkeyLabel { + view_id="mat_cycle", + frame={l=1, r=config_btn_width, t=4}, + key_back='CUSTOM_SHIFT_J', + key='CUSTOM_J', + label='Material:', + options=self.data_lists.short_mat_list, + initial_option=-1, + on_change=function(value) + self:updateValidity() + if self.on_change_mat then + self.on_change_mat(value) + end + end, + text_pen=getTextPen + }, + widgets.HotkeyLabel { + view_id="stone_label", + frame={l=3, t=6}, + key='CUSTOM_N', + label={ text= "Stone:", pen=function() return self.stone_enabled and UI_COLORS.HIGHLIGHTED or UI_COLORS.DESELECTED end }, + enabled=function() return self.stone_enabled end, + on_activate=function() self:openStonePopup() end + }, + widgets.Divider { frame={t=8}, frame_style_l=false, frame_style_r=false, }, + widgets.CycleHotkeyLabel { + view_id="special_cycle", + frame={l=1, r=config_btn_width, t=10}, + key_back='CUSTOM_SHIFT_K', + key='CUSTOM_K', + label='Special:', + options=self.data_lists.short_special_list, + initial_option=-1, + on_change=function(value) + self:updateValidity() + if self.on_change_special then + self.on_change_special(value) + end + end, + text_pen=getTextPen + }, + widgets.CycleHotkeyLabel { + view_id="variant_cycle", + frame={l=1, t=12}, + key_back='CUSTOM_SHIFT_L', + key='CUSTOM_L', + label='Variant:', + options=self.data_lists.variant_list, + initial_option=-1, + on_change=function(value) + self:updateValidity() + if self.on_change_variant then + self.on_change_variant(value) + end + end, + text_pen=getTextPen + }, + widgets.Divider { frame={t=14}, frame_style_l=false, frame_style_r=false, }, + } + + -- Advanced config buttons + self:addviews { + -- Shape + widgets.Label { + frame={l=config_btn_l, t=0}, + text=CONFIG_BUTTON, + on_click=function() self:openShapePopup() end, + }, + -- Material + widgets.Label { + frame={l=config_btn_l, t=4}, + text=CONFIG_BUTTON, + on_click=function() self:openMaterialPopup() end, + }, + -- Special + widgets.Label { + frame={l=config_btn_l, t=10}, + text=CONFIG_BUTTON, + on_click=function() self:openSpecialPopup() end, + }, + } + + self:changeStone(-1) + self:setVisibility(self.visible) +end + +function TileConfig:openShapePopup() + SelectDialog { + frame_title = "Shape Values", + base_category = "Shape", + item_list = self.data_lists.shape_list, + on_select = function(item) + self.other_shape.label = OTHER_LABEL_FORMAT.first..item.label..OTHER_LABEL_FORMAT.last + self.other_shape.value = item.value + self.subviews.shape_cycle.option_idx=#self.subviews.shape_cycle.options + self:updateValidity() + self.on_change_shape(item.value) + end, + }:show() +end + +function TileConfig:openMaterialPopup() + SelectDialog { + frame_title = "Material Values", + base_category = "Material", + item_list = self.data_lists.mat_list, + on_select = function(item) + self.other_mat.label = OTHER_LABEL_FORMAT.first..item.label..OTHER_LABEL_FORMAT.last + self.other_mat.value = item.value + self.subviews.mat_cycle.option_idx=#self.subviews.mat_cycle.options + self:updateValidity() + self.on_change_mat(item.value) + end, + }:show() +end + +function TileConfig:openSpecialPopup() + SelectDialog { + frame_title = "Special Values", + base_category = "Special", + item_list = self.data_lists.special_list, + on_select = function(item) + self.other_special.label = OTHER_LABEL_FORMAT.first..item.label..OTHER_LABEL_FORMAT.last + self.other_special.value = item.value + self.subviews.special_cycle.option_idx=#self.subviews.special_cycle.options + self:updateValidity() + self.on_change_special(item.value) + end, + }:show() +end + +function TileConfig:openStonePopup() + SelectDialog { + frame_title = "Stone Types", + base_category = "Stone", + item_list = self.data_lists.stone_list, + on_select = function(value) self:changeStone(value) end, + }:show() +end + +function TileConfig:changeStone(stone_index) + local stone_option = self.data_lists.stone_dict[-1] + if stone_index then + stone_option = self.data_lists.stone_dict[stone_index] + self.on_change_stone(stone_option.value) + end + + local label = self.subviews.stone_label + local base_label = copyall(label.label) + base_label.key = label.key + base_label.key_sep = label.key_sep + base_label.on_activate = label.on_activate + label:setText({ + base_label, + { gap=1, text=stone_option.label, pen=stone_option.pen } + }) +end + +function TileConfig:setStoneEnabled(bool) + self.stone_enabled = bool + if not bool then + self:changeStone(nil) + end +end + +function TileConfig:setVisibility(visibility) + self.frame = visibility and { h=15 } or { h=0 } + self.visible = visibility +end + +function TileConfig:updateValidity() + local variant_value = self.subviews.variant_cycle:getOptionValue(self.subviews.variant_cycle.option_idx) + local special_value = self.subviews.special_cycle:getOptionValue(self.subviews.special_cycle.option_idx) + local mat_value = self.subviews.mat_cycle:getOptionValue(self.subviews.mat_cycle.option_idx) + local shape_value = self.subviews.shape_cycle:getOptionValue(self.subviews.shape_cycle.option_idx) + + self.valid_combination = false + for i=df.tiletype._first_item, df.tiletype._last_item do + local name = df.tiletype[i] + if name then + local tile_attrs = df.tiletype.attrs[name] + if (shape_value == df.tiletype_shape.NONE or tile_attrs.shape == shape_value) + and (mat_value == df.tiletype_material.NONE or tile_attrs.material == mat_value) + and (special_value == df.tiletype_special.NONE or tile_attrs.special == special_value) + and (variant_value == df.tiletype_variant.NONE or tile_attrs.variant == variant_value or tile_attrs.variant == df.tiletype_variant.NONE) + then + self.valid_combination = true + break + end + end + end +end + +--================================-- +--|| TiletypeWindow ||-- +--================================-- +-- Interface for editing tiles + +TiletypeWindow = defclass(TiletypeWindow, widgets.Window) +TiletypeWindow.ATTRS { + name = "tiletype_window", + frame_title="Tiletypes", + frame=UI_AREA, + frame_inset={b=1, t=1}, + screen=DEFAULT_NIL, + options_popup=DEFAULT_NIL, + data_lists=DEFAULT_NIL, +} + +function TiletypeWindow:init() + self.cur_mode="paint" + self.mode_description = "" + self.cur_shape=-1 + self.cur_mat=-1 + self.cur_special=-1 + self.cur_variant=-1 + self.first_point=nil ---@type df.coord + self.last_point=nil ---@type df.coord + + local makeUIChars = function(center_char1, center_char2) + return { + {218, 196, 196, 191}, + {179, center_char1, center_char2, 179}, + {192, 196, 196, 217}, + } + end + local makeUIPen = function(border_pen, center_pen) + return { + border_pen, + {border_pen, center_pen, center_pen, border_pen}, + border_pen + } + end + + self:addviews { + BoxSelection { + view_id="box_selection", + screen=self.screen, + avoid_view=self, + on_confirm=function() self:confirm() end, + }, + widgets.ResizingPanel { + frame={t=0}, + autoarrange_subviews=true, + autoarrange_gap=1, + subviews={ + widgets.ButtonGroup { + frame={l=1}, + button_specs={ + { + chars=makeUIChars(MODE_SETTINGS["paint"].char1, MODE_SETTINGS["paint"].char2), + pens=makeUIPen(UI_COLORS.DESELECTED_BORDER, UI_COLORS.DESELECTED), + pens_hover=makeUIPen(UI_COLORS.HIGHLIGHTED_BORDER, UI_COLORS.HIGHLIGHTED), + tileset=TILESET, + tileset_offset=MODE_SETTINGS["paint"].offset, + tileset_stride=TILESET_STRIDE, + }, + { + chars=makeUIChars(MODE_SETTINGS["replace"].char1, MODE_SETTINGS["replace"].char2), + pens=makeUIPen(UI_COLORS.DESELECTED_BORDER, UI_COLORS.DESELECTED), + pens_hover=makeUIPen(UI_COLORS.HIGHLIGHTED_BORDER, UI_COLORS.HIGHLIGHTED), + tileset=TILESET, + tileset_offset=MODE_SETTINGS["replace"].offset, + tileset_stride=TILESET_STRIDE, + }, + { + chars=makeUIChars(MODE_SETTINGS["fill"].char1, MODE_SETTINGS["fill"].char2), + pens=makeUIPen(UI_COLORS.DESELECTED_BORDER, {fg=UI_COLORS.DESELECTED,bg=UI_COLORS.DESELECTED2}), + pens_hover=makeUIPen(UI_COLORS.HIGHLIGHTED_BORDER, {fg=UI_COLORS.HIGHLIGHTED,bg=UI_COLORS.HIGHLIGHTED2}), + tileset=TILESET, + tileset_offset=MODE_SETTINGS["fill"].offset, + tileset_stride=TILESET_STRIDE, + }, + { + chars=makeUIChars(MODE_SETTINGS["remove"].char1, MODE_SETTINGS["remove"].char2), + pens=makeUIPen(UI_COLORS.DESELECTED_BORDER, UI_COLORS.DESELECTED), + pens_hover=makeUIPen(UI_COLORS.HIGHLIGHTED_BORDER, UI_COLORS.HIGHLIGHTED), + tileset=TILESET, + tileset_offset=MODE_SETTINGS["remove"].offset, + tileset_stride=TILESET_STRIDE, + }, + }, + button_specs_selected={ + { + chars=makeUIChars(MODE_SETTINGS["paint"].char1, MODE_SETTINGS["paint"].char2), + pens=makeUIPen(UI_COLORS.SELECTED_BORDER, UI_COLORS.SELECTED), + tileset=TILESET, + tileset_offset=MODE_SETTINGS["paint"].selected_offset, + tileset_stride=TILESET_STRIDE, + }, + { + chars=makeUIChars(MODE_SETTINGS["replace"].char1, MODE_SETTINGS["replace"].char2), + pens=makeUIPen(UI_COLORS.SELECTED_BORDER, UI_COLORS.SELECTED), + tileset=TILESET, + tileset_offset=MODE_SETTINGS["replace"].selected_offset, + tileset_stride=TILESET_STRIDE, + }, + { + chars=makeUIChars(MODE_SETTINGS["fill"].char1, MODE_SETTINGS["fill"].char2), + pens=makeUIPen(UI_COLORS.SELECTED_BORDER, {fg=UI_COLORS.SELECTED,bg=UI_COLORS.SELECTED2}), + tileset=TILESET, + tileset_offset=MODE_SETTINGS["fill"].selected_offset, + tileset_stride=TILESET_STRIDE, + }, + { + chars=makeUIChars(MODE_SETTINGS["remove"].char1, MODE_SETTINGS["remove"].char2), + pens=makeUIPen(UI_COLORS.SELECTED_BORDER, UI_COLORS.SELECTED2), + tileset=TILESET, + tileset_offset=MODE_SETTINGS["remove"].selected_offset, + tileset_stride=TILESET_STRIDE, + }, + }, + key_back='CUSTOM_SHIFT_M', + key='CUSTOM_M', + label='Mode:', + options=MODE_LIST, + initial_option=MODE_SETTINGS[self.cur_mode].idx, + on_change=function(value) + self:setMode(value) + end, + }, + widgets.WrappedLabel { + frame={l=1}, + text_to_wrap=function() return self.mode_description end + }, + widgets.Divider { frame={h=1}, frame_style_l=false, frame_style_r=false, }, + TileConfig { + view_id="tile_config", + data_lists=self.data_lists, + on_change_shape=function(value) + self.cur_shape = value + end, + on_change_mat=function(value) + if value == df.tiletype_material.STONE then + self.subviews.tile_config:setStoneEnabled(true) + self.subviews.tile_config:changeStone(self.cur_stone) + else + self.subviews.tile_config:setStoneEnabled(false) + end + self.cur_mat = value + end, + on_change_stone=function(value) + self.cur_stone = value + end, + on_change_special=function(value) + self.cur_special = value + end, + on_change_variant=function(value) + self.cur_variant = value + end + }, + widgets.HotkeyLabel { + frame={l=1}, + key='STRING_A059', + label='More options', + on_activate=function() + self.options_popup.visible = not self.options_popup.visible + end + } + } + }, + } + + self:setMode(self.cur_mode) +end + +function TiletypeWindow:setMode(mode) + self.cur_mode = mode + local settings = MODE_SETTINGS[mode] + self.mode_description = settings.description + self.subviews.tile_config:setVisibility(settings.config) + if self.frame_parent_rect then + self:updateLayout() + end +end + +function TiletypeWindow:confirm() + local box = self.subviews.box_selection.box + + if box then + local settings = MODE_SETTINGS[self.cur_mode] + local option_values = self.options_popup.values + + if self.cur_mode == "remove" then + ---@type TileType + local emptyTiletype = { + shape = df.tiletype_shape.EMPTY, + material = df.tiletype_material.AIR, + special = df.tiletype_special.NORMAL, + hidden = option_values.hidden, + light = option_values.light, + subterranean = option_values.subterranean, + skyview = option_values.skyview, + aquifer = option_values.aquifer, + autocorrect = option_values.autocorrect, + } + box:iterate(function(pos) + if settings.validator(pos) then + setTile(pos, emptyTiletype) + end + end) + elseif self.subviews.tile_config.valid_combination then + ---@type TileType + local tiletype = { + shape = self.cur_shape, + material = self.cur_mat, + special = self.cur_special, + variant = self.cur_variant, + hidden = option_values.hidden, + light = option_values.light, + subterranean = option_values.subterranean, + skyview = option_values.skyview, + aquifer = option_values.aquifer, + autocorrect = option_values.autocorrect, + stone_material = self.cur_stone, + } + box:iterate(function(pos) + if settings.validator(pos) then + setTile(pos, tiletype) + end + end) + end + + self.subviews.box_selection:clear() + end +end + +function TiletypeWindow:onInput(keys) + if TiletypeWindow.super.onInput(self, keys) then + return true + end + if keys.LEAVESCREEN or keys._MOUSE_R then + if self.options_popup.visible then + self.options_popup.visible = false + return true + end + return false + end + + -- send movement and pause keys through + return not (keys.D_PAUSE or guidm.getMapKey(keys)) +end + +function TiletypeWindow:onDismiss() + self.subviews.box_selection:hideCursor() +end + +--================================-- +--|| TiletypeScreen ||-- +--================================-- +-- The base UI element that contains the visual widgets + +TiletypeScreen = defclass(TiletypeScreen, gui.ZScreen) +TiletypeScreen.ATTRS { + focus_path = "tiletypes", + pass_pause = true, + pass_movement_keys = true, + unrestricted = false +} + +function TiletypeScreen:init() + self.data_lists = self:generateDataLists() + + local options_popup = OptionsPopup{ + view_id="options_popup", + visible=false + } + self:addviews { + TiletypeWindow { + view_id="main_window", + screen=self, + options_popup=options_popup, + data_lists = self.data_lists + }, + options_popup + } +end + +function TiletypeScreen:generateDataLists() + local function itemColor(name) + return name == "NONE" and UI_COLORS.VALUE_NONE or UI_COLORS.VALUE + end + + local function getEnumLists(enum, short_dict, hidden_dict) + list = {} + short_list = {} + + for i=enum._first_item, enum._last_item do + local name = enum[i] + if name then + if hidden_dict and hidden_dict[i] then + goto continue + end + local item = { label= name, value= i, pen= itemColor(name) } + table.insert(list, { text=name, value=item }) + if short_dict then + if short_dict.all or short_dict[i] then + table.insert(short_list, item) + elseif short_dict.other == i then + short_list.other = copyall(item) + end + end + end + ::continue:: + end + + return list, short_list + end + + local data_lists = {} + data_lists.shape_list, data_lists.short_shape_list = getEnumLists(df.tiletype_shape, CYCLE_VALUES.shape, not self.unrestricted and HIDDEN_VALUES.shape) + data_lists.mat_list, data_lists.short_mat_list = getEnumLists(df.tiletype_material, CYCLE_VALUES.material, not self.unrestricted and HIDDEN_VALUES.material) + data_lists.special_list, data_lists.short_special_list = getEnumLists(df.tiletype_special, CYCLE_VALUES.special, not self.unrestricted and HIDDEN_VALUES.special) + _, data_lists.variant_list = getEnumLists(df.tiletype_variant, { all = true}) + + data_lists.stone_list = { { text = "none", value = -1 } } + data_lists.stone_dict = { [-1] = { label= "NONE", value= -1, pen= itemColor("NONE") } } + for i,mat in ipairs(df.global.world.raws.inorganics.all) do + if mat and mat.material + and not mat.flags[df.inorganic_flags.SOIL_ANY] + and not mat.material.flags[df.material_flags.IS_METAL] + and (self.unrestricted or not mat.flags[df.inorganic_flags.GENERATED]) + then + local state = mat.material.heat.melting_point <= 10015 and 1 or 0 + local name = mat.material.state_name[state]:gsub('^frozen ',''):gsub('^molten ',''):gsub('^condensed ','') + if mat.flags[df.inorganic_flags.GENERATED] then + -- Position 2 so that it is located immediately after "none" + if not data_lists.stone_list[2].item_list then + table.insert(data_lists.stone_list, 2, { text = "generated materials", category = "Generated", item_list = {} }) + end + table.insert(data_lists.stone_list[2].item_list, { text = name, value = i }) + else + table.insert(data_lists.stone_list, { text = name, value = i }) + end + data_lists.stone_dict[i] = { label= mat.id, value= i, pen= itemColor(mat.id) } + end + end + + return data_lists +end + +function TiletypeScreen:onDismiss() + view = nil + for _,value in pairs(self.subviews) do + if value.onDismiss then + value:onDismiss() + end + end +end + +--#endregion + +function main(args) + local opts = {} + local positionals = argparse.processArgsGetopt(args, { + { 'f', 'unrestricted', handler = function() opts.unrestricted = true end }, + }) + + if not dfhack.isMapLoaded() then + qerror("This script requires a map to be loaded") + end + + view = view and view:raise() or TiletypeScreen{ unrestricted = opts.unrestricted }:show() +end + +if not dfhack_flags.module then + main({...}) +end diff --git a/gui/unit-info-viewer.lua b/gui/unit-info-viewer.lua index 6ced7a6f9f..db1635a0a6 100644 --- a/gui/unit-info-viewer.lua +++ b/gui/unit-info-viewer.lua @@ -1,194 +1,9 @@ --- unit-info-viewer.lua --- Displays age, birth, maxage, shearing, milking, grazing, egg laying, body size, and death info about a unit. --- version 1.04 --- original author: Kurik Amudnil --- edited by expwnent ---[====[ - -gui/unit-info-viewer -==================== -Displays age, birth, maxage, shearing, milking, grazing, egg laying, body size, -and death info about a unit. - -]====] ---@ module = true -local gui = require 'gui' -local widgets =require 'gui.widgets' -local utils = require 'utils' - -local DEBUG = false -if DEBUG then print('-----') end - -local pens = { - BLACK = dfhack.pen.parse{fg=COLOR_BLACK,bg=0}, - BLUE = dfhack.pen.parse{fg=COLOR_BLUE,bg=0}, - GREEN = dfhack.pen.parse{fg=COLOR_GREEN,bg=0}, - CYAN = dfhack.pen.parse{fg=COLOR_CYAN,bg=0}, - RED = dfhack.pen.parse{fg=COLOR_RED,bg=0}, - MAGENTA = dfhack.pen.parse{fg=COLOR_MAGENTA,bg=0}, - BROWN = dfhack.pen.parse{fg=COLOR_BROWN,bg=0}, - GREY = dfhack.pen.parse{fg=COLOR_GREY,bg=0}, - DARKGREY = dfhack.pen.parse{fg=COLOR_DARKGREY,bg=0}, - LIGHTBLUE = dfhack.pen.parse{fg=COLOR_LIGHTBLUE,bg=0}, - LIGHTGREEN = dfhack.pen.parse{fg=COLOR_LIGHTGREEN,bg=0}, - LIGHTCYAN = dfhack.pen.parse{fg=COLOR_LIGHTCYAN,bg=0}, - LIGHTRED = dfhack.pen.parse{fg=COLOR_LIGHTRED,bg=0}, - LIGHTMAGENTA = dfhack.pen.parse{fg=COLOR_LIGHTMAGENTA,bg=0}, - YELLOW = dfhack.pen.parse{fg=COLOR_YELLOW,bg=0}, - WHITE = dfhack.pen.parse{fg=COLOR_WHITE,bg=0}, -} - -function getUnit_byID(id) -- get unit by id from units.all via binsearch - if type(id) == 'number' then - -- (vector,key,field,cmpfun,min,max) { item/nil , found true/false , idx/insert at } - return utils.binsearch(df.global.world.units.all,id,'id') - end -end - -function getUnit_byVS(silent) -- by view screen mode - silent = silent or false - -- if not world loaded, return nil ? - local u,tmp -- u: the unit to return ; tmp: temporary for intermediate tests/return values - local v = dfhack.gui.getCurViewscreen() - u = dfhack.gui.getSelectedUnit(true) -- supports gui scripts/plugin that provide a hook for getSelectedUnit() - if u then - return u - -- else: contexts not currently supported by dfhack.gui.getSelectedUnit() - elseif df.viewscreen_dwarfmodest:is_instance(v) then - tmp = df.global.plotinfo.main.mode - if tmp == 17 or tmp == 42 or tmp == 43 then - -- context: @dwarfmode/QueryBuiding/Some/Cage -- (q)uery cage - -- context: @dwarfmode/ZonesPenInfo/AssignUnit -- i (zone) -> pe(N) - -- context: @dwarfmode/ZonesPitInfo -- i (zone) -> (P)it - u = df.global.ui_building_assign_units[df.global.ui_building_item_cursor] - elseif tmp == 49 and df.global.plotinfo.burrows.in_add_units_mode then - -- @dwarfmode/Burrows/AddUnits - u = df.global.plotinfo.burrows.list_units[ df.global.plotinfo.burrows.unit_cursor_pos ] - - elseif df.global.plotinfo.follow_unit ~= -1 then - -- context: follow unit mode - u = getUnit_byID(df.global.plotinfo.follow_unit) - end -- end viewscreen_dwarfmodest - elseif df.viewscreen_petst:is_instance(v) then - -- context: @pet/List/Unit -- z (status) -> animals - if v.mode == 0 then -- List - if not v.is_vermin[v.cursor] then - u = v.animal[v.cursor].unit - end - --elseif v.mode = 1 then -- training knowledge (no unit reference) - elseif v.mode == 2 then -- select trainer - u = v.trainer_unit[v.trainer_cursor] - end - elseif df.viewscreen_layer_workshop_profilest:is_instance(v) then - -- context: @layer_workshop_profile/Unit -- (q)uery workshop -> (P)rofile -- df.global.plotinfo.main.mode == 17 - u = v.workers[v.layer_objects[0].cursor] - elseif df.viewscreen_layer_overall_healthst:is_instance(v) then - -- context @layer_overall_health/Units -- z -> health - u = v.unit[v.layer_objects[0].cursor] - elseif df.viewscreen_layer_militaryst:is_instance(v) then - local PG_ASSIGNMENTS = 0 - local PG_EQUIPMENT = 2 - local TB_POSITIONS = 1 - local TB_CANDIDATES = 2 - -- layer_objects[0: squads list; 1: positions list; 2: candidates list] - -- page 0:positions/assignments 1:alerts 2:equipment 3:uniforms 4:supplies 5:ammunition - if v.page == PG_ASSIGNMENTS and v.layer_objects[TB_CANDIDATES].enabled and v.layer_objects[TB_CANDIDATES].active then - -- context: @layer_military/Positions/Position/Candidates -- m -> Candidates - u = v.positions.candidates[v.layer_objects[TB_CANDIDATES].cursor] - elseif v.page == PG_ASSIGNMENTS and v.layer_objects[TB_POSITIONS].enabled and v.layer_objects[TB_POSITIONS].active then - -- context: @layer_military/Positions/Position -- m -> Positions - u = v.positions.assigned[v.layer_objects[TB_POSITIONS].cursor] - elseif v.page == PG_EQUIPMENT and v.layer_objects[TB_POSITIONS].enabled and v.layer_objects[TB_POSITIONS].active then - -- context: @layer_military/Equip/Customize/View/Position -- m -> (e)quip -> Positions - -- context: @layer_military/Equip/Uniform/Positions -- m -> (e)quip -> assign (U)niforms -> Positions - u = v.equip.units[v.layer_objects[TB_POSITIONS].cursor] - end - elseif df.viewscreen_layer_noblelistst:is_instance(v) then - if v.mode == 0 then - -- context: @layer_noblelist/List -- (n)obles - u = v.info[v.layer_objects[v.mode].cursor].unit - elseif v.mode == 1 then - -- context: @layer_noblelist/Appoint -- (n)obles -> (r)eplace - u = v.candidates[v.layer_objects[v.mode].cursor].unit - end - elseif df.viewscreen_unitst:is_instance(v) then - -- @unit -- (v)unit -> z ; loo(k) -> enter ; (n)obles -> enter ; others - u = v.unit - elseif df.viewscreen_customize_unitst:is_instance(v) then - -- @customize_unit -- @unit -> y - u = v.unit - elseif df.viewscreen_layer_unit_healthst:is_instance(v) then - -- @layer_unit_health -- @unit -> h ; @layer_overall_health/Units -> enter - if df.viewscreen_layer_overall_healthst:is_instance(v.parent) then - -- context @layer_overall_health/Units -- z (status)-> health - u = v.parent.unit[v.parent.layer_objects[0].cursor] - elseif df.viewscreen_unitst:is_instance(v.parent) then - -- @unit -- (v)unit -> z ; loo(k) -> enter ; (n)obles -> enter ; others - u = v.parent.unit - end - elseif df.viewscreen_textviewerst:is_instance(v) then - -- @textviewer -- @unit -> enter (thoughts and preferences) - if df.viewscreen_unitst:is_instance(v.parent) then - -- @unit -- @unit -> enter (thoughts and preferences) - u = v.parent.unit - elseif df.viewscreen_itemst:is_instance(v.parent) then - tmp = v.parent.entry_ref[v.parent.cursor_pos] - if df.general_ref_unit:is_instance(tmp) then -- general_ref_unit and derived ; general_ref_contains_unitst ; others? - u = getUnit_byID(tmp.unit_id) - end - elseif df.viewscreen_dwarfmodest:is_instance(v.parent) then - tmp = df.global.plotinfo.main.mode - if tmp == 24 then -- (v)iew units {g,i,p,w} -> z (thoughts and preferences) - -- context: @dwarfmode/ViewUnits/... - --if df.global.ui_selected_unit > -1 then -- -1 = 'no units nearby' - u = df.global.world.units.active[df.global.ui_selected_unit] - --end - elseif tmp == 25 then -- loo(k) unit -> enter (thoughs and preferences) - -- context: @dwarfmode/LookAround/Unit - tmp = df.global.ui_look_list.items[df.global.ui_look_cursor] - if tmp.type == 2 then -- 0:item 1:terrain >>2: unit<< 3:building 4:colony/vermin 7:spatter - u = tmp.data.Unit - end - end - elseif df.viewscreen_unitlistst:is_instance(v.parent) then -- (u)nit list -> (v)iew unit (not citizen) - -- context: @unitlist/Citizens ; @unitlist/Livestock ; @unitlist/Others ; @unitlist/Dead - u = v.parent.units[v.parent.page][ v.parent.cursor_pos[v.parent.page] ] - end - end -- switch viewscreen - if not u and not silent then - dfhack.printerr('No unit is selected in the UI or context not supported.') - end - return u -end -- getUnit_byVS() - ---http://lua-users.org/wiki/StringRecipes ---------- -function str2FirstUpper(str) - return str:gsub("^%l", string.upper) -end +--@module = true --------------------------------------------------- ---http://lua-users.org/wiki/StringRecipes ---------- -local function tchelper(first, rest) - return first:upper()..rest:lower() -end +local gui = require('gui') +local widgets = require('gui.widgets') --- Add extra characters to the pattern if you need to. _ and ' are --- found in the middle of identifiers and English words. --- We must also put %w_' into [%w_'] to make it handle normal stuff --- and extra stuff the same. --- This also turns hex numbers into, eg. 0Xa7d4 -function str2TitleCase(str) - return str:gsub("(%a)([%w_']*)", tchelper) -end - --------------------------------------------------- ---isBlank suggestion by http://stackoverflow.com/a/10330861 -function isBlank(x) - x = tostring(x) or "" - -- returns (not not match_begin), _ = match_end => not not true , _ => true - -- returns not not nil => false (no match) - return not not x:find("^%s*$") -end +local skills_progress = reqscript('internal/unit-info-viewer/skills-progress') -------------------------------------------------- ---------------------- Time ---------------------- @@ -202,592 +17,542 @@ local TU_PER_MONTH = TU_PER_DAY * 28 local TU_PER_YEAR = TU_PER_MONTH * 12 local MONTHS = { - 'Granite', - 'Slate', - 'Felsite', - 'Hematite', - 'Malachite', - 'Galena', - 'Limestone', - 'Sandstone', - 'Timber', - 'Moonstone', - 'Opal', - 'Obsidian', + 'Granite', + 'Slate', + 'Felsite', + 'Hematite', + 'Malachite', + 'Galena', + 'Limestone', + 'Sandstone', + 'Timber', + 'Moonstone', + 'Opal', + 'Obsidian', } Time = defclass(Time) function Time:init(args) - self.year = args.year or 0 - self.ticks = args.ticks or 0 + self.year = args.year or 0 + self.ticks = args.ticks or 0 end + function Time:getDays() -- >>float<< Days as age (including years) - return self.year * 336 + (self.ticks / TU_PER_DAY) + return self.year * 336 + (self.ticks / TU_PER_DAY) end + function Time:getMonths() -- >>int<< Months as age (not including years) - return math.floor (self.ticks / TU_PER_MONTH) + return self.ticks // TU_PER_MONTH end + function Time:getMonthStr() -- Month as date - return MONTHS[self:getMonths()+1] or 'error' + return MONTHS[self:getMonths() + 1] or 'error' end + function Time:getDayStr() -- Day as date - local d = math.floor ( (self.ticks % TU_PER_MONTH) / TU_PER_DAY ) + 1 - if d == 11 or d == 12 or d == 13 then - d = tostring(d)..'th' - elseif d % 10 == 1 then - d = tostring(d)..'st' - elseif d % 10 == 2 then - d = tostring(d)..'nd' - elseif d % 10 == 3 then - d = tostring(d)..'rd' - else - d = tostring(d)..'th' - end - return d + local d = ((self.ticks % TU_PER_MONTH) // TU_PER_DAY) + 1 + if d == 11 or d == 12 or d == 13 then + d = tostring(d) .. 'th' + elseif d % 10 == 1 then + d = tostring(d) .. 'st' + elseif d % 10 == 2 then + d = tostring(d) .. 'nd' + elseif d % 10 == 3 then + d = tostring(d) .. 'rd' + else + d = tostring(d) .. 'th' + end + return d end + --function Time:__add() --end function Time:__sub(other) - if DEBUG then print(self.year,self.ticks) end - if DEBUG then print(other.year,other.ticks) end - if self.ticks < other.ticks then - return Time{ year = (self.year - other.year - 1) , ticks = (TU_PER_YEAR + self.ticks - other.ticks) } - else - return Time{ year = (self.year - other.year) , ticks = (self.ticks - other.ticks) } - end + if self.ticks < other.ticks then + return Time{year=(self.year-other.year-1), ticks=(TU_PER_YEAR+self.ticks-other.ticks)} + else + return Time{year=(self.year-other.year), ticks=(self.ticks-other.ticks)} + end end --------------------------------------------------- --------------------------------------------------- -------------------------------------------------- --------------------- Identity -------------------- -------------------------------------------------- -local SINGULAR = 0 + +-- used in getting race/caste description strings local PLURAL = 1 ---local POSSESSIVE = 2 local PRONOUNS = { - [0]='She', - [1]='He', - [2]='It', + [df.pronoun_type.she] = 'She', + [df.pronoun_type.he] = 'He', + [df.pronoun_type.it] = 'It', } -local BABY = 0 -local CHILD = 1 -local ADULT = 2 - -local TRAINING_LEVELS = { - [0] = ' (Semi-Wild)', -- Semi-wild - ' (Trained)', -- Trained - ' (-Trained-)', -- Well-trained - ' (+Trained+)', -- Skillfully trained - ' (*Trained*)', -- Expertly trained - ' ('..string.char(240)..'Trained'..string.char(240)..')', -- Exceptionally trained - ' ('..string.char(15)..'Trained'..string.char(15)..')', -- Masterully Trained - ' (Tame)', -- Domesticated - '', -- undefined - '', -- wild/untameable + +local function get_pronoun(unit) + return PRONOUNS[unit.sex] or 'It' +end + +local GHOST_TYPES = { + [0] = 'A murderous ghost.', + 'A sadistic ghost.', + 'A secretive ghost.', + 'An energetic poltergeist.', + 'An angry ghost.', + 'A violent ghost.', + 'A moaning spirit returned from the dead. It will generally trouble one unfortunate at a time.', + 'A howling spirit. The ceaseless noise is making sleep difficult.', + 'A troublesome poltergeist.', + 'A restless haunt, generally troubling past acquaintances and relatives.', + 'A forlorn haunt, seeking out known locations or drifting around the place of death.', } +local function get_ghost_type(unit) + return GHOST_TYPES[unit.ghost_info.type] or 'A mysterious ghost.' +end + +-- non-local since it is used by deathcause DEATH_TYPES = { - [0] = ' died of old age', -- OLD_AGE - ' starved to death', -- HUNGER - ' died of dehydration', -- THIRST - ' was shot and killed', -- SHOT - ' bled to death', -- BLEED - ' drowned', -- DROWN - ' suffocated', -- SUFFOCATE - ' was struck down', -- STRUCK_DOWN - ' was scuttled', -- SCUTTLE - " didn't survive a collision", -- COLLISION - ' took a magma bath', -- MAGMA - ' took a magma shower', -- MAGMA_MIST - ' was incinerated by dragon fire', -- DRAGONFIRE - ' was killed by fire', -- FIRE - ' experienced death by SCALD', -- SCALD - ' was crushed by cavein', -- CAVEIN - ' was smashed by a drawbridge', -- DRAWBRIDGE - ' was killed by falling rocks', -- FALLING_ROCKS - ' experienced death by CHASM', -- CHASM - ' experienced death by CAGE', -- CAGE - ' was murdered', -- MURDER - ' was killed by a trap', -- TRAP - ' vanished', -- VANISH - ' experienced death by QUIT', -- QUIT - ' experienced death by ABANDON', -- ABANDON - ' suffered heat stroke', -- HEAT - ' died of hypothermia', -- COLD - ' experienced death by SPIKE', -- SPIKE - ' experienced death by ENCASE_LAVA', -- ENCASE_LAVA - ' experienced death by ENCASE_MAGMA', -- ENCASE_MAGMA - ' was preserved in ice', -- ENCASE_ICE - ' became headless', -- BEHEAD - ' was crucified', -- CRUCIFY - ' experienced death by BURY_ALIVE', -- BURY_ALIVE - ' experienced death by DROWN_ALT', -- DROWN_ALT - ' experienced death by BURN_ALIVE', -- BURN_ALIVE - ' experienced death by FEED_TO_BEASTS', -- FEED_TO_BEASTS - ' experienced death by HACK_TO_PIECES', -- HACK_TO_PIECES - ' choked on air', -- LEAVE_OUT_IN_AIR - ' experienced death by BOIL', -- BOIL - ' melted', -- MELT - ' experienced death by CONDENSE', -- CONDENSE - ' experienced death by SOLIDIFY', -- SOLIDIFY - ' succumbed to infection', -- INFECTION - "'s ghost was put to rest with a memorial", -- MEMORIALIZE - ' scared to death', -- SCARE - ' experienced death by DARKNESS', -- DARKNESS - ' experienced death by COLLAPSE', -- COLLAPSE - ' was drained of blood', -- DRAIN_BLOOD - ' was slaughtered', -- SLAUGHTER - ' became roadkill', -- VEHICLE - ' killed by a falling object', -- FALLING_OBJECT + [0] = ' died of old age', -- OLD_AGE + ' starved to death', -- HUNGER + ' died of dehydration', -- THIRST + ' was shot and killed', -- SHOT + ' bled to death', -- BLEED + ' drowned', -- DROWN + ' suffocated', -- SUFFOCATE + ' was struck down', -- STRUCK_DOWN + ' was scuttled', -- SCUTTLE + " didn't survive a collision", -- COLLISION + ' took a magma bath', -- MAGMA + ' took a magma shower', -- MAGMA_MIST + ' was incinerated by dragon fire', -- DRAGONFIRE + ' was killed by fire', -- FIRE + ' experienced death by SCALD', -- SCALD + ' was crushed by cavein', -- CAVEIN + ' was smashed by a drawbridge', -- DRAWBRIDGE + ' was killed by falling rocks', -- FALLING_ROCKS + ' experienced death by CHASM', -- CHASM + ' experienced death by CAGE', -- CAGE + ' was murdered', -- MURDER + ' was killed by a trap', -- TRAP + ' vanished', -- VANISH + ' experienced death by QUIT', -- QUIT + ' experienced death by ABANDON', -- ABANDON + ' suffered heat stroke', -- HEAT + ' died of hypothermia', -- COLD + ' experienced death by SPIKE', -- SPIKE + ' experienced death by ENCASE_LAVA', -- ENCASE_LAVA + ' experienced death by ENCASE_MAGMA', -- ENCASE_MAGMA + ' was preserved in ice', -- ENCASE_ICE + ' became headless', -- BEHEAD + ' was crucified', -- CRUCIFY + ' experienced death by BURY_ALIVE', -- BURY_ALIVE + ' experienced death by DROWN_ALT', -- DROWN_ALT + ' experienced death by BURN_ALIVE', -- BURN_ALIVE + ' experienced death by FEED_TO_BEASTS', -- FEED_TO_BEASTS + ' experienced death by HACK_TO_PIECES', -- HACK_TO_PIECES + ' choked on air', -- LEAVE_OUT_IN_AIR + ' experienced death by BOIL', -- BOIL + ' melted', -- MELT + ' experienced death by CONDENSE', -- CONDENSE + ' experienced death by SOLIDIFY', -- SOLIDIFY + ' succumbed to infection', -- INFECTION + "'s ghost was put to rest with a memorial", -- MEMORIALIZE + ' scared to death', -- SCARE + ' experienced death by DARKNESS', -- DARKNESS + ' experienced death by COLLAPSE', -- COLLAPSE + ' was drained of blood', -- DRAIN_BLOOD + ' was slaughtered', -- SLAUGHTER + ' became roadkill', -- VEHICLE + ' killed by a falling object', -- FALLING_OBJECT } ---GHOST_TYPES[unit.ghost_info.type].." This spirit has not been properly memorialized or buried." -local GHOST_TYPES = { - [0]="A murderous ghost.", - "A sadistic ghost.", - "A secretive ghost.", - "An energetic poltergeist.", - "An angry ghost.", - "A violent ghost.", - "A moaning spirit returned from the dead. It will generally trouble one unfortunate at a time.", - "A howling spirit. The ceaseless noise is making sleep difficult.", - "A troublesome poltergeist.", - "A restless haunt, generally troubling past acquaintances and relatives.", - "A forlorn haunt, seeking out known locations or drifting around the place of death.", -} +local function get_death_type(death_cause) + return DEATH_TYPES[death_cause] or ' died of unknown causes' +end +local function get_creature_data(unit) + return df.global.world.raws.creatures.all[unit.race] +end -Identity = defclass(Identity) -function Identity:init(args) - local u = args.unit - self.ident = dfhack.units.getIdentity(u) - - self.unit = u - self.name = dfhack.TranslateName( dfhack.units.getVisibleName(u) ) - self.name_en = dfhack.TranslateName( dfhack.units.getVisibleName(u) , true) - self.raw_prof = dfhack.units.getProfessionName(u) - self.pronoun = PRONOUNS[u.sex] or 'It' - - if self.ident then - self.birth_date = Time{year = self.ident.birth_year, ticks = self.ident.birth_second} - self.race_id = self.ident.race - self.caste_id = self.ident.caste - if self.ident.histfig_id > -1 then - self.hf_id = self.ident.histfig_id - end - else - self.birth_date = Time{year = self.unit.birth_year, ticks = self.unit.birth_time} - self.race_id = u.race - self.caste_id = u.caste - if u.hist_figure_id > -1 then - self.hf_id = u.hist_figure_id - end - end - self.race = df.global.world.raws.creatures.all[self.race_id] - self.caste = self.race.caste[self.caste_id] - - self.isCivCitizen = (df.global.plotinfo.civ_id == u.civ_id) - self.isStray = u.flags1.tame --self.isCivCitizen and not u.flags1.merchant - self.cur_date = Time{year = df.global.cur_year, ticks = df.global.cur_year_tick} - - - ------------ death ------------ - self.dead = u.flags2.killed - self.ghostly = u.flags3.ghostly - self.undead = u.enemy.undead - - if self.dead and self.hf_id then -- dead-dead not undead-dead - local events = df.global.world.history.events2 - local e - for idx = #events - 1,0,-1 do - e = events[idx] - if df.history_event_hist_figure_diedst:is_instance(e) and e.victim_hf == self.hf_id then - self.death_event = e - break - end - end - end - if u.counters.death_id > -1 then -- if undead/ghostly dead or dead-dead - self.incident = df.global.world.incidents.all[u.counters.death_id] - if not self.incident.flags.discovered then - self.missing = true - end - end - -- slaughtered? - if self.death_event then - self.death_date = Time{year = self.death_event.year, ticks = self.death_event.seconds} - elseif self.incident then - self.death_date = Time{year = self.incident.event_year, ticks = self.incident.event_time} - end - -- age now or age death? - if self.dead and self.death_date then -- if cursed with no age? -- if hacked a ressurection, such that they aren't dead anymore, don't use the death date - self.age_time = self.death_date - self.birth_date - else - self.age_time = self.cur_date - self.birth_date - end - if DEBUG then print( self.age_time.year,self.age_time.ticks,self.age_time:getMonths() ) end - ---------- ---------- ---------- - - - ---------- caste_name ---------- - self.caste_name = {} - if isBlank(self.caste.caste_name[SINGULAR]) then - self.caste_name[SINGULAR] = self.race.name[SINGULAR] - else - self.caste_name[SINGULAR] = self.caste.caste_name[SINGULAR] - end - if isBlank(self.caste.caste_name[PLURAL]) then - self.caste_name[PLURAL] = self.race.name[PLURAL] - else - self.caste_name[PLURAL] = self.caste.caste_name[PLURAL] - end - ---------- ---------- ---------- - - --------- growth_status --------- - -- 'baby_age' is the age the baby becomes a child - -- 'child_age' is the age the child becomes an adult - if self.age_time.year >= self.caste.misc.child_age then -- has child come of age becoming adult? - self.growth_status = ADULT - elseif self.age_time.year >= self.caste.misc.baby_age then -- has baby come of age becoming child? - self.growth_status = CHILD - else - self.growth_status = BABY - end - ---------- ---------- ---------- - - -------- aged_caste_name -------- - local caste_name, race_name - if self.growth_status == ADULT then - caste_name = self.caste.caste_name[SINGULAR] - race_name = self.race.name[SINGULAR] - elseif self.growth_status == CHILD then - caste_name = self.caste.child_name[SINGULAR] - race_name = self.race.general_child_name[SINGULAR] - else --if self.growth_status == BABY then - caste_name = self.caste.baby_name[SINGULAR] - race_name = self.race.general_baby_name[SINGULAR] - end - self.aged_caste_name = {} - if isBlank(caste_name[SINGULAR]) then - self.aged_caste_name[SINGULAR] = race_name[SINGULAR] - else - self.aged_caste_name[SINGULAR] = caste_name[SINGULAR] - end - if isBlank(caste_name[PLURAL]) then - self.aged_caste_name[PLURAL] = race_name[PLURAL] - else - self.aged_caste_name[PLURAL] = caste_name[PLURAL] - end - ---------- ---------- ---------- - - ----- Profession adjustment ----- - local prof = self.raw_prof - if self.undead then - prof = str2TitleCase( self.caste_name[SINGULAR] ) - if isBlank(u.enemy.undead.undead_name) then - prof = prof..' Corpse' - else - prof = u.enemy.undead.undead_name -- a reanimated body part will use this string instead - end - end - --[[ - if self.ghostly then - prof = 'Ghostly '..prof - end - --]] - if u.curse.name_visible and not isBlank(u.curse.name) then - prof = prof..' '..u.curse.name - end - if isBlank(self.name) then - if self.isStray then - prof = 'Stray '..prof --..TRAINING_LEVELS[u.training_level] - end - end - self.prof = prof - ---------- ---------- ---------- +local function get_name_chunk(unit) + return { + text=dfhack.units.getReadableName(unit, true), + pen=dfhack.units.getProfessionColor(unit) + } +end + +local function get_translated_name_chunk(unit) + local tname = dfhack.translation.translateName(dfhack.units.getVisibleName(unit), true) + if #tname == 0 then return '' end + return ('"%s"'):format(tname) +end + +local function get_description_chunk(unit) + local desc = dfhack.units.getCasteRaw(unit).description + if #desc == 0 then return end + return {text=desc, pen=COLOR_WHITE} +end + +-- dead-dead not undead-dead +local function get_death_event(unit) + if not dfhack.units.isKilled(unit) or unit.hist_figure_id == -1 then return end + local events = df.global.world.history.events2 + for idx = #events - 1, 0, -1 do + local e = events[idx] + if df.history_event_hist_figure_diedst:is_instance(e) and e.victim_hf == unit.hist_figure_id then + return e + end + end +end + +-- if undead/ghostly dead or dead-dead +local function get_death_incident(unit) + if unit.counters.death_id > -1 then + return df.global.world.incidents.all[unit.counters.death_id] + end +end + +local function get_age_chunk(unit) + if not dfhack.units.isAlive(unit) then return end + + local ident = dfhack.units.getIdentity(unit) + local birth_date = ident and Time{year=ident.birth_year, ticks=ident.birth_second} or + Time{year=unit.birth_year, ticks=unit.birth_time} + + local death_date + local event = get_death_event(unit) + if event then + death_date = Time{year=e.year, ticks=e.seconds} + end + local incident = get_death_incident(unit) + if not death_date and incident then + death_date = Time{year=incident.event_year, ticks=incident.event_time} + end + + local age + if death_date then + age = death_date - birth_date + else + local cur_date = Time{year=df.global.cur_year, ticks=df.global.cur_year_tick} + age = cur_date - birth_date + end + + local age_str + if age.year > 1 then + age_str = tostring(age.year) .. ' years old' + elseif age.year > 0 then + age_str = '1 year old' + else + local age_m = age:getMonths() + if age_m > 1 then + age_str = tostring(age_m) .. ' months old' + elseif age_m > 0 then + age_str = '1 month old' + else + age_str = 'a newborn' + end + end + + local blurb = ('%s is %s, born'):format(get_pronoun(unit), age_str) + + if birth_date.year < 0 then + blurb = blurb .. ' before the dawn of time.' + elseif birth_date.ticks < 0 then + blurb = ('%s in the year %d.'):format(blurb, birth_date.year) + else + blurb = ('%s on the %s of %s in the year %d.'):format(blurb, + birth_date:getDayStr(), birth_date:getMonthStr(), birth_date.year) + end + + return {text=blurb, pen=COLOR_YELLOW} +end + +local function get_max_age_chunk(unit) + if not dfhack.units.isAlive(unit) then return end + local caste = dfhack.units.getCasteRaw(unit) + local blurb + if caste.misc.maxage_min == -1 then + blurb = ' only die of unnatural causes.' + else + local avg_age = math.floor((caste.misc.maxage_max + caste.misc.maxage_min) // 2) + if avg_age == 0 then + blurb = ' usually die at a very young age.' + elseif avg_age == 1 then + blurb = ' live about 1 year.' + else + blurb = ' live about ' .. tostring(avg_age) .. ' years.' + end + end + blurb = caste.caste_name[PLURAL]:gsub("^%l", string.upper) .. blurb + return {text=blurb, pen=COLOR_DARKGREY} +end + +local function get_ghostly_chunk(unit) + if not dfhack.units.isGhost(unit) then return end + -- TODO: Arose in curse_year curse_time + local blurb = get_ghost_type(unit) .. + " This spirit has not been properly memorialized or buried." + return {text=blurb, pen=COLOR_LIGHTMAGENTA} +end + +local function get_dead_str(unit) + local incident = get_death_incident(unit) + if incident and incident.missing then + return ' is missing.', COLOR_WHITE + end + + local event = get_death_event(unit) + if event then + --str = "The Caste_name Unit_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.bow_item_subtype].name}" if e.weapon.bow_item_type == :WEAPON + return get_death_type(event.death_cause) .. PERIOD, COLOR_MAGENTA + elseif incident then + --str = "The #{u.race_tg.name[0]}" + --str << " #{u.name}" if u.name.has_name + --str << " died" + --str << " in year #{incident.event_year}" if incident + --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 + return get_death_type(incident.death_cause) .. PERIOD, COLOR_MAGENTA + elseif dfhack.units.isMarkedForSlaughter(unit) and dfhack.units.isKilled(unit) then + return ' was slaughtered.', COLOR_MAGENTA + elseif dfhack.units.isUndead(unit) then + return ' is undead.', COLOR_GREY + else + return ' is dead.', COLOR_MAGENTA + end +end + +local function get_dead_chunk(unit) + if dfhack.units.isAlive(unit) then return end + local str, pen = get_dead_str(unit) + return {text=dfhack.units.getReadableName(unit)..str, pen=pen} +end + +-- the metrics of the universe +local ELEPHANT_SIZE = 500000 +local DWARF_SIZE = 6000 +local CAT_SIZE = 500 + +local function get_conceivable_comparison(unit) + --[[ the objective here is to get a (resaonably) small number to help concieve + how large a thing is. "83 dwarves" doesn't really help convey the size of an + elephant much better than 5m cc, so at certain breakpoints we will use + different animals --]] + local size = unit.body.size_info.size_cur + local comparison_name, comparison_name_plural, comparison_size + if size > DWARF_SIZE*20 and get_creature_data(unit).creature_id ~= "ELEPHANT" then + comparison_name, comparison_name_plural, comparison_size = 'elephant', 'elephants', ELEPHANT_SIZE + elseif size <= DWARF_SIZE*0.25 and get_creature_data(unit).creature_id ~= "CAT" then + comparison_name, comparison_name_plural, comparison_size = 'cat', 'cats', CAT_SIZE + else + comparison_name, comparison_name_plural, comparison_size = 'dwarf', 'dwarves', DWARF_SIZE + end + local ratio = size / comparison_size + if ratio == 1 then + return ('1 average %s'):format(comparison_name) + end + for precision=1,4 do + if ratio >= 1/(10^precision) then + return ('%%.%df average %%s'):format(precision):format(ratio, comparison_name_plural) + end + end + return string.format('a miniscule part of an %s', comparison_name) +end + +local function get_size_compared_to_median(unit) + local size_modifier = unit.appearance.size_modifier + if size_modifier >= 110 then + return "larger than average" + elseif size_modifier <= 90 then + return "smaller than average" + else + return "about average" + end end --------------------------------------------------- --------------------------------------------------- ---[[ - prof_id ? - group_id ? - fort_race_id - fort_civ_id - --fort_group_id? ---]] +local function get_body_chunk(unit) + local blurb = ('%s weighs about as much as %s'):format(get_pronoun(unit), get_conceivable_comparison(unit)) + return {text=blurb, pen=COLOR_LIGHTBLUE} +end + +local function format_size_in_cc(unit) + -- internal measure is cubic centimeters divided by 10 + local cc = unit.body.size_info.size_cur * 10 + return dfhack.formatInt(cc) +end -UnitInfoViewer = defclass(UnitInfoViewer, gui.FramedScreen) -UnitInfoViewer.focus_path = 'unitinfoviewer' -- -> dfhack/lua/unitinfoviewer -UnitInfoViewer.ATTRS={ - frame_style = gui.GREY_LINE_FRAME, - frame_inset = 2, -- used by init - frame_outset = 1,--3, -- new, used by init; 0 = full screen, suggest 0, 1, or 3 or maybe 5 - --frame_title , -- not used - --frame_width,frame_height calculated by frame inset and outset in init +local function get_average_size(unit) + local blurb = ('%s is %s cc, which is %s in size.') + :format(get_pronoun(unit), format_size_in_cc(unit), get_size_compared_to_median(unit)) + return{text=blurb, pen=COLOR_LIGHTCYAN} +end + +local function get_grazer_chunk(unit) + if not dfhack.units.isGrazer(unit) then return end + local caste = dfhack.units.getCasteRaw(unit) + local blurb = 'Grazing satisfies ' .. tostring(caste.misc.grazer) .. ' units of hunger.' + return {text=blurb, pen=COLOR_LIGHTGREEN} +end + +local function get_milkable_chunk(unit) + if not dfhack.units.isAlive(unit) or not dfhack.units.isMilkable(unit) then return end + if not dfhack.units.isAnimal(unit) then return end + local caste = dfhack.units.getCasteRaw(unit) + local milk = dfhack.matinfo.decode(caste.extracts.milkable_mat, caste.extracts.milkable_matidx) + if not milk then return end + local days, seconds = math.modf(caste.misc.milkable / TU_PER_DAY) + local blurb = (seconds > 0) and (tostring(days) .. ' to ' .. tostring(days + 1)) or tostring(days) + if dfhack.units.isAdult(unit) then + blurb = ('%s secretes %s every %s days.'):format(get_pronoun(unit), milk:toString(), blurb) + else + blurb = ('%s secrete %s every %s days.'):format(caste.caste_name[PLURAL], milk:toString(), blurb) + end + return {text=blurb, pen=COLOR_LIGHTCYAN} +end + +local function get_shearable_chunk(unit) + if not dfhack.units.isAlive(unit) then return end + if not dfhack.units.isAnimal(unit) then return end + local caste = dfhack.units.getCasteRaw(unit) + local mat_types = caste.body_info.materials.mat_type + local mat_idxs = caste.body_info.materials.mat_index + for idx, mat_type in ipairs(mat_types) do + local mat_info = dfhack.matinfo.decode(mat_type, mat_idxs[idx]) + if mat_info and mat_info.material.flags.YARN then + local blurb + if dfhack.units.isAdult(unit) then + blurb = ('%s produces %s.'):format(get_pronoun(unit), mat_info:toString()) + else + blurb = ('%s produce %s.'):format(caste.caste_name[PLURAL], mat_info:toString()) + end + return {text=blurb, pen=COLOR_BROWN} + end + end +end + +local function get_egg_layer_chunk(unit) + if not dfhack.units.isAlive(unit) or not dfhack.units.isEggLayer(unit) then return end + local caste = dfhack.units.getCasteRaw(unit) + local clutch = (caste.misc.clutch_size_max + caste.misc.clutch_size_min) // 2 + local blurb = ('She lays clutches of about %d egg%s.'):format(clutch, clutch == 1 and '' or 's') + return {text=blurb, pen=COLOR_GREEN} +end + +---------------------------- +-- UnitInfo +-- + +UnitInfo = defclass(UnitInfo, widgets.Window) +UnitInfo.ATTRS { + frame_title='Unit info', + frame={w=50, h=25}, + resizable=true, + resize_min={w=40, h=10}, } -function UnitInfoViewer:init(args) -- requires args.unit - --if DEBUG then print('-----') end - local x,y = dfhack.screen.getWindowSize() - -- what if inset or outset are defined as {l,r,t,b}? - x = x - 2*(self.frame_inset + 1 + self.frame_outset) -- 1=frame border thickness - y = y - 2*(self.frame_inset + 1 + self.frame_outset) -- 1=frame border thickness - self.frame_width = args.frame_width or x - self.frame_height = args.frame_height or y - self.text = {} - if df.unit:is_instance(args.unit) then - self.ident = Identity{ unit = args.unit } - if not isBlank(self.ident.name_en) then - self.frame_title = 'Unit: '..self.ident.name_en - elseif not isBlank(self.ident.prof) then - self.frame_title = 'Unit: '..self.ident.prof - if self.ident.isStray then - self.frame_title = self.frame_title..TRAINING_LEVELS[self.ident.unit.training_level] - end - end - self:chunk_Name() - self:chunk_Description() - if not (self.ident.dead or self.ident.undead or self.ident.ghostly) then --not self.dead - if self.ident.isCivCitizen then - self:chunk_Age() - self:chunk_MaxAge() - end - if self.ident.isStray then - if self.ident.growth_status == ADULT then - self:chunk_Milkable() + +function UnitInfo:init() + self.unit_id = nil + + self:addviews{ + widgets.Label{ + view_id='nameprof', + frame={t=0, l=0, h=1}, + auto_height=false, + }, + widgets.Label{ + view_id='translated_name', + frame={t=1, l=0, h=1}, + auto_height=false, + }, + widgets.Label{ + view_id='chunks', + frame={t=3, l=0, b=0, r=0}, + auto_height=false, + text='Please select a unit.', + }, + } +end + +local function add_chunk(chunks, chunk, width) + if not chunk then return end + if type(chunk) == 'string' then + table.insert(chunks, chunk:wrap(width)) + table.insert(chunks, NEWLINE) + else + for _, line in ipairs(chunk.text:wrap(width):split(NEWLINE)) do + local newchunk = copyall(chunk) + newchunk.text = line + table.insert(chunks, newchunk) + table.insert(chunks, NEWLINE) + end end - self:chunk_Grazer() - if self.ident.growth_status == ADULT then - self:chunk_Shearable() + table.insert(chunks, NEWLINE) +end + +function UnitInfo:refresh(unit, width) + self.unit_id = unit.id + self.subviews.nameprof:setText{get_name_chunk(unit)} + self.subviews.translated_name:setText{get_translated_name_chunk(unit)} + + local chunks = {} + add_chunk(chunks, get_description_chunk(unit), width) + add_chunk(chunks, get_age_chunk(unit), width) + add_chunk(chunks, get_max_age_chunk(unit), width) + add_chunk(chunks, get_ghostly_chunk(unit), width) + add_chunk(chunks, get_dead_chunk(unit), width) + add_chunk(chunks, get_average_size(unit), width) + if get_creature_data(unit).creature_id ~= "DWARF" then + add_chunk(chunks, get_body_chunk(unit), width) end - if self.ident.growth_status == ADULT then - self:chunk_EggLayer() + add_chunk(chunks, get_grazer_chunk(unit), width) + add_chunk(chunks, get_milkable_chunk(unit), width) + add_chunk(chunks, get_shearable_chunk(unit), width) + add_chunk(chunks, get_egg_layer_chunk(unit), width) + self.subviews.chunks:setText(chunks) +end + +function UnitInfo:check_refresh(force) + local unit = dfhack.gui.getSelectedUnit(true) + if unit and (force or unit.id ~= self.unit_id) then + self:refresh(unit, self.frame_body.width-3) end - end - self:chunk_BodySize() - elseif self.ident.ghostly then - self:chunk_Dead() - self:chunk_Ghostly() - elseif self.ident.undead then - self:chunk_BodySize() - self:chunk_Dead() - else - self:chunk_Dead() - end - else - self:insert_chunk("No unit is selected in the UI or context not supported.",pens.LIGHTRED) - end - self:addviews{ widgets.Label{ frame={yalign=0}, text=self.text } } -end -function UnitInfoViewer:onInput(keys) - if keys.LEAVESCREEN or keys.SELECT then - self:dismiss() - end -end -function UnitInfoViewer:onGetSelectedUnit() - return self.ident.unit -end -function UnitInfoViewer:insert_chunk(str,pen) - local lines = str:wrap(self.frame_width):split(NEWLINE) - for i = 1,#lines do - table.insert(self.text,{text=lines[i],pen=pen}) - table.insert(self.text,NEWLINE) - end - table.insert(self.text,NEWLINE) -end -function UnitInfoViewer:chunk_Name() - local i = self.ident - local u = i.unit - local prof = i.prof - local color = dfhack.units.getProfessionColor(u) - local blurb - if i.ghostly then - prof = 'Ghostly '..prof - end - if i.isStray then - prof = prof..TRAINING_LEVELS[u.training_level] - end - if isBlank(i.name) then - if isBlank(prof) then - blurb = 'I am a mystery' - else - blurb = prof - end - else - if isBlank(prof) then - blurb=i.name - else - blurb=i.name..', '..prof - end - end - self:insert_chunk(blurb,dfhack.pen.parse{fg=color,bg=0}) -end -function UnitInfoViewer:chunk_Description() - local dsc = self.ident.caste.description - if not isBlank(dsc) then - self:insert_chunk(dsc,pens.WHITE) - end -end - -function UnitInfoViewer:chunk_Age() - local i = self.ident - local age_str -- = '' - if i.age_time.year > 1 then - age_str = tostring(i.age_time.year)..' years old' - elseif i.age_time.year > 0 then -- == 1 - age_str = '1 year old' - else --if age_time.year == 0 then - local age_m = i.age_time:getMonths() -- math.floor - if age_m > 1 then - age_str = tostring(age_m)..' months old' - elseif age_m > 0 then -- age_m == 1 - age_str = '1 month old' - else -- if age_m == 0 then -- and age_m < 0 which would be an error - age_str = 'a newborn' - end - end - local blurb = i.pronoun..' is '..age_str - if i.race_id == df.global.plotinfo.race_id then - blurb = blurb..', born on the '..i.birth_date:getDayStr()..' of '..i.birth_date:getMonthStr()..' in the year '..tostring(i.birth_date.year)..PERIOD - else - blurb = blurb..PERIOD - end - self:insert_chunk(blurb,pens.YELLOW) -end - -function UnitInfoViewer:chunk_MaxAge() - local i = self.ident - local maxage = math.floor( (i.caste.misc.maxage_max + i.caste.misc.maxage_min)/2 ) - --or i.unit.curse.add_tags1.NO_AGING hidden ident? - if i.caste.misc.maxage_min == -1 then - maxage = ' die of unnatural causes.' - elseif maxage == 0 then - maxage = ' die at a very young age.' - elseif maxage == 1 then - maxage = ' live about '..tostring(maxage)..' year.' - else - maxage = ' live about '..tostring(maxage)..' years.' - end - --' is expected to '.. - local blurb = str2FirstUpper(i.caste_name[PLURAL])..maxage - self:insert_chunk(blurb,pens.DARKGREY) -end -function UnitInfoViewer:chunk_Grazer() - if self.ident.caste.flags.GRAZER then - local blurb = 'Grazing satisfies '..tostring(self.ident.caste.misc.grazer)..' units of hunger.' - self:insert_chunk(blurb,pens.LIGHTGREEN) - end -end -function UnitInfoViewer:chunk_EggLayer() - local caste = self.ident.caste - if caste.flags.LAYS_EGGS then - local clutch = math.floor( (caste.misc.clutch_size_max + caste.misc.clutch_size_min)/2 ) - local blurb = 'Lays clutches of about '..tostring(clutch) - if clutch > 1 then - blurb = blurb..' eggs.' - else - blurb = blurb..' egg.' - end - self:insert_chunk(blurb,pens.GREEN) - end -end -function UnitInfoViewer:chunk_Milkable() - local i = self.ident - if i.caste.flags.MILKABLE then - local milk = dfhack.matinfo.decode( i.caste.extracts.milkable_mat , i.caste.extracts.milkable_matidx ) - if milk then - local days,seconds = math.modf ( i.caste.misc.milkable / TU_PER_DAY ) - days = (seconds > 0) and (tostring(days)..' to '..tostring(days + 1)) or tostring(days) - --local blurb = pronoun..' produces '..milk:toString()..' every '..days..' days.' - local blurb = (i.growth_status == ADULT) and (i.pronoun..' secretes ') or str2FirstUpper(i.caste_name[PLURAL])..' secrete ' - blurb = blurb..milk:toString()..' every '..days..' days.' - self:insert_chunk(blurb,pens.LIGHTCYAN) - end - end -end -function UnitInfoViewer:chunk_Shearable() - local i = self.ident - local mat_types = i.caste.body_info.materials.mat_type - local mat_idxs = i.caste.body_info.materials.mat_index - local mat_info, blurb - for idx,mat_type in ipairs(mat_types) do - mat_info = dfhack.matinfo.decode(mat_type,mat_idxs[idx]) - if mat_info and mat_info.material.flags.YARN then - blurb = (i.growth_status == ADULT) and (i.pronoun..' produces ') or str2FirstUpper(i.caste_name[PLURAL])..' produce ' - blurb = blurb..mat_info:toString()..PERIOD - self:insert_chunk(blurb,pens.BROWN) - end - end -end -local function get_size_in_cc(unit) - -- internal measure is cubic centimeters divided by 10 - return unit.body.size_info.size_cur * 10 -end -function UnitInfoViewer:chunk_BodySize() - local i = self.ident - local pat = i.unit.body.physical_attrs - local blurb = i.pronoun..' appears to be about ' .. get_size_in_cc(i.unit) .. ' cubic centimeters in size.' - self:insert_chunk(blurb,pens.LIGHTBLUE) -end -function UnitInfoViewer:chunk_Ghostly() - local blurb = GHOST_TYPES[self.ident.unit.ghost_info.type].." This spirit has not been properly memorialized or buried." - self:insert_chunk(blurb,pens.LIGHTMAGENTA) - -- Arose in curse_year curse_time -end -function UnitInfoViewer:chunk_Dead() - local i = self.ident - local blurb, str, pen - if i.missing then --dfhack.units.isDead(unit) - str = ' is missing.' - pen = pens.WHITE - elseif i.death_event then - --str = "The Caste_name Unit_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.bow_item_subtype].name}" if e.weapon.bow_item_type == :WEAPON - str = DEATH_TYPES[i.death_event.death_cause]..PERIOD - pen = pens.MAGENTA - elseif i.incident then - --str = "The #{u.race_tg.name[0]}" - --str << " #{u.name}" if u.name.has_name - --str << " died" - --str << " in year #{incident.event_year}" if incident - --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 - str = DEATH_TYPES[i.incident.death_cause]..PERIOD - pen = pens.MAGENTA - elseif i.unit.flags2.slaughter and i.unit.flags2.killed then - str = ' was slaughtered.' - pen = pens.MAGENTA - else - str = ' is dead.' - pen = pens.MAGENTA - end - if i.undead or i.ghostly then - str = ' is undead.' - pen = pens.GREY - end - blurb = 'The '..i.prof -- assume prof is not blank - if not isBlank(i.name) then - blurb = blurb..', '..i.name - end - blurb = blurb..str - self:insert_chunk(blurb,pen) -end - --- do nothing if being used as a module +end + +function UnitInfo:postComputeFrame() + -- re-wrap + self:check_refresh(true) +end + +function UnitInfo:render(dc) + self:check_refresh() + UnitInfo.super.render(self, dc) +end + +---------------------------- +-- UnitInfoScreen +-- + +UnitInfoScreen = defclass(UnitInfoScreen, gui.ZScreen) +UnitInfoScreen.ATTRS { + focus_path='unit-info-viewer', +} + +function UnitInfoScreen:init() + self:addviews{UnitInfo{}} +end + +function UnitInfoScreen:onDismiss() + view = nil +end + +OVERLAY_WIDGETS = { + skillprogress=skills_progress.SkillProgressOverlay, +} + if dfhack_flags.module then return end --- only show if UnitInfoViewer isn't the current focus -if dfhack.gui.getCurFocus() ~= 'dfhack/lua/'..UnitInfoViewer.focus_path then - local gui_no_unit = false -- show if not found? - local unit = getUnit_byVS(gui_no_unit) -- silent? or let the gui display - if unit or gui_no_unit then - local kan_viewscreen = UnitInfoViewer{unit = unit} - kan_viewscreen:show() - end -end +view = view and view:raise() or UnitInfoScreen{}:show() diff --git a/gui/unit-syndromes.lua b/gui/unit-syndromes.lua index 9454a586c8..52830f0383 100644 --- a/gui/unit-syndromes.lua +++ b/gui/unit-syndromes.lua @@ -1,7 +1,6 @@ -- GUI for exploring unit syndromes (and their effects). local gui = require('gui') -local utils = require('utils') local widgets = require('gui.widgets') local function getEffectTarget(target) @@ -24,18 +23,16 @@ end local function getEffectCreatureName(effect) if effect.race_str == "" then - return "UNKNOWN" + return "unknown" end local creature = df.global.world.raws.creatures.all[effect.race[0]] if effect.caste_str == "DEFAULT" then - return ("%s%s"):format(string.upper(creature.name[0]), (", %s"):format(effect.caste_str)) + return creature.name[0] else - -- TODO: Caste seems to be entirely unused. local caste = creature.caste[effect.caste[0]] - - return ("%s%s"):format(string.upper(creature.name[0]), (", %s"):format(string.upper(caste.name[0]))) + return ("%s%s"):format(creature.name[0], (", %s"):format(caste.caste_name[0])) end end @@ -161,7 +158,7 @@ local EffectFlagDescription = { return ("REMOVES: \n%s"):format(table.concat(tags, " \n")) end, [df.creature_interaction_effect_type.DISPLAY_TILE] = function(effect) - return ("TILE: %s %s"):format(effect.color, effect.tile) + return ("TILE: %s (%s, %s, %s)"):format(effect.tile, effect.sym_color[0], effect.sym_color[1], effect.sym_color[2]) end, [df.creature_interaction_effect_type.FLASH_TILE] = function(effect) return ("FLASH TILE: %s %s"):format(effect.sym_color[1], effect.sym_color[0]) @@ -179,11 +176,11 @@ local EffectFlagDescription = { return ("ABILITY: %s"):format(getEffectInteraction(effect)) end, [df.creature_interaction_effect_type.SKILL_ROLL_ADJUST] = function(effect) - return ("MODIFIER=%s, CHANGE=%s"):format(effect.multiplier, effect.chance) + return ("MODIFIER=%s, CHANCE=%s"):format(effect.multiplier, effect.prob) end, [df.creature_interaction_effect_type.BODY_TRANSFORMATION] = function(effect) - if effect.chance > 0 then - return ("%s, CHANCE=%s%%"):format(getEffectCreatureName(effect), effect.chance) + if effect.prob > 0 then + return ("%s, CHANCE=%s%%"):format(getEffectCreatureName(effect), effect.prob) else return getEffectCreatureName(effect) end @@ -200,19 +197,22 @@ local EffectFlagDescription = { return ("RECIEVED DAMAGE SCALED BY %s%%%s"):format( (effect.fraction_mul * 100 / effect.fraction_div * 100) / 100, - material and ("vs. %s"):format(material.stone_name) + material and ("vs. %s"):format(material.stone_name) or '' ) end, - -- TODO: Unfinished, unknown fields from previous script. [df.creature_interaction_effect_type.BODY_MAT_INTERACTION] = function(effect) return ("%s %s"):format(effect.interaction_name, effect.interaction_id) end, - -- TODO: Unfinished. [df.creature_interaction_effect_type.BODY_APPEARANCE_MODIFIER] = function(effect) - return ("TODO"):format(effect.interaction_name, effect.interaction_id) + return ("VALUE=%s MODIFIER_TYPE=%s"):format( + effect.appearance_modifier_value, + df.appearance_modifier_type[effect.appearance_modifier]) end, [df.creature_interaction_effect_type.BP_APPEARANCE_MODIFIER] = function(effect) - return ("VALUE=%s CHANGE_TYPE_ENUM=%s%s"):format(effect.value, effect.unk_6c, getEffectTarget(effect.target)) + return ("VALUE=%s MODIFIER_TYPE=%s CHANGE_TYPE_ENUM=%s"):format( + effect.appearance_modifier_value, + df.appearance_modifier_type[effect.appearance_modifier], + getEffectTarget(effect.target)) end, [df.creature_interaction_effect_type.DISPLAY_NAME] = function(effect) return ("SET NAME: %s"):format(effect.name) @@ -236,28 +236,30 @@ local function getEffectDescription(effect) end local function getSyndromeName(syndrome_raw) - local is_transformation = false + local transform_creature_name - for _, effect in pairs(syndrome_raw.ce) do + for _, effect in ipairs(syndrome_raw.ce) do if df.creature_interaction_effect_body_transformationst:is_instance(effect) then - is_transformation = true + transform_creature_name = getEffectCreatureName(effect) + elseif df.creature_interaction_effect_display_namest:is_instance(effect) then + return effect.name:gsub("^%l", string.upper) end end if syndrome_raw.syn_name ~= "" then return syndrome_raw.syn_name:gsub("^%l", string.upper) - elseif is_transformation then - return "Body transformation" + elseif transform_creature_name then + return ("Body transformation: %s"):format(transform_creature_name) end return "Mystery" end local function getSyndromeEffects(syndrome_type) - local syndrome_raw = df.global.world.raws.syndromes.all[syndrome_type] + local syndrome_raw = df.global.world.raws.mat_table.syndromes.all[syndrome_type] local syndrome_effects = {} - for _, effect in pairs(syndrome_raw.ce) do + for _, effect in ipairs(syndrome_raw.ce) do table.insert(syndrome_effects, effect) end @@ -269,7 +271,7 @@ local function getSyndromeDescription(syndrome_raw, syndrome) local syndrome_min_duration = nil local syndrome_max_duration = nil - for _, effect in pairs(syndrome_raw.ce) do + for _, effect in ipairs(syndrome_raw.ce) do syndrome_min_duration = math.min(syndrome_min_duration or effect["end"], effect["end"]) syndrome_max_duration = math.max(syndrome_max_duration or effect["end"], effect["end"]) end @@ -282,7 +284,7 @@ local function getSyndromeDescription(syndrome_raw, syndrome) syndrome_duration = ("%s-%s"):format(syndrome_min_duration, syndrome_max_duration) end - return ("%-22s %s%s \n%s effects"):format( + return ("%-29s %s%s \n%s effects"):format( getSyndromeName(syndrome_raw), syndrome and ("%s of "):format(syndrome.ticks) or "", syndrome_duration, @@ -300,23 +302,11 @@ local function getUnitSyndromes(unit) return unit_syndromes end -local function getCitizens() - local units = {} - - for _, unit in pairs(df.global.world.units.active) do - if dfhack.units.isCitizen(unit) and dfhack.units.isDwarf(unit) then - table.insert(units, unit) - end - end - - return units -end - local function getLivestock() local units = {} for _, unit in pairs(df.global.world.units.active) do - local caste_flags = unit.caste and df.global.world.raws.creatures.all[unit.race].caste[unit.caste].flags + local caste_flags = dfhack.units.getCasteRaw(unit).flags if dfhack.units.isFortControlled(unit) and caste_flags and (caste_flags.PET or caste_flags.PET_EXOTIC) then table.insert(units, unit) @@ -344,7 +334,7 @@ local function getHostiles() local units = {} for _, unit in pairs(df.global.world.units.active) do - if dfhack.units.isDanger(unit) or dfhack.units.isGreatDanger(unit) then + if dfhack.units.isDanger(unit) then table.insert(units, unit) end end @@ -365,9 +355,9 @@ end UnitSyndromes = defclass(UnitSyndromes, widgets.Window) UnitSyndromes.ATTRS { frame_title='Unit Syndromes', - frame={w=50, h=30}, + frame={w=54, h=30}, resizable=true, - resize_min={w=43, h=20}, + resize_min={w=30, h=20}, } function UnitSyndromes:init() @@ -382,7 +372,7 @@ function UnitSyndromes:init() view_id = 'category', frame = {t = 0, l = 0}, choices = { - { text = "Dwarves", get_choices = getCitizens }, + { text = "Citizens and Residents", get_choices = dfhack.units.getCitizens }, { text = "Livestock", get_choices = getLivestock }, { text = "Wild animals", get_choices = getWildAnimals }, { text = "Hostile", get_choices = getHostiles }, @@ -448,11 +438,11 @@ function UnitSyndromes:onInput(keys) return UnitSyndromes.super.onInput(self, keys) end -function UnitSyndromes:showUnits(index, choice) +function UnitSyndromes:showUnits(_, choice) local choices = {} if choice.text == "All syndromes" then - for _, syndrome in pairs(df.global.world.raws.syndromes.all) do + for _, syndrome in pairs(df.global.world.raws.mat_table.syndromes.all) do if #syndrome.ce == 0 then goto skipsyndrome end @@ -478,9 +468,8 @@ function UnitSyndromes:showUnits(index, choice) table.insert(choices, { unit_id = unit.id, - text = ("%s %s \n%s syndromes"):format( - string.upper(df.global.world.raws.creatures.all[unit.race].name[0]), - dfhack.TranslateName(unit.name), + text = ("%s\n%s syndrome(s)"):format( + dfhack.units.getReadableName(unit), #unit_syndromes ), }) @@ -493,7 +482,7 @@ function UnitSyndromes:showUnits(index, choice) end function UnitSyndromes:showUnitSyndromes(index, choice) - local unit = utils.binsearch(df.global.world.units.all, choice.unit_id, 'id') + local unit = df.unit.find(choice.unit_id) local unit_syndromes = getUnitSyndromes(unit) local choices = {} @@ -502,7 +491,7 @@ function UnitSyndromes:showUnitSyndromes(index, choice) end for _, syndrome in pairs(unit_syndromes) do - local syndrome_raw = df.global.world.raws.syndromes.all[syndrome.type] + local syndrome_raw = df.global.world.raws.mat_table.syndromes.all[syndrome.type] if #syndrome_raw.ce == 0 then goto skipsyndrome diff --git a/gui/workshop-job.lua b/gui/workshop-job.lua index 406c15ebb0..b071ee1142 100644 --- a/gui/workshop-job.lua +++ b/gui/workshop-job.lua @@ -75,7 +75,7 @@ function JobDetails:init(args) if self.job.flags.suspend then status = { text = 'Suspended', pen = COLOR_RED } elseif worker then - status = { text = dfhack.TranslateName(dfhack.units.getVisibleName(worker)), pen = COLOR_GREEN } + status = { text = dfhack.units.getReadableName(worker), pen = COLOR_GREEN } end self:addviews{ @@ -175,7 +175,7 @@ function JobDetails:initListChoices() end local choices = {} - for i,iobj in ipairs(self.job.job_items) do + for i,iobj in ipairs(self.job.job_items.elements) do local head = 'Item '..(i+1)..': '..(items[i] or 0)..' of '..iobj.quantity if iobj.min_dimension > 0 then head = head .. '(size '..iobj.min_dimension..')' diff --git a/hermit.lua b/hermit.lua index 57f0b08993..649004427f 100644 --- a/hermit.lua +++ b/hermit.lua @@ -3,8 +3,6 @@ --@ module=true local argparse = require('argparse') -local json = require('json') -local persist = require('persist-table') local repeatutil = require('repeat-util') local GLOBAL_KEY = 'hermit' @@ -22,12 +20,12 @@ function isEnabled() end local function persist_state() - persist.GlobalTable[GLOBAL_KEY] = json.encode{enabled=enabled} + dfhack.persistent.saveSiteData(GLOBAL_KEY, {enabled=enabled}) end local function load_state() - local persisted_data = json.decode(persist.GlobalTable[GLOBAL_KEY] or '') or {} - enabled = persisted_data.enabled or false + local persisted_data = dfhack.persistent.getSiteData(GLOBAL_KEY, {enabled=false}) + enabled = persisted_data.enabled end function event_loop() diff --git a/hfs-pit.lua b/hfs-pit.lua index fb4cf7341b..a89c3e6eb3 100644 --- a/hfs-pit.lua +++ b/hfs-pit.lua @@ -27,6 +27,7 @@ Examples:: ]====] +local guidm = require('gui.dwarfmode') local args={...} if args[1] == '?' or args[1] == 'help' then @@ -34,7 +35,7 @@ if args[1] == '?' or args[1] == 'help' then return end -local pos = copyall(df.global.cursor) +local pos = guidm.getCursorPos() local size = tonumber(args[1]) if size == nil or size < 1 then size = 1 end @@ -49,7 +50,7 @@ for index, feature in ipairs(df.global.world.features.map_features) do end end -if pos.x==-30000 then +if not pos then qerror("Select a location by placing the cursor") end local x = 0 @@ -62,41 +63,40 @@ for x=pos.x-size,pos.x+size,1 do while z <= pos.z do local block = dfhack.maps.ensureTileBlock(x,y,z) if block then - if block.tiletype[x%16][y%16] ~= 335 then + local old_tt = block.tiletype[x%16][y%16] + if not hitAir and old_tt ~= df.tiletype.FeatureWall and old_tt ~= df.tiletype.EeriePit then hitAir = true end - if hitAir == true then + if hitAir then if not hitCeiling then if block.global_feature ~= underworldLayer or z > 10 then hitCeiling = true end if stairs == 1 and x == pos.x and y == pos.y then - if block.tiletype[x%16][y%16] == 32 then + if old_tt == df.tiletype.OpenSpace or old_tt == df.tiletype.RampTop then if z == pos.z then - block.tiletype[x%16][y%16] = 56 + block.tiletype[x%16][y%16] = df.tiletype.StoneStairD else - block.tiletype[x%16][y%16] = 55 + block.tiletype[x%16][y%16] = df.tiletype.StoneStairUD end else - block.tiletype[x%16][y%16] = 57 + block.tiletype[x%16][y%16] = df.tiletype.StoneStairU end end end - if hitCeiling == true then + if hitCeiling then local needsWall = block.designation[x%16][y%16].flow_size > 0 or wallOff == 1 if (x == pos.x-size or x == pos.x+size or y == pos.y-size or y == pos.y+size) and z==pos.z then --Do nothing, this is the lip of the hole - elseif x == pos.x-size and y == pos.y-size then if needsWall == true then block.tiletype[x%16][y%16]=320 end - elseif x == pos.x-size and y == pos.y+size then if needsWall == true then block.tiletype[x%16][y%16]=321 end - elseif x == pos.x+size and y == pos.y+size then if needsWall == true then block.tiletype[x%16][y%16]=322 end - elseif x == pos.x+size and y == pos.y-size then if needsWall == true then block.tiletype[x%16][y%16]=323 end - elseif x == pos.x-size or x == pos.x+size then if needsWall == true then block.tiletype[x%16][y%16]=324 end - elseif y == pos.y-size or y == pos.y+size then if needsWall == true then block.tiletype[x%16][y%16]=325 end + elseif x == pos.x-size and y == pos.y-size then if needsWall then block.tiletype[x%16][y%16]=df.tiletype.StoneWallSmoothRD end + elseif x == pos.x-size and y == pos.y+size then if needsWall then block.tiletype[x%16][y%16]=df.tiletype.StoneWallSmoothRU end + elseif x == pos.x+size and y == pos.y+size then if needsWall then block.tiletype[x%16][y%16]=df.tiletype.StoneWallSmoothLU end + elseif x == pos.x+size and y == pos.y-size then if needsWall then block.tiletype[x%16][y%16]=df.tiletype.StoneWallSmoothLD end + elseif x == pos.x-size or x == pos.x+size then if needsWall then block.tiletype[x%16][y%16]=df.tiletype.StoneWallSmoothUD end + elseif y == pos.y-size or y == pos.y+size then if needsWall then block.tiletype[x%16][y%16]=df.tiletype.StoneWallSmoothLR end elseif stairs == 1 and x == pos.x and y == pos.y then - if z == pos.z then block.tiletype[x%16][y%16]=56 - else block.tiletype[x%16][y%16]=55 end - else block.tiletype[x%16][y%16]=32 + if z == pos.z then block.tiletype[x%16][y%16]=df.tiletype.StoneStairD + else block.tiletype[x%16][y%16]=df.tiletype.StoneStairUD end + else block.tiletype[x%16][y%16]=df.tiletype.OpenSpace end - block.designation[x%16][y%16].hidden = false - --block.designation[x%16][y%16].liquid_type = true -- if true, magma. if false, water. block.designation[x%16][y%16].flow_size = 0 dfhack.maps.enableBlockUpdates(block) block.designation[x%16][y%16].flow_forbid = false diff --git a/hide-tutorials.lua b/hide-tutorials.lua index 2ca950e3bf..e6124de0de 100644 --- a/hide-tutorials.lua +++ b/hide-tutorials.lua @@ -12,10 +12,6 @@ function isEnabled() return enabled end -local function is_fort_map_loaded() - return df.global.gamemode == df.game_mode.DWARF and dfhack.isMapLoaded() -end - local help = df.global.game.main_interface.help local function close_help() @@ -43,15 +39,36 @@ function skip_tutorial_prompt() end end +local function get_prefix() + if dfhack.world.isFortressMode() then + return 'POPUP_' + elseif dfhack.world.isAdventureMode() then + return 'ADVENTURE_POPUP_' + end +end + local function hide_all_popups() + local prefix = get_prefix() + if not prefix then return end for i,name in ipairs(df.help_context_type) do - if not name:startswith('POPUP_') then goto continue end + if not name:startswith(prefix) then goto continue end utils.insert_sorted(df.global.plotinfo.tutorial_seen, i) utils.insert_sorted(df.global.plotinfo.tutorial_hide, i) ::continue:: end end +local function show_all_popups() + local prefix = get_prefix() + if not prefix then return end + for i,name in ipairs(df.help_context_type) do + if not name:startswith(prefix) then goto continue end + utils.erase_sorted(df.global.plotinfo.tutorial_seen, i) + utils.erase_sorted(df.global.plotinfo.tutorial_hide, i) + ::continue:: + end +end + dfhack.onStateChange[GLOBAL_KEY] = function(sc) if not enabled then return end @@ -65,7 +82,7 @@ dfhack.onStateChange[GLOBAL_KEY] = function(sc) dfhack.timeout(100, 'frames', skip_tutorial_prompt) dfhack.timeout(1000, 'frames', skip_tutorial_prompt) end - elseif sc == SC_MAP_LOADED and df.global.gamemode == df.game_mode.DWARF then + elseif sc == SC_MAP_LOADED then hide_all_popups() end end @@ -81,13 +98,15 @@ end if args[1] == "enable" then enabled = true - if is_fort_map_loaded() then + if dfhack.isMapLoaded() then hide_all_popups() end elseif args[1] == "disable" then enabled = false -elseif is_fort_map_loaded() then +elseif args[1] == "reset" then + show_all_popups() +elseif dfhack.isMapLoaded() then hide_all_popups() else - qerror('hide-tutorials needs a loaded fortress map to work') + qerror('hide-tutorials needs a loaded fortress or adventure map to work') end diff --git a/husbandry.lua b/husbandry.lua new file mode 100644 index 0000000000..9198013a1f --- /dev/null +++ b/husbandry.lua @@ -0,0 +1,325 @@ + +--@enable = true +--@module = true + +local utils = require 'utils' +local repeatutil = require("repeat-util") + +local verbose = false +---conditional printing of debug messages +---@param message string +local function debug(message) + if verbose then + print(message) + end +end + +-- From workorder.lua +---------------------------8<----------------------------- + +local function isValidAnimal(unit) + -- this should also check for the absence of misc trait 55 (as of 50.09), but we don't + -- currently have an enum definition for that value yet + return dfhack.units.isOwnCiv(unit) + and dfhack.units.isAlive(unit) + and dfhack.units.isAdult(unit) + and dfhack.units.isActive(unit) + and dfhack.units.isFortControlled(unit) + and dfhack.units.isTame(unit) + and not dfhack.units.isMarkedForSlaughter(unit) + and not dfhack.units.getMiscTrait(unit, df.misc_trait_type.Migrant, false) +end + +-- true/false or nil if no shearable_tissue_layer with length > 0. +local function canShearCreature(unit) + local stls = df.global.world.raws.creatures + .all[unit.race] + .caste[unit.caste] + .shearable_tissue_layer + + local any + for _, stl in ipairs(stls) do + if stl.length > 0 then + for _, bpi in ipairs(stl.bp_modifiers_idx) do + any = { unit.appearance.bp_modifiers[bpi], stl.length } + if unit.appearance.bp_modifiers[bpi] >= stl.length then + return true, any + end + end + end + end + + if any then return false, any end + -- otherwise: nil +end + +---------------------------8<----------------------------- + +local function canMilkCreature(u) + if dfhack.units.isMilkable(u) and not dfhack.units.isPet(u) then + local mt_milk = dfhack.units.getMiscTrait(u, df.misc_trait_type.MilkCounter, false) + if not mt_milk then return true else return false end + else + return nil + end +end + +---@param p1 df.coord +---@param p2 df.coord +---@return number +function distance(p1, p2) + return math.max(math.abs(p1.x - p2.x), math.abs(p1.y - p2.y)) + 2 * math.abs(p1.z - p2.z) +end + +---find appropriate workshop to milk or shear an animal +---@param unit df.unit +---@param collection table +---@return df.building_workshopst? +local function getAppropriateWorkshop(unit, collection) + local zone_ref = dfhack.units.getGeneralRef(unit, df.general_ref_type.BUILDING_CIVZONE_ASSIGNED) + local zone = zone_ref and zone_ref:getBuilding() or nil + + -- if animal is assigned to a zone containing workshops, only use those + if zone then + local contains_workshop = false + local best = nil + local worst_load = 10 + for _, workshop in pairs(collection[zone.z] or {}) do + if dfhack.buildings.containsTile(zone, workshop.centerx, workshop.centery) then + contains_workshop = true + local workshop_pos = xyz2pos(workshop.centerx, workshop.centery, workshop.z) + if dfhack.maps.canWalkBetween(unit.pos, workshop_pos) and #workshop.jobs < worst_load then + worst_load = #workshop.jobs + best = workshop + end + end + end + if contains_workshop or state.pasture then + return best + end + elseif not state.roaming then + return nil -- not treating roaming animals + end + -- otherwise, use the closest workshop to the animal + local closest = nil + local dist = nil + for _, level in pairs(collection) do + for _, workshop in pairs(level) do + local workshop_pos = xyz2pos(workshop.centerx, workshop.centery, workshop.z) + if dfhack.maps.canWalkBetween(unit.pos, workshop_pos) then + local d = distance(unit.pos, workshop_pos) + if not closest or d < dist then + closest = workshop + dist = d + end + end + end + end + return (closest and #closest.jobs < 10) and closest or nil +end + +local function shearCreature(unit, workshop) + local job = dfhack.job.createLinked() + job.job_type = df.job_type.ShearCreature + dfhack.job.addGeneralRef(job, df.general_ref_type.UNIT_SHEAREE, unit.id) + dfhack.job.assignToWorkshop(job, workshop) +end + +local function milkCreature(unit, workshop) + local job = dfhack.job.createLinked() + job.job_type = df.job_type.MilkCreature + dfhack.job.addGeneralRef(job, df.general_ref_type.UNIT_MILKEE, unit.id) + dfhack.job.assignToWorkshop(job, workshop) +end + + +-- configuration management + +GLOBAL_KEY = 'husbandry' + +local function get_default_state() + return { + enabled = false, + milking = true, + shearing = true, + roaming = true; + pasture = false + } +end + +state = state or get_default_state() + +function isEnabled() + return state.enabled +end + +function persist_state() + dfhack.persistent.saveSiteData(GLOBAL_KEY, { + enabled=state.enabled, + milking=state.milking, + shearing=state.shearing, + roaming=state.roaming, + pasture=state.pasture, + }) +end + +--- Load the saved state of the script +local function load_state() + -- load persistent data + local persisted_data = dfhack.persistent.getSiteData(GLOBAL_KEY, get_default_state()) + state.enabled = persisted_data.enabled + state.milking = persisted_data.milking + state.shearing = persisted_data.shearing + state.roaming = persisted_data.roaming + state.pasture = persisted_data.pasture + return state +end + +-- main script action + +local function action() + debug('husbandry: running loop') + + -- organize workshops by allowed labors and z-level + ---@type table + local farmer_shearing = {} + ---@type table + local farmer_milking = {} + for _, workshop in ipairs(df.global.world.buildings.other.WORKSHOP_FARMER) do + if not workshop.profile.blocked_labors[df.unit_labor.SHEARER] then + table.insert(ensure_key(farmer_shearing, workshop.z), workshop) + end + if not workshop.profile.blocked_labors[df.unit_labor.MILK] then + table.insert(ensure_key(farmer_milking, workshop.z), workshop) + end + end + + -- gather units that are already being milked or sheared + ---@type table + local unit_milking = {} + ---@type table + local unit_shearing = {} + + -- go over all workshops to to catch player-initiated jobs + for _, workshop in ipairs(df.global.world.buildings.other.WORKSHOP_FARMER) do + for _, job in ipairs(workshop.jobs) do + if state.milking and job.job_type == df.job_type.MilkCreature then + local milkee = dfhack.job.getGeneralRef(job, df.general_ref_type.UNIT_MILKEE) + if milkee then + unit_milking[milkee.unit_id] = true + end + elseif state.shearing and job.job_type == df.job_type.ShearCreature then + local shearee = dfhack.job.getGeneralRef(job, df.general_ref_type.UNIT_SHEAREE) + if shearee then + unit_shearing[shearee.unit_id] = true + end + end + end + end + + -- look for units that can be milked/sheared and for which there is no active job + for _, unit in ipairs(df.global.world.units.active) do + if not isValidAnimal(unit) then goto skip end + + if state.shearing and canShearCreature(unit) and not unit_shearing[unit.id] then + local workshop = getAppropriateWorkshop(unit, farmer_shearing) + if workshop then + shearCreature(unit, workshop) + end + end + + if state.milking and canMilkCreature(unit) and not unit_milking[unit.id] then + local workshop = getAppropriateWorkshop(unit, farmer_milking) + if workshop then + milkCreature(unit, workshop) + end + end + + ::skip:: + end +end + +-- enable management + +local function start() + if state.enabled then + repeatutil.scheduleUnlessAlreadyScheduled(GLOBAL_KEY, 1000, 'ticks', action) + end +end + +local function stop() + repeatutil.cancel(GLOBAL_KEY) +end + +dfhack.onStateChange[GLOBAL_KEY] = function(sc) + if sc == SC_MAP_UNLOADED then + state.enabled = false + return + end + + if sc ~= SC_MAP_LOADED or df.global.gamemode ~= df.game_mode.DWARF then + return + end + + load_state() + start() +end + +if dfhack_flags.module then + return +end + +if dfhack_flags.enable then + if dfhack_flags.enable_state then + state.enabled = true + start() + else + state.enabled = false + stop() + end + persist_state() + return +end + +-- command-line interface + +local argparse = require('argparse') +local positionals = argparse.processArgsGetopt({ ... }, {}) + +local state_vars = utils.invert({ "milking", "shearing", "roaming", "pasture" }) + +local function setFlags(positionals, value) + for i = 2, #positionals do + local flag = positionals[i] + if state_vars[flag] then + debug(("setting %s = %s"):format(flag, value)) + state[flag] = value + end + end +end + +load_state() +if not positionals[1] or positionals[1] == 'status' then + print(("husbandry is %s"):format(state.enabled and "enabled" or "not enabled")) + print(("currently %smilking%s%sshearing animals"):format( + state.milking and "" or "not ", + state.milking == state.shearing and " and " or " but ", + state.shearing and "" or "not ")) + print(("%s roaming animals"):format(state.roaming and "including" or "ignoring")) + if state.pasture then + print("not milking/shearing animals inside pastures without workshops") + end +elseif positionals[1] == "set" then + if positionals[2] == "default" then + state = get_default_state() + else + setFlags(positionals, true) + end +elseif positionals[1] == "unset" then + setFlags(positionals, false) +elseif positionals[1] == "now" then + action() +else + qerror("unrecognized option") +end +persist_state() diff --git a/idle-crafting.lua b/idle-crafting.lua new file mode 100644 index 0000000000..e5d25751bf --- /dev/null +++ b/idle-crafting.lua @@ -0,0 +1,693 @@ +--@module = true +--@enable = true + +local overlay = require('plugins.overlay') +local widgets = require('gui.widgets') +local repeatutil = require("repeat-util") +local orders = require('plugins.orders') + +---iterate over input materials of workshop with stockpile links +---@param workshop df.building_workshopst +---@param action fun(item:df.item):any +local function for_inputs(workshop, action) + if #workshop.profile.links.take_from_pile == 0 then + dfhack.error('workshop has no links') + else + for _, stockpile in ipairs(workshop.profile.links.take_from_pile) do + for _, item in ipairs(dfhack.buildings.getStockpileContents(stockpile)) do + if item:isAssignedToThisStockpile(stockpile.id) then + for _, contained_item in ipairs(dfhack.items.getContainedItems(item)) do + action(contained_item) + end + else + action(item) + end + end + end + for _, contained_item in ipairs(workshop.contained_items) do + if contained_item.use_mode == df.building_item_role_type.TEMP then + action(contained_item.item) + end + end + end +end + +---choose random value based on positive integer weights +---@generic T +---@param choices table +---@return T +function weightedChoice(choices) + local sum = 0 + for _, weight in pairs(choices) do + sum = sum + weight + end + if sum <= 0 then + return nil + end + local random = math.random(sum) + for choice, weight in pairs(choices) do + if random > weight then + random = random - weight + else + return choice + end + end + return nil --never reached on well-formed input +end + +---make totem at specified workshop +---@param unit df.unit +---@param workshop df.building_workshopst +---@return boolean +function makeTotem(unit, workshop) + local job = dfhack.job.createLinked() + job.job_type = df.job_type.MakeTotem + job.mat_type = -1 + + local jitem = df.job_item:new() + jitem.item_type = df.item_type.NONE --the game seems to leave this uninitialized + jitem.mat_type = -1 + jitem.mat_index = -1 + jitem.quantity = 1 + jitem.vector_id = df.job_item_vector_id.ANY_REFUSE + jitem.flags1.unrotten = true + jitem.flags2.totemable = true + jitem.flags2.body_part = true + job.job_items.elements:insert('#', jitem) + + dfhack.job.assignToWorkshop(job, workshop) + return dfhack.job.addWorker(job, unit) +end + +---make totem at specified workshop +---@param unit df.unit +---@param workshop df.building_workshopst +---@return boolean +function makeHornCrafts(unit, workshop) + local job = dfhack.job.createLinked() + job.job_type = df.job_type.MakeCrafts + job.mat_type = -1 + job.material_category.horn = true + + local jitem = df.job_item:new() + jitem.item_type = df.item_type.NONE --the game seems to leave this uninitialized + jitem.mat_type = -1 + jitem.mat_index = -1 + jitem.quantity = 1 + jitem.vector_id = df.job_item_vector_id.ANY_REFUSE + jitem.flags1.unrotten = true + jitem.flags2.horn = true + jitem.flags2.body_part = true + job.job_items.elements:insert('#', jitem) + + dfhack.job.assignToWorkshop(job, workshop) + return dfhack.job.addWorker(job, unit) +end + +---make bone crafts at specified workshop +---@param unit df.unit +---@param workshop df.building_workshopst +---@return boolean +function makeBoneCraft(unit, workshop) + local job = dfhack.job.createLinked() + job.job_type = df.job_type.MakeCrafts + job.mat_type = -1 + job.material_category.bone = true + + local jitem = df.job_item:new() + jitem.item_type = df.item_type.NONE + jitem.mat_type = -1 + jitem.mat_index = -1 + jitem.quantity = 1 + jitem.vector_id = df.job_item_vector_id.ANY_REFUSE + jitem.flags1.unrotten = true + jitem.flags2.bone = true + jitem.flags2.body_part = true + job.job_items.elements:insert('#', jitem) + + dfhack.job.assignToWorkshop(job, workshop) + return dfhack.job.addWorker(job, unit) +end + +---make shell crafts at specified workshop +---@param unit df.unit +---@param workshop df.building_workshopst +---@return boolean +function makeShellCraft(unit, workshop) + local job = dfhack.job.createLinked() + job.job_type = df.job_type.MakeCrafts + job.mat_type = -1 + job.material_category.shell = true + + local jitem = df.job_item:new() + jitem.item_type = df.item_type.NONE + jitem.mat_type = -1 + jitem.mat_index = -1 + jitem.quantity = 1 + jitem.vector_id = df.job_item_vector_id.ANY_REFUSE + jitem.flags1.unrotten = true + jitem.flags2.shell = true + jitem.flags2.body_part = true + job.job_items.elements:insert('#', jitem) + + dfhack.job.assignToWorkshop(job, workshop) + return dfhack.job.addWorker(job, unit) +end + +---make rock crafts at specified workshop +---@param unit df.unit +---@param workshop df.building_workshopst +---@return boolean "" +function makeRockCraft(unit, workshop) + local job = dfhack.job.createLinked() + job.job_type = df.job_type.MakeCrafts + job.mat_type = 0 + + local jitem = df.job_item:new() + jitem.item_type = df.item_type.BOULDER + jitem.mat_type = 0 + jitem.mat_index = -1 + jitem.quantity = 1 + jitem.vector_id = df.job_item_vector_id.BOULDER + jitem.flags2.non_economic = true + jitem.flags3.hard = true + job.job_items.elements:insert('#', jitem) + + dfhack.job.assignToWorkshop(job, workshop) + return dfhack.job.addWorker(job, unit) +end + +---categorize and count crafting materials (for Craftsdwarf's workshop) +---@param tab table +---@param item df.item +local function categorize_craft(tab,item) + if df.item_corpsepiecest:is_instance(item) then + if item.corpse_flags.bone then + tab['bone'] = (tab['bone'] or 0) + item.material_amount.Bone + elseif item.corpse_flags.skull then + tab['skull'] = (tab['skull'] or 0) + 1 + elseif item.corpse_flags.horn then + tab['horn'] = (tab['horn'] or 0) + item.material_amount.Horn + elseif item.corpse_flags.shell then + tab['shell'] = (tab['shell'] or 0) + 1 + end + elseif df.item_boulderst:is_instance(item) then + tab['boulder'] = (tab['boulder'] or 0) + 1 + end +end + +-- script logic + +local GLOBAL_KEY = 'idle-crafting' + +enabled = enabled or false +function isEnabled() + return enabled +end + +---IDs of workshops where idle crafting is permitted +---@type table +allowed = allowed or {} + +---IDs of workshops that have encountered failures (e.g. missing materials) +---@type table +failing = failing or {} + +---IDs of watched units in need of crafting items +---@type table[] +watched = watched or {} + +---priority thresholds for crafting needs +---@type integer[] +thresholds = thresholds or { 10000, 1000, 500 } + +---@type boolean +ignore_happy = ignore_happy == nil and true or ignore_happy + +-- 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 +-- also, we clear the frame counter values since the frame counter gets reset on load +local function to_persist_allowed() + local persistable_allowed = {} + for workshop_id in pairs(allowed) do + persistable_allowed[tostring(workshop_id)] = -1 + end + return persistable_allowed +end + +-- loads both from the older array format and the new string table format +local function from_persist_allowed(persisted_allowed) + if not persisted_allowed then + return + end + local usable_allowed = {} + for workshop_id in pairs(persisted_allowed) do + usable_allowed[tonumber(workshop_id)] = -1 + end + return usable_allowed +end + +local function persist_state() + dfhack.persistent.saveSiteData(GLOBAL_KEY, { + enabled=enabled, + allowed=to_persist_allowed(), + thresholds=thresholds, + ignore_happy=ignore_happy + }) +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 + allowed = from_persist_allowed(persisted_data.allowed) or {} + thresholds = persisted_data.thresholds or { 10000, 1000, 500 } + ignore_happy = persisted_data.ignore_happy == nil and true or ignore_happy +end + +--frequently accessed values +local CraftObject = df.need_type['CraftObject'] +local BONE_CARVE = df.unit_labor['BONE_CARVE'] +local STONE_CRAFT = df.unit_labor['STONE_CRAFT'] + +---negative crafting focus penalty +---@generic T +---@param unit df.unit +---@param value_if_absent T +---@return number|T +function getCraftingNeed(unit, value_if_absent) + local focus_penalty = dfhack.units.getFocusPenalty(unit, CraftObject) + return focus_penalty > 1000 and value_if_absent or -focus_penalty +end + +local function stop() + enabled = false + repeatutil.cancel(GLOBAL_KEY .. 'main') + repeatutil.cancel(GLOBAL_KEY .. 'unit') +end + +local function checkForWorkshop() + if not next(allowed) then + -- print('no available workshops, disabling') + stop() + end +end + +---retrieve workshop by id +---@param id integer +---@return df.building_workshopst|nil +local function locateWorkshop(id) + local workshop = df.building.find(id) + if df.building_workshopst:is_instance(workshop) and workshop.type == df.workshop_type.Craftsdwarfs then + return workshop + else + return nil + end +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 + +---select crafting job based on available resources +---@param workshop df.building_workshopst +---@return (fun(unit:df.unit, workshop:df.building_workshopst):boolean)? +function select_crafting_job(workshop) + local tab = {} + for_inputs(workshop, curry(categorize_craft,tab)) + local blocked_labors = workshop.profile.blocked_labors + if blocked_labors[STONE_CRAFT] then + tab['boulder'] = nil + end + if blocked_labors[BONE_CARVE] then + tab['bone'] = nil + tab['skull'] = nil + tab['horn'] = nil + tab['shell'] = nil + end + local material = weightedChoice(tab) + if material == 'bone' then return makeBoneCraft + elseif material == 'skull' then return makeTotem + elseif material == 'horn' then return makeHornCrafts + elseif material == 'shell' then return makeShellCraft + elseif material == 'boulder' then return makeRockCraft + else + return nil + end +end + + +---check if unit is ready and try to create a crafting job for it +---@param workshop df.building_workshopst +---@param idx integer "index of the unit's group" +---@param unit_id integer +---@return boolean "proceed to next workshop" +local function processUnit(workshop, idx, unit_id) + local unit = df.unit.find(unit_id) + -- check that unit is still there, not caged or chained, and still has crafting needs + if not unit or unit.flags1.caged or unit.flags1.chained or getCraftingNeed(unit, -1) < 0 then + watched[idx][unit_id] = nil + return false + elseif not canAccessWorkshop(unit, workshop) then + -- dfhack.print('-') + return false + elseif not dfhack.units.isJobAvailable(unit) then + -- dfhack.print('.') + return false + end + -- We have an available unit + local success = false + if #workshop.profile.links.take_from_pile == 0 then + -- can we do something smarter here? + if workshop.profile.blocked_labors[STONE_CRAFT] == false then + success = makeRockCraft(unit, workshop) + end + if not success and workshop.profile.blocked_labors[BONE_CARVE] == false then + success = makeBoneCraft(unit, workshop) + end + if not success then + dfhack.printerr('idle-crafting: profile allows neither bone carving nor stonecrafting') + end + else + local craftItem = select_crafting_job(workshop) + if craftItem then + success = craftItem(unit, workshop) + else + print('idle-crafting: workshop has no usable materials in linked stockpiles') + failing[workshop.id] = true + end + end + if success then + print('idle-crafting: assigned crafting job to ' .. dfhack.df2console(dfhack.units.getReadableName(unit))) + watched[idx][unit_id] = nil + allowed[workshop.id] = df.global.world.frame_counter + end + return true +end + + +---@param workshop df.building_workshopst +---@return boolean +local function invalidProfile(workshop) + local profile = workshop.profile + return (#profile.permitted_workers > 0) or + (profile.blocked_labors[BONE_CARVE] and profile.blocked_labors[STONE_CRAFT]) +end + +-- try to catch units that currently don't have a job and send them to satisfy +-- their crafting needs. +local function unit_loop() + local current_frame = df.global.world.frame_counter + for workshop_id, last_job_frame in pairs(allowed) do + -- skip workshops where jobs appear to have been cancelled (e.g. to missing materials) + if failing[workshop_id] then + goto next_workshop + end + + local workshop = locateWorkshop(workshop_id) + -- workshop may have been destroyed, assigned a master, or does not allow crafting + if not workshop or invalidProfile(workshop) then + -- print('workshop destroyed or has invalid profile') + allowed[workshop_id] = nil --clearing during iteration is permitted + goto next_workshop + end + + -- only consider workshop if not currently in use + if #workshop.jobs > 0 then + goto next_workshop + end + + -- check that we didn't schedule a job on the last iteration + if (last_job_frame >= 0) and (current_frame < last_job_frame + 60) then + -- print(('idle-crafting: disabling failing workshop (%d) until the next run of main loop'): + -- format(workshop_id)) + failing[workshop_id] = true + goto next_workshop + end + + -- dfhack.print(('idle-crafting: locating crafter for %s (%d)'): + -- format(dfhack.buildings.getName(workshop), workshop_id)) + + -- workshop is free to use, try to find a unit + for idx, _ in ipairs(thresholds) do + for unit_id, _ in pairs(watched[idx]) do + if processUnit(workshop, idx, unit_id) then + goto next_workshop + end + end + -- dfhack.print('/') + end + + -- print('no unit found') + ::next_workshop:: + end + -- disable loop if there are no more units + if not next(watched) then + repeatutil.cancel(GLOBAL_KEY .. 'unit') + end + -- disable tool if there are no more workshops + checkForWorkshop() + persist_state() +end + +local function main_loop() + -- print('idle crafting: running main loop') + checkForWorkshop() + if not enabled then + return + end + -- put failing workshops back into the loop + failing = {} + for workshop_id, _ in pairs(allowed) do + allowed[workshop_id] = -1 + end + + local num_watched = {} + local watching = false + + ---@type table[] + watched = {} + for idx, _ in ipairs(thresholds) do + watched[idx] = {} + num_watched[idx] = 0 + end + + local max_threshold = thresholds[1] + for _, unit in ipairs(dfhack.units.getCitizens(true, false)) do + + local crafting_need = getCraftingNeed(unit, nil) + + if not crafting_need then + goto next_unit + end + + local is_happy = unit.status.current_soul.personality.stress < -25000 and + unit.status.current_soul.personality.longterm_stress < 0 + + for idx, threshold in ipairs(thresholds) do + if ignore_happy and is_happy and threshold < max_threshold then + -- ignore happy and ecstatic units for any threshold but the highest + -- print(dfhack.df2console(("idle-crafting: skipping happy unit %s"):format(dfhack.units.getReadableName(unit)))) + goto next_unit + end + + if crafting_need > threshold then + watched[idx][unit.id] = true + num_watched[idx] = num_watched[idx] + 1 + watching = true + goto next_unit + end + end + ::next_unit:: + end + -- print(('watching %s dwarfs with crafting needs'):format( + -- table.concat(num_watched, '/') + -- )) + + if watching then + repeatutil.scheduleUnlessAlreadyScheduled(GLOBAL_KEY .. 'unit', 53, 'ticks', unit_loop) + end +end + +---enable main loop +---@param enable boolean|nil +local function start(enable) + enabled = enable or enabled + if enabled then + repeatutil.scheduleUnlessAlreadyScheduled(GLOBAL_KEY .. 'main', 8419, 'ticks', main_loop) + end +end + +--- Handles automatic loading +dfhack.onStateChange[GLOBAL_KEY] = function(sc) + 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() + start() +end + +-- +-- Overlay to select workshops +-- + + +IdleCraftingOverlay = defclass(IdleCraftingOverlay, overlay.OverlayWidget) +IdleCraftingOverlay.ATTRS { + desc = "Adds a toggle for recreational crafting to Craftdwarf's workshops.", + default_pos = { x = -39, y = 41 }, + version = 2, + default_enabled = true, + viewscreens = { + 'dwarfmode/ViewSheets/BUILDING/Workshop/Craftsdwarfs/Workers', + }, + frame = { w = 58, h = 1 }, + visible = orders.can_set_labors +} + +function IdleCraftingOverlay:init() + self:addviews { + widgets.BannerPanel{ + frame={l=0, w=54}, + subviews={ + widgets.CycleHotkeyLabel { + view_id = 'leisure_toggle', + frame = { t=0, l = 1, r = 1 }, + label = 'Allow idle dwarves to satisfy crafting needs:', + key = 'CUSTOM_I', + options = { + { label = 'yes', value = true, pen = COLOR_GREEN }, + { label = 'no', value = false }, + }, + initial_option = 'no', + on_change = self:callback('onClick'), + enabled = function() + local bld = dfhack.gui.getSelectedBuilding(true) + if not bld then return end + return not invalidProfile(bld) + end, + } + }, + }, + widgets.HelpButton{ + frame={r=0}, + command='idle-crafting', + }, + } +end + +function IdleCraftingOverlay:onClick(new, _) + local workshop = dfhack.gui.getSelectedBuilding(true) + allowed[workshop.id] = new and -1 or nil + if new and not enabled then + start(true) + end + if not next(allowed) then + stop() + end + persist_state() +end + +function IdleCraftingOverlay:onRenderBody(painter) + local workshop = dfhack.gui.getSelectedBuilding(true) + if not workshop then + return + end + self.subviews.leisure_toggle:setOption(allowed[workshop.id] or false) +end + +OVERLAY_WIDGETS = { + idlecrafting = IdleCraftingOverlay +} + +-- +-- commandline interface +-- + +if dfhack_flags.module then + return +end + +if df.global.gamemode ~= df.game_mode.DWARF then + qerror('this tool requires a loaded fort') +end + +if dfhack_flags.enable then + if dfhack_flags.enable_state then + qerror('This tool is enabled by permitting idle crafting at a Craftsdarf\'s workshop') + else + allowed = {} + stop() + persist_state() + return + end +end + +local fulfillment_level = +{ 'unfettered', 'level-headed', 'untroubled', 'not distracted', 'unfocused', 'distracted', 'badly distracted' } +local fulfillment_threshold = +{ 300, 200, 100, -999, -9999, -99999, -500000 } + +local argparse = require('argparse') + +load_state() +local positionals = argparse.processArgsGetopt({ ... }, {}) + +if not positionals[1] or positionals[1] == 'status' then + ---@type integer[] + stats = {} + for _, unit in ipairs(dfhack.units.getCitizens(true, false)) do + local crafting_need = getCraftingNeed(unit, nil) + if not crafting_need then + goto continue + end + for i = 1, 7 do + if -crafting_need >= fulfillment_threshold[i] then + stats[i] = stats[i] and stats[i] + 1 or 1 + goto continue + end + end + ::continue:: + end + print('Fulfillment levels for "craft item" needs') + for k, v in pairs(stats) do + print(('%4d %s'):format(v, fulfillment_level[k])) + end + local num_workshops = 0 + for _, _ in pairs(allowed) do + num_workshops = num_workshops + 1 + end + print(('Script is %s with %d workshops configured for idle crafting'): + format(enabled and 'enabled' or 'disabled', num_workshops)) + print(('The thresholds for "craft item" needs are %s'): + format(table.concat(thresholds, ','))) + if ignore_happy then + print('Will only assign crafting jobs to happy dwarves with strong needs') + else + print('Will treat happy units like all other units') + end + +elseif positionals[1] == 'thresholds' then + thresholds = argparse.numberList(positionals[2], 'thresholds') + table.sort(thresholds, function (a, b) return a > b end) + print(('Thresholds for "craft item" needs set to %s'): + format(table.concat(thresholds, ','))) +elseif positionals[1] == 'happy' then + ignore_happy = not argparse.boolean(positionals[2], 'happy') +elseif positionals[1] == 'disable' then + allowed = {} + stop() +end +persist_state() diff --git a/immortal-cravings.lua b/immortal-cravings.lua new file mode 100644 index 0000000000..654a6ac245 --- /dev/null +++ b/immortal-cravings.lua @@ -0,0 +1,302 @@ +--@enable = true +--@module = true + +local repeatutil = require("repeat-util") + +--- constants + +local focus_threshold = -9000 +local dependence_threshold = 33600 * 2 + +--- utility functions + +verbose = false +---conditional printing of debug messages +---@param message string +local function debug(message) + if verbose then + print(message) + end +end + +---3D city metric +---@param p1 df.coord +---@param p2 df.coord +---@return number +function distance(p1, p2) + return math.max(math.abs(p1.x - p2.x), math.abs(p1.y - p2.y)) + math.abs(p1.z - p2.z) +end + +---maybe a candidate for utils.lua? +---find best available item in an item vector (according to some metric) +---@generic T : df.item +---@param item_vector T[] +---@param metric fun(item: T): number? +---@return T? +function findBest(item_vector, metric, smallest) + local best = nil + local mbest = nil + for _, item in ipairs(item_vector) do + mitem = metric(item) + if mitem and (not best or (smallest and mitem < mbest or mitem > mbest)) then + best = item + mbest = mitem + end + end + return best +end + +---find closest accessible item in an item vector +---@generic T : df.item +---@param pos df.coord +---@param item_vector T[] +---@param is_good? fun(item: T): boolean +---@return T? +local function findClosest(pos, item_vector, is_good) + local function metric(item) + if not is_good or is_good(item) then + local pitem = xyz2pos(dfhack.items.getPosition(item)) + return dfhack.maps.canWalkBetween(pos, pitem) and distance(pos, pitem) or nil + end + return nil + end + return findBest(item_vector, metric, true) +end + +---find a drink +---@param pos df.coord +---@return df.item_drinkst? +local function get_closest_drink(pos) + local is_good = function (drink) + local container = dfhack.items.getContainer(drink) + return not drink.flags.in_job and container and container:isFoodStorage() + end + return findClosest(pos, df.global.world.items.other.DRINK, is_good) +end + +---find available meal with highest per-portion value +---@return df.item_foodst? +local function get_best_meal(pos) + + ---@param meal df.item_foodst + local function portion_value(meal) + local accessible = dfhack.maps.canWalkBetween(pos,xyz2pos(dfhack.items.getPosition(meal))) + if meal.flags.in_job or meal.flags.rotten or not accessible then + return nil + else + -- check that meal is either on the ground or in food storage (and not in a backpack) + local container = dfhack.items.getContainer(meal) + if not container or container:isFoodStorage() then + return dfhack.items.getValue(meal) / meal.stack_size + else + return nil + end + end + end + + return findBest(df.global.world.items.other.FOOD, portion_value) +end + +---create a Drink job for the given unit +---@param unit df.unit +local function goDrink(unit) + local drink = get_closest_drink(unit.pos) + if not drink then + -- print('no accessible drink found') + return + end + local job = dfhack.job.createLinked() + job.job_type = df.job_type.DrinkItem + job.flags.special = true + local dx, dy, dz = dfhack.items.getPosition(drink) + job.pos = xyz2pos(dx, dy, dz) + if not dfhack.job.attachJobItem(job, drink, df.job_role_type.Other, -1, -1) then + error('could not attach drink') + return + end + dfhack.job.addWorker(job, unit) + local name = dfhack.units.getReadableName(unit) + print(dfhack.df2console('immortal-cravings: %s is getting a drink'):format(name)) +end + +---create Eat job for the given unit +---@param unit df.unit +local function goEat(unit) + local meal_stack = get_best_meal(unit.pos) + if not meal_stack then + -- print('no accessible meals found') + return + end + + ---@type df.item|df.item_foodst + local meal + if meal_stack.stack_size > 1 then + meal = meal_stack:splitStack(1, true) + meal:categorize(true) + else + meal = meal_stack + end + dfhack.items.setOwner(meal, unit) + + local job = dfhack.job.createLinked() + job.job_type = df.job_type.Eat + job.flags.special = true + local dx, dy, dz = dfhack.items.getPosition(meal) + job.pos = xyz2pos(dx, dy, dz) + if not dfhack.job.attachJobItem(job, meal, df.job_role_type.Other, -1, -1) then + error('could not attach meal') + return + end + dfhack.job.addWorker(job, unit) + local name = dfhack.units.getReadableName(unit) + print(dfhack.df2console('immortal-cravings: %s is getting something to eat'):format(name)) +end + +---@param unit df.unit +---@param flag_name string +local function is_active_caste_flag(unit, flag_name) + return not unit.uwss_remove_caste_flag[flag_name] and + (unit.uwss_add_caste_flag[flag_name] or dfhack.units.casteFlagSet(unit.race, unit.caste, df.caste_raw_flags[flag_name])) +end + +---@param unit df.unit +local function needs_alcohol(unit) + local mt = dfhack.units.getMiscTrait(unit, df.misc_trait_type.WantsDrink, false) + return mt and mt.value > dependence_threshold +end + +--- script logic + +local GLOBAL_KEY = 'immortal-cravings' + +enabled = enabled or false +function isEnabled() + return enabled +end + +local function persist_state() + dfhack.persistent.saveSiteData(GLOBAL_KEY, { + enabled=enabled, + }) +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 +end + +DrinkAlcohol = df.need_type.DrinkAlcohol +EatGoodMeal = df.need_type.EatGoodMeal + +---@type integer[] +watched = watched or {} + +---unit loop: check for idle watched units and create eat/drink jobs for them +local function unit_loop() + debug(('immortal-cravings: running unit loop (%d watched units)'):format(#watched)) + ---@type integer[] + local kept = {} + for _, unit_id in ipairs(watched) do + local unit = df.unit.find(unit_id) + if + not unit or not dfhack.units.isActive(unit) or + unit.flags1.caged or unit.flags1.chained + then + goto next_unit + end + if not dfhack.units.isJobAvailable(unit) then + debug("immortal-cravings: skipping busy"..dfhack.units.getReadableName(unit)) + table.insert(kept, unit.id) + else + -- unit is alcohol dependent and approaching withdrawal symptoms + if needs_alcohol(unit) then + goDrink(unit) + goto next_unit + end + + -- otherwise satisfy a personality need + for _, need in ipairs(unit.status.current_soul.personality.needs) do + if need.id == DrinkAlcohol and need.focus_level < focus_threshold then + goDrink(unit) + break + elseif need.id == EatGoodMeal and need.focus_level < focus_threshold then + goEat(unit) + break + end + end + end + ::next_unit:: + end + watched = kept + if #watched == 0 then + debug('immortal-cravings: no more watched units, cancelling unit loop') + repeatutil.cancel(GLOBAL_KEY .. '-unit') + end +end + + + +---main loop: look for citizens with personality needs for food/drink but w/o physiological need +local function main_loop() + debug('immortal-cravings watching:') + watched = {} + for _, unit in ipairs(dfhack.units.getCitizens(false, false)) do + if + (is_active_caste_flag(unit, 'NO_DRINK') or is_active_caste_flag(unit, 'NO_EAT')) and + unit.counters2.stomach_content == 0 and + (dfhack.units.getFocusPenalty(unit, DrinkAlcohol, EatGoodMeal) < focus_threshold or + needs_alcohol(unit)) + then + table.insert(watched, unit.id) + debug(' ' .. dfhack.df2console(dfhack.units.getReadableName(unit))) + end + end + + if #watched > 0 then + repeatutil.scheduleUnlessAlreadyScheduled(GLOBAL_KEY..'-unit', 59, 'ticks', unit_loop) + end +end + +local function start() + if enabled then + repeatutil.scheduleUnlessAlreadyScheduled(GLOBAL_KEY..'-main', 4003, 'ticks', main_loop) + end +end + +local function stop() + repeatutil.cancel(GLOBAL_KEY..'-main') + repeatutil.cancel(GLOBAL_KEY..'-unit') +end + + + +-- script action + +--- Handles automatic loading +dfhack.onStateChange[GLOBAL_KEY] = function(sc) + if sc == SC_MAP_UNLOADED then + enabled = false + -- repeat-util will cancel the loops on unload + return + end + + if sc ~= SC_MAP_LOADED or df.global.gamemode ~= df.game_mode.DWARF then + return + end + + load_state() + start() +end + +if dfhack_flags.enable then + if dfhack_flags.enable_state then + enabled = true + start() + else + enabled = false + stop() + end + persist_state() +end diff --git a/instruments.lua b/instruments.lua new file mode 100644 index 0000000000..fe9c232472 --- /dev/null +++ b/instruments.lua @@ -0,0 +1,162 @@ +local argparse = require('argparse') +local workorder = reqscript('workorder') + +-- civilization ID of the player civilization +local civ_id = df.global.plotinfo.civ_id +local raws = df.global.world.raws + +---@type instrument itemdef_instrumentst +---@return reaction|nil +function getAssemblyReaction(instrument_id) + for _, reaction in ipairs(raws.reactions.reactions) do + if reaction.source_enid == civ_id and + reaction.category == 'INSTRUMENT' and + reaction.code:find(instrument_id, 1, true) + then + return reaction + end + end + return nil +end + +-- patch in thread type +---@type reagent reaction_reagent_itemst +---@return string +function reagentString(reagent) + if reagent.code == 'thread' then + local silk = reagent.flags2.silk and "silk " or "" + local yarn = reagent.flags2.yarn and "yarn " or "" + local plant = reagent.flags2.plant and "plant " or "" + return silk .. yarn .. plant .. "thread" + else + return reagent.code + end +end + +---@type reaction reaction +---@return string +function describeReaction(reaction) + local skill = df.job_skill[reaction.skill] + local reagents = {} + for _, reagent in ipairs(reaction.reagents) do + table.insert(reagents, reagentString(reagent)) + end + return skill .. ": " .. table.concat(reagents, ", ") +end + +local function print_list() + -- gather instrument piece reactions and index them by the instrument they are part of + local instruments = {} + for _, reaction in ipairs(raws.reactions.reactions) do + if reaction.source_enid == civ_id and reaction.category == 'INSTRUMENT_PIECE' then + local iname = reaction.name:match("[^ ]+ ([^ ]+)") + table.insert(ensure_key(instruments, iname), + reaction.name .. " (" .. describeReaction(reaction) .. ")") + end + end + + -- go over instruments + for _, instrument in ipairs(raws.itemdefs.instruments) do + if not (instrument.source_enid == civ_id) then goto continue end + + local building_tag = instrument.flags.PLACED_AS_BUILDING and " (building, " or " (handheld, " + local reaction = getAssemblyReaction(instrument.id) + dfhack.print(dfhack.df2console(instrument.name .. building_tag)) + if #instrument.pieces == 0 then + print(dfhack.df2console(describeReaction(reaction) .. ")")) + else + print(dfhack.df2console(df.job_skill[reaction.skill] .. "/assemble)")) + for _, str in pairs(instruments[instrument.name]) do + print(dfhack.df2console(" " .. str)) + end + end + print() + ::continue:: + end +end + +local function order_instrument(name, amount, quiet) + local instrument = nil + + for _, instr in ipairs(raws.itemdefs.instruments) do + if dfhack.toSearchNormalized(instr.name) == name and instr.source_enid == civ_id then + instrument = instr + end + end + + if not instrument then + qerror("Could not find instrument " .. name) + end + + local orders = {} + + for i, reaction in ipairs(raws.reactions.reactions) do + if reaction.source_enid == civ_id and reaction.category == 'INSTRUMENT_PIECE' and reaction.code:find(instrument.id, 1, true) then + local part_order = { + id=i, + amount_total=amount, + reaction=reaction.code, + job="CustomReaction", + } + table.insert(orders, part_order) + end + end + + if #orders < #instrument.pieces then + print("Warning: Could not find reactions for all instrument pieces") + end + + local assembly_reaction = getAssemblyReaction(instrument.id) + + local assembly_order = { + id=-1, + amount_total=amount, + reaction=assembly_reaction.code, + job="CustomReaction", + order_conditions={} + } + + for _, order in ipairs(orders) do + table.insert( + assembly_order.order_conditions, + { + condition="Completed", + order=order.id + } + ) + end + + table.insert(orders, assembly_order) + + orders = workorder.preprocess_orders(orders) + workorder.fillin_defaults(orders) + workorder.create_orders(orders, quiet) + + if not quiet then + print("\nCreated " .. #orders .. " work orders") + end +end + +local help = false +local quiet = false +local positionals = argparse.processArgsGetopt({...}, { + {'h', 'help', handler=function() help = true end}, + {'q', 'quiet', handler=function() quiet = true end}, +}) + +if help or positionals[1] == 'help' then + print(dfhack.script_help()) + return +end + +if #positionals == 0 or positionals[1] == "list" then + print_list() +elseif positionals[1] == "order" then + local instrument_name = positionals[2] + if not instrument_name then + qerror("Usage: instruments order []") + end + + local amount = positionals[3] or 1 + order_instrument(instrument_name, amount, quiet) +end diff --git a/internal/advfort/advfort_items.lua b/internal/advfort/advfort_items.lua index 2842849e6d..32131521c3 100644 --- a/internal/advfort/advfort_items.lua +++ b/internal/advfort/advfort_items.lua @@ -35,7 +35,7 @@ function update_slot_text(slot) slot.text=string.format("%02d. Filled(%d/%d):%s",slot.id+1,slot.filled_amount,slot.job_item.quantity,items) end ---items-> table => key-> id of job.job_items, value-> table => key (num), value => item(ref) +--items-> table => key-> id of job.job_items.elements, value-> table => key (num), value => item(ref) function jobitemEditor:init(args) --self.job=args.job if self.job==nil and self.job_items==nil then qerror("This screen must have job target or job_items list") end @@ -156,7 +156,7 @@ function jobitemEditor:fill() local job_item if self.job then - job_item=self.job.job_items[k] + job_item=self.job.job_items.elements[k] else job_item=self.job_items[k] end @@ -189,7 +189,7 @@ function jobitemEditor:commit() for _,slot in pairs(self.slots) do if slot.id == orderedSlotID then for _1,cur_item in pairs(slot.items) do - self.job.items:insert("#",{new=true,item=cur_item,role=df.job_item_ref.T_role.Reagent,job_item_idx=slot.id}) + self.job.items:insert("#",{new=true,item=cur_item,role=df.job_role_type.Reagent,job_item_idx=slot.id}) end end end diff --git a/internal/advtools/convo.lua b/internal/advtools/convo.lua new file mode 100644 index 0000000000..2b66a4ed66 --- /dev/null +++ b/internal/advtools/convo.lua @@ -0,0 +1,175 @@ +--@ module=true + +local overlay = require('plugins.overlay') +local utils = require('utils') + +local ignore_words = utils.invert{ + "a", "an", "by", "in", "occurred", "of", "or", + "s", "the", "this", "to", "was", "which" +} + +local adventure = df.global.game.main_interface.adventure + +-- Gets the keywords already present on the dialog choice +local function getKeywords(choice) + local keywords = {} + for _, keyword in ipairs(choice.keywords) do + table.insert(keywords, keyword.value:lower()) + end + return keywords +end + +-- Adds a keyword to the dialog choice +local function addKeyword(choice, keyword) + local keyword_ptr = df.new('string') + keyword_ptr.value = dfhack.toSearchNormalized(keyword) + choice.keywords:insert('#', keyword_ptr) +end + +-- Adds multiple keywords to the dialog choice +local function addKeywords(choice, keywords) + for _, keyword in ipairs(keywords) do + addKeyword(choice, keyword) + end +end + +-- Generates keywords based on the text of the dialog choice, plus keywords for special cases +local function generateKeywordsForChoice(choice) + local new_keywords, keywords_set = {}, utils.invert(getKeywords(choice)) + + -- Puts the keyword into a new_keywords table, but only if unique and not ignored + local function collect_keyword(word) + if ignore_words[word] or keywords_set[word] then return end + table.insert(new_keywords, word) + keywords_set[word] = true + end + + -- generate keywords from useful words in the text + for _, data in ipairs(choice.title.text) do + for word in dfhack.toSearchNormalized(data.value):gmatch('%w+') do + -- collect additional keywords based on the special words + if word == 'slew' or word == 'slain' then + collect_keyword('kill') + collect_keyword('slay') + elseif word == 'you' or word == 'your' then + collect_keyword('me') + end + -- collect the actual word if it's unique and not ignored + collect_keyword(word) + end + end + addKeywords(choice, new_keywords) +end + +-- Helper function to create new dialog choices, returns the created choice +local function new_choice(choice_type, title, keywords) + local choice = df.adventure_conversation_choice_infost:new() + choice.choice = df.talk_choice:new() + choice.choice.type = choice_type + local text = df.new("string") + text.value = title + choice.title.text:insert("#", text) + + if keywords ~= nil then + addKeywords(choice, keywords) + end + return choice +end + +local function addWhereaboutsChoice(race, name, target_id, heard_of) + local title = "Ask for the whereabouts of the " .. race .. " " .. dfhack.translation.translateName(name, true) + if heard_of then + title = title .. " (Heard of)" + end + local choice = new_choice(df.talk_choice_type.AskForDirectionsToHF, title, dfhack.translation.translateName(name):split()) + -- insert before the last choice, which is usually "back" + adventure.conversation.conv_choice_info:insert(#adventure.conversation.conv_choice_info-1, choice) + choice.choice.invocation_target_hfid = target_id +end + +local function addHistFigWhereaboutsChoice(profile) + local histfig = df.historical_figure.find(profile.histfig_id) + local race = "" + local creature = df.creature_raw.find(histfig.race) + if creature then + local caste = creature.caste[histfig.caste] + race = caste.caste_name[0] + end + + addWhereaboutsChoice(race, histfig.name, histfig.id, profile._type == df.relationship_profile_hf_historicalst) +end + +local function addIdentityWhereaboutsChoice(identity) + local identity_name = identity.name + local race = "" + local creature = df.creature_raw.find(identity.race) + if creature then + local caste = creature.caste[identity.caste] + race = caste.caste_name[0] + else + -- no race given for the identity, assume it's the histfig + local histfig = df.historical_figure.find(identity.histfig_id) + creature = df.creature_raw.find(histfig.race) + if creature then + local caste = creature.caste[histfig.caste] + race = caste.caste_name[0] + end + end + addWhereaboutsChoice(race, identity_name, identity.impersonated_hf) +end + +-- Condense the rumor system choices +local function rumorUpdate() + local conversation_state = adventure.conversation.conv_act.events[0].menu + -- add new conversation options depending on state + + -- If we're asking about directions, add ability to ask about all our relationships - visual, historical and identities. + -- In vanilla, we're only allowed to ask for directions to people we learned in anything that's added to df.global.adventure.rumor + if conversation_state == df.conversation_state_type.AskDirections then + local adventurer_figure = df.historical_figure.find(dfhack.world.getAdventurer().hist_figure_id) + local relationships = adventurer_figure.info.relationships + + local visual = relationships.hf_visual + local historical = relationships.hf_historical + local identity = relationships.hf_identity + + for _, profile in ipairs(visual) do + addHistFigWhereaboutsChoice(profile) + end + + -- This option will likely always fail unless the false identity is impersonating someone + -- but giving away the false identity's true historical figure feels cheap. + for _, profile in ipairs(identity) do + addIdentityWhereaboutsChoice(df.identity.find(profile.identity_id)) + end + + -- Historical entities go last so as to not give away fake identities + for _, profile in ipairs(historical) do + addHistFigWhereaboutsChoice(profile) + end + end + + -- generate extra keywords for all options + for i, choice in ipairs(adventure.conversation.conv_choice_info) do + generateKeywordsForChoice(choice) + end +end + +-- Overlay + +AdvRumorsOverlay = defclass(AdvRumorsOverlay, overlay.OverlayWidget) +AdvRumorsOverlay.ATTRS{ + desc='Adds keywords to conversation entries.', + default_enabled=true, + viewscreens='dungeonmode/Conversation', + frame={w=0, h=0}, +} + +local last_first_entry = nil +function AdvRumorsOverlay:render() + -- Only update if the first entry pointer changed, this reliably indicates the list changed + if #adventure.conversation.conv_choice_info <= 0 or last_first_entry == adventure.conversation.conv_choice_info[0] then return end + -- Remember the last first entry. This entry changes even if we quit out and return on the same menu! + last_first_entry = adventure.conversation.conv_choice_info[0] + rumorUpdate() +end diff --git a/internal/advtools/fastcombat.lua b/internal/advtools/fastcombat.lua new file mode 100644 index 0000000000..f9d87c6868 --- /dev/null +++ b/internal/advtools/fastcombat.lua @@ -0,0 +1,89 @@ +--@ module=true +local overlay = require('plugins.overlay') +local gui = require('gui') +local widgets = require('gui.widgets') + +-- Overlay +AdvCombatOverlay = defclass(AdvCombatOverlay, overlay.OverlayWidget) +AdvCombatOverlay.ATTRS{ + desc='Skip combat animations and announcements with a click or key press.', + default_enabled=true, + viewscreens='dungeonmode', + fullscreen=true, + default_pos={x=1, y=7}, + frame={h=15}, +} + +function AdvCombatOverlay:init() + self.skip_combat = false + + self:addviews{ + widgets.Panel{ + frame={w=113}, + view_id='announcement_panel_mask' + } + } +end + +function AdvCombatOverlay:preUpdateLayout(parent_rect) + self.frame.w = parent_rect.width +end + +function AdvCombatOverlay:render(dc) + if df.global.adventure.player_control_state == df.adventure_game_loop_type.TAKING_INPUT then + self.skip_combat = false + return + end + if self.skip_combat then + -- Instantly process the projectile travelling + df.global.adventure.projsubloop_visible_projectile = false + -- Skip the combat swing animations + df.global.adventure.game_loop_animation_timer_start = df.global.adventure.game_loop_animation_timer_start + 1000 + end +end + + +local COMBAT_MOVE_KEYS = { + _MOUSE_L=true, + SELECT=true, + A_MOVE_N=true, + A_MOVE_S=true, + A_MOVE_E=true, + A_MOVE_W=true, + A_MOVE_NW=true, + A_MOVE_NE=true, + A_MOVE_SW=true, + A_MOVE_SE=true, + A_MOVE_SAME_SQUARE=true, + A_ATTACK=true, + A_COMBAT_ATTACK=true, +} + +function AdvCombatOverlay:onInput(keys) + for code,_ in pairs(keys) do + if not COMBAT_MOVE_KEYS[code] then goto continue end + if df.global.adventure.player_control_state ~= df.adventure_game_loop_type.TAKING_INPUT then + -- Instantly speed up the combat + self.skip_combat = true + elseif df.global.world.status.temp_flag.adv_showing_announcements then + -- Don't let mouse skipping work when you click within the adventure mode announcement panel + if keys._MOUSE_L and self.subviews.announcement_panel_mask:getMousePos() then + return + end + -- Instantly process the projectile travelling + -- (for some reason, projsubloop is still active during "TAKING INPUT" phase) + df.global.adventure.projsubloop_visible_projectile = false + + -- If there is more to be seen in this box... + if df.global.world.status.temp_flag.adv_have_more then + -- Scroll down to the very bottom + df.global.world.status.adv_scroll_position = #df.global.world.status.adv_announcement - 10 + -- Nothing new left to see, get us OUT OF HERE!! + else + -- Allow us to quit out of showing announcements by clicking anywhere OUTSIDE the box + df.global.world.status.temp_flag.adv_showing_announcements = false + end + end + ::continue:: + end +end diff --git a/internal/advtools/party.lua b/internal/advtools/party.lua new file mode 100644 index 0000000000..4ef9f7cbf9 --- /dev/null +++ b/internal/advtools/party.lua @@ -0,0 +1,51 @@ +--@ module=true + +local dialogs = require('gui.dialogs') +local utils = require('utils') + +local makeown = reqscript('makeown') + +local function addToCoreParty(nemesis) + -- Adds them to the party core members list + local party = df.global.adventure.interactions + -- problem: the "brain" icon deciding on manual/automatic control is somewhat broken. + -- research leads me to believe the data is stored per-unit or unit ID, need to figure out + -- where that data is stored exactly. Might be one of the unknown variables? + party.party_core_members:insert('#', nemesis.figure.id) + local extra_member_idx, _ = utils.linear_index(party.party_extra_members, nemesis.figure.id) + if extra_member_idx then + party.party_extra_members:erase(extra_member_idx) + end + -- Adds them to unretire list + nemesis.flags.ADVENTURER = true + + -- Make sure they're no longer nameless + local unit = df.unit.find(nemesis.figure.unit_id) + makeown.name_unit(unit) + if not nemesis.figure.name.has_name then + local old_name = nemesis.figure.name + nemesis.figure.name = unit.name:new() + old_name:delete() + end +end + +local function showExtraPartyPrompt() + local choices = {} + for _, histfig_id in ipairs(df.global.adventure.interactions.party_extra_members) do + local hf = df.historical_figure.find(histfig_id) + if not hf then goto continue end + local nemesis, unit = df.nemesis_record.find(hf.nemesis_id), df.unit.find(hf.unit_id) + if not nemesis or not unit or unit.flags2.killed then goto continue end + local name = dfhack.units.getReadableName(unit) + table.insert(choices, {text=name, nemesis=nemesis, search_key=dfhack.toSearchNormalized(name)}) + ::continue:: + end + dialogs.showListPrompt('party', 'Select someone to add to your "Core Party" (able to assume control, able to unretire):', COLOR_WHITE, + choices, function(id, choice) + addToCoreParty(choice.nemesis) + end, nil, nil, true) +end + +function run() + showExtraPartyPrompt() +end diff --git a/internal/caravan/common.lua b/internal/caravan/common.lua index 88527c3a1f..e0a54b0b04 100644 --- a/internal/caravan/common.lua +++ b/internal/caravan/common.lua @@ -2,6 +2,7 @@ local dialogs = require('gui.dialogs') local predicates = reqscript('internal/caravan/predicates') +local utils = require('utils') local widgets = require('gui.widgets') CH_UP = string.char(30) @@ -9,13 +10,12 @@ CH_DN = string.char(31) CH_MONEY = string.char(15) CH_EXCEPTIONAL = string.char(240) -local to_pen = dfhack.pen.parse -SOME_PEN = to_pen{ch=':', fg=COLOR_YELLOW} -ALL_PEN = to_pen{ch='+', fg=COLOR_LIGHTGREEN} +SOME_PEN = dfhack.pen.parse{ch=':', fg=COLOR_YELLOW} +ALL_PEN = dfhack.pen.parse{ch=string.char(251), fg=COLOR_LIGHTGREEN} function add_words(words, str) - for word in str:gmatch("[%w]+") do - table.insert(words, word:lower()) + for word in dfhack.toSearchNormalized(str):gmatch("[%w]+") do + table.insert(words, word) end end @@ -25,8 +25,24 @@ function make_search_key(str) return table.concat(words, ' ') end -local function get_broker_skill() +function make_container_search_key(item, desc) + local words = {} + add_words(words, desc) + for _, contained_item in ipairs(dfhack.items.getContainedItems(item)) do + add_words(words, dfhack.items.getReadableDescription(contained_item)) + end + return table.concat(words, ' ') +end + +function get_broker_skill() local broker = dfhack.units.getUnitByNobleRole('broker') + local interface_trade = df.global.game.main_interface.trade + if interface_trade.open == true + and interface_trade.choosing_merchant == false + and interface_trade.fortress_trader ~= nil + then + broker = interface_trade.fortress_trader + end if not broker then return 0 end for _,skill in ipairs(broker.status.current_soul.skills) do if skill.id == df.job_skill.APPRAISAL then @@ -36,7 +52,7 @@ local function get_broker_skill() return 0 end -local function get_threshold(broker_skill) +function get_threshold(broker_skill) if broker_skill <= df.skill_rating.Dabbling then return 0 end if broker_skill <= df.skill_rating.Novice then return 10 end if broker_skill <= df.skill_rating.Adequate then return 25 end @@ -52,13 +68,13 @@ local function get_threshold(broker_skill) if broker_skill <= df.skill_rating.Master then return 4000 end if broker_skill <= df.skill_rating.HighMaster then return 5000 end if broker_skill <= df.skill_rating.GrandMaster then return 10000 end - return math.huge + return math.maxinteger end local function estimate(value, round_base, granularity) local rounded = ((value+round_base)//granularity)*granularity local clamped = math.max(rounded, granularity) - return clamped + return dfhack.formatInt(clamped) end -- If the item's value is below the threshold, it gets shown exactly as-is. @@ -66,49 +82,14 @@ end -- Otherwise, if it's less than or equal to [threshold + 50] * 3, it will round to the nearest multiple of 100 -- Otherwise, if it's less than or equal to [threshold + 50] * 30, it will round to the nearest multiple of 1000 -- Otherwise, it will display a guess equal to [threshold + 50] * 30 rounded up to the nearest multiple of 1000. -function obfuscate_value(value) - local threshold = get_threshold(get_broker_skill()) - if value < threshold then return tostring(value) end +function obfuscate_value(value, threshold) + threshold = threshold or get_threshold(get_broker_skill()) + if value < threshold then return dfhack.formatInt(value) end threshold = threshold + 50 - if value <= threshold then return ('~%d'):format(estimate(value, 5, 10)) end - if value <= threshold*3 then return ('~%d'):format(estimate(value, 50, 100)) end - if value <= threshold*30 then return ('~%d'):format(estimate(value, 500, 1000)) end - return ('%d?'):format(estimate(threshold*30, 999, 1000)) -end - -local function to_title_case(str) - str = str:gsub('(%a)([%w_]*)', - function (first, rest) return first:upper()..rest:lower() end) - str = str:gsub('_', ' ') - return str -end - -local function get_item_type_str(item) - local str = to_title_case(df.item_type[item:getType()]) - if str == 'Trapparts' then - str = 'Mechanism' - end - return str -end - -local function get_artifact_name(item) - local gref = dfhack.items.getGeneralRef(item, df.general_ref_type.IS_ARTIFACT) - if not gref then return end - local artifact = df.artifact_record.find(gref.artifact_id) - if not artifact then return end - local name = dfhack.TranslateName(artifact.name) - return ('%s (%s)'):format(name, get_item_type_str(item)) -end - -function get_item_description(item) - local desc = item.flags.artifact and get_artifact_name(item) or - dfhack.items.getDescription(item, 0, true) - local wear_level = item:getWear() - if wear_level == 1 then desc = ('x%sx'):format(desc) - elseif wear_level == 2 then desc = ('X%sX'):format(desc) - elseif wear_level == 3 then desc = ('XX%sXX'):format(desc) - end - return desc + if value <= threshold then return ('~%s'):format(estimate(value, 5, 10)) end + if value <= threshold*3 then return ('~%s'):format(estimate(value, 50, 100)) end + if value <= threshold*30 then return ('~%s'):format(estimate(value, 500, 1000)) end + return ('%s?'):format(estimate(threshold*30, 999, 1000)) end -- takes into account trade agreements @@ -137,10 +118,10 @@ function get_slider_widgets(self, suffix) key_back='CUSTOM_SHIFT_C', key='CUSTOM_SHIFT_V', options={ - {label='XXTatteredXX', value=3}, - {label='XFrayedX', value=2}, - {label='xWornx', value=1}, - {label='Pristine', value=0}, + {label='XXTatteredXX', value=3, pen=COLOR_BROWN}, + {label='XFrayedX', value=2, pen=COLOR_LIGHTRED}, + {label='xWornx', value=1, pen=COLOR_YELLOW}, + {label='Pristine', value=0, pen=COLOR_GREEN}, }, initial_option=3, on_change=function(val) @@ -158,10 +139,10 @@ function get_slider_widgets(self, suffix) key_back='CUSTOM_SHIFT_E', key='CUSTOM_SHIFT_R', options={ - {label='XXTatteredXX', value=3}, - {label='XFrayedX', value=2}, - {label='xWornx', value=1}, - {label='Pristine', value=0}, + {label='XXTatteredXX', value=3, pen=COLOR_BROWN}, + {label='XFrayedX', value=2, pen=COLOR_LIGHTRED}, + {label='xWornx', value=1, pen=COLOR_YELLOW}, + {label='Pristine', value=0, pen=COLOR_GREEN}, }, initial_option=0, on_change=function(val) @@ -186,7 +167,7 @@ function get_slider_widgets(self, suffix) }, }, widgets.Panel{ - frame={t=5, l=0, r=0, h=4}, + frame={t=6, l=0, r=0, h=4}, subviews={ widgets.CycleHotkeyLabel{ view_id='min_quality'..suffix, @@ -196,13 +177,13 @@ function get_slider_widgets(self, suffix) key_back='CUSTOM_SHIFT_Z', key='CUSTOM_SHIFT_X', options={ - {label='Ordinary', value=0}, - {label='-Well Crafted-', value=1}, - {label='+Finely Crafted+', value=2}, - {label='*Superior*', value=3}, - {label=CH_EXCEPTIONAL..'Exceptional'..CH_EXCEPTIONAL, value=4}, - {label=CH_MONEY..'Masterful'..CH_MONEY, value=5}, - {label='Artifact', value=6}, + {label='Ordinary', value=0, pen=COLOR_GRAY}, + {label='-Well Crafted-', value=1, pen=COLOR_LIGHTBLUE}, + {label='+Fine Crafted+', value=2, pen=COLOR_BLUE}, + {label='*Superior*', value=3, pen=COLOR_YELLOW}, + {label=CH_EXCEPTIONAL..'Exceptional'..CH_EXCEPTIONAL, value=4, pen=COLOR_BROWN}, + {label=CH_MONEY..'Masterful'..CH_MONEY, value=5, pen=COLOR_MAGENTA}, + {label='Artifact', value=6, pen=COLOR_GREEN}, }, initial_option=0, on_change=function(val) @@ -220,13 +201,13 @@ function get_slider_widgets(self, suffix) key_back='CUSTOM_SHIFT_Q', key='CUSTOM_SHIFT_W', options={ - {label='Ordinary', value=0}, - {label='-Well Crafted-', value=1}, - {label='+Finely Crafted+', value=2}, - {label='*Superior*', value=3}, - {label=CH_EXCEPTIONAL..'Exceptional'..CH_EXCEPTIONAL, value=4}, - {label=CH_MONEY..'Masterful'..CH_MONEY, value=5}, - {label='Artifact', value=6}, + {label='Ordinary', value=0, pen=COLOR_GRAY}, + {label='-Well Crafted-', value=1, pen=COLOR_LIGHTBLUE}, + {label='+Fine Crafted+', value=2, pen=COLOR_BLUE}, + {label='*Superior*', value=3, pen=COLOR_YELLOW}, + {label=CH_EXCEPTIONAL..'Exceptional'..CH_EXCEPTIONAL, value=4, pen=COLOR_BROWN}, + {label=CH_MONEY..'Masterful'..CH_MONEY, value=5, pen=COLOR_MAGENTA}, + {label='Artifact', value=6, pen=COLOR_GREEN}, }, initial_option=6, on_change=function(val) @@ -251,7 +232,7 @@ function get_slider_widgets(self, suffix) }, }, widgets.Panel{ - frame={t=10, l=0, r=0, h=4}, + frame={t=12, l=0, r=0, h=4}, subviews={ widgets.CycleHotkeyLabel{ view_id='min_value'..suffix, @@ -261,14 +242,14 @@ function get_slider_widgets(self, suffix) key_back='CUSTOM_SHIFT_B', key='CUSTOM_SHIFT_N', options={ - {label='1'..CH_MONEY, value={index=1, value=1}}, - {label='20'..CH_MONEY, value={index=2, value=20}}, - {label='50'..CH_MONEY, value={index=3, value=50}}, - {label='100'..CH_MONEY, value={index=4, value=100}}, - {label='500'..CH_MONEY, value={index=5, value=500}}, - {label='1000'..CH_MONEY, value={index=6, value=1000}}, + {label='1'..CH_MONEY, value={index=1, value=1}, pen=COLOR_BROWN}, + {label='20'..CH_MONEY, value={index=2, value=20}, pen=COLOR_BROWN}, + {label='50'..CH_MONEY, value={index=3, value=50}, pen=COLOR_BROWN}, + {label='100'..CH_MONEY, value={index=4, value=100}, pen=COLOR_BROWN}, + {label='500'..CH_MONEY, value={index=5, value=500}, pen=COLOR_BROWN}, + {label='1000'..CH_MONEY, value={index=6, value=1000}, pen=COLOR_BROWN}, -- max "min" value is less than max "max" value since the range of inf - inf is not useful - {label='5000'..CH_MONEY, value={index=7, value=5000}}, + {label='5000'..CH_MONEY, value={index=7, value=5000}, pen=COLOR_BROWN}, }, initial_option=1, on_change=function(val) @@ -286,13 +267,13 @@ function get_slider_widgets(self, suffix) key_back='CUSTOM_SHIFT_T', key='CUSTOM_SHIFT_Y', options={ - {label='1'..CH_MONEY, value={index=1, value=1}}, - {label='20'..CH_MONEY, value={index=2, value=20}}, - {label='50'..CH_MONEY, value={index=3, value=50}}, - {label='100'..CH_MONEY, value={index=4, value=100}}, - {label='500'..CH_MONEY, value={index=5, value=500}}, - {label='1000'..CH_MONEY, value={index=6, value=1000}}, - {label='Max', value={index=7, value=math.huge}}, + {label='1'..CH_MONEY, value={index=1, value=1}, pen=COLOR_BROWN}, + {label='20'..CH_MONEY, value={index=2, value=20}, pen=COLOR_BROWN}, + {label='50'..CH_MONEY, value={index=3, value=50}, pen=COLOR_BROWN}, + {label='100'..CH_MONEY, value={index=4, value=100}, pen=COLOR_BROWN}, + {label='500'..CH_MONEY, value={index=5, value=500}, pen=COLOR_BROWN}, + {label='1000'..CH_MONEY, value={index=6, value=1000}, pen=COLOR_BROWN}, + {label='Max', value={index=7, value=math.maxinteger}, pen=COLOR_GREEN}, }, initial_option=7, on_change=function(val) @@ -359,8 +340,8 @@ end function get_banned_items() local banned_items = {} - for _, mandate in ipairs(df.global.world.mandates) do - if mandate.mode == df.mandate.T_mode.Export then + for _, mandate in ipairs(df.global.world.mandates.all) do + if mandate.mode == df.mandate_type.Export then register_item_type(banned_items, mandate) end end @@ -369,8 +350,8 @@ end local function analyze_noble(unit, risky_items, banned_items) for _, preference in ipairs(unit.status.current_soul.preferences) do - if preference.type == df.unit_preference.T_type.LikeItem and - preference.active + if preference.type == df.unitpref_type.LikeItem and + preference.flags.visible then register_item_type(risky_items, preference, banned_items) end @@ -379,7 +360,7 @@ end local function get_mandate_noble_roles() local roles = {} - for _, link in ipairs(df.global.world.world_data.active_site[0].entity_links) do + for _, link in ipairs(dfhack.world.getCurrentSite().entity_links) do local he = df.historical_entity.find(link.entity_id); if not he or (he.type ~= df.historical_entity_type.SiteGovernment and @@ -505,10 +486,22 @@ function get_advanced_filter_widgets(self, context) } end -function get_info_widgets(self, export_agreements, context) +function get_info_widgets(self, export_agreements, strict_ethical_bins_default, context) return { + widgets.CycleHotkeyLabel{ + view_id='provenance', + frame={t=0, l=0, w=34}, + key='CUSTOM_SHIFT_P', + label='Item origins:', + options={ + {label='All', value='all', pen=COLOR_GREEN}, + {label='Fort-made only', value='local', pen=COLOR_BLUE}, + {label='Foreign-made only', value='foreign', pen=COLOR_YELLOW}, + }, + on_change=function() self:refresh_list() end, + }, widgets.Panel{ - frame={t=0, l=0, r=0, h=2}, + frame={t=2, l=0, r=0, h=2}, subviews={ widgets.Label{ frame={t=0, l=0}, @@ -532,7 +525,7 @@ function get_info_widgets(self, export_agreements, context) key='CUSTOM_SHIFT_A', options={ {label='Yes', value=true, pen=COLOR_GREEN}, - {label='No', value=false} + {label='No', value=false}, }, initial_option=false, on_change=function() self:refresh_list() end, @@ -541,7 +534,7 @@ function get_info_widgets(self, export_agreements, context) }, }, widgets.Panel{ - frame={t=3, l=0, r=0, h=3}, + frame={t=5, l=0, r=0, h=4}, subviews={ widgets.Label{ frame={t=0, l=0}, @@ -556,7 +549,7 @@ function get_info_widgets(self, export_agreements, context) key='CUSTOM_SHIFT_G', options={ {label='Show only ethically acceptable items', value='only', pen=COLOR_GREEN}, - {label='Ignore ethical restrictions', value='show'}, + {label='Ignore ethical restrictions', value='show', pen=COLOR_YELLOW}, {label='Show only ethically unacceptable items', value='hide', pen=COLOR_RED}, }, initial_option='only', @@ -564,10 +557,26 @@ function get_info_widgets(self, export_agreements, context) visible=self.animal_ethics or self.wood_ethics, on_change=function() self:refresh_list() end, }, + widgets.ToggleHotkeyLabel{ + view_id='strict_ethical_bins', + frame={t=3, l=0}, + key='CUSTOM_SHIFT_U', + options={ + {label='Include mixed bins', value=false, pen=COLOR_GREEN}, + {label='Exclude mixed bins', value=true, pen=COLOR_YELLOW}, + }, + initial_option=strict_ethical_bins_default, + option_gap=0, + visible=function() + if not self.animal_ethics and not self.wood_ethics then return false end + return self.subviews.ethical:getOptionValue() ~= 'show' + end, + on_change=function() self:refresh_list() end, + }, }, }, widgets.Panel{ - frame={t=7, l=0, r=0, h=5}, + frame={t=10, l=0, r=0, h=5}, subviews={ widgets.Label{ frame={t=0, l=0}, @@ -623,35 +632,85 @@ function scan_banned(item, risky_items) return false, false end -local function is_wood_based(mat_type, mat_index) - if mat_type == df.builtin_mats.LYE or - mat_type == df.builtin_mats.GLASS_CLEAR or - mat_type == df.builtin_mats.GLASS_CRYSTAL or - (mat_type == df.builtin_mats.COAL and mat_index == 1) or - mat_type == df.builtin_mats.POTASH or - mat_type == df.builtin_mats.ASH or - mat_type == df.builtin_mats.PEARLASH - then +local function is_wood_based_material(mat_type, mat_index) + if mat_type == df.builtin_mats.GLASS_CLEAR or mat_type == df.builtin_mats.GLASS_CRYSTAL then return true end local mi = dfhack.matinfo.decode(mat_type, mat_index) - return mi and mi.material and + return mi and mi.mode == 'plant' and mi.material and (mi.material.flags.WOOD or - mi.material.flags.STRUCTURAL_PLANT_MAT or - mi.material.flags.SOAP) + mi.material.flags.STRUCTURAL_PLANT_MAT) +end + +local item_types_never_wood = utils.invert{ + df.item_type.SMALLGEM, + df.item_type.BLOCKS, + df.item_type.ROUGH, + df.item_type.BOULDER, + df.item_type.CORPSE, + df.item_type.CORPSEPIECE, + df.item_type.REMAINS, + df.item_type.MEAT, + df.item_type.FISH, + df.item_type.FISH_RAW, + df.item_type.VERMIN, + df.item_type.PET, + df.item_type.SEEDS, + df.item_type.PLANT, + df.item_type.SKIN_TANNED, + df.item_type.PLANT_GROWTH, + df.item_type.DRINK, + df.item_type.CHEESE, + df.item_type.FOOD, + df.item_type.COIN, + df.item_type.GLOB, + df.item_type.ROCK, + df.item_type.EGG, +} + +local function is_wood_based_item(item) + local itype = item:getType() + + if item_types_never_wood[itype] then return false end + + local mat_type, mat_index = item:getMaterial(), item:getMaterialIndex() + + if itype == df.item_type.BAR then + if mat_type == df.builtin_mats.POTASH or + mat_type == df.builtin_mats.ASH or + mat_type == df.builtin_mats.PEARLASH or + (mat_type == df.builtin_mats.COAL and mat_index == 1) + then + return true + end + local mi = dfhack.matinfo.decode(mat_type, mat_index) + return mi and mi.mode == 'creature' + elseif itype == df.item_type.LIQUID_MISC then + return mat_type == df.builtin_mats.LYE + end + + -- detect obsidian short swords + if itype == df.item_type.WEAPON then + local mi = dfhack.matinfo.decode(mat_type, mat_index) + if mi and mi.mode == 'inorganic' and mi.material and not mi.material.flags.IS_METAL then + return true + end + end + + return is_wood_based_material(mat_type, mat_index) end function has_wood(item) if item.flags2.grown then return false end - if is_wood_based(item:getMaterial(), item:getMaterialIndex()) then + if is_wood_based_item(item) then return true end if item:hasImprovements() then for _, imp in ipairs(item.improvements) do - if is_wood_based(imp.mat_type, imp.mat_index) then + if is_wood_based_material(imp.mat_type, imp.mat_index) then return true end end diff --git a/internal/caravan/movegoods.lua b/internal/caravan/movegoods.lua index fa39d219b3..77e8222979 100644 --- a/internal/caravan/movegoods.lua +++ b/internal/caravan/movegoods.lua @@ -14,16 +14,16 @@ local widgets = require('gui.widgets') MoveGoods = defclass(MoveGoods, widgets.Window) MoveGoods.ATTRS { frame_title='Move goods to/from depot', - frame={w=85, h=46}, + frame={w=86, h=46}, resizable=true, - resize_min={h=35}, - frame_inset={l=1, t=1, b=1, r=0}, + resize_min={h=40}, + frame_inset={l=0, t=1, b=1, r=0}, pending_item_ids=DEFAULT_NIL, depot=DEFAULT_NIL, } -local STATUS_COL_WIDTH = 7 -local VALUE_COL_WIDTH = 6 +local DIST_COL_WIDTH = 7 +local VALUE_COL_WIDTH = 9 local QTY_COL_WIDTH = 5 local function sort_noop(a, b) @@ -65,20 +65,36 @@ local function sort_by_value_asc(a, b) return a.data[value_field] < b.data[value_field] end -local function sort_by_status_desc(a, b) +local function sort_by_dist_desc(a, b) local a_unselected = a.data.selected == 0 or (a.item_id and not a.data.items[a.item_id].pending) local b_unselected = b.data.selected == 0 or (b.item_id and not b.data.items[b.item_id].pending) if a_unselected == b_unselected then - return sort_by_value_desc(a, b) + local a_at_depot = a.data.num_at_depot == a.data.quantity + local b_at_depot = b.data.num_at_depot == b.data.quantity + if a_at_depot ~= b_at_depot then + return a_at_depot + end + if a.data.dist == b.data.dist then + return sort_by_value_desc(a, b) + end + return a.data.dist < b.data.dist end return not a_unselected end -local function sort_by_status_asc(a, b) +local function sort_by_dist_asc(a, b) local a_unselected = a.data.selected == 0 or (a.item_id and not a.data.items[a.item_id].pending) local b_unselected = b.data.selected == 0 or (b.item_id and not b.data.items[b.item_id].pending) if a_unselected == b_unselected then - return sort_by_value_desc(a, b) + local a_at_depot = a.data.num_at_depot == a.data.quantity + local b_at_depot = b.data.num_at_depot == b.data.quantity + if a_at_depot ~= b_at_depot then + return b_at_depot + end + if a.data.dist == b.data.dist then + return sort_by_value_desc(a, b) + end + return a.data.dist > b.data.dist end return not b_unselected end @@ -139,12 +155,12 @@ function MoveGoods:init() self:addviews{ widgets.CycleHotkeyLabel{ view_id='sort', - frame={l=0, t=0, w=21}, + frame={l=1, t=0, w=21}, label='Sort by:', key='CUSTOM_SHIFT_S', options={ - {label='status'..common.CH_DN, value=sort_by_status_desc}, - {label='status'..common.CH_UP, value=sort_by_status_asc}, + {label='dist'..common.CH_DN, value=sort_by_dist_desc}, + {label='dist'..common.CH_UP, value=sort_by_dist_asc}, {label='value'..common.CH_DN, value=sort_by_value_desc}, {label='value'..common.CH_UP, value=sort_by_value_asc}, {label='qty'..common.CH_DN, value=sort_by_quantity_desc}, @@ -152,53 +168,59 @@ function MoveGoods:init() {label='name'..common.CH_DN, value=sort_by_name_desc}, {label='name'..common.CH_UP, value=sort_by_name_asc}, }, - initial_option=sort_by_status_desc, + initial_option=sort_by_dist_desc, on_change=self:callback('refresh_list', 'sort'), }, widgets.EditField{ view_id='search', - frame={l=26, t=0}, + frame={l=27, t=0, r=1}, label_text='Search: ', on_char=function(ch) return ch:match('[%l -]') end, }, widgets.Panel{ - frame={t=2, l=0, w=38, h=14}, - subviews=common.get_slider_widgets(self), - }, - widgets.ToggleHotkeyLabel{ - view_id='hide_forbidden', - frame={t=2, l=40, w=28}, - label='Hide forbidden items:', - key='CUSTOM_SHIFT_F', - options={ - {label='Yes', value=true, pen=COLOR_GREEN}, - {label='No', value=false} + frame={t=2, l=0, r=0, h=18}, + frame_style=gui.FRAME_INTERIOR, + subviews={ + widgets.Panel{ + frame={t=0, l=0, w=38}, + subviews=common.get_slider_widgets(self), + }, + widgets.ToggleHotkeyLabel{ + view_id='hide_forbidden', + frame={t=0, l=40, w=28}, + label='Hide forbidden items:', + key='CUSTOM_SHIFT_F', + options={ + {label='Yes', value=true, pen=COLOR_GREEN}, + {label='No', value=false} + }, + initial_option=false, + on_change=function() self:refresh_list() end, + }, + widgets.Panel{ + frame={t=1, l=40, r=0}, + subviews=common.get_info_widgets(self, get_export_agreements(), false, self.predicate_context), + }, }, - initial_option=false, - on_change=function() self:refresh_list() end, - }, - widgets.Panel{ - frame={t=4, l=40, r=1, h=12}, - subviews=common.get_info_widgets(self, get_export_agreements(), self.predicate_context), }, widgets.Panel{ - frame={t=17, l=0, r=0, b=6}, + frame={t=21, l=0, r=0, b=7}, subviews={ widgets.CycleHotkeyLabel{ - view_id='sort_status', - frame={t=0, l=STATUS_COL_WIDTH+1-7, w=7}, + view_id='sort_dist', + frame={t=0, l=DIST_COL_WIDTH+1-7, w=7}, options={ - {label='status', value=sort_noop}, - {label='status'..common.CH_DN, value=sort_by_status_desc}, - {label='status'..common.CH_UP, value=sort_by_status_asc}, + {label='dist', value=sort_noop}, + {label='dist'..common.CH_DN, value=sort_by_dist_desc}, + {label='dist'..common.CH_UP, value=sort_by_dist_asc}, }, - initial_option=sort_by_status_desc, + initial_option=sort_by_dist_desc, option_gap=0, - on_change=self:callback('refresh_list', 'sort_status'), + on_change=self:callback('refresh_list', 'sort_dist'), }, widgets.CycleHotkeyLabel{ view_id='sort_value', - frame={t=0, l=STATUS_COL_WIDTH+2+VALUE_COL_WIDTH+1-6, w=6}, + frame={t=0, l=DIST_COL_WIDTH+2+VALUE_COL_WIDTH+1-6, w=6}, options={ {label='value', value=sort_noop}, {label='value'..common.CH_DN, value=sort_by_value_desc}, @@ -209,7 +231,7 @@ function MoveGoods:init() }, widgets.CycleHotkeyLabel{ view_id='sort_quantity', - frame={t=0, l=STATUS_COL_WIDTH+2+VALUE_COL_WIDTH+2+QTY_COL_WIDTH+1-4, w=4}, + frame={t=0, l=DIST_COL_WIDTH+2+VALUE_COL_WIDTH+2+QTY_COL_WIDTH+1-4, w=4}, options={ {label='qty', value=sort_noop}, {label='qty'..common.CH_DN, value=sort_by_quantity_desc}, @@ -220,7 +242,7 @@ function MoveGoods:init() }, widgets.CycleHotkeyLabel{ view_id='sort_name', - frame={t=0, l=STATUS_COL_WIDTH+2+VALUE_COL_WIDTH+2+QTY_COL_WIDTH+2, w=5}, + frame={t=0, l=DIST_COL_WIDTH+2+VALUE_COL_WIDTH+2+QTY_COL_WIDTH+2, w=5}, options={ {label='name', value=sort_noop}, {label='name'..common.CH_DN, value=sort_by_name_desc}, @@ -239,48 +261,60 @@ function MoveGoods:init() }, } }, - widgets.Label{ - frame={l=0, b=4, h=1, r=0}, - text={ - 'Total value of items marked for trade:', - {gap=1, - text=function() return common.obfuscate_value(self.value_pending) end}, - }, - }, - widgets.HotkeyLabel{ - frame={l=0, b=2}, - label='Select all/none', - key='CUSTOM_CTRL_A', - on_activate=self:callback('toggle_visible'), - auto_width=true, - }, - widgets.ToggleHotkeyLabel{ - view_id='group_items', - frame={l=25, b=2, w=24}, - label='Group items:', - key='CUSTOM_CTRL_G', - options={ - {label='Yes', value=true, pen=COLOR_GREEN}, - {label='No', value=false} - }, - initial_option=true, - on_change=function() self:refresh_list() end, + widgets.Divider{ + frame={b=6, h=1}, + frame_style=gui.FRAME_INTERIOR, + frame_style_l=false, + frame_style_r=false, }, - widgets.ToggleHotkeyLabel{ - view_id='inside_containers', - frame={l=51, b=2, w=30}, - label='Inside containers:', - key='CUSTOM_CTRL_I', - options={ - {label='Yes', value=true, pen=COLOR_GREEN}, - {label='No', value=false} + widgets.Panel{ + frame={l=1, r=1, b=0, h=5}, + subviews={ + widgets.Label{ + frame={l=0, t=0}, + text={ + 'Total value of items marked for trade:', + {gap=1, + text=function() return common.obfuscate_value(self.value_pending) end, + pen=COLOR_GREEN}, + }, + }, + widgets.Label{ + frame={l=0, t=2}, + text='Click to mark/unmark for trade. Shift click to mark/unmark a range of items.', + }, + widgets.HotkeyLabel{ + frame={l=0, b=0}, + label='Select all/none', + key='CUSTOM_CTRL_N', + on_activate=self:callback('toggle_visible'), + auto_width=true, + }, + widgets.ToggleHotkeyLabel{ + view_id='group_items', + frame={l=25, b=0, w=24}, + label='Group items:', + key='CUSTOM_CTRL_G', + options={ + {label='Yes', value=true, pen=COLOR_GREEN}, + {label='No', value=false} + }, + initial_option=true, + on_change=function() self:refresh_list() end, + }, + widgets.ToggleHotkeyLabel{ + view_id='inside_containers', + frame={l=51, b=0, w=30}, + label='Inside containers:', + key='CUSTOM_CTRL_I', + options={ + {label='Yes', value=true, pen=COLOR_GREEN}, + {label='No', value=false} + }, + initial_option=false, + on_change=function() self:refresh_list() end, + }, }, - initial_option=false, - on_change=function() self:refresh_list() end, - }, - widgets.WrappedLabel{ - frame={b=0, l=0, r=0}, - text_to_wrap='Click to mark/unmark for trade. Shift click to mark/unmark a range of items.', }, } @@ -300,7 +334,7 @@ function MoveGoods:refresh_list(sort_widget, sort_fn) self.subviews[sort_widget]:cycle() return end - for _,widget_name in ipairs{'sort', 'sort_status', 'sort_value', 'sort_quantity', 'sort_name'} do + for _,widget_name in ipairs{'sort', 'sort_dist', 'sort_value', 'sort_quantity', 'sort_name'} do self.subviews[widget_name]:setOption(sort_fn) end local list = self.subviews.list @@ -324,20 +358,18 @@ local function is_tradeable_item(item, depot) item.flags.spider_web or item.flags.construction or item.flags.encased or - item.flags.unk12 or item.flags.murder or item.flags.trader or item.flags.owned or item.flags.garbage_collect or - item.flags.on_fire or - item.flags.in_chest + item.flags.on_fire then return false end if item.flags.in_inventory then local gref = dfhack.items.getGeneralRef(item, df.general_ref_type.CONTAINED_IN_ITEM) if not gref then return false end - if not is_container(df.item.find(gref.item_id)) or item:isLiquidPowder() then + if not is_container(gref:getItem()) or item:isLiquidPowder() then return false end end @@ -349,7 +381,7 @@ local function is_tradeable_item(item, depot) if item.flags.in_building then if dfhack.items.getHolderBuilding(item) ~= depot then return false end for _, contained_item in ipairs(depot.contained_items) do - if contained_item.use_mode == 0 then return true end + if contained_item.use_mode == df.building_item_role_type.TEMP then return true end -- building construction materials if item == contained_item.item then return false end end @@ -367,57 +399,66 @@ local function get_entry_icon(data, item_id) return common.SOME_PEN end -local function make_choice_text(at_depot, value, quantity, desc) +local function make_choice_text(at_depot, dist, value, quantity, desc, cache_threshold) return { - {width=STATUS_COL_WIDTH-2, text=at_depot and 'depot' or ''}, - {gap=2, width=VALUE_COL_WIDTH, rjustify=true, text=common.obfuscate_value(value)}, + {width=DIST_COL_WIDTH-2, rjustify=true, text=at_depot and 'depot' or tostring(dist)}, + {gap=2, width=VALUE_COL_WIDTH, rjustify=true, text=common.obfuscate_value(value, cache_threshold)}, {gap=2, width=QTY_COL_WIDTH, rjustify=true, text=quantity}, {gap=2, text=desc}, } end +local function is_ethical_item(item, animal_ethics, wood_ethics) + return (not animal_ethics or not item:isAnimalProduct()) and + (not wood_ethics or not common.has_wood(item)) +end + local function is_ethical_product(item, animal_ethics, wood_ethics) if not animal_ethics and not wood_ethics then return true end - if item.flags.container then - local contained_items = dfhack.items.getContainedItems(item) - if df.item_binst:is_instance(item) then - -- ignore the safety of the bin itself (unless the bin is empty) - -- so items inside can still be traded - local has_items = false - for _, contained_item in ipairs(contained_items) do - has_items = true - if (not animal_ethics or not contained_item:isAnimalProduct()) and - (not wood_ethics or not common.has_wood(contained_item)) - then - -- bin passes if at least one contained item is safe - return true + + -- if item is not a container or is an empty container, then the ethics is not mixed + -- and the ethicality of the item speaks for itself + local has_ethical = is_ethical_item(item, animal_ethics, wood_ethics) + local is_mixed = false + if not item.flags.container then + return has_ethical, is_mixed + end + local contained_items = dfhack.items.getContainedItems(item) + if #contained_items == 0 then + return has_ethical, is_mixed + end + + if df.item_binst:is_instance(item) then + for _, contained_item in ipairs(contained_items) do + if is_ethical_item(contained_item, animal_ethics, wood_ethics) then + if not has_ethical then + has_ethical, is_mixed = true, true + break end + elseif has_ethical then + is_mixed = true + break end - if has_items then - -- no contained items are safe - return false - end - else - -- for other types of containers, any contamination makes it untradeable - for _, contained_item in ipairs(contained_items) do - if (animal_ethics and contained_item:isAnimalProduct()) or - (wood_ethics and common.has_wood(contained_item)) - then - return false - end + end + elseif has_ethical then + -- for other types of containers, any contamination makes it unethical since contained + -- items cannot be individually selected in the barter screen + for _, contained_item in ipairs(contained_items) do + if not is_ethical_item(contained_item, animal_ethics, wood_ethics) then + has_ethical = false + break end end end - return (not animal_ethics or not item:isAnimalProduct()) and - (not wood_ethics or not common.has_wood(item)) + return has_ethical, is_mixed end local function make_container_search_key(item, desc) local words = {} common.add_words(words, desc) for _, contained_item in ipairs(dfhack.items.getContainedItems(item)) do - common.add_words(words, common.get_item_description(contained_item)) + common.add_words(words, dfhack.items.getReadableDescription(contained_item)) end return table.concat(words, ' ') end @@ -436,33 +477,41 @@ local function contains_non_liquid_powder(container) return false end -function MoveGoods:cache_choices(group_items, inside_containers) +local function get_distance(bld, pos) + return math.max(math.abs(bld.centerx - pos.x), math.abs(bld.centery - pos.y)) + math.abs(bld.z - pos.z) +end + +function MoveGoods:cache_choices() + local group_items = self.subviews.group_items:getOptionValue() + local inside_containers = self.subviews.inside_containers:getOptionValue() local cache_idx = get_cache_index(group_items, inside_containers) if self.choices_cache[cache_idx] then return self.choices_cache[cache_idx] end local pending = self.pending_item_ids local groups = {} - for _, item in ipairs(df.global.world.items.all) do - local item_id = item.id + for _, item in ipairs(df.global.world.items.other.IN_PLAY) do if not item or not is_tradeable_item(item, self.depot) then goto continue end if inside_containers and is_container(item) and contains_non_liquid_powder(item) then goto continue elseif not inside_containers and item.flags.in_inventory then goto continue end + local item_id = item.id local value = common.get_perceived_value(item) if value <= 0 then goto continue end + local dist = get_distance(self.depot, xyz2pos(dfhack.items.getPosition(item))) local is_pending = not not pending[item_id] or item.flags.in_building local is_forbidden = item.flags.forbid local is_banned, is_risky = common.scan_banned(item, self.risky_items) local is_requested = dfhack.items.isRequestedTradeGood(item) local wear_level = item:getWear() - local desc = common.get_item_description(item) + local desc = dfhack.items.getReadableDescription(item) local key = ('%s/%d'):format(desc, value) if groups[key] then local group = groups[key] group.data.items[item_id] = {item=item, pending=is_pending, banned=is_banned, requested=is_requested} group.data.quantity = group.data.quantity + 1 + group.data.dist = math.min(group.data.dist or math.huge, dist) group.data.selected = group.data.selected + (is_pending and 1 or 0) group.data.num_at_depot = group.data.num_at_depot + (item.flags.in_building and 1 or 0) group.data.has_forbidden = group.data.has_forbidden or is_forbidden @@ -470,7 +519,7 @@ function MoveGoods:cache_choices(group_items, inside_containers) group.data.has_risky = group.data.has_risky or is_risky group.data.has_requested = group.data.has_requested or is_requested else - local is_ethical = is_ethical_product(item, self.animal_ethics, self.wood_ethics) + local has_ethical, is_ethical_mixed = is_ethical_product(item, self.animal_ethics, self.wood_ethics) local data = { desc=desc, per_item_value=value, @@ -480,14 +529,17 @@ function MoveGoods:cache_choices(group_items, inside_containers) item_subtype=item:getSubtype(), quantity=1, quality=item.flags.artifact and 6 or item:getQuality(), + dist=dist, wear=wear_level, selected=is_pending and 1 or 0, num_at_depot=item.flags.in_building and 1 or 0, has_forbidden=is_forbidden, + has_foreign=item.flags.foreign, has_banned=is_banned, has_risky=is_risky, has_requested=is_requested, - ethical=is_ethical, + has_ethical=has_ethical, + ethical_mixed=is_ethical_mixed, dirty=false, } local search_key @@ -507,17 +559,20 @@ function MoveGoods:cache_choices(group_items, inside_containers) end local group_choices, nogroup_choices = {}, {} + local cache_threshold = common.get_threshold(common.get_broker_skill()) for _, group in pairs(groups) do local data = group.data for item_id, item_data in pairs(data.items) do local nogroup_choice = copyall(group) nogroup_choice.icon = curry(get_entry_icon, data, item_id) - nogroup_choice.text = make_choice_text(item_data.item.flags.in_building, data.per_item_value, 1, data.desc) + nogroup_choice.text = make_choice_text(item_data.item.flags.in_building, + data.dist, data.per_item_value, 1, data.desc, cache_threshold) nogroup_choice.item_id = item_id table.insert(nogroup_choices, nogroup_choice) end data.total_value = data.per_item_value * data.quantity - group.text = make_choice_text(data.num_at_depot == data.quantity, data.total_value, data.quantity, data.desc) + group.text = make_choice_text(data.num_at_depot == data.quantity, data.dist, + data.total_value, data.quantity, data.desc, cache_threshold) table.insert(group_choices, group) self.value_pending = self.value_pending + (data.per_item_value * data.selected) end @@ -528,13 +583,14 @@ function MoveGoods:cache_choices(group_items, inside_containers) end function MoveGoods:get_choices() - local raw_choices = self:cache_choices(self.subviews.group_items:getOptionValue(), - self.subviews.inside_containers:getOptionValue()) + local raw_choices = self:cache_choices() local choices = {} local include_forbidden = not self.subviews.hide_forbidden:getOptionValue() + local provenance = self.subviews.provenance:getOptionValue() local banned = self.subviews.banned:getOptionValue() local only_agreement = self.subviews.only_agreement:getOptionValue() local ethical = self.subviews.ethical:getOptionValue() + local strict_ethical_bins = self.subviews.strict_ethical_bins:getOptionValue() local min_condition = self.subviews.min_condition:getOptionValue() local max_condition = self.subviews.max_condition:getOptionValue() local min_quality = self.subviews.min_quality:getOptionValue() @@ -544,8 +600,9 @@ function MoveGoods:get_choices() for _,choice in ipairs(raw_choices) do local data = choice.data if ethical ~= 'show' then - if ethical == 'hide' and data.ethical then goto continue end - if ethical == 'only' and not data.ethical then goto continue end + if strict_ethical_bins and data.ethical_mixed then goto continue end + if ethical == 'hide' and data.has_ethical then goto continue end + if ethical == 'only' and not data.has_ethical then goto continue end end if not include_forbidden then if choice.item_id then @@ -556,6 +613,13 @@ function MoveGoods:get_choices() goto continue end end + if provenance ~= 'all' then + if (provenance == 'local' and data.has_foreign) or + (provenance == 'foreign' and not data.has_foreign) + then + goto continue + end + end if min_condition < data.wear then goto continue end if max_condition > data.wear then goto continue end if min_quality > data.quality then goto continue end @@ -673,6 +737,7 @@ function MoveGoodsModal:init() self.depot = self.depot or dfhack.gui.getSelectedBuilding(true) self:addviews{ MoveGoods{ + view_id='move_goods', pending_item_ids=self.pending_item_ids, depot=self.depot, }, @@ -684,7 +749,7 @@ function MoveGoodsModal:onDismiss() local depot = self.depot if not depot then return end local pending = self.pending_item_ids - for _, choice in ipairs(self.subviews.list:getChoices()) do + for _, choice in ipairs(self.subviews.move_goods:cache_choices()) do if not choice.data.dirty then goto continue end for item_id, item_data in pairs(choice.data.items) do local item = item_data.item @@ -693,6 +758,10 @@ function MoveGoodsModal:onDismiss() if dfhack.items.getHolderBuilding(item) == depot then item.flags.in_building = true else + -- TODO: if there is just one (ethical, if filtered) item inside of a bin, mark the item for + -- trade instead of the bin + -- TODO: give containers that have some items inside of them marked for trade a ":" marker in the UI + -- TODO: correlate items inside containers marked for trade across the cached choices so no choices are lost dfhack.items.markForTrade(item, depot) end elseif not item_data.pending and pending[item_id] then @@ -700,7 +769,7 @@ function MoveGoodsModal:onDismiss() if spec_ref then dfhack.job.removeJob(spec_ref.data.job) end - elseif not item_data.pending and item.flags.in_building then + elseif not item_data.pending and item.flags.in_building and dfhack.items.getHolderBuilding(item) == depot then item.flags.in_building = false end end @@ -715,15 +784,6 @@ end -- MoveGoodsOverlay -- -MoveGoodsOverlay = defclass(MoveGoodsOverlay, overlay.OverlayWidget) -MoveGoodsOverlay.ATTRS{ - default_pos={x=-64, y=10}, - default_enabled=true, - viewscreens='dwarfmode/ViewSheets/BUILDING/TradeDepot', - frame={w=33, h=1}, - frame_background=gui.CLEAR_PEN, -} - local function has_trade_depot_and_caravan() local bld = dfhack.gui.getSelectedBuilding(true) if not bld or bld:getBuildStage() < bld:getMaxBuildStage() then @@ -748,6 +808,17 @@ local function has_trade_depot_and_caravan() return false end +MoveGoodsOverlay = defclass(MoveGoodsOverlay, overlay.OverlayWidget) +MoveGoodsOverlay.ATTRS{ + desc='Adds link to trade depot building to launch the DFHack trade goods UI.', + default_pos={x=-64, y=10}, + default_enabled=true, + viewscreens='dwarfmode/ViewSheets/BUILDING/TradeDepot', + frame={w=33, h=1}, + frame_background=gui.CLEAR_PEN, + visible=has_trade_depot_and_caravan, +} + function MoveGoodsOverlay:init() self:addviews{ widgets.TextButton{ @@ -755,17 +826,35 @@ function MoveGoodsOverlay:init() label='DFHack move trade goods', key='CUSTOM_CTRL_T', on_activate=function() MoveGoodsModal{}:show() end, - enabled=has_trade_depot_and_caravan, }, } end +-- ------------------- +-- MoveGoodsHiderOverlay +-- + +MoveGoodsHiderOverlay = defclass(MoveGoodsHiderOverlay, overlay.OverlayWidget) +MoveGoodsHiderOverlay.ATTRS{ + desc='Hides the vanilla trade goods selection button.', + default_pos={x=-70, y=12}, + viewscreens='dwarfmode/ViewSheets/BUILDING/TradeDepot', + frame={w=27, h=3}, + frame_background=gui.CLEAR_PEN, + visible=has_trade_depot_and_caravan, +} + +function MoveGoodsHiderOverlay:onInput(keys) + return keys._MOUSE_L and self:getMouseFramePos() +end + -- ------------------- -- AssignTradeOverlay -- AssignTradeOverlay = defclass(AssignTradeOverlay, overlay.OverlayWidget) AssignTradeOverlay.ATTRS{ + desc='Adds link to the trade goods screen to launch the DFHack trade goods UI.', default_pos={x=-41,y=-5}, default_enabled=true, viewscreens='dwarfmode/AssignTrade', diff --git a/internal/caravan/pedestal.lua b/internal/caravan/pedestal.lua index a7842891f9..1184a85c3e 100644 --- a/internal/caravan/pedestal.lua +++ b/internal/caravan/pedestal.lua @@ -19,6 +19,16 @@ for k, v in pairs(STATUS) do STATUS_REVMAP[v.value] = k end +-- save filters (sans search string) between dialog invocations +local filters = { + min_quality=0, + max_quality=6, + hide_unreachable=true, + hide_forbidden=false, + hide_written=false, + inside_containers=true, +} + -- ------------------- -- AssignItems -- @@ -105,7 +115,7 @@ local function get_containing_temple_or_guildhall(display_bld) end end if not loc_id then return end - local site = df.global.world.world_data.active_site[0] + local site = dfhack.world.getCurrentSite() local location = utils.binsearch(site.buildings, loc_id, 'id') if not location then return end local loc_type = location:getType() @@ -126,7 +136,7 @@ end local function get_pending_value(display_bld) local value = get_assigned_value(display_bld) for _, contained_item in ipairs(display_bld.contained_items) do - if contained_item.use_mode ~= 0 or + if contained_item.use_mode ~= df.building_item_role_type.TEMP or not contained_item.item.flags.in_building then goto continue @@ -214,8 +224,9 @@ function AssignItems:init() {label=common.CH_MONEY..'Masterful'..common.CH_MONEY, value=5}, {label='Artifact', value=6}, }, - initial_option=0, + initial_option=filters.min_quality, on_change=function(val) + filters.min_quality = val if self.subviews.max_quality:getOptionValue() < val then self.subviews.max_quality:setOption(val) end @@ -238,8 +249,9 @@ function AssignItems:init() {label=common.CH_MONEY..'Masterful'..common.CH_MONEY, value=5}, {label='Artifact', value=6}, }, - initial_option=6, + initial_option=filters.max_quality, on_change=function(val) + filters.max_quality = val if self.subviews.min_quality:getOptionValue() > val then self.subviews.min_quality:setOption(val) end @@ -269,20 +281,43 @@ function AssignItems:init() {label='Yes', value=true, pen=COLOR_GREEN}, {label='No', value=false} }, - initial_option=true, - on_change=function() self:refresh_list() end, + initial_option=filters.hide_unreachable, + on_change=function(val) + filters.hide_unreachable = val + self:refresh_list() + end, }, widgets.ToggleHotkeyLabel{ view_id='hide_forbidden', - frame={t=2, l=40, w=28}, + frame={t=1, l=40, w=30}, label='Hide forbidden items:', key='CUSTOM_SHIFT_F', options={ {label='Yes', value=true, pen=COLOR_GREEN}, {label='No', value=false} }, - initial_option=false, - on_change=function() self:refresh_list() end, + option_gap=3, + initial_option=filters.hide_forbidden, + on_change=function(val) + filters.hide_forbidden = val + self:refresh_list() + end, + }, + widgets.ToggleHotkeyLabel{ + view_id='hide_written', + frame={t=3, l=40, w=30}, + label='Hide written items:', + key='CUSTOM_SHIFT_W', + options={ + {label='Yes', value=true, pen=COLOR_GREEN}, + {label='No', value=false} + }, + option_gap=5, + initial_option=filters.hide_written, + on_change=function(val) + filters.hide_written = val + self:refresh_list() + end, }, }, }, @@ -353,7 +388,7 @@ function AssignItems:init() widgets.HotkeyLabel{ frame={l=0, b=2}, label='Select all/none', - key='CUSTOM_CTRL_A', + key='CUSTOM_CTRL_N', on_activate=self:callback('toggle_visible'), auto_width=true, }, @@ -366,8 +401,11 @@ function AssignItems:init() {label='Yes', value=true, pen=COLOR_GREEN}, {label='No', value=false} }, - initial_option=true, - on_change=function() self:refresh_list() end, + initial_option=filters.inside_containers, + on_change=function(val) + filters.inside_containers = val + self:refresh_list() + end, }, widgets.WrappedLabel{ frame={b=0, l=0, r=0}, @@ -416,13 +454,11 @@ local function is_displayable_item(item) item.flags.spider_web or item.flags.construction or item.flags.encased or - item.flags.unk12 or item.flags.murder or item.flags.trader or item.flags.owned or item.flags.garbage_collect or - item.flags.on_fire or - item.flags.in_chest + item.flags.on_fire then return false end @@ -444,7 +480,7 @@ local function is_displayable_item(item) local bld = dfhack.items.getHolderBuilding(item) if not bld then return false end for _, contained_item in ipairs(bld.contained_items) do - if contained_item.use_mode == 0 then return true end + if contained_item.use_mode == df.building_item_role_type.TEMP then return true end -- building construction materials if item == contained_item.item then return false end end @@ -467,10 +503,10 @@ local function get_status(item, display_bld) return STATUS.AVAILABLE.value end -local function make_choice_text(data) +local function make_choice_text(data, threshold) return { {width=STATUS_COL_WIDTH, text=function() return STATUS[STATUS_REVMAP[data.status]].label end}, - {gap=2, width=VALUE_COL_WIDTH, rjustify=true, text=common.obfuscate_value(data.value)}, + {gap=2, width=VALUE_COL_WIDTH, rjustify=true, text=common.obfuscate_value(data.value, threshold)}, {gap=2, text=data.desc}, } end @@ -479,7 +515,7 @@ local function make_container_search_key(item, desc) local words = {} common.add_words(words, desc) for _, contained_item in ipairs(dfhack.items.getContainedItems(item)) do - common.add_words(words, common.get_item_description(contained_item)) + common.add_words(words, dfhack.items.getReadableDescription(contained_item)) end return table.concat(words, ' ') end @@ -494,8 +530,9 @@ end function AssignItems:cache_choices(inside_containers, display_bld) if self.choices_cache[inside_containers] then return self.choices_cache[inside_containers] end + local cache_threshold = common.get_threshold(common.get_broker_skill()) local choices = {} - for _, item in ipairs(df.global.world.items.all) do + for _, item in ipairs(df.global.world.items.other.IN_PLAY) do if not is_displayable_item(item) then goto continue end if inside_containers and is_container(item) and contains_non_liquid_powder(item) then goto continue @@ -503,7 +540,7 @@ function AssignItems:cache_choices(inside_containers, display_bld) goto continue end local value = common.get_perceived_value(item) - local desc = common.get_item_description(item) + local desc = dfhack.items.getReadableDescription(item) local status = get_status(item, self.bld) local reachable = dfhack.maps.canWalkBetween(xyz2pos(dfhack.items.getPosition(item)), xyz2pos(display_bld.centerx, display_bld.centery, display_bld.z)) @@ -523,7 +560,7 @@ function AssignItems:cache_choices(inside_containers, display_bld) end local entry = { search_key=search_key, - text=make_choice_text(data), + text=make_choice_text(data, cache_threshold), data=data, } table.insert(choices, entry) @@ -534,17 +571,24 @@ function AssignItems:cache_choices(inside_containers, display_bld) return choices end +local function is_written_work(item) + if df.item_bookst:is_instance(item) then return true end + return df.item_toolst:is_instance(item) and item:hasToolUse(df.tool_uses.CONTAIN_WRITING) +end + function AssignItems:get_choices() local raw_choices = self:cache_choices(self.subviews.inside_containers:getOptionValue(), self.bld) local choices = {} local include_unreachable = not self.subviews.hide_unreachable:getOptionValue() local include_forbidden = not self.subviews.hide_forbidden:getOptionValue() + local include_written = not self.subviews.hide_written:getOptionValue() local min_quality = self.subviews.min_quality:getOptionValue() local max_quality = self.subviews.max_quality:getOptionValue() for _,choice in ipairs(raw_choices) do local data = choice.data if not include_unreachable and not data.reachable then goto continue end if not include_forbidden and data.item.flags.forbid then goto continue end + if not include_written and is_written_work(data.item) then goto continue end if min_quality > data.quality then goto continue end if max_quality < data.quality then goto continue end table.insert(choices, choice) @@ -556,9 +600,10 @@ end local function unassign_item(bld, item) if not bld then return end - local _, found, idx = utils.binsearch(bld.displayed_items, item.id) - if found then + local idx, _ = utils.linear_index(bld.displayed_items, item.id) + if idx then bld.displayed_items:erase(idx) + item.flags.in_building = false end end @@ -585,7 +630,7 @@ local function attach_item(item, display_bld) local ref = df.new(df.general_ref_building_display_furniturest) ref.building_id = display_bld.id item.general_refs:insert('#', ref) - utils.insert_sorted(display_bld.displayed_items, item.id) + display_bld.displayed_items:insert('#', item.id) item.flags.forbid = false item.flags.in_building = false end @@ -666,11 +711,11 @@ end PedestalOverlay = defclass(PedestalOverlay, overlay.OverlayWidget) PedestalOverlay.ATTRS{ + desc='Adds link to the display furniture building panel to launch the DFHack display assignment UI.', default_pos={x=-40, y=34}, default_enabled=true, viewscreens='dwarfmode/ViewSheets/BUILDING/DisplayFurniture', frame={w=23, h=1}, - frame_background=gui.CLEAR_PEN, } local function is_valid_building() @@ -682,7 +727,7 @@ function PedestalOverlay:init() self:addviews{ widgets.TextButton{ frame={t=0, l=0}, - label='DFHack assign items', + label='DFHack assign', key='CUSTOM_CTRL_T', visible=is_valid_building, on_activate=function() AssignItemsModal{}:show() end, diff --git a/internal/caravan/predicates.lua b/internal/caravan/predicates.lua index 6780e46e55..695a9db06c 100644 --- a/internal/caravan/predicates.lua +++ b/internal/caravan/predicates.lua @@ -11,7 +11,7 @@ end local PREDICATE_LIBRARY = { {name='weapons-grade metal', match=function(item) if item:getMaterial() ~= 0 then return false end - local flags = df.global.world.raws.inorganics[item:getMaterialIndex()].material.flags + local flags = df.global.world.raws.inorganics.all[item:getMaterialIndex()].material.flags return flags.IS_METAL and (flags.ITEMS_METAL or flags.ITEMS_WEAPON or flags.ITEMS_WEAPON_RANGED or flags.ITEMS_AMMO or flags.ITEMS_ARMOR) end}, diff --git a/internal/caravan/trade.lua b/internal/caravan/trade.lua index 6d5f3a8d89..f78bc75c05 100644 --- a/internal/caravan/trade.lua +++ b/internal/caravan/trade.lua @@ -9,6 +9,7 @@ local common = reqscript('internal/caravan/common') local gui = require('gui') local overlay = require('plugins.overlay') local predicates = reqscript('internal/caravan/predicates') +local utils = require('utils') local widgets = require('gui.widgets') trader_selected_state = trader_selected_state or {} @@ -16,15 +17,6 @@ broker_selected_state = broker_selected_state or {} handle_ctrl_click_on_render = handle_ctrl_click_on_render or false handle_shift_click_on_render = handle_shift_click_on_render or false -local GOODFLAG = { - UNCONTAINED_UNSELECTED = 0, - UNCONTAINED_SELECTED = 1, - CONTAINED_UNSELECTED = 2, - CONTAINED_SELECTED = 3, - CONTAINER_COLLAPSED_UNSELECTED = 4, - CONTAINER_COLLAPSED_SELECTED = 5, -} - local trade = df.global.game.main_interface.trade -- ------------------- @@ -34,55 +26,18 @@ local trade = df.global.game.main_interface.trade Trade = defclass(Trade, widgets.Window) Trade.ATTRS { frame_title='Select trade goods', - frame={w=84, h=47}, + frame={w=86, h=47}, resizable=true, - resize_min={w=48, h=27}, -} - -local TOGGLE_MAP = { - [GOODFLAG.UNCONTAINED_UNSELECTED] = GOODFLAG.UNCONTAINED_SELECTED, - [GOODFLAG.UNCONTAINED_SELECTED] = GOODFLAG.UNCONTAINED_UNSELECTED, - [GOODFLAG.CONTAINED_UNSELECTED] = GOODFLAG.CONTAINED_SELECTED, - [GOODFLAG.CONTAINED_SELECTED] = GOODFLAG.CONTAINED_UNSELECTED, - [GOODFLAG.CONTAINER_COLLAPSED_UNSELECTED] = GOODFLAG.CONTAINER_COLLAPSED_SELECTED, - [GOODFLAG.CONTAINER_COLLAPSED_SELECTED] = GOODFLAG.CONTAINER_COLLAPSED_UNSELECTED, -} - -local TARGET_MAP = { - [true]={ - [GOODFLAG.UNCONTAINED_UNSELECTED] = GOODFLAG.UNCONTAINED_SELECTED, - [GOODFLAG.UNCONTAINED_SELECTED] = GOODFLAG.UNCONTAINED_SELECTED, - [GOODFLAG.CONTAINED_UNSELECTED] = GOODFLAG.CONTAINED_SELECTED, - [GOODFLAG.CONTAINED_SELECTED] = GOODFLAG.CONTAINED_SELECTED, - [GOODFLAG.CONTAINER_COLLAPSED_UNSELECTED] = GOODFLAG.CONTAINER_COLLAPSED_SELECTED, - [GOODFLAG.CONTAINER_COLLAPSED_SELECTED] = GOODFLAG.CONTAINER_COLLAPSED_SELECTED, - }, - [false]={ - [GOODFLAG.UNCONTAINED_UNSELECTED] = GOODFLAG.UNCONTAINED_UNSELECTED, - [GOODFLAG.UNCONTAINED_SELECTED] = GOODFLAG.UNCONTAINED_UNSELECTED, - [GOODFLAG.CONTAINED_UNSELECTED] = GOODFLAG.CONTAINED_UNSELECTED, - [GOODFLAG.CONTAINED_SELECTED] = GOODFLAG.CONTAINED_UNSELECTED, - [GOODFLAG.CONTAINER_COLLAPSED_UNSELECTED] = GOODFLAG.CONTAINER_COLLAPSED_UNSELECTED, - [GOODFLAG.CONTAINER_COLLAPSED_SELECTED] = GOODFLAG.CONTAINER_COLLAPSED_UNSELECTED, - }, -} - -local TARGET_REVMAP = { - [GOODFLAG.UNCONTAINED_UNSELECTED] = false, - [GOODFLAG.UNCONTAINED_SELECTED] = true, - [GOODFLAG.CONTAINED_UNSELECTED] = false, - [GOODFLAG.CONTAINED_SELECTED] = true, - [GOODFLAG.CONTAINER_COLLAPSED_UNSELECTED] = false, - [GOODFLAG.CONTAINER_COLLAPSED_SELECTED] = true, + resize_min={w=48, h=40}, } local function get_entry_icon(data) - if TARGET_REVMAP[trade.goodflag[data.list_idx][data.item_idx]] then + if trade.goodflag[data.list_idx][data.item_idx].selected then return common.ALL_PEN end end -local function sort_noop(a, b) +local function sort_noop() -- this function is used as a marker and never actually gets called error('sort_noop should not be called') end @@ -139,7 +94,7 @@ end local STATUS_COL_WIDTH = 7 local VALUE_COL_WIDTH = 6 -local FILTER_HEIGHT = 15 +local FILTER_HEIGHT = 18 function Trade:init() self.cur_page = 1 @@ -174,8 +129,8 @@ function Trade:init() label='Bins:', key='CUSTOM_SHIFT_B', options={ - {label='trade bin with contents', value=true}, - {label='trade contents only', value=false}, + {label='Trade bin with contents', value=true, pen=COLOR_YELLOW}, + {label='Trade contents only', value=false, pen=COLOR_GREEN}, }, initial_option=false, on_change=function() self:refresh_list() end, @@ -215,23 +170,24 @@ function Trade:init() }, widgets.Panel{ frame={t=7, l=0, r=0, h=FILTER_HEIGHT}, + frame_style=gui.FRAME_INTERIOR, visible=function() return self.subviews.filters:getOptionValue() end, on_layout=function() local panel_frame = self.subviews.list_panel.frame if self.subviews.filters:getOptionValue() then - panel_frame.t = 7 + FILTER_HEIGHT + panel_frame.t = 7 + FILTER_HEIGHT + 1 else panel_frame.t = 7 end end, subviews={ widgets.Panel{ - frame={t=0, l=0, w=38, h=FILTER_HEIGHT}, + frame={t=0, l=0, w=38}, visible=function() return self.cur_page == 1 end, subviews=common.get_slider_widgets(self, '1'), }, widgets.Panel{ - frame={t=0, l=0, w=38, h=FILTER_HEIGHT}, + frame={t=0, l=0, w=38}, visible=function() return self.cur_page == 2 end, subviews=common.get_slider_widgets(self, '2'), }, @@ -241,15 +197,15 @@ function Trade:init() subviews=common.get_advanced_filter_widgets(self, self.predicate_contexts[1]), }, widgets.Panel{ - frame={t=2, l=40, r=0, h=FILTER_HEIGHT-2}, + frame={t=1, l=40, r=0}, visible=function() return self.cur_page == 2 end, - subviews=common.get_info_widgets(self, {trade.mer.buy_prices}, self.predicate_contexts[2]), + subviews=common.get_info_widgets(self, {trade.mer.buy_prices}, true, self.predicate_contexts[2]), }, }, }, widgets.Panel{ view_id='list_panel', - frame={t=7, l=0, r=0, b=4}, + frame={t=7, l=0, r=0, b=5}, subviews={ widgets.CycleHotkeyLabel{ view_id='sort_status', @@ -295,17 +251,23 @@ function Trade:init() }, } }, + widgets.Divider{ + frame={b=4, h=1}, + frame_style=gui.FRAME_INTERIOR, + frame_style_l=false, + frame_style_r=false, + }, + widgets.Label{ + frame={b=2, l=0, r=0}, + text='Click to mark/unmark for trade. Shift click to mark/unmark a range of items.', + }, widgets.HotkeyLabel{ - frame={l=0, b=2}, + frame={l=0, b=0}, label='Select all/none', - key='CUSTOM_CTRL_A', + key='CUSTOM_CTRL_N', on_activate=self:callback('toggle_visible'), auto_width=true, }, - widgets.WrappedLabel{ - frame={b=0, l=0, r=0}, - text_to_wrap='Click to mark/unmark for trade. Shift click to mark/unmark a range of items.', - }, } -- replace the FilteredList's built-in EditField with our own @@ -353,9 +315,9 @@ local function is_ethical_product(item, animal_ethics, wood_ethics) (not wood_ethics or not common.has_wood(item)) end -local function make_choice_text(value, desc) +local function make_choice_text(value, threshold, desc) return { - {width=STATUS_COL_WIDTH+VALUE_COL_WIDTH, rjustify=true, text=common.obfuscate_value(value)}, + {width=STATUS_COL_WIDTH+VALUE_COL_WIDTH, rjustify=true, text=common.obfuscate_value(value, threshold)}, {gap=2, text=desc}, } end @@ -366,15 +328,16 @@ function Trade:cache_choices(list_idx, trade_bins) local goodflags = trade.goodflag[list_idx] local trade_bins_choices, notrade_bins_choices = {}, {} local parent_data + local cache_threshold = common.get_threshold(common.get_broker_skill()) for item_idx, item in ipairs(trade.good[list_idx]) do local goodflag = goodflags[item_idx] - if goodflag ~= GOODFLAG.CONTAINED_UNSELECTED and goodflag ~= GOODFLAG.CONTAINED_SELECTED then + if not goodflag.contained then parent_data = nil end local is_banned, is_risky = common.scan_banned(item, self.risky_items) local is_requested = dfhack.items.isRequestedTradeGood(item, trade.mer) local wear_level = item:getWear() - local desc = common.get_item_description(item) + local desc = dfhack.items.getReadableDescription(item) local is_ethical = is_ethical_product(item, self.animal_ethics, self.wood_ethics) local data = { desc=desc, @@ -384,10 +347,12 @@ function Trade:cache_choices(list_idx, trade_bins) item_idx=item_idx, quality=item.flags.artifact and 6 or item:getQuality(), wear=wear_level, + has_foreign=item.flags.foreign, has_banned=is_banned, has_risky=is_risky, has_requested=is_requested, - ethical=is_ethical, + has_ethical=is_ethical, + ethical_mixed=false, } if parent_data then data.update_container_fn = function(from, to) @@ -396,15 +361,22 @@ function Trade:cache_choices(list_idx, trade_bins) parent_data.has_banned = parent_data.has_banned or is_banned parent_data.has_risky = parent_data.has_risky or is_risky parent_data.has_requested = parent_data.has_requested or is_requested - parent_data.ethical = parent_data.ethical and is_ethical + parent_data.ethical_mixed = parent_data.ethical_mixed or (parent_data.has_ethical ~= is_ethical) + parent_data.has_ethical = parent_data.has_ethical or is_ethical + end + local is_container = df.item_binst:is_instance(item) + local search_key + if (trade_bins and is_container) or item:isFoodStorage() then + search_key = common.make_container_search_key(item, desc) + else + search_key = common.make_search_key(desc) end local choice = { - search_key=common.make_search_key(desc), + search_key=search_key, icon=curry(get_entry_icon, data), data=data, - text=make_choice_text(data.value, desc), + text=make_choice_text(data.value, cache_threshold, desc), } - local is_container = df.item_binst:is_instance(item) if not data.update_container_fn then table.insert(trade_bins_choices, choice) end @@ -421,9 +393,11 @@ end function Trade:get_choices() local raw_choices = self:cache_choices(self.cur_page-1, self.subviews.trade_bins:getOptionValue()) + local provenance = self.subviews.provenance:getOptionValue() local banned = self.cur_page == 1 and 'ignore' or self.subviews.banned:getOptionValue() local only_agreement = self.cur_page == 2 and self.subviews.only_agreement:getOptionValue() or false local ethical = self.cur_page == 1 and 'show' or self.subviews.ethical:getOptionValue() + local strict_ethical_bins = self.subviews.strict_ethical_bins:getOptionValue() local min_condition = self.subviews['min_condition'..self.cur_page]:getOptionValue() local max_condition = self.subviews['max_condition'..self.cur_page]:getOptionValue() local min_quality = self.subviews['min_quality'..self.cur_page]:getOptionValue() @@ -434,8 +408,16 @@ function Trade:get_choices() for _,choice in ipairs(raw_choices) do local data = choice.data if ethical ~= 'show' then - if ethical == 'hide' and data.ethical then goto continue end - if ethical == 'only' and not data.ethical then goto continue end + if strict_ethical_bins and data.ethical_mixed then goto continue end + if ethical == 'hide' and data.has_ethical then goto continue end + if ethical == 'only' and not data.has_ethical then goto continue end + end + if provenance ~= 'all' then + if (provenance == 'local' and data.has_foreign) or + (provenance == 'foreign' and not data.has_foreign) + then + goto continue + end end if min_condition < data.wear then goto continue end if max_condition > data.wear then goto continue end @@ -461,11 +443,13 @@ end local function toggle_item_base(choice, target_value) local goodflag = trade.goodflag[choice.data.list_idx][choice.data.item_idx] - local goodflag_map = target_value == nil and TOGGLE_MAP or TARGET_MAP[target_value] - trade.goodflag[choice.data.list_idx][choice.data.item_idx] = goodflag_map[goodflag] - target_value = TARGET_REVMAP[trade.goodflag[choice.data.list_idx][choice.data.item_idx]] + if target_value == nil then + target_value = not goodflag.selected + end + local prev_value = goodflag.selected + goodflag.selected = target_value if choice.data.update_container_fn then - choice.data.update_container_fn(TARGET_REVMAP[goodflag], target_value) + choice.data.update_container_fn(prev_value, target_value) end return target_value end @@ -510,7 +494,7 @@ end -- TradeScreen -- -view = view or nil +trade_view = trade_view or nil TradeScreen = defclass(TradeScreen, gui.ZScreen) TradeScreen.ATTRS { @@ -534,15 +518,18 @@ end function TradeScreen:onRenderFrame() if not df.global.game.main_interface.trade.open then - if view then view:dismiss() end - elseif self.reset_pending then + if trade_view then trade_view:dismiss() end + elseif self.reset_pending and + (dfhack.gui.matchFocusString('dfhack/lua/caravan/trade') or + dfhack.gui.matchFocusString('dwarfmode/Trade/Default')) + then self.reset_pending = nil self.trade_window:reset_cache() end end function TradeScreen:onDismiss() - view = nil + trade_view = nil end -- ------------------- @@ -562,39 +549,40 @@ local function set_height(list_idx, delta) trade.i_height[list_idx] - page_height)) end -local function select_shift_clicked_container_items(new_state, old_state, list_idx) +local function flags_match(goodflag1, goodflag2) + return goodflag1.selected == goodflag2.selected and + goodflag1.contained == goodflag2.contained and + goodflag1.container_collapsed == goodflag2.container_collapsed and + goodflag1.filtered_off == goodflag2.filtered_off +end + +local function select_shift_clicked_container_items(new_state, old_state_fn, list_idx) -- if ctrl is also held, collapse the container too local also_collapse = dfhack.internal.getModifiers().ctrl - local collapsed_item_count, collapsing_container, in_container = 0, false, false + local collapsed_item_count, collapsing_container, in_target_container = 0, false, false for k, goodflag in ipairs(new_state) do - if in_container then - if goodflag <= GOODFLAG.UNCONTAINED_SELECTED - or goodflag >= GOODFLAG.CONTAINER_COLLAPSED_UNSELECTED then - break - end - - new_state[k] = GOODFLAG.CONTAINED_SELECTED - + if in_target_container then + if not goodflag.contained then break end + goodflag.selected = true if collapsing_container then collapsed_item_count = collapsed_item_count + 1 end goto continue end - if goodflag == old_state[k] then goto continue end + local old_goodflag = old_state_fn(k) + if flags_match(goodflag, old_goodflag) then goto continue end local is_container = df.item_binst:is_instance(trade.good[list_idx][k]) if not is_container then goto continue end -- deselect the container itself - if also_collapse or - old_state[k] == GOODFLAG.CONTAINER_COLLAPSED_UNSELECTED or - old_state[k] == GOODFLAG.CONTAINER_COLLAPSED_SELECTED then - collapsing_container = goodflag == GOODFLAG.UNCONTAINED_SELECTED - new_state[k] = GOODFLAG.CONTAINER_COLLAPSED_UNSELECTED - else - new_state[k] = GOODFLAG.UNCONTAINED_UNSELECTED + goodflag.selected = false + + if also_collapse or old_goodflag.container_collapsed then + goodflag.container_collapsed = true + collapsing_container = not old_goodflag.container_collapsed end - in_container = true + in_target_container = true ::continue:: end @@ -604,37 +592,27 @@ local function select_shift_clicked_container_items(new_state, old_state, list_i end end -local CTRL_CLICK_STATE_MAP = { - [GOODFLAG.UNCONTAINED_UNSELECTED] = GOODFLAG.CONTAINER_COLLAPSED_UNSELECTED, - [GOODFLAG.UNCONTAINED_SELECTED] = GOODFLAG.CONTAINER_COLLAPSED_SELECTED, - [GOODFLAG.CONTAINER_COLLAPSED_UNSELECTED] = GOODFLAG.UNCONTAINED_UNSELECTED, - [GOODFLAG.CONTAINER_COLLAPSED_SELECTED] = GOODFLAG.UNCONTAINED_SELECTED, -} - -- collapses uncollapsed containers and restores the selection state for the container -- and contained items -local function toggle_ctrl_clicked_containers(new_state, old_state, list_idx) - local toggled_item_count, in_container, is_collapsing = 0, false, false +local function toggle_ctrl_clicked_containers(new_state, old_state_fn, list_idx) + local toggled_item_count, in_target_container, is_collapsing = 0, false, false for k, goodflag in ipairs(new_state) do - if in_container then - if goodflag <= GOODFLAG.UNCONTAINED_SELECTED - or goodflag >= GOODFLAG.CONTAINER_COLLAPSED_UNSELECTED then - break - end + local old_goodflag = old_state_fn(k) + if in_target_container then + if not goodflag.contained then break end toggled_item_count = toggled_item_count + 1 - new_state[k] = old_state[k] + utils.assign(goodflag, old_goodflag) goto continue end - if goodflag == old_state[k] then goto continue end - local is_contained = goodflag == GOODFLAG.CONTAINED_UNSELECTED or goodflag == GOODFLAG.CONTAINED_SELECTED - if is_contained then goto continue end + if flags_match(goodflag, old_goodflag) or goodflag.contained then goto continue end local is_container = df.item_binst:is_instance(trade.good[list_idx][k]) if not is_container then goto continue end - new_state[k] = CTRL_CLICK_STATE_MAP[old_state[k]] - in_container = true - is_collapsing = goodflag == GOODFLAG.UNCONTAINED_UNSELECTED or goodflag == GOODFLAG.UNCONTAINED_SELECTED + goodflag.selected = old_goodflag.selected + goodflag.container_collapsed = not old_goodflag.container_collapsed + in_target_container = true + is_collapsing = goodflag.container_collapsed ::continue:: end @@ -667,27 +645,17 @@ end local function collapseContainers(item_list, list_idx) local num_items_collapsed = 0 for k, goodflag in ipairs(item_list) do - if goodflag == GOODFLAG.CONTAINED_UNSELECTED - or goodflag == GOODFLAG.CONTAINED_SELECTED then - goto continue - end + if goodflag.contained then goto continue end local item = trade.good[list_idx][k] local is_container = df.item_binst:is_instance(item) if not is_container then goto continue end - local collapsed_this_container = false - if goodflag == GOODFLAG.UNCONTAINED_SELECTED then - item_list[k] = GOODFLAG.CONTAINER_COLLAPSED_SELECTED - collapsed_this_container = true - elseif goodflag == GOODFLAG.UNCONTAINED_UNSELECTED then - item_list[k] = GOODFLAG.CONTAINER_COLLAPSED_UNSELECTED - collapsed_this_container = true - end - - if collapsed_this_container then + if not goodflag.container_collapsed then + goodflag.container_collapsed = true num_items_collapsed = num_items_collapsed + #dfhack.items.getContainedItems(item) end + ::continue:: end @@ -707,12 +675,19 @@ local function collapseEverything() end local function copyGoodflagState() - trader_selected_state = copyall(trade.goodflag[0]) - broker_selected_state = copyall(trade.goodflag[1]) + -- utils.clone will return a lua table, with indices offset by 1 + -- we'll use getSavedGoodflag to map the index back to the original value + trader_selected_state = utils.clone(trade.goodflag[0], true) + broker_selected_state = utils.clone(trade.goodflag[1], true) +end + +local function getSavedGoodflag(saved_state, k) + return saved_state[k+1] end TradeOverlay = defclass(TradeOverlay, overlay.OverlayWidget) TradeOverlay.ATTRS{ + desc='Adds convenience functions for working with bins to the trade screen.', default_pos={x=-3,y=-12}, default_enabled=true, viewscreens='dwarfmode/Trade/Default', @@ -768,12 +743,12 @@ end function TradeOverlay:onRenderBody(dc) if handle_shift_click_on_render then handle_shift_click_on_render = false - select_shift_clicked_container_items(trade.goodflag[0], trader_selected_state, 0) - select_shift_clicked_container_items(trade.goodflag[1], broker_selected_state, 1) + select_shift_clicked_container_items(trade.goodflag[0], curry(getSavedGoodflag, trader_selected_state), 0) + select_shift_clicked_container_items(trade.goodflag[1], curry(getSavedGoodflag, broker_selected_state), 1) elseif handle_ctrl_click_on_render then handle_ctrl_click_on_render = false - toggle_ctrl_clicked_containers(trade.goodflag[0], trader_selected_state, 0) - toggle_ctrl_clicked_containers(trade.goodflag[1], broker_selected_state, 1) + toggle_ctrl_clicked_containers(trade.goodflag[0], curry(getSavedGoodflag, trader_selected_state), 0) + toggle_ctrl_clicked_containers(trade.goodflag[1], curry(getSavedGoodflag, broker_selected_state), 1) end end @@ -797,6 +772,7 @@ end TradeBannerOverlay = defclass(TradeBannerOverlay, overlay.OverlayWidget) TradeBannerOverlay.ATTRS{ + desc='Adds link to the trade screen to launch the DFHack trade UI.', default_pos={x=-31,y=-7}, default_enabled=true, viewscreens='dwarfmode/Trade/Default', @@ -811,7 +787,7 @@ function TradeBannerOverlay:init() label='DFHack trade UI', key='CUSTOM_CTRL_T', enabled=function() return trade.stillunloading == 0 and trade.havetalker == 1 end, - on_activate=function() view = view and view:raise() or TradeScreen{}:show() end, + on_activate=function() trade_view = trade_view and trade_view:raise() or TradeScreen{}:show() end, }, } end @@ -820,8 +796,208 @@ function TradeBannerOverlay:onInput(keys) if TradeBannerOverlay.super.onInput(self, keys) then return true end if keys._MOUSE_R or keys.LEAVESCREEN then - if view then - view:dismiss() + if trade_view then + trade_view:dismiss() + end + end +end + +-- ------------------- +-- Ethics +-- + +Ethics = defclass(Ethics, widgets.Window) +Ethics.ATTRS { + frame_title='Ethical transgressions', + frame={w=45, h=30}, + resizable=true, +} + +function Ethics:init() + self.choices = {} + self.animal_ethics = common.is_animal_lover_caravan(trade.mer) + self.wood_ethics = common.is_tree_lover_caravan(trade.mer) + + self:addviews{ + widgets.Label{ + frame={l=0, t=0}, + text={ + 'You have ', + {text=self:callback('get_transgression_count'), pen=self:callback('get_transgression_color')}, + ' item', + {text=function() return self:get_transgression_count() == 1 and '' or 's' end}, + ' selected for trade', NEWLINE, + 'that would offend the merchants:', + }, + }, + widgets.List{ + view_id='list', + frame={l=0, r=0, t=3, b=2}, + }, + widgets.HotkeyLabel{ + frame={l=0, b=0}, + key='CUSTOM_CTRL_N', + label='Deselect items in trade list', + auto_width=true, + on_activate=self:callback('deselect_transgressions'), + }, + } + + self:rescan() +end + +function Ethics:get_transgression_count() + return #self.choices +end + +function Ethics:get_transgression_color() + return next(self.choices) and COLOR_LIGHTRED or COLOR_LIGHTGREEN +end + +-- also used by confirm +function for_selected_item(list_idx, fn) + local goodflags = trade.goodflag[list_idx] + local in_selected_container = false + for item_idx, item in ipairs(trade.good[list_idx]) do + local goodflag = goodflags[item_idx] + if not goodflag.contained then + in_selected_container = goodflag.selected + end + if in_selected_container or goodflag.selected then + if fn(item_idx, item) then + return + end + end + end +end + +local function for_ethics_violation(fn, animal_ethics, wood_ethics) + if not animal_ethics and not wood_ethics then return end + for_selected_item(1, function(item_idx, item) + if not is_ethical_product(item, animal_ethics, wood_ethics) then + if fn(item_idx, item) then return true end + end + end) +end + +function Ethics:rescan() + local choices = {} + for_ethics_violation(function(item_idx, item) + local choice = { + text=dfhack.items.getReadableDescription(item), + data={item_idx=item_idx}, + } + table.insert(choices, choice) + end, self.animal_ethics, self.wood_ethics) + + self.subviews.list:setChoices(choices) + self.choices = choices +end + +function Ethics:deselect_transgressions() + local goodflags = trade.goodflag[1] + for _,choice in ipairs(self.choices) do + goodflags[choice.data.item_idx].selected = false + end + self:rescan() +end + +-- ------------------- +-- EthicsScreen +-- + +ethics_view = ethics_view or nil + +EthicsScreen = defclass(EthicsScreen, gui.ZScreen) +EthicsScreen.ATTRS { + focus_path='caravan/trade/ethics', +} + +function EthicsScreen:init() + self.ethics_window = Ethics{} + self:addviews{self.ethics_window} +end + +function EthicsScreen:onInput(keys) + if self.reset_pending then return false end + local handled = EthicsScreen.super.onInput(self, keys) + if keys._MOUSE_L and not self.ethics_window:getMouseFramePos() then + -- check for modified selection + self.reset_pending = true + end + return handled +end + +function EthicsScreen:onRenderFrame() + if not df.global.game.main_interface.trade.open then + if ethics_view then ethics_view:dismiss() end + elseif self.reset_pending and + (dfhack.gui.matchFocusString('dfhack/lua/caravan/trade') or + dfhack.gui.matchFocusString('dwarfmode/Trade/Default')) + then + self.reset_pending = nil + self.ethics_window:rescan() + end +end + +function EthicsScreen:onDismiss() + ethics_view = nil +end + +-- -------------------------- +-- TradeEthicsWarningOverlay +-- + +-- also called by confirm +function has_ethics_violation() + local violated = false + for_ethics_violation(function() + violated = true + return true + end, common.is_animal_lover_caravan(trade.mer), common.is_tree_lover_caravan(trade.mer)) + return violated +end + +TradeEthicsWarningOverlay = defclass(TradeEthicsWarningOverlay, overlay.OverlayWidget) +TradeEthicsWarningOverlay.ATTRS{ + desc='Adds warning to the trade screen when you are about to offend the elves.', + default_pos={x=-54,y=-5}, + default_enabled=true, + viewscreens='dwarfmode/Trade/Default', + frame={w=9, h=2}, + visible=has_ethics_violation, +} + +function TradeEthicsWarningOverlay:init() + self:addviews{ + widgets.BannerPanel{ + frame={l=0, w=9}, + subviews={ + widgets.Label{ + frame={l=1, r=1}, + text={ + 'Ethics', NEWLINE, + 'warning', + }, + on_click=function() ethics_view = ethics_view and ethics_view:raise() or EthicsScreen{}:show() end, + text_pen=COLOR_LIGHTRED, + auto_width=false, + }, + }, + }, + } +end + +function TradeEthicsWarningOverlay:preUpdateLayout(rect) + self.frame.w = (rect.width - 95) // 2 +end + +function TradeEthicsWarningOverlay:onInput(keys) + if TradeEthicsWarningOverlay.super.onInput(self, keys) then return true end + + if keys._MOUSE_R or keys.LEAVESCREEN then + if ethics_view then + ethics_view:dismiss() end end end diff --git a/internal/caravan/tradeagreement.lua b/internal/caravan/tradeagreement.lua index 52f4b5e91a..f714670258 100644 --- a/internal/caravan/tradeagreement.lua +++ b/internal/caravan/tradeagreement.lua @@ -1,23 +1,69 @@ --@ module = true - +local dlg = require('gui.dialogs') local gui = require('gui') local overlay = require('plugins.overlay') local widgets = require('gui.widgets') +local diplomacy = df.global.game.main_interface.diplomacy + TradeAgreementOverlay = defclass(TradeAgreementOverlay, overlay.OverlayWidget) TradeAgreementOverlay.ATTRS{ + desc='Adds quick toggles for groups of trade agreement items.', default_pos={x=45, y=-6}, default_enabled=true, viewscreens='dwarfmode/Diplomacy/Requests', - frame={w=25, h=3}, + frame={w=25, h=4}, frame_style=gui.MEDIUM_FRAME, frame_background=gui.CLEAR_PEN, } -local diplomacy = df.global.game.main_interface.diplomacy +local function transform_mat_list(matList) + local list = {} + for key, value in pairs(matList.mat_index) do + list[key] = {type=matList.mat_type[key], index=value} + end + return list +end + +local function decode_mat_list(mat) + return dfhack.matinfo.decode(mat.type, mat.index).material.material_value +end + +local select_by_value_tab = { + Leather={ + get_mats=function(resources) return transform_mat_list(resources.organic.leather) end, + decode=decode_mat_list, + }, + SmallCutGems={ + get_mats=function(resources) return resources.gems end, + decode=function(id) return dfhack.matinfo.decode(0, id).material.material_value end, + }, + Meat={ + get_mats=function(resources) return transform_mat_list(resources.misc_mat.meat) end, + decode=decode_mat_list, + }, + Parchment={ + get_mats=function(resources) return transform_mat_list(resources.organic.parchment) end, + decode=decode_mat_list, + }, +} +select_by_value_tab.LargeCutGems = select_by_value_tab.SmallCutGems + +local function get_cur_tab_category() + return diplomacy.taking_requests_tablist[diplomacy.taking_requests_selected_tab] +end + +local function get_select_by_value_tab(category) + category = category or get_cur_tab_category() + return select_by_value_tab[df.entity_sell_category[category]] +end + +local function get_cur_priority_list() + return diplomacy.environment.dipev.sell_requests.priority[get_cur_tab_category()] +end + local function diplomacy_toggle_cat() - local priority_idx = diplomacy.taking_requests_tablist[diplomacy.taking_requests_selected_tab] - local priority = diplomacy.environment.meeting.sell_requests.priority[priority_idx] + local priority = get_cur_priority_list() if #priority == 0 then return end local target_val = priority[0] == 0 and 4 or 0 for i in ipairs(priority) do @@ -25,6 +71,15 @@ local function diplomacy_toggle_cat() end end +local function select_by_value(prices, val) + local priority = get_cur_priority_list() + for i in ipairs(priority) do + if prices[i] == val then + priority[i] = 4 + end + end +end + function TradeAgreementOverlay:init() self:addviews{ widgets.HotkeyLabel{ @@ -34,4 +89,53 @@ function TradeAgreementOverlay:init() on_activate=diplomacy_toggle_cat, }, } + self:addviews{ + widgets.HotkeyLabel{ + frame={t=1, l=0}, + label='Select by value', + key='CUSTOM_CTRL_M', + on_activate=self:callback('select_by_value'), + enabled=get_select_by_value_tab, + }, + } +end + +local function get_prices(tab) + local resource = tab.get_mats(df.historical_entity.find(diplomacy.actor.civ_id).resources) + local prices = {} + local matValuesUnique = {} + local filter = {} + for civid, matid in pairs(resource) do + local price = tab.decode(matid) + prices[civid] = price + if not filter[price] then + local val = {value=price, count=1} + filter[price] = val + table.insert(matValuesUnique, val) + else + filter[price].count = filter[price].count + 1 + end + end + table.sort(matValuesUnique, function(a, b) return a.value < b.value end) + return prices, matValuesUnique +end + +function TradeAgreementOverlay:select_by_value() + local cat = get_cur_tab_category() + local cur_tab = get_select_by_value_tab(cat) + + local resource_name = df.entity_sell_category[cat] + if resource_name:endswith('Gems') then resource_name = 'Gem' end + local prices, matValuesUnique = get_prices(cur_tab) + local list = {} + for index, value in ipairs(matValuesUnique) do + list[index] = ('%4d%s (%d type%s of %s)'):format( + value.value, string.char(15), value.count, value.count == 1 and '' or 's', resource_name:lower()) + end + dlg.showListPrompt( + "Select materials with base value", "", + COLOR_WHITE, + list, + function(id) select_by_value(prices, matValuesUnique[id].value) end + ) end diff --git a/internal/confirm/specs.lua b/internal/confirm/specs.lua new file mode 100644 index 0000000000..8daffa92d4 --- /dev/null +++ b/internal/confirm/specs.lua @@ -0,0 +1,617 @@ +--@module = true + +-- if adding a new spec, run `confirm` to load it and make it live +-- +-- remember to reload the overlay when adding/changing specs that have +-- intercept_frames defined + +local json = require('json') +local trade_internal = reqscript('internal/caravan/trade') +local gui = require('gui') + +local CONFIG_FILE = 'dfhack-config/confirm.json' + +-- populated by ConfirmSpec constructor below +REGISTRY = {} + +ConfirmSpec = defclass(ConfirmSpec) +ConfirmSpec.ATTRS{ + id=DEFAULT_NIL, + title='DFHack confirm', + message='Are you sure?', + intercept_keys={}, + intercept_frame=DEFAULT_NIL, + debug_frame=false, -- set to true when debugging frame positioning + context=DEFAULT_NIL, + predicate=DEFAULT_NIL, + on_propagate=DEFAULT_NIL, -- called if prompt is bypassed (Ok clicked or paused) + pausable=false, +} + +function ConfirmSpec:init() + if not self.id then + error('must set id to a unique string') + end + if type(self.intercept_keys) ~= 'table' then + self.intercept_keys = {self.intercept_keys} + end + for _, key in ipairs(self.intercept_keys) do + if key ~= '_MOUSE_L' and key ~= '_MOUSE_R' and not df.interface_key[key] then + error('Invalid key: ' .. tostring(key)) + end + end + if not self.context then + error('context must be set to a bounding focus string') + end + + -- protect against copy-paste errors when defining new specs + if REGISTRY[self.id] then + error('id already registered: ' .. tostring(self.id)) + end + + -- auto-register + REGISTRY[self.id] = self +end + +local mi = df.global.game.main_interface +local plotinfo = df.global.plotinfo + +local function trade_goods_any_selected(which) + local any_selected = false + trade_internal.for_selected_item(which, function() + any_selected = true + return true + end) + return any_selected +end + +local function trade_goods_all_selected(which) + local num_selected = 0 + trade_internal.for_selected_item(which, function(idx) + num_selected = num_selected + 1 + end) + return #mi.trade.goodflag[which] == num_selected +end + +local function trade_agreement_items_any_selected() + local diplomacy = mi.diplomacy + for _, tab in ipairs(diplomacy.environment.dipev.sell_requests.priority) do + for _, priority in ipairs(tab) do + if priority ~= 0 then + return true + end + end + end +end + +local function has_caravans() + for _, caravan in pairs(df.global.plotinfo.caravans) do + if caravan.time_remaining > 0 then + return true + end + end +end + +local function get_num_uniforms() + local site = dfhack.world.getCurrentSite() or {} + for _, entity_site_link in ipairs(site.entity_links or {}) do + local he = df.historical_entity.find(entity_site_link.entity_id) + if he and he.type == df.historical_entity_type.SiteGovernment then + return #he.uniforms + end + end + return 0 +end + +ConfirmSpec{ + id='trade-cancel', + title='Cancel trade', + message='Are you sure you want leave this screen? Selected items will not be saved.', + intercept_keys={'LEAVESCREEN', '_MOUSE_R'}, + context='dwarfmode/Trade/Default', + predicate=function() return trade_goods_any_selected(0) or trade_goods_any_selected(1) end, +} + +ConfirmSpec{ + id='trade-mark-all-fort', + title='Mark all fortress goods', + message='Are you sure you want mark all fortress goods at the depot? Your current fortress goods selections will be lost.', + intercept_keys='_MOUSE_L', + intercept_frame={r=47, b=7, w=12, h=3}, + context='dwarfmode/Trade/Default', + predicate=function() return trade_goods_any_selected(1) and not trade_goods_all_selected(1) end, + pausable=true, +} + +ConfirmSpec{ + id='trade-unmark-all-fort', + title='Unmark all fortress goods', + message='Are you sure you want unmark all fortress goods at the depot? Your current fortress goods selections will be lost.', + intercept_keys='_MOUSE_L', + intercept_frame={r=30, b=7, w=14, h=3}, + context='dwarfmode/Trade/Default', + predicate=function() return trade_goods_any_selected(1) and not trade_goods_all_selected(1) end, + pausable=true, +} + +ConfirmSpec{ + id='trade-mark-all-merchant', + title='Mark all merchant goods', + message='Are you sure you want mark all merchant goods at the depot? Your current merchant goods selections will be lost.', + intercept_keys='_MOUSE_L', + intercept_frame={l=0, r=72, b=7, w=12, h=3}, + context='dwarfmode/Trade/Default', + predicate=function() return trade_goods_any_selected(0) and not trade_goods_all_selected(0) end, + pausable=true, +} + +ConfirmSpec{ + id='trade-unmark-all-merchant', + title='Mark all merchant goods', + message='Are you sure you want mark all merchant goods at the depot? Your current merchant goods selections will be lost.', + intercept_keys='_MOUSE_L', + intercept_frame={l=0, r=40, b=7, w=14, h=3}, + context='dwarfmode/Trade/Default', + predicate=function() return trade_goods_any_selected(0) and not trade_goods_all_selected(0) end, + pausable=true, +} + +local function get_ethics_message(msg) + local lines = {msg} + if trade_internal.has_ethics_violation() then + table.insert(lines, '') + table.insert(lines, 'You have items selected that will offend the merchants. Proceeding with this trade will anger them. You can click on the Ethics warning badge to see which items the merchants will find offensive.') + end + return table.concat(lines, NEWLINE) +end + +ConfirmSpec{ + id='trade-confirm-trade', + title='Confirm trade', + message=curry(get_ethics_message, 'Are you sure you want to trade the selected goods?'), + intercept_keys='_MOUSE_L', + intercept_frame={l=0, r=23, b=4, w=11, h=3}, + context='dwarfmode/Trade/Default', + predicate=function() return trade_goods_any_selected(1) end, + pausable=true, +} + +ConfirmSpec{ + id='trade-seize', + title='Seize merchant goods', + message='Are you sure you want seize marked merchant goods? This will make the merchant unwilling to trade further and will damage relations with the merchant\'s civilization.', + intercept_keys='_MOUSE_L', + intercept_frame={l=0, r=73, b=4, w=11, h=3}, + context='dwarfmode/Trade/Default', + predicate=function() return mi.trade.mer.mood > 0 and trade_goods_any_selected(0) end, + pausable=true, +} + +ConfirmSpec{ + id='trade-offer', + title='Offer fortress goods', + message=curry(get_ethics_message, 'Are you sure you want to offer these goods? You will receive no payment.'), + intercept_keys='_MOUSE_L', + intercept_frame={l=40, r=5, b=4, w=19, h=3}, + context='dwarfmode/Trade/Default', + predicate=function() return trade_goods_any_selected(1) end, + pausable=true, +} + +ConfirmSpec{ + id='diplomacy-request', + title='Cancel trade agreement', + message='Are you sure you want to leave this screen? The trade agreement selection will not be saved until you hit the "Done" button at the bottom of the screen.', + intercept_keys={'LEAVESCREEN', '_MOUSE_R'}, + context='dwarfmode/Diplomacy/Requests', + predicate=trade_agreement_items_any_selected, +} + +ConfirmSpec{ + id='haul-delete-route', + title='Delete hauling route', + message='Are you sure you want to delete this route?', + intercept_keys='_MOUSE_L', + context='dwarfmode/Hauling', + predicate=function() return mi.current_hover == df.main_hover_instruction.HAULING_REMOVE_ROUTE end, + pausable=true, +} + +ConfirmSpec{ + id='haul-delete-stop', + title='Delete hauling stop', + message='Are you sure you want to delete this stop?', + intercept_keys='_MOUSE_L', + context='dwarfmode/Hauling', + predicate=function() return mi.current_hover == df.main_hover_instruction.HAULING_REMOVE_STOP end, + pausable=true, +} + +ConfirmSpec{ + id='depot-remove', + title='Remove depot', + message='Are you sure you want to remove this depot? Merchants are present and will lose profits.', + intercept_keys='_MOUSE_L', + context='dwarfmode/ViewSheets/BUILDING/TradeDepot', + predicate=function() + return mi.current_hover == df.main_hover_instruction.BUILDING_SHEET_REMOVE and has_caravans() + end, +} + +ConfirmSpec{ + id='squad-disband', + title='Disband squad', + message='Are you sure you want to disband this squad?', + intercept_keys='_MOUSE_L', + context='dwarfmode/Squads', + predicate=function() return mi.current_hover == df.main_hover_instruction.SQUAD_DISBAND end, + pausable=true, +} + +ConfirmSpec{ + id='uniform-delete', + title='Delete uniform', + message='Are you sure you want to delete this uniform?', + intercept_keys='_MOUSE_L', + intercept_frame={r=131, t=23, w=6, h=27}, + context='dwarfmode/AssignUniform', + predicate=function(_, mouse_offset) + local num_uniforms = get_num_uniforms() + if num_uniforms == 0 then return false end + -- adjust detection area depending on presence of scrollbar + if num_uniforms > 8 and mouse_offset.x > 2 then + return false + elseif num_uniforms <= 8 and mouse_offset.x <= 1 then + return false + end + -- exclude the "No uniform" option (which has no delete button) + return mouse_offset.y // 3 < num_uniforms - mi.assign_uniform.scroll_position + end, + pausable=true, +} + +local se = mi.squad_equipment +local uniform_starting_state = nil + +local function uniform_has_changes() + for k, v in pairs(uniform_starting_state or {}) do + if type(v) == table then + if #v + 1 ~= #se[k] then return true end + for k2, v2 in pairs(v) do + if v2 ~= se[k][k2] then return true end + end + else + if v ~= se[k] then return true end + end + end + return false +end + +local function ensure_uniform_record() + if uniform_starting_state then return end + uniform_starting_state = { + cs_cat=copyall(se.cs_cat), + cs_it_spec_item_id=copyall(se.cs_it_spec_item_id), + cs_it_type=copyall(se.cs_it_type), + cs_it_subtype=copyall(se.cs_it_subtype), + cs_civ_mat=copyall(se.cs_civ_mat), + cs_spec_mat=copyall(se.cs_spec_mat), + cs_spec_matg=copyall(se.cs_spec_matg), + cs_color_pattern_index=copyall(se.cs_color_pattern_index), + cs_icp_flag=copyall(se.cs_icp_flag), + cs_assigned_item_number=copyall(se.cs_assigned_item_number), + cs_assigned_item_id=copyall(se.cs_assigned_item_id), + cs_uniform_flag=se.cs_uniform_flag, + } +end + +local function clear_uniform_record() + uniform_starting_state = nil +end + +local function clicked_on_confirm_button(mouse_offset) + -- buttons are all in the top 3 lines + if mouse_offset.y > 2 then return false end + -- clicking on the Confirm button saves the uniform and closes the panel + if mouse_offset.x >= 38 and mouse_offset.x <= 46 then return true end + -- the "Confirm and save uniform" button does the same thing, but it is + -- only enabled if a name has been entered + if #mi.squad_equipment.customizing_squad_uniform_nickname == 0 then + return false + end + return mouse_offset.x >= 74 and mouse_offset.x <= 99 +end + +ConfirmSpec{ + id='uniform-discard-changes', + title='Discard uniform changes', + message='Are you sure you want to discard changes to this uniform?', + intercept_keys={'LEAVESCREEN', '_MOUSE_L', '_MOUSE_R'}, + -- sticks out the left side so it can move with the panel + -- when the screen is resized too narrow + intercept_frame={r=32, t=19, w=101, b=3}, + context='dwarfmode/Squads/Equipment/Customizing/Default', + predicate=function(keys, mouse_offset) + if keys.LEAVESCREEN or keys._MOUSE_R then + return uniform_has_changes() + end + if clicked_on_confirm_button(mouse_offset) then + clear_uniform_record() + else + ensure_uniform_record() + end + return false + end, + on_propagate=clear_uniform_record, +} + +local hotkey_reset_action = 'reset' +local num_hotkeys = 16 +ConfirmSpec{ + id='hotkey-reset', + title='Reassign or clear zoom hotkeys', + message=function() return ('Are you sure you want to %s this zoom hotkey?'):format(hotkey_reset_action) end, + intercept_keys='_MOUSE_L', + intercept_frame={r=32, t=11, w=12, b=9}, + context='dwarfmode/Hotkey', + predicate=function(_, mouse_offset) + local _, sh = dfhack.screen.getWindowSize() + local num_sections = (sh - 20) // 3 + local selected_section = mouse_offset.y // 3 + -- if this isn't a button section, exit early + if selected_section % 2 ~= 0 then return false end + -- if this hotkey isn't set, then all actions are ok + local selected_offset = selected_section // 2 + local selected_idx = selected_offset + mi.hotkey.scroll_position + local max_visible_buttons = num_sections // 2 + if selected_offset >= max_visible_buttons or + selected_idx >= num_hotkeys or + plotinfo.main.hotkeys[selected_idx].cmd == df.hotkey_type.None + then + return false + end + -- adjust detection area depending on presence of scrollbar + if max_visible_buttons < num_hotkeys then + if mouse_offset.x > 7 then + return false + elseif mouse_offset.x <= 3 then + hotkey_reset_action = 'reassign' + else + hotkey_reset_action = 'clear' + end + elseif max_visible_buttons >= num_hotkeys then + if mouse_offset.x <= 1 then + return false + elseif mouse_offset.x <= 5 then + hotkey_reset_action = 'reassign' + else + hotkey_reset_action = 'clear' + end + end + return true + end, + pausable=true, +} + +local selected_convict_name = 'this creature' +ConfirmSpec{ + id='convict', + title='Confirm conviction', + message=function() + return ('Are you sure you want to convict %s? This action is irreversible.'):format(selected_convict_name) + end, + intercept_keys={'_MOUSE_L', 'SELECT'}, + context='dwarfmode/Info/JUSTICE/Convicting', + predicate=function(keys) + local convict = dfhack.gui.getWidget(mi.info.justice, 'Tabs', 'Open cases', 'Right panel', 'Convict') + local scroll_rows = dfhack.gui.getWidget(convict, 'Unit List', 1) + local selected_pos + if keys.SELECT then + selected_pos = convict.cursor_idx + else + local visible_rows = scroll_rows.num_visible + if visible_rows == 0 then return false end + local scroll_pos = scroll_rows.scroll + local first_portrait_rect = dfhack.gui.getWidget(scroll_rows, scroll_pos, 0).rect + local last_name_rect = dfhack.gui.getWidget(scroll_rows, scroll_pos+visible_rows-1, 1).rect + local x, y = dfhack.screen.getMousePos() + if x < first_portrait_rect.x1 or x > last_name_rect.x2 or + y < first_portrait_rect.y1 or y >= first_portrait_rect.y1+3*visible_rows + then + return false + end + selected_pos = scroll_pos + (y - first_portrait_rect.y1) // 3 + end + local unit = dfhack.gui.getWidget(scroll_rows, selected_pos, 0).u + selected_convict_name = dfhack.translation.translateName(dfhack.units.getVisibleName(unit)) + if selected_convict_name == '' then + selected_convict_name = 'this creature' + end + return true + end, +} + +local function make_order_material_desc(order, noun) + local desc = '' + if order.mat_type >= 0 then + local matinfo = dfhack.matinfo.decode(order.mat_type, order.mat_index) + if matinfo then + desc = desc .. ' ' .. matinfo:toString() + end + else + for k,v in pairs(order.material_category) do + if v then + desc = desc .. ' ' .. k + break + end + end + end + return desc .. ' ' .. noun +end + +local orders = df.global.world.manager_orders.all +local itemdefs = df.global.world.raws.itemdefs +local reactions = df.global.world.raws.reactions.reactions + +local meal_type_by_ingredient_count = { + [2] = 'easy', + [3] = 'fine', + [4] = 'lavish', +} + +local function make_order_desc(order) + if order.job_type == df.job_type.CustomReaction then + for _, reaction in ipairs(reactions) do + if reaction.code == order.reaction_name then + return reaction.name + end + end + return '' + elseif order.job_type == df.job_type.PrepareMeal then + -- DF uses mat_type as ingredient count? + local meal_type = meal_type_by_ingredient_count[order.mat_type] + if meal_type then + return 'prepare ' .. meal_type .. ' meal' + end + return 'prepare meal' + end + local noun + if order.job_type == df.job_type.MakeArmor then + noun = itemdefs.armor[order.item_subtype].name + elseif order.job_type == df.job_type.MakeWeapon then + noun = itemdefs.weapons[order.item_subtype].name + elseif order.job_type == df.job_type.MakeShield then + noun = itemdefs.shields[order.item_subtype].name + elseif order.job_type == df.job_type.MakeAmmo then + noun = itemdefs.ammo[order.item_subtype].name + elseif order.job_type == df.job_type.MakeHelm then + noun = itemdefs.helms[order.item_subtype].name + elseif order.job_type == df.job_type.MakeGloves then + noun = itemdefs.gloves[order.item_subtype].name + elseif order.job_type == df.job_type.MakePants then + noun = itemdefs.pants[order.item_subtype].name + elseif order.job_type == df.job_type.MakeShoes then + noun = itemdefs.shoes[order.item_subtype].name + elseif order.job_type == df.job_type.MakeTool then + noun = itemdefs.tools[order.item_subtype].name + elseif order.job_type == df.job_type.MakeTrapComponent then + noun = itemdefs.trapcomps[order.item_subtype].name + elseif order.job_type == df.job_type.SmeltOre then + noun = 'ore' + else + -- caption is usually "verb noun(-phrase)" + noun = df.job_type.attrs[order.job_type].caption + end + return make_order_material_desc(order, noun) +end + +ConfirmSpec{ + id='order-remove', + title='Remove manger order', + message=function() + local order_desc = '' + local scroll_pos = mi.info.work_orders.scroll_position_work_orders + local ir = gui.get_interface_rect() + local y_offset = ir.width > 154 and 8 or 10 + local order_rows = (ir.height - y_offset - 9) // 3 + local max_scroll_pos = math.max(0, #orders - order_rows) -- DF keeps list view "full" (no empty rows at bottom), if possible + if scroll_pos > max_scroll_pos then + -- sometimes, DF does not adjust scroll_position_work_orders (when + -- scrolled to bottom: order removed, or list view height grew); + -- compensate to keep order_idx in sync (and in bounds) + scroll_pos = max_scroll_pos + end + local _, y = dfhack.screen.getMousePos() + if y then + local order_idx = scroll_pos + (y - y_offset) // 3 + local order = safe_index(orders, order_idx) + if order then + order_desc = make_order_desc(order) + end + end + return ('Are you sure you want to remove this manager order?\n\n%s'):format(dfhack.capitalizeStringWords(order_desc)) + end, + intercept_keys='_MOUSE_L', + context='dwarfmode/Info/WORK_ORDERS/Default', + predicate=function() return mi.current_hover == df.main_hover_instruction.WORK_ORDERS_REMOVE end, + pausable=true, +} + +ConfirmSpec{ + id='zone-remove', + title='Remove zone', + message='Are you sure you want to remove this zone?', + intercept_keys='_MOUSE_L', + context='dwarfmode/Zone', -- this is just Zone and not Zone/Some so we can pause across zones + predicate=function() return dfhack.gui.matchFocusString('dwarfmode/Zone/Some') end, + intercept_frame={l=40, t=8, w=4, h=3}, + pausable=true, +} + +ConfirmSpec{ + id='burrow-remove', + title='Remove burrow', + message='Are you sure you want to remove this burrow?', + intercept_keys='_MOUSE_L', + context='dwarfmode/Burrow', + predicate=function() + return mi.current_hover == df.main_hover_instruction.BURROW_REMOVE_EXISTING or + mi.current_hover == df.main_hover_instruction.BURROW_PAINT_REMOVE + end, + pausable=true, +} + +ConfirmSpec{ + id='stockpile-remove', + title='Remove stockpile', + message='Are you sure you want to remove this stockpile?', + intercept_keys='_MOUSE_L', + context='dwarfmode/Stockpile', + predicate=function() return mi.current_hover == df.main_hover_instruction.STOCKPILE_REMOVE_EXISTING end, + pausable=true, +} + +ConfirmSpec{ + id='embark-site-finder', + title='Re-run finder', + message='Are you sure you want to re-run the site finder? Your current map highlights will be lost.', + intercept_keys='_MOUSE_L', + intercept_frame={r=2, t=36, w=7, h=3}, + context='choose_start_site/SiteFinder', + predicate=function() + return dfhack.gui.getDFViewscreen(true).find_results ~= df.viewscreen_choose_start_sitest.T_find_results.None + end, + pausable=true, +} + +-------------------------- +-- Config file management +-- + +local function get_config() + local f = json.open(CONFIG_FILE) + local updated = false + -- scrub any invalid data + for id in pairs(f.data) do + if not REGISTRY[id] then + updated = true + f.data[id] = nil + end + end + -- add any missing confirmation ids + for id in pairs(REGISTRY) do + if not f.data[id] then + updated = true + f.data[id] = { + id=id, + enabled=true, + } + end + end + if updated then + f:write() + end + return f +end + +config = get_config() diff --git a/internal/control-panel/common.lua b/internal/control-panel/common.lua new file mode 100644 index 0000000000..4cdc23191e --- /dev/null +++ b/internal/control-panel/common.lua @@ -0,0 +1,214 @@ +--@module = true + +local helpdb = require('helpdb') +local json = require('json') +local migration = reqscript('internal/control-panel/migration') +local registry = reqscript('internal/control-panel/registry') +local repeatUtil = require('repeat-util') +local tweak = require('plugins.tweak') +local utils = require('utils') + +local CONFIG_FILE = 'dfhack-config/control-panel.json' + +REPEATS_GLOBAL_KEY = 'control-panel-repeats' + +local function get_config() + local f = json.open(CONFIG_FILE) + local updated = false + -- ensure proper structure + ensure_key(f.data, 'commands') + ensure_key(f.data, 'preferences') + if f.exists then + -- remove unknown or out of date entries from the loaded config + for k in pairs(f.data) do + if k ~= 'commands' and k ~= 'preferences' then + updated = true + f.data[k] = nil + end + end + for name, config_command_data in pairs(f.data.commands) do + local data = registry.COMMANDS_BY_NAME[name] + if not data or config_command_data.version ~= data.version then + updated = true + f.data.commands[name] = nil + end + end + for name, config_pref_data in pairs(f.data.preferences) do + local data = registry.PREFERENCES_BY_NAME[name] + if not data or config_pref_data.version ~= data.version then + updated = true + f.data.preferences[name] = nil + end + end + else + -- migrate any data from old configs + migration.migrate(f.data) + updated = next(f.data.commands) or next(f.data.preferences) + end + if updated then + f:write() + end + return f +end + +config = config or get_config() + +local function munge_repeat_name(name) + return 'control-panel/' .. name +end + +local function unmunge_repeat_name(munged_name) + if munged_name:startswith('control-panel/') then + return munged_name:sub(15) + end +end + +function get_enabled_map() + local enabled_map = {} + local output = dfhack.run_command_silent('enable'):split('\n+') + for _,line in ipairs(output) do + local _,_,command,enabled_str = line:find('%s*(%S+):%s+(%S+)') + if enabled_str then + enabled_map[command] = enabled_str == 'on' + end + end + -- repeat entries override tool names for control-panel + for _,munged_name in ipairs(repeatUtil.listScheduled()) do + local name = unmunge_repeat_name(munged_name) + if name then + enabled_map[name] = true + end + end + -- get tweak state + for name, enabled in pairs(tweak.tweak_get_status()) do + enabled_map[name] = enabled + end + return enabled_map +end + +function get_first_word(str) + local word = str:trim():split(' +')[1] + if word:startswith(':') then word = word:sub(2) end + return word +end + +function command_passes_filters(data, target_group, filter_strs) + if data.group ~= target_group then + return false + end + filter_strs = filter_strs or {} + local first_word = get_first_word(data.help_command or data.command) + if dfhack.getMortalMode() and helpdb.has_tag(first_word, 'armok') then + return false + end + return data.help_command and + utils.search_text(data.help_command, filter_strs) or + utils.search_text(data.command, filter_strs) +end + +function get_description(data) + if data.desc then + return data.desc + end + local first_word = get_first_word(data.help_command or data.command) + return helpdb.is_entry(first_word) and helpdb.get_entry_short_help(first_word) or '' +end + +local function persist_repeats() + local cp_repeats = {} + for _, data in ipairs(registry.COMMANDS_BY_IDX) do + if data.mode == 'repeat' then + if repeatUtil.isScheduled(munge_repeat_name(data.command)) then + cp_repeats[data.command] = true + else + cp_repeats[data.command] = false + end + end + end + dfhack.persistent.saveSiteData(REPEATS_GLOBAL_KEY, cp_repeats) +end + +function apply_command(data, enabled_map, enabled) + enabled_map = enabled_map or {} + if enabled == nil then + enabled = safe_index(config.data.commands, data.command, 'autostart') + enabled = enabled or (enabled == nil and data.default) + if not enabled then return end + end + if enabled then + for _, conflict in ipairs(data.conflicts or {}) do + local conflict_data = registry.COMMANDS_BY_NAME[conflict] + if conflict_data and enabled_map[conflict] then + enabled_map[conflict] = false + apply_command(conflict_data, enabled_map, false) + end + end + end + if data.mode == 'enable' or data.mode == 'system_enable' or data.mode == 'tweak' then + if enabled_map[data.command] == nil then + dfhack.printerr(('tool not enableable: "%s"'):format(data.command)) + return false + elseif data.mode == 'tweak' then + dfhack.run_command{'tweak', data.command, 'quiet', enabled and '' or 'disable'} + else + dfhack.run_command{enabled and 'enable' or 'disable', data.command} + end + elseif data.mode == 'repeat' then + local munged_name = munge_repeat_name(data.command) + if enabled then + local command_str = ('repeat --name %s %s\n'): + format(munged_name, table.concat(data.params, ' ')) + dfhack.run_command(command_str) + else + repeatUtil.cancel(munged_name) + end + persist_repeats() + elseif data.mode == 'run' then + if enabled then + dfhack.run_command(data.command) + end + else + dfhack.printerr(('unhandled command: "%s"'):format(data.command)) + return false + end + return true +end + +function set_preference(data, in_value) + local expected_type = type(data.default) + local value = in_value + if expected_type == 'boolean' and type(value) ~= 'boolean' then + value = argparse.boolean(value) + end + if expected_type == "number" then + value = tonumber(value) or value + end + local actual_type = type(value) + if actual_type ~= expected_type then + qerror(('"%s" has an unexpected value type: got: %s; expected: %s'):format( + in_value, actual_type, expected_type)) + end + if data.min and data.min > value then + qerror(('value too small: got: %s; minimum: %s'):format(value, data.min)) + end + data.set_fn(value) + if data.default ~= value then + config.data.preferences[data.name] = { + val=value, + version=data.version, + } + else + config.data.preferences[data.name] = nil + end +end + +function set_autostart(data, enabled) + if enabled ~= not not data.default then + config.data.commands[data.command] = { + autostart=enabled, + version=data.version, + } + else + config.data.commands[data.command] = nil + end +end diff --git a/internal/control-panel/migration.lua b/internal/control-panel/migration.lua new file mode 100644 index 0000000000..cb0a639a8f --- /dev/null +++ b/internal/control-panel/migration.lua @@ -0,0 +1,131 @@ +-- migrate configuration from 50.11-r4 and prior to new format +--@module = true + +-- read old files, add converted data to config_data, overwrite old files with +-- a message that says they are deprecated and can be deleted with the proper +-- procedure. we can't delete them outright since steam may just restore them due to +-- Steam Cloud. We *could* delete them, though, if we know that we've been started +-- from Steam as DFHack and not as DF + +local argparse = require('argparse') +local registry = reqscript('internal/control-panel/registry') + +-- init files +local SYSTEM_INIT_FILE = 'dfhack-config/init/dfhack.control-panel-system.init' +local AUTOSTART_FILE = 'dfhack-config/init/onMapLoad.control-panel-new-fort.init' +local REPEATS_FILE = 'dfhack-config/init/onMapLoad.control-panel-repeats.init' +local PREFERENCES_INIT_FILE = 'dfhack-config/init/dfhack.control-panel-preferences.init' + +local function save_tombstone_file(path) + local ok, f = pcall(io.open, path, 'w') + if not ok or not f then + dialogs.showMessage('Error', + ('Cannot open file for writing: "%s"'):format(path)) + return + end + f:write('# This file was once used by gui/control-panel\n') + f:write('# If you are on Steam, you can delete this file manually\n') + f:write('# by starting DFHack in the Steam client, then deleting\n') + f:write('# this file while DF is running. Otherwise Steam Cloud will\n') + f:write('# restore the file when you next run DFHack.\n') + f:write('#\n') + f:write('# If you\'re not on Steam, you can delete this file at any time.\n') + f:close() +end + +local function add_autostart(config_data, name) + if not registry.COMMANDS_BY_NAME[name].default then + config_data.commands[name] = {autostart=true} + end +end + +local function add_preference(config_data, name, val) + local data = registry.PREFERENCES_BY_NAME[name] + if type(data.default) == 'boolean' then + ok, val = pcall(argparse.boolean, val) + if not ok then return end + elseif type(data.default) == 'number' then + val = tonumber(val) + if not val then return end + end + if data.default ~= val then + config_data.preferences[name] = {val=val} + end +end + +local function parse_lines(fname, line_fn) + local ok, f = pcall(io.open, fname) + if not ok or not f then return end + for line in f:lines() do + line = line:trim() + if #line > 0 and not line:startswith('#') then + line_fn(line) + end + end +end + +local function migrate_system(config_data) + parse_lines(SYSTEM_INIT_FILE, function(line) + local service = line:match('^enable ([%S]+)$') + if not service then return end + local data = registry.COMMANDS_BY_NAME[service] + if data and (data.mode == 'system_enable' or data.command == 'work-now') then + add_autostart(config_data, service) + end + end) + save_tombstone_file(SYSTEM_INIT_FILE) +end + +local function migrate_autostart(config_data) + parse_lines(AUTOSTART_FILE, function(line) + local service = line:match('^on%-new%-fortress enable ([%S]+)$') + or line:match('^on%-new%-fortress (.+)') + if not service then return end + local data = registry.COMMANDS_BY_NAME[service] + if data and (data.mode == 'enable' or data.mode == 'run') then + add_autostart(config_data, service) + end + end) + save_tombstone_file(AUTOSTART_FILE) +end + +local REPEAT_MAP = { + autoMilkCreature='automilk', + autoShearCreature='autoshear', + ['dead-units-burrow']='fix/dead-units', + ['empty-wheelbarrows']='fix/empty-wheelbarrows', + ['general-strike']='fix/general-strike', + ['stuck-instruments']='fix/stuck-instruments', +} + +local function migrate_repeats(config_data) + parse_lines(REPEATS_FILE, function(line) + local service = line:match('^repeat %-%-name ([%S]+)') + if not service then return end + service = REPEAT_MAP[service] or service + local data = registry.COMMANDS_BY_NAME[service] + if data and data.mode == 'repeat' then + add_autostart(config_data, service) + end + end) + save_tombstone_file(REPEATS_FILE) +end + +local function migrate_preferences(config_data) + parse_lines(PREFERENCES_INIT_FILE, function(line) + local name, val = line:match('^:lua .+%.([^=]+)=(.+)') + if not name or not val then return end + local data = registry.PREFERENCES_BY_NAME[name] + if data then + add_preference(config_data, name, val) + end + end) + save_tombstone_file(PREFERENCES_INIT_FILE) +end + +function migrate(config_data) + migrate_system(config_data) + migrate_autostart(config_data) + migrate_repeats(config_data) + migrate_preferences(config_data) +end diff --git a/internal/control-panel/registry.lua b/internal/control-panel/registry.lua new file mode 100644 index 0000000000..0759ed398c --- /dev/null +++ b/internal/control-panel/registry.lua @@ -0,0 +1,260 @@ +--@module = true + +local gui = require('gui') +local widgets = require('gui.widgets') +local utils = require('utils') + +-- please keep in alphabetical order per group +-- add a 'version' attribute if we want to reset existing configs for a command to the default +COMMANDS_BY_IDX = { + -- automation tools + {command='autobutcher', group='automation', mode='enable'}, + {command='autobutcher target 10 10 14 2 BIRD_GOOSE', group='automation', mode='run', + desc='Enable if you usually want to raise geese.'}, + {command='autobutcher target 10 10 14 2 BIRD_TURKEY', group='automation', mode='run', + desc='Enable if you usually want to raise turkeys.'}, + {command='autobutcher target 10 10 14 2 BIRD_CHICKEN', group='automation', mode='run', + desc='Enable if you usually want to raise chickens.'}, + {command='autobutcher target 10 10 14 2 BIRD_PEAFOWL_BLUE', group='automation', mode='run', + desc='Enable if you usually want to raise peafowl.'}, + {command='autochop', group='automation', mode='enable'}, + {command='autocheese', group='automation', mode='repeat', + params={'--time', '14', '--timeUnits', 'days', '--command', '[', 'autocheese', ']'}}, + {command='autoclothing', group='automation', mode='enable'}, + {command='autofarm', group='automation', mode='enable'}, + {command='autofarm threshold 150 grass_tail_pig', group='automation', mode='run', + desc='Enable if you usually farm pig tails for the clothing industry.'}, + {command='autofish', group='automation', mode='enable'}, + --{command='autolabor', group='automation', mode='enable'}, -- hide until it works better + {command='automilk', help_command='workorder', group='automation', mode='repeat', + desc='Automatically milk creatures that are ready for milking.', + params={'--time', '14', '--timeUnits', 'days', '--command', '[', 'workorder', '"{\\"job\\":\\"MilkCreature\\",\\"item_conditions\\":[{\\"condition\\":\\"AtLeast\\",\\"value\\":2,\\"flags\\":[\\"empty\\"],\\"item_type\\":\\"BUCKET\\"}]}"', ']'}}, + {command='autonestbox', group='automation', mode='enable'}, + {command='autoshear', help_command='workorder', group='automation', mode='repeat', + desc='Automatically shear creatures that are ready for shearing.', + params={'--time', '14', '--timeUnits', 'days', '--command', '[', 'workorder', 'ShearCreature', ']'}}, + {command='autoslab', group='automation', mode='enable'}, + {command='autotraining', group='automation', mode='enable', + desc='Automatically assign units with training needs to training squads. '}, + {command='ban-cooking all', group='automation', mode='run'}, + {command='buildingplan set boulders false', group='automation', mode='run', + desc='Enable if you usually don\'t want to use boulders for construction.'}, + {command='buildingplan set logs false', group='automation', mode='run', + desc='Enable if you usually don\'t want to use logs for construction.'}, + {command='cleanowned', group='automation', mode='repeat', + desc='Encourage dwarves to discard tattered clothing at the dump when there is new available clothing.', + conflicts={'cleanowned-nodump'}, + params={'--time', '1', '--timeUnits', 'months', '--command', '[', 'cleanowned', 'X', ']'}}, + {command='cleanowned-nodump', group='automation', mode='repeat', + desc='Drop tattered clothing, but don\'t mark it for dumping. Pairs well with tailor and tailor confiscate false.', + conflicts={'cleanowned'}, + params={'--time', '1', '--timeUnits', 'months', '--command', '[', 'cleanowned', 'X', 'nodump', ']'}}, + {command='gui/settings-manager load-standing-orders', group='automation', mode='run', + desc='Go to the Standing Orders tab in the Labor screen to save your current settings.'}, + {command='gui/settings-manager load-work-details', group='automation', mode='run', + desc='Go to the Work Details tab in the Labor screen to save your current definitions.'}, + {command='infinite-sky', group='automation', mode='enable'}, + {command='logistics enable autoretrain', group='automation', mode='run', + desc='Automatically assign trainers to partially trained livestock so they don\'t revert to wild.'}, + {command='nestboxes', group='automation', mode='enable'}, + {command='orders-sort', help_command='orders', group='automation', mode='repeat', + desc='Sort manager orders by repeat frequency so one-time orders can be completed.', + params={'--time', '1', '--timeUnits', 'days', '--command', '[', 'orders', 'sort', ']'}}, + {command='prioritize', group='automation', mode='enable'}, + {command='seedwatch', group='automation', mode='enable'}, + {command='suspendmanager', group='automation', mode='enable'}, + {command='tailor', group='automation', mode='enable'}, + {command='tailor confiscate false', group='automation', mode='run', + desc='Enable if you don\'t want old clothes to be dumped. Pairs well with cleanowned-nodump.'}, + + -- bugfix tools + {command='adamantine-cloth-wear', help_command='tweak', group='bugfix', mode='tweak', default=true, + desc='Prevents adamantine clothing from wearing out while being worn.'}, + -- re-inserted below for non-Windows users (tweak can't load on Windows) + -- can be restored here once we solve issue #4292 + -- {command='craft-age-wear', help_command='tweak', group='bugfix', mode='tweak', default=true, + -- desc='Allows items crafted from organic materials to wear out over time.'}, + -- Issue resolved in 53.01; tool no longer required. + -- {command='fix/archery-practice', group='bugfix', mode='repeat', default=true, + -- desc='Fix quivers and training ammo items to allow archery practice to take place.', + -- params={'--time', '449', '--timeUnits', 'ticks', '--command', '[', 'fix/archery-practice', '-q', ']'}}, + {command='fix/blood-del', group='bugfix', mode='run', default=true}, + {command='fix/dead-units', group='bugfix', mode='repeat', default=true, + desc='Fix units still being assigned to burrows after death.', + params={'--time', '7', '--timeUnits', 'days', '--command', '[', 'fix/dead-units', '--burrow', '-q', ']'}}, + {command='fix/dry-buckets', group='bugfix', mode='repeat', default=true, + desc='Allow discarded water buckets and clogged wells to be used again.', + params={'--time', '7', '--timeUnits', 'days', '--command', '[', 'fix/dry-buckets', '-q', ']'}}, + {command='fix/empty-wheelbarrows', group='bugfix', mode='repeat', default=true, + desc='Make abandoned full wheelbarrows usable again.', + params={'--time', '1', '--timeUnits', 'days', '--command', '[', 'fix/empty-wheelbarrows', '-q', ']'}}, + {command='fix/engravings', group='bugfix', mode='repeat', default=true, + params={'--time', '1', '--timeUnits', 'days', '--command', '[', 'fix/engravings', '-q', ']'}}, + {command='fix/general-strike', group='bugfix', mode='repeat', default=true, + desc='Prevent dwarves from getting stuck and refusing to work.', + params={'--time', '1', '--timeUnits', 'days', '--command', '[', 'fix/general-strike', '-q', ']'}}, + {command='fix/ownership', group='bugfix', mode='repeat', default=true, + desc='Fixes instances of units claiming the same item or an item they don\'t own.', + params={'--time', '1', '--timeUnits', 'days', '--command', '[', 'fix/ownership', ']'}}, + {command='fix/protect-nicks', group='bugfix', mode='enable', default=true}, + {command='fix/stuck-instruments', group='bugfix', mode='repeat', default=true, + params={'--time', '1', '--timeUnits', 'days', '--command', '[', 'fix/stuck-instruments', ']'}}, + {command='fix/stuck-squad', group='bugfix', mode='repeat', default=true, + params={'--time', '1', '--timeUnits', 'days', '--command', '[', 'fix/stuck-squad', ']'}}, + {command='fix/stuck-worship', group='bugfix', mode='repeat', default=true, + params={'--time', '1', '--timeUnits', 'days', '--command', '[', 'fix/stuck-worship', '-q', ']'}}, + {command='fix/noexert-exhaustion', group='bugfix', mode='repeat', default=true, + params={'--time', '439', '--timeUnits', 'ticks', '--command', '[', 'fix/noexert-exhaustion', ']'}}, + {command='fix/wildlife', group='bugfix', mode='repeat', + params={'--time', '2', '--timeUnits', 'days', '--command', '[', 'fix/wildlife', '-wq', ']'}}, + {command='flask-contents', help_command='tweak', group='bugfix', mode='tweak', default=true, + desc='Displays flask contents in the item name, similar to barrels and bins.'}, + {command='named-codices', help_command='tweak', group='bugfix', mode='tweak', default=true, + desc='Displays titles for books instead of a material description.'}, + {command='preserve-tombs', group='bugfix', mode='enable', default=true}, + {command='reaction-gloves', help_command='tweak', group='bugfix', mode='tweak', default=true, + desc='Fixes reactions not producing gloves in sets with correct handedness.'}, + + -- gameplay tools + {command='agitation-rebalance', group='gameplay', mode='enable'}, + {command='aquifer drain --all --skip-top 2', group='gameplay', mode='run', + desc='Ensure that your maps have no more than 2 layers of aquifer.'}, + {command='combine', group='gameplay', mode='repeat', + desc='Combine partial stacks in stockpiles into full stacks.', + params={'--time', '7', '--timeUnits', 'days', '--command', '[', 'combine', 'all', '-q', ']'}}, + {command='deteriorate', group='gameplay', mode='enable'}, + {command='deteriorate enable all', group='gameplay', mode='run', + desc='Enable if you want deteriorate to run on all supported categories instead of just corpses.'}, + {command='deteriorate frequency 0.15 all', group='gameplay', mode='run', + desc='Enable if you want to speed item deterioration up so they take less than a month to rot away.'}, + {command='deteriorate frequency 3 all', group='gameplay', mode='run', + desc='Enable if you want to slow item deterioration down so they take about a year to rot away.'}, + {command='dwarfvet', group='gameplay', mode='enable'}, + {command='eggs-fertile', help_command='tweak', group='gameplay', mode='tweak', default=true, + desc='Displays an indicator on fertile eggs.'}, + {command='emigration', group='gameplay', mode='enable'}, + {command='fast-heat', help_command='tweak', group='gameplay', mode='tweak', default=true, + desc='Improves temperature update performance.'}, + {command='fastdwarf', group='gameplay', mode='enable'}, + {command='hermit', group='gameplay', mode='enable'}, + {command='hide-tutorials', group='gameplay', mode='system_enable'}, + {command='immortal-cravings', group='gameplay', mode='enable'}, + {command='light-aquifers-only', group='gameplay', mode='run'}, + {command='misery', group='gameplay', mode='enable'}, + {command='orders-reevaluate', help_command='orders', group='gameplay', mode='repeat', + desc='Invalidates all work orders once a month, allowing conditions to be rechecked.', + params={'--time', '1', '--timeUnits', 'months', '--command', '[', 'orders', 'recheck', ']'}}, + {command='partial-items', help_command='tweak', group='gameplay', mode='tweak', default=true, + desc='Displays percentages on partially-consumed items like hospital cloth.'}, + {command='pop-control', group='gameplay', mode='enable'}, + {command='realistic-melting', help_command='tweak', group='gameplay', mode='tweak', + desc='Fixes metal duplication exploits by setting the melt return for all items to ~95%, reduced by 10% for each wear level.'}, + {command='starvingdead', group='gameplay', mode='enable'}, + {command='timestream', group='gameplay', mode='enable'}, + {command='work-now', group='gameplay', mode='enable'}, +} + +-- temporary workaround for Windows users until the tweak works +if dfhack.getOSType() ~= 'windows' then + local idx = utils.linear_index(COMMANDS_BY_IDX, 'adamantine-cloth-wear', 'command') or 1 + table.insert(COMMANDS_BY_IDX, idx + 1, { + command='craft-age-wear', help_command='tweak', group='bugfix', mode='tweak', default=true, + desc='Allows items crafted from organic materials to wear out over time.', + }) +end + +COMMANDS_BY_NAME = {} +for _,data in ipairs(COMMANDS_BY_IDX) do + COMMANDS_BY_NAME[data.command] = data +end + +-- keep in desired display order +PREFERENCES_BY_IDX = { + { + name='HIDE_ARMOK_TOOLS', + label='Mortal mode: hide "armok" tools', + desc='Don\'t show tools that give you god-like powers wherever DFHack tools are listed.', + default=false, + get_fn=dfhack.getMortalMode, + set_fn=dfhack.setMortalMode, + }, + { + name='FILTER_FULL_TEXT', + label='DFHack searches full text', + desc='When searching, whether to match anywhere in the text (true) or just at the start of words (false).', + default=false, + get_fn=function() return utils.FILTER_FULL_TEXT end, + set_fn=function(val) utils.FILTER_FULL_TEXT = val end, + }, + { + name='HIDE_CONSOLE_ON_STARTUP', + label='Hide console on startup (MS Windows only)', + desc='Hide the external DFHack terminal window on startup. Use the "show" command to unhide it.', + default=true, + get_fn=dfhack.getHideConsoleOnStartup, + set_fn=dfhack.setHideConsoleOnStartup, + }, + { + name='DEFAULT_INITIAL_PAUSE', + label='DFHack tools autopause game', + desc='Always pause the game when a DFHack tool window is shown (you can still unpause afterwards).', + default=true, + get_fn=function() return gui.DEFAULT_INITIAL_PAUSE end, + set_fn=function(val) gui.DEFAULT_INITIAL_PAUSE = val end, + }, + { + name='NUMBER_FORMAT', + label='Large number formatting', + desc='Number formatting style for DFHack tool UIs.', + default=0, + options={ + {label='None (ex: 1234567)', value=0}, + {label='English (ex: 1,234,567)', value=1}, + {label='System locale', value=2}, + {label='SI suffix (ex: 1.23M)', value=3}, + {label='Scientific (ex: 1.2e+06)', value=4}, + }, + get_fn=dfhack.internal.getPreferredNumberFormat, + set_fn=dfhack.internal.setPreferredNumberFormat, + }, + { + name='INTERCEPT_HANDLED_HOTKEYS', + label='Intercept handled hotkeys', + desc='Prevent key events handled by DFHack windows from also affecting the vanilla widgets.', + default=true, + get_fn=dfhack.internal.getSuppressDuplicateKeyboardEvents, + set_fn=dfhack.internal.setSuppressDuplicateKeyboardEvents, + }, + { + name='DOUBLE_CLICK_MS', + label='Mouse double click speed (ms)', + desc='How long to wait for the second click of a double click, in ms.', + default=500, + min=50, + get_fn=widgets.getDoubleClickMs, + set_fn=widgets.setDoubleClickMs, + }, + { + name='SCROLL_DELAY_MS', + label='Mouse scroll repeat delay (ms)', + desc='The delay between events when holding the mouse button down on a scrollbar, in ms.', + default=20, + min=5, + get_fn=widgets.getScrollDelayMs, + set_fn=widgets.setScrollDelayMs, + }, + { + name='SCROLL_INITIAL_DELAY_MS', + label='Mouse initial scroll repeat delay (ms)', + desc='The delay before scrolling quickly when holding the mouse button down on a scrollbar, in ms.', + default=300, + min=5, + get_fn=widgets.getScrollInitialDelayMs, + set_fn=widgets.setScrollInitialDelayMs, + }, +} + +PREFERENCES_BY_NAME = {} +for _,data in ipairs(PREFERENCES_BY_IDX) do + PREFERENCES_BY_NAME[data.name] = data +end diff --git a/internal/design/shapes.lua b/internal/design/shapes.lua index d092048c94..4f8b3f6554 100644 --- a/internal/design/shapes.lua +++ b/internal/design/shapes.lua @@ -1,7 +1,9 @@ -- shape definitions for gui/dig --@ module = true -local Point = reqscript("internal/design/util").Point +local util = reqscript("internal/design/util") + +local Point = util.Point if not dfhack_flags.module then qerror("this script cannot be called directly") @@ -213,6 +215,8 @@ end Ellipse = defclass(Ellipse, Shape) Ellipse.ATTRS { name = "Ellipse", + texture_offset = 5, + button_chars = util.make_ascii_button(9, 248) } function Ellipse:init() @@ -224,7 +228,7 @@ function Ellipse:init() key = "CUSTOM_H", }, thickness = { - name = "Thickness", + name = "Line thickness", type = "plusminus", value = 2, enabled = { "hollow", true }, @@ -241,41 +245,42 @@ function Ellipse:init() } end +-- move the test point slightly closer to the circle center for a more pleasing curvature +local bias = 0.4 + function Ellipse:has_point(x, y) local center_x, center_y = self.width / 2, self.height / 2 - local point_x, point_y = x - center_x, y - center_y - local is_inside = - (point_x / (self.width / 2)) ^ 2 + (point_y / (self.height / 2)) ^ 2 <= 1 - - if self.options.hollow.value and is_inside then - local all_points_inside = true - for dx = -self.options.thickness.value, self.options.thickness.value do - for dy = -self.options.thickness.value, self.options.thickness.value do - if dx ~= 0 or dy ~= 0 then - local surrounding_x, surrounding_y = x + dx, y + dy - local surrounding_point_x, surrounding_point_y = - surrounding_x - center_x, - surrounding_y - center_y - if (surrounding_point_x / (self.width / 2)) ^ 2 + (surrounding_point_y / (self.height / 2)) ^ 2 > - 1 then - all_points_inside = false - break - end + local point_x, point_y = math.abs(x-center_x)-bias, math.abs(y-center_y)-bias + local is_inside = 1 >= (point_x / center_x) ^ 2 + (point_y / center_y) ^ 2 + + if not self.options.hollow.value or not is_inside then + return is_inside + end + + local all_points_inside = true + for dx = -self.options.thickness.value, self.options.thickness.value do + for dy = -self.options.thickness.value, self.options.thickness.value do + if dx ~= 0 or dy ~= 0 then + local surr_x, surr_y = x + dx, y + dy + local surr_point_x, surr_point_y = math.abs(surr_x-center_x)-bias, math.abs(surr_y-center_y)-bias + if 1 <= (surr_point_x / center_x) ^ 2 + (surr_point_y / center_y) ^ 2 then + all_points_inside = false + break end end - if not all_points_inside then - break - end end - return not all_points_inside - else - return is_inside + if not all_points_inside then + break + end end + return not all_points_inside end Rectangle = defclass(Rectangle, Shape) Rectangle.ATTRS { name = "Rectangle", + texture_offset = 1, + button_chars = util.make_ascii_button(222, 221) } function Rectangle:init() @@ -287,7 +292,7 @@ function Rectangle:init() key = "CUSTOM_H", }, thickness = { - name = "Thickness", + name = "Line thickness", type = "plusminus", value = 2, enabled = { "hollow", true }, @@ -322,6 +327,8 @@ end Rows = defclass(Rows, Shape) Rows.ATTRS { name = "Rows", + texture_offset = 9, + button_chars = util.make_ascii_button(197, 197) } function Rows:init() @@ -360,6 +367,8 @@ end Diag = defclass(Diag, Shape) Diag.ATTRS { name = "Diagonal", + texture_offset = 13, + button_chars = util.make_ascii_button('/', '/') } function Diag:init() @@ -375,7 +384,7 @@ function Diag:init() name = "Reverse", type = "bool", value = false, - key = "CUSTOM_R", + key = "CUSTOM_SHIFT_R", }, } end @@ -393,18 +402,66 @@ function Diag:has_point(x, y) end end -Line = defclass(Line, Shape) +LineDrawer = defclass(LineDrawer, Shape) + +function LineDrawer:plot_thickness(x, y, thickness) + local map_width, map_height = dfhack.maps.getTileSize() + for i = math.max(y - math.floor(thickness / 2), 0), math.min(y + math.ceil(thickness / 2) - 1, map_height - 1) do + for j = math.max(x - math.floor(thickness / 2), 0), math.min(x + math.ceil(thickness / 2) - 1, map_width - 1) do + if not self.arr[j] then self.arr[j] = {} end + if not self.arr[j][i] then + self.arr[j][i] = true + self.num_tiles = self.num_tiles + 1 + end + end + end +end + +function LineDrawer:plot_bresenham(x0, y0, x1, y1, thickness) + local dx = math.abs(x1 - x0) + local dy = math.abs(y1 - y0) + local sx = x0 < x1 and 1 or -1 + local sy = y0 < y1 and 1 or -1 + local e2, x, y + + x = x0 + y = y0 + local err = dx - dy + while true do + self:plot_thickness(x, y, thickness) + + if sx * x >= sx * x1 and sy * y >= sy * y1 then + break + end + + e2 = 2 * err + + if e2 > -dy then + err = err - dy + x = x + sx + end + + if e2 < dx then + err = err + dx + y = y + sy + end + end +end + +Line = defclass(Line, LineDrawer) Line.ATTRS { name = "Line", extra_points = { { label = "Curve Point" }, { label = "Second Curve Point" } }, - invertable = false, -- Doesn't support invert - basic_shape = false -- Driven by points, not rectangle bounds + invertable = false, -- Doesn't support invert + basic_shape = false, -- Driven by points, not rectangle bounds + texture_offset = 17, + button_chars = util.make_ascii_button(250, '(') } function Line:init() self.options = { thickness = { - name = "Thickness", + name = "Line thickness", type = "plusminus", value = 1, min = 1, @@ -420,63 +477,26 @@ function Line:init() } end -function Line:plot_bresenham(x0, y0, x1, y1, thickness) - local dx = math.abs(x1 - x0) - local dy = math.abs(y1 - y0) - local sx = x0 < x1 and 1 or -1 - local sy = y0 < y1 and 1 or -1 - local err = dx - dy - local e2, x, y - - for i = 0, thickness - 1 do - x = x0 - y = y0 + i - while true do - for j = -math.floor(thickness / 2), math.ceil(thickness / 2) - 1 do - if not self.arr[x + j] then self.arr[x + j] = {} end - self.arr[x + j][y] = true - self.num_tiles = self.num_tiles + 1 - end - - if x == x1 and y == y1 + i then - break - end - - e2 = 2 * err - - if e2 > -dy then - err = err - dy - x = x + sx - end - - if e2 < dx then - err = err + dx - y = y + sy - end - end - end - +local function get_granularity(x0, y0, x1, y1, bezier_point1, bezier_point2) + local spread_x = math.max(x0, x1, bezier_point1.x, bezier_point2 and bezier_point2.x or 0) - + math.min(x0, x1, bezier_point1.x, bezier_point2 and bezier_point2.x or math.huge) + local spread_y = math.max(y0, y1, bezier_point1.y, bezier_point2 and bezier_point2.y or 0) - + math.min(y0, y1, bezier_point1.y, bezier_point2 and bezier_point2.y or math.huge) + return 1 / ((spread_x + spread_y) * 10) end function Line:cubic_bezier(x0, y0, x1, y1, bezier_point1, bezier_point2, thickness) local t = 0 local x2, y2 = bezier_point1.x, bezier_point1.y local x3, y3 = bezier_point2.x, bezier_point2.y + local granularity = get_granularity(x0, y0, x1, y1, bezier_point1, bezier_point2) while t <= 1 do local x = math.floor(((1 - t) ^ 3 * x0 + 3 * (1 - t) ^ 2 * t * x2 + 3 * (1 - t) * t ^ 2 * x3 + t ^ 3 * x1) + 0.5) local y = math.floor(((1 - t) ^ 3 * y0 + 3 * (1 - t) ^ 2 * t * y2 + 3 * (1 - t) * t ^ 2 * y3 + t ^ 3 * y1) + 0.5) - for i = 0, thickness - 1 do - for j = -math.floor(thickness / 2), math.ceil(thickness / 2) - 1 do - if not self.arr[x + j] then self.arr[x + j] = {} end - if not self.arr[x + j][y + i] then - self.arr[x + j][y + i] = true - self.num_tiles = self.num_tiles + 1 - end - end - end - t = t + 0.01 + self:plot_thickness(x, y, thickness) + t = t + granularity end -- Get the last point @@ -484,33 +504,19 @@ function Line:cubic_bezier(x0, y0, x1, y1, bezier_point1, bezier_point2, thickne 0.5) local y_end = math.floor(((1 - 1) ^ 3 * y0 + 3 * (1 - 1) ^ 2 * 1 * y2 + 3 * (1 - 1) * 1 ^ 2 * y3 + 1 ^ 3 * y1) + 0.5) - for i = 0, thickness - 1 do - for j = -math.floor(thickness / 2), math.ceil(thickness / 2) - 1 do - if not self.arr[x_end + j] then self.arr[x_end + j] = {} end - if not self.arr[x_end + j][y_end + i] then - self.arr[x_end + j][y_end + i] = true - self.num_tiles = self.num_tiles + 1 - end - end - end + + self:plot_thickness(x_end, y_end, thickness) end function Line:quadratic_bezier(x0, y0, x1, y1, bezier_point1, thickness) local t = 0 local x2, y2 = bezier_point1.x, bezier_point1.y + local granularity = get_granularity(x0, y0, x1, y1, bezier_point1) while t <= 1 do local x = math.floor(((1 - t) ^ 2 * x0 + 2 * (1 - t) * t * x2 + t ^ 2 * x1) + 0.5) local y = math.floor(((1 - t) ^ 2 * y0 + 2 * (1 - t) * t * y2 + t ^ 2 * y1) + 0.5) - for i = 0, thickness - 1 do - for j = -math.floor(thickness / 2), math.ceil(thickness / 2) - 1 do - if not self.arr[x + j] then self.arr[x + j] = {} end - if not self.arr[x + j][y + i] then - self.arr[x + j][y + i] = true - self.num_tiles = self.num_tiles + 1 - end - end - end - t = t + 0.01 + self:plot_thickness(x, y, thickness) + t = t + granularity end end @@ -551,13 +557,15 @@ FreeForm.ATTRS = { min_points = 1, max_points = DEFAULT_NIL, basic_shape = false, - can_mirror = true + can_mirror = true, + texture_offset = 21, + button_chars = util.make_ascii_button('?', '*') } function FreeForm:init() self.options = { thickness = { - name = "Thickness", + name = "Line thickness", type = "plusminus", value = 1, min = 1, @@ -618,8 +626,10 @@ function FreeForm:update(points, extra_points) for x, y_row in pairs(line_class.arr) do for y, _ in pairs(y_row) do if not self.arr[x] then self.arr[x] = {} end - self.arr[x][y] = true - self.num_tiles = self.num_tiles + 1 + if not self.arr[x][y] then + self.arr[x][y] = true + self.num_tiles = self.num_tiles + 1 + end end end end @@ -657,7 +667,139 @@ function FreeForm:point_in_polygon(x, y) return inside end +Star = defclass(Star, LineDrawer) +Star.ATTRS { + name = "Star", + texture_offset = 25, + button_chars = util.make_ascii_button('*', 15), + extra_points = { { label = "Main Axis" } } +} + +function Star:init() + self.options = { + hollow = { + name = "Hollow", + type = "bool", + value = false, + key = "CUSTOM_H", + }, + thickness = { + name = "Line thickness", + type = "plusminus", + value = 1, + enabled = { "hollow", true }, + min = 1, + max = function(shape) if not shape.height or not shape.width then + return nil + else + return math.ceil(math.min(shape.height, shape.width) / 2) + + end + end, + keys = { "CUSTOM_T", "CUSTOM_SHIFT_T" }, + }, + total_points = { + name = "Total points", + type = "plusminus", + value = 5, + min = 3, + max = 100, + keys = { "CUSTOM_B", "CUSTOM_SHIFT_B" }, + }, + next_point_offset = { + name = "Next point offset", + type = "plusminus", + value = 2, + min = 1, + max = 100, + keys = { "CUSTOM_N", "CUSTOM_SHIFT_N" }, + }, + } +end + +function Star:has_point(x, y) + if 1 < ((x-self.center.x) / self.center.x) ^ 2 + ((y-self.center.y) / self.center.y) ^ 2 then return false end + + local inside = 0 + for l = 1, self.options.total_points.value do + if x * self.lines[l].slope.x - self.lines[l].intercept.x < y * self.lines[l].slope.y - self.lines[l].intercept.y then + inside = inside + 1 + else + inside = inside - 1 + end + end + return self.threshold > 0 and inside > self.threshold or inside < self.threshold +end + +function vmagnitude(point) + return math.sqrt(point.x * point.x + point.y * point.y) +end + +function vnormalize(point) + local magnitude = vmagnitude(point) + return { x = point.x / magnitude, y = point.y / magnitude } +end + +function add_offset(coord, offset) + return coord + (offset > 0 and math.floor(offset+0.5) or math.ceil(offset-0.5)) +end + +function Star:update(points, extra_points) + self.num_tiles = 0 + self.points = copyall(points) + self.arr = {} + if #points < self.min_points then return end + self.threshold = self.options.total_points.value - 2 * self.options.next_point_offset.value + + local thickness = 1 + if self.options.hollow.value then + thickness = self.options.thickness.value + end + + local top_left, bot_right = self:get_point_dims() + self.height = bot_right.y - top_left.y - thickness + 1 + self.width = bot_right.x - top_left.x - thickness + 1 + if self.height < 2 or self.width < 2 then return end + self.center = { x = (bot_right.x - top_left.x + ((thickness - 1) % 2)) * 0.5, y = (bot_right.y - top_left.y + ((thickness - 1) % 2)) * 0.5 } + local axes = {} + + axes[1] = (#extra_points > 0) and { x = extra_points[1].x - self.center.x - top_left.x, y = extra_points[1].y - self.center.y - top_left.y } or { x = 0, y = -self.center.y } + if vmagnitude(axes[1]) < 0.5 then axes[1].y = -self.center.y end + axes[1] = vnormalize(axes[1]) + + for a = 2, self.options.total_points.value do + local angle = math.pi * (a - 1.0) * 2.0 / self.options.total_points.value + axes[a] = { x = math.cos(angle) * axes[1].x - math.sin(angle) * axes[1].y, y = math.sin(angle) * axes[1].x + math.cos(angle) * axes[1].y } + end + + + self.lines = {} + for l = 1, self.options.total_points.value do + local p1 = { x = self.center.x + axes[l].x * self.width * 0.5, y = self.center.y + axes[l].y * self.height * 0.5 } + local next_axis = axes[(l-1+self.options.next_point_offset.value) % self.options.total_points.value + 1] + local p2 = { x = self.center.x + next_axis.x * self.width * 0.5, y = self.center.y + next_axis.y * self.height * 0.5 } + self.lines[l] = { slope = { x = p2.y - p1.y, y = p2.x - p1.x }, intercept = { x = (p2.y - p1.y) * p1.x, y = (p2.x - p1.x) * p1.y } } + self:plot_bresenham(add_offset(top_left.x, p1.x), add_offset(top_left.y, p1.y), add_offset(top_left.x, p2.x), add_offset(top_left.y, p2.y), thickness) + self:plot_bresenham(add_offset(top_left.x, p2.x), add_offset(top_left.y, p2.y), add_offset(top_left.x, p1.x), add_offset(top_left.y, p1.y), thickness) + end + + if not self.options.hollow.value or self.invert then + for x = top_left.x, bot_right.x do + if not self.arr[x] then self.arr[x] = {} end + for y = top_left.y, bot_right.y do + local value = self.arr[x][y] or (not self.options.hollow.value and self:has_point(x - top_left.x, y - top_left.y)) + if self.invert then + self.arr[x][y] = not value + else + self.arr[x][y] = value + end + + self.num_tiles = self.num_tiles + (self.arr[x][y] and 1 or 0) + end + end + end +end -- module users can get shapes through this global, shape option values -- persist in these as long as the module is loaded -- idk enough lua to know if this is okay to do or not -all_shapes = { Rectangle {}, Ellipse {}, Rows {}, Diag {}, Line {}, FreeForm {} } +all_shapes = { Rectangle {}, Ellipse {}, Star {}, Rows {}, Diag {}, Line {}, FreeForm {} } diff --git a/internal/design/util.lua b/internal/design/util.lua index ae3886dce8..ea70483bf1 100644 --- a/internal/design/util.lua +++ b/internal/design/util.lua @@ -1,10 +1,34 @@ -- Utilities for design.lua --@ module = true +function make_ascii_button(ch1, ch2) + return { + {218, 196, 196, 191}, + {179, ch1, ch2, 179}, + {192, 196, 196, 217}, + } +end + +function make_button_spec(ch1, ch2, ch1_color, ch2_color, border_color, border_hcolor, x, y) + return { + chars=make_ascii_button(ch1, ch2), + pens={ + {border_color, border_color, border_color, border_color}, + {border_color, ch1_color, ch2_color, border_color}, + {border_color, border_color, border_color, border_color}, + }, + pens_hover={ + {border_hcolor, border_hcolor, border_hcolor, border_hcolor}, + {border_hcolor, ch1_color, ch2_color, border_hcolor}, + {border_hcolor, border_hcolor, border_hcolor, border_hcolor}, + }, + asset={page='INTERFACE_BITS', x=x, y=y}, + } +end + -- Point class used by gui/design Point = defclass(Point) Point.ATTRS { - __is_point = true, x = DEFAULT_NIL, y = DEFAULT_NIL, z = DEFAULT_NIL @@ -82,5 +106,5 @@ end function getMousePoint() local pos = dfhack.gui.getMousePos() - return pos and Point{x = pos.x, y = pos.y, z = pos.z} or nil + return pos and Point(pos) or nil end diff --git a/internal/dwarf-op/dorf_tables.lua b/internal/dwarf-op/dorf_tables.lua index ad89f84cf1..410f897c4e 100644 --- a/internal/dwarf-op/dorf_tables.lua +++ b/internal/dwarf-op/dorf_tables.lua @@ -218,7 +218,7 @@ professions = { --Arts & Crafts & Dwarfism CRAFTSMAN = { skills = {WOODCRAFT=2, STONECRAFT=2, METALCRAFT=2} }, - ENGRAVER = { skills = {DETAILSTONE=5} }, + ENGRAVER = { skills = {ENGRAVE_STONE=5} }, MECHANIC = { skills = {MECHANICS=5} }, --Plants & Animals diff --git a/internal/emigration/emigrate-nobles.lua b/internal/emigration/emigrate-nobles.lua new file mode 100644 index 0000000000..f4ef605de0 --- /dev/null +++ b/internal/emigration/emigrate-nobles.lua @@ -0,0 +1,346 @@ +--@module = true + +--[[ +TODO: + * Feature: have rightful ruler immigrate to fort if off-site +]]-- + +local argparse = require("argparse") + +local unit_link_utils = reqscript("internal/emigration/unit-link-utils") + +local options = { + all = false, + unitId = -1, + list = false +} + +-- adapted from Units::get_land_title() +---@return df.world_site|nil +local function findSiteOfRule(np) + local site = nil + local civ = np.entity -- lawmakers seem to be all civ-level positions + for _, link in ipairs(civ.site_links) do + if not link.flags.land_for_holding then goto continue end + if link.position_profile_id ~= np.assignment.id then goto continue end + + site = df.world_site.find(link.target) + break + ::continue:: + end + + return site +end + +---@return df.world_site|nil +local function findCapital(civ) + local civCapital = nil + for _, link in ipairs(civ.site_links) do + if link.flags.capital then + civCapital = df.world_site.find(link.target) + break + end + end + + return civCapital +end + +---@param unit df.unit +---@param nobleList { unit: df.unit, site: df.world_site, id: number }[] +---@param thisSite df.world_site +---@param civ df.historical_entity +local function addNobleOfOtherSite(unit, nobleList, thisSite, civ) + local nps = dfhack.units.getNoblePositions(unit) or {} + local noblePos = nil + for _, np in ipairs(nps) do + if np.position.flags.IS_LAW_MAKER then + noblePos = np + break + end + end + + if not noblePos then return end -- unit is not nobility + + -- Monarchs do not seem to have an world_site associated to them (?) + if noblePos.position.flags.RULES_FROM_LOCATION and noblePos.entity.id == civ.id then + local capital = findCapital(civ) + if capital and capital.id ~= thisSite.id then + table.insert(nobleList, {unit = unit, site = capital, id = noblePos.assignment.id}) + end + return + end + + local name = dfhack.units.getReadableName(unit) + -- Logic for dukes, counts, barons + local site = findSiteOfRule(noblePos) + if not site then qerror("could not find land of "..name) end + + if site.id == thisSite.id then return end -- noble rules current fort + table.insert(nobleList, {unit = unit, site = site, id = noblePos.assignment.id}) +end + +---@param unit df.unit +local function removeMandates(unit) + local mandates = df.global.world.mandates.all + for i=#mandates-1,0,-1 do + local mandate = mandates[i] + if mandate.unit and mandate.unit.id == unit.id then + mandates:erase(i) + mandate:delete() + end + end +end + +-- adapted from emigration::desert() +---@param unit df.unit +---@param toSite df.world_site +---@param prevEnt df.historical_entity +---@param civ df.historical_entity +---@param removeMayor boolean +local function emigrate(unit, toSite, prevEnt, civ, removeMayor) + local histFig = df.historical_figure.find(unit.hist_figure_id) + if not histFig then + print("Could not find associated historical figure!") + return + end + + unit_link_utils.markUnitForEmigration(unit, civ.id, true) + + -- remove current job + if unit.job.current_job then dfhack.job.removeJob(unit.job.current_job) end + + -- break up any social activities + for _, actId in ipairs(unit.social_activities) do + local act = df.activity_entry.find(actId) + if act then act.events[0].flags.dismissed = true end + end + + -- cancel any associated mandates + removeMandates(unit) + + unit_link_utils.removeUnitAssociations(unit) + unit_link_utils.removeHistFigFromEntity(histFig, prevEnt, removeMayor) + + -- have unit join new site government + local siteGov = df.historical_entity.find(toSite.cur_owner_id) + if not siteGov then qerror("could not find entity associated with new site") end + unit_link_utils.addHistFigToSite(histFig, toSite.id, siteGov) + + -- announce the changes + local unitName = dfhack.df2console(dfhack.units.getReadableName(unit)) + local siteName = dfhack.df2console(dfhack.translation.translateName(toSite.name, true)) + local govName = dfhack.df2console(dfhack.translation.translateName(siteGov.name, true)) + local line = unitName .. " has left to join " ..govName.. " as lord of " .. siteName .. "." + print("+ "..dfhack.df2console(line)) + dfhack.gui.showAnnouncement(line, COLOR_WHITE) +end + +------------------------ +-- [[ GUARD CHECKS ]] -- +------------------------ + +---@param unit df.unit +local function inSpecialJob(unit) + local job = unit.job.current_job + if not job then return false end + + if job.flags.special then return true end -- cannot cancel + + local jobType = job.job_type -- taken from notifications::for_moody() + return df.job_type_class[df.job_type.attrs[jobType].type] == 'StrangeMood' +end + +---@param unit df.unit +local function isSoldier(unit) + return unit.military.squad_id ~= -1 +end + +-- just an enum +local AdminType = { + NOT_ADMIN = { sym = " " }, + IS_ELECTED = { sym = "*" }, + IS_ADMIN = { sym = "!" } +} + +---@param unit df.unit +---@param fortEnt df.historical_entity +local function getAdminType(unit, fortEnt) + ---@diagnostic disable-next-line: missing-parameter + local nps = dfhack.units.getNoblePositions(unit) or {} + local result = AdminType.NOT_ADMIN + + ---@diagnostic disable-next-line: param-type-mismatch + for _, np in ipairs(nps) do + if np.entity.id ~= fortEnt.id then goto continue end + if np.position.flags.ELECTED then + result = AdminType.IS_ELECTED + goto continue + end + + -- Mayors cannot be evicted if they are also appointed administrators (e.g. manager) + result = AdminType.IS_ADMIN + break + ::continue:: + end + return result +end + +----------------------- +-- [[ PRINT MODES ]] -- +----------------------- + +---@param nobleList { unit: df.unit, site: df.world_site }[] +---@param fortEnt df.historical_entity +local function listNoblesFound(nobleList, fortEnt) + for _, record in ipairs(nobleList) do + local unit = record.unit + local site = record.site + + -- avoid scoping errors + local adminType = nil + local siteName = "" + + local nobleName = dfhack.units.getReadableName(unit) + local unitMsg = unit.id..": "..nobleName + if isSoldier(unit) then + local squad = df.squad.find(unit.military.squad_id) + local squadName = squad + and dfhack.translation.translateName(squad.name, true) + or "unknown squad" + + unitMsg = "! "..unitMsg.." - soldier in "..squadName + goto print + end + + adminType = getAdminType(unit, fortEnt) + if adminType ~= AdminType.NOT_ADMIN then + local status = adminType == AdminType.IS_ADMIN + and "fort administrator" -- isAdmin + or "elected official" -- isElected + unitMsg = adminType.sym.." "..unitMsg.." - "..status + goto print + end + + siteName = dfhack.translation.translateName(site.name, true) + unitMsg = " "..unitMsg.." - to "..siteName + + ::print:: + print(unitMsg) + end +end + +local function printNoNobles() + if options.unitId == -1 then + print("No eligible nobles to be emigrated.") + else + print("Unit ID "..options.unitId.." is not an eligible noble.") + end +end + +------------------------- +-- [[ MAIN FUNCTION ]] -- +------------------------- + +local function main() + ---@diagnostic disable-next-line: assign-type-mismatch + local fort = dfhack.world.getCurrentSite() ---@type df.world_site + if not fort then qerror("could not find current site") end + + local fortEnt = df.global.plotinfo.main.fortress_entity + + local civ = df.historical_entity.find(df.global.plotinfo.civ_id) + if not civ then qerror("could not find current civ") end + + ---@type { unit: df.unit, site: df.world_site, id: number }[] + local freeloaders = {} + for _, unit in ipairs(dfhack.units.getCitizens()) do + if options.unitId ~= -1 and unit.id ~= options.unitId then goto continue end + + addNobleOfOtherSite(unit, freeloaders, fort, civ) + ::continue:: + end + + if #freeloaders == 0 then + printNoNobles() + return + end + + if options.list then + listNoblesFound(freeloaders, fortEnt) + return + end + + for _, record in ipairs(freeloaders) do + local noble = record.unit + local site = record.site + local adminType = nil + + local nobleName = dfhack.units.getReadableName(noble) + if inSpecialJob(noble) then + print("! "..nobleName.." is busy! Leave alone for now.") + goto continue + elseif isSoldier(noble) then + print("! "..nobleName.." is in a squad! Unassign unit and try again.") + goto continue + end + + adminType = getAdminType(noble, fortEnt) + if adminType == AdminType.IS_ADMIN then + print("! "..nobleName.." is an administrator! Unassign unit and try again.") + goto continue + end + + local isElected = adminType == AdminType.IS_ELECTED + emigrate(noble, site, fortEnt, civ, isElected) + unit_link_utils.unassignSymbols(record.id, civ, fort) + ::continue:: + end +end + +local function initChecks() + if not dfhack.world.isFortressMode() or not dfhack.isMapLoaded() then + qerror('needs a loaded fortress map') + end + + if options.list then return true end -- list option does not require unit options + + local noOptions = options.unitId == -1 and not options.all + if noOptions then + unit = dfhack.gui.getSelectedUnit(true) + if unit then + options.unitId = unit.id + local name = dfhack.units.getReadableName(unit) + print("Selecting "..name.." (ID "..unit.id..")") + else + options.list = true + print("Defaulting to list mode:") + end + + return true + end + + local invalidUnit = options.unitId ~= -1 and options.all + if invalidUnit then qerror("Either specify one unit or all.") end + + return true +end + +local function resetOptions() + options.all = false + options.unitId = -1 + options.list = false +end + +function run(args) + argparse.processArgsGetopt(args, { + {"a", "all", handler=function() options.all = true end}, + {"u", "unit", hasArg=true, handler=function(id) options.unitId = tonumber(id) end}, + {"l", "list", handler=function() options.list = true end} + }) + + if initChecks() then + main() + end + + resetOptions() +end diff --git a/internal/emigration/unit-link-utils.lua b/internal/emigration/unit-link-utils.lua new file mode 100644 index 0000000000..e3bc294d46 --- /dev/null +++ b/internal/emigration/unit-link-utils.lua @@ -0,0 +1,257 @@ +--@module = true + +---@param histFig df.historical_figure +---@param oldEntity df.historical_entity +local function unassignMayor(histFig, oldEntity) + local assignmentId = -1 + local positionId = -1 + local nps = dfhack.units.getNoblePositions(histFig) or {} + for _,pos in ipairs(nps) do + if pos.entity.id == oldEntity.id and pos.position.flags.ELECTED then + pos.assignment.histfig = -1 + pos.assignment.histfig2 = -1 + assignmentId = pos.assignment.id + positionId = pos.position.id + end + end + if assignmentId == -1 then qerror("could not find mayor assignment!") end + + local startYear = -1 -- remove mayor assignment + for k,v in ipairs(histFig.entity_links) do + if v.entity_id == oldEntity.id + and df.histfig_entity_link_positionst:is_instance(v) + and v.assignment_id == assignmentId + then + startYear = v.start_year + histFig.entity_links:erase(k) + v:delete() + break + end + end + if startYear == -1 then qerror("could not find entity link!") end + + histFig.entity_links:insert('#', { + new = df.histfig_entity_link_former_positionst, + assignment_id = assignmentId, + start_year = startYear, + entity_id = oldEntity.id, + end_year = df.global.cur_year, + link_strength = 100 + }) + + local hfEventId = df.global.hist_event_next_id + df.global.hist_event_next_id = df.global.hist_event_next_id+1 + df.global.world.history.events:insert("#", { + new = df.history_event_remove_hf_entity_linkst, + year = df.global.cur_year, + seconds = df.global.cur_year_tick, + id = hfEventId, + civ = oldEntity.id, + histfig = histFig.id, + link_type = df.histfig_entity_link_type.POSITION, + position_id = positionId + }) +end + +---@param histFig df.historical_figure +---@param oldEntity df.historical_entity +---@param removeMayor boolean +function removeHistFigFromEntity(histFig, oldEntity, removeMayor) + if not histFig or not oldEntity then return end + + local histFigId = histFig.id + + -- erase the unit from the fortress entity + for k,v in ipairs(oldEntity.histfig_ids) do + if v == histFigId then + df.global.plotinfo.main.fortress_entity.histfig_ids:erase(k) + break + end + end + for k,v in ipairs(oldEntity.hist_figures) do + if v.id == histFigId then + df.global.plotinfo.main.fortress_entity.hist_figures:erase(k) + break + end + end + for k,v in ipairs(oldEntity.nemesis) do + if v.figure.id == histFigId then + df.global.plotinfo.main.fortress_entity.nemesis:erase(k) + df.global.plotinfo.main.fortress_entity.nemesis_ids:erase(k) + break + end + end + + -- remove mayor assignment if exists + if removeMayor then unassignMayor(histFig, oldEntity) end + + -- remove the old entity link and create new one to indicate former membership + histFig.entity_links:insert("#", {new = df.histfig_entity_link_former_memberst, entity_id = oldEntity.id, link_strength = 100}) + for k,v in ipairs(histFig.entity_links) do + if v._type == df.histfig_entity_link_memberst and v.entity_id == oldEntity.id then + histFig.entity_links:erase(k) + break + end + end +end + +---Creates events indicating a histfig's move to a new site and joining its entity. +---@param histFig df.historical_figure +---@param siteId number Set to -1 if unneeded +---@param siteGov df.historical_entity +function addHistFigToSite(histFig, siteId, siteGov) + if not histFig or not siteGov then return nil end + + local histFigId = histFig.id + + -- add new site gov to histfig links + histFig.entity_links:insert("#", { + new = df.histfig_entity_link_memberst, + entity_id = siteGov.id, + link_strength = 100 + }) + + -- add histfig to new site gov + siteGov.histfig_ids:insert('#', histFigId) + siteGov.hist_figures:insert('#', histFig) + local hfEventId = df.global.hist_event_next_id + df.global.hist_event_next_id = df.global.hist_event_next_id+1 + df.global.world.history.events:insert("#", { + new = df.history_event_add_hf_entity_linkst, + year = df.global.cur_year, + seconds = df.global.cur_year_tick, + id = hfEventId, + civ = siteGov.id, + histfig = histFigId, + link_type = df.histfig_entity_link_type.MEMBER + }) + + if siteId <= -1 then return end -- skip site join event + + -- create event indicating histfig moved to site + hfEventId = df.global.hist_event_next_id + df.global.hist_event_next_id = df.global.hist_event_next_id+1 + df.global.world.history.events:insert("#", { + new = df.history_event_change_hf_statest, + year = df.global.cur_year, + seconds = df.global.cur_year_tick, + id = hfEventId, + hfid = histFigId, + state = df.whereabouts_type.settler, + reason = df.history_event_reason.none, + site = siteId + }) +end + +---@param unit df.unit +function removeUnitAssociations(unit) + -- free owned rooms + for i = #unit.owned_buildings-1, 0, -1 do + local tmp = df.building.find(unit.owned_buildings[i].id) + dfhack.buildings.setOwner(tmp, nil) + end + + -- remove from workshop profiles + for _, bld in ipairs(df.global.world.buildings.other.WORKSHOP_ANY) do + for k, v in ipairs(bld.profile.permitted_workers) do + if v == unit.id then + bld.profile.permitted_workers:erase(k) + break + end + end + end + for _, bld in ipairs(df.global.world.buildings.other.FURNACE_ANY) do + for k, v in ipairs(bld.profile.permitted_workers) do + if v == unit.id then + bld.profile.permitted_workers:erase(k) + break + end + end + end + + -- disassociate from work details + for _, detail in ipairs(df.global.plotinfo.labor_info.work_details) do + for k, v in ipairs(detail.assigned_units) do + if v == unit.id then + detail.assigned_units:erase(k) + break + end + end + end + + -- unburrow + for _, burrow in ipairs(df.global.plotinfo.burrows.list) do + dfhack.burrows.setAssignedUnit(burrow, unit, false) + end +end + +---@param unit df.unit +---@param civId number +---@param leaveNow boolean Decides if unit leaves immediately or with merchants +function markUnitForEmigration(unit, civId, leaveNow) + unit.following = nil + unit.civ_id = civId + + if leaveNow then + unit.flags1.forest = true + unit.flags2.visitor = true + unit.animal.leave_countdown = 2 + else + unit.flags1.merchant = true + end +end + +---@param item df.item +local function getPos(item) + local x, y, z = dfhack.items.getPosition(item) + if not x or not y or not z then + return nil + end + + if dfhack.maps.isTileVisible(x, y, z) then + return xyz2pos(x, y, z) + end +end + +---@param assignmentId number +---@param entity df.historical_entity +---@param site df.world_site +function unassignSymbols(assignmentId, entity, site) + local claims = entity.artifact_claims + local artifacts = df.global.world.artifacts.all + + for i=#claims-1,0,-1 do + local claim = claims[i] + if claim.claim_type ~= df.artifact_claim_type.Symbol then goto continue end + if claim.symbol_claim_id ~= assignmentId then goto continue end + + local artifact = artifacts[claim.artifact_id] + local item = artifact.item + local artifactName = dfhack.translation.translateName(artifact.name) + + -- we can probably keep artifact.entity_claims since we still hold it + local itemPos = getPos(item) + local success = false + if not itemPos then + if artifact.site == site.id then + print(" ! "..artifactName.." cannot be found!") + goto removeClaim + else + print(" ! "..artifactName.." is not in this site!") + goto continue + end + end + + success = dfhack.items.moveToGround(item, itemPos) + if success then print(" + dropped "..artifactName) + else print(" ! could not drop "..artifactName) + end + + -- they do not seem to "own" their artifacts, no additional cleaning seems necessary + + ::removeClaim:: + claims:erase(i) + claim:delete() + ::continue:: + end +end diff --git a/internal/exportlegends/asyncexport.lua b/internal/exportlegends/asyncexport.lua new file mode 100644 index 0000000000..21c4bb95fd --- /dev/null +++ b/internal/exportlegends/asyncexport.lua @@ -0,0 +1,104 @@ +--@module = true + +local gui = require('gui') +local overlay = require('plugins.overlay') +local widgets = require('gui.widgets') + +progress_item = nil +num_done = nil +num_total = nil + +function reset_state() + progress_item = '' + num_done = -1 + num_total = -1 +end +reset_state() + +-- ------------------- +-- LegendsOverlay +-- + +LegendsOverlay = defclass(LegendsOverlay, overlay.OverlayWidget) +LegendsOverlay.ATTRS{ + desc='Adds extended export progress bar to the legends main screen.', + default_pos={x=2, y=2}, + default_enabled=true, + viewscreens='legends', + frame={w=55, h=5}, +} + +function LegendsOverlay:init() + self:addviews{ + widgets.Panel{ + view_id='button_mask', + frame={t=0, l=0, w=15, h=3}, + }, + widgets.BannerPanel{ + frame={b=0, l=0, r=0, h=1}, + visible=function() return dfhack.gui.matchFocusString('legends/Default', dfhack.gui.getDFViewscreen(true)) end, + subviews={ + widgets.ToggleHotkeyLabel{ + view_id='do_export', + frame={t=0, l=1, r=1}, + label='Also export DFHack extended legends data:', + key='CUSTOM_CTRL_D', + visible=function() return num_total < 0 end, + }, + widgets.Label{ + frame={t=0, l=1}, + text={ + 'Exporting ', + {width=27, text=function() return progress_item end}, + ' ', + {text=function() return ('%.2f'):format((num_done * 100) / num_total) end, pen=COLOR_YELLOW}, + '% complete' + }, + visible=function() return num_total >= 0 end, + }, + }, + }, + } +end + +function LegendsOverlay:onInput(keys) + if keys._MOUSE_L and self.subviews.button_mask:getMousePos() and self.subviews.do_export:getOptionValue() then + if num_total < 0 then + dfhack.run_script('exportlegends') + else + return true + end + end + return LegendsOverlay.super.onInput(self, keys) +end + +-- ------------------- +-- DoneMaskOverlay +-- + +DoneMaskOverlay = defclass(DoneMaskOverlay, overlay.OverlayWidget) +DoneMaskOverlay.ATTRS{ + desc='Prevents legends mode from being exited while an export is in progress.', + default_pos={x=-2, y=2}, + default_enabled=true, + viewscreens='legends', + frame={w=9, h=3}, +} + +function DoneMaskOverlay:init() + self:addviews{ + widgets.Panel{ + frame_background=gui.CLEAR_PEN, + visible=function() return num_total >= 0 end, + } + } +end + +function DoneMaskOverlay:onInput(keys) + if num_total >= 0 then + if keys.LEAVESCREEN or (keys._MOUSE_L and self:getMousePos()) then + return true + end + end + return DoneMaskOverlay.super.onInput(self, keys) +end diff --git a/internal/exportlegends/racefilter.lua b/internal/exportlegends/racefilter.lua new file mode 100644 index 0000000000..48af2be76c --- /dev/null +++ b/internal/exportlegends/racefilter.lua @@ -0,0 +1,130 @@ +--@module = true + +local dlg = require('gui.dialogs') +local overlay = require('plugins.overlay') +local widgets = require('gui.widgets') + +local choices, race_to_label, hfid_to_race, hfid_to_name, cur_race, prev_search + +function reset_state() + choices = {} + race_to_label = {[-1]='All'} + hfid_to_race = {} + hfid_to_name = {} + cur_race = -1 + prev_search = '' +end +reset_state() + +-- ------------------- +-- RaceFilterOverlay +-- + +RaceFilterOverlay = defclass(RaceFilterOverlay, overlay.OverlayWidget) +RaceFilterOverlay.ATTRS { + desc="Adds the ability to filter historical figures by race in legends mode.", + default_pos={x=56, y=11}, + default_enabled=true, + viewscreens='legends', -- finer grained visibility managed in render and onInput functions + frame={w=54, h=1}, -- can't use visible property due to self.dirty state management +} + +function RaceFilterOverlay:init() + self:addviews{ + widgets.BannerPanel{ + subviews={ + widgets.HotkeyLabel{ + frame={l=1}, + label='Filter by race:', + key='CUSTOM_ALT_S', + auto_width=true, + on_activate=self:callback('choose_race'), + }, + widgets.Label{ + frame={l=24}, + text={{text=function() return race_to_label[cur_race] end}}, + text_pen=COLOR_YELLOW, + }, + }, + }, + } +end + +function RaceFilterOverlay:set_race(_, choice) + if cur_race == choice.race then return end + cur_race = choice.race + self.dirty = true +end + +function RaceFilterOverlay:choose_race() + if #choices == 0 then + for race,cre in ipairs(df.global.world.raws.creatures.all) do + local label = string.lower(cre.creature_id) + race_to_label[race] = label + table.insert(choices, {text=label, race=race}) + end + table.sort(choices, function(a, b) return a.text < b.text end) + table.insert(choices, 1, {text='All', race=-1}) + end + + dlg.showListPrompt('Races', 'Choose race filter', COLOR_WHITE, choices, + self:callback('set_race'), nil, 30, true) +end + +local function do_filter(scr, filter_str, full_refresh) + print('filtering', cur_race, filter_str, full_refresh) + if full_refresh then + scr.histfigs_filtered:resize(#scr.histfigs) + for i=0,#scr.histfigs-1 do + scr.histfigs_filtered[i] = i + end + end + local filter_by_name = full_refresh and #filter_str > 0 + filter_str = dfhack.toSearchNormalized(filter_str) + if cur_race < 0 and not filter_by_name then return end + for idx=#scr.histfigs_filtered-1,0,-1 do + local hfid = scr.histfigs[scr.histfigs_filtered[idx]] + if not hfid_to_race[hfid] then + local hf = df.historical_figure.find(hfid) + hfid_to_race[hfid] = hf and hf.race or -1 + hfid_to_name[hfid] = hf and + dfhack.toSearchNormalized( + ('%s %s'):format(dfhack.translation.translateName(hf.name, false), dfhack.translation.translateName(hf.name, true))) or '' + end + if cur_race >= 0 and hfid_to_race[hfid] ~= cur_race then + scr.histfigs_filtered:erase(idx) + elseif filter_by_name and not hfid_to_name[hfid]:match(filter_str) then + scr.histfigs_filtered:erase(idx) + end + end +end + +local function get_cur_page(scr) + scr = scr or dfhack.gui.getDFViewscreen(true) + return scr.page[scr.active_page_index] +end + +local function is_hf_page(scr, page) + page = page or get_cur_page(scr) + return page.mode == df.legends_mode_type.HFS and page.index == -1 +end + +function RaceFilterOverlay:render(dc) + local scr = dfhack.gui.getDFViewscreen(true) + local page = get_cur_page(scr) + if not is_hf_page(scr, page) then + self.dirty = true + return + end + if self.dirty or prev_search ~= page.filter_str then + do_filter(scr, page.filter_str, self.dirty) + prev_search = page.filter_str + self.dirty = false + end + RaceFilterOverlay.super.render(self, dc) +end + +function RaceFilterOverlay:onInput(keys) + if not is_hf_page() then return end + RaceFilterOverlay.super.onInput(self, keys) +end diff --git a/internal/gm-unit/editor_body.lua b/internal/gm-unit/editor_body.lua index b6e8935308..ab135976bf 100644 --- a/internal/gm-unit/editor_body.lua +++ b/internal/gm-unit/editor_body.lua @@ -2,10 +2,11 @@ --@ module = true local dialog = require 'gui.dialogs' -local gui = require 'gui' local widgets = require 'gui.widgets' local base_editor = reqscript("internal/gm-unit/base_editor") +rng = rng or dfhack.random.new(nil, 10) + -- TODO: Trigger recalculation of body sizes after size is edited Editor_Body_Modifier=defclass(Editor_Body_Modifier, widgets.Window) @@ -27,17 +28,29 @@ function Editor_Body_Modifier:setPartModifier(indexList, value) for _, index in ipairs(indexList) do self.target_unit.appearance.bp_modifiers[index] = tonumber(value) end + + -- Update the unit's portrait + self.target_unit.flags4.portrait_must_be_refreshed = true + -- Update the world texture + self.target_unit.flags4.any_texture_must_be_refreshed = true + self:updateChoices() end function Editor_Body_Modifier:setBodyModifier(modifierIndex, value) self.target_unit.appearance.body_modifiers[modifierIndex] = tonumber(value) + + -- Update the unit's portrait + self.target_unit.flags4.portrait_must_be_refreshed = true + -- Update the world texture + self.target_unit.flags4.any_texture_must_be_refreshed = true + self:updateChoices() end function Editor_Body_Modifier:selected(index, selected) dialog.showInputPrompt( - self:beautifyString(df.appearance_modifier_type[selected.modifier.entry.type]), + self:beautifyString(df.appearance_modifier_type[selected.modifier.entry.modifier.type]), "Enter new value:", nil, tostring(selected.value), @@ -63,8 +76,8 @@ function Editor_Body_Modifier:random() local startIndex = rng:random(6) -- Will give a number between 0-5 which, when accounting for the fact that the range table starts at 0, gives us the index of which of the first 6 to use -- Set the ranges - local min = selected.modifier.entry.ranges[startIndex] - local max = selected.modifier.entry.ranges[startIndex+1] + local min = selected.modifier.entry.modifier.ranges[startIndex] + local max = selected.modifier.entry.modifier.ranges[startIndex+1] -- Get the difference between the two local difference = math.abs(min - max) @@ -86,7 +99,7 @@ function Editor_Body_Modifier:step(amount) -- Build a table of description ranges local ranges = {} - for index, value in ipairs(selected.modifier.entry.desc_range) do + for index, value in ipairs(selected.modifier.entry.modifier.desc_range) do -- Only add a new entry if: There are none, or the value is higher than the previous range if #ranges == 0 or value > ranges[#ranges] then table.insert(ranges, value) @@ -129,7 +142,7 @@ function Editor_Body_Modifier:updateChoices() else -- Body currentValue = self.target_unit.appearance.body_modifiers[modifier.index] end - table.insert(choices, {text = self:beautifyString(df.appearance_modifier_type[modifier.entry.type]) .. ": " .. currentValue, value = currentValue, modifier = modifier}) + table.insert(choices, {text = self:beautifyString(df.appearance_modifier_type[modifier.entry.modifier.type]) .. ": " .. currentValue, value = currentValue, modifier = modifier}) end self.subviews.modifiers:setChoices(choices) @@ -183,8 +196,8 @@ function makePartList(caste) for index, modifier in ipairs(caste.bp_appearance.modifiers) do local name - if modifier.noun ~= "" then - name = modifier.noun + if modifier.modifier.noun ~= "" then + name = modifier.modifier.noun else name = caste.body_info.body_parts[modifier.body_parts[0]].name_singular[0].value -- Use the name of the first body part modified end diff --git a/internal/gm-unit/editor_civilization.lua b/internal/gm-unit/editor_civilization.lua index c062814156..b12b00e62d 100644 --- a/internal/gm-unit/editor_civilization.lua +++ b/internal/gm-unit/editor_civilization.lua @@ -66,7 +66,7 @@ function civ_name(id,format_name,format_no_name,name_other,name_invalid) return name_invalid or "" end end - local t={NAME=dfhack.TranslateName(civ.name),ENGLISH=dfhack.TranslateName(civ.name,true),ID=civ.id} --TODO race?, maybe something from raws? + local t={NAME=dfhack.translation.translateName(civ.name),ENGLISH=dfhack.translation.translateName(civ.name,true),ID=civ.id} --TODO race?, maybe something from raws? if t.NAME=="" then return string.gsub(format_no_name or " ($ID)", "%$(%w+)", t) end @@ -107,7 +107,7 @@ function CivBox:choose_race() end function CivBox:init(info) self.subviews.list.frame={t=3,r=0,l=0} - self.subviews.list.edit.ignore_keys={"STRING_A047"}, + self.subviews.list.edit.text_area.text_area.ignore_keys={"STRING_A047"} self:addviews{ widgets.Label{frame={t=1,l=0},text={ {text="Filter race ",key="STRING_A047",key_sep="()",on_activate=self:callback("choose_race")}, diff --git a/internal/gm-unit/editor_colors.lua b/internal/gm-unit/editor_colors.lua index 9356f3788e..decebda005 100644 --- a/internal/gm-unit/editor_colors.lua +++ b/internal/gm-unit/editor_colors.lua @@ -10,6 +10,29 @@ Editor_Colors.ATTRS{ frame_title = "Colors editor" } +rng = rng or dfhack.random.new(nil, 10) + +local function weightedRoll(weightedTable) + local maxWeight = 0 + for index, result in ipairs(weightedTable) do + maxWeight = maxWeight + result.weight + end + + local roll = rng:random(maxWeight) + 1 + local currentNum = roll + local result + + for index, currentResult in ipairs(weightedTable) do + currentNum = currentNum - currentResult.weight + if currentNum <= 0 then + result = currentResult.id + break + end + end + + return result +end + function patternString(patternId) local pattern = df.descriptor_pattern.find(patternId) local prefix @@ -41,7 +64,7 @@ end function Editor_Colors:random() local featureChoiceIndex, featureChoice = self.subviews.features:getSelected() -- This is the part / feature that's selected - local caste = df.creature_raw.find(self.target_unit.race).caste[self.target_unit.caste] + local caste = dfhack.units.getCasteRaw(self.target_unit) -- Nil check in case there are no features if featureChoiceIndex == nil then @@ -74,22 +97,19 @@ function Editor_Colors:random() -- Set the unit's appearance for the feature to the new pattern self.target_unit.appearance.colors[featureChoice.index] = options[result].index - -- Notify the user on the change, so they get some visual feedback that something has happened - local pluralWord - if featureChoice.mod.unk_6c == 1 then - pluralWord = "are" - else - pluralWord = "is" - end - - dialog.showMessage("Color randomised!", - featureChoice.text .. " " .. pluralWord .." now " .. patternString(options[result].patternId), - nil, nil) + -- Update the unit's portrait + self.target_unit.flags4.portrait_must_be_refreshed = true + -- Update the world texture + self.target_unit.flags4.any_texture_must_be_refreshed = true end function Editor_Colors:colorSelected(index, choice) -- Update the modifier for the unit self.target_unit.appearance.colors[self.modIndex] = choice.index + -- Update the unit's portrait + self.target_unit.flags4.portrait_must_be_refreshed = true + -- Update the world texture + self.target_unit.flags4.any_texture_must_be_refreshed = true end function Editor_Colors:featureSelected(index, choice) @@ -116,7 +136,7 @@ function Editor_Colors:featureSelected(index, choice) end function Editor_Colors:updateChoices() - local caste = df.creature_raw.find(self.target_unit.race).caste[self.target_unit.caste] + local caste = dfhack.units.getCasteRaw(self.target_unit) local choices = {} for index, colorMod in ipairs(caste.color_modifiers) do table.insert(choices, {text = colorMod.part:gsub("^%l", string.upper), mod = colorMod, index = index}) diff --git a/internal/gm-unit/editor_counters.lua b/internal/gm-unit/editor_counters.lua index 0bf258df1e..c740bcfb32 100644 --- a/internal/gm-unit/editor_counters.lua +++ b/internal/gm-unit/editor_counters.lua @@ -9,7 +9,6 @@ Editor_Counters=defclass(Editor_Counters, base_editor.Editor) Editor_Counters.ATTRS{ frame_title = "Counters editor", counters1={ - "think_counter", "job_counter", "swap_counter", "winded", diff --git a/internal/gm-unit/editor_profession.lua b/internal/gm-unit/editor_profession.lua index 0fb279fc55..79f6ac928a 100644 --- a/internal/gm-unit/editor_profession.lua +++ b/internal/gm-unit/editor_profession.lua @@ -44,6 +44,11 @@ end function Editor_Prof:save_profession(_, choice) self.target_unit.profession = choice.profession self.target_unit.profession2 = choice.profession + local hf = df.historical_figure.find(self.target_unit.hist_figure_id) + if hf then + hf.profession = choice.profession + end + self.target_unit.flags4.any_texture_must_be_refreshed = true end function Editor_Prof:onOpen() diff --git a/internal/gm-unit/editor_skills.lua b/internal/gm-unit/editor_skills.lua index b97ee60ddc..e23902d434 100644 --- a/internal/gm-unit/editor_skills.lua +++ b/internal/gm-unit/editor_skills.lua @@ -24,7 +24,13 @@ function list_skills(unit, learned_only) u_skill={rating=-1,experience=0} end local rating - if u_skill.rating >=0 then + if u_skill.rating > df.skill_rating.Legendary then + local legendary_bonus= u_skill.rating - df.skill_rating.Legendary + rating=df.skill_rating.attrs[df.skill_rating.Legendary] + rating={ xp_threshold=rating.xp_threshold, caption=rating.caption } + rating.xp_threshold= rating.xp_threshold + legendary_bonus * 100 + rating.caption = rating.caption.."+"..legendary_bonus + elseif u_skill.rating >=0 then rating=df.skill_rating.attrs[u_skill.rating] else rating={caption="",xp_threshold=0} diff --git a/internal/journal/contexts/adventure.lua b/internal/journal/contexts/adventure.lua new file mode 100644 index 0000000000..9957cbb615 --- /dev/null +++ b/internal/journal/contexts/adventure.lua @@ -0,0 +1,77 @@ +--@ module = true + +local JOURNAL_WELCOME_COPY = [=[ +Welcome to gui/journal, the adventurer's notebook for Dwarf Fortress! + +Here, you can jot down your travels, keep track of important places, or note anything worth remembering. +The text you write here is saved together with your adventurer. + +For guidance on navigation and hotkeys, tap the ? button in the upper right corner. +Safe travels! +]=] + +local TOC_WELCOME_COPY = [=[ +Start a line with # symbols and a space to create a header. For example: + +# My section heading + +or + +## My section subheading + +Those headers will appear here, and you can click on them to jump to them in the text.]=] + + +AdventurerJournalContext = defclass(AdventurerJournalContext) +AdventurerJournalContext.ATTRS{ + save_prefix='', + adventurer_id=DEFAULT_NIL +} + +function get_adventurer_context_key(prefix, adventurer_id) + return string.format( + '%sjournal:adventurer:%s', + prefix, + adventurer_id + ) +end + +function AdventurerJournalContext:save_content(text, cursor) + if dfhack.isWorldLoaded() then + dfhack.persistent.saveWorldData( + get_adventurer_context_key(self.save_prefix, self.adventurer_id), + {text={text}, cursor={cursor}} + ) + end +end + +function AdventurerJournalContext:load_content() + if dfhack.isWorldLoaded() then + local world_data = dfhack.persistent.getWorldData( + get_adventurer_context_key(self.save_prefix, self.adventurer_id) + ) or {} + + if not world_data.text then + world_data.text={''} + world_data.show_tutorial = true + end + world_data.cursor = world_data.cursor or {#world_data.text[1] + 1} + return world_data + end +end + +function AdventurerJournalContext:delete_content() + if dfhack.isWorldLoaded() then + dfhack.persistent.deleteWorldData( + get_adventurer_context_key(self.save_prefix, self.adventurer_id) + ) + end +end + +function AdventurerJournalContext:welcomeCopy() + return JOURNAL_WELCOME_COPY +end + +function AdventurerJournalContext:tocWelcomeCopy() + return TOC_WELCOME_COPY +end diff --git a/internal/journal/contexts/dummy.lua b/internal/journal/contexts/dummy.lua new file mode 100644 index 0000000000..1993bb770b --- /dev/null +++ b/internal/journal/contexts/dummy.lua @@ -0,0 +1,23 @@ +--@ module = true + +-- Dummy Context, no storage -- + +DummyJournalContext = defclass(DummyJournalContext) + +function DummyJournalContext:save_content(text, cursor) +end + +function DummyJournalContext:load_content() + return {text={''}, cursor={1}, show_tutorial=true} +end + +function DummyJournalContext:delete_content() +end + +function DummyJournalContext:welcomeCopy() + return '' +end + +function DummyJournalContext:tocWelcomeCopy() + return '' +end diff --git a/internal/journal/contexts/fortress.lua b/internal/journal/contexts/fortress.lua new file mode 100644 index 0000000000..46a28f568c --- /dev/null +++ b/internal/journal/contexts/fortress.lua @@ -0,0 +1,72 @@ +--@ module = true + +local JOURNAL_WELCOME_COPY = [=[ +Welcome to gui/journal, the chronicler's tool for Dwarf Fortress! + +Here, you can carve out notes, sketch your grand designs, or record the history of your fortress. +The text you write here is saved together with your fort. + +For guidance on navigation and hotkeys, tap the ? button in the upper right corner. +Happy digging! +]=] + +local TOC_WELCOME_COPY = [=[ +Start a line with # symbols and a space to create a header. For example: + +# My section heading + +or + +## My section subheading + +Those headers will appear here, and you can click on them to jump to them in the text.]=] + + +FortressJournalContext = defclass(FortressJournalContext) +FortressJournalContext.ATTRS{ + save_prefix='' +} + +function get_fort_context_key(prefix) + return prefix .. 'journal' +end + +function FortressJournalContext:save_content(text, cursor) + if dfhack.isWorldLoaded() then + dfhack.persistent.saveSiteData( + get_fort_context_key(self.save_prefix), + {text={text}, cursor={cursor}} + ) + end +end + +function FortressJournalContext:load_content() + if dfhack.isWorldLoaded() then + local site_data = dfhack.persistent.getSiteData( + get_fort_context_key(self.save_prefix) + ) or {} + + if not site_data.text then + site_data.text={''} + site_data.show_tutorial = true + end + site_data.cursor = site_data.cursor or {#site_data.text[1] + 1} + return site_data + end +end + +function FortressJournalContext:delete_content() + if dfhack.isWorldLoaded() then + dfhack.persistent.deleteSiteData( + get_fort_context_key(self.save_prefix) + ) + end +end + +function FortressJournalContext:welcomeCopy() + return JOURNAL_WELCOME_COPY +end + +function FortressJournalContext:tocWelcomeCopy() + return TOC_WELCOME_COPY +end diff --git a/internal/journal/journal_context.lua b/internal/journal/journal_context.lua new file mode 100644 index 0000000000..989e10f06c --- /dev/null +++ b/internal/journal/journal_context.lua @@ -0,0 +1,45 @@ +--@ module = true + +local widgets = require 'gui.widgets' +local utils = require('utils') +local DummyJournalContext = reqscript('internal/journal/contexts/dummy').DummyJournalContext +local FortressJournalContext = reqscript('internal/journal/contexts/fortress').FortressJournalContext +local AdventurerJournalContext = reqscript('internal/journal/contexts/adventure').AdventurerJournalContext + +JOURNAL_CONTEXT_MODE = { + FORTRESS='fortress', + ADVENTURE='adventure', + DUMMY='dummy' +} + +function detect_journal_context_mode() + if dfhack.world.isFortressMode() then + return JOURNAL_CONTEXT_MODE.FORTRESS + elseif dfhack.world.isAdventureMode() then + return JOURNAL_CONTEXT_MODE.ADVENTURE + else + qerror('unsupported game mode') + end +end + +function journal_context_factory(journal_context_mode, save_prefix) + if journal_context_mode == JOURNAL_CONTEXT_MODE.FORTRESS then + return FortressJournalContext{save_prefix} + elseif journal_context_mode == JOURNAL_CONTEXT_MODE.ADVENTURE then + local interactions = df.global.adventure.interactions + if #interactions.party_core_members == 0 or interactions.party_core_members[0] == nil then + qerror('Can not identify party core member') + end + + local adventurer_id = interactions.party_core_members[0] + + return AdventurerJournalContext{ + save_prefix, + adventurer_id=adventurer_id + } + elseif journal_context_mode == JOURNAL_CONTEXT_MODE.DUMMY then + return DummyJournalContext{} + else + qerror('unsupported game mode') + end +end diff --git a/internal/journal/shifter.lua b/internal/journal/shifter.lua new file mode 100644 index 0000000000..1c4aefea40 --- /dev/null +++ b/internal/journal/shifter.lua @@ -0,0 +1,55 @@ +-- >> / << toggle button +--@ module = true + +local widgets = require 'gui.widgets' + +local TO_THE_RIGHT = string.char(16) +local TO_THE_LEFT = string.char(17) + +function get_shifter_text(state) + local ch = state and TO_THE_RIGHT or TO_THE_LEFT + return { + ' ', NEWLINE, + ch, NEWLINE, + ch, NEWLINE, + ' ', NEWLINE, + } +end + +Shifter = defclass(Shifter, widgets.Widget) +Shifter.ATTRS { + frame={l=0, w=1, t=0, b=0}, + collapsed=false, + on_changed=DEFAULT_NIL, +} + +function Shifter:init() + self:addviews{ + widgets.Label{ + view_id='shifter_label', + frame={l=0, r=0, t=0, b=0}, + text=get_shifter_text(self.collapsed), + on_click=function () + self:toggle(not self.collapsed) + end + } + } +end + +function Shifter:toggle(state) + if state == nil then + self.collapsed = not self.collapsed + else + self.collapsed = state + end + + self.subviews.shifter_label:setText( + get_shifter_text(self.collapsed) + ) + + self:updateLayout() + + if self.on_changed then + self.on_changed(self.collapsed) + end +end diff --git a/internal/journal/table_of_contents.lua b/internal/journal/table_of_contents.lua new file mode 100644 index 0000000000..c1e2096df9 --- /dev/null +++ b/internal/journal/table_of_contents.lua @@ -0,0 +1,160 @@ +--@ module = true + +local gui = require 'gui' +local widgets = require 'gui.widgets' + +local df_major_version = tonumber(dfhack.getCompiledDFVersion():match('%d+')) + +local INVISIBLE_FRAME = { + frame_pen=gui.CLEAR_PEN, + signature_pen=false, +} + +TableOfContents = defclass(TableOfContents, widgets.Panel) +TableOfContents.ATTRS { + frame_style=INVISIBLE_FRAME, + frame_background = gui.CLEAR_PEN, + on_submit=DEFAULT_NIL, + text_cursor=DEFAULT_NIL +} + +function TableOfContents:init() + self:addviews{ + widgets.List{ + frame={l=0, t=0, r=0, b=3}, + view_id='table_of_contents', + choices={}, + on_submit=self.on_submit + }, + } + + if df_major_version < 51 then + -- widgets below this line require DF 51 + -- TODO: remove this check once DF 51 is stable and DFHack is no longer + -- releasing new versions for DF 50 + return + end + + local function can_prev() + local toc = self.subviews.table_of_contents + return #toc:getChoices() > 0 + end + local function can_next() + local toc = self.subviews.table_of_contents + local num_choices = #toc:getChoices() + return num_choices > 0 + end + + self:addviews{ + widgets.HotkeyLabel{ + frame={b=1, l=0}, + key='A_MOVE_N_DOWN', + label='Prev Section', + auto_width=true, + on_activate=self:callback('previousSection'), + enabled=can_prev, + }, + widgets.Label{ + frame={l=5, b=1, w=1}, + text_pen=function() return can_prev() and COLOR_LIGHTGREEN or COLOR_GREEN end, + text=string.char(24), + }, + widgets.HotkeyLabel{ + frame={b=0, l=0}, + key='A_MOVE_S_DOWN', + label='Next Section', + auto_width=true, + on_activate=self:callback('nextSection'), + enabled=can_next, + }, + widgets.Label{ + frame={l=5, b=0, w=1}, + text_pen=function() return can_next() and COLOR_LIGHTGREEN or COLOR_GREEN end, + text=string.char(25), + }, + } +end + +function TableOfContents:previousSection() + local section_cursor, section = self:currentSection() + + if section == nil then + return + end + + if section.line_cursor == self.text_cursor then + self.subviews.table_of_contents:setSelected(section_cursor - 1) + end + + self.subviews.table_of_contents:submit() +end + +function TableOfContents:nextSection() + local section_cursor, section = self:currentSection() + + if section == nil then + return + end + + local curr_sel = self.subviews.table_of_contents:getSelected() + + local target_sel = self.text_cursor and section_cursor + 1 or curr_sel + 1 + + if curr_sel ~= target_sel then + self.subviews.table_of_contents:setSelected(target_sel) + self.subviews.table_of_contents:submit() + end +end + +function TableOfContents:setSelectedSection(section_index) + local curr_sel = self.subviews.table_of_contents:getSelected() + + if curr_sel ~= section_index then + self.subviews.table_of_contents:setSelected(section_index) + end +end + +function TableOfContents:currentSection() + local section_ind = nil + + for ind, choice in ipairs(self.subviews.table_of_contents.choices) do + if choice.line_cursor > self.text_cursor then + break + end + section_ind = ind + end + + return section_ind, self.subviews.table_of_contents.choices[section_ind] +end + +function TableOfContents:setCursor(cursor) + self.text_cursor = cursor +end + +function TableOfContents:sections() + return self.subviews.table_of_contents.choices +end + +function TableOfContents:reload(text, cursor) + if not self.visible then + return + end + + local sections = {} + + local line_cursor = 1 + for line in text:gmatch("[^\n]*") do + local header, section = line:match("^(#+)%s(.+)") + if header ~= nil then + table.insert(sections, { + line_cursor=line_cursor, + text=string.rep(" ", #header - 1) .. section, + }) + end + + line_cursor = line_cursor + #line + 1 + end + + self.text_cursor = cursor + self.subviews.table_of_contents:setChoices(sections) +end diff --git a/internal/manipulator/presets.lua b/internal/manipulator/presets.lua new file mode 100644 index 0000000000..259d4a43ea --- /dev/null +++ b/internal/manipulator/presets.lua @@ -0,0 +1,10 @@ +--@module=true + +PRESETS = { + { + name='', + groups={ + + }, + }, +} diff --git a/internal/notes/note_manager.lua b/internal/notes/note_manager.lua new file mode 100644 index 0000000000..56342abb11 --- /dev/null +++ b/internal/notes/note_manager.lua @@ -0,0 +1,187 @@ +--@ module = true + +local gui = require('gui') +local widgets = require('gui.widgets') +local guidm = require('gui.dwarfmode') + +local waypoints = df.global.plotinfo.waypoints +local map_points = df.global.plotinfo.waypoints.points + + +NoteManager = defclass(NoteManager, gui.ZScreen) +NoteManager.ATTRS{ + focus_path='notes/note-manager', + note=DEFAULT_NIL, + on_update=DEFAULT_NIL, + on_dismiss=DEFAULT_NIL, +} + +function NoteManager:init() + self.note_pos = nil + local edit_mode = self.note ~= nil + + self:addviews{ + widgets.Window{ + frame={w=35,h=20}, + frame_inset={t=1}, + resizable=true, + frame_title='Note', + subviews={ + widgets.HotkeyLabel { + key='CUSTOM_ALT_N', + label='Name', + frame={l=0,t=0}, + auto_width=true, + on_activate=function() self.subviews.name:setFocus(true) end, + }, + widgets.TextArea{ + view_id='name', + frame={t=1,h=3}, + frame_style=gui.FRAME_INTERIOR, + init_text=self.note and self.note.point.name or '', + one_line_mode=true + }, + widgets.HotkeyLabel { + key='CUSTOM_ALT_C', + label='Comment', + frame={l=0,t=5}, + auto_width=true, + on_activate=function() self.subviews.comment:setFocus(true) end, + }, + widgets.TextArea{ + view_id='comment', + frame={t=6,b=3}, + frame_style=gui.FRAME_INTERIOR, + init_text=self.note and self.note.point.comment or '', + }, + widgets.Panel{ + view_id='buttons', + frame={b=0,h=1}, + frame_inset={l=1,r=1}, + subviews={ + widgets.HotkeyLabel{ + view_id='Save', + frame={l=0,t=0,h=1}, + auto_width=true, + label='Save', + key='CUSTOM_CTRL_ENTER', + visible=edit_mode, + on_activate=function() self:saveNote() end, + enabled=function() return #self.subviews.name:getText() > 0 end, + }, + widgets.HotkeyLabel{ + view_id='Create', + frame={l=0,t=0,h=1}, + auto_width=true, + label='Create', + key='CUSTOM_CTRL_ENTER', + visible=not edit_mode, + on_activate=function() self:createNote() end, + enabled=function() return #self.subviews.name:getText() > 0 end, + }, + widgets.HotkeyLabel{ + view_id='delete', + frame={r=0,t=0,h=1}, + auto_width=true, + label='Delete', + key='CUSTOM_CTRL_D', + visible=edit_mode, + on_activate=function() self:deleteNote() end, + }, + } + } + }, + }, + } +end + +function NoteManager:setNotePos(note_pos) + self.note_pos = note_pos +end + +function NoteManager:createNote() + local cursor_pos = self.note_pos or guidm.getCursorPos() + if cursor_pos == nil then + dfhack.printerr('Enable keyboard cursor to add a note.') + return + end + + local name = self.subviews.name:getText() + local comment = self.subviews.comment:getText() + + if #name == 0 then + dfhack.printerr('Note need at least a name') + return + end + + map_points:insert("#", { + new=true, + + id = waypoints.next_point_id, + tile=88, + fg_color=7, + bg_color=0, + name=name, + comment=comment, + pos=cursor_pos + }) + waypoints.next_point_id = waypoints.next_point_id + 1 + + if self.on_update then + self.on_update() + end + + self:dismiss() +end + +function NoteManager:saveNote() + if self.note == nil then + return + end + + local name = self.subviews.name:getText() + local comment = self.subviews.comment:getText() + + if #name == 0 then + dfhack.printerr('Note need at least a name') + return + end + + self.note.point.name = name + self.note.point.comment = comment + if self.note_pos then + self.note.pos=self.note_pos + end + + if self.on_update then + self.on_update() + end + + self:dismiss() +end + +function NoteManager:deleteNote() + if self.note == nil then + return + end + + for ind, map_point in pairs(map_points) do + if map_point.id == self.note.point.id then + map_points:erase(ind) + break + end + end + + if self.on_update then + self.on_update() + end + + self:dismiss() +end + +function NoteManager:onDismiss() + self.note = nil + if self.on_dismiss then + self:on_dismiss() + end +end diff --git a/internal/notify/notifications.lua b/internal/notify/notifications.lua new file mode 100644 index 0000000000..108fe92f97 --- /dev/null +++ b/internal/notify/notifications.lua @@ -0,0 +1,776 @@ +--@module = true + +local dlg = require('gui.dialogs') +local gui = require('gui') +local json = require('json') +local utils = require('utils') +local list_agreements = reqscript('list-agreements') +local repeat_util = require('repeat-util') +local stuck_squad = reqscript('fix/stuck-squad') +local warn_stranded = reqscript('warn-stranded') + +local CONFIG_FILE = 'dfhack-config/notify.json' + +local buildings = df.global.world.buildings +local caravans = df.global.plotinfo.caravans +local units = df.global.world.units + +-- TODO: Add a proper API and UI for notification configuration +-- this is global so one can use `:lua reqscript('internal/notify/notifications').save_time_threshold_mins=X` to change the threshold to X mins. +save_time_threshold_mins = save_time_threshold_mins or 15 + +function for_iter(vec, match_fn, action_fn, reverse) + local offset = type(vec) == 'table' and 1 or 0 + local idx1 = reverse and #vec-1+offset or offset + local idx2 = reverse and offset or #vec-1+offset + local step = reverse and -1 or 1 + for idx=idx1,idx2,step do + local elem = vec[idx] + if match_fn(elem) then + if action_fn(elem) then return end + end + end +end + +local function get_active_depot() + for _, bld in ipairs(buildings.other.TRADE_DEPOT) do + if bld:getBuildStage() == bld:getMaxBuildStage() and + (#bld.jobs == 0 or bld.jobs[0].job_type ~= df.job_type.DestroyBuilding) and + #bld.contained_items > 0 and not bld.contained_items[0].item.flags.forbid + then + return bld + end + end +end + +local function is_adv_unhidden(unit) + local flags = dfhack.maps.getTileFlags(dfhack.units.getPosition(unit)) + return flags and not flags.hidden and flags.pile +end + +local function for_agitated_creature(fn, reverse) + for_iter(units.active, function(unit) + return not dfhack.units.isDead(unit) and + dfhack.units.isActive(unit) and + not unit.flags1.caged and + not unit.flags1.chained and + dfhack.units.isAgitated(unit) + end, fn, reverse) +end + +local function for_invader(fn, reverse) + for_iter(units.active, function(unit) + return not dfhack.units.isDead(unit) and + dfhack.units.isActive(unit) and + not unit.flags1.caged and + not unit.flags1.chained and + dfhack.units.isInvader(unit) and + not dfhack.units.isHidden(unit) + end, fn, reverse) +end + +local function for_hostile(fn, reverse) + for_iter(units.active, function(unit) + return not dfhack.units.isDead(unit) and + dfhack.units.isActive(unit) and + not unit.flags1.caged and + not unit.flags1.chained and + not dfhack.units.isInvader(unit) and + not dfhack.units.isFortControlled(unit) and + not dfhack.units.isHidden(unit) and + not dfhack.units.isAgitated(unit) and + dfhack.units.isDanger(unit) + end, fn, reverse) +end + +local function is_in_dire_need(unit) + return unit.counters2.hunger_timer > 75000 or + unit.counters2.thirst_timer > 50000 or + unit.counters2.sleepiness_timer > 150000 +end + +local function for_starving(fn, reverse) + for_iter(units.active, function(unit) + return not dfhack.units.isDead(unit) and + dfhack.units.isActive(unit) and + dfhack.units.isSane(unit) and + dfhack.units.isFortControlled(unit) and + is_in_dire_need(unit) + end, fn, reverse) +end + +local function for_moody(fn, reverse) + for_iter(dfhack.units.getCitizens(true), function(unit) + local job = unit.job.current_job + return job and df.job_type_class[df.job_type.attrs[job.job_type].type] == 'StrangeMood' + end, fn, reverse) +end + +local function is_stealer(unit) + local casteFlags = dfhack.units.getCasteRaw(unit).flags + if casteFlags.CURIOUS_BEAST_EATER or + casteFlags.CURIOUS_BEAST_GUZZLER or + casteFlags.CURIOUS_BEAST_ITEM + then + return true + end +end + +local function for_nuisance(fn, reverse) + for_iter(units.active, function(unit) + return not dfhack.units.isDead(unit) and + dfhack.units.isActive(unit) and + (is_stealer(unit) or dfhack.units.isMischievous(unit)) and + not unit.flags1.caged and + not unit.flags1.chained and + not dfhack.units.isHidden(unit) and + not dfhack.units.isFortControlled(unit) and + not dfhack.units.isInvader(unit) and + not dfhack.units.isAgitated(unit) and + not dfhack.units.isDanger(unit) + end, fn, reverse) +end + +local function for_wildlife(fn, reverse) + for_iter(units.active, function(unit) + return not dfhack.units.isDead(unit) and + dfhack.units.isActive(unit) and + dfhack.units.isWildlife(unit) and + not unit.flags1.caged and + not unit.flags1.chained and + not dfhack.units.isHidden(unit) and + not dfhack.units.isDanger(unit) and + not is_stealer(unit) and + not dfhack.units.isMischievous(unit) and + not dfhack.units.isVisitor(unit) + end, fn, reverse) +end + +local function for_wildlife_adv(fn, reverse) + local adv_id = dfhack.world.getAdventurer().id + for_iter(units.active, function(unit) + return not dfhack.units.isDead(unit) and + dfhack.units.isActive(unit) and + dfhack.units.isWildlife(unit) and + not unit.flags1.caged and + not unit.flags1.chained and + not dfhack.units.isHidden(unit) and + unit.relationship_ids.GroupLeader ~= adv_id and + unit.relationship_ids.PetOwner ~= adv_id and + is_adv_unhidden(unit) + end, fn, reverse) +end + +local function for_injured(fn, reverse) + for_iter(dfhack.units.getCitizens(true), function(unit) + return unit.health and unit.health.flags.needs_healthcare + end, fn, reverse) +end + +function count_units(for_fn, which) + local count = 0 + for_fn(function() count = count + 1 end) + if count > 0 then + return ('%d %s%s'):format( + count, + which, + count == 1 and '' or 's' + ) + end +end + +local function has_functional_hospital(site) + for _,loc in ipairs(site.buildings) do + if not df.abstract_building_hospitalst:is_instance(loc) or loc.flags.DOES_NOT_EXIST then + goto continue + end + local diag, bone, surg = false, false, false + for _,occ in ipairs(loc.occupations) do + if df.unit.find(occ.unit_id) then + if occ.type == df.occupation_type.DOCTOR or occ.type == df.occupation_type.DIAGNOSTICIAN then + diag = true + end + if occ.type == df.occupation_type.DOCTOR or occ.type == df.occupation_type.BONE_DOCTOR then + bone = true + end + if occ.type == df.occupation_type.DOCTOR or occ.type == df.occupation_type.SURGEON then + surg = true + end + end + end + if diag and bone and surg then + return true + end + ::continue:: + end +end + +local function injured_units(for_fn, which) + local message = count_units(for_fn, which) + if message then + if not has_functional_hospital(dfhack.world.getCurrentSite()) then + message = message .. '; no functional hospital!' + end + return message + end +end + +local function summarize_units(for_fn) + local counts = {} + for_fn(function(unit) + local names = dfhack.units.getCasteRaw(unit).caste_name + local record = ensure_key(counts, names[0], {count=0, plural=names[1]}) + record.count = record.count + 1 + end) + if not next(counts) then return end + local strs = {} + for singular,record in pairs(counts) do + table.insert(strs, ('%d %s'):format(record.count, record.count > 1 and record.plural or singular)) + end + return ('Wildlife: %s'):format(table.concat(strs, ', ')) +end + +function zoom_to_next(for_fn, state, reverse) + local first_found, ret + for_fn(function(unit) + if not first_found then + first_found = unit + end + if not state then + dfhack.gui.revealInDwarfmodeMap( + xyz2pos(dfhack.units.getPosition(unit)), true, true) + ret = unit.id + return true + elseif unit.id == state then + state = nil + end + end, reverse) + if ret then return ret end + if first_found then + dfhack.gui.revealInDwarfmodeMap( + xyz2pos(dfhack.units.getPosition(first_found)), true, true) + return first_found.id + end +end + +local function get_stranded_message() + local count = #warn_stranded.getStrandedGroups() + if count > 0 then + return ('%d group%s of citizens %s stranded'):format( + count, + count == 1 and '' or 's', + count == 1 and 'is' or 'are' + ) + end +end + +local function get_blood() + return dfhack.world.getAdventurer().body.blood_count +end + +local function get_max_blood() + return dfhack.world.getAdventurer().body.blood_max +end + +local function get_max_breath() + local adventurer = dfhack.world.getAdventurer() + local toughness = dfhack.units.getPhysicalAttrValue(adventurer, df.physical_attribute_type.TOUGHNESS) + local endurance = dfhack.units.getPhysicalAttrValue(adventurer, df.physical_attribute_type.ENDURANCE) + local base_ticks = 200 + + return math.floor((endurance + toughness) / 4) + base_ticks +end + +local function get_breath() + return get_max_breath() - dfhack.world.getAdventurer().counters.suffocation +end + +local function get_bar(get_fn, get_max_fn, text, color) + if get_fn() < get_max_fn() then + local label_text = {} + table.insert(label_text, {text=text, pen=color, width=6}) + + local bar_width = 16 + local percentage = get_fn() / get_max_fn() + local barstop = math.floor((bar_width * percentage) + 0.5) + for idx = 0, bar_width-1 do + local bar_color = color + local char = 219 + if idx >= barstop then + -- offset it to the hollow graphic + bar_color = COLOR_DARKGRAY + char = 177 + end + table.insert(label_text, {width=1, tile={ch=char, fg=bar_color}}) + end + return label_text + end + return nil +end + +local function get_save_alert() + local mins_since_save = dfhack.persistent.getUnsavedSeconds()//60 + local pen = COLOR_LIGHTCYAN + if mins_since_save < save_time_threshold_mins then return end + if mins_since_save >= 4*save_time_threshold_mins then + pen = COLOR_LIGHTRED + elseif mins_since_save >= 2*save_time_threshold_mins then + pen = COLOR_YELLOW + end + return { + {text='Last save: ', pen=COLOR_WHITE}, + {text=dfhack.formatInt(mins_since_save) ..' mins ago', pen=pen}, + } +end + +local function save_popup() + local mins_since_save = dfhack.persistent.getUnsavedSeconds()//60 + local message = 'It has been ' .. dfhack.formatInt(mins_since_save) .. ' minutes since your last save.' + if dfhack.world.isFortressMode() then + message = message .. '\n\nWould you like to save now? (Note: You can also close this reminder and save manually)' + dlg.showYesNoPrompt('Save now?', message, nil, function() dfhack.run_script('quicksave') end) + else + message = message .. '\n\nClose this popup to open the options menu and select "Save and continue playing"' + dlg.showMessage('Save reminder', message, COLOR_WHITE, function() gui.simulateInput(dfhack.gui.getDFViewscreen(true), 'OPTIONS') end) + end +end + +---@return df.unit.id[] +local function _get_active_unit_ids_with_missing_nemesis_records() + local list = {} + for _, unit in ipairs(units.active) do + local ref = dfhack.units.getGeneralRef(unit, df.general_ref_type.IS_NEMESIS) + if ref then + local nrec = ref:getNemesis() + if nrec == nil then + table.insert(list, unit.id) + end + end + end + return list +end + +---@generic T +---@param vector `T`[] # a df vector or array, or a Lua list (not tested yet). +---@param field string? # nil, or the field name to sort on. +---@param comparator fun(a:T, b:T):integer|nil +--- # an optional comparator that returns -1,0,1 per utils.compare_* . +--- # nil falls back to utils.compare or utils.compare_field. +--- # if a comparator is given, the field parameter is ignored. +---@return boolean +local function verify_vector_is_sorted(vector, field, comparator) + assert(type(vector) == 'table' or utils.is_container(vector)) + assert(type(field) == 'string' or field == nil) + assert(type(comparator) == 'function' or comparator == nil) + comparator = comparator or utils.compare_field(field) + local lo, hi + if type(vector) == 'table' then + lo, hi = 1, #vector + else + lo, hi = 0, #vector-1 + end + local sorted = true + for i = lo, hi-1 do + if comparator(vector[i], vector[i+1]) ~= -1 then + sorted = false + break + end + end + return sorted +end + +local Cache_nemesis_all = defclass(Cache_nemesis_all, nil) -- singleton, don't need to instantiate. +Cache_nemesis_all.ATTRS{} + +function Cache_nemesis_all:invalidate() + self.ATTRS.cached_on_year = -1 +end + +---@param force boolean? # true to force updating the cached data +function Cache_nemesis_all:populate(force) + local recheck_after = (self.ATTRS.cached_on_tick or 0) + 1200 + if force ~= true + and dfhack.world.ReadCurrentYear() == self.ATTRS.cached_on_year + and dfhack.world.ReadCurrentTick() < recheck_after + and self.ATTRS.nemesis_all_length == #df.global.world.nemesis.all + and self.ATTRS.units_active_length == #df.global.world.units.active + then + return + end + self.ATTRS.cached_on_year = dfhack.world.ReadCurrentYear() + self.ATTRS.cached_on_tick = dfhack.world.ReadCurrentTick() + self.ATTRS.nemesis_all_length = #df.global.world.nemesis.all + self.ATTRS.units_active_length = #df.global.world.units.active + self.ATTRS.nemesis_all_is_sorted = verify_vector_is_sorted(df.global.world.nemesis.all, 'id') + self.ATTRS.affected_unit_ids = _get_active_unit_ids_with_missing_nemesis_records() +end + +function Cache_nemesis_all:init() + self:invalidate() +end + +---@return boolean +function Cache_nemesis_all:is_sorted() + self:populate() + return self.ATTRS.nemesis_all_is_sorted +end + +---@return df.unit[] +function Cache_nemesis_all:get_affected_unit_ids() + self:populate() + return self.ATTRS.affected_unit_ids +end + +-- the order of this list controls the order the notifications will appear in the overlay +NOTIFICATIONS_BY_IDX = { + { + name='missing_nemesis', + desc='Reports missing nemesis records, indicating savegame corruption.', + default=true, + fn = function() + if not Cache_nemesis_all:is_sorted() then + return { { + pen = COLOR_LIGHTRED, + text = 'nemesis vector not sorted' + } } + end + local count = df.global.nemesis_next_id - #df.global.world.nemesis.all + if count == 0 then return end + return { { + pen = #Cache_nemesis_all:get_affected_unit_ids() > 0 + and COLOR_LIGHTRED or COLOR_YELLOW, + text = ('missing %d nemesis record%s'):format(count, count == 1 and '' or 's') + } } + end, + on_click=function() + if not Cache_nemesis_all:is_sorted() then + local message = + 'This save game is corrupt.\n\nThe world.nemesis.global vector\n' .. + 'of this savegame is not sorted.\n\nSome attempts to lookup the\n' .. + 'nemesis record for a unit or\nhistorical figure will fail.\n\n' .. + 'This should be reported to\nBay 12 Games as a bug.\n' + dlg.showMessage('nemesis vector not sorted', message, COLOR_RED) + return + end + local list = Cache_nemesis_all:get_affected_unit_ids() + local message = { + { pen = COLOR_RED, text = 'This save game may be corrupt.' }, NEWLINE, + NEWLINE, + { pen = COLOR_WHITE, text = 'This save game contains units which are missing' }, NEWLINE, + { pen = COLOR_WHITE, text = 'their assigned nemesis records.' }, NEWLINE, + NEWLINE, + { pen = COLOR_WHITE, text = 'Missing nemesis records have been known to cause' }, NEWLINE, + { pen = COLOR_WHITE, text = 'crashes during game save and when retiring forts.' }, NEWLINE, + NEWLINE, + { pen = COLOR_WHITE, text = 'Units with missing nemesis records will' }, NEWLINE, + { pen = #list > 0 and COLOR_RED or COLOR_WHITE, + text = 'permanently disappear' }, + { pen = COLOR_WHITE, text = ' if they leave the map or' }, NEWLINE, + { pen = COLOR_WHITE, text = 'if the fort is retired.' }, NEWLINE, + NEWLINE, + } + if #list > 0 then + table.insert(message, { pen = COLOR_RED, + text = 'These units on the map are missing their nemesis records:' }) + table.insert(message, NEWLINE) + for _, unit_id in ipairs(list) do + local unit = df.unit.find(unit_id) + local text = unit and dfhack.units.getReadableName(unit) + or "missing unit for unit id " .. unit_id + if #text > 55 then text = dfhack.units.getReadableName(unit, true); end + table.insert(message, { pen = COLOR_LIGHTRED, text = text }) + table.insert(message, NEWLINE) + end + else + table.insert(message, { pen = COLOR_YELLOW, + text = 'No units on the map are missing their nemesis records.' }) + table.insert(message, NEWLINE) + end + dlg.showMessage((#list > 0 and 'Units on the map are' or 'This world is') + .. ' missing nemesis records', message, COLOR_WHITE) + end, + }, + { + name='stuck_squad', + desc='Notifies when a squad is stuck on the world map.', + default=true, + dwarf_fn=function() + local stuck_armies, outbound_army, returning_army = stuck_squad.scan_fort_armies() + if #stuck_armies == 0 then return end + if repeat_util.isScheduled('control-panel/fix/stuck-squad') and (outbound_army or returning_army) then + return + end + return ('%d squad%s need%s rescue'):format( + #stuck_armies, + #stuck_armies == 1 and '' or 's', + #stuck_armies == 1 and 's' or '' + ) + end, + on_click=function() + local message = 'A squad is lost on the world map and needs rescue!\n\n' .. + 'Please send a messenger to a holding or a squad out on a mission\n' .. + 'that will return to the fort (e.g. a Demand one-time tribute mission,\n' .. + 'but not a Conquer and occupy mission). They will rescue the stuck\n' .. + 'squad on their way home.' + if not repeat_util.isScheduled('control-panel/fix/stuck-squad') then + message = message .. '\n\n' .. + 'Please enable fix/stuck-squad in the DFHack control panel to enable\n'.. + 'missions to rescue stuck squads.' + end + dlg.showMessage('Rescue stuck squads', message, COLOR_WHITE) + end, + }, + { + name='auto_train', + desc='Notifies when there are no squads set up for training', + default=true, + dwarf_fn=function() + local at = reqscript('autotraining') + if (at.isEnabled() and at.checkSquads() == nil) then + return {{text="autotraining: no squads selected",pen=COLOR_LIGHTRED}} + end + end, + on_click=function() + local message = + "You have no squads selected for training.\n".. + "You should have a squad set up to be constantly training with about 8 units needed for training.\n".. + "Then you can select that squad for training in the config.\n\nWould you like to open the config? Alternatively, simply close this popup to go create a squad." + dlg.showYesNoPrompt('Training Squads not configured', message, COLOR_WHITE, function () dfhack.run_command('gui/autotraining') end) + end, + }, + { + name='traders_ready', + desc='Notifies when traders are ready to trade at the depot.', + default=true, + dwarf_fn=function() + if #caravans == 0 then return end + local num_ready = 0 + for _, car in ipairs(caravans) do + if car.trade_state ~= df.caravan_state.T_trade_state.AtDepot then + goto skip + end + local car_civ = car.entity + for _, unit in ipairs(df.global.world.units.active) do + if unit.civ_id ~= car_civ or not dfhack.units.isMerchant(unit) then + goto continue + end + for _, inv_item in ipairs(unit.inventory) do + if inv_item.item.flags.trader then + goto skip + end + end + ::continue:: + end + num_ready = num_ready + 1 + ::skip:: + end + if num_ready > 0 then + return ('%d trader%s %s ready to trade'):format( + num_ready, + num_ready == 1 and '' or 's', + num_ready == 1 and 'is' or 'are' + ) + end + end, + on_click=function() + local bld = get_active_depot() + if bld then + dfhack.gui.revealInDwarfmodeMap( + xyz2pos(bld.centerx, bld.centery, bld.z), true, true) + end + end, + }, + { + name='mandates_expiring', + desc='Notifies when a production mandate is within 1 month of expiring.', + default=true, + dwarf_fn=function() + local count = 0 + for _, mandate in ipairs(df.global.world.mandates.all) do + if mandate.mode == df.mandate_type.Make and + mandate.timeout_limit - mandate.timeout_counter < 2500 + then + count = count + 1 + end + end + if count > 0 then + return ('%d production mandate%s near deadline'):format( + count, + count == 1 and '' or 's' + ) + end + end, + on_click=function() + gui.simulateInput(dfhack.gui.getDFViewscreen(), 'D_NOBLES') + end, + }, + { + name='petitions_agreed', + desc='Notifies when you have agreed to build (but have not yet built) a guildhall or temple.', + default=true, + dwarf_fn=function() + local t_agr, g_agr = list_agreements.get_fort_agreements(true) + local sum = #t_agr + #g_agr + if sum > 0 then + return ('%d petition%s outstanding'):format( + sum, sum == 1 and '' or 's') + end + end, + on_click=function() dfhack.run_script('gui/petitions') end, + }, + { + name='moody_status', + desc='Describes the status of the current moody dwarf: gathering materials, working, or stuck', + default=true, + dwarf_fn=function() + local message + for_moody(function(unit) + local job = unit.job.current_job + local bld = dfhack.job.getHolder(job) + if not bld then + if dfhack.buildings.findAtTile(unit.path.dest) then + message = 'moody dwarf is claiming a workshop' + else + message = {{text='moody dwarf can\'t find needed workshop!', pen=COLOR_LIGHTRED}} + end + elseif job.flags.fetching or job.flags.bringing or + unit.path.goal == df.unit_path_goal.None + then + message = 'moody dwarf is gathering items' + elseif job.flags.working then + message = 'moody dwarf is working' + else + message = {{text='moody dwarf can\'t find needed item!', pen=COLOR_LIGHTRED}} + end + return true + end) + return message + end, + on_click=curry(zoom_to_next, for_moody), + }, + { + name='warn_starving', + desc='Reports units that are dangerously hungry, thirsty, or drowsy.', + default=true, + dwarf_fn=curry(count_units, for_starving, 'starving, dehydrated, or drowsy unit'), + on_click=curry(zoom_to_next, for_starving), + }, + { + name='agitated_count', + desc='Notifies when there are agitated animals on the map.', + default=true, + dwarf_fn=curry(count_units, for_agitated_creature, 'agitated animal'), + on_click=curry(zoom_to_next, for_agitated_creature), + }, + { + name='invader_count', + desc='Notifies when there are active invaders on the map.', + default=true, + dwarf_fn=curry(count_units, for_invader, 'invader'), + on_click=curry(zoom_to_next, for_invader), + }, + { + name='hostile_count', + desc='Notifies when there are non-invader hostiles (e.g. megabeasts) on the map.', + default=true, + dwarf_fn=curry(count_units, for_hostile, 'hostile'), + on_click=curry(zoom_to_next, for_hostile), + }, + { + name='warn_nuisance', + desc='Notifies when thieving or mischievous creatures are on the map.', + default=true, + dwarf_fn=curry(count_units, for_nuisance, 'thieving or mischievous creature'), + on_click=curry(zoom_to_next, for_nuisance), + }, + { + name='warn_stranded', + desc='Notifies when units are stranded from the main group.', + default=true, + dwarf_fn=get_stranded_message, + on_click=function() dfhack.run_script('warn-stranded') end, + }, + { + name='wildlife', + desc='Gives a summary of visible wildlife on the map.', + default=false, + dwarf_fn=curry(summarize_units, for_wildlife), + on_click=curry(zoom_to_next, for_wildlife), + }, + { + name='wildlife_adv', + desc='Gives a summary of visible wildlife on the map.', + default=false, + adv_fn=curry(summarize_units, for_wildlife_adv), + on_click=curry(zoom_to_next, for_wildlife_adv), + }, + { + name='injured', + desc='Shows number of injured citizens and a warning if there is no functional hospital.', + default=true, + dwarf_fn=curry(injured_units, for_injured, 'injured citizen'), + on_click=curry(zoom_to_next, for_injured), + }, + { + name='suffocation_adv', + desc='Shows a suffocation bar when you are drowning or breathless.', + default=true, + critical=true, + adv_fn=curry(get_bar, get_breath, get_max_breath, "Air", COLOR_LIGHTCYAN), + on_click=nil, + }, + { + name='bleeding_adv', + desc='Shows a bleeding bar when you are losing blood.', + default=true, + critical=true, + adv_fn=curry(get_bar, get_blood, get_max_blood, "Blood", COLOR_RED), + on_click=nil, + }, + { + name='save-reminder', + desc=('Shows a reminder if it has been more than %d minute%s since your last save.'):format(save_time_threshold_mins, save_time_threshold_mins == 1 and '' or 's'), + default=true, + fn=get_save_alert, + on_click=save_popup, + }, +} + +NOTIFICATIONS_BY_NAME = {} +for _, v in ipairs(NOTIFICATIONS_BY_IDX) do + NOTIFICATIONS_BY_NAME[v.name] = v +end + +local function get_config() + local f = json.open(CONFIG_FILE) + local updated = false + if f.exists then + -- remove unknown or out of date entries from the loaded config + for k, v in pairs(f.data) do + if not NOTIFICATIONS_BY_NAME[k] or NOTIFICATIONS_BY_NAME[k].version ~= v.version then + updated = true + f.data[k] = nil + end + end + end + for k, v in pairs(NOTIFICATIONS_BY_NAME) do + if not f.data[k] or f.data[k].version ~= v.version then + f.data[k] = {enabled=v.default, version=v.version} + updated = true + end + end + if updated then + f:write() + end + return f +end + +config = get_config() + +dfhack.onStateChange['internal/notify/notifications'] = function(event) + if event == SC_WORLD_LOADED or event == SC_WORLD_UNLOADED + or event == SC_MAP_LOADED or event == SC_MAP_UNLOADED + then + Cache_nemesis_all:invalidate() + end +end diff --git a/internal/quickfort/aliases.lua b/internal/quickfort/aliases.lua index 7f09922c7a..27f63d11e7 100644 --- a/internal/quickfort/aliases.lua +++ b/internal/quickfort/aliases.lua @@ -11,7 +11,7 @@ local quickfort_reader = reqscript('internal/quickfort/reader') local log = quickfort_common.log -common_aliases_filename = 'hack/data/quickfort/aliases-common.txt' +common_aliases_filename = dfhack.getHackPath()..'/data/quickfort/aliases-common.txt' user_aliases_filename = 'dfhack-config/quickfort/aliases.txt' -- special keycode shortcuts inherited from python quickfort. diff --git a/internal/quickfort/api.lua b/internal/quickfort/api.lua index 0608cc3a41..96d8ff6035 100644 --- a/internal/quickfort/api.lua +++ b/internal/quickfort/api.lua @@ -6,11 +6,7 @@ if not dfhack_flags.module then end require('dfhack.buildings') -- loads additional functions into dfhack.buildings -local utils = require('utils') local quickfort_command = reqscript('internal/quickfort/command') -local quickfort_common = reqscript('internal/quickfort/common') -local quickfort_building = reqscript('internal/quickfort/building') -local quickfort_map = reqscript('internal/quickfort/map') local quickfort_parse = reqscript('internal/quickfort/parse') function normalize_data(data, pos) diff --git a/internal/quickfort/build.lua b/internal/quickfort/build.lua index 3d09c0f53f..2d51475de7 100644 --- a/internal/quickfort/build.lua +++ b/internal/quickfort/build.lua @@ -6,9 +6,7 @@ buildings (e.g. beds have to be inside, doors have to be adjacent to a wall, etc.). A notable exception is that we allow constructions and machine components to be designated regardless of whether they are reachable or currently supported. This allows the user to designate an entire floor of an above-ground -building or an entire power system without micromanagement. We also don't -enforce that materials are accessible from the designation location. That is -something that the player can manage. +building or an entire power system without micromanagement. ]] if not dfhack_flags.module then @@ -16,7 +14,7 @@ if not dfhack_flags.module then end local argparse = require('argparse') -local utils = require('utils') +local orders = require('plugins.orders') local quickfort_common = reqscript('internal/quickfort/common') local quickfort_building = reqscript('internal/quickfort/building') local quickfort_orders = reqscript('internal/quickfort/orders') @@ -24,6 +22,7 @@ local quickfort_parse = reqscript('internal/quickfort/parse') local quickfort_place = reqscript('internal/quickfort/place') local quickfort_transform = reqscript('internal/quickfort/transform') local stockpiles = require('plugins.stockpiles') +local utils = require('utils') local ok, buildingplan = pcall(require, 'plugins.buildingplan') if not ok then @@ -39,7 +38,12 @@ local log = quickfort_common.log local function is_valid_tile_base(pos) local flags, occupancy = dfhack.maps.getTileFlags(pos) if not flags then return false end - return not flags.hidden and occupancy.building == 0 + if (flags.liquid_type == true or flags.liquid_type == df.tile_liquid.Magma) and + flags.flow_size >= 1 + then + return false + end + return not flags.hidden and flags.flow_size <= 1 and occupancy.building == 0 end local function is_valid_tile_generic(pos) @@ -64,7 +68,8 @@ local function is_valid_tile_dirt(pos) local mat = tileattrs.material local bad_shape = shape == df.tiletype_shape.BOULDER or - shape == df.tiletype_shape.PEBBLES + shape == df.tiletype_shape.PEBBLES or + shape == df.tiletype_shape.WALL local good_material = mat == df.tiletype_material.SOIL or mat == df.tiletype_material.GRASS_LIGHT or @@ -72,7 +77,30 @@ local function is_valid_tile_dirt(pos) mat == df.tiletype_material.GRASS_DRY or mat == df.tiletype_material.GRASS_DEAD or mat == df.tiletype_material.PLANT - return is_valid_tile_generic(pos) and not bad_shape and good_material + return good_material and not bad_shape and is_valid_tile_generic(pos) +end + +local function is_floor(pos) + local tt = dfhack.maps.getTileType(pos) + if not tt then return false end + local shape = df.tiletype.attrs[tt].shape + return df.tiletype_shape.attrs[shape].basic_shape == df.tiletype_shape_basic.Floor +end + +local function has_mud(pos) + local block = dfhack.maps.getTileBlock(pos) + for _, bev in ipairs(block.block_events) do + if bev:getType() ~= df.block_square_event_type.material_spatter then goto continue end + if bev.mat_type == df.builtin_mats.MUD and bev.mat_state == df.matter_state.Solid then + return true + end + ::continue:: + end + return false +end + +local function is_valid_tile_farm(pos) + return is_valid_tile_dirt(pos) or (is_floor(pos) and has_mud(pos)) end -- essentially, anywhere you could build a construction, plus constructed floors @@ -111,7 +139,7 @@ local function is_valid_tile_bridge(pos, db_entry, b) (dir == T_direction.Right and pos.x == b.pos.x+b.width-1) then return is_valid_tile_has_space(pos) end - return is_valid_tile_has_space_or_is_ramp(pos) + return is_valid_tile_machine(pos) end -- although vanilla allows constructions to be built on top of constructed @@ -156,10 +184,10 @@ local function is_tile_generic_and_wall_adjacent(pos) end local function is_tile_floor_adjacent(pos) - return is_valid_tile_generic(xyz2pos(pos.x+1, pos.y, pos.z)) or - is_valid_tile_generic(xyz2pos(pos.x-1, pos.y, pos.z)) or - is_valid_tile_generic(xyz2pos(pos.x, pos.y+1, pos.z)) or - is_valid_tile_generic(xyz2pos(pos.x, pos.y-1, pos.z)) + return is_floor(xyz2pos(pos.x+1, pos.y, pos.z)) or + is_floor(xyz2pos(pos.x-1, pos.y, pos.z)) or + is_floor(xyz2pos(pos.x, pos.y+1, pos.z)) or + is_floor(xyz2pos(pos.x, pos.y-1, pos.z)) end -- for wells @@ -185,7 +213,7 @@ local function is_tile_coverable(pos) shape ~= df.tiletype_shape.STAIR_DOWN) then return false end - return is_tile_floor_adjacent(pos) + return true end -- @@ -235,6 +263,50 @@ local function do_farm_props(db_entry, props) end end +local LABOR_MAP = {} +for idx, name in ipairs(df.unit_labor) do + local caption = df.unit_labor.attrs[idx].caption + if caption then + LABOR_MAP[name:lower()] = idx + LABOR_MAP[caption:lower()] = idx + end +end + +local RATING_MAP = {} +for idx, name in ipairs(df.skill_rating) do + local caption = df.skill_rating.attrs[idx].caption + if caption then + RATING_MAP[name:lower()] = idx + RATING_MAP[caption:lower()] = idx + end +end + +local function parse_profile_prop(map, prop) + return map[prop:lower()] +end + +local function parse_labor_prop(prop) + local ret = {} + for _, spec in ipairs(argparse.stringList(prop)) do + local val = parse_profile_prop(LABOR_MAP, spec) + if val then + ret[val] = true + else + dfhack.printerr(('unknown labor type: "%s"'):format(spec)) + end + end + return ret +end + +local function make_labor_profile(db_entry, labors, is_mask) + local blocked_labors = {resize=false} + for _, name in ipairs(orders.get_profile_labors(db_entry.type, db_entry.subtype)) do + local is_specified = labors[df.unit_labor[name]] + blocked_labors[name] = (is_specified and is_mask) or (not is_specified and not is_mask) + end + return blocked_labors +end + local function do_workshop_furnace_props(db_entry, props) if props.take_from then db_entry.links.take_from = argparse.stringList(props.take_from) @@ -248,6 +320,32 @@ local function do_workshop_furnace_props(db_entry, props) ensure_key(db_entry.props, 'profile').max_general_orders = math.max(0, math.min(10, tonumber(props.max_general_orders))) props.max_general_orders = nil end + if props.labor_mask then + local labors = parse_labor_prop(props.labor_mask) + ensure_key(db_entry.props, 'profile').blocked_labors = make_labor_profile(db_entry, labors, true) + props.labor_mask = nil + end + if props.labor then + local labors = parse_labor_prop(props.labor) + ensure_key(db_entry.props, 'profile').blocked_labors = make_labor_profile(db_entry, labors, false) + props.labor = nil + end + if props.min_skill then + local val = parse_profile_prop(RATING_MAP, props.min_skill) + ensure_key(db_entry.props, 'profile').min_level = val + props.min_skill = nil + end + if props.max_skill then + local val = parse_profile_prop(RATING_MAP, props.max_skill) + local profile = ensure_key(db_entry.props, 'profile') + profile.max_level = val + if profile.max_level and profile.min_level then + if profile.max_level < profile.min_level then + profile.max_level = profile.min_level + end + end + props.max_skill = nil + end end local function do_roller_props(db_entry, props) @@ -265,7 +363,7 @@ local function do_trackstop_props(db_entry, props) (props.friction == '50000' or props.friction == '10000' or props.friction == '500' or props.friction == '50' or props.friction == '10') then - db_entry.props.friction = tonumber(props.friction) + ensure_key(db_entry.props, 'track_stop_info').friction = tonumber(props.friction) props.friction = nil end if props.take_from then @@ -311,10 +409,17 @@ local function add_stop(name, pos, adjustments) id=stop_id, pos=pos, }) - stockpiles.import_route('library/everything', route.id, stop_id, 'set') + local opts = { + route_id=route.id, + stop_id=stop_id, + mode='set', + } + stockpiles.import_settings('library/everything', opts) for _, adj in ipairs(adjustments) do log('applying stockpile preset: %s %s', adj.mode, adj.name) - stockpiles.import_route(adj.name, route.id, stop_id, adj.mode, adj.filters) + opts.mode = adj.mode + opts.filters = adj.filters + stockpiles.import_settings(adj.name, opts) end return route.stops[#route.stops-1] end @@ -544,13 +649,13 @@ local function make_transform_trackstop_fn(vector, friction) return make_transform_building_fn(vector, trackstop_revmap, post_fn) end local function make_trackstop_entry(direction, friction) - local label, fields, transform = 'No Dump', {friction=friction}, nil + local label, fields, transform = 'No Dump', {track_stop_info={friction=friction}}, nil if direction then - fields.use_dump = 1 + ensure_key(fields.track_stop_info, 'track_flags').use_dump = true for k,v in pairs(direction) do local trackstop_data_entry = trackstop_data[k][v] label = trackstop_data_entry.label - fields[k] = v + fields.track_stop_info[k] = v transform = make_transform_trackstop_fn( trackstop_data_entry.vector, friction) end @@ -714,8 +819,7 @@ local building_db_raw = { Mrsssqq=make_roller_entry(df.screw_pump_direction.FromWest, 30000), Mrsssqqq=make_roller_entry(df.screw_pump_direction.FromWest, 20000), Mrsssqqqq=make_roller_entry(df.screw_pump_direction.FromWest, 10000), - -- Instruments are not yet supported by DFHack - -- I={label='Instrument', type=df.building_type.Instrument}, + I={label='Instrument', type=df.building_type.Instrument}, S={label='Support', type=df.building_type.Support, is_valid_tile_fn=is_valid_tile_has_space}, m={label='Animal Trap', type=df.building_type.AnimalTrap}, @@ -740,8 +844,9 @@ local building_db_raw = { p={label='Farm Plot', type=df.building_type.FarmPlot, has_extents=true, no_extents_if_solid=true, - is_valid_tile_fn=is_valid_tile_dirt, + is_valid_tile_fn=is_valid_tile_farm, is_valid_extent_fn=is_extent_nonempty, + ignore_extent_errors=true, props_fn=do_farm_props}, o={label='Paved Road', type=df.building_type.RoadPaved, has_extents=true, @@ -829,9 +934,13 @@ local building_db_raw = { subtype=df.siegeengine_type.Ballista}, ic={label='Catapult', type=df.building_type.SiegeEngine, subtype=df.siegeengine_type.Catapult}, + it={label='Bolt Thrower', type=df.building_type.SiegeEngine, + subtype=df.siegeengine_type.BoltThrower}, -- constructions Cw={label='Wall', type=df.building_type.Construction, subtype=df.construction_type.Wall}, + CW={label='Reinforced Wall', + type=df.building_type.Construction, subtype=df.construction_type.ReinforcedWall}, Cf={label='Floor', type=df.building_type.Construction, subtype=df.construction_type.Floor}, Cr={label='Ramp', @@ -1110,6 +1219,10 @@ local function custom_building(_, keys) db_entry.props.name = props.name props.name = nil end + if props.do_now then + db_entry.do_now = true + props.do_now = nil + end if db_entry.props_fn then db_entry:props_fn(props) end for k,v in pairs(props) do dfhack.printerr(('unhandled property: "%s"="%s"'):format(k, v)) @@ -1148,7 +1261,7 @@ local function create_building(b, cache, dry_run) pos=b.pos, width=b.width, height=b.height, direction=db_entry.direction, fields=fields} if not bld then - -- this is an error instead of a qerror since our validity checking + -- this is an error instead of just a message since our validity checking -- is supposed to prevent this from ever happening error(string.format('unable to place %s: %s', db_entry.label, err)) end @@ -1192,6 +1305,12 @@ local function create_building(b, cache, dry_run) db_entry.custom or -1) then log('registering %s with buildingplan', db_entry.label) buildingplan.addPlannedBuilding(bld) + if db_entry.do_now then + buildingplan.makeTopPriority(bld) + end + end + if db_entry.do_now and #bld.jobs > 0 then + bld.jobs[0].flags.do_now = true end end diff --git a/internal/quickfort/building.lua b/internal/quickfort/building.lua index 7c16b84b74..147442a656 100644 --- a/internal/quickfort/building.lua +++ b/internal/quickfort/building.lua @@ -528,9 +528,11 @@ function check_tiles_and_extents(ctx, buildings) local pos = xyz2pos(b.pos.x+extent_x-1, b.pos.y+extent_y-1, b.pos.z) local is_valid_tile = db_entry.is_valid_tile_fn(pos,db_entry,b) + -- don't mark individual invalid tiles in extent-based buildings + -- as invalid; the building can still build around it owns_preview = quickfort_preview.set_preview_tile(ctx, pos, - is_valid_tile) + is_valid_tile or db_entry.has_extents or false) if not is_valid_tile then log('tile not usable: (%d, %d, %d)', pos.x, pos.y, pos.z) col[extent_y] = false @@ -542,7 +544,7 @@ function check_tiles_and_extents(ctx, buildings) if not db_entry.is_valid_extent_fn(b) then log('no room for %s at (%d, %d, %d)', db_entry.label, b.pos.x, b.pos.y, b.pos.z) - if owns_preview then + if owns_preview and not db_entry.ignore_extent_errors then for x=b.pos.x,b.pos.x+b.width-1 do for y=b.pos.y,b.pos.y+b.height-1 do local p = xyz2pos(x, y, b.pos.z) diff --git a/internal/quickfort/burrow.lua b/internal/quickfort/burrow.lua index bfb7e0958b..ffd0ccf173 100644 --- a/internal/quickfort/burrow.lua +++ b/internal/quickfort/burrow.lua @@ -13,7 +13,6 @@ local quickfort_preview = reqscript('internal/quickfort/preview') local utils = require('utils') local log = quickfort_common.log -local logfn = quickfort_common.logfn local burrow_db = { a={label='Add', add=true}, @@ -97,9 +96,9 @@ local function do_burrow(ctx, db_entry, pos) stats['burrow_tiles_'..(db_entry.add and 'added' or 'removed')].value + 1 if db_entry.civalert then if db_entry.add then - civalert.set_civalert_burrow_if_unset(b) + civalert.add_civalert_burrow(b.id) else - civalert.unset_civalert_burrow_if_set(b) + civalert.remove_civalert_burrow(b.id) end end if db_entry.autochop_clear or db_entry.autochop_chop then diff --git a/internal/quickfort/command.lua b/internal/quickfort/command.lua index 1b5a85caa1..a27184ba73 100644 --- a/internal/quickfort/command.lua +++ b/internal/quickfort/command.lua @@ -49,7 +49,7 @@ local function make_ctx_base(prev_ctx) end local function make_ctx(prev_ctx, command, blueprint_name, cursor, aliases, quiet, - dry_run, preview, preserve_engravings) + marker, priority, dry_run, preview, preserve_engravings) local ctx = make_ctx_base(prev_ctx) local params = { command=command, @@ -57,6 +57,8 @@ local function make_ctx(prev_ctx, command, blueprint_name, cursor, aliases, quie cursor=cursor, aliases=aliases, quiet=quiet, + marker=marker, + priority=priority, dry_run=dry_run, preview=preview, preserve_engravings=preserve_engravings, @@ -84,6 +86,8 @@ function init_ctx(params, prev_ctx) copyall(params.cursor), -- copy since we modify this during processing params.aliases or {}, params.quiet, + params.marker or {blueprint=false, warm=false, damp=false}, + params.priority or 4, params.dry_run, params.preview and {tiles={}, bounds={}, invalid_tiles=0, total_tiles=0} or nil, @@ -192,7 +196,7 @@ function finish_commands(ctx) end local function do_one_command(prev_ctx, command, cursor, blueprint_name, section_name, - mode, quiet, dry_run, preserve_engravings, + mode, quiet, marker, priority, dry_run, preserve_engravings, modifiers) if not cursor then if command == 'orders' or mode == 'notes' then @@ -209,6 +213,8 @@ local function do_one_command(prev_ctx, command, cursor, blueprint_name, section cursor=cursor, aliases=quickfort_list.get_aliases(blueprint_name), quiet=quiet, + marker=marker, + priority=priority, dry_run=dry_run, preserve_engravings=preserve_engravings}, prev_ctx) @@ -221,20 +227,20 @@ local function do_one_command(prev_ctx, command, cursor, blueprint_name, section return ctx end -local function do_bp_name(commands, cursor, bp_name, sec_names, quiet, dry_run, - preserve_engravings, modifiers) +local function do_bp_name(commands, cursor, bp_name, sec_names, quiet, marker, priority, + dry_run, preserve_engravings, modifiers) local ctx for _,sec_name in ipairs(sec_names) do local mode = quickfort_list.get_blueprint_mode(bp_name, sec_name) for _,command in ipairs(commands) do ctx = do_one_command(ctx, command, cursor, bp_name, sec_name, mode, quiet, - dry_run, preserve_engravings, modifiers) + marker, priority, dry_run, preserve_engravings, modifiers) end end return ctx end -local function do_list_num(commands, cursor, list_nums, quiet, dry_run, +local function do_list_num(commands, cursor, list_nums, quiet, marker, priority, dry_run, preserve_engravings, modifiers) local ctx for _,list_num in ipairs(list_nums) do @@ -242,7 +248,7 @@ local function do_list_num(commands, cursor, list_nums, quiet, dry_run, quickfort_list.get_blueprint_by_number(list_num) for _,command in ipairs(commands) do ctx = do_one_command(ctx, command, cursor, bp_name, sec_name, mode, quiet, - dry_run, preserve_engravings, modifiers) + marker, priority, dry_run, preserve_engravings, modifiers) end end return ctx @@ -256,6 +262,7 @@ function do_command(args) end local cursor = guidm.getCursorPos() local quiet, verbose, dry_run, section_names = false, false, false, {''} + local marker, priority = {blueprint=false, warm=false, damp=false}, 4 local preserve_engravings = df.item_quality.Masterful local modifiers = quickfort_parse.get_modifiers_defaults() local other_args = argparse.processArgsGetopt(args, { @@ -266,9 +273,24 @@ function do_command(args) preserve_engravings = quickfort_parse.parse_preserve_engravings( optarg) end}, {'d', 'dry-run', handler=function() dry_run = true end}, + {'m', 'marker', hasArg=true, handler=function(optarg) + for _,m in ipairs(argparse.stringList(optarg)) do + if m == 'blueprint' then marker.blueprint = true + elseif m == 'warm' then marker.warm = true + elseif m == 'damp' then marker.damp = true + else + qerror(('invalid marker type: "%s"'):format(m)) + end + end + end}, {'n', 'name', hasArg=true, handler=function(optarg) section_names = argparse.stringList(optarg) end}, + {'p', 'priority', hasArg=true, + handler=function(optarg) + priority = argparse.positiveInt(optarg, 'priority') + priority = math.min(7, priority) + end}, {'q', 'quiet', handler=function() quiet = true end}, {'r', 'repeat', hasArg=true, handler=function(optarg) @@ -299,11 +321,13 @@ function do_command(args) local ctx if not ok then ctx = do_bp_name(args.commands, cursor, blueprint_name, section_names, - quiet, dry_run, preserve_engravings, modifiers) + quiet, marker, priority, dry_run, preserve_engravings, modifiers) else - ctx = do_list_num(args.commands, cursor, list_nums, quiet, dry_run, - preserve_engravings, modifiers) + ctx = do_list_num(args.commands, cursor, list_nums, quiet, marker, priority, + dry_run, preserve_engravings, modifiers) end + ctx.marker = marker + ctx.priority = priority finish_commands(ctx) end) end diff --git a/internal/quickfort/dig.lua b/internal/quickfort/dig.lua index 4aa7e33d39..e01cced381 100644 --- a/internal/quickfort/dig.lua +++ b/internal/quickfort/dig.lua @@ -11,13 +11,14 @@ if not dfhack_flags.module then qerror('this script cannot be called directly') end -local utils = require('utils') +local warmdamp = require('plugins.dig') local quickfort_common = reqscript('internal/quickfort/common') local quickfort_map = reqscript('internal/quickfort/map') local quickfort_parse = reqscript('internal/quickfort/parse') local quickfort_preview = reqscript('internal/quickfort/preview') local quickfort_set = reqscript('internal/quickfort/set') local quickfort_transform = reqscript('internal/quickfort/transform') +local utils = require('utils') local log = quickfort_common.log @@ -111,6 +112,7 @@ end local values_run = { dig_default=df.tile_dig_designation.Default, + dig_chop=dfhack.designations.markPlant, dig_channel=df.tile_dig_designation.Channel, dig_upstair=df.tile_dig_designation.UpStair, dig_downstair=df.tile_dig_designation.DownStair, @@ -139,6 +141,7 @@ local values_run = { -- if there is demand, though. local values_undo = { dig_default=df.tile_dig_designation.No, + dig_chop=dfhack.designations.unmarkPlant, dig_channel=df.tile_dig_designation.No, dig_upstair=df.tile_dig_designation.No, dig_downstair=df.tile_dig_designation.No, @@ -180,7 +183,10 @@ end local function do_chop(digctx) if digctx.flags.hidden then return nil end if is_tree(digctx.tileattrs) then - return function() digctx.flags.dig = values.dig_default end + return function() + local plant = dfhack.maps.getPlantAtTile(digctx.pos) + if plant then values.dig_chop(plant) end + end end return function() end -- noop, but not an error end @@ -238,11 +244,12 @@ local function do_up_down_stair(digctx) if is_construction(digctx.tileattrs) or (not is_wall(digctx.tileattrs) and not is_fortification(digctx.tileattrs) and + not is_diggable_floor(digctx.tileattrs) and not is_up_stair(digctx.tileattrs)) then return nil end end - if is_up_stair(digctx.tileattrs) then + if is_diggable_floor(digctx.tileattrs) then return function() digctx.flags.dig = values.dig_downstair end end return function() digctx.flags.dig = values.dig_updownstair end @@ -263,8 +270,9 @@ end local function do_remove_ramps(digctx) if digctx.on_map_edge or digctx.flags.hidden then return nil end if is_construction(digctx.tileattrs) or - not is_removable_shape(digctx.tileattrs) then - return nil; + not is_removable_shape(digctx.tileattrs) + then + return function() end -- noop, but not an error end return function() digctx.flags.dig = values.dig_default end end @@ -531,7 +539,7 @@ local dig_db = { F={action=do_fortification, use_priority=true, can_clobber_engravings=true}, T={action=do_track, use_priority=true, can_clobber_engravings=true}, v={action=do_toggle_engravings}, - -- the semantics are unclear if the code is M but m or force_marker_mode is + -- the semantics are unclear if the code is M but mb or force_marker_mode is -- also specified. skipping all other marker mode settings when toggling -- marker mode seems to make the most sense. M={action=do_toggle_marker, skip_marker_mode=true}, @@ -590,12 +598,25 @@ for _,v in pairs(dig_db) do if v.use_priority then v.priority = 4 end end --- handles marker mode 'm' prefix and priority suffix +-- handles marker mode 'm' prefixes and priority suffix local function extended_parser(_, keys) - local marker_mode = false - if keys:startswith('m') then - keys = string.sub(keys, 2) - marker_mode = true + local marker_mode = {blueprint=false, warm=false, damp=false} + while keys:startswith('m') do + keys = keys:sub(2) + if keys:startswith('b') then + marker_mode.blueprint = true + keys = keys:sub(2) + elseif keys:startswith('w') then + marker_mode.warm = true + keys = keys:sub(2) + elseif keys:startswith('d') then + marker_mode.damp = true + keys = keys:sub(2) + else + -- handle old marker mode syntax + marker_mode.blueprint = true + break + end end local found, _, code, priority = keys:find('^(%D*)(%d*)$') if not found then return nil end @@ -641,7 +662,7 @@ local function set_priority(digctx, priority) pbse.priority[digctx.pos.x % 16][digctx.pos.y % 16] = priority * 1000 end -local function dig_tile(digctx, db_entry) +local function dig_tile(ctx, digctx, db_entry) local action_fn = db_entry.action(digctx) if not action_fn then return nil end return function() @@ -655,12 +676,27 @@ local function dig_tile(digctx, db_entry) set_priority(digctx, 4) else if not db_entry.skip_marker_mode then - local marker_mode = db_entry.marker_mode or - quickfort_set.get_setting('force_marker_mode') + local marker_mode = ctx.marker.blueprint or + (db_entry.marker_mode and db_entry.marker_mode.blueprint) or + quickfort_set.get_setting('force_marker_mode') digctx.occupancy.dig_marked = marker_mode end if db_entry.use_priority then - set_priority(digctx, db_entry.priority) + local priority = db_entry.priority - 4 + ctx.priority + if priority > 7 then + ctx.stats.dig_priority_overflow.value = ctx.stats.dig_priority_overflow.value + 1 + priority = 7 + elseif priority < 1 then + ctx.stats.dig_priority_underflow.value = ctx.stats.dig_priority_underflow.value + 1 + priority = 1 + end + set_priority(digctx, priority) + end + if ctx.marker.warm or (db_entry.marker_mode and db_entry.marker_mode.warm) then + warmdamp.addTileWarmDig(digctx.pos) + end + if ctx.marker.damp or (db_entry.marker_mode and db_entry.marker_mode.damp) then + warmdamp.addTileDampDig(digctx.pos) end end end @@ -669,7 +705,7 @@ end local function ensure_engravings_cache(ctx) if ctx.engravings_cache then return end local engravings_cache = {} - for _,engraving in ipairs(df.global.world.engravings) do + for _,engraving in ipairs(df.global.world.event.engravings) do local pos = engraving.pos local grid = ensure_key(engravings_cache, pos.z) local row = ensure_key(grid, pos.y) @@ -817,7 +853,7 @@ local function do_run_impl(zlevel, grid, ctx) goto inner_continue end end - local action_fn = dig_tile(digctx, db_entry) + local action_fn = dig_tile(ctx, digctx, db_entry) quickfort_preview.set_preview_tile(ctx, extent_pos, action_fn ~= nil) if not action_fn then @@ -859,6 +895,10 @@ local function ensure_ctx_stats(ctx, prefix) {label='Tiles that could not be designated for digging', value=0} ctx.stats.dig_protected_engraving = ctx.stats.dig_protected_engraving or {label='Engravings protected from destruction', value=0} + ctx.stats.dig_priority_underflow = ctx.stats.dig_priority_underflow or + {label='Tiles whose priority had to be clamped to 1', value=0} + ctx.stats.dig_priority_overflow = ctx.stats.dig_priority_overflow or + {label='Tiles whose priority had to be clamped to 7', value=0} end function do_run(zlevel, grid, ctx) diff --git a/internal/quickfort/list.lua b/internal/quickfort/list.lua index 9ede350a97..e6c231cc5a 100644 --- a/internal/quickfort/list.lua +++ b/internal/quickfort/list.lua @@ -5,6 +5,7 @@ if not dfhack_flags.module then qerror('this script cannot be called directly') end +local argparse = require('argparse') local scriptmanager = require('script-manager') local utils = require('utils') local xlsxreader = require('plugins.xlsxreader') @@ -231,6 +232,7 @@ function do_list_internal(show_library, show_hidden) if not show_hidden and v.modeline.hidden then goto continue end local display_data = { id=i, + library=v.is_library, path=v.path, mode=v.modeline.mode, section_name=get_section_name(v.sheet_name, v.modeline.label), @@ -253,7 +255,7 @@ end function do_list(args) local show_library, show_hidden, filter_mode = true, false, nil - local filter_strings = utils.processArgsGetopt(args, { + local filter_strings = argparse.processArgsGetopt(args, { {'u', 'useronly', handler=function() show_library = false end}, {'h', 'hidden', handler=function() show_hidden = true end}, {'m', 'mode', hasArg=true, @@ -302,3 +304,20 @@ function do_list(args) :format(num_library_blueprints)) end end + +function do_delete(args) + local userpath_prefix = ('%s/'):format(quickfort_set.get_setting('blueprints_user_dir')) + for _, blueprint_name in ipairs(args) do + local path = get_blueprint_filepath(blueprint_name) + if not path:startswith(userpath_prefix) then + dfhack.printerr( + ('only player-owned blueprints can be deleted. not deleting library/mod blueprint: "%s"'):format(path)) + else + if os.remove(path) then + print(('removed blueprint: "%s"'):format(path)) + else + dfhack.printerr(('failed to remove blueprint: "%s"'):format(path)) + end + end + end +end diff --git a/internal/quickfort/notes.lua b/internal/quickfort/notes.lua index aa64168607..5667cfb933 100644 --- a/internal/quickfort/notes.lua +++ b/internal/quickfort/notes.lua @@ -31,7 +31,11 @@ function do_run(_, grid, ctx) if #line > 0 then table.insert(lines, table.concat(line, ' ')) end - table.insert(ctx.messages, table.concat(lines, '\n')) + local message = table.concat(lines, '\n') + if not ctx.messages_set[message] then + table.insert(ctx.messages, message) + ctx.messages_set[message] = true + end end function do_orders() diff --git a/internal/quickfort/orders.lua b/internal/quickfort/orders.lua index 53adbcae21..de3b974408 100644 --- a/internal/quickfort/orders.lua +++ b/internal/quickfort/orders.lua @@ -29,13 +29,25 @@ local function inc_order_spec(order_specs, quantity, reactions, label) label = label:gsub('_', ' ') log('needs job to build: %s %s', tostring(quantity), label) if not order_specs[label] then - local order = nil + local order, instrument_name = nil, nil for _,v in ipairs(reactions) do - local name = v.name:lower() - -- just find the first procedurally generated instrument + local name = dfhack.lowerCp437(v.name) + -- just find the first procedurally generated buildable instrument if label == 'instrument' and name:find('^assemble [^ ]+$') then - order = v.order - break + local iname = name:match('^assemble (.*)') + for _, instrument in ipairs(df.global.world.raws.itemdefs.instruments) do + if instrument.source_enid == df.global.plotinfo.civ_id and + instrument.flags.PLACED_AS_BUILDING and + dfhack.lowerCp437(instrument.name) == iname + then + instrument_name = iname + order = v.order + break + end + end + if order then + break + end -- the success of these matchers depends on the job name that is -- generated by stockflow.lua, which mimics the job name generated -- in the UI. I'm not particularly fond of this fragile method of @@ -53,8 +65,16 @@ local function inc_order_spec(order_specs, quantity, reactions, label) break end end - if not order then error(string.format('unhandled label: %s', label)) end - order_specs[label] = {order=order, quantity=0} + if not order then + if label == 'instrument' then + -- no buildable instruments found; this is a quirk of the generated + -- world and is not an error + return + else + error(string.format('unhandled label: %s', label)) + end + end + order_specs[label] = {order=order, quantity=0, instrument_name=instrument_name} end order_specs[label].quantity = order_specs[label].quantity + quantity end @@ -88,6 +108,8 @@ local function process_filter(order_specs, filter, reactions) elseif filter.item_type == df.item_type.BALLISTAPARTS then label = 'ballista parts' elseif filter.item_type == df.item_type.BAR then label = 'magnetite ore' + elseif filter.item_type == df.item_type.BOLT_THROWER_PARTS then + label = 'bolt thrower parts' elseif filter.item_type == df.item_type.BOX then label = 'coffer' elseif filter.item_type == df.item_type.CAGE then label = 'wooden cage' elseif filter.item_type == df.item_type.CATAPULTPARTS then @@ -130,9 +152,16 @@ end local function create_order(ctx, label, order_spec) local quantity = math.ceil(order_spec.quantity) + if order_spec.instrument_name then + label = ('%s (%s)'):format(label, order_spec.instrument_name) + end log('ordering %d %s', quantity, label) if not ctx.dry_run and stockflow then - stockflow.create_orders(order_spec.order, quantity) + if order_spec.instrument_name then + dfhack.run_script('instruments', 'order', order_spec.instrument_name, tostring(quantity), '-q') + else + stockflow.create_orders(order_spec.order, quantity) + end table.insert(ctx.stats, {label=('Ordered '..label), value=quantity, is_order=true}) else table.insert(ctx.stats, {label=('Would order '..label), value=quantity, is_order=true}) diff --git a/internal/quickfort/place.lua b/internal/quickfort/place.lua index 66eccda46c..e9bc42a179 100644 --- a/internal/quickfort/place.lua +++ b/internal/quickfort/place.lua @@ -155,7 +155,7 @@ local function make_db_entry(keys) num_barrels=num_barrels, num_wheelbarrows=num_wheelbarrows, links={give_to={}, take_from={}}, - props={}, + props={storage={}}, adjustments={}, logistics={}, } @@ -163,41 +163,41 @@ local function make_db_entry(keys) return db_entry end +local logistics_props = { + 'automelt', + 'autotrade', + 'autodump', + 'autotrain', + 'autoforbid', + 'autoclaim', +} + local function custom_stockpile(_, keys) local token_and_label, props, adjustments = parse_keys(keys) local db_entry = make_db_entry(token_and_label.token) if not db_entry then return nil end if token_and_label.label then db_entry.label = ('%s/%s'):format(db_entry.label, token_and_label.label) + db_entry.global_label = db_entry.label end if next(adjustments) then db_entry.adjustments[adjustments] = true end -- logistics properties - if props.automelt == 'true' then - db_entry.logistics.automelt = true - props.automelt = nil - end - if props.autotrade == 'true' then - db_entry.logistics.autotrade = true - props.autotrade = nil - end - if props.autodump == 'true' then - db_entry.logistics.autodump = true - props.autodump = nil - end - if props.autotrain == 'true' then - db_entry.logistics.autotrain = true - props.autotrain = nil + for _, logistics_prop in ipairs(logistics_props) do + if props[logistics_prop] == 'true' then + db_entry.logistics[logistics_prop] = true + props[logistics_prop] = nil + end end - -- convert from older parsing style to properties - db_entry.props.max_barrels = db_entry.num_barrels + -- convert to assign()able properties + db_entry.props.storage.max_barrels = db_entry.num_barrels db_entry.num_barrels = nil - db_entry.props.max_bins = db_entry.num_bins + db_entry.props.storage.max_bins = db_entry.num_bins db_entry.num_bins = nil - db_entry.props.max_wheelbarrows = db_entry.num_wheelbarrows + db_entry.props.storage.max_wheelbarrows = db_entry.num_wheelbarrows db_entry.num_wheelbarrows = nil -- alias properties @@ -215,19 +215,19 @@ local function custom_stockpile(_, keys) -- actual properties if props.barrels then - db_entry.props.max_barrels = tonumber(props.barrels) + db_entry.props.storage.max_barrels = tonumber(props.barrels) props.barrels = nil end if props.bins then - db_entry.props.max_bins = tonumber(props.bins) + db_entry.props.storage.max_bins = tonumber(props.bins) props.bins = nil end if props.wheelbarrows then - db_entry.props.max_wheelbarrows = tonumber(props.wheelbarrows) + db_entry.props.storage.max_wheelbarrows = tonumber(props.wheelbarrows) props.wheelbarrows = nil end if props.links_only == 'true' then - db_entry.props.use_links_only = 1 + ensure_key(db_entry.props, 'stockpile_flag').use_links_only = 1 props.links_only = nil end if props.name then @@ -257,35 +257,36 @@ local function configure_stockpile(bld, db_entry) for _,cat in ipairs(db_entry.categories) do local name = ('library/cat_%s'):format(cat) log('enabling stockpile category: %s', cat) - stockpiles.import_stockpile(name, {id=bld.id, mode='enable'}) + stockpiles.import_settings(name, {id=bld.id, mode='enable'}) end for adjlist in pairs(db_entry.adjustments or {}) do for _,adj in ipairs(adjlist) do log('applying stockpile preset: %s %s (filters=)', adj.mode, adj.name, table.concat(adj.filters or {}, ',')) - stockpiles.import_stockpile(adj.name, {id=bld.id, mode=adj.mode, filters=adj.filters}) + stockpiles.import_settings(adj.name, {id=bld.id, mode=adj.mode, filters=adj.filters}) end end end local function init_containers(db_entry, ntiles) - if db_entry.want_barrels or db_entry.props.max_barrels then - local max_barrels = db_entry.props.max_barrels or + local storage = db_entry.props.storage + if db_entry.want_barrels or storage.max_barrels then + local max_barrels = storage.max_barrels or quickfort_set.get_setting('stockpiles_max_barrels') - db_entry.props.max_barrels = (max_barrels < 0 or max_barrels >= ntiles) and ntiles or max_barrels - log('barrels set to %d', db_entry.props.max_barrels) + storage.max_barrels = (max_barrels < 0 or max_barrels >= ntiles) and ntiles or max_barrels + log('barrels set to %d', storage.max_barrels) end - if db_entry.want_bins or db_entry.props.max_bins then - local max_bins = db_entry.props.max_bins or + if db_entry.want_bins or storage.max_bins then + local max_bins = storage.max_bins or quickfort_set.get_setting('stockpiles_max_bins') - db_entry.props.max_bins = (max_bins < 0 or max_bins >= ntiles) and ntiles or max_bins - log('bins set to %d', db_entry.props.max_bins) + storage.max_bins = (max_bins < 0 or max_bins >= ntiles) and ntiles or max_bins + log('bins set to %d', storage.max_bins) end - if db_entry.want_wheelbarrows or db_entry.props.max_wheelbarrows then - local max_wb = db_entry.props.max_wheelbarrows or + if db_entry.want_wheelbarrows or storage.max_wheelbarrows then + local max_wb = storage.max_wheelbarrows or quickfort_set.get_setting('stockpiles_max_wheelbarrows') if max_wb < 0 then max_wb = 1 end - db_entry.props.max_wheelbarrows = (max_wb >= ntiles - 1) and ntiles-1 or max_wb - log('wheelbarrows set to %d', db_entry.props.max_wheelbarrows) + storage.max_wheelbarrows = (max_wb >= ntiles - 1) and ntiles-1 or max_wb + log('wheelbarrows set to %d', storage.max_wheelbarrows) end end @@ -323,17 +324,8 @@ local function create_stockpile(s, link_data, dry_run) end if next(db_entry.logistics) then local logistics_command = {'logistics', 'add', '-s', tostring(bld.stockpile_number)} - if db_entry.logistics.automelt then - table.insert(logistics_command, 'melt') - end - if db_entry.logistics.autotrade then - table.insert(logistics_command, 'trade') - end - if db_entry.logistics.autodump then - table.insert(logistics_command, 'dump') - end - if db_entry.logistics.autotrain then - table.insert(logistics_command, 'train') + for logistics_prop in pairs(db_entry.logistics) do + table.insert(logistics_command, logistics_prop:sub(5)) end log('running logistics command: "%s"', table.concat(logistics_command, ' ')) dfhack.run_command(logistics_command) @@ -465,7 +457,7 @@ function do_orders(zlevel, grid, ctx) local db_entry = s.db_entry local props = db_entry.props or {} quickfort_orders.enqueue_container_orders(ctx, - props.max_bins, props.max_barrels, props.max_wheelbarrows) + props.storage.max_bins, props.storage.max_barrels, props.storage.max_wheelbarrows) end end diff --git a/internal/quickfort/preview.lua b/internal/quickfort/preview.lua index ea98b9de76..a1a7afd5f2 100644 --- a/internal/quickfort/preview.lua +++ b/internal/quickfort/preview.lua @@ -11,7 +11,7 @@ end function set_preview_tile(ctx, pos, is_valid_tile, override) local preview = ctx.preview if not preview then return false end - local preview_row = ensure_key(ensure_key(ctx.preview.tiles, pos.z), pos.y) + local preview_row = ensure_keys(ctx.preview.tiles, pos.z, pos.y) if preview_row[pos.x] == nil then preview.total_tiles = preview.total_tiles + 1 end diff --git a/internal/quickfort/set.lua b/internal/quickfort/set.lua index 8bc33df5b5..531503f247 100644 --- a/internal/quickfort/set.lua +++ b/internal/quickfort/set.lua @@ -7,7 +7,7 @@ end local settings = { blueprints_user_dir={default_value='dfhack-config/blueprints'}, - blueprints_library_dir={default_value='hack/data/blueprints'}, + blueprints_library_dir={default_value=dfhack.getHackPath()..'/data/blueprints'}, force_marker_mode={default_value=false}, stockpiles_max_barrels={default_value=-1}, stockpiles_max_bins={default_value=-1}, diff --git a/internal/quickfort/stockflow.lua b/internal/quickfort/stockflow.lua index 52fa743118..1202bae64f 100644 --- a/internal/quickfort/stockflow.lua +++ b/internal/quickfort/stockflow.lua @@ -11,6 +11,7 @@ local function reaction_entry(reactions, job_type, values, name) local order = df.manager_order:new() -- These defaults differ from the newly created order's. order:assign{ + frequency = df.workquota_frequency_type.OneTime, job_type = job_type, item_type = -1, item_subtype = -1, @@ -116,7 +117,7 @@ function collect_reactions() reaction_entry(result, job_types.CatchLiveFish) -- Cutting, encrusting, and metal extraction. - local rock_types = df.global.world.raws.inorganics + local rock_types = df.global.world.raws.inorganics.all for rock_id = #rock_types-1, 0, -1 do local material = rock_types[rock_id].material local rock_name = material.state_adj.Solid @@ -129,19 +130,19 @@ function collect_reactions() reaction_entry(result, job_types.EncrustWithGems, { mat_type = 0, mat_index = rock_id, - item_category = {finished_goods = true}, + specflag = {encrust_flags={finished_goods=true}}, }, "Encrust Finished Goods With "..rock_name) reaction_entry(result, job_types.EncrustWithGems, { mat_type = 0, mat_index = rock_id, - item_category = {furniture = true}, + specflag = {encrust_flags={furniture=true}}, }, "Encrust Furniture With "..rock_name) reaction_entry(result, job_types.EncrustWithGems, { mat_type = 0, mat_index = rock_id, - item_category = {ammo = true}, + specflag = {encrust_flags={ammo=true}}, }, "Encrust Ammo With "..rock_name) end @@ -172,17 +173,17 @@ function collect_reactions() reaction_entry(result, job_types.EncrustWithGlass, { mat_type = glass_id, - item_category = {finished_goods = true}, + specflag = {encrust_flags={finished_goods=true}}, }, "Encrust Finished Goods With "..glass_name) reaction_entry(result, job_types.EncrustWithGlass, { mat_type = glass_id, - item_category = {furniture = true}, + specflag = {encrust_flags={furniture=true}}, }, "Encrust Furniture With "..glass_name) reaction_entry(result, job_types.EncrustWithGlass, { mat_type = glass_id, - item_category = {ammo = true}, + specflag = {encrust_flags={ammo=true}}, }, "Encrust Ammo With "..glass_name) end end @@ -628,13 +629,14 @@ end -- Place a new copy of the order onto the manager's queue. function create_orders(order, amount) local new_order = order:new() + new_order.frequency = df.workquota_frequency_type.OneTime amount = math.floor(amount) new_order.amount_left = amount new_order.amount_total = amount -- Todo: Create in a validated state if the fortress is small enough? new_order.status.validated = false new_order.status.active = false - new_order.id = df.global.world.manager_order_next_id - df.global.world.manager_order_next_id = df.global.world.manager_order_next_id + 1 - df.global.world.manager_orders:insert('#', new_order) + new_order.id = df.global.world.manager_orders.manager_order_next_id + df.global.world.manager_orders.manager_order_next_id = df.global.world.manager_orders.manager_order_next_id + 1 + df.global.world.manager_orders.all:insert('#', new_order) end diff --git a/internal/quickfort/zone.lua b/internal/quickfort/zone.lua index 2118260d9b..0900d57f8d 100644 --- a/internal/quickfort/zone.lua +++ b/internal/quickfort/zone.lua @@ -6,32 +6,33 @@ if not dfhack_flags.module then end require('dfhack.buildings') -- loads additional functions into dfhack.buildings -local utils = require('utils') +local preserve_rooms = require('plugins.preserve-rooms') local quickfort_common = reqscript('internal/quickfort/common') local quickfort_building = reqscript('internal/quickfort/building') local quickfort_parse = reqscript('internal/quickfort/parse') +local utils = require('utils') local log = quickfort_common.log local logfn = quickfort_common.logfn local function parse_pit_pond_props(zone_data, props) if props.pond == 'true' then - ensure_key(zone_data, 'zone_settings').pit_pond = df.building_civzonest.T_zone_settings.T_pit_pond.top_of_pond + ensure_keys(zone_data, 'zone_settings', 'pond', 'flag').keep_filled = true props.pond = nil end end local function parse_gather_props(zone_data, props) if props.pick_trees == 'false' then - ensure_keys(zone_data, 'zone_settings', 'gather').pick_trees = false + ensure_keys(zone_data, 'zone_settings', 'gather', 'flags').pick_trees = false props.pick_trees = nil end if props.pick_shrubs == 'false' then - ensure_keys(zone_data, 'zone_settings', 'gather').pick_shrubs = false + ensure_keys(zone_data, 'zone_settings', 'gather', 'flags').pick_shrubs = false props.pick_shrubs = nil end if props.gather_fallen == 'false' then - ensure_keys(zone_data, 'zone_settings', 'gather').gather_fallen = false + ensure_keys(zone_data, 'zone_settings', 'gather', 'flags').gather_fallen = false props.gather_fallen = nil end end @@ -60,11 +61,11 @@ end local function parse_tomb_props(zone_data, props) if props.pets == 'true' then - ensure_keys(zone_data, 'zone_settings', 'tomb').no_pets = false + ensure_keys(zone_data, 'zone_settings', 'tomb', 'flags').no_pets = false props.pets = nil end if props.citizens == 'false' then - ensure_keys(zone_data, 'zone_settings', 'tomb').no_citizens = true + ensure_keys(zone_data, 'zone_settings', 'tomb', 'flags').no_citizens = true props.citizens = nil end end @@ -105,9 +106,9 @@ local zone_db_raw = { b={label='Bedroom', default_data={type=df.civzone_type.Bedroom}}, h={label='Dining Hall', default_data={type=df.civzone_type.DiningHall}}, n={label='Pen/Pasture', default_data={type=df.civzone_type.Pen, - assign={zone_settings={pen={unk=1}}}}}, + assign={zone_settings={pen={flags={check_occupants=true}}}}}}, p={label='Pit/Pond', props_fn=parse_pit_pond_props, default_data={type=df.civzone_type.Pond, - assign={zone_settings={pit_pond=df.building_civzonest.T_zone_settings.T_pit_pond.top_of_pit}}}}, + assign={zone_settings={pond={flag={keep_filled=true}}}}}}, w={label='Water Source', default_data={type=df.civzone_type.WaterSource}}, j={label='Dungeon', default_data={type=df.civzone_type.Dungeon}}, f={label='Fishing', default_data={type=df.civzone_type.FishingArea}}, @@ -120,14 +121,15 @@ local zone_db_raw = { d={label='Garbage Dump', default_data={type=df.civzone_type.Dump}}, t={label='Animal Training', default_data={type=df.civzone_type.AnimalTraining}}, T={label='Tomb', props_fn=parse_tomb_props, default_data={type=df.civzone_type.Tomb, - assign={zone_settings={tomb={whole=1}}}}}, + assign={zone_settings={tomb={flags={whole=1}}}}}}, g={label='Gather/Pick Fruit', props_fn=parse_gather_props, default_data={type=df.civzone_type.PlantGathering, - assign={zone_settings={gather={pick_trees=true, pick_shrubs=true, gather_fallen=true}}}}}, + assign={zone_settings={gather={flags={pick_trees=true, pick_shrubs=true, gather_fallen=true}}}}}}, c={label='Clay', default_data={type=df.civzone_type.ClayCollection}}, } for _, v in pairs(zone_db_raw) do utils.assign(v, zone_template) - ensure_key(v.default_data, 'assign').is_active = 8 -- set to active by default + -- set to active by default + ensure_keys(v.default_data, 'assign', 'spec_sub_flag').active = true end -- we may want to offer full name aliases for the single letter ones above @@ -135,7 +137,7 @@ local aliases = {} local valid_locations = { tavern={new=df.abstract_building_inn_tavernst, - assign={name={type=df.language_name_type.SymbolFood}, + assign={name={type=df.language_name_type.FoodStore}, contents={desired_goblets=10, desired_instruments=5, need_more={goblets=true, instruments=true}}}}, hospital={new=df.abstract_building_hospitalst, @@ -156,10 +158,10 @@ local valid_locations = { contents={desired_instruments=5, need_more={instruments=true}}}}, } local valid_restrictions = { - visitors={AllowVisitors=true, AllowResidents=true, OnlyMembers=false}, - residents={AllowVisitors=false, AllowResidents=true, OnlyMembers=false}, - citizens={AllowVisitors=false, AllowResidents=false, OnlyMembers=false}, - members={AllowVisitors=false, AllowResidents=false, OnlyMembers=true}, + visitors={VISITORS_ALLOWED=true, NON_CITIZENS_ALLOWED=true, MEMBERS_ONLY=false}, + residents={VISITORS_ALLOWED=false, NON_CITIZENS_ALLOWED=true, MEMBERS_ONLY=false}, + citizens={VISITORS_ALLOWED=false, NON_CITIZENS_ALLOWED=false, MEMBERS_ONLY=false}, + members={VISITORS_ALLOWED=false, NON_CITIZENS_ALLOWED=false, MEMBERS_ONLY=true}, } for _, v in pairs(valid_locations) do ensure_key(v, 'assign').flags = valid_restrictions.visitors @@ -226,12 +228,6 @@ local function parse_location_props(props) return location_data end -local function get_noble_unit(noble) - local unit = dfhack.units.getUnitByNobleRole(noble) - if not unit then log('could not find a noble position for: "%s"', noble) end - return unit -end - local function parse_zone_config(c, props) if not rawget(zone_db_raw, c) then return 'Invalid', nil @@ -241,7 +237,7 @@ local function parse_zone_config(c, props) utils.assign(zone_data, db_entry.default_data) zone_data.location = parse_location_props(props) if props.active == 'false' then - zone_data.is_active = 0 + ensure_key(zone_data, 'spec_sub_flag').active = false props.active = nil end if props.name then @@ -249,13 +245,7 @@ local function parse_zone_config(c, props) props.name = nil end if props.assigned_unit then - zone_data.assigned_unit = get_noble_unit(props.assigned_unit) - if not zone_data.assigned_unit and props.assigned_unit:lower() == 'sheriff' then - zone_data.assigned_unit = get_noble_unit('captain_of_the_guard') - end - if not zone_data.assigned_unit then - dfhack.printerr(('could not find a unit assigned to noble position: "%s"'):format(props.assigned_unit)) - end + zone_data.assigned_unit = props.assigned_unit props.assigned_unit = nil end if db_entry.props_fn then db_entry.props_fn(zone_data, props) end @@ -312,7 +302,7 @@ local function set_location(zone, location, ctx) dfhack.printerr('cannot create a guildhall without a specified profession') return end - local site = df.global.world.world_data.active_site[0] + local site = dfhack.world.getCurrentSite() local loc_id = nil if location.label and safe_index(ctx, 'zone', 'locations', location.label) then local cached_loc = ctx.zone.locations[location.label] @@ -386,11 +376,12 @@ local function create_zone(zone, data, ctx) set_location(bld, data.location, ctx) data.location = nil end - if data.assigned_unit then - dfhack.buildings.setOwner(bld, data.assigned_unit) - data.assigned_unit = nil - end + local assigned_unit = data.assigned_unit + data.assigned_unit = nil utils.assign(bld, data) + if assigned_unit then + preserve_rooms.assignToRole(assigned_unit, bld) + end return ntiles end diff --git a/internal/unit-info-viewer/skills-progress.lua b/internal/unit-info-viewer/skills-progress.lua new file mode 100644 index 0000000000..fdf9cb5bdc --- /dev/null +++ b/internal/unit-info-viewer/skills-progress.lua @@ -0,0 +1,173 @@ +--@ module=true + +local utils = require("utils") +local widgets = require('gui.widgets') +local overlay = require('plugins.overlay') + +local view_sheets = df.global.game.main_interface.view_sheets + +local function get_skill(id, unit) + if not unit then return nil end + local soul = unit.status.current_soul + if not soul then return nil end + return utils.binsearch( + soul.skills, + view_sheets.unit_skill[id], + "id" + ) +end + +SkillProgressOverlay=defclass(SkillProgressOverlay, overlay.OverlayWidget) +SkillProgressOverlay.ATTRS { + desc="Display progress bars for learning skills on unit viewsheets.", + default_pos={x=-43,y=18}, + default_enabled=true, + viewscreens= { + 'dwarfmode/ViewSheets/UNIT/Skills/Labor', + 'dwarfmode/ViewSheets/UNIT/Skills/Combat', + 'dwarfmode/ViewSheets/UNIT/Skills/Social', + 'dwarfmode/ViewSheets/UNIT/Skills/Other', + 'dungeonmode/ViewSheets/UNIT/Skills/Labor', + 'dungeonmode/ViewSheets/UNIT/Skills/Combat', + 'dungeonmode/ViewSheets/UNIT/Skills/Social', + 'dungeonmode/ViewSheets/UNIT/Skills/Other', + }, + frame={w=54, h=20}, +} + +function SkillProgressOverlay:init() + self:addviews{ + widgets.Label{ + view_id='annotations', + frame={t=0, r=0, w=16, b=0}, + auto_height=false, + text='', + text_pen=COLOR_GRAY, + }, + widgets.BannerPanel{ + frame={b=0, l=1, h=1}, + subviews={ + widgets.ToggleHotkeyLabel{ + frame={l=1, w=25}, + label='Progress Bar:', + key='CUSTOM_CTRL_B', + options={ + {label='No', value=false, pen=COLOR_WHITE}, + {label='Yes', value=true, pen=COLOR_YELLOW}, + }, + view_id='toggle_progress', + initial_option=true + }, + widgets.ToggleHotkeyLabel{ + frame={l=29, w=23}, + label='Experience:', + key='CUSTOM_CTRL_E', + options={ + {label='No', value=false, pen=COLOR_WHITE}, + {label='Yes', value=true, pen=COLOR_YELLOW}, + }, + view_id='toggle_experience', + initial_option=true + }, + }, + }, + } +end + +function SkillProgressOverlay:preUpdateLayout(parent_rect) + self.frame.h = parent_rect.height - 21 +end + +local function get_threshold(lvl) + return 500 + lvl * 100 +end + +function SkillProgressOverlay:onRenderFrame(dc, rect) + local annotations = {} + local current_unit = df.unit.find(view_sheets.active_id) + if current_unit and current_unit.portrait_texpos > 0 then + -- If a portrait is present, displace the bars down 2 tiles + table.insert(annotations, "\n\n") + end + + local progress_bar_needed = not dfhack.world.isAdventureMode() or not dfhack.screen.inGraphicsMode() + self.subviews.toggle_progress.visible = progress_bar_needed + local progress_bar = self.subviews.toggle_progress:getOptionValue() and progress_bar_needed + local experience = self.subviews.toggle_experience:getOptionValue() + + local margin = self.subviews.annotations.frame.w + local num_elems = self.frame.h // 3 - 1 + local start = math.min(view_sheets.scroll_position_unit_skill, + math.max(0,#view_sheets.unit_skill-num_elems)) + local max_elem = math.min(#view_sheets.unit_skill-1, + view_sheets.scroll_position_unit_skill+num_elems-1) + for idx = start, max_elem do + local skill = get_skill(idx, current_unit) + if not skill then + table.insert(annotations, "\n\n\n\n") + goto continue + end + local xp_threshold = get_threshold(skill.rating) + if experience then + if not progress_bar then + table.insert(annotations, NEWLINE) + end + local level_color = COLOR_WHITE + local rating_val = math.max(0, skill.rating - skill.rusty) + if skill.rusty > 0 then + level_color = COLOR_LIGHTRED + elseif skill.rating >= df.skill_rating.Legendary then + level_color = COLOR_LIGHTCYAN + end + table.insert(annotations, { + text=('Lv%s'):format(rating_val >= 100 and '++' or tostring(rating_val)), + width=7, + pen=level_color, + }) + table.insert(annotations, { + text=('%4d/%4d'):format(skill.experience, xp_threshold), + pen=level_color, + width=9, + rjustify=true, + }) + end + + -- 3rd line (last) + + -- Progress Bar + if progress_bar then + table.insert(annotations, NEWLINE) + local percentage = skill.experience / xp_threshold + local barstop = math.floor((margin * percentage) + 0.5) + for i = 0, margin-1 do + local color = COLOR_LIGHTCYAN + local char = 219 + -- start with the filled middle progress bar + local tex_idx = 1 + -- at the beginning, use the left rounded corner + if i == 0 then + tex_idx = 0 + end + -- at the end, use the right rounded corner + if i == margin-1 then + tex_idx = 2 + end + if i >= barstop then + -- offset it to the hollow graphic + tex_idx = tex_idx + 3 + color = COLOR_DARKGRAY + char = 177 + end + table.insert(annotations, { width = 1, tile={tile=df.global.init.load_bar_texpos[tex_idx], ch=char, fg=color}}) + end + end + -- End! + table.insert(annotations, NEWLINE) + table.insert(annotations, NEWLINE) + + ::continue:: + end + self.subviews.annotations:setText(annotations) + + SkillProgressOverlay.super.onRenderFrame(self, dc, rect) +end diff --git a/internal/view-item-info/item-descriptions.lua b/internal/view-item-info/item-descriptions.lua index aed8296e28..6dace92dda 100644 --- a/internal/view-item-info/item-descriptions.lua +++ b/internal/view-item-info/item-descriptions.lua @@ -95,6 +95,8 @@ descriptions = { "as logs or bars. Cutting boulders into blocks gives four times as", "many items, which are lighter for faster hauling and yield smooth", "constructions."}, + BOLT_THROWER_PARTS = { + "Can be used to construct a Bolt Thrower."}, BOX = { "A container for storing items. They are required by nobles, and will", "increase the value of rooms they are placed in. Also required to store", "hospital supplies. They can be made of stone or metal (coffers),", diff --git a/item.lua b/item.lua new file mode 100644 index 0000000000..7077984410 --- /dev/null +++ b/item.lua @@ -0,0 +1,475 @@ +--@module = true + +----------------------------------------------------------- +-- helper functions +----------------------------------------------------------- + +-- check whether an item is inside a burrow +local function containsItem(burrow,item) + local res = false + local x,y,z = dfhack.items.getPosition(item) + if x then + res = dfhack.burrows.isAssignedTile(burrow, xyz2pos(x,y,z)) + end + return res +end + +-- fast reachability test for items that requires precomputed walkability groups for +-- all citizens. Returns false for items w/o valid position (e.g., items in inventories). +--- @param item item +--- @param wgroups table +--- @return boolean +function fastReachable(item,wgroups) + local x, y, z = dfhack.items.getPosition(item) + if x then -- item has a valid position + local igroup = dfhack.maps.getWalkableGroup(xyz2pos(x, y, z)) + return not not wgroups[igroup] + else + return false + end +end + +--- @return table +function citizenWalkabilityGroups() + local cgroups = {} + for _, unit in pairs(dfhack.units.getCitizens(true)) do + local wgroup = dfhack.maps.getWalkableGroup(unit.pos) + cgroups[wgroup] = true + end + cgroups[0] = false -- exclude unwalkable tiles + return cgroups +end + + +--- @param tab conditions +--- @param pred fun(_:item):boolean +--- @param negate { negate : boolean }|nil +local function addPositiveOrNegative(tab, pred, negate) + if negate and negate.negate == true then + table.insert(tab, function (item) return not pred(item) end) + else + table.insert(tab, pred) + end +end + + +----------------------------------------------------------------------- +-- external API: helpers to assemble filters and `execute` to execute. +----------------------------------------------------------------------- + +--- @alias conditions (fun(item:item):boolean)[] + +--- @param tab conditions +--- @param burrow burrow +--- @param negate { negate : boolean }|nil +function condition_burrow(tab,burrow, negate) + local pred = function (item) return containsItem(burrow, item) end + addPositiveOrNegative(tab, pred, negate) +end + +--- @param tab conditions +--- @param match number|string +--- @param negate { negate : boolean }|nil +function condition_type(tab, match, negate) + local pred = nil + if type(match) == "string" then + pred = function (item) return df.item_type[item:getType()] == string.upper(match) end + elseif type(match) == "number" then + pred = function (item) return item:getType() == type end + else error("match argument must be string or number") + end + addPositiveOrNegative(tab, pred, negate) +end + +--- @param tab conditions +--- @param negate { negate : boolean }|nil +function condition_reachable(tab, negate) + local cgroups = citizenWalkabilityGroups() + local pred = function(item) return fastReachable(item, cgroups) end + addPositiveOrNegative(tab, pred, negate) +end + +-- uses the singular form without stack size (i.e., prickle berry) +--- @param tab conditions +--- @param pattern string # Lua pattern: https://www.lua.org/manual/5.3/manual.html#6.4.1 +--- @param negate { negate : boolean }|nil +function condition_description(tab, pattern, negate) + local pred = + function(item) + -- remove trailing stack size for corpse pieces like "wool" (work around DF bug) + local desc = dfhack.items.getDescription(item, 1):gsub(' %[%d+%]','') + return not not desc:find(pattern) + end + addPositiveOrNegative(tab, pred, negate) +end + +--- @param tab conditions +--- @param material string +--- @param negate { negate : boolean }|nil +function condition_material(tab, material, negate) + local pred = function(item) return dfhack.matinfo.decode(item):toString() == material end + addPositiveOrNegative(tab, pred, negate) +end + +--- @param tab conditions +--- @param match string +--- @param negate { negate : boolean }|nil +function condition_matcat(tab, match, negate) + if df.dfhack_material_category[match] ~= nil then + local pred = + function (item) + local matinfo = dfhack.matinfo.decode(item) + return matinfo:matches{[match]=true} + end + addPositiveOrNegative(tab, pred, negate) + else + qerror("invalid material category") + end +end + +--- @param tab conditions +--- @param lower number # range: 0 (pristine) to 3 (XX) +--- @param upper number # range: 0 (pristine) to 3 (XX) +--- @param negate { negate : boolean }|nil +function condition_wear(tab, lower, upper, negate) + local pred = function(item) return lower <= item.wear and item.wear <= upper end + addPositiveOrNegative(tab, pred, negate) +end + +--- @param tab conditions +--- @param lower number # range: 0 (standard) to 5 (masterwork) +--- @param upper number # range: 0 (standard) to 5 (masterwork) +--- @param negate { negate : boolean }|nil +function condition_quality(tab, lower, upper, negate) + local pred = function(item) return lower <= item:getQuality() and item:getQuality() <= upper end + addPositiveOrNegative(tab, pred, negate) +end + +--- @param tab conditions +--- @param lower number # range: 0 (standard) to 5 (masterwork) +--- @param upper number # range: 0 (standard) to 5 (masterwork) +--- @param negate { negate : boolean }|nil +function condition_overall_quality(tab, lower, upper, negate) + local pred = function(item) return lower <= item:getOverallQuality() and item:getOverallQuality() <= upper end + addPositiveOrNegative(tab, pred, negate) +end + +--- @param tab conditions +--- @param negate { negate : boolean }|nil +function condition_forbid(tab, negate) + local pred = function(item) return item.flags.forbid end + addPositiveOrNegative(tab, pred, negate) +end + +--- @param tab conditions +--- @param negate { negate : boolean }|nil +function condition_melt(tab, negate) + local pred = function (item) return item.flags.melt end + addPositiveOrNegative(tab, pred, negate) +end + +--- @param tab conditions +--- @param negate { negate : boolean }|nil +function condition_dump(tab, negate) + local pred = function(item) return item.flags.dump end + addPositiveOrNegative(tab, pred, negate) +end + +--- @param tab conditions +function condition_hidden(tab, negate) + local pred = function(item) return item.flags.hidden end + addPositiveOrNegative(tab, pred, negate) +end + +function condition_owned(tab, negate) + local pred = function(item) return item.flags.owned end + addPositiveOrNegative(tab, pred, negate) +end + +--- @param tab conditions +--- @param negate { negate : boolean }|nil +function condition_stockpiled(tab, negate) + local stocked = {} + for _, stockpile in ipairs(df.global.world.buildings.other.STOCKPILE) do + for _, item_container in ipairs(dfhack.buildings.getStockpileContents(stockpile)) do + stocked[item_container.id] = true + local contents = dfhack.items.getContainedItems(item_container) + for _, item_bag in ipairs(contents) do + stocked[item_bag.id] = true + local contents2 = dfhack.items.getContainedItems(item_bag) + for _, item in ipairs(contents2) do + stocked[item.id] = true + end + end + end + end + local pred = function(item) return stocked[item.id] end + addPositiveOrNegative(tab, pred, negate) +end + +--- @param action "melt"|"unmelt"|"forbid"|"unforbid"|"dump"|"undump"|"count"|"hide"|"unhide" +--- @param conditions conditions +--- @param options { help : boolean, artifact : boolean, dryrun : boolean, bytype : boolean, owned : boolean, nowebs : boolean, verbose : boolean } +--- @param return_items boolean|nil +--- @return number, item[], table +function execute(action, conditions, options, return_items) + local count = 0 + local items = {} + local types = {} + local descriptions = {} + + for _, item in pairs(df.global.world.items.other.IN_PLAY) do + -- never act on items used for constructions/building materials and carried by hostiles + -- also skip artifacts, unless explicitly told to include them + if item.flags.construction or + item.flags.garbage_collect or + item.flags.in_building or + item.flags.hostile or + (item.flags.artifact and not options.artifact) or + item.flags.on_fire or + item.flags.trader or + (item.flags.spider_web and options.nowebs) or + (item.flags.owned and not options.owned) + then + goto skipitem + end + + -- implicit filters: + if action == 'melt' and (item.flags.melt or not dfhack.items.canMelt(item)) or + action == 'unmelt' and not item.flags.melt or + action == 'forbid' and item.flags.forbid or + action == 'unforbid' and not item.flags.forbid or + action == 'dump' and (item.flags.dump or item.flags.artifact) or + action == 'undump' and not item.flags.dump or + action == 'hide' and item.flags.hidden or + action == 'unhide' and not item.flags.hidden + then + goto skipitem + end + + -- check conditions provided via options + -- note we use pairs instead of ipairs since the caller could have + -- added conditions with non-list keys + for _, condition in pairs(conditions) do + if not condition(item) then goto skipitem end + end + + -- skip items that are in unrevealed parts of the map + local x, y, z = dfhack.items.getPosition(item) + if x and not dfhack.maps.isTileVisible(x, y, z) then + goto skipitem + end + + -- item matches the filters + count = count + 1 + if options.bytype then + local it = item:getType() + types[it] = (types[it] or 0) + 1 + end + + -- carry out the action + if action == 'forbid' and not options.dryrun then + item.flags.forbid = true + elseif action == 'unforbid' and not options.dryrun then + item.flags.forbid = false + elseif action == 'dump' and not options.dryrun then + item.flags.dump = true + elseif action == 'undump' and not options.dryrun then + item.flags.dump = false + elseif action == 'melt' and not options.dryrun then + dfhack.items.markForMelting(item) + elseif action == 'unmelt' and not options.dryrun then + dfhack.items.cancelMelting(item) + elseif action == "hide" and not options.dryrun then + item.flags.hidden = true + elseif action == "unhide" and not options.dryrun then + item.flags.hidden = false + end + + if options.verbose then + local desc = dfhack.items.getReadableDescription(item) + descriptions[desc] = (descriptions[desc] or 0) + 1 + end + + if return_items then table.insert(items, item) end + + :: skipitem :: + end + + local desc_list = {} + for desc in pairs(descriptions) do + table.insert(desc_list, desc) + end + table.sort(desc_list) + for _, desc in ipairs(desc_list) do + print(('%4d %s'):format(descriptions[desc], dfhack.df2console(desc))) + end + + return count, items, types +end + +--- @param action "melt"|"unmelt"|"forbid"|"unforbid"|"dump"|"undump"|"count"|"hide"|"unhide" +--- @param conditions conditions +--- @param options { help : boolean, artifact : boolean, dryrun : boolean, bytype : boolean, owned : boolean, verbose : boolean } +function executeWithPrinting (action, conditions, options) + local count, _ , types = execute(action, conditions, options) + if options.verbose and count > 0 then + print() + end + if action == "count" then + print(count, 'items matched the filter options') + elseif options.dryrun then + print(count, 'items would be modified') + else + print(count, 'items were modified') + end + if options.bytype and count > 0 then + local sorted = {} + for tp, ct in pairs(types) do + table.insert(sorted, { type = tp, count = ct }) + end + table.sort(sorted, function(a, b) return a.count > b.count end) + print(("\n%-14s %5s\n"):format("TYPE", "COUNT")) + for _, t in ipairs(sorted) do + print(("%-14s %5s"):format(df.item_type[t.type], t.count)) + end + print() + end +end + +----------------------------------------------------------------------- +-- script action: check for arguments and main action and run act +----------------------------------------------------------------------- + +if dfhack_flags.module then + return +end + +local argparse = require('argparse') + +local options = { + help = false, + artifact = false, + dryrun = false, + bytype = false, + owned = false, + nowebs = false, + verbose = false, + filterquality = false, + totalquality = false, +} + +--- @type (fun(item:item):boolean)[] +local conditions = {} + +local minQuality, maxQuality = 0, 5 + +local function flagsFilter(args, negate) + local flags = argparse.stringList(args, "flag list") + for _,flag in ipairs(flags) do + if flag == 'forbid' then condition_forbid(conditions, negate) + elseif flag == 'forbidden' then condition_forbid(conditions, negate) -- be lenient + elseif flag == 'dump' then condition_dump(conditions, negate) + elseif flag == 'hidden' then condition_hidden(conditions, negate) + elseif flag == 'melt' then condition_melt(conditions, negate) + elseif flag == 'owned' then + options.owned = true + condition_owned(conditions, negate) + else qerror('unkown flag "'..flag..'"') + end + end +end + +local positionals = argparse.processArgsGetopt({ ... }, { + { 'h', 'help', handler = function() options.help = true end }, + { 'v', 'verbose', handler = function() options.verbose = true end }, + { 'a', 'include-artifacts', handler = function() options.artifact = true end }, + { nil, 'include-owned', handler = function() options.owned = true end }, + { nil, 'ignore-webs', handler = function() options.nowebs = true end }, + { 'n', 'dry-run', handler = function() options.dryrun = true end }, + { nil, 'by-type', handler = function() options.bytype = true end }, + { nil, 'total-quality', handler = function() options.totalquality = true end }, + { 'i', 'inside', hasArg = true, + handler = function (name) + local burrow = dfhack.burrows.findByName(name,true) + if burrow then condition_burrow(conditions, burrow) + else qerror('burrow '..name..' not found') end + end + }, + { 'o', 'outside', hasArg = true, + handler = function (name) + local burrow = dfhack.burrows.findByName(name,true) + if burrow then condition_burrow(conditions, burrow, { negate = true }) + else qerror('burrow '..name..' not found') end + end + }, + { 'r', 'reachable', + handler = function () condition_reachable(conditions) end }, + { 'u', 'unreachable', + handler = function () condition_reachable(conditions, { negate = true }) end }, + { 't', 'type', hasArg = true, + handler = function (type) condition_type(conditions,type) end }, + { 'm', 'material', hasArg = true, + handler = function (material) condition_material(conditions, material) end }, + { 'c', 'mat-category', hasArg = true, + handler = function (matcat) condition_matcat(conditions, matcat) end }, + { 'w', 'min-wear', hasArg = true, + handler = function(levelst) + local level = argparse.nonnegativeInt(levelst, 'min-wear') + condition_wear(conditions, level , 3) end }, + { 'W', 'max-wear', hasArg = true, + handler = function(levelst) + local level = argparse.nonnegativeInt(levelst, 'max-wear') + condition_wear(conditions, 0, level) end }, + -- Need to process total-quality argument before processing min/max-quality arguments, + -- since there's no guarantee the user will call total-quality first in the command line. + { 'q', 'min-quality', hasArg = true, + handler = function(levelst) + local level = argparse.nonnegativeInt(levelst, 'min-quality') + minQuality = level options.filterquality = true end }, + -- condition_quality(conditions, level, 5) end }, + { 'Q', 'max-quality', hasArg = true, + handler = function(levelst) + local level = argparse.nonnegativeInt(levelst, 'max-quality') + maxQuality = level options.filterquality = true end }, + -- condition_quality(conditions, 0, level) end }, + { nil, 'stockpiled', + handler = function () condition_stockpiled(conditions) end }, + { nil, 'scattered', + handler = function () condition_stockpiled(conditions, { negate = true}) end }, + { nil, 'marked', hasArg = true, + handler = function (args) flagsFilter(args) end }, + { nil, 'not-marked', hasArg = true, + handler = function (args) flagsFilter(args, { negate = true }) end }, + { nil, 'visible', + handler = function () condition_hidden(conditions, { negate = true }) end } +}) + +if options.help or positionals[1] == 'help' then + print(dfhack.script_help()) + return +end + +if options.filterquality then + if options.totalquality then + condition_overall_quality(conditions, minQuality, maxQuality) + else + condition_quality(conditions, minQuality, maxQuality) + end +end + +for i=2,#positionals do + condition_description(conditions, positionals[i]) +end + +if positionals[1] == 'forbid' then executeWithPrinting('forbid', conditions, options) +elseif positionals[1] == 'unforbid' then executeWithPrinting('unforbid', conditions, options) +elseif positionals[1] == 'dump' then executeWithPrinting('dump', conditions, options) +elseif positionals[1] == 'undump' then executeWithPrinting('undump', conditions, options) +elseif positionals[1] == 'melt' then executeWithPrinting('melt', conditions, options) +elseif positionals[1] == 'unmelt' then executeWithPrinting('unmelt', conditions, options) +elseif positionals[1] == 'count' then executeWithPrinting('count', conditions, options) +elseif positionals[1] == 'hide' then executeWithPrinting('hide', conditions, options) +elseif positionals[1] == 'unhide' then executeWithPrinting('unhide', conditions, options) +else qerror('main action not recognized') +end diff --git a/justice.lua b/justice.lua new file mode 100644 index 0000000000..489618e49d --- /dev/null +++ b/justice.lua @@ -0,0 +1,61 @@ +local argparse = require('argparse') + +local TICKS_PER_SEASON_TICK = 10 +local TICKS_PER_DAY = 1200 + +local function list_convicts() + local found = false + for _,punishment in ipairs(df.global.plotinfo.punishments) do + local unit = df.unit.find(punishment.criminal) + if unit and punishment.prison_counter > 0 then + found = true + local days = math.ceil((punishment.prison_counter * TICKS_PER_SEASON_TICK) / TICKS_PER_DAY) + print(('%s (id: %d): serving a sentence of %d day(s)'):format( + dfhack.units.getReadableName(unit), unit.id, days)) + end + end + if not found then + print('No criminals currently serving sentences.') + end +end + +local function pardon_unit(unit) + for _,punishment in ipairs(df.global.plotinfo.punishments) do + if punishment.criminal == unit.id then + punishment.prison_counter = 0 + return + end + end + qerror('Unit is not currently serving a sentence!') +end + +local function command_pardon(unit_id) + local unit = nil + if not unit_id then + unit = dfhack.gui.getSelectedUnit(true) + if not unit then qerror('No unit selected!') end + else + unit = df.unit.find(unit_id) + if not unit then qerror(('No unit with id %d'):format(unit_id)) end + end + pardon_unit(unit) +end + +local unit_id = nil + +local positionals = argparse.processArgsGetopt({...}, + { + {'u', 'unit', hasArg=true, + handler=function(optarg) unit_id = argparse.nonnegativeInt(optarg, 'unit') end}, + } +) + +local command = positionals[1] + +if command == 'pardon' then + command_pardon(unit_id) +elseif not command or command == 'list' then + list_convicts() +else + qerror(('Unrecognised command: %s'):format(command)) +end diff --git a/launch.lua b/launch.lua index 1ec31ba210..942dde409e 100644 --- a/launch.lua +++ b/launch.lua @@ -1,26 +1,19 @@ -- Launch unit to cursor location -- Based on propel.lua by Roses, molested by Rumrusher and I until this happened, sorry. ---[====[ -launch -====== -Activate with a cursor on screen and you will go there rapidly. Attack -something first to ride them there. +local guidm = require('gui.dwarfmode') -]====] function launch(unitSource,unitRider) - local curpos - if df.global.adventure.menu == df.ui_advmode_menu.Look then - curpos = df.global.cursor - elseif df.global.gamemode == df.game_mode.ADVENTURE then - qerror("No [l] cursor located! You would have slammed into the ground and exploded.") - else + if not dfhack.world.isAdventureMode() then qerror("Must be used in adventurer mode or the arena!") end - + local curpos = guidm.getCursorPos() + if not curpos then + qerror("No cursor located! You would have slammed into the ground and exploded.") + end local count=0 - local l = df.global.world.proj_list + local l = df.global.world.projectiles.all local lastlist=l l=l.next while l do @@ -61,13 +54,15 @@ function launch(unitSource,unitRider) proj.flags.high_flying=true --this probably doesn't do anything, let me know if you figure out what it is proj.flags.parabolic=true proj.flags.no_collide=true - proj.flags.unk9=true + proj.flags.no_adv_pause=true proj.speed_x=resultx*10000 proj.speed_y=resulty*10000 proj.speed_z=resultz*15000 --higher z speed makes it easier to reach a target safely - if df.global.world.units.active[0].job.hunt_target==nil then + + local adv = dfhack.world.getAdventurer() + if adv.job.hunt_target==nil then proj.flags.safe_landing=true - elseif df.global.world.units.active[0].job.hunt_target then + elseif adv.job.hunt_target then proj.flags.safe_landing=false end local unitoccupancy = dfhack.maps.ensureTileBlock(unitSource.pos).occupancy[unitSource.pos.x%16][unitSource.pos.y%16] @@ -80,11 +75,11 @@ function launch(unitSource,unitRider) unitSource.flags1.on_ground=false end -local unitSource = df.global.world.units.active[0] +local unitSource = dfhack.world.getAdventurer() local unitRider = nil --as:df.unit if unitSource.job.hunt_target ~= nil then unitRider = unitSource - unitSource = df.global.world.units.active[0].job.hunt_target + unitSource = unitSource.job.hunt_target unitSource.general_refs:insert("#",{new=df.general_ref_unit_riderst,unit_id=unitRider.id}) unitRider.relationship_ids.RiderMount=unitSource.id unitRider.flags1.rider=true diff --git a/lever.lua b/lever.lua index 9f57aef3f0..39e9be9e53 100644 --- a/lever.lua +++ b/lever.lua @@ -1,5 +1,5 @@ -- Inspect and pull levers -local argparse = require('argparse') +--@ module = true function leverPullJob(lever, priority) local ref = df.general_ref_building_holderst:new() @@ -18,8 +18,6 @@ function leverPullJob(lever, priority) dfhack.job.linkIntoWorld(job, true) dfhack.job.checkBuildingsNow() - - print(leverDescribe(lever)) end function leverPullInstant(lever) @@ -35,10 +33,14 @@ function leverPullInstant(lever) else lever.state = 1 end - - print(leverDescribe(lever)) end +local flag_names = { + [df.building_type.Bridge]={closed="raised", closing="raising", opening="lowering"}, + [df.building_type.Weapon]={closed="retracted", closing="retracting", opening="unretracting"}, +} +setmetatable(flag_names, {__index=function() return {closed="closed", closing="closing", opening="opening"} end}) + function leverDescribe(lever) local lever_name = '' if #lever.name > 0 then @@ -74,23 +76,18 @@ function leverDescribe(lever) for _, m in ipairs(lever.linked_mechanisms) do local tref = dfhack.items.getGeneralRef(m, df.general_ref_type.BUILDING_HOLDER) if tref then - tg = tref:getBuilding() + local tg = tref:getBuilding() if pcall(function() return tg.gate_flags end) then - if tg.gate_flags.closed then - state = "closed" - else - state = "opened" - end + local btype = tg:getType() + state = flag_names[btype].closed - if tg.gate_flags.closing then - state = state .. (', closing (%d)'):format(tg.timer) + if tg.gate_flags[flag_names[btype].closing] then + state = state .. (', %s (%d)'):format(flag_names[btype].closing, tg.timer) + elseif tg.gate_flags[flag_names[btype].opening] then + state = state .. (', %s (%d)'):format(flag_names[btype].opening, tg.timer) end - if tg.gate_flags.opening then - state = state .. (', opening (%d)'):format(tg.timer) - end - end t = t .. @@ -146,8 +143,15 @@ function PullLever(opts) else leverPullJob(lever, opts.priority) end + print(leverDescribe(lever)) end +if dfhack_flags.module then + return +end + +local argparse = require('argparse') + local function parse_commandline(args) local opts = {} local commands = argparse.processArgsGetopt(args, {{ diff --git a/light-aquifers-only.lua b/light-aquifers-only.lua index 27c437bcfb..17bfa55482 100644 --- a/light-aquifers-only.lua +++ b/light-aquifers-only.lua @@ -8,19 +8,11 @@ if args[1] == 'help' then end if not dfhack.isWorldLoaded() then - qerror("Error: This script requires a world to be loaded.") + qerror('This script requires a world to be loaded.') end if dfhack.isMapLoaded() then - for _, block in ipairs(df.global.world.map.map_blocks) do - if block.flags.has_aquifer then - for k = 0, 15 do - for l = 0, 15 do - block.occupancy[k][l].heavy_aquifer = false - end - end - end - end + dfhack.run_command('aquifer', 'convert', 'light', '--all') return end diff --git a/linger.lua b/linger.lua index d40ea4f200..1e618827fc 100644 --- a/linger.lua +++ b/linger.lua @@ -1,66 +1,38 @@ --- Take over your killer in adventure mode. --- author: Atomic Chicken --- Meant as a substitute for the long gone "reincarnate" dfusion feature. --- calls "bodyswap.lua" to carry out the shift in unit control. - ---[====[ - -linger -====== -Enables the player to take control of their adventurer's killer. -Run this script after being presented with "You are deceased." - -The killer is identified by examining the historical event -generated when the adventurer died. If this is unsuccessful, -the killer is assumed to be the last unit to have attacked the -adventurer prior to their death. - -This will fail if the unit in question is no longer present -on the local map. - -(Adventure mode only!) - -]====] - local bodyswap = reqscript('bodyswap') if df.global.gamemode ~= df.game_mode.ADVENTURE then - qerror("This script can only be used in adventure mode!") + qerror("This script can only be used in adventure mode!") end local adventurer = df.nemesis_record.find(df.global.adventure.player_id).unit if not adventurer.flags2.killed then - qerror("Your adventurer hasn't died yet!") + qerror("Your adventurer hasn't died yet!") end function getHistoricalSlayer(unit) - local histFig = unit.hist_figure_id ~= -1 and df.historical_figure.find(unit.hist_figure_id) - if not histFig then - return - end + local histFig = unit.hist_figure_id ~= -1 and df.historical_figure.find(unit.hist_figure_id) + if not histFig then + return + end - local deathEvents = df.global.world.history.events_death - for i = #deathEvents-1,0,-1 do - local event = deathEvents[i] --as:df.history_event_hist_figure_diedst - if event.victim_hf == unit.hist_figure_id then - return df.historical_figure.find(event.slayer_hf) + local deathEvents = df.global.world.history.events_death + for i = #deathEvents - 1, 0, -1 do + local event = deathEvents[i] --as:df.history_event_hist_figure_diedst + if event.victim_hf == unit.hist_figure_id then + return df.historical_figure.find(event.slayer_hf) + end end - end end local slayerHistFig = getHistoricalSlayer(adventurer) local slayer = slayerHistFig and df.unit.find(slayerHistFig.unit_id) if not slayer then - slayer = df.unit.find(adventurer.relationship_ids.LastAttacker) + slayer = df.unit.find(adventurer.relationship_ids.LastAttacker) end if not slayer then - qerror("Killer not found!") + qerror("Killer not found!") elseif slayer.flags2.killed then - local slayerName = "" - if slayer.name.has_name then - slayerName = ", "..dfhack.TranslateName(slayer.name).."," - end - qerror("Your slayer"..slayerName.." is dead!") + qerror("Your slayer, " .. dfhack.df2console(dfhack.units.getReadableName(slayer)) .. " is dead!") end bodyswap.swapAdvUnit(slayer) diff --git a/list-agreements.lua b/list-agreements.lua index ca2b029f32..a2e8b125cd 100644 --- a/list-agreements.lua +++ b/list-agreements.lua @@ -1,38 +1,6 @@ --- list location agreements with guilds or religions - ---[====[ - -list-agreements -=============== - -Lists Guildhall and Temple agreements in fortress mode. -In addition: - -* Translates names of the associated Guilds and Orders respectively -* Displays worshiped Professions and Deities respectively -* Petition age and status satisfied, denied or expired, or blank for outstanding - -Usage:: - - list-agreements [options] +--@module = true -Examples: - - ``list-agreements`` - List outstanding, unfullfilled location agreements. - - ``list-agreements all`` - Lists all location agreements, whether satisfied, denied, or expired. - -Options: - -:all: list all agreements; past and present -:help: shows this help screen - -]====] -local playerfortid = df.global.plotinfo.site_id -- Player fortress id -local templeagreements = {} -- Table of agreements for temples in player fort -local guildhallagreements = {} -- Table of agreements for guildhalls in player fort +-- list location agreements with guilds or religions function get_location_name(loctier,loctype) local locstr = "Unknown Location" @@ -67,16 +35,14 @@ function get_petition_age(agr) local agr_year = agr.details[0].year local cur_year_tick = df.global.cur_year_tick local cur_year = df.global.cur_year - local del_year, del_year_tick - --delta, check to prevent off by 1 error, not validated - if cur_year_tick > agr_year_tick then - del_year = cur_year - agr_year - del_year_tick = cur_year_tick - agr_year_tick - else - del_year = cur_year - agr_year - 1 - del_year_tick = agr_year_tick - cur_year_tick + local del_year = cur_year - agr_year + local del_year_tick = cur_year_tick - agr_year_tick + if del_year_tick < 0 then + del_year = del_year - 1 + del_year_tick = del_year_tick + 403200 end - local julian_day = math.floor(del_year_tick / 1200) + 1 + -- Round up to the nearest day, since we don't do fractions + local julian_day = math.ceil(del_year_tick / 1200) local del_month = math.floor(julian_day / 28) local del_day = julian_day % 28 return {del_year,del_month,del_day} @@ -98,18 +64,26 @@ end function get_agr_party_name(agr) --assume party 0 is guild/order, 1 is local government as siteid = playerfortid local party_id = agr.parties[0].entity_ids[0] - local party_name = dfhack.TranslateName(df.global.world.entities.all[party_id].name, true) + local party_name = dfhack.translation.translateName(df.global.world.entities.all[party_id].name, true) if not party_name then party_name = 'An Unknown Entity or Group' end return party_name end +---@param agr df.agreement function get_deity_name(agr) - local religion_id = agr.details[0].data.Location.deity_data.Religion - local deities = df.global.world.entities.all[religion_id].relations.deities - if #deities == 0 then return 'An Unknown Deity' end - return dfhack.TranslateName(df.global.world.history.figures[deities[0]].name,true) + local practice_type = agr.details[0].data.Location.deity_type + local practice_id = agr.details[0].data.Location.deity_data.practice_id -- could be either entity_id or hf_id + if practice_type == df.religious_practice_type.WORSHIP_HFID then + local hf = df.historical_figure.find(practice_id) + if not hf then return 'An Unknown Deity' end + return dfhack.translation.translateName(hf.name, true) + else + local deities = df.global.world.entities.all[practice_id].relations.deities + if #deities == 0 then return 'An Unknown Deity' end + return dfhack.translation.translateName(df.global.world.history.figures[deities[0]].name, true) + end end --get resolution status, and string @@ -156,6 +130,29 @@ function generate_output(agr,loctype) end end +function get_fort_agreements(cull_resolved) + local t_agr, g_agr = {}, {} + local playerfortid = df.global.plotinfo.site_id -- Player fortress id + + for _, agr in pairs(df.agreement.get_vector()) do + if agr.details[0].data.Location.site == playerfortid then + if not is_resolved(agr) or not cull_resolved then + if get_location_type(agr) == df.abstract_building_type.TEMPLE then + table.insert(t_agr, agr) + elseif get_location_type(agr) == df.abstract_building_type.GUILDHALL then + table.insert(g_agr, agr) + end + end + end + end + + return t_agr, g_agr +end + +if dfhack_flags.module then + return +end + --------------------------------------------------------------------------- -- Main Script operation --------------------------------------------------------------------------- @@ -178,17 +175,7 @@ if cmd then end end -for _, agr in pairs(df.agreement.get_vector()) do - if agr.details[0].data.Location.site == playerfortid then - if not is_resolved(agr) or not cull_resolved then - if get_location_type(agr) == df.abstract_building_type.TEMPLE then - table.insert(templeagreements, agr) - elseif get_location_type(agr) == df.abstract_building_type.GUILDHALL then - table.insert(guildhallagreements, agr) - end - end - end -end +local templeagreements, guildhallagreements = get_fort_agreements(cull_resolved) print "-----------------------" print "Agreements for Temples:" diff --git a/list-waves.lua b/list-waves.lua index 8a6b6114bb..744c6dc646 100644 --- a/list-waves.lua +++ b/list-waves.lua @@ -1,209 +1,243 @@ --- displays migration wave information for citizens/units --- Written by Josh Cooper(cppcooper) sometime around Decement 2019, last modified: 2020-02-19 -utils ={} -utils = require('utils') -local validArgs = utils.invert({ - 'unit', - 'all', - 'granularity', - 'showarrival', - 'help' -}) -local args = utils.processArgs({...}, validArgs) -local help = [====[ - -list-waves -========== -This script displays information about migration waves of the specified citizen(s). - -Examples:: - - list-waves -all -showarrival -granularity days - list-waves -all -showarrival - list-waves -unit -granularity days - list-waves -unit - list-waves -unit -all -showarrival -granularity days - -**Selection options:** - -These options are used to specify what wave information to display - -``-unit``: - Displays the highlighted unit's arrival wave information - -``-all``: - Displays all citizens' arrival wave information - -**Other options:** - -``-granularity ``: - Specifies the granularity of wave enumeration: ``years``, ``seasons``, ``months``, ``days`` - If omitted, the default granularity is ``seasons``, the same as Dwarf Therapist - -``-showarrival``: - Shows the arrival information for the selected unit. - If ``-all`` is specified the info displayed will be relative to the - granularity used. Note: this option is always used when ``-unit`` is used. - -]====] - ---[[ -The script must loop through all active units in df.global.world.units.active and build -each wave one dwarf at a time. This requires calculating arrival information for each -dwarf and combining this information into a sort of unique wave ID number. After this -is finished these wave UIDs are looped through and normalized so they start at zero and -incremented by one for each new wave UID. - -As you might surmise, this is a relatively dumb script and every execution has the -worst case big O execution. I've taken the liberty of only counting citizen units, -this should only include dwarves as far as I know. - -]] - -selected = dfhack.gui.getSelectedUnit() -local ticks_per_day = 1200 -local ticks_per_month = 28 * ticks_per_day -local ticks_per_season = 3 * ticks_per_month -local ticks_per_year = 12 * ticks_per_month -local current_tick = df.global.cur_year_tick -local seasons = { - 'spring', - 'summer', - 'autumn', - 'winter', +-- displays migration wave information for citizens + +local argparse = require('argparse') +local utils = require('utils') + +local TICKS_PER_DAY = 1200 +local TICKS_PER_MONTH = 28 * TICKS_PER_DAY +local TICKS_PER_SEASON = 3 * TICKS_PER_MONTH + +local function get_season(year_ticks) + local seasons = { + 'spring', + 'summer', + 'autumn', + 'winter', + } + + return tostring(seasons[year_ticks // TICKS_PER_SEASON + 1]) +end + +local granularities = { + days={ + to_wave_fn=function(elink) return elink.year * 28 * 12 + elink.seconds // TICKS_PER_DAY end, + to_string_fn=function(elink) return ('year %d, month %d (%s), day %d'):format( + elink.year, elink.seconds // TICKS_PER_MONTH + 1, get_season(elink.seconds), elink.seconds // TICKS_PER_DAY + 1) end, + }, + months={ + to_wave_fn=function(elink) return elink.year * 12 + elink.seconds // TICKS_PER_MONTH end, + to_string_fn=function(elink) return ('year %d, month %d (%s)'):format( + elink.year, elink.seconds // TICKS_PER_MONTH + 1, get_season(elink.seconds)) end, + }, + seasons={ + to_wave_fn=function(elink) return elink.year * 4 + elink.seconds // TICKS_PER_SEASON end, + to_string_fn=function(elink) return ('the %s of year %d'):format(get_season(elink.seconds), elink.year) end, + }, + years={ + to_wave_fn=function(elink) return elink.year end, + to_string_fn=function(elink) return ('year %d'):format(elink.year) end, + }, } -function TableLength(table) - local count = 0 - for i,k in pairs(table) do - count = count + 1 - end - return count + +local plotinfo = df.global.plotinfo + +local function match_unit_id(unit_id, hf) + if not unit_id or hf.unit_id < 0 then return false end + return hf.unit_id == unit_id end ---sorted pairs -function spairs(t, cmp) - -- collect the keys - local keys = {} - for k,v in pairs(t) do - table.insert(keys,k) + +local function add_hfdata(opts, hfs, ev, hfid) + local hf = df.historical_figure.find(hfid) + if not hf or + dfhack.units.casteFlagSet(hf.race, hf.caste, df.caste_raw_flags.PET) or + dfhack.units.casteFlagSet(hf.race, hf.caste, df.caste_raw_flags.PET_EXOTIC) + then + return end + hfs[hfid] = hfs[hfid] or { + hf=hf, + year=ev.year, + seconds=ev.seconds, + dead=false, + petitioned=false, + highlight=match_unit_id(opts.unit_id, hf), + } +end - utils.sort_vector(keys, nil, cmp) +local function record_histfig_residency(opts, hfs, ev, enid, hfid) + if enid == plotinfo.group_id then + add_hfdata(opts, hfs, ev, hfid) + hfs[hfid].petitioned = true + end +end - -- return the iterator function - local i = 0 - return function() - i = i + 1 - if keys[i] then - return keys[i], t[keys[i]] +local function record_residency_agreement(opts, hfs, ev) + local agreement = df.agreement.find(ev.agreement_id) + if not agreement then return end + local found = false + for _,details in ipairs(agreement.details) do + if details.type == df.agreement_details_type.Residency and details.data.Residency.site == plotinfo.site_id then + found = true + break + end + end + if not found then return end + if #agreement.parties ~= 2 or #agreement.parties[1].entity_ids ~= 1 then return end + local enid = agreement.parties[1].entity_ids[0] + if #agreement.parties[0].histfig_ids == 1 then + local hfid = agreement.parties[0].histfig_ids[0] + record_histfig_residency(opts, hfs, ev, enid, hfid) + elseif #agreement.parties[0].entity_ids == 1 then + local troupe = df.historical_entity.find(agreement.parties[0].entity_ids[0]) + if troupe and troupe.type == df.historical_entity_type.PerformanceTroupe then + for _,hfid in ipairs(troupe.histfig_ids) do + record_histfig_residency(opts, hfs, ev, enid, hfid) + end end end end -function isDwarfCitizen(dwf) - return dfhack.units.isCitizen(dwf) -end - - - -waves={} -function getWave(dwf) - arrival_time = current_tick - dwf.curse.time_on_site - --print(string.format("Current year %s, arrival_time = %s, ticks_per_year = %s", df.global.cur_year, arrival_time, ticks_per_year)) - arrival_year = df.global.cur_year + (arrival_time // ticks_per_year) - arrival_season = 1 + (arrival_time % ticks_per_year) // ticks_per_season - arrival_month = 1 + (arrival_time % ticks_per_year) // ticks_per_month - arrival_day = 1 + ((arrival_time % ticks_per_year) % ticks_per_month) // ticks_per_day - if args.granularity then - if args.granularity == "days" then - wave = arrival_day + (100 * arrival_month) + (10000 * arrival_year) - elseif args.granularity == "months" then - wave = arrival_month + (100 * arrival_year) - elseif args.granularity == "seasons" then - wave = arrival_season + (10 * arrival_year) - elseif args.granularity == "years" then - wave = arrival_year - else - error("Invalid granularity value. Omit the option if you want 'seasons'. Note: plurals only.") + +-- returns map of histfig id to {hf=df.historical_figure, year=int, seconds=int, dead=bool, petitioned=bool, highlight=bool} +local function get_histfigs(opts) + local hfs = {} + for _,ev in ipairs(df.global.world.history.events) do + local evtype = ev:getType() + if evtype == df.history_event_type.CHANGE_HF_STATE then + if ev.site == plotinfo.site_id and ev.state == df.whereabouts_type.settler then + add_hfdata(opts, hfs, ev, ev.hfid) + end + elseif evtype == df.history_event_type.AGREEMENT_FORMED then + record_residency_agreement(opts, hfs, ev) + elseif evtype == df.history_event_type.HIST_FIGURE_DIED then + if hfs[ev.victim_hf] then + hfs[ev.victim_hf].dead = true + end + elseif evtype == df.history_event_type.HIST_FIGURE_REVIVED then + if hfs[ev.histfig] then + hfs[ev.histfig].dead = false + end end - else - wave = 10 * arrival_year + arrival_season end - if waves[wave] == nil then - waves[wave] = {} + return hfs +end + +local function cull_histfigs(opts, hfs) + for hfid,hfdata in pairs(hfs) do + if not opts.petitioners and hfdata.petitioned or + not opts.dead and hfdata.dead + then + hfs[hfid] = nil + end end - table.insert(waves[wave],dwf) - if args.unit and dwf == selected then - print(string.format(" Selected citizen arrived in the %s of year %d, month %d, day %d.",seasons[arrival_season],arrival_year,arrival_month,arrival_day)) + return hfs +end + +local function get_waves(opts) + local waves = {} + for _,hfdata in pairs(cull_histfigs(opts, get_histfigs(opts))) do + local waveid = granularities[opts.granularity].to_wave_fn(hfdata) + if not waveid then goto continue end + table.insert(ensure_keys(waves, waveid, hfdata.petitioned and 'petitioners' or 'migrants'), hfdata) + if not waves[waveid].desc then + waves[waveid].desc = granularities[opts.granularity].to_string_fn(hfdata) + end + waves[waveid].highlight = waves[waveid].highlight or hfdata.highlight + waves[waveid].size = (waves[waveid].size or 0) + 1 + ::continue:: end + return waves end -for _,v in ipairs(df.global.world.units.active) do - if isDwarfCitizen(v) then - getWave(v) +local function spairs(t) + local keys = {} + for k in pairs(t) do + table.insert(keys, k) + end + utils.sort_vector(keys) + local i = 0 + return function() + i = i + 1 + local k = keys[i] + if k then + return k, t[k] + end end end -if args.help or (not args.all and not args.unit) then - print(help) -else - zwaves = {} - i = 0 - for k,v in spairs(waves, utils.compare) do - if args.showarrival then - season = nil - month = nil - day = nil - if args.granularity then - if args.granularity == "days" then - year = k // 10000 - month = (k - (10000 * year)) // 100 - season = 1 + (month // 3) - day = k - ((100 * month) + (10000 * year)) - elseif args.granularity == "months" then - year = k // 100 - month = k - (100 * year) - season = 1 + (month // 3) - elseif args.granularity == "seasons" then - year = k // 10 - season = k - (10 * year) - elseif args.granularity == "years" then - year = k - end - else - year = k // 10 - season = k - (10 * year) - end +local function print_units(header, hfs) + print() + print((' %s:'):format(header)) + for _,hfdata in ipairs(hfs) do + local deceased = hfdata.dead and ' (deceased)' or '' + local highlight = hfdata.highlight and ' (selected unit)' or '' + local unit = df.unit.find(hfdata.hf.unit_id) + local name = unit and dfhack.units.getReadableName(unit) or dfhack.units.getReadableName(hfdata.hf) + print((' %s%s%s'):format(dfhack.df2console(name), deceased, highlight)) + end +end - if season ~= nil then - season = string.format("the %s of",seasons[season]) - else - season = "" - end - if month ~= nil then - month = string.format(", month %d", month) - else - month = "" - end - if day ~= nil then - day = string.format(", day %d", day) - else - day = "" - end - if args.all then - print(string.format(" Wave %d of citizens arrived in %s year %d%s%s.",i,season,year,month,day)) +local function print_waves(opts, waves) + local wave_num = 0 + for _,wave in spairs(waves) do + wave_num = wave_num + 1 + if opts.wave_filter and not opts.wave_filter[wave_num-1] then goto continue end + local highlight = wave.highlight and ' (includes selected unit)' or '' + print(('Wave %2d consisted of %2d unit(s) and arrived in %s%s'):format(wave_num-1, wave.size, wave.desc, highlight)) + if opts.names then + if wave.migrants and #wave.migrants > 0 then + print_units('Migrants', wave.migrants) end - end - zwaves[i] = waves[k] - for _,dwf in spairs(v, utils.compare) do - if args.unit and dwf == selected then - print(string.format(" Selected citizen belongs to wave %d",i)) + if wave.petitioners and #wave.petitioners > 0 then + print_units('Units who joined via petition', wave.petitioners) end + print() end - i = i + 1 + ::continue:: end +end - if args.all then - for i = 0, TableLength(zwaves)-1 do - print(string.format(" Wave %2s has %2d dwarf citizens.", i, #zwaves[i])) - end +local opts = { + granularity='seasons', + dead=true, + names=true, + petitioners=true, + unit_id=nil, + wave_filter=nil, +} +local help = false +local positionals = argparse.processArgsGetopt({...}, { + {'d', 'no-dead', handler=function() opts.dead = false end}, + {'g', 'granularity', hasArg=true, handler=function(arg) opts.granularity = arg end}, + {'h', 'help', handler=function() help = true end}, + {'n', 'no-names', handler=function() opts.names = false end}, + {'p', 'no-petitioners', handler=function() opts.petitioners = false end}, + {'u', 'unit', hasArg=true, handler=function(arg) opts.unit_id = tonumber(arg) end}, + }) + +if positionals[1] == 'help' or help == true then + print(dfhack.script_help()) + return +end + +if not dfhack.world.isFortressMode() or not dfhack.isMapLoaded() then + qerror('please load a fortress') +end + +if not granularities[opts.granularity] then + qerror(('Invalid granularity value: "%s". Omit the option if you want "seasons".'):format(opts.granularity)) +end + +for _,wavenum in ipairs(positionals) do + local wavenumnum = tonumber(wavenum) + if wavenumnum then + opts.wave_filter = opts.wave_filter or {} + opts.wave_filter[wavenumnum] = true end end + +if not opts.unit_id then + local selected_unit = dfhack.gui.getSelectedUnit(true) + opts.unit_id = selected_unit and selected_unit.id +end + +print_waves(opts, get_waves(opts)) diff --git a/load-save.lua b/load-save.lua index 47d5faf46a..1fff6953e0 100644 --- a/load-save.lua +++ b/load-save.lua @@ -12,7 +12,7 @@ if not loadgame_screen then end local found = false for idx, item in ipairs(title_screen.menu_line_id) do - if item == df.viewscreen_titlest.T_menu_line_id.Continue then + if item == df.main_choice_type.Continue then found = true title_screen.sel_menu_line = idx break diff --git a/locate-ore.lua b/locate-ore.lua index 6fbf9c67d5..4bad5386ef 100644 --- a/locate-ore.lua +++ b/locate-ore.lua @@ -2,15 +2,11 @@ local argparse = require('argparse') -local tile_attrs = df.tiletype.attrs - local function extractKeys(target_table) local keyset = {} - for k, _ in pairs(target_table) do table.insert(keyset, k) end - return keyset end @@ -40,24 +36,6 @@ local function getRandomFromTable(target_table) return target_table[key] end -local function randomSort(target_table) - local rnd = {} - table.sort( target_table, - function ( a, b) - rnd[a] = rnd[a] or math.random() - rnd[b] = rnd[b] or math.random() - return rnd[a] > rnd[b] - end ) -end - -local function sequence(min, max) - local tbl = {} - for i=min,max do - table.insert(tbl, i) - end - return tbl -end - local function sortTableBy(tbl, sort_func) local sorted = {} for _, value in pairs(tbl) do @@ -71,7 +49,7 @@ end local function matchesMetalOreById(mat_indices, target_ore) for _, mat_index in ipairs(mat_indices) do - local metal_raw = df.global.world.raws.inorganics[mat_index] + local metal_raw = df.global.world.raws.inorganics.all[mat_index] if metal_raw ~= nil and string.lower(metal_raw.id) == target_ore then return true end @@ -80,147 +58,123 @@ local function matchesMetalOreById(mat_indices, target_ore) return false end -local function findOreVeins(target_ore, show_undiscovered) - if target_ore then - target_ore = string.lower(target_ore) - end +local tile_attrs = df.tiletype.attrs - local ore_veins = {} - for _, block in pairs(df.global.world.map.map_blocks) do - for _, bevent in pairs(block.block_events) do +local function isValidMineralTile(opts, pos, check_designation) + if not opts.all and not dfhack.maps.isTileVisible(pos) then return false end + local tt = dfhack.maps.getTileType(pos) + if not tt then return false end + return tile_attrs[tt].material == df.tiletype_material.MINERAL and + (not check_designation or dfhack.maps.getTileFlags(pos).dig == df.tile_dig_designation.No) and + tile_attrs[tt].shape == df.tiletype_shape.WALL +end + +local function findOres(opts, check_designation, target_ore) + local ore_types = {} + for _, block in ipairs(df.global.world.map.map_blocks) do + for _, bevent in ipairs(block.block_events) do if bevent:getType() ~= df.block_square_event_type.mineral then goto skipevent end - local ino_raw = df.global.world.raws.inorganics[bevent.inorganic_mat] + local ino_raw = df.global.world.raws.inorganics.all[bevent.inorganic_mat] if not ino_raw.flags.METAL_ORE then goto skipevent end - if not show_undiscovered and not bevent.flags.discovered then + if not opts.all and not bevent.flags.discovered then goto skipevent end local lower_raw = string.lower(ino_raw.id) if not target_ore or lower_raw == target_ore or matchesMetalOreById(ino_raw.metal_ore.mat_index, target_ore) then - if not ore_veins[bevent.inorganic_mat] then - local vein_info = { + local positions = ensure_key(ore_types, bevent.inorganic_mat, { inorganic_id = ino_raw.id, inorganic_mat = bevent.inorganic_mat, metal_ore = ino_raw.metal_ore, positions = {} - } - ore_veins[bevent.inorganic_mat] = vein_info + }).positions + local block_pos = block.map_pos + for y=0,15 do + local row = bevent.tile_bitmask.bits[y] + for x=0,15 do + if row & (1 << x) == 1 then + local pos = xyz2pos(block_pos.x + x, block_pos.y + y, block_pos.z) + if isValidMineralTile(opts, pos, check_designation) then + table.insert(positions, pos) + end + end + end end - - table.insert(ore_veins[bevent.inorganic_mat].positions, block.map_pos) end - :: skipevent :: end end - return ore_veins + -- trim veins with zero valid tiles + for key,vein in pairs(ore_types) do + if #vein.positions == 0 then + ore_types[key] = nil + end + end + + return ore_types end local function designateDig(pos) local designation = dfhack.maps.getTileFlags(pos) designation.dig = df.tile_dig_designation.Default + dfhack.maps.getTileBlock(pos).flags.designated = true end -local function getOreDescription(ore) - local str = ("%s ("):format(string.lower(tostring(ore.inorganic_id))) - for _, mat_index in ipairs(ore.metal_ore.mat_index) do - local metal_raw = df.global.world.raws.inorganics[mat_index] - str = ("%s%s, "):format(str, string.lower(metal_raw.id)) +local function getOreDescription(opts, vein) + local visible = opts.all and '' or 'visible ' + local str = ('%5d %stile(s) of %s ('):format(#vein.positions, visible, tostring(vein.inorganic_id):lower()) + for _, mat_index in ipairs(vein.metal_ore.mat_index) do + local metal_raw = df.global.world.raws.inorganics.all[mat_index] + str = ('%s%s, '):format(str, string.lower(metal_raw.id)) end - str = str:gsub(", %s*$", "") .. ')' + str = str:gsub(', %s*$', '') .. ')' return str end -local options, args = { - help = false, - show_undiscovered = false -}, {...} +local function selectOreTile(opts, target_ore) + local ore_types = findOres(opts, true, target_ore) + local target_vein = getRandomFromTable(ore_types) + if target_vein == nil then + local visible = opts.all and '' or 'visible ' + qerror('Cannot find any undesignated ' .. visible .. target_ore) + end + local target_pos = target_vein.positions[math.random(#target_vein.positions)] + dfhack.gui.revealInDwarfmodeMap(target_pos, true, true) + designateDig(target_pos) + print(('Here is some %s'):format(target_vein.inorganic_id)) +end + +local opts = { + all=false, + help=false, +} -local positionals = argparse.processArgsGetopt(args, { - {'h', 'help', handler=function() options.help = true end}, - {'a', 'all', handler=function() options.show_undiscovered = true end}, +local positionals = argparse.processArgsGetopt({...}, { + {'a', 'all', handler=function() opts.all = true end}, + {'h', 'help', handler=function() opts.help = true end}, }) -if positionals[1] == "help" or options.help then +local target_ore = positionals[1] +if target_ore == 'help' or opts.help then print(dfhack.script_help()) return end -if positionals[1] == nil or positionals[1] == "list" then - print(dfhack.script_help()) - local veins = findOreVeins(nil, options.show_undiscovered) - local sorted = sortTableBy(veins, function(a, b) return #a.positions < #b.positions end) +if not target_ore or target_ore == 'list' then + local ore_types = findOres(opts, false) + local sorted = sortTableBy(ore_types, function(a, b) return #a.positions < #b.positions end) - for _, vein in ipairs(sorted) do - print(" " .. getOreDescription(vein)) + for _,ore_type in ipairs(sorted) do + print(' ' .. getOreDescription(opts, ore_type)) end - return else - local veins = findOreVeins(positionals[1], options.show_undiscovered) - local vein_keys = extractKeys(veins) - - if #vein_keys == 0 then - qerror("Cannot find unmined " .. positionals[1]) - end - - local target_vein = getRandomFromTable(veins) - if target_vein == nil then - -- really shouldn't happen at this point - qerror("Failed to choose vein from available choices") - end - - local pos_keyset = extractKeys(target_vein.positions) - local dxs = sequence(0, 15) - local dys = sequence(0, 15) - - randomSort(pos_keyset) - randomSort(dxs) - randomSort(dys) - - local target_pos = nil - for _, k in pairs(pos_keyset) do - local block_pos = target_vein.positions[k] - for _, dx in pairs(dxs) do - for _, dy in pairs(dys) do - local pos = { x = block_pos.x + dx, y = block_pos.y + dy, z = block_pos.z } - -- Enforce world boundaries - if pos.x <= 0 or pos.x >= df.global.world.map.x_count or pos.y <= 0 or pos.y >= df.global.world.map.y_count then - goto skip_pos - end - - if not options.show_undiscovered and not dfhack.maps.isTileVisible(pos) then - goto skip_pos - end - - local tile_type = dfhack.maps.getTileType(pos) - local tile_mat = tile_attrs[tile_type].material - local shape = tile_attrs[tile_type].shape - local designation = dfhack.maps.getTileFlags(pos) - if tile_mat == df.tiletype_material.MINERAL and designation.dig == df.tile_dig_designation.No and shape == df.tiletype_shape.WALL then - target_pos = pos - goto complete - end - - :: skip_pos :: - end - end - end - - :: complete :: - - if target_pos ~= nil then - dfhack.gui.pauseRecenter(target_pos) - designateDig(target_pos) - print(("Here is some %s at (%d, %d, %d)"):format(target_vein.inorganic_id, target_pos.x, target_pos.y, target_pos.z)) - else - qerror("Cannot find unmined " .. positionals[1]) - end + selectOreTile(opts, positionals[1]:lower()) end diff --git a/machine-toggle.lua b/machine-toggle.lua new file mode 100644 index 0000000000..e380711b3b --- /dev/null +++ b/machine-toggle.lua @@ -0,0 +1,376 @@ +-- Overlay to modify pressure plates and gear assemblies after construction +--@ module = true + +if not dfhack_flags.module then + qerror('machine-toggle cannot be called directly') +end + +local gui = require('gui') +local widgets = require('gui.widgets') +local overlay = require('plugins.overlay') + +local getBuild = dfhack.gui.getSelectedBuilding + +local FLUID_DEPTHS = {} +for i=1,8 do -- 0 to 7 + FLUID_DEPTHS[i] = {value=i-1, label=tostring(i-1)} +end + +local TRACK_WEIGHTS = {} +TRACK_WEIGHTS[1] = {value=1, label=tostring(1)..string.char(226)} -- 1 +for i=2,41 do -- 50 to 2000 + TRACK_WEIGHTS[i] = {value=(i-1)*50, label=('%d%s'):format((i-1)*50, string.char(226))} +end +TRACK_WEIGHTS[41].label = TRACK_WEIGHTS[41].label..'/Any' -- 2000 is Any for track_max + +local UNIT_WEIGHTS = {} +for i=1,200 do -- 1000 to 200000; actual creature labels would require checking raws + UNIT_WEIGHTS[i] = {value=i*1000, label=('(%d)'):format(i*1000)} +end + +PlateOverlay = defclass(PlateOverlay, overlay.OverlayWidget) +PlateOverlay.ATTRS{ + desc='Adds widgets for reconfiguring pressure plates after construction.', + default_pos={x=-54, y=32}, + version=1, + default_enabled=true, + viewscreens='dwarfmode/ViewSheets/BUILDING/Trap/PressurePlate', + frame={w=44, h=7}, + frame_style=gui.MEDIUM_FRAME, + frame_background=gui.CLEAR_PEN, +} + +local function clamp(value, low, high) + return math.min(high, math.max(low, value)) +end + +local function testBound(val, old_val, low, high) -- Hotkey cycle ends + return (val == low and old_val == high) or (val == high and old_val == low) +end + +function PlateOverlay:isFluidTab() return self.cur_tab == 1 or self.cur_tab == 2 end + +function PlateOverlay:isWaterTab() return self.cur_tab == 1 end + +function PlateOverlay:isTrackTab() return self.cur_tab == 3 end + +function PlateOverlay:isCreaturesTab() return self.cur_tab == 4 end + +function PlateOverlay:hasTextInputTab() return self.cur_tab == 3 or self.cur_tab == 4 end + +function PlateOverlay:swapTab(idx) + self.cur_tab = idx + self.subviews.min_text:setFocus(false) + self.subviews.max_text:setFocus(false) +end + +function PlateOverlay:changeFluidMin(val, old_val) + if testBound(val, old_val, 0, 7) then + return --Don't edge wrap; it would mess with the other setting + end + local p = getBuild().plate_info + if self:isWaterTab() then + p.water_min = val + p.water_max = math.max(val, p.water_max) + else + p.magma_min = val + p.magma_max = math.max(val, p.magma_max) + end +end + +function PlateOverlay:changeFluidMax(val, old_val) + if testBound(val, old_val, 0, 7) then + return + end + local p = getBuild().plate_info + if self:isWaterTab() then + p.water_max = val + p.water_min = math.min(val, p.water_min) + else + p.magma_max = val + p.magma_min = math.min(val, p.magma_min) + end +end + +function PlateOverlay:changeTrackMin(val, old_val) + if testBound(val, old_val, 1, 2000) then + return + end + local p = getBuild().plate_info + val = math.min(val, 2000) + p.track_min = val + p.track_max = math.max(val, p.track_max) +end + +function PlateOverlay:changeTrackMax(val, old_val) + if testBound(val, old_val, 1, 2000) then + return + end + local p = getBuild().plate_info + val = math.min(val, 2000) + p.track_max = val + p.track_min = math.min(val, p.track_min) +end + +function PlateOverlay:changeUnitMin(val, old_val) + if testBound(val, old_val, 1000, 200000) then + return + end + local p = getBuild().plate_info + val = math.min(val, 200000) + p.unit_min = val + p.unit_max = math.max(val, p.unit_max) +end + +function PlateOverlay:changeUnitMax(val, old_val) + if testBound(val, old_val, 1000, 200000) then + return + end + local p = getBuild().plate_info + val = math.min(val, 200000) + p.unit_max = val + p.unit_min = math.min(val, p.unit_min) +end + +function PlateOverlay:inputMin(text) + local val = math.tointeger(text) or -1 + if val < 0 then + return -- Bad input + elseif self:isTrackTab() then + self:changeTrackMin(val, 0) + elseif self:isCreaturesTab() then + self:changeUnitMin(val, 0) + end +end + +function PlateOverlay:inputMax(text) + local val = math.tointeger(text) or -1 + if val < 0 then + return + elseif self:isTrackTab() then + self:changeTrackMax(val, 0) + elseif self:isCreaturesTab() then + self:changeUnitMax(val, 0) + end +end + +function PlateOverlay:render(dc) + local p = getBuild().plate_info + local sv = self.subviews + + if self:isFluidTab() then -- Water or Magma + local f = self:isWaterTab() and 'water' or 'magma' + sv.fluid_toggle:setOption(p.flags[f]) + sv.fluid_min.option_idx = p[f..'_min']+1 + sv.fluid_max.option_idx = p[f..'_max']+1 + + elseif self:isTrackTab() then + sv.track_toggle:setOption(p.flags.track) + sv.track_min.option_idx = clamp(p.track_min//50+1, 1, 41) + sv.track_max.option_idx = clamp(p.track_max//50+1, 1, 41) + + if not sv.min_text.focus then + sv.min_text:setText(tostring(p.track_min)) + end + if not sv.max_text.focus then + sv.max_text:setText(tostring(p.track_max)) + end + elseif self:isCreaturesTab() then + sv.unit_toggle:setOption(p.flags.units) + sv.citizen_toggle:setOption(p.flags.citizens) + sv.unit_min.option_idx = clamp(p.unit_min//1000, 1, 200) + sv.unit_max.option_idx = clamp(p.unit_max//1000, 1, 200) + + if not sv.min_text.focus then + sv.min_text:setText(tostring(p.unit_min)) + end + if not sv.max_text.focus then + sv.max_text:setText(tostring(p.unit_max)) + end + end + PlateOverlay.super.render(self, dc) +end + +function PlateOverlay:init() + self.cur_tab = 1 -- 1:Water, 2:Magma, 3:Track, 4:Creatures + + self:addviews{ + widgets.TabBar{ + frame={t=0, l=0}, + labels={ + 'Water', + 'Magma', + 'Track', + 'Creatures', + }, + on_select=self:callback('swapTab'), + get_cur_page=function() return self.cur_tab end, + }, + -- Water and Magma (shared widgets) + widgets.ToggleHotkeyLabel{ + view_id='fluid_toggle', + frame={t=2, l=0, w=13}, + label=function() return self:isWaterTab() and 'Water:' or 'Magma:' end, + key='CUSTOM_SHIFT_X', + on_change=function() + local p = getBuild().plate_info + local f = self:isWaterTab() and 'water' or 'magma' + p.flags[f] = not p.flags[f] + end, + visible=self:callback('isFluidTab'), + }, + widgets.CycleHotkeyLabel{ + view_id='fluid_min', + frame={t=3, l=0, w=16}, + label='Min Depth:', + key_back='CUSTOM_SHIFT_E', + key='CUSTOM_SHIFT_R', + options=FLUID_DEPTHS, + on_change=self:callback('changeFluidMin'), + visible=self:callback('isFluidTab'), + }, + widgets.CycleHotkeyLabel{ + view_id='fluid_max', + frame={t=3, r=1, w=16}, + label='Max Depth:', + key_back='CUSTOM_SHIFT_C', + key='CUSTOM_SHIFT_V', + options=FLUID_DEPTHS, + on_change=self:callback('changeFluidMax'), + visible=self:callback('isFluidTab'), + }, + widgets.RangeSlider{ + frame={t=4, l=0}, + num_stops=#FLUID_DEPTHS, + get_left_idx_fn=function() return self.subviews.fluid_min:getOptionValue()+1 end, + get_right_idx_fn=function() return self.subviews.fluid_max:getOptionValue()+1 end, + on_left_change=function(idx) self.subviews.fluid_min:setOption(idx-1, true) end, + on_right_change=function(idx) self.subviews.fluid_max:setOption(idx-1, true) end, + visible=self:callback('isFluidTab'), + }, + -- Track + widgets.ToggleHotkeyLabel{ + view_id='track_toggle', + frame={t=2, l=0, w=17}, + label='Minecarts:', + key='CUSTOM_SHIFT_X', + on_change=function() + local p = getBuild().plate_info + p.flags.track = not p.flags.track + end, + visible=self:callback('isTrackTab'), + }, + widgets.CycleHotkeyLabel{ + view_id='track_min', + frame={t=3, l=0, w=25}, + label='Min Weight:', + key_back='CUSTOM_SHIFT_E', + key='CUSTOM_SHIFT_R', + options=TRACK_WEIGHTS, + on_change=self:callback('changeTrackMin'), + visible=self:callback('isTrackTab'), + }, + widgets.CycleHotkeyLabel{ + view_id='track_max', + frame={t=4, l=0, w=25}, + label='Max Weight:', + key_back='CUSTOM_SHIFT_C', + key='CUSTOM_SHIFT_V', + options=TRACK_WEIGHTS, + on_change=self:callback('changeTrackMax'), + visible=self:callback('isTrackTab'), + }, + -- Creatures + widgets.ToggleHotkeyLabel{ + view_id='unit_toggle', + frame={t=2, l=0, w=17}, + label='Creatures:', + key='CUSTOM_SHIFT_X', + on_change=function() + local p = getBuild().plate_info + p.flags.units = not p.flags.units + end, + visible=self:callback('isCreaturesTab'), + }, + widgets.ToggleHotkeyLabel{ + view_id='citizen_toggle', + frame={t=2, r=0, w=16}, + label='Citizens:', + key='CUSTOM_X', + on_change=function() + local p = getBuild().plate_info + p.flags.citizens = not p.flags.citizens + end, + visible=self:callback('isCreaturesTab'), + }, + widgets.CycleHotkeyLabel{ + view_id='unit_min', + frame={t=3, l=0, w=24}, + label='Min Weight:', + key_back='CUSTOM_SHIFT_E', + key='CUSTOM_SHIFT_R', + options=UNIT_WEIGHTS, + on_change=self:callback('changeUnitMin'), + visible=self:callback('isCreaturesTab'), + }, + widgets.CycleHotkeyLabel{ + view_id='unit_max', + frame={t=4, l=0, w=24}, + label='Max Weight:', + key_back='CUSTOM_SHIFT_C', + key='CUSTOM_SHIFT_V', + options=UNIT_WEIGHTS, + on_change=self:callback('changeUnitMax'), + visible=self:callback('isCreaturesTab'), + }, + -- Text input (shared widgets) + widgets.EditField{ + view_id='min_text', + frame={t=3, r=0, w=16}, + key='CUSTOM_SHIFT_T', + on_submit=self:callback('inputMin'), + visible=self:callback('hasTextInputTab'), + }, + widgets.EditField{ + view_id='max_text', + frame={t=4, r=0, w=16}, + key='CUSTOM_SHIFT_B', + on_submit=self:callback('inputMax'), + visible=self:callback('hasTextInputTab'), + }, + } +end + +GearOverlay = defclass(GearOverlay, overlay.OverlayWidget) +GearOverlay.ATTRS{ + desc='Adds widget for toggling gear assemblies.', + default_pos={x=-83, y=32}, + version=1, + default_enabled=true, + viewscreens='dwarfmode/ViewSheets/BUILDING/GearAssembly', + frame={w=15, h=3}, + frame_style=gui.MEDIUM_FRAME, + frame_background=gui.CLEAR_PEN, +} + +function GearOverlay:render(dc) + self.subviews.gear_toggle:setOption(not getBuild().gear_flags.disengaged) + GearOverlay.super.render(self, dc) +end + +function GearOverlay:init() + self:addviews{ + widgets.ToggleHotkeyLabel{ + view_id='gear_toggle', + frame={t=0, l=0}, + label='State:', + key='CUSTOM_SHIFT_X', + on_change=function() getBuild():setTriggerState(0) end, + }, + } +end + +OVERLAY_WIDGETS = { + pressureplate=PlateOverlay, + gearassembly=GearOverlay, +} diff --git a/make-legendary.lua b/make-legendary.lua index 97144b10cf..a0c96c0cb2 100644 --- a/make-legendary.lua +++ b/make-legendary.lua @@ -1,140 +1,110 @@ -- Make a skill or skills of a unit Legendary +5 --- by vjek -local help = [====[ -make-legendary -============== -Makes the selected dwarf legendary in one skill, a group of skills, or all -skills. View groups with ``make-legendary classes``, or all skills with -``make-legendary list``. Use ``make-legendary MINING`` when you need something -dug up, or ``make-legendary all`` when only perfection will do. +local utils = require('utils') -]====] - --- this function will return the number of elements, starting at zero. --- useful for counting things where #foo doesn't work -function count_this(to_be_counted) - local count = -1 - local var1 = "" - while var1 ~= nil do - count = count + 1 - var1 = (to_be_counted[count]) - end - count=count-1 - return count +function getName(unit) + return dfhack.df2console(dfhack.units.getReadableName(unit)) end -function getName(unit) - return dfhack.df2console(dfhack.TranslateName(dfhack.units.getVisibleName(unit))) +function legendize(unit, skill_idx) + if skill_idx >= 0 and skill_idx <= df.job_skill._last_item then + utils.insert_or_update(unit.status.current_soul.skills, + {new=true, id=skill_idx, rating=df.skill_rating.Legendary5}, + 'id') + end end function make_legendary(skillname) - local skillnamenoun,skillnum - local unit=dfhack.gui.getSelectedUnit() - - if unit==nil then - print ("No unit under cursor! Aborting with extreme prejudice.") + local unit = dfhack.gui.getSelectedUnit() + if not unit then return end - if (df.job_skill[skillname]) then - skillnamenoun = df.job_skill.attrs[df.job_skill[skillname]].caption_noun - else - print ("The skill name provided is not in the list.") - return + local skillnum = df.job_skill[skillname] + if not skillnum then + qerror('The skill name provided is not in the list') end - if skillnamenoun ~= nil then - local utils = require 'utils' - skillnum = df.job_skill[skillname] - utils.insert_or_update(unit.status.current_soul.skills, { new = true, id = skillnum, rating = 20 }, 'id') - print (getName(unit) .. " is now a Legendary "..skillnamenoun) - else - print ("Empty skill name noun, bailing out!") - return + local skillnamenoun = df.job_skill.attrs[skillnum].caption_noun + if not skillnamenoun then + qerror('skill name noun not found') end + + legendize(unit, skillnum) + print(getName(unit) .. ' is now a legendary ' .. skillnamenoun) end 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]) + for i, name in ipairs(df.job_skill) do + local attr = df.job_skill.attrs[i] + if attr.caption then + print(('%s (%s), Type: %s'):format( + name, attr.caption, df.job_skill_class[attr.type])) + end end - print ("Provide the UPPER CASE argument, for example: PROCESSPLANTS rather than Threshing") + print() + print('Provide the UPPER_CASE argument, for example: ENGRAVE_STONE rather than Engraving.') end function BreathOfArmok() - local unit=dfhack.gui.getSelectedUnit() - if unit==nil then - print ("No unit under cursor! Aborting with extreme prejudice.") + local unit = dfhack.gui.getSelectedUnit() + if not unit then return end - local i - local count_max = count_this(df.job_skill) - local utils = require 'utils' - for i=0, count_max do - utils.insert_or_update(unit.status.current_soul.skills, { new = true, id = i, rating = 20 }, 'id') + for i in ipairs(df.job_skill) do + if i >= 0 then + legendize(unit, i) + end end - print ("The breath of Armok has engulfed "..getName(unit)) + print('The breath of Armok has engulfed ' .. getName(unit)) end function LegendaryByClass(skilltype) - local unit=dfhack.gui.getSelectedUnit() - if unit==nil then - print ("No unit under cursor! Aborting with extreme prejudice.") + local unit = dfhack.gui.getSelectedUnit() + if not unit then return end - - local utils = require 'utils' - 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 skilltype == skillclass then - print ("Skill "..df.job_skill.attrs[i].caption.." is type: "..skillclass.." and is now Legendary for "..getName(unit)) - utils.insert_or_update(unit.status.current_soul.skills, { new = true, id = i, rating = 20 }, 'id') + for i in ipairs(df.job_skill) do + local attr = df.job_skill.attrs[i] + if skilltype == df.job_skill_class[attr.type] then + print(('%s skill %s is now legendary for %s'):format( + skilltype, attr.caption, getName(unit))) + legendize(unit, i) end end end function PrintSkillClassList() - local i - local count_max = count_this(df.job_skill_class) - for i=0, count_max do - print(df.job_skill_class[i]) + print('Skill class names:') + for _, name in ipairs(df.job_skill_class) do + print(' ' .. name) end - print ("Provide one of these arguments, and all skills of that type will be made Legendary") - print ("For example: Medical will make all medical skills legendary") + print() + print('Provide one of these arguments, and all skills of that type will be made Legendary') + print('For example: Medical will make all medical skills legendary') end --main script operation starts here ---- local opt = ... -local skillname -if opt then - if opt=="list" then - PrintSkillList() - return - end - if opt=="classes" then - PrintSkillClassList() - return - end - if opt=="all" then - BreathOfArmok() - return - end - if opt=="Normal" or opt=="Medical" or opt=="Personal" or opt=="Social" or opt=="Cultural" or opt=="MilitaryWeapon" or opt=="MilitaryAttack" or opt=="MilitaryDefense" or opt=="MilitaryMisc" or opt=="MilitaryUnarmed" then - LegendaryByClass(opt) - return - end - skillname = opt -else - print(help) +if not opt then + print(dfhack.script_help()) return end -make_legendary(skillname) +if opt == 'list' then + PrintSkillList() + return +elseif opt == 'classes' then + PrintSkillClassList() + return +elseif opt == 'all' then + BreathOfArmok() + return +elseif df.job_skill_class[opt] then + LegendaryByClass(opt) + return +else + make_legendary(opt) +end diff --git a/makeown.lua b/makeown.lua index 3a18060a75..f7657f3250 100644 --- a/makeown.lua +++ b/makeown.lua @@ -1,5 +1,7 @@ --@module=true +local utils = require('utils') + local function get_translation(race_id) local race_name = df.global.world.raws.creatures.all[race_id].creature_id for _,translation in ipairs(df.global.world.raws.language.translations) do @@ -30,43 +32,185 @@ function name_unit(unit) unit.name.parts_of_speech.RearCompound = df.part_of_speech.Verb3rdPerson unit.name.type = df.language_name_type.Figure unit.name.has_name = true + + local hf = df.historical_figure.find(unit.hist_figure_id) + if not hf then return end + hf.name:assign(unit.name) end local function fix_clothing_ownership(unit) - -- extracted/translated from tweak makeown plugin - -- to be called by tweak-fixmigrant/makeown - -- units forced into the fort by removing the flags do not own their clothes - -- which has the result that they drop all their clothes and become unhappy because they are naked + if #unit.uniform.uniform_drop == 0 then return end + -- makeown'd units do not own their clothes which results in them dropping all their clothes and + -- becoming unhappy because they are naked -- so we need to make them own their clothes and add them to their uniform - local fixcount = 0 --int fixcount = 0; - for j=0,#unit.inventory-1 do --for(size_t j=0; jinventory.size(); j++) - local inv_item = unit.inventory[j] --unidf::unit_inventory_item* inv_item = unit->inventory[j]; - local item = inv_item.item --df::item* item = inv_item->item; - -- unforbid items (for the case of kidnapping caravan escorts who have their stuff forbidden by default) - -- moved forbid false to inside if so that armor/weapons stay equiped - if inv_item.mode == df.unit_inventory_item.T_mode.Worn then --if(inv_item->mode == df::unit_inventory_item::T_mode::Worn) - -- ignore armor? - -- it could be leather boots, for example, in which case it would not be nice to forbid ownership - --if(item->getEffectiveArmorLevel() != 0) - -- continue; - - if not dfhack.items.getOwner(item) then --if(!Items::getOwner(item)) - if dfhack.items.setOwner(item,unit) then --if(Items::setOwner(item, unit)) - item.flags.forbid = false --inv_item->item->flags.bits.forbid = 0; - -- add to uniform, so they know they should wear their clothes - unit.military.uniforms[0]:insert('#',item.id) --insert_into_vector(unit->military.uniforms[0], item->id); - fixcount = fixcount + 1 --fixcount++; - else - dfhack.printerr("makeown: could not change ownership for an item!") - end - end + for _, inv_item in ipairs(unit.inventory) do + local item = inv_item.item + -- only act on worn items, not weapons + if inv_item.mode == df.inv_item_role_type.Worn and + not dfhack.items.getOwner(item) and + dfhack.items.setOwner(item, unit) + then + -- unforbid items (for the case of kidnapping caravan escorts who have their stuff forbidden by default) + item.flags.forbid = false + unit.uniform.uniforms[df.unit_uniform_mode_type.CLOTHING]:insert('#', item.id) end end -- clear uniform_drop (without this they would drop their clothes and pick them up some time later) - -- dirty? - unit.military.uniform_drop:resize(0) --unit->military.uniform_drop.clear(); - ----out << "ownership for " << fixcount << " clothes fixed" << endl; - print("makeown: claimed ownership for "..tostring(fixcount).." worn items") + unit.uniform.uniform_drop:resize(0) +end + +function clear_enemy_status(unit) + if unit.enemy.enemy_status_slot <= -1 then return end + + local status_cache = df.global.world.enemy_status_cache + local status_slot = unit.enemy.enemy_status_slot + + unit.enemy.enemy_status_slot = -1 + status_cache.slot_used[status_slot] = false + + for index in ipairs(status_cache.rel_map[status_slot]) do + status_cache.rel_map[status_slot][index].ur = -1 + end + + for index in ipairs(status_cache.rel_map) do + status_cache.rel_map[index][status_slot].ur = -1 + end + + -- TODO: what if there were status slots taken above status_slot? + -- does everything need to be moved down by one to fill the gap? + if status_cache.next_slot > status_slot then + status_cache.next_slot = status_slot + end + + return true +end + +function remove_from_conflict(unit) + -- Remove the unit from any conflict activity + -- They will prompty re-engage if there is an actual enemy around + local to_remove = {} + for act_idx,act_id in ipairs(unit.activities) do + local act = df.activity_entry.find(act_id) + if not act or act.type ~= df.activity_entry_type.Conflict then goto continue end + for _,ev in ipairs(act.events) do + if ev:getType() ~= df.activity_event_type.Conflict then goto next_ev end + for _,side in ipairs(ev.sides) do + utils.erase_sorted(side.histfig_ids, unit.hist_figure_id) + utils.erase_sorted(side.unit_ids, unit.id) + end + ::next_ev:: + end + table.insert(to_remove, 1, act_idx) + ::continue:: + end + + for _,act_idx in ipairs(to_remove) do + unit.activities:erase(act_idx) + end + + -- return whether we removed unit from any conflicts + return #to_remove > 0 +end + +local prof_map = { + [df.profession.MERCHANT]=df.profession.TRADER, + [df.profession.THIEF]=df.profession.STANDARD, + [df.profession.MASTER_THIEF]=df.profession.STANDARD, + [df.profession.CRIMINAL]=df.profession.STANDARD, + [df.profession.DRUNK]=df.profession.STANDARD, + [df.profession.MONSTER_SLAYER]=df.profession.STANDARD, + [df.profession.SCOUT]=df.profession.STANDARD, + [df.profession.BEAST_HUNTER]=df.profession.STANDARD, + [df.profession.SNATCHER]=df.profession.STANDARD, + [df.profession.MERCENARY]=df.profession.STANDARD, +} + +local function sanitize_profession(prof) + return prof_map[prof] or prof +end + +local hostile_jobs = utils.invert{ + df.job_type.Kidnap, + df.job_type.HeistItem, + df.job_type.AcceptHeistItem, +} + +local function cancel_hostile_jobs(job) + if not job or not hostile_jobs[job.job_type] then return end + dfhack.job.removeJob(job) +end + +local function fix_unit(unit) + unit.flags1.marauder = false; + unit.flags1.merchant = false; + unit.flags1.forest = false; + unit.flags1.diplomat = false; + unit.flags1.active_invader = false; + unit.flags1.hidden_in_ambush = false; + unit.flags1.invader_origin = false; + unit.flags1.coward = false + unit.flags1.hidden_ambusher = false; + unit.flags1.invades = false; + unit.flags2.underworld = false; --or on a demon! + unit.flags2.resident = false; + unit.flags2.visitor_uninvited = false; --in case you use makeown on a beast :P + unit.flags2.visitor = false; + unit.flags3.guest = false; + unit.flags4.invader_waits_for_parley = false; + unit.flags4.agitated_wilderness_creature = false; + + unit.civ_id = df.global.plotinfo.civ_id; + + unit.profession = sanitize_profession(unit.profession) + unit.profession2 = sanitize_profession(unit.profession2) + + unit.invasion_id = -1 + unit.enemy.army_controller_id = -1 + unit.enemy.army_controller = nil + + unit.relationship_ids.GroupLeader = -1 + for _,other in ipairs(df.global.world.units.active) do + if other.relationship_ids.GroupLeader == unit.id then + other.relationship_ids.GroupLeader = -1 + end + end + + -- remove unit from all current conflicts + unit.activities:resize(0) + for _,act in ipairs(df.global.world.activities.all) do + if act.type ~= df.activity_entry_type.Conflict then goto continue end + for _,ev in ipairs(act.events) do + if ev:getType() ~= df.activity_event_type.Conflict then goto next_ev end + for _,side in ipairs(ev.sides) do + utils.erase_sorted(side.histfig_ids, unit.hist_figure_id) + utils.erase_sorted(side.unit_ids, unit.id) + end + ::next_ev:: + end + ::continue:: + end + + clear_enemy_status(unit) + remove_from_conflict(unit) + cancel_hostile_jobs(unit.job.current_job) +end + +local function add_to_entity(hf, eid) + local en = df.historical_entity.find(eid) + if not en then return end + utils.insert_sorted(en.histfig_ids, hf.id) + utils.insert_sorted(en.hist_figures, hf, 'id') + if hf.nemesis_id < 0 then return end + utils.insert_sorted(en.nemesis_ids, hf.nemesis_id) +end + +local function remove_from_entity(hf, eid) + local en = df.historical_entity.find(eid) + if not en then return end + utils.erase_sorted(en.histfig_ids, hf.id) + utils.erase_sorted(en.hist_figures, hf, 'id') + if hf.nemesis_id < 0 then return end + utils.erase_sorted(en.nemesis_ids, hf.nemesis_id) end local function entity_link(hf, eid, do_event, add, replace_idx) @@ -76,16 +220,14 @@ local function entity_link(hf, eid, do_event, add, replace_idx) local link = add and df.histfig_entity_link_memberst:new() or df.histfig_entity_link_former_memberst:new() link.entity_id = eid - print("created entity link: "..tostring(eid)) if replace_idx > -1 then local e = hf.entity_links[replace_idx] link.link_strength = (e.link_strength > 3) and (e.link_strength - 2) or e.link_strength hf.entity_links[replace_idx] = link -- replace member link with former member link e:delete() - print("replaced entity link") else - link.link_strength = 100 + link.link_strength = 100 hf.entity_links:insert('#', link) end @@ -101,223 +243,95 @@ local function entity_link(hf, eid, do_event, add, replace_idx) df.global.world.history.events:insert('#',event) df.global.hist_event_next_id = df.global.hist_event_next_id + 1 end + + if add then + add_to_entity(hf, eid) + else + remove_from_entity(hf, eid) + end end -local function change_state(hf, site_id, pos) - hf.info.whereabouts.whereabouts_type = 1 -- state? arrived? - hf.info.whereabouts.site = site_id +local function fix_whereabouts(hf, site_id) + hf.info.whereabouts.state = df.whereabouts_type.settler + if hf.info.whereabouts.site_id == site_id then return end + hf.info.whereabouts.site_id = site_id local event = df.history_event_change_hf_statest:new() event.year = df.global.cur_year event.seconds = df.global.cur_year_tick event.id = df.global.hist_event_next_id event.hfid = hf.id - event.state = 1 - event.reason = 13 --decided to stay on a whim i guess + event.state = df.whereabouts_type.settler + event.reason = df.history_event_reason.whim event.site = site_id event.region = -1 event.layer = -1 - event.region_pos:assign(pos) - df.global.world.history.events:insert('#',event) + event.region_pos:assign(df.world_site.find(site_id).pos) + df.global.world.history.events:insert('#', event) df.global.hist_event_next_id = df.global.hist_event_next_id + 1 end +local function fix_histfig(unit) + local hf = df.historical_figure.find(unit.hist_figure_id) + if not hf then return end -function make_citizen(unit) - local civ_id = df.global.plotinfo.civ_id --get civ id - local group_id = df.global.plotinfo.group_id --get group id - local site_id = df.global.plotinfo.site_id --get site id - - local fortent = df.historical_entity.find(group_id) --get fort's entity - local civent = df.historical_entity.find(civ_id) + local civ_id = df.global.plotinfo.civ_id + local group_id = df.global.plotinfo.group_id - local region_pos = df.world_site.find(site_id).pos -- used with state events and hf state - - local hf - if unit.flags1.important_historical_figure or unit.flags2.important_historical_figure then --if its a histfig - hf = df.historical_figure.find(unit.hist_figure_id) --then get the histfig + hf.civ_id = civ_id + if hf.info and hf.info.whereabouts then + fix_whereabouts(hf, df.global.plotinfo.site_id) end - if not hf then --if its not a histfig then make it a histfig - --new_hf = true - hf = df.new(df.historical_figure) - hf.id = df.global.hist_figure_next_id - df.global.hist_figure_next_id = df.global.hist_figure_next_id+1 - hf.profession = unit.profession - hf.race = unit.race - hf.caste = unit.caste - hf.sex = unit.sex - hf.appeared_year = df.global.cur_year - hf.born_year = unit.birth_year - hf.born_seconds = unit.birth_time - hf.curse_year = unit.curse_year - hf.curse_seconds = unit.curse_time - hf.birth_year_bias=unit.birth_year_bias - hf.birth_time_bias=unit.birth_time_bias - hf.old_year = unit.old_year - hf.old_seconds = unit.old_time - hf.died_year = -1 - hf.died_seconds = -1 - hf.name:assign(unit.name) - hf.civ_id = unit.civ_id - hf.population_id = unit.population_id - hf.breed_id = -1 - hf.unit_id = unit.id - - --history_event_add_hf_entity_linkst not reported for civ on starting 7 - entity_link(hf, civ_id, false) -- so lets skip event here - entity_link(hf, group_id) - - hf.info = df.historical_figure_info:new() - hf.info.whereabouts = df.historical_figure_info.T_whereabouts:new() - hf.info.whereabouts.region_id = -1; - hf.info.whereabouts.underground_region_id = -1; - hf.info.whereabouts.army_id = -1; - hf.info.whereabouts.unk_1 = -1; - hf.info.whereabouts.unk_2 = -1; - change_state(hf, df.global.plotinfo.site_id, region_pos) - - - --lets skip skills for now - --local skills = df.historical_figure_info.T_skills:new() -- skills snap shot - -- ... - --info.skills = skills - - df.global.world.history.figures:insert('#', hf) - - --new_hf_loc = df.global.world.history.figures[#df.global.world.history.figures - 1] - fortent.histfig_ids:insert('#', hf.id) - fortent.hist_figures:insert('#', hf) - civent.histfig_ids:insert('#', hf.id) - civent.hist_figures:insert('#', hf) - - unit.flags1.important_historical_figure = true - unit.flags2.important_historical_figure = true - unit.hist_figure_id = hf.id - unit.hist_figure_id2 = hf.id - print("makeown: created historical figure: "..tostring(hf.id)) - else - -- only insert into civ/fort if not already there - -- Migrants change previous histfig_entity_link_memberst to histfig_entity_link_former_memberst - -- for group entities, add link_member for new group, and reports events for remove from group, - -- remove from civ, change state, add civ, and add group - - hf.civ_id = civ_id -- ensure current civ_id - - local found_civlink = false - local found_fortlink = false - local v = hf.entity_links - for k=#v-1,0,-1 do - if df.histfig_entity_link_memberst:is_instance(v[k]) then - entity_link(hf, v[k].entity_id, true, false, k) + -- make former members of any civ/site that isn't ours that they are currently a member of + local found_civlink = false + local found_fortlink = false + for k=#hf.entity_links-1,0,-1 do + local el = hf.entity_links[k] + if df.histfig_entity_link_memberst:is_instance(el) then + local eid = el.entity_id + local he = df.historical_entity.find(eid) + if not he then goto continue end + if he.type == df.historical_entity_type.Civilization then + if he.id == civ_id then + found_civlink = true + goto continue + end + elseif he.type == df.historical_entity_type.SiteGovernment then + if he.id == group_id then + found_fortlink = true + goto continue + end + else + -- don't touch other kinds of memberships + goto continue end + entity_link(hf, eid, true, false, k) + ::continue:: end + end - if hf.info and hf.info.whereabouts then - change_state(hf, df.global.plotinfo.site_id, region_pos) - -- leave info nil if not found for now - end - - if not found_civlink then entity_link(hf,civ_id) end - if not found_fortlink then entity_link(hf,group_id) end + -- add them to our civ/site if they aren't already + if not found_civlink then entity_link(hf, civ_id) end + if not found_fortlink then entity_link(hf, group_id) end - --change entity_links - local found = false - for _,v in ipairs(civent.histfig_ids) do - if v == hf.id then found = true; break end - end - if not found then - civent.histfig_ids:insert('#', hf.id) - civent.hist_figures:insert('#', hf) - end - found = false - for _,v in ipairs(fortent.histfig_ids) do - if v == hf.id then found = true; break end - end - if not found then - fortent.histfig_ids:insert('#', hf.id) - fortent.hist_figures:insert('#', hf) - end - print("makeown: migrated historical figure") - end -- hf - - local nemesis = dfhack.units.getNemesis(unit) - if not nemesis then - nemesis = df.nemesis_record:new() - nemesis.figure = hf - nemesis.unit = unit - nemesis.unit_id = unit.id - nemesis.save_file_id = civent.save_file_id - nemesis.unk10, nemesis.unk11, nemesis.unk12 = -1, -1, -1 - --group_leader_id = -1 - nemesis.id = df.global.nemesis_next_id - nemesis.member_idx = civent.next_member_idx - civent.next_member_idx = civent.next_member_idx + 1 - - df.global.world.nemesis.all:insert('#', nemesis) - df.global.nemesis_next_id = df.global.nemesis_next_id + 1 - - nemesis_link = df.general_ref_is_nemesisst:new() - nemesis_link.nemesis_id = nemesis.id - unit.general_refs:insert('#', nemesis_link) - - --new_nemesis_loc = df.global.world.nemesis.all[#df.global.world.nemesis.all - 1] - fortent.nemesis_ids:insert('#', nemesis.id) - fortent.nemesis:insert('#', nemesis) - civent.nemesis_ids:insert('#', nemesis.id) - civent.nemesis:insert('#', nemesis) - print("makeown: created nemesis entry") - else-- only insert into civ/fort if not already there - local found = false - for _,v in ipairs(civent.nemesis_ids) do - if v == nemesis.id then found = true; break end - end - if not found then - civent.nemesis_ids:insert('#', nemesis.id) - civent.nemesis:insert('#', nemesis) - end - found = false - for _,v in ipairs(fortent.nemesis_ids) do - if v == nemesis.id then found = true; break end - end - if not found then - fortent.nemesis_ids:insert('#', nemesis.id) - fortent.nemesis:insert('#', nemesis) - end - print("makeown: migrated nemesis entry") - end -- nemesis - - -- generate a name for the unit if it doesn't already have one - name_unit(unit) + hf.profession = sanitize_profession(unit.profession) end +---@param unit df.unit function make_own(unit) - unit.flags1.marauder = false; - unit.flags1.merchant = false; - unit.flags1.forest = false; - unit.flags1.diplomat = false; - unit.flags1.active_invader = false; - unit.flags1.hidden_in_ambush = false; - unit.flags1.invader_origin = false; - unit.flags1.hidden_ambusher = false; - unit.flags1.invades = false; - unit.flags2.underworld = false; --or on a demon! - unit.flags2.resident = false; - unit.flags2.visitor_uninvited = false; --in case you use makeown on a beast :P - unit.flags2.visitor = false; - unit.flags3.guest = false; - unit.flags4.invader_waits_for_parley = false; - unit.flags4.agitated_wilderness_creature = false; - - unit.civ_id = df.global.plotinfo.civ_id; - - if unit.profession == df.profession.MERCHANT then unit.profession = df.profession.TRADER end - if unit.profession2 == df.profession.MERCHANT then unit.profession2 = df.profession.TRADER end + dfhack.units.makeown(unit) + fix_unit(unit) + fix_histfig(unit) fix_clothing_ownership(unit) local caste_flags = unit.enemy.caste_flags if caste_flags.CAN_SPEAK or caste_flags.CAN_LEARN then - make_citizen(unit) + -- generate a name for the unit if it doesn't already have one + name_unit(unit) + else + unit.flags1.tame = true + unit.training_level = df.animal_training_level.Domesticated end end @@ -325,7 +339,7 @@ if dfhack_flags.module then return end -unit = dfhack.gui.getSelectedUnit() +unit = dfhack.gui.getSelectedUnit(true) if not unit then qerror('No unit selected!') else diff --git a/markdown.lua b/markdown.lua index 5ca9f03749..61da34c464 100644 --- a/markdown.lua +++ b/markdown.lua @@ -1,227 +1,142 @@ --- Save a text screen in markdown (eg for reddit) --- This is a derivatiwe work based upon scripts/forum-dwarves.lua by Caldfir and expwnent --- Adapted for markdown by Mchl https://github.com/Mchl -local helpstr = [====[ - -markdown -======== -Save a copy of a text screen in markdown (useful for Reddit, among other sites). -See `forum-dwarves` for BBCode export (for e.g. the Bay12 Forums). - -This script will attempt to read the current df-screen, and if it is a -text-viewscreen (such as the dwarf 'thoughts' screen or an item / creature -'description') or an announcement list screen (such as announcements and -combat reports) then append a marked-down version of this text to the -target file (for easy pasting on reddit for example). -Previous entries in the file are not overwritten, so you -may use the``markdown`` command multiple times to create a single -document containing the text from multiple screens (eg: text screens -from several dwarves, or text screens from multiple artifacts/items, -or some combination). - -Usage:: - - markdown [-n] [filename] - -:-n: overwrites contents of output file -:filename: - if provided, save to :file:`md_{filename}.md` instead - of the default :file:`md_export.md` - -The screens which have been tested and known to function properly with -this script are: - -#. dwarf/unit 'thoughts' screen -#. item/art 'description' screen -#. individual 'historical item/figure' screens -#. manual -#. announements screen -#. combat reports screen -#. latest news (when meeting with liaison) - -There may be other screens to which the script applies. It should be -safe to attempt running the script with any screen active, with an -error message to inform you when the selected screen is not appropriate -for this script. - -]====] - -local args = {...} - -if args[1] == 'help' then - print(helpstr) - return -end +-- Save the description of a selected unit or item in Markdown file in UTF-8 +-- This script extracts the description of a selected unit or item and saves it +-- as a Markdown file encoded in UTF-8 in the root game directory. -local writemode = 'a' +local gui = require('gui') +local argparse = require('argparse') --- check if we want to append to an existing file (default) or overwrite previous contents -if args[1] == '-n' or args[1] == '/n' then - writemode = 'w' - table.remove(args, 1) -end +-- Get world name for default filename +local worldName = dfhack.df2utf(dfhack.translation.translateName(df.global.world.world_data.name)):gsub(" ", "_") + +local help, overwrite, filenameArg = false, false, nil +local positionals = argparse.processArgsGetopt({ ... }, { + {'o', 'overwrite', handler=function() overwrite = true end}, + {'h', 'help', handler=function() help = true end}, +}) -local filename +-- Extract non-option arguments (filename) +filenameArg = positionals[1] -if args[1] ~= nil then - filename = 'md_' .. table.remove(args, 1) .. '.md' -else - filename = 'md_export.md' +if help then + print(dfhack.script_help()) + return end -local utils = require 'utils' -local gui = require 'gui' -local dialog = require 'gui.dialogs' - -local scrn = dfhack.gui.getCurViewscreen() -local flerb = dfhack.gui.getFocusString(scrn) - -local months = { - [1] = 'Granite', - [2] = 'Slate', - [3] = 'Felsite', - [4] = 'Hematite', - [5] = 'Malachite', - [6] = 'Galena', - [7] = 'Limestone', - [8] = 'Sandstone', - [9] = 'Timber', - [10] = 'Moonstone', - [11] = 'Opal', - [12] = 'Obsidian', -} +-- Determine write mode and filename +local writemode = overwrite and 'w' or 'a' +local filename = 'markdown_' .. (filenameArg or worldName) .. '.md' +-- Utility functions local function getFileHandle() - return io.open(filename, writemode) + local handle, error = io.open(filename, writemode) + if not handle then + qerror("Error opening file: " .. filename .. ". " .. error) + end + return handle end local function closeFileHandle(handle) - handle:write('\n***\n\n') + handle:write('\n---\n\n') handle:close() - print ('Data exported to "' .. filename .. '"') -end - -local function reformat(strin) - local strout = strin - - -- [P] tags seem to indicate a new paragraph - local newline_idx = string.find(strout, '[P]', 1, true) - while newline_idx ~= nil do - strout = string.sub(strout, 1, newline_idx - 1) .. '\n***\n\n' .. string.sub(strout, newline_idx + 3) - newline_idx = string.find(strout, '[P]', 1, true) - end - - -- [R] tags seem to indicate a new 'section'. Let's mark it with a horizontal line. - newline_idx = string.find(strout, '[R]', 1, true) - while newline_idx ~= nil do - strout = string.sub(strout, 1, newline_idx - 1) .. '\n***\n\n' .. string.sub(strout,newline_idx + 3) - newline_idx = string.find(strout, '[R]', 1, true) + if writemode == 'a' then + print('\nData appended to "' .. 'Dwarf Fortress/' .. filename .. '"') + elseif writemode == 'w' then + print('\nData overwritten in "' .. 'Dwarf Fortress/' .. filename .. '"') end +end - -- No idea what [B] tags might indicate. Just removing them seems to work fine - newline_idx = string.find(strout, '[B]', 1, true) - while newline_idx ~= nil do - strout = string.sub(strout, 1, newline_idx - 1) .. string.sub(strout,newline_idx + 3) - newline_idx = string.find(strout, '[B]', 1, true) - end +local function reformat(str) + -- [B] tags seem to indicate a new paragraph + -- [R] tags seem to indicate a sub-blocks of text.Treat them as paragraphs. + -- [P] tags seem to be redundant + -- [C] tags indicate color. Remove all color information + return str:gsub('%[B%]', '\n\n') + :gsub('%[R%]', '\n\n') + :gsub('%[P%]', '') + :gsub('%[C:%d+:%d+:%d+%]', '') + :gsub('\n\n+', '\n\n') +end - -- Reddit doesn't support custom colors in markdown. We need to remove all color information :( - local color_idx = string.find(strout, '[C:', 1, true) - while color_idx ~= nil do - strout = string.sub(strout, 1, color_idx - 1) .. string.sub(strout, color_idx + 9) - color_idx = string.find(strout, '[C:', 1, true) - end +local function getNameRaceAgeProf(unit) + --%s is a placeholder for a string, and %d is a placeholder for a number. + return string.format("%s, %d years old %s.", dfhack.units.getReadableName(unit), df.global.cur_year - unit + .birth_year, dfhack.units.getProfessionName(unit)) +end - return strout +-- Main logic for item and unit processing +local item = dfhack.gui.getSelectedItem(true) +local unit = dfhack.gui.getSelectedUnit(true) + +if not item and not unit then + dfhack.printerr([[ +Error: No unit or item is currently selected. +- To select a unit, click on it. +- For items that are installed as buildings (like statues or beds), +open the building's interface and click the magnifying glass icon. +Please select a valid target and try running the script again.]]) + -- Early return to avoid proceeding further if no unit or item is selected + return end -local function formattime(year, ticks) - -- Dwarf Mode month is 33600 ticks long - local month = math.floor(ticks / 33600) - local dayRemainder = ticks - month * 33600 - - -- Dwarf Mode day is 1200 ticks long - local day = math.floor(dayRemainder / 1200) - local timeRemainder = dayRemainder - day * 1200 - - -- Assuming a 24h day each Dwarf Mode tick corresponds to 72 seconds - local seconds = timeRemainder * 72 - - local H = string.format("%02.f", math.floor(seconds / 3600)); - local m = string.format("%02.f", math.floor(seconds / 60 - (H * 60))); - local i = string.format("%02.f", math.floor(seconds - H * 3600 - m * 60)); - - day = day + 1 - if (day == 1 or day == 21) then - day = day .. 'st' --luacheck: retype - elseif (day == 2 or day == 22) then - day = day .. 'nd' --luacheck: retype - elseif (day == 3 or day == 23) then - day = day .. 'rd' --luacheck: retype - else - day = day .. 'th' --luacheck: retype - end +local log = getFileHandle() - return (day .. " " .. months[month + 1] .. " " .. year .. " " .. H .. ":" .. m..":" .. i) -end +local gps = df.global.gps +local mi = df.global.game.main_interface -if flerb == 'textviewer' then - local scrn = scrn --as:df.viewscreen_textviewerst +if item then + -- Item processing + local itemRawName = dfhack.items.getDescription(item, 0, true) + local itemRawDescription = mi.view_sheets.raw_description + log:write('### ' .. + dfhack.df2utf(itemRawName) .. '\n\n#### Description: \n' .. reformat(dfhack.df2utf(itemRawDescription)) .. '\n') + print('Exporting description of the ' .. itemRawName) +elseif unit then + -- Unit processing + -- Simulate UI interactions to load data into memory (click through tabs). Note: Constant might change with DF updates/patches + local is_adv = dfhack.world.isAdventureMode() + local screen = dfhack.gui.getDFViewscreen() + local windowSize = dfhack.screen.getWindowSize() - local lines = scrn.src_text + -- Click "Personality" + local personalityWidthConstant = is_adv and 68 or 48 + local personalityHeightConstant = is_adv and 13 or 11 - if lines ~= nil then + gps.mouse_x = windowSize - personalityWidthConstant + gps.mouse_y = personalityHeightConstant - local log = getFileHandle() - log:write('### ' .. dfhack.df2utf(scrn.title) .. '\n') + gui.simulateInput(screen, '_MOUSE_L') - print('Exporting ' .. dfhack.df2console(scrn.title) .. '\n') + -- Click "Health" + local healthWidthConstant = 74 + local healthHeightConstant = is_adv and 15 or 13 - for n,x in ipairs(lines) do - log:write(reformat(dfhack.df2utf(x.value)).." ") --- debug output --- print(x.value) - end - closeFileHandle(log) - end + gps.mouse_x = windowSize - healthWidthConstant + gps.mouse_y = healthHeightConstant -elseif flerb == 'announcelist' then - local scrn = scrn --as:df.viewscreen_announcelistst - - local lines = scrn.reports - - if lines ~= nil then - local log = getFileHandle() - local lastTime = "" - - for n,x in ipairs(lines) do - local currentTime = formattime(x.year, x.time) - if (currentTime ~= lastTime) then - lastTime = currentTime - log:write('\n***\n\n') - log:write('## ' .. currentTime .. '\n') - end --- debug output --- print(x.text) - log:write(x.text .. '\n') - end - closeFileHandle(log) - end + gui.simulateInput(screen, '_MOUSE_L') + + -- Click "Health/Description" + local healthDescriptionWidthConstant = is_adv and 74 or 51 + local healthDescriptionHeightConstant = is_adv and 17 or 15 + gps.mouse_x = windowSize - healthDescriptionWidthConstant + gps.mouse_y = healthDescriptionHeightConstant -elseif flerb == 'topicmeeting' then - local lines = scrn.text --hint:df.viewscreen_topicmeetingst + gui.simulateInput(screen, '_MOUSE_L') - if lines ~= nil then - local log = getFileHandle() + local unit_description_raw = #mi.view_sheets.unit_health_raw_str > 0 and mi.view_sheets.unit_health_raw_str[0].value or '' + local unit_personality_raw = mi.view_sheets.personality_raw_str - for n,x in ipairs(lines) do --- debug output --- print(x.value) - log:write(x.value .. '\n') + log:write('### ' .. + dfhack.df2utf(getNameRaceAgeProf(unit)) .. + '\n\n#### Description: \n' .. reformat(dfhack.df2utf(unit_description_raw)) .. '\n') + if #unit_personality_raw > 0 then + log:write('\n#### Personality: \n') + for _, unit_personality in ipairs(unit_personality_raw) do + log:write(reformat(dfhack.df2utf(unit_personality.value)) .. '\n') end - closeFileHandle(log) end -else - print 'This is not a textview, announcelist or topicmeeting screen. Can\'t export data, sorry.' + print('Exporting Health/Description & Personality/Traits data for: \n' .. dfhack.df2console(getNameRaceAgeProf(unit))) end + +closeFileHandle(log) diff --git a/max-wave.lua b/max-wave.lua deleted file mode 100644 index bb78e91304..0000000000 --- a/max-wave.lua +++ /dev/null @@ -1,36 +0,0 @@ -local args = {...} - -local wave_size = tonumber(args[1]) -local max_pop = tonumber(args[2]) -local current_pop = 0 - -if not wave_size then - print(dfhack.script_help()) - qerror('max-wave: wave_size required') -end - -local function isCitizen(unit) - return dfhack.units.isCitizen(unit) or - (dfhack.units.isOwnCiv(unit) and - dfhack.units.isAlive(unit) and - df.global.world.raws.creatures.all[unit.race].caste[unit.caste].flags.CAN_LEARN and - not (dfhack.units.isMerchant(unit) or dfhack.units.isForest(unit) or unit.flags1.diplomat or unit.flags2.visitor)) - end - ---One would think the game would track this value somewhere... -for k,v in ipairs(df.global.world.units.active) do - if isCitizen(v) then - current_pop = current_pop + 1 - end - end - -local new_limit = current_pop + wave_size - -if max_pop and new_limit > max_pop then new_limit = max_pop end - -if new_limit == df.global.d_init.population_cap then - print('max-wave: Population cap (' .. new_limit .. ') not changed, maximum population reached') -else - df.global.d_init.population_cap = new_limit - print('max-wave: Population cap set to ' .. new_limit) -end diff --git a/migrants-now.lua b/migrants-now.lua index 1f0b390dc1..ef82e531f1 100644 --- a/migrants-now.lua +++ b/migrants-now.lua @@ -1,14 +1,5 @@ -- Force a migrant wave (only after hardcoded waves) ---[====[ -migrants-now -============ -Forces an immediate migrant wave. Only works after migrants have -arrived naturally. Roughly equivalent to `modtools/force` with:: - - modtools/force -eventType migrants - -]====] df.global.timed_events:insert('#',{ new = true, type = df.timed_event_type.Migrants, diff --git a/modtools/add-syndrome.lua b/modtools/add-syndrome.lua index 198193ae3c..5cbd330467 100644 --- a/modtools/add-syndrome.lua +++ b/modtools/add-syndrome.lua @@ -88,7 +88,7 @@ local syndrome if tonumber(args.syndrome) then syndrome = df.syndrome.find(tonumber(args.syndrome)) else - for _,syn in ipairs(df.global.world.raws.syndromes.all) do + for _,syn in ipairs(df.global.world.raws.mat_table.syndromes.all) do if syn.syn_name == args.syndrome then syndrome = syn break diff --git a/modtools/create-item.lua b/modtools/create-item.lua index 031f5c56b1..03a60b352f 100644 --- a/modtools/create-item.lua +++ b/modtools/create-item.lua @@ -37,6 +37,15 @@ local no_quality_item_types = utils.invert{ 'BRANCH', } +local typesThatUseCreaturesExceptCorpses = utils.invert { + 'REMAINS', + 'FISH', + 'FISH_RAW', + 'VERMIN', + 'PET', + 'EGG', +} + local CORPSE_PIECES = utils.invert{'BONE', 'SKIN', 'CARTILAGE', 'TOOTH', 'NERVE', 'NAIL', 'HORN', 'HOOF', 'CHITIN', 'SHELL', 'IVORY', 'SCALE'} local HAIR_PIECES = utils.invert{'HAIR', 'EYEBROW', 'EYELASH', 'MOUSTACHE', 'CHIN_WHISKERS', 'SIDEBURNS'} @@ -53,7 +62,8 @@ local function moveToContainer(item, creator, container_type) end end local bucketType = dfhack.items.findType(container_type .. ':NONE') - local bucket = df.item.find(dfhack.items.createItem(bucketType, -1, containerMat.type, containerMat.index, creator)) + local buckets = dfhack.items.createItem(creator, bucketType, -1, containerMat.type, containerMat.index) + local bucket = buckets[1] dfhack.items.moveToContainer(item, bucket) return bucket end @@ -68,7 +78,10 @@ local function createCorpsePiece(creator, bodypart, partlayer, creatureID, caste casteID = tonumber(casteID) bodypart = tonumber(bodypart) partlayer = tonumber(partlayer) - -- somewhat similar to the bodypart variable below, a value of -1 here means that the user wants to spawn a whole body part. we set the partlayer to 0 (outermost) because the specific layer isn't important, and we're spawning them all anyway. if it's a generic corpsepiece we ignore it, as it gets added to anyway below (we can't do it below because between here and there there's lines that reference the part layer + -- somewhat similar to the bodypart variable below, a value of -1 here means that the user wants to spawn a whole body part. + -- we set the partlayer to 0 (outermost) because the specific layer isn't important, and we're spawning them all anyway. + -- if it's a generic corpsepiece we ignore it, as it gets added to anyway below (we can't do it below because between here and + -- there there's lines that reference the part layer if partlayer == -1 and not generic then partlayer = 0 wholePart = true @@ -118,8 +131,8 @@ local function createCorpsePiece(creator, bodypart, partlayer, creatureID, caste local itemSubtype = dfhack.items.findSubtype(item_type .. ':NONE') local material = 'CREATURE_MAT:' .. raceName .. ':' .. layerMat local materialInfo = dfhack.matinfo.find(material) - local item_id = dfhack.items.createItem(itemType, itemSubtype, materialInfo['type'], materialInfo.index, creator) - local item = df.item.find(item_id) + local items = dfhack.items.createItem(creator, itemType, itemSubtype, materialInfo['type'], materialInfo.index) + local item = items[1] -- if the item type is a corpsepiece, we know we have one, and then go on to set the appropriate flags if item_type == 'CORPSEPIECE' then if layerName == 'BONE' then -- check if bones @@ -140,8 +153,8 @@ local function createCorpsePiece(creator, bodypart, partlayer, creatureID, caste item.corpse_flags.tooth = true item.material_amount.Tooth = 1 elseif layerName == 'NERVE' then -- check if nervous tissue - item.corpse_flags.skull1 = true -- apparently "skull1" is supposed to be named "rots/can_rot" - item.corpse_flags.separated_part = true + item.corpse_flags.rottable = true + item.corpse_flags.use_blood_color = true -- elseif layerName == "NAIL" then -- check if nail (NO SPECIAL FLAGS) elseif layerName == 'HORN' or layerName == 'HOOF' then -- check if nail item.corpse_flags.horn = true @@ -152,7 +165,7 @@ local function createCorpsePiece(creator, bodypart, partlayer, creatureID, caste end -- checking for skull if not generic and not isCorpse and creatorBody.body_parts[bodypart].token == 'SKULL' then - item.corpse_flags.skull2 = true + item.corpse_flags.skull = true end end local matType @@ -167,7 +180,8 @@ local function createCorpsePiece(creator, bodypart, partlayer, creatureID, caste item.race = creatureID item.normal_race = creatureID item.normal_caste = casteID - -- usually the first two castes are for the creature's sex, so we set the item's sex to the caste if both the creature has one and it's a valid sex id (0 or 1) + -- usually the first two castes are for the creature's sex, so we set the item's sex to + -- the caste if both the creature has one and it's a valid sex id (0 or 1) if casteID < 2 and #(creatorRaceRaw.caste) > 1 then item.sex = casteID else @@ -175,10 +189,10 @@ local function createCorpsePiece(creator, bodypart, partlayer, creatureID, caste end -- on a dwarf tissue index 3 (bone) is 22, but this is not always the case for all creatures, so we get the mat_type of index 3 instead -- here we also set the actual referenced creature material of the corpsepiece - item.bone1.mat_type = matType - item.bone1.mat_index = creatureID - item.bone2.mat_type = matType - item.bone2.mat_index = creatureID + item.largest_tissue.mat_type = matType + item.largest_tissue.mat_index = creatureID + item.largest_unrottable_tissue.mat_type = matType + item.largest_unrottable_tissue.mat_index = creatureID -- skin (and presumably other parts) use body part modifiers for size or amount for i = 0,200 do -- fuck it this works -- inserts @@ -188,12 +202,14 @@ local function createCorpsePiece(creator, bodypart, partlayer, creatureID, caste for i,n in pairs(creatorBody.body_parts) do -- inserts item.body.body_part_relsize:insert('#', n.relsize) - item.body.components.body_part_status:insert(i, creator.body.components.body_part_status[0]) --copy the status of the creator's first part to every body_part_status of the desired creature + --copy the status of the creator's first part to every body_part_status of the desired creature + item.body.components.body_part_status:insert(i, creator.body.components.body_part_status[0]) item.body.components.body_part_status[i].missing = true end for i in pairs(creatorBody.layer_part) do -- inserts - item.body.components.layer_status:insert(i, creator.body.components.layer_status[0]) --copy the layer status of the creator's first layer to every layer_status of the desired creature + -- copy the layer status of the creator's first layer to every layer_status of the desired creature + item.body.components.layer_status:insert(i, creator.body.components.layer_status[0]) item.body.components.layer_status[i].gone = true end if item_type == 'CORPSE' then @@ -212,7 +228,9 @@ local function createCorpsePiece(creator, bodypart, partlayer, creatureID, caste item.body.components.layer_status[creatorBody.body_parts[i].layers[n].layer_id].gone = false else -- search through the target creature's body parts and bring back every one which has the desired material - if creatorRaceRaw.tissue[creatorBody.body_parts[i].layers[n].tissue_id].tissue_material_str[1] == layerMat and creatorBody.body_parts[i].token ~= 'SKULL' and not creatorBody.body_parts[i].flags.SMALL then + if creatorRaceRaw.tissue[creatorBody.body_parts[i].layers[n].tissue_id].tissue_material_str[1] == layerMat and + creatorBody.body_parts[i].token ~= 'SKULL' and not creatorBody.body_parts[i].flags.SMALL then + item.body.components.body_part_status[i].missing = false item.body.components.layer_status[creatorBody.body_parts[i].layers[n].layer_id].gone = false -- save the index of the bone layer to a variable @@ -225,7 +243,7 @@ local function createCorpsePiece(creator, bodypart, partlayer, creatureID, caste if wholePart then for i in pairs(creatorBody.body_parts[bodypart].layers) do item.body.components.layer_status[creatorBody.body_parts[bodypart].layers[i].layer_id].gone = false - item.corpse_flags.separated_part = true + item.corpse_flags.use_blood_color = true item.corpse_flags.unbutchered = true end end @@ -239,51 +257,69 @@ local function createCorpsePiece(creator, bodypart, partlayer, creatureID, caste end local function createItem(mat, itemType, quality, creator, description, amount) - local item = df.item.find(dfhack.items.createItem(itemType[1], itemType[2], mat[1], mat[2], creator)) - local item2 = nil - assert(item, 'failed to create item') + -- The "reaction-gloves" tweak can cause this to create multiple gloves + local items = dfhack.items.createItem(creator, itemType[1], itemType[2], mat[1], mat[2]) + assert(#items > 0, ('failed to create item: item_type: %s, item_subtype: %s, mat_type: %s, mat_index: %s, unit: %s'):format( + itemType[1], itemType[2], mat[1], mat[2], creator and creator.id or 'nil')) + local item = items[1] local mat_token = dfhack.matinfo.decode(item):getToken() quality = math.max(0, math.min(5, quality - 1)) - item:setQuality(quality) + -- If we got multiple gloves, set quality on all of them + for _, it in pairs(items) do + it:setQuality(quality) + end local item_type = df.item_type[itemType[1]] if item_type == 'SLAB' then item.description = description elseif item_type == 'GLOVES' then - --create matching gloves - item:setGloveHandedness(1) - item2 = df.item.find(dfhack.items.createItem(itemType[1], itemType[2], mat[1], mat[2], creator)) - assert(item2, 'failed to create item') - item2:setQuality(quality) - item2:setGloveHandedness(2) + --create matching gloves, if necessary + if item:getGloveHandedness() == 0 then + for _, it in pairs(items) do + it:setGloveHandedness(1) + end + local items2 = dfhack.items.createItem(creator, itemType[1], itemType[2], mat[1], mat[2]) + assert(#items2 > 0, 'failed to create second gloves') + for _, it2 in pairs(items2) do + it2:setQuality(quality) + it2:setGloveHandedness(2) + table.insert(items, it2) + end + end elseif item_type == 'SHOES' then --create matching shoes - item2 = df.item.find(dfhack.items.createItem(itemType[1], itemType[2], mat[1], mat[2], creator)) - assert(item2, 'failed to create item') - item2:setQuality(quality) + local items2 = dfhack.items.createItem(creator, itemType[1], itemType[2], mat[1], mat[2]) + assert(#items2 > 0, 'failed to create second shoes') + for _, it2 in pairs(items2) do + it2:setQuality(quality) + table.insert(items, it2) + end end if tonumber(amount) > 1 then item:setStackSize(amount) - if item2 then item2:setStackSize(amount) end end if item_type == 'DRINK' then return moveToContainer(item, creator, 'BARREL') elseif mat_token == 'WATER' or mat_token == 'LYE' then return moveToContainer(item, creator, 'BUCKET') end - return {item, item2} + return items end -local function get_first_citizen() +local function get_default_unit() local citizens = dfhack.units.getCitizens(true) - if not citizens or not citizens[1] then - qerror('Could not choose a creator unit. Please select one in the UI') + if citizens and citizens[1] then + return citizens[1] + end + local adventurer = dfhack.world.getAdventurer() + if adventurer then + return adventurer end - return citizens[1] + qerror('Could not choose a creator unit. Please select one in the UI') end -- returns the list of created items, or nil on error function hackWish(accessors, opts) - local unit = accessors.get_unit(opts) or get_first_citizen() + local unit = accessors.get_unit(opts) or get_default_unit() local qualityok, quality = false, df.item_quality.Ordinary local itemok, itemtype, itemsubtype = accessors.get_item_type() if not itemok then return end @@ -309,18 +345,26 @@ function hackWish(accessors, opts) end if not mattype or not itemtype then return end if df.item_type.attrs[itemtype].is_stackable then - return createItem({mattype, matindex}, {itemtype, itemsubtype}, quality, unit, description, count) + local mat = typesThatUseCreaturesExceptCorpses[df.item_type[itemtype]] and {matindex, casteId} or {mattype, matindex} + return createItem(mat, {itemtype, itemsubtype}, quality, unit, description, count) end local items = {} for _ = 1,count do if itemtype == df.item_type.CORPSEPIECE or itemtype == df.item_type.CORPSE then table.insert(items, createCorpsePiece(unit, bodypart, partlayerID, matindex, casteId, corpsepieceGeneric)) else - for _,item in ipairs(createItem({mattype, matindex}, {itemtype, itemsubtype}, quality, unit, description, 1)) do + local mat = typesThatUseCreaturesExceptCorpses[df.item_type[itemtype]] and {matindex, casteId} or {mattype, matindex} + for _,item in ipairs(createItem(mat, {itemtype, itemsubtype}, quality, unit, description, 1)) do table.insert(items, item) end end end + if opts.pos then + for _,item in ipairs(items) do + dfhack.items.moveToGround(item, opts.pos) + end + end + return items end @@ -406,9 +450,4 @@ local accessors = { end, } -local items = hackWish(accessors, {}) -if items and opts.pos then - for _,item in ipairs(items) do - dfhack.items.moveToGround(item, opts.pos) - end -end +hackWish(accessors, {pos=opts.pos}) diff --git a/modtools/create-unit.lua b/modtools/create-unit.lua index 687be8ae13..4fce4e4f4b 100644 --- a/modtools/create-unit.lua +++ b/modtools/create-unit.lua @@ -50,7 +50,7 @@ function createUnit(raceStr, casteStr, pos, locationRange, locationType, age, do if entityRawName and entityRawName~="" then local isValidRawName - for k,v in ipairs(df.global.world.raws.entities) do + for k,v in ipairs(df.global.world.raws.entities.all) do if v.code == entityRawName then isValidRawName = true break @@ -517,10 +517,8 @@ function createFigure(unit,he_civ,he_group) df.global.world.history.figures:insert("#", hf) - hf.info = df.historical_figure_info:new() - hf.info.whereabouts = df.historical_figure_info.T_whereabouts:new() - hf.info.whereabouts.death_condition_parameter_1 = -1 - hf.info.whereabouts.death_condition_parameter_2 = -1 + hf.info = {new=true} + hf.info.whereabouts = {new=true} -- set values that seem related to state and do event --change_state(hf, dfg.ui.site_id, region_pos) @@ -603,7 +601,7 @@ function nameUnit(unit, entityRawName) --choose three random words in the appropriate things local entity_raw if entityRawName and entityRawName~="" then - for k,v in ipairs(df.global.world.raws.entities) do + for k,v in ipairs(df.global.world.raws.entities.all) do if v.code == entityRawName then entity_raw = v break @@ -905,7 +903,7 @@ function domesticateUnit(unit) unit.animal.population.region_y = -1 unit.animal.population.unk_28 = -1 unit.animal.population.population_idx = -1 - unit.animal.population.depth = -1 + unit.animal.population.layer_depth = -1 -- And make them tame (from Dirst) unit.flags1.tame = true @@ -915,17 +913,18 @@ end function wildUnit(unit) local casteFlags = unit.enemy.caste_flags - -- x = df.global.world.world_data.active_site[0].pos.x - -- y = df.global.world.world_data.active_site[0].pos.y + -- x = dfhack.world.getCurrentSite().pos.x + -- y = dfhack.world.getCurrentSite().pos.y -- region = df.global.map.map_blocks[df.global.map.x_count_block*x+y] if not(casteFlags.CAN_SPEAK or casteFlags.CAN_LEARN) then - if #df.global.world.world_data.active_site > 0 then -- empty in adventure mode - unit.animal.population.region_x = df.global.world.world_data.active_site[0].pos.x - unit.animal.population.region_y = df.global.world.world_data.active_site[0].pos.y + if dfhack.isSiteLoaded() then + local site = dfhack.world.getCurrentSite() + unit.animal.population.region_x = site.pos.x + unit.animal.population.region_y = site.pos.y end unit.animal.population.unk_28 = -1 unit.animal.population.population_idx = -1 -- Eventually want to make a real population - unit.animal.population.depth = -1 -- Eventually this should be a parameter + unit.animal.population.layer_depth = -1 -- Eventually this should be a parameter unit.animal.leave_countdown = 99999 -- Eventually this should be a parameter unit.flags2.roaming_wilderness_population_source = true unit.flags2.roaming_wilderness_population_source_not_a_map_feature = true @@ -957,7 +956,6 @@ function enableUnitLabors(unit, default, skilled) labors.HANDLE_VEHICLES = true labors.HAUL_TRADE = true labors.PULL_LEVER = true - labors.REMOVE_CONSTRUCTION = true labors.HAUL_WATER = true labors.BUILD_ROAD = true labors.BUILD_CONSTRUCTION = true diff --git a/modtools/equip-item.lua b/modtools/equip-item.lua index eca12201ee..ad8e916487 100644 --- a/modtools/equip-item.lua +++ b/modtools/equip-item.lua @@ -94,6 +94,6 @@ if not part then end local mode = args.mode -mode = df.unit_inventory_item.T_mode[mode] --luacheck: retype +mode = df.inv_item_role_type[mode] --luacheck: retype equipItem(unit, item, partId, mode) diff --git a/modtools/extra-gamelog.lua b/modtools/extra-gamelog.lua index c2360758c8..8b9538513c 100644 --- a/modtools/extra-gamelog.lua +++ b/modtools/extra-gamelog.lua @@ -1,17 +1,4 @@ -- Regularly writes extra info to gamelog.txt -local help = [====[ - -modtools/extra-gamelog -====================== -This script writes extra information to the gamelog. -This is useful for tools like :forums:`Soundsense <106497>`. - -Usage:: - - modtools/extra-gamelog enable - modtools/extra-gamelog disable - -]====] local msg = dfhack.gui.writeToGamelog @@ -48,7 +35,7 @@ function log_on_load(op) local fort_ent = df.global.plotinfo.main.fortress_entity local civ_ent = df.historical_entity.find(df.global.plotinfo.civ_id) local function fullname(item) - return dfhack.TranslateName(item.name)..' ('..dfhack.TranslateName(item.name ,true)..')' + return dfhack.translation.translateName(item.name)..' ('..dfhack.translation.translateName(item.name ,true)..')' end msg('Loaded '..df.global.world.cur_savegame.save_dir..', '..fullname(df.global.world.world_data).. ' at coordinates ('..site.pos.x..','..site.pos.y..')') @@ -64,7 +51,6 @@ function log_nobles() local expedition_leader = nil local mayor = nil local function check(unit) - if not dfhack.units.isCitizen(unit) then return end for _, pos in ipairs(dfhack.units.getNoblePositions(unit) or {}) do if pos.position.name[0] == "expedition leader" then expedition_leader = unit @@ -73,7 +59,7 @@ function log_nobles() end end end - for _, unit in ipairs(df.global.world.units.active) do + for _, unit in ipairs(dfhack.units.getCitizens()) do check(unit) end @@ -85,7 +71,7 @@ function log_nobles() if expedition_leader == nil then msg("Expedition leader position is now vacant.") else - msg(dfhack.TranslateName(dfhack.units.getVisibleName(expedition_leader)).." became expedition leader.") + msg(dfhack.translation.translateName(dfhack.units.getVisibleName(expedition_leader)).." became expedition leader.") end end @@ -93,7 +79,7 @@ function log_nobles() if mayor == nil then msg("Mayor position is now vacant.") else - msg(dfhack.TranslateName(dfhack.units.getVisibleName(mayor)).." became mayor.") + msg(dfhack.translation.translateName(dfhack.units.getVisibleName(mayor)).." became mayor.") end end old_mayor = mayor @@ -194,5 +180,5 @@ elseif args[1] == 'enable' then extra_gamelog_enabled = true event_loop() else - print(help) + print(dfhack.script_help()) end diff --git a/modtools/force.lua b/modtools/force.lua deleted file mode 100644 index eb49b91552..0000000000 --- a/modtools/force.lua +++ /dev/null @@ -1,64 +0,0 @@ --- Forces an event (caravan, migrants, etc) --- author Putnam --- edited by expwnent -local utils = require 'utils' - -local function findCiv(arg) - local entities = df.global.world.entities.all - if tonumber(arg) then return df.historical_entity.find(tonumber(arg)) end - if arg then - for eid,entity in ipairs(entities) do - if entity.entity_raw.code == arg then return entity end - end - end - return nil -end - -local validArgs = utils.invert({ - 'eventType', - 'help', - 'civ' -}) - -local args = utils.processArgs({...}, validArgs) -if args.help then - print(dfhack.script_help()) - return -end - -if not args.eventType then - error 'Specify an eventType.' -elseif not df.timed_event_type[args.eventType] then - error('Invalid eventType: ' .. args.eventType) -elseif args.eventType == 'FeatureAttack' then - qerror('Event type: FeatureAttack is not currently supported') -end - -local civ = nil --as:df.historical_entity -if args.civ == 'player' then - civ = df.historical_entity.find(df.global.plotinfo.civ_id) -elseif args.civ then - civ = findCiv(args.civ) -end -if args.civ and not civ then - error('Invalid civ: ' .. args.civ) -end -if args.eventType == 'Caravan' or args.eventType == 'Diplomat' then - if not civ then - error('Specify civ for this eventType') - end -end - -if args.eventType == 'Migrants' then - civ = df.historical_entity.find(df.global.plotinfo.civ_id) -end - -local timedEvent = df.timed_event:new() -timedEvent.type = df.timed_event_type[args.eventType] -timedEvent.season = df.global.cur_season -timedEvent.season_ticks = df.global.cur_season_tick -if civ then - timedEvent.entity = civ -end - -df.global.timed_events:insert('#', timedEvent) diff --git a/modtools/if-entity.lua b/modtools/if-entity.lua index 0bba513cde..ae066cbe73 100644 --- a/modtools/if-entity.lua +++ b/modtools/if-entity.lua @@ -1,38 +1,6 @@ -- Run a command if the current entity matches a given ID. ---[[ -Consider this public domain (CC0). - - Milo Christiansen -]] - -local usage = [====[ -modtools/if-entity -================== - -Run a command if the current entity matches a given ID. - -To use this script effectively it needs to be called from "raw/onload.init". -Calling this from the main dfhack.init file will do nothing, as no world has -been loaded yet. - -Usage: - -- ``id``: - Specify the entity ID to match -- ``cmd [ commandStrs ]``: - Specify the command to be run if the current entity matches the entity - given via -id - -All arguments are required. - -Example: - -- Print a message if you load an elf fort, but not a dwarf, human, etc. fort:: - - if-entity -id "FOREST" -cmd [ lua "print('Dirty hippies.')" ] -]====] - -local utils = require 'utils' +local utils = require('utils') local validArgs = utils.invert({ 'help', @@ -43,14 +11,18 @@ local validArgs = utils.invert({ local args = utils.processArgs({...}, validArgs) if not args.id or not args.cmd or args.help then - dfhack.print(usage) + print(dfhack.script_help()) return end +if df.global.gamemode ~= df.game_mode.DWARF or not dfhack.isMapLoaded() then + error('emigration needs a loaded fortress map to work') +end + + local entsrc = df.historical_entity.find(df.global.plotinfo.civ_id) -if entsrc == nil then - dfhack.printerr("Could not find current entity. No world loaded?") - return +if not entsrc then + error('could not find current entity') end if entsrc.entity_raw.code == args.id then diff --git a/modtools/invader-item-destroyer.lua b/modtools/invader-item-destroyer.lua index 7512e44e28..6f28b55c2a 100644 --- a/modtools/invader-item-destroyer.lua +++ b/modtools/invader-item-destroyer.lua @@ -84,9 +84,7 @@ eventful.onUnitDeath.invaderItemDestroyer = function(unitId) if item.pos.z ~= unit.pos.z then return end - item.flags.garbage_collect = true - item.flags.forbid = true - item.flags.hidden = true + dfhack.items.remove(item) end for _,item in ipairs(unit.inventory) do diff --git a/modtools/item-trigger.lua b/modtools/item-trigger.lua index 1851378702..290dbc224a 100644 --- a/modtools/item-trigger.lua +++ b/modtools/item-trigger.lua @@ -2,401 +2,329 @@ --author expwnent --based on itemsyndrome by Putnam --equipment modes and combined trigger conditions added by AtomicChicken -local usage = [====[ -modtools/item-trigger -===================== -This powerful tool triggers DFHack commands when a unit equips, unequips, or -attacks another unit with specified item types, specified item materials, or -specified item contaminants. +local eventful = require('plugins.eventful') +local utils = require('utils') -Arguments:: +--as:{_type:table,_array:{_type:table,triggers:{_type:table,itemType:string,material:string,contaminant:string},args:{_type:table,_array:{_type:table,checkAttackEvery:string,checkInventoryEvery:string,command:'string[]',itemType:string,onStrike:__arg,onEquip:__arg,onUnequip:__arg,material:string,contaminant:string}}}} +itemTriggers = itemTriggers or {} - -clear - clear all registered triggers - -checkAttackEvery n - check the attack event at least every n ticks - -checkInventoryEvery n - check inventory event at least every n ticks - -itemType type - trigger the command for items of this type - examples: - ITEM_WEAPON_PICK - RING - -onStrike - trigger the command on appropriate weapon strikes - -onEquip mode - trigger the command when someone equips an appropriate item - Optionally, the equipment mode can be specified - Possible values for mode: - Hauled - Weapon - Worn - Piercing - Flask - WrappedAround - StuckIn - InMouth - Pet - SewnInto - Strapped - multiple values can be specified simultaneously - example: -onEquip [ Weapon Worn Hauled ] - -onUnequip mode - trigger the command when someone unequips an appropriate item - see above note regarding 'mode' values - -material mat - trigger the commmand on items with the given material - examples - INORGANIC:IRON - CREATURE:DWARF:BRAIN - PLANT:OAK:WOOD - -contaminant mat - trigger the command for items with a given material contaminant - examples - INORGANIC:GOLD - CREATURE:HUMAN:BLOOD - PLANT:MUSHROOM_HELMET_PLUMP:DRINK - WATER - -command [ commandStrs ] - specify the command to be executed - commandStrs - \\ATTACKER_ID - \\DEFENDER_ID - \\ITEM_MATERIAL - \\ITEM_MATERIAL_TYPE - \\ITEM_ID - \\ITEM_TYPE - \\CONTAMINANT_MATERIAL - \\CONTAMINANT_MATERIAL_TYPE - \\CONTAMINANT_MATERIAL_INDEX - \\MODE - \\UNIT_ID - \\anything -> \anything - anything -> anything -]====] -local eventful = require 'plugins.eventful' -local utils = require 'utils' - -itemTriggers = itemTriggers or {} --as:{_type:table,_array:{_type:table,triggers:{_type:table,itemType:string,material:string,contaminant:string},args:{_type:table,_array:{_type:table,checkAttackEvery:string,checkInventoryEvery:string,command:'string[]',itemType:string,onStrike:__arg,onEquip:__arg,onUnequip:__arg,material:string,contaminant:string}}}} -eventful.enableEvent(eventful.eventType.UNIT_ATTACK,1) -- this event type is cheap, so checking every tick is fine -eventful.enableEvent(eventful.eventType.INVENTORY_CHANGE,5) -- this is expensive, but you might still want to set it lower -eventful.enableEvent(eventful.eventType.UNLOAD,1) +eventful.enableEvent(eventful.eventType.UNIT_ATTACK, 1) -- this event type is cheap, so checking every tick is fine +eventful.enableEvent(eventful.eventType.INVENTORY_CHANGE, 5) -- this is expensive, but you might still want to set it lower +eventful.enableEvent(eventful.eventType.UNLOAD, 1) eventful.onUnload.itemTrigger = function() - itemTriggers = {} + itemTriggers = {} end function processTrigger(command) - local command2 = {} --as:string[] - for i,arg in ipairs(command.command) do - if arg == '\\ATTACKER_ID' then - command2[i] = '' .. command.attacker.id - elseif arg == '\\DEFENDER_ID' then - command2[i] = '' .. command.defender.id - elseif arg == '\\ITEM_MATERIAL' then - command2[i] = command.itemMat:getToken() - elseif arg == '\\ITEM_MATERIAL_TYPE' then - command2[i] = command.itemMat['type'] - elseif arg == '\\ITEM_MATERIAL_INDEX' then - command2[i] = command.itemMat.index - elseif arg == '\\ITEM_ID' then - command2[i] = '' .. command.item.id - elseif arg == '\\ITEM_TYPE' then - command2[i] = command.itemType - elseif arg == '\\CONTAMINANT_MATERIAL' then - command2[i] = command.contaminantMat:getToken() - elseif arg == '\\CONTAMINANT_MATERIAL_TYPE' then - command2[i] = command.contaminantMat['type'] - elseif arg == '\\CONTAMINANT_MATERIAL_INDEX' then - command2[i] = command.contaminantMat.index - elseif arg == '\\MODE' then - command2[i] = command.mode - elseif arg == '\\UNIT_ID' then - command2[i] = command.unit.id - elseif string.sub(arg,1,1) == '\\' then - command2[i] = string.sub(arg,2) - else - command2[i] = arg - end - end - dfhack.run_command(table.unpack(command2)) + local command2 = {} --as:string[] + for i, arg in ipairs(command.command) do + if arg == '\\ATTACKER_ID' then + command2[i] = '' .. command.attacker.id + elseif arg == '\\DEFENDER_ID' then + command2[i] = '' .. command.defender.id + elseif arg == '\\ITEM_MATERIAL' then + command2[i] = command.itemMat:getToken() + elseif arg == '\\ITEM_MATERIAL_TYPE' then + command2[i] = command.itemMat['type'] + elseif arg == '\\ITEM_MATERIAL_INDEX' then + command2[i] = command.itemMat.index + elseif arg == '\\ITEM_ID' then + command2[i] = '' .. command.item.id + elseif arg == '\\ITEM_TYPE' then + command2[i] = command.itemType + elseif arg == '\\CONTAMINANT_MATERIAL' then + command2[i] = command.contaminantMat:getToken() + elseif arg == '\\CONTAMINANT_MATERIAL_TYPE' then + command2[i] = command.contaminantMat['type'] + elseif arg == '\\CONTAMINANT_MATERIAL_INDEX' then + command2[i] = command.contaminantMat.index + elseif arg == '\\MODE' then + command2[i] = command.mode + elseif arg == '\\UNIT_ID' then + command2[i] = command.unit.id + elseif string.sub(arg, 1, 1) == '\\' then + command2[i] = string.sub(arg, 2) + else + command2[i] = arg + end + end + dfhack.run_command(table.unpack(command2)) end function getitemType(item) - if item:getSubtype() ~= -1 and dfhack.items.getSubtypeDef(item:getType(),item:getSubtype()) then - return dfhack.items.getSubtypeDef(item:getType(),item:getSubtype()).id - else - return df.item_type[item:getType()] - end + if item:getSubtype() ~= -1 and dfhack.items.getSubtypeDef(item:getType(), item:getSubtype()) then + return dfhack.items.getSubtypeDef(item:getType(), item:getSubtype()).id + else + return df.item_type[item:getType()] + end end -function compareInvModes(reqMode,itemMode) - if reqMode == nil then - return - end - if not tonumber(reqMode) and df.unit_inventory_item.T_mode[itemMode] == tostring(reqMode) then - return true - elseif tonumber(reqMode) == itemMode then - return true - end +function compareInvModes(reqMode, itemMode) + if reqMode == nil then + return + end + if not tonumber(reqMode) and df.inv_item_role_type[itemMode] == tostring(reqMode) then + return true + elseif tonumber(reqMode) == itemMode then + return true + end end -function checkMode(triggerArgs,data) - local data = data --as:{_type:table,mode:string,modeType:number,attacker:df.unit,defender:df.unit,unit:df.unit,item:df.item,itemMat:dfhack.matinfo,contaminantMat:dfhack.matinfo} - local mode = data.mode - for _,argArray in ipairs(triggerArgs) do - local modes = argArray --as:__arg[] - if modes[tostring(mode)] then - local modeType = data.modeType - local reqModeType = modes[tostring(mode)] - if #reqModeType == 1 then - if compareInvModes(reqModeType,modeType) or compareInvModes(reqModeType[1],modeType) then - utils.fillTable(argArray,data) - processTrigger(argArray) - utils.unfillTable(argArray,data) - end - elseif #reqModeType > 1 then - for _,r in ipairs(reqModeType) do - if compareInvModes(r,modeType) then - utils.fillTable(argArray,data) - processTrigger(argArray) - utils.unfillTable(argArray,data) - end +--data as:{_type:table,mode:string,modeType:number,attacker:df.unit,defender:df.unit,unit:df.unit,item:df.item,itemMat:dfhack.matinfo,contaminantMat:dfhack.matinfo} +function checkMode(triggerArgs, data) + local mode = data.mode + for _, argArray in ipairs(triggerArgs) do + local modes = argArray --as:__arg[] + if modes[tostring(mode)] then + local modeType = data.modeType + local reqModeType = modes[tostring(mode)] + if #reqModeType == 1 then + if compareInvModes(reqModeType, modeType) or compareInvModes(reqModeType[1], modeType) then + utils.fillTable(argArray, data) + processTrigger(argArray) + utils.unfillTable(argArray, data) + end + elseif #reqModeType > 1 then + for _, r in ipairs(reqModeType) do + if compareInvModes(r, modeType) then + utils.fillTable(argArray, data) + processTrigger(argArray) + utils.unfillTable(argArray, data) + end + end + else + utils.fillTable(argArray, data) + processTrigger(argArray) + utils.unfillTable(argArray, data) + end + end end - else - utils.fillTable(argArray,data) - processTrigger(argArray) - utils.unfillTable(argArray,data) - end - end - end end function checkForTrigger(data) - local itemTypeStr = data.itemType - local itemMatStr = data.itemMat:getToken() - local contaminantStr - if data.contaminantMat then - contaminantStr = data.contaminantMat:getToken() - end - for _,triggerBundle in ipairs(itemTriggers) do - local count = 0 - local trigger = triggerBundle['triggers'] - local triggerCount = 0 - if trigger['itemType'] then - triggerCount = triggerCount + 1 - end - if trigger['material'] then - triggerCount = triggerCount + 1 - end - if trigger['contaminant'] then - triggerCount = triggerCount + 1 - end - if itemTypeStr and trigger['itemType'] == itemTypeStr then - count = count+1 - end - if itemMatStr and trigger['material'] == itemMatStr then - count = count+1 - end - if contaminantStr and trigger['contaminant'] == contaminantStr then - count = count+1 - end - if count == triggerCount then - checkMode(triggerBundle['args'],data) - end - end + local itemTypeStr = data.itemType + local itemMatStr = data.itemMat:getToken() + local contaminantStr + if data.contaminantMat then + contaminantStr = data.contaminantMat:getToken() + end + for _, triggerBundle in ipairs(itemTriggers) do + local count = 0 + local trigger = triggerBundle['triggers'] + local triggerCount = 0 + if trigger['itemType'] then + triggerCount = triggerCount + 1 + end + if trigger['material'] then + triggerCount = triggerCount + 1 + end + if trigger['contaminant'] then + triggerCount = triggerCount + 1 + end + if itemTypeStr and trigger['itemType'] == itemTypeStr then + count = count + 1 + end + if itemMatStr and trigger['material'] == itemMatStr then + count = count + 1 + end + if contaminantStr and trigger['contaminant'] == contaminantStr then + count = count + 1 + end + if count == triggerCount then + checkMode(triggerBundle['args'], data) + end + end end function checkForDuplicates(args) - for k,triggerBundle in ipairs(itemTriggers) do - local count = 0 - local trigger = triggerBundle['triggers'] - if trigger['itemType'] == args.itemType then - count = count+1 - end - if trigger['material'] == args.material then - count = count+1 - end - if trigger['contaminant'] == args.contaminant then - count = count+1 - end - if count == 3 then--counts nil values too - return k - end - end + for k, triggerBundle in ipairs(itemTriggers) do + local count = 0 + local trigger = triggerBundle['triggers'] + if trigger['itemType'] == args.itemType then + count = count + 1 + end + if trigger['material'] == args.material then + count = count + 1 + end + if trigger['contaminant'] == args.contaminant then + count = count + 1 + end + if count == 3 then --counts nil values too + return k + end + end end function handler(data) - local itemMat = dfhack.matinfo.decode(data.item) - local itemType = getitemType(data.item) - data.itemMat = itemMat - data.itemType = itemType - - if data.item.contaminants and #data.item.contaminants > 0 then --hint:df.item_actual - for _,contaminant in ipairs(data.item.contaminants or {}) do --hint:df.item_actual - local contaminantMat = dfhack.matinfo.decode(contaminant.mat_type, contaminant.mat_index) - data.contaminantMat = contaminantMat - checkForTrigger(data) - data.contaminantMat = nil - end - else - checkForTrigger(data) - end + local itemMat = dfhack.matinfo.decode(data.item) + local itemType = getitemType(data.item) + data.itemMat = itemMat + data.itemType = itemType + + if data.item.contaminants and #data.item.contaminants > 0 then --hint:df.item_actual + for _, contaminant in ipairs(data.item.contaminants or {}) do --hint:df.item_actual + local contaminantMat = dfhack.matinfo.decode(contaminant.base.mat_type, contaminant.base.mat_index) + data.contaminantMat = contaminantMat + checkForTrigger(data) + data.contaminantMat = nil + end + else + checkForTrigger(data) + end end function equipHandler(unit, item, mode, modeType) - local data = {} - data.mode = tostring(mode) - data.modeType = tonumber(modeType) - data.item = df.item.find(item) - data.unit = df.unit.find(unit) - if data.item and data.unit then -- they must both be not nil or errors will occur after this point with instant reactions. - handler(data) - end + local data = {} + data.mode = tostring(mode) + data.modeType = tonumber(modeType) + data.item = df.item.find(item) + data.unit = df.unit.find(unit) + if data.item and data.unit then -- they must both be not nil or errors will occur after this point with instant reactions. + handler(data) + end end function modeHandler(unit, item, modeOld, modeNew) - local mode - local modeType - if modeOld then - mode = "onUnequip" - modeType = modeOld - equipHandler(unit, item, mode, modeType) - end - if modeNew then - mode = "onEquip" - modeType = modeNew - equipHandler(unit, item, mode, modeType) - end + local mode + local modeType + if modeOld then + mode = "onUnequip" + modeType = modeOld + equipHandler(unit, item, mode, modeType) + end + if modeNew then + mode = "onEquip" + modeType = modeNew + equipHandler(unit, item, mode, modeType) + end end eventful.onInventoryChange.equipmentTrigger = function(unit, item, item_old, item_new) - local modeOld = (item_old and item_old.mode) - local modeNew = (item_new and item_new.mode) - if modeOld ~= modeNew then - modeHandler(unit,item,modeOld,modeNew) - end + local modeOld = (item_old and item_old.mode) + local modeNew = (item_new and item_new.mode) + if modeOld ~= modeNew then + modeHandler(unit, item, modeOld, modeNew) + end end -eventful.onUnitAttack.attackTrigger = function(attacker,defender,wound) - attacker = df.unit.find(attacker) --luacheck: retype - defender = df.unit.find(defender) --luacheck: retype +eventful.onUnitAttack.attackTrigger = function(attacker, defender, wound) + attacker = df.unit.find(attacker) --luacheck: retype + defender = df.unit.find(defender) --luacheck: retype - if not attacker then - return - end + if not attacker then return end - local attackerWeapon - for _,item in ipairs(attacker.inventory) do - if item.mode == df.unit_inventory_item.T_mode.Weapon then - attackerWeapon = item.item - break - end - end + local attackerWeapon + for _, item in ipairs(attacker.inventory) do + if item.mode == df.inv_item_role_type.Weapon then + attackerWeapon = item.item + break + end + end - if not attackerWeapon then - return - end + if not attackerWeapon then + return + end - local data = {} - data.attacker = attacker - data.defender = defender - data.item = attackerWeapon - data.mode = 'onStrike' - handler(data) + local data = {} + data.attacker = attacker + data.defender = defender + data.item = attackerWeapon + data.mode = 'onStrike' + handler(data) end local validArgs = utils.invert({ - 'clear', - 'help', - 'checkAttackEvery', - 'checkInventoryEvery', - 'command', - 'itemType', - 'onStrike', - 'onEquip', - 'onUnequip', - 'material', - 'contaminant', + 'clear', + 'help', + 'checkAttackEvery', + 'checkInventoryEvery', + 'command', + 'itemType', + 'onStrike', + 'onEquip', + 'onUnequip', + 'material', + 'contaminant', }) -local args = utils.processArgs({...}, validArgs) +local args = utils.processArgs({ ... }, validArgs) if args.help then - print(usage) - return + print(dfhack.script_help()) + return end if args.clear then - itemTriggers = {} + itemTriggers = {} end if args.checkAttackEvery then - if not tonumber(args.checkAttackEvery) then - error('checkAttackEvery must be a number') - end - eventful.enableEvent(eventful.eventType.UNIT_ATTACK,tonumber(args.checkAttackEvery)) + if not tonumber(args.checkAttackEvery) then + error('checkAttackEvery must be a number') + end + eventful.enableEvent(eventful.eventType.UNIT_ATTACK, tonumber(args.checkAttackEvery)) end if args.checkInventoryEvery then - if not tonumber(args.checkInventoryEvery) then - error('checkInventoryEvery must be a number') - end - eventful.enableEvent(eventful.eventType.INVENTORY_CHANGE,tonumber(args.checkInventoryEvery)) + if not tonumber(args.checkInventoryEvery) then + error('checkInventoryEvery must be a number') + end + eventful.enableEvent(eventful.eventType.INVENTORY_CHANGE, tonumber(args.checkInventoryEvery)) end if not args.command then - if not args.clear then - error 'specify a command' - end - return + if not args.clear then + error 'specify a command' + end + return end if args.itemType and dfhack.items.findType(args.itemType) == -1 then - local temp - for _,itemdef in ipairs(df.global.world.raws.itemdefs.all) do - if itemdef.id == args.itemType then - temp = args.itemType--itemdef.subtype - break - end - end - if not temp then - error 'Could not find item type.' - end - args.itemType = temp + local temp + for _, itemdef in ipairs(df.global.world.raws.itemdefs.all) do + if itemdef.id == args.itemType then + temp = args.itemType --itemdef.subtype + break + end + end + if not temp then + error 'Could not find item type.' + end + args.itemType = temp end local numConditions = (args.material and 1 or 0) + (args.itemType and 1 or 0) + (args.contaminant and 1 or 0) if numConditions == 0 then - error 'Specify at least one material, itemType or contaminant.' + error 'Specify at least one material, itemType or contaminant.' end local index if #itemTriggers > 0 then - index = checkForDuplicates(args) + index = checkForDuplicates(args) end if not index then - index = #itemTriggers+1 - itemTriggers[index] = {} - local triggerArray = {} - if args.itemType then - triggerArray['itemType'] = args.itemType - end - if args.material then - triggerArray['material'] = args.material - end - if args.contaminant then - triggerArray['contaminant'] = args.contaminant - end - itemTriggers[index]['triggers'] = triggerArray + index = #itemTriggers + 1 + itemTriggers[index] = {} + local triggerArray = {} + if args.itemType then + triggerArray['itemType'] = args.itemType + end + if args.material then + triggerArray['material'] = args.material + end + if args.contaminant then + triggerArray['contaminant'] = args.contaminant + end + itemTriggers[index]['triggers'] = triggerArray end if not itemTriggers[index]['args'] then - itemTriggers[index]['args'] = {} + itemTriggers[index]['args'] = {} end local triggerArgs = itemTriggers[index]['args'] args.itemType = nil args.material = nil args.contaminant = nil -table.insert(triggerArgs,args) +table.insert(triggerArgs, args) diff --git a/modtools/moddable-gods.lua b/modtools/moddable-gods.lua index 59fc1ba16c..f852992faf 100644 --- a/modtools/moddable-gods.lua +++ b/modtools/moddable-gods.lua @@ -1,117 +1,136 @@ --- Create gods from the command-line ---based on moddableGods by Putnam ---edited by expwnent -local usage = [====[ - -modtools/moddable-gods -====================== -This is a standardized version of Putnam's moddableGods script. It allows you -to create gods on the command-line. - -Arguments:: - - -name godName - sets the name of the god to godName - if there's already a god of that name, the script halts - -spheres [ sphereList ] - define a space-separated list of spheres of influence of the god - -gender male|female|neuter - sets the gender of the god - -depictedAs str - often depicted as a str - -verbose - if specified, prints details about the created god - -]====] -local utils = require 'utils' - -local validArgs = utils.invert({ - 'help', - 'name', - 'spheres', - 'gender', - 'depictedAs', - 'verbose', --- 'entities', -}) -local args = utils.processArgs({...}, validArgs) +local argparse = require('argparse') + +local function get_spheres(arg) + local spheres = {} + for _, sphere in ipairs(argparse.stringList(arg, 'spheres')) do + local sphereType = df.sphere_type[sphere] + if not sphereType then + qerror('invalid sphere: ' .. sphere) + end + table.insert(spheres, sphereType) + end + return spheres +end -if args.help then - print(usage) - return +local function get_gender(arg) + if arg == 'male' then + return df.pronoun_type.he + elseif arg == 'female' then + return df.pronoun_type.she + elseif arg == 'neuter' then + return df.pronoun_type.it + else + qerror('invalid gender: ' .. arg) + end end -if not args.name or not args.depictedAs or not args.spheres or not args.gender then - error('All arguments must be specified.') +local function get_race(arg) + local int_arg = tonumber(arg) + if int_arg then + local raw = df.creature_raw.find(int_arg) + if not raw then + qerror('race id ' .. int_arg .. ' does not exist') + end + return int_arg + end + for k, raw in ipairs(df.global.world.raws.creatures.all) do + if raw.creature_id == arg or raw.name[0] == arg then + return k + end + end + qerror('race ' .. arg .. ' does not exist') +end + +local function do_god(opts) + local godFig = df.historical_figure:new() + godFig.race = opts.race + godFig.caste = 0 + godFig.sex = opts.gender + + godFig.appeared_year = -1 + godFig.born_year = -1 + godFig.born_seconds = -1 + godFig.curse_year = -1 + godFig.curse_seconds = -1 + godFig.old_year = -1 + godFig.old_seconds = -1 + godFig.died_year = -1 + godFig.died_seconds = -1 + + godFig.name.has_name = true + godFig.name.first_name = opts.name + + godFig.breed_id = -1 + godFig.flags.deity = true + godFig.flags.brag_on_kill = true + godFig.flags.kill_quest = true + godFig.flags.chatworthy = true + godFig.flags.flashes = true + godFig.flags.never_cull = true + + godFig.info = df.historical_figure_info:new() + godFig.info.metaphysical = {new=true} + godFig.info.known_info = {new=true} + for _,sphere in ipairs(opts.spheres) do + godFig.info.metaphysical.spheres:insert('#', sphere) + end + + godFig.pool_id = -1 -- will get a pool_id when game is saved and reloaded + godFig.id = df.global.hist_figure_next_id + df.global.hist_figure_next_id = 1 + df.global.hist_figure_next_id + df.global.world.history.figures:insert('#', godFig) + + return godFig end -local templateGod -for _,fig in ipairs(df.global.world.history.figures) do - if fig.flags.deity then - templateGod = fig - break - end +if not dfhack.isWorldLoaded() then + qerror('This script requires a loaded world.') end -if not templateGod then - error 'Could not find template god.' + +local opts = { + name=nil, + spheres=nil, + gender=nil, + race=nil, + quiet=false, + help=false, +} + +local _ = argparse.processArgsGetopt({ ... }, { + {'n', 'name', hasArg=true, handler=function(arg) opts.name = arg end}, + {'s', 'spheres', hasArg=true, handler=function(arg) opts.spheres = get_spheres(arg) end}, + {'g', 'gender', hasArg=true, handler=function(arg) opts.gender = get_gender(arg) end}, + {'d', 'depicted-as', hasArg=true, handler=function(arg) opts.race = get_race(arg) end}, + {'h', 'help', handler=function() opts.help = true end}, + {'q', 'quiet', handler=function() opts.quiet = true end}, +}) + +if opts.help then + print(dfhack.script_help()) + return end -local gender -if args.gender == 'male' then - gender = 1 -elseif args.gender == 'female' then - gender = 0 -elseif args.gender == "neuter" then - gender = -1 -else - error 'invalid gender' +if not opts.name or not opts.spheres or #opts.name == 0 or #opts.spheres == 0 then + qerror('name and spheres must be specified.') end -local race -for k,v in ipairs(df.global.world.raws.creatures.all) do - if v.creature_id == args.depictedAs or v.name[0] == args.depictedAs then - race = k - break +for _, fig in ipairs(df.global.world.history.figures) do + if fig.name.first_name == opts.name then + print('god "' .. opts.name .. '" already exists.') + return end end -if not race then - error('invalid race: ' .. args.depictedAs) -end -for _,fig in ipairs(df.global.world.history.figures) do - if fig.name.first_name == args.name then - print('god ' .. args.name .. ' already exists. Skipping') - return - end +if not opts.gender then + opts.gender = math.random(-1, 1) end -local godFig = df.historical_figure:new() -godFig.appeared_year = -1 -godFig.born_year = -1 -godFig.born_seconds = -1 -godFig.curse_year = -1 -godFig.curse_seconds = -1 -godFig.old_year = -1 -godFig.old_seconds = -1 -godFig.died_year = -1 -godFig.died_seconds = -1 -godFig.name.has_name = true -godFig.breed_id = -1 -godFig.flags:assign(templateGod.flags) -godFig.id = df.global.hist_figure_next_id -df.global.hist_figure_next_id = 1+df.global.hist_figure_next_id -godFig.info = df.historical_figure_info:new() -godFig.info.spheres = {new=true} -godFig.info.known_info = df.historical_figure_info.T_known_info:new() -godFig.race = race -godFig.caste = 0 -godFig.sex = gender -godFig.name.first_name = args.name -for _,sphere in ipairs(args.spheres) do - godFig.info.spheres.spheres:insert('#',df.sphere_type[sphere]) +if not opts.race then + opts.race = get_race('dwarf') end -df.global.world.history.figures:insert('#',godFig) -if args.verbose then - print(godFig.name.first_name .. " created as historical figure " .. tostring(godFig.id)) +local godFig = do_god(opts) + +if not opts.quiet then + print(godFig.name.first_name .. " created as historical figure " .. tostring(godFig.id)) end diff --git a/modtools/pref-edit.lua b/modtools/pref-edit.lua index a8638932fe..471930a7ca 100644 --- a/modtools/pref-edit.lua +++ b/modtools/pref-edit.lua @@ -35,7 +35,7 @@ Valid filters: Include one of these to describe what the id argument represents. - ``-type ``: This describes the type of the preference. Can be entered either using the numerical ID or text id. - Run ``lua @df.unit_preference.T_type`` for a full list of valid values. + Run ``lua @df.unitpref_type`` for a full list of valid values. - ``-subtype ``: The value for an item's subtype - ``-material ``: @@ -267,7 +267,7 @@ function main(...) if args.type and tonumber(args.type) then type = tonumber(args.type) elseif args.type then - type = df.unit_preference.T_type[args.type] + type = df.unitpref_type[args.type] end -- Handle material diff --git a/modtools/reaction-trigger-transition.lua b/modtools/reaction-trigger-transition.lua index 4d0fa2dd1f..0f139e5eb9 100644 --- a/modtools/reaction-trigger-transition.lua +++ b/modtools/reaction-trigger-transition.lua @@ -34,9 +34,9 @@ for _,reaction in ipairs(df.global.world.raws.reactions.reactions) do if product.mat_index < 0 then return end - local inorganic = df.global.world.raws.inorganics[product.mat_index] + local inorganic = df.global.world.raws.inorganics.all[product.mat_index] local didInorganicName - for _,syndrome in ipairs(inorganic.material.syndrome) do + for _,syndrome in ipairs(inorganic.material.syndrome.syndrome) do local workerOnly = true local allowMultipleTargets = false; local command diff --git a/modtools/reaction-trigger.lua b/modtools/reaction-trigger.lua index 0b6eda6684..813fd3aa7a 100644 --- a/modtools/reaction-trigger.lua +++ b/modtools/reaction-trigger.lua @@ -2,57 +2,7 @@ -- author expwnent -- replaces autoSyndrome --@ module = true -local usage = [====[ -modtools/reaction-trigger -========================= -Triggers dfhack commands when custom reactions complete, regardless of whether -it produced anything, once per completion. Arguments:: - - -clear - unregister all reaction hooks - -reactionName name - specify the name of the reaction - -syndrome name - specify the name of the syndrome to be applied to valid targets - -allowNonworkerTargets - allow other units to be targeted if the worker is invalid or ignored - -allowMultipleTargets - allow all valid targets within range to be affected - if absent: - if running a script, only one target will be used - if applying a syndrome, then only one target will be infected - -ignoreWorker - ignores the worker when selecting the targets - -dontSkipInactive - when selecting targets in range, include creatures that are inactive - dead creatures count as inactive - -range [ x y z ] - controls how far elligible targets can be from the workshop - defaults to [ 0 0 0 ] (on a workshop tile) - negative numbers can be used to ignore outer squares of the workshop - line of sight is not respected, and the worker is always within range - -resetPolicy policy - the policy in the case that the syndrome is already present - policy - NewInstance (default) - DoNothing - ResetDuration - AddDuration - -command [ commandStrs ] - specify the command to be run on the target(s) - special args - \\WORKER_ID - \\TARGET_ID - \\BUILDING_ID - \\LOCATION - \\REACTION_NAME - \\anything -> \anything - anything -> anything - when used with -syndrome, the target must be valid for the syndrome - otherwise, the command will not be run for that target - -]====] local eventful = require 'plugins.eventful' local syndromeUtil = require 'syndrome-util' local utils = require 'utils' @@ -65,7 +15,7 @@ eventful.onUnload.reactionTrigger = function() end local function findSyndrome(name) - for _,syndrome in ipairs(df.global.world.raws.syndromes.all) do + for _,syndrome in ipairs(df.global.world.raws.mat_table.syndromes.all) do if syndrome.syn_name == name then return syndrome end @@ -220,7 +170,6 @@ eventful.onJobCompleted.reactionTrigger = function(job) doAction(action) end end -eventful.enableEvent(eventful.eventType.JOB_COMPLETED,0) --0 is necessary to catch cancelled jobs and not trigger them local validArgs = utils.invert({ 'help', @@ -232,7 +181,7 @@ local validArgs = utils.invert({ 'allowMultipleTargets', 'range', 'ignoreWorker', - 'dontSkipInactive', + 'dontSkipInactive', --TODO: positions for inactive units are meaningless! 'resetPolicy' }) @@ -242,7 +191,7 @@ end local args = utils.processArgs({...}, validArgs) if args.help then - print(usage) + print(dfhack.script_help()) return end @@ -262,4 +211,6 @@ if args.syndrome and not findSyndrome(args.syndrome) then error('Could not find syndrome ' .. args.syndrome) end +eventful.enableEvent(eventful.eventType.JOB_COMPLETED,0) --0 is necessary to catch cancelled jobs and not trigger them + table.insert(reactionHooks[args.reactionName], args) diff --git a/modtools/set-belief.lua b/modtools/set-belief.lua index fa16095eea..ae9f53062d 100644 --- a/modtools/set-belief.lua +++ b/modtools/set-belief.lua @@ -6,75 +6,8 @@ > When using script functions, use numerical IDs for representing beliefs. > The random assignment is placeholder for now. I don't understand how they work ]] -local help = [====[ - -modtools/set-belief -=================== -Changes the beliefs (values) of units. -Requires a belief, modifier, and a target. - -Valid beliefs: - -:all: - Apply the edit to all the target's beliefs -:belief : - ID of the belief to edit. For example, 0 or LAW. - -Valid modifiers: - -:set <-50-50>: - Set belief to given strength. -:tier <1-7>: - Set belief to within the bounds of a strength tier: - - ===== ======== - Value Strength - ===== ======== - 1 Lowest - 2 Very Low - 3 Low - 4 Neutral - 5 High - 6 Very High - 7 Highest - ===== ======== - -:modify : - Modify current belief strength by given amount. - Negative values need a ``\`` before the negative symbol e.g. ``\-1`` -:step : - Modify current belief tier up/down by given amount. - Negative values need a ``\`` before the negative symbol e.g. ``\-1`` -:random: - Use the default probabilities to set the belief to a new random value. -:default: - Belief will be set to cultural default. - -Valid targets: - -:citizens: - All (sane) citizens of your fort will be affected. Will do nothing in adventure mode. -:unit : - The given unit will be affected. - -If no target is given, the provided unit can't be found, or no unit id is given with the unit -argument, the script will try and default to targeting the currently selected unit. - -Other arguments: - -:help: - Shows this help page. -:list: - Prints a list of all beliefs + their IDs. -:noneed: - By default, unit's needs will be recalculated to reflect new beliefs after every run. - Use this argument to disable that functionality. -:listunit: - Prints a list of all a unit's beliefs. Cultural defaults are marked with ``*``. - -]====] - -local utils = require 'utils' + +local utils = require('utils') local validArgs = utils.invert({ "all", @@ -220,7 +153,7 @@ end -- Gives a list of all the unit's beliefs and their values. -- Returns a table where the keys are the belief names and its values are its strength -- If tiers is true, the value is the tier of the belief instead of the trait's strength -local function getUnitBeliefList(unit, tiers) +function getUnitBeliefList(unit, tiers) local list = {} for id, beliefName in ipairs(df.value_type) do @@ -330,7 +263,7 @@ end -- Print unit's beliefs into the dfhack console. Cultural beliefs are marked with a * function printUnitBeliefs(unit) - print("Beliefs for " .. dfhack.TranslateName(unit.name) .. ":") + print("Beliefs for " .. dfhack.df2console(dfhack.units.getReadableName(unit)) .. ":") for id, name in ipairs(df.value_type) do if name ~= 'NONE' then local strength = getUnitBelief(unit, id) @@ -352,7 +285,7 @@ function main(...) local setneed = dfhack.reqscript("modtools/set-need") if args.help then - print(help) + print(dfhack.script_help()) return end @@ -396,11 +329,7 @@ function main(...) qerror("-citizens argument only available in Fortress Mode.") end - for _, unit in pairs(df.global.world.units.active) do - if dfhack.units.isCitizen(unit) then - table.insert(unitsList, unit) - end - end + unitsList = dfhack.units.getCitizens(false, true) end -- Belief check diff --git a/modtools/set-need.lua b/modtools/set-need.lua index 8698d8744b..fd235e28a7 100644 --- a/modtools/set-need.lua +++ b/modtools/set-need.lua @@ -455,7 +455,7 @@ end -- Print unit's needs, levels, and focus into dfhack console function printUnitNeeds(unit) - print("Needs for " .. dfhack.TranslateName(unit.name) .. ":") + print("Needs for " .. dfhack.df2console(dfhack.units.getReadableName(unit)) .. ":") for index, needInstance in ipairs(unit.status.current_soul.personality.needs) do local name = df.need_type[needInstance.id] local level = needInstance.need_level diff --git a/modtools/set-personality.lua b/modtools/set-personality.lua index 12655b496a..8e13af0e4a 100644 --- a/modtools/set-personality.lua +++ b/modtools/set-personality.lua @@ -5,78 +5,7 @@ > When using script functions, use numerical IDs for representing personality traits. ]] --- hey set-belief, can I copy your homework? --- sure, just change it up a bit so it doesn't look obvious you copied --- ok -local help = [====[ - -modtools/set-personality -======================== -Changes the personality of units. -Requires a trait, modifier, and a target. - -Valid traits: - -:all: - Apply the edit to all the target's traits -:trait : - ID of the trait to edit. For example, 0 or HATE_PROPENSITY. - -Valid modifiers: - -:set <0-100>: - Set trait to given strength. -:tier <1-7>: - Set trait to within the bounds of a strength tier. - - ===== ======== - Value Strength - ===== ======== - 1 Lowest - 2 Very Low - 3 Low - 4 Neutral - 5 High - 6 Very High - 7 Highest - ===== ======== - -:modify : - Modify current base trait strength by given amount. - Negative values need a ``\`` before the negative symbol e.g. ``\-1`` -:step : - Modify current trait tier up/down by given amount. - Negative values need a ``\`` before the negative symbol e.g. ``\-1`` -:random: - Set the trait to a new random value. -:average: - Sets trait to the creature's caste's average value (as defined in the PERSONALITY creature tokens). - -Valid targets: - -:citizens: - All (sane) citizens of your fort will be affected. Will do nothing in adventure mode. -:unit : - The given unit will be affected. - -If no target is given, the provided unit can't be found, or no unit id is given with the unit -argument, the script will try and default to targeting the currently selected unit. - -Other arguments: - -:help: - Shows this help page. -:list: - Prints a list of all facets + their IDs. -:noneed: - By default, unit's needs will be recalculated to reflect new traits after every run. - Use this argument to disable that functionality. -:listunit: - Prints a list of all a unit's personality traits, with their modified trait value in brackets. - -]====] - -local utils = require 'utils' +local utils = require('utils') local validArgs = utils.invert({ "all", @@ -156,7 +85,7 @@ function getUnitTraitCurrent(unit, trait) local base = getUnitTraitBase(unit, trait) local value = base if unit.status.current_soul.personality.temporary_trait_changes ~= nil then - value = value + unit.status.current_soul.personality.temporary_trait_changes[trait] + value = value + unit.status.current_soul.personality.temporary_trait_changes.traits[trait] end return value @@ -236,12 +165,12 @@ end -- Gets the range of the unit caste's min, average, and max value for a trait, as defined in the PERSONALITY creature tokens. function getUnitCasteTraitRange(unit, trait) - local caste = df.creature_raw.find(unit.race).caste[unit.caste] + local caste = dfhack.units.getCasteRaw(unit) local range = {} - range.min = caste.personality.a[df.personality_facet_type[trait]] - range.mid = caste.personality.b[df.personality_facet_type[trait]] - range.max = caste.personality.c[df.personality_facet_type[trait]] + range.min = caste.personality.min[df.personality_facet_type[trait]] + range.mid = caste.personality.med[df.personality_facet_type[trait]] + range.max = caste.personality.max[df.personality_facet_type[trait]] return range end @@ -314,7 +243,7 @@ function randomTraitValue() end function printUnitTraits(unit) - print("Traits of " .. dfhack.TranslateName(unit.name) .. ":") + print("Traits of " .. dfhack.df2console(dfhack.units.getReadableName(unit)) .. ":") for id, name in ipairs(df.personality_facet_type) do if name ~= 'NONE' then local baseValue = getUnitTraitBase(unit, id) @@ -330,7 +259,7 @@ function main(...) local setneed = dfhack.reqscript("modtools/set-need") if args.help then - print(help) + print(dfhack.script_help()) return end @@ -366,19 +295,12 @@ function main(...) table.insert(unitsList, unit) end elseif args.citizens then - -- Technically this will exclude insane citizens, but this is the - -- easiest thing that dfhack provides - -- Abort if not in Fort mode if not dfhack.world.isFortressMode() then - qerror("-citizens argument only available in Fortress Mode.") + qerror("--citizens argument only available in Fortress Mode.") end - for _, unit in pairs(df.global.world.units.active) do - if dfhack.units.isCitizen(unit) then - table.insert(unitsList, unit) - end - end + unitsList = dfhack.units.getCitizens(false, true) end -- Trait check diff --git a/modtools/spawn-liquid.lua b/modtools/spawn-liquid.lua index 419b0b4595..9a3d2417e8 100644 --- a/modtools/spawn-liquid.lua +++ b/modtools/spawn-liquid.lua @@ -21,6 +21,10 @@ function spawnLiquid(position, liquid_level, liquid_type) local map_block = dfhack.maps.getTileBlock(position) local tile = dfhack.maps.getTileFlags(position) + if liquid_level == 0 then + liquid_type = df.tile_liquid.Water + end + tile.flow_size = liquid_level or 3 tile.liquid_type = liquid_type tile.flow_forbid = liquid_type == df.tile_liquid.Magma or liquid_level >= 4 @@ -34,6 +38,8 @@ function spawnLiquid(position, liquid_level, liquid_type) z_level.update = true z_level.update_twice = true + df.global.world.reindex_pathfinding = true + resetTemperature(position) end diff --git a/modtools/syndrome-trigger.lua b/modtools/syndrome-trigger.lua index 861848ac77..1718668337 100644 --- a/modtools/syndrome-trigger.lua +++ b/modtools/syndrome-trigger.lua @@ -133,7 +133,7 @@ function processSyndrome(syndrome) end local synFound = false -for _,syn in ipairs(df.global.world.raws.syndromes.all) do +for _,syn in ipairs(df.global.world.raws.mat_table.syndromes.all) do local matchedSyn = false if args.syndrome then if syn.syn_name == args.syndrome then diff --git a/necronomicon.lua b/necronomicon.lua index 68ef8d083c..4914b85a60 100644 --- a/necronomicon.lua +++ b/necronomicon.lua @@ -1,5 +1,4 @@ -- lists books that contain secrets of life and death. --- Author: Ajhaa local argparse = require("argparse") @@ -10,14 +9,16 @@ function get_book_interactions(item) improvement._type == df.itemimprovement_writingst then for _, content_id in ipairs(improvement.contents) do local written_content = df.written_content.find(content_id) - title = written_content.title + if not written_content then goto continue end + title = written_content.title for _, ref in ipairs (written_content.refs) do if ref._type == df.general_ref_interactionst then - local interaction = df.global.world.raws.interactions[ref.interaction_id] + local interaction = df.interaction.find(ref.interaction_id) table.insert(book_interactions, interaction) end end + ::continue:: end end end @@ -34,7 +35,7 @@ end function get_item_artifact(item) for _, ref in ipairs(item.general_refs) do if ref._type == df.general_ref_is_artifactst then - return df.global.world.artifacts.all[ref.artifact_id] + return df.artifact_record.find(ref.artifact_id) end end end @@ -59,7 +60,7 @@ function necronomicon(include_slabs) for _, item in ipairs(df.global.world.items.other.SLAB) do if check_slab_secrets(item) then local artifact = get_item_artifact(item) - local name = dfhack.TranslateName(artifact.name) + local name = dfhack.translation.translateName(artifact.name) print(" " .. dfhack.df2console(name)) end end @@ -80,11 +81,37 @@ function necronomicon(include_slabs) end end +function necronomicon_world(include_slabs) + if include_slabs then + print("Slabs:") + print() + for _,rec in ipairs(df.global.world.artifacts.all) do + if df.item_slabst:is_instance(rec.item) and check_slab_secrets(rec.item) then + print(dfhack.df2console(dfhack.translation.translateName(rec.name))) + end + end + print() + end + print("Books and Scrolls:") + print() + for _,rec in ipairs(df.global.world.artifacts.all) do + if df.item_bookst:is_instance(rec.item) or df.item_toolst:is_instance(rec.item) then + local title, interactions = get_book_interactions(rec.item) + + if next(interactions) then + print(" " .. dfhack.df2console(title)) + print_interactions(interactions) + print() + end + end + end +end local help = false -local include_slabs = false +local include_slabs, scan_world = false, false local args = argparse.processArgsGetopt({...}, { {"s", "include-slabs", handler=function() include_slabs = true end}, + {"w", "world", handler=function() scan_world = true end}, {"h", "help", handler=function() help = true end} }) @@ -92,8 +119,12 @@ local cmd = args[1] if help or cmd == "help" then print(dfhack.script_help()) -elseif cmd == nil or cmd == "" then - necronomicon(include_slabs) +elseif not cmd then + if scan_world then + necronomicon_world(include_slabs) + else + necronomicon(include_slabs) + end else print(('necronomicon: Invalid argument: "%s"'):format(cmd)) end diff --git a/notes.lua b/notes.lua new file mode 100644 index 0000000000..db4d7107ba --- /dev/null +++ b/notes.lua @@ -0,0 +1,176 @@ +--@ module = true + +local overlay = require('plugins.overlay') +local guidm = require('gui.dwarfmode') +local note_manager = reqscript('internal/notes/note_manager') + +textures = { + green_pin = dfhack.textures.loadTileset( + dfhack.getHackPath()..'/data/art/note_green_pin_map.png', + 32, + 32, + true + ) +} + +NotesOverlay = defclass(NotesOverlay, overlay.OverlayWidget) +NotesOverlay.ATTRS{ + desc='Render map notes.', + viewscreens='dwarfmode', + default_enabled=true, + overlay_onupdate_max_freq_seconds=30, +} + +local map_points = df.global.plotinfo.waypoints.points + +function NotesOverlay:init() + self.visible_notes = {} + self.note_manager = nil + self.last_click_pos = {} + self:reloadVisibleNotes() +end + +function NotesOverlay:overlay_onupdate() + self:reloadVisibleNotes() +end + +function NotesOverlay:overlay_trigger(cmd) + if cmd == 'add' then + self:showNoteManager() + else + self:reloadVisibleNotes() + end +end + +function NotesOverlay:onInput(keys) + if keys._MOUSE_L then + local top_most_screen = dfhack.gui.getDFViewscreen(true) + if dfhack.gui.matchFocusString('dwarfmode/Default', top_most_screen) then + local pos = dfhack.gui.getMousePos() + if pos == nil then + return false + end + + local note = self:clickedNote(pos) + if note ~= nil then + self:showNoteManager(note) + end + end + end +end + +function NotesOverlay:clickedNote(click_pos) + local pos_curr_note = same_xyz(self.last_click_pos, click_pos) + and self.note_manager + and self.note_manager.note + or nil + + self.last_click_pos = click_pos + + local last_note_on_pos = nil + local first_note_on_pos = nil + for _, note in ipairs(self.visible_notes) do + if same_xyz(note.point.pos, click_pos) then + if (last_note_on_pos and pos_curr_note + and last_note_on_pos.point.id == pos_curr_note.point.id + ) then + return note + end + + first_note_on_pos = first_note_on_pos or note + last_note_on_pos = note + end + end + + return first_note_on_pos +end + +function NotesOverlay:showNoteManager(note) + if self.note_manager ~= nil then + self.note_manager:dismiss() + end + + self.note_manager = note_manager.NoteManager{ + note=note, + on_update=function() self:reloadVisibleNotes() end + } + + return self.note_manager:show() +end + +function NotesOverlay:viewportChanged() + return self.viewport_pos.x ~= df.global.window_x or + self.viewport_pos.y ~= df.global.window_y or + self.viewport_pos.z ~= df.global.window_z +end + +function NotesOverlay:onRenderFrame(dc) + if not df.global.pause_state and not dfhack.screen.inGraphicsMode() then + return + end + + if self:viewportChanged() then + self:reloadVisibleNotes() + end + + dc:map(true) + + local texpos = dfhack.textures.getTexposByHandle(textures.green_pin[1]) + dc:pen({fg=COLOR_BLACK, bg=COLOR_LIGHTCYAN, tile=texpos}) + + for _, note in pairs(self.visible_notes) do + dc + :seek(note.screen_pos.x, note.screen_pos.y) + :char('N') + end + + dc:map(false) +end + +function NotesOverlay:reloadVisibleNotes() + self.visible_notes = {} + + local viewport = guidm.Viewport.get() + self.viewport_pos = { + x=df.global.window_x, + y=df.global.window_y, + z=df.global.window_z + } + + for _, map_point in ipairs(map_points) do + if (viewport:isVisible(map_point.pos) + and map_point.name ~= nil and #map_point.name > 0) + then + local screen_pos = viewport:tileToScreen(map_point.pos) + table.insert(self.visible_notes, { + point=map_point, + screen_pos=screen_pos + }) + end + end +end + +-- register widgets +OVERLAY_WIDGETS = { + map_notes=NotesOverlay +} + +local function main(args) + if #args == 0 then + return + end + + if args[1] == 'add' then + local cursor_pos = guidm.getCursorPos() + if cursor_pos == nil then + dfhack.printerr('Enable keyboard cursor to add a note.') + return + end + + return dfhack.run_command_silent('overlay trigger notes.map_notes add') + end +end + +if not dfhack_flags.module then + main({...}) +end diff --git a/once-per-save.lua b/once-per-save.lua index 5cb91d247e..d7e8c79594 100644 --- a/once-per-save.lua +++ b/once-per-save.lua @@ -1,90 +1,40 @@ --- runs dfhack commands unless ran already in this save +-- runs dfhack commands unless ran already in this save (world) -local HELP = [====[ +local argparse = require('argparse') -once-per-save -============= -Runs commands like `multicmd`, but only unless -not already ran once in current save. You may actually -want `on-new-fortress`. +local GLOBAL_KEY = 'once-per-save' -Only successfully ran commands are saved. +local opts = { + help=false, + rerun=false, + reset=false, +} -Parameters: +local positionals = argparse.processArgsGetopt({...}, { + {'h', 'help', handler=function() opts.help = true end}, + {nil, 'rerun', handler=function() opts.rerun = true end}, + {nil, 'reset', handler=function() opts.reset = true end}, +}) ---help display this help ---rerun commands ignore saved commands ---reset deletes saved commands - -]====] - -local STORAGEKEY_PREFIX = 'once-per-save' -local storagekey = STORAGEKEY_PREFIX .. ':' .. tostring(df.global.plotinfo.site_id) - -local args = {...} -local rerun = false - -local utils = require 'utils' -local arg_help = utils.invert{"?", "-?", "-help", "--help"} -local arg_rerun = utils.invert{"-rerun", "--rerun"} -local arg_reset = utils.invert{"-reset", "--reset"} -if arg_help[args[1]] then - print(HELP) +if opts.help or positionals[1] == 'help' then + print(dfhack.script_help()) return -elseif arg_rerun[args[1]] then - rerun = true - table.remove(args, 1) -elseif arg_reset[args[1]] then - while dfhack.persistent.delete(storagekey) do end - table.remove(args, 1) end -if #args == 0 then return end - -local age = df.global.plotinfo.fortress_age -local year = df.global.cur_year -local year_tick = df.global.cur_year_tick -local year_tick_advmode = df.global.cur_year_tick_advmode -local function is_later(a, b) - for i, v in ipairs(a) do - if v < b[i] then - return true - elseif v > b[i] then - return false - --else: v == b[i] so keep iterating - end - end - return false +if opts.reset then + dfhack.persistent.deleteWorldData(GLOBAL_KEY) end +if #positionals == 0 then return end -local once_run = {} -if not rerun then - local entries = dfhack.persistent.get_all(storagekey) or {} - for i, entry in ipairs(entries) do - local ints = entry.ints - if ints[1] > age - or age == 0 and is_later({ints[2], ints[3], ints[4]}, {year, year_tick, year_tick_advmode}) - then - print (dfhack.current_script_name() .. ': unretired fortress, deleting `' .. entry.value .. '`') - --printall_recurse(entry) -- debug - entry:delete() - else - once_run[entry.value]=entry - end - end -end +local state = dfhack.persistent.getWorldData(GLOBAL_KEY, {}) -local save = dfhack.persistent.save for cmd in table.concat(args, ' '):gmatch("%s*([^;]+);?%s*") do - if not once_run[cmd] then - local ok = dfhack.run_command(cmd) == 0 - if ok then - once_run[cmd] = save({key = storagekey, - value = cmd, - ints = { age, year, year_tick, year_tick_advmode }}, - true) - elseif rerun and once_run[cmd] then - once_run[cmd]:delete() + cmd = cmd:trim() + if not state[cmd] or opts.rerun then + if dfhack.run_command(cmd) == CR_OK then + state[cmd] = {already_run=true} end end end + +dfhack.persistent.saveWorldData(GLOBAL_KEY, state) diff --git a/open-legends.lua b/open-legends.lua index c584f14f46..4f9e4d4c46 100644 --- a/open-legends.lua +++ b/open-legends.lua @@ -1,90 +1,203 @@ -- open legends screen when in fortress mode ---@ module = true local dialogs = require('gui.dialogs') local gui = require('gui') local utils = require('utils') +local widgets = require('gui.widgets') -Restorer = defclass(Restorer, gui.Screen) -Restorer.ATTRS{ - focus_path='open-legends' +tainted = tainted or false + +-- -------------------------------- +-- LegendsManager +-- + +LegendsManager = defclass(LegendsManager, gui.ZScreen) +LegendsManager.ATTRS { + focus_path='open-legends', + defocused=true, + no_autoquit=false, } -function Restorer:init() - print('initializing restorer') +function LegendsManager:init() + tainted = true + + -- back up what we can to make a return to the previous mode possible. + -- note that even with these precautions, data **is lost** when switching + -- to legends mode and back. testing shows that a savegame made directly + -- after returning from legends mode will be **smaller** than a savegame + -- made just before entering legends mode. We don't know exactly what is + -- missing, but it shows that jumping back and forth between modes is not + -- safe. self.region_details_backup = {} --as:df.world_region_details[] - local v = df.global.world.world_data.region_details - while (#v > 0) do - table.insert(self.region_details_backup, 1, v[0]) - v:erase(0) - end + local vec = df.global.world.world_data.midmap_data.region_details + utils.assign(self.region_details_backup, vec) + vec:resize(0) + + self.gametype_backup = df.global.gametype + df.global.gametype = df.game_type.VIEW_LEGENDS + + local legends = df.viewscreen_legendsst:new() + legends.page:insert("#", {new=true, header="Legends", mode=0, index=-1}) + dfhack.screen.show(legends) + + self:addviews{ + widgets.Panel{ + frame=gui.get_interface_frame(), + subviews={ + widgets.Panel{ + view_id='done_mask', + frame={t=1, r=2, w=8, h=3}, + }, + }, + }, + } end -function Restorer:onIdle() - self:dismiss() +function LegendsManager:isMouseOver() + return self.subviews.done_mask:getMouseFramePos() end -function Restorer:onDismiss() - print('dismissing restorer') - local v = df.global.world.world_data.region_details - while (#v > 0) do v:erase(0) end - for _,item in pairs(self.region_details_backup) do - v:insert(0, item) +function LegendsManager:onInput(keys) + if keys.LEAVESCREEN or keys._MOUSE_R or (keys._MOUSE_L and self.subviews.done_mask:getMousePos()) then + if self.no_autoquit then + self:dismiss() + else + dialogs.showYesNoPrompt('Exiting to avoid save corruption', + 'Dwarf Fortress is in a non-playable state\nand will now exit to protect your savegame.', + COLOR_YELLOW, + self:callback('dismiss')) + end + return true end + return LegendsManager.super.onInput(self, keys) end -function show_screen() - local ok, err = pcall(function() - Restorer{}:show() - dfhack.screen.show(df.viewscreen_legendsst:new()) - end) - if not ok then - qerror('Failed to set up legends screen: ' .. tostring(err)) +function LegendsManager:onDestroy() + if not self.no_autoquit then + dfhack.run_command('die') + else + df.global.gametype = self.gametype_backup + + local vec = df.global.world.world_data.midmap_data.region_details + vec:resize(0) + utils.assign(vec, self.region_details_backup) + + dfhack.run_script('devel/pop-screen') + + -- disable autosaves for the remainder of this session + df.global.d_init.feature.autosave = df.d_init_autosave.NONE end end -function main(force) - if not dfhack.isWorldLoaded() then - qerror('no world loaded') - end +-- -------------------------------- +-- LegendsWarning +-- - local view = df.global.gview.view - while view do - if df.viewscreen_legendsst:is_instance(view) then - qerror('legends screen already displayed') - end - view = view.child - end +LegendsWarning = defclass(LegendsWarning, widgets.Window) +LegendsWarning.ATTRS { + frame_title='Open Legends Mode', + frame={w=50, h=14}, + autoarrange_subviews=true, + autoarrange_gap=1, + no_autoquit=false, +} - if not dfhack.world.isFortressMode(df.global.gametype) and not dfhack.world.isAdventureMode(df.global.gametype) and not force then - qerror('mode not tested: ' .. df.game_type[df.global.gametype] .. ' (use "force" to force)') +function LegendsWarning:init() + if not self.no_autoquit then + self.frame.h = self.frame.h + 5 end - - if force then - show_screen() - else - dialogs.showYesNoPrompt('Save corruption possible', - 'This script can CORRUPT YOUR SAVE. If you care about this world,\n' .. - 'DO NOT SAVE AFTER RUNNING THIS SCRIPT - run "die" to quit DF\n' .. - 'without saving.\n\n' .. - 'To use this script safely,\n' .. - '1. Press "esc" to exit this prompt\n' .. - '2. Pause DF\n' .. - '3. Run "quicksave" to save this world\n' .. - '4. Run this script again and press ENTER to enter legends mode\n' .. - '5. IMMEDIATELY AFTER EXITING LEGENDS, run "die" to quit DF\n\n' .. - 'Press "esc" below to go back, or "y" to enter legends mode.\n' .. - 'By pressing "y", you acknowledge that your save could be\n' .. - 'permanently corrupted if you do not follow the above steps.', - COLOR_LIGHTRED, - show_screen - ) + if dfhack.world.isFortressMode() then + self.frame.h = self.frame.h + (self.no_autoquit and 7 or 2) end + + self:addviews{ + widgets.Label{ + text={ + 'This script allows you to jump into legends', NEWLINE, + 'mode from a active game, but beware that this', NEWLINE, + 'is a', {gap=1, text='ONE WAY TRIP', pen=COLOR_RED}, '.', NEWLINE, + NEWLINE, + 'Returning to play from legends mode', NEWLINE, + 'would make the game unstable, so to protect', NEWLINE, + 'your savegame, Dwarf Fortress will exit when', NEWLINE, + 'you are done browsing.', + }, + visible=not self.no_autoquit, + }, + widgets.Label{ + text={ + 'You have opted for a ', {text='two-way ticket', pen=COLOR_RED} ,' to legends', NEWLINE, + 'mode. Remember to ', {text='quit to desktop and restart', pen=COLOR_RED}, NEWLINE, + 'DF when you\'re done to avoid save corruption.' + }, + visible=self.no_autoquit, + }, + widgets.Label{ + text={ + 'When you return to fort mode, automatic', NEWLINE, + 'autosaves will be disabled until you restart', NEWLINE, + 'DF to avoid accidentally overwriting good', NEWLINE, + 'savegames.', + }, + visible=self.no_autoquit and dfhack.world.isFortressMode(), + }, + widgets.Label{ + text='This is your last chance to save your game.', + text_pen=COLOR_LIGHTRED, + }, + widgets.HotkeyLabel{ + frame={l=0}, + key='CUSTOM_SHIFT_S', + label='Please click here to create an Autosave', + text_pen=COLOR_YELLOW, + on_activate=function() dfhack.run_command('quicksave') end, + visible=dfhack.world.isFortressMode(), + }, + widgets.Label{ + text={ + (dfhack.world.isFortressMode() and 'Alternately,' or 'You can') .. ' exit out of this dialog and', NEWLINE, + 'create a named save of your choice.', + }, + }, + widgets.HotkeyLabel{ + key='CUSTOM_ALT_L', + label='Click here to continue to legends mode', + text_pen=self.no_autoquit and COLOR_RED or nil, + auto_width=true, + on_activate=function() + self.parent_view:dismiss() + LegendsManager{no_autoquit=self.no_autoquit}:show() + end, + }, + } +end + +LegendsWarningScreen = defclass(LegendsWarningScreen, gui.ZScreenModal) +LegendsWarningScreen.ATTRS { + focus_path='open-legends/warning', + no_autoquit=false, +} + +function LegendsWarningScreen:init() + self:addviews{LegendsWarning{no_autoquit=self.no_autoquit}} end -if dfhack_flags.module then - return +function LegendsWarningScreen:onDismiss() + view = nil +end + +if not dfhack.isWorldLoaded() then + qerror('no world loaded') +end + +local function main(args) + local no_autoquit = args[1] == '--no-autoquit' + + if tainted then + LegendsManager{no_autoquit=no_autoquit}:show() + else + view = view and view:raise() or LegendsWarningScreen{no_autoquit=no_autoquit}:show() + end end -local iargs = utils.invert{...} -main(iargs.force) +main{...} diff --git a/pop-control.lua b/pop-control.lua index 9fbfe29ff3..8d76587bba 100644 --- a/pop-control.lua +++ b/pop-control.lua @@ -1,108 +1,123 @@ -local script = require("gui.script") -local persistTable = require("persist-table") - --- (Hopefully) get original settings -originalPopCap = originalPopCap or df.global.d_init.population_cap -originalStrictPopCap = originalStrictPopCap or df.global.d_init.strict_population_cap -originalVisitorCap = originalVisitorCap or df.global.d_init.visitor_cap - -local function popControl(forceEnterSettings) - if df.global.gamemode ~= df.game_mode.DWARF then - if forceEnterSettings then - -- did reenter-settings, show an error - qerror("Not in fort mode") - return - else - -- silent automatic behaviour - return - end +--@module = true +--@enable = true + +local argparse = require('argparse') +local repeatutil = require('repeat-util') +local utils = require('utils') + +local GLOBAL_KEY = 'pop-control' + +local function get_default_state() + return { + enabled=false, + max_wave=10, + max_pop=200, + } +end + +state = state or get_default_state() + +function isEnabled() + return state.enabled +end + +local function persist_state() + dfhack.persistent.saveSiteData(GLOBAL_KEY, state) +end + +local function adjust_caps() + if not state.enabled then return end + local new_cap = math.min(state.max_pop, #dfhack.units.getCitizens() + state.max_wave) + if new_cap ~= df.global.d_init.dwarf.population_cap then + df.global.d_init.dwarf.population_cap = new_cap + print('pop-control: Population cap set to ' .. new_cap) end +end + +local function do_enable() + state.enabled = true + repeatutil.scheduleEvery(GLOBAL_KEY, 1, "months", adjust_caps) +end + +local function do_disable() + state.enabled = false + repeatutil.cancel(GLOBAL_KEY) + df.global.d_init.dwarf.population_cap = state.max_pop + print('pop-control: Population cap reset to ' .. state.max_pop) +end - if not persistTable.GlobalTable.fortPopInfo then - persistTable.GlobalTable.fortPopInfo = {} +local function do_set(which, val) + local num = argparse.positiveInt(val, which) + if which == 'wave-size' then + state.max_wave = num + elseif which == 'max-pop' then + state.max_pop = num + else + qerror(('unknown setting: "%s"'):format(which)) end + adjust_caps() +end + +local function do_reset() + local enabled = state.enabled + state = get_default_state() + state.enabled = enabled + adjust_caps() +end - local siteId = df.global.plotinfo.site_id - - script.start(function() - local siteInfo = persistTable.GlobalTable.fortPopInfo[siteId] - if not siteInfo or forceEnterSettings then - -- get new settings - persistTable.GlobalTable.fortPopInfo[siteId] = nil -- i don't know if persist-table works well with reassignent - persistTable.GlobalTable.fortPopInfo[siteId] = {} - siteInfo = persistTable.GlobalTable.fortPopInfo[siteId] - if script.showYesNoPrompt("Hermit", "Hermit mode?") then - siteInfo.hermit = "true" - else - siteInfo.hermit = "false" - local _ -- ignore - -- migrant cap - local migrantCapInput - while not tonumber(migrantCapInput) do - _, migrantCapInput = script.showInputPrompt("Migrant cap", "Maximum migrants per wave?") - end - siteInfo.migrantCap = migrantCapInput - -- pop cap - local popCapInput - while not tonumber(popCapInput) or popCapInput == "" do - _, popCapInput = script.showInputPrompt("Population cap", "Maximum population? Settings population cap: " .. originalPopCap .. "\n(assuming wasn't changed before first call of this script)") - end - siteInfo.popCap = tostring(tonumber(popCapInput) or originalPopCap) - -- strict pop cap - local strictPopCapInput - while not tonumber(strictPopCapInput) or strictPopCapInput == "" do - _, strictPopCapInput = script.showInputPrompt("Strict population cap", "Strict maximum population? Settings strict population cap " .. originalStrictPopCap .. "\n(assuming wasn't changed before first call of this script)") - end - siteInfo.strictPopCap = tostring(tonumber(strictPopCapInput) or originalStrictPopCap) - -- visitor cap - local visitorCapInput - while not tonumber(visitorCapInput) or visitorCapInput == "" do - _, visitorCapInput = script.showInputPrompt("Visitors", "Vistitor cap? Settings visitor cap " .. originalVisitorCap .. "\n(assuming wasn't changed before first call of this script)") - end - siteInfo.visitorCap = tostring(tonumber(visitorCap) or originalVisitorCap) - end - end - -- use settings - if siteInfo.hermit == "true" then - dfhack.run_command("hermit enable") - -- NOTE: could, maybe should cancel max-wave repeat here - else - dfhack.run_command("hermit disable") - dfhack.run_command("repeat -name max-wave -timeUnits months -time 1 -command [ max-wave " .. siteInfo.migrantCap .. " " .. siteInfo.popCap .. " ]") - df.global.d_init.strict_population_cap = tonumber(siteInfo.strictPopCap) - df.global.d_init.visitor_cap = tonumber(siteInfo.visitorCap) - end - end) +local function print_status() + print(('pop-control is %s.'):format(state.enabled and 'enabled' or 'disabled')) + print() + print('Settings:') + print((' wave-size: %3d'):format(state.max_wave)) + print((' max-pop: %3d'):format(state.max_pop)) + print() + print('Current game caps:') + print((' population cap: %3d'):format(df.global.d_init.dwarf.population_cap)) + print((' strict pop cap: %3d'):format(df.global.d_init.dwarf.strict_population_cap)) + print((' visitor cap: %3d'):format(df.global.d_init.dwarf.visitor_cap)) end -local function viewSettings() - local siteId = df.global.plotinfo.site_id - if not persistTable.GlobalTable.fortPopInfo or not persistTable.GlobalTable.fortPopInfo[siteId] then - print("Could not find site information") +--- Handles automatic loading +dfhack.onStateChange[GLOBAL_KEY] = function(sc) + if sc ~= SC_MAP_LOADED or not dfhack.world.isFortressMode() then return end - local siteInfo = persistTable.GlobalTable.fortPopInfo[siteId] - if siteInfo.hermit == "true" then - print("Hermit: true") - return + + state = get_default_state() + utils.assign(state, dfhack.persistent.getSiteData(GLOBAL_KEY, state)) + if state.enabled then + do_enable() end - print("Hermit: false") - print("Migrant cap: " .. siteInfo.migrantCap) - print("Population cap: " .. siteInfo.popCap) - print("Strict population cap: " .. siteInfo.strictPopCap) - print("Visitor cap: " .. siteInfo.visitorCap) end -local function help() - print("syntax: pop-control [on-load|reenter-settings|view-settings]") +if dfhack_flags.module then + return end -local action_switch = { - ["on-load"] = function() popControl(false) end, - ["reenter-settings"] = function() popControl(true) end, - ["view-settings"] = function() viewSettings() end -} -setmetatable(action_switch, {__index = function() return help end}) +if not dfhack.world.isFortressMode() or not dfhack.isMapLoaded() then + qerror('needs a loaded fortress map to work') +end local args = {...} -action_switch[args[1] or "help"]() +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 == 'set' then + do_set(args[1], args[2]) +elseif command == 'reset' then + do_reset() +elseif not command or command == 'status' then + print_status() + return +else + print(dfhack.script_help()) + return +end + +persist_state() diff --git a/position.lua b/position.lua index 9ebf9a1942..094211fb29 100644 --- a/position.lua +++ b/position.lua @@ -1,14 +1,25 @@ ---prints current time and position ---[====[ +-- Report cursor and mouse position, along with other info. +--@ module = true -position -======== -Reports the current time: date, clock time, month, and season. Also reports -location: z-level, cursor position, window size, and mouse location. +local argparse = require('argparse') +local guidm = require('gui.dwarfmode') -]====] +local function parse_args(args) + local opts = {} + local positionals = argparse.processArgsGetopt(args, + { + {'c', 'copy', handler=function() opts.copy = true end}, + }) -local months = { + if #positionals > 0 then + qerror('Too many positionals!') + end + + return opts +end + +local months = +{ 'Granite, in early Spring.', 'Slate, in mid Spring.', 'Felsite, in late Spring.', @@ -23,29 +34,102 @@ local months = { 'Obsidian, in late Winter.', } ---Fortress mode counts 1200 ticks per day and 403200 per year ---Adventurer mode counts 86400 ticks to a day and 29030400 ticks per year ---Twelve months per year, 28 days to every month, 336 days per year - -local julian_day = math.floor(df.global.cur_year_tick / 1200) + 1 -local month = math.floor(julian_day / 28) + 1 --days and months are 1-indexed -local day = julian_day % 28 - -local time_of_day = math.floor(df.global.cur_year_tick_advmode / 336) -local second = time_of_day % 60 -local minute = math.floor(time_of_day / 60) % 60 -local hour = math.floor(time_of_day / 3600) % 24 - -print('Time:') -print(' The time is '..string.format('%02d:%02d:%02d', hour, minute, second)) -print(' The date is '..string.format('%05d-%02d-%02d', df.global.cur_year, month, day)) -print(' It is the month of '..months[month]) ---TODO: print(' It is the Age of '..age_name) - -print('Place:') -print(' The z-level is z='..df.global.window_z) -print(' The cursor is at x='..df.global.cursor.x..', y='..df.global.cursor.y) -print(' The window is '..df.global.gps.dimx..' tiles wide and '..df.global.gps.dimy..' tiles high') -if df.global.gps.mouse_x == -1 then print(' The mouse is not in the DF window') else -print(' The mouse is at x='..df.global.gps.mouse_x..', y='..df.global.gps.mouse_y..' within the window') end ---TODO: print(' The fortress is at '..x, y..' on the world map ('..worldsize..' square)') +function print_time_info() + --Fortress mode counts 1200 ticks per day and 403200 per year + --Adventurer mode counts 86400 ticks to a day and 29030400 ticks per year + --Twelve months per year, 28 days to every month, 336 days per year + local julian_day = df.global.cur_year_tick // 1200 + 1 + local month = julian_day // 28 + 1 --days and months are 1-indexed + local day = julian_day % 28 + + local time_of_day = df.global.cur_year_tick_advmode // 336 + local second = time_of_day % 60 + local minute = time_of_day // 60 % 60 + local hour = time_of_day // 3600 % 24 + + print('Time:') + print((' The time is %02d:%02d:%02d'):format(hour, minute, second)) + print((' The date is %03d-%02d-%02d'):format(df.global.cur_year, month, day)) + print(' It is the month of '..months[month]) + + local eras = df.global.world.history.eras + if #eras > 0 then + print(' It is the '..eras[#eras-1].title.name..'.') + end +end + +function get_adv_region_pos() --Regional coords + if not dfhack.world.getAdventurer() then --Army exists when unit doesn't + local army = df.army.find(df.global.adventure.player_army_id) + if army then + return army.pos.x//48, army.pos.y//48 + end + end + local wd = df.global.world.world_data + return wd.midmap_data.adv_region_x, wd.midmap_data.adv_region_y +end + +local function print_world_info() + local wd = df.global.world.world_data + local site = dfhack.world.getCurrentSite() + if site then + print((' The current site is at x=%d, y=%d on the %dx%d world map.'): + format(site.pos.x, site.pos.y, wd.world_width, wd.world_height)) + end + + if dfhack.world.isAdventureMode() then + local x, y = get_adv_region_pos() + print((' The adventurer is at x=%d, y=%d on the %dx%d world map.'): + format(x, y, wd.world_width, wd.world_height)) + end +end + +function print_place_info(cursor) + print('Place:') + print(' The z-level is z='..df.global.window_z) + + if cursor then + local x, y = cursor.x, cursor.y + print((' The keyboard cursor is at x=%d, y=%d (%d+%d, %d+%d)'): + format(x, y, x//16*16, x%16, y//16*16, y%16)) + else + print(' The keyboard cursor is inactive.') + end + + local x, y = dfhack.screen.getWindowSize() + print(' The window is '..x..' tiles wide and '..y..' tiles high.') + + x, y = dfhack.screen.getMousePos() + if x then + print(' The mouse is at x='..x..', y='..y..' within the window.') + local pos = dfhack.gui.getMousePos() + if pos then + print(' The mouse is over map tile x='..pos.x..', y='..pos.y) + end + else + print(' The mouse is not in the DF window.') + end + + print_world_info() +end + +if dfhack_flags.module then + return +end + +function main(opts) + local cursor = guidm.getCursorPos() + + if opts.copy then --Copy keyboard cursor to clipboard + if not cursor then + qerror('No keyboard cursor!') + end + dfhack.internal.setClipboardTextCp437(('%d,%d,%d'):format(cursor.x, cursor.y, cursor.z)) + return --Don't print anything + end + + print_time_info() + print_place_info(cursor) +end + +main(parse_args({...})) diff --git a/pref-adjust.lua b/pref-adjust.lua index 9eae2aad34..5881028ffb 100644 --- a/pref-adjust.lua +++ b/pref-adjust.lua @@ -1,42 +1,12 @@ -- Adjust all preferences of one or all dwarves in play -- by vjek ---[====[ - -pref-adjust -=========== -``pref-adjust all`` removes/changes preferences from all dwarves, and -``pref-adjust one`` which works for a single currently selected dwarf. -For either, the script inserts an 'ideal' set which is easy to satisfy: - - ... likes iron, steel, weapons, armor, shields/bucklers and plump helmets - for their rounded tops. When possible, she prefers to consume dwarven - wine, plump helmets, and prepared meals (quarry bush). She absolutely - detests trolls, buzzards, vultures and crundles. - -Additionally, ``pref-adjust goth`` will insert a less than ideal set, which -is quite challenging, for a single dwarf: - - ... likes dwarf skin, corpses, body parts, remains, coffins, the color - black, crosses, glumprongs for their living shadows and snow demons for - their horrifying features. When possible, she prefers to consume sewer - brew, gutter cruor and bloated tubers. She absolutely detests elves, - humans and dwarves. - -To see what values can be used with each type of preference, use -``pref-adjust list``. Optionally, a single dwarf or all dwarves can have -their preferences cleared manually with the use of ``pref-adjust clear`` -and ``pref-adjust clear_all``, respectively. Existing preferences are -automatically cleared, normally. - -]====] utils = require 'utils' pss_counter = pss_counter or 31415926 -- --------------------------------------------------------------------------- function insert_preference(unit, preftype, val1) - - if preftype == df.unit_preference.T_type.LikeMaterial then + if preftype == df.unitpref_type.LikeMaterial then utils.insert_or_update(unit.status.current_soul.preferences, { new = true, type = preftype, @@ -48,7 +18,7 @@ function insert_preference(unit, preftype, val1) prefstring_seed = pss_counter, }, 'prefstring_seed') -- mattype for some is non zero, those non-inorganic like creature:gazelle:hoof is 42,344 - elseif preftype == df.unit_preference.T_type.LikeFood then + elseif preftype == df.unitpref_type.LikeFood then consumable_type = val1[1] consumable_name = val1[2] utils.insert_or_update(unit.status.current_soul.preferences, { @@ -62,7 +32,7 @@ function insert_preference(unit, preftype, val1) active = true, prefstring_seed = pss_counter, }, 'prefstring_seed') - elseif df.unit_preference.T_type[preftype] ~= nil then + elseif df.unitpref_type[preftype] ~= nil then utils.insert_or_update(unit.status.current_soul.preferences, { new = true, type = preftype, @@ -91,68 +61,68 @@ function brainwash_unit(unit, profile) if profile == "IDEAL" then -- Material Likes: IRON(0),STEEL(8),ADAMANTINE(25) - insert_preference(unit, df.unit_preference.T_type.LikeMaterial, "IRON") - insert_preference(unit, df.unit_preference.T_type.LikeMaterial, "STEEL") - -- insert_preference(unit, df.unit_preference.T_type.LikeMaterial, "ADAMANTINE") + insert_preference(unit, df.unitpref_type.LikeMaterial, "IRON") + insert_preference(unit, df.unitpref_type.LikeMaterial, "STEEL") + -- insert_preference(unit, df.unitpref_type.LikeMaterial, "ADAMANTINE") -- Item likes: (WEAPON, ARMOR, SHIELD) - insert_preference(unit, df.unit_preference.T_type.LikeItem, df.item_type.WEAPON) - insert_preference(unit, df.unit_preference.T_type.LikeItem, df.item_type.ARMOR) - insert_preference(unit, df.unit_preference.T_type.LikeItem, df.item_type.SHIELD) + insert_preference(unit, df.unitpref_type.LikeItem, df.item_type.WEAPON) + insert_preference(unit, df.unitpref_type.LikeItem, df.item_type.ARMOR) + insert_preference(unit, df.unitpref_type.LikeItem, df.item_type.SHIELD) -- Plant Likes: "Likes plump helmets for their rounded tops" - insert_preference(unit, df.unit_preference.T_type.LikePlant, dfhack.matinfo.find("MUSHROOM_HELMET_PLUMP:STRUCTURAL").index) - -- insert_preference(unit, df.unit_preference.T_type.LikePlant, dfhack.matinfo.find("PEACH").index) + insert_preference(unit, df.unitpref_type.LikePlant, dfhack.matinfo.find("MUSHROOM_HELMET_PLUMP:STRUCTURAL").index) + -- insert_preference(unit, df.unitpref_type.LikePlant, dfhack.matinfo.find("PEACH").index) -- Prefers to consume drink: (From plump helmets we get dwarven wine) - insert_preference(unit, df.unit_preference.T_type.LikeFood, {df.item_type.DRINK, "MUSHROOM_HELMET_PLUMP:DRINK"}) + insert_preference(unit, df.unitpref_type.LikeFood, {df.item_type.DRINK, "MUSHROOM_HELMET_PLUMP:DRINK"}) -- Prefers to consume food: (plump helmets, mushrooms) - insert_preference(unit, df.unit_preference.T_type.LikeFood, {df.item_type.PLANT, "MUSHROOM_HELMET_PLUMP:MUSHROOM"}) + insert_preference(unit, df.unitpref_type.LikeFood, {df.item_type.PLANT, "MUSHROOM_HELMET_PLUMP:MUSHROOM"}) -- Prefers to consume prepared meals: (quarry bush) - insert_preference(unit, df.unit_preference.T_type.LikeFood, {df.item_type.FOOD, "BUSH_QUARRY"}) + insert_preference(unit, df.unitpref_type.LikeFood, {df.item_type.FOOD, "BUSH_QUARRY"}) -- Creature detests (TROLL, BIRD_BUZZARD, BIRD_VULTURE, CRUNDLE) - insert_preference(unit, df.unit_preference.T_type.HateCreature, list_of_creatures.TROLL) - insert_preference(unit, df.unit_preference.T_type.HateCreature, list_of_creatures.BIRD_BUZZARD) - insert_preference(unit, df.unit_preference.T_type.HateCreature, list_of_creatures.BIRD_VULTURE) - insert_preference(unit, df.unit_preference.T_type.HateCreature, list_of_creatures.CRUNDLE) + insert_preference(unit, df.unitpref_type.HateCreature, list_of_creatures.TROLL) + insert_preference(unit, df.unitpref_type.HateCreature, list_of_creatures.BIRD_BUZZARD) + insert_preference(unit, df.unitpref_type.HateCreature, list_of_creatures.BIRD_VULTURE) + insert_preference(unit, df.unitpref_type.HateCreature, list_of_creatures.CRUNDLE) if #df.global.world.poetic_forms.all > 0 then - insert_preference(unit, df.unit_preference.T_type.LikePoeticForm, 0) -- this just inserts the first song out of typically many. + insert_preference(unit, df.unitpref_type.LikePoeticForm, 0) -- this just inserts the first song out of typically many. end if #df.global.world.musical_forms.all > 0 then - insert_preference(unit, df.unit_preference.T_type.LikeMusicalForm, 0) -- same goes for music + insert_preference(unit, df.unitpref_type.LikeMusicalForm, 0) -- same goes for music end if #df.global.world.dance_forms.all > 0 then - insert_preference(unit, df.unit_preference.T_type.LikeDanceForm, 0) -- and dancing + insert_preference(unit, df.unitpref_type.LikeDanceForm, 0) -- and dancing end -- end IDEAL profile elseif profile == "GOTH" then - insert_preference(unit, df.unit_preference.T_type.LikeMaterial, "CREATURE:DWARF:SKIN") - insert_preference(unit, df.unit_preference.T_type.LikeItem, df.item_type.CORPSE) - insert_preference(unit, df.unit_preference.T_type.LikeItem, df.item_type.CORPSEPIECE) - insert_preference(unit, df.unit_preference.T_type.LikeItem, df.item_type.REMAINS) - insert_preference(unit, df.unit_preference.T_type.LikeItem, df.item_type.COFFIN) - insert_preference(unit, df.unit_preference.T_type.LikeColor, list_of_colors.BLACK) - insert_preference(unit, df.unit_preference.T_type.LikeShape, list_of_shapes.CROSS) - insert_preference(unit, df.unit_preference.T_type.LikePlant, dfhack.matinfo.find("GLUMPRONG").index) - insert_preference(unit, df.unit_preference.T_type.LikeFood, {df.item_type.DRINK, "WEED_RAT:DRINK"}) - insert_preference(unit, df.unit_preference.T_type.LikeFood, {df.item_type.DRINK, "SLIVER_BARB:DRINK"}) - insert_preference(unit, df.unit_preference.T_type.LikeFood, {df.item_type.PLANT, "TUBER_BLOATED:STRUCTURAL"}) - insert_preference(unit, df.unit_preference.T_type.HateCreature, list_of_creatures.ELF) - insert_preference(unit, df.unit_preference.T_type.HateCreature, list_of_creatures.HUMAN) - insert_preference(unit, df.unit_preference.T_type.HateCreature, list_of_creatures.DWARF) + insert_preference(unit, df.unitpref_type.LikeMaterial, "CREATURE:DWARF:SKIN") + insert_preference(unit, df.unitpref_type.LikeItem, df.item_type.CORPSE) + insert_preference(unit, df.unitpref_type.LikeItem, df.item_type.CORPSEPIECE) + insert_preference(unit, df.unitpref_type.LikeItem, df.item_type.REMAINS) + insert_preference(unit, df.unitpref_type.LikeItem, df.item_type.COFFIN) + insert_preference(unit, df.unitpref_type.LikeColor, list_of_colors.BLACK) + insert_preference(unit, df.unitpref_type.LikeShape, list_of_shapes.CROSS) + insert_preference(unit, df.unitpref_type.LikePlant, dfhack.matinfo.find("GLUMPRONG").index) + insert_preference(unit, df.unitpref_type.LikeFood, {df.item_type.DRINK, "WEED_RAT:DRINK"}) + insert_preference(unit, df.unitpref_type.LikeFood, {df.item_type.DRINK, "SLIVER_BARB:DRINK"}) + insert_preference(unit, df.unitpref_type.LikeFood, {df.item_type.PLANT, "TUBER_BLOATED:STRUCTURAL"}) + insert_preference(unit, df.unitpref_type.HateCreature, list_of_creatures.ELF) + insert_preference(unit, df.unitpref_type.HateCreature, list_of_creatures.HUMAN) + insert_preference(unit, df.unitpref_type.HateCreature, list_of_creatures.DWARF) if list_of_creatures.DEMON_1 and df.global.world.raws.creatures.all[list_of_creatures.DEMON_1].prefstring[0] ~= '' then - insert_preference(unit, df.unit_preference.T_type.LikeCreature, list_of_creatures.DEMON_1) + insert_preference(unit, df.unitpref_type.LikeCreature, list_of_creatures.DEMON_1) end if #df.global.world.poetic_forms.all > 0 then - insert_preference(unit, df.unit_preference.T_type.LikePoeticForm, #df.global.world.poetic_forms.all - 1) -- this just inserts the last song out of typically many. + insert_preference(unit, df.unitpref_type.LikePoeticForm, #df.global.world.poetic_forms.all - 1) -- this just inserts the last song out of typically many. end if #df.global.world.musical_forms.all > 0 then - insert_preference(unit, df.unit_preference.T_type.LikeMusicalForm, #df.global.world.musical_forms.all - 1) -- same goes for music + insert_preference(unit, df.unitpref_type.LikeMusicalForm, #df.global.world.musical_forms.all - 1) -- same goes for music end if #df.global.world.dance_forms.all > 0 then - insert_preference(unit, df.unit_preference.T_type.LikeDanceForm, #df.global.world.dance_forms.all - 1) -- and dancing + insert_preference(unit, df.unitpref_type.LikeDanceForm, #df.global.world.dance_forms.all - 1) -- and dancing end -- end GOTH profile else @@ -172,27 +142,23 @@ function clear_preferences(unit) end -- --------------------------------------------------------------------------- function clearpref_all_dwarves() - for _,unit in ipairs(df.global.world.units.active) do - if unit.race == df.global.plotinfo.race_id then - print("Clearing Preferences for "..unit_name_to_console(unit)) - clear_preferences(unit) - end + for _,unit in ipairs(dfhack.units.getCitizens()) do + print("Clearing Preferences for "..unit_name_to_console(unit)) + clear_preferences(unit) end end -- --------------------------------------------------------------------------- function adjust_all_dwarves(profile) - for _,unit in ipairs(df.global.world.units.active) do - if unit.race == df.global.plotinfo.race_id then - print("Adjusting "..unit_name_to_console(unit)) - brainwash_unit(unit,profile) - end + for _,unit in ipairs(dfhack.units.getCitizens()) do + print("Adjusting "..unit_name_to_console(unit)) + brainwash_unit(unit,profile) end end -- --------------------------------------------------------------------------- function build_all_lists(printflag) list_of_inorganics={} -- Type 0 "Likes iron.." list_of_inorganics_string="" - vec=df.global.world.raws.inorganics -- also df.global.world.raws.inorganics_subset[0].id available + vec=df.global.world.raws.inorganics.all -- also df.global.world.raws.inorganics.cheap[0].id available for k=0,#vec-1 do name=vec[k].id list_of_inorganics[name]=k @@ -269,7 +235,7 @@ function build_all_lists(printflag) list_of_poems_string = "" vec=df.global.world.poetic_forms.all for k=0,#vec-1 do - name=dfhack.TranslateName(vec[k].name,true) + name=dfhack.translation.translateName(vec[k].name,true) list_of_poems[name]=k list_of_poems_string=list_of_poems_string..k..":"..name.."," end @@ -281,7 +247,7 @@ function build_all_lists(printflag) list_of_music_string = "" vec=df.global.world.musical_forms.all for k=0,#vec-1 do - name=dfhack.TranslateName(vec[k].name,true) + name=dfhack.translation.translateName(vec[k].name,true) list_of_music[name]=k list_of_music_string=list_of_music_string..k..":"..name.."," end @@ -293,7 +259,7 @@ function build_all_lists(printflag) list_of_dances_string = "" vec=df.global.world.dance_forms.all for k=0,#vec-1 do - name=dfhack.TranslateName(vec[k].name,true) + name=dfhack.translation.translateName(vec[k].name,true) list_of_dances[name]=k list_of_dances_string=list_of_dances_string..k..":"..name.."," end @@ -302,8 +268,53 @@ function build_all_lists(printflag) end end -- end func build_all_lists -- --------------------------------------------------------------------------- +function get_preferences(unit) + if not unit then + print("No unit selected!") + return + end + + local preferences = unit.status.current_soul.preferences + if #preferences == 0 then + print("Unit " .. unit_name_to_console(unit) .. " has no preferences.") + return + end + + print("Preferences for " .. unit_name_to_console(unit) .. ":") + for _, pref in ipairs(preferences) do + 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 +end -- end function: get_preferences +-- --------------------------------------------------------------------------- function unit_name_to_console(unit) - return dfhack.df2console(dfhack.TranslateName(dfhack.units.getVisibleName(unit))) + return dfhack.df2console(dfhack.units.getReadableName(unit)) end @@ -315,7 +326,7 @@ build_all_lists(false) local opt = ({...})[1] function handle_one(profile) - local unit = dfhack.gui.getSelectedUnit() + local unit = dfhack.gui.getSelectedUnit(true) if unit == nil then print ("No unit available! Aborting with extreme prejudice.") return @@ -332,7 +343,7 @@ end if opt == "list" then build_all_lists(true) elseif opt == "clear" then - local unit = dfhack.gui.getSelectedUnit() + local unit = dfhack.gui.getSelectedUnit(true) if unit==nil then print ("No unit available! Aborting with extreme prejudice.") return @@ -350,16 +361,11 @@ elseif opt == "all" then handle_all("IDEAL") elseif opt == "goth_all" then handle_all("GOTH") +elseif opt == "show" then + local unit = dfhack.gui.getSelectedUnit(true) + get_preferences(unit) else - print ("Sets preferences of one dwarf, or of all dwarves, using profiles.") - print ("Valid options:") - print ("list -- show available preference type lists") - print ("clear -- clear preferences of selected unit") - print ("clear_all -- clear preferences of all units") - print ("goth -- alter current dwarf preferences to Goth") - print ("goth_all -- alter all dwarf preferences to Goth") - print ("one -- alter current dwarf preferences to Ideal") - print ("all -- alter all dwarf preferences to Ideal") + print(dfhack.script_help()) if opt and opt ~= "help" then qerror("Unrecognized option: " .. opt) end diff --git a/prefchange.lua b/prefchange.lua index 96a836610a..81b3f10915 100644 --- a/prefchange.lua +++ b/prefchange.lua @@ -4,35 +4,7 @@ -- updated with print all and clear all functions on the 11th of April 2015 -- Praise Armok! ---[====[ - -prefchange -========== -Sets preferences for mooding to include a weapon type, equipment type, -and material. If you also wish to trigger a mood, see -`strangemood`. - -Valid options: - -:show: show preferences of all units -:c: clear preferences of selected unit -:all: clear preferences of all units -:axp: likes axes, breastplates, and steel -:has: likes hammers, mail shirts, and steel -:swb: likes short swords, high boots, and steel -:spb: likes spears, high boots, and steel -:mas: likes maces, shields, and steel -:xbh: likes crossbows, helms, and steel -:pig: likes picks, gauntlets, and steel -:log: likes long swords, gauntlets, and steel -:dap: likes daggers, greaves, and steel - -Feel free to adjust the values as you see fit, change the has steel to -platinum, change the axp axes to great axes, whatnot. - -]====] - -local utils = require 'utils' +local utils = require('utils') -- --------------------------------------------------------------------------- function axeplate() @@ -46,7 +18,7 @@ function axeplate() local pss_counter=31415926 local prefcount = #(unit.status.current_soul.preferences) - print ("Before, unit "..dfhack.TranslateName(dfhack.units.getVisibleName(unit)).." has "..prefcount.." preferences") + print ("Before, unit "..dfhack.df2console(dfhack.units.getReadableName(unit)).." has "..prefcount.." preferences") -- axes and breastplates utils.insert_or_update(unit.status.current_soul.preferences, { new = true, type = 4 , item_type = df.item_type.WEAPON , creature_id = df.item_type.WEAPON , color_id = df.item_type.WEAPON , shape_id = df.item_type.WEAPON , plant_id = df.item_type.WEAPON , item_subtype = 1 , mattype = -1 , matindex = -1 , active = true, prefstring_seed = pss_counter }, 'prefstring_seed') @@ -57,7 +29,7 @@ function axeplate() utils.insert_or_update(unit.status.current_soul.preferences, { new = true, type = 0 , item_type = -1 , creature_id = -1 , color_id = -1 , shape_id = -1 , plant_id = -1 , item_subtype = -1 , mattype = 0 , matindex = dfhack.matinfo.find("STEEL").index , active = true, prefstring_seed = pss_counter }, 'prefstring_seed') prefcount = #(unit.status.current_soul.preferences) - print ("After, unit "..dfhack.TranslateName(dfhack.units.getVisibleName(unit)).." has "..prefcount.." preferences") + print ("After, unit "..dfhack.df2console(dfhack.units.getReadableName(unit)).." has "..prefcount.." preferences") end -- --------------------------------------------------------------------------- @@ -72,7 +44,7 @@ function hammershirt() local pss_counter=31415926 local prefcount = #(unit.status.current_soul.preferences) - print ("Before, unit "..dfhack.TranslateName(dfhack.units.getVisibleName(unit)).." has "..prefcount.." preferences") + print ("Before, unit "..dfhack.df2console(dfhack.units.getReadableName(unit)).." has "..prefcount.." preferences") -- hammers and mail shirts utils.insert_or_update(unit.status.current_soul.preferences, { new = true, type = 4 , item_type = df.item_type.WEAPON , creature_id = df.item_type.WEAPON , color_id = df.item_type.WEAPON , shape_id = df.item_type.WEAPON , plant_id = df.item_type.WEAPON , item_subtype = 2 , mattype = -1 , matindex = -1 , active = true, prefstring_seed = pss_counter }, 'prefstring_seed') @@ -83,7 +55,7 @@ function hammershirt() utils.insert_or_update(unit.status.current_soul.preferences, { new = true, type = 0 , item_type = -1 , creature_id = -1 , color_id = -1 , shape_id = -1 , plant_id = -1 , item_subtype = -1 , mattype = 0 , matindex = dfhack.matinfo.find("STEEL").index , active = true, prefstring_seed = pss_counter }, 'prefstring_seed') prefcount = #(unit.status.current_soul.preferences) - print ("After, unit "..dfhack.TranslateName(dfhack.units.getVisibleName(unit)).." has "..prefcount.." preferences") + print ("After, unit "..dfhack.df2console(dfhack.units.getReadableName(unit)).." has "..prefcount.." preferences") end -- --------------------------------------------------------------------------- @@ -98,7 +70,7 @@ function swordboot() local pss_counter=31415926 local prefcount = #(unit.status.current_soul.preferences) - print ("Before, unit "..dfhack.TranslateName(dfhack.units.getVisibleName(unit)).." has "..prefcount.." preferences") + print ("Before, unit "..dfhack.df2console(dfhack.units.getReadableName(unit)).." has "..prefcount.." preferences") -- short swords and high boots utils.insert_or_update(unit.status.current_soul.preferences, { new = true, type = 4 , item_type = df.item_type.WEAPON , creature_id = df.item_type.WEAPON , color_id = df.item_type.WEAPON , shape_id = df.item_type.WEAPON , plant_id = df.item_type.WEAPON , item_subtype = 3 , mattype = -1 , matindex = -1 , active = true, prefstring_seed = pss_counter }, 'prefstring_seed') @@ -109,7 +81,7 @@ function swordboot() utils.insert_or_update(unit.status.current_soul.preferences, { new = true, type = 0 , item_type = -1 , creature_id = -1 , color_id = -1 , shape_id = -1 , plant_id = -1 , item_subtype = -1 , mattype = 0 , matindex = dfhack.matinfo.find("STEEL").index , active = true, prefstring_seed = pss_counter }, 'prefstring_seed') prefcount = #(unit.status.current_soul.preferences) - print ("After, unit "..dfhack.TranslateName(dfhack.units.getVisibleName(unit)).." has "..prefcount.." preferences") + print ("After, unit "..dfhack.df2console(dfhack.units.getReadableName(unit)).." has "..prefcount.." preferences") end -- --------------------------------------------------------------------------- @@ -124,7 +96,7 @@ function spearboot() local pss_counter=31415926 local prefcount = #(unit.status.current_soul.preferences) - print ("Before, unit "..dfhack.TranslateName(dfhack.units.getVisibleName(unit)).." has "..prefcount.." preferences") + print ("Before, unit "..dfhack.df2console(dfhack.units.getReadableName(unit)).." has "..prefcount.." preferences") -- spears and high boots utils.insert_or_update(unit.status.current_soul.preferences, { new = true, type = 4 , item_type = df.item_type.WEAPON , creature_id = df.item_type.WEAPON , color_id = df.item_type.WEAPON , shape_id = df.item_type.WEAPON , plant_id = df.item_type.WEAPON , item_subtype = 4 , mattype = -1 , matindex = -1 , active = true, prefstring_seed = pss_counter }, 'prefstring_seed') @@ -135,7 +107,7 @@ function spearboot() utils.insert_or_update(unit.status.current_soul.preferences, { new = true, type = 0 , item_type = -1 , creature_id = -1 , color_id = -1 , shape_id = -1 , plant_id = -1 , item_subtype = -1 , mattype = 0 , matindex = dfhack.matinfo.find("STEEL").index , active = true, prefstring_seed = pss_counter }, 'prefstring_seed') prefcount = #(unit.status.current_soul.preferences) - print ("After, unit "..dfhack.TranslateName(dfhack.units.getVisibleName(unit)).." has "..prefcount.." preferences") + print ("After, unit "..dfhack.df2console(dfhack.units.getReadableName(unit)).." has "..prefcount.." preferences") end -- --------------------------------------------------------------------------- @@ -150,7 +122,7 @@ function maceshield() local pss_counter=31415926 local prefcount = #(unit.status.current_soul.preferences) - print ("Before, unit "..dfhack.TranslateName(dfhack.units.getVisibleName(unit)).." has "..prefcount.." preferences") + print ("Before, unit "..dfhack.df2console(dfhack.units.getReadableName(unit)).." has "..prefcount.." preferences") -- maces and shields utils.insert_or_update(unit.status.current_soul.preferences, { new = true, type = 4 , item_type = df.item_type.WEAPON , creature_id = df.item_type.WEAPON , color_id = df.item_type.WEAPON , shape_id = df.item_type.WEAPON , plant_id = df.item_type.WEAPON , item_subtype = 5 , mattype = -1 , matindex = -1 , active = true, prefstring_seed = pss_counter }, 'prefstring_seed') @@ -161,7 +133,7 @@ function maceshield() utils.insert_or_update(unit.status.current_soul.preferences, { new = true, type = 0 , item_type = -1 , creature_id = -1 , color_id = -1 , shape_id = -1 , plant_id = -1 , item_subtype = -1 , mattype = 0 , matindex = dfhack.matinfo.find("STEEL").index , active = true, prefstring_seed = pss_counter }, 'prefstring_seed') prefcount = #(unit.status.current_soul.preferences) - print ("After, unit "..dfhack.TranslateName(dfhack.units.getVisibleName(unit)).." has "..prefcount.." preferences") + print ("After, unit "..dfhack.df2console(dfhack.units.getReadableName(unit)).." has "..prefcount.." preferences") end -- --------------------------------------------------------------------------- @@ -176,7 +148,7 @@ function xbowhelm() local pss_counter=31415926 local prefcount = #(unit.status.current_soul.preferences) - print ("Before, unit "..dfhack.TranslateName(dfhack.units.getVisibleName(unit)).." has "..prefcount.." preferences") + print ("Before, unit "..dfhack.df2console(dfhack.units.getReadableName(unit)).." has "..prefcount.." preferences") -- crossbows and helms utils.insert_or_update(unit.status.current_soul.preferences, { new = true, type = 4 , item_type = df.item_type.WEAPON , creature_id = df.item_type.WEAPON , color_id = df.item_type.WEAPON , shape_id = df.item_type.WEAPON , plant_id = df.item_type.WEAPON , item_subtype = 6 , mattype = -1 , matindex = -1 , active = true, prefstring_seed = pss_counter }, 'prefstring_seed') @@ -187,7 +159,7 @@ function xbowhelm() utils.insert_or_update(unit.status.current_soul.preferences, { new = true, type = 0 , item_type = -1 , creature_id = -1 , color_id = -1 , shape_id = -1 , plant_id = -1 , item_subtype = -1 , mattype = 0 , matindex = dfhack.matinfo.find("STEEL").index , active = true, prefstring_seed = pss_counter }, 'prefstring_seed') prefcount = #(unit.status.current_soul.preferences) - print ("After, unit "..dfhack.TranslateName(dfhack.units.getVisibleName(unit)).." has "..prefcount.." preferences") + print ("After, unit "..dfhack.df2console(dfhack.units.getReadableName(unit)).." has "..prefcount.." preferences") end -- --------------------------------------------------------------------------- @@ -202,7 +174,7 @@ function pickglove() local pss_counter=31415926 local prefcount = #(unit.status.current_soul.preferences) - print ("Before, unit "..dfhack.TranslateName(dfhack.units.getVisibleName(unit)).." has "..prefcount.." preferences") + print ("Before, unit "..dfhack.df2console(dfhack.units.getReadableName(unit)).." has "..prefcount.." preferences") -- picks and gauntlets utils.insert_or_update(unit.status.current_soul.preferences, { new = true, type = 4 , item_type = df.item_type.WEAPON , creature_id = df.item_type.WEAPON , color_id = df.item_type.WEAPON , shape_id = df.item_type.WEAPON , plant_id = df.item_type.WEAPON , item_subtype = 7 , mattype = -1 , matindex = -1 , active = true, prefstring_seed = pss_counter }, 'prefstring_seed') @@ -213,7 +185,7 @@ function pickglove() utils.insert_or_update(unit.status.current_soul.preferences, { new = true, type = 0 , item_type = -1 , creature_id = -1 , color_id = -1 , shape_id = -1 , plant_id = -1 , item_subtype = -1 , mattype = 0 , matindex = dfhack.matinfo.find("STEEL").index , active = true, prefstring_seed = pss_counter }, 'prefstring_seed') prefcount = #(unit.status.current_soul.preferences) - print ("After, unit "..dfhack.TranslateName(dfhack.units.getVisibleName(unit)).." has "..prefcount.." preferences") + print ("After, unit "..dfhack.df2console(dfhack.units.getReadableName(unit)).." has "..prefcount.." preferences") end -- --------------------------------------------------------------------------- @@ -228,7 +200,7 @@ function longglove() local pss_counter=31415926 local prefcount = #(unit.status.current_soul.preferences) - print ("Before, unit "..dfhack.TranslateName(dfhack.units.getVisibleName(unit)).." has "..prefcount.." preferences") + print ("Before, unit "..dfhack.df2console(dfhack.units.getReadableName(unit)).." has "..prefcount.." preferences") -- long swords and gauntlets, skipped bows and whatnot utils.insert_or_update(unit.status.current_soul.preferences, { new = true, type = 4 , item_type = df.item_type.WEAPON , creature_id = df.item_type.WEAPON , color_id = df.item_type.WEAPON , shape_id = df.item_type.WEAPON , plant_id = df.item_type.WEAPON , item_subtype = 13 , mattype = -1 , matindex = -1 , active = true, prefstring_seed = pss_counter }, 'prefstring_seed') @@ -239,7 +211,7 @@ function longglove() utils.insert_or_update(unit.status.current_soul.preferences, { new = true, type = 0 , item_type = -1 , creature_id = -1 , color_id = -1 , shape_id = -1 , plant_id = -1 , item_subtype = -1 , mattype = 0 , matindex = dfhack.matinfo.find("STEEL").index , active = true, prefstring_seed = pss_counter }, 'prefstring_seed') prefcount = #(unit.status.current_soul.preferences) - print ("After, unit "..dfhack.TranslateName(dfhack.units.getVisibleName(unit)).." has "..prefcount.." preferences") + print ("After, unit "..dfhack.df2console(dfhack.units.getReadableName(unit)).." has "..prefcount.." preferences") end -- --------------------------------------------------------------------------- @@ -254,7 +226,7 @@ function daggerpants() local pss_counter=31415926 local prefcount = #(unit.status.current_soul.preferences) - print ("Before, unit "..dfhack.TranslateName(dfhack.units.getVisibleName(unit)).." has "..prefcount.." preferences") + print ("Before, unit "..dfhack.df2console(dfhack.units.getReadableName(unit)).." has "..prefcount.." preferences") -- daggers and greaves, skipped the weapons which are too large for most dwarves utils.insert_or_update(unit.status.current_soul.preferences, { new = true, type = 4 , item_type = df.item_type.WEAPON , creature_id = df.item_type.WEAPON , color_id = df.item_type.WEAPON , shape_id = df.item_type.WEAPON , plant_id = df.item_type.WEAPON , item_subtype = 16 , mattype = -1 , matindex = -1 , active = true, prefstring_seed = pss_counter }, 'prefstring_seed') @@ -265,7 +237,7 @@ function daggerpants() utils.insert_or_update(unit.status.current_soul.preferences, { new = true, type = 0 , item_type = -1 , creature_id = -1 , color_id = -1 , shape_id = -1 , plant_id = -1 , item_subtype = -1 , mattype = 0 , matindex = dfhack.matinfo.find("STEEL").index , active = true, prefstring_seed = pss_counter }, 'prefstring_seed') prefcount = #(unit.status.current_soul.preferences) - print ("After, unit "..dfhack.TranslateName(dfhack.units.getVisibleName(unit)).." has "..prefcount.." preferences") + print ("After, unit "..dfhack.df2console(dfhack.units.getReadableName(unit)).." has "..prefcount.." preferences") end -- --------------------------------------------------------------------------- @@ -302,20 +274,16 @@ function clear_all(v) end -- --------------------------------------------------------------------------- function printpref_all_dwarves() - for _,v in ipairs(df.global.world.units.active) do - if v.race == df.global.plotinfo.race_id then - print("Showing Preferences for "..dfhack.TranslateName(dfhack.units.getVisibleName(v))) - print_all(v) - end + for _,unit in ipairs(dfhack.units.getCitizens()) do + print("Showing Preferences for "..dfhack.df2console(dfhack.units.getReadableName(unit))) + print_all(unit) end end -- --------------------------------------------------------------------------- function clearpref_all_dwarves() - for _,v in ipairs(df.global.world.units.active) do - if v.race == df.global.plotinfo.race_id then - print("Clearing Preferences for "..dfhack.TranslateName(dfhack.units.getVisibleName(v))) - clear_all(v) - end + for _,unit in ipairs(dfhack.units.getCitizens()) do + print("Clearing Preferences for "..dfhack.df2console(dfhack.units.getReadableName(unit))) + clear_all(unit) end end -- --------------------------------------------------------------------------- diff --git a/prioritize.lua b/prioritize.lua index ce284dc1f3..70c0c470fd 100644 --- a/prioritize.lua +++ b/prioritize.lua @@ -3,10 +3,11 @@ --@enable = true local argparse = require('argparse') -local json = require('json') local eventful = require('plugins.eventful') -local persist = require('persist-table') +local gui = require('gui') +local overlay = require('plugins.overlay') local utils = require('utils') +local widgets = require('gui.widgets') local GLOBAL_KEY = 'prioritize' -- used for state change hooks and persistence @@ -23,19 +24,20 @@ local DEFAULT_JOB_TYPES = { 'SeekInfant', 'SetBone', 'Surgery', 'Suture', -- ensure prisoners and animals are tended to quickly -- (Animal/prisoner storage already covered by 'StoreItemInStockpile' above) - 'SlaughterAnimal', 'PenLargeAnimal', 'LoadCageTrap', + 'SlaughterAnimal', 'ButcherAnimal', 'PenLargeAnimal', 'ChainAnimal', 'LoadCageTrap', -- ensure noble tasks never get starved 'InterrogateSubject', 'ManageWorkOrders', 'ReportCrime', 'TradeAtDepot', -- get tasks done quickly that might block the player from getting on to -- the next thing they want to do 'BringItemToDepot', 'DestroyBuilding', 'DumpItem', 'FellTree', - 'RemoveConstruction', 'PullLever' + 'RemoveConstruction', 'PullLever', 'FillPond', 'PutItemOnDisplay', } -- set of job types that we are watching. maps job_type (as a number) to --- {num_prioritized=number, --- hauler_matchers=map of type to num_prioritized, --- reaction_matchers=map of string to num_prioritized} +-- { +-- hauler_matchers=map of type to num_prioritized, +-- reaction_matchers=map of string to num_prioritized, +-- } -- this needs to be global so we don't lose player-set state when the script is -- reparsed. Also a getter function that can be mocked out by unit tests. g_watched_job_matchers = g_watched_job_matchers or {} @@ -44,33 +46,33 @@ function get_watched_job_matchers() return g_watched_job_matchers end eventful.enableEvent(eventful.eventType.UNLOAD, 1) eventful.enableEvent(eventful.eventType.JOB_INITIATED, 5) -local function has_elements(collection) - for _,_ in pairs(collection) do return true end - return false -end - function isEnabled() - return has_elements(get_watched_job_matchers()) + return next(get_watched_job_matchers()) end local function persist_state() - persist.GlobalTable[GLOBAL_KEY] = json.encode(get_watched_job_matchers()) + local data_to_persist = {} + -- convert enum keys into strings so json doesn't get confused and think the map is a sparse list + for k, v in pairs(get_watched_job_matchers()) do + data_to_persist[tostring(k)] = v + end + dfhack.persistent.saveSiteData(GLOBAL_KEY, data_to_persist) end local function make_matcher_map(keys) if not keys then return nil end local t = {} for _,key in ipairs(keys) do - t[key] = 0 + t[key] = true end return t end local function make_job_matcher(unit_labors, reaction_names) - local matcher = {num_prioritized=0} - matcher.hauler_matchers = make_matcher_map(unit_labors) - matcher.reaction_matchers = make_matcher_map(reaction_names) - return matcher + return { + hauler_matchers=make_matcher_map(unit_labors), + reaction_matchers=make_matcher_map(reaction_names), + } end local function matches(job_matcher, job) @@ -86,9 +88,9 @@ local function matches(job_matcher, job) return true end --- returns true if the job is matched and it is not already high priority +-- returns true if the job is matched local function boost_job_if_matches(job, job_matchers) - if matches(job_matchers[job.job_type], job) and not job.flags.do_now then + if matches(job_matchers[job.job_type], job) then job.flags.do_now = true return true end @@ -96,20 +98,7 @@ local function boost_job_if_matches(job, job_matchers) end local function on_new_job(job) - local watched_job_matchers = get_watched_job_matchers() - if boost_job_if_matches(job, watched_job_matchers) then - jm = watched_job_matchers[job.job_type] - jm.num_prioritized = jm.num_prioritized + 1 - if jm.hauler_matchers then - local hms = jm.hauler_matchers - hms[job.item_subtype] = hms[job.item_subtype] + 1 - end - if jm.reaction_matchers then - local rms = jm.reaction_matchers - rms[job.reaction_name] = rms[job.reaction_name] + 1 - end - persist_state() - end + boost_job_if_matches(job, get_watched_job_matchers()) end local function clear_watched_job_matchers() @@ -122,8 +111,7 @@ local function clear_watched_job_matchers() end local function update_handlers() - local watched_job_matchers = get_watched_job_matchers() - if has_elements(watched_job_matchers) then + if next(get_watched_job_matchers()) then eventful.onUnload.prioritize = clear_watched_job_matchers eventful.onJobInitiated.prioritize = on_new_job else @@ -131,45 +119,49 @@ local function update_handlers() end end -local function get_annotation_str(annotation) - return (' (%s)'):format(annotation) -end - local function get_unit_labor_str(unit_labor) local labor_str = df.unit_labor[unit_labor] return ('%s%s'):format(labor_str:sub(6,6), labor_str:sub(7):lower()) end local function get_unit_labor_annotation_str(unit_labor) - return get_annotation_str(get_unit_labor_str(unit_labor)) + return (' --haul-labor %s'):format(get_unit_labor_str(unit_labor)) +end + +local function get_reaction_annotation_str(reaction) + return (' --reaction-name %s'):format(reaction) end -local function print_status_line(num_jobs, job_type, annotation) +local function get_status_line(job_type, annotation) annotation = annotation or '' - print(('%6d %s%s'):format(num_jobs, df.job_type[job_type], annotation)) + return (' %s%s'):format(df.job_type[job_type], annotation) end local function status() - local first = true + local lines = {} local watched_job_matchers = get_watched_job_matchers() for k,v in pairs(watched_job_matchers) do - if first then - print('Automatically prioritized jobs:') - first = false - end if v.hauler_matchers then - for hk,hv in pairs(v.hauler_matchers) do - print_status_line(hv, k, get_unit_labor_annotation_str(hk)) + for hk in pairs(v.hauler_matchers) do + table.insert(lines, get_status_line(k, get_unit_labor_annotation_str(hk))) end elseif v.reaction_matchers then - for rk,rv in pairs(v.reaction_matchers) do - print_status_line(rv, k, get_annotation_str(rk)) + for rk in pairs(v.reaction_matchers) do + table.insert(lines, get_status_line(k, get_reaction_annotation_str(rk))) end else - print_status_line(v.num_prioritized, k) + table.insert(lines, get_status_line(k)) end end - if first then print('Not automatically prioritizing any jobs.') end + if not next(lines) then + print('Not automatically prioritizing any jobs.') + return + end + table.sort(lines) + print('Automatically prioritized jobs:') + for _, line in ipairs(lines) do + print(line) + end end -- encapsulate df state in functions so unit tests can mock them out @@ -179,6 +171,9 @@ end function get_reactions() return df.global.world.raws.reactions.reactions end +function get_job_list() + return df.global.world.jobs.list +end local function for_all_live_postings(cb) for _,posting in ipairs(get_postings()) do @@ -188,16 +183,32 @@ local function for_all_live_postings(cb) end end +local function for_all_jobs(cb) + for _,job in utils.listpairs(get_job_list()) do + if not job.flags.special then + cb(job) + end + end +end + local function boost(job_matchers, opts) - local count = 0 - for_all_live_postings( - function(posting) - if boost_job_if_matches(posting.job, job_matchers) then - count = count + 1 + local count, already_prioritized = 0, 0 + for_all_jobs( + function(job) + local was_prioritized = job.flags.do_now + if boost_job_if_matches(job, job_matchers) then + if was_prioritized then + already_prioritized = already_prioritized + 1 + else + count = count + 1 + end end end) if not opts.quiet then print(('Prioritized %d job%s.'):format(count, count == 1 and '' or 's')) + if already_prioritized > 0 then + print(('%d job%s already prioritized.'):format(already_prioritized, already_prioritized == 1 and '' or 's')) + end end end @@ -285,7 +296,7 @@ local JOB_TYPES_DENYLIST = utils.invert{ } local DIG_SMOOTH_WARNING = { - 'Priortizing current pending jobs, but skipping automatic boosting of dig and', + 'Priortizing current jobs, but skipping automatic boosting of dig and', 'smooth/engrave job types. Automatic priority boosting of these types of jobs', 'will overwhelm the DF job scheduler. Instead, consider specializing units for', 'mining and related work details, and using vanilla designation priorities.', @@ -305,7 +316,7 @@ local function boost_and_watch(job_matchers, opts) boost_and_watch_special(job_type, job_matcher, function(jm) return jm.reaction_matchers end, function(jm) jm.reaction_matchers = nil end, - get_annotation_str, quiet) + get_reaction_annotation_str, quiet) elseif JOB_TYPES_DENYLIST[job_type] then for _,msg in ipairs(DIG_SMOOTH_WARNING) do dfhack.printerr(msg) @@ -364,7 +375,7 @@ local function remove_watch_special(job_type, job_matcher, end end end - if not has_elements(wspecial_matchers) then + if not next(wspecial_matchers) then watched_job_matchers[job_type] = nil end end @@ -410,7 +421,7 @@ local function remove_watch(job_matchers, opts) end return jm.reaction_matchers end, - get_annotation_str, quiet) + get_reaction_annotation_str, quiet) else error('unhandled case') -- should not ever happen end @@ -426,34 +437,35 @@ local function get_job_type_str(job) get_unit_labor_annotation_str(job.item_subtype)) elseif job_type == df.job_type.CustomReaction then return ('%s%s'):format(job_type_str, - get_annotation_str(job.reaction_name)) + get_reaction_annotation_str(job.reaction_name)) else return job_type_str end end local function print_current_jobs(job_matchers, opts) - local job_counts_by_type = {} - local filtered = has_elements(job_matchers) - for_all_live_postings( - function(posting) - local job = posting.job - if filtered and not job_matchers[job.job_type] then return end - local job_type = get_job_type_str(job) - if not job_counts_by_type[job_type] then - job_counts_by_type[job_type] = 0 - end - job_counts_by_type[job_type] = job_counts_by_type[job_type] + 1 - end) + local all_jobs, unclaimed_jobs = {}, {} + local filtered = next(job_matchers) + local function count_job(jobs, job) + if filtered and not job_matchers[job.job_type] then return end + if job.flags.do_now then return end + local job_type = get_job_type_str(job) + jobs[job_type] = (jobs[job_type] or 0) + 1 + end + for_all_jobs(curry(count_job, all_jobs)) + for_all_live_postings(function(posting) count_job(unclaimed_jobs, posting.job) end) local first = true - for k,v in pairs(job_counts_by_type) do + for k,v in pairs(all_jobs) do if first then - print('Current unclaimed jobs:') + print('Current prioritizable jobs:') + print() + print(('unclaimed total job type')) + print(('--------- ----- --------')) first = false end - print(('%4d %s'):format(v, k)) + print(('%9d %5d %s'):format(unclaimed_jobs[k] or 0, v, k)) end - if first then print('No current unclaimed jobs.') end + if first then print('No current prioritizable jobs.') end end local function print_registry_section(header, t) @@ -496,7 +508,7 @@ local function print_registry() table.insert(t, v.code) end end - if not has_elements(t) then + if not next(t) then t = {'Load a game to see reactions'} end print_registry_section('Reaction names (for CustomReaction jobs)', t) @@ -601,7 +613,7 @@ local function parse_commandline(args) end opts.job_matchers = job_matchers - if action == status and has_elements(job_matchers) then + if action == status and next(job_matchers) then action = boost end opts.action = action @@ -613,15 +625,18 @@ dfhack.onStateChange[GLOBAL_KEY] = function(sc) if sc ~= SC_MAP_LOADED or df.global.gamemode ~= df.game_mode.DWARF then return end - local persisted_data = json.decode(persist.GlobalTable[GLOBAL_KEY] or '') or {} - -- sometimes the keys come back as strings; fix that up + local persisted_data = dfhack.persistent.getSiteData(GLOBAL_KEY, {}) + -- convert the string keys back into enum values + g_watched_job_matchers = {} for k,v in pairs(persisted_data) do - if type(k) == 'string' then - persisted_data[tonumber(k)] = v - persisted_data[k] = nil + -- very old saves may still have numbers in the persisted table + if type(k) ~= 'number' then + k = tonumber(k) + end + if k then + g_watched_job_matchers[k] = v end end - g_watched_job_matchers = persisted_data update_handlers() end @@ -639,6 +654,99 @@ if dfhack.internal.IN_TEST then } end +-------------------------------- +-- EnRouteOverlay +-- + +--- Return the selected active construction or destruction job +local function getSelectedActiveBuildingJob() + -- This is not relying on dfhack.gui.getSelectedJob() because we don't want + -- the job of a selected or followed unit, only of a selected building + local building = dfhack.gui.getSelectedBuilding(true) + if not building then + return nil + end + + -- Find if the building is being constructed + for _, job in ipairs(building.jobs) do + if not job.flags.suspend and (job.job_type == df.job_type.ConstructBuilding or job.job_type == df.job_type.ConstructBuilding) then + return job + end + end + + return nil +end + + +local function is_visible() + return getSelectedActiveBuildingJob() ~= nil +end + +EnRouteOverlay = defclass(EnRouteOverlay, overlay.OverlayWidget) +EnRouteOverlay.ATTRS{ + desc='Adds a panel to unbuilt buildings indicating whether a dwarf is on their way to build.', + default_pos={x=-40, y=26}, + default_enabled=true, + viewscreens='dwarfmode/ViewSheets/BUILDING', + frame={w=57, h=5}, + frame_style=gui.FRAME_MEDIUM, + frame_background=gui.CLEAR_PEN, + visible=is_visible, +} + +function EnRouteOverlay:init() + self:addviews{ + widgets.Label{ + frame={t=0, l=0}, + text={ + 'Job taken by:', + {gap=1, text=self:callback('get_builder_name'), pen=self:callback('get_builder_name_pen')} + }, + on_click=self:callback('zoom_to_builder'), + }, + widgets.ToggleHotkeyLabel{ + view_id='do_now', + frame={t=2, l=0}, + label='Make top priority:', + key='CUSTOM_CTRL_T', + on_change=function(val) + local job = getSelectedActiveBuildingJob() + if not job then return end + job.flags.do_now = val + end, + }, + } +end + +function EnRouteOverlay:get_builder_name() + if not self.builder then return 'N/A' end + return dfhack.units.getReadableName(self.builder, true) +end + +function EnRouteOverlay:get_builder_name_pen() + if not self.builder then return COLOR_DARKGRAY end + return COLOR_GREEN +end + +function EnRouteOverlay:zoom_to_builder() + local job = getSelectedActiveBuildingJob() + if not job then return end + local builder = dfhack.job.getWorker(job) + if builder then + dfhack.gui.revealInDwarfmodeMap(xyz2pos(dfhack.units.getPosition(builder)), true, true) + end +end + +function EnRouteOverlay:render(dc) + local job = getSelectedActiveBuildingJob() + self.builder = dfhack.job.getWorker(job) + self.subviews.do_now:setOption(job.flags.do_now) + EnRouteOverlay.super.render(self, dc) + self.builder = nil +end + +OVERLAY_WIDGETS = {enroute=EnRouteOverlay} + if dfhack_flags.module then return end @@ -648,6 +756,10 @@ if df.global.gamemode ~= df.game_mode.DWARF or not dfhack.isMapLoaded() then return end +-------------------------------- +-- main +-- + local args = {...} if dfhack_flags.enable then diff --git a/putontable.lua b/putontable.lua index 9b412d02b4..ca8d00e5d7 100644 --- a/putontable.lua +++ b/putontable.lua @@ -10,19 +10,21 @@ Arguments: ]====] -local pos=df.global.cursor +local guidm = require('gui.dwarfmode') + local args={...} local doall if args[1]=="-a" or args[1]=="--all" then doall=true end local items={} --as:df.item[] -local build=dfhack.buildings.findAtTile(pos.x,pos.y,pos.z) +local pos = guidm.getCursorPos() +local build = pos and dfhack.buildings.findAtTile(pos.x,pos.y,pos.z) or nil if not df.building_tablest:is_instance(build) then error("No table found at cursor") end -for k,v in pairs(df.global.world.items.all) do - if pos.x==v.pos.x and pos.y==v.pos.y and pos.z==v.pos.z and v.flags.on_ground then +for k,v in pairs(df.global.world.items.other.IN_PLAY) do + if v.flags.on_ground and same_xyz(v.pos, pos) then table.insert(items,v) if not doall then break diff --git a/questport.lua b/questport.lua index d18ff38fa9..d43702860c 100644 --- a/questport.lua +++ b/questport.lua @@ -1,23 +1,4 @@ ---Teleports your adventurer to the location of your quest log map cursor. ---[====[ - -questport -========= -Teleports your adventurer to the location of your quest log map cursor. - -Use by opening the quest log map and moving the cursor to your target location -before running the script. Note that this can be done both within and outside of -fast travel mode, and that it is possible to questport in situations where -fast travel is normally prohibited. - -It is currently not possible to questport into inaccessible locations like -ocean and mountain tiles. - -See `reveal-adv-map` if you wish to teleport into hidden map tiles. - -]====] - -local gui = require 'gui' +local gui = require('gui') local function processTravelNoArmy(advmode, advScreen, target_x, target_y) advmode.travel_origin_x = target_x @@ -53,7 +34,7 @@ advmode.message = '' -- clear messages like "You cannot travel until you leave t if advmode.menu == df.ui_advmode_menu.Default then advmode.menu = df.ui_advmode_menu.Travel - advmode.travel_not_moved = true + advmode.travel_not_moved = 1 processTravelNoArmy(advmode, advScreen, target_x, target_y) elseif advmode.menu == df.ui_advmode_menu.Travel then diff --git a/quickfort.lua b/quickfort.lua index 2e2c1ac03d..5930b565f2 100644 --- a/quickfort.lua +++ b/quickfort.lua @@ -55,11 +55,6 @@ function apply_blueprint(params) return quickfort_api.clean_stats(ctx.stats) end --- interactive script -if dfhack_flags.module then - return -end - local function do_help() print(dfhack.script_help()) end @@ -79,10 +74,16 @@ dfhack.onStateChange[GLOBAL_KEY] = function(sc) end end +-- interactive script +if dfhack_flags.module then + return +end + local action_switch = { set=quickfort_set.do_set, reset=do_reset, list=quickfort_list.do_list, + delete=quickfort_list.do_delete, gui=do_gui, run=quickfort_command.do_command, orders=quickfort_command.do_command, diff --git a/quicksave.lua b/quicksave.lua index 6e267bccff..54c1358ecd 100644 --- a/quicksave.lua +++ b/quicksave.lua @@ -22,18 +22,9 @@ if not dfhack.world.isFortressMode() then qerror('This script can only be used in fortress mode') end -local ui_main = df.global.plotinfo.main -local flags4 = df.global.d_init.flags4 - -local function restore_autobackup() - if ui_main.autosave_request and dfhack.isMapLoaded() then - dfhack.timeout(10, 'frames', restore_autobackup) - else - flags4.AUTOBACKUP = true - end -end - function save() + local ui_main = df.global.plotinfo.main + -- Request auto-save (preparation steps below discovered from rev eng) ui_main.autosave_request = true ui_main.autosave_timer = 5 @@ -45,13 +36,6 @@ function save() ui_main.save_progress.info.cur_unit_chunk = nil ui_main.save_progress.info.cur_unit_chunk_num = -1 ui_main.save_progress.info.units_offloaded = -1 - - -- And since it will overwrite the backup, disable it temporarily - if flags4.AUTOBACKUP then - flags4.AUTOBACKUP = false - restore_autobackup() - end - print 'The game should autosave now.' end diff --git a/quickstart-guide.lua b/quickstart-guide.lua index 8511cedfa3..4194dc0a59 100644 --- a/quickstart-guide.lua +++ b/quickstart-guide.lua @@ -3,7 +3,7 @@ local gui = require('gui') local widgets = require('gui.widgets') -local GUIDE_FILE = 'hack/docs/docs/Quickstart.txt' +local GUIDE_FILE = dfhack.getHackPath()..'/docs/docs/Quickstart.txt' local function add_section_widget(sections, section) if #section == 0 then return end @@ -24,6 +24,7 @@ local function get_sections() end local prev_line = nil for line in lines do + line = dfhack.utf2df(line) if line:match('^[=-]+$') then add_section_widget(sections, section) section = {} diff --git a/region-pops.lua b/region-pops.lua index 80f73ceaab..15d249604c 100644 --- a/region-pops.lua +++ b/region-pops.lua @@ -70,7 +70,7 @@ function enum_populations() end end - for i,v in ipairs(df.global.world.populations) do + for i,v in ipairs(df.global.world.populations.all) do local typeid = df.world_population_type[v.type] local is_plant = is_plant_map[typeid] diff --git a/rejuvenate.lua b/rejuvenate.lua index 38eb181fa6..ac0cf84f48 100644 --- a/rejuvenate.lua +++ b/rejuvenate.lua @@ -1,48 +1,95 @@ --- set age of selected unit --- by vjek --@ module = true local utils = require('utils') -function rejuvenate(unit, force, dry_run, age) - local current_year = df.global.cur_year - if not age then - age = 20 +local DEFAULT_CHILD_AGE = 18 +local DEFAULT_OLD_AGE = 160 +local ANY_BABY = df.global.world.units.other.ANY_BABY + +local function get_caste_misc(unit) + local craw = dfhack.units.getCasteRaw(unit) + if not craw then return end + return craw.misc +end + +local function get_adult_age(misc) + return misc and misc.child_age or DEFAULT_CHILD_AGE +end + +local function get_rand_old_age(misc) + return misc and math.random(misc.maxage_min, misc.maxage_max) or DEFAULT_OLD_AGE +end + +-- called by armoks-blessing +function rejuvenate(unit, quiet, force, dry_run, age) + local name = dfhack.df2console(dfhack.units.getReadableName(unit)) + local misc = get_caste_misc(unit) + local adult_age = get_adult_age(misc) + age = age or adult_age + if age < adult_age then + dfhack.printerr('cannot set age to child or baby range') + return end + local current_year = df.global.cur_year local new_birth_year = current_year - age - local name = dfhack.df2console(dfhack.TranslateName(dfhack.units.getVisibleName(unit))) + local new_old_year = unit.old_year < 0 and -1 or math.max(unit.old_year, new_birth_year + get_rand_old_age(misc)) if unit.birth_year > new_birth_year and not force then - print(name .. ' is under ' .. age .. ' years old. Use -force to force.') + if not quiet then + dfhack.printerr(name .. ' is under ' .. age .. ' years old. Use --force to force.') + end return end if dry_run then print('would change: ' .. name) return end + + local hf = df.historical_figure.find(unit.hist_figure_id) unit.birth_year = new_birth_year - if unit.old_year < new_birth_year + 160 then - unit.old_year = new_birth_year + 160 - end + if hf then hf.born_year = new_birth_year end + unit.old_year = new_old_year + if hf then hf.old_year = new_old_year end + if unit.profession == df.profession.BABY or unit.profession == df.profession.CHILD then + if unit.profession == df.profession.BABY then + local idx = utils.linear_index(ANY_BABY, unit.id, 'id') + if idx then + ANY_BABY:erase(idx) + end + unit.flags1.rider = false + unit.relationship_ids.RiderMount = -1 + unit.mount_type = df.rider_positions_type.STANDARD + unit.profession2 = df.profession.STANDARD + unit.idle_area_type = df.unit_station_type.MillBuilding + unit.mood = -1 + + -- let the mom know she isn't carrying anyone anymore + local mother = df.unit.find(unit.relationship_ids.Mother) + if mother then mother.flags1.ridden = false end + end unit.profession = df.profession.STANDARD + unit.profession2 = df.profession.STANDARD + if hf then hf.profession = df.profession.STANDARD end + end + unit.flags4.portrait_must_be_refreshed = true + unit.flags4.any_texture_must_be_refreshed = true + if dfhack.world.isFortressMode() then + dfhack.units.setAutomaticProfessions(unit) + end + if not quiet then + print(name .. ' is now ' .. age .. ' years old and will live a normal lifespan henceforth') end - print(name .. ' is now ' .. age .. ' years old and will live to at least 160') end -function main(args) - local current_year, newbirthyear +local function main(args) local units = {} --as:df.unit[] if args.all then - for _, u in ipairs(df.global.world.units.all) do - if dfhack.units.isCitizen(u) then - table.insert(units, u) - end - end + units = dfhack.units.getCitizens() else - table.insert(units, dfhack.gui.getSelectedUnit(true) or qerror("No unit under cursor! Aborting.")) + table.insert(units, dfhack.gui.getSelectedUnit(true) or qerror("Please select a unit in the UI.")) end for _, u in ipairs(units) do - rejuvenate(u, args.force, args['dry-run'], args.age) + rejuvenate(u, false, args.force, args['dry-run'], tonumber(args.age)) end end diff --git a/remove-stress.lua b/remove-stress.lua index e92ccb4b80..251ab774ae 100644 --- a/remove-stress.lua +++ b/remove-stress.lua @@ -2,32 +2,21 @@ --By Putnam; http://www.bay12forums.com/smf/index.php?topic=139553.msg5820486#msg5820486 --edited by Bumber --@module = true -local help = [====[ -remove-stress -============= -Sets stress to -1,000,000; the normal range is 0 to 500,000 with very stable or -very stressed dwarves taking on negative or greater values respectively. -Applies to the selected unit, or use ``remove-stress -all`` to apply to all units. - -Using the argument ``-value 0`` will reduce stress to the value 0 instead of -1,000,000. -Negative values must be preceded by a backslash (\): ``-value \-10000``. -Note that this can only be used to *decrease* stress - it cannot be increased -with this argument. - -]====] - -local utils = require 'utils' +local utils = require('utils') function removeStress(unit,value) - if unit.counters.soldier_mood > df.unit.T_counters.T_soldier_mood.Enraged then + if unit.counters.soldier_mood > df.soldier_mood_type.Enraged then -- Tantrum, Depressed, or Oblivious - unit.counters.soldier_mood = df.unit.T_counters.T_soldier_mood.None + unit.counters.soldier_mood = df.soldier_mood_type.None end if unit.status.current_soul then if unit.status.current_soul.personality.stress > value then unit.status.current_soul.personality.stress = value end + if unit.status.current_soul.personality.longterm_stress > value then + unit.status.current_soul.personality.longterm_stress = value + end end end @@ -42,7 +31,7 @@ function main(...) local stress_value = -1000000 if args.help then - print(help) + print(dfhack.script_help()) return end @@ -55,7 +44,7 @@ function main(...) end if args.all then - for k,v in ipairs(df.global.world.units.active) do + for k,v in ipairs(dfhack.units.getCitizens()) do removeStress(v,stress_value) end else diff --git a/remove-wear.lua b/remove-wear.lua index 90621b54c9..72e92b0a5f 100644 --- a/remove-wear.lua +++ b/remove-wear.lua @@ -8,7 +8,7 @@ if not args[1] or args[1] == 'help' or args[1] == '-h' or args[1] == '--help' th print(dfhack.script_help()) return elseif args[1] == 'all' or args[1] == '-all' then - for _, item in ipairs(df.global.world.items.all) do + for _, item in ipairs(df.global.world.items.other.IN_PLAY) do if item:getWear() > 0 then --hint:df.item_actual item:setWear(0) count = count + 1 diff --git a/repeat.lua b/repeat.lua index 771d61fafe..ce3ab43e67 100644 --- a/repeat.lua +++ b/repeat.lua @@ -46,8 +46,8 @@ elseif args.command then end if args.list then - for k in pairs(repeatUtil.repeating) do - print(k) + for _,name in ipairs(repeatUtil.listScheduled()) do + print(name) end return end diff --git a/resize-armor.lua b/resize-armor.lua new file mode 100644 index 0000000000..1735520c95 --- /dev/null +++ b/resize-armor.lua @@ -0,0 +1,150 @@ +-- Resize armor and clothing. + +local argparse = require('argparse') +local utils = require('utils') + +local function isValidItem(item) + local itemTypes = { + df.item_type.ARMOR, + df.item_type.SHOES, + df.item_type.GLOVES, + df.item_type.HELM, + df.item_type.PANTS, + } + if not utils.linear_index(itemTypes, item:getType()) or + item.flags.in_job or + item.flags.hostile or + item.flags.removed or + item.flags.dead_dwarf or + item.flags.rotten or + item.flags.spider_web or + item.flags.construction or + item.flags.encased or + item.flags.trader or + item.flags.garbage_collect or + item.flags.forbid or + item.flags.dump or + item.flags.on_fire or + item.flags.melt + then + return false + end + return true +end + +local function GetstockpileItems(stockpile) + local stockpileItems = dfhack.buildings.getStockpileContents(stockpile) + local items = {} + for _, stockpileItem in ipairs(stockpileItems) do + local containedItems = {} + for _, generalRef in ipairs(stockpileItem.general_refs) do + if df.general_ref_contains_itemst:is_instance(generalRef) then + containedItems = dfhack.items.getContainedItems(stockpileItem) + end + end + if #containedItems > 0 then + for _, containedItem in ipairs(containedItems) do + if isValidItem(containedItem) then + table.insert(items, containedItem) + end + end + end + if #stockpileItems > 0 and isValidItem(stockpileItem) then + table.insert(items, stockpileItem) + end + end + return items +end + +local function GetRace() + local race + if dfhack.world.isAdventureMode() then + race = dfhack.world.getAdventurer().race + elseif dfhack.world.isFortressMode() then + local site = dfhack.world.getCurrentSite() + for _, entityLink in ipairs(site.entity_links) do + local entity = df.historical_entity.find(entityLink.entity_id) + if entity and entity.type == df.historical_entity_type.Civilization then + race = entity.race + break + end + end + end + return race +end + +local function ResizeItems(items, race) + local raceName = race and dfhack.units.getRaceNamePluralById(race) + local resizeCount = 0 + for _, item in ipairs(items) do + local itemName = item and dfhack.items.getReadableDescription(item) + if item:getMakerRace() ~= race then + item:setMakerRace(race) + item:calculateWeight() + resizeCount = resizeCount + 1 + if #items == 1 then print(('%s resized for %s.'):format(itemName, raceName)) end + elseif #items == 1 then print(('%s is already sized for %s.'):format(itemName, raceName)) end + end + if resizeCount > 1 then + print(('%d items resized for %s.'):format(resizeCount, raceName)) + elseif resizeCount == 0 and #items > 1 then + print(('All items are already sized for %s'):format(raceName)) + end +end + +local function ParseCommandLine(args) + local options = { + help = false, + race = nil, + } + local positionals = argparse.processArgsGetopt(args, { + {'h', 'help', handler = function() options.help = true end}, + {'r', 'race', hasArg = true, handler = function(arg) options.race = argparse.nonnegativeInt(arg, 'race') end}, + }) + return options +end + +local function Main(args) + local options = ParseCommandLine(args) + if args[1] == 'help' or options.help then + print(dfhack.script_help()) + return + end + local items = {} + local item = dfhack.gui.getSelectedItem(true) + if item then + if isValidItem(item) then + table.insert(items, item) + else + qerror('Selected item cannot be resized.') + end + elseif dfhack.world.isFortressMode() then + local stockpile = dfhack.gui.getSelectedStockpile(true) + if stockpile and df.building_stockpilest:is_instance(stockpile) then + items = GetstockpileItems(stockpile) + if #items < 1 then qerror('Selected stockpile contains no items that can be resized.') end + end + end + if #items > 0 then + local race = options.race or GetRace() + if not race then + qerror('Unable to obtain race ID. Please specify race ID manually.') + end + ResizeItems(items, race) + else + if dfhack.world.isAdventureMode() then + qerror('No item selected.') + elseif dfhack.world.isFortressMode() then + qerror('No item or stockpile selected.') + end + end +end + +if not dfhack.isMapLoaded() or ( + not dfhack.world.isAdventureMode() and not dfhack.world.isFortressMode() + ) +then + qerror('This script requires the game to be in adventure or fortress mode.') +end + +Main({...}) diff --git a/resurrect-adv.lua b/resurrect-adv.lua index 6eedb37968..ce317fe082 100644 --- a/resurrect-adv.lua +++ b/resurrect-adv.lua @@ -1,31 +1,16 @@ --- Bring your adventurer back to life. --- author: Atomic Chicken --- essentially a wrapper for "full-heal.lua" - ---[====[ - -resurrect-adv -============= -Brings a dead adventurer back to life, fully healing them -in the process. - -This script only targets the current player character in -your party, and should be run after being presented with -the "You are deceased" message. It is not possible to -resurrect the adventurer after the game has been ended. - -]====] - local fullHeal = reqscript('full-heal') -if df.global.gamemode ~= df.game_mode.ADVENTURE then - qerror("This script can only be used in adventure mode!") +if not dfhack.world.isAdventureMode() then + qerror("This script can only be used in adventure mode!") end -local adventurer = df.nemesis_record.find(df.global.adventure.player_id).unit +local adventurer = dfhack.world.getAdventurer() if not adventurer or not adventurer.flags2.killed then - qerror("Your adventurer hasn't died yet!") + qerror("Your adventurer hasn't died yet!") end fullHeal.heal(adventurer, true) -df.global.adventure.player_control_state = 1 -- this ensures that the player will be able to regain control of their unit after resurrection if the script is run before hitting DONE at the "You are deceased" message + +-- this ensures that the player will be able to regain control of their unit after +-- resurrection if the script is run before hitting DONE at the "You are deceased" message +df.global.adventure.player_control_state = 1 diff --git a/reveal-adv-map.lua b/reveal-adv-map.lua index 7d52d070ea..e99cae9dc8 100644 --- a/reveal-adv-map.lua +++ b/reveal-adv-map.lua @@ -1,69 +1,46 @@ --- Exposes/hides the entire world map in adventure mode. --- author: Atomic Chicken - --@ module = true -local usage = [====[ - -reveal-adv-map -============== - -This script can be used to either reveal or hide all tiles on the -world map in adventure mode (visible when viewing the quest log -or fast travelling, for example). - -Note that the script does not reveal hidden lairs, camps, etc. -See `reveal-hidden-sites` for this functionality. - -Arguments:: - - -hide - Include this if you want to hide all world tiles instead - of revealing them - -]====] - -local utils = require 'utils' +local utils = require('utils') function revealAdvMap(hide) - local world = df.global.world.world_data - for world_x = 0, world.world_width-1, 1 do - for world_y = 0, world.world_height-1, 1 do - df.global.world.world_data.region_map[world_x]:_displace(world_y).flags.discovered = not hide + local world = df.global.world.world_data + for world_x = 0, world.world_width - 1, 1 do + for world_y = 0, world.world_height - 1, 1 do + df.global.world.world_data.region_map[world_x]:_displace(world_y).flags.discovered = not hide + end end - end --- update the quest log configuration if it is already open (restricts map cursor movement): - local view = dfhack.gui.getCurViewscreen() - if view._type == df.viewscreen_adventure_logst then - local player = view.player_region - if hide then - view.cursor.x = player.x - view.cursor.y = player.y + -- update the quest log configuration if it is already open (restricts map cursor movement): + local view = dfhack.gui.getDFViewscreen(true) + if view._type == df.viewscreen_adventure_logst then + local player = view.player_region + if hide then + view.cursor.x = player.x + view.cursor.y = player.y + end + view.min_discovered.x = (hide and player.x) or 0 + view.min_discovered.y = (hide and player.y) or 0 + view.max_discovered.x = (hide and player.x) or world.world_width - 1 + view.max_discovered.y = (hide and player.y) or world.world_height - 1 end - view.min_discovered.x = (hide and player.x) or 0 - view.min_discovered.y = (hide and player.y) or 0 - view.max_discovered.x = (hide and player.x) or world.world_width-1 - view.max_discovered.y = (hide and player.y) or world.world_height-1 - end end local validArgs = utils.invert({ - 'hide', - 'help' + 'hide', + 'help' }) local args = utils.processArgs({...}, validArgs) if dfhack_flags.module then - return + return end if args.help then - print(usage) - return + print(dfhack.script_help()) + return end -if df.global.gamemode ~= df.game_mode.ADVENTURE then - qerror("This script can only be used in adventure mode!") +if not dfhack.world.isAdventureMode() then + qerror("This script can only be used in adventure mode!") end revealAdvMap(args.hide and true) diff --git a/reveal-hidden-sites.lua b/reveal-hidden-sites.lua index 85c455afae..77e87f5129 100644 --- a/reveal-hidden-sites.lua +++ b/reveal-hidden-sites.lua @@ -1,26 +1,7 @@ --- Expose all undiscovered sites. --- author: Atomic Chicken - ---[====[ - -reveal-hidden-sites -=================== -This script reveals all sites in the world -that have yet to be discovered by the player -(camps, lairs, shrines, vaults, etc) -thus making them visible on the map. - -Usable in both fortress and adventure mode. - -See `reveal-adv-map` if you also want to expose -hidden world map tiles in adventure mode. - -]====] - local count = 0 for _, site in ipairs(df.global.world.world_data.sites) do - if site.flags.Undiscovered then - site.flags.Undiscovered = false + if site.flag.HIDDEN then + site.flag.HIDDEN = false count = count + 1 end end diff --git a/set-orientation.lua b/set-orientation.lua index 19583f7913..f1b1233cd5 100644 --- a/set-orientation.lua +++ b/set-orientation.lua @@ -155,7 +155,7 @@ function randomiseOrientation(unit, sex) return end - local caste = df.creature_raw.find(unit.race).caste[unit.caste] + local caste = dfhack.units.getCasteRaw(unit) -- Build a weighted table for use in the weighted roll function local sexname = getSexString(sex) @@ -214,7 +214,7 @@ function main(...) -- View if args.view then - print("Orientation of " .. dfhack.TranslateName(unit.name) .. ":") + print("Orientation of " .. dfhack.df2console(dfhack.units.getReadableName(unit)) .. ":") print("Male: " .. getInterestString(getInterest(unit, "male"))) print("Female: " .. getInterestString(getInterest(unit, "female"))) return diff --git a/siren.lua b/siren.lua index 6a53f17a11..f3d9967b97 100644 --- a/siren.lua +++ b/siren.lua @@ -1,16 +1,6 @@ -- Wakes up the sleeping, ends parties ---[====[ -siren -===== -Wakes up sleeping units and stops parties, either everywhere or in the burrows -given as arguments. In return, adds bad thoughts about noise, tiredness and lack -of protection. The script is intended for emergencies, e.g. when a siege -appears, and all your military is partying. - -]====] - -local utils = require 'utils' +local utils = require('utils') local args = {...} local burrows = {} --as:df.burrow[] @@ -90,9 +80,9 @@ for _,v in ipairs(df.global.plotinfo.invasions.list) do end -- Stop rest -for _,v in ipairs(df.global.world.units.active) do +for _,v in ipairs(dfhack.units.getCitizens()) do local x,y,z = dfhack.units.getPosition(v) - if x and dfhack.units.isCitizen(v) and is_in_burrows(xyz2pos(x,y,z)) then + if x and is_in_burrows(xyz2pos(x,y,z)) then if not in_siege and v.military.squad_id < 0 then add_thought(v, df.emotion_type.Nervousness, df.unit_thought_type.LackProtection) end diff --git a/source.lua b/source.lua index cce4a92933..67d052f95b 100644 --- a/source.lua +++ b/source.lua @@ -1,15 +1,20 @@ --@ module = true +local guidm = require('gui.dwarfmode') local repeatUtil = require('repeat-util') -liquidSources = liquidSources or {} +local GLOBAL_KEY = 'source' -- used for state change hooks and persistence -local sourceId = 'liquidSources' +g_sources_list = g_sources_list or {} + +local function persist_state(liquidSources) + dfhack.persistent.saveSiteData(GLOBAL_KEY, liquidSources) +end local function formatPos(pos) return ('[%d, %d, %d]'):format(pos.x, pos.y, pos.z) end -function IsFlowPassable(pos) +local function is_flow_passable(pos) local tiletype = dfhack.maps.getTileType(pos) local titletypeAttrs = df.tiletype.attrs[tiletype] local shape = titletypeAttrs.shape @@ -17,22 +22,14 @@ function IsFlowPassable(pos) return tiletypeShapeAttrs.passable_flow end -function AddLiquidSource(pos, liquid, amount) - table.insert(liquidSources, { - liquid = liquid, - amount = amount, - pos = copyall(pos), - }) - - repeatUtil.scheduleEvery(sourceId, 12, 'ticks', function() - if next(liquidSources) == nil then - repeatUtil.cancel(sourceId) +local function load_liquid_source() + repeatUtil.scheduleEvery(GLOBAL_KEY, 12, 'ticks', function() + if #g_sources_list == 0 then + repeatUtil.cancel(GLOBAL_KEY) else - for _, v in pairs(liquidSources) do + for _, v in ipairs(g_sources_list) do local block = dfhack.maps.getTileBlock(v.pos) - local x = v.pos.x - local y = v.pos.y - if block and IsFlowPassable(v.pos) then + if block and is_flow_passable(v.pos) then local isMagma = v.liquid == 'magma' local flags = dfhack.maps.getTileFlags(v.pos) @@ -56,29 +53,64 @@ function AddLiquidSource(pos, liquid, amount) end) end -function DeleteLiquidSource(pos) - for k, v in pairs(liquidSources) do - if same_xyz(pos, v.pos) then liquidSources[k] = nil end - return +local function delete_source_at(idx) + local v = g_sources_list[idx] + + if v then + local block = dfhack.maps.getTileBlock(v.pos) + if block and is_flow_passable(v.pos) then + local flags = dfhack.maps.getTileFlags(v.pos) + flags.flow_size = 0 + dfhack.maps.enableBlockUpdates(block, true) + end + table.remove(g_sources_list, idx) + end +end + +local function add_liquid_source(pos, liquid, amount) + local new_source = {liquid = liquid, amount = amount, pos = copyall(pos)} + print(("Adding %d %s to %s"):format(amount, liquid, formatPos(pos))) + for k, v in ipairs(g_sources_list) do + if same_xyz(pos, v.pos) then + delete_source_at(k) + break + end + end + + table.insert(g_sources_list, new_source) + + load_liquid_source() +end + +local function delete_liquid_source(pos) + print(("Deleting Source at %s"):format(formatPos(pos))) + for k, v in ipairs(g_sources_list) do + if same_xyz(pos, v.pos) then + print("Source Found") + delete_source_at(k) + break + end end end -function ClearLiquidSources() - for k, _ in pairs(liquidSources) do - liquidSources[k] = nil +local function clear_liquid_sources() + while #g_sources_list > 0 do + delete_source_at(#g_sources_list) end end -function ListLiquidSources() +local function list_liquid_sources() print('Current Liquid Sources:') - for _,v in pairs(liquidSources) do + for _,v in ipairs(g_sources_list) do print(('%s %s %d'):format(formatPos(v.pos), v.liquid, v.amount)) end end -function FindLiquidSourceAtPos(pos) - for k,v in pairs(liquidSources) do +local function find_liquid_source_at_pos(pos) + print(("Searching for Source at %s"):format(formatPos(pos))) + for k,v in ipairs(g_sources_list) do if same_xyz(v.pos, pos) then + print("Source Found") return k end end @@ -88,26 +120,26 @@ end function main(args) local command = args[1] - if command == 'list' then - ListLiquidSources() + if not command or command == 'list' then + list_liquid_sources() return end if command == 'clear' then - ClearLiquidSources() + clear_liquid_sources() print("Cleared sources") return end - local targetPos = copyall(df.global.cursor) - local index = FindLiquidSourceAtPos(targetPos) + local targetPos = guidm.getCursorPos() + local index = find_liquid_source_at_pos(targetPos) if command == 'delete' then - if targetPos.x < 0 then + if not targetPos then qerror("Please place the cursor where there is a source to delete") end - if not index then - DeleteLiquidSource(targetPos) + if index then + delete_liquid_source(targetPos) print(('Deleted source at %s'):format(formatPos(targetPos))) else qerror(('%s Does not contain a liquid source'):format(formatPos(targetPos))) @@ -116,7 +148,7 @@ function main(args) end if command == 'add' then - if targetPos.x < 0 then + if not targetPos then qerror('Please place the cursor where you would like a source') end local liquidArg = args[2] @@ -127,18 +159,44 @@ function main(args) if not (liquidArg == 'magma' or liquidArg == 'water') then qerror('Liquid must be either "water" or "magma"') end - if not IsFlowPassable(targetPos) then + if not is_flow_passable(targetPos) then qerror("Tile not flow passable: I'm afraid I can't let you do that, Dave.") end local amountArg = tonumber(args[3]) or 7 - AddLiquidSource(targetPos, liquidArg, amountArg) + add_liquid_source(targetPos, liquidArg, amountArg) print(('Added %s %d at %s'):format(liquidArg, amountArg, formatPos(targetPos))) return end +end + +dfhack.onStateChange[GLOBAL_KEY] = function(sc) + if sc == SC_WORLD_UNLOADED then + g_sources_list = {} + end + + if sc ~= SC_MAP_LOADED or df.global.gamemode ~= df.game_mode.DWARF then + return + end + + local data = dfhack.persistent.getSiteData(GLOBAL_KEY, {}) + g_sources_list = {} + + -- fix up potential errors in previous versions where the list could be non-contiguous + for _, v in pairs(data) do + table.insert(g_sources_list, v) + end - print(dfhack.script_help()) + load_liquid_source() end -if not dfhack_flags.module then - main({...}) +if dfhack_flags.module then + return end + +if df.global.gamemode ~= df.game_mode.DWARF or not dfhack.isMapLoaded() then + dfhack.printerr('source needs a loaded fortress map to work') + return +end + +main{...} +persist_state(g_sources_list) diff --git a/startdwarf.lua b/startdwarf.lua index 5f779a5933..464a766d4a 100644 --- a/startdwarf.lua +++ b/startdwarf.lua @@ -1,15 +1,18 @@ --@ module=true local argparse = require('argparse') +local gui = require('gui') local overlay = require('plugins.overlay') local widgets = require('gui.widgets') StartDwarfOverlay = defclass(StartDwarfOverlay, overlay.OverlayWidget) StartDwarfOverlay.ATTRS{ + desc='Adds a scrollbar (if necessary) to the list of starting dwarves.', default_pos={x=5, y=9}, default_enabled=true, viewscreens='setupdwarfgame/Dwarves', frame={w=5, h=10}, + fullscreen=true, } function StartDwarfOverlay:init() @@ -46,22 +49,21 @@ function StartDwarfOverlay:on_scrollbar(scroll_spec) end function StartDwarfOverlay:render(dc) - local sw, sh = dfhack.screen.getWindowSize() - local list_height = sh - 17 local scr = dfhack.gui.getDFViewscreen(true) local num_units = #scr.s_unit - local units_per_page = list_height // 3 - local scrollbar = self.subviews.scrollbar - self.frame.w = sw // 2 - 4 - self.frame.h = list_height - self:updateLayout() - - local top = math.min(scr.selected_u + 1, num_units - units_per_page + 1) - scrollbar:update(top, units_per_page, num_units) + local top = math.min(scr.selected_u + 1, num_units - self.units_per_page + 1) + self.subviews.scrollbar:update(top, self.units_per_page, num_units) StartDwarfOverlay.super.render(self, dc) end +function StartDwarfOverlay:preUpdateLayout(rect) + local list_height = rect.height - 17 + self.units_per_page = list_height // 3 + self.frame.w = (rect.width - 8) // 2 + self.frame.h = list_height +end + OVERLAY_WIDGETS = { overlay=StartDwarfOverlay, } diff --git a/starvingdead.lua b/starvingdead.lua index 8bf523fa38..b61db20de0 100644 --- a/starvingdead.lua +++ b/starvingdead.lua @@ -3,130 +3,153 @@ --@module = true local argparse = require('argparse') -local json = require('json') -local persist = require('persist-table') +local utils = require('utils') local GLOBAL_KEY = 'starvingdead' -starvingDeadInstance = starvingDeadInstance or nil +local function get_default_state() + return { + enabled=false, + decay_rate=1, + death_threshold=6, + last_cycle_tick=0, + } +end + +state = state or get_default_state() function isEnabled() - return starvingDeadInstance ~= nil + return state.enabled end local function persist_state() - persist.GlobalTable[GLOBAL_KEY] = json.encode({ - enabled = starvingDeadInstance ~= nil, - decay_rate = starvingDeadInstance and starvingDeadInstance.decay_rate or 1, - death_threshold = starvingDeadInstance and starvingDeadInstance.death_threshold or 6 - }) + dfhack.persistent.saveSiteData(GLOBAL_KEY, state) end -dfhack.onStateChange[GLOBAL_KEY] = function(sc) - 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 - - local persisted_data = json.decode(persist.GlobalTable[GLOBAL_KEY] or '{}') - - if persisted_data.enabled then - starvingDeadInstance = StarvingDead{ - decay_rate = persisted_data.decay_rate, - death_threshold = persisted_data.death_threshold - } - end -end - -StarvingDead = defclass(StarvingDead) -StarvingDead.ATTRS{ - decay_rate = 1, - death_threshold = 6, -} - -function StarvingDead:init() - self.timeout_id = nil - -- Percentage goal each attribute should reach before death. - local attribute_goal = 10 - self.attribute_decay = (attribute_goal ^ (1 / ((self.death_threshold * 28 / self.decay_rate)))) / 100 - - self:checkDecay() - print(([[StarvingDead started, checking every %s days and killing off at %s months]]):format(self.decay_rate, self.death_threshold)) -end - -function StarvingDead:checkDecay() - for _, unit in pairs(df.global.world.units.active) do - if (unit.enemy.undead and not unit.flags1.inactive) then - -- time_on_site is measured in ticks, a month is 33600 ticks. - -- @see https://dwarffortresswiki.org/index.php/Time - for _, attribute in pairs(unit.body.physical_attrs) do - attribute.value = math.floor(attribute.value - (attribute.value * self.attribute_decay)) - end - - if unit.curse.time_on_site > (self.death_threshold * 33600) then - unit.animal.vanish_countdown = 1 - end +-- threshold each attribute should reach before death. +local ATTRIBUTE_THRESHOLD_PERCENT = 10 + +local TICKS_PER_DAY = 1200 +local TICKS_PER_MONTH = 28 * TICKS_PER_DAY +local TICKS_PER_YEAR = 12 * TICKS_PER_MONTH + +local function do_decay() + local decay_exponent = state.decay_rate / (state.death_threshold * 28) + local attribute_decay = (ATTRIBUTE_THRESHOLD_PERCENT ^ decay_exponent) / 100 + + for _, unit in pairs(df.global.world.units.active) do + if (unit.enemy.undead and not unit.flags1.inactive) then + for _,attribute in pairs(unit.body.physical_attrs) do + attribute.value = math.floor(attribute.value - (attribute.value * attribute_decay)) + end + + if unit.usable_interaction.time_on_site > (state.death_threshold * TICKS_PER_MONTH) then + unit.animal.vanish_countdown = 1 + end + end end - end +end - self.timeout_id = dfhack.timeout(self.decay_rate, 'days', self:callback('checkDecay')) +local function get_normalized_tick() + return dfhack.world.ReadCurrentTick() + TICKS_PER_YEAR * dfhack.world.ReadCurrentYear() end -if dfhack_flags.module then - return +timeout_id = timeout_id or nil + +local function event_loop() + if not state.enabled then return end + + local current_tick = get_normalized_tick() + local ticks_per_cycle = TICKS_PER_DAY * state.decay_rate + local timeout_ticks = ticks_per_cycle + + if current_tick - state.last_cycle_tick < ticks_per_cycle then + timeout_ticks = state.last_cycle_tick - current_tick + ticks_per_cycle + else + do_decay() + state.last_cycle_tick = current_tick + persist_state() + end + timeout_id = dfhack.timeout(timeout_ticks, 'ticks', event_loop) end -local options, args = { - decay_rate = nil, - death_threshold = nil -}, {...} +local function do_enable() + if state.enabled then return end -local positionals = argparse.processArgsGetopt(args, { - {'h', 'help', handler = function() options.help = true end}, - {'r', 'decay-rate', hasArg = true, handler=function(arg) options.decay_rate = argparse.positiveInt(arg, 'decay-rate') end }, - {'t', 'death-threshold', hasArg = true, handler=function(arg) options.death_threshold = argparse.positiveInt(arg, 'death-threshold') end }, -}) + state.enabled = true + state.last_cycle_tick = get_normalized_tick() + event_loop() +end -if dfhack_flags.enable then - if dfhack_flags.enable_state then - if starvingDeadInstance then - return +local function do_disable() + if not state.enabled then return end + + state.enabled = false + if timeout_id then + dfhack.timeout_active(timeout_id, nil) + timeout_id = nil end +end - starvingDeadInstance = StarvingDead{} - persist_state() - else - if not starvingDeadInstance then - return +dfhack.onStateChange[GLOBAL_KEY] = function(sc) + if sc == SC_MAP_UNLOADED then + do_disable() + return end - dfhack.timeout_active(starvingDeadInstance.timeout_id, nil) - starvingDeadInstance = nil - end -else - if not dfhack.isMapLoaded() then - qerror('This script requires a fortress map to be loaded') - end + if sc ~= SC_MAP_LOADED or not dfhack.world.isFortressMode() then + return + end - if positionals[1] == "help" or options.help then - print(dfhack.script_help()) + state = get_default_state() + utils.assign(state, dfhack.persistent.getSiteData(GLOBAL_KEY, state)) + + event_loop() +end + +if dfhack_flags.module then return - end +end - if positionals[1] == nil then - if starvingDeadInstance then - starvingDeadInstance.decay_rate = options.decay_rate or starvingDeadInstance.decay_rate - starvingDeadInstance.death_threshold = options.death_threshold or starvingDeadInstance.death_threshold +if not dfhack.isMapLoaded() or not dfhack.world.isFortressMode() then + qerror('This script requires a fortress map to be loaded') +end - print(([[StarvingDead is running, checking every %s days and killing off at %s months]]):format( - starvingDeadInstance.decay_rate, starvingDeadInstance.death_threshold - )) +if dfhack_flags.enable then + if dfhack_flags.enable_state then + do_enable() else - print("StarvingDead is not running!") + do_disable() end - end +end + +local opts = {} +local positionals = argparse.processArgsGetopt({...}, { + {'h', 'help', handler=function() opts.help = true end}, + {'r', 'decay-rate', hasArg=true, + handler=function(arg) opts.decay_rate = argparse.positiveInt(arg, 'decay-rate') end }, + {'t', 'death-threshold', hasArg=true, + handler=function(arg) opts.death_threshold = argparse.positiveInt(arg, 'death-threshold') end }, +}) + + +if positionals[1] == "help" or opts.help then + print(dfhack.script_help()) + return +end + +if opts.decay_rate then + state.decay_rate = opts.decay_rate +end +if opts.death_threshold then + state.death_threshold = opts.death_threshold +end +persist_state() + +if state.enabled then + print(([[StarvingDead is running, decaying undead every %s day%s and killing off at %s month%s]]):format( + state.decay_rate, state.decay_rate == 1 and '' or 's', state.death_threshold, state.death_threshold == 1 and '' or 's')) +else + print(([[StarvingDead is not running, but would decay undead every %s day%s and kill off at %s month%s]]):format( + state.decay_rate, state.decay_rate == 1 and '' or 's', state.death_threshold, state.death_threshold == 1 and '' or 's')) end diff --git a/store-owned.lua b/store-owned.lua new file mode 100644 index 0000000000..2558c1f94c --- /dev/null +++ b/store-owned.lua @@ -0,0 +1,406 @@ +-- Task units to store their owned items. + +local argparse = require("argparse") +local utils = require('utils') + +local function GetUnit(item, options) + local unit = dfhack.items.getOwner(item) + if not unit then + print('Selected item is not owned by any unit.') + elseif not dfhack.units.isJobAvailable(unit) and not options.discard then + unit = nil + print('Owner of selected item is currently busy.') + end + return unit +end + +local function isValidItem(item, unit) + local validItem = true + local itemPos = item and xyz2pos(dfhack.items.getPosition(item)) + local building = dfhack.items.getHolderBuilding(item) + if item.flags.in_job or + item.flags.hostile or + item.flags.removed or + item.flags.dead_dwarf or + item.flags.rotten or + item.flags.spider_web or + item.flags.construction or + item.flags.encased or + item.flags.trader or + item.flags.garbage_collect or + item.flags.forbid or + item.flags.dump or + item.flags.on_fire or + item.flags.melt or + not dfhack.maps.isTileVisible(itemPos) + then + validItem = false + print('Selected item cannot be stored.') + elseif building then + for _, civzone in ipairs(building.relations) do + if civzone.assigned_unit_id == unit.id then + if df.building_cabinetst:is_instance(building) or + df.building_armorstandst:is_instance(building) or + df.building_weaponrackst:is_instance(building) or + df.building_boxst:is_instance(building) + then + validItem = false + print('Selected item is already stored in the owner\'s room furniture.') + goto skipCheck + end + end + end + if item.flags.in_building then + validItem = false + print('Selected item is part of a building.') + end + else + local unitPos = unit and xyz2pos(dfhack.units.getPosition(unit)) + if not dfhack.maps.canWalkBetween(itemPos, unitPos) then + validItem = false + print('Owner of selected item cannot reach the item.') + end + end + ::skipCheck:: + return validItem +end + +local function isClothing(item) + if df.item_armorst:is_instance(item) or + df.item_shoesst:is_instance(item) or + df.item_glovesst:is_instance(item) or + df.item_helmst:is_instance(item) or + df.item_pantsst:is_instance(item) + then + return true + end + return false +end + +local function isArmor(item) + if isClothing(item) and item.subtype.armorlevel > 0 then + return true + end + return false +end + +local function isWeapon(item) + if df.item_weaponst:is_instance(item) then + return true + end + return false +end + +local function isGoods(item) + if df.item_amuletst:is_instance(item) or + df.item_braceletst:is_instance(item) or + df.item_crownst:is_instance(item) or + df.item_earringst:is_instance(item) or + df.item_figurinest:is_instance(item) or + df.item_gemst:is_instance(item) or + df.item_instrumentst:is_instance(item) or + df.item_ringst:is_instance(item) or + df.item_scepterst:is_instance(item) or + df.item_totemst:is_instance(item) or + df.item_toyst:is_instance(item) or + df.item_toolst:is_instance(item) -- Owned books and scrolls, which can be stored in boxes. + then + return true + end + return false +end + +local function GetContainerType(item) + -- Use chest as default, in case of any item not explicitly categorized. + local containerType = df.building_boxst + if isArmor(item) then + containerType = df.building_armorstandst + elseif isWeapon(item) then + containerType = df.building_weaponrackst + elseif isClothing(item) then + containerType = df.building_cabinetst + elseif isGoods(item) then + containerType = df.building_boxst + end + return containerType +end + +local function isEnoughCapacity(item, container) + local capacity = 0 + local totalVol = 0 + local itemVol = item:getVolume() + for _, containedItem in ipairs(container.contained_items) do + -- Assume only one item is the container. + if containedItem.item.flags.in_building and capacity == 0 then + if df.item_armorstandst:is_instance(containedItem.item) or + df.item_weaponrackst:is_instance(containedItem.item) or + df.item_cabinetst:is_instance(containedItem.item) or + df.item_boxst:is_instance(containedItem.item) + then + capacity = dfhack.items.getCapacity(containedItem.item) + end + else + totalVol = totalVol + containedItem.item:getVolume() + end + end + local remainingCapacity = capacity - (totalVol + itemVol) + if remainingCapacity >= 0 then + return true + end + return false +end + +local function GetCivzoneName(civzone) + local strCivzoneName + if #civzone.name > 0 then + strCivzoneName = civzone.name + else + strCivzoneName = dfhack.buildings.getName(civzone) + end + return strCivzoneName +end + +local function GetPosFromContainer(item, unitPos, civzone) + local containerType = GetContainerType(item) + for _, container in ipairs(civzone.contained_buildings) do + if containerType:is_instance(container) then + local containerPos = utils.getBuildingCenter(container) + if containerPos and + isEnoughCapacity(item, container) and + dfhack.maps.canWalkBetween(containerPos, unitPos) + then + local jobPos = containerPos + local containerName = dfhack.buildings.getName(container) + local strCivzoneName = GetCivzoneName(civzone) + print(('Sufficient storage space available;\n ...selected item will be stored inside %s in %s.'):format(containerName, strCivzoneName)) + return jobPos + end + end + end + return nil +end + +-- Items can be placed on any walkable tile, but in order to mimic native behavior +-- more closely, the item will be placed on a tile not occupied by a building. +-- However, to reduce visual clutter, any loose items already occupying the tile will be ignored. +local function GetPosFromTile(unitPos, civzone) + local jobPos = civzone and xyz2pos(civzone.centerx, civzone.centery, civzone.z) + if not dfhack.maps.canWalkBetween(jobPos, unitPos) or dfhack.buildings.findAtTile(jobPos) then + jobPos = nil + local x1, x2, y1, y2, z = civzone.x1, civzone.x2, civzone.y1, civzone.y2, civzone.z + for y = y1, y2, 1 do + for x = x1, x2, 1 do + if dfhack.buildings.containsTile(civzone, x, y) then + local tilePos = xyz2pos(x, y, z) + if dfhack.maps.canWalkBetween(tilePos, unitPos) and + not dfhack.buildings.findAtTile(tilePos) + then + jobPos = tilePos + goto returnPos + end + end + end + end + end + ::returnPos:: + if jobPos then + local strCivzoneName = GetCivzoneName(civzone) + print(('Unobstructed tile available;\n ...selected item will be dropped off on the floor of %s.'):format(strCivzoneName)) + end + return jobPos +end + +local function GetPosFromCivzone(civzones, item, unitPos, zoneType, putOnFloor) + local jobPos + for _, civzone in ipairs(civzones) do + if civzone.type == zoneType then + strCivzoneName = GetCivzoneName(civzone) + if not putOnFloor then + jobPos = GetPosFromContainer(item, unitPos, civzone) + else + jobPos = GetPosFromTile(unitPos, civzone) + end + end + if jobPos then return jobPos end + end + return nil +end + +local function GetPosFromOwnedCivzone(civzones, item, unitPos, putOnFloor) + local jobPos + local zoneTypes = { + df.civzone_type.Bedroom, + df.civzone_type.Office, + df.civzone_type.DiningHall, + df.civzone_type.Tomb + } + for _, zoneType in ipairs(zoneTypes) do + jobPos = GetPosFromCivzone(civzones, item, unitPos, zoneType, putOnFloor) + if jobPos then return jobPos end + end + return nil +end + +local function GetPosFromDepot(unitPos) + for _, depot in ipairs(df.global.world.buildings.other.TRADE_DEPOT) do + -- Item will be dropped on the center tile of the trade depot rather than stored inside it. + local depotPos = depot and utils.getBuildingCenter(depot) + if depotPos and dfhack.maps.canWalkBetween(depotPos, unitPos) then + local jobPos = depotPos + print('Depot accessible;\n ...selected item will be dropped off at the '..dfhack.buildings.getName(depot)..'.') + return jobPos + else + print('Unable to drop selected item off at the trade depot.') + end + end + return nil +end + +-- Prioritize storing in bedroom > office > dining room > tomb > dormitory > trade depot. +local function GetJobPos(unit, item, options) + local jobPos + local unitPos = unit and xyz2pos(dfhack.units.getPosition(unit)) + local civzones = unit and unit.owned_buildings + local putOnFloor = false + local strNoStorage = 'Unable to store selected item inside a storage furniture in' + local strNoTile = 'Unable to drop selected item off on the floor of' + local strOwnRoom = 'owner\'s assigned room(s)' + local strDorm = 'the dormitory' + if options.dorm or #civzones < 1 then + if #civzones < 1 then + print('Owner of selected item does not have any assigned rooms.') + end + goto getFromDorm + elseif options.depot then + goto getFromDepot + end + -- Get container in owned rooms to put item in. + jobPos = GetPosFromOwnedCivzone(civzones, item, unitPos, putOnFloor) + if not jobPos then + print(('%s %s.'):format(strNoStorage, strOwnRoom)) + putOnFloor = true + -- Get tile in owned rooms to drop item on. + jobPos = GetPosFromOwnedCivzone(civzones, item, unitPos, putOnFloor) + end + ::getFromDorm:: + if not jobPos then + if not options.dorm and #civzones > 0 then + print(('%s %s.'):format(strNoTile, strOwnRoom)) + end + if #df.global.world.buildings.other.ZONE_DORMITORY > 0 then + civzones = df.global.world.buildings.other.ZONE_DORMITORY + else + print('No dormitory available.') + goto getFromDepot + end + local zoneType = df.civzone_type.Dormitory + putOnFloor = false + -- Get container in dorm to put item in. + jobPos = GetPosFromCivzone(civzones, item, unitPos, zoneType, putOnFloor) + if not jobPos then + print(('%s %s.'):format(strNoStorage, strDorm)) + putOnFloor = true + -- Get tile in dorm to drop item on. + jobPos = GetPosFromCivzone(civzones, item, unitPos, zoneType, putOnFloor) + end + end + ::getFromDepot:: + if not jobPos then + if not options.depot and #civzones > 0 then + print(('%s %s.'):format(strNoTile, strDorm)) + end + if #df.global.world.buildings.other.TRADE_DEPOT > 0 then + jobPos = GetPosFromDepot(unitPos) + else + print('No trade depot available.') + end + end + return jobPos +end + +local function AssignJob(item, unit, jobPos) + local job = df.job:new() + job.job_type = df.job_type.StoreOwnedItem + job.pos = jobPos + dfhack.job.attachJobItem(job, item, df.job_role_type.Hauled, -1, -1) + dfhack.job.addWorker(job, unit) + dfhack.job.linkIntoWorld(job, true) + local strUnitName = unit and dfhack.units.getReadableName(unit) + local strItemName = item and dfhack.items.getReadableDescription(item) + print(('%s has been tasked to store away %s.'):format(strUnitName, strItemName)) +end + +local function RemoveFromUniform(item, unit, options) + if options.discard then + local strItemName = item and dfhack.items.getReadableDescription(item) + local strUnitName = unit and dfhack.units.getReadableName(unit) + dfhack.items.setOwner(item, nil) + print(('Ownership of %s was removed from %s.'):format(strItemName, strUnitName)) + end + local uniforms = { + unit.uniform.uniforms.CLOTHING, + unit.uniform.uniforms.REGULAR, + unit.uniform.uniforms.TRAINING, + unit.uniform.uniforms.TRAINING_RANGED + } + for _, uniform in ipairs(uniforms) do + local idx = utils.linear_index(uniform, item.id) + if idx then uniform:erase(idx) end + end +end + +local function ParseCommandLine(args) + local options = { + help = false, + dorm = false, + depot = false, + discard = false + } + local positionals = argparse.processArgsGetopt(args, { + {'h', 'help', handler = function() options.help = true end}, + {'', 'dorm', handler = function() options.dorm = true end}, + {'', 'depot', handler = function() options.depot = true + options.dorm = false end}, + {'', 'discard', handler = function() options.discard = true + options.dorm = false options.depot = false end}, + }) + return options +end + +local function Main(args) + local options = ParseCommandLine(args) + if args[1] == 'help' or options.help then + print(dfhack.script_help()) + return + end + local item = dfhack.gui.getSelectedItem(true) + if not item then + qerror('No item selected.') + end + local unit = GetUnit(item, options) + local strCannotStore = 'Unable to task selected item for storage.' + if unit and not options.discard then + local validItem = isValidItem(item, unit) + if not validItem then + qerror(strCannotStore) + end + local jobPos = GetJobPos(unit, item, options) + if not jobPos then + qerror('Unable to store selected item in any owned room, dormitory, or trade depot.') + end + AssignJob(item, unit, jobPos) + elseif not unit and not options.discard then + qerror(strCannotStore) + else + if not unit then + qerror('Cannot remove ownership of an ownerless item.') + end + end + RemoveFromUniform(item, unit, options) +end + +if not dfhack.isSiteLoaded() and not dfhack.world.isFortressMode() then + qerror('This script requires the game to be in fortress mode.') +end + +Main({...}) diff --git a/stripcaged.lua b/stripcaged.lua index 2585057c56..d153d8cda9 100644 --- a/stripcaged.lua +++ b/stripcaged.lua @@ -1,4 +1,5 @@ local argparse = require('argparse') +local guidm = require('gui.dwarfmode') local opts = {} local positionals = argparse.processArgsGetopt({...}, { {'h', 'help', handler = function() opts.help = true end}, @@ -60,7 +61,7 @@ local function cage_dump_armor(list) if df.general_ref_contains_unitst:is_instance(ref) then local inventory = df.unit.find(ref.unit_id).inventory for _, it in ipairs(inventory) do - if it.mode == df.unit_inventory_item.T_mode.Worn then + if it.mode == df.inv_item_role_type.Worn then count = count + dump_item(it.item) end end @@ -81,7 +82,7 @@ local function cage_dump_weapons(list) if df.general_ref_contains_unitst:is_instance(ref) then local inventory = df.unit.find(ref.unit_id).inventory for _, it in ipairs(inventory) do - if it.mode == df.unit_inventory_item.T_mode.Weapon then + if it.mode == df.inv_item_role_type.Weapon then count = count + dump_item(it.item) end end @@ -206,8 +207,8 @@ if positionals[2] == 'here' then end end -- Is the player trying to select a cage using the keyboard cursor? - elseif df.global.cursor.z > -10000 then -- cursor has values around -30000 if not valid/active. - local cursor = df.global.cursor + elseif guidm.getCursorPos() then + local cursor = guidm.getCursorPos() for _, cage in ipairs(df.global.world.items.other.ANY_CAGE_OR_TRAP) do if same_xyz(cursor, cage.pos) then table.insert(list, cage) diff --git a/superdwarf.lua b/superdwarf.lua index eb3b3b0648..d3dd754ce0 100644 --- a/superdwarf.lua +++ b/superdwarf.lua @@ -21,12 +21,7 @@ local function getUnit(obj) end local function getName(unit) - local name = dfhack.TranslateName(unit.name) - -- Animals will have a profession name if they aren't named - if name == '' then - name = dfhack.units.getProfessionName(unit) - end - return name + return dfhack.df2console(dfhack.units.getReadableName(unit)) end local function onTimer() @@ -57,10 +52,8 @@ commands = { repeatUtil.scheduleEvery(timerId, 1, 'ticks', onTimer) end, all = function(arg) - for _, unit in pairs(df.global.world.units.active) do - if dfhack.units.isCitizen(unit) then - commands.add(unit) - end + for _, unit in pairs(dfhack.units.getCitizens()) do + commands.add(unit) end end, del = function(arg) diff --git a/suspend.lua b/suspend.lua index bbbe4e4293..8d250e0675 100644 --- a/suspend.lua +++ b/suspend.lua @@ -1,29 +1,29 @@ --- Suspend jobs - --- It can either suspend all jobs, or just jobs that risk blocking others. +-- Suspend all construction jobs +local utils = require('utils') local argparse = require('argparse') -local suspendmanager = reqscript('suspendmanager') -local help, onlyblocking = false, false +local help = false argparse.processArgsGetopt({...}, { {'h', 'help', handler=function() help = true end}, - {'b', 'onlyblocking', handler=function() onlyblocking = true end}, }) +---cancel job and remove worker +---@param job df.job +local function suspend(job) + job.flags.suspend = true + job.flags.working = false + dfhack.job.removeWorker(job, 0); +end + + if help then print(dfhack.script_help()) return end --- Only initialize suspendmanager if we want to suspend blocking jobs -manager = onlyblocking and suspendmanager.SuspendManager{preventBlocking=true} or nil -if manager then - manager:refresh() -end - -suspendmanager.foreach_construction_job(function (job) - if not manager or manager:shouldBeSuspended(job) then - suspendmanager.suspend(job) +for _,job in utils.listpairs(df.global.world.jobs.list) do + if job.job_type == df.job_type.ConstructBuilding then + suspend(job) end -end) +end diff --git a/suspendmanager.lua b/suspendmanager.lua deleted file mode 100644 index 283d99234f..0000000000 --- a/suspendmanager.lua +++ /dev/null @@ -1,887 +0,0 @@ --- Avoid suspended jobs and creating unreachable jobs ---@module = true ---@enable = true - -local json = require('json') -local persist = require('persist-table') -local argparse = require('argparse') -local eventful = require('plugins.eventful') -local utils = require('utils') -local repeatUtil = require('repeat-util') -local gui = require('gui') -local overlay = require('plugins.overlay') -local widgets = require('gui.widgets') -local ok, buildingplan = pcall(require, 'plugins.buildingplan') -if not ok then - buildingplan = nil -end - -local GLOBAL_KEY = 'suspendmanager' -- used for state change hooks and persistence - -enabled = enabled or false - -eventful.enableEvent(eventful.eventType.JOB_INITIATED, 10) -eventful.enableEvent(eventful.eventType.JOB_COMPLETED, 10) - ---- List of reasons for a job to be suspended ----@enum reason -REASON = { - --- The job is under water and dwarves will suspend the job when starting it - UNDER_WATER = 1, - --- The job is planned by buildingplan, but not yet ready to start - BUILDINGPLAN = 2, - --- Fuzzy risk detection of jobs blocking each other in shapes like corners - RISK_BLOCKING = 3, - --- Building job on top of an erasable designation (smoothing, carving, ...) - ERASE_DESIGNATION = 4, - --- Blocks a dead end (either a corridor or on top of a wall) - DEADEND = 5, - --- Would cave in immediately on completion - UNSUPPORTED = 6, -} - -REASON_TEXT = { - [REASON.UNDER_WATER] = 'underwater', - [REASON.BUILDINGPLAN] = 'planned', - [REASON.RISK_BLOCKING] = 'blocking', - [REASON.ERASE_DESIGNATION] = 'designation', - [REASON.DEADEND] = 'dead end', - [REASON.UNSUPPORTED] = 'unsupported', -} - ---- Description of suspension ---- This only cover the reason where suspendmanager actively ---- suspend jobs -REASON_DESCRIPTION = { - [REASON.RISK_BLOCKING] = 'May block another build job', - [REASON.ERASE_DESIGNATION] = 'Waiting for carve/smooth/engrave', - [REASON.DEADEND] = 'Blocks another build job', - [REASON.UNSUPPORTED] = 'Would collapse immediately' -} - ---- Suspension reasons from an external source ---- SuspendManager does not actively suspend such jobs, but ---- will not unsuspend them -EXTERNAL_REASONS = utils.invert{ - REASON.UNDER_WATER, - REASON.BUILDINGPLAN, -} - ----@class SuspendManager ----@field preventBlocking boolean ----@field suspensions table ----@field leadsToDeadend table ----@field lastAutoRunTick integer -SuspendManager = defclass(SuspendManager) -SuspendManager.ATTRS { - --- When enabled, suspendmanager also tries to suspend blocking jobs, - --- when not enabled, it only cares about avoiding unsuspending jobs suspended externally - preventBlocking = false, - - --- Current job suspensions with their reasons - suspensions = {}, - - --- Current job that are part of a dead-end, not worth considering as an exit - leadsToDeadend = {}, - - --- Last tick where it was run automatically - lastAutoRunTick = -1, -} - ---- SuspendManager instance kept between frames ----@type SuspendManager -Instance = Instance or SuspendManager{preventBlocking=true} - -function isEnabled() - return enabled -end - -function preventBlockingEnabled() - return Instance.preventBlocking -end - ---- Returns true if the job is maintained suspended by suspendmanager ----@param job job -function isKeptSuspended(job) - if not isEnabled() or not preventBlockingEnabled() then - return false - end - - local reason = Instance.suspensions[job.id] - return reason and not EXTERNAL_REASONS[reason] -end - -local function persist_state() - persist.GlobalTable[GLOBAL_KEY] = json.encode({ - enabled=enabled, - prevent_blocking=Instance.preventBlocking, - }) -end - ----@param setting string ----@param value string|boolean -function update_setting(setting, value) - if setting == "preventblocking" then - if (value == "true" or value == true) then - Instance.preventBlocking = true - elseif (value == "false" or value == false) then - Instance.preventBlocking = false - else - qerror(tostring(value) .. " is not a valid value for preventblocking, it must be true or false") - end - else - qerror(setting .. " is not a valid setting.") - end - persist_state() -end - - ---- Suspend a job ----@param job job -function suspend(job) - job.flags.suspend = true - job.flags.working = false - dfhack.job.removeWorker(job, 0) -end - ---- Unsuspend a job ----@param job job -function unsuspend(job) - job.flags.suspend = false -end - ---- Loop over all the construction jobs ----@param fn function A function taking a job as argument -function foreach_construction_job(fn) - for _,job in utils.listpairs(df.global.world.jobs.list) do - if job.job_type == df.job_type.ConstructBuilding then - fn(job) - end - end -end - -local CONSTRUCTION_IMPASSABLE = utils.invert{ - df.construction_type.Wall, - df.construction_type.Fortification, -} - -local CONSTRUCTION_WALL_SUPPORT = utils.invert{ - df.construction_type.Wall, - df.construction_type.Fortification, - df.construction_type.UpStair, - df.construction_type.UpDownStair, -} - -local CONSTRUCTION_FLOOR_SUPPORT = utils.invert{ - df.construction_type.Floor, - df.construction_type.DownStair, - df.construction_type.Ramp, - df.construction_type.TrackN, - df.construction_type.TrackS, - df.construction_type.TrackE, - df.construction_type.TrackW, - df.construction_type.TrackNS, - df.construction_type.TrackNE, - df.construction_type.TrackSE, - df.construction_type.TrackSW, - df.construction_type.TrackEW, - df.construction_type.TrackNSE, - df.construction_type.TrackNSW, - df.construction_type.TrackNEW, - df.construction_type.TrackSEW, - df.construction_type.TrackNSEW, - df.construction_type.TrackRampN, - df.construction_type.TrackRampS, - df.construction_type.TrackRampE, - df.construction_type.TrackRampW, - df.construction_type.TrackRampNS, - df.construction_type.TrackRampNE, - df.construction_type.TrackRampNW, - df.construction_type.TrackRampSE, - df.construction_type.TrackRampSW, - df.construction_type.TrackRampEW, - df.construction_type.TrackRampNSE, - df.construction_type.TrackRampNSW, - df.construction_type.TrackRampNEW, - df.construction_type.TrackRampSEW, - df.construction_type.TrackRampNSEW, -} - --- all the tiletype shapes which provide support as if a wall --- note that these shapes act as if there is a floor above them, --- (including an up stair with no down stair above) which then connects --- orthogonally at that level. --- see: https://dwarffortresswiki.org/index.php/DF2014:Cave-in -local TILETYPE_SHAPE_WALL_SUPPORT = utils.invert{ - df.tiletype_shape.WALL, - df.tiletype_shape.FORTIFICATION, - df.tiletype_shape.STAIR_UP, - df.tiletype_shape.STAIR_UPDOWN, -} - --- all the tiletype shapes which provide support as if it were a floor. --- Tested as of v50.10 - YES, twigs do provide orthogonal support like a floor. -local TILETYPE_SHAPE_FLOOR_SUPPORT = utils.invert{ - df.tiletype_shape.FLOOR, - df.tiletype_shape.STAIR_DOWN, - df.tiletype_shape.RAMP, - df.tiletype_shape.BOULDER, - df.tiletype_shape.PEBBLES, - df.tiletype_shape.SAPLING, - df.tiletype_shape.BROOK_BED, - df.tiletype_shape.BROOK_TOP, - df.tiletype_shape.SHRUB, - df.tiletype_shape.TWIG, - df.tiletype_shape.BRANCH, - df.tiletype_shape.TRUNK_BRANCH, -} - -local BUILDING_IMPASSABLE = utils.invert{ - df.building_type.Floodgate, - df.building_type.Statue, - df.building_type.WindowGlass, - df.building_type.WindowGem, - df.building_type.GrateWall, - df.building_type.BarsVertical, -} - ---- Designation job type that are erased if a building is built on top of it -local ERASABLE_DESIGNATION = utils.invert{ - df.job_type.CarveTrack, - df.job_type.SmoothFloor, - df.job_type.DetailFloor, -} - ---- Job types that impact suspendmanager ---- Any completed pathable job can impact suspendmanager by allowing or disallowing ---- access to construction job. ---- Any job read by suspendmanager such as smoothing and carving can also impact ---- job suspension, since it suspends construction job on top of it -local FILTER_JOB_TYPES = utils.invert{ - df.job_type.CarveRamp, - df.job_type.CarveTrack, - df.job_type.CarveUpDownStaircase, - df.job_type.CarveUpwardStaircase, - df.job_type.CarveDownwardStaircase, - df.job_type.ConstructBuilding, - df.job_type.DestroyBuilding, - df.job_type.DetailFloor, - df.job_type.Dig, - df.job_type.DigChannel, - df.job_type.FellTree, - df.job_type.SmoothFloor, - df.job_type.RemoveConstruction, - df.job_type.RemoveStairs, -} - ---- Returns true if the job is a planned job from buildingplan -local function isBuildingPlanJob(job) - local bld = dfhack.job.getHolder(job) - return bld and buildingplan and buildingplan.isPlannedBuilding(bld) -end - ---- Check if a building is blocking once constructed ----@param building building_constructionst|building ----@return boolean -local function isImpassable(building) - local type = building:getType() - if type == df.building_type.Construction then - return CONSTRUCTION_IMPASSABLE[building.type] - else - return BUILDING_IMPASSABLE[type] - end -end - ---- If there is a construction plan to build an unwalkable tile, return the building ----@param pos coord ----@return building? -local function plansToConstructImpassableAt(pos) - --- @type building_constructionst|building - local building = dfhack.buildings.findAtTile(pos) - if not building then return nil end - if not building.flags.exists and isImpassable(building) then - return building - end - return nil -end - ---- Check if the tile can be walked on ----@param pos coord -local function walkable(pos) - local tileblock = dfhack.maps.getTileBlock(pos) - return tileblock and tileblock.walkable[pos.x % 16][pos.y % 16] > 0 -end - ---- Check if the tile is suitable tile to stand on for construction (walkable & not a tree branch) ----@param pos coord -local function isSuitableAccess(pos) - local tt = dfhack.maps.getTileType(pos) - - if not tt then - -- no tiletype, likely out of bound - return false - end - - local attrs = df.tiletype.attrs[tt] - if attrs.shape == df.tiletype_shape.BRANCH or attrs.shape == df.tiletype_shape.TRUNK_BRANCH then - -- Branches can be walked on, but most of the time we can assume that it's not a suitable access. - return false - end - return walkable(pos) -end - ---- List neighbour coordinates of a position ----@param pos coord ----@return table -local function neighbours(pos) - return { - {x=pos.x-1, y=pos.y, z=pos.z}, - {x=pos.x+1, y=pos.y, z=pos.z}, - {x=pos.x, y=pos.y-1, z=pos.z}, - {x=pos.x, y=pos.y+1, z=pos.z}, - } -end - ---- list neighbour coordinates of pos which if is a Wall, will support a Wall at pos ----@param pos coord ----@return table -local function neighboursWallSupportsWall(pos) - return { - {x=pos.x-1, y=pos.y, z=pos.z}, -- orthogonal same level - {x=pos.x+1, y=pos.y, z=pos.z}, - {x=pos.x, y=pos.y-1, z=pos.z}, - {x=pos.x, y=pos.y+1, z=pos.z}, - {x=pos.x-1, y=pos.y, z=pos.z-1}, -- orthogonal level below - {x=pos.x+1, y=pos.y, z=pos.z-1}, - {x=pos.x, y=pos.y-1, z=pos.z-1}, - {x=pos.x, y=pos.y+1, z=pos.z-1}, - {x=pos.x-1, y=pos.y, z=pos.z+1}, -- orthogonal level above - {x=pos.x+1, y=pos.y, z=pos.z+1}, - {x=pos.x, y=pos.y-1, z=pos.z+1}, - {x=pos.x, y=pos.y+1, z=pos.z+1}, - {x=pos.x, y=pos.y, z=pos.z-1}, -- directly below - {x=pos.x, y=pos.y, z=pos.z+1}, -- directly above - } -end - ---- list neighbour coordinates of pos which if is a Floor, will support a Wall at pos ----@param pos coord ----@return table -local function neighboursFloorSupportsWall(pos) - return { - {x=pos.x-1, y=pos.y, z=pos.z}, -- orthogonal same level - {x=pos.x+1, y=pos.y, z=pos.z}, - {x=pos.x, y=pos.y-1, z=pos.z}, - {x=pos.x, y=pos.y+1, z=pos.z}, - {x=pos.x, y=pos.y, z=pos.z+1}, -- directly above - {x=pos.x-1, y=pos.y, z=pos.z+1}, --orthogonal level above - {x=pos.x+1, y=pos.y, z=pos.z+1}, - {x=pos.x, y=pos.y-1, z=pos.z+1}, - {x=pos.x, y=pos.y+1, z=pos.z+1}, - } -end - ---- list neighbour coordinates of pos which if is a Wall, will support a Floor at pos ----@param pos coord ----@return table -local function neighboursWallSupportsFloor(pos) - return { - {x=pos.x-1, y=pos.y, z=pos.z}, -- orthogonal same level - {x=pos.x+1, y=pos.y, z=pos.z}, - {x=pos.x, y=pos.y-1, z=pos.z}, - {x=pos.x, y=pos.y+1, z=pos.z}, - {x=pos.x-1, y=pos.y, z=pos.z-1}, -- orthogonal level below - {x=pos.x+1, y=pos.y, z=pos.z-1}, - {x=pos.x, y=pos.y-1, z=pos.z-1}, - {x=pos.x, y=pos.y+1, z=pos.z-1}, - {x=pos.x, y=pos.y, z=pos.z-1}, -- directly below - } -end - ---- list neighbour coordinates of pos which if is a Floor, will support a Floor at pos ----@param pos coord ----@return table -local function neighboursFloorSupportsFloor(pos) - return { - {x=pos.x-1, y=pos.y, z=pos.z}, -- orthogonal same level - {x=pos.x+1, y=pos.y, z=pos.z}, - {x=pos.x, y=pos.y-1, z=pos.z}, - {x=pos.x, y=pos.y+1, z=pos.z}, - } -end - -local function hasWalkableNeighbour(pos) - for _,n in pairs(neighbours(pos)) do - if (walkable(n)) then return true end - end - return false -end - -local function tileHasSupportWall(pos) - local tt = dfhack.maps.getTileType(pos) - if tt then - local attrs = df.tiletype.attrs[tt] - if TILETYPE_SHAPE_WALL_SUPPORT[attrs.shape] then return true end - end - return false -end - -local function tileHasSupportFloor(pos) - local tt = dfhack.maps.getTileType(pos) - if tt then - local attrs = df.tiletype.attrs[tt] - if TILETYPE_SHAPE_FLOOR_SUPPORT[attrs.shape] then return true end - end - return false -end - -local function tileHasSupportBuilding(pos) - local bld = dfhack.buildings.findAtTile(pos) - if bld then - return bld:getType() == df.building_type.Support and bld.flags.exists - end - return false -end - ---- -local function constructionIsUnsupported(job) - if job.job_type ~= df.job_type.ConstructBuilding then return false end - - local building = dfhack.job.getHolder(job) - if not building or building:getType() ~= df.building_type.Construction then return false end - - local pos = {x=building.centerx, y=building.centery,z=building.z} - - -- if no neighbour is walkable it can't be constructed now anyways, - -- this early return helps reduce "spam" - if not hasWalkableNeighbour(pos) then return false end - - -- find out what type of construction - local constr_type = building:getSubtype() - local wall_would_support = {} - local floor_would_support = {} - local supportbld_would_support = {} - - if CONSTRUCTION_FLOOR_SUPPORT[constr_type] then - wall_would_support = neighboursWallSupportsFloor(pos) - floor_would_support = neighboursFloorSupportsFloor(pos) - supportbld_would_support = {{x=pos.x, y=pos.y, z=pos.z-1}} - elseif CONSTRUCTION_WALL_SUPPORT[constr_type] then - wall_would_support = neighboursWallSupportsWall(pos) - floor_would_support = neighboursFloorSupportsWall(pos) - supportbld_would_support = {{x=pos.x, y=pos.y, z=pos.z-1}, {x=pos.x, y=pos.y, z=pos.z+1}} - else return false -- some unknown construction - don't suspend - end - - for _,n in pairs(wall_would_support) do - if tileHasSupportWall(n) then return false end - end - for _,n in pairs(floor_would_support) do - if tileHasSupportFloor(n) then return false end - end - -- check for a support building below the tile - for _,n in pairs(supportbld_would_support) do - if tileHasSupportBuilding(n) then return false end - end - return true -end - ---- Get the amount of risk a tile is to be blocked ---- -1: There is a nearby walkable area with no plan to build a wall ---- >=0: Surrounded by either unwalkable tiles, or tiles that will be constructed ---- with unwalkable buildings. The value is the number of already unwalkable tiles. ----@param pos coord -local function riskOfStuckConstructionAt(pos) - local risk = 0 - for _,neighbourPos in pairs(neighbours(pos)) do - if not walkable(neighbourPos) then - -- blocked neighbour, increase danger - risk = risk + 1 - elseif not plansToConstructImpassableAt(neighbourPos) then - -- walkable neighbour with no plan to build a wall, no danger - return -1 - end - end - return risk -end - ---- Return true if this job is at risk of blocking another one -local function riskBlocking(job) - -- Not a construction job, no risk - if job.job_type ~= df.job_type.ConstructBuilding then return false end - - local building = dfhack.job.getHolder(job) - --- Not building a blocking construction, no risk - if not building or not isImpassable(building) then return false end - - --- job.pos is sometimes off by one, get the building pos - local pos = {x=building.centerx,y=building.centery,z=building.z} - - -- The construction is on a non walkable tile, it can't get worst - if not isSuitableAccess(pos) then return false end - - --- Get self risk of being blocked - local risk = riskOfStuckConstructionAt(pos) - - for _,neighbourPos in pairs(neighbours(pos)) do - if plansToConstructImpassableAt(neighbourPos) and riskOfStuckConstructionAt(neighbourPos) > risk then - --- This neighbour job is at greater risk of getting stuck - return true - end - end - - return false -end - ---- Analyzes the given job, and if it is at a dead end, follow the "corridor" and ---- mark the jobs containing it as dead end blocking jobs -function SuspendManager:suspendDeadend(start_job) - local building = dfhack.job.getHolder(start_job) - if not building then return end - local pos = {x=building.centerx,y=building.centery,z=building.z} - - --- Support dead ends of a maximum length of 1000 - for _=0,1000 do - -- building plan on the way to the exit - ---@type building? - local exit = nil - for _,neighbourPos in pairs(neighbours(pos)) do - if not isSuitableAccess(neighbourPos) then - -- non walkable neighbour, not an exit - goto continue - end - - local impassablePlan = plansToConstructImpassableAt(neighbourPos) - if not impassablePlan then - -- walkable neighbour with no building scheduled, not in a dead end - return - end - - if self.leadsToDeadend[impassablePlan.id] then - -- already visited, not an exit - goto continue - end - - if exit then - -- more than one exit, not in a dead end - return - end - - -- the building plan is a candidate to exit - exit = impassablePlan - - ::continue:: - end - - if not exit then return end - - -- exit is the single exit point of this corridor, suspend its construction job, - -- mark the current tile of the corridor as leading to a dead-end - -- and continue the exploration from its position - for _,job in ipairs(exit.jobs) do - if job.job_type == df.job_type.ConstructBuilding then - self.suspensions[job.id] = REASON.DEADEND - end - end - self.leadsToDeadend[building.id] = true - - building = exit - pos = {x=exit.centerx,y=exit.centery,z=exit.z} - end -end - ---- Return true if the building overlaps with a tile with a designation flag ----@param building building -local function buildingOnDesignation(building) - local z = building.z - for x=building.x1,building.x2 do - for y=building.y1,building.y2 do - local flags, occupancy = dfhack.maps.getTileFlags(x,y,z) - if flags.dig ~= df.tile_dig_designation.No or - flags.smooth > 0 or - occupancy.carve_track_north or - occupancy.carve_track_east or - occupancy.carve_track_south or - occupancy.carve_track_west - then - return true - end - end - end -end - ---- Return the reason for suspending a job or nil if it should not be suspended ---- @param job job ---- @return reason? -function SuspendManager:shouldBeSuspended(job) - local reason = self.suspensions[job.id] - if reason and EXTERNAL_REASONS[reason] then - -- don't actively suspend external reasons for suspension - return nil - end - return reason -end - ---- Return the reason for keeping a job suspended or nil if it can be unsuspended ---- @param job job ---- @return reason? -function SuspendManager:shouldStaySuspended(job) - return self.suspensions[job.id] -end - ---- Return a human readable description of why suspendmanager keeps a job suspended ---- or "External interruption" if the job is not kept suspended by suspendmanager -function SuspendManager:suspensionDescription(job) - local reason = self.suspensions[job.id] - return reason and REASON_DESCRIPTION[reason] or "External interruption" -end - ---- Recompute the list of suspended jobs -function SuspendManager:refresh() - self.suspensions = {} - self.leadsToDeadend = {} - - for _,job in utils.listpairs(df.global.world.jobs.list) do - -- External reasons to suspend a job - if job.job_type == df.job_type.ConstructBuilding then - if dfhack.maps.getTileFlags(job.pos).flow_size > 1 then - self.suspensions[job.id]=REASON.UNDER_WATER - end - - if isBuildingPlanJob(job) then - self.suspensions[job.id]=REASON.BUILDINGPLAN - end - end - - - - if not self.preventBlocking then goto continue end - - -- Internal reasons to suspend a job - if riskBlocking(job) then - self.suspensions[job.id]=REASON.RISK_BLOCKING - end - - -- Check for construction jobs which may be unsupported - if constructionIsUnsupported(job) then - self.suspensions[job.id]=REASON.UNSUPPORTED - end - - -- If this job is a dead end, mark jobs leading to it as dead end - self:suspendDeadend(job) - - -- First designation protection check: tile with designation flag - if job.job_type == df.job_type.ConstructBuilding then - ---@type building - local building = dfhack.job.getHolder(job) - if building then - if buildingOnDesignation(building) then - self.suspensions[job.id]=REASON.ERASE_DESIGNATION - end - end - end - - -- Second designation protection check: designation job - if ERASABLE_DESIGNATION[job.job_type] then - local building = dfhack.buildings.findAtTile(job.pos) - if building ~= nil then - for _,building_job in ipairs(building.jobs) do - if building_job.job_type == df.job_type.ConstructBuilding then - self.suspensions[building_job.id]=REASON.ERASE_DESIGNATION - end - end - end - end - - ::continue:: - end -end - -local function run_now() - Instance:refresh() - foreach_construction_job(function(job) - if job.flags.suspend then - if not Instance:shouldStaySuspended(job) then - unsuspend(job) - end - else - if Instance:shouldBeSuspended(job) then - suspend(job) - end - end - end) -end - ---- @param job job -local function on_job_change(job) - local tick = df.global.cur_year_tick - if Instance.preventBlocking and FILTER_JOB_TYPES[job.job_type] and tick ~= Instance.lastAutoRunTick then - Instance.lastAutoRunTick = tick - -- Note: This method could be made incremental by taking in account the - -- changed job - run_now() - end -end - -local function update_triggers() - if enabled then - eventful.onJobInitiated[GLOBAL_KEY] = on_job_change - eventful.onJobCompleted[GLOBAL_KEY] = on_job_change - repeatUtil.scheduleEvery(GLOBAL_KEY, 1, "days", run_now) - else - eventful.onJobInitiated[GLOBAL_KEY] = nil - eventful.onJobCompleted[GLOBAL_KEY] = nil - repeatUtil.cancel(GLOBAL_KEY) - end -end - -dfhack.onStateChange[GLOBAL_KEY] = function(sc) - 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 - - local persisted_data = json.decode(persist.GlobalTable[GLOBAL_KEY] or '') - enabled = (persisted_data or {enabled=false})['enabled'] - Instance.preventBlocking = (persisted_data or {prevent_blocking=true})['prevent_blocking'] - update_triggers() -end - -local function main(args) - if df.global.gamemode ~= df.game_mode.DWARF or not dfhack.isMapLoaded() then - dfhack.printerr('suspendmanager needs a loaded fortress map to work') - return - end - - if dfhack_flags and dfhack_flags.enable then - args = {dfhack_flags.enable_state and 'enable' or 'disable'} - end - - local help = false - local positionals = argparse.processArgsGetopt(args, { - {"h", "help", handler=function() help = true end}, - }) - local command = positionals[1] - - if help or command == "help" then - print(dfhack.script_help()) - return - elseif command == "enable" then - run_now() - enabled = true - elseif command == "disable" then - enabled = false - elseif command == "set" then - update_setting(positionals[2], positionals[3]) - elseif command == nil then - print(string.format("suspendmanager is currently %s", (enabled and "enabled" or "disabled"))) - if Instance.preventBlocking then - print("It is configured to prevent construction jobs from blocking each others") - else - print("It is configured to unsuspend all jobs") - end - else - qerror("Unknown command " .. command) - return - end - - persist_state() - update_triggers() -end - -if not dfhack_flags.module then - main({...}) -end - --- Overlay Widgets -StatusOverlay = defclass(StatusOverlay, overlay.OverlayWidget) -StatusOverlay.ATTRS{ - default_pos={x=-39,y=16}, - default_enabled=true, - viewscreens='dwarfmode/ViewSheets/BUILDING', - frame={w=59, h=3}, - frame_style=gui.MEDIUM_FRAME, - frame_background=gui.CLEAR_PEN, -} - -function StatusOverlay:init() - self:addviews{ - widgets.Label{ - frame={t=0, l=0}, - text={ - {text=self:callback('get_status_string')} - } - }, - } -end - -function StatusOverlay:get_status_string() - local job = dfhack.gui.getSelectedJob() - if job and job.flags.suspend then - return "Suspended because: " .. Instance:suspensionDescription(job) .. "." - end - return "Not suspended." -end - -function StatusOverlay:render(dc) - local job = dfhack.gui.getSelectedJob() - if not job or job.job_type ~= df.job_type.ConstructBuilding or not isEnabled() or isBuildingPlanJob(job) then - return - end - StatusOverlay.super.render(self, dc) -end - -ToggleOverlay = defclass(ToggleOverlay, overlay.OverlayWidget) -ToggleOverlay.ATTRS{ - default_pos={x=-57,y=23}, - default_enabled=true, - viewscreens='dwarfmode/ViewSheets/BUILDING', - frame={w=40, h=1}, - frame_background=gui.CLEAR_PEN, -} - -function ToggleOverlay:init() - self:addviews{ - widgets.ToggleHotkeyLabel{ - view_id="enable_toggle", - frame={t=0, l=0, w=34}, - label="Suspendmanager is", - key="CUSTOM_CTRL_M", - options={{value=true, label="Enabled"}, - {value=false, label="Disabled"}}, - initial_option = isEnabled(), - on_change=function(val) dfhack.run_command{val and "enable" or "disable", "suspendmanager"} end - }, - } -end - -function ToggleOverlay:shouldRender() - local job = dfhack.gui.getSelectedJob() - return job and job.job_type == df.job_type.ConstructBuilding and not isBuildingPlanJob(job) -end - -function ToggleOverlay:render(dc) - if not self:shouldRender() then - return - end - -- Update the option: the "initial_option" value is not up to date since the widget - -- is not reinitialized for overlays - self.subviews.enable_toggle:setOption(isEnabled(), false) - ToggleOverlay.super.render(self, dc) -end - -function ToggleOverlay:onInput(keys) - if not self:shouldRender() then - return - end - ToggleOverlay.super.onInput(self, keys) -end - -OVERLAY_WIDGETS = { - status=StatusOverlay, - toggle=ToggleOverlay, -} diff --git a/sync-windmills.lua b/sync-windmills.lua index cc25703395..36a2d67972 100644 --- a/sync-windmills.lua +++ b/sync-windmills.lua @@ -3,7 +3,7 @@ local argparse = require('argparse') local function process_windmills(rotate_fn, timer_fn) for _, bld in ipairs(df.global.world.buildings.other.WINDMILL) do if bld.is_working ~= 0 then - bld.visual_rotated = rotate_fn() + bld.rotation = rotate_fn() bld.rotate_timer = timer_fn() end end @@ -24,8 +24,8 @@ end process_windmills( (opts.randomize or opts.timing) and - function() return math.random(1, 2) == 1 end or - function() return false end, + function() return math.random(0, 1) end or + function() return 0 end, opts.randomize and not opts.timing and function() return math.random(0, 74) end or function() return 0 end) diff --git a/teleport.lua b/teleport.lua index 4a55f6e99b..8c7f052d38 100644 --- a/teleport.lua +++ b/teleport.lua @@ -2,31 +2,8 @@ -- author Putnam -- edited by expwnent --@module = true ---[====[ -teleport -======== -Teleports a unit to given coordinates. - -.. note:: - - `gui/teleport` is an in-game UI for this script. - -Examples: - -* prints ID of unit beneath cursor:: - - teleport -showunitid - -* prints coordinates beneath cursor:: - - teleport -showpos - -* teleports unit ``1234`` to ``56,115,26`` - - teleport -unit 1234 -x 56 -y 115 -z 26 - -]====] +local guidm = require('gui.dwarfmode') function teleport(unit,pos) dfhack.units.teleport(unit, pos) @@ -53,12 +30,12 @@ if args.showunitid or args.showpos then if args.showunitid then print(dfhack.gui.getSelectedUnit(true).id) else - printall(df.global.cursor) + printall(guidm.getCursorPos()) end else local unit = tonumber(args.unit) and df.unit.find(tonumber(args.unit)) or dfhack.gui.getSelectedUnit(true) - local pos = not(not args.x or not args.y or not args.z) and {x=args.x,y=args.y,z=args.z} or {x=df.global.cursor.x,y=df.global.cursor.y,z=df.global.cursor.z} + local pos = not(not args.x or not args.y or not args.z) and {x=args.x,y=args.y,z=args.z} or guidm.getCursorPos() if not unit then qerror('A unit needs to be selected or specified. Use teleport -showunitid to get a unit\'s ID.') end - if not pos.x or pos.x==-30000 then qerror('A position needs to be highlighted or specified. Use teleport -showpos to get a position\'s exact xyz values.') end + if not pos then qerror('A position needs to be highlighted or specified. Use teleport -showpos to get a position\'s exact xyz values.') end teleport(unit,pos) end diff --git a/test/gui/journal.lua b/test/gui/journal.lua new file mode 100644 index 0000000000..7d3d101bbc --- /dev/null +++ b/test/gui/journal.lua @@ -0,0 +1,638 @@ +local gui = require('gui') +local gui_journal = reqscript('gui/journal') +local journal_context = reqscript('internal/journal/journal_context') + +config = { + target = 'gui/journal', + mode = 'fortress' +} + +local function simulate_input_keys(...) + local keys = {...} + for _,key in ipairs(keys) do + gui.simulateInput(dfhack.gui.getCurViewscreen(true), key) + end + + gui_journal.view:onRender() +end + +local function simulate_input_text(text) + local screen = dfhack.gui.getCurViewscreen(true) + + for i = 1, #text do + local charcode = string.byte(text:sub(i,i)) + local code_key = string.format('STRING_A%03d', charcode) + + gui.simulateInput(screen, { [code_key]=true }) + end + + gui_journal.view:onRender() +end + +local function simulate_mouse_click(element, x, y) + local screen = dfhack.gui.getCurViewscreen(true) + + local g_x, g_y = element.frame_body:globalXY(x, y) + df.global.gps.mouse_x = g_x + df.global.gps.mouse_y = g_y + + if not element.frame_body:inClipGlobalXY(g_x, g_y) then + print('--- Click outside provided element area, re-check the test') + return + end + + gui.simulateInput(screen, { + _MOUSE_L=true, + _MOUSE_L_DOWN=true, + }) + gui.simulateInput(screen, '_MOUSE_L_DOWN') + + gui_journal.view:onRender() +end + +local function simulate_mouse_drag(element, x_from, y_from, x_to, y_to) + local g_x_from, g_y_from = element.frame_body:globalXY(x_from, y_from) + local g_x_to, g_y_to = element.frame_body:globalXY(x_to, y_to) + + df.global.gps.mouse_x = g_x_from + df.global.gps.mouse_y = g_y_from + + gui.simulateInput(dfhack.gui.getCurViewscreen(true), { + _MOUSE_L=true, + _MOUSE_L_DOWN=true, + }) + gui.simulateInput(dfhack.gui.getCurViewscreen(true), '_MOUSE_L_DOWN') + + df.global.gps.mouse_x = g_x_to + df.global.gps.mouse_y = g_y_to + gui.simulateInput(dfhack.gui.getCurViewscreen(true), '_MOUSE_L_DOWN') + + gui_journal.view:onRender() +end + +local function arrange_empty_journal(options) + options = options or {} + + gui_journal.main({ + save_prefix=options.save_prefix or 'test:', + context_mode=options.context_mode or gui_journal.JOURNAL_CONTEXT_MODE.DUMMY, + save_layout=options.allow_layout_restore or false, + }) + + local journal = gui_journal.view + local journal_window = journal.subviews.journal_window + + if not options.allow_layout_restore then + journal_window.frame= {w = 50, h = 50} + end + + if options.w then + journal_window.frame.w = options.w + 8 + end + + if options.h then + journal_window.frame.h = options.h + 6 + end + + local text_area = journal_window.subviews.journal_editor.text_area + + text_area.enable_cursor_blink = false + + if not options.allow_layout_restore then + local toc_panel = journal_window.subviews.table_of_contents_panel + toc_panel.visible = false + toc_panel.frame.w = 25 + end + + journal:updateLayout() + journal:onRender() + + return journal, text_area, journal_window +end + +local function read_rendered_text(text_area) + local pen = nil + local text = '' + + local frame_body = text_area.frame_body + + for y=frame_body.clip_y1,frame_body.clip_y2 do + + for x=frame_body.clip_x1,frame_body.clip_x2 do + pen = dfhack.screen.readTile(x, y) + + if pen == nil or pen.ch == nil or pen.ch == 0 or pen.fg == 0 then + break + else + text = text .. string.char(pen.ch) + end + end + + text = text .. '\n' + end + + return text:gsub("\n+$", "") +end + +local function read_selected_text(text_area) + local pen = nil + local text = '' + + for y=0,text_area.frame_body.height do + local has_sel = false + + for x=0,text_area.frame_body.width do + local g_x, g_y = text_area.frame_body:globalXY(x, y) + pen = dfhack.screen.readTile(g_x, g_y) + + local pen_char = string.char(pen.ch) + if pen == nil or pen.ch == nil or pen.ch == 0 then + break + elseif pen.bg == COLOR_CYAN then + has_sel = true + text = text .. pen_char + end + end + if has_sel then + text = text .. '\n' + end + end + + return text:gsub("\n+$", "") +end + +function test.load() + local journal, text_area = arrange_empty_journal() + text_area:setText(' ') + journal:onRender() + + expect.eq('dfhack/lua/journal', dfhack.gui.getCurFocus(true)[1]) + expect.eq(read_rendered_text(text_area), '_') + + journal:dismiss() +end + +function test.restore_layout() + local journal, _ = arrange_empty_journal({allow_layout_restore=true}) + + journal.subviews.journal_window.frame = { + l = 13, + t = 13, + w = 80, + h = 23 + } + journal.subviews.table_of_contents_panel.frame.w = 37 + + journal:updateLayout() + + journal:dismiss() + + journal, _ = arrange_empty_journal({allow_layout_restore=true}) + + expect.eq(journal.subviews.journal_window.frame.l, 13) + expect.eq(journal.subviews.journal_window.frame.t, 13) + expect.eq(journal.subviews.journal_window.frame.w, 80) + expect.eq(journal.subviews.journal_window.frame.h, 23) + + journal:dismiss() +end + +function test.restore_text_between_sessions() + local journal, text_area = arrange_empty_journal({ + w=80, + context_mode=gui_journal.JOURNAL_CONTEXT_MODE.FORTRESS + }) + + simulate_input_keys('CUSTOM_CTRL_A') + simulate_input_keys('CUSTOM_DELETE') + + local text = table.concat({ + '60: Lorem ipsum dolor sit amet, consectetur adipiscing elit.', + '112: Sed consectetur, urna sit amet aliquet egestas,', + '60: Lorem ipsum dolor sit amet, consectetur adipiscing elit.', + }, '\n') + + simulate_input_text(text) + simulate_mouse_click(text_area, 10, 1) + + expect.eq(read_rendered_text(text_area), table.concat({ + '60: Lorem ipsum dolor sit amet, consectetur adipiscing elit.', + '112: Sed c_nsectetur, urna sit amet aliquet egestas,', + '60: Lorem ipsum dolor sit amet, consectetur adipiscing elit.', + }, '\n')); + + journal:dismiss() + + journal, text_area = arrange_empty_journal({ + w=80, + context_mode=gui_journal.JOURNAL_CONTEXT_MODE.FORTRESS + }) + + expect.eq(read_rendered_text(text_area), table.concat({ + '60: Lorem ipsum dolor sit amet, consectetur adipiscing elit.', + '112: Sed c_nsectetur, urna sit amet aliquet egestas,', + '60: Lorem ipsum dolor sit amet, consectetur adipiscing elit.', + }, '\n')); + + journal:dismiss() +end + +function test.generate_table_of_contents() + local journal, text_area = arrange_empty_journal({w=100, h=10}) + + local text = table.concat({ + '# Header 1', + 'Lorem ipsum dolor sit amet, consectetur adipiscing elit.', + 'Nulla ut lacus ut tortor semper consectetur.', + '# Header 2', + 'Ut eu orci non nibh hendrerit posuere.', + 'Sed euismod odio eu fringilla bibendum.', + '## Subheader 1', + 'Etiam dignissim diam nec aliquet facilisis.', + 'Integer tristique purus at tellus luctus, vel aliquet sapien sollicitudin.', + '## Subheader 2', + 'Fusce ornare est vitae urna feugiat, vel interdum quam vestibulum.', + '10: Vivamus id felis scelerisque, lobortis diam ut, mollis nisi.', + '### Subsubheader 1', + '# Header 3', + 'Donec quis lectus ac erat placerat eleifend.', + 'Aenean non orci id erat malesuada pharetra.', + 'Nunc in lectus et metus finibus venenatis.', + }, '\n') + + simulate_input_text(text) + + expect.eq(journal.subviews.table_of_contents_panel.visible, false) + + simulate_input_keys('CUSTOM_CTRL_O') + + expect.eq(journal.subviews.table_of_contents_panel.visible, true) + + local toc_items = journal.subviews.table_of_contents.choices + + expect.eq(#toc_items, 6) + + local expectChoiceToMatch = function (a, b) + expect.eq(a.line_cursor, b.line_cursor) + expect.eq(a.text, b.text) + end + + expectChoiceToMatch(toc_items[1], {line_cursor=1, text='Header 1'}) + expectChoiceToMatch(toc_items[2], {line_cursor=114, text='Header 2'}) + expectChoiceToMatch(toc_items[3], {line_cursor=204, text=' Subheader 1'}) + expectChoiceToMatch(toc_items[4], {line_cursor=338, text=' Subheader 2'}) + expectChoiceToMatch(toc_items[5], {line_cursor=485, text=' Subsubheader 1'}) + expectChoiceToMatch(toc_items[6], {line_cursor=504, text='Header 3'}) + + journal:dismiss() +end + +function test.jump_to_table_of_contents_sections() + local journal, text_area = arrange_empty_journal({ + w=100, + h=10, + allow_layout_restore=false + }) + + local text = table.concat({ + '# Header 1', + 'Lorem ipsum dolor sit amet, consectetur adipiscing elit.', + 'Nulla ut lacus ut tortor semper consectetur.', + '# Header 2', + 'Ut eu orci non nibh hendrerit posuere.', + 'Sed euismod odio eu fringilla bibendum.', + '## Subheader 1', + 'Etiam dignissim diam nec aliquet facilisis.', + 'Integer tristique purus at tellus luctus, vel aliquet sapien sollicitudin.', + '## Subheader 2', + 'Fusce ornare est vitae urna feugiat, vel interdum quam vestibulum.', + '10: Vivamus id felis scelerisque, lobortis diam ut, mollis nisi.', + '### Subsubheader 1', + '# Header 3', + 'Donec quis lectus ac erat placerat eleifend.', + 'Aenean non orci id erat malesuada pharetra.', + 'Nunc in lectus et metus finibus venenatis.', + }, '\n') + + simulate_input_text(text) + + simulate_input_keys('CUSTOM_CTRL_O') + + local toc = journal.subviews.table_of_contents + + toc:setSelected(1) + toc:submit() + + gui_journal.view:onRender() + + expect.eq(read_rendered_text(text_area), table.concat({ + '_ Header 1', + 'Lorem ipsum dolor sit amet, consectetur adipiscing elit.', + 'Nulla ut lacus ut tortor semper consectetur.', + '# Header 2', + 'Ut eu orci non nibh hendrerit posuere.', + 'Sed euismod odio eu fringilla bibendum.', + '## Subheader 1', + 'Etiam dignissim diam nec aliquet facilisis.', + 'Integer tristique purus at tellus luctus, vel aliquet sapien sollicitudin.', + '## Subheader 2', + }, '\n')) + + toc:setSelected(2) + toc:submit() + + gui_journal.view:onRender() + + expect.eq(read_rendered_text(text_area), table.concat({ + '_ Header 2', + 'Ut eu orci non nibh hendrerit posuere.', + 'Sed euismod odio eu fringilla bibendum.', + '## Subheader 1', + 'Etiam dignissim diam nec aliquet facilisis.', + 'Integer tristique purus at tellus luctus, vel aliquet sapien sollicitudin.', + '## Subheader 2', + 'Fusce ornare est vitae urna feugiat, vel interdum quam vestibulum.', + '10: Vivamus id felis scelerisque, lobortis diam ut, mollis nisi.', + '### Subsubheader 1', + }, '\n')) + + toc:setSelected(3) + toc:submit() + + gui_journal.view:onRender() + + expect.eq(read_rendered_text(text_area), table.concat({ + '_# Subheader 1', + 'Etiam dignissim diam nec aliquet facilisis.', + 'Integer tristique purus at tellus luctus, vel aliquet sapien sollicitudin.', + '## Subheader 2', + 'Fusce ornare est vitae urna feugiat, vel interdum quam vestibulum.', + '10: Vivamus id felis scelerisque, lobortis diam ut, mollis nisi.', + '### Subsubheader 1', + '# Header 3', + 'Donec quis lectus ac erat placerat eleifend.', + 'Aenean non orci id erat malesuada pharetra.', + }, '\n')) + + toc:setSelected(4) + toc:submit() + + gui_journal.view:onRender() + + expect.eq(read_rendered_text(text_area), table.concat({ + 'Etiam dignissim diam nec aliquet facilisis.', + 'Integer tristique purus at tellus luctus, vel aliquet sapien sollicitudin.', + '_# Subheader 2', + 'Fusce ornare est vitae urna feugiat, vel interdum quam vestibulum.', + '10: Vivamus id felis scelerisque, lobortis diam ut, mollis nisi.', + '### Subsubheader 1', + '# Header 3', + 'Donec quis lectus ac erat placerat eleifend.', + 'Aenean non orci id erat malesuada pharetra.', + 'Nunc in lectus et metus finibus venenatis.', + }, '\n')) + + toc:setSelected(5) + toc:submit() + + gui_journal.view:onRender() + + expect.eq(read_rendered_text(text_area), table.concat({ + 'Etiam dignissim diam nec aliquet facilisis.', + 'Integer tristique purus at tellus luctus, vel aliquet sapien sollicitudin.', + '## Subheader 2', + 'Fusce ornare est vitae urna feugiat, vel interdum quam vestibulum.', + '10: Vivamus id felis scelerisque, lobortis diam ut, mollis nisi.', + '_## Subsubheader 1', + '# Header 3', + 'Donec quis lectus ac erat placerat eleifend.', + 'Aenean non orci id erat malesuada pharetra.', + 'Nunc in lectus et metus finibus venenatis.', + }, '\n')) + + toc:setSelected(6) + toc:submit() + + gui_journal.view:onRender() + + expect.eq(read_rendered_text(text_area), table.concat({ + 'Etiam dignissim diam nec aliquet facilisis.', + 'Integer tristique purus at tellus luctus, vel aliquet sapien sollicitudin.', + '## Subheader 2', + 'Fusce ornare est vitae urna feugiat, vel interdum quam vestibulum.', + '10: Vivamus id felis scelerisque, lobortis diam ut, mollis nisi.', + '### Subsubheader 1', + '_ Header 3', + 'Donec quis lectus ac erat placerat eleifend.', + 'Aenean non orci id erat malesuada pharetra.', + 'Nunc in lectus et metus finibus venenatis.', + }, '\n')) + + journal:dismiss() +end + +function test.resize_table_of_contents_together() + local journal, text_area = arrange_empty_journal({ + w=100, + h=20, + allow_layout_restore=false + }) + + local text = table.concat({ + '# Header 1', + 'Lorem ipsum dolor sit amet, consectetur adipiscing elit.', + 'Nulla ut lacus ut tortor semper consectetur.', + }, '\n') + + simulate_input_text(text) + + expect.eq(text_area.frame_body.width, 101) + + simulate_input_keys('CUSTOM_CTRL_O') + + expect.eq(text_area.frame_body.width, 101 - 24) + + local toc_panel = journal.subviews.table_of_contents_panel + -- simulate mouse drag resize of toc panel + simulate_mouse_drag( + toc_panel, + toc_panel.frame_body.width + 1, + 1, + toc_panel.frame_body.width + 1 + 10, + 1 + ) + + expect.eq(text_area.frame_body.width, 101 - 24 - 10) + + journal:dismiss() +end + +function test.table_of_contents_selection_follows_cursor() + local journal, text_area = arrange_empty_journal({ + w=100, + h=50, + allow_layout_restore=false + }) + + local text = table.concat({ + '# Header 1', + 'Lorem ipsum dolor sit amet, consectetur adipiscing elit.', + 'Nulla ut lacus ut tortor semper consectetur.', + '# Header 2', + 'Ut eu orci non nibh hendrerit posuere.', + 'Sed euismod odio eu fringilla bibendum.', + '## Subheader 1', + 'Etiam dignissim diam nec aliquet facilisis.', + 'Integer tristique purus at tellus luctus, vel aliquet sapien sollicitudin.', + '## Subheader 2', + 'Fusce ornare est vitae urna feugiat, vel interdum quam vestibulum.', + '10: Vivamus id felis scelerisque, lobortis diam ut, mollis nisi.', + '### Subsubheader 1', + '# Header 3', + 'Donec quis lectus ac erat placerat eleifend.', + 'Aenean non orci id erat malesuada pharetra.', + 'Nunc in lectus et metus finibus venenatis.', + }, '\n') + + simulate_input_text(text) + + simulate_input_keys('CUSTOM_CTRL_O') + + local toc = journal.subviews.table_of_contents + + text_area:setCursor(1) + gui_journal.view:onRender() + + expect.eq(toc:getSelected(), 1) + + + text_area:setCursor(8) + gui_journal.view:onRender() + + expect.eq(toc:getSelected(), 1) + + + text_area:setCursor(140) + gui_journal.view:onRender() + + expect.eq(toc:getSelected(), 2) + + + text_area:setCursor(300) + gui_journal.view:onRender() + + expect.eq(toc:getSelected(), 3) + + + text_area:setCursor(646) + gui_journal.view:onRender() + + expect.eq(toc:getSelected(), 6) + + journal:dismiss() +end + +function test.table_of_contents_keyboard_navigation() + local journal, text_area = arrange_empty_journal({ + w=100, + h=50, + allow_layout_restore=false + }) + + local text = table.concat({ + '# Header 1', + 'Lorem ipsum dolor sit amet, consectetur adipiscing elit.', + 'Nulla ut lacus ut tortor semper consectetur.', + '# Header 2', + 'Ut eu orci non nibh hendrerit posuere.', + 'Sed euismod odio eu fringilla bibendum.', + '## Subheader 1', + 'Etiam dignissim diam nec aliquet facilisis.', + 'Integer tristique purus at tellus luctus, vel aliquet sapien sollicitudin.', + '## Subheader 2', + 'Fusce ornare est vitae urna feugiat, vel interdum quam vestibulum.', + '10: Vivamus id felis scelerisque, lobortis diam ut, mollis nisi.', + '### Subsubheader 1', + '# Header 3', + 'Donec quis lectus ac erat placerat eleifend.', + 'Aenean non orci id erat malesuada pharetra.', + 'Nunc in lectus et metus finibus venenatis.', + }, '\n') + + simulate_input_text(text) + + simulate_input_keys('CUSTOM_CTRL_O') + + local toc = journal.subviews.table_of_contents + + text_area:setCursor(5) + gui_journal.view:onRender() + + simulate_input_keys('A_MOVE_N_DOWN') + + expect.eq(toc:getSelected(), 1) + + simulate_input_keys('A_MOVE_N_DOWN') + + expect.eq(toc:getSelected(), 6) + + simulate_input_keys('A_MOVE_N_DOWN') + simulate_input_keys('A_MOVE_N_DOWN') + + expect.eq(toc:getSelected(), 4) + + simulate_input_keys('A_MOVE_S_DOWN') + + expect.eq(toc:getSelected(), 5) + + simulate_input_keys('A_MOVE_S_DOWN') + simulate_input_keys('A_MOVE_S_DOWN') + simulate_input_keys('A_MOVE_S_DOWN') + + expect.eq(toc:getSelected(), 2) + + + text_area:setCursor(250) + gui_journal.view:onRender() + + simulate_input_keys('A_MOVE_N_DOWN') + + expect.eq(toc:getSelected(), 3) + + journal:dismiss() +end + +function test.show_fortress_tutorials_on_first_use() + local save_prefix = 'test:' + local context = journal_context.journal_context_factory( + gui_journal.JOURNAL_CONTEXT_MODE.FORTRESS, + save_prefix + ) + -- reset saved data + context:delete_content() + + local journal, text_area, journal_window = arrange_empty_journal({ + w=125, + context_mode=gui_journal.JOURNAL_CONTEXT_MODE.FORTRESS, + save_prefix=save_prefix + }) + + simulate_input_keys('CUSTOM_CTRL_O') + + expect.str_find( + "Welcome to gui/journal, the chronicler's tool for Dwarf Fortress!", + read_rendered_text(text_area) + ); + + simulate_input_text(' ') + + expect.eq(read_rendered_text(text_area), ' _'); + + local toc_panel = journal_window.subviews.table_of_contents_panel + expect.str_find('Start a line with\n# symbols', read_rendered_text(toc_panel)); + + simulate_input_text('\n# Section 1') + + expect.str_find('Section 1\n', read_rendered_text(toc_panel)); + journal:dismiss() +end diff --git a/test/gui/notes.lua b/test/gui/notes.lua new file mode 100644 index 0000000000..815ba7bfaa --- /dev/null +++ b/test/gui/notes.lua @@ -0,0 +1,365 @@ +local gui = require('gui') +local gui_notes = reqscript('gui/notes') +local utils = require('utils') +local guidm = require('gui.dwarfmode') +local overlay = require('plugins.overlay') +local notes_textures = reqscript('notes').textures + +config = { + target = 'gui/notes', + mode = 'fortress' +} + +local waypoints = df.global.plotinfo.waypoints +local map_points = df.global.plotinfo.waypoints.points + +local map_points_backup = nil + +local function arrange_notes(notes) + map_points_backup = utils.clone(map_points) + map_points:resize(0) + + for _, note in ipairs(notes or {}) do + map_points:insert("#", { + new=true, + + id=waypoints.next_point_id, + tile=88, + fg_color=7, + bg_color=0, + name=note.name, + comment=note.comment, + pos=note.pos + }) + + waypoints.next_point_id = waypoints.next_point_id + 1 + end +end + +local function arrange_gui_notes(options) + options = options or {} + + -- running tests removes all overlays because of IN_TEST reloading. + -- rescan so we can load the gui/notes widget for these tests + overlay.rescan() + + arrange_notes(options.notes) + + gui_notes.main() + + local view = gui_notes.view + view.enable_selector_blink = false + + view:updateLayout() + view:onRender() + + return view, view.subviews.notes_window +end + +local function cleanup(gui_notes) + gui_notes:dismiss() + + df.global.plotinfo.waypoints.points:resize(#map_points_backup) + for ind, map_point in ipairs(map_points_backup) do + df.global.plotinfo.waypoints.points[ind - 1] = map_point + end + map_points_backup = nil +end + +function get_visible_map_center() + local viewport = guidm.Viewport.get() + + local half_x = math.max( + math.floor((viewport.x1 + viewport.x2) / 2), + 2 + ) + local half_y = math.max( + math.floor((viewport.y1 + viewport.y2) / 2), + 2 + ) + + return half_x, half_y, viewport.z +end + +function test.load_gui_notes() + local gui_notes = arrange_gui_notes() + expect.eq(gui_notes.visible, true) + cleanup(gui_notes) +end + +function test.provide_notes_list() + local notes = { + {name='note 1', comment='comment 1', pos={x=1, y=1, z=1}}, + {name='note 2', comment='comment 2', pos={x=2, y=2, z=2}}, + {name='note 3', comment='comment 3', pos={x=3, y=3, z=3}}, + } + + local gui_notes = arrange_gui_notes({ notes=notes }) + local note_list = gui_notes.subviews.note_list:getChoices() + + for ind, note in ipairs(notes) do + local gui_note = note_list[ind] + expect.eq(gui_note.text, note.name) + expect.eq(gui_note.point.comment, note.comment) + expect.table_eq(gui_note.point.pos, note.pos) + end + + cleanup(gui_notes) +end + +function test.auto_select_first_note() + local notes = { + {name='green note 1', comment='comment 1', pos={x=1, y=1, z=1}}, + {name='green note 2', comment='comment 2', pos={x=2, y=2, z=2}}, + {name='blue note 3', comment='comment 3', pos={x=3, y=3, z=3}}, + } + + local gui_notes = arrange_gui_notes({ notes=notes }) + expect.eq(gui_notes.subviews.name.text_to_wrap, 'green note 1') + expect.eq(gui_notes.subviews.comment.text_to_wrap, 'comment 1') + + cleanup(gui_notes) +end + +function test.select_on_arrow_up_down() + local notes = { + {name='green note 1', comment='comment 1', pos={x=1, y=1, z=1}}, + {name='green note 2', comment='comment 2', pos={x=2, y=2, z=2}}, + {name='blue note 3', comment='comment 3', pos={x=3, y=3, z=3}}, + } + + local gui_notes = arrange_gui_notes({ notes=notes }) + local screen = dfhack.gui.getCurViewscreen(true) + + gui.simulateInput(screen, 'KEYBOARD_CURSOR_DOWN') + expect.eq(gui_notes.subviews.name.text_to_wrap, 'green note 2') + expect.eq(gui_notes.subviews.comment.text_to_wrap, 'comment 2') + + gui.simulateInput(screen, 'KEYBOARD_CURSOR_DOWN') + expect.eq(gui_notes.subviews.name.text_to_wrap, 'blue note 3') + expect.eq(gui_notes.subviews.comment.text_to_wrap, 'comment 3') + + gui.simulateInput(screen, 'KEYBOARD_CURSOR_DOWN') + expect.eq(gui_notes.subviews.name.text_to_wrap, 'green note 1') + expect.eq(gui_notes.subviews.comment.text_to_wrap, 'comment 1') + + gui.simulateInput(screen, 'KEYBOARD_CURSOR_UP') + expect.eq(gui_notes.subviews.name.text_to_wrap, 'blue note 3') + expect.eq(gui_notes.subviews.comment.text_to_wrap, 'comment 3') + + gui.simulateInput(screen, 'KEYBOARD_CURSOR_UP') + expect.eq(gui_notes.subviews.name.text_to_wrap, 'green note 2') + expect.eq(gui_notes.subviews.comment.text_to_wrap, 'comment 2') + + cleanup(gui_notes) +end + +function test.center_at_submit_note() + local notes = { + {name='green note 1', comment='comment 1', pos={x=1, y=1, z=1}}, + {name='green note 2', comment='comment 2', pos={x=2, y=2, z=2}}, + {name='blue note 3', comment='comment 3', pos={x=3, y=3, z=3}}, + } + + local gui_notes = arrange_gui_notes({ notes=notes }) + local screen = dfhack.gui.getCurViewscreen(true) + + -- it would be best to check viewport, but it's not updated instantly + -- and I do not know way how to force it + -- local viewport = guidm.Viewport.get() + + local last_recenter_pos = nil + mock.patch(dfhack.gui, 'pauseRecenter', function (pos) + last_recenter_pos = pos + end, function () + gui.simulateInput(screen, 'KEYBOARD_CURSOR_DOWN') + gui.simulateInput(screen, 'SELECT') + + expect.eq(last_recenter_pos.x, 2) + expect.eq(last_recenter_pos.y, 2) + expect.eq(last_recenter_pos.z, 2) + + gui.simulateInput(screen, 'KEYBOARD_CURSOR_DOWN') + gui.simulateInput(screen, 'SELECT') + + expect.eq(last_recenter_pos.x, 3) + expect.eq(last_recenter_pos.y, 3) + expect.eq(last_recenter_pos.z, 3) + end) + + cleanup(gui_notes) +end + +function test.filter_notes() + local notes = { + {name='green note 1', comment='comment 1', pos={x=1, y=1, z=1}}, + {name='green note 2', comment='comment 2', pos={x=2, y=2, z=2}}, + {name='blue note 3', comment='comment 3', pos={x=3, y=3, z=3}}, + } + + local gui_notes = arrange_gui_notes({ notes=notes }) + gui_notes.subviews.search:setText('green') + + local note_list = gui_notes.subviews.note_list:getChoices() + expect.eq(#note_list, 2) + + for ind, note in ipairs({table.unpack(notes, 1, 2)}) do + local gui_note = note_list[ind] + expect.eq(gui_note.text, note.name) + expect.eq(gui_note.point.comment, note.comment) + expect.table_eq(gui_note.point.pos, note.pos) + end + + expect.eq(gui_notes.subviews.name.text_to_wrap, 'green note 1') + expect.eq(gui_notes.subviews.comment.text_to_wrap, 'comment 1') + + gui_notes.subviews.search:setText('blue') + + local note_list = gui_notes.subviews.note_list:getChoices() + expect.eq(#note_list, 1) + + expect.eq(note_list[1].text, notes[3].name) + expect.eq(note_list[1].point.comment, notes[3].comment) + expect.table_eq(note_list[1].point.pos, notes[3].pos) + + expect.eq(gui_notes.subviews.name.text_to_wrap, 'blue note 3') + expect.eq(gui_notes.subviews.comment.text_to_wrap, 'comment 3') + + gui_notes.subviews.search:setText('red') + + local note_list = gui_notes.subviews.note_list:getChoices() + expect.eq(#note_list, 0) + + cleanup(gui_notes) +end + +function test.edit_note() + local notes = { + {name='green note 1', comment='comment 1', pos={x=1, y=1, z=1}}, + {name='green note 2', comment='comment 2', pos={x=2, y=2, z=2}}, + {name='blue note 3', comment='comment 3', pos={x=3, y=3, z=3}}, + } + + local gui_notes, gui_notes_window = arrange_gui_notes({ notes=notes }) + local screen = dfhack.gui.getCurViewscreen(true) + + gui.simulateInput(screen, 'KEYBOARD_CURSOR_DOWN') + gui.simulateInput(screen, 'CUSTOM_CTRL_E') + + local note_manager = gui_notes_window.note_manager + expect.ne(note_manager, nil) + + expect.eq(note_manager.subviews.name:getText(), 'green note 2') + expect.eq(note_manager.subviews.comment:getText(), 'comment 2') + + note_manager.subviews.name:setText('updated green note 2') + note_manager.subviews.comment:setText('updated comment 2') + local screen = dfhack.gui.getCurViewscreen(true) + gui.simulateInput(dfhack.gui.getCurViewscreen(true), 'CUSTOM_CTRL_ENTER') + + local note_list = gui_notes.subviews.note_list:getChoices() + expect.eq(#note_list, 3) + + local updated_note = note_list[2] + + expect.eq(updated_note.text, 'updated green note 2') + expect.eq(updated_note.point.name, 'updated green note 2') + expect.eq(updated_note.point.comment, 'updated comment 2') + + cleanup(gui_notes) +end + +function test.delete_note() + local notes = { + {name='green note 1', comment='comment 1', pos={x=1, y=1, z=1}}, + {name='green note 2', comment='comment 2', pos={x=2, y=2, z=2}}, + {name='blue note 3', comment='comment 3', pos={x=3, y=3, z=3}}, + } + + local gui_notes = arrange_gui_notes({ notes=notes }) + local screen = dfhack.gui.getCurViewscreen(true) + + gui.simulateInput(screen, 'KEYBOARD_CURSOR_DOWN') + gui.simulateInput(screen, 'CUSTOM_CTRL_D') + + local note_list = gui_notes.subviews.note_list:getChoices() + expect.eq(#note_list, 2) + + for ind, note in ipairs({notes[1], notes[3]}) do + local gui_note = note_list[ind] + expect.eq(gui_note.text, note.name) + expect.eq(gui_note.point.comment, note.comment) + expect.table_eq(gui_note.point.pos, note.pos) + end + + expect.eq(gui_notes.subviews.name.text_to_wrap, 'blue note 3') + expect.eq(gui_notes.subviews.comment.text_to_wrap, 'comment 3') + + cleanup(gui_notes) +end + +function test.create_new_note() + local notes = { + {name='green note 1', comment='comment 1', pos={x=1, y=1, z=1}}, + } + + local gui_notes, gui_notes_window = arrange_gui_notes({ notes=notes }) + + local note_list = gui_notes.subviews.note_list:getChoices() + expect.eq(#note_list, 1) + + gui.simulateInput(dfhack.gui.getCurViewscreen(true), 'CUSTOM_CTRL_N') + + local viewport = guidm.Viewport.get() + + local half_x, half_y = get_visible_map_center() + + local pos = {x=half_x, y=half_y, z=viewport.z} + local screen_pos = viewport:tileToScreen(pos) + df.global.cursor = pos + + -- should not be a test function to map screen tile to mouse pos? + df.global.gps.precise_mouse_x = screen_pos.x * df.global.gps.viewport_zoom_factor / 4 + df.global.gps.precise_mouse_y = screen_pos.y * df.global.gps.viewport_zoom_factor / 4 + + df.global.gps.mouse_x = screen_pos.x + df.global.gps.mouse_y = screen_pos.y + + gui_notes:render(gui.Painter.new()) + + local pen = dfhack.screen.readTile(screen_pos.x, screen_pos.y, true) + + if dfhack.screen.inGraphicsMode() then + local pin_textpos = dfhack.textures.getTexposByHandle( + notes_textures.green_pin[1] + ) + expect.eq(pen and pen.tile, pin_textpos) + else + expect.eq(pen and pen.ch, string.byte('X')) + end + + gui.simulateInput(dfhack.gui.getCurViewscreen(true), { + _MOUSE_L=true, + _MOUSE_L_DOWN=true, + }) + + local note_manager = gui_notes_window.note_manager + + expect.ne(note_manager, nil) + expect.eq(note_manager.visible, true) + + note_manager.subviews.name:setText('note 2') + note_manager.subviews.comment:setText('new note') + + gui.simulateInput(dfhack.gui.getCurViewscreen(true), 'CUSTOM_CTRL_ENTER') + + local note_list = gui_notes.subviews.note_list:getChoices() + expect.eq(#note_list, 2) + + local gui_note = note_list[2] + expect.eq(gui_note.text, 'note 2') + expect.eq(gui_note.point.comment, 'new note') + expect.table_eq(gui_note.point.pos, pos) + + cleanup(gui_notes) +end diff --git a/test/gui/quantum.lua b/test/gui/quantum.lua index c61596c1a2..9e539ed263 100644 --- a/test/gui/quantum.lua +++ b/test/gui/quantum.lua @@ -1,111 +1,36 @@ +config.target = 'gui/quantum' + local q = reqscript('gui/quantum').unit_test_hooks local quickfort = reqscript('quickfort') -local quickfort_building = reqscript('internal/quickfort/building') -- Note: the gui_quantum quickfort ecosystem integration test exercises the -- QuantumUI functions -function test.is_in_extents() - -- create an upside-down "T" with tiles down the center and bottom - local extent_grid = { - [1]={[5]=true}, - [2]={[5]=true}, - [3]={[1]=true,[2]=true,[3]=true,[4]=true,[5]=true}, - [4]={[5]=true}, - [5]={[5]=true}, - } - local extents = quickfort_building.make_extents( - {width=5, height=5, extent_grid=extent_grid}) - dfhack.with_temp_object(extents, function() - local bld = {x1=10, x2=14, y1=20, room={extents=extents}} - expect.false_(q.is_in_extents(bld, 10, 20)) - expect.false_(q.is_in_extents(bld, 14, 23)) - expect.true_(q.is_in_extents(bld, 12, 20)) - expect.true_(q.is_in_extents(bld, 14, 24)) - end) -end - function test.is_valid_pos() + local pos, qsp_pos = {x=1, y=2, z=3}, {x=4, y=5, z=6} local all_good = {place_designated={value=1}, build_designated={value=1}} local all_bad = {place_designated={value=0}, build_designated={value=0}} local bad_place = {place_designated={value=0}, build_designated={value=1}} local bad_build = {place_designated={value=1}, build_designated={value=0}} mock.patch(quickfort, 'apply_blueprint', mock.func(all_good), function() - expect.true_(q.is_valid_pos()) end) + expect.true_(q.is_valid_pos(pos, qsp_pos)) end) mock.patch(quickfort, 'apply_blueprint', mock.func(all_bad), function() - expect.false_(q.is_valid_pos()) end) + expect.false_(q.is_valid_pos(pos, qsp_pos)) end) mock.patch(quickfort, 'apply_blueprint', mock.func(bad_place), function() - expect.false_(q.is_valid_pos()) end) + expect.false_(q.is_valid_pos(pos, qsp_pos)) end) mock.patch(quickfort, 'apply_blueprint', mock.func(bad_build), function() - expect.false_(q.is_valid_pos()) end) -end - -function test.get_feeder_pos() - local tiles = {[20]={[30]={[40]=true}}} - expect.table_eq({x=40, y=30, z=20}, q.get_feeder_pos(tiles)) -end - -function test.get_moves() - local move_prefix, move_back_prefix = 'mp', 'mbp' - local increase_token, decrease_token = '+', '-' - - local start_pos, end_pos = 10, 15 - expect.table_eq({'mp{+ 5}', 'mbp{- 5}'}, - {q.get_moves(move_prefix, move_back_prefix, start_pos, end_pos, - increase_token, decrease_token)}) - - start_pos, end_pos = 15, 10 - expect.table_eq({'mp{- 5}', 'mbp{+ 5}'}, - {q.get_moves(move_prefix, move_back_prefix, start_pos, end_pos, - increase_token, decrease_token)}) - - start_pos, end_pos = 10, 10 - expect.table_eq({'mp', 'mbp'}, - {q.get_moves(move_prefix, move_back_prefix, start_pos, end_pos, - increase_token, decrease_token)}) - - start_pos, end_pos = 1, -1 - expect.table_eq({'mp{- 2}', 'mbp{+ 2}'}, - {q.get_moves(move_prefix, move_back_prefix, start_pos, end_pos, - increase_token, decrease_token)}) -end - -function test.get_quantumstop_data() - local dump_pos = {x=40, y=30, z=20} - local feeder_pos = {x=41, y=32, z=23} - local name = '' - expect.eq('{quantumstop move="{< 3}{Down 2}{Right 1}" move_back="{> 3}{Up 2}{Left 1}"}', - q.get_quantumstop_data(dump_pos, feeder_pos, name)) - - name = 'foo' - expect.eq('{quantumstop name="foo quantum" move="{< 3}{Down 2}{Right 1}" move_back="{> 3}{Up 2}{Left 1}"}{givename name="foo dumper"}', - q.get_quantumstop_data(dump_pos, feeder_pos, name)) - - dump_pos = {x=40, y=32, z=20} - feeder_pos = {x=40, y=30, z=20} - name = '' - expect.eq('{quantumstop move="{Up 2}" move_back="{Down 2}"}', - q.get_quantumstop_data(dump_pos, feeder_pos, name)) -end - -function test.get_quantum_data() - expect.eq('{quantum}', q.get_quantum_data('')) - expect.eq('{quantum name="foo"}', q.get_quantum_data('foo')) + expect.false_(q.is_valid_pos(pos, qsp_pos)) end) end +--local function create_quantum(pos, qsp_pos, feeder_id, name, trackstop_dir) function test.create_quantum() local pos, qsp_pos = {x=1, y=2, z=3}, {x=4, y=5, z=6} - local feeder_tiles = {[0]={[0]={[0]=true}}} - local all_good = {place_designated={value=1}, build_designated={value=1}, - query_skipped_tiles={value=0}} - local bad_place = {place_designated={value=0}, build_designated={value=1}, - query_skipped_tiles={value=0}} - local bad_build = {place_designated={value=1}, build_designated={value=0}, - query_skipped_tiles={value=0}} - local bad_query = {place_designated={value=1}, build_designated={value=1}, - query_skipped_tiles={value=1}} + local feeder_id = 1900 + local all_good = {place_designated={value=1}, build_designated={value=1}} + local bad_place = {place_designated={value=0}, build_designated={value=1}} + local bad_build = {place_designated={value=1}, build_designated={value=0}} local function mock_apply_blueprint(ret_for_pos, ret_for_qsp_pos) return function(args) @@ -116,35 +41,21 @@ function test.create_quantum() mock.patch(quickfort, 'apply_blueprint', mock_apply_blueprint(all_good, all_good), function() - q.create_quantum(pos, qsp_pos, feeder_tiles, '', 'N') + q.create_quantum(pos, qsp_pos, {{id=feeder_id}}, '', 'N') -- passes if no error is thrown end) mock.patch(quickfort, 'apply_blueprint', mock_apply_blueprint(all_good, bad_place), function() - expect.error_match('failed to place quantum stockpile', function() - q.create_quantum(pos, qsp_pos, feeder_tiles, '', 'N') + expect.error_match('failed to place stockpile', function() + q.create_quantum(pos, qsp_pos, {{id=feeder_id}}, '', 'N') end) end) mock.patch(quickfort, 'apply_blueprint', mock_apply_blueprint(bad_build, all_good), function() expect.error_match('failed to build trackstop', function() - q.create_quantum(pos, qsp_pos, feeder_tiles, '', 'N') - end) - end) - - mock.patch(quickfort, 'apply_blueprint', - mock_apply_blueprint(bad_query, all_good), function() - expect.error_match('failed to query trackstop', function() - q.create_quantum(pos, qsp_pos, feeder_tiles, '', 'N') - end) - end) - - mock.patch(quickfort, 'apply_blueprint', - mock_apply_blueprint(all_good, bad_query), function() - expect.error_match('failed to query quantum stockpile', function() - q.create_quantum(pos, qsp_pos, feeder_tiles, '', 'N') + q.create_quantum(pos, qsp_pos, {{id=feeder_id}}, '', 'N') end) end) end diff --git a/test/gui/workorder-details.lua b/test/gui/workorder-details.lua index 3984dc87ef..a04ed4eec9 100644 --- a/test/gui/workorder-details.lua +++ b/test/gui/workorder-details.lua @@ -15,7 +15,8 @@ local wait = function(n) --delay(n or 30) -- enable for debugging the tests end --- handle confirm plugin: we may need to additionally confirm order removal +-- handle confirm: we may need to additionally confirm order removal +--[[ local confirm = require 'plugins.confirm' local confirmRemove = function() end if confirm.isEnabled() then @@ -34,6 +35,7 @@ if confirm.isEnabled() then end end end +]] function test.changeOrderDetails() --[[ this is not needed because of how gui.simulateInput'D_JOBLIST' works @@ -46,7 +48,7 @@ function test.changeOrderDetails() send_keys('D_JOBLIST', 'UNITJOB_MANAGER') expect.true_(df.viewscreen_jobmanagementst:is_instance(dfhack.gui.getCurViewscreen(true)), "We need to be in the jobmanagement/Main screen") - local ordercount = #df.global.world.manager_orders + local ordercount = #df.global.world.manager_orders.all --- create an order dfhack.run_command [[workorder "{ \"frequency\" : \"OneTime\", \"job\" : \"CutGems\", \"material\" : \"INORGANIC:SLADE\" }"]] @@ -55,7 +57,7 @@ function test.changeOrderDetails() wait() send_keys('MANAGER_DETAILS') expect.true_(df.viewscreen_workquota_detailsst:is_instance(dfhack.gui.getCurViewscreen(true)), "We need to be in the workquota_details screen") - expect.eq(ordercount + 1, #df.global.world.manager_orders, "Test order should have been added") + expect.eq(ordercount + 1, #df.global.world.manager_orders.all, "Test order should have been added") local job = dfhack.gui.getCurViewscreen(true).order local item = job.items[0] @@ -88,7 +90,7 @@ function test.changeOrderDetails() wait() send_keys('LEAVESCREEN', 'LEAVESCREEN', 'MANAGER_REMOVE') confirmRemove() - expect.eq(ordercount, #df.global.world.manager_orders, "Test order should've been removed") + expect.eq(ordercount, #df.global.world.manager_orders.all, "Test order should've been removed") -- go back to map screen wait() send_keys('LEAVESCREEN', 'LEAVESCREEN') @@ -107,11 +109,11 @@ function test.unsetAllItemTraits() send_keys('D_JOBLIST', 'UNITJOB_MANAGER') expect.true_(df.viewscreen_jobmanagementst:is_instance(dfhack.gui.getCurViewscreen(true)), "We need to be in the jobmanagement/Main screen") - local ordercount = #df.global.world.manager_orders + local ordercount = #df.global.world.manager_orders.all --- create an order dfhack.run_command [[workorder "{ \"frequency\" : \"OneTime\", \"job\" : \"CutGems\", \"material\" : \"INORGANIC:SLADE\" }"]] - expect.eq(ordercount + 1, #df.global.world.manager_orders, "Test order should have been added") + expect.eq(ordercount + 1, #df.global.world.manager_orders.all, "Test order should have been added") wait() send_keys('STANDARDSCROLL_UP') -- move cursor to newly created CUT SLADE wait() @@ -150,7 +152,7 @@ function test.unsetAllItemTraits() wait() send_keys('LEAVESCREEN', 'LEAVESCREEN', 'MANAGER_REMOVE') confirmRemove() - expect.eq(ordercount, #df.global.world.manager_orders, "Test order should've been removed") + expect.eq(ordercount, #df.global.world.manager_orders.all, "Test order should've been removed") -- go back to map screen wait() send_keys('LEAVESCREEN', 'LEAVESCREEN') diff --git a/test/overlay/notes.lua b/test/overlay/notes.lua new file mode 100644 index 0000000000..401e02af94 --- /dev/null +++ b/test/overlay/notes.lua @@ -0,0 +1,245 @@ +local gui = require('gui') +local overlay = require('plugins.overlay') +local guidm = require('gui.dwarfmode') +local utils = require('utils') +local notes_textures = reqscript('notes').textures + +local waypoints = df.global.plotinfo.waypoints +local map_points = df.global.plotinfo.waypoints.points + +config = { + target = 'notes', + mode = 'fortress' +} + +local map_points_backup = nil +local was_overlay_enabled = overlay.isEnabled() + +local function install_notes_overlay(options) + options = options or {} + + map_points_backup = utils.clone(map_points) + map_points:resize(0) + + local was_overlay_enabled = overlay.isEnabled() + + overlay.setEnabled(true) + overlay.rescan() + overlay.overlay_command({'enable', 'notes.map_notes'}) + + if not overlay.isOverlayEnabled('notes.map_notes') then + qerror('can not enable notes.map_notes overlay') + end + + local overlay_state = overlay.get_state() + + return overlay_state.db['notes.map_notes'].widget +end + +local function reload_notes() + overlay.overlay_command({'trigger', 'notes.map_notes'}) +end + +local function cleanup(notes_overlay) + if notes_overlay.note_manager then + notes_overlay.note_manager:dismiss() + end + + df.global.plotinfo.waypoints.points:resize(#map_points_backup) + for ind, map_point in ipairs(map_points_backup) do + df.global.plotinfo.waypoints.points[ind - 1] = map_point + end + map_points_backup = nil + + reload_notes() + + overlay.setEnabled(was_overlay_enabled) +end + +local function add_note(notes_overlay, pos, name, comment) + df.global.cursor = copyall(pos) + + local cmd_result = overlay.overlay_command({ + 'trigger', 'notes.map_notes', 'add' + }) + + notes_overlay.note_manager.subviews.name:setText(name) + notes_overlay.note_manager.subviews.comment:setText(comment) + + gui.simulateInput(dfhack.gui.getCurViewscreen(true), 'CUSTOM_CTRL_ENTER') +end + +function assert_note_pen(pen) + if dfhack.screen.inGraphicsMode() then + local pin_textpos = dfhack.textures.getTexposByHandle( + notes_textures.green_pin[1] + ) + expect.eq(pen and pen.tile, pin_textpos) + else + expect.eq(pen and pen.ch, string.byte('N')) + end +end + +function set_mouse_screen_pos(screen_pos) + -- should not be a test function to map screen tile to mouse pos? + df.global.gps.precise_mouse_x = screen_pos.x * df.global.gps.viewport_zoom_factor / 4 + df.global.gps.precise_mouse_y = screen_pos.y * df.global.gps.viewport_zoom_factor / 4 + + df.global.gps.mouse_x = screen_pos.x + df.global.gps.mouse_y = screen_pos.y + +end + +function get_visible_map_center() + local viewport = guidm.Viewport.get() + + local map_width, map_height = dfhack.maps.getTileSize() + local world_rect = gui.mkdims_wh(0, 0, map_width, map_height) + -- find center of visible part of the map + local map_rect = gui.ViewRect{rect=world_rect}:viewport(viewport) + + local half_x = math.floor((map_rect.clip_x1 + map_rect.clip_x2) / 2) + local normalized_half_x = math.min( + math.max(half_x, map_rect.clip_x1), + map_rect.clip_x2 + ) + + local half_y = math.floor((map_rect.clip_y1 + map_rect.clip_y2) / 2) + local normalized_half_y = math.min( + math.max(half_y, map_rect.clip_y1), + map_rect.clip_y2 + ) + + return normalized_half_x, normalized_half_y, viewport.z +end + +function test.load_notes_overlay() + local notes_overlay = install_notes_overlay() + expect.ne(notes_overlay, nil) + cleanup(notes_overlay) +end + +function test.trigger_add_new_note_modal() + local notes_overlay = install_notes_overlay() + + local cmd_result = overlay.overlay_command({ + 'trigger', 'notes.map_notes', 'add' + }) + + expect.eq(cmd_result, true) + expect.ne(notes_overlay.note_manager, nil) + expect.eq(notes_overlay.note_manager.visible, true) + + cleanup(notes_overlay) +end + +function test.render_existing_notes() + local notes_overlay = install_notes_overlay() + + local viewport = guidm.Viewport.get() + + local half_x, half_y = get_visible_map_center() + + local pos_1 = {x=half_x, y=half_y, z=viewport.z} + local pos_2 = {x=half_x - 2, y=half_y + 2, z=viewport.z} + local pos_3 = {x=half_x + 2, y=half_y + 2, z=viewport.z} + + add_note(notes_overlay, pos_1, 'note 1', 'first note') + add_note(notes_overlay, pos_2, 'note 2', 'second note') + add_note(notes_overlay, pos_3, 'note 3', 'last note') + + reload_notes() + + for _, pos in ipairs({pos_1, pos_2, pos_3}) do + notes_overlay:render(gui.Painter.new()) + + local screen_pos = viewport:tileToScreen(pos) + local pen = dfhack.screen.readTile(screen_pos.x, screen_pos.y, true) + assert_note_pen(pen) + end + + cleanup(notes_overlay) +end + +function test.edit_clicked_note() + local notes_overlay = install_notes_overlay() + + local viewport = guidm.Viewport.get() + + local half_x, half_y, z = get_visible_map_center() + + local pos_1 = {x=half_x, y=half_y, z=viewport.z} + local pos_2 = {x=half_x - 2, y=half_y + 2, z=viewport.z} + local pos_3 = {x=half_x + 2, y=half_y + 2, z=viewport.z} + + add_note(notes_overlay, pos_1, 'note 1', 'note to edit') + add_note(notes_overlay, pos_2, 'note 2', 'other note') + add_note(notes_overlay, pos_3, 'note 3', 'another note') + + reload_notes() + + local screen_pos = viewport:tileToScreen(pos_1) + + set_mouse_screen_pos(screen_pos) + + gui.simulateInput(dfhack.gui.getCurViewscreen(true), { + _MOUSE_L=true, + _MOUSE_L_DOWN=true, + }) + + local note_manager = notes_overlay.note_manager + expect.ne(note_manager, nil) + expect.eq(note_manager:isDismissed(), false) + + expect.eq(note_manager.subviews.name:getText(), 'note 1') + expect.eq(note_manager.subviews.comment:getText(), 'note to edit') + + note_manager.subviews.name:setText('edited note 1') + note_manager.subviews.comment:setText('edited comment') + + gui.simulateInput(dfhack.gui.getCurViewscreen(true), 'CUSTOM_CTRL_ENTER') + + expect.eq(map_points[0].name, 'edited note 1') + expect.eq(map_points[0].comment, 'edited comment') + + cleanup(notes_overlay) +end + +function test.delete_clicked_note() + local notes_overlay = install_notes_overlay() + + local viewport = guidm.Viewport.get() + + local half_x, half_y = get_visible_map_center() + + local pos_1 = {x=half_x, y=half_y, z=viewport.z} + local pos_2 = {x=half_x - 2, y=half_y + 2, z=viewport.z} + local pos_3 = {x=half_x + 2, y=half_y + 2, z=viewport.z} + + add_note(notes_overlay, pos_1, 'note 1', 'note to edit') + add_note(notes_overlay, pos_2, 'note 2', 'other note') + add_note(notes_overlay, pos_3, 'note 3', 'another note') + + reload_notes() + + local screen_pos = viewport:tileToScreen(pos_2) + + local rect = gui.ViewRect{rect=notes_overlay.frame_rect} + + set_mouse_screen_pos(screen_pos) + + gui.simulateInput(dfhack.gui.getCurViewscreen(true), { + _MOUSE_L=true, + _MOUSE_L_DOWN=true, + }) + + expect.eq(#map_points, 3) + + gui.simulateInput(dfhack.gui.getCurViewscreen(true), 'CUSTOM_CTRL_D') + + expect.eq(#map_points, 2) + expect.eq(map_points[0].name, 'note 1') + expect.eq(map_points[1].name, 'note 3') + + cleanup(notes_overlay) +end diff --git a/test/prioritize.lua b/test/prioritize.lua index a7f47bc8fd..ded0f4ab55 100644 --- a/test/prioritize.lua +++ b/test/prioritize.lua @@ -1,5 +1,4 @@ local eventful = require('plugins.eventful') -local persist = require('persist-table') local prioritize = reqscript('prioritize') local utils = require('utils') local p = prioritize.unit_test_hooks @@ -18,7 +17,6 @@ local function get_mock_reactions() return mock_reactions end local function test_wrapper(test_fn) mock.patch({{eventful, 'onUnload', mock_eventful_onUnload}, {eventful, 'onJobInitiated', mock_eventful_onJobInitiated}, - {persist, 'GlobalTable', {}}, {prioritize, 'print', mock_print}, {prioritize, 'get_watched_job_matchers', get_mock_watched_job_matchers}, diff --git a/timestream.lua b/timestream.lua deleted file mode 100644 index d483b06cf7..0000000000 --- a/timestream.lua +++ /dev/null @@ -1,420 +0,0 @@ --- speeds up the calendar, units, or both --- based on https://gist.github.com/IndigoFenix/cf358b8c994caa0f93d5 - ---[====[ - -timestream -========== -Controls the speed of the calendar and creatures. Fortress mode only. Experimental. - -The script is also capable of dynamically speeding up the game based on your current FPS to mitigate the effects of FPS death. See examples below to see how. - -Usage:: - - timestream [-rate R] [-fps FPS] [-units [FLAG]] [-debug] - -Examples: - -- ``timestream -rate 2``: - Calendar runs at x2 normal speed, units run at normal speed -- ``timestream -fps 100``: - Calendar runs at dynamic speed to simulate 100 FPS, units normal -- ``timestream -fps 100 -units``: - Calendar & units are simulated at 100 FPS -- ``timestream -rate 1``: - Resets everything back to normal, regardless of other arguments -- ``timestream -rate 1 -fps 50 -units``: - Same as above -- ``timestream -fps 100 -units 2``: - Activates a different mode for speeding up units, using the native DF - ``debug_turbospeed`` flag (similar to `fastdwarf` 2) instead of adjusting - timers of all units. This results in rubberbanding unit motion, so it is not - recommended over the default method. - -Original timestream.lua: https://gist.github.com/IndigoFenix/cf358b8c994caa0f93d5 -]====] - - -local MINIMAL_FPS = 10 -- This ensures you won't get crazy values on pausing/saving, or other artefacts on extremely low FPS. -local DEFAULT_MAX_FPS = 100 - - ---- DO NOT CHANGE BELOW UNLESS YOU KNOW WHAT YOU'RE DOING --- - -local utils = require("utils") -args = utils.processArgs({...}, utils.invert({ - 'rate', - 'fps', - 'units', - 'debug', -})) -local rate = tonumber(args.rate) or -1 -local desired_fps = tonumber(args.fps) -local simulating_units = tonumber(args.units) -if args.units == '' then - simulating_units = 1 -end -local debug_mode = not not args.debug - -local current_fps = desired_fps -local prev_tick = 0 -local ticks_left = 0 -local simulating_desired_fps = false -local prev_frames = df.global.world.frame_counter -local last_frame = df.global.world.frame_counter -local prev_time = df.global.enabler.clock -local ui_main = df.global.plotinfo.main -local saved_game_frame = -1 -local frames_until_speeding = 0 -local speedy_frame_delta = desired_fps or DEFAULT_MAX_FPS - -local SEASON_LEN = 3360 -local YEAR_LEN = 403200 - -if not dfhack.world.isFortressMode() then - print("timestream: Will start when fortress mode is loaded.") -end - -if rate == nil then - rate = 1 -elseif rate < 0 then - rate = 0 -end - -simulating_desired_fps = true -if desired_fps == nil then - desired_fps = DEFAULT_MAX_FPS - if simulating_units ~= 1 and simulating_units ~= 2 then - simulating_desired_fps = false - end -elseif desired_fps < MINIMAL_FPS then - desired_fps = MINIMAL_FPS -end -current_fps = desired_fps - -eventNow = false -seasonNow = false -timestream = 0 -counter = 0 -if df.global.cur_season_tick < SEASON_LEN then - month = 1 -elseif df.global.cur_season_tick < SEASON_LEN * 2 then - month = 2 -else - month = 3 -end - -dfhack.onStateChange.loadTimestream = function(code) - if code==SC_MAP_LOADED then - if rate ~= 1 then - last_frame = df.global.world.frame_counter - --if rate > 0 then -- Won't behave well with unit simulation - if rate > 1 and not simulating_desired_fps then - print('timestream: Time running at x'..rate..".") - else - print('timestream: Time running dynamically to simulate '..desired_fps..' FPS.') - if rate ~= 0 then - print('timestream: Rate setting ignored.') - end - reset_frame_count() - rate = 1 - if simulating_units == 1 or simulating_units == 2 then - print("timestream: Unit simulation is on.") - if simulating_units ~= 2 then - df.global.debug_turbospeed = false - end - end - end - ticks_left = rate - 1 - - eventNow = false - seasonNow = false - timestream = 0 - if df.global.cur_season_tick < SEASON_LEN then - month = 1 - elseif df.global.cur_season_tick < SEASON_LEN * 2 then - month = 2 - else - month = 3 - end - if loaded ~= true then - dfhack.timeout(1,"frames",function() update() end) - loaded = true - end - else - print('timestream: Time set to normal speed.') - loaded = false - df.global.debug_turbospeed = false - end - if debug_mode then - print("timestream: Debug mode is on.") - end - end -end - -function update() - loaded = false - prev_tick = df.global.cur_year_tick - local current_frame = df.global.world.frame_counter - if (rate ~= 1 or simulating_desired_fps) and dfhack.world.isFortressMode() then - if last_frame + 1 == current_frame then - timestream = 0 - - --[[if rate < 1 then - if df.global.cur_year_tick - math.floor(df.global.cur_year_tick/10)*10 == 5 then - if counter > 1 then - counter = counter - 1 - timestream = -1 - else - counter = counter + math.floor(ticks_left) - end - end - else - --]] - --counter = counter + rate-1 - counter = counter + math.floor(ticks_left) - while counter >= 10 do - counter = counter - 10 - timestream = timestream + 1 - end - --end - eventFound = false - for i=0,#df.global.timed_events-1,1 do - event=df.global.timed_events[i] - if event.season == df.global.cur_season and event.season_ticks <= df.global.cur_season_tick then - if eventNow == false then - --df.global.cur_season_tick=event.season_ticks - event.season_ticks = df.global.cur_season_tick - eventNow = true - end - eventFound = true - end - end - if eventFound == false then eventNow = false end - - if df.global.cur_season_tick >= SEASON_LEN - 1 and df.global.cur_season_tick < SEASON_LEN * 2 - 1 and month == 1 then - seasonNow = true - month = 2 - if df.global.cur_season_tick > SEASON_LEN - 1 then - df.global.cur_season_tick = SEASON_LEN - end - elseif df.global.cur_season_tick >= SEASON_LEN * 2 - 1 and df.global.cur_season_tick < SEASON_LEN * 3 - 1 and month == 2 then - seasonNow = true - month = 3 - if df.global.cur_season_tick > SEASON_LEN * 2 - 1 then - df.global.cur_season_tick = SEASON_LEN * 2 - end - elseif df.global.cur_season_tick >= SEASON_LEN * 3 - 1 then - seasonNow = true - month = 1 - if df.global.cur_season_tick > SEASON_LEN * 3 then - df.global.cur_season_tick = SEASON_LEN * 3 - 1 - end - else - seasonNow = false - end - - if df.global.cur_year > 0 then - if timestream ~= 0 then - if df.global.cur_season_tick < 0 then - df.global.cur_season_tick = df.global.cur_season_tick + SEASON_LEN * 3 - df.global.cur_season = df.global.cur_season-1 - eventNow = true - end - if df.global.cur_season < 0 then - df.global.cur_season = df.global.cur_season + 4 - df.global.cur_year_tick = df.global.cur_year_tick + YEAR_LEN - df.global.cur_year = df.global.cur_year - 1 - eventNow = true - end - if (eventNow == false and seasonNow == false) or timestream < 0 then - if timestream > 0 then - df.global.cur_season_tick=df.global.cur_season_tick + timestream - remainder = df.global.cur_year_tick - math.floor(df.global.cur_year_tick/10)*10 - df.global.cur_year_tick=(df.global.cur_season_tick*10)+((df.global.cur_season)*(SEASON_LEN * 3 * 10)) + remainder - elseif timestream < 0 then - df.global.cur_season_tick=df.global.cur_season_tick - df.global.cur_year_tick=(df.global.cur_season_tick*10)+((df.global.cur_season)*(SEASON_LEN * 3 * 10)) - end - end - end - end - - if simulating_desired_fps then - if saved_game_frame ~= -1 and saved_game_frame + 2 == current_frame then - if debug_mode then - print("Game was saved two ticks ago (saved_game_frame(".. saved_game_frame .. ") + 2 == current_frame(" .. current_frame ..")") - end - reset_frame_count() - saved_game = -1 - end - local counted_frames = current_frame - prev_frames - if counted_frames >= desired_fps then - current_fps = 1000 * desired_fps / (df.global.enabler.clock - prev_time) - if current_fps < desired_fps then - rate = desired_fps/current_fps - else - rate = 1 -- We don't want to slow down the game - end - reset_frame_count() - if current_fps < MINIMAL_FPS then - current_fps = MINIMAL_FPS - end - local missing_frames = desired_fps - current_fps - speedy_frame_delta = desired_fps/missing_frames - if missing_frames == 0 then - speedy_frame_delta = desired_fps - end - if debug_mode then - print("prev_frames: " .. prev_frames .. ", current_fps: ".. current_fps.. ", rate: " .. rate) - end - end - - if simulating_units == 2 then - if frames_until_speeding <= 0 then - frames_until_speeding = frames_until_speeding + speedy_frame_delta - if debug_mode then - print("speedy_frame_delta: "..speedy_frame_delta..", speedy_frame: "..counted_frames.."/"..desired_fps) - end - df.global.debug_turbospeed = true - last_frame_sped_up = current_frame - else - frames_until_speeding = frames_until_speeding - 1 - if df.global.debug_turbospeed then - df.global.debug_turbospeed = false - end - end - elseif simulating_units == 1 then - local dec = math.floor(ticks_left) - 1 -- A value used to determine how much more to decrement from the timers per tick. - for k1, unit in pairs(df.global.world.units.active) do - if dfhack.units.isActive(unit) then - if unit.sex == 0 then -- Check to see if unit is female. - local ptimer = unit.pregnancy_timer - if ptimer > 0 then - ptimer = ptimer - dec - if ptimer < 1 then - ptimer = 1 - end - unit.pregnancy_timer = ptimer - end - end - for k2, action in pairs(unit.actions) do - local action_type = action.type - if action_type == df.unit_action_type.Move then - local d = action.data.move.timer - dec - if d < 1 then - d = 1 - end - action.data.move.timer = d - - elseif action_type == df.unit_action_type.Attack then - local d = action.data.attack.timer1 - dec - if d <= 1 then - d = 1 - action.data.attack.timer2 = 1 -- I don't know why, but if I don't add this line then there's a bug where people just dogpile each other and don't fight. - end - d = action.data.attack.timer2 - dec - if d < 1 then - d = 1 - end - action.data.attack.timer2 = d - elseif action_type == df.unit_action_type.HoldTerrain then - local d = action.data.holdterrain.timer - dec - if d < 1 then - d = 1 - end - action.data.holdterrain.timer = d - elseif action_type == df.unit_action_type.Climb then - local d = action.data.climb.timer - dec - if d < 1 then - d = 1 - end - action.data.climb.timer = d - elseif action_type == df.unit_action_type.Job then - local d = action.data.job.timer - dec - if d < 1 then - d = 1 - end - action.data.job.timer = d - elseif action_type == df.unit_action_type.Talk then - local d = action.data.talk.timer - dec - if d < 1 then - d = 1 - end - action.data.talk.timer = d - elseif action_type == df.unit_action_type.Unsteady then - local d = action.data.unsteady.timer - dec - if d < 1 then - d = 1 - end - action.data.unsteady.timer = d - elseif action_type == df.unit_action_type.StandUp then - local d = action.data.standup.timer - dec - if d < 1 then - d = 1 - end - action.data.standup.timer = d - elseif action_type == df.unit_action_type.LieDown then - local d = action.data.liedown.timer - dec - if d < 1 then - d = 1 - end - action.data.liedown.timer = d - elseif action_type == df.unit_action_type.Job2 then - local d = action.data.job2.timer - dec - if d < 1 then - d = 1 - end - action.data.job2.timer = d - elseif action_type == df.unit_action_type.PushObject then - local d = action.data.pushobject.timer - dec - if d < 1 then - d = 1 - end - action.data.pushobject.timer = d - elseif action_type == df.unit_action_type.SuckBlood then - local d = action.data.suckblood.timer - dec - if d < 1 then - d = 1 - end - action.data.suckblood.timer = d - end - end - end - end - end - end - ticks_left = ticks_left - math.floor(ticks_left) + rate - last_frame = current_frame - else - if debug_mode then - print("last_frame("..last_frame..") + 1 != current_frame("..current_frame..")") - end - reset_frame_count() - end - if ui_main.autosave_request then - if debug_mode then - print("Save state detected") - end - saved_game_frame = current_frame - end - if not loaded then - loaded = true - dfhack.timeout(1,"frames",function() update() end) - end - end -end - -function reset_frame_count() - if debug_mode then - print("Resetting frame count") - end - prev_time = df.global.enabler.clock - prev_frames = df.global.world.frame_counter -end - ---Initial call - -if dfhack.isMapLoaded() then - dfhack.onStateChange.loadTimestream(SC_MAP_LOADED) -end diff --git a/toggle-kbd-cursor.lua b/toggle-kbd-cursor.lua index ecc72bba68..3033049dbe 100644 --- a/toggle-kbd-cursor.lua +++ b/toggle-kbd-cursor.lua @@ -1,13 +1,19 @@ +local gui = require('gui') local guidm = require('gui.dwarfmode') -local flags4 = df.global.d_init.flags4 - -if flags4.KEYBOARD_CURSOR then - flags4.KEYBOARD_CURSOR = false - guidm.setCursorPos(xyz2pos(-30000, -30000, -30000)) - print('Keyboard cursor disabled.') +if dfhack.world.isAdventureMode() then + local open = df.global.game.main_interface.adventure.look.open + gui.simulateInput(dfhack.gui.getDFViewscreen(), open and 'LEAVESCREEN' or 'A_LOOK') + print('Look mode '..(open and 'disabled.' or 'enabled.')) else - guidm.setCursorPos(guidm.Viewport.get():getCenter()) - flags4.KEYBOARD_CURSOR = true - print('Keyboard cursor enabled.') + local flags = df.global.d_init.feature.flags + if flags.KEYBOARD_CURSOR then + flags.KEYBOARD_CURSOR = false + guidm.clearCursorPos() + print('Keyboard cursor disabled.') + else + guidm.setCursorPos(guidm.Viewport.get():getCenter()) + flags.KEYBOARD_CURSOR = true + print('Keyboard cursor enabled.') + end end diff --git a/trackstop.lua b/trackstop.lua index 6657209b08..46ba6cdb2c 100644 --- a/trackstop.lua +++ b/trackstop.lua @@ -1,4 +1,4 @@ --- Overlay to allow changing track stop friction and dump direction after construction +-- Overlay to allow changing track stop and roller settings after construction --@ module = true if not dfhack_flags.module then @@ -10,10 +10,12 @@ local widgets = require('gui.widgets') local overlay = require('plugins.overlay') local utils = require('utils') -local NORTH = 'North ^' -local EAST = 'East >' -local SOUTH = 'South v' -local WEST = 'West <' +local getBuild = dfhack.gui.getSelectedBuilding + +local NORTH = 'North '..string.char(24) +local EAST = 'East '..string.char(26) +local SOUTH = 'South '..string.char(25) +local WEST = 'West '..string.char(27) local LOW = 'Low' local MEDIUM = 'Medium' @@ -30,7 +32,6 @@ local FRICTION_MAP = { [HIGH] = 10000, [MAX] = 50000, } - local FRICTION_MAP_REVERSE = utils.invert(FRICTION_MAP) local SPEED_MAP = { @@ -40,7 +41,6 @@ local SPEED_MAP = { [HIGHER] = 40000, [MAX] = 50000, } - local SPEED_MAP_REVERSE = utils.invert(SPEED_MAP) local DIRECTION_MAP = { @@ -49,12 +49,13 @@ local DIRECTION_MAP = { [SOUTH] = df.screw_pump_direction.FromNorth, [WEST] = df.screw_pump_direction.FromEast, } - local DIRECTION_MAP_REVERSE = utils.invert(DIRECTION_MAP) TrackStopOverlay = defclass(TrackStopOverlay, overlay.OverlayWidget) TrackStopOverlay.ATTRS{ - default_pos={x=-73, y=29}, + desc='Adds widgets for reconfiguring trackstops after construction.', + default_pos={x=-73, y=32}, + version=2, default_enabled=true, viewscreens='dwarfmode/ViewSheets/BUILDING/Trap/TrackStop', frame={w=25, h=4}, @@ -62,70 +63,59 @@ TrackStopOverlay.ATTRS{ frame_background=gui.CLEAR_PEN, } -function TrackStopOverlay:getFriction() - return dfhack.gui.getSelectedBuilding().friction -end - function TrackStopOverlay:setFriction(friction) - local building = dfhack.gui.getSelectedBuilding() - - building.friction = FRICTION_MAP[friction] + getBuild().track_stop_info.friction = FRICTION_MAP[friction] end function TrackStopOverlay:getDumpDirection() - local building = dfhack.gui.getSelectedBuilding() - local use_dump = building.use_dump - local dump_x_shift = building.dump_x_shift - local dump_y_shift = building.dump_y_shift + local info = getBuild().track_stop_info + local x = info.dump_x_shift + local y = info.dump_y_shift - if use_dump == 0 then + if not info.track_flags.use_dump then return NONE else - if dump_x_shift == 0 and dump_y_shift == -1 then + if x == 0 and y == -1 then return NORTH - elseif dump_x_shift == 1 and dump_y_shift == 0 then + elseif x == 1 and y == 0 then return EAST - elseif dump_x_shift == 0 and dump_y_shift == 1 then + elseif x == 0 and y == 1 then return SOUTH - elseif dump_x_shift == -1 and dump_y_shift == 0 then + elseif x == -1 and y == 0 then return WEST end end end function TrackStopOverlay:setDumpDirection(direction) - local building = dfhack.gui.getSelectedBuilding() + local info = getBuild().track_stop_info if direction == NONE then - building.use_dump = 0 - building.dump_x_shift = 0 - building.dump_y_shift = 0 + info.track_flags.use_dump = false + info.dump_x_shift = 0 + info.dump_y_shift = 0 elseif direction == NORTH then - building.use_dump = 1 - building.dump_x_shift = 0 - building.dump_y_shift = -1 + info.track_flags.use_dump = true + info.dump_x_shift = 0 + info.dump_y_shift = -1 elseif direction == EAST then - building.use_dump = 1 - building.dump_x_shift = 1 - building.dump_y_shift = 0 + info.track_flags.use_dump = true + info.dump_x_shift = 1 + info.dump_y_shift = 0 elseif direction == SOUTH then - building.use_dump = 1 - building.dump_x_shift = 0 - building.dump_y_shift = 1 + info.track_flags.use_dump = true + info.dump_x_shift = 0 + info.dump_y_shift = 1 elseif direction == WEST then - building.use_dump = 1 - building.dump_x_shift = -1 - building.dump_y_shift = 0 + info.track_flags.use_dump = true + info.dump_x_shift = -1 + info.dump_y_shift = 0 end end function TrackStopOverlay:render(dc) - local building = dfhack.gui.getSelectedBuilding() - local friction = building.friction - local friction_cycle = self.subviews.friction - - friction_cycle:setOption(FRICTION_MAP_REVERSE[friction]) - + local f = getBuild().track_stop_info.friction + self.subviews.friction:setOption(FRICTION_MAP_REVERSE[f]) self.subviews.dump_direction:setOption(self:getDumpDirection()) TrackStopOverlay.super.render(self, dc) @@ -145,7 +135,7 @@ function TrackStopOverlay:init() WEST, }, view_id='dump_direction', - on_change=function(val) self:setDumpDirection(val) end, + on_change=self:callback('setDumpDirection'), }, widgets.CycleHotkeyLabel{ label='Friction', @@ -159,14 +149,16 @@ function TrackStopOverlay:init() {label=MAX, value=MAX, pen=COLOR_RED}, }, view_id='friction', - on_change=function(val) self:setFriction(val) end, + on_change=self:callback('setFriction'), }, } end RollerOverlay = defclass(RollerOverlay, overlay.OverlayWidget) RollerOverlay.ATTRS{ - default_pos={x=-71, y=29}, + desc='Adds widgets for reconfiguring rollers after construction.', + default_pos={x=-71, y=32}, + version=2, default_enabled=true, viewscreens='dwarfmode/ViewSheets/BUILDING/Rollers', frame={w=27, h=4}, @@ -174,39 +166,20 @@ RollerOverlay.ATTRS{ frame_background=gui.CLEAR_PEN, } -function RollerOverlay:getDirection() - local building = dfhack.gui.getSelectedBuilding() - local direction = building.direction - - return DIRECTION_MAP_REVERSE[direction] -end - function RollerOverlay:setDirection(direction) - local building = dfhack.gui.getSelectedBuilding() - - building.direction = DIRECTION_MAP[direction] -end - -function RollerOverlay:getSpeed() - local building = dfhack.gui.getSelectedBuilding() - local speed = building.speed - - return SPEED_MAP_REVERSE[speed] + getBuild().direction = DIRECTION_MAP[direction] end function RollerOverlay:setSpeed(speed) - local building = dfhack.gui.getSelectedBuilding() - - building.speed = SPEED_MAP[speed] + getBuild().speed = SPEED_MAP[speed] end function RollerOverlay:render(dc) - local building = dfhack.gui.getSelectedBuilding() + local b = getBuild() + self.subviews.direction:setOption(DIRECTION_MAP_REVERSE[b.direction]) + self.subviews.speed:setOption(SPEED_MAP_REVERSE[b.speed]) - self.subviews.direction:setOption(DIRECTION_MAP_REVERSE[building.direction]) - self.subviews.speed:setOption(SPEED_MAP_REVERSE[building.speed]) - - TrackStopOverlay.super.render(self, dc) + RollerOverlay.super.render(self, dc) end function RollerOverlay:init() @@ -217,7 +190,7 @@ function RollerOverlay:init() key='CUSTOM_CTRL_X', options={NORTH, EAST, SOUTH, WEST}, view_id='direction', - on_change=function(val) self:setDirection(val) end, + on_change=self:callback('setDirection'), }, widgets.CycleHotkeyLabel{ label='Speed', @@ -231,7 +204,7 @@ function RollerOverlay:init() {label=MAX, value=MAX, pen=COLOR_RED}, }, view_id='speed', - on_change=function(val) self:setSpeed(val) end, + on_change=self:callback('setSpeed'), }, } end diff --git a/troubleshoot-item.lua b/troubleshoot-item.lua index a509407048..db7167b558 100644 --- a/troubleshoot-item.lua +++ b/troubleshoot-item.lua @@ -1,13 +1,4 @@ --- troubleshoot-item.lua --@ module = true ---[====[ - -troubleshoot-item -================= -Print various properties of the selected item. Sometimes useful for -troubleshooting issues such as why dwarves won't pick up a certain item. - -]====] local function coord_to_str(coord) local out = {} @@ -45,8 +36,7 @@ function troubleshoot_item(item, out) if unit_holder.unit_id then local unit = df.unit.find(unit_holder.unit_id) if unit then - local unit_details = string.format("%s - %s", dfhack.TranslateName(dfhack.units.getVisibleName(unit)), dfhack.units.getProfessionName(unit)) - out('Held by unit: ' .. unit_details) + out('Held by unit: ' .. dfhack.units.getReadableName(unit)) else warn('Could not find unit with unit_id: ' .. unit_holder.unit_id) end diff --git a/twaterlvl.lua b/twaterlvl.lua index 46b0c598bf..09bff0fc79 100644 --- a/twaterlvl.lua +++ b/twaterlvl.lua @@ -1,3 +1,3 @@ - -df.global.d_init.flags1.SHOW_FLOW_AMOUNTS = not df.global.d_init.flags1.SHOW_FLOW_AMOUNTS +local flags = df.global.d_init.display.flags +flags.SHOW_FLOW_AMOUNTS = not flags.SHOW_FLOW_AMOUNTS print('Water level display toggled.') diff --git a/undump-buildings.lua b/undump-buildings.lua index d8bf60c5aa..0d6ef6b375 100644 --- a/undump-buildings.lua +++ b/undump-buildings.lua @@ -1,35 +1,22 @@ --- Undesignates building base materials for dumping. ---[====[ - -undump-buildings -================ -Undesignates building base materials for dumping. - -]====] - function undump_buildings() - local buildings = df.global.world.buildings.all local undumped = 0 - for i = 0, #buildings - 1 do - local building = buildings[i] + for _, building in ipairs(df.global.world.buildings.all) do -- Zones and stockpiles don't have the contained_items field. - if df.building_actual:is_instance(building) then - local items = building.contained_items --hint:df.building_actual - for j = 0, #items - 1 do - local contained = items[j] - if contained.use_mode == 2 and contained.item.flags.dump then - -- print(building, contained.item) - undumped = undumped + 1 - contained.item.flags.dump = false - end + if not df.building_actual:is_instance(building) then goto continue end + for _, contained in ipairs(building.contained_items) do + if contained.use_mode == df.building_item_role_type.PERM and + contained.item.flags.dump + then + undumped = undumped + 1 + contained.item.flags.dump = false end end + ::continue:: end if undumped > 0 then - local s = "s" - if undumped == 1 then s = "" end - print("Undumped "..undumped.." item"..s..".") + local s = undumped == 1 and '' or 's' + print(('Undumped %s in-use building item%s'):format(undumped, s)) end end diff --git a/ungeld.lua b/ungeld.lua index b3736974d6..0ea29c2a4e 100644 --- a/ungeld.lua +++ b/ungeld.lua @@ -1,28 +1,13 @@ --- Ungelds animals --- Written by Josh Cooper(cppcooper) on 2019-12-10, last modified: 2020-02-23 utils = require('utils') + local validArgs = utils.invert({ 'unit', 'help', }) local args = utils.processArgs({...}, validArgs) -local help = [====[ - -ungeld -====== -A wrapper around `geld` that ungelds the specified animal. - -Valid options: - -``-unit ``: Ungelds the unit with the specified ID. - This is optional; if not specified, the selected unit is used instead. - -``-help``: Shows this help information - -]====] if args.help then - print(help) + print(dfhack.script_help()) return end diff --git a/uniform-unstick.lua b/uniform-unstick.lua index 469686e2a2..5be03a4cc5 100644 --- a/uniform-unstick.lua +++ b/uniform-unstick.lua @@ -1,279 +1,443 @@ --- Prompt units to adjust their uniform. -local help = [====[ +--@ module=true -uniform-unstick -=============== - -Prompt units to reevaluate their uniform, by removing/dropping potentially conflicting worn items. - -Unlike a "replace clothing" designation, it won't remove additional clothing -if it's coexisting with a uniform item already on that body part. -It also won't remove clothing (e.g. shoes, trousers) if the unit has yet to claim an -armor item for that bodypart. (e.g. if you're still manufacturing them.) - -By default it simply prints info about the currently selected unit, -to actually drop items, you need to provide it the -drop option. - -The default algorithm assumes that there's only one armor item assigned per body part, -which means that it may miss cases where one piece of armor is blocked but the other -is present. The -multi option can possibly get around this, but at the cost of ignoring -left/right distinctions when dropping items. +local gui = require('gui') +local overlay = require('plugins.overlay') +local utils = require('utils') +local widgets = require('gui.widgets') -In some cases, an assigned armor item can't be put on because someone else is wearing/holding it. -The -free option will cause the assigned item to be removed from the container/dwarven inventory -and placed onto the ground, ready for pickup. +local validArgs = utils.invert({ + 'all', + 'drop', + 'free', + 'multi', + 'help' +}) -In no cases should the command cause a uniform item that is being properly worn to be removed/dropped. +-- Functions -Targets: +-- @param item df.item +-- @return string +local function item_description(item) + return "item #" .. item.id .. " '" .. dfhack.df2console(dfhack.items.getDescription(item, 0, true)) .. "'" +end -:(no target): Force the selected dwarf to put on their uniform. -:-all: Force the uniform on all military dwarves. +-- @param item df.item +-- @return df.coord|nil +local function get_visible_item_pos(item) + local x, y, z = dfhack.items.getPosition(item) + if not x or not y or not z then + return + end -Options: + if dfhack.maps.isTileVisible(x, y, z) then + return xyz2pos(x, y, z) + end +end -:(none): Simply show identified issues (dry-run). -:-drop: Cause offending worn items to be placed on ground under unit. -:-free: Remove to-equip items from containers or other's inventories, and place on ground. -:-multi: Be more agressive in removing items, best for when uniforms have muliple items per body part. -]====] +-- @param unit df.unit +-- @return df.squad_position|nil +local function get_squad_position(unit) + local squad = df.squad.find(unit.military.squad_id) + if not squad then + return + end -local utils = require('utils') + if squad.entity_id ~= df.global.plotinfo.group_id then + print("WARNING: Unit " .. dfhack.df2console(dfhack.units.getReadableName(unit)) .. " is a member of a squad from another site!" .. + " This may be preventing them from doing any useful work." .. + " You can fix this by assigning them to a local squad and then unassigning them.") + print() + return + end -local validArgs = utils.invert({ - 'all', - 'drop', - 'free', - 'multi', - 'help' -}) + if #squad.positions > unit.military.squad_position then + return squad.positions[unit.military.squad_position] + end +end --- Functions +-- @param unit df.unit +-- @param item df.item +-- @return number[] list of body part ids +local function bodyparts_that_can_wear(unit, item) + local bodyparts = {} + local unitparts = dfhack.units.getCasteRaw(unit).body_info.body_parts + + for bodypart_flag, item_type in pairs({ + HEAD = df.item_helmst, + UPPERBODY = df.item_armorst, + GRASP = df.item_glovesst, + LOWERBODY = df.item_pantsst, + STANCE = df.item_shoesst, + }) do + if item._type == item_type then + for index, part in ipairs(unitparts) do + if part.flags[bodypart_flag] then + table.insert(bodyparts, index) + end + end + end + end -function item_description(item) - return dfhack.df2console( dfhack.items.getDescription(item, 0, true) ) + return bodyparts end -function get_item_pos(item) - local x, y, z = dfhack.items.getPosition(item) - if x == nil or y == nil or z == nil then - return nil - end - - if not dfhack.maps.isValidTilePos(x,y,z) then - print("NOT VALID TILE") - return nil - end - if not dfhack.maps.isTileVisible(x,y,z) then - print("NOT VISIBLE TILE") - return nil - end - return xyz2pos(x, y, z) +-- @param unit_name string +-- @param labor_name string +local function print_bad_labor(unit_name, labor_name) + return print("WARNING: Unit " .. unit_name .. " has the " .. labor_name .. + " labor enabled, which conflicts with military uniforms.") end -function find_squad_position(unit) - for i, squad in pairs( df.global.world.squads.all ) do - for i, position in pairs( squad.positions ) do - if position.occupant == unit.hist_figure_id then - return position - end +-- @param squad_position df.squad_position +-- @param item_id number +local function remove_item_from_position(squad_position, item_id) + utils.erase_sorted(squad_position.equipment.assigned_items, item_id) + for _, uniform_slot_specs in ipairs(squad_position.equipment.uniform) do + for _, uniform_spec in ipairs(uniform_slot_specs) do + for idx, assigned_item_id in ipairs(uniform_spec.assigned) do + if assigned_item_id == item_id then + uniform_spec.assigned:erase(idx) + return + end + end + end + end + for _, special_case in ipairs({"quiver", "backpack", "flask"}) do + if squad_position.equipment[special_case] == item_id then + squad_position.equipment[special_case] = -1 + return + end end - end - return nil end -function bodyparts_that_can_wear(unit, item) +-- Will figure out which items need to be moved to the floor, returns an item_id:item map +-- and a flag that indicates whether a separator line needs to be printed +local function process(unit, args) + local silent = args.all -- Don't print details if we're iterating through all dwarves + local unit_name = dfhack.df2console(dfhack.units.getReadableName(unit)) + local printed = false - local bodyparts = {} - local unitparts = df.creature_raw.find(unit.race).caste[unit.caste].body_info.body_parts + if not silent then + print("Processing unit " .. unit_name) + printed = true + end + + -- The return value + local to_drop = {} -- item id to item object + + -- First get squad position for an early-out for non-military dwarves + local squad_position = get_squad_position(unit) + if not squad_position then + if not silent then + print(unit_name .. " does not have a military uniform.") + print() + end + return + end + + if unit.status.labors.MINE then + print_bad_labor(unit_name, "mining") + printed = true + elseif unit.status.labors.CUTWOOD then + print_bad_labor(unit_name, "woodcutting") + printed = true + elseif unit.status.labors.HUNT then + print_bad_labor(unit_name, "hunting") + printed = true + end - if item._type == df.item_helmst then - for index, part in pairs(unitparts) do - if part.flags.HEAD then - table.insert(bodyparts, index) - end + -- Find all worn items which may be at issue. + local worn_items = {} -- map of item ids to item objects + local worn_parts = {} -- map of item ids to body part ids + for _, inv_item in ipairs(unit.inventory) do + local item = inv_item.item + -- Include weapons so we can check we have them later + if inv_item.mode == df.inv_item_role_type.Worn or + inv_item.mode == df.inv_item_role_type.Weapon or + inv_item.mode == df.inv_item_role_type.Strapped or + inv_item.mode == df.inv_item_role_type.Flask + then + worn_items[item.id] = item + worn_parts[item.id] = inv_item.body_part_id + end end - elseif item._type == df.item_armorst then - for index, part in pairs(unitparts) do - if part.flags.UPPERBODY then - table.insert(bodyparts, index) - end + + -- Now get info about which items have been assigned as part of the uniform + local uniform_assigned_items = {} -- assigned item ids mapped to item objects + for _, uniform_slot_specs in ipairs(squad_position.equipment.uniform) do + for _, uniform_spec in ipairs(uniform_slot_specs) do + for _, assigned_item_id in ipairs(uniform_spec.assigned) do + -- Include weapon and shield so we can avoid dropping them, or pull them out of container/inventory later + uniform_assigned_items[assigned_item_id] = df.item.find(assigned_item_id) + end + end end - elseif item._type == df.item_glovesst then - for index, part in pairs(unitparts) do - if part.flags.GRASP then - table.insert(bodyparts, index) - end + for _, special_case in ipairs({"quiver", "backpack", "flask"}) do + local assigned_item_id = squad_position.equipment[special_case] + if assigned_item_id ~= -1 then + uniform_assigned_items[assigned_item_id] = df.item.find(assigned_item_id) + end end - elseif item._type == df.item_pantsst then - for index, part in pairs(unitparts) do - if part.flags.LOWERBODY then - table.insert(bodyparts, index) - end + + -- Figure out which assigned items are currently not being worn + -- and if some other unit is carrying the item, unassign it from this unit's uniform + + local present_ids = {} -- map of item ID to item object + local missing_ids = {} -- map of item ID to item object + for item_id, item in pairs(uniform_assigned_items) do + if not worn_items[item_id] then + if not silent then + print(unit_name .. " is missing an assigned item, " .. item_description(item)) + printed = true + end + if dfhack.items.getGeneralRef(item, df.general_ref_type.UNIT_HOLDER) then + print(unit_name .. " cannot equip item: another unit has a claim on " .. item_description(item)) + printed = true + if args.free then + print(" Removing from uniform") + uniform_assigned_items[item_id] = nil + remove_item_from_position(squad_position, item_id) + end + else + missing_ids[item_id] = item + if args.free then + to_drop[item_id] = item + end + end + else + present_ids[item_id] = item + end end - elseif item._type == df.item_shoesst then - for index, part in pairs(unitparts) do - if part.flags.STANCE then - table.insert(bodyparts, index) - end + + -- Make the equipment.assigned_items list consistent with what is present in equipment.uniform + for i=#(squad_position.equipment.assigned_items)-1,0,-1 do + local assigned_item_id = squad_position.equipment.assigned_items[i] + if uniform_assigned_items[assigned_item_id] == nil then + local item = df.item.find(assigned_item_id) + if item ~= nil then + print(unit_name .. " has an improperly assigned item, " .. item_description(item) .. "; removing it") + else + print(unit_name .. " has a nonexistent item assigned, item # " .. assigned_item_id .. "; removing it") + end + printed = true + squad_position.equipment.assigned_items:erase(i) + end end - else - -- print("Ignoring item type for "..item_description(item) ) - end - return bodyparts -end + -- Figure out which worn items should be dropped + + -- First, figure out which body parts are covered by the uniform pieces we have. + -- unless --multi is specified, in which we don't care + local covered = {} -- map of body part id to true/nil + if not args.multi then + for item_id, item in pairs(present_ids) do + -- only the five clothing types can block armor for the bodypart they're worn on. + if utils.linear_index({ df.item_helmst, df.item_armorst, df.item_glovesst, + df.item_pantsst, df.item_shoesst }, item._type) + then + covered[worn_parts[item_id]] = true + end + end + end --- Will figure out which items need to be moved to the floor, returns an item_id:item map -function process(unit, args) - local silent = args.all -- Don't print details if we're iterating through all dwarves - local unit_name = dfhack.df2console( dfhack.TranslateName( dfhack.units.getVisibleName(unit) ) ) + -- Figure out body parts which should be covered but aren't + local uncovered = {} + for _, item in pairs(missing_ids) do + for _, bp in ipairs(bodyparts_that_can_wear(unit, item)) do + if not covered[bp] then + uncovered[bp] = true + end + end + end - if not silent then - print("Processing unit "..unit_name) - end + -- Drop clothing (except uniform pieces) from body parts which should be covered but aren't + for worn_item_id, item in pairs(worn_items) do + if uniform_assigned_items[worn_item_id] == nil -- don't drop uniform pieces + -- only the five clothing types can block armor for the bodypart they're worn on. + and utils.linear_index({ df.item_helmst, df.item_armorst, df.item_glovesst, + df.item_pantsst, df.item_shoesst }, item._type) + then + if uncovered[worn_parts[worn_item_id]] then + print(unit_name .. " potentially has " .. item_description(item) .. " blocking a missing uniform item.") + printed = true + if args.drop then + to_drop[worn_item_id] = item + end + end + end + end - -- The return value - local to_drop = {} -- item id to item object + return to_drop, printed +end - -- First get squad position for an early-out for non-military dwarves - local squad_position = find_squad_position(unit) - if squad_position == nil then - if not silent then - print("Unit "..unit_name.." does not have a military uniform.") +local function do_drop(item_list, printed) + if not item_list then + return end - return nil - end - - -- Find all worn items which may be at issue. - local worn_items = {} -- map of item ids to item objects - local worn_parts = {} -- map of item ids to body part ids - for k, inv_item in pairs(unit.inventory) do - local item = inv_item.item - if inv_item.mode == df.unit_inventory_item.T_mode.Worn or inv_item.mode == df.unit_inventory_item.T_mode.Weapon then -- Include weapons so we can check we have them later - worn_items[ item.id ] = item - worn_parts[ item.id ] = inv_item.body_part_id - end - end - - -- Now get info about which items have been assigned as part of the uniform - local assigned_items = {} -- assigned item ids mapped to item objects - for loc, specs in pairs( squad_position.uniform ) do - for i, spec in pairs(specs) do - for i, assigned in pairs( spec.assigned ) do - -- Include weapon and shield so we can avoid dropping them, or pull them out of container/inventory later - assigned_items[ assigned ] = df.item.find( assigned ) - end + + for _, item in pairs(item_list) do + local pos = get_visible_item_pos(item) + + -- only drop if the item is on the map and is being held by a unit. + if not pos then + dfhack.printerr("Could not find drop location for " .. item_description(item)) + elseif dfhack.items.getHolderUnit(item) == nil then + -- dfhack.printerr("Not in inventory: " .. item_description(item)) + else + if dfhack.items.moveToGround(item, pos) then + print("Dropped " .. item_description(item)) + else + dfhack.printerr("Could not drop " .. item_description(item)) + end + end end - end - - -- Figure out which assigned items are currently not being worn - - local present_ids = {} -- map of item ID to item object - local missing_ids = {} -- map of item ID to item object - for u_id, item in pairs(assigned_items) do - if worn_items[ u_id ] == nil then - print("Unit "..unit_name.." is missing an assigned item, object #"..u_id.." '"..item_description(item).."'" ) - missing_ids[ u_id ] = item - if args.free then - to_drop[ u_id ] = item - end - else - present_ids[ u_id ] = item + + -- add a spacing line if there was any output + if printed then + print() end - end +end - -- Figure out which worn items should be dropped +local function main(args) + args = utils.processArgs(args, validArgs) - -- First, figure out which body parts are covered by the uniform pieces we have. - local covered = {} -- map of body part id to true/nil - for id, item in pairs( present_ids ) do - if item._type ~= df.item_weaponst and item._type ~= df.item_shieldst then -- weapons and shields don't "cover" the bodypart they're assigned to. (Needed to figure out if we're missing gloves.) - covered[ worn_parts[ id ] ] = true - end - end - - if multi then - covered = {} -- Don't consider current covers - drop for anything which is missing - end - - -- Figure out body parts which should be covered but aren't - local uncovered = {} - for id, item in pairs(missing_ids) do - for i, bp in pairs( bodyparts_that_can_wear(unit, item) ) do - if not covered[bp] then - uncovered[bp] = true - end + if args.help then + print(dfhack.script_help()) + return end - end - - -- Drop everything (except uniform pieces) from body parts which should be covered but aren't - for w_id, item in pairs(worn_items) do - if assigned_items[ w_id ] == nil then -- don't drop uniform pieces (including shields, weapons for hands) - if uncovered[ worn_parts[ w_id ] ] then - print("Unit "..unit_name.." potentially has object #"..w_id.." '"..item_description(item).."' blocking a missing uniform item.") - if args.drop then - to_drop[ w_id ] = item + + if args.all then + for _, unit in ipairs(dfhack.units.getCitizens(true)) do + do_drop(process(unit, args)) + end + else + local unit = dfhack.gui.getSelectedUnit() + if unit then + do_drop(process(unit, args)) + else + qerror("Please select a unit if not running with --all") end - end end - end +end - return to_drop +ReportWindow = defclass(ReportWindow, widgets.Window) +ReportWindow.ATTRS { + frame_title='Equipment conflict report', + frame={w=100, h=35}, + resizable=true, + resize_min={w=60, h=20}, + report=DEFAULT_NIL, +} + +function ReportWindow:init() + self:addviews{ + widgets.Label{ + frame={t=0, l=0}, + text_pen=COLOR_YELLOW, + text='Equipment conflict report:', + }, + widgets.Panel{ + frame={t=2, b=7}, + subviews={ + widgets.WrappedLabel{ + frame={t=0}, + text_to_wrap=self.report, + }, + }, + }, + widgets.WrappedLabel{ + frame={b=4, h=2, l=0}, + text_pen=COLOR_LIGHTRED, + text_to_wrap='After resolving conflicts, be sure to click the "Update equipment" button to reassign new equipment!', + auto_height=false, + }, + widgets.Panel{ + frame={b=0, w=34, h=3}, + frame_style=gui.FRAME_THIN, + subviews={ + widgets.HotkeyLabel{ + label='Try to resolve conflicts', + key='CUSTOM_CTRL_T', + on_activate=function() + dfhack.run_script('uniform-unstick', '--all', '--drop', '--free') + self.parent_view:dismiss() + end, + }, + }, + }, + } end +ReportScreen = defclass(ReportScreen, gui.ZScreenModal) +ReportScreen.ATTRS { + focus_path='equipreport', + report=DEFAULT_NIL, +} -function do_drop( item_list ) - if item_list == nil then - return nil - end +function ReportScreen:init() + self:addviews{ReportWindow{report=self.report}} +end - local mode_swap = false - if df.global.plotinfo.main.mode == df.ui_sidebar_mode.ViewUnits then - df.global.plotinfo.main.mode = df.ui_sidebar_mode.Default - mode_swap = true - end +local MIN_WIDTH = 26 + +EquipOverlay = defclass(EquipOverlay, overlay.OverlayWidget) +EquipOverlay.ATTRS{ + desc='Adds a link to the equip screen to fix equipment conflicts.', + default_pos={x=7,y=23}, + default_enabled=true, + viewscreens='dwarfmode/Squads/Equipment/Default', + frame={w=MIN_WIDTH, h=1}, + version=1 +} + +function EquipOverlay:init() + self:addviews{ + widgets.TextButton{ + view_id='button', + frame={t=0, w=MIN_WIDTH, r=0, h=1}, + label='Detect conflicts', + key='CUSTOM_CTRL_T', + on_activate=self:callback('run_report'), + }, + widgets.TextButton{ + view_id='button_good', + frame={t=0, w=MIN_WIDTH, r=0, h=1}, + label=' No conflicts ', + text_pen=COLOR_GREEN, + key='CUSTOM_CTRL_T', + visible=false, + }, + } +end - for id, item in pairs(item_list) do - local pos = get_item_pos(item) - if pos == nil then - dfhack.printerr("Could not find drop location for item #"..id.." "..item_description(item)) +function EquipOverlay:run_report() + local output = dfhack.run_command_silent({'uniform-unstick', '--all'}) + if #output == 0 then + self.subviews.button.visible = false + self.subviews.button_good.visible = true + local end_ms = dfhack.getTickCount() + 5000 + local function label_reset() + if dfhack.getTickCount() < end_ms then + dfhack.timeout(10, 'frames', label_reset) + else + self.subviews.button_good.visible = false + self.subviews.button.visible = true + end + end + label_reset() else - local retval = dfhack.items.moveToGround( item, pos ) - if retval == false then - dfhack.printerr("Could not drop object #"..id.." "..item_description(item)) - else - print("Dropped item #"..id.." '"..item_description(item).."'") - end + ReportScreen{report=output}:show() end - end - - if mode_swap then - df.global.plotinfo.main.mode = df.ui_sidebar_mode.ViewUnits - end end +function EquipOverlay:preUpdateLayout(parent_rect) + self.frame.w = math.max(0, parent_rect.width - 133) + MIN_WIDTH +end --- Main - -local args = utils.processArgs({...}, validArgs) +OVERLAY_WIDGETS = {overlay=EquipOverlay} -if args.help then - print(help) +if dfhack_flags.module then return end -if args.all then - for k,unit in ipairs(df.global.world.units.active) do - if dfhack.units.isCitizen(unit) then - local to_drop = process(unit,args) - do_drop( to_drop ) - end - end -else - local unit=dfhack.gui.getSelectedUnit() - if unit then - local to_drop = process(unit,args) - do_drop( to_drop ) - end -end +main({...}) diff --git a/unretire-anyone.lua b/unretire-anyone.lua index 170c82a812..8ca19ff886 100644 --- a/unretire-anyone.lua +++ b/unretire-anyone.lua @@ -1,75 +1,116 @@ +--@module=true + local options = {} local argparse = require('argparse') -local commands = argparse.processArgsGetopt({...}, { - {'d', 'dead', handler=function() options.dead = true end} +local commands = argparse.processArgsGetopt({ ... }, { + { 'd', 'dead', handler = function() options.dead = true end } }) -local dialogs = require 'gui.dialogs' - -local viewscreen = dfhack.gui.getCurViewscreen() -if viewscreen._type ~= df.viewscreen_setupadventurest then - qerror("This script can only be used during adventure mode setup!") -end +local dialogs = require('gui.dialogs') --luacheck: in=df.viewscreen_setupadventurest,df.nemesis_record -function addNemesisToUnretireList(advSetUpScreen, nemesis) - local unretireOption = false - for i = #advSetUpScreen.race_ids-1, 0, -1 do - if advSetUpScreen.race_ids[i] == -2 then -- this is the "Specific Person" option on the menu - unretireOption = true - break +local function addNemesisToUnretireList(advSetUpScreen, nemesis, index) + local unretireOption = false + for i = #advSetUpScreen.valid_race - 1, 0, -1 do + if advSetUpScreen.valid_race[i] == -2 then -- this is the "Specific Person" option on the menu + unretireOption = true + break + end + end + + if not unretireOption then + advSetUpScreen.valid_race:insert('#', -2) end - end - if not unretireOption then - advSetUpScreen.race_ids:insert('#', -2) - end + -- Revive the historical figure + local histFig = nemesis.figure + if histFig.died_year >= -1 then + histFig.died_year = -1 + histFig.died_seconds = -1 + end - nemesis.flags.ADVENTURER = true - advSetUpScreen.nemesis_ids:insert('#', nemesis.id) + nemesis.flags.ADVENTURER = true + -- nemesis.id and df.global.world.nemesis.all index should *usually* align but there may be bugged scenarios where they don't. + -- This is a workaround for the issue by using the vector index rather than nemesis.id + advSetUpScreen.nemesis_index:insert('#', index) end ---luacheck: in=table -function showNemesisPrompt(advSetUpScreen) - local choices = {} - for _,nemesis in ipairs(df.global.world.nemesis.all) do - if nemesis.figure and not nemesis.flags.ADVENTURER then -- these are already available for unretiring - local histFig = nemesis.figure - local histFlags = histFig.flags - if (histFig.died_year == -1 or histFlags.ghost or options.dead) and not histFlags.deity and not histFlags.force then - local creature = df.creature_raw.find(histFig.race).caste[histFig.caste] - local name = creature.caste_name[0] - if histFig.died_year >= -1 then - histFig.died_year = -1 - histFig.died_seconds = -1 - end - if histFig.info and histFig.info.curse then - local curse = histFig.info.curse - if curse.name ~= '' then +function getHistfigShortSummary(histFig) + local race = df.creature_raw.find(histFig.race) + local name = 'Unknown Creature' + local sym = nil + if race then + local creature = race.caste[histFig.caste] + name = creature.caste_name[0] + sym = df.pronoun_type.attrs[creature.sex].symbol + end + if histFig.info and histFig.info.curse then + local curse = histFig.info.curse + if curse.name ~= '' then name = name .. ' ' .. curse.name - end - if curse.undead_name ~= '' then - name = curse.undead_name .. " - reanimated " .. name - end - end - if histFlags.ghost then - name = name .. " ghost" end - local sym = df.pronoun_type.attrs[creature.sex].symbol - if sym then - name = name .. ' (' .. sym .. ')' + if curse.undead_name ~= '' then + name = curse.undead_name .. " - reanimated " .. name end - if histFig.name.has_name then - name = dfhack.TranslateName(histFig.name) .. " - (" .. dfhack.TranslateName(histFig.name, true).. ") - " .. name + end + if histFig.flags.ghost then + name = name .. " ghost" + end + if sym then + name = name .. ' (' .. sym .. ')' + end + name = name .. + '\n ' .. dfhack.units.getReadableName(histFig) + if histFig.name.has_name then + name = name .. + '\n "' .. dfhack.translation.translateName(histFig.name, true) .. '"' + else + name = name .. + '\nUnnamed' + end + return name +end + +--luacheck: in=table +local function showNemesisPrompt(advSetUpScreen) + local choices = {} + for i, nemesis in ipairs(df.global.world.nemesis.all) do + if nemesis.figure then + local histFig = nemesis.figure + if (histFig.died_year == -1 or histFig.flags.ghost or options.dead) and + not histFig.flags.deity and + not histFig.flags.force + then + if histFig.died_year == -1 and nemesis.flags.ADVENTURER then + -- already available for unretiring + goto continue + end + local name = getHistfigShortSummary(histFig) + table.insert(choices, { text = name, nemesis = nemesis, search_key = name:lower(), idx = i }) + end end - table.insert(choices, {text = name, nemesis = nemesis, search_key = name:lower()}) - end + ::continue:: end - end - dialogs.showListPrompt('unretire-anyone', "Select someone to add to the \"Specific Person\" list:", COLOR_WHITE, choices, function(id, choice) - addNemesisToUnretireList(advSetUpScreen, choice.nemesis) - end, nil, nil, true) + + dialogs.ListBox{ + frame_title = 'unretire-anyone', + text = 'Select someone to add to the "Specific Person" list:', + text_pen = COLOR_WHITE, + choices = choices, + on_select = function(id, choice) addNemesisToUnretireList(advSetUpScreen, choice.nemesis, choice.idx) end, + with_filter = true, + row_height = 3, + }:show() +end + +if dfhack_flags.module then + return +end + +local viewscreen = dfhack.gui.getDFViewscreen(true) +if viewscreen._type ~= df.viewscreen_setupadventurest then + qerror("This script can only be used during adventure mode setup!") end showNemesisPrompt(viewscreen) diff --git a/unsuspend.lua b/unsuspend.lua deleted file mode 100644 index 2a56f5dea9..0000000000 --- a/unsuspend.lua +++ /dev/null @@ -1,216 +0,0 @@ --- unsuspend construction jobs; buildingplan-safe ---@module = true - -local guidm = require('gui.dwarfmode') -local argparse = require('argparse') -local suspendmanager = reqscript('suspendmanager') - -local overlay = require('plugins.overlay') - -local ok, buildingplan = pcall(require, 'plugins.buildingplan') -if not ok then - buildingplan = nil -end - -local textures = dfhack.textures.loadTileset('hack/data/art/unsuspend.png', 32, 32, true) - -SuspendOverlay = defclass(SuspendOverlay, overlay.OverlayWidget) -SuspendOverlay.ATTRS{ - viewscreens='dwarfmode', - default_enabled=true, - overlay_only=true, - overlay_onupdate_max_freq_seconds=30, -} - -function SuspendOverlay:init() - self:reset() - -- there can only be one of these widgets allocated at a time, so this is - -- safe - dfhack.onStateChange.unsuspend = function(code) - if code ~= SC_MAP_LOADED then return end - self:reset() - end -end - -function SuspendOverlay:reset() - -- value of df.global.building_next_id on last scan - self.prev_building_next_id = 0 - - -- increments on every refresh so we can clear old data from the map - self.data_version = 0 - - -- map of building id -> {suspended=bool, suspend_count=int, version=int} - -- suspended is the job suspension state as of the last refresh - -- if suspend_count > 1 then this is a repeat offender (red 'X') - -- only buildings whose construction is in progress should be in this map. - self.in_progress_buildings = {} - - -- viewport for cached screen_buildings - self.viewport = {} - - -- map of building ids to current visible screen position - self.screen_buildings = {} -end - -function SuspendOverlay:overlay_onupdate() - local added = false - self.data_version = self.data_version + 1 - suspendmanager.foreach_construction_job(function(job) - self:update_building(dfhack.job.getHolder(job).id, job) - added = true - end) - self.prev_building_next_id = df.global.building_next_id - if added then - -- invalidate screen_buildings cache - self.viewport = {} - end - -- clear out old data - for bld_id,data in pairs(self.in_progress_buildings) do - if data.version ~= self.data_version then - self.in_progress_buildings[bld_id] = nil - end - end -end - -function SuspendOverlay:update_building(bld_id, job) - local suspended = job.flags.suspend - local data = self.in_progress_buildings[bld_id] - if not data then - self.in_progress_buildings[bld_id] = {suspended=suspended, - suspend_count=suspended and 1 or 0, version=self.data_version} - else - if suspended and suspended ~= data.suspended then - data.suspend_count = data.suspend_count + 1 - end - data.suspended = suspended - data.version = self.data_version - end -end - -function SuspendOverlay:process_new_buildings() - local added = false - for bld_id=self.prev_building_next_id,df.global.building_next_id-1 do - local bld = df.building.find(bld_id) - if not bld or #bld.jobs ~= 1 then - goto continue - end - local job = bld.jobs[0] - if job.job_type ~= df.job_type.ConstructBuilding then - goto continue - end - self:update_building(bld_id, job) - added = true - ::continue:: - end - self.prev_building_next_id = df.global.building_next_id - if added then - -- invalidate screen_buildings cache - self.viewport = {} - end -end - --- returns true if viewport has changed -function SuspendOverlay:update_viewport(viewport) - local pviewport = self.viewport - if viewport.z == pviewport.z - and viewport.x1 == pviewport.x1 and viewport.x2 == pviewport.x2 - and viewport.y1 == pviewport.y1 and viewport.y2 == pviewport.y2 then - return false - end - self.viewport = viewport - return true -end - -function SuspendOverlay:refresh_screen_buildings() - local viewport = guidm.Viewport.get() - if not self:update_viewport(viewport) then return end - local screen_buildings, z = {}, viewport.z - for bld_id,data in pairs(self.in_progress_buildings) do - local bld = df.building.find(bld_id) - if bld then - local pos = {x=bld.centerx, y=bld.centery, z=bld.z} - if viewport:isVisible(pos) then - screen_buildings[bld_id] = viewport:tileToScreen(pos) - end - end - end - self.screen_buildings = screen_buildings -end - -local tp = function(offset) - return dfhack.textures.getTexposByHandle(textures[offset]) -end - -function SuspendOverlay:render_marker(dc, bld, screen_pos) - if not bld or #bld.jobs ~= 1 then return end - local data = self.in_progress_buildings[bld.id] - if not data then return end - local job = bld.jobs[0] - if job.job_type ~= df.job_type.ConstructBuilding - or not job.flags.suspend then - return - end - local color, ch, texpos = COLOR_YELLOW, 'x', tp(2) - if buildingplan and buildingplan.isPlannedBuilding(bld) then - color, ch, texpos = COLOR_GREEN, 'P', tp(4) - elseif suspendmanager and suspendmanager.isKeptSuspended(job) then - color, ch, texpos = COLOR_WHITE, 'x', tp(3) - elseif data.suspend_count > 1 then - color, ch, texpos = COLOR_RED, 'X', tp(1) - end - dc:seek(screen_pos.x, screen_pos.y):tile(ch, texpos, color) -end - -function SuspendOverlay:onRenderFrame(dc) - if not df.global.pause_state and not dfhack.screen.inGraphicsMode() then - return - end - - self:process_new_buildings() - self:refresh_screen_buildings() - - dc:map(true) - for bld_id,screen_pos in pairs(self.screen_buildings) do - self:render_marker(dc, df.building.find(bld_id), screen_pos) - end - dc:map(false) -end - -OVERLAY_WIDGETS = {overlay=SuspendOverlay} - -if dfhack_flags.module then - return -end - -local quiet, skipblocking = false, false -argparse.processArgsGetopt({...}, { - {'q', 'quiet', handler=function() quiet = true end}, - {'s', 'skipblocking', handler=function() skipblocking = true end}, -}) - -local skipped_counts = {} -local unsuspended_count = 0 - -local manager = suspendmanager.SuspendManager{preventBlocking=skipblocking} -manager:refresh() -suspendmanager.foreach_construction_job(function(job) - if not job.flags.suspend then return end - - local skip_reason=manager:shouldStaySuspended(job, skipblocking) - if skip_reason then - skipped_counts[skip_reason] = (skipped_counts[skip_reason] or 0) + 1 - return - end - suspendmanager.unsuspend(job) - unsuspended_count = unsuspended_count + 1 -end) - -if not quiet then - for reason,count in pairs(skipped_counts) do - print(string.format('Not unsuspending %d %s job(s)', count, suspendmanager.REASON_TEXT[reason])) - end - - if unsuspended_count > 0 then - print(string.format('Unsuspended %d job(s).', unsuspended_count)) - end -end diff --git a/view-unit-reports.lua b/view-unit-reports.lua index e6ef103c5b..ece49be340 100644 --- a/view-unit-reports.lua +++ b/view-unit-reports.lua @@ -1,15 +1,4 @@ -- View combat reports for currently selected unit ---[====[ - -view-unit-reports -================= -Show combat reports for the current unit. - -Current unit is unit near cursor in 'v', selected in 'u' list, -unit/corpse/splatter at cursor in 'k'. And newest unit with race when -'k' at race-specific blood spatter. - -]====] local function get_combat_logs(unit) local output = {} diff --git a/warn-starving.lua b/warn-starving.lua deleted file mode 100644 index 225d41cfd7..0000000000 --- a/warn-starving.lua +++ /dev/null @@ -1,126 +0,0 @@ --- Pause and warn if a unit is starving --- By Meneth32, PeridexisErrant, Lethosor ---@ module = true - -starvingUnits = starvingUnits or {} --as:bool[] -dehydratedUnits = dehydratedUnits or {} --as:bool[] -sleepyUnits = sleepyUnits or {} --as:bool[] - -function clear() - starvingUnits = {} - dehydratedUnits = {} - sleepyUnits = {} -end - -local gui = require 'gui' -local utils = require 'utils' -local widgets = require 'gui.widgets' -local units = df.global.world.units.active - -local args = utils.invert({...}) -if args.all or args.clear then - clear() -end - -local checkOnlySane = false -if args.sane then - checkOnlySane = true -end - -Warning = defclass(Warning, gui.ZScreen) -Warning.ATTRS = { - focus_path='warn-starving', - force_pause=true, - pass_mouse_clicks=false, -} - -function Warning:init(info) - local main = widgets.Window{ - frame={w=80, h=18}, - frame_title='Warning', - resizable=true, - autoarrange_subviews=true - } - - main:addviews{ - widgets.WrappedLabel{ - text_to_wrap=table.concat(info.messages, NEWLINE), - } - } - - self:addviews{main} -end - -function Warning:onDismiss() - view = nil -end - -local function findRaceCaste(unit) - local rraw = df.creature_raw.find(unit.race) - return rraw, safe_index(rraw, 'caste', unit.caste) -end - -local function getSexString(sex) - local sym = df.pronoun_type.attrs[sex].symbol - if not sym then - return "" - end - return "("..sym..")" -end - -local function nameOrSpeciesAndNumber(unit) - if unit.name.has_name then - return dfhack.TranslateName(dfhack.units.getVisibleName(unit))..' '..getSexString(unit.sex),true - else - return 'Unit #'..unit.id..' ('..df.creature_raw.find(unit.race).caste[unit.caste].caste_name[0]..' '..getSexString(unit.sex)..')',false - end -end - -local function checkVariable(var, limit, description, map, unit) - local rraw = findRaceCaste(unit) - local species = rraw.name[0] - local profname = dfhack.units.getProfessionName(unit) - if #profname == 0 then profname = nil end - local name = nameOrSpeciesAndNumber(unit) - if var > limit then - if not map[unit.id] then - map[unit.id] = true - return name .. ", " .. (profname or species) .. " is " .. description .. "!" - end - else - map[unit.id] = false - end - return nil -end - -function doCheck() - local messages = {} --as:string[] - for i=#units-1, 0, -1 do - local unit = units[i] - local rraw = findRaceCaste(unit) - if not rraw or not dfhack.units.isFortControlled(unit) or dfhack.units.isDead(unit) then goto continue end - if checkOnlySane and not dfhack.units.isSane(unit) then goto continue end - table.insert(messages, checkVariable(unit.counters2.hunger_timer, 75000, 'starving', starvingUnits, unit)) - table.insert(messages, checkVariable(unit.counters2.thirst_timer, 50000, 'dehydrated', dehydratedUnits, unit)) - table.insert(messages, checkVariable(unit.counters2.sleepiness_timer, 150000, 'very drowsy', sleepyUnits, unit)) - ::continue:: - end - if #messages > 0 then - dfhack.color(COLOR_LIGHTMAGENTA) - for _, msg in pairs(messages) do - print(dfhack.df2console(msg)) - end - dfhack.color() - return Warning{messages=messages}:show() - end -end - -if dfhack_flags.module then - return -end - -if not dfhack.isMapLoaded() then - qerror('warn-starving requires a map to be loaded') -end - -view = view and view:raise() or doCheck() diff --git a/warn-stealers.lua b/warn-stealers.lua deleted file mode 100644 index 10d8e6a192..0000000000 --- a/warn-stealers.lua +++ /dev/null @@ -1,102 +0,0 @@ ---@ enable = true - -local eventful = require("plugins.eventful") -local repeatUtil = require("repeat-util") - -local eventfulKey = "warn-stealers" -local numTicksBetweenChecks = 100 - -function gamemodeCheck() - if df.global.gamemode == df.game_mode.DWARF then - return true - end - cache = nil - print("warn-stealers must be run in fort mode") - disable() - return false -end - -cache = cache or {} - -local races = df.global.world.raws.creatures.all - -function addToCacheIfStealer(unitId) - if not gamemodeCheck() then - return - end - local unit = df.unit.find(unitId) - assert(unit, "New active unit detected with id " .. unitId .. " was not found!") - local casteFlags = races[unit.race].caste[unit.caste].flags - if casteFlags.CURIOUS_BEAST_EATER or casteFlags.CURIOUS_BEAST_GUZZLER or casteFlags.CURIOUS_BEAST_ITEM then - cache[unitId] = true - end -end - -function announce(unit) - local caste = races[unit.race].caste[unit.caste] - local casteFlags = caste.flags - local desires = {} - if casteFlags.CURIOUS_BEAST_EATER then - table.insert(desires, "eat food") - end - if casteFlags.CURIOUS_BEAST_GUZZLER then - table.insert(desires, "guzzle drinks") - end - if casteFlags.CURIOUS_BEAST_ITEM then - table.insert(desires, "steal items") - end - local str = table.concat(desires, " and ") - dfhack.gui.showZoomAnnouncement(-1, unit.pos, "A " .. caste.caste_name[0] .. " has appeared, it may " .. str .. ".", COLOR_RED, true) -end - -function checkCache() - if not gamemodeCheck() then - return - end - for unitId in pairs(cache) do - local unit = df.unit.find(unitId) - if unit then - if unit.flags1.inactive then - cache[unitId] = nil - elseif not dfhack.units.isHidden(unit) then - announce(unit) - cache[unitId] = nil - end - else - cache[unitId] = nil - end - end -end - -function help() - print(dfhack.script_help()) -end - -function enable() - if not gamemodeCheck() then - return - end - eventful.enableEvent(eventful.eventType.UNIT_NEW_ACTIVE, numTicksBetweenChecks) - eventful.onUnitNewActive[eventfulKey] = addToCacheIfStealer - repeatUtil.scheduleEvery(eventfulKey, numTicksBetweenChecks, "ticks", checkCache) - -- in case any units were missed - for _, unit in ipairs(df.global.world.units.active) do - addToCacheIfStealer(unit.id) - end - print("warn-stealers running") -end - -function disable() - eventful.onUnitNewActive[eventfulKey] = nil - repeatUtil.cancel(eventfulKey) - print("warn-stealers stopped") -end - -local action_switch = {enable = enable, disable = disable} -setmetatable(action_switch, {__index = function() return help end}) - -args = {...} -if dfhack_flags and dfhack_flags.enable then - args = {dfhack_flags.enable_state and "enable" or "disable"} -end -action_switch[args[1] or "help"]() diff --git a/warn-stranded.lua b/warn-stranded.lua index b68f80ead8..d916057d05 100644 --- a/warn-stranded.lua +++ b/warn-stranded.lua @@ -1,43 +1,139 @@ -- Detects and alerts when a citizen is stranded --- Logic heavily based off of warn-starving --- GUI heavily based off of autobutcher --@module = true -local gui = require 'gui' -local widgets = require 'gui.widgets' -local argparse = require 'argparse' -local args = {...} -local scriptPrefix = 'warn-stranded' +local gui = require('gui') +local widgets = require('gui.widgets') + +local GLOBAL_KEY = 'warn-stranded_v2' + ignoresCache = ignoresCache or {} --- =============================================== --- Utility Functions --- =============================================== +local function persist_state() + -- convert integer keys to strings for storage + local data = {} + for k,v in pairs(ignoresCache) do + data[tostring(k)] = v + end + dfhack.persistent.saveSiteData(GLOBAL_KEY, data) +end --- Clear the ignore list -local function clear() - for index, entry in pairs(ignoresCache) do - entry:delete() - ignoresCache[index] = nil +dfhack.onStateChange[GLOBAL_KEY] = function(sc) + if sc ~= SC_MAP_LOADED or df.global.gamemode ~= df.game_mode.DWARF then + return + end + ignoresCache = dfhack.persistent.getSiteData(GLOBAL_KEY, {}) + -- convert the string keys back into integers + for k,v in pairs(ignoresCache) do + if type(k) == 'string' then + ignoresCache[tonumber(k)] = v + ignoresCache[k] = nil + end + end +end + +-- ==================================== +-- Core logic +-- ==================================== + +local function getWalkGroup(pos) + local walkGroup = dfhack.maps.getWalkableGroup(pos) + return walkGroup ~= 0 and walkGroup or nil +end + +local function hasAllowlistedJob(unit) + local job = unit.job.current_job + if not job then return false end + return job.job_type == df.job_type.GatherPlants or + df.job_type_class[df.job_type.attrs[job.job_type].type] == 'Digging' +end + +local function hasAllowlistedPos(pos) + local bld = dfhack.buildings.findAtTile(pos) + return bld and bld:getType() == df.building_type.Hatch and + not bld.door_flags.closed +end + +-- used by gui/notify +function getStrandedGroups() + if not dfhack.isMapLoaded() then + return {} + end + + local groupCount = 0 + local unitsByWalkGroup, ignoredUnitsByWalkGroup = {}, {} + + for _, unit in ipairs(dfhack.units.getCitizens(true)) do + if unit.relationship_ids.RiderMount ~= -1 then goto skip end + local unitPos = xyz2pos(dfhack.units.getPosition(unit)) + local walkGroup = getWalkGroup(unitPos) + + -- if on an unpathable tile, use the walkGroup of an adjacent tile. this prevents + -- warnings for units that are walking under falling water, which sometimes makes + -- a tile unwalkable while the unit is standing on it + if not walkGroup then + walkGroup = getWalkGroup(xyz2pos(unitPos.x-1, unitPos.y-1, unitPos.z)) + or getWalkGroup(xyz2pos(unitPos.x, unitPos.y-1, unitPos.z)) + or getWalkGroup(xyz2pos(unitPos.x+1, unitPos.y-1, unitPos.z)) + or getWalkGroup(xyz2pos(unitPos.x-1, unitPos.y, unitPos.z)) + or getWalkGroup(xyz2pos(unitPos.x+1, unitPos.y, unitPos.z)) + or getWalkGroup(xyz2pos(unitPos.x-1, unitPos.y+1, unitPos.z)) + or getWalkGroup(xyz2pos(unitPos.x, unitPos.y+1, unitPos.z)) + or getWalkGroup(xyz2pos(unitPos.x+1, unitPos.y+1, unitPos.z)) + or 0 + end + + -- Skip units who are: + -- gathering plants (could be on stepladder) + -- digging (could be digging self out of hole) + -- standing on an open hatch (which is its own pathability group) + -- to avoid false positives + if hasAllowlistedJob(unit) or hasAllowlistedPos(unitPos) then + goto skip + end + if ignoresCache[unit.id] then + table.insert(ensure_key(ignoredUnitsByWalkGroup, walkGroup), unit) + else + if not unitsByWalkGroup[walkGroup] then + groupCount = groupCount + 1 + end + table.insert(ensure_key(unitsByWalkGroup, walkGroup), unit) + end + ::skip:: + end + + local groupList = {} + for walkGroup, units in pairs(unitsByWalkGroup) do + table.insert(groupList, {units=units, walkGroup=walkGroup}) end + table.sort(groupList, function(a, b) return #a['units'] < #b['units'] end) + + -- The largest group is not stranded by definition + local mainGroup + if #groupList > 0 then + mainGroup = groupList[#groupList].walkGroup + table.remove(groupList, #groupList) + end + + return groupList, ignoredUnitsByWalkGroup, mainGroup end --- Taken from warn-starving +-- ============================= +-- Gui +-- ============================= + local function getSexString(sex) local sym = df.pronoun_type.attrs[sex].symbol - if sym then - return "("..sym..")" - else - return "" + return ('(%s)'):format(tostring(sym)) end + return '' end --- Partially taken from warn-starving local function getUnitDescription(unit) - return ('[%s] %s %s'):format(dfhack.units.getProfessionName(unit), - dfhack.TranslateName(dfhack.units.getVisibleName(unit)), - getSexString(unit.sex)) + return ('[%s] %s %s'):format( + dfhack.units.getProfessionName(unit), + dfhack.translation.translateName(dfhack.units.getVisibleName(unit)), + getSexString(unit.sex)) end -- Use group data, index, and command arguments to generate a group @@ -58,97 +154,49 @@ local function getGroupDesignation(group, groupIndex, walkGroup) return groupDesignation end --- Add unit.id to text local function addId(text, unit) return text..'|'..unit.id..'| ' end --- =============================================== --- Persistence API --- =============================================== --- Optional refresh parameter forces us to load from API instead of using cache - --- Uses persistent API. Low-level, gets all entries currently in our persistent table --- will return an empty array if needed. Clears and adds entries to our cache. --- Returns the new global ignoresCache value -local function loadIgnoredUnits() - local ignores = dfhack.persistent.get_all(scriptPrefix) - ignoresCache = {} - - if ignores == nil then return ignoresCache end - - for _, entry in ipairs(ignores) do - unit_id = entry.ints[1] - ignoresCache[unit_id] = entry - end - - return ignoresCache -end - --- Uses persistent API. Optional refresh parameter forces us to load from API, --- instead of using our cache. --- Returns the persistent entry or nil -local function unitIgnored(unit, refresh) - if refresh then loadIgnoredUnits() end - - return ignoresCache[unit.id] -end - --- Check for and potentially add [IGNORED] to text. -local function addIgnored(text, unit, refresh) - if unitIgnored(unit, refresh) then - return text..'[IGNORED] ' - end - - return text +local function getIgnoredPrefix(unit) + return ignoresCache[unit.id] and '[IGNORED] ' or '' end --- Uses persistent API. Toggles a unit's ignored status by deleting the entry from the persistence API --- and from the ignoresCache table. -- Returns true if the unit was already ignored, false if it wasn't. -local function toggleUnitIgnore(unit, refresh) - local entry = unitIgnored(unit, refresh) - - if entry then - entry:delete() - ignoresCache[unit.id] = nil - return true - else - entry = dfhack.persistent.save({key = scriptPrefix, ints = {unit.id}}, true) - ignoresCache[unit.id] = entry - return false - end +local function toggleUnitIgnore(unit) + local was_ignored = ignoresCache[unit.id] + ignoresCache[unit.id] = not was_ignored or nil + persist_state() + return was_ignored end -- Does the usual GUI pattern when groups can be in a partial state -- Will ignore everything, unless all units in group are already ignored -- If all units in the group are ignored, then it will unignore all of them local function toggleGroup(groups, groupNumber) - if groupNumber > #groups then + local group = groups[groupNumber] + + if not group then print('Group '..groupNumber..' does not exist') return false end - if groups[groupNumber]['mainGroup'] then + if group.mainGroup then print('Group '..groupNumber..' is the main group of dwarves. Cannot toggle.') return false end - local group = groups[groupNumber] - local allIgnored = true - for _, unit in ipairs(group['units']) do - if not unitIgnored(unit) then + for _, unit in ipairs(group.units) do + if not ignoresCache[unit.id] then allIgnored = false - goto process + break end end - ::process:: - for _, unit in ipairs(group['units']) do - local isIgnored = unitIgnored(unit) + for _, unit in ipairs(group.units) do + local isIgnored = ignoresCache[unit.id] if isIgnored then isIgnored = true else isIgnored = false end - if allIgnored == isIgnored then toggleUnitIgnore(unit) end @@ -157,18 +205,21 @@ local function toggleGroup(groups, groupNumber) return true end --- =============================================================== --- Graphical Interface --- =============================================================== +local function clear() + ignoresCache = {} + persist_state() +end + WarningWindow = defclass(WarningWindow, widgets.Window) WarningWindow.ATTRS{ frame={w=60, h=25, r=2, t=18}, resize_min={w=50, h=15}, frame_title='Stranded citizen warning', resizable=true, + groups=DEFAULT_NIL, } -function WarningWindow:init(info) +function WarningWindow:init() self:addviews{ widgets.List{ frame={l=0, r=0, t=0, b=6}, @@ -212,7 +263,6 @@ function WarningWindow:init(info) }, } - self.groups = info.groups self:initListChoices() end @@ -222,40 +272,31 @@ function WarningWindow:initListChoices() for groupIndex, group in ipairs(self.groups) do local groupDesignation = getGroupDesignation(group, groupIndex) - for _, unit in ipairs(group['units']) do - local text = '' - - text = addIgnored(text, unit) + for _, unit in ipairs(group.units) do + local text = getIgnoredPrefix(unit) text = text..getUnitDescription(unit)..groupDesignation - - table.insert(choices, { text = text, data = {unit = unit, group = groupIndex} }) + table.insert(choices, {text=text, data={unit=unit, group=groupIndex}}) end end - local list = self.subviews.list - list:setChoices(choices) + self.subviews.list:setChoices(choices) end function WarningWindow:onIgnore(_, choice) if not choice then _, choice = self.subviews.list:getSelected() end - local unit = choice.data['unit'] - - toggleUnitIgnore(unit) + toggleUnitIgnore(choice.data.unit) self:initListChoices() end function WarningWindow:onIgnoreAll() - local choices = self.subviews.list:getChoices() - - for _, choice in ipairs(choices) do + for _, choice in ipairs(self.subviews.list:getChoices()) do -- We don't want to flip ignored units to unignored - if not unitIgnored(choice.data['unit']) then - toggleUnitIgnore(choice.data['unit']) + if not ignoresCache[choice.data.unit] then + toggleUnitIgnore(choice.data.unit) end end - self:initListChoices() end @@ -266,24 +307,29 @@ end function WarningWindow:onZoom() local _, choice = self.subviews.list:getSelected() - local unit = choice.data['unit'] + local unit = choice.data.unit local target = xyz2pos(dfhack.units.getPosition(unit)) - dfhack.gui.revealInDwarfmodeMap(target, false, true) + dfhack.gui.revealInDwarfmodeMap(target, true, true) end function WarningWindow:onToggleGroup() - local index, choice = self.subviews.list:getSelected() - local group = choice.data['group'] + local _, choice = self.subviews.list:getSelected() + local group = choice.data.group toggleGroup(self.groups, group) self:initListChoices() end -WarningScreen = defclass(WarningScreen, gui.ZScreenModal) +WarningScreen = defclass(WarningScreen, gui.ZScreen) +WarningScreen.ATTRS{ + focus_path='warn-stranded', + initial_pause=true, + groups=DEFAULT_NIL, +} -function WarningScreen:init(info) - self:addviews{WarningWindow{groups=info.groups}} +function WarningScreen:init() + self:addviews{WarningWindow{groups=self.groups}} end function WarningScreen:onDismiss() @@ -294,117 +340,48 @@ end -- Core Logic -- ====================================================================== -local function compareGroups(group_one, group_two) - return #group_one['units'] < #group_two['units'] -end - -local function getWalkGroup(pos) - local block = dfhack.maps.getTileBlock(pos) - if not block then return end - local walkGroup = dfhack.maps.getWalkableGroup(pos) - return walkGroup ~= 0 and walkGroup or nil -end - -local function getStrandedUnits() - local groupCount = 0 - local grouped = {} - local citizens = dfhack.units.getCitizens(true) - - -- Don't use ignored units to determine if there are any stranded units - -- but keep them to display later - local ignoredGroup = {} - - -- Pathability group calculation is from gui/pathable - for _, unit in ipairs(citizens) do - local unitPos = xyz2pos(dfhack.units.getPosition(unit)) - local walkGroup = getWalkGroup(unitPos) or 0 - - -- if on an unpathable tile, use the walkGroup of an adjacent tile. this prevents - -- warnings for units that are walking under falling water, which sometimes makes - -- a tile unwalkable while the unit is standing on it - if walkGroup == 0 then - walkGroup = getWalkGroup(xyz2pos(unitPos.x-1, unitPos.y-1, unitPos.z)) - or getWalkGroup(xyz2pos(unitPos.x, unitPos.y-1, unitPos.z)) - or getWalkGroup(xyz2pos(unitPos.x+1, unitPos.y-1, unitPos.z)) - or getWalkGroup(xyz2pos(unitPos.x-1, unitPos.y, unitPos.z)) - or getWalkGroup(xyz2pos(unitPos.x+1, unitPos.y, unitPos.z)) - or getWalkGroup(xyz2pos(unitPos.x-1, unitPos.y+1, unitPos.z)) - or getWalkGroup(xyz2pos(unitPos.x, unitPos.y+1, unitPos.z)) - or getWalkGroup(xyz2pos(unitPos.x+1, unitPos.y+1, unitPos.z)) - or 0 - end - - if unitIgnored(unit) then - table.insert(ensure_key(ignoredGroup, walkGroup), unit) - else - table.insert(ensure_key(grouped, walkGroup), unit) - - -- Count each new group - if #grouped[walkGroup] == 1 then - groupCount = groupCount + 1 - end - end - end - - -- No one is stranded, so stop here - if groupCount <= 1 then - return false, ignoredGroup - end - - -- We needed the table for easy grouping - -- Now let us get an array so we can sort easily - local rawGroups = {} - for index, units in pairs(grouped) do - table.insert(rawGroups, { units = units, walkGroup = index }) +local function getStrandedGroupsWithIgnored(groupList, ignoredUnitsByWalkGroup, mainGroup) + if not groupList then + groupList, ignoredUnitsByWalkGroup, mainGroup = getStrandedGroups() end - -- This data structure is super easy to sort from biggest to smallest - -- Our group number is just the array index and is sorted for us - table.sort(rawGroups, compareGroups) - - -- The biggest group is not stranded - mainGroup = rawGroups[#rawGroups]['walkGroup'] - table.remove(rawGroups, #rawGroups) - - -- Merge ignoredGroup with grouped - for index, units in pairs(ignoredGroup) do + -- Merge ignoredGroups with strandedGroups + for walkGroup, units in pairs(ignoredUnitsByWalkGroup or {}) do local groupIndex = nil -- Handle ignored units in mainGroup by shifting other groups down -- We need to list them so they can be toggled - if index == mainGroup then - table.insert(rawGroups, 1, { units = {}, walkGroup = mainGroup, mainGroup = true }) + if walkGroup == mainGroup then + table.insert(groupList, 1, {units={}, walkGroup=mainGroup, mainGroup=true}) groupIndex = 1 end -- Find matching group - for i, group in ipairs(rawGroups) do - if group.walkGroup == index then + for i, group in ipairs(groupList) do + if group.walkGroup == walkGroup then groupIndex = i end end -- No matching group - if groupIndex == nil then - table.insert(rawGroups, { units = {}, walkGroup = index }) - groupIndex = #rawGroups + if not groupIndex then + table.insert(groupList, {units={}, walkGroup=walkGroup}) + groupIndex = #groupList end -- Put all the units in the appropriate group for _, unit in ipairs(units) do - table.insert(rawGroups[groupIndex]['units'], unit) + table.insert(groupList[groupIndex].units, unit) end end -- Key = group number (not pathability group number) -- Value = { units = , walkGroup = , mainGroup = } - return true, rawGroups + return groupList end local function findCitizen(unitId) - local citizens = dfhack.units.getCitizens() - - for _, citizen in ipairs(citizens) do + for _, citizen in ipairs(dfhack.units.getCitizens(true, true)) do if citizen.id == unitId then return citizen end end @@ -412,18 +389,20 @@ local function findCitizen(unitId) end local function ignoreGroup(groups, groupNumber) - if groupNumber > #groups then + local group = groups[groupNumber] + + if not group then print('Group '..groupNumber..' does not exist') return false end - if groups[groupNumber]['mainGroup'] then + if group.mainGroup then print('Group '..groupNumber..' is the main group of dwarves. Not ignoring.') return false end - for _, unit in ipairs(groups[groupNumber]['units']) do - if unitIgnored(unit) then + for _, unit in ipairs(group.units) do + if ignoresCache[unit.id] then print('Unit '..unit.id..' already ignored, doing nothing to them.') else print('Ignoring unit '..unit.id) @@ -435,17 +414,15 @@ local function ignoreGroup(groups, groupNumber) end local function unignoreGroup(groups, groupNumber) - if groupNumber > #groups then + local group = groups[groupNumber] + + if not group then print('Group '..groupNumber..' does not exist') return false end - if groups[groupNumber]['mainGroup'] then - print('Group '..groupNumber..' is the main group of dwarves. Unignoring.') - end - - for _, unit in ipairs(groups[groupNumber]['units']) do - if unitIgnored(unit) then + for _, unit in ipairs(group.units) do + if ignoresCache[unit.id] then print('Unignoring unit '..unit.id) ignored = toggleUnitIgnore(unit) else @@ -456,24 +433,16 @@ local function unignoreGroup(groups, groupNumber) return true end -function doCheck() - local result, strandedGroups = getStrandedUnits() +local function doCheck() + local groupList, ignoredUnitsByWalkGroup, mainGroup = getStrandedGroups() - if result then - return WarningScreen{groups=strandedGroups}:show() + if #groupList > 0 then + return WarningScreen{ + groups=getStrandedGroupsWithIgnored(groupList, ignoredUnitsByWalkGroup, mainGroup), + }:show() end end --- Load ignores list on save game load --- WARNING: This has to be above `dfhack_flags.module` or it will not work as intended on first game load -dfhack.onStateChange[scriptPrefix] = function(state_change) - if state_change ~= SC_MAP_LOADED or df.global.gamemode ~= df.game_mode.DWARF then - return - end - - loadIgnoredUnits() -end - if dfhack_flags.module then return end @@ -486,113 +455,87 @@ end -- Command Line Interface -- ========================================================================= -local positionals = argparse.processArgsGetopt(args, {}) +local positionals = {...} local parameter = tonumber(positionals[2]) if positionals[1] == 'clear' then print('Clearing unit ignore list.') clear() - elseif positionals[1] == 'status' then - local result, strandedGroups = getStrandedUnits() - - if result then + local strandedGroups = getStrandedGroupsWithIgnored() + if #strandedGroups > 0 then for groupIndex, group in ipairs(strandedGroups) do local groupDesignation = getGroupDesignation(group, groupIndex, true) for _, unit in ipairs(group['units']) do - local text = '' - - text = addIgnored(text, unit) + local text = getIgnoredPrefix(unit) text = addId(text, unit) - print(text..getUnitDescription(unit)..groupDesignation) + print(text..dfhack.df2console(getUnitDescription(unit))..groupDesignation) end end - return true end - - print('No citizens are currently stranded.') - - -- We have some ignored citizens - if not (next(strandedGroups) == nil) then - print('\nIgnored citizens:') - - for walkGroup, units in pairs(strandedGroups) do - for _, unit in ipairs(units) do - local text = '' - - text = addId(text, unit) - text = text..getUnitDescription(unit)..' {'..walkGroup..'}' - - print(text) - end + print() + print('Ignored citizens:') + for walkGroup, units in pairs(strandedGroups) do + for _, unit in ipairs(units) do + local text = '' + text = addId(text, unit) + print(text..dfhack.df2console(getUnitDescription(unit))..' {'..walkGroup..'}') end end - + if #strandedGroups == 0 then + print(' None') + end elseif positionals[1] == 'ignore' then if not parameter then print('Must provide unit id to the ignore command.') return false end - local citizen = findCitizen(parameter) - if citizen == nil then print('No citizen with unit id '..parameter..' found in the fortress') return false end - - if unitIgnored(citizen) then + if ignoresCache[citizen.id] then print('Unit '..parameter..' is already ignored. You may want to use the unignore command.') return false end - print('Ignoring unit '..parameter) toggleUnitIgnore(citizen) - elseif positionals[1] == 'ignoregroup' then if not parameter then print('Must provide group id to the ignoregroup command.') end - print('Ignoring group '..parameter) - local _, strandedCitizens = getStrandedUnits() - ignoreGroup(strandedCitizens, parameter) - + local strandedGroups = getStrandedGroupsWithIgnored() + ignoreGroup(strandedGroups, parameter) elseif positionals[1] == 'unignore' then if not parameter then print('Must provide unit id to unignore command.') return false end - local citizen = findCitizen(parameter) - if citizen == nil then print('No citizen with unit id '..parameter..' found in the fortress') return false end - - if not unitIgnored(citizen) then + if not ignoresCache[citizen.id] then print('Unit '..parameter..' is not ignored. You may want to use the ignore command.') return false end - print('Unignoring unit '..parameter) toggleUnitIgnore(citizen) - elseif positionals[1] == 'unignoregroup' then if not parameter then print('Must provide group id to unignoregroup command.') return false end - print('Unignoring group '..parameter) - - local _, strandedCitizens = getStrandedUnits() - unignoreGroup(strandedCitizens, parameter) + local strandedGroups = getStrandedGroupsWithIgnored() + unignoreGroup(strandedGroups, parameter) else view = view and view:raise() or doCheck() end diff --git a/workorder.lua b/workorder.lua index 3467cde14e..21a3d7bde2 100644 --- a/workorder.lua +++ b/workorder.lua @@ -5,6 +5,9 @@ -- which is a great place to look up stuff like "How the hell do I find out if -- a creature can be sheared?!!" +--@ module=true + + local function print_help() print(dfhack.script_help()) end @@ -37,11 +40,11 @@ end local used_types = { df.job_type, df.item_type, - df.manager_order.T_frequency, - df.manager_order_condition_item.T_compare_type, - df.manager_order_condition_order.T_condition, + df.workquota_frequency_type, + df.logic_condition_type, + df.workquota_order_condition_type, df.tool_uses, - df.job_art_specification.T_type + df.job_art_specifier_type } local function print_types(_, filter) for _, t in ipairs(used_types) do @@ -73,18 +76,15 @@ local function orders_match(a, b) end end - local subtables = { - "item_category", - "material_category", - } + for key, value in ipairs(a.specflag.encrust_flags) do + if b.specflag.encrust_flags[key] ~= value then + return false + end + end - for _, fieldname in ipairs(subtables) do - local aa = a[fieldname] - local bb = b[fieldname] - for key, value in ipairs(aa) do - if bb[key] ~= value then - return false - end + for key, value in ipairs(a.material_category) do + if b.material_category[key] ~= value then + return false end end @@ -94,7 +94,7 @@ end -- Get the remaining quantity for open matching orders in the queue. local function cur_order_quantity(order) local amount, cur_order, cur_idx = 0, nil, nil - for idx, managed in ipairs(world.manager_orders) do + for idx, managed in ipairs(world.manager_orders.all) do if orders_match(order, managed) then -- if infinity, don't plan anything if 0 == managed.amount_total then @@ -181,14 +181,14 @@ end -- creates a df.manager_order from it's definition. -- this is translated orders.cpp to Lua, -local function create_orders(orders) +function create_orders(orders, quiet) -- is dfhack.with_suspend necessary? -- we need id mapping to restore saved order_conditions local id_mapping = {} for _, it in ipairs(orders) do - id_mapping[it["id"]] = world.manager_order_next_id - world.manager_order_next_id = world.manager_order_next_id + 1 + id_mapping[it["id"]] = world.manager_orders.manager_order_next_id + world.manager_orders.manager_order_next_id = world.manager_orders.manager_order_next_id + 1 end for _, it in ipairs (orders) do @@ -243,7 +243,7 @@ local function create_orders(orders) end if it["item_category"] then - local ok, bad = set_flags_from_list(it["item_category"], order.item_category) + local ok, bad = set_flags_from_list(it["item_category"], order.specflag.encrust_flags) if not ok then qerror ("Invalid item_category value for manager order: " .. bad) end @@ -265,7 +265,7 @@ local function create_orders(orders) end if it["art"] then - order.art_spec.type = ensure_df_id(df.job_art_specification.T_type, it["art"]["type"]) + order.art_spec.type = ensure_df_id(df.job_art_specifier_type, it["art"]["type"]) or qerror ("Invalid art type value for manager order: " .. it["art"]["type"]) order.art_spec.id = tonumber( it["art"]["id"] ) if it["art"]["subid"] then @@ -278,7 +278,7 @@ local function create_orders(orders) --order.status.validated = it["is_validated"] -- ignoring --order.status.active = it["is_active"] -- ignoring - order.frequency = ensure_df_id(df.manager_order.T_frequency, it["frequency"]) + order.frequency = ensure_df_id(df.workquota_frequency_type, it["frequency"]) or qerror("Invalid frequency value for manager order: " .. it["frequency"]) -- finished_year, finished_year_tick @@ -300,7 +300,7 @@ local function create_orders(orders) condition = df.manager_order_condition_item:new() dfhack.with_onerror(function() condition:delete() end, -- cleanup in case of errors function() - condition.compare_type = ensure_df_id(df.manager_order_condition_item.T_compare_type, it2["condition"]) + condition.compare_type = ensure_df_id(df.logic_condition_type, it2["condition"]) or qerror ("Invalid item condition for manager order: " .. it2["condition"] ) condition.compare_val = tonumber(it2["value"]) @@ -342,13 +342,13 @@ local function create_orders(orders) if it2["bearing"] then local bearing = it2["bearing"] local idx - for i, raw in ipairs(world.raws.inorganics) do + for i, raw in ipairs(world.raws.inorganics.all) do if raw.id == bearing then idx = i break end end - condition.inorganic_bearing = idx + condition.metal_ore = idx or qerror( "Invalid item condition inorganic bearing type for manager order: " .. it2["bearing"] ) end @@ -386,7 +386,7 @@ local function create_orders(orders) condition.order_id = id ~= it["id"] and id_mapping[id] or qerror("Missing order condition target for manager order: " .. it2["order"]) - condition.condition = ensure_df_id(df.manager_order_condition_order.T_condition, it2["condition"]) + condition.condition = ensure_df_id(df.workquota_order_condition_type, it2["condition"]) or qerror ( "Invalid order condition type for manager order: " .. it2["condition"] ) -- condition.unk_1 @@ -395,7 +395,7 @@ local function create_orders(orders) end) end end - --order.items = vector + --order.items.elements = vector local amount = it.amount_total if it.__reduce_amount then @@ -412,7 +412,7 @@ local function create_orders(orders) cur_order.amount_total = cur_order.amount_total + diff if cur_order.amount_left <= 0 then if verbose then print('negative amount; removing existing order') end - world.manager_orders:erase(cur_order_idx) + world.manager_orders.all:erase(cur_order_idx) cur_order:delete() end end @@ -429,16 +429,22 @@ local function create_orders(orders) order.amount_left = amount order.amount_total = amount - print("Queuing " .. df.job_type[order.job_type] - .. (amount==0 and " infinitely" or " x"..amount)) - world.manager_orders:insert('#', order) + local job_type = df.job_type[order.job_type] + if job_type == "CustomReaction" then + job_type = job_type .. " '" .. order.reaction_name .. "'" + end + if not quiet then + print("Queuing " .. job_type + .. (amount==0 and " infinitely" or " x"..amount)) + end + world.manager_orders.all:insert('#', order) end end) end end -- set missing values, process special `amount_total` value -local function preprocess_orders(orders) +function preprocess_orders(orders) -- if called with single order make an array if orders.job then orders = {orders} @@ -506,7 +512,7 @@ local order_defaults = { frequency = 'OneTime' } local _order_mt = {__index = order_defaults} -local function fillin_defaults(orders) +function fillin_defaults(orders) for _, order in ipairs(orders) do setmetatable(order, _order_mt) end @@ -588,10 +594,7 @@ end -- true/false or nil if no shearable_tissue_layer with length > 0. local function canShearCreature(u) - local stls = world.raws.creatures - .all[u.race] - .caste[u.caste] - .shearable_tissue_layer + local stls = dfhack.units.getCasteRaw(u).shearable_tissue_layer local any for _, stl in ipairs(stls) do @@ -648,5 +651,9 @@ actions = { ["--reset"] = function() initialized = false end, } +if dfhack_flags.module then + return +end + -- Lua is beautiful. (actions[ (...) or "?" ] or default_action)(...)