|
| 1 | +-- AutoCeiling.lua |
| 2 | +-- Purpose: flood-fill the connected dug area on the cursor z-level (z0) |
| 3 | +-- and place constructed floors directly above (z0+1). When the buildingplan |
| 4 | +-- plugin is enabled, planned constructions are created. Otherwise we fall back |
| 5 | +-- to native construction designations so dwarves get immediate jobs. |
| 6 | +-- The script skips tiles that already have a player-made construction or |
| 7 | +-- any existing building at the target tile on z0+1. |
| 8 | + |
| 9 | +------------------------- |
| 10 | +-- Configuration defaults |
| 11 | +------------------------- |
| 12 | +local CONFIG = { |
| 13 | + MAX_FILL_TILES = 2000, -- positive integer; safety limit |
| 14 | + ALLOW_DIAGONALS = false, -- set true to allow 8-way fill |
| 15 | + MAX_LIMIT_HARD = 4000, -- hard clamp to avoid runaway fills |
| 16 | +} |
| 17 | + |
| 18 | +------------------------- |
| 19 | +-- Utilities and guards |
| 20 | +------------------------- |
| 21 | +local function err(msg) qerror('AutoCeiling: ' .. tostring(msg)) end |
| 22 | + |
| 23 | +local function xyz2pos(x, y, z) |
| 24 | + return { x = x, y = y, z = z } |
| 25 | +end |
| 26 | + |
| 27 | +-- Cache frequently used modules/tables for readability |
| 28 | +local maps = dfhack.maps |
| 29 | +local constructions = dfhack.constructions |
| 30 | +local buildings = dfhack.buildings |
| 31 | +local tattrs = df.tiletype.attrs |
| 32 | + |
| 33 | +------------------------- |
| 34 | +-- World and map helpers |
| 35 | +------------------------- |
| 36 | +local function in_bounds(x, y, z) |
| 37 | + return maps.isValidTilePos(x, y, z) |
| 38 | +end |
| 39 | + |
| 40 | +local function get_tiletype(x, y, z) |
| 41 | + return maps.getTileType(x, y, z) |
| 42 | +end |
| 43 | + |
| 44 | +local function tile_shape(tt) |
| 45 | + if not tt then return nil end |
| 46 | + local a = tattrs[tt] |
| 47 | + return (a and a.shape ~= df.tiletype_shape.NONE) and a.shape or nil |
| 48 | +end |
| 49 | + |
| 50 | +------------------------- |
| 51 | +-- Predicates |
| 52 | +------------------------- |
| 53 | +local function is_walkable_dug(tt) |
| 54 | + local s = tile_shape(tt) |
| 55 | + if not s then return false end |
| 56 | + return s == df.tiletype_shape.FLOOR |
| 57 | + or s == df.tiletype_shape.RAMP |
| 58 | + or s == df.tiletype_shape.STAIR_UP |
| 59 | + or s == df.tiletype_shape.STAIR_DOWN |
| 60 | + or s == df.tiletype_shape.STAIR_UPDOWN |
| 61 | + or s == df.tiletype_shape.EMPTY |
| 62 | +end |
| 63 | + |
| 64 | +local function is_constructed_tile(x, y, z) |
| 65 | + return constructions.findAtTile(x, y, z) ~= nil |
| 66 | +end |
| 67 | + |
| 68 | +local function has_any_building(x, y, z) |
| 69 | + return buildings.findAtTile(xyz2pos(x, y, z)) ~= nil |
| 70 | +end |
| 71 | + |
| 72 | +------------------------- |
| 73 | +-- Flood fill |
| 74 | +------------------------- |
| 75 | +local function flood_fill_footprint(seed_x, seed_y, z0) |
| 76 | + local footprint = {} |
| 77 | + local visited = {} |
| 78 | + local queue = { { seed_x, seed_y } } |
| 79 | + visited[seed_x .. ',' .. seed_y] = true |
| 80 | + local queue_pos = 1 |
| 81 | + |
| 82 | + local function push_if_ok(x, y) |
| 83 | + if not in_bounds(x, y, z0) then return end |
| 84 | + local key = x .. ',' .. y |
| 85 | + if visited[key] then return end |
| 86 | + local tt = get_tiletype(x, y, z0) |
| 87 | + if is_walkable_dug(tt) then |
| 88 | + visited[key] = true |
| 89 | + table.insert(queue, { x, y }) |
| 90 | + end |
| 91 | + end |
| 92 | + |
| 93 | + while queue_pos <= #queue and #footprint < CONFIG.MAX_FILL_TILES do |
| 94 | + local x, y = table.unpack(queue[queue_pos]) |
| 95 | + queue_pos = queue_pos + 1 |
| 96 | + table.insert(footprint, { x = x, y = y }) |
| 97 | + push_if_ok(x + 1, y) |
| 98 | + push_if_ok(x - 1, y) |
| 99 | + push_if_ok(x, y + 1) |
| 100 | + push_if_ok(x, y - 1) |
| 101 | + if CONFIG.ALLOW_DIAGONALS then |
| 102 | + push_if_ok(x + 1, y + 1) |
| 103 | + push_if_ok(x + 1, y - 1) |
| 104 | + push_if_ok(x - 1, y + 1) |
| 105 | + push_if_ok(x - 1, y - 1) |
| 106 | + end |
| 107 | + end |
| 108 | + |
| 109 | + if #queue > CONFIG.MAX_FILL_TILES then |
| 110 | + dfhack.printerr(('AutoCeiling: flood fill truncated at %d tiles'):format(CONFIG.MAX_FILL_TILES)) |
| 111 | + end |
| 112 | + return footprint |
| 113 | +end |
| 114 | + |
| 115 | +------------------------- |
| 116 | +-- Placement strategies |
| 117 | +------------------------- |
| 118 | +local function place_planned(bp, pos) |
| 119 | + local ok, bld = pcall(function() |
| 120 | + return dfhack.buildings.constructBuilding{ |
| 121 | + type = df.building_type.Construction, |
| 122 | + subtype = df.construction_type.Floor, |
| 123 | + pos = pos |
| 124 | + } |
| 125 | + end) |
| 126 | + if not ok or not bld then return false, 'construct-error' end |
| 127 | + pcall(function() bp.addPlannedBuilding(bld) end) |
| 128 | + return true |
| 129 | +end |
| 130 | + |
| 131 | +local function place_native(cons, pos) |
| 132 | + if not cons or not cons.designateNew then return false, 'no-constructions-api' end |
| 133 | + |
| 134 | + local ok, res = pcall(function() |
| 135 | + return cons.designateNew(pos, df.construction_type.Floor, -1, -1) |
| 136 | + end) |
| 137 | + if ok and res then return true end |
| 138 | + |
| 139 | + local ok2, res2 = pcall(function() |
| 140 | + return cons.designateNew(pos, df.construction_type.Floor, df.item_type.BOULDER, -1) |
| 141 | + end) |
| 142 | + if ok2 and res2 then return true end |
| 143 | + |
| 144 | + return false, 'designate-error' |
| 145 | +end |
| 146 | + |
| 147 | +------------------------- |
| 148 | +-- Main |
| 149 | +------------------------- |
| 150 | +local utils = require('utils') |
| 151 | + |
| 152 | +local function main(...) |
| 153 | + local args = {...} |
| 154 | + |
| 155 | + for _, raw in ipairs(args) do |
| 156 | + local s = tostring(raw):lower() |
| 157 | + local num = tonumber(s) |
| 158 | + if num then |
| 159 | + if num < 1 then err('MAX_FILL_TILES must be >= 1') end |
| 160 | + if num > CONFIG.MAX_LIMIT_HARD then |
| 161 | + dfhack.printerr(('clamping MAX_FILL_TILES from %d to %d'):format(num, CONFIG.MAX_LIMIT_HARD)) |
| 162 | + num = CONFIG.MAX_LIMIT_HARD |
| 163 | + end |
| 164 | + CONFIG.MAX_FILL_TILES = math.floor(num) |
| 165 | + elseif s == 't' or s == 'true' then |
| 166 | + CONFIG.ALLOW_DIAGONALS = true |
| 167 | + elseif s == 'h' or s == 'help' then |
| 168 | + print('Usage: autoceiling [t] [<max_fill_tiles>]') |
| 169 | + print(' t: enable diagonal flood fill') |
| 170 | + print((' <max_fill_tiles>: positive integer, default %d, max %d') |
| 171 | + :format(CONFIG.MAX_FILL_TILES, CONFIG.MAX_LIMIT_HARD)) |
| 172 | + return |
| 173 | + elseif s ~= '' then |
| 174 | + err('unknown argument: ' .. tostring(raw)) |
| 175 | + end |
| 176 | + end |
| 177 | + |
| 178 | + local cur = utils.clone(df.global.cursor) |
| 179 | + if cur.x == -30000 then err('cursor not set. Move to a dug tile and run again.') end |
| 180 | + local z0 = cur.z |
| 181 | + local seed_tt = get_tiletype(cur.x, cur.y, z0) |
| 182 | + if not is_walkable_dug(seed_tt) then err('cursor tile is not dug/open interior') end |
| 183 | + |
| 184 | + local footprint = flood_fill_footprint(cur.x, cur.y, z0) |
| 185 | + if #footprint == 0 then |
| 186 | + print('AutoCeiling: nothing to do — no connected dug tiles found at cursor') |
| 187 | + return |
| 188 | + end |
| 189 | + local z_surface = z0 + 1 |
| 190 | + |
| 191 | + local ok, bp = pcall(require, 'plugins.buildingplan') |
| 192 | + if not ok then |
| 193 | + bp = nil |
| 194 | + elseif bp and (not bp.isEnabled or not bp.isEnabled()) then |
| 195 | + bp = nil |
| 196 | + end |
| 197 | + local cons = dfhack.constructions |
| 198 | + |
| 199 | + local placed, skipped = 0, 0 |
| 200 | + local reasons = {} |
| 201 | + local function skip(reason) |
| 202 | + skipped = skipped + 1 |
| 203 | + reasons[reason] = (reasons[reason] or 0) + 1 |
| 204 | + end |
| 205 | + |
| 206 | + for i, foot in ipairs(footprint) do |
| 207 | + local x, y = foot.x, foot.y |
| 208 | + local pos = xyz2pos(x, y, z_surface) |
| 209 | + if not in_bounds(x, y, z_surface) then |
| 210 | + skip('oob') |
| 211 | + elseif is_constructed_tile(x, y, z_surface) then |
| 212 | + skip('constructed') |
| 213 | + elseif has_any_building(x, y, z_surface) then |
| 214 | + skip('building') |
| 215 | + else |
| 216 | + local ok_place, why |
| 217 | + if bp then |
| 218 | + ok_place, why = place_planned(bp, pos) |
| 219 | + else |
| 220 | + ok_place, why = place_native(cons, pos) |
| 221 | + end |
| 222 | + if ok_place then placed = placed + 1 else skip(why or 'unknown') end |
| 223 | + end |
| 224 | + end |
| 225 | + |
| 226 | + if bp and bp.doCycle then pcall(function() bp.doCycle() end) end |
| 227 | + |
| 228 | + print(('AutoCeiling: placed %d floor construction(s); skipped %d'):format(placed, skipped)) |
| 229 | + if bp then |
| 230 | + print('buildingplan active: created planned floors that will auto-assign materials') |
| 231 | + elseif cons and cons.designateNew then |
| 232 | + print('used native construction designations') |
| 233 | + else |
| 234 | + print('no buildingplan and no constructions API available') |
| 235 | + end |
| 236 | + for k, v in pairs(reasons) do |
| 237 | + print((' skipped %-18s %d'):format(k, v)) |
| 238 | + end |
| 239 | +end |
| 240 | + |
| 241 | +main(...) |
0 commit comments