local _ENV = mkmodule('plugins.buildingplan') --[[ Native functions: * bool isPlannableBuilding(df::building_type type, int16_t subtype, int32_t custom) * bool isPlannedBuilding(df::building *bld) * void addPlannedBuilding(df::building *bld) * void doCycle() * void scheduleCycle() --]] local argparse = require('argparse') local gui = require('gui') local guidm = require('gui.dwarfmode') local overlay = require('plugins.overlay') local utils = require('utils') local widgets = require('gui.widgets') require('dfhack.buildings') local function process_args(opts, args) if args[1] == 'help' then opts.help = true return end return argparse.processArgsGetopt(args, { {'h', 'help', handler=function() opts.help = true end}, }) end function parse_commandline(...) local args, opts = {...}, {} local positionals = process_args(opts, args) if opts.help then return false end local command = table.remove(positionals, 1) if not command or command == 'status' then printStatus() elseif command == 'set' then setSetting(positionals[1], positionals[2] == 'true') else return false end return true end function get_num_filters(btype, subtype, custom) local filters = dfhack.buildings.getFiltersByType({}, btype, subtype, custom) return filters and #filters or 0 end -------------------------------- -- Planner Overlay -- local uibs = df.global.buildreq local function cur_building_has_no_area() if uibs.building_type == df.building_type.Construction then return false end local filters = dfhack.buildings.getFiltersByType({}, uibs.building_type, uibs.building_subtype, uibs.custom_type) -- this works because all variable-size buildings have either no item -- filters or a quantity of -1 for their first (and only) item return filters and filters[1] and (not filters[1].quantity or filters[1].quantity > 0) end local function is_choosing_area() return uibs.selection_pos.x >= 0 end local function get_cur_area_dims() if not is_choosing_area() then return 1, 1, 1 end return math.abs(uibs.selection_pos.x - uibs.pos.x) + 1, math.abs(uibs.selection_pos.y - uibs.pos.y) + 1, math.abs(uibs.selection_pos.z - uibs.pos.z) + 1 end local function get_cur_filters() return dfhack.buildings.getFiltersByType({}, uibs.building_type, uibs.building_subtype, uibs.custom_type) end local function is_plannable() return get_cur_filters() and not (uibs.building_type == df.building_type.Construction and uibs.building_subtype == df.construction_type.TrackNSEW) end local function is_stairs() return uibs.building_type == df.building_type.Construction and uibs.building_subtype == df.construction_type.UpDownStair end local direction_panel_frame = {t=4, h=13, w=46, r=28} local direction_panel_types = utils.invert{ df.building_type.Bridge, df.building_type.ScrewPump, df.building_type.WaterWheel, df.building_type.AxleHorizontal, df.building_type.Rollers, } local function has_direction_panel() return direction_panel_types[uibs.building_type] or (uibs.building_type == df.building_type.Trap and uibs.building_subtype == df.trap_type.TrackStop) end local pressure_plate_panel_frame = {t=4, h=37, w=46, r=28} local function has_pressure_plate_panel() return uibs.building_type == df.building_type.Trap and uibs.building_subtype == df.trap_type.PressurePlate end local function is_over_options_panel() local frame = nil if has_direction_panel() then frame = direction_panel_frame elseif has_pressure_plate_panel() then frame = pressure_plate_panel_frame else return false end local v = widgets.Widget{frame=frame} local rect = gui.mkdims_wh(0, 0, dfhack.screen.getWindowSize()) v:updateLayout(gui.ViewRect{rect=rect}) return v:getMousePos() 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 function get_item_line_text(idx) local filter = get_cur_filters()[idx] local desc = 'Unknown' if filter.has_tool_use then desc = to_title_case(df.tool_uses[filter.has_tool_use]) end if filter.item_type then desc = to_title_case(df.item_type[filter.item_type]) end if filter.flags2 and filter.flags2.building_material then desc = "Generic material"; if filter.flags2.fire_safe then desc = "Fire-safe material"; end if filter.flags2.magma_safe then desc = "Magma-safe material"; end elseif filter.vector_id then desc = to_title_case(df.job_item_vector_id[filter.vector_id]) end if desc:endswith('s') then desc = desc:sub(1,-2) end if desc == 'Trappart' then desc = 'Mechanism' end local quantity = filter.quantity or 1 local dimx, dimy, dimz = get_cur_area_dims() if quantity < 1 then quantity = (((dimx * dimy) // 4) + 1) * dimz else quantity = quantity * dimx * dimy * dimz end desc = ('%d %s%s'):format(quantity, desc, quantity == 1 and '' or 's') local available = countAvailableItems(uibs.building_type, uibs.building_subtype, uibs.custom_type, idx - 1) local note = available >= quantity and 'Can build now' or 'Will wait for item' return ('%-21s%s%s'):format(desc:sub(1,21), (' '):rep(13), note) end ItemLine = defclass(ItemLine, widgets.Panel) ItemLine.ATTRS{ idx=DEFAULT_NIL, } function ItemLine:init() self.frame.h = 1 self.visible = function() return #get_cur_filters() >= self.idx end self:addviews{ widgets.Label{ frame={t=0, l=0}, text={{text=function() return get_item_line_text(self.idx) end}}, }, widgets.Label{ frame={t=0, l=22}, text='[filter][x]', }, } end PlannerOverlay = defclass(PlannerOverlay, overlay.OverlayWidget) PlannerOverlay.ATTRS{ default_pos={x=6,y=9}, default_enabled=true, viewscreens='dwarfmode/Building/Placement', frame={w=54, h=9}, frame_style=gui.MEDIUM_FRAME, frame_background=gui.CLEAR_PEN, } function PlannerOverlay:init() self:addviews{ widgets.Label{ frame={}, auto_width=true, text='No items required.', visible=function() return #get_cur_filters() == 0 end, }, ItemLine{frame={t=0, l=0}, idx=1}, ItemLine{frame={t=2, l=0}, idx=2}, ItemLine{frame={t=4, l=0}, idx=3}, ItemLine{frame={t=6, l=0}, idx=4}, widgets.CycleHotkeyLabel{ view_id="stairs_top_subtype", frame={t=3, l=0}, key="CUSTOM_R", label="Top Stair Type: ", visible=is_stairs, options={ {label='Auto', value='auto'}, {label='UpDown', value=df.construction_type.UpDownStair}, {label='Down', value=df.construction_type.DownStair}, }, }, widgets.CycleHotkeyLabel { view_id="stairs_bottom_subtype", frame={t=4, l=0}, key="CUSTOM_B", label="Bottom Stair Type: ", visible=is_stairs, options={ {label='Auto', value='auto'}, {label='UpDown', value=df.construction_type.UpDownStair}, {label='Up', value=df.construction_type.UpStair}, }, }, widgets.Label{ frame={b=0, l=17}, text={ 'Selected area: ', {text=function() return ('%dx%dx%d'):format(get_cur_area_dims()) end }, }, visible=is_choosing_area, }, } end function PlannerOverlay:do_config() dfhack.run_script('gui/buildingplan') end function PlannerOverlay:onInput(keys) if not is_plannable() then return false end if keys.LEAVESCREEN or keys._MOUSE_R_DOWN then return false end if PlannerOverlay.super.onInput(self, keys) then return true end if keys._MOUSE_L_DOWN then if is_over_options_panel() then return false end if self:getMouseFramePos() then return true end if #uibs.errors > 0 then return true end local pos = dfhack.gui.getMousePos() if pos then if is_choosing_area() or cur_building_has_no_area() then if #get_cur_filters() == 0 then return false -- we don't add value; let the game place it end self:place_building() uibs.selection_pos:clear() return true elseif not is_choosing_area() then return false end end end return keys._MOUSE_L end function PlannerOverlay:render(dc) if not is_plannable() then return end PlannerOverlay.super.render(self, dc) end local to_pen = dfhack.pen.parse local GOOD_PEN = to_pen{ch='o', 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)} function PlannerOverlay:onRenderFrame(dc, rect) PlannerOverlay.super.onRenderFrame(self, dc, rect) if not is_choosing_area() then return end local bounds = { x1 = math.min(uibs.selection_pos.x, uibs.pos.x), x2 = math.max(uibs.selection_pos.x, uibs.pos.x), y1 = math.min(uibs.selection_pos.y, uibs.pos.y), y2 = math.max(uibs.selection_pos.y, uibs.pos.y), } local pen = #uibs.errors > 0 and BAD_PEN or GOOD_PEN local function get_overlay_pen(pos) return pen end guidm.renderMapOverlay(get_overlay_pen, bounds) end function PlannerOverlay:place_building() local direction = uibs.direction local width, height, depth = get_cur_area_dims() local _, adjusted_width, adjusted_height = dfhack.buildings.getCorrectSize( width, height, uibs.building_type, uibs.building_subtype, uibs.custom_type, direction) -- get the upper-left corner of the building/area at min z-level local has_selection = is_choosing_area() local start_pos = xyz2pos( has_selection and math.min(uibs.selection_pos.x, uibs.pos.x) or uibs.pos.x - adjusted_width//2, has_selection and math.min(uibs.selection_pos.y, uibs.pos.y) or uibs.pos.y - adjusted_height//2, has_selection and math.min(uibs.selection_pos.z, uibs.pos.z) or uibs.pos.z ) if uibs.building_type == df.building_type.ScrewPump then if direction == df.screw_pump_direction.FromSouth then start_pos.y = start_pos.y + 1 elseif direction == df.screw_pump_direction.FromEast then start_pos.x = start_pos.x + 1 end end local min_x, max_x = start_pos.x, start_pos.x local min_y, max_y = start_pos.y, start_pos.y local min_z, max_z = start_pos.z, start_pos.z if adjusted_width == 1 and adjusted_height == 1 and (width > 1 or height > 1 or depth > 1) then max_x = min_x + width - 1 max_y = min_y + height - 1 max_z = math.max(uibs.selection_pos.z, uibs.pos.z) end local blds = {} local subtype = uibs.building_subtype for z=min_z,max_z do for y=min_y,max_y do for x=min_x,max_x do local pos = xyz2pos(x, y, z) if is_stairs() then if z == min_z then subtype = self.subviews.stairs_bottom_subtype:getOptionValue() if subtype == 'auto' then local tt = dfhack.maps.getTileType(pos) local shape = df.tiletype.attrs[tt].shape if shape == df.tiletype_shape.STAIR_DOWN then subtype = uibs.building_subtype else subtype = df.construction_type.UpStair end end elseif z == max_z then subtype = self.subviews.stairs_top_subtype:getOptionValue() if subtype == 'auto' then local tt = dfhack.maps.getTileType(pos) local shape = df.tiletype.attrs[tt].shape if shape == df.tiletype_shape.STAIR_UP then subtype = uibs.building_subtype else subtype = df.construction_type.DownStair end end else subtype = uibs.building_subtype end end local bld, err = dfhack.buildings.constructBuilding{pos=pos, type=uibs.building_type, subtype=subtype, custom=uibs.custom_type, width=adjusted_width, height=adjusted_height, direction=direction} if err then for _,b in ipairs(blds) do dfhack.buildings.deconstruct(b) end dfhack.printerr(err .. (' (%d, %d, %d)'):format(pos.x, pos.y, pos.z)) return end -- assign fields for the types that need them. we can't pass them all in -- to the call to constructBuilding since attempting to assign unrelated -- fields to building types that don't support them causes errors. for k,v in pairs(bld) do if k == 'friction' then bld.friction = uibs.friction end if k == 'use_dump' then bld.use_dump = uibs.use_dump end if k == 'dump_x_shift' then bld.dump_x_shift = uibs.dump_x_shift end if k == 'dump_y_shift' then bld.dump_y_shift = uibs.dump_y_shift end if k == 'speed' then bld.speed = uibs.speed end end table.insert(blds, bld) end end end for _,bld in ipairs(blds) do addPlannedBuilding(bld) end scheduleCycle() end InspectorOverlay = defclass(InspectorOverlay, overlay.OverlayWidget) InspectorOverlay.ATTRS{ default_pos={x=-41,y=14}, default_enabled=true, viewscreens='dwarfmode/ViewSheets/BUILDING', frame={w=30, h=9}, frame_style=gui.MEDIUM_FRAME, frame_background=gui.CLEAR_PEN, } function InspectorOverlay:init() self:addviews{ widgets.Label{ frame={t=0, l=0}, text='Waiting for items:', }, widgets.Label{ frame={t=1, l=0}, text='item1', }, widgets.Label{ frame={t=2, l=0}, text='item2', }, widgets.Label{ frame={t=3, l=0}, text='item3', }, widgets.Label{ frame={t=4, l=0}, text='item4', }, widgets.HotkeyLabel{ frame={t=5, l=0}, label='adjust filters', key='CUSTOM_CTRL_F', }, widgets.HotkeyLabel{ frame={t=6, l=0}, label='make top priority', key='CUSTOM_CTRL_T', }, } end function InspectorOverlay:onInput(keys) if not isPlannedBuilding(dfhack.gui.getSelectedBuilding()) then return false end return InspectorOverlay.super.onInput(self, keys) end function InspectorOverlay:render(dc) if not isPlannedBuilding(dfhack.gui.getSelectedBuilding()) then return end InspectorOverlay.super.render(self, dc) end OVERLAY_WIDGETS = { planner=PlannerOverlay, inspector=InspectorOverlay, } local dialogs = require('gui.dialogs') local guidm = require('gui.dwarfmode') -- returns whether the items matched by the specified filter can have a quality -- rating. This also conveniently indicates whether an item can be decorated. -- does not need the core suspended -- reverse_idx is 0-based and is expected to be counted from the *last* filter function item_can_be_improved(btype, subtype, custom, reverse_idx) local filter = get_filter(btype, subtype, custom, reverse_idx) if filter.flags2 and filter.flags2.building_material then return false; end return filter.item_type ~= df.item_type.WOOD and filter.item_type ~= df.item_type.BLOCKS and filter.item_type ~= df.item_type.BAR and filter.item_type ~= df.item_type.BOULDER end -- -- GlobalSettings dialog -- local GlobalSettings = defclass(GlobalSettings, dialogs.MessageBox) GlobalSettings.focus_path = 'buildingplan_globalsettings' GlobalSettings.ATTRS{ settings = {} } function GlobalSettings:onDismiss() for k,v in pairs(self.settings) do -- call back into C++ to save changes setSetting(k, v) end end -- does not need the core suspended. function show_global_settings_dialog(settings) GlobalSettings{ frame_title="Buildingplan Global Settings", settings=settings, }:show() end function GlobalSettings:toggle_setting(name) self.settings[name] = not self.settings[name] end function GlobalSettings:get_setting_string(name) if self.settings[name] then return 'On' end return 'Off' end function GlobalSettings:get_setting_pen(name) if self.settings[name] then return COLOR_LIGHTGREEN end return COLOR_LIGHTRED end function GlobalSettings:is_setting_enabled(name) return self.settings[name] end function GlobalSettings:make_setting_label_token(text, key, name, width) return {text=text, key=key, key_sep=': ', key_pen=COLOR_LIGHTGREEN, on_activate=self:callback('toggle_setting', name), width=width} end function GlobalSettings:make_setting_value_token(name) return {text=self:callback('get_setting_string', name), enabled=self:callback('is_setting_enabled', name), pen=self:callback('get_setting_pen', name), dpen=COLOR_GRAY} end -- mockup: --[[ Buildingplan Global Settings e: Enable all: Off Enables buildingplan for all building types. Use this to avoid having to manually enable buildingplan for each building type that you want to plan. Note that DFHack quickfort will use buildingplan to manage buildings regardless of whether buildingplan is "enabled" for the building type. Allowed types for generic, fire-safe, and magma-safe building material: b: Blocks: On s: Boulders: On w: Wood: On r: Bars: Off Changes to these settings will be applied to newly-planned buildings. A: Apply building material filter settings to existing planned buildings Use this if your planned buildings can't be completed because the settings above were too restrictive when the buildings were originally planned. M: Edit list of materials to avoid potash pearlash ash coal Buildingplan will avoid using these material types when a planned building's material filter is set to 'any'. They can stil be matched when they are explicitly allowed by a planned building's material filter. Changes to this list take effect for existing buildings immediately. g: Allow bags: Off This allows bags to be placed where a 'coffer' is planned. f: Legacy Quickfort Mode: Off Compatibility mode for the legacy Python-based Quickfort application. This setting is not needed for DFHack quickfort. --]] function GlobalSettings:init() self.subviews.label:setText{ self:make_setting_label_token('Enable all', 'CUSTOM_E', 'all_enabled', 12), self:make_setting_value_token('all_enabled'), '\n', ' Enables buildingplan for all building types. Use this to avoid having\n', ' to manually enable buildingplan for each building type that you want\n', ' to plan. Note that DFHack quickfort will use buildingplan to manage\n', ' buildings regardless of whether buildingplan is "enabled" for the\n', ' building type.\n', '\n', 'Allowed types for generic, fire-safe, and magma-safe building material:\n', self:make_setting_label_token('Blocks', 'CUSTOM_B', 'blocks', 10), self:make_setting_value_token('blocks'), '\n', self:make_setting_label_token('Boulders', 'CUSTOM_S', 'boulders', 10), self:make_setting_value_token('boulders'), '\n', self:make_setting_label_token('Wood', 'CUSTOM_W', 'logs', 10), self:make_setting_value_token('logs'), '\n', self:make_setting_label_token('Bars', 'CUSTOM_R', 'bars', 10), self:make_setting_value_token('bars'), '\n', ' Changes to these settings will be applied to newly-planned buildings.\n', ' If no types are enabled above, then any building material is allowed.\n', '\n', self:make_setting_label_token('Legacy Quickfort Mode', 'CUSTOM_F', 'quickfort_mode', 23), self:make_setting_value_token('quickfort_mode'), '\n', ' Compatibility mode for the legacy Python-based Quickfort application.\n', ' This setting is not needed for DFHack quickfort.' } end return _ENV