diff --git a/docs/Lua API.rst b/docs/Lua API.rst index 14c1bd0ed..d00acd271 100644 --- a/docs/Lua API.rst +++ b/docs/Lua API.rst @@ -3756,15 +3756,19 @@ the plugin. See existing files in ``plugins/lua`` for examples. blueprint ========= -Native functions provided by the `blueprint` plugin: +Lua functions provided by the `blueprint` plugin to programmatically generate +blueprint files: * ``dig(start, end, name)`` * ``build(start, end, name)`` * ``place(start, end, name)`` * ``query(start, end, name)`` - ``start`` and ``end`` are tables containing positions (see - ``xyz2pos``). ``name`` is used as the basis for the filename. + ``start`` and ``end`` are tables containing positions (see ``xyz2pos``). + ``name`` is used as the basis for the generated filenames. + +The names of the functions are also available as the keys of the +``valid_phases`` table. .. _building-hacks: diff --git a/docs/Plugins.rst b/docs/Plugins.rst index 24dd90c43..01b13eebf 100644 --- a/docs/Plugins.rst +++ b/docs/Plugins.rst @@ -37,22 +37,72 @@ For more information, see `the full Stonesense README `. blueprint ========= -Exports a portion of your fortress into QuickFort style blueprint files. +The ``blueprint`` command exports the structure of a portion of your fortress in +a blueprint file that you (or anyone else) can later play back with `quickfort`. + +Blueprints are ``.csv`` or ``.xlsx`` files created in the ``blueprints`` +subdirectory of your DF folder. The map area to turn into a blueprint is either +selected interactively with the ``blueprint gui`` command or, if the GUI is not +used, starts at the active cursor location and extends right and down for the +requested width and height. Usage:: - blueprint [dig] [build] [place] [query] + blueprint [] [ []] [] + blueprint gui [ []] [] + +Examples: + +``blueprint gui`` + Runs `gui/blueprint`, the interactive blueprint frontend, where all + configuration for a ``blueprint`` command can be set visually and + interactively. + +``blueprint 30 40 bedrooms`` + Generates blueprints for an area 30 tiles wide by 40 tiles tall, starting + from the active cursor on the current z-level. Output is written to files + with names matching the pattern ``bedrooms-PHASE.csv`` in the ``blueprints`` + directory. + +``blueprint 30 40 bedrooms dig --cursor 108,100,150`` + Generates only the ``bedrooms-dig.csv`` file from the previous example, and + the blueprint start coordinate is set to a specific value instead of using + the in-game cursor position. -Options (If only region and name are given, export all): +Positional Parameters: -:x,y,z: Size of map area to export -:name: Name of export files -:dig: Export dig commands to "-dig.csv" -:build: Export build commands to "-build.csv" -:place: Export stockpile commands to "-place.csv" -:query: Export query commands to "-query.csv" +:``width``: Width of the area (in tiles) to translate. +:``height``: Height of the area (in tiles) to translate. +:``depth``: Number of z-levels to translate. Positive numbers go *up* from the + cursor and negative numbers go *down*. Defaults to 1 if not specified, + indicating that the blueprint should only include the current z-level. +:``name``: Base name for blueprint files created in the ``blueprints`` + directory. If no name is specified, "blueprint" is used by default. The + string must contain some characters other than numbers so the name won't be + confused with the optional ``depth`` parameter. + +Phases: + +If you want to generate blueprints only for specific phases, add their names to +the commandline, anywhere after the blueprint base name. You can list multiple +phases; just separate them with a space. + +:``dig``: Generate quickfort ``#dig`` blueprints. +:``build``: Generate quickfort ``#build`` blueprints for constructions and + buildings. +:``place``: Generate quickfort ``#place`` blueprints for placing stockpiles. +:``query``: Generate quickfort ``#query`` blueprints for configuring rooms. + +If no phases are specified, all blueprints are created. + +Options: -Goes very well with `quickfort`, for re-importing. +:``-c``, ``--cursor ,,``: + Use the specified map coordinates instead of the current cursor position for + the upper left corner of the blueprint range. If this option is specified, + then an active game map cursor is not necessary. +:``-h``, ``--help``: + Show command help text. .. _remotefortressreader: diff --git a/docs/changelog.txt b/docs/changelog.txt index 16589f759..322e959ac 100644 --- a/docs/changelog.txt +++ b/docs/changelog.txt @@ -41,6 +41,9 @@ changelog.txt uses a syntax similar to RST, with a few special sequences: - ``quickfortress.csv`` blueprint: fixed refuse stockpile config and prevented stockpiles from covering stairways ## Misc Improvements +- `blueprint`: make ``depth`` and ``name`` parameters optional. ``depth`` now defaults to ``1`` (current level only) and ``name`` defaults to "blueprint" +- `blueprint`: allow ``depth`` to be negative, which will result in the blueprints being written from the highest z-level to the lowest. before, blueprints were always written from the lowest z-level to the highest. +- `blueprint`: add the ``--cursor`` option to set the starting coordinate for the generated blueprints. a game cursor is no longer necessary if this option is used. - `tweak` hide-priority: changed so that priorities stay hidden (or visible) when exiting and re-entering the designations menu ## Lua diff --git a/library/include/modules/Gui.h b/library/include/modules/Gui.h index 819ad1558..455032fea 100644 --- a/library/include/modules/Gui.h +++ b/library/include/modules/Gui.h @@ -156,6 +156,7 @@ namespace DFHack DFHACK_EXPORT bool setViewCoords (const int32_t x, const int32_t y, const int32_t z); DFHACK_EXPORT bool getCursorCoords (int32_t &x, int32_t &y, int32_t &z); + DFHACK_EXPORT bool getCursorCoords (df::coord &pos); DFHACK_EXPORT bool setCursorCoords (const int32_t x, const int32_t y, const int32_t z); DFHACK_EXPORT bool getDesignationCoords (int32_t &x, int32_t &y, int32_t &z); diff --git a/library/modules/Gui.cpp b/library/modules/Gui.cpp index 3ba851438..04225e111 100644 --- a/library/modules/Gui.cpp +++ b/library/modules/Gui.cpp @@ -648,13 +648,18 @@ bool Gui::item_details_hotkey(df::viewscreen *top) return !!strict_virtual_cast(top); } +static bool has_cursor() +{ + return df::global::cursor && df::global::cursor->x != -30000; +} + bool Gui::cursor_hotkey(df::viewscreen *top) { if (!dwarfmode_hotkey(top)) return false; // Also require the cursor. - if (!df::global::cursor || df::global::cursor->x == -30000) + if (!has_cursor()) return false; return true; @@ -1788,7 +1793,15 @@ bool Gui::getCursorCoords (int32_t &x, int32_t &y, int32_t &z) x = df::global::cursor->x; y = df::global::cursor->y; z = df::global::cursor->z; - return (x == -30000) ? false : true; + return has_cursor(); +} + +bool Gui::getCursorCoords (df::coord &pos) +{ + pos.x = df::global::cursor->x; + pos.y = df::global::cursor->y; + pos.z = df::global::cursor->z; + return has_cursor(); } //FIXME: confine writing of coords to map bounds? diff --git a/plugins/blueprint.cpp b/plugins/blueprint.cpp index f460cdb40..2158f4086 100644 --- a/plugins/blueprint.cpp +++ b/plugins/blueprint.cpp @@ -8,9 +8,11 @@ #include #include -#include -#include +#include "Console.h" +#include "DataDefs.h" +#include "DataIdentity.h" #include "LuaTools.h" +#include "PluginManager.h" #include "TileTypes.h" #include "modules/Buildings.h" @@ -27,22 +29,63 @@ #include "df/building_trapst.h" #include "df/building_water_wheelst.h" #include "df/building_workshopst.h" +#include "df/world.h" using std::string; using std::endl; using std::vector; using std::ofstream; -using std::swap; -using std::find; using std::pair; using namespace DFHack; -using namespace df::enums; DFHACK_PLUGIN("blueprint"); +REQUIRE_GLOBAL(world); -enum phase {DIG=1, BUILD=2, PLACE=4, QUERY=8}; +struct blueprint_options { + // whether to display help + bool help = false; -command_result blueprint(color_ostream &out, vector ¶meters); + // starting tile coordinate of the translation area (if not set then all + // coordinates are set to -30000) + df::coord start; + + // dimensions of translation area. width and height are guaranteed to be + // greater than 0. depth can be positive or negative, but not zero. + int32_t width = 0; + int32_t height = 0; + int32_t depth = 0; + + // base name to use for generated files + string name; + + // whether to autodetect which phases to output + bool auto_phase = false; + + // if not autodetecting, which phases to output + bool dig = false; + bool build = false; + bool place = false; + bool query = false; + + static struct_identity _identity; +}; +static const struct_field_info blueprint_options_fields[] = { + { struct_field_info::PRIMITIVE, "help", offsetof(blueprint_options, help), &df::identity_traits::identity, 0, 0 }, + { struct_field_info::SUBSTRUCT, "start", offsetof(blueprint_options, start), &df::coord::_identity, 0, 0 }, + { struct_field_info::PRIMITIVE, "width", offsetof(blueprint_options, width), &df::identity_traits::identity, 0, 0 }, + { struct_field_info::PRIMITIVE, "height", offsetof(blueprint_options, height), &df::identity_traits::identity, 0, 0 }, + { struct_field_info::PRIMITIVE, "depth", offsetof(blueprint_options, depth), &df::identity_traits::identity, 0, 0 }, + { struct_field_info::PRIMITIVE, "name", offsetof(blueprint_options, name), df::identity_traits::get(), 0, 0 }, + { struct_field_info::PRIMITIVE, "auto_phase", offsetof(blueprint_options, auto_phase), &df::identity_traits::identity, 0, 0 }, + { struct_field_info::PRIMITIVE, "dig", offsetof(blueprint_options, dig), &df::identity_traits::identity, 0, 0 }, + { struct_field_info::PRIMITIVE, "build", offsetof(blueprint_options, build), &df::identity_traits::identity, 0, 0 }, + { struct_field_info::PRIMITIVE, "place", offsetof(blueprint_options, place), &df::identity_traits::identity, 0, 0 }, + { struct_field_info::PRIMITIVE, "query", offsetof(blueprint_options, query), &df::identity_traits::identity, 0, 0 }, + { struct_field_info::END } +}; +struct_identity blueprint_options::_identity(sizeof(blueprint_options), &df::allocator_fn, NULL, "blueprint_options", NULL, blueprint_options_fields); + +command_result blueprint(color_ostream &out, vector ¶meters); DFhackCExport command_result plugin_init(color_ostream &out, vector &commands) { @@ -55,25 +98,6 @@ DFhackCExport command_result plugin_shutdown(color_ostream &out) return CR_OK; } -command_result help(color_ostream &out) -{ - out << "blueprint width height depth name [dig] [build] [place] [query]" << endl - << " width, height, depth: area to translate in tiles" << endl - << " name: base name for blueprint files" << endl - << " dig: generate blueprints for digging" << endl - << " build: generate blueprints for building" << endl - << " place: generate blueprints for stockpiles" << endl - << " query: generate blueprints for querying (room designations)" << endl - << " defaults to generating all blueprints" << endl - << endl - << "blueprint translates a portion of your fortress into blueprints suitable for" << endl - << " digfort/fortplan/quickfort. Blueprints are created in the \"blueprints\"" << endl - << " subdirectory of the DF folder with names following a \"name-phase.csv\" pattern." << endl - << " Translation starts at the current cursor location and includes all tiles in the" << endl - << " range specified." << endl; - return CR_OK; -} - pair get_building_size(df::building* b) { return pair(b->x2 - b->x1 + 1, b->y2 - b->y1 + 1); @@ -562,7 +586,7 @@ string get_tile_query(df::building* b) return " "; } -void init_stream(ofstream &out, std::string basename, std::string target) +void init_stream(ofstream &out, string basename, string target) { std::ostringstream out_path; out_path << basename << "-" << target << ".csv"; @@ -570,19 +594,15 @@ void init_stream(ofstream &out, std::string basename, std::string target) out << "#" << target << endl; } -command_result do_transform(DFCoord start, DFCoord end, string name, uint32_t phases, std::ostringstream &err) +command_result do_transform(const DFCoord &start, const DFCoord &end, + const blueprint_options &options, + std::ostringstream &err) { ofstream dig, build, place, query; - std::string basename = "blueprints/" + name; - -#ifdef _WIN32 - // normalize to forward slashes - std::replace(basename.begin(), basename.end(), '\\', '/'); -#endif - + string basename = "blueprints/" + options.name; size_t last_slash = basename.find_last_of("/"); - std::string parent_path = basename.substr(0, last_slash); + string parent_path = basename.substr(0, last_slash); // create output directory if it doesn't already exist std::error_code ec; @@ -592,179 +612,185 @@ command_result do_transform(DFCoord start, DFCoord end, string name, uint32_t ph return CR_FAILURE; } - if (phases & QUERY) + if (options.auto_phase || options.query) { - //query = ofstream((name + "-query.csv").c_str(), ofstream::trunc); init_stream(query, basename, "query"); } - if (phases & PLACE) + if (options.auto_phase || options.place) { - //place = ofstream(name + "-place.csv", ofstream::trunc); init_stream(place, basename, "place"); } - if (phases & BUILD) + if (options.auto_phase || options.build) { - //build = ofstream(name + "-build.csv", ofstream::trunc); init_stream(build, basename, "build"); } - if (phases & DIG) + if (options.auto_phase || options.dig) { - //dig = ofstream(name + "-dig.csv", ofstream::trunc); init_stream(dig, basename, "dig"); } - if (start.x > end.x) - { - swap(start.x, end.x); - start.x++; - end.x++; - } - if (start.y > end.y) - { - swap(start.y, end.y); - start.y++; - end.y++; - } - if (start.z > end.z) - { - swap(start.z, end.z); - start.z++; - end.z++; - } - for (int32_t z = start.z; z < end.z; z++) + const int32_t z_inc = start.z < end.z ? 1 : -1; + const string z_key = start.z < end.z ? "#<" : "#>"; + for (int32_t z = start.z; z != end.z; z += z_inc) { for (int32_t y = start.y; y < end.y; y++) { for (int32_t x = start.x; x < end.x; x++) { - df::building* b = DFHack::Buildings::findAtTile(DFCoord(x, y, z)); - if (phases & QUERY) + df::building* b = Buildings::findAtTile(DFCoord(x, y, z)); + if (options.auto_phase || options.query) query << get_tile_query(b) << ','; - if (phases & PLACE) + if (options.auto_phase || options.place) place << get_tile_place(x, y, b) << ','; - if (phases & BUILD) + if (options.auto_phase || options.build) build << get_tile_build(x, y, b) << ','; - if (phases & DIG) + if (options.auto_phase || options.dig) dig << get_tile_dig(x, y, z) << ','; } - if (phases & QUERY) + if (options.auto_phase || options.query) query << "#" << endl; - if (phases & PLACE) + if (options.auto_phase || options.place) place << "#" << endl; - if (phases & BUILD) + if (options.auto_phase || options.build) build << "#" << endl; - if (phases & DIG) + if (options.auto_phase || options.dig) dig << "#" << endl; } - if (z < end.z - 1) + if (z != end.z - z_inc) { - if (phases & QUERY) - query << "#<" << endl; - if (phases & PLACE) - place << "#<" << endl; - if (phases & BUILD) - build << "#<" << endl; - if (phases & DIG) - dig << "#<" << endl; + if (options.auto_phase || options.query) + query << z_key << endl; + if (options.auto_phase || options.place) + place << z_key << endl; + if (options.auto_phase || options.build) + build << z_key << endl; + if (options.auto_phase || options.dig) + dig << z_key << endl; } } - if (phases & QUERY) + if (options.auto_phase || options.query) query.close(); - if (phases & PLACE) + if (options.auto_phase || options.place) place.close(); - if (phases & BUILD) + if (options.auto_phase || options.build) build.close(); - if (phases & DIG) + if (options.auto_phase || options.dig) dig.close(); + return CR_OK; } -bool cmd_option_exists(vector& parameters, const string& option) +static bool get_options(blueprint_options &opts, + const vector ¶meters) { - return find(parameters.begin(), parameters.end(), option) != parameters.end(); + auto L = Lua::Core::State; + color_ostream_proxy out(Core::getInstance().getConsole()); + Lua::StackUnwinder top(L); + + if (!lua_checkstack(L, parameters.size() + 2) || + !Lua::PushModulePublic( + out, L, "plugins.blueprint", "parse_commandline")) + { + out.printerr("Failed to load blueprint Lua code\n"); + return false; + } + + Lua::Push(L, &opts); + + for (const string ¶m : parameters) + Lua::Push(L, param); + + if (!Lua::SafeCall(out, L, parameters.size() + 1, 0)) + return false; + + return true; +} + +static void print_help() +{ + auto L = Lua::Core::State; + color_ostream_proxy out(Core::getInstance().getConsole()); + Lua::StackUnwinder top(L); + + if (!lua_checkstack(L, 1) || + !Lua::PushModulePublic(out, L, "plugins.blueprint", "print_help") || + !Lua::SafeCall(out, L, 0, 0)) + { + out.printerr("Failed to load blueprint Lua code\n"); + } } command_result blueprint(color_ostream &out, vector ¶meters) { - if (parameters.size() < 4 || parameters.size() > 8) - return help(out); CoreSuspender suspend; - if (!Maps::IsValid()) + + if (parameters.size() >= 1 && parameters[0] == "gui") { - out.printerr("Map is not available!\n"); - return CR_FAILURE; + std::ostringstream command; + command << "gui/blueprint"; + for (const string ¶m : parameters) + { + command << " " << param; + } + string command_str = command.str(); + out.print("launching %s\n", command_str.c_str()); + + Core::getInstance().setHotkeyCmd(command_str); + return CR_OK; } - int32_t x, y, z; - if (!Gui::getCursorCoords(x, y, z)) + + blueprint_options options; + if (!get_options(options, parameters) || options.help) { - out.printerr("Can't get cursor coords! Make sure you have an active cursor in DF.\n"); + print_help(); + return options.help ? CR_OK : CR_FAILURE; + } + + if (!Maps::IsValid()) + { + out.printerr("Map is not available!\n"); return CR_FAILURE; } - DFCoord start (x, y, z); - DFCoord end (x + stoi(parameters[0]), y + stoi(parameters[1]), z + stoi(parameters[2])); - uint32_t option = 0; - if (parameters.size() == 4) + + // start coordinates can come from either the commandline or the map cursor + DFCoord start(options.start); + if (start.x == -30000) { - option = DIG | BUILD | PLACE | QUERY; + if (!Gui::getCursorCoords(start)) + { + out.printerr("Can't get cursor coords! Make sure you specify the" + " --cursor parameter or have an active cursor in DF.\n"); + return CR_FAILURE; + } } - else + if (!Maps::isValidTilePos(start)) { - if (cmd_option_exists(parameters, "dig")) - option |= DIG; - if (cmd_option_exists(parameters, "build")) - option |= BUILD; - if (cmd_option_exists(parameters, "place")) - option |= PLACE; - if (cmd_option_exists(parameters, "query")) - option |= QUERY; + out.printerr("Invalid start position: %d,%d,%d\n", + start.x, start.y, start.z); + return CR_FAILURE; } - std::ostringstream err; - DFHack::command_result result = do_transform(start, end, parameters[3], option, err); - if (result != CR_OK) - out.printerr("%s\n", err.str().c_str()); - return result; -} - -static int create(lua_State *L, uint32_t options) { - df::coord start, end; - lua_settop(L, 3); - Lua::CheckDFAssign(L, &start, 1); - if (!start.isValid()) - luaL_argerror(L, 1, "invalid start position"); - Lua::CheckDFAssign(L, &end, 2); - if (!end.isValid()) - luaL_argerror(L, 2, "invalid end position"); - string filename(lua_tostring(L, 3)); + // end coords are one beyond the last processed coordinate. note that + // options.depth can be negative. + DFCoord end(start.x + options.width, start.y + options.height, + start.z + options.depth); + + // crop end coordinate to map bounds. we've already verified that start is + // a valid coordinate, and width, height, and depth are non-zero, so our + // final area is always going to be at least 1x1x1. + df::world::T_map &map = df::global::world->map; + if (end.x > map.x_count) + end.x = map.x_count; + if (end.y > map.y_count) + end.y = map.y_count; + if (end.z > map.z_count) + end.z = map.z_count; + if (end.z < -1) + end.z = -1; std::ostringstream err; - DFHack::command_result result = do_transform(start, end, filename, options, err); + command_result result = do_transform(start, end, options, err); if (result != CR_OK) - luaL_error(L, "%s", err.str().c_str()); - lua_pushboolean(L, result); - return 1; -} - -static int dig(lua_State *L) { - return create(L, DIG); -} - -static int build(lua_State *L) { - return create(L, BUILD); -} - -static int place(lua_State *L) { - return create(L, PLACE); -} - -static int query(lua_State *L) { - return create(L, QUERY); + out.printerr("%s\n", err.str().c_str()); + return result; } - -DFHACK_PLUGIN_LUA_COMMANDS { - DFHACK_LUA_COMMAND(dig), - DFHACK_LUA_COMMAND(build), - DFHACK_LUA_COMMAND(place), - DFHACK_LUA_COMMAND(query), - DFHACK_LUA_END -}; diff --git a/plugins/lua/blueprint.lua b/plugins/lua/blueprint.lua index 98f24f487..e9b7ec667 100644 --- a/plugins/lua/blueprint.lua +++ b/plugins/lua/blueprint.lua @@ -1,14 +1,165 @@ local _ENV = mkmodule('plugins.blueprint') ---[[ +local utils = require('utils') - Native functions: +-- the info here is very basic and minimal, so hopefully we won't need to change +-- it when features are added and the full blueprint docs in Plugins.rst are +-- updated. +local help_text = [=[ - * dig(start, end, name) - * build(start, end, name) - * place(start, end, name) - * query(start, end, name) +blueprint +========= ---]] +Records the structure of a portion of your fortress in quickfort blueprints. + +Usage: + + blueprint [] [ []] [] + blueprint gui [ []] [] + +Examples: + +blueprint gui + Runs gui/blueprint, the interactive blueprint frontend, where all + configuration can be set visually and interactively. + +blueprint 30 40 bedrooms + Generates blueprints for an area 30 tiles wide by 40 tiles tall, starting + from the active cursor on the current z-level. Output files are written to + the "blueprints" directory. + +See the online DFHack documentation for more examples and details. +]=] + +function print_help() print(help_text) end + +local valid_phase_list = { + 'dig', + 'build', + 'place', + 'query', +} + +valid_phases = utils.invert(valid_phase_list) + +local function parse_cursor(opts, arg) + local _, _, x, y, z = arg:find('^(%d+),(%d+),(%d+)$') + if not x then + qerror(('invalid argument for --cursor option: "%s"; expected format' .. + ' is ",,", for example: "30,60,150"'):format(arg)) + end + -- be careful not to replace struct members when called from C++, but also + -- create the table as needed when called from lua + if not opts.start then opts.start = {} end + opts.start.x = tonumber(x) + opts.start.y = tonumber(y) + opts.start.z = tonumber(z) +end + +local function parse_positionals(opts, args, start_argidx) + local argidx = start_argidx or 1 + + -- set defaults + opts.name, opts.auto_phase = 'blueprint', true + + local name = args[argidx] + if not name then return end + if name == '' then + qerror(('invalid basename: "%s"; must be a valid, non-empty pathname') + :format(args[argidx])) + end + argidx = argidx + 1 + -- normalize paths to forward slashes + opts.name = name:gsub(package.config:sub(1,1), "/") + + local auto_phase = true + local phase = args[argidx] + while phase do + if not valid_phases[phase] then + qerror(('unknown phase: "%s"; expected one of: %s'): + format(phase, table.concat(valid_phase_list, ', '))) + end + auto_phase = false + opts[phase] = true + argidx = argidx + 1 + phase = args[argidx] + end + opts.auto_phase = auto_phase +end + +local function process_args(opts, args) + if args[1] == 'help' then + opts.help = true + return + end + + return utils.processArgsGetopt(args, { + {'c', 'cursor', hasArg=true, + handler=function(optarg) parse_cursor(opts, optarg) end}, + {'h', 'help', handler=function() opts.help = true end}, + }) +end + +-- used by the gui/blueprint script +function parse_gui_commandline(opts, args) + local positionals = process_args(opts, args) + if opts.help then return end + parse_positionals(opts, positionals) +end + +-- dimension must be a non-nil integer that is >= 1 (or at least non-zero if +-- negative_ok is true) +local function is_bad_dim(dim, negative_ok) + return not dim or + (not negative_ok and dim < 1 or dim == 0) or + dim ~= math.floor(dim) +end + +function parse_commandline(opts, ...) + local positionals = process_args(opts, {...}) + if opts.help then return end + + local width, height = tonumber(positionals[1]), tonumber(positionals[2]) + if is_bad_dim(width) or is_bad_dim(height) then + qerror(('invalid width or height: "%s" "%s"; width and height must' .. + ' be positive integers'):format(positionals[1], positionals[2])) + end + opts.width, opts.height, opts.depth = width, height, 1 + + local depth = tonumber(positionals[3]) + if depth then + if is_bad_dim(depth, true) then + qerror(('invalid depth: "%s"; must be a non-zero integer') + :format(positionals[3])) + end + opts.depth = depth + end + + parse_positionals(opts, positionals, depth and 4 or 3) +end + +-- compatibility with old exported API. we route the request back through +-- run_command so we have a unified path for parameter processing and invariant +-- checking. +local function do_blueprint(start_pos, end_pos, name, phase) + local width = math.abs(start_pos.x - end_pos.x) + 1 + local height = math.abs(start_pos.y - end_pos.y) + 1 + local depth = math.abs(start_pos.z - end_pos.z) + 1 + if start_pos.z > end_pos.z then depth = -depth end + + local x = math.min(start_pos.x, end_pos.x) + local y = math.min(start_pos.y, end_pos.y) + local z = start_pos.z + + local cursor = ('--cursor=%d,%d,%d'):format(x, y, z) + + return dfhack.run_command('blueprint', + tostring(width), tostring(height), + tostring(depth), tostring(name), + phase, cursor) +end +for phase in pairs(valid_phases) do + _ENV[phase] = function(s, e, n) do_blueprint(s, e, n, phase) end +end return _ENV diff --git a/test/plugins/blueprint.lua b/test/plugins/blueprint.lua new file mode 100644 index 000000000..e579a668f --- /dev/null +++ b/test/plugins/blueprint.lua @@ -0,0 +1,164 @@ +local b = require('plugins.blueprint') + +-- also covers code shared between parse_gui_commandline and parse_commandline +function test.parse_gui_commandline() + local opts = {} + b.parse_gui_commandline(opts, {}) + expect.table_eq({auto_phase=true, name='blueprint'}, opts) + + opts = {} + b.parse_gui_commandline(opts, {'help'}) + expect.table_eq({help=true}, opts) + + opts = {} + b.parse_gui_commandline(opts, {'--help'}) + expect.table_eq({help=true}, opts) + + opts = {} + b.parse_gui_commandline(opts, {'-h'}) + expect.table_eq({help=true}, opts) + + opts = {} + b.parse_gui_commandline(opts, {'--cursor=1,2,3'}) + expect.table_eq({auto_phase=true, name='blueprint', start={x=1,y=2,z=3}}, + opts) + + opts = {} + expect.error_match('invalid argument', + function() b.parse_gui_commandline( + opts, {'--cursor=-1,2,3'}) end, + 'negative coordinate') + + opts = {} + expect.error_match('invalid argument', + function() b.parse_gui_commandline( + opts, {'--cursor=1,b,3'}) end, + 'non-numeric coordinate') + + opts = {} + b.parse_gui_commandline(opts, {'imaname'}) + expect.table_eq({auto_phase=true, name='imaname'}, opts) + + opts = {} + expect.error_match('invalid basename', + function() b.parse_gui_commandline(opts, {''}) end) + + opts = {} + b.parse_gui_commandline(opts, {'imaname', 'dig', 'query'}) + expect.table_eq({auto_phase=false, name='imaname', dig=true, query=true}, + opts) + + opts = {} + expect.error_match('unknown phase', + function() b.parse_gui_commandline( + opts, {'imaname', 'garbagephase'}) end) +end + +function test.parse_commandline() + local opts = {} + b.parse_commandline(opts, '1', '2') + expect.table_eq({auto_phase=true,name='blueprint',width=1,height=2,depth=1}, + opts) + + opts = {} + b.parse_commandline(opts, '1', '2', '3') + expect.table_eq({auto_phase=true,name='blueprint',width=1,height=2,depth=3}, + opts) + + opts = {} + b.parse_commandline(opts, '1', '2', '-3') + expect.table_eq({auto_phase=true,name='blueprint',width=1,height=2,depth=-3}, + opts) + + opts = {} + b.parse_commandline(opts, '1', '2', 'imaname') + expect.table_eq({auto_phase=true,name='imaname',width=1,height=2,depth=1}, + opts) + + opts = {} + b.parse_commandline(opts, '1', '2', '10imaname') + expect.table_eq({auto_phase=true,name='10imaname',width=1,height=2,depth=1}, + opts, 'invalid depth is considered a basename') + + opts = {} + b.parse_commandline(opts, '1', '2', '-10imaname') + expect.table_eq({auto_phase=true,name='-10imaname',width=1,height=2,depth=1}, + opts, 'invalid negative depth is considered a basename') + + opts = {} + b.parse_commandline(opts, '1', '2', '3', 'imaname') + expect.table_eq({auto_phase=true,name='imaname',width=1,height=2,depth=3}, + opts) + + opts = {} + expect.error_match('invalid width or height', + function() b.parse_commandline(opts) end, + 'missing width') + + opts = {} + expect.error_match('invalid width or height', + function() b.parse_commandline(opts, '10') end, + 'missing height') + + opts = {} + expect.error_match('invalid width or height', + function() b.parse_commandline(opts, '0') end, + 'zero height') + + opts = {} + expect.error_match('invalid width or height', + function() b.parse_commandline(opts, 'hi') end, + 'invalid width') + + opts = {} + expect.error_match('invalid width or height', + function() b.parse_commandline(opts, '10', 'hi') end, + 'invalid height') + + opts = {} + expect.error_match('invalid depth', + function() b.parse_commandline(opts, '1', '2', '0') end, + 'zero depth') +end + +function test.do_blueprint_positive_dims() + local mock_run_command = mock.func() + mock.patch(dfhack, 'run_command', mock_run_command, + function() + local spos = {x=10, y=20, z=30} + local epos = {x=11, y=21, z=31} + b.query(spos, epos, 'imaname') + expect.eq(1, mock_run_command.call_count) + expect.table_eq({'blueprint', '2', '2', '2', 'imaname', 'query', + '--cursor=10,20,30'}, + mock_run_command.call_args[1]) + end) +end + +function test.do_blueprint_negative_dims() + local mock_run_command = mock.func() + mock.patch(dfhack, 'run_command', mock_run_command, + function() + local spos = {x=11, y=21, z=31} + local epos = {x=10, y=20, z=30} + b.query(spos, epos, 'imaname') + expect.eq(1, mock_run_command.call_count) + expect.table_eq({'blueprint', '2', '2', '-2', 'imaname', 'query', + '--cursor=10,20,31'}, + mock_run_command.call_args[1]) + end) +end + +function test.do_blueprint_ensure_cursor_is_at_upper_left() + local mock_run_command = mock.func() + mock.patch(dfhack, 'run_command', mock_run_command, + function() + local spos = {x=11, y=20, z=30} + local epos = {x=10, y=21, z=31} + b.query(spos, epos, 'imaname') + expect.eq(1, mock_run_command.call_count) + expect.table_eq({'blueprint', '2', '2', '2', 'imaname', 'query', + '--cursor=10,20,30'}, + mock_run_command.call_args[1]) + end) +end