Skip to content
Merged
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
1 change: 1 addition & 0 deletions docs/changelog.txt
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,7 @@ Template for new versions:
- `sort`: the stress/happiness icons in the top bar are now clickable, opening a list of the citizens in that stress category; selecting a citizen centers the map on them
- `stocks`: the overlay's ``collapse all`` hotkey now toggles, expanding all categories again when everything is collapsed
- `stockpiles`: the overlay now also attaches to the minecart hauling stop conditions panel, offering import/export of route stop conditions
- `suspendmanager`: suspend deconstruction jobs that would collapse connected machinery, such as waterwheels supporting other components

## Fixes
- Plugin commands that throw uncaught C++ exceptions now report an error instead of crashing the game
Expand Down
3 changes: 3 additions & 0 deletions docs/plugins/suspendmanager.rst
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,9 @@ When enabled, ``suspendmanager`` will watch your active jobs and:
which would cause the designation to be lost.
- suspend construction jobs that would cave in immediately on completion,
such as when building walls or floors next to grates/bars.
- suspend deconstruction jobs on machine components (gears, axles,
waterwheels, etc.) whose removal would leave part of the machine without
support and collapse it.

See `gui/suspendmanager` for a graphical configuration interface.

Expand Down
4 changes: 3 additions & 1 deletion plugins/lua/suspendmanager.lua
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,9 @@ local function getSelectedBuildingJob()

-- Find if the building is being constructed
for _, job in ipairs(building.jobs) do
if job.job_type == df.job_type.ConstructBuilding then
if job.job_type == df.job_type.ConstructBuilding
or (job.job_type == df.job_type.DestroyBuilding
and job.flags.suspend) then
return job
end
end
Expand Down
104 changes: 93 additions & 11 deletions plugins/suspendmanager.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -16,17 +16,22 @@
#include "df/item.h"
#include "df/job.h"
#include "df/job_item_ref.h"
#include "df/machine.h"
#include "df/machine_info.h"
#include "df/machine_nodest.h"
#include "df/map_block.h"
#include "df/tile_designation.h"
#include "df/tile_occupancy.h"
#include "df/world.h"

#include <bitset>
#include <functional>
#include <numeric>
#include <ranges>
#include <sstream>
#include <string>
#include <unordered_map>
#include <unordered_set>
#include <vector>

using std::string;
Expand Down Expand Up @@ -79,6 +84,8 @@ enum Reason {
UNSUPPORTED = 6,
// Has an unmovable item on top of the building job
ITEM_IN_JOB = 7,
// Removing the building would orphan machine components
DANGER_OF_COLLAPSE = 8,
};

