diff --git a/CMakeLists.txt b/CMakeLists.txt index 50a8f497..7d81258b 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -1343,6 +1343,7 @@ add_executable(wowee_editor tools/editor/cli_add.cpp tools/editor/cli_random.cpp tools/editor/cli_items_export.cpp + tools/editor/cli_items_mutate.cpp tools/editor/editor_app.cpp tools/editor/editor_camera.cpp tools/editor/editor_viewport.cpp diff --git a/tools/editor/cli_items_mutate.cpp b/tools/editor/cli_items_mutate.cpp new file mode 100644 index 00000000..5973fe98 --- /dev/null +++ b/tools/editor/cli_items_mutate.cpp @@ -0,0 +1,364 @@ +#include "cli_items_mutate.hpp" + +#include + +#include +#include +#include +#include +#include +#include +#include +#include + +namespace wowee { +namespace editor { +namespace cli { + +namespace { + +int handleSetItem(int& i, int argc, char** argv) { + // Edit fields on an existing item in place. Lookup is by + // id by default; '#N' for index lookup. Only specified + // flags are changed; everything else is preserved + // verbatim — including any extra fields added by hand. + // + // Supported flags: --name, --quality, --displayId, + // --itemLevel, --stackable. Each takes one positional + // argument that follows the flag. + std::string zoneDir = argv[++i]; + std::string lookup = argv[++i]; + namespace fs = std::filesystem; + std::string path = zoneDir + "/items.json"; + if (!fs::exists(path)) { + std::fprintf(stderr, + "set-item: %s has no items.json\n", zoneDir.c_str()); + return 1; + } + nlohmann::json doc; + try { + std::ifstream in(path); + in >> doc; + } catch (...) { + std::fprintf(stderr, + "set-item: %s is not valid JSON\n", path.c_str()); + return 1; + } + if (!doc.contains("items") || !doc["items"].is_array()) { + std::fprintf(stderr, + "set-item: %s has no 'items' array\n", path.c_str()); + return 1; + } + auto& items = doc["items"]; + int foundIdx = -1; + if (!lookup.empty() && lookup[0] == '#') { + try { + int idx = std::stoi(lookup.substr(1)); + if (idx >= 0 && static_cast(idx) < items.size()) + foundIdx = idx; + } catch (...) {} + } else { + uint32_t targetId = 0; + try { targetId = static_cast(std::stoul(lookup)); } + catch (...) { + std::fprintf(stderr, + "set-item: lookup '%s' is not a number\n", + lookup.c_str()); + return 1; + } + for (size_t k = 0; k < items.size(); ++k) { + if (items[k].contains("id") && + items[k]["id"].is_number_unsigned() && + items[k]["id"].get() == targetId) { + foundIdx = static_cast(k); + break; + } + } + } + if (foundIdx < 0) { + std::fprintf(stderr, + "set-item: no match for '%s' in %s\n", + lookup.c_str(), path.c_str()); + return 1; + } + auto& it = items[foundIdx]; + std::vector changes; + // Walk the remaining args looking for known --field value + // pairs. Anything unrecognized is reported and aborts so + // typos don't silently no-op. + while (i + 2 < argc) { + std::string flag = argv[i + 1]; + std::string val = argv[i + 2]; + if (flag.size() < 2 || flag[0] != '-' || flag[1] != '-') break; + if (flag == "--name") { + it["name"] = val; + changes.push_back("name=" + val); + } else if (flag == "--quality") { + try { + uint32_t q = static_cast(std::stoul(val)); + if (q > 6) { + std::fprintf(stderr, + "set-item: quality %u out of range (0..6)\n", q); + return 1; + } + it["quality"] = q; + changes.push_back("quality=" + val); + } catch (...) { + std::fprintf(stderr, + "set-item: --quality needs a number\n"); + return 1; + } + } else if (flag == "--displayId") { + try { + it["displayId"] = static_cast(std::stoul(val)); + changes.push_back("displayId=" + val); + } catch (...) { + std::fprintf(stderr, + "set-item: --displayId needs a number\n"); + return 1; + } + } else if (flag == "--itemLevel") { + try { + it["itemLevel"] = static_cast(std::stoul(val)); + changes.push_back("itemLevel=" + val); + } catch (...) { + std::fprintf(stderr, + "set-item: --itemLevel needs a number\n"); + return 1; + } + } else if (flag == "--stackable") { + try { + uint32_t s = static_cast(std::stoul(val)); + if (s == 0 || s > 1000) { + std::fprintf(stderr, + "set-item: stackable %u out of range (1..1000)\n", s); + return 1; + } + it["stackable"] = s; + changes.push_back("stackable=" + val); + } catch (...) { + std::fprintf(stderr, + "set-item: --stackable needs a number\n"); + return 1; + } + } else { + std::fprintf(stderr, + "set-item: unknown flag '%s' (typo?)\n", flag.c_str()); + return 1; + } + i += 2; + } + if (changes.empty()) { + std::fprintf(stderr, + "set-item: no field flags supplied — nothing to change\n"); + return 1; + } + std::ofstream out(path); + if (!out) { + std::fprintf(stderr, + "set-item: failed to write %s\n", path.c_str()); + return 1; + } + out << doc.dump(2); + out.close(); + std::printf("Updated item %d in %s:\n", foundIdx, path.c_str()); + for (const auto& c : changes) { + std::printf(" %s\n", c.c_str()); + } + return 0; +} + +int handleCopyZoneItems(int& i, int argc, char** argv) { + // Copy items from one zone to another. Default mode + // replaces the destination items.json wholesale; --merge + // appends each source item to the existing destination + // list, re-id'ing on collision so the destination's + // existing IDs are preserved and the source's new + // entries get fresh ones. + std::string fromZone = argv[++i]; + std::string toZone = argv[++i]; + bool mergeMode = false; + if (i + 1 < argc && std::strcmp(argv[i + 1], "--merge") == 0) { + mergeMode = true; i++; + } + namespace fs = std::filesystem; + std::string srcPath = fromZone + "/items.json"; + if (!fs::exists(srcPath)) { + std::fprintf(stderr, + "copy-zone-items: %s has no items.json\n", fromZone.c_str()); + return 1; + } + if (!fs::exists(toZone) || !fs::is_directory(toZone)) { + std::fprintf(stderr, + "copy-zone-items: dest %s is not a directory\n", + toZone.c_str()); + return 1; + } + nlohmann::json src; + try { + std::ifstream in(srcPath); + in >> src; + } catch (...) { + std::fprintf(stderr, + "copy-zone-items: %s is not valid JSON\n", srcPath.c_str()); + return 1; + } + if (!src.contains("items") || !src["items"].is_array()) { + std::fprintf(stderr, + "copy-zone-items: %s has no 'items' array\n", + srcPath.c_str()); + return 1; + } + std::string dstPath = toZone + "/items.json"; + nlohmann::json dst = nlohmann::json::object({{"items", + nlohmann::json::array()}}); + int copied = 0, reIded = 0; + if (mergeMode && fs::exists(dstPath)) { + try { + std::ifstream in(dstPath); + in >> dst; + } catch (...) {} + if (!dst.contains("items") || !dst["items"].is_array()) { + dst["items"] = nlohmann::json::array(); + } + std::set usedIds; + for (const auto& it : dst["items"]) { + if (it.contains("id") && it["id"].is_number_unsigned()) { + usedIds.insert(it["id"].get()); + } + } + for (const auto& it : src["items"]) { + nlohmann::json newItem = it; + uint32_t srcId = it.value("id", 0u); + if (srcId == 0 || usedIds.count(srcId)) { + // Pick the next free id. + uint32_t fresh = 1; + while (usedIds.count(fresh)) ++fresh; + newItem["id"] = fresh; + usedIds.insert(fresh); + if (srcId != 0) reIded++; + } else { + usedIds.insert(srcId); + } + dst["items"].push_back(newItem); + copied++; + } + } else { + // Replace mode: destination becomes a verbatim copy of + // the source items array. + dst["items"] = src["items"]; + copied = static_cast(src["items"].size()); + } + std::ofstream out(dstPath); + if (!out) { + std::fprintf(stderr, + "copy-zone-items: failed to write %s\n", dstPath.c_str()); + return 1; + } + out << dst.dump(2); + out.close(); + std::printf("Copied %d item(s) from %s to %s\n", + copied, fromZone.c_str(), toZone.c_str()); + std::printf(" mode : %s\n", + mergeMode ? "merge (append + re-id)" : "replace"); + std::printf(" dst total : %zu\n", dst["items"].size()); + if (reIded > 0) { + std::printf(" re-ided : %d (id collisions)\n", reIded); + } + return 0; +} + +int handleCloneItem(int& i, int argc, char** argv) { + // Duplicate the item at given 0-based index. Auto-assigns + // the smallest unused positive id; optional + // overrides the cloned name (without it the new entry + // gets " (copy)" appended). + std::string zoneDir = argv[++i]; + int idx = -1; + try { idx = std::stoi(argv[++i]); } + catch (...) { + std::fprintf(stderr, + "clone-item: index must be an integer\n"); + return 1; + } + std::string newName; + if (i + 1 < argc && argv[i + 1][0] != '-') newName = argv[++i]; + namespace fs = std::filesystem; + std::string path = zoneDir + "/items.json"; + if (!fs::exists(path)) { + std::fprintf(stderr, + "clone-item: %s has no items.json\n", zoneDir.c_str()); + return 1; + } + nlohmann::json doc; + try { + std::ifstream in(path); + in >> doc; + } catch (...) { + std::fprintf(stderr, + "clone-item: %s is not valid JSON\n", path.c_str()); + return 1; + } + if (!doc.contains("items") || !doc["items"].is_array()) { + std::fprintf(stderr, + "clone-item: %s has no 'items' array\n", path.c_str()); + return 1; + } + auto& items = doc["items"]; + if (idx < 0 || static_cast(idx) >= items.size()) { + std::fprintf(stderr, + "clone-item: index %d out of range (have %zu)\n", + idx, items.size()); + return 1; + } + // Pick the next free id. + std::set used; + for (const auto& it : items) { + if (it.contains("id") && it["id"].is_number_unsigned()) { + used.insert(it["id"].get()); + } + } + uint32_t newId = 1; + while (used.count(newId)) ++newId; + nlohmann::json clone = items[idx]; + clone["id"] = newId; + if (!newName.empty()) { + clone["name"] = newName; + } else { + std::string oldName = clone.value("name", std::string("(unnamed)")); + clone["name"] = oldName + " (copy)"; + } + items.push_back(clone); + std::ofstream out(path); + if (!out) { + std::fprintf(stderr, + "clone-item: failed to write %s\n", path.c_str()); + return 1; + } + out << doc.dump(2); + out.close(); + std::printf("Cloned item idx %d to '%s' (id=%u) in %s (now %zu total)\n", + idx, clone["name"].get().c_str(), + newId, path.c_str(), items.size()); + return 0; +} + + +} // namespace + +bool handleItemsMutate(int& i, int argc, char** argv, int& outRc) { + if (std::strcmp(argv[i], "--set-item") == 0 && i + 2 < argc) { + outRc = handleSetItem(i, argc, argv); return true; + } + if (std::strcmp(argv[i], "--copy-zone-items") == 0 && i + 2 < argc) { + outRc = handleCopyZoneItems(i, argc, argv); return true; + } + if (std::strcmp(argv[i], "--clone-item") == 0 && i + 2 < argc) { + outRc = handleCloneItem(i, argc, argv); return true; + } + return false; +} + +} // namespace cli +} // namespace editor +} // namespace wowee diff --git a/tools/editor/cli_items_mutate.hpp b/tools/editor/cli_items_mutate.hpp new file mode 100644 index 00000000..47302a4e --- /dev/null +++ b/tools/editor/cli_items_mutate.hpp @@ -0,0 +1,18 @@ +#pragma once + +namespace wowee { +namespace editor { +namespace cli { + +// Dispatch the items.json mutation handlers — edit / duplicate / +// transfer item records in a zone's items.json. +// --set-item edit fields on an existing item by id or #idx +// --copy-zone-items copy items between zones (replace or merge) +// --clone-item duplicate an item with a fresh id +// +// Returns true if matched; outRc holds the exit code. +bool handleItemsMutate(int& i, int argc, char** argv, int& outRc); + +} // namespace cli +} // namespace editor +} // namespace wowee diff --git a/tools/editor/main.cpp b/tools/editor/main.cpp index 4d17fc50..34131e2d 100644 --- a/tools/editor/main.cpp +++ b/tools/editor/main.cpp @@ -44,6 +44,7 @@ #include "cli_add.hpp" #include "cli_random.hpp" #include "cli_items_export.hpp" +#include "cli_items_mutate.hpp" #include "content_pack.hpp" #include "npc_spawner.hpp" #include "object_placer.hpp" @@ -512,6 +513,9 @@ int main(int argc, char* argv[]) { if (wowee::editor::cli::handleItemsExport(i, argc, argv, outRc)) { return outRc; } + if (wowee::editor::cli::handleItemsMutate(i, argc, argv, outRc)) { + return outRc; + } } if (std::strcmp(argv[i], "--data") == 0 && i + 1 < argc) { dataPath = argv[++i]; @@ -1440,326 +1444,6 @@ int main(int argc, char* argv[]) { outPath.c_str(), col.triangles.size(), col.walkableCount(), col.steepCount()); return 0; - } else if (std::strcmp(argv[i], "--set-item") == 0 && i + 2 < argc) { - // Edit fields on an existing item in place. Lookup is by - // id by default; '#N' for index lookup. Only specified - // flags are changed; everything else is preserved - // verbatim — including any extra fields added by hand. - // - // Supported flags: --name, --quality, --displayId, - // --itemLevel, --stackable. Each takes one positional - // argument that follows the flag. - std::string zoneDir = argv[++i]; - std::string lookup = argv[++i]; - namespace fs = std::filesystem; - std::string path = zoneDir + "/items.json"; - if (!fs::exists(path)) { - std::fprintf(stderr, - "set-item: %s has no items.json\n", zoneDir.c_str()); - return 1; - } - nlohmann::json doc; - try { - std::ifstream in(path); - in >> doc; - } catch (...) { - std::fprintf(stderr, - "set-item: %s is not valid JSON\n", path.c_str()); - return 1; - } - if (!doc.contains("items") || !doc["items"].is_array()) { - std::fprintf(stderr, - "set-item: %s has no 'items' array\n", path.c_str()); - return 1; - } - auto& items = doc["items"]; - int foundIdx = -1; - if (!lookup.empty() && lookup[0] == '#') { - try { - int idx = std::stoi(lookup.substr(1)); - if (idx >= 0 && static_cast(idx) < items.size()) - foundIdx = idx; - } catch (...) {} - } else { - uint32_t targetId = 0; - try { targetId = static_cast(std::stoul(lookup)); } - catch (...) { - std::fprintf(stderr, - "set-item: lookup '%s' is not a number\n", - lookup.c_str()); - return 1; - } - for (size_t k = 0; k < items.size(); ++k) { - if (items[k].contains("id") && - items[k]["id"].is_number_unsigned() && - items[k]["id"].get() == targetId) { - foundIdx = static_cast(k); - break; - } - } - } - if (foundIdx < 0) { - std::fprintf(stderr, - "set-item: no match for '%s' in %s\n", - lookup.c_str(), path.c_str()); - return 1; - } - auto& it = items[foundIdx]; - std::vector changes; - // Walk the remaining args looking for known --field value - // pairs. Anything unrecognized is reported and aborts so - // typos don't silently no-op. - while (i + 2 < argc) { - std::string flag = argv[i + 1]; - std::string val = argv[i + 2]; - if (flag.size() < 2 || flag[0] != '-' || flag[1] != '-') break; - if (flag == "--name") { - it["name"] = val; - changes.push_back("name=" + val); - } else if (flag == "--quality") { - try { - uint32_t q = static_cast(std::stoul(val)); - if (q > 6) { - std::fprintf(stderr, - "set-item: quality %u out of range (0..6)\n", q); - return 1; - } - it["quality"] = q; - changes.push_back("quality=" + val); - } catch (...) { - std::fprintf(stderr, - "set-item: --quality needs a number\n"); - return 1; - } - } else if (flag == "--displayId") { - try { - it["displayId"] = static_cast(std::stoul(val)); - changes.push_back("displayId=" + val); - } catch (...) { - std::fprintf(stderr, - "set-item: --displayId needs a number\n"); - return 1; - } - } else if (flag == "--itemLevel") { - try { - it["itemLevel"] = static_cast(std::stoul(val)); - changes.push_back("itemLevel=" + val); - } catch (...) { - std::fprintf(stderr, - "set-item: --itemLevel needs a number\n"); - return 1; - } - } else if (flag == "--stackable") { - try { - uint32_t s = static_cast(std::stoul(val)); - if (s == 0 || s > 1000) { - std::fprintf(stderr, - "set-item: stackable %u out of range (1..1000)\n", s); - return 1; - } - it["stackable"] = s; - changes.push_back("stackable=" + val); - } catch (...) { - std::fprintf(stderr, - "set-item: --stackable needs a number\n"); - return 1; - } - } else { - std::fprintf(stderr, - "set-item: unknown flag '%s' (typo?)\n", flag.c_str()); - return 1; - } - i += 2; - } - if (changes.empty()) { - std::fprintf(stderr, - "set-item: no field flags supplied — nothing to change\n"); - return 1; - } - std::ofstream out(path); - if (!out) { - std::fprintf(stderr, - "set-item: failed to write %s\n", path.c_str()); - return 1; - } - out << doc.dump(2); - out.close(); - std::printf("Updated item %d in %s:\n", foundIdx, path.c_str()); - for (const auto& c : changes) { - std::printf(" %s\n", c.c_str()); - } - return 0; - } else if (std::strcmp(argv[i], "--copy-zone-items") == 0 && i + 2 < argc) { - // Copy items from one zone to another. Default mode - // replaces the destination items.json wholesale; --merge - // appends each source item to the existing destination - // list, re-id'ing on collision so the destination's - // existing IDs are preserved and the source's new - // entries get fresh ones. - std::string fromZone = argv[++i]; - std::string toZone = argv[++i]; - bool mergeMode = false; - if (i + 1 < argc && std::strcmp(argv[i + 1], "--merge") == 0) { - mergeMode = true; i++; - } - namespace fs = std::filesystem; - std::string srcPath = fromZone + "/items.json"; - if (!fs::exists(srcPath)) { - std::fprintf(stderr, - "copy-zone-items: %s has no items.json\n", fromZone.c_str()); - return 1; - } - if (!fs::exists(toZone) || !fs::is_directory(toZone)) { - std::fprintf(stderr, - "copy-zone-items: dest %s is not a directory\n", - toZone.c_str()); - return 1; - } - nlohmann::json src; - try { - std::ifstream in(srcPath); - in >> src; - } catch (...) { - std::fprintf(stderr, - "copy-zone-items: %s is not valid JSON\n", srcPath.c_str()); - return 1; - } - if (!src.contains("items") || !src["items"].is_array()) { - std::fprintf(stderr, - "copy-zone-items: %s has no 'items' array\n", - srcPath.c_str()); - return 1; - } - std::string dstPath = toZone + "/items.json"; - nlohmann::json dst = nlohmann::json::object({{"items", - nlohmann::json::array()}}); - int copied = 0, reIded = 0; - if (mergeMode && fs::exists(dstPath)) { - try { - std::ifstream in(dstPath); - in >> dst; - } catch (...) {} - if (!dst.contains("items") || !dst["items"].is_array()) { - dst["items"] = nlohmann::json::array(); - } - std::set usedIds; - for (const auto& it : dst["items"]) { - if (it.contains("id") && it["id"].is_number_unsigned()) { - usedIds.insert(it["id"].get()); - } - } - for (const auto& it : src["items"]) { - nlohmann::json newItem = it; - uint32_t srcId = it.value("id", 0u); - if (srcId == 0 || usedIds.count(srcId)) { - // Pick the next free id. - uint32_t fresh = 1; - while (usedIds.count(fresh)) ++fresh; - newItem["id"] = fresh; - usedIds.insert(fresh); - if (srcId != 0) reIded++; - } else { - usedIds.insert(srcId); - } - dst["items"].push_back(newItem); - copied++; - } - } else { - // Replace mode: destination becomes a verbatim copy of - // the source items array. - dst["items"] = src["items"]; - copied = static_cast(src["items"].size()); - } - std::ofstream out(dstPath); - if (!out) { - std::fprintf(stderr, - "copy-zone-items: failed to write %s\n", dstPath.c_str()); - return 1; - } - out << dst.dump(2); - out.close(); - std::printf("Copied %d item(s) from %s to %s\n", - copied, fromZone.c_str(), toZone.c_str()); - std::printf(" mode : %s\n", - mergeMode ? "merge (append + re-id)" : "replace"); - std::printf(" dst total : %zu\n", dst["items"].size()); - if (reIded > 0) { - std::printf(" re-ided : %d (id collisions)\n", reIded); - } - return 0; - } else if (std::strcmp(argv[i], "--clone-item") == 0 && i + 2 < argc) { - // Duplicate the item at given 0-based index. Auto-assigns - // the smallest unused positive id; optional - // overrides the cloned name (without it the new entry - // gets " (copy)" appended). - std::string zoneDir = argv[++i]; - int idx = -1; - try { idx = std::stoi(argv[++i]); } - catch (...) { - std::fprintf(stderr, - "clone-item: index must be an integer\n"); - return 1; - } - std::string newName; - if (i + 1 < argc && argv[i + 1][0] != '-') newName = argv[++i]; - namespace fs = std::filesystem; - std::string path = zoneDir + "/items.json"; - if (!fs::exists(path)) { - std::fprintf(stderr, - "clone-item: %s has no items.json\n", zoneDir.c_str()); - return 1; - } - nlohmann::json doc; - try { - std::ifstream in(path); - in >> doc; - } catch (...) { - std::fprintf(stderr, - "clone-item: %s is not valid JSON\n", path.c_str()); - return 1; - } - if (!doc.contains("items") || !doc["items"].is_array()) { - std::fprintf(stderr, - "clone-item: %s has no 'items' array\n", path.c_str()); - return 1; - } - auto& items = doc["items"]; - if (idx < 0 || static_cast(idx) >= items.size()) { - std::fprintf(stderr, - "clone-item: index %d out of range (have %zu)\n", - idx, items.size()); - return 1; - } - // Pick the next free id. - std::set used; - for (const auto& it : items) { - if (it.contains("id") && it["id"].is_number_unsigned()) { - used.insert(it["id"].get()); - } - } - uint32_t newId = 1; - while (used.count(newId)) ++newId; - nlohmann::json clone = items[idx]; - clone["id"] = newId; - if (!newName.empty()) { - clone["name"] = newName; - } else { - std::string oldName = clone.value("name", std::string("(unnamed)")); - clone["name"] = oldName + " (copy)"; - } - items.push_back(clone); - std::ofstream out(path); - if (!out) { - std::fprintf(stderr, - "clone-item: failed to write %s\n", path.c_str()); - return 1; - } - out << doc.dump(2); - out.close(); - std::printf("Cloned item idx %d to '%s' (id=%u) in %s (now %zu total)\n", - idx, clone["name"].get().c_str(), - newId, path.c_str(), items.size()); - return 0; } else if (std::strcmp(argv[i], "--scaffold-zone") == 0 && i + 1 < argc) { // Generate a minimal valid empty zone — useful for kickstarting // a new authoring session without needing to launch the GUI.