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 @@ -71,6 +71,7 @@ Template for new versions:
- `aquifer`: make ``--skip-top`` and top-relative ``--levels`` take effect for the ``drain``, ``convert``, and ``add`` actions instead of being silently ignored
- `autodump`: ``destroy`` no longer leaves the contents of destroyed containers in limbo or crashes when destroying unit-held items
- `buildingplan`: fix "Unlink all" only unlinking some mechanisms (or crashing) when freeing mechanisms from the building
- `tiletypes`: remove plants (including trees) rooted on a tile that is painted into something that cannot host them, so cleared ground no longer regrows floating trees
- `timestream`: deal properly with units who have breathing difficulties
- `stocks`: overlay now resets scroll position when collapsing categories so the item list is no longer left blank and unscrollable

Expand Down
75 changes: 75 additions & 0 deletions plugins/tiletypes.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,10 @@ using std::set;
#include "modules/Maps.h"

#include "df/coord2d.h"
#include "df/map_block_column.h"
#include "df/plant.h"
#include "df/plant_tree_info.h"
#include "df/plant_type.h"
#include "df/tile_dig_designation.h"
#include "df/world.h"

Expand Down Expand Up @@ -793,6 +797,64 @@ bool processTileType(color_ostream & out, TileType &paint, std::vector<std::stri
return found;
}

// whether a plant rooted at a tile survives the tile being painted to 'tt'
static bool plantTileSupported(df::plant *plant, df::tiletype tt)
{
auto shape = tileShape(tt);
auto material = tileMaterial(tt);
if (plant->tree_info) // grown tree; pos is the trunk base
return material == tiletype_material::TREE
|| material == tiletype_material::ROOT
|| material == tiletype_material::MUSHROOM;
if (plant->type == plant_type::DRY_TREE || plant->type == plant_type::WET_TREE)
// unsprouted tree; lives on a ground tile until it grows
return shape != tiletype_shape::NONE && shape != tiletype_shape::EMPTY
&& material != tiletype_material::AIR;
// shrubs and saplings
return shape == tiletype_shape::SHRUB || shape == tiletype_shape::SAPLING;
}

// delete a plant record, including tree_info for grown trees; the tree's
// remaining tiles are left as-is for the user to paint over or collapse
static void removePlant(df::plant *plant)
{
vector<df::plant *> *vec = nullptr;
switch (plant->type)
{ // Remove from the per-type vector
case plant_type::DRY_TREE: vec = &world->plants.tree_dry; break;
case plant_type::WET_TREE: vec = &world->plants.tree_wet; break;
case plant_type::DRY_PLANT: vec = &world->plants.shrub_dry; break;
case plant_type::WET_PLANT: vec = &world->plants.shrub_wet; break;
}
if (vec)
std::erase(*vec, plant);

auto col = Maps::getBlockColumn((plant->pos.x / 48) * 3, (plant->pos.y / 48) * 3);
if (col)
std::erase(col->plants, plant);

if (plant->tree_info)
{
delete plant->tree_info->body;
delete plant->tree_info->roots;
delete plant->tree_info;
}

std::erase(world->plants.all, plant);
delete plant;
}

// remove the plant rooted at pos if the painted tile can no longer host it
static void removePlantIfUnsupported(const df::coord &pos)
{
auto plant = Maps::getPlantAtTile(pos);
if (!plant)
return;
auto tt = Maps::getTileType(pos);
if (!tt || !plantTileSupported(plant, *tt))
removePlant(plant);
}

