diff --git a/docs/changelog.txt b/docs/changelog.txt index be0b51c69e..53af30e474 100644 --- a/docs/changelog.txt +++ b/docs/changelog.txt @@ -68,6 +68,8 @@ Template for new versions: - In ``Screen`` module, Fix out-of-bounds color table access when Lua pens use ``COLOR_RESET`` - rename the ``stockpiles`` tool tag to ``stockpile`` so it no longer collides with the `stockpiles` plugin in `gui/launcher` - ``Units::teleport``: update unit occupancy on the full 3x3 footprint of EQUIPMENT units (e.g. wagons) instead of only their center tile +- Fixed persistent site data API (``dfhack.persistent.saveSiteData``/``getSiteData``) failing on newly reclaimed fortresses until the first save +- `3dveins`: fix crash when a tile's biome cannot be resolved - `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 - `autodump`: ``destroy`` no longer leaves the contents of destroyed containers in limbo or crashes when destroying unit-held items @@ -75,8 +77,10 @@ Template for new versions: - `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 +- `export-world-map`: fix crash exporting site data for a site whose owner has an undefined race - `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 +- `strangemood`: fix crash when ``--id`` is given a non-numeric unit id +- `suspendmanager`: fix out-of-bounds parameter read when ``set`` is given too few arguments ## Misc Improvements - Added ``Coord2d`` and ``Coord3d`` C++ templates, providing a standard set operations for 2-tuples and 3-tuples of any numeric type diff --git a/plugins/3dveins.cpp b/plugins/3dveins.cpp index db25c75fbc..30f270fbc0 100644 --- a/plugins/3dveins.cpp +++ b/plugins/3dveins.cpp @@ -655,7 +655,9 @@ bool GeoBiome::init_layers() GeoLayer *VeinGenerator::mapLayer(Block *pb, df::coord2d tile) { int idx = pb->biomeIndexAt(tile); - GeoBiome *biome = biome_by_idx.at(idx); + if (idx < 0) + return NULL; + GeoBiome *biome = biome_by_idx[idx]; int lidx = pb->layerIndexAt(tile); if (unsigned(lidx) >= biome->layers.size()) diff --git a/plugins/export-world-map.cpp b/plugins/export-world-map.cpp index 48fc90fa16..880c44497b 100644 --- a/plugins/export-world-map.cpp +++ b/plugins/export-world-map.cpp @@ -218,9 +218,10 @@ static command_result export_sites(color_ostream &out) df::creature_raw *race = nullptr; if (owner){ race = df::creature_raw::find(owner->race); - DEBUG(warning, out).print("owner ({}) of site ({}) has undefined race ({})", owner->id, site->id, owner->race); if (!race) { - df::creature_raw::find(civ->race); + DEBUG(warning, out).print("owner ({}) of site ({}) has undefined race ({})\n", owner->id, site->id, owner->race); + if (civ) + race = df::creature_raw::find(civ->race); } } diff --git a/plugins/strangemood.cpp b/plugins/strangemood.cpp index 2ab221195d..ffb71aa476 100644 --- a/plugins/strangemood.cpp +++ b/plugins/strangemood.cpp @@ -6,6 +6,7 @@ #include "PluginManager.h" #include "modules/Gui.h" +#include "MiscUtils.h" #include "modules/Units.h" #include "modules/Items.h" #include "modules/Job.h" @@ -172,7 +173,13 @@ command_result df_strangemood (color_ostream &out, vector & parameters) out.printerr("No unit id specified!\n"); return CR_WRONG_USAGE; } - unit = df::unit::find(std::stoi(parameters[i])); + int id = string_to_int(parameters[i], -1); + if (id < 0) + { + out.printerr("Invalid unit id: '{}'\n", parameters[i]); + return CR_WRONG_USAGE; + } + unit = df::unit::find(id); if (!unit) return CR_FAILURE; } diff --git a/plugins/suspendmanager.cpp b/plugins/suspendmanager.cpp index 13a37d1901..f102b53ba9 100644 --- a/plugins/suspendmanager.cpp +++ b/plugins/suspendmanager.cpp @@ -890,7 +890,7 @@ static command_result do_command(color_ostream &out, vector ¶meters) return plugin_enable(out,true); } else if (parameters[0] == "disable") { return plugin_enable(out,false); - } else if (parameters[0] == "set" && parameters[1] == "preventblocking") { + } else if (parameters.size() == 3 && parameters[0] == "set" && parameters[1] == "preventblocking") { if (parameters[2] == "true") { suspendmanager_instance->prevent_blocking = true; config.set_bool(CONFIG_PREVENT_BLOCKING, true); diff --git a/test/plugins/3dveins.lua b/test/plugins/3dveins.lua new file mode 100644 index 0000000000..406d6817e9 --- /dev/null +++ b/test/plugins/3dveins.lua @@ -0,0 +1,18 @@ +config.mode = 'fortress' +config.target = '3dveins' + +function test.rewrites_veins() + local output, status = dfhack.run_command_silent('3dveins') + expect.eq(CR_OK, status) + expect.str_find('Writing tiles', output) +end + +function test.verbose_option() + local _, status = dfhack.run_command_silent('3dveins', 'verbose') + expect.eq(CR_OK, status) +end + +function test.bad_option_is_wrong_usage() + local _, status = dfhack.run_command_silent('3dveins', 'bogus') + expect.eq(CR_WRONG_USAGE, status) +end diff --git a/test/plugins/autodump.lua b/test/plugins/autodump.lua new file mode 100644 index 0000000000..f7c3e62f6e --- /dev/null +++ b/test/plugins/autodump.lua @@ -0,0 +1,111 @@ +config.mode = 'fortress' +config.target = 'autodump' + +local dwarfmode = require('gui.dwarfmode') + +local function find_floor_pos() + for _, block in ipairs(df.global.world.map.map_blocks) do + for x = 0, 15 do + for y = 0, 15 do + local tt = block.tiletype[x][y] + local des = block.designation[x][y] + local occ = block.occupancy[x][y] + if df.tiletype.attrs[tt].shape == df.tiletype_shape.FLOOR + and not des.hidden and occ.building == 0 then + return block.map_pos.x + x, block.map_pos.y + y, + block.map_pos.z + end + end + end + end +end + +local function set_cursor(x, y, z) + dwarfmode.setCursorPos(xyz2pos(x, y, z)) +end + +local function clear_cursor() + dwarfmode.clearCursorPos() +end + +local function find_dumpable_item() + for _, item in ipairs(df.global.world.items.other.IN_PLAY) do + if not item.flags.dump and not item.flags.construction + and not item.flags.in_building and not item.flags.artifact + and not item.flags.in_job and not item.flags.forbid + and not item.flags.owned and not item.flags.in_inventory then + return item + end + end +end + +function test.dump_moves_item_to_cursor() + local x, y, z = find_floor_pos() + local item = find_dumpable_item() + expect.ne(nil, x, 'test needs a revealed floor tile') + expect.ne(nil, item, 'test needs a dumpable item') + + return dfhack.with_finalize(function() + item.flags.dump = false + clear_cursor() + end, function() + set_cursor(x, y, z) + item.flags.dump = true + + local _, status = dfhack.run_command_silent('autodump') + expect.eq(CR_OK, status) + -- item was teleported to the cursor and marked as dumped + expect.eq(x, item.pos.x) + expect.eq(y, item.pos.y) + expect.eq(z, item.pos.z) + expect.false_(item.flags.dump) + expect.true_(item.flags.forbid) + end) +end + +function test.conflicting_filters_is_wrong_usage() + local output, status = dfhack.run_command_silent('autodump', 'visible', 'hidden') + expect.eq(CR_WRONG_USAGE, status) + expect.str_find("both hidden and visible", output) +end + +function test.bad_option_is_wrong_usage() + local _, status = dfhack.run_command_silent('autodump', 'bogus') + expect.eq(CR_WRONG_USAGE, status) +end + +function test.destroy_and_undestroy() + local item = find_dumpable_item() + expect.ne(nil, item, 'test needs a dumpable item') + + local was_paused = dfhack.world.ReadPauseState() + return dfhack.with_finalize(function() + dfhack.world.SetPauseState(was_paused) + dfhack.run_command_silent('autodump', 'undestroy') + item.flags.dump = false + item.flags.garbage_collect = false + item.flags.forbid = false + item.flags.hidden = false + end, function() + item.flags.dump = true + -- undestroy only restores marks made in the same frame, so the + -- game must stay paused between destroy and undestroy + dfhack.world.SetPauseState(true) + + local _, status = dfhack.run_command_silent('autodump', 'destroy') + expect.eq(CR_OK, status) + expect.true_(item.flags.garbage_collect) + expect.true_(item.flags.forbid) + expect.true_(item.flags.hidden) + + local output, status2 = dfhack.run_command_silent('autodump', 'undestroy') + expect.eq(CR_OK, status2) + expect.str_find('unmarked for destruction', output) + expect.false_(item.flags.garbage_collect) + expect.false_(item.flags.forbid) + expect.false_(item.flags.hidden) + -- undestroy restores the pre-destroy flags, including the dump + -- flag we set ourselves + expect.true_(item.flags.dump) + end) +end diff --git a/test/plugins/changeitem.lua b/test/plugins/changeitem.lua new file mode 100644 index 0000000000..13a04ff552 --- /dev/null +++ b/test/plugins/changeitem.lua @@ -0,0 +1,83 @@ +config.mode = 'fortress' +config.target = 'changeitem' + +local function find_floor_item_pos() + for _, item in ipairs(df.global.world.items.other.IN_PLAY) do + if not item.flags.hidden and not item.flags.in_inventory + and not item.flags.in_job and not item.flags.construction then + local x, y, z = dfhack.items.getPosition(item) + if x then return item, x, y, z end + end + end +end + +local function set_cursor(x, y, z) + df.global.cursor:assign{x=x, y=y, z=z} +end + +local function clear_cursor() + df.global.cursor:assign{x=-30000, y=-30000, z=-30000} +end + +function test.here_quality_changes_item() + local item, x, y, z = find_floor_item_pos() + expect.ne(nil, item, 'test needs an item on the ground') + local orig_quality = item.quality + + return dfhack.with_finalize(function() + item.quality = orig_quality + clear_cursor() + end, function() + set_cursor(x, y, z) + local output, status = dfhack.run_command_silent('changeitem', + 'here', 'q', '4') + expect.eq(CR_OK, status) + expect.str_find('items processed', output) + expect.eq(4, item.quality) + end) +end + +function test.here_empty_tile_processes_nothing() + -- find a floor tile and put the cursor on it; if it happens to hold + -- items the count is still reported + local block = df.global.world.map.map_blocks[0] + local x, y, z = block.map_pos.x, block.map_pos.y, block.map_pos.z + + return dfhack.with_finalize(clear_cursor, function() + set_cursor(x, y, z) + local output, status = dfhack.run_command_silent('changeitem', 'here') + expect.eq(CR_OK, status) + expect.str_find('items processed', output) + end) +end + +function test.here_no_cursor_is_failure() + return dfhack.with_finalize(clear_cursor, function() + clear_cursor() + local output, status = dfhack.run_command_silent('changeitem', 'here') + expect.eq(CR_FAILURE, status) + expect.str_find('Cursor position not found', output) + end) +end + +function test.no_selection_is_failure() + local output, status = dfhack.run_command_silent('changeitem') + expect.eq(CR_FAILURE, status) + expect.str_find('No item selected', output) +end + +function test.material_missing_arg_is_wrong_usage() + local output, status = dfhack.run_command_silent('changeitem', 'm') + expect.eq(CR_WRONG_USAGE, status) + expect.str_find('no material specified', output) +end + +function test.bad_quality_is_wrong_usage() + local _, status = dfhack.run_command_silent('changeitem', 'q', '9') + expect.eq(CR_WRONG_USAGE, status) +end + +function test.bad_option_is_wrong_usage() + local _, status = dfhack.run_command_silent('changeitem', 'bogus') + expect.eq(CR_WRONG_USAGE, status) +end diff --git a/test/plugins/changelayer.lua b/test/plugins/changelayer.lua new file mode 100644 index 0000000000..122d91b86d --- /dev/null +++ b/test/plugins/changelayer.lua @@ -0,0 +1,31 @@ +config.mode = 'fortress' +config.target = 'changelayer' + +function test.no_material_is_wrong_usage() + local output, status = dfhack.run_command_silent('changelayer') + expect.eq(CR_WRONG_USAGE, status) + expect.str_find('specify a material', output) +end + +function test.bad_material_is_failure() + local output, status = dfhack.run_command_silent('changelayer', 'BOGUSMAT') + expect.eq(CR_FAILURE, status) + expect.str_find('No such material', output) +end + +function test.no_cursor_is_failure() + return dfhack.with_finalize(function() + df.global.cursor:assign{x=-30000, y=-30000, z=-30000} + end, function() + df.global.cursor:assign{x=-30000, y=-30000, z=-30000} + local output, status = dfhack.run_command_silent('changelayer', + 'GRANITE') + expect.eq(CR_FAILURE, status) + expect.str_find('No cursor', output) + end) +end + +function test.help_is_wrong_usage() + local _, status = dfhack.run_command_silent('changelayer', '?') + expect.eq(CR_WRONG_USAGE, status) +end diff --git a/test/plugins/createitem.lua b/test/plugins/createitem.lua new file mode 100644 index 0000000000..ba2127641c --- /dev/null +++ b/test/plugins/createitem.lua @@ -0,0 +1,90 @@ +config.mode = 'fortress' +config.target = 'createitem' + +local function find_floor_pos() + for _, block in ipairs(df.global.world.map.map_blocks) do + for x = 0, 15 do + for y = 0, 15 do + local tt = block.tiletype[x][y] + local des = block.designation[x][y] + local occ = block.occupancy[x][y] + if df.tiletype.attrs[tt].shape == df.tiletype_shape.FLOOR + and not des.hidden and occ.building == 0 then + return block.map_pos.x + x, block.map_pos.y + y, + block.map_pos.z + end + end + end + end +end + +local function set_cursor(x, y, z) + df.global.cursor:assign{x=x, y=y, z=z} +end + +local function clear_cursor() + df.global.cursor:assign{x=-30000, y=-30000, z=-30000} +end + +local function count_items_at(x, y, z) + local n = 0 + for _, item in ipairs(df.global.world.items.other.IN_PLAY) do + if item.pos.x == x and item.pos.y == y and item.pos.z == z then + n = n + 1 + end + end + return n +end + +function test.creates_item_at_cursor() + local x, y, z = find_floor_pos() + expect.ne(nil, x, 'test needs a revealed floor tile') + local before = count_items_at(x, y, z) + + local created + return dfhack.with_finalize(function() + if created then dfhack.items.remove(created) end + clear_cursor() + dfhack.run_command_silent('createitem', 'floor') + end, function() + set_cursor(x, y, z) + local _, status = dfhack.run_command_silent('createitem', + 'BOULDER', 'INORGANIC:GRANITE') + expect.eq(CR_OK, status) + expect.eq(before + 1, count_items_at(x, y, z)) + + for _, item in ipairs(df.global.world.items.other.IN_PLAY) do + if item.pos.x == x and item.pos.y == y and item.pos.z == z + and item:getType() == df.item_type.BOULDER then + created = item + break + end + end + expect.ne(nil, created, 'created boulder should exist') + end) +end + +function test.floor_mode_sets_destination() + local output, status = dfhack.run_command_silent('createitem', 'floor') + expect.eq(CR_OK, status) + expect.str_find('placed on the floor', output) +end + +function test.no_args_is_wrong_usage() + local _, status = dfhack.run_command_silent('createitem') + expect.eq(CR_WRONG_USAGE, status) +end + +function test.bad_item_type_is_failure() + local output, status = dfhack.run_command_silent('createitem', + 'BOGUS', 'INORGANIC:GRANITE') + expect.eq(CR_FAILURE, status) + expect.str_find('valid item type', output) +end + +function test.bad_material_is_failure() + local output, status = dfhack.run_command_silent('createitem', + 'BOULDER', 'BOGUSMAT') + expect.eq(CR_FAILURE, status) + expect.str_find('Unrecognized material', output) +end diff --git a/test/plugins/deramp.lua b/test/plugins/deramp.lua new file mode 100644 index 0000000000..eeb9f2f15e --- /dev/null +++ b/test/plugins/deramp.lua @@ -0,0 +1,44 @@ +config.mode = 'fortress' +config.target = 'deramp' + +local function find_ramp_pos() + for _, block in ipairs(df.global.world.map.map_blocks) do + for x = 0, 15 do + for y = 0, 15 do + local tt = block.tiletype[x][y] + if df.tiletype.attrs[tt].shape == df.tiletype_shape.RAMP + and not block.designation[x][y].hidden then + return block, x, y + end + end + end + end +end + +function test.removes_designated_ramp() + local block, x, y = find_ramp_pos() + expect.ne(nil, block, 'test needs a revealed ramp tile') + + local des = block.designation[x][y] + local orig_dig = des.dig + return dfhack.with_finalize(function() + block.designation[x][y].dig = orig_dig + end, function() + -- mark the ramp for removal, as if the player had designated it + des.dig = df.tile_dig_designation.Default + local _, status = dfhack.run_command_silent('deramp') + expect.eq(CR_OK, status) + local shape = df.tiletype.attrs[block.tiletype[x][y]].shape + expect.ne(df.tiletype_shape.RAMP, shape) + end) +end + +function test.no_designations_is_ok() + local _, status = dfhack.run_command_silent('deramp') + expect.eq(CR_OK, status) +end + +function test.arg_is_wrong_usage() + local _, status = dfhack.run_command_silent('deramp', 'bogus') + expect.eq(CR_WRONG_USAGE, status) +end diff --git a/test/plugins/export-world-map.lua b/test/plugins/export-world-map.lua new file mode 100644 index 0000000000..dc914f2e8b --- /dev/null +++ b/test/plugins/export-world-map.lua @@ -0,0 +1,29 @@ +config.mode = 'fortress' +config.target = 'export-world-map' + +local export_dir = dfhack.getConfigPath() .. '/map-export' + +local function sites_csv() + local f = io.open(export_dir .. '/sites.csv') + if not f then return nil end + local content = f:read('*a') + f:close() + return content +end + +function test.sites_export_writes_csv() + return dfhack.with_finalize(function() + os.remove(export_dir .. '/sites.csv') + end, function() + local _, status = dfhack.run_command_silent('export-world-map', 'sites') + expect.eq(CR_OK, status) + local content = sites_csv() + expect.ne(nil, content, 'sites.csv was not written') + expect.true_(#content > 0, 'sites.csv is empty') + end) +end + +function test.unknown_topic_is_wrong_usage() + local _, status = dfhack.run_command_silent('export-world-map', 'bogus-topic') + expect.eq(CR_WRONG_USAGE, status) +end diff --git a/test/plugins/filltraffic.lua b/test/plugins/filltraffic.lua new file mode 100644 index 0000000000..fbbb853697 --- /dev/null +++ b/test/plugins/filltraffic.lua @@ -0,0 +1,117 @@ +config.mode = 'fortress' +config.target = 'filltraffic' + +local function find_floor_pos() + for _, block in ipairs(df.global.world.map.map_blocks) do + for x = 0, 15 do + for y = 0, 15 do + local tt = block.tiletype[x][y] + local shape = df.tiletype.attrs[tt].shape + local des = block.designation[x][y] + local occ = block.occupancy[x][y] + if shape == df.tiletype_shape.FLOOR and not des.hidden + and occ.building == 0 then + return block.map_pos.x + x, block.map_pos.y + y, + block.map_pos.z + end + end + end + end +end + +local function traffic_at(x, y, z) + return dfhack.maps.getTileBlock(x, y, z).designation[x % 16][y % 16].traffic +end + +local function set_cursor(x, y, z) + df.global.cursor:assign{x=x, y=y, z=z} +end + +local function clear_cursor() + df.global.cursor:assign{x=-30000, y=-30000, z=-30000} +end + +function test.fill_high_then_restore() + local x, y, z = find_floor_pos() + expect.ne(nil, x, 'test needs a revealed floor tile') + + return dfhack.with_finalize(function() + set_cursor(x, y, z) + -- always leave the tile normal + dfhack.run_command_silent('filltraffic', 'N') + clear_cursor() + end, function() + set_cursor(x, y, z) + local _, status = dfhack.run_command_silent('filltraffic', 'H') + expect.eq(CR_OK, status) + expect.eq(df.tile_traffic.High, traffic_at(x, y, z)) + + local _, status2 = dfhack.run_command_silent('filltraffic', 'N') + expect.eq(CR_OK, status2) + expect.eq(df.tile_traffic.Normal, traffic_at(x, y, z)) + end) +end + +function test.same_type_is_failure() + local x, y, z = find_floor_pos() + expect.ne(nil, x) + + return dfhack.with_finalize(clear_cursor, function() + set_cursor(x, y, z) + -- the tile is already Normal, so filling Normal is a no-op failure + local output, status = dfhack.run_command_silent('filltraffic', 'N') + expect.eq(CR_FAILURE, status) + expect.str_find('already set to the target', output) + end) +end + +function test.no_cursor_is_failure() + clear_cursor() + -- the cursor_hotkey command guard rejects before the plugin runs + local output, status = dfhack.run_command_silent('filltraffic', 'H') + expect.eq(CR_WRONG_USAGE, status) + expect.str_find('unsuitable UI state', output) +end + +function test.wall_is_failure() + -- find a revealed wall tile + local wx, wy, wz + for _, block in ipairs(df.global.world.map.map_blocks) do + for x = 0, 15 do + for y = 0, 15 do + local tt = block.tiletype[x][y] + if df.tiletype.attrs[tt].shape == df.tiletype_shape.WALL + and not block.designation[x][y].hidden then + wx, wy, wz = block.map_pos.x + x, + block.map_pos.y + y, block.map_pos.z + end + end + end + end + expect.ne(nil, wx, 'test needs a revealed wall tile') + + return dfhack.with_finalize(clear_cursor, function() + set_cursor(wx, wy, wz) + local output, status = dfhack.run_command_silent('filltraffic', 'H') + expect.eq(CR_FAILURE, status) + expect.str_find('wall', output) + end) +end + +function test.bad_option_is_wrong_usage() + local _, status = dfhack.run_command_silent('filltraffic', 'Q') + expect.eq(CR_WRONG_USAGE, status) +end + +function test.multi_char_option_is_wrong_usage() + local _, status = dfhack.run_command_silent('filltraffic', 'HH') + expect.eq(CR_WRONG_USAGE, status) +end + +function test.restrictice_and_restrictliquids() + -- restrict traffic on ice/liquid tiles; safe full-map passes + local _, status = dfhack.run_command_silent('restrictice') + expect.eq(CR_OK, status) + local _, status2 = dfhack.run_command_silent('restrictliquids') + expect.eq(CR_OK, status2) +end diff --git a/test/plugins/flows.lua b/test/plugins/flows.lua new file mode 100644 index 0000000000..7973f905f9 --- /dev/null +++ b/test/plugins/flows.lua @@ -0,0 +1,10 @@ +config.mode = 'fortress' +config.target = 'flows' + +function test.counts_liquids() + local output, status = dfhack.run_command_silent('flows') + expect.eq(CR_OK, status) + expect.str_find('Blocks with liquid_1', output) + expect.str_find('Water tiles:', output) + expect.str_find('Magma tiles:', output) +end diff --git a/test/plugins/liquids.lua b/test/plugins/liquids.lua new file mode 100644 index 0000000000..7442ba36b9 --- /dev/null +++ b/test/plugins/liquids.lua @@ -0,0 +1,78 @@ +config.mode = 'fortress' +config.target = 'liquids' + +local function find_floor_pos() + for _, block in ipairs(df.global.world.map.map_blocks) do + for x = 0, 15 do + for y = 0, 15 do + local tt = block.tiletype[x][y] + local des = block.designation[x][y] + local occ = block.occupancy[x][y] + if df.tiletype.attrs[tt].shape == df.tiletype_shape.FLOOR + and not des.hidden and des.flow_size == 0 + and occ.building == 0 then + return block.map_pos.x + x, block.map_pos.y + y, + block.map_pos.z + end + end + end + end +end + +local function set_cursor(x, y, z) + df.global.cursor:assign{x=x, y=y, z=z} +end + +local function clear_cursor() + df.global.cursor:assign{x=-30000, y=-30000, z=-30000} +end + +local function des_at(x, y, z) + return dfhack.maps.getTileBlock(x, y, z).designation[x % 16][y % 16] +end + +function test.liquids_here_spawns_magma_at_cursor() + local x, y, z = find_floor_pos() + expect.ne(nil, x, 'test needs a revealed dry floor tile') + + local was_paused = dfhack.world.ReadPauseState() + return dfhack.with_finalize(function() + local des = des_at(x, y, z) + des.flow_size = 0 + dfhack.world.SetPauseState(was_paused) + clear_cursor() + end, function() + -- pause so the magma cannot spread before we clean it up + dfhack.world.SetPauseState(true) + set_cursor(x, y, z) + + local _, status = dfhack.run_command_silent('liquids-here') + expect.eq(CR_OK, status) + 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(true, des.liquid_type) + end) +end + +function test.liquids_needs_console() + -- the interactive liquid spawner requires a console; the dispatcher + -- rejects non-console invocations before the plugin runs + local _, status = dfhack.run_command_silent('liquids') + expect.eq(CR_NEEDS_CONSOLE, status) +end + +function test.liquids_here_help_is_wrong_usage() + local _, status = dfhack.run_command_silent('liquids-here', '?') + expect.eq(CR_WRONG_USAGE, status) +end + +function test.liquids_here_no_cursor_is_failure() + return dfhack.with_finalize(clear_cursor, function() + clear_cursor() + -- the cursor_hotkey command guard rejects before the plugin runs + local output, status = dfhack.run_command_silent('liquids-here') + expect.eq(CR_WRONG_USAGE, status) + expect.str_find('unsuitable UI state', output) + end) +end diff --git a/test/plugins/showmood.lua b/test/plugins/showmood.lua new file mode 100644 index 0000000000..12478db3be --- /dev/null +++ b/test/plugins/showmood.lua @@ -0,0 +1,14 @@ +config.mode = 'fortress' +config.target = 'showmood' + +function test.reports_no_active_mood() + -- the test fort has no strange mood in progress + local output, status = dfhack.run_command_silent('showmood') + expect.eq(CR_OK, status) + expect.str_find('No strange moods currently active', output) +end + +function test.arg_is_wrong_usage() + local _, status = dfhack.run_command_silent('showmood', 'bogus') + expect.eq(CR_WRONG_USAGE, status) +end diff --git a/test/plugins/strangemood.lua b/test/plugins/strangemood.lua new file mode 100644 index 0000000000..f4baa438f5 --- /dev/null +++ b/test/plugins/strangemood.lua @@ -0,0 +1,179 @@ +config.mode = 'fortress' +config.target = 'strangemood' + +-- note: the --force/--type/--skill happy path is deliberately not tested +-- here: inducing a real strange mood mutates the fort (dwarf claims a +-- workshop and can go berserk if unmet), which is too disruptive mid-suite. + +function test.help_is_wrong_usage() + local _, status = dfhack.run_command_silent('strangemood', 'help') + expect.eq(CR_WRONG_USAGE, status) +end + +function test.unknown_option_is_wrong_usage() + local output, status = dfhack.run_command_silent('strangemood', '--bogus') + expect.eq(CR_WRONG_USAGE, status) + expect.str_find('Unrecognized parameter', output) +end + +function test.id_missing_value() + local output, status = dfhack.run_command_silent('strangemood', '--id') + expect.eq(CR_WRONG_USAGE, status) + expect.str_find('No unit id specified', output) +end + +function test.id_not_a_number() + -- regression: the raw std::stoi call threw an uncaught exception on + -- non-numeric input, crashing the game + local output, status = dfhack.run_command_silent('strangemood', '--id', 'abc') + expect.eq(CR_WRONG_USAGE, status) + expect.str_find('Invalid unit id', output) +end + +function test.id_nonexistent_unit() + local _, status = dfhack.run_command_silent('strangemood', '--id', '99999999') + expect.eq(CR_FAILURE, status) +end + +function test.type_missing_value() + local _, status = dfhack.run_command_silent('strangemood', '--type') + expect.eq(CR_WRONG_USAGE, status) +end + +function test.type_bad_value() + local output, status = dfhack.run_command_silent('strangemood', '--type', 'bogus') + expect.eq(CR_WRONG_USAGE, status) + expect.str_find('not recognized', output) +end + +-- the following tests trigger real strange moods and validate the generated +-- job_items against the vanilla request tables. they restore all unit and +-- plotinfo state they touch. the mooded dwarf cannot act on the job because +-- the whole test runs atomically on the main thread. + +-- skill name -> expected item_type of the mood's base item +local BASE_ITEMS = { + miner='BOULDER', engraver='BOULDER', mason='BOULDER', + stonecutter='BOULDER', stonecarver='BOULDER', mechanic='BOULDER', + carpenter='WOOD', woodcrafter='WOOD', bowyer='WOOD', + tanner='SKIN_TANNED', leatherworker='SKIN_TANNED', + weaver='CLOTH', clothier='CLOTH', + weaponsmith='BAR', armorsmith='BAR', metalsmith='BAR', + metalcrafter='BAR', + gemcutter='ROUGH', gemsetter='ROUGH', glassmaker='ROUGH', + bonecarver='NONE', +} + +-- every item_type a fey/secretive/possessed mood may ever request +-- (bars/wafers and cloth are the only dimensional demands; thread is +-- never requested) +local ALLOWED_ITEM_TYPES = { + BOULDER=true, WOOD=true, SKIN_TANNED=true, CLOTH=true, BAR=true, + ROUGH=true, SMALLGEM=true, BLOCKS=true, NONE=true, +} + +-- mirrors isUnitMoodable in strangemood.cpp, plus "not already mid-job" +local function pick_moodable_unit() + for _, u in ipairs(dfhack.units.getCitizens()) do + if not u.flags1.had_mood and not u.flags1.has_mood and + u.mood == df.mood_type.None and not u.job.current_job and + u.status2.limbs_grasp_count > 0 and + df.profession.attrs[u.profession].moodable and + dfhack.units.casteFlagSet(u.race, u.caste, + df.caste_raw_flags.STRANGE_MOODS) then + return u + end + end +end + +local function clear_mood(unit) + local job = unit.job.current_job + unit.job.current_job = nil + if job then + dfhack.job.removeJob(job) + end + unit.mood = df.mood_type.None + unit.mood_copy = df.mood_type.None + unit.flags1.has_mood = false + unit.flags1.had_mood = false + unit.moodstage = df.mood_stage_type.INITIAL + unit.status.artifact_name.type = -1 +end + +local function trigger_mood(unit, ...) + local _, status = dfhack.run_command_silent('strangemood', + '--force', '--id', tostring(unit.id), ...) + return status == CR_OK and unit.job.current_job or nil +end + +local function check_dimension(item, expected_type) + -- strangemood pre-multiplies quantity by the item's dimension and sets + -- min_dimension, working around a vanilla bug + if item.item_type == df.item_type.BAR then + expect.eq(150, item.min_dimension) + expect.eq(0, item.quantity % 150) + elseif item.item_type == df.item_type.CLOTH then + expect.eq(10000, item.min_dimension) + expect.eq(0, item.quantity % 10000) + else + expect.eq(-1, item.min_dimension) + end +end + +function test.base_items_match_skill() + local unit = pick_moodable_unit() + expect.true_(unit, 'need a moodable citizen without a job') + local saved_cooldown = df.global.plotinfo.mood_cooldown + return dfhack.with_finalize(function() + clear_mood(unit) + df.global.plotinfo.mood_cooldown = saved_cooldown + end, function() + for skill, expected in pairs(BASE_ITEMS) do + local job = trigger_mood(unit, '--skill', skill) + expect.true_(job, 'mood job for ' .. skill) + local base = job.job_items.elements[0] + expect.eq(expected, df.item_type[base.item_type], + 'base item for ' .. skill) + check_dimension(base) + for _, item in ipairs(job.job_items.elements) do + expect.ne(df.item_type.THREAD, item.item_type) + expect.true_(ALLOWED_ITEM_TYPES[df.item_type[item.item_type]], + ('unexpected %s item for %s'):format( + df.item_type[item.item_type], skill)) + if item.item_type == df.item_type.NONE then + expect.true_(item.flags2.body_part) + end + check_dimension(item) + end + clear_mood(unit) + end + end) +end + +function test.fell_and_macabre_base_items() + local unit = pick_moodable_unit() + expect.true_(unit, 'need a moodable citizen without a job') + local saved_cooldown = df.global.plotinfo.mood_cooldown + return dfhack.with_finalize(function() + clear_mood(unit) + df.global.plotinfo.mood_cooldown = saved_cooldown + end, function() + local job = trigger_mood(unit, '--type', 'fell') + expect.true_(job, 'fell mood job') + expect.eq(df.job_type.StrangeMoodFell, job.job_type) + expect.eq(df.item_type.CORPSE, job.job_items.elements[0].item_type) + expect.true_(job.job_items.elements[0].flags1.murdered) + clear_mood(unit) + + job = trigger_mood(unit, '--type', 'macabre') + expect.true_(job, 'macabre mood job') + expect.eq(df.job_type.StrangeMoodBrooding, job.job_type) + local base = job.job_items.elements[0] + if base.item_type == df.item_type.NONE then + expect.true_(base.flags2.body_part) + expect.true_(base.flags2.bone or base.flags2.totemable) + else + expect.eq(df.item_type.REMAINS, base.item_type) + end + end) +end diff --git a/test/plugins/suspendmanager.lua b/test/plugins/suspendmanager.lua new file mode 100644 index 0000000000..1eb79d2983 --- /dev/null +++ b/test/plugins/suspendmanager.lua @@ -0,0 +1,65 @@ +config.mode = 'fortress' +config.target = 'suspendmanager' + +local function is_enabled() + local output = dfhack.run_command_silent('suspendmanager') + return output:find('is enabled') ~= nil +end + +local function prevents_blocking() + local output = dfhack.run_command_silent('suspendmanager') + return output:find('but not suspending') == nil +end + +function test.status_reflects_enable_state() + local was_enabled = is_enabled() + + return dfhack.with_finalize(function() + dfhack.run_command_silent('suspendmanager', + was_enabled and 'enable' or 'disable') + end, function() + local _, status = dfhack.run_command_silent('suspendmanager', 'enable') + expect.eq(CR_OK, status) + expect.true_(is_enabled()) + + local _, status2 = dfhack.run_command_silent('suspendmanager', 'disable') + expect.eq(CR_OK, status2) + expect.false_(is_enabled()) + end) +end + +function test.now_runs_cycle() + local _, status = dfhack.run_command_silent('suspendmanager', 'now') + expect.eq(CR_OK, status) +end + +function test.set_preventblocking() + local was_preventing = prevents_blocking() + + return dfhack.with_finalize(function() + dfhack.run_command_silent('suspendmanager', 'set', + 'preventblocking', was_preventing and 'true' or 'false') + end, function() + local _, status = dfhack.run_command_silent('suspendmanager', 'set', + 'preventblocking', 'false') + expect.eq(CR_OK, status) + local _, status2 = dfhack.run_command_silent('suspendmanager', 'set', + 'preventblocking', 'true') + expect.eq(CR_OK, status2) + end) +end + +function test.set_missing_args_is_wrong_usage() + local _, status = dfhack.run_command_silent('suspendmanager', 'set') + expect.eq(CR_WRONG_USAGE, status) +end + +function test.bad_option_is_wrong_usage() + local _, status = dfhack.run_command_silent('suspendmanager', 'bogus') + expect.eq(CR_WRONG_USAGE, status) +end + +function test.unsuspend_runs() + local _, status = dfhack.run_command_silent('unsuspend') + expect.eq(CR_OK, status) +end diff --git a/test/plugins/tubefill.lua b/test/plugins/tubefill.lua new file mode 100644 index 0000000000..7eef13ec2f --- /dev/null +++ b/test/plugins/tubefill.lua @@ -0,0 +1,19 @@ +config.mode = 'fortress' +config.target = 'tubefill' + +function test.fills_tubes() + local output, status = dfhack.run_command_silent('tubefill') + expect.eq(CR_OK, status) + expect.str_find('Found and changed', output) +end + +function test.hollow_option() + local output, status = dfhack.run_command_silent('tubefill', 'hollow') + expect.eq(CR_OK, status) + expect.str_find('Found and changed', output) +end + +function test.help_is_wrong_usage() + local _, status = dfhack.run_command_silent('tubefill', '?') + expect.eq(CR_WRONG_USAGE, status) +end