inline bool isExternalReason(Reason reason) {
Expand All @@ -101,6 +108,8 @@ static string reasonToString(Reason reason) {
return "Would collapse immediately";
case Reason::ITEM_IN_JOB:
return "Blocked by an unmovable item";
case Reason::DANGER_OF_COLLAPSE:
return "Would collapse machinery";
default:
return "External reason";
}
Expand Down Expand Up @@ -541,6 +550,73 @@ class SuspendManager {
return true;
}

// A machine node is anchored only if its entire footprint sits on
// non-open tiles. Nodes hanging over open space survive only through
// graph connectivity to an anchored node; the stability pass destroys
// any connected component with no anchor (verified in-game).
static bool isAnchoredToTerrain(df::building *bld) {
for (auto x = bld->x1; x <= bld->x2; ++x) {
for (auto y = bld->y1; y <= bld->y2; ++y) {
auto tile_type = Maps::getTileType(x, y, bld->z);
if (!tile_type || tileShape(*tile_type) == df::enums::tiletype_shape::EMPTY)
return false;
}
}
return true;
}

// Would removing this building's node leave a connected component of
// its machine with no anchored node?
static bool wouldOrphanMachine(df::building *bld) {
auto machine_info = bld->getMachineInfo();
if (!machine_info)
return false;
auto machine = df::machine::find(machine_info->machine_id);
if (!machine)
return false;

auto &nodes = machine->components;
int target = -1;
for (size_t i = 0; i < nodes.size(); ++i) {
if (nodes[i] && nodes[i]->building_id == bld->id) {
target = i;
break;
}
}
if (target < 0 || nodes.size() == 1)
return false;

// union-find over the remaining nodes
vector<int> parent(nodes.size());
std::iota(parent.begin(), parent.end(), 0);
std::function<int(int)> root = [&](int i) {
while (parent[i] != i) {
parent[i] = parent[parent[i]];
i = parent[i];
}
return i;
};
for (size_t i = 0; i < nodes.size(); ++i) {
if ((int)i == target || !nodes[i])
continue;
for (auto c : nodes[i]->connections) {
if (c < 0 || c >= (int)nodes.size() || c == target || !nodes[c])
continue;
parent[root(i)] = root(c);
}
}

std::unordered_map<int, bool> anchored;
for (size_t i = 0; i < nodes.size(); ++i) {
if ((int)i == target || !nodes[i])
continue;
auto other = df::building::find(nodes[i]->building_id);
int r = root(i);
anchored[r] = anchored[r] || (other && isAnchoredToTerrain(other));
}
return std::ranges::any_of(anchored, [](auto &p) { return !p.second; });
}

void suspendBuilding(df::building *building, Reason reason){
for (auto job : building->jobs)
if (job->job_type == df::job_type::ConstructBuilding)
Expand Down Expand Up @@ -657,6 +733,13 @@ class SuspendManager {
// check carving/detailing jobs and suspend buildings over them
preserveDesigations(job);

if (job->job_type == df::job_type::DestroyBuilding) {
auto building = Job::getHolder(job);
if (building && wouldOrphanMachine(building))
suspensions[job->id] = Reason::DANGER_OF_COLLAPSE;
continue;
}

// remaining checks only apply to construction jobs
if (!isConstructionJob(job)) continue;

Expand Down Expand Up @@ -708,7 +791,7 @@ class SuspendManager {
Reason reason;


for (auto job : df::global::world->jobs.list | std::views::filter(isConstructionJob))
for (auto job : df::global::world->jobs.list | std::views::filter(isSuspendableJob))
{
if (job->flags.bits.suspend && !suspensions.contains(job->id)) {
unsuspend(job); // suspended for no reason
Expand All @@ -728,6 +811,10 @@ class SuspendManager {
return job->job_type == job_type::ConstructBuilding;
}

static bool isSuspendableJob(df::job *job) {
return isConstructionJob(job) || job->job_type == job_type::DestroyBuilding;
}

bool keptSuspended(df::job *job) {
Reason reason;
if (tryGetReason(job,reason) && !isExternalReason(reason))
Expand Down Expand Up @@ -894,21 +981,16 @@ static command_result do_command(color_ostream &out, vector<string> &parameters)
if (parameters[2] == "true") {
suspendmanager_instance->prevent_blocking = true;
config.set_bool(CONFIG_PREVENT_BLOCKING, true);
if (is_enabled) {
do_cycle(out);
out.print("{}", suspendmanager_instance->getStatus(out));
}
return CR_OK;
} else if (parameters[2] == "false") {
suspendmanager_instance->prevent_blocking = false;
config.set_bool(CONFIG_PREVENT_BLOCKING, false);
if (is_enabled) {
do_cycle(out);
out.print("{}", suspendmanager_instance->getStatus(out));
}
return CR_OK;
} else
return CR_WRONG_USAGE;
if (is_enabled) {
do_cycle(out);
out.print("{}", suspendmanager_instance->getStatus(out));
}
return CR_OK;
} else {
return CR_WRONG_USAGE;
}
Expand Down
180 changes: 180 additions & 0 deletions test/plugins/suspendmanager.lua
Original file line number Diff line number Diff line change
Expand Up @@ -63,3 +63,183 @@ function test.unsuspend_runs()
local _, status = dfhack.run_command_silent('unsuspend')
expect.eq(CR_OK, status)
end

-- machine collapse protection (#5777)

local function tile_shape(x, y, z)
local block = dfhack.maps.getTileBlock(x, y, z)
if not block then return nil end
local tt = block.tiletype[x % 16][y % 16]
return df.tiletype.attrs[tt].shape
end

local function free_tile(x, y, z)
local block = dfhack.maps.getTileBlock(x, y, z)
if not block then return false end
local lx, ly = x % 16, y % 16
local des = block.designation[lx][ly]
if des.dig ~= df.tile_dig_designation.No or des.flow_size ~= 0 then
return false
end
if block.occupancy[lx][ly].building ~= df.tile_building_occ.None then
return false
end
return not dfhack.buildings.findAtTile(xyz2pos(x, y, z))
end

-- find a floor tile with two open tiles to its east; if none exists,
-- channel open tiles out of a wall face so the site is floor + two open tiles
local function make_pit_site()
local function clear_run(x, y, z, shape)
for i = 1, 2 do
if tile_shape(x + i, y, z) ~= shape or not free_tile(x + i, y, z) then
return false
end
end
return true
end
-- stay clear of the unbuildable map border
local margin = 20
local map = df.global.world.map
local wall_site = nil
for _, block in ipairs(df.global.world.map.map_blocks) do
local bx, by, bz = block.map_pos.x, block.map_pos.y, block.map_pos.z
if bx < margin or by < margin or bx > map.x_count - margin - 16
or by > map.y_count - margin - 16 then
goto next_block
end
for x = 0, 13 do for y = 0, 15 do
local wx, wy = bx + x, by + y
if tile_shape(wx, wy, bz) == df.tiletype_shape.FLOOR
and free_tile(wx, wy, bz) then
if clear_run(wx, wy, bz, df.tiletype_shape.EMPTY) then
return wx, wy, bz
end
if not wall_site
and clear_run(wx, wy, bz, df.tiletype_shape.WALL) then
wall_site = {wx, wy, bz}
end
end
end end
::next_block::
end
if not wall_site then return nil end
local x, y, z = wall_site[1], wall_site[2], wall_site[3]
for i = 1, 2 do
local db = dfhack.maps.getTileBlock(x + i, y, z)
db.designation[(x + i) % 16][y % 16].dig = df.tile_dig_designation.Channel
end
dfhack.run_command_silent('dig-now')
if not clear_run(x, y, z, df.tiletype_shape.EMPTY) then return nil end
return x, y, z
end

-- complete a machine building without dwarf labor: remove its construction
-- job, mark it built, and join it to the machine graph
local function build_machine(btype, x, y, z, width)
local bld = dfhack.buildings.constructBuilding{
type=btype, pos=xyz2pos(x, y, z), width=width, height=1,
}
if not bld then return nil end
for i = #bld.jobs - 1, 0, -1 do
dfhack.job.removeJob(bld.jobs[i])
end
bld.flags.exists = true
bld.construction_stage = 1
df.global.world.machines:add_to_machine(bld)
return bld
end

local function destroy_job_of(bld)
bld:queueDestroy()
for _, job in ipairs(bld.jobs) do
if job.job_type == df.job_type.DestroyBuilding then
return job
end
end
end

-- fully remove a completed building: deconstruct() only queues a destroy job
-- for those, so reset the build stage to reach its immediate-deletion path
local function delete_building(bld)
if not bld then return end
local id = bld.id
for i = #bld.jobs - 1, 0, -1 do
dfhack.job.removeJob(bld.jobs[i])
end
bld.construction_stage = 0
dfhack.buildings.deconstruct(bld)
-- the object is deleted but its pointer lingers in the global vector
local all = df.global.world.buildings.all
for i = #all - 1, 0, -1 do
if all[i].id == id then
all:erase(i)
end
end
end

-- drop machine graph nodes left pointing at deleted buildings
local function prune_dead_machines()
local machines = df.global.world.machines.all
for i = #machines - 1, 0, -1 do
local machine = machines[i]
for j = #machine.components - 1, 0, -1 do
if not df.building.find(machine.components[j].building_id) then
machine.components:erase(j)
end
end
if #machine.components == 0 then
machines:erase(i)
end
end
end

function test.destroying_machine_anchor_suspends_job()
local x, y, z = make_pit_site()
expect.ne(nil, x, 'no diggable site for the machine rig')
if not x then return end

local was_enabled = is_enabled()
if not was_enabled then
dfhack.run_command_silent('suspendmanager', 'enable')
end

local anchor = build_machine(df.building_type.GearAssembly, x, y, z, 1)
local axle = build_machine(df.building_type.AxleHorizontal, x + 1, y, z, 1)
local hanging = build_machine(df.building_type.GearAssembly, x + 2, y, z, 1)

return dfhack.with_finalize(function()
if not was_enabled then
dfhack.run_command_silent('suspendmanager', 'disable')
end
for _, bld in pairs{anchor, axle, hanging} do
delete_building(bld)
end
prune_dead_machines()
end, function()
expect.ne(nil, anchor, 'anchor gear not built')
expect.ne(nil, axle, 'axle not built')
expect.ne(nil, hanging, 'hanging gear not built')
if not anchor or not axle or not hanging then return end

-- all three components must be in the same machine
local machine_id = anchor.machine.machine_id
expect.ne(-1, machine_id)
expect.eq(machine_id, axle.machine.machine_id)
expect.eq(machine_id, hanging.machine.machine_id)

-- removing the endpoint leaves an anchored component: safe
local end_job = destroy_job_of(hanging)
expect.ne(nil, end_job, 'no destroy job on endpoint')
-- removing the anchor orphans the axle and the endpoint: unsafe
local anchor_job = destroy_job_of(anchor)
expect.ne(nil, anchor_job, 'no destroy job on anchor')
if not end_job or not anchor_job then return end

dfhack.run_command_silent('suspendmanager', 'now')
expect.true_(anchor_job.flags.suspend,
'anchor destroy job was not suspended')
expect.false_(end_job.flags.suspend,
'endpoint destroy job was suspended')
end)
end
Loading