diff --git a/docs/changelog.txt b/docs/changelog.txt index be0b51c69e..bfb2d0b0d3 100644 --- a/docs/changelog.txt +++ b/docs/changelog.txt @@ -70,10 +70,13 @@ Template for new versions: - ``Units::teleport``: update unit occupancy on the full 3x3 footprint of EQUIPMENT units (e.g. wagons) instead of only their center tile - `3dveins`: fix failure on embarks that cross midmap tiles - `aquifer`: make ``--skip-top`` and top-relative ``--levels`` take effect for the ``drain``, ``convert``, and ``add`` actions instead of being silently ignored +- `aquifer`: make ``--help``/``-h`` actually print help instead of running a map-wide ``list`` +- `autochop`: fix crash when a burrow-based command names a burrow that doesn't exist - `autodump`: ``destroy`` no longer leaves the contents of destroyed containers in limbo or crashes when destroying unit-held items - `buildingplan`: fix roller material estimate asking for one chain per tile instead of one chain total - `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 +- `burrow`: fix ``tiles clear`` and other tile operations silently failing because burrow block z-coordinates were stored incorrectly in the ``Burrows`` module - `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 - Fixed persistent site data API (``dfhack.persistent.saveSiteData``/``getSiteData``) failing on newly reclaimed fortresses until the first save diff --git a/library/modules/Burrows.cpp b/library/modules/Burrows.cpp index 17ab0389d4..548ef98d87 100644 --- a/library/modules/Burrows.cpp +++ b/library/modules/Burrows.cpp @@ -204,7 +204,9 @@ df::block_burrow *Burrows::getBlockMask(df::burrow *burrow, df::map_block *block prev->next = link; df::coord base(world->map.region_x*3,world->map.region_y*3,world->map.region_z); - df::coord pos = base + block->map_pos/16; + // map_pos x/y are tile coordinates while z is already a z-level + df::coord pos(base.x + block->map_pos.x/16, + base.y + block->map_pos.y/16, base.z + block->map_pos.z); burrow->block_x.push_back(pos.x); burrow->block_y.push_back(pos.y); @@ -225,7 +227,9 @@ bool Burrows::deleteBlockMask(df::burrow *burrow, df::map_block *block, df::bloc return false; df::coord base(world->map.region_x*3,world->map.region_y*3,world->map.region_z); - df::coord pos = base + block->map_pos/16; + // map_pos x/y are tile coordinates while z is already a z-level + df::coord pos(base.x + block->map_pos.x/16, + base.y + block->map_pos.y/16, base.z + block->map_pos.z); destroyBurrowMask(mask); diff --git a/plugins/lua/aquifer.lua b/plugins/lua/aquifer.lua index d735a380d1..e9eab45cb8 100644 --- a/plugins/lua/aquifer.lua +++ b/plugins/lua/aquifer.lua @@ -84,7 +84,7 @@ function parse_commandline(args) {'z', 'cur-zlevel', handler=function() opts.curz = true end}, }) - if help or positionals[1] == 'help' then + if opts.help or positionals[1] == 'help' then print(dfhack.script_help()) return false end diff --git a/plugins/lua/autochop.lua b/plugins/lua/autochop.lua index a1a7368821..768bee61a4 100644 --- a/plugins/lua/autochop.lua +++ b/plugins/lua/autochop.lua @@ -38,6 +38,9 @@ local function do_set_burrow_config(var_name, val, burrows) end for _,bspec in ipairs(burrows) do local config = autochop_getBurrowConfig(bspec) + if not config then + qerror(('burrow not found: %s'):format(bspec)) + end config[var_name] = val autochop_setBurrowConfig(config.id, config.chop, config.clearcut, config.protect_brewable, config.protect_edible, diff --git a/test/plugins/aquifer.lua b/test/plugins/aquifer.lua new file mode 100644 index 0000000000..b47d36fbcb --- /dev/null +++ b/test/plugins/aquifer.lua @@ -0,0 +1,152 @@ +config.mode = 'fortress' +config.target = 'aquifer' + +local aquifer = require('plugins.aquifer') + +local function capture(fn) + local calls = {} + mock.patch({ + {aquifer, 'aquifer_list', function(pos1, pos2, levels, leaky) + calls.list = {pos1=pos1, pos2=pos2, levels=levels, leaky=leaky} + end}, + {aquifer, 'aquifer_drain', function(aq, pos1, pos2, skip, levels, leaky, all) + calls.drain = {aq=aq, pos1=pos1, pos2=pos2, skip=skip, + levels=levels, leaky=leaky, all=all} + return 7 + end}, + {aquifer, 'aquifer_convert', function(aq, pos1, pos2, skip, levels, leaky, all) + calls.convert = {aq=aq, pos1=pos1, pos2=pos2, skip=skip, + levels=levels, leaky=leaky, all=all} + return 5 + end}, + {aquifer, 'aquifer_add', function(aq, pos1, pos2, skip, levels, leaky, all) + calls.add = {aq=aq, pos1=pos1, pos2=pos2, skip=skip, + levels=levels, leaky=leaky, all=all} + return 3 + end}, + }, fn) + return calls +end + +function test.help_returns_false() + -- regression test: --help used to run a map-wide list instead + local called = false + mock.patch({ + {aquifer, 'aquifer_list', function() called = true end}, + -- helpdb can't resolve the script entry when called via require() + {dfhack, 'script_help', function() return 'test help' end}, + }, function() + expect.false_(aquifer.parse_commandline({'--help'})) + expect.false_(aquifer.parse_commandline({'-h'})) + expect.false_(aquifer.parse_commandline({'help'})) + end) + expect.false_(called) +end + +function test.list_defaults_to_all_levels() + local calls = capture(function() + expect.true_(aquifer.parse_commandline({'list'})) + end) + expect.eq(0, calls.list.pos1.x) + expect.eq(df.global.world.map.z_count - 1, calls.list.pos2.z) +end + +function test.list_cur_zlevel() + local calls = capture(function() + expect.true_(aquifer.parse_commandline({'list', '--cur-zlevel'})) + end) + local curz = df.global.window_z + expect.eq(curz, calls.list.pos1.z) + expect.eq(curz, calls.list.pos2.z) +end + +function test.coords_normalize_min_max() + local calls = capture(function() + expect.true_(aquifer.parse_commandline( + {'list', '9,9,100', '3,4,99'})) + end) + expect.eq(3, calls.list.pos1.x) + expect.eq(9, calls.list.pos2.x) + expect.eq(4, calls.list.pos1.y) + expect.eq(9, calls.list.pos2.y) + expect.eq(99, calls.list.pos1.z) + expect.eq(100, calls.list.pos2.z) +end + +function test.single_coord_defaults_to_cursor_or_same_pos() + local calls = capture(function() + expect.true_(aquifer.parse_commandline({'list', '3,4,99'})) + end) + expect.eq(3, calls.list.pos1.x) + if df.global.window_z == 99 then + -- cursor would need to be on z 99; either way pos2 is consistent + expect.true_(calls.list.pos2.x == 3 or calls.list.pos2.x ~= nil) + end +end + +function test.drain_requires_no_aquifer_type() + local calls = capture(function() + expect.true_(aquifer.parse_commandline({'drain', '--cur-zlevel'})) + end) + expect.eq('all', calls.drain.aq) + expect.eq(0, calls.drain.skip) +end + +function test.add_and_convert_require_aquifer_type() + capture(function() + expect.error_match('must specify an aquifer type', + function() aquifer.parse_commandline({'add', '--cur-zlevel'}) end) + expect.error_match('must specify an aquifer type', + function() aquifer.parse_commandline({'convert', '--cur-zlevel'}) end) + end) +end + +function test.add_light_with_skip_and_levels() + local calls = capture(function() + expect.true_(aquifer.parse_commandline( + {'add', 'light', '--cur-zlevel', '--skip-top', '2'})) + end) + expect.eq('light', calls.add.aq) + expect.eq(2, calls.add.skip) + expect.eq(1, calls.add.levels) +end + +function test.levels_option_with_zup() + -- --zup keeps the bottom N levels of the range + local calls = capture(function() + expect.true_(aquifer.parse_commandline( + {'drain', 'heavy', '0,0,90', '10,10,95', '--zup', '--levels', '2'})) + end) + expect.eq(90, calls.drain.pos1.z) + expect.eq(91, calls.drain.pos2.z) + expect.eq(2, calls.drain.levels) + expect.eq('heavy', calls.drain.aq) +end + +function test.levels_option_with_zdown() + -- --zdown keeps the top N levels of the range + local calls = capture(function() + expect.true_(aquifer.parse_commandline( + {'drain', 'heavy', '0,0,90', '10,10,95', '--zdown', '--levels', '2'})) + end) + expect.eq(94, calls.drain.pos1.z) + expect.eq(95, calls.drain.pos2.z) + expect.eq(2, calls.drain.levels) +end + +function test.invalid_levels_and_skip_top_rejected() + expect.error(function() + aquifer.parse_commandline({'drain', '--levels', '0'}) + end) + expect.error(function() + aquifer.parse_commandline({'drain', '--skip-top', '-1'}) + end) +end + +function test.unknown_action_falls_back_to_list() + -- positionals that aren't actions are treated as coords; an unparseable + -- coord raises an error through argparse + expect.error(function() + aquifer.parse_commandline({'bogus', '--cur-zlevel'}) + end) +end diff --git a/test/plugins/autochop.lua b/test/plugins/autochop.lua new file mode 100644 index 0000000000..9cfbb9ffc8 --- /dev/null +++ b/test/plugins/autochop.lua @@ -0,0 +1,111 @@ +config.mode = 'fortress' +config.target = 'autochop' + +local autochop = require('plugins.autochop') + +local function with_targets(fn) + local old_max, old_min = autochop.autochop_getTargets() + return dfhack.with_finalize( + function() + autochop.autochop_setTargets(old_max, old_min) + autochop.autochop_undesignate() + end, + fn) +end + +local function with_burrow(fn) + local burrows = df.global.plotinfo.burrows + local b = df.burrow:new() + b.id = burrows.next_id + burrows.next_id = burrows.next_id + 1 + b.name = 'DFHACK_TEST_AUTOCHOP' + burrows.list:insert('#', b) + return dfhack.with_finalize( + function() + for i = #burrows.list - 1, 0, -1 do + if burrows.list[i] == b then + burrows.list:erase(i) + end + end + end, + function() fn(b) end) +end + +function test.target_command_sets_max_and_min() + with_targets(function() + expect.true_(autochop.parse_commandline('target', '50', '40')) + local max, min = autochop.autochop_getTargets() + expect.eq(50, max) + expect.eq(40, min) + end) +end + +function test.target_defaults_min_to_80_percent_of_max() + with_targets(function() + expect.true_(autochop.parse_commandline('target', '200')) + local max, min = autochop.autochop_getTargets() + expect.eq(200, max) + expect.eq(160, min) + end) +end + +function test.target_rejects_invalid_values() + expect.error_match('non%-negative integer', + function() autochop.setTargets('-1') end) + expect.error_match('non%-negative integer', + function() autochop.setTargets('notanumber') end) + expect.error_match('between 0 and the maximum', + function() autochop.setTargets('50', '60') end) + expect.error_match('between 0 and the maximum', + function() autochop.setTargets('50', '-1') end) +end + +function test.chop_and_nochop_toggle_burrow() + with_burrow(function(b) + expect.true_(autochop.parse_commandline('chop', b.name)) + expect.eq(1, autochop.autochop_getBurrowConfig(b.id).chop) + expect.true_(autochop.parse_commandline('nochop', b.name)) + expect.eq(0, autochop.autochop_getBurrowConfig(b.id).chop) + end) +end + +function test.clearcut_and_noclearcut_toggle_burrow() + with_burrow(function(b) + expect.true_(autochop.parse_commandline('clearcut', b.name)) + expect.eq(1, autochop.autochop_getBurrowConfig(b.id).clearcut) + expect.true_(autochop.parse_commandline('noclear', b.name)) + expect.eq(0, autochop.autochop_getBurrowConfig(b.id).clearcut) + end) +end + +function test.protect_sets_each_listed_type() + with_burrow(function(b) + expect.true_(autochop.parse_commandline('protect', 'brewable,edible', b.name)) + local config = autochop.autochop_getBurrowConfig(b.id) + expect.eq(1, config.protect_brewable) + expect.eq(1, config.protect_edible) + expect.eq(0, config.protect_cookable) + expect.true_(autochop.parse_commandline('unprotect', 'edible', b.name)) + config = autochop.autochop_getBurrowConfig(b.id) + expect.eq(1, config.protect_brewable) + expect.eq(0, config.protect_edible) + end) +end + +function test.burrow_command_requires_a_burrow() + expect.error_match('no target burrows', + function() autochop.parse_commandline('chop') end) + expect.error_match('burrow not found', + function() autochop.parse_commandline('chop', 'DFHACK_NONEXISTENT_BURROW') end) +end + +function test.help_and_unknown_command_return_false() + expect.false_(autochop.parse_commandline('help')) + expect.false_(autochop.parse_commandline('--help')) + expect.false_(autochop.parse_commandline('bogus')) +end + +function test.status_and_no_command_return_true() + expect.true_(autochop.parse_commandline()) + expect.true_(autochop.parse_commandline('status')) +end diff --git a/test/plugins/burrow.lua b/test/plugins/burrow.lua new file mode 100644 index 0000000000..dc557cd89d --- /dev/null +++ b/test/plugins/burrow.lua @@ -0,0 +1,102 @@ +config.mode = 'fortress' +config.target = 'burrow' + +local burrow = require('plugins.burrow') + +local function with_burrow(fn, name) + local burrows = df.global.plotinfo.burrows + -- burrow name lookups return the first match, so remove any test burrows + -- leaked by previous runs before creating ours + name = name or 'DFHACK_TEST_BURROW' + for i = #burrows.list - 1, 0, -1 do + if burrows.list[i].name == name then + dfhack.burrows.clearTiles(burrows.list[i]) + burrows.list[i]:delete() + burrows.list:erase(i) + end + end + local b = df.burrow:new() + b.id = burrows.next_id + burrows.next_id = burrows.next_id + 1 + b.name = name + burrows.list:insert('#', b) + return dfhack.with_finalize( + function() + for i = #burrows.list - 1, 0, -1 do + if burrows.list[i] == b then + burrows.list:erase(i) + end + end + dfhack.burrows.clearTiles(b) + dfhack.burrows.clearUnits(b) + b:delete() + end, + function() fn(b) end) +end + +local function assigned(b, x, y, z) + return dfhack.burrows.isAssignedTile(b, xyz2pos(x, y, z)) +end + +function test.tiles_box_add_and_remove() + with_burrow(function(b) + expect.true_(burrow.parse_commandline( + 'tiles', 'box-add', b.name, '10,10,120', '12,12,120')) + expect.true_(assigned(b, 10, 10, 120)) + expect.true_(assigned(b, 12, 12, 120)) + expect.false_(assigned(b, 9, 10, 120)) + expect.false_(assigned(b, 10, 10, 119)) + + expect.true_(burrow.parse_commandline( + 'tiles', 'box-remove', b.name, '11,11,120', '12,12,120')) + expect.true_(assigned(b, 10, 10, 120)) + expect.false_(assigned(b, 12, 12, 120)) + end) +end + +function test.tiles_clear() + with_burrow(function(b) + burrow.parse_commandline('tiles', 'box-add', b.name, '10,10,120', '12,12,120') + expect.true_(assigned(b, 10, 10, 120)) + expect.true_(burrow.parse_commandline('tiles', 'clear', b.name)) + expect.false_(assigned(b, 10, 10, 120)) + end) +end + +function test.tiles_add_copies_from_other_burrow() + with_burrow(function(src) + burrow.parse_commandline('tiles', 'box-add', src.name, '20,20,120', '21,21,120') + with_burrow(function(dst) + expect.true_(burrow.parse_commandline( + 'tiles', 'add', dst.name, src.name)) + expect.true_(assigned(dst, 20, 20, 120)) + expect.true_(assigned(src, 20, 20, 120)) + end, 'DFHACK_TEST_BURROW_DST') + end, 'DFHACK_TEST_BURROW_SRC') +end + +function test.units_add_copies_from_other_burrow() + -- create a source burrow with a unit assigned directly + with_burrow(function(src) + local unit = dfhack.units.getCitizens()[1] + if not unit then return end + dfhack.burrows.setAssignedUnit(src, unit, true) + expect.true_(dfhack.burrows.isAssignedUnit(src, unit)) + with_burrow(function(dst) + expect.true_(burrow.parse_commandline( + 'units', 'add', dst.name, src.name)) + expect.true_(dfhack.burrows.isAssignedUnit(dst, unit)) + expect.true_(burrow.parse_commandline( + 'units', 'remove', dst.name, src.name)) + expect.false_(dfhack.burrows.isAssignedUnit(dst, unit)) + expect.true_(dfhack.burrows.isAssignedUnit(src, unit)) + end, 'DFHACK_TEST_BURROW_DST') + end, 'DFHACK_TEST_BURROW_SRC') +end + +function test.help_and_bad_mode_return_false() + expect.false_(burrow.parse_commandline('help')) + expect.false_(burrow.parse_commandline('--help')) + expect.false_(burrow.parse_commandline('bogus', 'clear')) + expect.false_(burrow.parse_commandline('tiles', 'bogus')) +end diff --git a/test/plugins/dig-now.lua b/test/plugins/dig-now.lua new file mode 100644 index 0000000000..9a3ae9da86 --- /dev/null +++ b/test/plugins/dig-now.lua @@ -0,0 +1,96 @@ +config.mode = 'fortress' +config.target = 'dig-now' + +local dignow = require('plugins.dig-now') + +local function make_opts() + return { + boulder_percents={}, + dump_pos={}, + start={}, + ['end']={}, + } +end + +function test.clean_sets_zero_percentages() + local opts = make_opts() + dignow.parse_commandline(opts, '--clean') + expect.eq(0, opts.boulder_percents.layer) + expect.eq(0, opts.boulder_percents.vein) + expect.eq(0, opts.boulder_percents.small_cluster) + expect.eq(0, opts.boulder_percents.deep) +end + +function test.everywhere_sets_full_percentages() + local opts = make_opts() + dignow.parse_commandline(opts, '--everywhere') + expect.eq(100, opts.boulder_percents.layer) + expect.eq(100, opts.boulder_percents.deep) +end + +function test.percentages_custom_values() + local opts = make_opts() + dignow.parse_commandline(opts, '--percentages', '0,33,100,100') + expect.eq(0, opts.boulder_percents.layer) + expect.eq(33, opts.boulder_percents.vein) + expect.eq(100, opts.boulder_percents.small_cluster) + expect.eq(100, opts.boulder_percents.deep) +end + +function test.percentages_rejects_bad_values() + local opts = make_opts() + expect.error_match('invalid percentages', + function() dignow.parse_commandline(opts, '-p', '0,33,101,100') end) + expect.error_match('invalid percentages', + function() dignow.parse_commandline(opts, '-p', '0,33,-5,100') end) + expect.error(function() + dignow.parse_commandline(opts, '-p', '0,33,100') end) +end + +function test.coords_normalize_start_end() + local opts = make_opts() + dignow.parse_commandline(opts, '9,8,95', '3,4,94') + expect.eq(3, opts.start.x) + expect.eq(9, opts['end'].x) + expect.eq(4, opts.start.y) + expect.eq(8, opts['end'].y) + expect.eq(94, opts.start.z) + expect.eq(95, opts['end'].z) +end + +function test.single_coord_sets_same_end() + local opts = make_opts() + dignow.parse_commandline(opts, '5,6,94') + expect.eq(5, opts.start.x) + expect.eq(5, opts['end'].x) + expect.eq(6, opts['end'].y) + expect.eq(94, opts['end'].z) +end + +function test.cur_zlevel_covers_whole_map() + local opts = make_opts() + dignow.parse_commandline(opts, '--cur-zlevel') + local map = df.global.world.map + expect.eq(0, opts.start.x) + expect.eq(map.x_count - 1, opts['end'].x) + expect.eq(map.y_count - 1, opts['end'].y) + expect.eq(df.global.window_z, opts.start.z) + expect.eq(df.global.window_z, opts['end'].z) +end + +function test.help_flag_leaves_opts_untouched() + local opts = make_opts() + dignow.parse_commandline(opts, '--help') + expect.true_(opts.help) + expect.nil_(opts.start.x) + dignow.parse_commandline(opts, 'help') + expect.true_(opts.help) +end + +function test.dump_pos_parses_coords() + local opts = make_opts() + dignow.parse_commandline(opts, '--dump', '1,2,90') + expect.eq(1, opts.dump_pos.x) + expect.eq(2, opts.dump_pos.y) + expect.eq(90, opts.dump_pos.z) +end diff --git a/test/plugins/regrass.lua b/test/plugins/regrass.lua new file mode 100644 index 0000000000..73953564f3 --- /dev/null +++ b/test/plugins/regrass.lua @@ -0,0 +1,71 @@ +config.mode = 'fortress' +config.target = 'regrass' + +local regrass = require('plugins.regrass') + +local function parse(args) + local opts, pos1, pos2 = {}, {}, {} + return regrass.parse_commandline(opts, pos1, pos2, args), opts, pos1, pos2 +end + +function test.plant_requires_force() + expect.error_match('without %-%-force', + function() parse({'--plant', 'GRASS'}) end) +end + +function test.plant_with_force_resolves_grass_by_name() + local grasses = df.global.world.raws.plants.grasses + if #grasses == 0 then return end + local grass = grasses[0] + local _, opts = parse({'--force', '--plant', grass.id}) + expect.eq(grass.index, opts.forced_plant) +end + +function test.plant_with_force_resolves_grass_by_index() + local _, opts = parse({'--force', '--plant', '0'}) + expect.eq(0, opts.forced_plant) +end + +function test.unknown_plant_errors() + expect.error_match('Plant raw not found', + function() parse({'--force', '--plant', 'NO_SUCH_PLANT_XYZ'}) end) +end + +function test.list_flag_forces_plant_listing() + local _, opts = parse({'--list'}) + expect.eq(-2, opts.forced_plant) +end + +function test.force_without_plant_picks_random_grass() + if #df.global.world.raws.plants.grasses == 0 then return end + local _, opts = parse({'--force'}) + expect.true_(type(opts.forced_plant) == 'number') + expect.true_(opts.forced_plant >= 0) +end + +function test.option_flags_set() + local _, opts = parse({'--max', '--new', '--ashes', '--buildings', + '--mud', '--block', '--zlevel'}) + expect.true_(opts.max_grass) + expect.true_(opts.new_grass) + expect.true_(opts.ashes) + expect.true_(opts.buildings) + expect.true_(opts.mud) + expect.true_(opts.block) + expect.true_(opts.zlevel) +end + +function test.too_many_positionals_error() + expect.error_match('Too many positionals', + function() parse({'1,1,1', '2,2,2', '3,3,3'}) end) +end + +function test.positionals_assign_coords() + local _, _, pos1, pos2 = parse({'10,11,95', '20,21,96'}) + expect.eq(10, pos1.x) + expect.eq(11, pos1.y) + expect.eq(95, pos1.z) + expect.eq(20, pos2.x) + expect.eq(21, pos2.y) + expect.eq(96, pos2.z) +end