Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
237 changes: 237 additions & 0 deletions autoceiling.lua
Original file line number Diff line number Diff line change
@@ -0,0 +1,237 @@
-- 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 = 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
}

-------------------------
-- Utilities and guards
-------------------------
local function err(msg) qerror('AutoCeiling: ' .. tostring(msg)) end

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 function in_bounds(x, y, z)
return maps.isValidTilePos(x, y, z)
end

local function get_tiletype(x, y, z)
return maps.getTileType(x, y, z)
end

local function tile_shape(tt)
if not tt then return nil end
local a = tattrs[tt]
return (a and a.shape ~= df.tiletype_shape.NONE) and a.shape 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)
return constructions.findAtTile(x, y, z) ~= nil
end

local function has_any_building(x, y, z)
return buildings.findAtTile(xyz2pos(x, y, z)) ~= nil
end

-------------------------
-- Flood fill
-------------------------
local function flood_fill_footprint(seed_x, seed_y, z0)
local footprint = {}
local visited = {}
local queue = { { seed_x, seed_y } }
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I see you're special-casing the very first queue[] element and visited[] element. I think that's fine,
but it would be more elegant if you could use push_if_ok(seed values) instead.

I'm not going to game out whether that would work with your current code. Just something to consider.

Maybe something like this would work":

local queue = {}
local queue_pos = 1

local function push_if_ok()
...

push_if_ok(seed_x, seed_y)

Again, I didn't test this.

visited[seed_x .. ',' .. seed_y] = true
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 })
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm wondering if your queue could directly store pos elements. i.e.
table.insert(queue, xyz2pos(x, y, z0))

Then you could pass the current queue element directly to your functions or to API calls.

You could also just copy a queue element directly into footprint[].

I mean, your code clearly works, but doing it that way seems like it would be more elegant.

Let's see, you still couldn't use a pos as a vistied[] key. You would still need to manually build key so you can test it in one call as an associative array/dictionary.

(There is an API call same_xyz(pos1, pos2) but you can't probe an associative array for the values of a table, while you can test for existence of string key in one probe.)

(Although... if it's the exact same table instead of a copy or a newly-constructed table, an existence probe would work. I think.)

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(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 #queue > 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, pos)
local ok, bld = pcall(function()
return dfhack.buildings.constructBuilding{
type = df.building_type.Construction,
subtype = df.construction_type.Floor,
pos = pos
}
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, 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 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 = {...}

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] [<max_fill_tiles>]')
print(' t: enable diagonal flood fill')
print(' <max_fill_tiles>: positive integer, default ' .. CONFIG.MAX_FILL_TILES)
return
elseif s ~= '' then
err('unknown argument: ' .. tostring(raw))
end
end

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

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

-- 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 = dfhack.constructions

local placed, skipped = 0, 0
local reasons = {}
local function skip(reason)
skipped = skipped + 1
reasons[reason] = (reasons[reason] or 0) + 1
end

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
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, pos)
else
ok, why = place_native(cons, pos)
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.designateNew 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(...)
49 changes: 49 additions & 0 deletions docs/autoceiling.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
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.

Usage
-----

::

autoceiling [t] [<max>]

Examples
--------

``autoceiling``
Run with default settings (4,000 tile flood-fill limit, no diagonal fill).

``autoceiling t``
Enable diagonal flood-fill connections (8-way fill).

``autoceiling 500``
Raise or lower flood-fill limits.

``autoceiling t 6000`` or ``autoceiling 6000 t``
Allow diagonals and increase fill limit to 6,000 tiles.

Options
-------

``t``
Enables 8-directional (diagonal) flood fill mode.

``<max>``
Sets the maximum number of tiles the flood fill can cover (default: 4000).

These are the only two options available for this command. Use ``t`` to toggle
diagonal fill and ``<max>`` to control the tile limit for flood fill.