diff --git a/docs/changelog.txt b/docs/changelog.txt index 4dae12dbfc..9ac57f6fce 100644 --- a/docs/changelog.txt +++ b/docs/changelog.txt @@ -91,6 +91,8 @@ Template for new versions: - `burrow`: fix ``tiles clear`` and other tile operations silently failing because burrow block z-coordinates were stored incorrectly in the ``Burrows`` module - `export-world-map`: fix crash exporting site data for a site whose owner has an undefined race - `gui/quickfort`: show smoothing designations immediately after applying a blueprint +- `liquids`: allow painting liquids and obsidian into unallocated sky blocks +- `liquids`: update light/outside flags of covered tiles when painting obsidian or river sources - `overlay`: overlay hotkeys no longer steal typed characters while the game is capturing text input, e.g. when renaming a building - `sort`: fix the Places/Workshops and task searches missing workshops by their current display names (e.g. "Stoneworker's Workshop", "Screw Press") - `stocks`: overlay now resets scroll position when collapsing categories so the item list is no longer left blank and unscrollable @@ -115,6 +117,7 @@ Template for new versions: - Added ``Maps::describeSurroundings`` to get surroundings classification from savagery and evilness - Added ``Maps::getCurrentWeather`` to determine the current weather on the map. - Added ``Maps::forEachTile`` with ``Maps::TileFilter``/``Maps::TileActions`` to scan a cuboid of tiles, matching tiles by tiletype, tiletype attributes, and designation/occupancy bits, and counting, rewriting tiletypes, editing designation/occupancy bits, spawning constructions, or invoking a callback on matching tiles +- Added ``MapExtras::MapCache::propagateVerticalFlags`` to update the light/outside/subterranean flags of tiles below a tile that changed between an open and a solid shape - Added ``MiscUtils::print_range`` to print ranges with prefix, separator, and suffix - Added ``Units::breathes`` and ``Units::getBreathingState`` - Deprecated ``World::ReadCurrentWeather`` in favor of ``Maps::getCurrentWeather`` diff --git a/library/include/modules/MapCache.h b/library/include/modules/MapCache.h index c4502cc4b9..7428536afc 100644 --- a/library/include/modules/MapCache.h +++ b/library/include/modules/MapCache.h @@ -433,6 +433,11 @@ class DFHACK_EXPORT MapCache return b ? b->Allocate() : false; } + /// Propagate the light, outside, and subterranean flags of the tile at + /// pos to the tiles below it. Call after the tile at pos changed + /// between an open and a solid shape. + void propagateVerticalFlags(DFCoord pos); + /// delete the block from memory void discardBlock(Block *block); diff --git a/library/modules/MapCache.cpp b/library/modules/MapCache.cpp index a14d81288e..8f8f2eb4c7 100644 --- a/library/modules/MapCache.cpp +++ b/library/modules/MapCache.cpp @@ -1412,6 +1412,50 @@ void MapExtras::MapCache::discardBlock(Block *block) delete block; } +void MapExtras::MapCache::propagateVerticalFlags(DFCoord pos) +{ + df::tiletype_shape shape = tileShape(tiletypeAt(pos)); + bool open = shape == tiletype_shape::EMPTY || shape == tiletype_shape::RAMP_TOP; + + df::tile_designation td = designationAt(pos); + if (open) + { + if (!ensureBlockAt(DFCoord(pos.x, pos.y, pos.z+1))) { + // only the sky above + td.bits.light = true; + td.bits.outside = true; + td.bits.subterranean = false; + } + } + else + { + // a solid tile covers the column below; subterranean is a property + // of each tile and is preserved + td.bits.light = false; + td.bits.outside = false; + } + + for (int32_t z = pos.z - 1; + z >= 0 && ensureBlockAt(DFCoord(pos.x, pos.y, z)); + --z) + { + DFCoord pos_below(pos.x, pos.y, z); + df::tile_designation td_below = designationAt(pos_below); + bool sub = open ? td.bits.subterranean : td_below.bits.subterranean; + if (td_below.bits.light == td.bits.light + && td_below.bits.outside == td.bits.outside + && td_below.bits.subterranean == sub) + break; + td_below.bits.light = td.bits.light; + td_below.bits.outside = td.bits.outside; + td_below.bits.subterranean = sub; + setDesignationAt(pos_below, td_below); + shape = tileShape(tiletypeAt(pos_below)); + if (shape != tiletype_shape::EMPTY && shape != tiletype_shape::RAMP_TOP) + break; + } +} + void MapExtras::MapCache::resetTags() { for (auto it = blocks.begin(); it != blocks.end(); ++it) diff --git a/plugins/liquids.cpp b/plugins/liquids.cpp index 0b5526d996..c104102aee 100644 --- a/plugins/liquids.cpp +++ b/plugins/liquids.cpp @@ -150,6 +150,64 @@ struct OperationMode { command_result df_liquids_execute(color_ostream &out); command_result df_liquids_execute(color_ostream &out, OperationMode &mode, df::coord pos); +// allocate the map block containing pos if it doesn't exist yet; newly +// allocated sky blocks are filled with open space tiles like natural sky +static void ensure_block(MapExtras::MapCache &mcache, const DFCoord &pos) +{ + auto block = mcache.BlockAtTile(pos); + if (!block || block->is_valid()) + return; + if (!block->Allocate()) + return; + for (int x = 0; x < 16; x++) + for (int y = 0; y < 16; y++) + block->setTiletypeAt(df::coord2d(x, y), tiletype::OpenSpace); +} + +// ensure the target map blocks exist so that liquids and obsidian can be +// placed in unallocated sky blocks +static void ensure_brush_blocks(MapExtras::MapCache &mcache, + const OperationMode &mode, const DFCoord &cursor) +{ + switch (mode.brush) + { + case B_POINT: + case B_BLOCK: + ensure_block(mcache, cursor); + break; + case B_RANGE: + { + // RectangleBrush paints the size-sized box extending + // forward from the cursor; note that z is already in map + // block units so only x and y get scaled down + DFCoord lo = cursor / 16; + DFCoord hi = (cursor + mode.size - DFCoord(1,1,1)) / 16; + for (int32_t z = cursor.z; z < cursor.z + mode.size.z; z++) + for (int32_t x = lo.x; x <= hi.x; x++) + for (int32_t y = lo.y; y <= hi.y; y++) + ensure_block(mcache, DFCoord(x*16, y*16, z)); + } + break; + case B_COLUMN: + // ensure blocks upward while the column stays open, mirroring + // ColumnBrush's stopping condition + for (int32_t z = cursor.z; z < (int32_t)mcache.maxZ(); z++) + { + DFCoord pos(cursor.x, cursor.y, z); + ensure_block(mcache, pos); + df::tiletype tt = mcache.tiletypeAt(pos); + if (!DFHack::LowPassable(tt) && + !(z == cursor.z && DFHack::HighPassable(tt))) + break; + } + break; + case B_FLOOD: + // flood fill only travels through water tiles, which unallocated + // blocks can never contain + break; + } +} + static void print_prompt(std::ostream &str, OperationMode &cur_mode) { str <<"[" << paint_mode_name[cur_mode.paint] << ":" << brush_name[cur_mode.brush]; @@ -446,6 +504,11 @@ command_result df_liquids_execute(color_ostream &out, OperationMode &cur_mode, d } MapCache mcache; + + // allocate any unallocated target blocks so that liquids and obsidian + // can be placed in the sky + ensure_brush_blocks(mcache, cur_mode, cursor); + coord_vec all_tiles = brush->points(mcache,cursor); // Force the game to recompute its walkability cache @@ -465,6 +528,7 @@ command_result df_liquids_execute(color_ostream &out, OperationMode &cur_mode, d des.bits.flow_size = 0; des.bits.flow_forbid = false; mcache.setDesignationAt(*iter, des); + mcache.propagateVerticalFlags(*iter); iter ++; } break; @@ -475,6 +539,7 @@ command_result df_liquids_execute(color_ostream &out, OperationMode &cur_mode, d while (iter != all_tiles.end()) { mcache.setTiletypeAt(*iter, findRandomVariant(tiletype::LavaFloor1)); + mcache.propagateVerticalFlags(*iter); iter ++; } break; @@ -497,6 +562,7 @@ command_result df_liquids_execute(color_ostream &out, OperationMode &cur_mode, d Block * b = mcache.BlockAt((*iter)/16); b->enableBlockUpdates(true); + mcache.propagateVerticalFlags(*iter); iter++; } break; diff --git a/test/plugins/liquids.lua b/test/plugins/liquids.lua index d7213ae47a..3b121a06c2 100644 --- a/test/plugins/liquids.lua +++ b/test/plugins/liquids.lua @@ -2,6 +2,7 @@ config.mode = 'fortress' config.target = 'liquids' local dwarfmode = require('gui.dwarfmode') +local liquids = require('plugins.liquids') local function find_floor_pos() for _, block in ipairs(df.global.world.map.map_blocks) do @@ -29,6 +30,20 @@ local function clear_cursor() dwarfmode.clearCursorPos() end +-- wait for any screen above the map to go away (e.g. a lingering load +-- screen, or the prerelease warning shown by dev builds on map load) +-- so that commands guarded by the cursor_hotkey UI check can run +local function ensure_dwarfmode_view() + delay_until(function() + local top = dfhack.gui.getCurViewscreen() + if df.viewscreen_dwarfmodest:is_instance(top) then return true end + if type(top.dismiss) == 'function' then + top:dismiss() + end + return false + end) +end + local function des_at(x, y, z) return select(1, dfhack.maps.getTileFlags(x, y, z)) end @@ -46,6 +61,7 @@ function test.liquids_here_spawns_magma_at_cursor() end, function() -- pause so the magma cannot spread before we clean it up dfhack.world.SetPauseState(true) + ensure_dwarfmode_view() set_cursor(x, y, z) local _, status = dfhack.run_command_silent('liquids-here') @@ -57,6 +73,251 @@ function test.liquids_here_spawns_magma_at_cursor() end) end +local OPEN_SHAPES = { + [df.tiletype_shape.EMPTY] = true, + [df.tiletype_shape.RAMP_TOP] = true, +} + +-- find an open tile directly above another open, sky-lit tile +local function find_stacked_open_tiles() + for _, block in ipairs(df.global.world.map.map_blocks) do + local below = block.map_pos.z >= 1 and + dfhack.maps.getTileBlock(block.map_pos.x, block.map_pos.y, + block.map_pos.z - 1) or nil + if below then + for x = 0, 15 do + for y = 0, 15 do + local des_below = below.designation[x][y] + if des_below.light and des_below.outside + and OPEN_SHAPES[df.tiletype.attrs[ + block.tiletype[x][y]].shape] + and OPEN_SHAPES[df.tiletype.attrs[ + below.tiletype[x][y]].shape] then + return block.map_pos.x + x, block.map_pos.y + y, + block.map_pos.z + end + end + end + end + end +end + +-- find an open, sky-lit tile in the topmost allocated block of a map +-- column whose column to the east also reaches that z (needed by the +-- range test) +local function find_sky_top_pos() + 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 not dfhack.maps.getTileBlock(bx, by, bz + 1) + and dfhack.maps.getTileBlock(bx + 16, by, bz) then + for x = 0, 15 do + for y = 0, 15 do + local tt = block.tiletype[x][y] + local des = block.designation[x][y] + if df.tiletype.attrs[tt].shape == df.tiletype_shape.EMPTY + and des.light and des.outside then + return bx + x, by + y, bz + end + end + end + end + end +end + +-- detach a block from block_index so its position reads as unallocated; +-- ensureTileBlock can then materialize it from the block below in the +-- same column. returns a function that restores the original block +local function unallocate_block(x, y, z) + local column = df.global.world.map.block_index + [math.floor(x / 16)][math.floor(y / 16)] + local block = column[z] + column[z] = nil + expect.ne(nil, block, 'test needs an allocated block to detach') + expect.eq(nil, dfhack.maps.getTileBlock(x, y, z), + 'test setup could not detach the block') + return function() column[z] = block end +end + +-- snapshot the light/outside/subterranean flags of the open column below +-- pos; paint may propagate coverage down through all of it +local function snapshot_column_flags(x, y, z) + local column = {} + for dz = z, 0, -1 do + local b = dfhack.maps.getTileBlock(x, y, dz) + if not b or not OPEN_SHAPES[ + df.tiletype.attrs[b.tiletype[x % 16][y % 16]].shape] then + break + end + local d = b.designation[x % 16][y % 16] + column[dz] = {block=b, light=d.light, outside=d.outside, + subterranean=d.subterranean} + end + return column +end + +local function restore_column_flags(x, y, column) + for dz, orig in pairs(column) do + local d = orig.block.designation[x % 16][y % 16] + d.light = orig.light + d.outside = orig.outside + d.subterranean = orig.subterranean + end +end + +function test.paint_obsidian_covers_tiles_below() + local x, y, z = find_stacked_open_tiles() + expect.ne(nil, x, 'test needs vertically adjacent open, lit tiles') + + local block = dfhack.maps.getTileBlock(x, y, z) + local orig_tt = block.tiletype[x % 16][y % 16] + local orig_des_whole = block.designation[x % 16][y % 16].whole + local column = snapshot_column_flags(x, y, z - 1) + + return dfhack.with_finalize(function() + block.tiletype[x % 16][y % 16] = orig_tt + block.designation[x % 16][y % 16].whole = orig_des_whole + restore_column_flags(x, y, column) + end, function() + liquids.paint(xyz2pos(x, y, z), 'point', 'obsidian') + + expect.eq(df.tiletype.LavaWall, dfhack.maps.getTileType(x, y, z)) + local des = des_at(x, y, z - 1) + expect.false_(des.light) + expect.false_(des.outside) + end) +end + +function test.paint_obsidian_floor_covers_tiles_below() + local x, y, z = find_stacked_open_tiles() + expect.ne(nil, x, 'test needs vertically adjacent open, lit tiles') + + local block = dfhack.maps.getTileBlock(x, y, z) + local orig_tt = block.tiletype[x % 16][y % 16] + local orig_des_whole = block.designation[x % 16][y % 16].whole + local column = snapshot_column_flags(x, y, z - 1) + + return dfhack.with_finalize(function() + block.tiletype[x % 16][y % 16] = orig_tt + block.designation[x % 16][y % 16].whole = orig_des_whole + restore_column_flags(x, y, column) + end, function() + liquids.paint(xyz2pos(x, y, z), 'point', 'obsidian_floor') + + local tt = dfhack.maps.getTileType(x, y, z) + expect.eq(df.tiletype_shape.FLOOR, df.tiletype.attrs[tt].shape) + expect.eq(df.tiletype_material.LAVA_STONE, + df.tiletype.attrs[tt].material) + local des = des_at(x, y, z - 1) + expect.false_(des.light) + expect.false_(des.outside) + end) +end + +function test.paint_river_source_covers_tiles_below() + local x, y, z = find_stacked_open_tiles() + expect.ne(nil, x, 'test needs vertically adjacent open, lit tiles') + + local block = dfhack.maps.getTileBlock(x, y, z) + local orig_tt = block.tiletype[x % 16][y % 16] + local orig_des_whole = block.designation[x % 16][y % 16].whole + local column = snapshot_column_flags(x, y, z - 1) + + local was_paused = dfhack.world.ReadPauseState() + return dfhack.with_finalize(function() + block.tiletype[x % 16][y % 16] = orig_tt + block.designation[x % 16][y % 16].whole = orig_des_whole + restore_column_flags(x, y, column) + dfhack.world.SetPauseState(was_paused) + end, function() + -- pause so the spawned water cannot spread before we clean it up + dfhack.world.SetPauseState(true) + liquids.paint(xyz2pos(x, y, z), 'point', 'riversource') + + expect.eq(df.tiletype.RiverSource, dfhack.maps.getTileType(x, y, z)) + local des = des_at(x, y, z - 1) + expect.false_(des.light) + expect.false_(des.outside) + end) +end + +function test.paint_water_allocates_sky_block() + local x, y, z = find_sky_top_pos() + expect.ne(nil, x, 'test needs a top-of-column sky block') + + -- detach the block so its position is unallocated; the paint should + -- recreate it via ensureTileBlock + local restore_block = unallocate_block(x, y, z) + + local was_paused = dfhack.world.ReadPauseState() + return dfhack.with_finalize(function() + -- swap the original block back in; the block allocated by the + -- paint remains in map_blocks but is no longer referenced + restore_block() + dfhack.world.SetPauseState(was_paused) + end, function() + -- pause so the spawned water cannot spread before we clean it up + dfhack.world.SetPauseState(true) + liquids.paint(xyz2pos(x, y, z), 'point', 'water', 7) + + local block = dfhack.maps.getTileBlock(x, y, z) + expect.ne(nil, block, 'paint did not allocate the block') + local des = des_at(x, y, z) + expect.eq(7, des.flow_size) + -- liquid_type is a 1-bit field exposed to Lua as a boolean + expect.eq(false, des.liquid_type) + end) +end + +function test.paint_obsidian_allocates_sky_block() + local x, y, z = find_sky_top_pos() + expect.ne(nil, x, 'test needs a top-of-column sky block') + + local restore_block = unallocate_block(x, y, z) + + -- the painted wall also covers open tiles in the block below + local column = snapshot_column_flags(x, y, z - 1) + return dfhack.with_finalize(function() + restore_block() + restore_column_flags(x, y, column) + end, function() + liquids.paint(xyz2pos(x, y, z), 'point', 'obsidian') + + local block = dfhack.maps.getTileBlock(x, y, z) + expect.ne(nil, block, 'paint did not allocate the block') + expect.eq(df.tiletype.LavaWall, dfhack.maps.getTileType(x, y, z)) + end) +end + +function test.paint_water_range_into_unallocated_sky() + local x, y, z = find_sky_top_pos() + expect.ne(nil, x, 'test needs adjacent top-of-column sky blocks') + + -- the range brush paints forward from the cursor, so a 4x1x1 range + -- starting near the block edge paints two tiles in each block + local bx = x - (x % 16) + local x0, y0 = bx + 14, y + local restore_west = unallocate_block(bx, y0, z) + local restore_east = unallocate_block(bx + 16, y0, z) + + local was_paused = dfhack.world.ReadPauseState() + return dfhack.with_finalize(function() + restore_east() + restore_west() + dfhack.world.SetPauseState(was_paused) + end, function() + -- pause so the spawned water cannot spread before we clean it up + dfhack.world.SetPauseState(true) + liquids.paint(xyz2pos(x0, y0, z), 'range', 'water', 5, + xyz2pos(4, 1, 1)) + + for dx = 0, 3 do + local des = des_at(x0 + dx, y0, z) + expect.eq(5, des.flow_size, + ('tile (%d,%d,%d)'):format(x0 + dx, y0, z)) + end + end) +end + function test.liquids_needs_console() -- the interactive liquid spawner requires a console; the dispatcher -- rejects non-console invocations before the plugin runs