static bool paintTileProcessing(MapExtras::Block* block, const df::coord2d& blockPos, const TileType& target) {
df::tiletype source = block->tiletypeAt(blockPos);
df::tile_designation des = block->DesignationAt(blockPos);
Expand Down Expand Up @@ -1070,6 +1132,17 @@ static PaintResult paintArea(MapExtras::MapCache& map, const df::coord& pos1, co
else if (target.aquifer > 0)
Maps::setAreaAquifer(pos1, pos2, target.aquifer == 2, filter);

df::coord lo(std::min(pos1.x, pos2.x), std::min(pos1.y, pos2.y), std::min(pos1.z, pos2.z));
df::coord hi(std::max(pos1.x, pos2.x), std::max(pos1.y, pos2.y), std::max(pos1.z, pos2.z));
for (int16_t z = lo.z; z <= hi.z; z++)
for (int16_t x = lo.x; x <= hi.x; x++)
for (int16_t y = lo.y; y <= hi.y; y++)
{
df::coord pos(x, y, z);
if (filter(pos, nullptr))
removePlantIfUnsupported(pos);
}

// force the game to recompute its walkability cache on the next tick
world->reindex_pathfinding = true;
}
Expand Down Expand Up @@ -1116,6 +1189,8 @@ static PaintResult paintTile(MapExtras::MapCache &map, const df::coord &pos,
else if (target.aquifer > 0)
Maps::setTileAquifer(pos, target.aquifer == 2);

removePlantIfUnsupported(pos);

// force the game to recompute its walkability cache on the next tick
world->reindex_pathfinding = true;
}
Expand Down
118 changes: 118 additions & 0 deletions test/plugins/tiletypes.lua
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
config.mode = 'fortress'
config.target = 'tiletypes'

local function set_cursor(pos)
df.global.cursor.x = pos.x
df.global.cursor.y = pos.y
df.global.cursor.z = pos.z
end

local function reset_cursor()
df.global.cursor.x = -30000
df.global.cursor.y = -30000
df.global.cursor.z = -30000
end

-- options is a list of "<option> <value>" paint spec strings; each is emitted
-- as its own paint command since a paint command consumes a single
-- option/value pair
local function paint_at(pos, options)
set_cursor(pos)
local parts = {'tiletypes-command point', 'paint any'}
for _, opt in ipairs(options) do
table.insert(parts, 'paint ' .. opt)
end
table.insert(parts, 'run')
return dfhack.run_command_silent(table.concat(parts, ' ; '))
end

local function tiletype_spec(tt)
local attrs = df.tiletype.attrs[tt]
return {
'shape ' .. (df.tiletype_shape[attrs.shape] or 'ANY'),
'material ' .. (df.tiletype_material[attrs.material] or 'ANY'),
'special ' .. (df.tiletype_special[attrs.special] or 'ANY'),
'variant ' .. (df.tiletype_variant[attrs.variant] or 'ANY'),
}
end

local WALL_SPEC = {'shape WALL', 'material SOIL', 'special NORMAL'}

local function find_plant(want_tree)
for _, plant in ipairs(df.global.world.plants.all) do
if want_tree == (plant.tree_info ~= nil) and
dfhack.maps.getTileType(plant.pos) then
return plant
end
end
end

local function check_plant_survival(pos, expect_removed)
local plant = dfhack.maps.getPlantAtTile(pos)
if expect_removed then
expect.nil_(plant)
else
expect.ne(nil, plant)
end
end

function test.painting_away_tile_removes_plant()
local plant = find_plant(false)
if not plant then qerror('no shrub or sapling found on the map') end
local pos = xyz2pos(plant.pos.x, plant.pos.y, plant.pos.z)
local orig_tt = dfhack.maps.getTileType(pos)
dfhack.with_finalize(function()
paint_at(pos, tiletype_spec(orig_tt))
reset_cursor()
end, function()
check_plant_survival(pos, false)
local _, status = paint_at(pos, WALL_SPEC)
expect.eq(CR_OK, status)
check_plant_survival(pos, true)
end)
end

function test.painting_compatible_tile_keeps_plant()
local plant = find_plant(false)
if not plant then qerror('no shrub or sapling found on the map') end
local pos = xyz2pos(plant.pos.x, plant.pos.y, plant.pos.z)
dfhack.with_finalize(function()
reset_cursor()
end, function()
local orig_tt = dfhack.maps.getTileType(pos)
local _, status = paint_at(pos, tiletype_spec(orig_tt))
expect.eq(CR_OK, status)
check_plant_survival(pos, false)
end)
end

function test.painting_trunk_removes_tree()
local plant = find_plant(true)
if not plant then qerror('no grown tree found on the map') end
local pos = xyz2pos(plant.pos.x, plant.pos.y, plant.pos.z)
local orig_tt = dfhack.maps.getTileType(pos)
dfhack.with_finalize(function()
paint_at(pos, tiletype_spec(orig_tt))
reset_cursor()
end, function()
check_plant_survival(pos, false)
local _, status = paint_at(pos, WALL_SPEC)
expect.eq(CR_OK, status)
check_plant_survival(pos, true)
end)
end

function test.tiletypes_needs_console()
local _, status = dfhack.run_command_silent('tiletypes')
expect.eq(CR_NEEDS_CONSOLE, status)
end

function test.command_help_runs()
local _, status = dfhack.run_command_silent('tiletypes-command help')
expect.eq(CR_OK, status)
end

function test.here_point_bad_option_is_failure()
local _, status = dfhack.run_command_silent('tiletypes-here-point --bogus')
expect.eq(CR_FAILURE, status)
end
Loading