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 ac959adc36..2ca5c64fcc 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.4.0 + rev: v4.6.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.0 + rev: 0.29.1 hooks: - id: check-github-workflows - repo: https://github.com/Lucas-C/pre-commit-hooks - rev: v1.5.4 + rev: v1.5.5 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.4.0 + rev: v4.6.0 hooks: - id: forbid-new-submodules diff --git a/CMakeLists.txt b/CMakeLists.txt index 3631626adc..e9e5234b0d 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -1,9 +1,8 @@ install(DIRECTORY ${CMAKE_CURRENT_SOURCE_DIR} DESTINATION ${DFHACK_DATA_DESTINATION} FILES_MATCHING PATTERN "*.lua" - PATTERN "*.rb" PATTERN "*.json" - PATTERN "3rdparty" EXCLUDE + PATTERN "scripts/docs" EXCLUDE PATTERN "scripts/test" EXCLUDE ) 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-thought.lua b/add-thought.lua index e5e506c9bb..40228356c1 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.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..6b2b0d09af --- /dev/null +++ b/advtools.lua @@ -0,0 +1,30 @@ +--@ module=true + +local convo = reqscript('internal/advtools/convo') +local party = reqscript('internal/advtools/party') + +OVERLAY_WIDGETS = { + conversation=convo.AdvRumorsOverlay, +} + +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..e5a9ad7fdc --- /dev/null +++ b/agitation-rebalance.lua @@ -0,0 +1,791 @@ +--@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: ' .. 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.get_state().config[WIDGET_NAME].enabled 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: ' .. 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]) +elseif command == 'enable' or command == 'disable' then + enable_feature(args[1], 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..5f930f985f 100644 --- a/armoks-blessing.lua +++ b/armoks-blessing.lua @@ -1,43 +1,8 @@ -- 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 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.") @@ -239,22 +204,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 +231,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 +247,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.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 end end diff --git a/assign-minecarts.lua b/assign-minecarts.lua index 5554e69022..3cff2cd726 100644 --- a/assign-minecarts.lua +++ b/assign-minecarts.lua @@ -21,6 +21,13 @@ local function has_stops(route) return #route.stops > 0 end +local function get_minecart(route) + if not has_minecart(route) 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 @@ -31,7 +38,7 @@ end local function assign_minecart_to_route(route, quiet, minecart) if has_minecart(route) then - return true + return get_minecart(route) end if not has_stops(route) then if not quiet then @@ -57,11 +64,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 diff --git a/assign-preferences.lua b/assign-preferences.lua index 3eedaf30b6..ba791278f2 100644 --- a/assign-preferences.lua +++ b/assign-preferences.lua @@ -305,7 +305,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 diff --git a/assign-profile.lua b/assign-profile.lua index a996c46135..d1207a44bf 100644 --- a/assign-profile.lua +++ b/assign-profile.lua @@ -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/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/ban-cooking.lua b/ban-cooking.lua index eca6b63c57..fdb59fbe05 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 @@ -155,30 +137,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 +173,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 +208,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 +224,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 +259,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/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..2d3a0cfb79 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, } 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 67bde5550a..ff92542951 100644 --- a/changelog.txt +++ b/changelog.txt @@ -27,15 +27,400 @@ Template for new versions: # Future ## New Tools +- `embark-anyone`: allows you to embark as any civilisation, including dead, and non-dwarven ones ## 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 ## Fixes +- `timestream`: ensure child growth events (e.g. becoming an adult) are not skipped over ## Misc Improvements +- `gui/sitemap`: show whether a unit is friendly, hostile, or wildlife +- `gui/sitemap`: show whether a unit is caged ## Removed +# 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 callling 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 shoees 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 irriation-related attacks so they are less constant and are more responsive to recent player bahavior +- `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 resistence to being affected by visible corpses +- `add-thought`: (reinstated) add custom thoughts to a dwarf +- `devel/input-monitor`: interactive UI for debugging input issues + +## Fixes +- `gui/design`: clicking the center point when there is a design mark behind it will no longer simultaneously enter both mark dragging and center dragging modes. Now you can click once to move the shape, and click twice to move only the mark behind the center point. +- `fix/retrieve-units`: prevent pulling in duplicate units from offscreen +- `warn-stranded`: when there was at least one truly stuck unit and miners were actively mining, the miners were also confusingly shown in the stuck units list +- `source`: fix issue where removing sources would make some other sources inactive +- `caravan`: display book and scroll titles in the goods and trade dialogs instead of generic scroll descriptions +- `item`: avoid error when scanning items that have no quality rating (like bars and other construction materials) +- `gui/blueprint`: changed hotkey for setting blueprint origin tile so it doesn't conflict with default map movement keys +- `gui/control-panel`: fix error when toggling autostart settings + +## Misc Improvements +- `exportlegends`: make progress increase smoothly over the entire export and increase precision of progress percentage +- `gui/autobutcher`: ask for confirmation before zeroing out targets for all races +- `caravan`: move goods to trade depot dialog now allocates more space for the display of the value of very expensive items +- `extinguish`: allow selecting units/items/buildings in the UI to target them for extinguishing; keyboard cursor is only required for extinguishing map tiles that cannot be selected any other way +- `item`: change syntax so descriptions can be searched for without indicating the ``--description`` option. e.g. it's now ``item count royal`` instead of ``item count --description royal`` +- `item`: add ``--verbose`` option to print each item as it is matched +- `gui/mod-manager`: will automatically unmark the default mod profile from being the default if it fails to load (due to missing or incompatible mods) +- `gui/quickfort`: can now dynamically adjust the dig priority of tiles designated by dig blueprints +- `gui/quickfort`: can now opt to apply dig blueprints in marker mode + +## Removed +- `gui/manager-quantity`: the vanilla UI can now modify manager order quantities after creation +- `gui/create-tree`: replaced by `gui/sandbox` +- `warn-starving`: combined into `gui/notify` +- `warn-stealers`: combined into `gui/notify` + +# 50.11-r6 + +## Fixes +- `makeown`: fix error when adopting units that need a historical figure to be created +- `item`: fix missing item categories when using ``--by-type`` + +# 50.11-r5 + +## New Tools +- `control-panel`: new commandline interface for control panel functions +- `uniform-unstick`: (reinstated) force squad members to drop items that they picked up in the wrong order so they can get everything equipped properly +- `gui/reveal`: temporarily unhide terrain and then automatically hide it again when you're ready to unpause +- `gui/teleport`: mouse-driven interface for selecting and teleporting units +- `gui/biomes`: visualize and inspect biome regions on the map +- `gui/embark-anywhere`: bypass those pesky warnings and embark anywhere you want to +- `item`: perform bulk operations on groups of items. + +## New Features +- `uniform-unstick`: add overlay to the squad equipment screen to show a equipment conflict report and give you a one-click button to (attempt to) fix +- `gui/settings-manager`: save and load embark difficulty settings and standing orders; options for auto-load on new embark + +## Fixes +- `source`: water and magma sources and sinks now persist with fort across saves and loads +- `gui/design`: fix incorrect dimensions being shown when you're placing a stockpile, but a start coordinate hasn't been selected yet +- `warn-stranded`: don't warn for citizens who are only transiently stranded, like those on stepladders gathering plants or digging themselves out of a hole +- `ban-cooking`: fix banning creature alcohols resulting in error +- `confirm`: properly detect clicks on the remove zone button even when the unit selection screen is also open (e.g. the vanilla assign animal to pasture panel) +- `caravan`: ensure items are marked for trade when the move trade goods dialog is closed even when they were selected and then the list filters were changed such that the items were no longer actively shown +- `quickfort`: if a blueprint specifies an up/down stair, but the tile the blueprint is applied to cannot make an up stair (e.g. it has already been dug out), still designate a down stair if possible +- `suspendmanager`: correctly handle building collisions with smoothing designations when the building is on the edge of the map +- `empty-bin`: now correctly sends ammunition in carried quivers to the tile underneath the unit instead of teleporting them to an invalid (or possibly just far away) location + +## Misc Improvements +- `warn-stranded`: center the screen on the unit when you select one in the list +- `gui/control-panel`: reduce frequency for `warn-stranded` check to once every 2 days +- `gui/control-panel`: tools are now organized by type: automation, bugfix, and gameplay +- `confirm`: updated confirmation dialogs to use clickable widgets and draggable windows +- `confirm`: added confirmation prompt for right clicking out of the trade agreement screen (so your trade agreement selections aren't lost) +- `confirm`: added confirmation prompts for irreversible actions on the trade screen +- `confirm`: added confirmation prompt for deleting a uniform +- `confirm`: added confirmation prompt for convicting a criminal +- `confirm`: added confirmation prompt for re-running the embark site finder +- `confirm`: added confirmation prompt for reassigning or clearing zoom hotkeys +- `confirm`: added confirmation prompt for exiting the uniform customization page without saving +- `gui/autobutcher`: interface redesigned to better support mouse control +- `gui/launcher`: now persists the most recent 32KB of command output even if you close it and bring it back up +- `gui/quickcmd`: clickable buttons for command add/remove/edit operations +- `uniform-unstick`: warn if a unit belongs to a squad from a different site (can happen with migrants from previous forts) +- `gui/mass-remove`: can now differentiate planned constructions, stockpiles, and regular buildings +- `gui/mass-remove`: can now remove zones +- `gui/mass-remove`: can now cancel removal for buildings and constructions +- `fix/stuck-instruments`: now handles instruments that are left in the "in job" state but that don't have any actual jobs associated with them +- `gui/launcher`: make autocomplete case insensitive + +# 50.11-r4 + +## New Tools +- `build-now`: (reinstated) instantly complete unsuspended buildings that are ready to be built + +## Fixes +- `combine`: prevent stack sizes from growing beyond quantities that you would normally see in vanilla gameplay +- `gui/design`: Center dragging shapes now track the mouse correctly + +## Misc Improvements +- `caravan`: enable searching within containers in trade screen when in "trade bin with contents" mode + +# 50.11-r3 + +## New Tools +- `sync-windmills`: synchronize or randomize movement of active windmills +- `trackstop`: (reimplemented) integrated overlay for changing track stop and roller settings after construction + +## New Features +- `gui/design`: show selected dimensions next to the mouse cursor when designating with vanilla tools, for example when painting a burrow or designating digging +- `quickfort`: new ``burrow`` blueprint mode for designating or manipulating burrows +- `unforbid`: now ignores worn and tattered items by default (X/XX), use -X to bypass +- `fix/dead-units`: gained ability to scrub dead units from burrow membership lists + +## Fixes +- `gui/unit-syndromes`: show the syndrome names properly in the UI +- `emigration`: fix clearing of work details assigned to units that leave the fort + +## Misc Improvements +- `warn-stranded`: don't warn for units that are temporarily on unwalkable tiles (e.g. as they pass under a waterfall) + +## Removed +- `gui/control-panel`: removed always-on system services from the ``System`` tab: `buildingplan`, `confirm`, `logistics`, and `overlay`. The base services should not be turned off by the player. Individual confirmation prompts can be managed via `gui/confirm`, and overlays (including those for `buildingplan` and `logistics`) are managed on the control panel ``Overlays`` tab. +- `gui/control-panel`: removed `autolabor` from the ``Fort`` and ``Autostart`` tabs. The tool does not function correctly with the new labor types, and is causing confusion. You can still enable `autolabor` from the commandline with ``enable autolabor`` if you understand and accept its limitations. + # 50.11-r2 ## New Tools @@ -57,6 +442,7 @@ Template for new versions: - `gui/sandbox`: fix scrollbar moving double distance on click - `hide-tutorials`: fix the embark tutorial prompt sometimes not being skipped - `full-heal`: fix removal of corpse after resurrection +- `toggle-kbd-cursor`: clear the cursor position when disabling, preventing the game from sometimes jumping the viewport around when cursor keys are hit ## Misc Improvements - `prioritize`: refuse to automatically prioritize dig and smooth/carve job types since it can break the DF job scheduler; instead, print a suggestion that the player use specialized units and vanilla designation priorities 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..554795229e 100644 --- a/colonies.lua +++ b/colonies.lua @@ -30,7 +30,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 +39,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 @@ -61,8 +61,8 @@ 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 = {...} @@ -75,7 +75,7 @@ elseif args[1] == 'convert' then 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..3253d28db3 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,15 +170,15 @@ 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 @@ -185,22 +188,22 @@ local function stacks_add_item(stockpile, stacks, stack_type, item, container) 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 +216,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 +234,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 +257,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 +268,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 +286,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 +297,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 +326,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 +377,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 +395,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 +410,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 +433,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 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 +488,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 +560,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 +571,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 +583,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 +616,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 +660,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 +705,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 +723,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 +735,12 @@ 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) 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 +788,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 +806,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..fb0a108ed6 --- /dev/null +++ b/confirm.lua @@ -0,0 +1,202 @@ +--@ 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 +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() + if self.paused_conf and + not dfhack.gui.matchFocusString(self.paused_conf.context, + dfhack.gui.getDFViewscreen(true)) + then + self.paused_conf = nil + 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.paused_conf or 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 + 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_conf = conf + 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 + dialogs.showYesNoPrompt(conf.title, utils.getval(conf.message):wrap(45), COLOR_YELLOW, + propagate_fn, nil, curry(propagate_fn, true), 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..9b0d4fad61 100644 --- a/deathcause.lua +++ b/deathcause.lua @@ -1,44 +1,19 @@ -- show death cause of a creature -local utils = require('utils') + 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 +function getItemAtPosition(pos) + for _, item in ipairs(df.global.world.items.other.ANY_CORPSE) do + 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') -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] + return df.creature_raw.find(race_id).name[0] end function getDeathStringFromCause(cause) @@ -55,9 +30,8 @@ function displayDeathUnit(unit) str = str .. (" %s"):format(dfhack.TranslateName(unit.name)) end - if not unit.flags2.killed and not unit.flags3.ghostly then - str = str .. " is not dead yet!" - print(str) + if not dfhack.units.isDead(unit) then + print(str .. " is not dead yet!") return end @@ -70,7 +44,7 @@ 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)) end @@ -99,7 +73,7 @@ function displayDeathEventHistFigUnit(histfig_unit, event) 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), @@ -128,20 +102,15 @@ function getDeathEventForHistFig(histfig_id) end end end - - return nil end function displayDeathHistFig(histfig) - local histfig_unit = findUnit(histfig.unit_id) + 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 + if not dfhack.units.isDead(histfig_unit) then print(("%s is not dead yet!"):format(dfhack.TranslateName(histfig_unit.name))) else local death_event = getDeathEventForHistFig(histfig.id) @@ -149,42 +118,46 @@ function displayDeathHistFig(histfig) 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] - end +local function is_corpse_item(item) + if not item then return false end + local itype = item:getType() + return itype == df.item_type.CORPSE or itype == df.item_type.CORPSEPIECE end -if not selected_unit and not selected_item then - qerror("Please select a corpse") -end +local view_sheets = df.global.game.main_interface.view_sheets -if selected_item then - hist_figure_id = selected_item.hist_figure_id -elseif selected_unit then - hist_figure_id = selected_unit.hist_figure_id +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 +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) else - displayDeathHistFig(findHistFig(hist_figure_id)) + displayDeathHistFig(df.historical_figure.find(hist_figure_id)) end diff --git a/deep-embark.lua b/deep-embark.lua index 5953ae0dac..2e5f141283 100644 --- a/deep-embark.lua +++ b/deep-embark.lua @@ -1,6 +1,3 @@ --- Embark underground. --- author: Atomic Chicken - --@ module = true local utils = require 'utils' @@ -50,8 +47,8 @@ function getFeatureBlocks(featureID) end function isValidTiletype(tiletype) - local tiletype = df.tiletype[tiletype] - local tiletypeAttrs = df.tiletype.attrs[tiletype] + local tt = df.tiletype[tiletype] + local tiletypeAttrs = df.tiletype.attrs[tt] local material = tiletypeAttrs.material local forbiddenMaterials = { df.tiletype_material.TREE, -- so as not to embark stranded on top of a tree @@ -95,7 +92,7 @@ function blockGlowingBarrierAnnouncements(recenter) dfhack.timeout(1,'ticks', function() -- barrier disappears after 1 tick announcementFlags:assign(oldFlags) -- restore announcement settings if recenter then --- Remove glowing barrier notifications: + -- Remove glowing barrier notifications: local status = df.global.world.status local announcements = status.announcements for i = #announcements-1, 0, -1 do @@ -125,13 +122,13 @@ function reveal(pos) 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 + local barriers = df.global.world.event.glowing_barriers local barrier = df.glowing_barrier:new() barrier.buildings:insert('#',-1) -- being unbound to a building makes the barrier disappear immediately barrier.pos:assign(pos) barriers:insert('#',barrier) local hfs = df.glowing_barrier:new() - hfs.triggered = true -- this prevents HFS events (which can otherwise be triggered by the barrier disappearing) + 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 @@ -181,7 +178,7 @@ function moveEmbarkStuff(selectedBlock, embarkTiles) 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) + 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) @@ -216,7 +213,6 @@ function moveEmbarkStuff(selectedBlock, embarkTiles) 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 @@ -259,7 +255,7 @@ 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 + for _, spire in ipairs(df.global.world.event.deep_vein_hollows) do spire.triggered = true end end diff --git a/deteriorate.lua b/deteriorate.lua index eb94dcd143..ddd6c0f872 100644 --- a/deteriorate.lua +++ b/deteriorate.lua @@ -41,7 +41,7 @@ local function keep_usable(opts, item) item.corpse_flags.bone or item.corpse_flags.horn or item.corpse_flags.leather or - item.corpse_flags.skull2 or + item.corpse_flags.skull or item.corpse_flags.tooth) or ( item.corpse_flags.hair_wool or item.corpse_flags.pearl or @@ -109,8 +109,7 @@ local function deteriorate(opts, get_item_vectors_fn, is_valid_fn, increment_wea 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 + dfhack.items.remove(item) count = count + 1 end end 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 1b5913ca20..5d8169697b 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 @@ -130,6 +123,7 @@ address('world_site_type',df.world_site,'type') address('active_sites_vector',df.world_data,'active_site') address('gview',globals,'gview') address('external_flag',globals,'game','external_flag') +address('global_equipment_update',globals,'plotinfo','equipment','update') vtable('viewscreen_setupdwarfgame_vtable','viewscreen_setupdwarfgamest') header('offsets') @@ -175,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') @@ -243,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') @@ -320,11 +308,11 @@ 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') @@ -405,13 +393,13 @@ address('focus_level',df.unit_personality.T_needs,'focus_level') address('need_level',df.unit_personality.T_needs,'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') @@ -433,22 +421,27 @@ 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') header('activity_offsets') address('activity_type',df.activity_entry,'type') @@ -459,8 +452,8 @@ 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') @@ -510,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/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/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/make-dt.pl b/devel/make-dt.pl index 4bde9e22c6..4bffeb0d55 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'; 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/query.lua b/devel/query.lua index e18ba39450..18859eaaed 100644 --- a/devel/query.lua +++ b/devel/query.lua @@ -220,7 +220,7 @@ function getSelectionData() path_info_pattern = path_info elseif args.job then debugf(0,"job selection") - selection = dfhack.gui.getSelectedJob() + selection = dfhack.gui.getSelectedJob(true) if selection == nil and df.global.cursor.x >= 0 then local pos = { x=df.global.cursor.x, y=df.global.cursor.y, diff --git a/devel/sc.lua b/devel/sc.lua index 6cf8aab27b..3c3eb7c6cc 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 diff --git a/devel/scan-vtables.lua b/devel/scan-vtables.lua index c64d549d16..74ce5a1294 100644 --- a/devel/scan-vtables.lua +++ b/devel/scan-vtables.lua @@ -7,7 +7,7 @@ if osType ~= 'linux' then end local df_ranges = {} -for i,mem in ipairs(dfhack.internal.getMemRanges()) do +for _, mem in ipairs(dfhack.internal.getMemRanges()) do if mem.read and ( string.match(mem.name,'/dwarfort%.exe$') or string.match(mem.name,'/dwarfort$') @@ -29,43 +29,68 @@ function is_df_addr(a) 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 names = {} - 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(g_src) + local vtables = {} + for _, range in ipairs(df_ranges) do + if (not range.read) or range.write or range.execute then + goto next_range + end + if not not range.name:match('g_src') ~= g_src then + goto next_range + end + + local base = range.name:match('.*/(.*)$') + 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 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 names[demangled_name] + then + local base_str = '' + if g_src then + vtable = vtable - range.base_addr + base_str = (" base='%s'"):format(base) end + vtables[demangled_name] = {value=vtable, base_str=base_str} end + ::next_ptr:: + end + ::next_range:: + end + for name, data in pairs(vtables) do + if not names[name] then + print((""):format(name, data.value, data.base_str)) + names[name] = true end - end - ::next_range:: end + +scan_ranges(false) +scan_ranges(true) 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..4bd9811ed5 --- /dev/null +++ b/devel/tree-info.lua @@ -0,0 +1,153 @@ +--Print a tree_info visualization of the tree at the cursor. +--@module = true + +local branch_dir = +{ + [0] = ' ', + [1] = string.char(26), --W + [2] = string.char(25), --N + [3] = string.char(217), --WN + [4] = string.char(27), --E + [5] = string.char(196), --WE + [6] = string.char(192), --NE + [7] = string.char(193), --WNE + [8] = string.char(24), --S + [9] = string.char(191), --WS + [10] = string.char(179), --NS + [11] = string.char(180), --WNS + [12] = string.char(218), --ES + [13] = string.char(194), --WES + [14] = string.char(195), --NES + [15] = string.char(197), --WNES +} + +local function print_color(s, color) + dfhack.color(color) + dfhack.print(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_dir[bits.branches_dir] 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 p = dfhack.maps.getPlantAtTile(copyall(df.global.cursor)) + 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 707ecf8e94..459ff92159 100644 --- a/devel/unit-path.lua +++ b/devel/unit-path.lua @@ -47,15 +47,6 @@ local function getTileType(cursor) end end -local function getTileWalkable(cursor) - local block = dfhack.maps.getTileBlock(cursor) - if block then - return block.walkable[cursor.x%16][cursor.y%16] - else - return 0 - end -end - local function paintMapTile(dc, vp, cursor, pos, ...) if not same_xyz(cursor, pos) then local stile = vp:tileToScreen(pos) @@ -115,7 +106,7 @@ function UnitPathUI:renderPath(dc,vp,cursor) end end local color = COLOR_LIGHTGREEN - if getTileWalkable(pt) == 0 then color = COLOR_LIGHTRED end + if dfhack.maps.getWalkableGroup(pt) == 0 then color = COLOR_LIGHTRED end paintMapTile(dc, vp, cursor, pt, char, color) end end diff --git a/do-job-now.lua b/do-job-now.lua index c6994dc5f3..4e83970c8f 100644 --- a/do-job-now.lua +++ b/do-job-now.lua @@ -1,27 +1,11 @@ -- 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``:: +local utils = require('utils') - keybinding add Alt-N do-job-now - -Also see ``do-job-now`` `tweak` and `prioritize`. -]====] +local function print_help() + print(dfhack.script_help()) end -local utils = require 'utils' - local function getUnitName(unit) local language_name = dfhack.units.getVisibleName(unit) if language_name.has_name then @@ -150,7 +134,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..c62cdb1f83 --- /dev/null +++ b/docs/advtools.rst @@ -0,0 +1,37 @@ +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. diff --git a/docs/agitation-rebalance.rst b/docs/agitation-rebalance.rst new file mode 100644 index 0000000000..790af212b4 --- /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 resistence. + +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 widlife 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 exeeds 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/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/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..1e365cd159 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,24 +65,93 @@ 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 acceptible 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 convenent 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. 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/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..9a0ec5f530 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,6 +34,7 @@ Examples Commands -------- + ``all`` Search all stockpiles. ``here`` @@ -34,57 +42,48 @@ Commands 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..ab7dff04d9 --- /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 frightenly 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/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..beb6785221 --- /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 verison of DF so the array of strings can be read. + +Usage +----- + +:: + + devel/dump-tooltip-ids 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..8d271084c2 --- /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 lables 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/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..753317e883 --- /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 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`). 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 d53345b117..58a3d623e7 100644 --- a/docs/emigration.rst +++ b/docs/emigration.rst @@ -3,7 +3,7 @@ emigration .. dfhack-tool:: :summary: Allow dwarves to emigrate from the fortress when stressed. - :tags: fort auto gameplay units + :tags: fort gameplay units If a dwarf is spiraling downward and is unable to cope in your fort, this tool will give them the choice to leave the fortress (and the map). diff --git a/docs/empty-bin.rst b/docs/empty-bin.rst index 2ac8138b47..1d45eb81aa 100644 --- a/docs/empty-bin.rst +++ b/docs/empty-bin.rst @@ -6,12 +6,37 @@ 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, onto the floor. + +``empty-bin --liquids`` + Empty the contents of selected containers or all containers in the selected stockpile or building, including containers with liquids, onto the floor. + +``empty-bin --recursive --liquids`` + Empty the contents of selected containers or all containers in the selected stockpile or building, including containers with liquids and containers contents that are containers, such as a bags of seeds or filled waterskins, onto the floor. + +Options +-------------- + +``-r``, ``--recursive`` + Recursively empty containers. +``-l``, ``--liquids`` + Move contained liquids (DRINK and LIQUID_MISC) to the floor, making them unusable. diff --git a/docs/exterminate.rst b/docs/exterminate.rst index 1e2609a81c..664019aec2 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 ------- @@ -41,6 +47,8 @@ Options 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..ceaae431d2 100644 --- a/docs/extinguish.rst +++ b/docs/extinguish.rst @@ -8,7 +8,8 @@ extinguish 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/fix/dead-units.rst b/docs/fix/dead-units.rst index 04e5e53ed2..49156be5f7 100644 --- a/docs/fix/dead-units.rst +++ b/docs/fix/dead-units.rst @@ -10,9 +10,26 @@ If so many units have died at your fort that your dead units list exceeds about (like slaughtered animals and nameless goblins) from the unit list, allowing migrants to start coming again. +It also supports scanning burrows and cleaning out dead units from burrow +assignments. The vanilla UI doesn't provide any way to remove dead units, and +the dead units artificially increase the reported count of units that are +assigned to the burrow. + Usage ----- :: - fix/dead-units + fix/dead-units [--active] [-q] + fix/dead-units --burrow [-q] + +Options +------- + +``--active`` + Scrub units that have been dead for more than a month from the ``active`` + vector. This is the default if no option is specified. +``--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). diff --git a/docs/fix/empty-wheelbarrows.rst b/docs/fix/empty-wheelbarrows.rst index a6070d3091..eb6d155104 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 ----- 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/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..18ee5518a9 --- /dev/null +++ b/docs/fix/ownership.rst @@ -0,0 +1,20 @@ +fix/ownership +============= + +.. dfhack-tool:: + :summary: Fixes instances of units claiming the same item or an item they don't own. + :tags: fort bugfix units + +Due to a bug a unit can believe they own an item when they actually do not. + +When enabled in `gui/control-panel`, `fix/ownership` will run once a day to check citizens and residents and make sure they don't +mistakenly own an item they shouldn't. + +This should help issues of units getting stuck in a "Store owned item" job. + +Usage +----- + +:: + + fix/ownership diff --git a/docs/fix/population-cap.rst b/docs/fix/population-cap.rst index e259a83044..c1ebb9ae04 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 wave 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..1b3a0bc6ae --- /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 accross 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-worship.rst b/docs/fix/stuck-worship.rst new file mode 100644 index 0000000000..b0dbfe07fe --- /dev/null +++ b/docs/fix/stuck-worship.rst @@ -0,0 +1,31 @@ +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 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/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/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/aquifer.rst b/docs/gui/aquifer.rst new file mode 100644 index 0000000000..52d47541b8 --- /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/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..ef501afa65 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 ----- 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 2c2e2feb8b..29d46dfebb 100644 --- a/docs/gui/design.rst +++ b/docs/gui/design.rst @@ -15,3 +15,11 @@ Usage :: gui/design + +Overlay +------- + +This script provides an overlay that shows the selected dimensions when +designating something with vanilla tools, for example when painting a burrow or +designating digging. The dimensions show up in a tooltip that follows the mouse +cursor. diff --git a/docs/gui/embark-anywhere.rst b/docs/gui/embark-anywhere.rst new file mode 100644 index 0000000000..4583b04cd7 --- /dev/null +++ b/docs/gui/embark-anywhere.rst @@ -0,0 +1,29 @@ +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!! + +Any and all consequences of embarking in strange locations are up to you to +handle (possibly with other `armok ` tools). + +Usage +----- + +:: + + gui/embark-anywhere + +The command will only work when you are on the screen where you can choose your +embark site. The DFHack logo is not displayed on that screen since its default +position conflicts with the vanilla embark size selection panel. Remember that +you can bring up DFHack's `context menu ` with +:kbd:`Ctrl`:kbd:`Shift`:kbd:`C` or the +`in-game command launcher ` directly with the backtick key (\`). diff --git a/docs/gui/gm-editor.rst b/docs/gui/gm-editor.rst index 080c49b98e..084b367ecf 100644 --- a/docs/gui/gm-editor.rst +++ b/docs/gui/gm-editor.rst @@ -20,12 +20,20 @@ If you just want to browse without fear of accidentally changing anything, hit 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. + Usage ----- ``gui/gm-editor [-f]`` - Open the editor on whatever is selected or viewed (e.g. unit/item - description screen) + Open the editor on whatever is selected or viewed (e.g. unit/item/building/ + engraving/etc.) ``gui/gm-editor [-f] `` Evaluate a lua expression and opens the editor on its results. Field prefixes of ``df.global`` can be omitted. diff --git a/docs/gui/journal.rst b/docs/gui/journal.rst new file mode 100644 index 0000000000..ba3619d2e1 --- /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: fort interface + +The `gui/journal` interface makes it easy to take notes and document +important details for the fortresses. + +With this multi-line text editor, +you can keep track of your fortress'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 weekds 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:`Ctrl` + :kbd:`D` deletes the character under the cursor. +- Line Navigation: :kbd:`Ctrl` + :kbd:`H` (like "Home") moves the cursor to the beginning of the current line, and :kbd:`Ctrl` + :kbd:`E` (like "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/launcher.rst b/docs/gui/launcher.rst index 04d81b41f3..fdfe2cb729 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 filterd 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/mass-remove.rst b/docs/gui/mass-remove.rst index 135b5678b2..6a3becb8f4 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 ----- diff --git a/docs/gui/mechanisms.rst b/docs/gui/mechanisms.rst deleted file mode 100644 index 9de336e84b..0000000000 --- a/docs/gui/mechanisms.rst +++ /dev/null @@ -1,27 +0,0 @@ -gui/mechanisms -============== - -.. dfhack-tool:: - :summary: List mechanisms and links connected to a building. - :tags: unavailable - -This convenient tool lists the mechanisms connected to the building and the -buildings linked via the mechanisms. Navigating the list centers the view on the -relevant linked building. - -To exit, press :kbd:`Esc` or :kbd:`Enter`; :kbd:`Esc` recenters on the original -building, while :kbd:`Enter` leaves focus on the current one. -:kbd:`Shift`:kbd:`Enter` has an effect equivalent to pressing :kbd:`Enter`, and -then re-entering the mechanisms UI. - -Usage ------ - -:: - - gui/mechanisms - -Screenshot ----------- - -.. image:: /docs/images/mechanisms.png diff --git a/docs/gui/mod-manager.rst b/docs/gui/mod-manager.rst index 3d88415dc7..8972fece72 100644 --- a/docs/gui/mod-manager.rst +++ b/docs/gui/mod-manager.rst @@ -5,9 +5,8 @@ gui/mod-manager :summary: Save and restore lists of 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. +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. Usage ----- 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/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/settings-manager.rst b/docs/gui/settings-manager.rst index e0cdc355d4..683ccb8917 100644 --- a/docs/gui/settings-manager.rst +++ b/docs/gui/settings-manager.rst @@ -2,21 +2,53 @@ 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 a new panel that 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 definitons. 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/sitemap.rst b/docs/gui/sitemap.rst new file mode 100644 index 0000000000..74e1fde942 --- /dev/null +++ b/docs/gui/sitemap.rst @@ -0,0 +1,24 @@ +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. 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 diff --git a/docs/gui/teleport.rst b/docs/gui/teleport.rst index 82d15abeb0..6222af4f8d 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 stil 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..578238c8e2 --- /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 avaialable 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..6139bb266c 100644 --- a/docs/gui/unit-info-viewer.rst +++ b/docs/gui/unit-info-viewer.rst @@ -3,14 +3,27 @@ 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 +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 ----- :: 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/instruments.rst b/docs/instruments.rst new file mode 100644 index 0000000000..815eb81b82 --- /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 for 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 givel components may vary. + +``instruments order ilul`` + Creates work orders to assemble one ïlul. Spelling doesn't 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..465382eebe --- /dev/null +++ b/docs/item.rst @@ -0,0 +1,223 @@ +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 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). + +``--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 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/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..52eb022d8e 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 specifing 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/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/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/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/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/necronomicon.rst b/docs/necronomicon.rst index 0e784443a0..5028304ee3 100644 --- a/docs/necronomicon.rst +++ b/docs/necronomicon.rst @@ -3,7 +3,7 @@ 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 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/prioritize.rst b/docs/prioritize.rst index 852e4c1e37..acc1d55f65 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. +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,17 +29,13 @@ 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). @@ -60,9 +56,9 @@ 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 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. ``-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 ``-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/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/resurrect-adv.rst b/docs/resurrect-adv.rst index d125502dbb..1d4c975084 100644 --- a/docs/resurrect-adv.rst +++ b/docs/resurrect-adv.rst @@ -3,7 +3,7 @@ resurrect-adv .. dfhack-tool:: :summary: Bring a dead adventurer back to life. - :tags: unavailable + :tags: adventure armok units Have you ever died, but wish you hadn't? This tool can help : ) When you see the "You are deceased" message, run this command to be resurrected and fully healed. diff --git a/docs/reveal-adv-map.rst b/docs/reveal-adv-map.rst index e710255f3a..16274713d5 100644 --- a/docs/reveal-adv-map.rst +++ b/docs/reveal-adv-map.rst @@ -3,7 +3,7 @@ reveal-adv-map .. dfhack-tool:: :summary: Reveal or hide the world map. - :tags: unavailable + :tags: adventure armok map This tool 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 traveling). diff --git a/docs/reveal-hidden-sites.rst b/docs/reveal-hidden-sites.rst index bf994db8ad..b0891094ac 100644 --- a/docs/reveal-hidden-sites.rst +++ b/docs/reveal-hidden-sites.rst @@ -3,12 +3,12 @@ reveal-hidden-sites .. dfhack-tool:: :summary: Reveal all sites in the world. - :tags: adventure fort armok map + :tags: adventure embark fort armok map This tool reveals all sites in the world that have yet to be discovered by the player (camps, lairs, shrines, vaults, etc), making them visible on the map. -It is usable in both fortress and adventure mode. +It is usable in both fortress and adventure mode, or on the embark map. Please see `reveal-adv-map` if you also want to expose hidden world map tiles in adventure mode. diff --git a/docs/source.rst b/docs/source.rst index 783660d19a..94926c0cdb 100644 --- a/docs/source.rst +++ b/docs/source.rst @@ -20,7 +20,7 @@ Usage Add a source or drain at the selected tile position. If the target level is not specified, it defaults to ``7``. The cursor must be over a flow-passable tile (e.g. empty space, floor, staircase, etc.) and not too high in the sky. -``source list`` +``source [list]`` List all currently registered source tiles. ``source delete`` Remove the source under the cursor. diff --git a/docs/starvingdead.rst b/docs/starvingdead.rst index 1e0dc938df..12f7efa14c 100644 --- a/docs/starvingdead.rst +++ b/docs/starvingdead.rst @@ -3,7 +3,7 @@ starvingdead .. dfhack-tool:: :summary: Prevent infinite accumulation of roaming undead. - :tags: fort auto fps gameplay units + :tags: fort fps gameplay units With this tool running, all undead that have been on the map for one month gradually decay, losing strength, speed, and toughness. After six months, diff --git a/docs/suspend.rst b/docs/suspend.rst index 6c50ab0f37..568e60b161 100644 --- a/docs/suspend.rst +++ b/docs/suspend.rst @@ -3,16 +3,11 @@ suspend .. dfhack-tool:: :summary: Suspends building construction jobs. - :tags: fort productivity jobs + :tags: fort jobs -This tool will suspend jobs. It can either suspend all the current jobs, or only -construction jobs that are likely to block other jobs. When building walls, it's -common that wall corners get stuck because dwarves build the two adjacent walls -before the corner. The ``--onlyblocking`` option will only suspend jobs that can -potentially lead to this situation. - -See `suspendmanager` in `gui/control-panel` to automatically suspend and -unsuspend jobs. +This tool will suspend jobs all construction jobs. This is mainly useful as an +emergency measure. Consider using `suspendmanager` from `gui/control-panel` to +automatically suspend and unsuspend jobs. Usage ----- @@ -20,16 +15,3 @@ Usage :: suspend - -Options -------- - -``-b``, ``--onlyblocking`` - Only suspend jobs that are likely to block other jobs. - -.. note:: - - ``--onlyblocking`` does not check pathing (which would be very expensive); it only - looks at immediate neighbours. As such, it is possible that this tool will miss - suspending some jobs that prevent access to other farther away jobs, for example - when building a large rectangle of solid walls. diff --git a/docs/suspendmanager.rst b/docs/suspendmanager.rst deleted file mode 100644 index fc26461833..0000000000 --- a/docs/suspendmanager.rst +++ /dev/null @@ -1,30 +0,0 @@ -suspendmanager -============== - -.. dfhack-tool:: - :summary: Intelligently suspend and unsuspend jobs. - :tags: fort auto jobs - -This tool will watch your active jobs and: - -- unsuspend jobs that have become suspended due to inaccessible materials, - items temporarily in the way, or worker dwarves getting scared by wildlife -- suspend most construction jobs that would prevent a dwarf from reaching another - construction job, such as when building a wall corner or high walls -- suspend construction jobs on top of a smoothing, engraving or track carving - designation. This prevents the construction job from being completed first, - which would erase the designation. -- suspend construction jobs that would cave in immediately on completion, - such as when building walls or floors next to grates/bars. - -Usage ------ - -``suspendmanager`` - Display the current status - -``suspendmanager (enable|disable)`` - Enable or disable ``suspendmanager`` - -``suspendmanager set preventblocking (true|false)`` - Prevent construction jobs from blocking each others (enabled by default). See `suspend`. diff --git a/docs/sync-windmills.rst b/docs/sync-windmills.rst new file mode 100644 index 0000000000..21bf44e156 --- /dev/null +++ b/docs/sync-windmills.rst @@ -0,0 +1,41 @@ +sync-windmills +============== + +.. dfhack-tool:: + :summary: Synchronize or randomize windmill movement. + :tags: fort buildings + +Windmills cycle between two graphical states to simulate movement. This is the +polarity of the appearance. Each windmill also has a timer that controls when +the windmill switches polarity. Each windmill's timer starts from zero at the +instant that it is built, so two different windmills will rarely have exactly +the same state. This tool can adjust the alignment of polarity and timers +across your active windmills to your preference. + +Note that this tool will not affect windmills that have just been activated and +are still rotating to adjust to the regional wind direction. + +Usage +----- + +:: + + sync-windmills [] + +Examples +-------- + +``sync-windmills`` + Synchronize movement of all active windmills. +``sync-windmills -r`` + Randomize the movement of all active windmills. + +Options +------- + +``-q``, ``--quiet`` + Suppress non-error console output. +``-r``, ``--randomize`` + Randomize the polarity and timer value for all windmills. +``-t``, ``--timing-only`` + Randomize windmill polarity, but synchronize windmill timers. diff --git a/docs/timestream.rst b/docs/timestream.rst index aa15ea5ff0..8b6b8eba6c 100644 --- a/docs/timestream.rst +++ b/docs/timestream.rst @@ -3,52 +3,111 @@ timestream .. dfhack-tool:: :summary: Fix FPS death. - :tags: unavailable + :tags: fort gameplay fps Do you remember when you first start a new fort, your initial 7 dwarves zip around the screen and get things done so quickly? As a player, you never had -to wait for your initial dwarves to move across the map. Don't you wish that -your fort of 200 dwarves could be as zippy? This tool can help. +to wait for your initial dwarves to move across the map. Do you wish that your +fort of 200 dwarves and 800 animals could be as zippy? This tool can help. -``timestream`` keeps the game running quickly by dynamically adjusting the -calendar speed relative to the frames per second that your computer can support. -Your dwarves spend the same amount of in-game time to do their tasks, but the -time that you, the player, have to wait for the dwarves to do things speeds up. -This means that the dwarves in your fully developed fort appears as energetic as -a newly created one, and mature forts are much more fun to play. +``timestream`` keeps the game running quickly by tweaking the game simulation +according to the frames per second that your computer can support. This means +that your dwarves spend the same amount of time relative to the in-game +calendar to do their tasks, but the time that you, the player, have to wait for +the dwarves to do get things done is reduced. The result is that the dwarves in +your fully developed fort appear as energetic as the dwarves in a newly created +fort, and mature forts are much more fun to play. -If you just want to change the game calendar speed without adjusting dwarf -speed, this tool can do that too. Your dwarves will just be able to get -less/more done per season (depending on whether you speed up or slow down the -calendar). +Note that whereas your dwarves zip around like you're running at 100 FPS, the +vanilla onscreen FPS counter, if enabled, will still show a lower number. See +the `Technical details`_ section below if you're interested in what's going on +under the hood. Usage ----- -``timestream --units [--fps ]`` - Keep the game running as responsively as it did when it was running at the - given frames per second. Dwarves get the same amount done per game day, but - game days go by faster. If a target FPS is not given, it defaults to 100. -``timestream --rate ``, ``timestream --fps `` - Just change the rate of the calendar, without corresponding adjustments to - units. Game responsiveness will not change, but dwarves will be able to get - more (or less) done per game day. A rate of ``1`` is "normal" calendar - speed. Alternately, you can run the calendar at a rate that it would have - moved at while the game was running at the specified frames per second. +:: + + enable timestream + timestream [status] + timestream set + timestream reset Examples -------- -``timestream --units`` - Keep the game running as quickly and smoothly as it did when it ran - "naturally" at 100 FPS. This mode makes things much more pleasant for the - player without giving any advantage/disadvantage to your in-game dwarves. -``timestream --rate 2`` - Calendar runs at 2x normal speed and units get half as much done as usual - per game day. -``timestream --fps 100`` - Calendar runs at a dynamic speed to simulate 100 FPS. Units get a varying - amount of work done per game day, but will get less and less done as your - fort grows and your unadjusted FPS decreases. -``timestream --rate 1`` - Reset everything back to normal. +``enable timestream`` + Start adjusting the simulation to run at the currently configured apparent + FPS (default is whatever you have the FPS cap set to in the DF settings, + which is usually 100). + +``timestream set fps 50`` + Tweak the simulation so it runs at an apparent 50 frames per second. + +``timestream reset`` + Reset settings to defaults: the vanilla FPS cap with no calendar speed + advantage or disadvantage. + +Settings +-------- + +:fps: Set the target simulated FPS. The default target FPS is whatever you have + the FPS cap set to in the DF settings, and the minimum is 10. Setting the + target FPS *below* your current actual FPS will have no effect. You have + to set the vanilla FPS cap for that. Set a target FPS of -1 to make no + adjustment at all to the apparent FPS of the game. + +Technical details +----------------- + +So what is this magic? How does this tool make it look like the game is +suddenly running so much faster? + +Maybe an analogy would help. Pretend you're standing at the bottom of a +staircase and you want to walk up the stairs. You can walk up one stair every +second, and there are 100 stairs, so it will take you 100 seconds to walk up +all the stairs. + +Now let's use the Hand of Armok and fiddle with reality a bit. Let's say that +instead of walking up one step, you walk up 5 steps at once. At the same time +we move the wall clock 5 seconds ahead. If you look at the clock after reaching +the top of the stairs, it will still look like it took 100 seconds, but you did +it all in fewer "steps". + +That's essentially what ``timestream`` is doing to the game. All "actions" in +DF have counters associated with them. For example, when a dwarf wants to walk +to the next tile, a counter is initialized to 8. Every "tick" of the game (the +"frame" in FPS) decrements that counter by 1. When the counter gets to zero, +the dwarf appears on the next tile. + +When ``timestream`` is active, it monitors all those counters and makes them +decrement more per tick. It then balances things out by proportionally +advancing the in-game calendar. Therefore, more "happens" per step, and DF has +to simulate fewer "steps" for the same amount of work to get done. + +The cost of this simplification is that the world becomes less "smooth". As the +discrepancy between the actual and simulated FPS grows, more and more dwarves +will move to their next tiles at *exactly* the same time. Moreover, the rate of +action completion per unit is effectively capped at the granularity of the +simulation, so very fast units (say, those in a martial trance) will lose some +of their advantage. + +Limitations +----------- + +DF does critial game tasks every 10 calendar ticks that must not be skipped, so +`timestream` cannot advance more than 9 ticks at a time. This puts an upper +limit on how much `timestream` can help. With the default target of 100 FPS, +the game will start showing signs of slowdown if the real FPS drops below about +15. The interface will also become less responsive to mouse gestures as the +real FPS drops. + +Finally, not all aspects of the game are perfectly adjusted. For example, +armies on world map will move at the same (real-time) rate regardless of +changes that ``timestream`` is making to the calendar. + +Here is a (possibly incomplete) list of game elements that are not adjusted by +``timestream`` and will appear "slow" in-game: + +- Army movement across the world map (including raids sent out from the fort) +- Liquid movement and evaporation diff --git a/docs/trackstop.rst b/docs/trackstop.rst new file mode 100644 index 0000000000..88579b783f --- /dev/null +++ b/docs/trackstop.rst @@ -0,0 +1,10 @@ +trackstop +========= + +.. dfhack-tool:: + :summary: Add dynamic configuration options for track stops. + :tags: fort buildings interface + +This script provides 2 overlays that are managed by the `overlay` framework. The script does nothing when executed. +The trackstop overlay allows the player to change the friction and dump direction of a selected track stop after it has been constructed. +The rollers overlay allows the player to change the roller direction and speed of a selected roller after it has been constructed. diff --git a/docs/undump-buildings.rst b/docs/undump-buildings.rst index 8848442734..34fb4ef05f 100644 --- a/docs/undump-buildings.rst +++ b/docs/undump-buildings.rst @@ -3,7 +3,7 @@ undump-buildings .. dfhack-tool:: :summary: Undesignate building base materials for dumping. - :tags: unavailable + :tags: fort productivity buildings If you designate a bunch of tiles in dump mode, all the items on those tiles will be marked for dumping. Unfortunately, if there are buildings on any of diff --git a/docs/unforbid.rst b/docs/unforbid.rst index 4160b7b25e..03615492ad 100644 --- a/docs/unforbid.rst +++ b/docs/unforbid.rst @@ -24,3 +24,6 @@ Options ``-q``, ``--quiet`` Suppress non-error console output. + +``-X``, ``--include-worn`` + Include worn (X) and tattered (XX) items when unforbidding. diff --git a/docs/uniform-unstick.rst b/docs/uniform-unstick.rst index 3e877aae2d..223c9ff87a 100644 --- a/docs/uniform-unstick.rst +++ b/docs/uniform-unstick.rst @@ -3,10 +3,14 @@ uniform-unstick .. dfhack-tool:: :summary: Make military units reevaluate their uniforms. - :tags: unavailable + :tags: fort bugfix military This tool prompts military units to reevaluate their uniform, making them -remove and drop potentially conflicting worn items. +remove and drop potentially conflicting worn items. If multiple units claim the +same item, the item will be unassigned from all units that are not already +wearing the item. If this happens, you'll have to click the "Update equipment" +button on the Squads "Equip" screen in order for them to get new equipment +assigned. 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 @@ -15,6 +19,10 @@ item for that bodypart (e.g. if you're still manufacturing them). Uniforms that have no issues are being properly worn will not be affected. +When generating a report of conflicts, items that simply haven't been picked up +yet or uniform components that haven't been assigned by DF are not considered +conflicts and are not included in the report. + Usage ----- @@ -39,6 +47,19 @@ Strategy options Force the unit to drop conflicting worn items onto the ground, where they can then be reclaimed in the correct order. ``--free`` - Remove to-equip items from containers or other's inventories and place them - on the ground, ready to be claimed. This is most useful when someone else - is wearing/holding the required items. + Remove items from the uniform assignment if someone else has a claim on + them. This will also remove items from containers and place them on the + ground, ready to be claimed. +``--multi`` + Attempt to fix issues with uniforms that allow multiple items per body part. + +Overlay +------- + +This script adds a small link to the squad equipment page that will run +``uniform-unstick --all`` and show the report when clicked. After reviewing the +report, you can right click to exit and do nothing or you can click the "Try to +resolve conflicts" button, which runs the equivalent of +``uniform-unstick --all --drop --free``. If any items are unassigned (they'll +turn red on the equipment screen), hit the "Update Equipment" button to +reassign equipment. diff --git a/docs/unretire-anyone.rst b/docs/unretire-anyone.rst index 47879d8d14..8a986241f1 100644 --- a/docs/unretire-anyone.rst +++ b/docs/unretire-anyone.rst @@ -3,7 +3,7 @@ unretire-anyone .. dfhack-tool:: :summary: Adventure as any living historical figure. - :tags: unavailable + :tags: adventure embark armok This tool allows you to play as any living (or undead) historical figure (except for deities) in adventure mode. @@ -12,9 +12,10 @@ To use, simply run the command at the start of adventure mode character creation. You will be presented with a searchable list from which you may choose your desired historical figure. -This figure will be added to the "Specific Person" list at the bottom of the -creature selection page. They can then be picked for use as a player character, -the same as when regaining control of a retired adventurer. +This figure will be added to the list of people to choose from during character +creation (the "Specific person" option on the race list). They can then be +picked for use as a player character, the same as when regaining control of a +retired adventurer. Usage ----- @@ -27,5 +28,6 @@ Options ------- ``-d``, ``--dead`` - Enables user to unretire a dead historical figure to play as in adventure mode. - For instance, a user may wish to unretire and then play as a particular megabeast that had died during world-gen. + Enables user to unretire a dead historical figure to play as in adventure + mode. For instance, a user may wish to unretire and then play as a + particular megabeast that had died during world-gen. diff --git a/docs/unsuspend.rst b/docs/unsuspend.rst deleted file mode 100644 index 697792e585..0000000000 --- a/docs/unsuspend.rst +++ /dev/null @@ -1,55 +0,0 @@ -unsuspend -========= - -.. dfhack-tool:: - :summary: Unsuspends building construction jobs. - :tags: fort productivity jobs - -Unsuspends building construction jobs, except for jobs managed by `buildingplan` -and those where water flow is greater than 1. This allows you to quickly recover -if a bunch of jobs were suspended due to the workers getting scared off by -wildlife or items temporarily blocking building sites. - -See `suspendmanager` in `gui/control-panel` to automatically suspend and -unsuspend jobs. - -Usage ------ - -:: - - unsuspend - -Options -------- - -``-q``, ``--quiet`` - Disable text output - -``-s``, ``--skipblocking`` - Don't unsuspend construction jobs that risk blocking other jobs - -Overlay -------- - -This script also provides an overlay that is managed by the `overlay` framework. -When the overlay is enabled, an icon or letter will appear over suspended -buildings: - -- A clock icon (green ``P`` in ASCII mode) indicates that the building is still - in planning mode and is waiting on materials. The `buildingplan` plugin will - unsuspend it for you when those materials become available. -- A white ``x`` means that the building is maintained suspended by - `suspendmanager`, selecting it will provide a reason for the suspension -- A yellow ``x`` means that the building is suspended. If you don't have - `suspendmanager` managing suspensions for you, you can unsuspend it - manually or with the `unsuspend` command. -- A red ``X`` means that the building has been re-suspended multiple times. - You might need to look into whatever is preventing the building from being - built (e.g. the building material for the building is inaccessible or there - is an in-use item blocking the building site). - -Note that in ASCII mode the letter will only appear when the game is paused -since it takes up the whole tile and makes the underlying building invisible. -In graphics mode, the icon only covers part of the building and so can always -be visible. diff --git a/docs/warn-starving.rst b/docs/warn-starving.rst deleted file mode 100644 index 8e411ff285..0000000000 --- a/docs/warn-starving.rst +++ /dev/null @@ -1,37 +0,0 @@ -warn-starving -============= - -.. dfhack-tool:: - :summary: Report units that are dangerously hungry, thirsty, or drowsy. - :tags: fort animals units - -If any (live) units are starving, very thirsty, or very drowsy, the game will -pause and you'll get a warning dialog telling you which units are in danger. -This gives you a chance to rescue them (or take them out of their cages) before -they die. - -You can enable ``warn-starving`` notifications in `gui/control-panel` on the "Maintenance" tab. - -Usage ------ - -:: - - warn-starving [all] [sane] - -Examples --------- - -``warn-starving all sane`` - Report on all currently distressed units, excluding insane units that you - wouldn't be able to save anyway. - -Options -------- - -``all`` - Report on all distressed units, even if they have already been reported. By - default, only newly distressed units that haven't already been reported are - listed. -``sane`` - Ignore insane units. diff --git a/docs/warn-stealers.rst b/docs/warn-stealers.rst deleted file mode 100644 index 4c7bdd6ce9..0000000000 --- a/docs/warn-stealers.rst +++ /dev/null @@ -1,18 +0,0 @@ -warn-stealers -============= - -.. dfhack-tool:: - :summary: Watch for and warn about units that like to steal your stuff. - :tags: unavailable - -This script will watch for new units entering the map and will make a zoomable -announcement whenever a creature that can eat food, guzzle drinks, or steal -items moves into a revealed location. It will also re-warn about all revealed -stealers when enabled. - -Usage ------ - -:: - - enable warn-stealers diff --git a/docs/warn-stranded.rst b/docs/warn-stranded.rst index 10a92a8f2a..34fd5c7659 100644 --- a/docs/warn-stranded.rst +++ b/docs/warn-stranded.rst @@ -2,20 +2,22 @@ warn-stranded ============= .. dfhack-tool:: - :summary: Reports citizens that are stranded and can't reach any other citizens. + :summary: Reports citizens who can't reach any other citizens. :tags: fort units -If any (live) groups of fort citizens are stranded from the main (largest) group, -the game will pause and you'll get a warning dialog telling you which citizens are isolated. -This gives you a chance to rescue them before they get overly stressed or start starving. +If any groups of sane fort citizens are stranded from the main (largest) group, +you'll get a warning dialog telling you which citizens are isolated. This gives +you a chance to rescue them before they get overly stressed or starve. -Each citizen will be put into a group with the other citizens stranded together. +There is a command line interface that can print status of citizens without +pausing or bringing up a window. -There is a command line interface that can print status of citizens without pausing or bringing up a window. +If there are citizens that you are ok with stranding (say, you have isolated a +potential werebeast or vampire), you can mark them as ignored so they won't +trigger a warning. -The GUI and command-line both also have the ability to ignore citizens so they don't trigger a pause and window. - -You can enable ``warn-stranded`` notifications in `gui/control-panel` on the "Maintenance" tab. +This tool is integrated with `gui/notify` to automatically show notifications +when a stranded unit is detected. Usage ----- @@ -25,29 +27,25 @@ Usage warn-stranded warn-stranded status warn-stranded clear - warn-stranded (ignore|ignoregroup|unignore|unignoregroup) + warn-stranded (ignore|unignore) + warn-stranded (ignoregroup|unignoregroup) Examples -------- ``warn-stranded`` - Standard command that checks citizens and pops up a warning if any are stranded. - Does nothing when there are no unignored stranded citizens. + Standard command that checks citizens and pops up a warning if any are + stranded. Does nothing when there are no unignored stranded citizens. ``warn-stranded status`` - List all stranded citizens and all ignored citizens. Includes citizen unit ids. + List all groups of stranded citizens and all ignored citizens. Also shows + individual unit ids. ``warn-stranded clear`` Clear (unignore) all ignored citizens. -``warn-stranded ignore 1`` - Ignore citizen with unit id 1. +``warn-stranded ignore 15343`` + Ignore citizen with unit id 15343. ``warn-stranded ignoregroup 2`` Ignore stranded citizen group 2. - -``warn-stranded unignore 1`` - Unignore citizen with unit id 1. - -``warn-stranded unignoregroup 3`` - Unignore stranded citizen group 3. diff --git a/drain-aquifer.lua b/drain-aquifer.lua deleted file mode 100644 index 7e92293e39..0000000000 --- a/drain-aquifer.lua +++ /dev/null @@ -1,68 +0,0 @@ -local argparse = require('argparse') - -local zmin = 0 -local zmax = df.global.world.map.z_count - 1 - -local function drain() - local layers = {} --as:bool[] - local layer_count = 0 - local tile_count = 0 - - for _, block in ipairs(df.global.world.map.map_blocks) do - if not block.flags.has_aquifer then goto continue end - if block.map_pos.z < zmin or block.map_pos.z > zmax then goto continue end - - block.flags.has_aquifer = false - block.flags.check_aquifer = false - - for _, row in ipairs(block.designation) do - for _, tile in ipairs(row) do - if tile.water_table then - tile.water_table = false - tile_count = tile_count + 1 - end - end - end - - if not layers[block.map_pos.z] then - layers[block.map_pos.z] = true - layer_count = layer_count + 1 - end - ::continue:: - end - - print(('Cleared %d aquifer tile%s in %d layer%s.'):format( - tile_count, (tile_count ~= 1) and 's' or '', layer_count, (layer_count ~= 1) and 's' or '')) -end - -local help = false -local top = 0 - -local positionals = argparse.processArgsGetopt({...}, { - {'h', 'help', handler=function() help = true end}, - {'t', 'top', hasArg=true, handler=function(optarg) top = argparse.nonnegativeInt(optarg, 'top') end}, - {'d', 'zdown', handler=function() zmax = df.global.window_z end}, - {'u', 'zup', handler=function() zmin = df.global.window_z end}, - {'z', 'cur-zlevel', handler=function() zmax, zmin = df.global.window_z, df.global.window_z end}, -}) - -if help or positionals[1] == 'help' then - print(dfhack.script_help()) - return -end - -if top > 0 then - zmax = -1 - for _, block in ipairs(df.global.world.map.map_blocks) do - if block.flags.has_aquifer and zmax < block.map_pos.z then - zmax = block.map_pos.z - end - end - zmax = zmax - top - if zmax < zmin then - print('No aquifer levels need draining') - return - end -end - -drain() diff --git a/embark-anyone.lua b/embark-anyone.lua new file mode 100644 index 0000000000..0f6fc61ad2 --- /dev/null +++ b/embark-anyone.lua @@ -0,0 +1,116 @@ +local dialogs = require('gui.dialogs') +local utils = require('utils') + +function addCivToEmbarkList(info) + local viewscreen = dfhack.gui.getDFViewscreen(true) + + viewscreen.start_civ:insert ('#', info.civ) + viewscreen.start_civ_nem_num:insert ('#', info.nemeses) + viewscreen.start_civ_entpop_num:insert ('#', info.pops) + viewscreen.start_civ_site_num:insert ('#', info.sites) +end + +function embarkAnyone() + local viewscreen = dfhack.gui.getDFViewscreen(true) + local choices, existing_civs = {}, {} + + for _,existing_civ in ipairs(viewscreen.start_civ) do + existing_civs[existing_civ.id] = true + end + + if viewscreen._type ~= df.viewscreen_choose_start_sitest then + qerror("This script can only be used on the embark screen!") + end + + for i, civ in ipairs (df.global.world.entities.all) do + -- Test if entity is a civ + if civ.type ~= df.historical_entity_type.Civilization then goto continue end + -- Test if entity is already in embark list + if existing_civs[civ.id] then goto continue end + + local sites = 0 + local pops = 0 + local nemeses = 0 + local histfigs = 0 + local label = '' + + -- Civs keep links to sites they no longer hold, so check owner + -- We also take the opportunity to count population + for j, link in ipairs(civ.site_links) do + local site = df.world_site.find(link.target) + if site ~= nil and site.civ_id == civ.id then + sites = sites + 1 + + -- DF stores population info as an array of groups of residents (?). + -- Inspecting these further could give a more accurate count. + for _, group in ipairs(site.populace.inhabitants) do + pops = pops + group.count + end + end + + -- Count living nemeses + for _, nem_id in ipairs (civ.nemesis_ids) do + local nem = df.nemesis_record.find(nem_id) + if nem ~= nil and nem.figure.died_year == -1 then + nemeses = nemeses + 1 + end + end + + -- Count living histfigs + -- Used for death detection. May be redundant. + for _, fig_id in ipairs (civ.histfig_ids) do + local fig = df.historical_figure.find(fig_id) + if fig ~= nil and fig.died_year == -1 then + histfigs = histfigs + 1 + end + end + end + + -- Find the civ's name, or come up with one + if civ.name.has_name then + label = dfhack.TranslateName(civ.name, true) .. "\n" + else + label = "Unnamed " .. + dfhack.units.getRaceReadableNameById(civ.race) .. + " civilisation\n" + end + + -- Add species + label = label .. dfhack.units.getRaceNamePluralById(civ.race) .. "\n" + + -- Add pop & site count or mark civ as dead. + if histfigs == 0 and pops == 0 then + label = label .. "Dead" + else + label = label .. "Pop: " .. (pops + nemeses) .. " Sites: " .. sites + end + + table.insert(choices, {text = label, + info = {civ = civ, pops = pops, sites = sites, + nemeses = nemeses}}) + + ::continue:: + end + + if #choices > 0 then + dialogs.ListBox{ + frame_title = 'Embark Anyone', + text = 'Select a civilization to add to the list of origin civs:', + text_pen = COLOR_WHITE, + choices = choices, + on_select = function(id, choice) + addCivToEmbarkList(choice.info) + end, + with_filter = true, + row_height = 4, + }:show() + else + dialogs.MessageBox{ + frame_title = 'Embark Anyone', + text = 'No additional civilizations found.' + }:show() + end + +end + +embarkAnyone() diff --git a/embark-skills.lua b/embark-skills.lua index 2dd803839d..852e29d3f2 100644 --- a/embark-skills.lua +++ b/embark-skills.lua @@ -1,38 +1,7 @@ -- Adjusts dwarves' skills when embarking ---[====[ -embark-skills -============= -Adjusts dwarves' skills when embarking. - -Note that already-used skill points are not taken into account or reset. - -:points N: Sets the skill points remaining of the selected dwarf to ``N``. -:points N all: Sets the skill points remaining of all dwarves to ``N``. -:max: Sets all skills of the selected dwarf to "Proficient". -:max all: Sets all skills of all dwarves to "Proficient". -:legendary: Sets all skills of the selected dwarf to "Legendary". -:legendary all: Sets all skills of all dwarves to "Legendary". - -]====] - -VERSION = '0.1' - -function usage() - print([[Usage: - embark-skills points N: Sets the skill points remaining of the selected dwarf to 'N'. - embark-skills points N all: Sets the skill points remaining of all dwarves to 'N'. - embark-skills max: Sets all skills of the selected dwarf to "Proficient". - embark-skills max all: Sets all skills of all dwarves to "Proficient". - embark-skills legendary: Sets all skills of the selected dwarf to "Legendary". - embark-skills legendary all: Sets all skills of all dwarves to "Legendary". - embark-skills help: Display this help -embark-skills v]] .. VERSION) -end function err(msg) - dfhack.printerr(msg) - usage() - qerror('') + qerror(msg) end function adjust(dwarves, callback) @@ -42,13 +11,13 @@ function adjust(dwarves, callback) end local scr = dfhack.gui.getCurViewscreen() --as:df.viewscreen_setupdwarfgamest -if dfhack.gui.getCurFocus() ~= 'setupdwarfgame' or scr.show_play_now == 1 then - qerror('Must be called on the "Prepare carefully" screen') +if not dfhack.gui.matchFocusString('setupdwarfgame/Dwarves', scr) then + qerror('Must be called on the "Prepare carefully" screen, "Dwarves" tab') end local dwarf_info = scr.dwarf_info local dwarves = dwarf_info -local selected_dwarf = {[0] = scr.dwarf_info[scr.dwarf_cursor]} --as:df.setup_character_info[] +local selected_dwarf = {[0] = scr.dwarf_info[scr.selected_u]} --as:df.startup_charactersheetst[] local args = {...} if args[1] == 'points' then @@ -58,22 +27,22 @@ if args[1] == 'points' then end if args[3] ~= 'all' then dwarves = selected_dwarf end adjust(dwarves, function(dwf) - dwf.skill_points_remaining = points + dwf.skill_picks_left = points end) elseif args[1] == 'max' then if args[2] ~= 'all' then dwarves = selected_dwarf end adjust(dwarves, function(dwf) - for skill, level in pairs(dwf.skills) do - dwf.skills[skill] = df.skill_rating.Proficient + for skill, level in pairs(dwf.skilllevel) do + dwf.skilllevel[skill] = df.skill_rating.Proficient end end) elseif args[1] == 'legendary' then if args[2] ~= 'all' then dwarves = selected_dwarf end adjust(dwarves, function(dwf) - for skill, level in pairs(dwf.skills) do - dwf.skills[skill] = df.skill_rating.Legendary5 + for skill, level in pairs(dwf.skilllevel) do + dwf.skilllevel[skill] = df.skill_rating.Legendary5 end end) else - usage() + print(dfhack.script_help()) end diff --git a/emigration.lua b/emigration.lua index 742e1a147e..3721535a3d 100644 --- a/emigration.lua +++ b/emigration.lua @@ -1,12 +1,6 @@ ---Allow stressed dwarves to emigrate from the fortress --- For 34.11 by IndigoFenix; update and cleanup by PeridexisErrant --- old version: http://dffd.bay12games.com/file.php?id=8404 --@module = true --@enable = true -local json = require('json') -local persist = require('persist-table') - local GLOBAL_KEY = 'emigration' -- used for state change hooks and persistence enabled = enabled or false @@ -16,7 +10,7 @@ function isEnabled() end local function persist_state() - persist.GlobalTable[GLOBAL_KEY] = json.encode({enabled=enabled}) + dfhack.persistent.saveSiteData(GLOBAL_KEY, {enabled=enabled}) end function desireToStay(unit,method,civ_id) @@ -75,7 +69,7 @@ function desert(u,method,civ) end -- disassociate from work details - for _, detail in ipairs(df.global.plotinfo.hauling.work_details) do + for _, detail in ipairs(df.global.plotinfo.labor_info.work_details) do for k, v in ipairs(detail.assigned_units) do if v == u.id then detail.assigned_units:erase(k) @@ -84,6 +78,11 @@ function desert(u,method,civ) end end + -- unburrow + for _, burrow in ipairs(df.global.plotinfo.burrows.list) do + dfhack.burrows.setAssignedUnit(burrow, u, false) + end + -- erase the unit from the fortress entity for k,v in ipairs(fort_ent.histfig_ids) do if v == hf_id then @@ -154,17 +153,13 @@ function canLeave(unit) return false end - return dfhack.units.isCitizen(unit) and - dfhack.units.isActive(unit) and - not dfhack.units.isOpposedToLife(unit) and - not unit.flags1.merchant and - not unit.flags1.diplomat and - not unit.flags1.chained and - dfhack.units.getNoblePositions(unit) == nil and - unit.military.squad_id == -1 and - dfhack.units.isSane(unit) and - not dfhack.units.isBaby(unit) and - not dfhack.units.isChild(unit) + return dfhack.units.isActive(unit) and + dfhack.units.isCitizen(unit) and + not dfhack.units.getNoblePositions(unit) and + not unit.flags1.chained and + unit.military.squad_id == -1 and + not dfhack.units.isBaby(unit) and + not dfhack.units.isChild(unit) end function checkForDeserters(method,civ_id) @@ -215,8 +210,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/empty-bin.lua b/empty-bin.lua index a5b816bece..f94a140816 100644 --- a/empty-bin.lua +++ b/empty-bin.lua @@ -1,26 +1,74 @@ -- Empty a bin onto the floor --- Based on "emptybin" by StoneToad +-- Based on 'emptybin' by StoneToad -- https://gist.github.com/stonetoad/11129025 -- http://dwarffortresswiki.org/index.php/DF2014_Talk:Bin ---[====[ +local argparse = require('argparse') -empty-bin -========= +local options, args = { + help = false, + recursive = false, + liquids = false + }, + {...} -Empties the contents of the selected bin onto the floor. +local function emptyContainer(container) + local items = dfhack.items.getContainedItems(container) + if #items > 0 then + print('Emptying ' .. dfhack.items.getReadableDescription(container)) + local pos = xyz2pos(dfhack.items.getPosition(container)) + for _, item in ipairs(items) do + local skip_liquid = item:getType() == df.item_type.LIQUID_MISC or item:getType() == df.item_type.DRINK and not options.liquids + if skip_liquid then + print(' ' .. dfhack.items.getReadableDescription(item) .. ' was skipped because the --liquids flag was not provided') + else + print(' ' .. dfhack.items.getReadableDescription(item)) + dfhack.items.moveToGround(item, pos) + if options.recursive then + emptyContainer(item) + end + end + end + end +end -]====] +argparse.processArgsGetopt(args,{ + { 'h', 'help', handler = function() options.help = true end }, + { 'r', 'recursive', handler = function() options.recursive = true end }, + { 'l', 'liquids', handler = function() options.liquids = true end } + }) + +if options.help then + print(dfhack.script_help()) + return +end -local bin = dfhack.gui.getSelectedItem(true) or qerror("No item selected") -local items = dfhack.items.getContainedItems(bin) +local viewsheets = df.global.game.main_interface.view_sheets +local stockpile = dfhack.gui.getSelectedStockpile(true) +local selectedItem = dfhack.gui.getSelectedItem(true) +local selectedBuilding = dfhack.gui.getSelectedBuilding(true) -if #items > 0 then - print('Emptying ' .. dfhack.items.getDescription(bin, 0)) - for _, item in pairs(items) do - print(' ' .. dfhack.items.getDescription(item, 0)) - dfhack.items.moveToGround(item, bin.pos) +if stockpile then + local contents = dfhack.buildings.getStockpileContents(stockpile) + for _, container in ipairs(contents) do + emptyContainer(container) + end +elseif selectedItem then + emptyContainer(selectedItem) +elseif selectedBuilding then + if not selectedBuilding:isActual() then + return + end + for _, contained in ipairs(selectedBuilding.contained_items) do + if contained.use_mode == df.building_item_role_type.TEMP then + emptyContainer(contained.item) + end + end +elseif viewsheets.open then + for _, item_id in ipairs(viewsheets.viewing_itid) do + local item = df.item.find(item_id) + emptyContainer(item) end else - print('No contained items') + qerror('Please select a container, building, stockpile, or tile with a list of items.') end diff --git a/exportlegends.lua b/exportlegends.lua index 22373fd7a5..131e415676 100644 --- a/exportlegends.lua +++ b/exportlegends.lua @@ -7,8 +7,6 @@ local overlay = require('plugins.overlay') local script = require('gui.script') local widgets = require('gui.widgets') -local args = {...} - -- Get the date of the world as a string -- Format: "YYYYY-MM-DD" local function get_world_date_str() @@ -45,9 +43,8 @@ local function table_containskey(self, key) end progress_item = progress_item or '' -step_size = step_size or 1 -step_percent = -1 -progress_percent = progress_percent or -1 +num_done = num_done or -1 +num_total = num_total or -1 last_update_ms = 0 -- should be frequent enough so that user can still effectively use @@ -63,13 +60,16 @@ local function yield_if_timeout() end --luacheck: skip -local function progress_ipairs(vector, desc, interval) +local function progress_ipairs(vector, desc, skip_count, interval) desc = desc or 'item' + progress_item = desc interval = interval or 10000 local cb = ipairs(vector) return function(vector, k, ...) + if not skip_count then + num_done = num_done + 1 + end if k then - progress_percent = math.max(progress_percent, step_percent + ((k * step_size) // #vector)) if #vector >= interval and (k % interval == 0 or k == #vector - 1) then print((' %s %i/%i (%0.f%%)'):format(desc, k, #vector, (k * 100) / #vector)) end @@ -79,6 +79,22 @@ local function progress_ipairs(vector, desc, interval) end, vector, nil end +local function make_chunk(name, vector, fn) + num_total = num_total + #vector + return { + name=name, + vector=vector, + fn=fn, + } +end + +local function write_chunk(file, chunk) + yield_if_timeout() + file:write("<" .. chunk.name .. ">\n") + chunk.fn(chunk.vector) + file:write("\n") +end + -- wrapper that returns "unknown N" for df.enum_type[BAD_VALUE], -- instead of returning nil or causing an error local df_enums = {} --as:df @@ -107,11 +123,13 @@ local function printifvalue (file, indentation, tag, value) end end +local world = df.global.world + -- Export additional legends data, legends_plus.xml local function export_more_legends_xml() local problem_elements = {} - local filename = df.global.world.cur_savegame.save_dir.."-"..get_world_date_str().."-legends_plus.xml" + local filename = world.cur_savegame.save_dir.."-"..get_world_date_str().."-legends_plus.xml" local file = io.open(filename, 'w') if not file then qerror("could not open file: " .. filename) @@ -120,21 +138,13 @@ local function export_more_legends_xml() file:write("\n") file:write("\n") - file:write(""..escape_xml(dfhack.df2utf(dfhack.TranslateName(df.global.world.world_data.name))).."\n") - file:write(""..escape_xml(dfhack.df2utf(dfhack.TranslateName(df.global.world.world_data.name,1))).."\n") - - local function write_chunk(name, fn) - progress_item = name - yield_if_timeout() - file:write("<" .. name .. ">\n") - fn() - file:write("\n") - end + file:write(""..escape_xml(dfhack.df2utf(dfhack.TranslateName(world.world_data.name))).."\n") + file:write(""..escape_xml(dfhack.df2utf(dfhack.TranslateName(world.world_data.name,1))).."\n") local chunks = {} - table.insert(chunks, {name='landmasses', fn=function() - for landmassK, landmassV in progress_ipairs(df.global.world.world_data.landmasses, 'landmass') do + table.insert(chunks, make_chunk('landmasses', world.world_data.landmasses, function(vector) + for landmassK, landmassV in progress_ipairs(vector, 'landmasses') do file:write("\t\n") file:write("\t\t"..landmassV.index.."\n") file:write("\t\t"..escape_xml(dfhack.df2utf(dfhack.TranslateName(landmassV.name,1))).."\n") @@ -142,10 +152,10 @@ local function export_more_legends_xml() file:write("\t\t"..landmassV.max_x..","..landmassV.max_y.."\n") file:write("\t\n") end - end}) + end)) - table.insert(chunks, {name='mountain_peaks', fn=function() - for mountainK, mountainV in progress_ipairs(df.global.world.world_data.mountain_peaks, 'mountain') do + table.insert(chunks, make_chunk('mountain_peaks', world.world_data.mountain_peaks, function(vector) + for mountainK, mountainV in progress_ipairs(vector, 'mountains') do file:write("\t\n") file:write("\t\t"..mountainK.."\n") file:write("\t\t"..escape_xml(dfhack.df2utf(dfhack.TranslateName(mountainV.name,1))).."\n") @@ -156,10 +166,10 @@ local function export_more_legends_xml() end file:write("\t\n") end - end}) + end)) - table.insert(chunks, {name='regions', fn=function() - for regionK, regionV in progress_ipairs(df.global.world.world_data.regions, 'region') do + table.insert(chunks, make_chunk('regions', world.world_data.regions, function(vector) + for regionK, regionV in progress_ipairs(vector, 'regions') do file:write("\t\n") file:write("\t\t"..regionV.index.."\n") file:write("\t\t") @@ -179,10 +189,10 @@ local function export_more_legends_xml() end file:write("\t\n") end - end}) + end)) - table.insert(chunks, {name='underground_regions', fn=function() - for regionK, regionV in progress_ipairs(df.global.world.world_data.underground_regions, 'underground region') do + table.insert(chunks, make_chunk('underground_regions', world.world_data.underground_regions, function(vector) + for regionK, regionV in progress_ipairs(vector, 'underground region') do file:write("\t\n") file:write("\t\t"..regionV.index.."\n") file:write("\t\t") @@ -192,14 +202,14 @@ local function export_more_legends_xml() file:write("\n") file:write("\t\n") end - end}) + end)) - table.insert(chunks, {name='rivers', fn=function() - for riverK, riverV in progress_ipairs(df.global.world.world_data.rivers, 'river') do + table.insert(chunks, make_chunk('rivers', world.world_data.rivers, function(vector) + for riverK, riverV in progress_ipairs(vector, 'rivers') do file:write("\t\n") file:write("\t\t"..escape_xml(dfhack.df2utf(dfhack.TranslateName(riverV.name, 1))).."\n") file:write("\t\t") - for pathK, pathV in progress_ipairs(riverV.path.x, 'river section') do + for pathK, pathV in progress_ipairs(riverV.path.x, 'river section', true) do file:write(pathV..","..riverV.path.y[pathK]..",") file:write(riverV.flow[pathK]..",") file:write(riverV.exit_tile[pathK]..",") @@ -209,10 +219,10 @@ local function export_more_legends_xml() file:write("\t\t"..riverV.end_pos.x..","..riverV.end_pos.y.."\n") file:write("\t\n") end - end}) + end)) - table.insert(chunks, {name='creature_raw', fn=function() - for creatureK, creatureV in ipairs (df.global.world.raws.creatures.all) do + table.insert(chunks, make_chunk('creature_raw', world.raws.creatures.all, function(vector) + for creatureK, creatureV in ipairs (vector) do file:write("\t\n") file:write("\t\t"..creatureV.creature_id.."\n") file:write("\t\t"..escape_xml(dfhack.df2utf(creatureV.name[0])).."\n") @@ -224,10 +234,10 @@ local function export_more_legends_xml() end file:write("\t\n") end - end}) + end)) - table.insert(chunks, {name='sites', fn=function() - for siteK, siteV in progress_ipairs(df.global.world.world_data.sites, 'site') do + table.insert(chunks, make_chunk('sites', world.world_data.sites, function(vector) + for siteK, siteV in progress_ipairs(vector, 'sites') do file:write("\t\n") for k,v in pairs(siteV) do if (k == "id" or k == "civ_id" or k == "cur_owner_id") then @@ -246,9 +256,9 @@ local function export_more_legends_xml() end if df.abstract_building_templest:is_instance(buildingV) then file:write("\t\t\t\t"..buildingV.deity_type.."\n") - if buildingV.deity_type == df.temple_deity_type.Deity then + if buildingV.deity_type == df.religious_practice_type.WORSHIP_HFID then file:write("\t\t\t\t"..buildingV.deity_data.Deity.."\n") - elseif buildingV.deity_type == df.temple_deity_type.Religion then + elseif buildingV.deity_type == df.religious_practice_type.RELIGION_ENID then file:write("\t\t\t\t"..buildingV.deity_data.Religion.."\n") end end @@ -266,10 +276,10 @@ local function export_more_legends_xml() end file:write("\t\n") end - end}) + end)) - table.insert(chunks, {name='world_constructions', fn=function() - for wcK, wcV in progress_ipairs(df.global.world.world_data.constructions.list, 'construction') do + table.insert(chunks, make_chunk('world_constructions', world.world_data.constructions.list, function(vector) + for wcK, wcV in progress_ipairs(vector, 'constructions') do file:write("\t\n") file:write("\t\t"..wcV.id.."\n") file:write("\t\t"..escape_xml(dfhack.df2utf(dfhack.TranslateName(wcV.name,1))).."\n") @@ -281,10 +291,10 @@ local function export_more_legends_xml() file:write("\n") file:write("\t\n") end - end}) + end)) - table.insert(chunks, {name='artifacts', fn=function() - for artifactK, artifactV in progress_ipairs(df.global.world.artifacts.all, 'artifact') do + table.insert(chunks, make_chunk('artifacts', world.artifacts.all, function(vector) + for artifactK, artifactV in progress_ipairs(vector, 'artifacts') do file:write("\t\n") file:write("\t\t"..artifactV.id.."\n") local item = artifactV.item @@ -314,20 +324,20 @@ local function export_more_legends_xml() end file:write("\t\n") end - end}) + end)) - table.insert(chunks, {name='historical_figures', fn=function() - for hfK, hfV in progress_ipairs(df.global.world.history.figures, 'historical figure') do + table.insert(chunks, make_chunk('historical_figures', world.history.figures, function(vector) + for hfK, hfV in progress_ipairs(vector, 'historical figures') do file:write("\t\n") file:write("\t\t"..hfV.id.."\n") file:write("\t\t"..hfV.sex.."\n") if hfV.race >= 0 then file:write("\t\t"..escape_xml(dfhack.df2utf(df.creature_raw.find(hfV.race).name[0])).."\n") end file:write("\t\n") end - end}) + end)) - table.insert(chunks, {name='identities', fn=function() - for idK, idV in progress_ipairs(df.global.world.identities.all, 'identity') do + table.insert(chunks, make_chunk('identities', world.identities.all, function(vector) + for idK, idV in progress_ipairs(vector, 'identities') do file:write("\t\n") file:write("\t\t"..idV.id.."\n") file:write("\t\t"..escape_xml(dfhack.df2utf(dfhack.TranslateName(idV.name,1))).."\n") @@ -337,35 +347,35 @@ local function export_more_legends_xml() else dfhack.printerr ("Unknown df.identity_type value encountered: "..tostring(idV.type)..". Please report to DFHack team.") end - if idV.race >= 0 then file:write("\t\t"..(df.global.world.raws.creatures.all[idV.race].creature_id):lower().."\n") end - if idV.race >= 0 and idV.caste >= 0 then file:write("\t\t"..(df.global.world.raws.creatures.all[idV.race].caste[idV.caste].caste_id):lower().."\n") end + if idV.race >= 0 then file:write("\t\t"..(world.raws.creatures.all[idV.race].creature_id):lower().."\n") end + if idV.race >= 0 and idV.caste >= 0 then file:write("\t\t"..(world.raws.creatures.all[idV.race].caste[idV.caste].caste_id):lower().."\n") end file:write("\t\t"..idV.birth_year.."\n") file:write("\t\t"..idV.birth_second.."\n") if idV.profession >= 0 then file:write("\t\t"..(df_enums.profession[idV.profession]):lower().."\n") end file:write("\t\t"..idV.entity_id.."\n") file:write("\t\n") end - end}) + end)) - table.insert(chunks, {name='entity_populations', fn=function() - for entityPopK, entityPopV in progress_ipairs(df.global.world.entity_populations, 'entity population') do + table.insert(chunks, make_chunk('entity_populations', world.entity_populations, function(vector) + for entityPopK, entityPopV in progress_ipairs(vector, 'entity populations') do file:write("\t\n") file:write("\t\t"..entityPopV.id.."\n") for raceK, raceV in ipairs(entityPopV.races) do - local raceName = (df.global.world.raws.creatures.all[raceV].creature_id):lower() + local raceName = (world.raws.creatures.all[raceV].creature_id):lower() file:write("\t\t"..raceName..":"..entityPopV.counts[raceK].."\n") end file:write("\t\t"..entityPopV.civ_id.."\n") file:write("\t\n") end - end}) + end)) - table.insert(chunks, {name='entities', fn=function() - for entityK, entityV in progress_ipairs(df.global.world.entities.all, 'entity') do + table.insert(chunks, make_chunk('entities', world.entities.all, function(vector) + for entityK, entityV in progress_ipairs(vector, 'entities') do file:write("\t\n") file:write("\t\t"..entityV.id.."\n") if entityV.race >= 0 then - file:write("\t\t"..(df.global.world.raws.creatures.all[entityV.race].creature_id):lower().."\n") + file:write("\t\t"..(world.raws.creatures.all[entityV.race].creature_id):lower().."\n") end file:write("\t\t"..(df_enums.historical_entity_type[entityV.type]):lower().."\n") if entityV.type == df.historical_entity_type.Religion or entityV.type == df.historical_entity_type.MilitaryUnit then -- Get worshipped figures @@ -439,7 +449,7 @@ local function export_more_legends_xml() file:write("\t\t\n") file:write("\t\t\t"..occasionV.id.."\n") file:write("\t\t\t"..escape_xml(dfhack.df2utf(dfhack.TranslateName(occasionV.name,1))).."\n") - file:write("\t\t\t"..occasionV.event.."\n") + file:write("\t\t\t"..occasionV.purpose_id.."\n") for scheduleK, scheduleV in pairs(occasionV.schedule) do file:write("\t\t\t\n") file:write("\t\t\t\t"..scheduleK.."\n") @@ -468,10 +478,10 @@ local function export_more_legends_xml() end file:write("\t\n") end - end}) + end)) - table.insert(chunks, {name='historical_events', fn=function() - for ID, event in progress_ipairs(df.global.world.history.events, 'event') do + table.insert(chunks, make_chunk('historical_events', world.history.events, function(vector) + for ID, event in progress_ipairs(vector, 'historical events') do if df.history_event_add_hf_entity_linkst:is_instance(event) or df.history_event_add_hf_site_linkst:is_instance(event) or df.history_event_add_hf_hf_linkst:is_instance(event) @@ -769,23 +779,23 @@ local function export_more_legends_xml() end elseif k == "race" then if v > -1 then - file:write("\t\t"..escape_xml(dfhack.df2utf(df.global.world.raws.creatures.all[v].name[0])).."\n") + file:write("\t\t"..escape_xml(dfhack.df2utf(world.raws.creatures.all[v].name[0])).."\n") end elseif k == "caste" then if v > -1 then - file:write("\t\t"..(df.global.world.raws.creatures.all[event.race].caste[v].caste_id):lower().."\n") + file:write("\t\t"..(world.raws.creatures.all[event.race].caste[v].caste_id):lower().."\n") end elseif k == "interaction" and df.history_event_hf_does_interactionst:is_instance(event) then - if #df.global.world.raws.interactions[v].sources > 0 then - local str_1 = df.global.world.raws.interactions[v].sources[0].hist_string_1 + if #world.raws.interactions[v].sources > 0 then + local str_1 = world.raws.interactions[v].sources[0].hist_string_1 if string.sub (str_1, 1, 1) == " " and string.sub (str_1, string.len (str_1), string.len (str_1)) == " " then str_1 = string.sub (str_1, 2, string.len (str_1) - 1) end - file:write("\t\t"..str_1..df.global.world.raws.interactions[v].sources[0].hist_string_2.."\n") + file:write("\t\t"..str_1..world.raws.interactions[v].sources[0].hist_string_2.."\n") end elseif k == "interaction" and df.history_event_hf_learns_secretst:is_instance(event) then - if #df.global.world.raws.interactions[v].sources > 0 then - file:write("\t\t"..df.global.world.raws.interactions[v].sources[0].name.."\n") + if #world.raws.interactions[v].sources > 0 then + file:write("\t\t"..world.raws.interactions[v].sources[0].name.."\n") end elseif df.history_event_hist_figure_diedst:is_instance(event) and k == "weapon" then for detailK,detailV in pairs(v) do @@ -858,7 +868,7 @@ local function export_more_legends_xml() elseif df.history_event_change_hf_jobst:is_instance(event) and (k == "new_job" or k == "old_job") then file:write("\t\t<"..k..">"..df_enums.profession[v]:lower().."\n") elseif df.history_event_change_creature_typest:is_instance(event) and (k == "old_race" or k == "new_race") and v >= 0 then - file:write("\t\t<"..k..">"..escape_xml(dfhack.df2utf(df.global.world.raws.creatures.all[v].name[0])).."\n") + file:write("\t\t<"..k..">"..escape_xml(dfhack.df2utf(world.raws.creatures.all[v].name[0])).."\n") elseif tostring(v):find ("<") then if not problem_elements[tostring(event._type)] then problem_elements[tostring(event._type)] = {} @@ -884,10 +894,10 @@ local function export_more_legends_xml() file:write("\t\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 +908,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.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 +963,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\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\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\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,19 +1016,19 @@ local function export_more_legends_xml() end local function wrap_export() - if progress_percent >= 0 then + if num_total >= 0 then qerror('exportlegends already in progress') end - progress_percent = 0 - step_size = 1 + num_total = 0 + num_done = 0 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 + num_total = -1 + num_done = -1 progress_item = '' end @@ -1031,6 +1038,7 @@ end 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/Default', @@ -1048,21 +1056,21 @@ function LegendsOverlay:init() subviews={ widgets.ToggleHotkeyLabel{ view_id='do_export', - frame={t=0, l=1, w=53}, + frame={t=0, l=1, r=1}, label='Also export DFHack extended legends data:', key='CUSTOM_CTRL_D', - visible=function() return progress_percent < 0 end, + visible=function() return num_total < 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)' + {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 progress_percent >= 0 end, + visible=function() return num_total >= 0 end, }, }, }, @@ -1070,7 +1078,7 @@ function LegendsOverlay:init() end function LegendsOverlay:onInput(keys) - if keys._MOUSE_L and progress_percent < 0 and + if keys._MOUSE_L and num_total < 0 and self.subviews.button_mask:getMousePos() and self.subviews.do_export:getOptionValue() then @@ -1085,6 +1093,7 @@ end 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', @@ -1095,13 +1104,13 @@ function DoneMaskOverlay:init() self:addviews{ widgets.Panel{ frame_background=gui.CLEAR_PEN, - visible=function() return progress_percent >= 0 end, + visible=function() return num_total >= 0 end, } } end function DoneMaskOverlay:onInput(keys) - if progress_percent >= 0 then + if num_total >= 0 then if keys.LEAVESCREEN or (keys._MOUSE_L and self:getMousePos()) then return true end diff --git a/exterminate.lua b/exterminate.lua index f179b28648..06b764eb98 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,30 @@ 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 - end - :: skipunit :: + if not checkUnit(opts, unit) then goto continue end + 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 + ::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 +161,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,9 +183,9 @@ 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 }) @@ -170,46 +199,73 @@ if not positionals[1] then 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 +278,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/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 31660f93de..16cf6d6f39 100644 --- a/fix/dead-units.lua +++ b/fix/dead-units.lua @@ -1,28 +1,24 @@ --- Remove uninteresting dead units from the unit list. ---[====[ +local argparse = require('argparse') -fix/dead-units -============== -Removes uninteresting dead units from the unit list. Doesn't seem to give any -noticeable performance gain, but migrants normally stop if the unit list grows -to around 3000 units, and this script reduces it back. - -]====] local units = df.global.world.units.active -local count = 0 local MONTH = 1200 * 28 local YEAR = MONTH * 12 -for i=#units-1,0,-1 do - local unit = units[i] - if dfhack.units.isDead(unit) and not dfhack.units.isOwnRace(unit) then +local count = 0 + +local function scrub_active() + for i=#units-1,0,-1 do + local unit = units[i] + if not dfhack.units.isDead(unit) or dfhack.units.isOwnRace(unit) then + goto continue + end local remove = false if dfhack.units.isMarkedForSlaughter(unit) then remove = true elseif unit.hist_figure_id == -1 then remove = true elseif not dfhack.units.isOwnCiv(unit) and - not (dfhack.units.isMerchant(unit) or dfhack.units.isDiplomat(unit)) then + not (dfhack.units.isMerchant(unit) or dfhack.units.isDiplomat(unit)) then remove = true end if remove and unit.counters.death_id ~= -1 then @@ -44,7 +40,38 @@ for i=#units-1,0,-1 do count = count + 1 units:erase(i) end + ::continue:: end end -print('Units removed from active: '..count) +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 + 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 + +local args = {...} +if not args[1] then args[1] = '--active' end + +local quiet = false + +argparse.processArgsGetopt(args, { + {nil, 'active', handler=scrub_active}, + {nil, 'burrow', handler=scrub_burrows}, + {nil, 'burrows', handler=scrub_burrows}, + {'q', 'quiet', handler=function() quiet = true end}, +}) + +if count > 0 or not quiet then + print('Dead units scrubbed: ' .. count) +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..b49e5b161f 100644 --- a/fix/dry-buckets.lua +++ b/fix/dry-buckets.lua @@ -1,19 +1,18 @@ - - local emptied = 0 -local in_building = 0 +local in_building = 0 local water_type = dfhack.matinfo.find('WATER').type -for _,item in ipairs(df.global.world.items.all) do +for _,item in ipairs(df.global.world.items.other.IN_PLAY) 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 + 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 + in_building = in_building + 1 end dfhack.items.remove(item) emptied = emptied + 1 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..a3b7090880 100644 --- a/fix/loyaltycascade.lua +++ b/fix/loyaltycascade.lua @@ -1,9 +1,9 @@ --- Prevents a "loyalty cascade" (civil war) when a citizen is killed. +-- Prevents a "loyalty cascade" (intra-fort civil war) when a citizen is killed. -- 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 @@ -27,7 +27,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)) @@ -53,27 +53,27 @@ local function fixUnit(unit) -- 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.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 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.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 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_cache = df.global.world.enemy_status_cache local status_slot = unit.enemy.enemy_status_slot unit.enemy.enemy_status_slot = -1 @@ -84,13 +84,13 @@ local function fixUnit(unit) end for index, _ in pairs(status_cache.rel_map) do - status_cache.rel_map[index] = -1 + status_cache.rel_map[index][status_slot] = -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 + if status_cache.next_slot > status_slot then + status_cache.next_slot = status_slot end end @@ -98,14 +98,14 @@ local function fixUnit(unit) 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 from a loyalty cascade.'):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..44ea9d506f --- /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.curse.rem_tags1.NOEXERT) then --tag removal overrides tag addition, so if the NOEXERT tag is removed the unit cannot be NOEXERT. + return false + end + if(u.curse.add_tags1.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..b21b818a7e --- /dev/null +++ b/fix/ownership.lua @@ -0,0 +1,29 @@ +-- unit thinks they own the item but the item doesn't hold the proper +-- ref that actually makes this true +local function owner_not_recognized() + for _,unit in ipairs(dfhack.units.getCitizens()) do + for index = #unit.owned_items-1, 0, -1 do + local item = df.item.find(unit.owned_items[index]) + if not item then goto continue end + + 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 + print('Erasing ' .. dfhack.TranslateName(unit.name) .. ' invalid claim on item #' .. item.id) + unit.owned_items:erase(index) + ::continue:: + end + end +end + +local args = {...} + +if args[1] == "help" then + print(dfhack.script_help()) + return +end + +owner_not_recognized() diff --git a/fix/population-cap.lua b/fix/population-cap.lua index bf910bac1e..a68743b906 100644 --- a/fix/population-cap.lua +++ b/fix/population-cap.lua @@ -1,20 +1,3 @@ --- Tells mountainhomes your pop. to avoid overshoot - ---[====[ - -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) @@ -26,10 +9,6 @@ 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') - end - print('Creating an empty statistics structure...') civ.activity_stats = { new = true, created_weapons = { resize = #ui_stats.created_weapons }, @@ -42,6 +21,9 @@ if not civ_stats then end -- 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, ui_stats.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-worship.lua b/fix/stuck-worship.lua new file mode 100644 index 0000000000..0781181f5f --- /dev/null +++ b/fix/stuck-worship.lua @@ -0,0 +1,93 @@ +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 + +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 + print('rebalanced prayer needs for ' .. + dfhack.df2console(dfhack.TranslateName(dfhack.units.getVisibleName(unit)))) + end + ::next_unit:: +end diff --git a/fix/stuckdoors.lua b/fix/stuckdoors.lua index 085aa953f4..8e1e1bc026 100644 --- a/fix/stuckdoors.lua +++ b/fix/stuckdoors.lua @@ -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/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/fixnaked.lua b/fixnaked.lua index 725ab6f9c1..916d8f5ad3 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 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/forbid.lua b/forbid.lua index b0be9536f9..4a09732703 100644 --- a/forbid.lua +++ b/forbid.lua @@ -5,7 +5,7 @@ local argparse = require('argparse') local function getForbiddenItems() local items = {} - for _, item in pairs(df.global.world.items.all) do + for _, item in pairs(df.global.world.items.other.IN_PLAY) do if item.flags.forbid then local item_type = df.item_type[item:getType()] @@ -59,7 +59,7 @@ if positionals[1] == "all" then print("Forbidding all items on the map...") local count = 0 - for _, item in pairs(df.global.world.items.all) do + for _, item in pairs(df.global.world.items.other.IN_PLAY) do item.flags.forbid = true count = count + 1 end @@ -73,7 +73,7 @@ if positionals[1] == "unreachable" then local citizens = dfhack.units.getCitizens(true) local count = 0 - for _, item in pairs(df.global.world.items.all) do + for _, item in pairs(df.global.world.items.other.IN_PLAY) do if item.flags.construction or item.flags.in_building or item.flags.artifact then goto skipitem end 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..3981fd21e9 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 @@ -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/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/advfort.lua b/gui/advfort.lua index 9de46a9cf4..7f17afb8fe 100644 --- a/gui/advfort.lua +++ b/gui/advfort.lua @@ -196,11 +196,11 @@ 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 + --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 +242,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 +253,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 @@ -413,15 +413,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 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 +924,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 +1000,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 @@ -1277,7 +1276,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 @@ -1421,20 +1420,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 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..d3ec16c840 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 @@ -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/biomes.lua b/gui/biomes.lua new file mode 100644 index 0000000000..dedac20ac7 --- /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('hack/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('hack/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('hack/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..abedc26654 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,9 +171,8 @@ 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')}, + 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=15, w=19}, -- key='CUSTOM_Z', label='zone', @@ -241,8 +228,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 +280,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 +312,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 +322,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 +332,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 +349,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 +358,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'}}, @@ -609,7 +596,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 f043f4c222..29ee4cc561 100644 --- a/gui/civ-alert.lua +++ b/gui/civ-alert.lua @@ -1,9 +1,10 @@ --@ 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 @@ -35,15 +36,25 @@ local function clear_alarm() df.global.plotinfo.alerts.civ_alert_idx = 0 end -local function toggle_civalert_burrow(id) +function add_civalert_burrow(id) local burrows = get_civ_alert().burrows + utils.insert_sorted(burrows, id) +end + +function remove_civalert_burrow(id) + local burrows = get_civ_alert().burrows + utils.erase_sorted(burrows, id) if #burrows == 0 then - burrows:insert('#', id) - elseif burrows[0] == id then - burrows:resize(0) clear_alarm() + end +end + +local function toggle_civalert_burrow(id) + local burrows = get_civ_alert().burrows + if utils.binsearch(burrows, id) then + remove_civalert_burrow(id) else - burrows[0] = id + add_civalert_burrow(id) end end @@ -66,24 +77,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, }, @@ -96,6 +111,7 @@ 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', @@ -194,7 +210,7 @@ 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}, @@ -240,8 +256,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..9659c79785 100644 --- a/gui/companion-order.lua +++ b/gui/companion-order.lua @@ -62,13 +62,8 @@ function getxyz() -- this will return pointers x,y and z coordinates. 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 +144,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,7 +189,7 @@ function AddBackpackItems(backpack,items) end function GetItemsAtPos(pos) local ret={} - for k,v in pairs(df.global.world.items.all) do + for k,v in pairs(df.global.world.items.other.IN_PLAY) 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 table.insert(ret,v) end @@ -346,14 +341,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,7 +393,7 @@ end}, if not CheckCursor(pos) then return false end - adv=df.global.world.units.active[0] + adv=dfhack.world.getAdventurer() item=GetItemsAtPos(df.global.cursor)[1] print(item.id) for k,v in pairs(unit_list) do @@ -413,7 +408,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") 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 38de219e1b..becd57d1dc 100644 --- a/gui/control-panel.lua +++ b/gui/control-panel.lua @@ -1,169 +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', - 'autolabor', - '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 = { - 'buildingplan', - 'confirm', - 'logistics', - 'overlay', -} --- 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', ']'}}, - ['stuck-instruments']={ - desc='Fix activity references on stuck instruments to make them usable again.', - command={'--time', '1', '--timeUnits', 'days', '--command', '[', 'fix/stuck-instruments', ']'}}, - ['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', ']'}}, - ['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', ']'}}, - ['empty-wheelbarrows']={ - desc='Empties wheelbarrows which have rocks stuck in them.', - command={'--time', '1', '--timeUnits', 'days', '--command', '[', 'fix/empty-wheelbarrows', '-q', ']'}}, -} -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('[')} @@ -199,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() @@ -661,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() @@ -697,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 @@ -864,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, } @@ -876,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, @@ -890,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 @@ -913,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..ee224fca57 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) 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 a0a215d4ad..5e90a47978 100644 --- a/gui/design.lua +++ b/gui/design.lua @@ -1,21 +1,14 @@ -- A GUI front-end for creating designs ---@ module = false +--@ module = true -- 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,144 +27,158 @@ -- END TODOS ================ -local gui = require("gui") -local textures = require("gui.textures") -local guidm = require("gui.dwarfmode") -local widgets = require("gui.widgets") -local quickfort = reqscript("quickfort") -local shapes = reqscript("internal/design/shapes") -local util = reqscript("internal/design/util") -local plugin = require("plugins.design") +local gui = require('gui') +local guidm = require('gui.dwarfmode') +local overlay = require('plugins.overlay') +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/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 will be 3d +local function check_stockpile_dims() + if main_interface.bottom_mode_selected == df.main_bottom_mode_type.STOCKPILE_PAINT and + selection_rect.start_x > 0 + 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 + +OVERLAY_WIDGETS = { + dimensions=DimensionsOverlay, } --- --- HelpWindow --- -DESIGN_HELP_DEFAULT = { - "gui/design Help", - "============", - NEWLINE, - "This is a default help text." -} - 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 @@ -179,816 +186,683 @@ 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 shape_tileset = dfhack.textures.loadTileset('hack/data/art/design.png', 8, 12, true) + 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=24, + }) + table.insert(shape_button_specs_selected, { + chars=shape.button_chars, + pens=COLOR_YELLOW, + tileset=shape_tileset, + tileset_offset=shape.texture_offset+(24*3), + tileset_stride=24, + }) 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='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", + 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.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"), - }, - - 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.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_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.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_SHIFT_B', + 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, + self:addviews{ + shape_options_panel, + widgets.Divider{ + frame={h=1}, + frame_style=gui.FRAME_THIN, + frame_style_l=false, + frame_style_r=false, }, - 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, - }, - 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 @@ -1002,144 +876,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 @@ -1160,7 +899,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 @@ -1175,26 +914,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 @@ -1211,6 +949,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 @@ -1228,27 +971,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 @@ -1262,11 +994,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 @@ -1288,23 +1020,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 @@ -1313,11 +1047,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 @@ -1340,57 +1074,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 @@ -1406,20 +1138,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 @@ -1436,7 +1165,6 @@ function Design:onInput(keys) return true end - local pos = nil if keys._MOUSE_L and not self:getMouseFramePos() then pos = getMousePoint() @@ -1447,14 +1175,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 @@ -1465,34 +1194,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 @@ -1508,21 +1256,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 @@ -1532,35 +1265,35 @@ 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() if point.z == 0 then - return stairs_bottom_type == "auto" and "u" or stairs_bottom_type + return stairs_bottom_type == 'auto' and 'u' or stairs_bottom_type 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 = ( @@ -1568,22 +1301,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 @@ -1598,26 +1331,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 @@ -1637,7 +1372,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 @@ -1650,9 +1385,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 } @@ -1661,7 +1397,7 @@ function Design:commit() self.start_center = nil end - self:updateLayout() + self.needs_update = true end function Design:get_mirrored_points(points) @@ -1672,7 +1408,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)) @@ -1744,26 +1480,35 @@ 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 } + self.design_window = Design{} + self.help_window = HelpWindow{visible=false} + self:addviews{ + self.design_window, + self.help_window, + widgets.DimensionsTooltip{ + get_anchor_pos_fn=self.design_window:callback('get_anchor_pos'), + }, + } if SHOW_DEBUG_WINDOW then - self.debug_window = DesignDebugWindow { design_window = self.design_window } - self:addviews { self.debug_window } + self.debug_window = DesignDebugWindow{design_window=self.design_window} + self:addviews{self.debug_window} end end @@ -1774,7 +1519,7 @@ end 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..0349c0e06c 100644 --- a/gui/dfstatus.lua +++ b/gui/dfstatus.lua @@ -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..344663903c 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,13 @@ 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.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 +147,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 +172,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..1dd08b8475 100644 --- a/gui/family-affairs.lua +++ b/gui/family-affairs.lua @@ -1,32 +1,5 @@ -- 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 - -``gui/family-affairs [unitID]`` - shows GUI for the selected unit, or the specified unit ID - -``gui/family-affairs divorce [unitID]`` - removes all spouse and lover information from the unit - and it's partner, bypassing almost all checks. - -``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. - -]====] local dlg = require ('gui.dialogs') @@ -187,9 +160,8 @@ function ChooseNewSpouse (source) local choicelist = {} targetlist = {} - for k,v in pairs (df.global.world.units.active) do - if dfhack.units.isCitizen(v) - and v.race == source.race + for k,v in pairs (dfhack.units.getCitizens()) do + if v.race == source.race and v.sex ~= source.sex and v.relationship_ids.Spouse == -1 and v.relationship_ids.Lover == -1 @@ -250,10 +222,11 @@ end local args = {...} -if args[1] == "help" or args[1] == "?" then print(helpstr) return end +if args[1] == "help" or args[1] == "?" then print(dfhack.script_help()) return end if not dfhack.world.isFortressMode() then - print (helpstr) qerror ("invalid game mode") return + print(dfhack.script_help()) + qerror("invalid game mode") return end if args[1] == "divorce" and tonumber(args[2]) then @@ -278,13 +251,13 @@ if tonumber(args[1]) then end if selected then - if dfhack.units.isCitizen(selected) and dfhack.units.isSane(selected) then + if dfhack.units.isCitizen(selected) then MainDialog(selected) else qerror("You must select a sane fortress citizen.") return end else - print (helpstr) + print(dfhack.script_help()) qerror("Select a sane fortress dwarf") end diff --git a/gui/gm-editor.lua b/gui/gm-editor.lua index 86cf6c3879..9ec7cdd2ce 100644 --- a/gui/gm-editor.lua +++ b/gui/gm-editor.lua @@ -53,10 +53,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,14 +93,37 @@ 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}, + resize_min=RESIZE_MIN, read_only=(config.data.read_only or false) } @@ -433,10 +469,7 @@ function GmEditorUi:gotoPos() end end if pos then - dfhack.gui.revealInDwarfmodeMap(pos,true) - df.global.game.main_interface.recenter_indicator_m.x = pos.x - df.global.game.main_interface.recenter_indicator_m.y = pos.y - df.global.game.main_interface.recenter_indicator_m.z = pos.z + dfhack.gui.revealInDwarfmodeMap(pos,true,true) end end function GmEditorUi:editSelectedRaw(index,choice) diff --git a/gui/journal.lua b/gui/journal.lua new file mode 100644 index 0000000000..cfb6f234bc --- /dev/null +++ b/gui/journal.lua @@ -0,0 +1,364 @@ +-- 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 text_editor = reqscript('internal/journal/text_editor') +local shifter = reqscript('internal/journal/shifter') +local table_of_contents = reqscript('internal/journal/table_of_contents') + +local RESIZE_MIN = {w=54, h=20} +local TOC_RESIZE_MIN = {w=24} + +local JOURNAL_PERSIST_KEY = 'journal' + +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.]=] + +journal_config = journal_config or json.open('dfhack-config/journal.json') + +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, + + 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=TOC_WELCOME_COPY, + 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 colllapsed 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, + }, + text_editor.TextEditor{ + 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.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=JOURNAL_WELCOME_COPY + } + } + 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', + save_on_change=true, + save_layout=true, + save_prefix='' +} + +function JournalScreen:init() + local context = self:loadContext() + + self:addviews{ + JournalWindow{ + view_id='journal_window', + frame={w=65, h=45}, + + save_layout=self.save_layout, + + init_text=context.text[1], + init_cursor=context.cursor[1], + show_tutorial=context.show_tutorial or false, + + on_text_change=self:callback('saveContext'), + on_cursor_change=self:callback('saveContext') + }, + } +end + +function JournalScreen:loadContext() + local site_data = self.save_on_change and dfhack.persistent.getSiteData( + self.save_prefix .. JOURNAL_PERSIST_KEY + ) 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 + +function JournalScreen:onTextChange(text) + self:saveContext(text) +end + +function JournalScreen:saveContext() + if self.save_on_change and dfhack.isWorldLoaded() then + local text = self.subviews.journal_editor:getText() + local cursor = self.subviews.journal_editor:getCursor() + + dfhack.persistent.saveSiteData( + self.save_prefix .. JOURNAL_PERSIST_KEY, + {text={text}, cursor={cursor}} + ) + end +end + +function JournalScreen:onDismiss() + view = nil +end + +function main(options) + if not dfhack.isMapLoaded() or not dfhack.world.isFortressMode() then + qerror('journal requires a fortress map to be loaded') + end + + local save_layout = options and options.save_layout + local save_on_change = options and options.save_on_change + + 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, + save_on_change=save_on_change == nil and true or save_on_change, + }:show() +end + +if not dfhack_flags.module then + main() +end diff --git a/gui/launcher.lua b/gui/launcher.lua index cfb8d38efc..9ea1c49c54 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,6 +22,9 @@ 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') user_freq = user_freq or json.open('dfhack-config/command_counts.json') @@ -30,32 +32,125 @@ 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_A', + 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_W', 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..bc89747edc 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:', 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/mass-remove.lua b/gui/mass-remove.lua index 4be9fb338a..f138c50d87 100644 --- a/gui/mass-remove.lua +++ b/gui/mass-remove.lua @@ -4,15 +4,8 @@ local gui = require('gui') local guidm = require('gui.dwarfmode') local utils = require('utils') local widgets = require('gui.widgets') -local suspendmanager = reqscript('suspendmanager') -local ok, buildingplan = pcall(require, 'plugins.buildingplan') -if not ok then - buildingplan = nil -end - -local function remove_building(bld) - dfhack.buildings.deconstruct(bld) +local function noop() end local function get_first_job(bld) @@ -21,68 +14,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 +94,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 +104,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, + }, + 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, }, - ActionPanel{ - get_mark_fn=function() return self.mark 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=function() end}, - {label='Remove', value=remove_construction}, - -- {label='Unremove', value=unremove_construction}, + {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='suspend', - label='Suspend:', - key='CUSTOM_X', - key_back='CUSTOM_SHIFT_X', + view_id='stockpiles', + label='Stockpiles:', + key='CUSTOM_T', + key_sep=': ', + option_gap=4, options={ - {label='Leave alone', value=function() end}, - {label='Suspend', value=suspendmanager.suspend}, - {label='Unsuspend', value=suspendmanager.unsuspend}, + {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='remove', + label='Mode:', + key='CUSTOM_R', + options={ + {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 +254,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 +275,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 +290,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 +301,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 +329,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,7 +370,13 @@ 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() diff --git a/gui/masspit.lua b/gui/masspit.lua index f7d77176bb..cc83ee847e 100644 --- a/gui/masspit.lua +++ b/gui/masspit.lua @@ -160,7 +160,7 @@ 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 = df.unit.find(unit_id) local unit_name = unit.name.has_name and dfhack.TranslateName(unit.name) or dfhack.units.getRaceNameById(unit.race) -- Prevents duplicate units in assignments, which can cause crashes. diff --git a/gui/mechanisms.lua b/gui/mechanisms.lua deleted file mode 100644 index 32a85436c9..0000000000 --- a/gui/mechanisms.lua +++ /dev/null @@ -1,144 +0,0 @@ --- Shows mechanisms linked to the current building. ---[====[ - -gui/mechanisms -============== -Lists mechanisms connected to the building, and their links. Navigating -the list centers the view on the relevant linked buildings. - -.. image:: /docs/images/mechanisms.png - -To exit, press :kbd:`Esc` or :kbd:`Enter`; :kbd:`Esc` recenters on -the original building, while :kbd:`Enter` leaves focus on the current -one. :kbd:`Shift`:kbd:`Enter` has an effect equivalent to pressing -:kbd:`Enter`, and then re-entering the mechanisms UI. - -]====] -local utils = require 'utils' -local gui = require 'gui' -local guidm = require 'gui.dwarfmode' - -function listMechanismLinks(building) - local lst = {} - local function push(item, mode) - if item then - lst[#lst+1] = { - obj = item, mode = mode, - name = utils.getBuildingName(item) - } - end - end - - push(building, 'self') - - if not df.building_actual:is_instance(building) then - return lst - end - - local item, tref, tgt - for _,v in ipairs(building.contained_items) do - item = v.item - if df.item_trappartsst:is_instance(item) then - tref = dfhack.items.getGeneralRef(item, df.general_ref_type.BUILDING_TRIGGER) - if tref then - push(tref:getBuilding(), 'trigger') - end - tref = dfhack.items.getGeneralRef(item, df.general_ref_type.BUILDING_TRIGGERTARGET) - if tref then - push(tref:getBuilding(), 'target') - end - end - end - - return lst -end - -MechanismList = defclass(MechanismList, guidm.MenuOverlay) - -MechanismList.focus_path = 'mechanisms' - -function MechanismList:init(info) - self:assign{ - links = {}, selected = 1 - } - self:fillList(info.building) -end - -function MechanismList:fillList(building) - local links = listMechanismLinks(building) - - self.old_viewport = self:getViewport() - self.old_cursor = guidm.getCursorPos() - - if #links <= 1 then - links[1].mode = 'none' - end - - self.links = links - self.selected = 1 -end - -local colors = { - self = COLOR_CYAN, none = COLOR_CYAN, - trigger = COLOR_GREEN, target = COLOR_GREEN -} -local icons = { - self = 128, none = 63, trigger = 27, target = 26 -} - -function MechanismList:onRenderBody(dc) - dc:clear() - dc:seek(1,1):string("Mechanism Links", COLOR_WHITE):newline() - - for i,v in ipairs(self.links) do - local pen = { fg=colors[v.mode], bold = (i == self.selected) } - dc:newline(1):pen(pen):char(icons[v.mode]) - dc:advance(1):string(v.name) - end - - local nlinks = #self.links - - if nlinks <= 1 then - dc:newline():newline(1):string("This building has no links", COLOR_LIGHTRED) - end - - dc:newline():newline(1):pen(COLOR_WHITE) - dc:key('LEAVESCREEN'):string(": Back, ") - dc:key('SELECT'):string(": Switch"):newline(1) - dc:key_string('LEAVESCREEN_ALL', "Exit to map") -end - -function MechanismList:changeSelected(delta) - if #self.links <= 1 then return end - self.selected = 1 + (self.selected + delta - 1) % #self.links - self:selectBuilding(self.links[self.selected].obj) -end - -function MechanismList:onInput(keys) - if keys.SECONDSCROLL_UP then - self:changeSelected(-1) - elseif keys.SECONDSCROLL_DOWN then - self:changeSelected(1) - elseif keys.LEAVESCREEN or keys.LEAVESCREEN_ALL then - self:dismiss() - if self.selected ~= 1 and not keys.LEAVESCREEN_ALL then - self:selectBuilding(self.links[1].obj, self.old_cursor, self.old_viewport) - end - elseif keys.SELECT_ALL then - if self.selected > 1 then - self:fillList(self.links[self.selected].obj) - end - elseif keys.SELECT then - self:dismiss() - elseif self:simulateViewScroll(keys) then - return - end -end - -if not string.match(dfhack.gui.getCurFocus(), '^dwarfmode/QueryBuilding/Some') then - qerror("This script requires a mechanism-linked building to be selected in 'q' mode") -end - -local list = MechanismList{ building = df.global.world.selected_building } -list:show() -list:changeSelected(1) diff --git a/gui/mod-manager.lua b/gui/mod-manager.lua index 08e9ae5b03..51df00d677 100644 --- a/gui/mod-manager.lua +++ b/gui/mod-manager.lua @@ -11,12 +11,14 @@ local utils = require('utils') local presets_file = json.open("dfhack-config/mod-manager.json") local GLOBAL_KEY = 'mod-manager' -local function get_newregion_viewscreen() +-- get_newregion_viewscreen and get_modlist_fields are declared as global functions +-- so external tools can call them to get the DF mod list +function get_newregion_viewscreen() local vs = dfhack.gui.getViewscreenByType(df.viewscreen_new_regionst, 0) 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, @@ -157,7 +159,7 @@ local function overwrite_preset(idx) presets_file:write() end -local function load_preset(idx) +local function load_preset(idx, unset_default_on_failure) if idx > #presets_file.data then return end @@ -167,14 +169,39 @@ local function load_preset(idx) local failures = swap_modlist(viewscreen, modlist) if #failures > 0 then - local failures_str = "" + local text = {} + if unset_default_on_failure then + presets_file.data[idx].default = false + presets_file:write() + + 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 + table.insert(text, NEWLINE) + 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, '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 + dialogs.showMessage("Warning", text) +end end local function find_preset_by_name(name) @@ -378,7 +405,9 @@ ModmanageOverlay = defclass(ModmanageOverlay, overlay.OverlayWidget) ModmanageOverlay.ATTRS { frame = { w=16, h=3 }, frame_style = gui.MEDIUM_FRAME, - default_pos = { x=5, y=-5 }, + 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" }, default_enabled=true, } @@ -400,7 +429,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, } @@ -445,7 +476,7 @@ dfhack.onStateChange[GLOBAL_KEY] = function(sc) 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 +493,6 @@ end if dfhack_flags.module then return end + +-- TODO: when invoked as a command, should show information on which mods are loaded +-- and give the player the option to export the list (or at least copy it to the clipboard) diff --git a/gui/notify.lua b/gui/notify.lua new file mode 100644 index 0000000000..39bafe93c0 --- /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.InfoUnits, + df.main_hover_instruction.InfoJobs, + df.main_hover_instruction.InfoPlaces, + df.main_hover_instruction.InfoLabors, + df.main_hover_instruction.InfoWorkOrders, + df.main_hover_instruction.InfoNobles, + df.main_hover_instruction.InfoObjects, + df.main_hover_instruction.InfoJustice, +} + +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.adventurest.T_player_control_state.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 863e625ece..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,22 +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 block = dfhack.maps.getTileBlock(target) - local walk_group = block and block.walkable[target.x % 16][target.y % 16] or 0 - 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 @@ -90,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..c1e9866988 100644 --- a/gui/quickcmd.lua +++ b/gui/quickcmd.lua @@ -74,16 +74,26 @@ 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=1, l=0}, + key='CUSTOM_SHIFT_A', + label='Add command', + auto_width=true, + on_activate=self:callback('onAddCommand'), + }, + widgets.HotkeyLabel{ + frame={b=1, l=19}, + key='CUSTOM_SHIFT_D', + label='Delete command', + auto_width=true, + on_activate=self:callback('onDelCommand'), + }, + widgets.HotkeyLabel{ + frame={b=0, l=0}, + key='CUSTOM_SHIFT_E', + label='Edit command', + auto_width=true, + on_activate=self:callback('onEditCommand'), }, } @@ -92,7 +102,6 @@ end function QCMDDialog:submit(idx, choice) local screen = self.parent_view - local parent = screen._native.parent dfhack.screen.hideGuard(screen, function() dfhack.run_command(choice.command) end) diff --git a/gui/quickfort.lua b/gui/quickfort.lua index 60b4b0b4aa..2742ca7741 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(SelectDialog, 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}, + 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}, + 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) - end - local full_text = main - if #start_comment > 0 then - full_text = full_text .. '\n\n' .. start_comment + comment = ('%s'):format(v.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='', @@ -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/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/sandbox.lua b/gui/sandbox.lua index 3866fd3096..e99e3d3a64 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 @@ -215,13 +239,26 @@ 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 + 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,7 +444,7 @@ 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 @@ -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/settings-manager.lua b/gui/settings-manager.lua index 6fe62b252d..1a7e468bf5 100644 --- a/gui/settings-manager.lua +++ b/gui/settings-manager.lua @@ -1,11 +1,590 @@ --- An in-game init file editor ---[====[ +--@ module = true + +local argparse = require('argparse') +local control_panel = reqscript('control-panel') +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 li = df.global.plotinfo.labor_info + +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: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 +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', +} + +------------------------------ +-- 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 + +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, + 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 -gui/settings-manager -==================== -An in-game manager for settings defined in ``init.txt`` and ``d_init.txt``. +--[[ + +TODO: reinstate color editor -]====] +-- An in-game init file editor VERSION = '0.6.0' @@ -67,7 +646,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 +676,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 +1470,5 @@ if dfhack.gui.getCurFocus() == 'dfhack/lua/settings_manager' then dfhack.screen.dismiss(dfhack.gui.getCurViewscreen()) end settings_manager():show() + +]] diff --git a/gui/sitemap.lua b/gui/sitemap.lua new file mode 100644 index 0000000000..5dd34b48e3 --- /dev/null +++ b/gui/sitemap.lua @@ -0,0 +1,297 @@ +local gui = require('gui') +local utils = require('utils') +local widgets = require('gui.widgets') + +-- +-- Sitemap +-- + +Sitemap = defclass(Sitemap, widgets.Window) +Sitemap.ATTRS { + frame_title='Sitemap', + frame={w=47, r=2, t=18, h=23}, + resizable=true, +} + +local function to_title_case(str) + return dfhack.capitalizeStringWords(dfhack.lowerCp437(str:gsub('_', ' '))) +end + +local 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.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.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_unit_disposition_and_pen(unit) + local prefix = unit.flags1.caged and 'caged ' or '' + if dfhack.units.isDanger(unit) then + return prefix..'hostile', COLOR_LIGHTRED + end + if not dfhack.units.isFortControlled(unit) and dfhack.units.isWildlife(unit) then + return prefix..'wildlife', COLOR_GREEN + end + return prefix..'friendly', COLOR_LIGHTGREEN +end + +local function get_unit_choice_text(unit) + local disposition, disposition_pen = get_unit_disposition_and_pen(unit) + return { + dfhack.units.getReadableName(unit), + ' (', + {text=disposition, pen=disposition_pen}, + ')', + } +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) +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) +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=5, 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', + on_submit=zoom_to_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, + 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, + choices=artifact_choices, + visible=#artifact_choices > 0, + }, + }, + }, + }, + }, + widgets.Divider{ + frame={b=3, h=1}, + frame_style=gui.FRAME_THIN, + frame_style_l=false, + frame_style_r=false, + }, + widgets.Label{ + frame={b=0, l=0}, + text={ + 'Click on a name or hit ', {text='Enter', pen=COLOR_LIGHTGREEN}, NEWLINE, + 'to zoom to the selected target.' + }, + }, + } +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 + +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/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..068b416475 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.DOUBLE_CLICK_MS 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..c568ad8fa3 --- /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('hack/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 = copyall(df.global.cursor) + 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 cursorPos and keys.SELECT then + 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 = copyall(df.global.cursor) + 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) 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..ed677453a9 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,540 @@ 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), + pen=dfhack.units.getProfessionColor(unit) + } +end + +local function get_translated_name_chunk(unit) + local tname = dfhack.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}, + }, + widgets.Label{ + view_id='translated_name', + frame={t=1, l=0}, + }, + 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 994f967c08..b789bfd97d 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,18 +236,20 @@ 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 - syndrome_raw.syn_name:gsub("^%l", string.upper) - elseif is_transformation then - return "Body transformation" + return syndrome_raw.syn_name:gsub("^%l", string.upper) + elseif transform_creature_name then + return ("Body transformation: %s"):format(transform_creature_name) end return "Mystery" @@ -257,7 +259,7 @@ local function getSyndromeEffects(syndrome_type) local syndrome_raw = df.global.world.raws.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,7 +438,7 @@ 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 @@ -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 = {} diff --git a/gui/workshop-job.lua b/gui/workshop-job.lua index 406c15ebb0..5c368f8ba8 100644 --- a/gui/workshop-job.lua +++ b/gui/workshop-job.lua @@ -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/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..e89cd8e9f1 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 diff --git a/internal/advtools/convo.lua b/internal/advtools/convo.lua new file mode 100644 index 0000000000..96ed0f015b --- /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.TranslateName(name, true) + if heard_of then + title = title .. " (Heard of)" + end + local choice = new_choice(df.talk_choice_type.AskWhereabouts, title, dfhack.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/party.lua b/internal/advtools/party.lua new file mode 100644 index 0000000000..241edc4782 --- /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..cbf95eb809 100644 --- a/internal/caravan/common.lua +++ b/internal/caravan/common.lua @@ -11,7 +11,7 @@ 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} +ALL_PEN = to_pen{ch=string.char(251), fg=COLOR_LIGHTGREEN} function add_words(words, str) for word in str:gmatch("[%w]+") do @@ -25,6 +25,15 @@ function make_search_key(str) return table.concat(words, ' ') end +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 + local function get_broker_skill() local broker = dfhack.units.getUnitByNobleRole('broker') if not broker then return 0 end @@ -58,7 +67,7 @@ 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. @@ -68,47 +77,12 @@ end -- 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 + 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 +111,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 +132,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 +160,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 +170,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 +194,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 +225,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 +235,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 +260,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.huge}, pen=COLOR_GREEN}, }, initial_option=7, on_change=function(val) @@ -379,7 +353,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 +479,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='Foreign-made only', value='foreign', pen=COLOR_YELLOW}, + {label='Fort-made only', value='local', pen=COLOR_BLUE}, + }, + 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 +518,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 +527,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 +542,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 +550,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}, diff --git a/internal/caravan/movegoods.lua b/internal/caravan/movegoods.lua index fa39d219b3..c935c5fb1d 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_A', + 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) return { - {width=STATUS_COL_WIDTH-2, text=at_depot and 'depot' or ''}, + {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)}, {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 @@ -512,12 +564,13 @@ function MoveGoods:cache_choices(group_items, inside_containers) 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) 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) table.insert(group_choices, group) self.value_pending = self.value_pending + (data.per_item_value * data.selected) end @@ -528,13 +581,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 +598,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 +611,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 +735,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 +747,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 +756,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 +767,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 +782,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 +806,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 +824,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..d5ccfc3e1d 100644 --- a/internal/caravan/pedestal.lua +++ b/internal/caravan/pedestal.lua @@ -19,6 +19,15 @@ 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, + inside_containers=true, +} + -- ------------------- -- AssignItems -- @@ -105,7 +114,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 +135,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 +223,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 +248,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 +280,27 @@ 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=2, 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, }, }, }, @@ -366,8 +384,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 +437,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 +463,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 @@ -479,7 +498,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 @@ -495,7 +514,7 @@ function AssignItems:cache_choices(inside_containers, display_bld) if self.choices_cache[inside_containers] then return self.choices_cache[inside_containers] end 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 +522,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)) @@ -666,11 +685,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 +701,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/trade.lua b/internal/caravan/trade.lua index 6d5f3a8d89..05e3adf3b1 100644 --- a/internal/caravan/trade.lua +++ b/internal/caravan/trade.lua @@ -34,9 +34,9 @@ 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}, + resize_min={w=48, h=40}, } local TOGGLE_MAP = { @@ -139,7 +139,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 +174,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 +215,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 +242,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 +296,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', 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 @@ -374,7 +381,7 @@ function Trade:cache_choices(list_idx, trade_bins) 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 +391,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 +405,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), } - 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 +437,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 +452,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 @@ -510,7 +536,7 @@ end -- TradeScreen -- -view = view or nil +trade_view = trade_view or nil TradeScreen = defclass(TradeScreen, gui.ZScreen) TradeScreen.ATTRS { @@ -534,15 +560,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 -- ------------------- @@ -713,6 +742,7 @@ 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', @@ -797,6 +827,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 +842,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 +851,213 @@ 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_A', + 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 goodflag == GOODFLAG.UNCONTAINED_SELECTED or goodflag == GOODFLAG.CONTAINER_COLLAPSED_SELECTED then + in_selected_container = true + elseif goodflag == GOODFLAG.UNCONTAINED_UNSELECTED or goodflag == GOODFLAG.CONTAINER_COLLAPSED_UNSELECTED then + in_selected_container = false + end + if in_selected_container or TARGET_REVMAP[goodflag] 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 + local goodflag = goodflags[choice.data.item_idx] + if TARGET_REVMAP[goodflag] then + goodflags[choice.data.item_idx] = TOGGLE_MAP[goodflag] + end + 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..cbb33cd90e --- /dev/null +++ b/internal/confirm/specs.lua @@ -0,0 +1,522 @@ +--@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 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) + print(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.RouteRemove 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.StopRemove 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.BuildingRemove 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.SquadDisband 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={'_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/SquadEquipment/Customizing/Default', + predicate=function(keys, mouse_offset) + if keys._MOUSE_R then + return uniform_has_changes() + end + if clicked_on_confirm_button(mouse_offset) then + print('confirm click detected') + 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.ui_hotkey.T_cmd.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.TranslateName(dfhack.units.getVisibleName(unit)) + if selected_convict_name == '' then + selected_convict_name = 'this creature' + end + return true + end, +} + +ConfirmSpec{ + id='order-remove', + title='Remove manger order', + message='Are you sure you want to remove this manager order?', + intercept_keys='_MOUSE_L', + context='dwarfmode/Info/WORK_ORDERS/Default', + predicate=function() return mi.current_hover == df.main_hover_instruction.ManagerOrderRemove 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.BurrowRemove or + mi.current_hover == df.main_hover_instruction.BurrowRemovePaint + 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.StockpileRemove 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..21472c45c1 --- /dev/null +++ b/internal/control-panel/common.lua @@ -0,0 +1,211 @@ +--@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 pairs(repeatUtil.repeating) 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.repeating[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 + 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..83c4ac8b80 --- /dev/null +++ b/internal/control-panel/registry.lua @@ -0,0 +1,222 @@ +--@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='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='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='Encourage dwarves to drop tattered clothing on the floor when there is new available clothing.', + 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='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'}, + + -- 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.'}, + {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.'}, + {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/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, + desc='Fix activity references on stuck instruments to make them usable again.', + params={'--time', '1', '--timeUnits', 'days', '--command', '[', 'fix/stuck-instruments', ']'}}, + {command='fix/stuck-worship', group='bugfix', mode='repeat', default=true, + params={'--time', '1', '--timeUnits', 'days', '--command', '[', 'fix/stuck-worship', ']'}}, + {command='fix/noexert-exhaustion', group='bugfix', mode='repeat', default=true, + params={'--time', '439', '--timeUnits', 'ticks', '--command', '[', 'fix/noexert-exhaustion', ']'}}, + {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='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='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='starvingdead', group='gameplay', mode='enable'}, + {command='timestream', group='gameplay', mode='enable'}, + {command='work-now', group='gameplay', mode='enable'}, +} + +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=function() return widgets.DOUBLE_CLICK_MS end, + set_fn=function(val) widgets.DOUBLE_CLICK_MS = val end, + }, + { + 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=function() return widgets.SCROLL_DELAY_MS end, + set_fn=function(val) widgets.SCROLL_DELAY_MS = val end, + }, + { + 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=function() return widgets.SCROLL_INITIAL_DELAY_MS end, + set_fn=function(val) widgets.SCROLL_INITIAL_DELAY_MS = val end, + }, +} + +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..6db26c527b 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() @@ -397,14 +406,16 @@ Line = defclass(Line, Shape) 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, @@ -458,10 +469,19 @@ function Line:plot_bresenham(x0, y0, x1, y1, thickness) 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) @@ -476,7 +496,7 @@ function Line:cubic_bezier(x0, y0, x1, y1, bezier_point1, bezier_point2, thickne end end end - t = t + 0.01 + t = t + granularity end -- Get the last point @@ -498,6 +518,7 @@ 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) @@ -510,7 +531,7 @@ function Line:quadratic_bezier(x0, y0, x1, y1, bezier_point1, thickness) end end end - t = t + 0.01 + t = t + granularity end end @@ -551,13 +572,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, 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/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_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_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/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..67e12209a5 --- /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 and toc:getSelected() > 1 + end + local function can_next() + local toc = self.subviews.table_of_contents + local num_choices = #toc:getChoices() + return num_choices > 0 and toc:getSelected() < num_choices + 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/journal/text_editor.lua b/internal/journal/text_editor.lua new file mode 100644 index 0000000000..8bdd0aeb74 --- /dev/null +++ b/internal/journal/text_editor.lua @@ -0,0 +1,888 @@ +-- Multiline text editor for gui/journal +--@ module = true + +local gui = require 'gui' +local widgets = require 'gui.widgets' +local wrapped_text = reqscript('internal/journal/wrapped_text') + +local CLIPBOARD_MODE = {LOCAL = 1, LINE = 2} +local HISTORY_ENTRY = { + TEXT_BLOCK = 1, + WHITESPACE_BLOCK = 2, + BACKSPACE = 2, + DELETE = 3, + OTHER = 4 +} + +TextEditorHistory = defclass(TextEditorHistory) + +TextEditorHistory.ATTRS{ + history_size = 25, +} + +function TextEditorHistory:init() + self.past = {} + self.future = {} +end + +function TextEditorHistory:store(history_entry_type, text, cursor) + local last_entry = self.past[#self.past] + + if not last_entry or history_entry_type == HISTORY_ENTRY.OTHER or + last_entry.entry_type ~= history_entry_type then + table.insert(self.past, { + entry_type=history_entry_type, + text=text, + cursor=cursor + }) + end + + self.future = {} + + if #self.past > self.history_size then + table.remove(self.past, 1) + end +end + +function TextEditorHistory:undo(curr_text, curr_cursor) + if #self.past == 0 then + return nil + end + + local history_entry = table.remove(self.past, #self.past) + + table.insert(self.future, { + entry_type=OTHER, + text=curr_text, + cursor=curr_cursor + }) + + if #self.future > self.history_size then + table.remove(self.future, 1) + end + + return history_entry +end + +function TextEditorHistory:redo(curr_text, curr_cursor) + if #self.future == 0 then + return true + end + + local history_entry = table.remove(self.future, #self.future) + + table.insert(self.past, { + entry_type=OTHER, + text=curr_text, + cursor=curr_cursor + }) + + if #self.past > self.history_size then + table.remove(self.past, 1) + end + + return history_entry +end + +TextEditor = defclass(TextEditor, widgets.Panel) + +TextEditor.ATTRS{ + init_text = '', + init_cursor = DEFAULT_NIL, + text_pen = COLOR_LIGHTCYAN, + ignore_keys = {'STRING_A096'}, + select_pen = COLOR_CYAN, + on_text_change = DEFAULT_NIL, + on_cursor_change = DEFAULT_NIL, + debug = false +} + +function TextEditor:init() + self.render_start_line_y = 1 + + self:addviews{ + TextEditorView{ + view_id='text_area', + frame={l=0,r=3,t=0}, + text = self.init_text, + + text_pen = self.text_pen, + ignore_keys = self.ignore_keys, + select_pen = self.select_pen, + debug = self.debug, + + on_text_change = function (val) + self:updateLayout() + if self.on_text_change then + self.on_text_change(val) + end + end, + on_cursor_change = self:callback('onCursorChange') + }, + widgets.Scrollbar{ + view_id='scrollbar', + frame={r=0,t=1}, + on_scroll=self:callback('onScrollbar') + }, + widgets.HelpButton{command="gui/journal", frame={r=0,t=0}} + } + self:setFocus(true) +end + +function TextEditor:getText() + return self.subviews.text_area.text +end + +function TextEditor:getCursor() + return self.subviews.text_area.cursor +end + +function TextEditor:onCursorChange(cursor) + local x, y = self.subviews.text_area.wrapped_text:indexToCoords( + self.subviews.text_area.cursor + ) + + if y >= self.render_start_line_y + self.subviews.text_area.frame_body.height then + self:updateScrollbar( + y - self.subviews.text_area.frame_body.height + 1 + ) + elseif (y < self.render_start_line_y) then + self:updateScrollbar(y) + end + + if self.on_cursor_change then + self.on_cursor_change(cursor) + end +end + +function TextEditor:scrollToCursor(cursor_offset) + if self.subviews.scrollbar.visible then + local _, cursor_liny_y = self.subviews.text_area.wrapped_text:indexToCoords( + cursor_offset + ) + self:updateScrollbar(cursor_liny_y) + end +end + +function TextEditor:setCursor(cursor_offset) + return self.subviews.text_area:setCursor(cursor_offset) +end + +function TextEditor:getPreferredFocusState() + return true +end + +function TextEditor:postUpdateLayout() + self:updateScrollbar(self.render_start_line_y) + + if self.subviews.text_area.cursor == nil then + local cursor = self.init_cursor or #self.text + 1 + self.subviews.text_area:setCursor(cursor) + self:scrollToCursor(cursor) + end +end + +function TextEditor:onScrollbar(scroll_spec) + local height = self.subviews.text_area.frame_body.height + + local render_start_line = self.render_start_line_y + if scroll_spec == 'down_large' then + render_start_line = render_start_line + math.ceil(height / 2) + elseif scroll_spec == 'up_large' then + render_start_line = render_start_line - math.ceil(height / 2) + elseif scroll_spec == 'down_small' then + render_start_line = render_start_line + 1 + elseif scroll_spec == 'up_small' then + render_start_line = render_start_line - 1 + else + render_start_line = tonumber(scroll_spec) + end + + self:updateScrollbar(render_start_line) +end + +function TextEditor:updateScrollbar(scrollbar_current_y) + local lines_count = #self.subviews.text_area.wrapped_text.lines + + local render_start_line_y = (math.min( + #self.subviews.text_area.wrapped_text.lines - self.subviews.text_area.frame_body.height + 1, + math.max(1, scrollbar_current_y) + )) + + self.subviews.scrollbar:update( + render_start_line_y, + self.frame_body.height, + lines_count + ) + + if (self.frame_body.height >= lines_count) then + render_start_line_y = 1 + end + + self.render_start_line_y = render_start_line_y + self.subviews.text_area:setRenderStartLineY(self.render_start_line_y) +end + +function TextEditor:renderSubviews(dc) + self.subviews.text_area.frame_body.y1 = self.frame_body.y1-(self.render_start_line_y - 1) + + TextEditor.super.renderSubviews(self, dc) +end + +function TextEditor:onInput(keys) + if (self.subviews.scrollbar.is_dragging) then + return self.subviews.scrollbar:onInput(keys) + end + + return TextEditor.super.onInput(self, keys) +end + +TextEditorView = defclass(TextEditorView, widgets.Widget) + +TextEditorView.ATTRS{ + text = '', + text_pen = COLOR_LIGHTCYAN, + ignore_keys = {'STRING_A096'}, + pen_selection = COLOR_CYAN, + on_text_change = DEFAULT_NIL, + on_cursor_change = DEFAULT_NIL, + enable_cursor_blink = true, + debug = false, + history_size = 10, +} + +function TextEditorView:init() + self.sel_end = nil + self.clipboard = nil + self.clipboard_mode = CLIPBOARD_MODE.LOCAL + self.render_start_line_y = 1 + + self.cursor = nil + + self.main_pen = dfhack.pen.parse({ + fg=self.text_pen, + bg=COLOR_RESET, + bold=true + }) + self.sel_pen = dfhack.pen.parse({ + fg=self.text_pen, + bg=self.pen_selection, + bold=true + }) + + self.wrapped_text = wrapped_text.WrappedText{ + text=self.text, + wrap_width=256 + } + + self.history = TextEditorHistory{history_size=self.history_size} +end + +function TextEditorView:setRenderStartLineY(render_start_line_y) + self.render_start_line_y = render_start_line_y +end + +function TextEditorView:getPreferredFocusState() + return true +end + +function TextEditorView:postComputeFrame() + self:recomputeLines() +end + +function TextEditorView:recomputeLines() + self.wrapped_text:update( + self.text, + -- something cursor '_' need to be add at the end of a line + self.frame_body.width - 1 + ) +end + +function TextEditorView:setCursor(cursor_offset) + self.cursor = math.max( + 1, + math.min(#self.text + 1, cursor_offset) + ) + + if self.debug then + print('cursor', self.cursor) + end + + self.sel_end = nil + self.last_cursor_x = nil + + if self.on_cursor_change then + self.on_cursor_change(self.cursor) + end +end + +function TextEditorView:setSelection(from_offset, to_offset) + -- text selection is always start on self.cursor and on self.sel_end + self:setCursor(from_offset) + self.sel_end = to_offset + + if self.debug and to_offset then + print('sel_end', to_offset) + end +end + +function TextEditorView:hasSelection() + return not not self.sel_end +end + +function TextEditorView:eraseSelection() + if (self:hasSelection()) then + local from, to = self.cursor, self.sel_end + if (from > to) then + from, to = to, from + end + + local new_text = self.text:sub(1, from - 1) .. self.text:sub(to + 1) + self:setText(new_text) + + self:setCursor(from) + self.sel_end = nil + end +end + +function TextEditorView:setClipboard(text) + dfhack.internal.setClipboardTextCp437Multiline(text) +end + +function TextEditorView:copy() + if self.sel_end then + self.clipboard_mode = CLIPBOARD_MODE.LOCAL + + local from = self.cursor + local to = self.sel_end + + if from > to then + from, to = to, from + end + + self:setClipboard(self.text:sub(from, to)) + + return from, to + else + self.clipboard_mode = CLIPBOARD_MODE.LINE + + local curr_line = self.text:sub( + self:lineStartOffset(), + self:lineEndOffset() + ) + if curr_line:sub(-1,-1) ~= NEWLINE then + curr_line = curr_line .. NEWLINE + end + + self:setClipboard(curr_line) + + return self:lineStartOffset(), self:lineEndOffset() + end +end + +function TextEditorView:cut() + local from, to = self:copy() + if not self:hasSelection() then + self:setSelection(from, to) + end + self:eraseSelection() +end + +function TextEditorView:paste() + local clipboard_lines = dfhack.internal.getClipboardTextCp437Multiline() + local clipboard = table.concat(clipboard_lines, '\n') + if clipboard then + if self.clipboard_mode == CLIPBOARD_MODE.LINE and not self:hasSelection() then + local origin_offset = self.cursor + self:setCursor(self:lineStartOffset()) + self:insert(clipboard) + self:setCursor(#clipboard + origin_offset) + else + self:eraseSelection() + self:insert(clipboard) + end + + end +end + +function TextEditorView:setText(text) + local changed = self.text ~= text + self.text = text + + self:recomputeLines() + + if changed and self.on_text_change then + self.on_text_change(text) + end +end + +function TextEditorView:insert(text) + self:eraseSelection() + local new_text = + self.text:sub(1, self.cursor - 1) .. + text .. + self.text:sub(self.cursor) + + self:setText(new_text) + self:setCursor(self.cursor + #text) +end + +function TextEditorView:onRenderBody(dc) + dc:pen(self.main_pen) + + local max_width = dc.width + local new_line = self.debug and NEWLINE or '' + + local lines_to_render = math.min( + dc.height, + #self.wrapped_text.lines - self.render_start_line_y + 1 + ) + + dc:seek(0, self.render_start_line_y - 1) + for i = self.render_start_line_y, self.render_start_line_y + lines_to_render - 1 do + -- do not render new lines symbol + local line = self.wrapped_text.lines[i]:gsub(NEWLINE, new_line) + dc:string(line) + dc:newline() + end + + local show_focus = not self.enable_cursor_blink + or ( + not self:hasSelection() + and self.parent_view.focus + and gui.blink_visible(530) + ) + + if (show_focus) then + local x, y = self.wrapped_text:indexToCoords(self.cursor) + dc:seek(x - 1, y - 1) + :char('_') + end + + if self:hasSelection() then + local sel_new_line = self.debug and PERIOD or '' + local from, to = self.cursor, self.sel_end + if (from > to) then + from, to = to, from + end + + local from_x, from_y = self.wrapped_text:indexToCoords(from) + local to_x, to_y = self.wrapped_text:indexToCoords(to) + + local line = self.wrapped_text.lines[from_y] + :sub(from_x, to_y == from_y and to_x or nil) + :gsub(NEWLINE, sel_new_line) + + dc:pen(self.sel_pen) + :seek(from_x - 1, from_y - 1) + :string(line) + + for y = from_y + 1, to_y - 1 do + line = self.wrapped_text.lines[y]:gsub(NEWLINE, sel_new_line) + dc:seek(0, y - 1) + :string(line) + end + + if (to_y > from_y) then + local line = self.wrapped_text.lines[to_y] + :sub(1, to_x) + :gsub(NEWLINE, sel_new_line) + dc:seek(0, to_y - 1) + :string(line) + end + + dc:pen({fg=self.text_pen, bg=COLOR_RESET}) + end + + if self.debug then + local cursor_char = self:charAtCursor() + local x, y = self.wrapped_text:indexToCoords(self.cursor) + local debug_msg = string.format( + 'x: %s y: %s ind: %s #line: %s char: %s hist-: %s hist+: %s', + x, + y, + self.cursor, + self:lineEndOffset() - self:lineStartOffset(), + (cursor_char == NEWLINE and 'NEWLINE') or + (cursor_char == ' ' and 'SPACE') or + (cursor_char == '' and 'nil') or + cursor_char, + #self.history.past, + #self.history.future + ) + local sel_debug_msg = self.sel_end and string.format( + 'sel_end: %s', + self.sel_end + ) or '' + + dc:pen({fg=COLOR_LIGHTRED, bg=COLOR_RESET}) + :seek(0, self.parent_view.frame_body.height + self.render_start_line_y - 2) + :string(debug_msg) + :seek(0, self.parent_view.frame_body.height + self.render_start_line_y - 3) + :string(sel_debug_msg) + end +end + +function TextEditorView:charAtCursor() + return self.text:sub(self.cursor, self.cursor) +end + +function TextEditorView:getMultiLeftClick(x, y) + if self.last_click then + local from_last_click_ms = dfhack.getTickCount() - self.last_click.tick + + if ( + self.last_click.x ~= x or + self.last_click.y ~= y or + from_last_click_ms > widgets.DOUBLE_CLICK_MS + ) then + self.clicks_count = 0; + end + end + + return self.clicks_count or 0 +end + +function TextEditorView:triggerMultiLeftClick(x, y) + local clicks_count = self:getMultiLeftClick(x, y) + + self.clicks_count = clicks_count + 1 + if (self.clicks_count >= 4) then + self.clicks_count = 1 + end + + self.last_click = { + tick=dfhack.getTickCount(), + x=x, + y=y, + } + return self.clicks_count +end + +function TextEditorView:currentSpacesRange() + -- select "word" only from spaces + local prev_word_end, _ = self.text + :sub(1, self.cursor) + :find('[^%s]%s+$') + local _, next_word_start = self.text:find('%s[^%s]', self.cursor) + + return prev_word_end + 1 or 1, next_word_start - 1 or #self.text +end + +function TextEditorView:currentWordRange() + -- select current word + local _, prev_word_end = self.text + :sub(1, self.cursor - 1) + :find('.*[%s,."\']') + local next_word_start, _ = self.text:find('[%s,."\']', self.cursor) + + return (prev_word_end or 0) + 1, (next_word_start or #self.text + 1) - 1 +end + +function TextEditorView:lineStartOffset(offset) + local loc_offset = offset or self.cursor + return self.text:sub(1, loc_offset - 1):match(".*\n()") or 1 +end + +function TextEditorView:lineEndOffset(offset) + local loc_offset = offset or self.cursor + return self.text:find("\n", loc_offset) or #self.text + 1 +end + +function TextEditorView:wordStartOffset(offset) + return self.text + :sub(1, offset or self.cursor - 1) + :match('.*%s()[^%s]') or 1 +end + +function TextEditorView:wordEndOffset(offset) + return self.text + :match( + '%s*[^%s]*()', + offset or self.cursor + ) or #self.text + 1 +end + +function TextEditorView:onInput(keys) + for _,ignore_key in ipairs(self.ignore_keys) do + if keys[ignore_key] then + return false + end + end + + if self:onMouseInput(keys) then + return true + elseif self:onHistoryInput(keys) then + return true + elseif self:onTextManipulationInput(keys) then + return true + elseif self:onCursorInput(keys) then + return true + elseif keys.CUSTOM_CTRL_C then + self:copy() + return true + elseif keys.CUSTOM_CTRL_X then + self:cut() + self.history:store(HISTORY_ENTRY.OTHER, self.text, self.cursor) + return true + elseif keys.CUSTOM_CTRL_V then + self:paste() + self.history:store(HISTORY_ENTRY.OTHER, self.text, self.cursor) + return true + end +end + +function TextEditorView:onHistoryInput(keys) + if keys.CUSTOM_CTRL_Z then + local history_entry = self.history:undo(self.text, self.cursor) + + if history_entry then + self:setText(history_entry.text) + self:setCursor(history_entry.cursor) + end + + return true + elseif keys.CUSTOM_CTRL_Y then + local history_entry = self.history:redo(self.text, self.cursor) + + if history_entry then + self:setText(history_entry.text) + self:setCursor(history_entry.cursor) + end + + return true + end +end + +function TextEditorView:onMouseInput(keys) + if keys._MOUSE_L then + local mouse_x, mouse_y = self:getMousePos() + if mouse_x and mouse_y then + + local clicks_count = self:triggerMultiLeftClick( + mouse_x + 1, + mouse_y + 1 + ) + if clicks_count == 3 then + self:setSelection( + self:lineStartOffset(), + self:lineEndOffset() + ) + elseif clicks_count == 2 then + local cursor_char = self:charAtCursor() + + local is_white_space = ( + cursor_char == ' ' or cursor_char == NEWLINE + ) + + local from, to + if is_white_space then + from, to = self:currentSpacesRange() + else + from, to = self:currentWordRange() + end + + self:setSelection(from, to) + else + self:setCursor(self.wrapped_text:coordsToIndex( + mouse_x + 1, + mouse_y + 1 + )) + end + + return true + end + + elseif keys._MOUSE_L_DOWN then + + local mouse_x, mouse_y = self:getMousePos() + if mouse_x and mouse_y then + if (self:getMultiLeftClick(mouse_x + 1, mouse_y + 1) > 1) then + return true + end + + local offset = self.wrapped_text:coordsToIndex( + mouse_x + 1, + mouse_y + 1 + ) + + if self.cursor ~= offset then + self:setSelection(self.cursor, offset) + else + self.sel_end = nil + end + + return true + end + end +end + +function TextEditorView:onCursorInput(keys) + if keys.KEYBOARD_CURSOR_LEFT then + self:setCursor(self.cursor - 1) + return true + elseif keys.KEYBOARD_CURSOR_RIGHT then + self:setCursor(self.cursor + 1) + return true + elseif keys.KEYBOARD_CURSOR_UP then + local x, y = self.wrapped_text:indexToCoords(self.cursor) + local last_cursor_x = self.last_cursor_x or x + local offset = y > 1 and + self.wrapped_text:coordsToIndex(last_cursor_x, y - 1) or + 1 + self:setCursor(offset) + self.last_cursor_x = last_cursor_x + return true + elseif keys.KEYBOARD_CURSOR_DOWN then + local x, y = self.wrapped_text:indexToCoords(self.cursor) + local last_cursor_x = self.last_cursor_x or x + local offset = y < #self.wrapped_text.lines and + self.wrapped_text:coordsToIndex(last_cursor_x, y + 1) or + #self.text + 1 + self:setCursor(offset) + self.last_cursor_x = last_cursor_x + return true + elseif keys.KEYBOARD_CURSOR_UP_FAST then + self:setCursor(1) + return true + elseif keys.KEYBOARD_CURSOR_DOWN_FAST then + -- go to text end + self:setCursor(#self.text + 1) + return true + elseif keys.CUSTOM_CTRL_B or keys.A_MOVE_W_DOWN then + -- back one word + local word_start = self:wordStartOffset() + self:setCursor(word_start) + return true + elseif keys.CUSTOM_CTRL_F or keys.A_MOVE_E_DOWN then + -- forward one word + local word_end = self:wordEndOffset() + self:setCursor(word_end) + return true + elseif keys.CUSTOM_CTRL_H then + -- line start + self:setCursor( + self:lineStartOffset() + ) + return true + elseif keys.CUSTOM_CTRL_E then + -- line end + self:setCursor( + self:lineEndOffset() + ) + return true + end +end + +function TextEditorView:onTextManipulationInput(keys) + if keys.SELECT then + -- handle enter + self.history:store( + HISTORY_ENTRY.WHITESPACE_BLOCK, + self.text, + self.cursor + ) + self:insert(NEWLINE) + + return true + + elseif keys._STRING then + if keys._STRING == 0 then + -- handle backspace + self.history:store(HISTORY_ENTRY.BACKSPACE, self.text, self.cursor) + + if (self:hasSelection()) then + self:eraseSelection() + else + if (self.cursor == 1) then + return true + end + + self:setSelection( + self.cursor - 1, + self.cursor - 1 + ) + self:eraseSelection() + end + + else + local cv = string.char(keys._STRING) + + if (self:hasSelection()) then + self.history:store(HISTORY_ENTRY.OTHER, self.text, self.cursor) + self:eraseSelection() + else + local entry_type = cv == ' ' and HISTORY_ENTRY.WHITESPACE_BLOCK + or HISTORY_ENTRY.TEXT_BLOCK + self.history:store(entry_type, self.text, self.cursor) + end + + self:insert(cv) + end + + return true + elseif keys.CUSTOM_CTRL_A then + -- select all + self:setSelection(1, #self.text) + return true + elseif keys.CUSTOM_CTRL_U then + -- delete current line + self.history:store(HISTORY_ENTRY.OTHER, self.text, self.cursor) + + if (self:hasSelection()) then + -- delete all lines that has selection + self:setSelection( + self:lineStartOffset(self.cursor), + self:lineEndOffset(self.sel_end) + ) + self:eraseSelection() + else + self:setSelection( + self:lineStartOffset(), + self:lineEndOffset() + ) + self:eraseSelection() + end + + return true + elseif keys.CUSTOM_CTRL_K then + -- delete from cursor to end of current line + self.history:store(HISTORY_ENTRY.OTHER, self.text, self.cursor) + + local line_end = self:lineEndOffset(self.sel_end or self.cursor) - 1 + self:setSelection( + self.cursor, + math.max(line_end, self.cursor) + ) + self:eraseSelection() + + return true + elseif keys.CUSTOM_CTRL_D then + -- delete char, there is no support for `Delete` key + self.history:store(HISTORY_ENTRY.DELETE, self.text, self.cursor) + + if (self:hasSelection()) then + self:eraseSelection() + else + self:setText( + self.text:sub(1, self.cursor - 1) .. + self.text:sub(self.cursor + 1) + ) + end + + return true + elseif keys.CUSTOM_CTRL_W then + -- delete one word backward + self.history:store(HISTORY_ENTRY.OTHER, self.text, self.cursor) + + if not self:hasSelection() and self.cursor ~= 1 then + self:setSelection( + self:wordStartOffset(), + math.max(self.cursor - 1, 1) + ) + end + self:eraseSelection() + + return true + end +end diff --git a/internal/journal/wrapped_text.lua b/internal/journal/wrapped_text.lua new file mode 100644 index 0000000000..68001c877a --- /dev/null +++ b/internal/journal/wrapped_text.lua @@ -0,0 +1,70 @@ +--@ module = true + +-- This class caches lines of text wrapped to a specified width for performance +-- and readability. It can convert a given text index to (x, y) coordinates in +-- the wrapped text and vice versa. + +-- Usage: +-- This class should only be used in the following scenarios. +-- 1. When text or text features need to be rendered +-- (wrapped {x, y} coordinates are required). +-- 2. When mouse input needs to be converted to the original text position. + +-- Using this class in other scenarios may lead to issues with the component's +-- behavior when the text is wrapped. +WrappedText = defclass(WrappedText) + +WrappedText.ATTRS{ + text = '', + wrap_width = DEFAULT_NIL, +} + +function WrappedText:init() + self:update(self.text, self.wrap_width) +end + +function WrappedText:update(text, wrap_width) + self.lines = text:wrap( + wrap_width, + { + return_as_table=true, + keep_trailing_spaces=true, + keep_original_newlines=true + } + ) +end + +function WrappedText:coordsToIndex(x, y) + local offset = 0 + + local normalized_y = math.max( + 1, + math.min(y, #self.lines) + ) + + local line_bonus_length = normalized_y == #self.lines and 1 or 0 + local normalized_x = math.max( + 1, + math.min(x, #self.lines[normalized_y] + line_bonus_length) + ) + + for i=1, normalized_y - 1 do + offset = offset + #self.lines[i] + end + + return offset + normalized_x +end + +function WrappedText:indexToCoords(index) + local offset = index + + for y, line in ipairs(self.lines) do + local line_bonus_length = y == #self.lines and 1 or 0 + if offset <= #line + line_bonus_length then + return offset, y + end + offset = offset - #line + end + + return #self.lines[#self.lines] + 1, #self.lines +end diff --git a/internal/notify/notifications.lua b/internal/notify/notifications.lua new file mode 100644 index 0000000000..cd3341af62 --- /dev/null +++ b/internal/notify/notifications.lua @@ -0,0 +1,525 @@ +--@module = true + +local gui = require('gui') +local json = require('json') +local list_agreements = reqscript('list-agreements') +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 + +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 + +-- the order of this list controls the order the notifications will appear in the overlay +NOTIFICATIONS_BY_IDX = { + { + 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) do + if mandate.mode == df.mandate.T_mode.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 = 'moody dwarf can\'t find needed workshop!' + 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 = 'moody dwarf can\'t find needed item!' + 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, + }, +} + +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() 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..e2f65ad703 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 @@ -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 @@ -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, @@ -1110,6 +1215,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 +1257,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 +1301,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..9f258c7e77 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) 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 new file mode 100644 index 0000000000..ffd0ccf173 --- /dev/null +++ b/internal/quickfort/burrow.lua @@ -0,0 +1,200 @@ +-- burrow-related data and logic for the quickfort script +--@ module = true + +if not dfhack_flags.module then + qerror('this script cannot be called directly') +end + +local civalert = reqscript('gui/civ-alert') +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 utils = require('utils') + +local log = quickfort_common.log + +local burrow_db = { + a={label='Add', add=true}, + e={label='Erase', add=false}, +} + +local function custom_burrow(_, keys) + local token_and_label, props_start_pos = quickfort_parse.parse_token_and_label(keys, 1, '%w') + if not token_and_label or not rawget(burrow_db, token_and_label.token) then return nil end + local db_entry = copyall(burrow_db[token_and_label.token]) + local props = quickfort_parse.parse_properties(keys, props_start_pos) + if props.name then + db_entry.name = props.name + props.name = nil + end + if db_entry.add and props.create == 'true' then + db_entry.create = true + props.create = nil + end + if db_entry.add and props.civalert == 'true' then + db_entry.civalert = true + props.civalert = nil + end + if db_entry.add and props.autochop_clear == 'true' then + db_entry.autochop_clear = true + props.autochop_clear = nil + end + if db_entry.add and props.autochop_chop == 'true' then + db_entry.autochop_chop = true + props.autochop_chop = nil + end + + for k,v in pairs(props) do + dfhack.printerr(('unhandled property for symbol "%s": "%s"="%s"'):format( + token_and_label.token, k, v)) + end + + return db_entry +end + +setmetatable(burrow_db, {__index=custom_burrow}) + +local burrows = df.global.plotinfo.burrows + +local function create_burrow(name) + local b = df.burrow:new() + b.id = burrows.next_id + burrows.next_id = burrows.next_id + 1 + if name then + b.name = name + end + b.symbol_index = math.random(0, 22) + b.texture_r = math.random(0, 255) + b.texture_g = math.random(0, 255) + b.texture_b = math.random(0, 255) + b.texture_br = 255 - b.texture_r + b.texture_bg = 255 - b.texture_g + b.texture_bb = 255 - b.texture_b + burrows.list:insert('#', b) + return b +end + +local function do_burrow(ctx, db_entry, pos) + local stats = ctx.stats + local b + if db_entry.name then + b = dfhack.burrows.findByName(db_entry.name, true) + end + if not b and db_entry.add then + if db_entry.create then + b = create_burrow(db_entry.name) + stats.burrow_created.value = stats.burrow_created.value + 1 + else + log('could not find burrow to add to') + return + end + end + if b then + dfhack.burrows.setAssignedTile(b, pos, db_entry.add) + stats['burrow_tiles_'..(db_entry.add and 'added' or 'removed')].value = + stats['burrow_tiles_'..(db_entry.add and 'added' or 'removed')].value + 1 + if db_entry.civalert then + if db_entry.add then + civalert.add_civalert_burrow(b.id) + else + civalert.remove_civalert_burrow(b.id) + end + end + if db_entry.autochop_clear or db_entry.autochop_chop then + if db_entry.autochop_chop then + dfhack.run_command('autochop', (db_entry.add and '' or 'no')..'chop', tostring(b.id)) + end + if db_entry.autochop_clear then + dfhack.run_command('autochop', (db_entry.add and '' or 'no')..'clear', tostring(b.id)) + end + end + if not db_entry.add and db_entry.create and #dfhack.burrows.listBlocks(b) == 0 then + dfhack.burrows.clearTiles(b) + local _, _, idx = utils.binsearch(burrows.list, b.id, 'id') + if idx then + burrows.list:erase(idx) + b:delete() + stats.burrow_destroyed.value = stats.burrow_destroyed.value + 1 + end + end + elseif not db_entry.add then + for _,burrow in ipairs(burrows.list) do + dfhack.burrows.setAssignedTile(burrow, pos, false) + end + stats.burrow_tiles_removed.value = stats.burrow_tiles_removed.value + 1 + end +end + +function do_run_impl(zlevel, grid, ctx, invert) + local stats = ctx.stats + stats.burrow_created = stats.burrow_created or + {label='Burrows created', value=0} + stats.burrow_destroyed = stats.burrow_destroyed or + {label='Burrows destroyed', value=0} + stats.burrow_tiles_added = stats.burrow_tiles_added or + {label='Burrow tiles added', value=0} + stats.burrow_tiles_removed = stats.burrow_tiles_removed or + {label='Burrow tiles removed', value=0} + + ctx.bounds = ctx.bounds or quickfort_map.MapBoundsChecker{} + for y, row in pairs(grid) do + for x, cell_and_text in pairs(row) do + local cell, text = cell_and_text.cell, cell_and_text.text + local pos = xyz2pos(x, y, zlevel) + log('applying spreadsheet cell %s with text "%s" to map' .. + ' coordinates (%d, %d, %d)', cell, text, pos.x, pos.y, pos.z) + local db_entry = nil + local keys, extent = quickfort_parse.parse_cell(ctx, text) + if keys then db_entry = burrow_db[keys] end + if not db_entry then + dfhack.printerr(('invalid key sequence: "%s" in cell %s') + :format(text, cell)) + stats.invalid_keys.value = stats.invalid_keys.value + 1 + goto continue + end + if invert then + db_entry = copyall(db_entry) + db_entry.add = not db_entry.add + end + if extent.specified then + -- shift pos to the upper left corner of the extent and convert + -- the extent dimensions to positive, simplifying the logic below + pos.x = math.min(pos.x, pos.x + extent.width + 1) + pos.y = math.min(pos.y, pos.y + extent.height + 1) + end + for extent_x=1,math.abs(extent.width) do + for extent_y=1,math.abs(extent.height) do + local extent_pos = xyz2pos( + pos.x+extent_x-1, + pos.y+extent_y-1, + pos.z) + if not ctx.bounds:is_on_map(extent_pos) then + log('coordinates out of bounds; skipping (%d, %d, %d)', + extent_pos.x, extent_pos.y, extent_pos.z) + stats.out_of_bounds.value = + stats.out_of_bounds.value + 1 + else + quickfort_preview.set_preview_tile(ctx, extent_pos, true) + if not ctx.dry_run then + do_burrow(ctx, db_entry, extent_pos) + end + end + end + end + ::continue:: + end + end +end + +function do_run(zlevel, grid, ctx) + do_run_impl(zlevel, grid, ctx, false) +end + +function do_orders() + log('nothing to do for blueprints in mode: burrow') +end + +function do_undo(zlevel, grid, ctx) + do_run_impl(zlevel, grid, ctx, true) +end 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/orders.lua b/internal/quickfort/orders.lua index 53adbcae21..3dfae5b955 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 @@ -130,9 +150,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/parse.lua b/internal/quickfort/parse.lua index 1e29010e7f..4cf10482d4 100644 --- a/internal/quickfort/parse.lua +++ b/internal/quickfort/parse.lua @@ -15,6 +15,7 @@ valid_modes = utils.invert({ 'build', 'place', 'zone', + 'burrow', 'meta', 'notes', 'ignore', diff --git a/internal/quickfort/place.lua b/internal/quickfort/place.lua index 66eccda46c..c3533103f4 100644 --- a/internal/quickfort/place.lua +++ b/internal/quickfort/place.lua @@ -163,33 +163,33 @@ 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 @@ -227,7 +227,7 @@ local function custom_stockpile(_, keys) 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,12 +257,12 @@ 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 @@ -323,17 +323,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) diff --git a/internal/quickfort/stockflow.lua b/internal/quickfort/stockflow.lua index 52fa743118..42b54e148b 100644 --- a/internal/quickfort/stockflow.lua +++ b/internal/quickfort/stockflow.lua @@ -129,19 +129,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 +172,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 @@ -634,7 +634,7 @@ function create_orders(order, 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..0c46eb5ca0 100644 --- a/internal/quickfort/zone.lua +++ b/internal/quickfort/zone.lua @@ -16,22 +16,22 @@ 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 @@ -105,9 +105,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={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}}, @@ -122,12 +122,13 @@ local zone_db_raw = { T={label='Tomb', props_fn=parse_tomb_props, default_data={type=df.civzone_type.Tomb, assign={zone_settings={tomb={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 +136,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 +157,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 @@ -241,7 +242,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 @@ -254,7 +255,7 @@ local function parse_zone_config(c, props) 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)) + log('could not find a unit assigned to noble position: "%s"', props.assigned_unit) end props.assigned_unit = nil end @@ -312,7 +313,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] diff --git a/internal/unit-info-viewer/skills-progress.lua b/internal/unit-info-viewer/skills-progress.lua new file mode 100644 index 0000000000..7e7dd17149 --- /dev/null +++ b/internal/unit-info-viewer/skills-progress.lua @@ -0,0 +1,170 @@ +--@ 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 + +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 rating = df.skill_rating.attrs[math.max(df.skill_rating.Dabbling, math.min(skill.rating, df.skill_rating.Legendary5))] + 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, rating.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 / rating.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/item.lua b/item.lua new file mode 100644 index 0000000000..a6dc37712c --- /dev/null +++ b/item.lua @@ -0,0 +1,449 @@ +--@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 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], 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, +} + +--- @type (fun(item:item):boolean)[] +local conditions = {} + +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 }, + { '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 }, + { 'q', 'min-quality', hasArg = true, + handler = function(levelst) + local level = argparse.nonnegativeInt(levelst, 'min-quality') + condition_quality(conditions, level, 5) end }, + { 'Q', 'max-quality', hasArg = true, + handler = function(levelst) + local level = argparse.nonnegativeInt(levelst, 'max-quality') + 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 + +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/launch.lua b/launch.lua index 1ec31ba210..763eace9ad 100644 --- a/launch.lua +++ b/launch.lua @@ -65,9 +65,11 @@ function launch(unitSource,unitRider) 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 +82,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..28cb52e590 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,8 +33,6 @@ function leverPullInstant(lever) else lever.state = 1 end - - print(leverDescribe(lever)) end function leverDescribe(lever) @@ -146,8 +142,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..2207c48d1c 100644 --- a/linger.lua +++ b/linger.lua @@ -1,66 +1,42 @@ --- 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!") + local slayerName = "" + if slayer.name.has_name then + slayerName = ", " .. dfhack.TranslateName(slayer.name) .. "," + end + qerror("Your slayer" .. slayerName .. " is dead!") end bodyswap.swapAdvUnit(slayer) diff --git a/list-agreements.lua b/list-agreements.lua index ca2b029f32..7666c4c39f 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" @@ -156,6 +124,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 +169,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/locate-ore.lua b/locate-ore.lua index 6fbf9c67d5..a9e027fbbf 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 @@ -80,14 +58,21 @@ 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 @@ -97,130 +82,99 @@ local function findOreVeins(target_ore, show_undiscovered) 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 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[mat_index] - str = ("%s%s, "):format(str, string.lower(metal_raw.id)) + 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/make-legendary.lua b/make-legendary.lua index 97144b10cf..ca1edf9529 100644 --- a/make-legendary.lua +++ b/make-legendary.lua @@ -1,140 +1,106 @@ -- 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. - -]====] - --- 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 -end +local utils = require('utils') function getName(unit) return dfhack.df2console(dfhack.TranslateName(dfhack.units.getVisibleName(unit))) end -function make_legendary(skillname) - local skillnamenoun,skillnum - local unit=dfhack.gui.getSelectedUnit() +function legendize(unit, skill_idx) + utils.insert_or_update(unit.status.current_soul.skills, + {new=true, id=skill_idx, rating=df.skill_rating.Legendary5}, + 'id') +end - if unit==nil then - print ("No unit under cursor! Aborting with extreme prejudice.") +function make_legendary(skillname) + 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 + legendize(unit, i) 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..565b7c6856 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,74 @@ 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.unit_inventory_item.T_mode.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 + +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; + + 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 +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 +109,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 +132,93 @@ 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 - - --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) + -- 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 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 +226,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..a914e55bb3 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.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/create-item.lua b/modtools/create-item.lua index 031f5c56b1..b81978f7ab 100644 --- a/modtools/create-item.lua +++ b/modtools/create-item.lua @@ -53,7 +53,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 @@ -118,8 +119,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 +141,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 +153,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 @@ -175,10 +176,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 @@ -225,7 +226,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 +240,68 @@ 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 diff --git a/modtools/create-unit.lua b/modtools/create-unit.lua index 687be8ae13..8f5b623caf 100644 --- a/modtools/create-unit.lua +++ b/modtools/create-unit.lua @@ -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) @@ -915,13 +913,14 @@ 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 @@ -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/extra-gamelog.lua b/modtools/extra-gamelog.lua index c2360758c8..343b5ef34a 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 @@ -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 @@ -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/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..5ccb91bd42 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.unit_inventory_item.T_mode[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.unit_inventory_item.T_mode.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..a0f8f76944 100644 --- a/modtools/moddable-gods.lua +++ b/modtools/moddable-gods.lua @@ -1,31 +1,6 @@ --- Create gods from the command-line ---based on moddableGods by Putnam ---edited by expwnent -local usage = [====[ +local utils = require('utils') -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({ +local validArgs = utils.invert{ 'help', 'name', 'spheres', @@ -33,11 +8,11 @@ local validArgs = utils.invert({ 'depictedAs', 'verbose', -- 'entities', -}) +} local args = utils.processArgs({...}, validArgs) if args.help then - print(usage) + print(dfhack.script_help()) return end diff --git a/modtools/reaction-trigger-transition.lua b/modtools/reaction-trigger-transition.lua index 4d0fa2dd1f..139c4f85cd 100644 --- a/modtools/reaction-trigger-transition.lua +++ b/modtools/reaction-trigger-transition.lua @@ -36,7 +36,7 @@ for _,reaction in ipairs(df.global.world.raws.reactions.reactions) do end local inorganic = df.global.world.raws.inorganics[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..c71e84f378 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' @@ -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', @@ -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..b0b3019218 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", @@ -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-personality.lua b/modtools/set-personality.lua index 12655b496a..29c966e25c 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", @@ -236,7 +165,7 @@ 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]] @@ -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/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..42c90136f3 100644 --- a/position.lua +++ b/position.lua @@ -1,13 +1,3 @@ ---prints current time and position ---[====[ - -position -======== -Reports the current time: date, clock time, month, and season. Also reports -location: z-level, cursor position, window size, and mouse location. - -]====] - local months = { 'Granite, in early Spring.', 'Slate, in mid Spring.', diff --git a/pref-adjust.lua b/pref-adjust.lua index 9eae2aad34..a783c97094 100644 --- a/pref-adjust.lua +++ b/pref-adjust.lua @@ -1,34 +1,5 @@ -- 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 @@ -172,20 +143,16 @@ 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 -- --------------------------------------------------------------------------- diff --git a/prefchange.lua b/prefchange.lua index 96a836610a..826d1670bd 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() @@ -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 _,v in ipairs(dfhack.units.getCitizens()) do + print("Showing Preferences for "..dfhack.TranslateName(dfhack.units.getVisibleName(v))) + print_all(v) 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 _,v in ipairs(dfhack.units.getCitizens()) do + print("Clearing Preferences for "..dfhack.TranslateName(dfhack.units.getVisibleName(v))) + clear_all(v) end end -- --------------------------------------------------------------------------- diff --git a/prioritize.lua b/prioritize.lua index ce284dc1f3..269a8a3acc 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 print_status_line(num_jobs, job_type, annotation) +local function get_reaction_annotation_str(reaction) + return (' --reaction-name %s'):format(reaction) +end + +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,11 +183,19 @@ 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 + for_all_jobs( + function(job) + if not job.flags.do_now and boost_job_if_matches(job, job_matchers) then count = count + 1 end end) @@ -285,7 +288,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 +308,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 +367,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 +413,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 +429,34 @@ 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 + 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 +499,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 +604,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 +616,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 +645,82 @@ if dfhack.internal.IN_TEST then } end +-------------------------------- +-- EnRouteOverlay +-- + +local function is_visible() + local job = dfhack.gui.getSelectedJob(true) + return job and not job.flags.suspend and + (job.job_type == df.job_type.DestroyBuilding or + job.job_type == df.job_type.ConstructBuilding) +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 = dfhack.gui.getSelectedJob(true) + 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) +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 = dfhack.gui.getSelectedJob(true) + 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 = dfhack.gui.getSelectedJob(true) + 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 +730,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..072e75171f 100644 --- a/putontable.lua +++ b/putontable.lua @@ -21,7 +21,7 @@ local build=dfhack.buildings.findAtTile(pos.x,pos.y,pos.z) 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 +for k,v in pairs(df.global.world.items.other.IN_PLAY) 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 table.insert(items,v) if not doall then 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 0835689318..5930b565f2 100644 --- a/quickfort.lua +++ b/quickfort.lua @@ -19,6 +19,7 @@ function refresh_scripts() reqscript('internal/quickfort/api') reqscript('internal/quickfort/build') reqscript('internal/quickfort/building') + reqscript('internal/quickfort/burrow') reqscript('internal/quickfort/command') reqscript('internal/quickfort/common') reqscript('internal/quickfort/dig') @@ -54,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 @@ -78,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..e5352ce6ca 100644 --- a/quickstart-guide.lua +++ b/quickstart-guide.lua @@ -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/rejuvenate.lua b/rejuvenate.lua index 38eb181fa6..de9bf14b23 100644 --- a/rejuvenate.lua +++ b/rejuvenate.lua @@ -12,7 +12,7 @@ function rejuvenate(unit, force, dry_run, age) local new_birth_year = current_year - age local name = dfhack.df2console(dfhack.TranslateName(dfhack.units.getVisibleName(unit))) if unit.birth_year > new_birth_year and not force then - print(name .. ' is under ' .. age .. ' years old. Use -force to force.') + print(name .. ' is under ' .. age .. ' years old. Use --force to force.') return end if dry_run then @@ -30,16 +30,11 @@ function rejuvenate(unit, force, dry_run, age) end function main(args) - local current_year, newbirthyear 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) diff --git a/remove-stress.lua b/remove-stress.lua index e92ccb4b80..76bdee9dac 100644 --- a/remove-stress.lua +++ b/remove-stress.lua @@ -2,22 +2,8 @@ --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 @@ -42,7 +28,7 @@ function main(...) local stress_value = -1000000 if args.help then - print(help) + print(dfhack.script_help()) return end @@ -55,7 +41,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/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..6b821b8fcf 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) 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..ff9305ea32 100644 --- a/source.lua +++ b/source.lua @@ -1,15 +1,19 @@ --@ module = true 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 +21,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 +52,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 +119,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 index = find_liquid_source_at_pos(targetPos) if command == 'delete' then if targetPos.x < 0 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))) @@ -127,18 +158,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..f1519c8c0f 100644 --- a/starvingdead.lua +++ b/starvingdead.lua @@ -3,8 +3,6 @@ --@module = true local argparse = require('argparse') -local json = require('json') -local persist = require('persist-table') local GLOBAL_KEY = 'starvingdead' @@ -15,8 +13,8 @@ function isEnabled() end local function persist_state() - persist.GlobalTable[GLOBAL_KEY] = json.encode({ - enabled = starvingDeadInstance ~= nil, + dfhack.persistent.saveSiteData(GLOBAL_KEY, { + enabled = isEnabled(), decay_rate = starvingDeadInstance and starvingDeadInstance.decay_rate or 1, death_threshold = starvingDeadInstance and starvingDeadInstance.death_threshold or 6 }) @@ -32,7 +30,7 @@ dfhack.onStateChange[GLOBAL_KEY] = function(sc) return end - local persisted_data = json.decode(persist.GlobalTable[GLOBAL_KEY] or '{}') + local persisted_data = dfhack.persistent.getSiteData(GLOBAL_KEY, {}) if persisted_data.enabled then starvingDeadInstance = StarvingDead{ diff --git a/superdwarf.lua b/superdwarf.lua index eb3b3b0648..16f3cacffb 100644 --- a/superdwarf.lua +++ b/superdwarf.lua @@ -57,10 +57,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 new file mode 100644 index 0000000000..36a2d67972 --- /dev/null +++ b/sync-windmills.lua @@ -0,0 +1,37 @@ +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.rotation = rotate_fn() + bld.rotate_timer = timer_fn() + end + end +end + +local opts = {} +argparse.processArgsGetopt({...}, { + { 'h', 'help', handler = function() opts.help = true end }, + { 'q', 'quiet', handler = function() opts.quiet = true end }, + { 'r', 'randomize', handler = function() opts.randomize = true end }, + { 't', 'timing-only', handler = function() opts.timing = true end }, +}) + +if opts.help then + print(dfhack.script_help()) + return +end + +process_windmills( + (opts.randomize or opts.timing) and + 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) + +if not opts.quiet then + print(('%d windmills %s'):format( + #df.global.world.buildings.other.WINDMILL, + opts.randomize and 'randomized' or 'synchronized')) +end diff --git a/test/gui/journal.lua b/test/gui/journal.lua new file mode 100644 index 0000000000..29fcc1d694 --- /dev/null +++ b/test/gui/journal.lua @@ -0,0 +1,3070 @@ +local gui = require('gui') +local gui_journal = reqscript('gui/journal') + +config = { + target = 'gui/journal', + mode = 'fortress' +} + +local df_major_version = tonumber(dfhack.getCompiledDFVersion():match('%d+')) + +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='test:', + save_on_change=options.save_on_change or false, + 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.text_area + + text_area.enable_cursor_blink = false + if not options.save_on_change then + text_area:setText('') + end + + 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.load_input_multiline_text() + local journal, text_area, journal_window = arrange_empty_journal({w=80}) + + local text = table.concat({ + 'Lorem ipsum dolor sit amet, consectetur adipiscing elit.', + 'Pellentesque dignissim volutpat orci, sed molestie metus elementum vel.', + 'Donec sit amet mattis ligula, ac vestibulum lorem.', + }, '\n') + simulate_input_text(text) + + expect.eq(read_rendered_text(text_area), text .. '_') + + journal:dismiss() +end + +function test.handle_numpad_numbers_as_text() + local journal, text_area, journal_window = arrange_empty_journal({w=80}) + + local text = table.concat({ + 'Lorem ipsum dolor sit amet, consectetur adipiscing elit.', + }, '\n') + simulate_input_text(text) + + simulate_input_keys({ + STANDARDSCROLL_LEFT = true, + KEYBOARD_CURSOR_LEFT = true, + _STRING = 52, + STRING_A052 = true, + }) + + expect.eq(read_rendered_text(text_area), text .. '4_') + + simulate_input_keys({ + STRING_A054 = true, + STANDARDSCROLL_RIGHT = true, + KEYBOARD_CURSOR_RIGHT = true, + _STRING = 54, + }) + expect.eq(read_rendered_text(text_area), text .. '46_') + + simulate_input_keys({ + KEYBOARD_CURSOR_DOWN = true, + STRING_A050 = true, + _STRING = 50, + STANDARDSCROLL_DOWN = true, + }) + + expect.eq(read_rendered_text(text_area), text .. '462_') + + simulate_input_keys({ + KEYBOARD_CURSOR_UP = true, + STRING_A056 = true, + STANDARDSCROLL_UP = true, + _STRING = 56, + }) + + expect.eq(read_rendered_text(text_area), text .. '4628_') + journal:dismiss() +end + +function test.wrap_text_to_available_width() + local journal, text_area = arrange_empty_journal({w=55}) + + local text = table.concat({ + '60: Lorem ipsum dolor sit amet, consectetur adipiscing elit.', + '112: Sed consectetur, urna sit amet aliquet egestas, ante nibh porttitor mi, vitae rutrum eros metus nec libero.', + '41: Etiam id congue urna, vel aliquet mi.', + '45: Nam dignissim libero a interdum porttitor.', + '73: Proin dignissim euismod augue, laoreet porttitor est pellentesque ac.', + }, '\n') + + simulate_input_text(text) + + expect.eq(read_rendered_text(text_area), table.concat({ + '60: Lorem ipsum dolor sit amet, consectetur adipiscing ', + 'elit.', + '112: Sed consectetur, urna sit amet aliquet egestas, ', + 'ante nibh porttitor mi, vitae rutrum eros metus nec ', + 'libero.', + '41: Etiam id congue urna, vel aliquet mi.', + '45: Nam dignissim libero a interdum porttitor.', + '73: Proin dignissim euismod augue, laoreet porttitor ', + 'est pellentesque ac._', + }, '\n')); + + journal:dismiss() +end + +function test.submit_new_line() + local journal, text_area = arrange_empty_journal({w=55}) + + local text = table.concat({ + '60: Lorem ipsum dolor sit amet, consectetur adipiscing elit.', + '112: Sed consectetur, urna sit amet aliquet egestas, ante nibh porttitor mi, vitae rutrum eros metus nec libero.', + }, '\n') + + simulate_input_text(text) + + simulate_input_keys('SELECT') + simulate_input_keys('SELECT') + + expect.eq(read_rendered_text(text_area), table.concat({ + '60: Lorem ipsum dolor sit amet, consectetur adipiscing ', + 'elit.', + '112: Sed consectetur, urna sit amet aliquet egestas, ', + 'ante nibh porttitor mi, vitae rutrum eros metus nec ', + 'libero.', + '', + '_', + }, '\n')); + + text_area:setCursor(58) + journal:onRender() + + simulate_input_keys('SELECT') + + expect.eq(read_rendered_text(text_area), table.concat({ + '60: Lorem ipsum dolor sit amet, consectetur adipiscing ', + 'el', + '_t.', + '112: Sed consectetur, urna sit amet aliquet egestas, ', + 'ante nibh porttitor mi, vitae rutrum eros metus nec ', + 'libero.', + -- empty end lines are not rendered + }, '\n')); + + text_area:setCursor(84) + journal:onRender() + + simulate_input_keys('SELECT') + + expect.eq(read_rendered_text(text_area), table.concat({ + '60: Lorem ipsum dolor sit amet, consectetur adipiscing ', + 'el', + 'it.', + '112: Sed consectetur,', + -- wrapping changed + '_urna sit amet aliquet egestas, ante nibh porttitor ', + 'mi, vitae rutrum eros metus nec libero.', + -- empty end lines are not rendered + }, '\n')); + + journal:dismiss() +end + +function test.keyboard_arrow_up_navigation() + local journal, text_area = arrange_empty_journal({w=55}) + + local text = table.concat({ + '60: Lorem ipsum dolor sit amet, consectetur adipiscing elit.', + '112: Sed consectetur, urna sit amet aliquet egestas, ante nibh porttitor mi, vitae rutrum eros metus nec libero.', + '41: Etiam id congue urna, vel aliquet mi.', + '45: Nam dignissim libero a interdum porttitor.', + '73: Proin dignissim euismod augue, laoreet porttitor est pellentesque ac.', + }, '\n') + + simulate_input_text(text) + + simulate_input_keys('KEYBOARD_CURSOR_UP') + + expect.eq(read_rendered_text(text_area), table.concat({ + '60: Lorem ipsum dolor sit amet, consectetur adipiscing ', + 'elit.', + '112: Sed consectetur, urna sit amet aliquet egestas, ', + 'ante nibh porttitor mi, vitae rutrum eros metus nec ', + 'libero.', + '41: Etiam id congue urna, vel aliquet mi.', + '45: Nam dignissim libero a interdum porttitor.', + '73: Proin dignissim _uismod augue, laoreet porttitor ', + 'est pellentesque ac.', + }, '\n')); + + simulate_input_keys('KEYBOARD_CURSOR_UP') + + expect.eq(read_rendered_text(text_area), table.concat({ + '60: Lorem ipsum dolor sit amet, consectetur adipiscing ', + 'elit.', + '112: Sed consectetur, urna sit amet aliquet egestas, ', + 'ante nibh porttitor mi, vitae rutrum eros metus nec ', + 'libero.', + '41: Etiam id congue urna, vel aliquet mi.', + '45: Nam dignissim li_ero a interdum porttitor.', + '73: Proin dignissim euismod augue, laoreet porttitor ', + 'est pellentesque ac.', + }, '\n')); + + simulate_input_keys('KEYBOARD_CURSOR_UP') + simulate_input_keys('KEYBOARD_CURSOR_UP') + + expect.eq(read_rendered_text(text_area), table.concat({ + '60: Lorem ipsum dolor sit amet, consectetur adipiscing ', + 'elit.', + '112: Sed consectetur, urna sit amet aliquet egestas, ', + 'ante nibh porttitor mi, vitae rutrum eros metus nec ', + 'libero._', + '41: Etiam id congue urna, vel aliquet mi.', + '45: Nam dignissim libero a interdum porttitor.', + '73: Proin dignissim euismod augue, laoreet porttitor ', + 'est pellentesque ac.', + }, '\n')); + + simulate_input_keys('KEYBOARD_CURSOR_UP') + + expect.eq(read_rendered_text(text_area), table.concat({ + '60: Lorem ipsum dolor sit amet, consectetur adipiscing ', + 'elit.', + '112: Sed consectetur, urna sit amet aliquet egestas, ', + 'ante nibh porttitor _i, vitae rutrum eros metus nec ', + 'libero.', + '41: Etiam id congue urna, vel aliquet mi.', + '45: Nam dignissim libero a interdum porttitor.', + '73: Proin dignissim euismod augue, laoreet porttitor ', + 'est pellentesque ac.', + }, '\n')); + + simulate_input_keys('KEYBOARD_CURSOR_UP') + simulate_input_keys('KEYBOARD_CURSOR_UP') + simulate_input_keys('KEYBOARD_CURSOR_UP') + simulate_input_keys('KEYBOARD_CURSOR_UP') + + expect.eq(read_rendered_text(text_area), table.concat({ + '_0: Lorem ipsum dolor sit amet, consectetur adipiscing ', + 'elit.', + '112: Sed consectetur, urna sit amet aliquet egestas, ', + 'ante nibh porttitor mi, vitae rutrum eros metus nec ', + 'libero.', + '41: Etiam id congue urna, vel aliquet mi.', + '45: Nam dignissim libero a interdum porttitor.', + '73: Proin dignissim euismod augue, laoreet porttitor ', + 'est pellentesque ac.', + }, '\n')); + + simulate_input_keys('KEYBOARD_CURSOR_DOWN') + simulate_input_keys('KEYBOARD_CURSOR_DOWN') + expect.eq(read_rendered_text(text_area), table.concat({ + '60: Lorem ipsum dolor sit amet, consectetur adipiscing ', + 'elit.', + '112: Sed consectetur_ urna sit amet aliquet egestas, ', + 'ante nibh porttitor mi, vitae rutrum eros metus nec ', + 'libero.', + '41: Etiam id congue urna, vel aliquet mi.', + '45: Nam dignissim libero a interdum porttitor.', + '73: Proin dignissim euismod augue, laoreet porttitor ', + 'est pellentesque ac.', + }, '\n')); + + journal:dismiss() +end + +function test.keyboard_arrow_down_navigation() + local journal, text_area = arrange_empty_journal({w=55}) + + local text = table.concat({ + '60: Lorem ipsum dolor sit amet, consectetur adipiscing elit.', + '112: Sed consectetur, urna sit amet aliquet egestas, ante nibh porttitor mi, vitae rutrum eros metus nec libero.', + '41: Etiam id congue urna, vel aliquet mi.', + '45: Nam dignissim libero a interdum porttitor.', + '73: Proin dignissim euismod augue, laoreet porttitor est pellentesque ac.', + }, '\n') + + simulate_input_text(text) + text_area:setCursor(11) + journal:onRender() + + expect.eq(read_rendered_text(text_area), table.concat({ + '60: Lorem _psum dolor sit amet, consectetur adipiscing ', + 'elit.', + '112: Sed consectetur, urna sit amet aliquet egestas, ', + 'ante nibh porttitor mi, vitae rutrum eros metus nec ', + 'libero.', + '41: Etiam id congue urna, vel aliquet mi.', + '45: Nam dignissim libero a interdum porttitor.', + '73: Proin dignissim euismod augue, laoreet porttitor ', + 'est pellentesque ac.', + }, '\n')); + + simulate_input_keys('KEYBOARD_CURSOR_DOWN') + + expect.eq(read_rendered_text(text_area), table.concat({ + '60: Lorem ipsum dolor sit amet, consectetur adipiscing ', + 'elit._', + '112: Sed consectetur, urna sit amet aliquet egestas, ', + 'ante nibh porttitor mi, vitae rutrum eros metus nec ', + 'libero.', + '41: Etiam id congue urna, vel aliquet mi.', + '45: Nam dignissim libero a interdum porttitor.', + '73: Proin dignissim euismod augue, laoreet porttitor ', + 'est pellentesque ac.', + }, '\n')); + + simulate_input_keys('KEYBOARD_CURSOR_DOWN') + + 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, ', + 'ante nibh porttitor mi, vitae rutrum eros metus nec ', + 'libero.', + '41: Etiam id congue urna, vel aliquet mi.', + '45: Nam dignissim libero a interdum porttitor.', + '73: Proin dignissim euismod augue, laoreet porttitor ', + 'est pellentesque ac.', + }, '\n')); + + simulate_input_keys('KEYBOARD_CURSOR_DOWN') + simulate_input_keys('KEYBOARD_CURSOR_DOWN') + simulate_input_keys('KEYBOARD_CURSOR_DOWN') + simulate_input_keys('KEYBOARD_CURSOR_DOWN') + simulate_input_keys('KEYBOARD_CURSOR_DOWN') + simulate_input_keys('KEYBOARD_CURSOR_DOWN') + + expect.eq(read_rendered_text(text_area), table.concat({ + '60: Lorem ipsum dolor sit amet, consectetur adipiscing ', + 'elit.', + '112: Sed consectetur, urna sit amet aliquet egestas, ', + 'ante nibh porttitor mi, vitae rutrum eros metus nec ', + 'libero.', + '41: Etiam id congue urna, vel aliquet mi.', + '45: Nam dignissim libero a interdum porttitor.', + '73: Proin dignissim euismod augue, laoreet porttitor ', + 'est pellen_esque ac.', + }, '\n')); + + simulate_input_keys('KEYBOARD_CURSOR_DOWN') + + expect.eq(read_rendered_text(text_area), table.concat({ + '60: Lorem ipsum dolor sit amet, consectetur adipiscing ', + 'elit.', + '112: Sed consectetur, urna sit amet aliquet egestas, ', + 'ante nibh porttitor mi, vitae rutrum eros metus nec ', + 'libero.', + '41: Etiam id congue urna, vel aliquet mi.', + '45: Nam dignissim libero a interdum porttitor.', + '73: Proin dignissim euismod augue, laoreet porttitor ', + 'est pellentesque ac._', + }, '\n')); + + simulate_input_keys('KEYBOARD_CURSOR_UP') + + expect.eq(read_rendered_text(text_area), table.concat({ + '60: Lorem ipsum dolor sit amet, consectetur adipiscing ', + 'elit.', + '112: Sed consectetur, urna sit amet aliquet egestas, ', + 'ante nibh porttitor mi, vitae rutrum eros metus nec ', + 'libero.', + '41: Etiam id congue urna, vel aliquet mi.', + '45: Nam dignissim libero a interdum porttitor.', + '73: Proin _ignissim euismod augue, laoreet porttitor ', + 'est pellentesque ac.', + }, '\n')); + + journal:dismiss() +end + +function test.keyboard_arrow_left_navigation() + local journal, text_area = arrange_empty_journal({w=55}) + + local text = table.concat({ + '60: Lorem ipsum dolor sit amet, consectetur adipiscing elit.', + '112: Sed consectetur, urna sit amet aliquet egestas, ante nibh porttitor mi, vitae rutrum eros metus nec libero.', + }, '\n') + + simulate_input_text(text) + + simulate_input_keys('KEYBOARD_CURSOR_LEFT') + + expect.eq(read_rendered_text(text_area), table.concat({ + '60: Lorem ipsum dolor sit amet, consectetur adipiscing ', + 'elit.', + '112: Sed consectetur, urna sit amet aliquet egestas, ', + 'ante nibh porttitor mi, vitae rutrum eros metus nec ', + 'libero_', + }, '\n')); + + for i=1,6 do + simulate_input_keys('KEYBOARD_CURSOR_LEFT') + end + + expect.eq(read_rendered_text(text_area), table.concat({ + '60: Lorem ipsum dolor sit amet, consectetur adipiscing ', + 'elit.', + '112: Sed consectetur, urna sit amet aliquet egestas, ', + 'ante nibh porttitor mi, vitae rutrum eros metus nec ', + '_ibero.', + }, '\n')); + + simulate_input_keys('KEYBOARD_CURSOR_LEFT') + + expect.eq(read_rendered_text(text_area), table.concat({ + '60: Lorem ipsum dolor sit amet, consectetur adipiscing ', + 'elit.', + '112: Sed consectetur, urna sit amet aliquet egestas, ', + 'ante nibh porttitor mi, vitae rutrum eros metus nec_', + 'libero.', + }, '\n')); + + for i=1,105 do + simulate_input_keys('KEYBOARD_CURSOR_LEFT') + end + + expect.eq(read_rendered_text(text_area), table.concat({ + '60: Lorem ipsum dolor sit amet, consectetur adipiscing ', + 'elit._', + '112: Sed consectetur, urna sit amet aliquet egestas, ', + 'ante nibh porttitor mi, vitae rutrum eros metus nec ', + 'libero.', + }, '\n')); + + for i=1,60 do + simulate_input_keys('KEYBOARD_CURSOR_LEFT') + end + + expect.eq(read_rendered_text(text_area), table.concat({ + '_0: Lorem ipsum dolor sit amet, consectetur adipiscing ', + 'elit.', + '112: Sed consectetur, urna sit amet aliquet egestas, ', + 'ante nibh porttitor mi, vitae rutrum eros metus nec ', + 'libero.', + }, '\n')); + + simulate_input_keys('KEYBOARD_CURSOR_LEFT') + + expect.eq(read_rendered_text(text_area), table.concat({ + '_0: Lorem ipsum dolor sit amet, consectetur adipiscing ', + 'elit.', + '112: Sed consectetur, urna sit amet aliquet egestas, ', + 'ante nibh porttitor mi, vitae rutrum eros metus nec ', + 'libero.', + }, '\n')); + + journal:dismiss() +end + +function test.keyboard_arrow_right_navigation() + local journal, text_area = arrange_empty_journal({w=55}) + + local text = table.concat({ + '60: Lorem ipsum dolor sit amet, consectetur adipiscing elit.', + '112: Sed consectetur, urna sit amet aliquet egestas, ante nibh porttitor mi, vitae rutrum eros metus nec libero.', + }, '\n') + + simulate_input_text(text) + text_area:setCursor(1) + journal:onRender() + + simulate_input_keys('KEYBOARD_CURSOR_RIGHT') + + expect.eq(read_rendered_text(text_area), table.concat({ + '6_: Lorem ipsum dolor sit amet, consectetur adipiscing ', + 'elit.', + '112: Sed consectetur, urna sit amet aliquet egestas, ', + 'ante nibh porttitor mi, vitae rutrum eros metus nec ', + 'libero.', + }, '\n')); + + for i=1,53 do + simulate_input_keys('KEYBOARD_CURSOR_RIGHT') + end + + expect.eq(read_rendered_text(text_area), table.concat({ + '60: Lorem ipsum dolor sit amet, consectetur adipiscing_', + 'elit.', + '112: Sed consectetur, urna sit amet aliquet egestas, ', + 'ante nibh porttitor mi, vitae rutrum eros metus nec ', + 'libero.', + }, '\n')); + + simulate_input_keys('KEYBOARD_CURSOR_RIGHT') + + expect.eq(read_rendered_text(text_area), table.concat({ + '60: Lorem ipsum dolor sit amet, consectetur adipiscing ', + '_lit.', + '112: Sed consectetur, urna sit amet aliquet egestas, ', + 'ante nibh porttitor mi, vitae rutrum eros metus nec ', + 'libero.', + }, '\n')); + + for i=1,5 do + simulate_input_keys('KEYBOARD_CURSOR_RIGHT') + end + + expect.eq(read_rendered_text(text_area), table.concat({ + '60: Lorem ipsum dolor sit amet, consectetur adipiscing ', + 'elit._', + '112: Sed consectetur, urna sit amet aliquet egestas, ', + 'ante nibh porttitor mi, vitae rutrum eros metus nec ', + 'libero.', + }, '\n')); + + for i=1,113 do + simulate_input_keys('KEYBOARD_CURSOR_RIGHT') + end + + expect.eq(read_rendered_text(text_area), table.concat({ + '60: Lorem ipsum dolor sit amet, consectetur adipiscing ', + 'elit.', + '112: Sed consectetur, urna sit amet aliquet egestas, ', + 'ante nibh porttitor mi, vitae rutrum eros metus nec ', + 'libero._', + }, '\n')); + + simulate_input_keys('KEYBOARD_CURSOR_RIGHT') + + expect.eq(read_rendered_text(text_area), table.concat({ + '60: Lorem ipsum dolor sit amet, consectetur adipiscing ', + 'elit.', + '112: Sed consectetur, urna sit amet aliquet egestas, ', + 'ante nibh porttitor mi, vitae rutrum eros metus nec ', + 'libero._', + }, '\n')); + + journal:dismiss() +end + +function test.handle_backspace() + local journal, text_area = arrange_empty_journal({w=55}) + + local text = table.concat({ + '60: Lorem ipsum dolor sit amet, consectetur adipiscing elit.', + '112: Sed consectetur, urna sit amet aliquet egestas, ante nibh porttitor mi, vitae rutrum eros metus nec libero.', + }, '\n') + + simulate_input_text(text) + + simulate_input_keys('STRING_A000') + + expect.eq(read_rendered_text(text_area), table.concat({ + '60: Lorem ipsum dolor sit amet, consectetur adipiscing ', + 'elit.', + '112: Sed consectetur, urna sit amet aliquet egestas, ', + 'ante nibh porttitor mi, vitae rutrum eros metus nec ', + 'libero_', + }, '\n')); + + for i=1,3 do + simulate_input_keys('STRING_A000') + end + + expect.eq(read_rendered_text(text_area), table.concat({ + '60: Lorem ipsum dolor sit amet, consectetur adipiscing ', + 'elit.', + '112: Sed consectetur, urna sit amet aliquet egestas, ', + 'ante nibh porttitor mi, vitae rutrum eros metus nec lib_', + }, '\n')); + + text_area:setCursor(62) + journal:onRender() + + simulate_input_keys('STRING_A000') + + expect.eq(read_rendered_text(text_area), table.concat({ + '60: Lorem ipsum dolor sit amet, consectetur adipiscing ', + 'elit._12: Sed consectetur, urna sit amet aliquet ', + 'egestas, ante nibh porttitor mi, vitae rutrum eros ', + 'metus nec lib', + }, '\n')); + + text_area:setCursor(2) + journal:onRender() + + simulate_input_keys('STRING_A000') + + expect.eq(read_rendered_text(text_area), table.concat({ + '_: Lorem ipsum dolor sit amet, consectetur adipiscing ', + 'elit.112: Sed consectetur, urna sit amet aliquet ', + 'egestas, ante nibh porttitor mi, vitae rutrum eros ', + 'metus nec lib', + }, '\n')); + + simulate_input_keys('STRING_A000') + + expect.eq(read_rendered_text(text_area), table.concat({ + '_: Lorem ipsum dolor sit amet, consectetur adipiscing ', + 'elit.112: Sed consectetur, urna sit amet aliquet ', + 'egestas, ante nibh porttitor mi, vitae rutrum eros ', + 'metus nec lib', + }, '\n')); + + journal:dismiss() +end + +function test.handle_delete() + local journal, text_area = arrange_empty_journal({w=65}) + + local text = table.concat({ + '60: Lorem ipsum dolor sit amet, consectetur adipiscing elit.', + '112: Sed consectetur, urna sit amet aliquet egestas, ante nibh porttitor mi, vitae rutrum eros metus nec libero.', + '60: Lorem ipsum dolor sit amet, consectetur adipiscing elit.', + }, '\n') + + simulate_input_text(text) + + text_area:setCursor(1) + journal:onRender() + + simulate_input_keys('CUSTOM_CTRL_D') + + expect.eq(read_rendered_text(text_area), table.concat({ + '_: Lorem ipsum dolor sit amet, consectetur adipiscing elit.', + '112: Sed consectetur, urna sit amet aliquet egestas, ante nibh ', + 'porttitor mi, vitae rutrum eros metus nec libero.', + '60: Lorem ipsum dolor sit amet, consectetur adipiscing elit.', + }, '\n')); + + text_area:setCursor(124) + journal:onRender() + simulate_input_keys('CUSTOM_CTRL_D') + + expect.eq(read_rendered_text(text_area), table.concat({ + '0: Lorem ipsum dolor sit amet, consectetur adipiscing elit.', + '112: Sed consectetur, urna sit amet aliquet egestas, ante nibh ', + '_rttitor mi, vitae rutrum eros metus nec libero.', + '60: Lorem ipsum dolor sit amet, consectetur adipiscing elit.', + }, '\n')); + + text_area:setCursor(123) + journal:onRender() + simulate_input_keys('CUSTOM_CTRL_D') + + expect.eq(read_rendered_text(text_area), table.concat({ + '0: Lorem ipsum dolor sit amet, consectetur adipiscing elit.', + '112: Sed consectetur, urna sit amet aliquet egestas, ante ', + 'nibh_rttitor mi, vitae rutrum eros metus nec libero.', + '60: Lorem ipsum dolor sit amet, consectetur adipiscing elit.', + }, '\n')); + + text_area:setCursor(171) + journal:onRender() + simulate_input_keys('CUSTOM_CTRL_D') + + expect.eq(read_rendered_text(text_area), table.concat({ + '0: Lorem ipsum dolor sit amet, consectetur adipiscing elit.', + '112: Sed consectetur, urna sit amet aliquet egestas, ante ', + 'nibhorttitor mi, vitae rutrum eros metus nec libero._0: Lorem ', + 'ipsum dolor sit amet, consectetur adipiscing elit.', + }, '\n')); + + for i=1,59 do + simulate_input_keys('CUSTOM_CTRL_D') + end + + expect.eq(read_rendered_text(text_area), table.concat({ + '0: Lorem ipsum dolor sit amet, consectetur adipiscing elit.', + '112: Sed consectetur, urna sit amet aliquet egestas, ante ', + 'nibhorttitor mi, vitae rutrum eros metus nec libero._', + }, '\n')); + + simulate_input_keys('CUSTOM_CTRL_D') + + expect.eq(read_rendered_text(text_area), table.concat({ + '0: Lorem ipsum dolor sit amet, consectetur adipiscing elit.', + '112: Sed consectetur, urna sit amet aliquet egestas, ante ', + 'nibhorttitor mi, vitae rutrum eros metus nec libero._', + }, '\n')); + + journal:dismiss() +end + +function test.line_end() + local journal, text_area = arrange_empty_journal({w=65}) + + local text = table.concat({ + '60: Lorem ipsum dolor sit amet, consectetur adipiscing elit.', + '112: Sed consectetur, urna sit amet aliquet egestas, ante nibh porttitor mi, vitae rutrum eros metus nec libero.', + '60: Lorem ipsum dolor sit amet, consectetur adipiscing elit.', + }, '\n') + + simulate_input_text(text) + + text_area:setCursor(1) + journal:onRender() + + simulate_input_keys('CUSTOM_CTRL_E') + + expect.eq(read_rendered_text(text_area), table.concat({ + '60: Lorem ipsum dolor sit amet, consectetur adipiscing elit._', + '112: Sed consectetur, urna sit amet aliquet egestas, ante nibh ', + 'porttitor mi, vitae rutrum eros metus nec libero.', + '60: Lorem ipsum dolor sit amet, consectetur adipiscing elit.', + }, '\n')); + + text_area:setCursor(70) + journal:onRender() + + simulate_input_keys('CUSTOM_CTRL_E') + + expect.eq(read_rendered_text(text_area), table.concat({ + '60: Lorem ipsum dolor sit amet, consectetur adipiscing elit.', + '112: Sed consectetur, urna sit amet aliquet egestas, ante nibh ', + 'porttitor mi, vitae rutrum eros metus nec libero._', + '60: Lorem ipsum dolor sit amet, consectetur adipiscing elit.', + }, '\n')); + + text_area:setCursor(200) + journal:onRender() + + simulate_input_keys('CUSTOM_CTRL_E') + + expect.eq(read_rendered_text(text_area), table.concat({ + '60: Lorem ipsum dolor sit amet, consectetur adipiscing elit.', + '112: Sed consectetur, urna sit amet aliquet egestas, ante nibh ', + 'porttitor mi, vitae rutrum eros metus nec libero.', + '60: Lorem ipsum dolor sit amet, consectetur adipiscing elit._', + }, '\n')); + + simulate_input_keys('CUSTOM_CTRL_E') + + expect.eq(read_rendered_text(text_area), table.concat({ + '60: Lorem ipsum dolor sit amet, consectetur adipiscing elit.', + '112: Sed consectetur, urna sit amet aliquet egestas, ante nibh ', + 'porttitor mi, vitae rutrum eros metus nec libero.', + '60: Lorem ipsum dolor sit amet, consectetur adipiscing elit._', + }, '\n')); + + journal:dismiss() +end + +function test.line_beging() + local journal, text_area = arrange_empty_journal({w=65}) + + local text = table.concat({ + '60: Lorem ipsum dolor sit amet, consectetur adipiscing elit.', + '112: Sed consectetur, urna sit amet aliquet egestas, ante nibh porttitor mi, vitae rutrum eros metus nec libero.', + '60: Lorem ipsum dolor sit amet, consectetur adipiscing elit.', + }, '\n') + + simulate_input_text(text) + + simulate_input_keys('CUSTOM_CTRL_H') + + expect.eq(read_rendered_text(text_area), table.concat({ + '60: Lorem ipsum dolor sit amet, consectetur adipiscing elit.', + '112: Sed consectetur, urna sit amet aliquet egestas, ante nibh ', + 'porttitor mi, vitae rutrum eros metus nec libero.', + '_0: Lorem ipsum dolor sit amet, consectetur adipiscing elit.', + }, '\n')); + + text_area:setCursor(173) + journal:onRender() + + simulate_input_keys('CUSTOM_CTRL_H') + + expect.eq(read_rendered_text(text_area), table.concat({ + '60: Lorem ipsum dolor sit amet, consectetur adipiscing elit.', + '_12: Sed consectetur, urna sit amet aliquet egestas, ante nibh ', + 'porttitor mi, vitae rutrum eros metus nec libero.', + '60: Lorem ipsum dolor sit amet, consectetur adipiscing elit.', + }, '\n')); + + text_area:setCursor(1) + journal:onRender() + + simulate_input_keys('CUSTOM_CTRL_H') + + expect.eq(read_rendered_text(text_area), table.concat({ + '_0: Lorem ipsum dolor sit amet, consectetur adipiscing elit.', + '112: Sed consectetur, urna sit amet aliquet egestas, ante nibh ', + 'porttitor mi, vitae rutrum eros metus nec libero.', + '60: Lorem ipsum dolor sit amet, consectetur adipiscing elit.', + }, '\n')); + + journal:dismiss() +end + +function test.line_delete() + local journal, text_area = arrange_empty_journal({w=65}) + + local text = table.concat({ + '60: Lorem ipsum dolor sit amet, consectetur adipiscing elit.', + '112: Sed consectetur, urna sit amet aliquet egestas, ante nibh porttitor mi, vitae rutrum eros metus nec libero.', + '60: Lorem ipsum dolor sit amet, consectetur adipiscing elit.', + }, '\n') + + simulate_input_text(text) + + text_area:setCursor(65) + journal:onRender() + + simulate_input_keys('CUSTOM_CTRL_U') + + expect.eq(read_rendered_text(text_area), table.concat({ + '60: Lorem ipsum dolor sit amet, consectetur adipiscing elit.', + '_0: Lorem ipsum dolor sit amet, consectetur adipiscing elit.', + }, '\n')); + + simulate_input_keys('CUSTOM_CTRL_U') + + expect.eq(read_rendered_text(text_area), table.concat({ + '60: Lorem ipsum dolor sit amet, consectetur adipiscing elit.', + '_' + }, '\n')); + + text_area:setCursor(1) + journal:onRender() + + simulate_input_keys('CUSTOM_CTRL_U') + + expect.eq(read_rendered_text(text_area), table.concat({ + '_' + }, '\n')); + + simulate_input_keys('CUSTOM_CTRL_U') + + expect.eq(read_rendered_text(text_area), table.concat({ + '_' + }, '\n')); + + journal:dismiss() +end + +function test.line_delete_to_end() + local journal, text_area = arrange_empty_journal({w=65}) + + local text = table.concat({ + '60: Lorem ipsum dolor sit amet, consectetur adipiscing elit.', + '112: Sed consectetur, urna sit amet aliquet egestas, ante nibh porttitor mi, vitae rutrum eros metus nec libero.', + '60: Lorem ipsum dolor sit amet, consectetur adipiscing elit.', + }, '\n') + + simulate_input_text(text) + + text_area:setCursor(70) + journal:onRender() + + simulate_input_keys('CUSTOM_CTRL_K') + + expect.eq(read_rendered_text(text_area), table.concat({ + '60: Lorem ipsum dolor sit amet, consectetur adipiscing elit.', + '112: Sed_', + '60: Lorem ipsum dolor sit amet, consectetur adipiscing elit.', + }, '\n')); + + simulate_input_keys('CUSTOM_CTRL_K') + + expect.eq(read_rendered_text(text_area), table.concat({ + '60: Lorem ipsum dolor sit amet, consectetur adipiscing elit.', + '112: Sed_0: Lorem ipsum dolor sit amet, consectetur adipiscing ', + 'elit.', + }, '\n')); + + journal:dismiss() +end + +function test.delete_last_word() + local journal, text_area = arrange_empty_journal({w=65}) + + local text = table.concat({ + '60: Lorem ipsum dolor sit amet, consectetur adipiscing elit.', + '112: Sed consectetur, urna sit amet aliquet egestas, ante nibh porttitor mi, vitae rutrum eros metus nec libero.', + '60: Lorem ipsum dolor sit amet, consectetur adipiscing elit.', + }, '\n') + + simulate_input_text(text) + + simulate_input_keys('CUSTOM_CTRL_W') + + expect.eq(read_rendered_text(text_area), table.concat({ + '60: Lorem ipsum dolor sit amet, consectetur adipiscing elit.', + '112: Sed consectetur, urna sit amet aliquet egestas, ante nibh ', + 'porttitor mi, vitae rutrum eros metus nec libero.', + '60: Lorem ipsum dolor sit amet, consectetur adipiscing _', + }, '\n')); + + simulate_input_keys('CUSTOM_CTRL_W') + + expect.eq(read_rendered_text(text_area), table.concat({ + '60: Lorem ipsum dolor sit amet, consectetur adipiscing elit.', + '112: Sed consectetur, urna sit amet aliquet egestas, ante nibh ', + 'porttitor mi, vitae rutrum eros metus nec libero.', + '60: Lorem ipsum dolor sit amet, consectetur _', + }, '\n')); + + text_area:setCursor(82) + journal:onRender() + + simulate_input_keys('CUSTOM_CTRL_W') + + expect.eq(read_rendered_text(text_area), table.concat({ + '60: Lorem ipsum dolor sit amet, consectetur adipiscing elit.', + '112: Sed _ urna sit amet aliquet egestas, ante nibh porttitor ', + 'mi, vitae rutrum eros metus nec libero.', + '60: Lorem ipsum dolor sit amet, consectetur ', + }, '\n')); + + text_area:setCursor(37) + journal:onRender() + + simulate_input_keys('CUSTOM_CTRL_W') + + expect.eq(read_rendered_text(text_area), table.concat({ + '60: Lorem ipsum dolor sit amet, _ctetur adipiscing elit.', + '112: Sed , urna sit amet aliquet egestas, ante nibh porttitor ', + 'mi, vitae rutrum eros metus nec libero.', + '60: Lorem ipsum dolor sit amet, consectetur ', + }, '\n')); + + for i=1,6 do + simulate_input_keys('CUSTOM_CTRL_W') + end + + expect.eq(read_rendered_text(text_area), table.concat({ + '_ctetur adipiscing elit.', + '112: Sed , urna sit amet aliquet egestas, ante nibh porttitor ', + 'mi, vitae rutrum eros metus nec libero.', + '60: Lorem ipsum dolor sit amet, consectetur ', + }, '\n')); + + simulate_input_keys('CUSTOM_CTRL_W') + + expect.eq(read_rendered_text(text_area), table.concat({ + '_ctetur adipiscing elit.', + '112: Sed , urna sit amet aliquet egestas, ante nibh porttitor ', + 'mi, vitae rutrum eros metus nec libero.', + '60: Lorem ipsum dolor sit amet, consectetur ', + }, '\n')); + + journal:dismiss() +end + +function test.jump_to_text_end() + local journal, text_area = arrange_empty_journal({w=65}) + + local text = table.concat({ + '60: Lorem ipsum dolor sit amet, consectetur adipiscing elit.', + '112: Sed consectetur, urna sit amet aliquet egestas, ante nibh porttitor mi, vitae rutrum eros metus nec libero.', + '60: Lorem ipsum dolor sit amet, consectetur adipiscing elit.', + }, '\n') + + simulate_input_text(text) + + text_area:setCursor(1) + journal:onRender() + + simulate_input_keys('KEYBOARD_CURSOR_DOWN_FAST') + + expect.eq(read_rendered_text(text_area), table.concat({ + '60: Lorem ipsum dolor sit amet, consectetur adipiscing elit.', + '112: Sed consectetur, urna sit amet aliquet egestas, ante nibh ', + 'porttitor mi, vitae rutrum eros metus nec libero.', + '60: Lorem ipsum dolor sit amet, consectetur adipiscing elit._', + }, '\n')); + + simulate_input_keys('KEYBOARD_CURSOR_DOWN_FAST') + + expect.eq(read_rendered_text(text_area), table.concat({ + '60: Lorem ipsum dolor sit amet, consectetur adipiscing elit.', + '112: Sed consectetur, urna sit amet aliquet egestas, ante nibh ', + 'porttitor mi, vitae rutrum eros metus nec libero.', + '60: Lorem ipsum dolor sit amet, consectetur adipiscing elit._', + }, '\n')); + + journal:dismiss() +end + +function test.jump_to_text_begin() + local journal, text_area = arrange_empty_journal({w=65}) + + local text = table.concat({ + '60: Lorem ipsum dolor sit amet, consectetur adipiscing elit.', + '112: Sed consectetur, urna sit amet aliquet egestas, ante nibh porttitor mi, vitae rutrum eros metus nec libero.', + '60: Lorem ipsum dolor sit amet, consectetur adipiscing elit.', + }, '\n') + + simulate_input_text(text) + + simulate_input_keys('KEYBOARD_CURSOR_UP_FAST') + + expect.eq(read_rendered_text(text_area), table.concat({ + '_0: Lorem ipsum dolor sit amet, consectetur adipiscing elit.', + '112: Sed consectetur, urna sit amet aliquet egestas, ante nibh ', + 'porttitor mi, vitae rutrum eros metus nec libero.', + '60: Lorem ipsum dolor sit amet, consectetur adipiscing elit.', + }, '\n')); + + simulate_input_keys('KEYBOARD_CURSOR_UP_FAST') + + expect.eq(read_rendered_text(text_area), table.concat({ + '_0: Lorem ipsum dolor sit amet, consectetur adipiscing elit.', + '112: Sed consectetur, urna sit amet aliquet egestas, ante nibh ', + 'porttitor mi, vitae rutrum eros metus nec libero.', + '60: Lorem ipsum dolor sit amet, consectetur adipiscing elit.', + }, '\n')); + + journal:dismiss() +end + +function test.select_all() + local journal, text_area = arrange_empty_journal({w=65}) + + local text = table.concat({ + '60: Lorem ipsum dolor sit amet, consectetur adipiscing elit.', + '112: Sed consectetur, urna sit amet aliquet egestas, ante nibh porttitor mi, vitae rutrum eros metus nec libero.', + '60: Lorem ipsum dolor sit amet, consectetur adipiscing elit.', + }, '\n') + + simulate_input_text(text) + + expect.eq(read_rendered_text(text_area), table.concat({ + '60: Lorem ipsum dolor sit amet, consectetur adipiscing elit.', + '112: Sed consectetur, urna sit amet aliquet egestas, ante nibh ', + 'porttitor mi, vitae rutrum eros metus nec libero.', + '60: Lorem ipsum dolor sit amet, consectetur adipiscing elit._', + }, '\n')); + + simulate_input_keys('CUSTOM_CTRL_A') + + expect.eq(read_selected_text(text_area), table.concat({ + '60: Lorem ipsum dolor sit amet, consectetur adipiscing elit.', + '112: Sed consectetur, urna sit amet aliquet egestas, ante nibh ', + 'porttitor mi, vitae rutrum eros metus nec libero.', + '60: Lorem ipsum dolor sit amet, consectetur adipiscing elit.', + }, '\n')); + + journal:dismiss() +end + +function test.text_key_replace_selection() + local journal, text_area = arrange_empty_journal({w=65}) + + local text = table.concat({ + '60: Lorem ipsum dolor sit amet, consectetur adipiscing elit.', + '112: Sed consectetur, urna sit amet aliquet egestas, ante nibh porttitor mi, vitae rutrum eros metus nec libero.', + '60: Lorem ipsum dolor sit amet, consectetur adipiscing elit.', + '51: Sed consectetur, urna sit amet aliquet egestas.', + }, '\n') + + simulate_input_text(text) + + simulate_mouse_drag(text_area, 4, 0, 9, 0) + + expect.eq(read_rendered_text(text_area), table.concat({ + '60: Lorem ipsum dolor sit amet, consectetur adipiscing elit.', + '112: Sed consectetur, urna sit amet aliquet egestas, ante nibh ', + 'porttitor mi, vitae rutrum eros metus nec libero.', + '60: Lorem ipsum dolor sit amet, consectetur adipiscing elit.', + '51: Sed consectetur, urna sit amet aliquet egestas.', + }, '\n')); + + expect.eq(read_selected_text(text_area), 'Lorem '); + + simulate_input_text('+') + + expect.eq(read_rendered_text(text_area), table.concat({ + '60: +_psum dolor sit amet, consectetur adipiscing elit.', + '112: Sed consectetur, urna sit amet aliquet egestas, ante nibh ', + 'porttitor mi, vitae rutrum eros metus nec libero.', + '60: Lorem ipsum dolor sit amet, consectetur adipiscing elit.', + '51: Sed consectetur, urna sit amet aliquet egestas.', + }, '\n')); + + simulate_mouse_drag(text_area, 6, 1, 6, 2) + + simulate_input_text('!') + + expect.eq(read_rendered_text(text_area), table.concat({ + '60: +ipsum dolor sit amet, consectetur adipiscing elit.', + '112: S!_r mi, vitae rutrum eros metus nec libero.', + '60: Lorem ipsum dolor sit amet, consectetur adipiscing elit.', + '51: Sed consectetur, urna sit amet aliquet egestas.', + }, '\n')); + + simulate_mouse_drag(text_area, 3, 1, 6, 2) + + simulate_input_text('@') + + expect.eq(read_rendered_text(text_area), table.concat({ + '60: +ipsum dolor sit amet, consectetur adipiscing elit.', + '112@_m ipsum dolor sit amet, consectetur adipiscing elit.', + '51: Sed consectetur, urna sit amet aliquet egestas.', + }, '\n')); + + journal:dismiss() +end + +function test.arrows_reset_selection() + local journal, text_area = arrange_empty_journal({w=65}) + + local text = table.concat({ + '60: Lorem ipsum dolor sit amet, consectetur adipiscing elit.', + '112: Sed consectetur, urna sit amet aliquet egestas, ante nibh porttitor mi, vitae rutrum eros metus nec libero.', + }, '\n') + + simulate_input_text(text) + + simulate_input_keys('CUSTOM_CTRL_A') + + expect.eq(read_rendered_text(text_area), table.concat({ + '60: Lorem ipsum dolor sit amet, consectetur adipiscing elit.', + '112: Sed consectetur, urna sit amet aliquet egestas, ante nibh ', + 'porttitor mi, vitae rutrum eros metus nec libero.', + }, '\n')); + + expect.eq(read_selected_text(text_area), table.concat({ + '60: Lorem ipsum dolor sit amet, consectetur adipiscing elit.', + '112: Sed consectetur, urna sit amet aliquet egestas, ante nibh ', + 'porttitor mi, vitae rutrum eros metus nec libero.', + }, '\n')); + + simulate_input_keys('KEYBOARD_CURSOR_LEFT') + expect.eq(read_selected_text(text_area), '') + + simulate_input_keys('CUSTOM_CTRL_A') + + simulate_input_keys('KEYBOARD_CURSOR_RIGHT') + expect.eq(read_selected_text(text_area), '') + + simulate_input_keys('CUSTOM_CTRL_A') + + simulate_input_keys('KEYBOARD_CURSOR_UP') + expect.eq(read_selected_text(text_area), '') + + simulate_input_keys('CUSTOM_CTRL_A') + + simulate_input_keys('KEYBOARD_CURSOR_DOWN') + expect.eq(read_selected_text(text_area), '') + + journal:dismiss() +end + +function test.click_reset_selection() + local journal, text_area = arrange_empty_journal({w=65}) + + local text = table.concat({ + '60: Lorem ipsum dolor sit amet, consectetur adipiscing elit.', + '112: Sed consectetur, urna sit amet aliquet egestas, ante nibh porttitor mi, vitae rutrum eros metus nec libero.', + }, '\n') + + simulate_input_text(text) + + simulate_input_keys('CUSTOM_CTRL_A') + + expect.eq(read_rendered_text(text_area), table.concat({ + '60: Lorem ipsum dolor sit amet, consectetur adipiscing elit.', + '112: Sed consectetur, urna sit amet aliquet egestas, ante nibh ', + 'porttitor mi, vitae rutrum eros metus nec libero.', + }, '\n')); + + expect.eq(read_selected_text(text_area), table.concat({ + '60: Lorem ipsum dolor sit amet, consectetur adipiscing elit.', + '112: Sed consectetur, urna sit amet aliquet egestas, ante nibh ', + 'porttitor mi, vitae rutrum eros metus nec libero.', + }, '\n')); + + simulate_mouse_click(text_area, 4, 0) + expect.eq(read_selected_text(text_area), '') + + simulate_input_keys('CUSTOM_CTRL_A') + + simulate_mouse_click(text_area, 4, 8) + expect.eq(read_selected_text(text_area), '') + + journal:dismiss() +end + +function test.line_navigation_reset_selection() + local journal, text_area = arrange_empty_journal({w=65}) + + local text = table.concat({ + '60: Lorem ipsum dolor sit amet, consectetur adipiscing elit.', + '112: Sed consectetur, urna sit amet aliquet egestas, ante nibh porttitor mi, vitae rutrum eros metus nec libero.', + }, '\n') + + simulate_input_text(text) + + simulate_input_keys('CUSTOM_CTRL_A') + + expect.eq(read_rendered_text(text_area), table.concat({ + '60: Lorem ipsum dolor sit amet, consectetur adipiscing elit.', + '112: Sed consectetur, urna sit amet aliquet egestas, ante nibh ', + 'porttitor mi, vitae rutrum eros metus nec libero.', + }, '\n')); + + expect.eq(read_selected_text(text_area), table.concat({ + '60: Lorem ipsum dolor sit amet, consectetur adipiscing elit.', + '112: Sed consectetur, urna sit amet aliquet egestas, ante nibh ', + 'porttitor mi, vitae rutrum eros metus nec libero.', + }, '\n')); + + simulate_input_keys('CUSTOM_CTRL_H') + expect.eq(read_selected_text(text_area), '') + + simulate_input_keys('CUSTOM_CTRL_E') + expect.eq(read_selected_text(text_area), '') + + journal:dismiss() +end + +function test.jump_begin_or_end_reset_selection() + local journal, text_area = arrange_empty_journal({w=65}) + + local text = table.concat({ + '60: Lorem ipsum dolor sit amet, consectetur adipiscing elit.', + '112: Sed consectetur, urna sit amet aliquet egestas, ante nibh porttitor mi, vitae rutrum eros metus nec libero.', + }, '\n') + + simulate_input_text(text) + + simulate_input_keys('CUSTOM_CTRL_A') + + expect.eq(read_rendered_text(text_area), table.concat({ + '60: Lorem ipsum dolor sit amet, consectetur adipiscing elit.', + '112: Sed consectetur, urna sit amet aliquet egestas, ante nibh ', + 'porttitor mi, vitae rutrum eros metus nec libero.', + }, '\n')); + + expect.eq(read_selected_text(text_area), table.concat({ + '60: Lorem ipsum dolor sit amet, consectetur adipiscing elit.', + '112: Sed consectetur, urna sit amet aliquet egestas, ante nibh ', + 'porttitor mi, vitae rutrum eros metus nec libero.', + }, '\n')); + + simulate_input_keys('KEYBOARD_CURSOR_UP_FAST') + expect.eq(read_selected_text(text_area), '') + + simulate_input_keys('KEYBOARD_CURSOR_DOWN_FAST') + expect.eq(read_selected_text(text_area), '') + + journal:dismiss() +end + +function test.new_line_override_selection() + local journal, text_area = arrange_empty_journal({w=65}) + + local text = table.concat({ + '60: Lorem ipsum dolor sit amet, consectetur adipiscing elit.', + '112: Sed consectetur, urna sit amet aliquet egestas, ante nibh porttitor mi, vitae rutrum eros metus nec libero.', + '60: Lorem ipsum dolor sit amet, consectetur adipiscing elit.', + }, '\n') + + simulate_input_text(text) + + simulate_mouse_drag(text_area, 4, 0, 29, 2) + + expect.eq(read_rendered_text(text_area), table.concat({ + '60: Lorem ipsum dolor sit amet, consectetur adipiscing elit.', + '112: Sed consectetur, urna sit amet aliquet egestas, ante nibh ', + 'porttitor mi, vitae rutrum eros metus nec libero.', + '60: Lorem ipsum dolor sit amet, consectetur adipiscing elit.', + }, '\n')); + + expect.eq(read_selected_text(text_area), table.concat({ + 'Lorem ipsum dolor sit amet, consectetur adipiscing elit.', + '112: Sed consectetur, urna sit amet aliquet egestas, ante nibh ', + 'porttitor mi, vitae rutrum ero', + }, '\n')); + + simulate_input_keys('SELECT') + + expect.eq(read_rendered_text(text_area), table.concat({ + '60: ', + '_ metus nec libero.', + '60: Lorem ipsum dolor sit amet, consectetur adipiscing elit.', + }, '\n')); + + journal:dismiss() +end + +function test.backspace_delete_selection() + local journal, text_area = arrange_empty_journal({w=65}) + + local text = table.concat({ + '60: Lorem ipsum dolor sit amet, consectetur adipiscing elit.', + '112: Sed consectetur, urna sit amet aliquet egestas, ante nibh porttitor mi, vitae rutrum eros metus nec libero.', + '60: Lorem ipsum dolor sit amet, consectetur adipiscing elit.', + }, '\n') + + simulate_input_text(text) + + simulate_mouse_drag(text_area, 4, 0, 29, 2) + + expect.eq(read_rendered_text(text_area), table.concat({ + '60: Lorem ipsum dolor sit amet, consectetur adipiscing elit.', + '112: Sed consectetur, urna sit amet aliquet egestas, ante nibh ', + 'porttitor mi, vitae rutrum eros metus nec libero.', + '60: Lorem ipsum dolor sit amet, consectetur adipiscing elit.', + }, '\n')); + + expect.eq(read_selected_text(text_area), table.concat({ + 'Lorem ipsum dolor sit amet, consectetur adipiscing elit.', + '112: Sed consectetur, urna sit amet aliquet egestas, ante nibh ', + 'porttitor mi, vitae rutrum ero', + }, '\n')); + + simulate_input_keys('STRING_A000') + + expect.eq(read_rendered_text(text_area), table.concat({ + '60: _ metus nec libero.', + '60: Lorem ipsum dolor sit amet, consectetur adipiscing elit.', + }, '\n')); + + journal:dismiss() +end + +function test.delete_char_delete_selection() + local journal, text_area = arrange_empty_journal({w=65}) + + local text = table.concat({ + '60: Lorem ipsum dolor sit amet, consectetur adipiscing elit.', + '112: Sed consectetur, urna sit amet aliquet egestas, ante nibh porttitor mi, vitae rutrum eros metus nec libero.', + '60: Lorem ipsum dolor sit amet, consectetur adipiscing elit.', + }, '\n') + + simulate_input_text(text) + + simulate_mouse_drag(text_area, 4, 0, 29, 2) + + expect.eq(read_rendered_text(text_area), table.concat({ + '60: Lorem ipsum dolor sit amet, consectetur adipiscing elit.', + '112: Sed consectetur, urna sit amet aliquet egestas, ante nibh ', + 'porttitor mi, vitae rutrum eros metus nec libero.', + '60: Lorem ipsum dolor sit amet, consectetur adipiscing elit.', + }, '\n')); + + expect.eq(read_selected_text(text_area), table.concat({ + 'Lorem ipsum dolor sit amet, consectetur adipiscing elit.', + '112: Sed consectetur, urna sit amet aliquet egestas, ante nibh ', + 'porttitor mi, vitae rutrum ero', + }, '\n')); + + simulate_input_keys('CUSTOM_CTRL_D') + + expect.eq(read_rendered_text(text_area), table.concat({ + '60: _ metus nec libero.', + '60: Lorem ipsum dolor sit amet, consectetur adipiscing elit.', + }, '\n')); + + journal:dismiss() +end + +function test.delete_line_delete_selection_lines() + local journal, text_area = arrange_empty_journal({w=65}) + + local text = table.concat({ + '60: Lorem ipsum dolor sit amet, consectetur adipiscing elit.', + '112: Sed consectetur, urna sit amet aliquet egestas, ante nibh porttitor mi, vitae rutrum eros metus nec libero.', + '60: Lorem ipsum dolor sit amet, consectetur adipiscing elit.', + '51: Sed consectetur, urna sit amet aliquet egestas.', + }, '\n') + + simulate_input_text(text) + + simulate_mouse_drag(text_area, 4, 0, 9, 0) + + expect.eq(read_rendered_text(text_area), table.concat({ + '60: Lorem ipsum dolor sit amet, consectetur adipiscing elit.', + '112: Sed consectetur, urna sit amet aliquet egestas, ante nibh ', + 'porttitor mi, vitae rutrum eros metus nec libero.', + '60: Lorem ipsum dolor sit amet, consectetur adipiscing elit.', + '51: Sed consectetur, urna sit amet aliquet egestas.', + }, '\n')); + + expect.eq(read_selected_text(text_area), 'Lorem '); + + simulate_input_keys('CUSTOM_CTRL_U') + + expect.eq(read_rendered_text(text_area), table.concat({ + '_12: Sed consectetur, urna sit amet aliquet egestas, ante nibh ', + 'porttitor mi, vitae rutrum eros metus nec libero.', + '60: Lorem ipsum dolor sit amet, consectetur adipiscing elit.', + '51: Sed consectetur, urna sit amet aliquet egestas.', + }, '\n')); + + simulate_mouse_drag(text_area, 4, 1, 29, 2) + + simulate_input_keys('CUSTOM_CTRL_U') + + expect.eq(read_rendered_text(text_area), table.concat({ + '_1: Sed consectetur, urna sit amet aliquet egestas.', + }, '\n')); + + journal:dismiss() +end + +function test.delete_line_rest_delete_selection_lines() + local journal, text_area = arrange_empty_journal({w=65}) + + local text = table.concat({ + '60: Lorem ipsum dolor sit amet, consectetur adipiscing elit.', + '112: Sed consectetur, urna sit amet aliquet egestas, ante nibh porttitor mi, vitae rutrum eros metus nec libero.', + '60: Lorem ipsum dolor sit amet, consectetur adipiscing elit.', + '51: Sed consectetur, urna sit amet aliquet egestas.', + }, '\n') + + simulate_input_text(text) + + simulate_mouse_drag(text_area, 4, 0, 9, 0) + + expect.eq(read_rendered_text(text_area), table.concat({ + '60: Lorem ipsum dolor sit amet, consectetur adipiscing elit.', + '112: Sed consectetur, urna sit amet aliquet egestas, ante nibh ', + 'porttitor mi, vitae rutrum eros metus nec libero.', + '60: Lorem ipsum dolor sit amet, consectetur adipiscing elit.', + '51: Sed consectetur, urna sit amet aliquet egestas.', + }, '\n')); + + expect.eq(read_selected_text(text_area), 'Lorem '); + + simulate_input_keys('CUSTOM_CTRL_K') + + expect.eq(read_rendered_text(text_area), table.concat({ + '60: _', + '112: Sed consectetur, urna sit amet aliquet egestas, ante nibh ', + 'porttitor mi, vitae rutrum eros metus nec libero.', + '60: Lorem ipsum dolor sit amet, consectetur adipiscing elit.', + '51: Sed consectetur, urna sit amet aliquet egestas.', + }, '\n')); + + simulate_mouse_drag(text_area, 6, 1, 6, 2) + + simulate_input_keys('CUSTOM_CTRL_K') + + expect.eq(read_rendered_text(text_area), table.concat({ + '60: ', + '112: S_', + '60: Lorem ipsum dolor sit amet, consectetur adipiscing elit.', + '51: Sed consectetur, urna sit amet aliquet egestas.', + }, '\n')); + + simulate_mouse_drag(text_area, 3, 1, 6, 2) + + simulate_input_keys('CUSTOM_CTRL_K') + + expect.eq(read_rendered_text(text_area), table.concat({ + '60: ', + '112_', + '51: Sed consectetur, urna sit amet aliquet egestas.', + }, '\n')); + + journal:dismiss() +end + +function test.delete_last_word_delete_selection() + local journal, text_area = arrange_empty_journal({w=65}) + + local text = table.concat({ + '60: Lorem ipsum dolor sit amet, consectetur adipiscing elit.', + '112: Sed consectetur, urna sit amet aliquet egestas, ante nibh porttitor mi, vitae rutrum eros metus nec libero.', + '60: Lorem ipsum dolor sit amet, consectetur adipiscing elit.', + '51: Sed consectetur, urna sit amet aliquet egestas.', + }, '\n') + + simulate_input_text(text) + + simulate_mouse_drag(text_area, 4, 0, 9, 0) + + expect.eq(read_rendered_text(text_area), table.concat({ + '60: Lorem ipsum dolor sit amet, consectetur adipiscing elit.', + '112: Sed consectetur, urna sit amet aliquet egestas, ante nibh ', + 'porttitor mi, vitae rutrum eros metus nec libero.', + '60: Lorem ipsum dolor sit amet, consectetur adipiscing elit.', + '51: Sed consectetur, urna sit amet aliquet egestas.', + }, '\n')); + + expect.eq(read_selected_text(text_area), 'Lorem '); + + simulate_input_keys('CUSTOM_CTRL_W') + + expect.eq(read_rendered_text(text_area), table.concat({ + '60: _psum dolor sit amet, consectetur adipiscing elit.', + '112: Sed consectetur, urna sit amet aliquet egestas, ante nibh ', + 'porttitor mi, vitae rutrum eros metus nec libero.', + '60: Lorem ipsum dolor sit amet, consectetur adipiscing elit.', + '51: Sed consectetur, urna sit amet aliquet egestas.', + }, '\n')); + + simulate_mouse_drag(text_area, 6, 1, 6, 2) + + simulate_input_keys('CUSTOM_CTRL_W') + + expect.eq(read_rendered_text(text_area), table.concat({ + '60: ipsum dolor sit amet, consectetur adipiscing elit.', + '112: S_r mi, vitae rutrum eros metus nec libero.', + '60: Lorem ipsum dolor sit amet, consectetur adipiscing elit.', + '51: Sed consectetur, urna sit amet aliquet egestas.', + }, '\n')); + + simulate_mouse_drag(text_area, 3, 1, 6, 2) + + simulate_input_keys('CUSTOM_CTRL_W') + + expect.eq(read_rendered_text(text_area), table.concat({ + '60: ipsum dolor sit amet, consectetur adipiscing elit.', + '112_m ipsum dolor sit amet, consectetur adipiscing elit.', + '51: Sed consectetur, urna sit amet aliquet egestas.', + }, '\n')); + + journal:dismiss() +end + +function test.single_mouse_click_set_cursor() + local journal, text_area = arrange_empty_journal({w=65}) + + local text = table.concat({ + '60: Lorem ipsum dolor sit amet, consectetur adipiscing elit.', + '112: Sed consectetur, urna sit amet aliquet egestas, ante nibh porttitor mi, vitae rutrum eros metus nec libero.', + '60: Lorem ipsum dolor sit amet, consectetur adipiscing elit.', + }, '\n') + + simulate_input_text(text) + + simulate_mouse_click(text_area, 4, 0) + + expect.eq(read_rendered_text(text_area), table.concat({ + '60: _orem ipsum dolor sit amet, consectetur adipiscing elit.', + '112: Sed consectetur, urna sit amet aliquet egestas, ante nibh ', + 'porttitor mi, vitae rutrum eros metus nec libero.', + '60: Lorem ipsum dolor sit amet, consectetur adipiscing elit.', + }, '\n')); + + simulate_mouse_click(text_area, 40, 2) + + expect.eq(read_rendered_text(text_area), table.concat({ + '60: Lorem ipsum dolor sit amet, consectetur adipiscing elit.', + '112: Sed consectetur, urna sit amet aliquet egestas, ante nibh ', + 'porttitor mi, vitae rutrum eros metus ne_ libero.', + '60: Lorem ipsum dolor sit amet, consectetur adipiscing elit.', + }, '\n')); + + simulate_mouse_click(text_area, 49, 2) + + expect.eq(read_rendered_text(text_area), table.concat({ + '60: Lorem ipsum dolor sit amet, consectetur adipiscing elit.', + '112: Sed consectetur, urna sit amet aliquet egestas, ante nibh ', + 'porttitor mi, vitae rutrum eros metus nec libero._', + '60: Lorem ipsum dolor sit amet, consectetur adipiscing elit.', + }, '\n')); + + simulate_mouse_click(text_area, 60, 2) + + expect.eq(read_rendered_text(text_area), table.concat({ + '60: Lorem ipsum dolor sit amet, consectetur adipiscing elit.', + '112: Sed consectetur, urna sit amet aliquet egestas, ante nibh ', + 'porttitor mi, vitae rutrum eros metus nec libero._', + '60: Lorem ipsum dolor sit amet, consectetur adipiscing elit.', + }, '\n')); + + simulate_mouse_click(text_area, 0, 10) + + expect.eq(read_rendered_text(text_area), table.concat({ + '60: Lorem ipsum dolor sit amet, consectetur adipiscing elit.', + '112: Sed consectetur, urna sit amet aliquet egestas, ante nibh ', + 'porttitor mi, vitae rutrum eros metus nec libero.', + '_0: Lorem ipsum dolor sit amet, consectetur adipiscing elit.', + }, '\n')); + + simulate_mouse_click(text_area, 21, 10) + + expect.eq(read_rendered_text(text_area), table.concat({ + '60: Lorem ipsum dolor sit amet, consectetur adipiscing elit.', + '112: Sed consectetur, urna sit amet aliquet egestas, ante nibh ', + 'porttitor mi, vitae rutrum eros metus nec libero.', + '60: Lorem ipsum dolor_sit amet, consectetur adipiscing elit.', + }, '\n')); + + simulate_mouse_click(text_area, 63, 10) + + expect.eq(read_rendered_text(text_area), table.concat({ + '60: Lorem ipsum dolor sit amet, consectetur adipiscing elit.', + '112: Sed consectetur, urna sit amet aliquet egestas, ante nibh ', + 'porttitor mi, vitae rutrum eros metus nec libero.', + '60: Lorem ipsum dolor sit amet, consectetur adipiscing elit._', + }, '\n')); + + journal:dismiss() +end + +function test.double_mouse_click_select_word() + local journal, text_area = arrange_empty_journal({w=65}) + + local text = table.concat({ + '60: Lorem ipsum dolor sit amet, consectetur adipiscing elit.', + '112: Sed consectetur, urna sit amet aliquet egestas, ante nibh porttitor mi, vitae rutrum eros metus nec libero.', + '60: Lorem ipsum dolor sit amet, consectetur adipiscing elit.', + }, '\n') + + simulate_input_text(text) + + expect.eq(read_rendered_text(text_area), table.concat({ + '60: Lorem ipsum dolor sit amet, consectetur adipiscing elit.', + '112: Sed consectetur, urna sit amet aliquet egestas, ante nibh ', + 'porttitor mi, vitae rutrum eros metus nec libero.', + '60: Lorem ipsum dolor sit amet, consectetur adipiscing elit._', + }, '\n')); + + simulate_mouse_click(text_area, 0, 0) + simulate_mouse_click(text_area, 0, 0) + + expect.eq(read_selected_text(text_area), '60:') + + simulate_mouse_click(text_area, 4, 0) + simulate_mouse_click(text_area, 4, 0) + + expect.eq(read_selected_text(text_area), 'Lorem') + + simulate_mouse_click(text_area, 40, 2) + simulate_mouse_click(text_area, 40, 2) + + expect.eq(read_selected_text(text_area), 'nec') + + simulate_mouse_click(text_area, 58, 3) + simulate_mouse_click(text_area, 58, 3) + expect.eq(read_selected_text(text_area), 'elit') + + simulate_mouse_click(text_area, 60, 3) + simulate_mouse_click(text_area, 60, 3) + expect.eq(read_selected_text(text_area), '.') + + journal:dismiss() +end + +function test.double_mouse_click_select_white_spaces() + local journal, text_area = arrange_empty_journal({w=65}) + + local text = 'Lorem ipsum dolor sit amet, consectetur elit.' + simulate_input_text(text) + + expect.eq(read_rendered_text(text_area), text .. '_') + + simulate_mouse_click(text_area, 29, 0) + simulate_mouse_click(text_area, 29, 0) + + expect.eq(read_selected_text(text_area), ' ') + + journal:dismiss() +end + +function test.triple_mouse_click_select_line() + local journal, text_area = arrange_empty_journal({w=65}) + + local text = table.concat({ + '60: Lorem ipsum dolor sit amet, consectetur adipiscing elit.', + '112: Sed consectetur, urna sit amet aliquet egestas, ante nibh porttitor mi, vitae rutrum eros metus nec libero.', + '60: Lorem ipsum dolor sit amet, consectetur adipiscing elit.', + }, '\n') + + simulate_input_text(text) + + expect.eq(read_rendered_text(text_area), table.concat({ + '60: Lorem ipsum dolor sit amet, consectetur adipiscing elit.', + '112: Sed consectetur, urna sit amet aliquet egestas, ante nibh ', + 'porttitor mi, vitae rutrum eros metus nec libero.', + '60: Lorem ipsum dolor sit amet, consectetur adipiscing elit._', + }, '\n')); + + simulate_mouse_click(text_area, 0, 0) + simulate_mouse_click(text_area, 0, 0) + simulate_mouse_click(text_area, 0, 0) + + expect.eq( + read_selected_text(text_area), + '60: Lorem ipsum dolor sit amet, consectetur adipiscing elit.' + ) + + simulate_mouse_click(text_area, 4, 0) + simulate_mouse_click(text_area, 4, 0) + simulate_mouse_click(text_area, 4, 0) + + expect.eq( + read_selected_text(text_area), + '60: Lorem ipsum dolor sit amet, consectetur adipiscing elit.' + ) + + simulate_mouse_click(text_area, 40, 2) + simulate_mouse_click(text_area, 40, 2) + simulate_mouse_click(text_area, 40, 2) + + expect.eq(read_selected_text(text_area), table.concat({ + '112: Sed consectetur, urna sit amet aliquet egestas, ante nibh ', + 'porttitor mi, vitae rutrum eros metus nec libero.', + }, '\n')); + + simulate_mouse_click(text_area, 58, 3) + simulate_mouse_click(text_area, 58, 3) + simulate_mouse_click(text_area, 58, 3) + + expect.eq( + read_selected_text(text_area), + '60: Lorem ipsum dolor sit amet, consectetur adipiscing elit.' + ) + + simulate_mouse_click(text_area, 60, 3) + simulate_mouse_click(text_area, 60, 3) + simulate_mouse_click(text_area, 60, 3) + + expect.eq( + read_selected_text(text_area), + '60: Lorem ipsum dolor sit amet, consectetur adipiscing elit.' + ) + + journal:dismiss() +end + +function test.mouse_selection_control() + local journal, text_area = arrange_empty_journal({w=65}) + + local text = table.concat({ + '60: Lorem ipsum dolor sit amet, consectetur adipiscing elit.', + '112: Sed consectetur, urna sit amet aliquet egestas, ante nibh porttitor mi, vitae rutrum eros metus nec libero.', + '60: Lorem ipsum dolor sit amet, consectetur adipiscing elit.', + }, '\n') + + simulate_input_text(text) + + simulate_mouse_drag(text_area, 4, 0, 29, 0) + + expect.eq(read_rendered_text(text_area), table.concat({ + '60: Lorem ipsum dolor sit amet, consectetur adipiscing elit.', + '112: Sed consectetur, urna sit amet aliquet egestas, ante nibh ', + 'porttitor mi, vitae rutrum eros metus nec libero.', + '60: Lorem ipsum dolor sit amet, consectetur adipiscing elit.', + }, '\n')); + + expect.eq(read_selected_text(text_area), 'Lorem ipsum dolor sit amet') + + simulate_mouse_drag(text_area, 0, 0, 29, 0) + + expect.eq(read_selected_text(text_area), '60: Lorem ipsum dolor sit amet') + + simulate_mouse_drag(text_area, 32, 0, 32, 1) + + expect.eq(read_selected_text(text_area), table.concat({ + 'consectetur adipiscing elit.', + '112: Sed consectetur, urna sit am' + }, '\n')); + + simulate_mouse_drag(text_area, 32, 1, 48, 2) + + expect.eq(read_selected_text(text_area), table.concat({ + 'met aliquet egestas, ante nibh ', + 'porttitor mi, vitae rutrum eros metus nec libero.', + }, '\n')); + + simulate_mouse_drag(text_area, 42, 2, 59, 3) + + expect.eq(read_selected_text(text_area), table.concat({ + 'libero.', + '60: Lorem ipsum dolor sit amet, consectetur adipiscing elit.' + }, '\n')); + + simulate_mouse_drag(text_area, 42, 2, 65, 3) + + expect.eq(read_selected_text(text_area), table.concat({ + 'libero.', + '60: Lorem ipsum dolor sit amet, consectetur adipiscing elit.' + }, '\n')); + + simulate_mouse_drag(text_area, 42, 2, 65, 6) + + expect.eq(read_selected_text(text_area), table.concat({ + 'libero.', + '60: Lorem ipsum dolor sit amet, consectetur adipiscing elit.' + }, '\n')); + + simulate_mouse_drag(text_area, 42, 2, 42, 6) + + expect.eq(read_selected_text(text_area), table.concat({ + 'libero.', + '60: Lorem ipsum dolor sit amet, consectetur' + }, '\n')); + + journal:dismiss() +end + +function test.copy_and_paste_text_line() + local journal, text_area = arrange_empty_journal({w=65}) + + local text = table.concat({ + '112: Sed consectetur, urna sit amet aliquet egestas, ante nibh porttitor mi, vitae rutrum eros metus nec libero.', + '60: Lorem ipsum dolor sit amet, consectetur adipiscing elit.', + }, '\n') + + simulate_input_text(text) + + expect.eq(read_rendered_text(text_area), table.concat({ + '112: Sed consectetur, urna sit amet aliquet egestas, ante nibh ', + 'porttitor mi, vitae rutrum eros metus nec libero.', + '60: Lorem ipsum dolor sit amet, consectetur adipiscing elit._', + }, '\n')); + + simulate_input_keys('CUSTOM_CTRL_C') + simulate_input_keys('CUSTOM_CTRL_V') + + expect.eq(read_rendered_text(text_area), table.concat({ + '112: Sed consectetur, urna sit amet aliquet egestas, ante nibh ', + 'porttitor mi, vitae rutrum eros metus nec libero.', + '60: Lorem ipsum dolor sit amet, consectetur adipiscing elit.', + '60: Lorem ipsum dolor sit amet, consectetur adipiscing elit._', + }, '\n')); + + simulate_mouse_click(text_area, 15, 3) + simulate_input_keys('CUSTOM_CTRL_C') + simulate_input_keys('CUSTOM_CTRL_V') + + expect.eq(read_rendered_text(text_area), table.concat({ + '112: Sed consectetur, urna sit amet aliquet egestas, ante nibh ', + 'porttitor mi, vitae rutrum eros metus nec libero.', + '60: Lorem ipsum dolor sit amet, consectetur adipiscing elit.', + '60: Lorem ipsum dolor sit amet, consectetur adipiscing elit.', + '60: Lorem ipsum_dolor sit amet, consectetur adipiscing elit.', + }, '\n')); + + simulate_mouse_click(text_area, 5, 0) + simulate_input_keys('CUSTOM_CTRL_C') + simulate_input_keys('CUSTOM_CTRL_V') + + expect.eq(read_rendered_text(text_area), table.concat({ + '112: Sed consectetur, urna sit amet aliquet egestas, ante nibh ', + 'porttitor mi, vitae rutrum eros metus nec libero.', + '112: _ed consectetur, urna sit amet aliquet egestas, ante nibh ', + 'porttitor mi, vitae rutrum eros metus nec libero.', + '60: Lorem ipsum dolor sit amet, consectetur adipiscing elit.', + '60: Lorem ipsum dolor sit amet, consectetur adipiscing elit.', + '60: Lorem ipsum dolor sit amet, consectetur adipiscing elit.', + }, '\n')); + + simulate_mouse_click(text_area, 6, 0) + simulate_input_keys('CUSTOM_CTRL_C') + simulate_mouse_click(text_area, 5, 6) + simulate_input_keys('CUSTOM_CTRL_V') + + expect.eq(read_rendered_text(text_area), table.concat({ + '112: Sed consectetur, urna sit amet aliquet egestas, ante nibh ', + 'porttitor mi, vitae rutrum eros metus nec libero.', + '112: Sed consectetur, urna sit amet aliquet egestas, ante nibh ', + 'porttitor mi, vitae rutrum eros metus nec libero.', + '60: Lorem ipsum dolor sit amet, consectetur adipiscing elit.', + '60: Lorem ipsum dolor sit amet, consectetur adipiscing elit.', + '112: Sed consectetur, urna sit amet aliquet egestas, ante nibh ', + 'porttitor mi, vitae rutrum eros metus nec libero.', + '60: L_rem ipsum dolor sit amet, consectetur adipiscing elit.', + }, '\n')); + + journal:dismiss() +end + +function test.copy_and_paste_selected_text() + local journal, text_area = arrange_empty_journal({w=65}) + + local text = table.concat({ + '60: Lorem ipsum dolor sit amet, consectetur adipiscing elit.', + '112: Sed consectetur, urna sit amet aliquet egestas, ante nibh porttitor mi, vitae rutrum eros metus nec libero.', + '60: Lorem ipsum dolor sit amet, consectetur adipiscing elit.', + }, '\n') + + simulate_input_text(text) + + simulate_mouse_drag(text_area, 4, 0, 8, 0) + + expect.eq(read_rendered_text(text_area), table.concat({ + '60: Lorem ipsum dolor sit amet, consectetur adipiscing elit.', + '112: Sed consectetur, urna sit amet aliquet egestas, ante nibh ', + 'porttitor mi, vitae rutrum eros metus nec libero.', + '60: Lorem ipsum dolor sit amet, consectetur adipiscing elit.', + }, '\n')); + + expect.eq(read_selected_text(text_area), 'Lorem') + + simulate_input_keys('CUSTOM_CTRL_C') + simulate_input_keys('CUSTOM_CTRL_V') + + expect.eq(read_rendered_text(text_area), table.concat({ + '60: Lorem_ipsum dolor sit amet, consectetur adipiscing elit.', + '112: Sed consectetur, urna sit amet aliquet egestas, ante nibh ', + 'porttitor mi, vitae rutrum eros metus nec libero.', + '60: Lorem ipsum dolor sit amet, consectetur adipiscing elit.', + }, '\n')); + + simulate_mouse_click(text_area, 4, 2) + + simulate_input_keys('CUSTOM_CTRL_V') + + expect.eq(read_rendered_text(text_area), table.concat({ + '60: Lorem ipsum dolor sit amet, consectetur adipiscing elit.', + '112: Sed consectetur, urna sit amet aliquet egestas, ante nibh ', + 'portLorem_itor mi, vitae rutrum eros metus nec libero.', + '60: Lorem ipsum dolor sit amet, consectetur adipiscing elit.', + }, '\n')); + + simulate_mouse_click(text_area, 0, 0) + + simulate_input_keys('CUSTOM_CTRL_V') + + expect.eq(read_rendered_text(text_area), table.concat({ + 'Lorem_0: Lorem ipsum dolor sit amet, consectetur adipiscing ', + 'elit.', + '112: Sed consectetur, urna sit amet aliquet egestas, ante nibh ', + 'portLoremtitor mi, vitae rutrum eros metus nec libero.', + '60: Lorem ipsum dolor sit amet, consectetur adipiscing elit.', + }, '\n')); + + simulate_mouse_click(text_area, 60, 4) + + simulate_input_keys('CUSTOM_CTRL_V') + + expect.eq(read_rendered_text(text_area), table.concat({ + 'Lorem60: Lorem ipsum dolor sit amet, consectetur adipiscing ', + 'elit.', + '112: Sed consectetur, urna sit amet aliquet egestas, ante nibh ', + 'portLoremtitor mi, vitae rutrum eros metus nec libero.', + '60: Lorem ipsum dolor sit amet, consectetur adipiscing elit.Lorem_', + }, '\n')); + + journal:dismiss() +end + +function test.cut_and_paste_text_line() + local journal, text_area = arrange_empty_journal({w=65}) + + local text = table.concat({ + '112: Sed consectetur, urna sit amet aliquet egestas, ante nibh porttitor mi, vitae rutrum eros metus nec libero.', + '60: Lorem ipsum dolor sit amet, consectetur adipiscing elit.', + }, '\n') + + simulate_input_text(text) + + expect.eq(read_rendered_text(text_area), table.concat({ + '112: Sed consectetur, urna sit amet aliquet egestas, ante nibh ', + 'porttitor mi, vitae rutrum eros metus nec libero.', + '60: Lorem ipsum dolor sit amet, consectetur adipiscing elit._', + }, '\n')); + + simulate_input_keys('CUSTOM_CTRL_X') + simulate_input_keys('CUSTOM_CTRL_V') + + expect.eq(read_rendered_text(text_area), table.concat({ + '112: Sed consectetur, urna sit amet aliquet egestas, ante nibh ', + 'porttitor mi, vitae rutrum eros metus nec libero.', + '60: Lorem ipsum dolor sit amet, consectetur adipiscing elit.', + '_', + }, '\n')); + + simulate_mouse_click(text_area, 0, 0) + simulate_input_keys('CUSTOM_CTRL_X') + simulate_input_keys('CUSTOM_CTRL_V') + + expect.eq(read_rendered_text(text_area), table.concat({ + '112: Sed consectetur, urna sit amet aliquet egestas, ante nibh ', + 'porttitor mi, vitae rutrum eros metus nec libero.', + '_0: Lorem ipsum dolor sit amet, consectetur adipiscing elit.', + }, '\n')); + + simulate_mouse_click(text_area, 60, 2) + simulate_input_keys('CUSTOM_CTRL_X') + simulate_input_keys('CUSTOM_CTRL_V') + + expect.eq(read_rendered_text(text_area), table.concat({ + '112: Sed consectetur, urna sit amet aliquet egestas, ante nibh ', + 'porttitor mi, vitae rutrum eros metus nec libero.', + '60: Lorem ipsum dolor sit amet, consectetur adipiscing elit.', + '_', + }, '\n')); + + journal:dismiss() +end + +function test.cut_and_paste_selected_text() + local journal, text_area = arrange_empty_journal({w=65}) + + local text = table.concat({ + '60: Lorem ipsum dolor sit amet, consectetur adipiscing elit.', + '112: Sed consectetur, urna sit amet aliquet egestas, ante nibh porttitor mi, vitae rutrum eros metus nec libero.', + '60: Lorem ipsum dolor sit amet, consectetur adipiscing elit.', + }, '\n') + + simulate_input_text(text) + + simulate_mouse_drag(text_area, 4, 0, 8, 0) + + expect.eq(read_rendered_text(text_area), table.concat({ + '60: Lorem ipsum dolor sit amet, consectetur adipiscing elit.', + '112: Sed consectetur, urna sit amet aliquet egestas, ante nibh ', + 'porttitor mi, vitae rutrum eros metus nec libero.', + '60: Lorem ipsum dolor sit amet, consectetur adipiscing elit.', + }, '\n')); + + expect.eq(read_selected_text(text_area), 'Lorem') + + simulate_input_keys('CUSTOM_CTRL_X') + simulate_input_keys('CUSTOM_CTRL_V') + + expect.eq(read_rendered_text(text_area), table.concat({ + '60: Lorem_ipsum dolor sit amet, consectetur adipiscing elit.', + '112: Sed consectetur, urna sit amet aliquet egestas, ante nibh ', + 'porttitor mi, vitae rutrum eros metus nec libero.', + '60: Lorem ipsum dolor sit amet, consectetur adipiscing elit.', + }, '\n')); + + simulate_mouse_drag(text_area, 4, 0, 8, 0) + simulate_input_keys('CUSTOM_CTRL_X') + + simulate_mouse_click(text_area, 4, 2) + + simulate_input_keys('CUSTOM_CTRL_V') + + expect.eq(read_rendered_text(text_area), table.concat({ + '60: ipsum dolor sit amet, consectetur adipiscing elit.', + '112: Sed consectetur, urna sit amet aliquet egestas, ante nibh ', + 'portLorem_itor mi, vitae rutrum eros metus nec libero.', + '60: Lorem ipsum dolor sit amet, consectetur adipiscing elit.', + }, '\n')); + + simulate_mouse_drag(text_area, 5, 2, 8, 2) + simulate_input_keys('CUSTOM_CTRL_X') + + simulate_mouse_click(text_area, 0, 0) + simulate_input_keys('CUSTOM_CTRL_V') + + expect.eq(read_rendered_text(text_area), table.concat({ + 'orem_0: ipsum dolor sit amet, consectetur adipiscing elit.', + '112: Sed consectetur, urna sit amet aliquet egestas, ante nibh ', + 'portLtitor mi, vitae rutrum eros metus nec libero.', + '60: Lorem ipsum dolor sit amet, consectetur adipiscing elit.', + }, '\n')); + + simulate_mouse_drag(text_area, 5, 2, 8, 2) + simulate_input_keys('CUSTOM_CTRL_X') + + simulate_mouse_click(text_area, 60, 4) + simulate_input_keys('CUSTOM_CTRL_V') + + expect.eq(read_rendered_text(text_area), table.concat({ + 'orem60: ipsum dolor sit amet, consectetur adipiscing elit.', + '112: Sed consectetur, urna sit amet aliquet egestas, ante nibh ', + 'portLr mi, vitae rutrum eros metus nec libero.', + '60: Lorem ipsum dolor sit amet, consectetur adipiscing elit.tito_', + }, '\n')); + + 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,save_on_change=true}) + + simulate_input_keys('CUSTOM_CTRL_A') + simulate_input_keys('CUSTOM_CTRL_D') + + 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, save_on_change=true}) + + 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.scroll_long_text() + local journal, text_area = arrange_empty_journal({w=100, h=10}) + local scrollbar = journal.subviews.scrollbar + + local text = table.concat({ + 'Lorem ipsum dolor sit amet, consectetur adipiscing elit.', + 'Nulla ut lacus ut tortor semper consectetur.', + 'Nam scelerisque ligula vitae magna varius, vel porttitor tellus egestas.', + 'Suspendisse aliquet dolor ac velit maximus, ut tempor lorem tincidunt.', + 'Ut eu orci non nibh hendrerit posuere.', + 'Sed euismod odio eu fringilla bibendum.', + 'Etiam dignissim diam nec aliquet facilisis.', + 'Integer tristique purus at tellus luctus, vel aliquet sapien sollicitudin.', + 'Fusce ornare est vitae urna feugiat, vel interdum quam vestibulum.', + '10: Vivamus id felis scelerisque, lobortis diam ut, mollis nisi.', + 'Donec quis lectus ac erat placerat eleifend.', + 'Aenean non orci id erat malesuada pharetra.', + 'Nunc in lectus et metus finibus venenatis.', + 'Morbi id mauris dignissim, suscipit metus nec, auctor odio.', + 'Sed in libero eget velit condimentum lacinia ut quis dui.', + 'Praesent sollicitudin dui ac mollis lacinia.', + 'Ut gravida tortor ac accumsan suscipit.', + '18: Vestibulum at ante ut dui hendrerit pellentesque ut eu ex.', + }, '\n') + + simulate_input_text(text) + + expect.eq(read_rendered_text(text_area), table.concat({ + 'Fusce ornare est vitae urna feugiat, vel interdum quam vestibulum.', + '10: Vivamus id felis scelerisque, lobortis diam ut, mollis nisi.', + 'Donec quis lectus ac erat placerat eleifend.', + 'Aenean non orci id erat malesuada pharetra.', + 'Nunc in lectus et metus finibus venenatis.', + 'Morbi id mauris dignissim, suscipit metus nec, auctor odio.', + 'Sed in libero eget velit condimentum lacinia ut quis dui.', + 'Praesent sollicitudin dui ac mollis lacinia.', + 'Ut gravida tortor ac accumsan suscipit.', + '18: Vestibulum at ante ut dui hendrerit pellentesque ut eu ex._', + }, '\n')) + + simulate_mouse_click(scrollbar, 0, 0) + + expect.eq(read_rendered_text(text_area), table.concat({ + 'Integer tristique purus at tellus luctus, vel aliquet sapien sollicitudin.', + 'Fusce ornare est vitae urna feugiat, vel interdum quam vestibulum.', + '10: Vivamus id felis scelerisque, lobortis diam ut, mollis nisi.', + 'Donec quis lectus ac erat placerat eleifend.', + 'Aenean non orci id erat malesuada pharetra.', + 'Nunc in lectus et metus finibus venenatis.', + 'Morbi id mauris dignissim, suscipit metus nec, auctor odio.', + 'Sed in libero eget velit condimentum lacinia ut quis dui.', + 'Praesent sollicitudin dui ac mollis lacinia.', + 'Ut gravida tortor ac accumsan suscipit.', + }, '\n')) + + simulate_mouse_click(scrollbar, 0, 0) + simulate_mouse_click(scrollbar, 0, 0) + + expect.eq(read_rendered_text(text_area), table.concat({ + 'Sed euismod odio eu fringilla bibendum.', + 'Etiam dignissim diam nec aliquet facilisis.', + 'Integer tristique purus at tellus luctus, vel aliquet sapien sollicitudin.', + 'Fusce ornare est vitae urna feugiat, vel interdum quam vestibulum.', + '10: Vivamus id felis scelerisque, lobortis diam ut, mollis nisi.', + 'Donec quis lectus ac erat placerat eleifend.', + 'Aenean non orci id erat malesuada pharetra.', + 'Nunc in lectus et metus finibus venenatis.', + 'Morbi id mauris dignissim, suscipit metus nec, auctor odio.', + 'Sed in libero eget velit condimentum lacinia ut quis dui.', + }, '\n')) + + simulate_mouse_click(scrollbar, 0, scrollbar.frame_body.height - 2) + + expect.eq(read_rendered_text(text_area), table.concat({ + 'Fusce ornare est vitae urna feugiat, vel interdum quam vestibulum.', + '10: Vivamus id felis scelerisque, lobortis diam ut, mollis nisi.', + 'Donec quis lectus ac erat placerat eleifend.', + 'Aenean non orci id erat malesuada pharetra.', + 'Nunc in lectus et metus finibus venenatis.', + 'Morbi id mauris dignissim, suscipit metus nec, auctor odio.', + 'Sed in libero eget velit condimentum lacinia ut quis dui.', + 'Praesent sollicitudin dui ac mollis lacinia.', + 'Ut gravida tortor ac accumsan suscipit.', + '18: Vestibulum at ante ut dui hendrerit pellentesque ut eu ex._', + }, '\n')) + + simulate_mouse_click(scrollbar, 0, 2) + + expect.eq(read_rendered_text(text_area), table.concat({ + 'Suspendisse aliquet dolor ac velit maximus, ut tempor lorem tincidunt.', + 'Ut eu orci non nibh hendrerit posuere.', + 'Sed euismod odio eu fringilla bibendum.', + 'Etiam dignissim diam nec aliquet facilisis.', + 'Integer tristique purus at tellus luctus, vel aliquet sapien sollicitudin.', + 'Fusce ornare est vitae urna feugiat, vel interdum quam vestibulum.', + '10: Vivamus id felis scelerisque, lobortis diam ut, mollis nisi.', + '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.scroll_follows_cursor() + local journal, text_area = arrange_empty_journal({w=100, h=10}) + local scrollbar = journal.subviews.text_area_scrollbar + + local text = table.concat({ + 'Lorem ipsum dolor sit amet, consectetur adipiscing elit.', + 'Nulla ut lacus ut tortor semper consectetur.', + 'Nam scelerisque ligula vitae magna varius, vel porttitor tellus egestas.', + 'Suspendisse aliquet dolor ac velit maximus, ut tempor lorem tincidunt.', + 'Ut eu orci non nibh hendrerit posuere.', + 'Sed euismod odio eu fringilla bibendum.', + 'Etiam dignissim diam nec aliquet facilisis.', + 'Integer tristique purus at tellus luctus, vel aliquet sapien sollicitudin.', + 'Fusce ornare est vitae urna feugiat, vel interdum quam vestibulum.', + '10: Vivamus id felis scelerisque, lobortis diam ut, mollis nisi.', + 'Donec quis lectus ac erat placerat eleifend.', + 'Aenean non orci id erat malesuada pharetra.', + 'Nunc in lectus et metus finibus venenatis.', + 'Morbi id mauris dignissim, suscipit metus nec, auctor odio.', + 'Sed in libero eget velit condimentum lacinia ut quis dui.', + 'Praesent sollicitudin dui ac mollis lacinia.', + 'Ut gravida tortor ac accumsan suscipit.', + '18: Vestibulum at ante ut dui hendrerit pellentesque ut eu ex.', + }, '\n') + + simulate_input_text(text) + + expect.eq(read_rendered_text(text_area), table.concat({ + 'Fusce ornare est vitae urna feugiat, vel interdum quam vestibulum.', + '10: Vivamus id felis scelerisque, lobortis diam ut, mollis nisi.', + 'Donec quis lectus ac erat placerat eleifend.', + 'Aenean non orci id erat malesuada pharetra.', + 'Nunc in lectus et metus finibus venenatis.', + 'Morbi id mauris dignissim, suscipit metus nec, auctor odio.', + 'Sed in libero eget velit condimentum lacinia ut quis dui.', + 'Praesent sollicitudin dui ac mollis lacinia.', + 'Ut gravida tortor ac accumsan suscipit.', + '18: Vestibulum at ante ut dui hendrerit pellentesque ut eu ex._', + }, '\n')) + + simulate_mouse_click(text_area, 0, 8) + simulate_input_keys('KEYBOARD_CURSOR_UP') + + expect.eq(read_rendered_text(text_area), table.concat({ + '_nteger tristique purus at tellus luctus, vel aliquet sapien sollicitudin.', + 'Fusce ornare est vitae urna feugiat, vel interdum quam vestibulum.', + '10: Vivamus id felis scelerisque, lobortis diam ut, mollis nisi.', + 'Donec quis lectus ac erat placerat eleifend.', + 'Aenean non orci id erat malesuada pharetra.', + 'Nunc in lectus et metus finibus venenatis.', + 'Morbi id mauris dignissim, suscipit metus nec, auctor odio.', + 'Sed in libero eget velit condimentum lacinia ut quis dui.', + 'Praesent sollicitudin dui ac mollis lacinia.', + 'Ut gravida tortor ac accumsan suscipit.', + }, '\n')) + + simulate_input_keys('KEYBOARD_CURSOR_UP_FAST') + + simulate_mouse_click(text_area, 0, 9) + simulate_input_keys('KEYBOARD_CURSOR_DOWN') + + expect.eq(read_rendered_text(text_area), table.concat({ + 'Nulla ut lacus ut tortor semper consectetur.', + 'Nam scelerisque ligula vitae magna varius, vel porttitor tellus egestas.', + 'Suspendisse aliquet dolor ac velit maximus, ut tempor lorem tincidunt.', + 'Ut eu orci non nibh hendrerit posuere.', + 'Sed euismod odio eu fringilla bibendum.', + 'Etiam dignissim diam nec aliquet facilisis.', + 'Integer tristique purus at tellus luctus, vel aliquet sapien sollicitudin.', + 'Fusce ornare est vitae urna feugiat, vel interdum quam vestibulum.', + '10: Vivamus id felis scelerisque, lobortis diam ut, mollis nisi.', + '_onec quis lectus ac erat placerat eleifend.', + }, '\n')) + + simulate_mouse_click(text_area, 44, 10) + simulate_input_keys('KEYBOARD_CURSOR_RIGHT') + + expect.eq(read_rendered_text(text_area), table.concat({ + 'Nam scelerisque ligula vitae magna varius, vel porttitor tellus egestas.', + 'Suspendisse aliquet dolor ac velit maximus, ut tempor lorem tincidunt.', + 'Ut eu orci non nibh hendrerit posuere.', + 'Sed euismod odio eu fringilla bibendum.', + 'Etiam dignissim diam nec aliquet facilisis.', + 'Integer tristique purus at tellus luctus, vel aliquet sapien sollicitudin.', + 'Fusce ornare est vitae urna feugiat, vel interdum quam vestibulum.', + '10: Vivamus id felis scelerisque, lobortis diam ut, mollis nisi.', + 'Donec quis lectus ac erat placerat eleifend.', + '_enean non orci id erat malesuada pharetra.', + }, '\n')) + + simulate_mouse_click(text_area, 0, 2) + simulate_input_keys('KEYBOARD_CURSOR_LEFT') + + expect.eq(read_rendered_text(text_area), table.concat({ + 'Nulla ut lacus ut tortor semper consectetur._', + 'Nam scelerisque ligula vitae magna varius, vel porttitor tellus egestas.', + 'Suspendisse aliquet dolor ac velit maximus, ut tempor lorem tincidunt.', + 'Ut eu orci non nibh hendrerit posuere.', + 'Sed euismod odio eu fringilla bibendum.', + 'Etiam dignissim diam nec aliquet facilisis.', + 'Integer tristique purus at tellus luctus, vel aliquet sapien sollicitudin.', + 'Fusce ornare est vitae urna feugiat, vel interdum quam vestibulum.', + '10: Vivamus id felis scelerisque, lobortis diam ut, mollis nisi.', + 'Donec quis lectus ac erat placerat eleifend.', + }, '\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 + +if df_major_version < 51 then + -- temporary ignore test features that base on newest API of the DF game + return +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.fast_rewind_words_right() + local journal, text_area = arrange_empty_journal({w=55}) + + local text = table.concat({ + '60: Lorem ipsum dolor sit amet, consectetur adipiscing elit.', + '112: Sed consectetur, urna sit amet aliquet egestas, ante nibh porttitor mi, vitae rutrum eros metus nec libero.', + }, '\n') + + simulate_input_text(text) + text_area:setCursor(1) + journal:onRender() + + simulate_input_keys('A_MOVE_E_DOWN') + + expect.eq(read_rendered_text(text_area), table.concat({ + '60:_Lorem ipsum dolor sit amet, consectetur adipiscing ', + 'elit.', + '112: Sed consectetur, urna sit amet aliquet egestas, ', + 'ante nibh porttitor mi, vitae rutrum eros metus nec ', + 'libero.', + }, '\n')); + + simulate_input_keys('A_MOVE_E_DOWN') + + expect.eq(read_rendered_text(text_area), table.concat({ + '60: Lorem_ipsum dolor sit amet, consectetur adipiscing ', + 'elit.', + '112: Sed consectetur, urna sit amet aliquet egestas, ', + 'ante nibh porttitor mi, vitae rutrum eros metus nec ', + 'libero.', + }, '\n')); + + for i=1,6 do + simulate_input_keys('A_MOVE_E_DOWN') + end + + expect.eq(read_rendered_text(text_area), table.concat({ + '60: Lorem ipsum dolor sit amet, consectetur adipiscing_', + 'elit.', + '112: Sed consectetur, urna sit amet aliquet egestas, ', + 'ante nibh porttitor mi, vitae rutrum eros metus nec ', + 'libero.', + }, '\n')); + + simulate_input_keys('A_MOVE_E_DOWN') + + expect.eq(read_rendered_text(text_area), table.concat({ + '60: Lorem ipsum dolor sit amet, consectetur adipiscing ', + 'elit._', + '112: Sed consectetur, urna sit amet aliquet egestas, ', + 'ante nibh porttitor mi, vitae rutrum eros metus nec ', + 'libero.', + }, '\n')); + + simulate_input_keys('A_MOVE_E_DOWN') + + expect.eq(read_rendered_text(text_area), table.concat({ + '60: Lorem ipsum dolor sit amet, consectetur adipiscing ', + 'elit.', + '112:_Sed consectetur, urna sit amet aliquet egestas, ', + 'ante nibh porttitor mi, vitae rutrum eros metus nec ', + 'libero.', + }, '\n')); + + for i=1,17 do + simulate_input_keys('A_MOVE_E_DOWN') + end + + expect.eq(read_rendered_text(text_area), table.concat({ + '60: Lorem ipsum dolor sit amet, consectetur adipiscing ', + 'elit.', + '112: Sed consectetur, urna sit amet aliquet egestas, ', + 'ante nibh porttitor mi, vitae rutrum eros metus nec ', + 'libero._', + }, '\n')); + + simulate_input_keys('A_MOVE_E_DOWN') + + expect.eq(read_rendered_text(text_area), table.concat({ + '60: Lorem ipsum dolor sit amet, consectetur adipiscing ', + 'elit.', + '112: Sed consectetur, urna sit amet aliquet egestas, ', + 'ante nibh porttitor mi, vitae rutrum eros metus nec ', + 'libero._', + }, '\n')); + + journal:dismiss() +end + +function test.fast_rewind_words_left() + local journal, text_area = arrange_empty_journal({w=55}) + + local text = table.concat({ + '60: Lorem ipsum dolor sit amet, consectetur adipiscing elit.', + '112: Sed consectetur, urna sit amet aliquet egestas, ante nibh porttitor mi, vitae rutrum eros metus nec libero.', + }, '\n') + + simulate_input_text(text) + + simulate_input_keys('A_MOVE_W_DOWN') + + expect.eq(read_rendered_text(text_area), table.concat({ + '60: Lorem ipsum dolor sit amet, consectetur adipiscing ', + 'elit.', + '112: Sed consectetur, urna sit amet aliquet egestas, ', + 'ante nibh porttitor mi, vitae rutrum eros metus nec ', + '_ibero.', + }, '\n')); + + simulate_input_keys('A_MOVE_W_DOWN') + + expect.eq(read_rendered_text(text_area), table.concat({ + '60: Lorem ipsum dolor sit amet, consectetur adipiscing ', + 'elit.', + '112: Sed consectetur, urna sit amet aliquet egestas, ', + 'ante nibh porttitor mi, vitae rutrum eros metus _ec ', + 'libero.', + }, '\n')); + + for i=1,8 do + simulate_input_keys('A_MOVE_W_DOWN') + end + + expect.eq(read_rendered_text(text_area), table.concat({ + '60: Lorem ipsum dolor sit amet, consectetur adipiscing ', + 'elit.', + '112: Sed consectetur, urna sit amet aliquet egestas, ', + '_nte nibh porttitor mi, vitae rutrum eros metus nec ', + 'libero.', + }, '\n')); + + simulate_input_keys('A_MOVE_W_DOWN') + + expect.eq(read_rendered_text(text_area), table.concat({ + '60: Lorem ipsum dolor sit amet, consectetur adipiscing ', + 'elit.', + '112: Sed consectetur, urna sit amet aliquet _gestas, ', + 'ante nibh porttitor mi, vitae rutrum eros metus nec ', + 'libero.', + }, '\n')); + + for i=1,16 do + simulate_input_keys('A_MOVE_W_DOWN') + end + + expect.eq(read_rendered_text(text_area), table.concat({ + '_0: Lorem ipsum dolor sit amet, consectetur adipiscing ', + 'elit.', + '112: Sed consectetur, urna sit amet aliquet egestas, ', + 'ante nibh porttitor mi, vitae rutrum eros metus nec ', + 'libero.', + }, '\n')); + + simulate_input_keys('A_MOVE_W_DOWN') + + expect.eq(read_rendered_text(text_area), table.concat({ + '_0: Lorem ipsum dolor sit amet, consectetur adipiscing ', + 'elit.', + '112: Sed consectetur, urna sit amet aliquet egestas, ', + 'ante nibh porttitor mi, vitae rutrum eros metus nec ', + 'libero.', + }, '\n')); + + journal:dismiss() +end + +function test.fast_rewind_reset_selection() + local journal, text_area = arrange_empty_journal({w=65}) + + local text = table.concat({ + '60: Lorem ipsum dolor sit amet, consectetur adipiscing elit.', + '112: Sed consectetur, urna sit amet aliquet egestas, ante nibh porttitor mi, vitae rutrum eros metus nec libero.', + }, '\n') + + simulate_input_text(text) + + simulate_input_keys('CUSTOM_CTRL_A') + + expect.eq(read_rendered_text(text_area), table.concat({ + '60: Lorem ipsum dolor sit amet, consectetur adipiscing elit.', + '112: Sed consectetur, urna sit amet aliquet egestas, ante nibh ', + 'porttitor mi, vitae rutrum eros metus nec libero.', + }, '\n')); + + expect.eq(read_selected_text(text_area), table.concat({ + '60: Lorem ipsum dolor sit amet, consectetur adipiscing elit.', + '112: Sed consectetur, urna sit amet aliquet egestas, ante nibh ', + 'porttitor mi, vitae rutrum eros metus nec libero.', + }, '\n')); + + simulate_input_keys('A_MOVE_W_DOWN') + expect.eq(read_selected_text(text_area), '') + + simulate_input_keys('CUSTOM_CTRL_A') + + simulate_input_keys('A_MOVE_E_DOWN') + expect.eq(read_selected_text(text_area), '') + + journal:dismiss() +end + +function test.show_tutorials_on_first_use() + local journal, text_area, journal_window = arrange_empty_journal({w=65}) + simulate_input_keys('CUSTOM_CTRL_O') + + expect.str_find('Welcome to gui/journal', 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/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/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 index d483b06cf7..bf69f2a476 100644 --- a/timestream.lua +++ b/timestream.lua @@ -1,420 +1,496 @@ --- speeds up the calendar, units, or both --- based on https://gist.github.com/IndigoFenix/cf358b8c994caa0f93d5 +--@module = true +--@enable = true ---[====[ +local argparse = require('argparse') +local eventful = require('plugins.eventful') +local repeatutil = require("repeat-util") +local utils = require('utils') -timestream -========== -Controls the speed of the calendar and creatures. Fortress mode only. Experimental. +-- set to verbosity level +-- 1: dev warning messages +-- 2: timeskip tracing +-- 3: coverage tracing +DEBUG = DEBUG or 0 -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. +------------------------------------ +-- state management -Usage:: +local GLOBAL_KEY = 'timestream' - timestream [-rate R] [-fps FPS] [-units [FLAG]] [-debug] +local SETTINGS = { + { + name='fps', + validate=function(arg) + local val = argparse.positiveInt(arg, 'fps') + if val < 10 then qerror('target fps must be at least 10') end + return val + end, + default=function() return df.global.init.fps_cap end, + }, +} -Examples: +local function get_default_state() + local settings = {} + for _, v in ipairs(SETTINGS) do + settings[v.internal_name or v.name] = utils.getval(v.default) + end + return { + enabled=false, + settings=settings, + } +end + +state = state or get_default_state() + +function isEnabled() + return state.enabled +end + +local function persist_state() + dfhack.persistent.saveSiteData(GLOBAL_KEY, state) +end -- ``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. +------------------------------------ +-- business logic -Original timestream.lua: https://gist.github.com/IndigoFenix/cf358b8c994caa0f93d5 -]====] +-- ensure we never skip over cur_year_tick values that match this list +local TICK_TRIGGERS = { + {mod=10, rem={0}}, -- 0: season ticks (and lots of other stuff) + -- 0 mod 100: crop growth, strange mood, minimap update, rot + -- 20 mod 100: building updates + -- 40 mod 100: assign tombs to newly tomb-eligible corpses + -- 80 mod 100: incarceration updates + -- 40 mod 1000: remove excess seeds + {mod=50, rem={25, 35, 45}}, -- 25: stockpile updates + -- 35: check bags + -- 35 mod 100: job auction + -- 45: stockpile updates + {mod=100, rem={99}}, -- 99: new job creation +} +-- "owed" ticks we would like to skip at the next opportunity +timeskip_deficit = timeskip_deficit or 0.0 -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 +-- birthday_triggers is a dense sequence of cur_year_tick values -> next unit birthday +-- the sequence covers 0 .. greatest unit birthday value +-- this cache is augmented when new units appear (as per the new unit event) and is cleared and +-- refreshed from scratch once a year to evict data for units that are no longer active. +birthday_triggers = birthday_triggers or {} +-- coverage record for cur_year_tick % 50 so we can be sure that all items are being scanned +-- (DF scans 1/50th of items every tick based on cur_year_tick % 50) +-- we want every section hit at least once every 1000 ticks +tick_coverage = tick_coverage or {} + +-- only throttle due to tick_coverage at most once per season tick to avoid clustering +season_tick_throttled = season_tick_throttled or false + +local function register_birthday(unit) + local btick = unit.birth_time + if btick < 0 then return end + for tick=btick,0,-1 do + if (birthday_triggers[tick] or math.huge) > btick then + birthday_triggers[tick] = btick + else + break + end + end +end ---- DO NOT CHANGE BELOW UNLESS YOU KNOW WHAT YOU'RE DOING --- +local function check_new_unit(unit_id) + local unit = df.unit.find(unit_id) + if not unit then return end + if DEBUG >= 3 then + print('registering new unit', unit.id, dfhack.units.getReadableName(unit)) + end + register_birthday(unit) +end -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 +local function refresh_birthday_triggers() + birthday_triggers = {} + for _,unit in ipairs(df.global.world.units.active) do + if dfhack.units.isActive(unit) and not dfhack.units.isDead(unit) then + register_birthday(unit) + end + end 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.") + +local function reset_ephemeral_state() + timeskip_deficit = 0.0 + refresh_birthday_triggers() + tick_coverage = {} + season_tick_throttled = false end -if rate == nil then - rate = 1 -elseif rate < 0 then - rate = 0 +local function get_desired_timeskip(real_fps, desired_fps) + -- minus 1 to account for the current frame + return (desired_fps / real_fps) - 1 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 +local function clamp_coverage(timeskip) + if season_tick_throttled then return timeskip end + for val=1,timeskip do + local coverage_slot = (df.global.cur_year_tick+val) % 50 + if not tick_coverage[coverage_slot] then + season_tick_throttled = true + return val-1 + end end -elseif desired_fps < MINIMAL_FPS then - desired_fps = MINIMAL_FPS + return timeskip 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 + +local function record_coverage() + local coverage_slot = df.global.cur_year_tick % 50 + if DEBUG >= 3 and not tick_coverage[coverage_slot] then + print('recording coverage for slot:', coverage_slot) + end + tick_coverage[coverage_slot] = true 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 +local function get_next_birthday(next_tick) + return birthday_triggers[next_tick] or math.huge +end + +local function get_next_trigger_year_tick(next_tick) + local next_trigger_tick = math.huge + for _, trigger in ipairs(TICK_TRIGGERS) do + local cur_rem = next_tick % trigger.mod + for _, rem in ipairs(trigger.rem) do + if cur_rem <= rem then + next_trigger_tick = math.min(next_trigger_tick, next_tick + (rem - cur_rem)) + goto continue 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.") + next_trigger_tick = math.min(next_trigger_tick, next_tick + trigger.mod - cur_rem + trigger.rem[#trigger.rem]) + ::continue:: + end + return next_trigger_tick +end + +local function clamp_timeskip(timeskip) + timeskip = math.floor(timeskip) + if timeskip <= 0 then return 0 end + local next_tick = df.global.cur_year_tick + 1 + timeskip = math.min(timeskip, get_next_trigger_year_tick(next_tick)-next_tick) + timeskip = math.min(timeskip, get_next_birthday(next_tick)-next_tick) + return clamp_coverage(timeskip) +end + +local function increment_counter(obj, counter_name, timeskip) + if obj[counter_name] <= 0 then return end + obj[counter_name] = obj[counter_name] + timeskip +end + +local function decrement_counter(obj, counter_name, timeskip) + if obj[counter_name] <= 0 then return end + obj[counter_name] = math.max(1, obj[counter_name] - timeskip) +end + +local function adjust_unit_counters(unit, timeskip) + local c1 = unit.counters + decrement_counter(c1, 'think_counter', timeskip) + decrement_counter(c1, 'job_counter', timeskip) + decrement_counter(c1, 'swap_counter', timeskip) + decrement_counter(c1, 'winded', timeskip) + decrement_counter(c1, 'stunned', timeskip) + decrement_counter(c1, 'unconscious', timeskip) + decrement_counter(c1, 'suffocation', timeskip) + decrement_counter(c1, 'webbed', timeskip) + decrement_counter(c1, 'soldier_mood_countdown', timeskip) + decrement_counter(c1, 'pain', timeskip) + decrement_counter(c1, 'nausea', timeskip) + decrement_counter(c1, 'dizziness', timeskip) + local c2 = unit.counters2 + decrement_counter(c2, 'paralysis', timeskip) + decrement_counter(c2, 'numbness', timeskip) + decrement_counter(c2, 'fever', timeskip) + decrement_counter(c2, 'exhaustion', timeskip * 3) + increment_counter(c2, 'hunger_timer', timeskip) + increment_counter(c2, 'thirst_timer', timeskip) + local job = unit.job.current_job + if job and job.job_type == df.job_type.Rest then + decrement_counter(c2, 'sleepiness_timer', timeskip * 200) + elseif job and job.job_type == df.job_type.Sleep then + decrement_counter(c2, 'sleepiness_timer', timeskip * 19) + else + increment_counter(c2, 'sleepiness_timer', timeskip) + end + decrement_counter(c2, 'stomach_content', timeskip * 5) + decrement_counter(c2, 'stomach_food', timeskip * 5) + decrement_counter(c2, 'vomit_timeout', timeskip) + -- stored_fat wanders about based on other state; we can likely leave it alone and + -- not materially affect gameplay +end + +-- need to manually adjust job completion_timer values for jobs that are controlled by unit actions +-- with a timer of 1, which are destroyed immediately after they are created. longer-lived unit +-- actions are already sufficiently handled by dfhack.units.subtractGroupActionTimers(). +-- this will also decrement timers for jobs with actions that have just expired, but on average, this +-- should balance out to be correct, since we're losing time when we subtract from the action timers +-- and cap the value so it never drops below 1. +local function adjust_job_counter(unit, timeskip) + local job = unit.job.current_job + if not job then return end + for _,action in ipairs(unit.actions) do + if action.type == df.unit_action_type.Job or action.type == df.unit_action_type.JobRecover then + return end end + decrement_counter(job, 'completion_timer', timeskip) 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 +-- unit needs appear to be incremented on season ticks, so we don't need to worry about those +-- since the TICK_TRIGGERS check makes sure that we never skip season ticks +local function adjust_units(timeskip) + for _, unit in ipairs(df.global.world.units.active) do + if not dfhack.units.isActive(unit) then goto continue end + decrement_counter(unit, 'pregnancy_timer', timeskip) + dfhack.units.subtractGroupActionTimers(unit, timeskip, df.unit_action_type_group.All) + if not dfhack.units.isOwnGroup(unit) then goto continue end + adjust_unit_counters(unit, timeskip) + adjust_job_counter(unit, timeskip) + ::continue:: + end +end + +-- behavior ascertained from in-game observation +local function adjust_activities(timeskip) + for i, act in ipairs(df.global.world.activities.all) do + for _, ev in ipairs(act.events) do + if df.activity_event_training_sessionst:is_instance(ev) then + -- no counters + elseif df.activity_event_combat_trainingst:is_instance(ev) then + -- has organize_counter at a non-zero value, but it doesn't seem to move + elseif df.activity_event_skill_demonstrationst:is_instance(ev) then + -- can be negative or positive, but always counts towards 0 + if ev.organize_counter < 0 then + ev.organize_counter = math.min(-1, ev.organize_counter + timeskip) + else + decrement_counter(ev, 'organize_counter', timeskip) 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 + decrement_counter(ev, 'train_countdown', timeskip) + elseif df.activity_event_fill_service_orderst:is_instance(ev) then + -- no counters + elseif df.activity_event_individual_skill_drillst:is_instance(ev) then + -- only counts down on season ticks, nothing to do here + elseif df.activity_event_sparringst:is_instance(ev) then + decrement_counter(ev, 'countdown', timeskip * 2) + elseif df.activity_event_ranged_practicest:is_instance(ev) then + -- countdown appears to never move from 0 + decrement_counter(ev, 'countdown', timeskip) + elseif df.activity_event_harassmentst:is_instance(ev) then + if DEBUG >= 1 then + print('activity_event_harassmentst ready for analysis at index', i) 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 + elseif df.activity_event_encounterst:is_instance(ev) then + if DEBUG >= 1 then + print('activity_event_encounterst ready for analysis at index', i) 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 + elseif df.activity_event_reunionst:is_instance(ev) then + if DEBUG >= 1 then + print('activity_event_reunionst ready for analysis at index', i) 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 + elseif df.activity_event_conversationst:is_instance(ev) then + increment_counter(ev, 'pause', timeskip) + elseif df.activity_event_guardst:is_instance(ev) then + -- no counters + elseif df.activity_event_conflictst:is_instance(ev) then + increment_counter(ev, 'inactivity_timer', timeskip) + increment_counter(ev, 'attack_inactivity_timer', timeskip) + increment_counter(ev, 'stop_fort_fights_timer', timeskip) + elseif df.activity_event_prayerst:is_instance(ev) then + decrement_counter(ev, 'timer', timeskip) + elseif df.activity_event_researchst:is_instance(ev) then + -- no counters + elseif df.activity_event_playst:is_instance(ev) then + increment_counter(ev, 'down_time_counter', timeskip) + elseif df.activity_event_worshipst:is_instance(ev) then + increment_counter(ev, 'down_time_counter', timeskip) + elseif df.activity_event_socializest:is_instance(ev) then + increment_counter(ev, 'down_time_counter', timeskip) + elseif df.activity_event_ponder_topicst:is_instance(ev) then + decrement_counter(ev, 'timer', timeskip) + elseif df.activity_event_discuss_topicst:is_instance(ev) then + decrement_counter(ev, 'timer', timeskip) + elseif df.activity_event_teach_topicst:is_instance(ev) then + decrement_counter(ev, 'time_left', timeskip) + elseif df.activity_event_readst:is_instance(ev) then + decrement_counter(ev, 'timer', timeskip) + elseif df.activity_event_writest:is_instance(ev) then + decrement_counter(ev, 'timer', timeskip) + elseif df.activity_event_copy_written_contentst:is_instance(ev) then + decrement_counter(ev, 'time_left', timeskip) + elseif df.activity_event_make_believest:is_instance(ev) then + decrement_counter(ev, 'time_left', timeskip) + elseif df.activity_event_play_with_toyst:is_instance(ev) then + decrement_counter(ev, 'time_left', timeskip) + elseif df.activity_event_performancest:is_instance(ev) then + increment_counter(ev, 'current_position', timeskip) + elseif df.activity_event_store_objectst:is_instance(ev) then + if DEBUG >= 1 then + print('activity_event_store_objectst ready for analysis at index', i) end - else - seasonNow = false end + end + end +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 +local function on_tick() + record_coverage() - 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 + if df.global.cur_year_tick % 10 == 0 then + season_tick_throttled = false + if df.global.cur_year_tick % 1000 == 0 then + if DEBUG >= 1 then + if DEBUG >= 3 then + print('checking coverage') 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 + for coverage_slot=0,49 do + if not tick_coverage[coverage_slot] then + print('coverage slot not covered:', coverage_slot) 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() + tick_coverage = {} end - if ui_main.autosave_request then - if debug_mode then - print("Save state detected") - end - saved_game_frame = current_frame + if df.global.cur_year_tick == 0 then + refresh_birthday_triggers() end - if not loaded then - loaded = true - dfhack.timeout(1,"frames",function() update() end) + end + + local real_fps = math.max(1, dfhack.internal.getUnpausedFps()) + if real_fps >= state.settings.fps then + timeskip_deficit = 0.0 + return + end + + local desired_timeskip = get_desired_timeskip(real_fps, state.settings.fps) + timeskip_deficit + local timeskip = math.max(0, clamp_timeskip(desired_timeskip)) + + -- don't let our deficit grow unbounded if we can never catch up + timeskip_deficit = math.min(desired_timeskip - timeskip, 100.0) + + if DEBUG >= 2 then + print(('cur_year_tick: %d, real_fps: %d, timeskip: (%d, +%.2f)'):format( + df.global.cur_year_tick, real_fps, timeskip, timeskip_deficit)) + end + if timeskip <= 0 then return end + + df.global.cur_year_tick = df.global.cur_year_tick + timeskip + df.global.cur_year_tick_advmode = df.global.cur_year_tick_advmode + timeskip*144 + + adjust_units(timeskip) + adjust_activities(timeskip) +end + +------------------------------------ +-- hook management + +local function do_enable() + reset_ephemeral_state() + eventful.enableEvent(eventful.eventType.UNIT_NEW_ACTIVE, 10) + eventful.onUnitNewActive[GLOBAL_KEY] = check_new_unit + state.enabled = true + repeatutil.scheduleEvery(GLOBAL_KEY, 1, 'ticks', on_tick) +end + +local function do_disable() + state.enabled = false + eventful.onUnitNewActive[GLOBAL_KEY] = nil + repeatutil.cancel(GLOBAL_KEY) +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)) + if state.enabled then + do_enable() + end +end + +------------------------------------ +-- interface + +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 function print_status() + print(GLOBAL_KEY .. ' is ' .. (state.enabled and 'enabled' or 'not enabled')) + print() + print('settings:') + for _,v in ipairs(SETTINGS) do + print((' %15s: %s'):format(v.name, state.settings[v.internal_name or v.name])) + end + if DEBUG < 2 then return end + print() + print(('cur_year_tick: %d'):format(df.global.cur_year_tick)) + print(('timeskip_deficit: %.2f'):format(timeskip_deficit)) + if DEBUG < 3 then return end + print() + print('tick coverage:') + for coverage_slot=0,49 do + print((' slot %2d: %scovered'):format(coverage_slot, tick_coverage[coverage_slot] and '' or 'NOT ')) + end + print() + local bdays, bdays_list = {}, {} + for _, next_bday in pairs(birthday_triggers) do + if not bdays[next_bday] then + bdays[next_bday] = true + table.insert(bdays_list, next_bday) end end + print(('%d birthdays:'):format(#bdays_list)) + table.sort(bdays_list) + for _,bday in ipairs(bdays_list) do + print((' year tick: %d'):format(bday)) + end end -function reset_frame_count() - if debug_mode then - print("Resetting frame count") +local function do_set(setting_name, arg) + if not setting_name or not arg then + qerror('must specify setting and value') end - prev_time = df.global.enabler.clock - prev_frames = df.global.world.frame_counter + local _, setting = utils.linear_index(SETTINGS, setting_name, 'name') + if not setting then + qerror('setting not found: ' .. setting_name) + end + state.settings[setting.internal_name or setting.name] = setting.validate(arg) + print(('set %s to %s'):format(setting_name, state.settings[setting.internal_name or setting.name])) +end + +local function do_reset() + state = get_default_state() end ---Initial call +local args = {...} +local command = table.remove(args, 1) -if dfhack.isMapLoaded() then - dfhack.onStateChange.loadTimestream(SC_MAP_LOADED) +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/toggle-kbd-cursor.lua b/toggle-kbd-cursor.lua index b8bfd1ec37..ae66b1900d 100644 --- a/toggle-kbd-cursor.lua +++ b/toggle-kbd-cursor.lua @@ -1,12 +1,13 @@ local guidm = require('gui.dwarfmode') -local flags4 = df.global.d_init.flags4 +local flags = df.global.d_init.feature.flags -if flags4.KEYBOARD_CURSOR then - flags4.KEYBOARD_CURSOR = false +if flags.KEYBOARD_CURSOR then + flags.KEYBOARD_CURSOR = false + guidm.setCursorPos(xyz2pos(-30000, -30000, -30000)) print('Keyboard cursor disabled.') else guidm.setCursorPos(guidm.Viewport.get():getCenter()) - flags4.KEYBOARD_CURSOR = true + flags.KEYBOARD_CURSOR = true print('Keyboard cursor enabled.') end diff --git a/trackstop.lua b/trackstop.lua new file mode 100644 index 0000000000..bef89b9fd3 --- /dev/null +++ b/trackstop.lua @@ -0,0 +1,239 @@ +-- Overlay to allow changing track stop friction and dump direction after construction +--@ module = true + +if not dfhack_flags.module then + qerror('trackstop cannot be called directly') +end + +local gui = require('gui') +local widgets = require('gui.widgets') +local overlay = require('plugins.overlay') +local utils = require('utils') + +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' +local HIGH = 'High' +local HIGHER = 'Higher' +local MAX = 'Max' + +local NONE = 'None' + +local FRICTION_MAP = { + [NONE] = 10, + [LOW] = 50, + [MEDIUM] = 500, + [HIGH] = 10000, + [MAX] = 50000, +} + +local FRICTION_MAP_REVERSE = utils.invert(FRICTION_MAP) + +local SPEED_MAP = { + [LOW] = 10000, + [MEDIUM] = 20000, + [HIGH] = 30000, + [HIGHER] = 40000, + [MAX] = 50000, +} + +local SPEED_MAP_REVERSE = utils.invert(SPEED_MAP) + +local DIRECTION_MAP = { + [NORTH] = df.screw_pump_direction.FromSouth, + [EAST] = df.screw_pump_direction.FromWest, + [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{ + 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}, + frame_style=gui.MEDIUM_FRAME, + frame_background=gui.CLEAR_PEN, +} + +function TrackStopOverlay:setFriction(friction) + dfhack.gui.getSelectedBuilding().track_stop_info.friction = FRICTION_MAP[friction] +end + +function TrackStopOverlay:getDumpDirection() + local track_stop_info = dfhack.gui.getSelectedBuilding().track_stop_info + local use_dump = track_stop_info.track_flags.use_dump + local dump_x_shift = track_stop_info.dump_x_shift + local dump_y_shift = track_stop_info.dump_y_shift + + if not use_dump then + return NONE + else + if dump_x_shift == 0 and dump_y_shift == -1 then + return NORTH + elseif dump_x_shift == 1 and dump_y_shift == 0 then + return EAST + elseif dump_x_shift == 0 and dump_y_shift == 1 then + return SOUTH + elseif dump_x_shift == -1 and dump_y_shift == 0 then + return WEST + end + end +end + +function TrackStopOverlay:setDumpDirection(direction) + local track_stop_info = dfhack.gui.getSelectedBuilding().track_stop_info + + if direction == NONE then + track_stop_info.track_flags.use_dump = false + track_stop_info.dump_x_shift = 0 + track_stop_info.dump_y_shift = 0 + elseif direction == NORTH then + track_stop_info.track_flags.use_dump = true + track_stop_info.dump_x_shift = 0 + track_stop_info.dump_y_shift = -1 + elseif direction == EAST then + track_stop_info.track_flags.use_dump = true + track_stop_info.dump_x_shift = 1 + track_stop_info.dump_y_shift = 0 + elseif direction == SOUTH then + track_stop_info.track_flags.use_dump = true + track_stop_info.dump_x_shift = 0 + track_stop_info.dump_y_shift = 1 + elseif direction == WEST then + track_stop_info.track_flags.use_dump = true + track_stop_info.dump_x_shift = -1 + track_stop_info.dump_y_shift = 0 + end +end + +function TrackStopOverlay:render(dc) + local friction_cycle = self.subviews.friction + local friction = dfhack.gui.getSelectedBuilding().track_stop_info.friction + + friction_cycle:setOption(FRICTION_MAP_REVERSE[friction]) + + self.subviews.dump_direction:setOption(self:getDumpDirection()) + + TrackStopOverlay.super.render(self, dc) +end + +function TrackStopOverlay:init() + self:addviews{ + widgets.CycleHotkeyLabel{ + frame={t=0, l=0}, + label='Dump', + key='CUSTOM_CTRL_X', + options={ + {label=NONE, value=NONE, pen=COLOR_BLUE}, + NORTH, + EAST, + SOUTH, + WEST, + }, + view_id='dump_direction', + on_change=function(val) self:setDumpDirection(val) end, + }, + widgets.CycleHotkeyLabel{ + label='Friction', + frame={t=1, l=0}, + key='CUSTOM_CTRL_F', + options={ + {label=NONE, value=NONE, pen=COLOR_BLUE}, + {label=LOW, value=LOW, pen=COLOR_GREEN}, + {label=MEDIUM, value=MEDIUM, pen=COLOR_YELLOW}, + {label=HIGH, value=HIGH, pen=COLOR_LIGHTRED}, + {label=MAX, value=MAX, pen=COLOR_RED}, + }, + view_id='friction', + on_change=function(val) self:setFriction(val) end, + }, + } +end + +RollerOverlay = defclass(RollerOverlay, overlay.OverlayWidget) +RollerOverlay.ATTRS{ + 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}, + frame_style=gui.MEDIUM_FRAME, + 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] +end + +function RollerOverlay:setSpeed(speed) + local building = dfhack.gui.getSelectedBuilding() + + building.speed = SPEED_MAP[speed] +end + +function RollerOverlay:render(dc) + local building = dfhack.gui.getSelectedBuilding() + + self.subviews.direction:setOption(DIRECTION_MAP_REVERSE[building.direction]) + self.subviews.speed:setOption(SPEED_MAP_REVERSE[building.speed]) + + TrackStopOverlay.super.render(self, dc) +end + +function RollerOverlay:init() + self:addviews{ + widgets.CycleHotkeyLabel{ + label='Direction', + frame={t=0, l=0}, + key='CUSTOM_CTRL_X', + options={NORTH, EAST, SOUTH, WEST}, + view_id='direction', + on_change=function(val) self:setDirection(val) end, + }, + widgets.CycleHotkeyLabel{ + label='Speed', + frame={t=1, l=0}, + key='CUSTOM_CTRL_F', + options={ + {label=LOW, value=LOW, pen=COLOR_BLUE}, + {label=MEDIUM, value=MEDIUM, pen=COLOR_GREEN}, + {label=HIGH, value=HIGH, pen=COLOR_YELLOW}, + {label=HIGHER, value=HIGHER, pen=COLOR_LIGHTRED}, + {label=MAX, value=MAX, pen=COLOR_RED}, + }, + view_id='speed', + on_change=function(val) self:setSpeed(val) end, + }, + } +end + +OVERLAY_WIDGETS = { + trackstop=TrackStopOverlay, + rollers=RollerOverlay, +} 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/unforbid.lua b/unforbid.lua index 38667602c1..286218a211 100644 --- a/unforbid.lua +++ b/unforbid.lua @@ -2,12 +2,14 @@ local argparse = require('argparse') -local function unforbid_all(include_unreachable, quiet) - if not quiet then print('Unforbidding all items...') end +local function unforbid_all(include_unreachable, quiet, include_worn) + local p + if quiet then p=function(s) return end; else p=function(s) return print(s) end; end + p('Unforbidding all items...') local citizens = dfhack.units.getCitizens(true) local count = 0 - for _, item in pairs(df.global.world.items.all) do + for _, item in pairs(df.global.world.items.other.IN_PLAY) do if item.flags.forbid then if not include_unreachable then local reachable = false @@ -19,12 +21,17 @@ local function unforbid_all(include_unreachable, quiet) end if not reachable then - if not quiet then print((' unreachable: %s (skipping)'):format(item)) end + p((' unreachable: %s (skipping)'):format(item)) goto skipitem end end - if not quiet then print((' unforbid: %s'):format(item)) end + if ((not include_worn) and item.wear >= 2) then + p((' worn: %s (skipping)'):format(item)) + goto skipitem + end + + p((' unforbid: %s'):format(item)) item.flags.forbid = false count = count + 1 @@ -32,7 +39,7 @@ local function unforbid_all(include_unreachable, quiet) end end - if not quiet then print(('%d items unforbidden'):format(count)) end + p(('%d items unforbidden'):format(count)) end -- let the common --help parameter work, even though it's undocumented @@ -40,11 +47,13 @@ local options, args = { help = false, quiet = false, include_unreachable = false, -}, { ... } + include_worn = false +}, {...} local positionals = argparse.processArgsGetopt(args, { { 'h', 'help', handler = function() options.help = true end }, { 'q', 'quiet', handler = function() options.quiet = true end }, + { 'X', 'include-worn', handler = function() options.include_worn = true end}, { 'u', 'include-unreachable', handler = function() options.include_unreachable = true end }, }) @@ -54,5 +63,5 @@ if positionals[1] == nil or positionals[1] == 'help' or options.help then end if positionals[1] == 'all' then - unforbid_all(options.include_unreachable, options.quiet) + unforbid_all(options.include_unreachable, options.quiet, options.include_worn) end diff --git a/uniform-unstick.lua b/uniform-unstick.lua index 469686e2a2..af86aa8006 100644 --- a/uniform-unstick.lua +++ b/uniform-unstick.lua @@ -1,279 +1,389 @@ --- Prompt units to adjust their uniform. -local help = [====[ - -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. - -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. - -In no cases should the command cause a uniform item that is being properly worn to be removed/dropped. - -Targets: - -:(no target): Force the selected dwarf to put on their uniform. -:-all: Force the uniform on all military dwarves. - -Options: - -:(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. -]====] +--@ module=true +local gui = require('gui') +local overlay = require('plugins.overlay') local utils = require('utils') +local widgets = require('gui.widgets') local validArgs = utils.invert({ - 'all', - 'drop', - 'free', - 'multi', - 'help' + 'all', + 'drop', + 'free', + 'multi', + 'help' }) -- Functions -function item_description(item) - return dfhack.df2console( dfhack.items.getDescription(item, 0, true) ) +local function item_description(item) + return dfhack.df2console(dfhack.items.getDescription(item, 0, true)) 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) -end +local function get_item_pos(item) + local x, y, z = dfhack.items.getPosition(item) + if not x or not y or not z then + return + 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 + if dfhack.maps.isTileVisible(x, y, z) then + return xyz2pos(x, y, z) end - end - return nil end -function bodyparts_that_can_wear(unit, item) - - local bodyparts = {} - local unitparts = df.creature_raw.find(unit.race).caste[unit.caste].body_info.body_parts - - if item._type == df.item_helmst then - for index, part in pairs(unitparts) do - if part.flags.HEAD then - table.insert(bodyparts, index) - 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 +local function get_squad_position(unit, unit_name) + local squad = df.squad.find(unit.military.squad_id) + if squad then + if squad.entity_id ~= df.global.plotinfo.group_id then + print("WARNING: Unit " .. unit_name .. " 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 + else + return 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 + if #squad.positions > unit.military.squad_position then + return squad.positions[unit.military.squad_position] 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 +end + +local function bodyparts_that_can_wear(unit, item) + local bodyparts = {} + local unitparts = dfhack.units.getCasteRaw(unit).body_info.body_parts + + if item._type == df.item_helmst then + for index, part in ipairs(unitparts) do + if part.flags.HEAD then + table.insert(bodyparts, index) + end + end + elseif item._type == df.item_armorst then + for index, part in ipairs(unitparts) do + if part.flags.UPPERBODY then + table.insert(bodyparts, index) + end + end + elseif item._type == df.item_glovesst then + for index, part in ipairs(unitparts) do + if part.flags.GRASP then + table.insert(bodyparts, index) + end + end + elseif item._type == df.item_pantsst then + for index, part in ipairs(unitparts) do + if part.flags.LOWERBODY then + table.insert(bodyparts, index) + end + end + elseif item._type == df.item_shoesst then + for index, part in ipairs(unitparts) do + if part.flags.STANCE then + table.insert(bodyparts, index) + end + end + else + -- print("Ignoring item type for "..item_description(item) ) 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 + + return bodyparts +end + +-- returns new value of need_newline +local function print_line(text, need_newline) + if need_newline then + print() end - else - -- print("Ignoring item type for "..item_description(item) ) - end + print(text) + return false +end - return bodyparts +local function print_bad_labor(unit_name, labor_name, need_newline) + return print_line("WARNING: Unit " .. unit_name .. " has the " .. labor_name .. + " labor enabled, which conflicts with military uniforms.", need_newline) 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) ) ) +local function process(unit, args, need_newline) + 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))) - if not silent then - print("Processing unit "..unit_name) - end + if not silent then + need_newline = print_line("Processing unit " .. unit_name, need_newline) + end - -- The return value - local to_drop = {} -- item id to item object + -- 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 = find_squad_position(unit) - if squad_position == nil then - if not silent then - print("Unit "..unit_name.." does not have a military uniform.") + -- First get squad position for an early-out for non-military dwarves + local squad_position = get_squad_position(unit, unit_name) + if not squad_position then + if not silent then + need_newline = print_line(unit_name .. " does not have a military uniform.", need_newline) + end + 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 + + if unit.status.labors.MINE then + need_newline = print_bad_labor(unit_name, "mining", need_newline) + elseif unit.status.labors.CUTWOOD then + need_newline = print_bad_labor(unit_name, "woodcutting", need_newline) + elseif unit.status.labors.HUNT then + need_newline = print_bad_labor(unit_name, "hunting", need_newline) 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 + + -- 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.unit_inventory_item.T_mode.Worn or + inv_item.mode == df.unit_inventory_item.T_mode.Weapon or + inv_item.mode == df.unit_inventory_item.T_mode.Strapped + then + worn_items[item.id] = item + worn_parts[item.id] = inv_item.body_part_id + end end - end - -- Figure out which worn items should be dropped + -- 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 _, specs in ipairs(squad_position.equipment.uniform) do + for _, spec in ipairs(specs) do + for _, assigned in ipairs(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 + end + end - -- 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 + -- 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 u_id, item in pairs(assigned_items) do + if not worn_items[u_id] then + if not silent then + need_newline = print_line(unit_name .. " is missing an assigned item, object #" .. u_id .. " '" .. + item_description(item) .. "'", need_newline) + end + if dfhack.items.getGeneralRef(item, df.general_ref_type.UNIT_HOLDER) then + need_newline = print_line(unit_name .. " cannot equip item: another unit has a claim on object #" .. u_id .. " '" .. item_description(item) .. "'", need_newline) + if args.free then + print(" Removing from uniform") + assigned_items[u_id] = nil + for _, specs in ipairs(squad_position.equipment.uniform) do + for _, spec in ipairs(specs) do + for idx, assigned in ipairs(spec.assigned) do + if assigned == u_id then + spec.assigned:erase(idx) + break + end + end + end + end + end + else + missing_ids[u_id] = item + if args.free then + to_drop[u_id] = item + end + end + else + present_ids[u_id] = item + end 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 + + -- 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 id, item in pairs(present_ids) do + -- weapons and shields don't "cover" the bodypart they're assigned to. (Needed to figure out if we're missing gloves.) + if item._type ~= df.item_weaponst and item._type ~= df.item_shieldst then + covered[worn_parts[id]] = true + end + end 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 + + -- 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 + + -- 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 + need_newline = print_line(unit_name .. + " potentially has object #" .. + w_id .. " '" .. item_description(item) .. "' blocking a missing uniform item.", need_newline) + if args.drop then + to_drop[w_id] = item + end + end end - end end - end - return to_drop + return to_drop end +local function do_drop(item_list) + if not item_list then + return + end + + for id, item in pairs(item_list) do + local pos = get_item_pos(item) + if not pos then + dfhack.printerr("Could not find drop location for item #" .. id .. " " .. item_description(item)) + else + if dfhack.items.moveToGround(item, pos) then + print("Dropped item #" .. id .. " '" .. item_description(item) .. "'") + else + dfhack.printerr("Could not drop object #" .. id .. " " .. item_description(item)) + end + end + end +end -function do_drop( item_list ) - if item_list == nil then - return nil - end +local function main(args) + args = utils.processArgs(args, validArgs) - 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 + if args.help then + print(dfhack.script_help()) + return + 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)) + if args.all then + local need_newline = false + for _, unit in ipairs(dfhack.units.getCitizens(true)) do + do_drop(process(unit, args, need_newline)) + need_newline = true + end 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 + 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 - - if mode_swap then - df.global.plotinfo.main.mode = df.ui_sidebar_mode.ViewUnits - end end +ReportWindow = defclass(ReportWindow, widgets.Window) +ReportWindow.ATTRS { + frame_title='Equipment conflict report', + frame={w=100, h=45}, + resizable=true, -- if resizing makes sense for your dialog + resize_min={w=50, h=20}, -- try to allow users to shrink your windows + autoarrange_subviews=1, + autoarrange_gap=1, + report=DEFAULT_NIL, +} + +function ReportWindow:init() + self:addviews{ + widgets.HotkeyLabel{ + frame={t=0, l=0, r=0}, + label='Try to resolve conflicts', + key='CUSTOM_CTRL_T', + auto_width=true, + on_activate=function() + dfhack.run_script('uniform-unstick', '--all', '--drop', '--free') + self.parent_view:dismiss() + end, + }, + widgets.WrappedLabel{ + frame={t=2, l=0, r=0}, + text_pen=COLOR_LIGHTRED, + text_to_wrap='After resolving conflicts, be sure to click the "Update equipment" button to reassign new equipment!', + }, + widgets.WrappedLabel{ + frame={t=4, l=0, r=0}, + text_to_wrap=self.report, + }, + } +end --- Main +ReportScreen = defclass(ReportScreen, gui.ZScreenModal) +ReportScreen.ATTRS { + focus_path='equipreport', + report=DEFAULT_NIL, +} -local args = utils.processArgs({...}, validArgs) +function ReportScreen:init() + self:addviews{ReportWindow{report=self.report}} +end -if args.help then - print(help) - return +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=21}, + default_enabled=true, + viewscreens='dwarfmode/SquadEquipment/Default', + frame={w=MIN_WIDTH, h=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 -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 ) +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 + ReportScreen{report=output}:show() end - end -else - local unit=dfhack.gui.getSelectedUnit() - if unit then - local to_drop = process(unit,args) - do_drop( to_drop ) - end end + +function EquipOverlay:preUpdateLayout(parent_rect) + self.frame.w = math.max(0, parent_rect.width - 133) + MIN_WIDTH +end + +OVERLAY_WIDGETS = {overlay=EquipOverlay} + +if dfhack_flags.module then + return +end + +main({...}) diff --git a/unretire-anyone.lua b/unretire-anyone.lua index 170c82a812..0e79fccaba 100644 --- a/unretire-anyone.lua +++ b/unretire-anyone.lua @@ -1,75 +1,97 @@ 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() +local viewscreen = dfhack.gui.getDFViewscreen(true) if viewscreen._type ~= df.viewscreen_setupadventurest then - qerror("This script can only be used during adventure mode setup!") + qerror("This script can only be used during adventure mode setup!") end --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 +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 - end - if not unretireOption then - advSetUpScreen.race_ids:insert('#', -2) - end + if not unretireOption then + advSetUpScreen.valid_race:insert('#', -2) + end - nemesis.flags.ADVENTURER = true - advSetUpScreen.nemesis_ids:insert('#', nemesis.id) + -- 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 + -- 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 - name = name .. ' ' .. curse.name - end - if curse.undead_name ~= '' then - name = curse.undead_name .. " - reanimated " .. name - end + local choices = {} + for i, 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 = dfhack.units.getCasteRaw(histFig.race, histFig.caste) + local name = creature.caste_name[0] + 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 .. ')' + end + if histFig.name.has_name then + name = name .. + '\n' .. dfhack.TranslateName(histFig.name) .. + '\n"' .. dfhack.TranslateName(histFig.name, true) .. '"' + else + name = name .. + '\nUnnamed' + end + table.insert(choices, { text = name, nemesis = nemesis, search_key = name:lower(), idx = i }) + 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 .. ')' - end - if histFig.name.has_name then - name = dfhack.TranslateName(histFig.name) .. " - (" .. dfhack.TranslateName(histFig.name, true).. ") - " .. name - end - table.insert(choices, {text = name, nemesis = nemesis, search_key = name:lower()}) - end 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 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 81bdef5de4..239c4b335d 100644 --- a/warn-stranded.lua +++ b/warn-stranded.lua @@ -1,44 +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 utils = require 'utils' -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 --- Taken from warn-starving +-- ==================================== +-- 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 + +-- ============================= +-- 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.TranslateName(dfhack.units.getVisibleName(unit)), + getSexString(unit.sex)) end -- Use group data, index, and command arguments to generate a group @@ -59,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 @@ -158,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}, @@ -180,7 +230,7 @@ function WarningWindow:init(info) }, widgets.WrappedLabel{ frame={b=3, l=0}, - text_to_wrap='Double click to toggle unit ignore. Shift double click to toggle a group.', + text_to_wrap='Select to zoom to unit. Double click to toggle unit ignore. Shift double click to toggle a group.', }, widgets.HotkeyLabel{ frame={b=1, l=0}, @@ -213,7 +263,6 @@ function WarningWindow:init(info) }, } - self.groups = info.groups self:initListChoices() end @@ -223,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,25 +306,30 @@ function WarningWindow:onClear() end function WarningWindow:onZoom() - local index, choice = self.subviews.list:getSelected() - local unit = choice.data['unit'] + local _, choice = self.subviews.list:getSelected() + local unit = choice.data.unit local target = xyz2pos(dfhack.units.getPosition(unit)) - dfhack.gui.revealInDwarfmodeMap(target, 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() @@ -295,96 +340,48 @@ end -- Core Logic -- ====================================================================== -local function compareGroups(group_one, group_two) - return #group_one['units'] < #group_two['units'] -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 target = xyz2pos(dfhack.units.getPosition(unit)) - local block = dfhack.maps.getTileBlock(target) - local walkGroup = block and block.walkable[target.x % 16][target.y % 16] or 0 - - 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 @@ -392,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) @@ -415,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 @@ -436,22 +433,14 @@ local function unignoreGroup(groups, groupNumber) return true end -function doCheck() - local result, strandedGroups = getStrandedUnits() - - if result then - return WarningScreen{groups=strandedGroups}:show() - end -end +local function doCheck() + local groupList, ignoredUnitsByWalkGroup, mainGroup = getStrandedGroups() --- 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 + if #groupList > 0 then + return WarningScreen{ + groups=getStrandedGroupsWithIgnored(groupList, ignoredUnitsByWalkGroup, mainGroup), + }:show() end - - loadIgnoredUnits() end if dfhack_flags.module then @@ -466,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..c72ec99981 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 @@ -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 @@ -348,7 +348,7 @@ local function create_orders(orders) 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 @@ -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)(...)