From 26bf4ded0dc63ff42bb06ae04a39dcb23014a066 Mon Sep 17 00:00:00 2001 From: unboundlopez Date: Mon, 10 Nov 2025 00:15:37 -0600 Subject: [PATCH 1/4] Add autoceiling script and documentation --- autoceiling.lua | 224 +++++++++++++++++++++++++++++++++++++++++++ docs/autoceiling.rst | 42 ++++++++ 2 files changed, 266 insertions(+) create mode 100644 autoceiling.lua create mode 100644 docs/autoceiling.rst diff --git a/autoceiling.lua b/autoceiling.lua new file mode 100644 index 000000000..b1ca7cbc2 --- /dev/null +++ b/autoceiling.lua @@ -0,0 +1,224 @@ +-- AutoCeiling.lua +-- Purpose: flood-fill the connected dug area on the cursor z-level (z0) +-- and place constructed floors directly above (z0+1). When the buildingplan +-- plugin is enabled, planned constructions are created. Otherwise we fall back +-- to native construction designations so dwarves get immediate jobs. +-- The script skips tiles that already have a player-made construction or +-- any existing building at the target tile on z0+1. + +------------------------- +-- Configuration defaults +------------------------- +local CONFIG = { + MAX_FILL_TILES = 4000, -- safety limit + ALLOW_DIAGONALS = false -- can be overridden by parameter +} + +------------------------- +-- Utilities and guards +------------------------- +local function err(msg) qerror('AutoCeiling: ' .. tostring(msg)) end + +local function try_require(modname) + local ok, mod = pcall(require, modname) + if ok and mod then return mod end + return nil +end + +------------------------- +-- World and map helpers +------------------------- +local W = df.global.world +local XMAX, YMAX, ZMAX = W.map.x_count, W.map.y_count, W.map.z_count + +local function in_bounds(x, y, z) + return x >= 0 and y >= 0 and z >= 0 and x < XMAX and y < YMAX and z < ZMAX +end + +local function get_block(x, y, z) + return dfhack.maps.getTileBlock(x, y, z) +end + +local function get_tiletype(x, y, z) + local b = get_block(x, y, z) + if not b then return nil end + return b.tiletype[x % 16][y % 16] +end + +local function tile_shape(tt) + if not tt then return nil end + local a = df.tiletype.attrs[tt] + return a and a.shape or nil +end + +local function tile_material(tt) + if not tt then return nil end + local a = df.tiletype.attrs[tt] + return a and a.material or nil +end + +------------------------- +-- Predicates +------------------------- +local function is_walkable_dug(tt) + local s = tile_shape(tt) + if not s then return false end + return s == df.tiletype_shape.FLOOR + or s == df.tiletype_shape.RAMP + or s == df.tiletype_shape.STAIR_UP + or s == df.tiletype_shape.STAIR_DOWN + or s == df.tiletype_shape.STAIR_UPDOWN + or s == df.tiletype_shape.EMPTY +end + +local function is_constructed_tile(x, y, z) + local tt = get_tiletype(x, y, z) + local mat = tile_material(tt) + return mat == df.tiletype_material.CONSTRUCTION +end + +local function has_any_building(x, y, z) + -- Also detects in-progress constructions as buildings + return dfhack.buildings.findAtTile({ x = x, y = y, z = z }) ~= nil +end + +------------------------- +-- Flood fill +------------------------- +local function push_if_ok(q, visited, x, y, z) + if not in_bounds(x, y, z) then return end + local key = x .. ',' .. y + if visited[key] then return end + local tt = get_tiletype(x, y, z) + if is_walkable_dug(tt) then + visited[key] = true + q[#q + 1] = { x, y } + end +end + +local function flood_fill_footprint(seed_x, seed_y, z0) + local footprint = {} + local visited = {} + local q = { { seed_x, seed_y } } + visited[seed_x .. ',' .. seed_y] = true + local head = 1 + while head <= #q and #footprint < CONFIG.MAX_FILL_TILES do + local x, y = table.unpack(q[head]); head = head + 1 + footprint[#footprint + 1] = { x = x, y = y } + if CONFIG.ALLOW_DIAGONALS then + push_if_ok(q, visited, x + 1, y, z0) + push_if_ok(q, visited, x - 1, y, z0) + push_if_ok(q, visited, x, y + 1, z0) + push_if_ok(q, visited, x, y - 1, z0) + push_if_ok(q, visited, x + 1, y + 1, z0) + push_if_ok(q, visited, x + 1, y - 1, z0) + push_if_ok(q, visited, x - 1, y + 1, z0) + push_if_ok(q, visited, x - 1, y - 1, z0) + else + push_if_ok(q, visited, x + 1, y, z0) + push_if_ok(q, visited, x - 1, y, z0) + push_if_ok(q, visited, x, y + 1, z0) + push_if_ok(q, visited, x, y - 1, z0) + end + end + + if #q > CONFIG.MAX_FILL_TILES then + dfhack.printerr(('AutoCeiling: flood fill truncated at %d tiles'):format(CONFIG.MAX_FILL_TILES)) + end + return footprint +end + +------------------------- +-- Placement strategies +------------------------- +local function place_planned(bp, x, y, z) + local ok, bld = pcall(function() + return dfhack.buildings.constructBuilding{ + type = df.building_type.Construction, + subtype = df.construction_type.Floor, + pos = { x = x, y = y, z = z } + } + end) + if not ok or not bld then return false, 'construct-error' end + pcall(function() bp.addPlannedBuilding(bld) end) + return true +end + +local function place_native(cons, x, y, z) + if not cons or not cons.designate then return false, 'no-constructions-api' end + local ok, derr = pcall(function() + cons.designate{ pos = { x = x, y = y, z = z }, type = df.construction_type.Floor } + end) + if not ok then return false, 'designate-error' end + return true +end + +------------------------- +-- Main +------------------------- +local function main(...) + local args = {...} + -- Allow user to set diagonals with parameter 't' or 'true' + if #args > 0 and (args[1] == 't' or args[1] == 'true') then + CONFIG.ALLOW_DIAGONALS = true + end + + -- Validate cursor and tile + local cur = df.global.cursor + if cur.x == -30000 then err('cursor not set. Move to a dug tile and run again.') end + local z0 = cur.z + local seed_tt = get_tiletype(cur.x, cur.y, z0) + if not is_walkable_dug(seed_tt) then err('cursor tile is not dug/open interior') end + + -- Discover footprint and target surface level + local footprint = flood_fill_footprint(cur.x, cur.y, z0) + local z_surface = z0 + 1 + + -- Load optional DFHack helpers + local bp = try_require('plugins.buildingplan') + if bp and (not bp.isEnabled or not bp.isEnabled()) then bp = nil end + local cons = try_require('dfhack.constructions') + + local placed, skipped = 0, 0 + local reasons = {} + local function skip(reason) + skipped = skipped + 1 + reasons[reason] = (reasons[reason] or 0) + 1 + end + + -- Process each tile + for i = 1, #footprint do + local x, y = footprint[i].x, footprint[i].y + if not in_bounds(x, y, z_surface) then + skip('oob') + elseif is_constructed_tile(x, y, z_surface) then + skip('constructed') + elseif has_any_building(x, y, z_surface) then + skip('building') + else + local ok, why + if bp then + ok, why = place_planned(bp, x, y, z_surface) + else + ok, why = place_native(cons, x, y, z_surface) + end + if ok then placed = placed + 1 else skip(why or 'unknown') end + end + end + + if bp and bp.doCycle then pcall(function() bp.doCycle() end) end + + print(('AutoCeiling: placed %d floor construction(s); skipped %d'):format(placed, skipped)) + if bp then + print('buildingplan active: created planned floors that will auto-assign materials') + elseif cons and cons.designate then + print('used native construction designations') + else + print('no buildingplan and no constructions API available') + end + for k, v in pairs(reasons) do + print((' skipped %-18s %d'):format(k, v)) + end +end + +main(...) \ No newline at end of file diff --git a/docs/autoceiling.rst b/docs/autoceiling.rst new file mode 100644 index 000000000..73e958d7e --- /dev/null +++ b/docs/autoceiling.rst @@ -0,0 +1,42 @@ +AutoCeiling +============= + +This is a DFHack Lua script for **Dwarf Fortress (Steam version)** that automatically places constructed floors above any dug-out area. It uses a flood-fill algorithm to detect connected dug tiles on the selected Z-level, then creates planned floor constructions directly above them to seal the area. This helps prevent surface collapse and creature intrusion when mining under open ground. + +Features +-------- + +- **Automatic Flood Fill Detection**: Finds all connected dug tiles from the cursor location. +- **Smart Floor Placement**: Builds floors one level above the dug region. +- **Buildingplan Integration**: When the `buildingplan` plugin is active, floors are added as planned constructions and will auto-assign materials. +- **Native DF Construction Support**: Falls back to native designations if `buildingplan` is unavailable. +- **Safety Checks**: Skips tiles that already have player-made constructions or any existing buildings. +- **Parameter Input**: Run `autoceiling t` to enable diagonal flood fill (8-way). Default is 4-way fill. +- **Performance Limit**: Caps flood-fill to a configurable number of tiles (default 4000) for safety. + +Usage +----- + +1. Move the **game cursor** to a dug-out tile at the level you want to seal the ceiling. +2. In the DFHack console, run: + + ``` + autoceiling + ``` + or, for diagonal (8-way) flood fill: + ``` + autoceiling t + ``` + +3. The script will automatically: + - Scan connected walkable tiles at the current Z-level. + - Attempt to place floor constructions one Z-level above. + - Report how many tiles were placed and skipped. + +4. If the `buildingplan` plugin is active, you’ll see a message confirming planned floor placement. Otherwise, the script will use standard construction designations. + +Notes +----- + +- Ideal for use after large excavation projects to prevent breaches to the surface. +- Works well in conjunction with the **buildingplan** plugin for automatic material management. From 1473678259ee84b0132d232a6f4f9ab4f003d4f4 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Mon, 10 Nov 2025 06:21:23 +0000 Subject: [PATCH 2/4] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- autoceiling.lua | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/autoceiling.lua b/autoceiling.lua index b1ca7cbc2..de2e9521d 100644 --- a/autoceiling.lua +++ b/autoceiling.lua @@ -221,4 +221,4 @@ local function main(...) end end -main(...) \ No newline at end of file +main(...) From 7c3af08d6d72700737ae33fac313641df873a92b Mon Sep 17 00:00:00 2001 From: unboundlopez <47876628+unboundlopez@users.noreply.github.com> Date: Mon, 10 Nov 2025 07:53:53 -0600 Subject: [PATCH 3/4] Update autoceiling.rst --- docs/autoceiling.rst | 69 ++++++++++++++++++++++++-------------------- 1 file changed, 38 insertions(+), 31 deletions(-) diff --git a/docs/autoceiling.rst b/docs/autoceiling.rst index 73e958d7e..7ca33e166 100644 --- a/docs/autoceiling.rst +++ b/docs/autoceiling.rst @@ -1,42 +1,49 @@ -AutoCeiling -============= +autoceiling +=========== + +.. dfhack-tool:: + :summary: Place floors above dug areas to seal surface openings. + :tags: construction automation utility + +**AutoCeiling** is a DFHack Lua script that automatically places constructed +floors above any dug-out area. It uses a flood-fill algorithm to detect connected +dug tiles on the selected Z-level, then creates planned floor constructions +directly above them to seal the area. This prevents surface collapse and stops +creatures from entering your fortress through unexpected openings. It’s +especially useful when building farms directly below the surface, since those +areas are prone to collapsing without warning and can leave open spaces that +allow surface creatures to breach your fort. -This is a DFHack Lua script for **Dwarf Fortress (Steam version)** that automatically places constructed floors above any dug-out area. It uses a flood-fill algorithm to detect connected dug tiles on the selected Z-level, then creates planned floor constructions directly above them to seal the area. This helps prevent surface collapse and creature intrusion when mining under open ground. +Usage +----- + +:: + + autoceiling [t] [] -Features +Examples -------- -- **Automatic Flood Fill Detection**: Finds all connected dug tiles from the cursor location. -- **Smart Floor Placement**: Builds floors one level above the dug region. -- **Buildingplan Integration**: When the `buildingplan` plugin is active, floors are added as planned constructions and will auto-assign materials. -- **Native DF Construction Support**: Falls back to native designations if `buildingplan` is unavailable. -- **Safety Checks**: Skips tiles that already have player-made constructions or any existing buildings. -- **Parameter Input**: Run `autoceiling t` to enable diagonal flood fill (8-way). Default is 4-way fill. -- **Performance Limit**: Caps flood-fill to a configurable number of tiles (default 4000) for safety. +``autoceiling`` + Run with default settings (4,000 tile flood-fill limit, no diagonal fill). -Usage ------ +``autoceiling t`` + Enable diagonal flood-fill connections (8-way fill). -1. Move the **game cursor** to a dug-out tile at the level you want to seal the ceiling. -2. In the DFHack console, run: +``autoceiling 500`` + Raise or lower flood-fill limits. - ``` - autoceiling - ``` - or, for diagonal (8-way) flood fill: - ``` - autoceiling t - ``` +``autoceiling t 6000`` or ``autoceiling 6000 t`` + Allow diagonals and increase fill limit to 6,000 tiles. -3. The script will automatically: - - Scan connected walkable tiles at the current Z-level. - - Attempt to place floor constructions one Z-level above. - - Report how many tiles were placed and skipped. +Options +------- -4. If the `buildingplan` plugin is active, you’ll see a message confirming planned floor placement. Otherwise, the script will use standard construction designations. +``t`` + Enables 8-directional (diagonal) flood fill mode. -Notes ------ +```` + Sets the maximum number of tiles the flood fill can cover (default: 4000). -- Ideal for use after large excavation projects to prevent breaches to the surface. -- Works well in conjunction with the **buildingplan** plugin for automatic material management. +These are the only two options available for this command. Use ``t`` to toggle +diagonal fill and ```` to control the tile limit for flood fill. From bdeccb6185be71b9c14f44350bca35c33fb97ae7 Mon Sep 17 00:00:00 2001 From: unboundlopez <47876628+unboundlopez@users.noreply.github.com> Date: Mon, 10 Nov 2025 21:56:14 -0600 Subject: [PATCH 4/4] Update autoceiling.lua I made some changes, and sorry it's late. I think the mod has potential and could be used in most sessions, so if anyone is interested in taking it over, you have my full permission. --- autoceiling.lua | 179 ++++++++++++++++++++++++++---------------------- 1 file changed, 96 insertions(+), 83 deletions(-) diff --git a/autoceiling.lua b/autoceiling.lua index de2e9521d..67b4877cc 100644 --- a/autoceiling.lua +++ b/autoceiling.lua @@ -10,8 +10,9 @@ -- Configuration defaults ------------------------- local CONFIG = { - MAX_FILL_TILES = 4000, -- safety limit - ALLOW_DIAGONALS = false -- can be overridden by parameter + MAX_FILL_TILES = 2000, -- positive integer; safety limit + ALLOW_DIAGONALS = false, -- set true to allow 8-way fill + MAX_LIMIT_HARD = 4000 -- hard clamp to avoid runaway fills } ------------------------- @@ -19,42 +20,31 @@ local CONFIG = { ------------------------- local function err(msg) qerror('AutoCeiling: ' .. tostring(msg)) end -local function try_require(modname) - local ok, mod = pcall(require, modname) - if ok and mod then return mod end - return nil +local function xyz2pos(x, y, z) + return { x = x, y = y, z = z } end +-- Cache frequently used modules/tables for readability +local maps = dfhack.maps +local constructions = dfhack.constructions +local buildings = dfhack.buildings +local tattrs = df.tiletype.attrs + ------------------------- -- World and map helpers ------------------------- -local W = df.global.world -local XMAX, YMAX, ZMAX = W.map.x_count, W.map.y_count, W.map.z_count - local function in_bounds(x, y, z) - return x >= 0 and y >= 0 and z >= 0 and x < XMAX and y < YMAX and z < ZMAX -end - -local function get_block(x, y, z) - return dfhack.maps.getTileBlock(x, y, z) + return maps.isValidTilePos(x, y, z) end local function get_tiletype(x, y, z) - local b = get_block(x, y, z) - if not b then return nil end - return b.tiletype[x % 16][y % 16] + return maps.getTileType(x, y, z) end local function tile_shape(tt) if not tt then return nil end - local a = df.tiletype.attrs[tt] - return a and a.shape or nil -end - -local function tile_material(tt) - if not tt then return nil end - local a = df.tiletype.attrs[tt] - return a and a.material or nil + local a = tattrs[tt] + return (a and a.shape ~= df.tiletype_shape.NONE) and a.shape or nil end ------------------------- @@ -72,57 +62,51 @@ local function is_walkable_dug(tt) end local function is_constructed_tile(x, y, z) - local tt = get_tiletype(x, y, z) - local mat = tile_material(tt) - return mat == df.tiletype_material.CONSTRUCTION + return constructions.findAtTile(x, y, z) ~= nil end local function has_any_building(x, y, z) - -- Also detects in-progress constructions as buildings - return dfhack.buildings.findAtTile({ x = x, y = y, z = z }) ~= nil + return buildings.findAtTile(xyz2pos(x, y, z)) ~= nil end ------------------------- -- Flood fill ------------------------- -local function push_if_ok(q, visited, x, y, z) - if not in_bounds(x, y, z) then return end - local key = x .. ',' .. y - if visited[key] then return end - local tt = get_tiletype(x, y, z) - if is_walkable_dug(tt) then - visited[key] = true - q[#q + 1] = { x, y } - end -end - local function flood_fill_footprint(seed_x, seed_y, z0) local footprint = {} local visited = {} - local q = { { seed_x, seed_y } } + local queue = { { seed_x, seed_y } } visited[seed_x .. ',' .. seed_y] = true - local head = 1 - while head <= #q and #footprint < CONFIG.MAX_FILL_TILES do - local x, y = table.unpack(q[head]); head = head + 1 - footprint[#footprint + 1] = { x = x, y = y } + local queue_pos = 1 + + local function push_if_ok(x, y) + if not in_bounds(x, y, z0) then return end + local key = x .. ',' .. y + if visited[key] then return end + local tt = get_tiletype(x, y, z0) + if is_walkable_dug(tt) then + visited[key] = true + table.insert(queue, { x, y }) + end + end + + while queue_pos <= #queue and #footprint < CONFIG.MAX_FILL_TILES do + local x, y = table.unpack(queue[queue_pos]) + queue_pos = queue_pos + 1 + table.insert(footprint, { x = x, y = y }) + push_if_ok(x + 1, y) + push_if_ok(x - 1, y) + push_if_ok(x, y + 1) + push_if_ok(x, y - 1) if CONFIG.ALLOW_DIAGONALS then - push_if_ok(q, visited, x + 1, y, z0) - push_if_ok(q, visited, x - 1, y, z0) - push_if_ok(q, visited, x, y + 1, z0) - push_if_ok(q, visited, x, y - 1, z0) - push_if_ok(q, visited, x + 1, y + 1, z0) - push_if_ok(q, visited, x + 1, y - 1, z0) - push_if_ok(q, visited, x - 1, y + 1, z0) - push_if_ok(q, visited, x - 1, y - 1, z0) - else - push_if_ok(q, visited, x + 1, y, z0) - push_if_ok(q, visited, x - 1, y, z0) - push_if_ok(q, visited, x, y + 1, z0) - push_if_ok(q, visited, x, y - 1, z0) + push_if_ok(x + 1, y + 1) + push_if_ok(x + 1, y - 1) + push_if_ok(x - 1, y + 1) + push_if_ok(x - 1, y - 1) end end - if #q > CONFIG.MAX_FILL_TILES then + if #queue > CONFIG.MAX_FILL_TILES then dfhack.printerr(('AutoCeiling: flood fill truncated at %d tiles'):format(CONFIG.MAX_FILL_TILES)) end return footprint @@ -131,12 +115,12 @@ end ------------------------- -- Placement strategies ------------------------- -local function place_planned(bp, x, y, z) +local function place_planned(bp, pos) local ok, bld = pcall(function() return dfhack.buildings.constructBuilding{ type = df.building_type.Construction, subtype = df.construction_type.Floor, - pos = { x = x, y = y, z = z } + pos = pos } end) if not ok or not bld then return false, 'construct-error' end @@ -144,40 +128,69 @@ local function place_planned(bp, x, y, z) return true end -local function place_native(cons, x, y, z) - if not cons or not cons.designate then return false, 'no-constructions-api' end - local ok, derr = pcall(function() - cons.designate{ pos = { x = x, y = y, z = z }, type = df.construction_type.Floor } +local function place_native(cons, pos) + if not cons or not cons.designateNew then return false, 'no-constructions-api' end + + local ok, res = pcall(function() + return cons.designateNew(pos, df.construction_type.Floor, -1, -1) end) - if not ok then return false, 'designate-error' end - return true + if ok and res then return true end + + local ok2, res2 = pcall(function() + return cons.designateNew(pos, df.construction_type.Floor, df.item_type.BOULDER, -1) + end) + if ok2 and res2 then return true end + + return false, 'designate-error' end ------------------------- -- Main ------------------------- +local utils = require('utils') + local function main(...) local args = {...} - -- Allow user to set diagonals with parameter 't' or 'true' - if #args > 0 and (args[1] == 't' or args[1] == 'true') then - CONFIG.ALLOW_DIAGONALS = true + + for _, raw in ipairs(args) do + local s = tostring(raw):lower() + local num = tonumber(s) + if num then + if num < 1 then err('MAX_FILL_TILES must be >= 1') end + if num > CONFIG.MAX_LIMIT_HARD then + dfhack.printerr(('clamping MAX_FILL_TILES from %d to %d'):format(num, CONFIG.MAX_LIMIT_HARD)) + num = CONFIG.MAX_LIMIT_HARD + end + CONFIG.MAX_FILL_TILES = math.floor(num) + elseif s == 't' or s == 'true' then + CONFIG.ALLOW_DIAGONALS = true + elseif s == 'h' or s == 'help' then + print('Usage: autoceiling [t] []') + print(' t: enable diagonal flood fill') + print(' : positive integer, default ' .. CONFIG.MAX_FILL_TILES) + return + elseif s ~= '' then + err('unknown argument: ' .. tostring(raw)) + end end - -- Validate cursor and tile - local cur = df.global.cursor + local cur = utils.clone(df.global.cursor) if cur.x == -30000 then err('cursor not set. Move to a dug tile and run again.') end local z0 = cur.z local seed_tt = get_tiletype(cur.x, cur.y, z0) if not is_walkable_dug(seed_tt) then err('cursor tile is not dug/open interior') end - -- Discover footprint and target surface level local footprint = flood_fill_footprint(cur.x, cur.y, z0) + if #footprint == 0 then + print('AutoCeiling: nothing to do — no connected dug tiles found at cursor') + return + end local z_surface = z0 + 1 - -- Load optional DFHack helpers - local bp = try_require('plugins.buildingplan') + -- Require buildingplan directly; let it error if missing + local bp = require('plugins.buildingplan') if bp and (not bp.isEnabled or not bp.isEnabled()) then bp = nil end - local cons = try_require('dfhack.constructions') + local cons = dfhack.constructions local placed, skipped = 0, 0 local reasons = {} @@ -186,9 +199,9 @@ local function main(...) reasons[reason] = (reasons[reason] or 0) + 1 end - -- Process each tile - for i = 1, #footprint do - local x, y = footprint[i].x, footprint[i].y + for i, foot in ipairs(footprint) do + local x, y = foot.x, foot.y + local pos = xyz2pos(x, y, z_surface) if not in_bounds(x, y, z_surface) then skip('oob') elseif is_constructed_tile(x, y, z_surface) then @@ -198,9 +211,9 @@ local function main(...) else local ok, why if bp then - ok, why = place_planned(bp, x, y, z_surface) + ok, why = place_planned(bp, pos) else - ok, why = place_native(cons, x, y, z_surface) + ok, why = place_native(cons, pos) end if ok then placed = placed + 1 else skip(why or 'unknown') end end @@ -211,7 +224,7 @@ local function main(...) print(('AutoCeiling: placed %d floor construction(s); skipped %d'):format(placed, skipped)) if bp then print('buildingplan active: created planned floors that will auto-assign materials') - elseif cons and cons.designate then + elseif cons and cons.designateNew then print('used native construction designations') else print('no buildingplan and no constructions API available')