mirror of
https://github.com/Kelsidavis/WoWee.git
synced 2026-05-10 11:03:51 +00:00
Moves the report-export handlers (md / csv / html / sha256 / graphviz) for zone & project audits out of main.cpp: --export-zone-summary-md --export-zone-csv --export-zone-checksum --export-project-checksum --validate-project-checksum --export-zone-html --export-project-html --export-project-md --export-quest-graph Also moves the file-scope wowee_sha256 namespace (the SHA-256 implementation that the checksum exporters use) into the new module's anonymous namespace — it had no other callers in main.cpp so no cross-TU coupling needed. main.cpp drops 13,120 → 12,119 lines (-1,001). Build error during extraction (missing #include <unordered_set>) caught and fixed.
1090 lines
46 KiB
C++
1090 lines
46 KiB
C++
#include "cli_export.hpp"
|
||
|
||
#include "zone_manifest.hpp"
|
||
#include "npc_spawner.hpp"
|
||
#include "object_placer.hpp"
|
||
#include "quest_editor.hpp"
|
||
#include <nlohmann/json.hpp>
|
||
|
||
#include <algorithm>
|
||
#include <chrono>
|
||
#include <cstdint>
|
||
#include <cstdio>
|
||
#include <cstring>
|
||
#include <ctime>
|
||
#include <filesystem>
|
||
#include <fstream>
|
||
#include <map>
|
||
#include <set>
|
||
#include <sstream>
|
||
#include <string>
|
||
#include <unordered_set>
|
||
#include <vector>
|
||
|
||
namespace wowee {
|
||
namespace editor {
|
||
namespace cli {
|
||
|
||
namespace {
|
||
|
||
namespace wowee_sha256 {
|
||
struct State {
|
||
uint32_t h[8] = {0x6a09e667, 0xbb67ae85, 0x3c6ef372, 0xa54ff53a,
|
||
0x510e527f, 0x9b05688c, 0x1f83d9ab, 0x5be0cd19};
|
||
uint64_t totalBits = 0;
|
||
uint8_t buf[64] = {};
|
||
size_t bufLen = 0;
|
||
};
|
||
static inline uint32_t rotr(uint32_t x, uint32_t n) { return (x >> n) | (x << (32 - n)); }
|
||
static void compress(State& s, const uint8_t* block) {
|
||
static const uint32_t K[64] = {
|
||
0x428a2f98,0x71374491,0xb5c0fbcf,0xe9b5dba5,0x3956c25b,0x59f111f1,0x923f82a4,0xab1c5ed5,
|
||
0xd807aa98,0x12835b01,0x243185be,0x550c7dc3,0x72be5d74,0x80deb1fe,0x9bdc06a7,0xc19bf174,
|
||
0xe49b69c1,0xefbe4786,0x0fc19dc6,0x240ca1cc,0x2de92c6f,0x4a7484aa,0x5cb0a9dc,0x76f988da,
|
||
0x983e5152,0xa831c66d,0xb00327c8,0xbf597fc7,0xc6e00bf3,0xd5a79147,0x06ca6351,0x14292967,
|
||
0x27b70a85,0x2e1b2138,0x4d2c6dfc,0x53380d13,0x650a7354,0x766a0abb,0x81c2c92e,0x92722c85,
|
||
0xa2bfe8a1,0xa81a664b,0xc24b8b70,0xc76c51a3,0xd192e819,0xd6990624,0xf40e3585,0x106aa070,
|
||
0x19a4c116,0x1e376c08,0x2748774c,0x34b0bcb5,0x391c0cb3,0x4ed8aa4a,0x5b9cca4f,0x682e6ff3,
|
||
0x748f82ee,0x78a5636f,0x84c87814,0x8cc70208,0x90befffa,0xa4506ceb,0xbef9a3f7,0xc67178f2,
|
||
};
|
||
uint32_t w[64];
|
||
for (int i = 0; i < 16; ++i) {
|
||
w[i] = (uint32_t(block[i*4]) << 24) | (uint32_t(block[i*4+1]) << 16) |
|
||
(uint32_t(block[i*4+2]) << 8) | uint32_t(block[i*4+3]);
|
||
}
|
||
for (int i = 16; i < 64; ++i) {
|
||
uint32_t s0 = rotr(w[i-15], 7) ^ rotr(w[i-15], 18) ^ (w[i-15] >> 3);
|
||
uint32_t s1 = rotr(w[i-2], 17) ^ rotr(w[i-2], 19) ^ (w[i-2] >> 10);
|
||
w[i] = w[i-16] + s0 + w[i-7] + s1;
|
||
}
|
||
uint32_t a = s.h[0], b = s.h[1], c = s.h[2], d = s.h[3];
|
||
uint32_t e = s.h[4], f = s.h[5], g = s.h[6], h = s.h[7];
|
||
for (int i = 0; i < 64; ++i) {
|
||
uint32_t S1 = rotr(e, 6) ^ rotr(e, 11) ^ rotr(e, 25);
|
||
uint32_t ch = (e & f) ^ (~e & g);
|
||
uint32_t t1 = h + S1 + ch + K[i] + w[i];
|
||
uint32_t S0 = rotr(a, 2) ^ rotr(a, 13) ^ rotr(a, 22);
|
||
uint32_t mj = (a & b) ^ (a & c) ^ (b & c);
|
||
uint32_t t2 = S0 + mj;
|
||
h = g; g = f; f = e; e = d + t1;
|
||
d = c; c = b; b = a; a = t1 + t2;
|
||
}
|
||
s.h[0] += a; s.h[1] += b; s.h[2] += c; s.h[3] += d;
|
||
s.h[4] += e; s.h[5] += f; s.h[6] += g; s.h[7] += h;
|
||
}
|
||
static void update(State& s, const uint8_t* data, size_t len) {
|
||
s.totalBits += len * 8;
|
||
while (len > 0) {
|
||
size_t take = std::min(len, sizeof(s.buf) - s.bufLen);
|
||
std::memcpy(s.buf + s.bufLen, data, take);
|
||
s.bufLen += take; data += take; len -= take;
|
||
if (s.bufLen == 64) { compress(s, s.buf); s.bufLen = 0; }
|
||
}
|
||
}
|
||
static std::string hexFinal(State& s) {
|
||
s.buf[s.bufLen++] = 0x80;
|
||
if (s.bufLen > 56) {
|
||
std::memset(s.buf + s.bufLen, 0, 64 - s.bufLen);
|
||
compress(s, s.buf); s.bufLen = 0;
|
||
}
|
||
std::memset(s.buf + s.bufLen, 0, 56 - s.bufLen);
|
||
for (int i = 7; i >= 0; --i) s.buf[56 + (7 - i)] = (s.totalBits >> (i * 8)) & 0xFF;
|
||
compress(s, s.buf);
|
||
char out[65] = {};
|
||
for (int i = 0; i < 8; ++i) {
|
||
std::snprintf(out + i * 8, 9, "%08x", s.h[i]);
|
||
}
|
||
return std::string(out);
|
||
}
|
||
static std::string fileHex(const std::string& path) {
|
||
std::ifstream in(path, std::ios::binary);
|
||
if (!in) return "";
|
||
State s;
|
||
char chunk[16384];
|
||
while (in.read(chunk, sizeof(chunk)) || in.gcount() > 0) {
|
||
update(s, reinterpret_cast<const uint8_t*>(chunk),
|
||
static_cast<size_t>(in.gcount()));
|
||
}
|
||
return hexFinal(s);
|
||
}
|
||
static std::string hex(const uint8_t* data, size_t len) {
|
||
State s;
|
||
update(s, data, len);
|
||
return hexFinal(s);
|
||
}
|
||
} // namespace wowee_sha256
|
||
|
||
int handleExportZoneSummaryMd(int& i, int argc, char** argv) {
|
||
// Render a Markdown documentation page for a zone. Useful for
|
||
// designers tracking changes between versions, generating
|
||
// GitHub Pages docs, or reviewing zones in PRs without
|
||
// round-tripping through the GUI.
|
||
std::string zoneDir = argv[++i];
|
||
std::string outPath;
|
||
if (i + 1 < argc && argv[i + 1][0] != '-') {
|
||
outPath = argv[++i];
|
||
}
|
||
namespace fs = std::filesystem;
|
||
std::string manifestPath = zoneDir + "/zone.json";
|
||
if (!fs::exists(manifestPath)) {
|
||
std::fprintf(stderr,
|
||
"export-zone-summary-md: %s has no zone.json\n", zoneDir.c_str());
|
||
return 1;
|
||
}
|
||
wowee::editor::ZoneManifest zm;
|
||
if (!zm.load(manifestPath)) {
|
||
std::fprintf(stderr,
|
||
"export-zone-summary-md: failed to parse %s\n", manifestPath.c_str());
|
||
return 1;
|
||
}
|
||
// Default output: ZONE.md sitting next to zone.json.
|
||
if (outPath.empty()) outPath = zoneDir + "/ZONE.md";
|
||
// Load content sub-files; missing ones contribute 0 entries.
|
||
wowee::editor::NpcSpawner sp;
|
||
sp.loadFromFile(zoneDir + "/creatures.json");
|
||
wowee::editor::ObjectPlacer op;
|
||
op.loadFromFile(zoneDir + "/objects.json");
|
||
wowee::editor::QuestEditor qe;
|
||
qe.loadFromFile(zoneDir + "/quests.json");
|
||
std::ofstream md(outPath);
|
||
if (!md) {
|
||
std::fprintf(stderr,
|
||
"export-zone-summary-md: cannot write %s\n", outPath.c_str());
|
||
return 1;
|
||
}
|
||
md << "# " << (zm.displayName.empty() ? zm.mapName : zm.displayName) << "\n\n";
|
||
md << "*Auto-generated by `wowee_editor --export-zone-summary-md`. "
|
||
"Do not edit by hand.*\n\n";
|
||
md << "## Manifest\n\n";
|
||
md << "| Field | Value |\n";
|
||
md << "|---|---|\n";
|
||
md << "| Map name | `" << zm.mapName << "` |\n";
|
||
md << "| Display name | " << zm.displayName << " |\n";
|
||
md << "| Map ID | " << zm.mapId << " |\n";
|
||
if (!zm.biome.empty()) md << "| Biome | " << zm.biome << " |\n";
|
||
md << "| Base height | " << zm.baseHeight << " |\n";
|
||
md << "| Tile count | " << zm.tiles.size() << " |\n";
|
||
md << "| Allow flying | " << (zm.allowFlying ? "yes" : "no") << " |\n";
|
||
md << "| PvP enabled | " << (zm.pvpEnabled ? "yes" : "no") << " |\n";
|
||
md << "| Indoor | " << (zm.isIndoor ? "yes" : "no") << " |\n";
|
||
md << "| Sanctuary | " << (zm.isSanctuary ? "yes" : "no") << " |\n";
|
||
if (!zm.musicTrack.empty()) md << "| Music | `" << zm.musicTrack << "` |\n";
|
||
if (!zm.ambienceDay.empty()) md << "| Ambient (day) | `" << zm.ambienceDay << "` |\n";
|
||
if (!zm.ambienceNight.empty())md << "| Ambient (night) | `" << zm.ambienceNight << "` |\n";
|
||
if (!zm.description.empty()) {
|
||
md << "\n### Description\n\n" << zm.description << "\n";
|
||
}
|
||
md << "\n## Tiles\n\n";
|
||
md << "| tx | ty |\n|---|---|\n";
|
||
for (const auto& [tx, ty] : zm.tiles) {
|
||
md << "| " << tx << " | " << ty << " |\n";
|
||
}
|
||
md << "\n## Creatures (" << sp.spawnCount() << ")\n\n";
|
||
if (sp.spawnCount() == 0) {
|
||
md << "*No creature spawns.*\n";
|
||
} else {
|
||
md << "| # | Name | Lvl | DisplayId | Pos (x, y, z) | Flags |\n";
|
||
md << "|---|---|---|---|---|---|\n";
|
||
for (size_t k = 0; k < sp.spawnCount(); ++k) {
|
||
const auto& s = sp.getSpawns()[k];
|
||
md << "| " << k << " | " << s.name << " | " << s.level << " | "
|
||
<< s.displayId << " | ("
|
||
<< s.position.x << ", " << s.position.y << ", " << s.position.z
|
||
<< ") |";
|
||
if (s.hostile) md << " hostile";
|
||
if (s.questgiver) md << " quest";
|
||
if (s.vendor) md << " vendor";
|
||
if (s.trainer) md << " trainer";
|
||
md << " |\n";
|
||
}
|
||
}
|
||
md << "\n## Objects (" << op.getObjects().size() << ")\n\n";
|
||
if (op.getObjects().empty()) {
|
||
md << "*No object placements.*\n";
|
||
} else {
|
||
md << "| # | Type | Path | Pos | Scale |\n";
|
||
md << "|---|---|---|---|---|\n";
|
||
for (size_t k = 0; k < op.getObjects().size(); ++k) {
|
||
const auto& o = op.getObjects()[k];
|
||
md << "| " << k << " | "
|
||
<< (o.type == wowee::editor::PlaceableType::M2 ? "m2" : "wmo")
|
||
<< " | `" << o.path << "` | ("
|
||
<< o.position.x << ", " << o.position.y << ", " << o.position.z
|
||
<< ") | " << o.scale << " |\n";
|
||
}
|
||
}
|
||
md << "\n## Quests (" << qe.questCount() << ")\n\n";
|
||
if (qe.questCount() == 0) {
|
||
md << "*No quests.*\n";
|
||
} else {
|
||
using OT = wowee::editor::QuestObjectiveType;
|
||
auto typeName = [](OT t) {
|
||
switch (t) {
|
||
case OT::KillCreature: return "kill";
|
||
case OT::CollectItem: return "collect";
|
||
case OT::TalkToNPC: return "talk";
|
||
case OT::ExploreArea: return "explore";
|
||
case OT::EscortNPC: return "escort";
|
||
case OT::UseObject: return "use";
|
||
}
|
||
return "?";
|
||
};
|
||
for (size_t k = 0; k < qe.questCount(); ++k) {
|
||
const auto& q = qe.getQuests()[k];
|
||
md << "### " << k << ". " << q.title << "\n\n";
|
||
md << "- Required level: " << q.requiredLevel << "\n";
|
||
md << "- Quest giver NPC ID: " << q.questGiverNpcId << "\n";
|
||
md << "- Turn-in NPC ID: " << q.turnInNpcId << "\n";
|
||
md << "- XP: " << q.reward.xp << "\n";
|
||
if (q.reward.gold || q.reward.silver || q.reward.copper) {
|
||
md << "- Coin: " << q.reward.gold << "g "
|
||
<< q.reward.silver << "s " << q.reward.copper << "c\n";
|
||
}
|
||
if (!q.objectives.empty()) {
|
||
md << "- Objectives:\n";
|
||
for (const auto& obj : q.objectives) {
|
||
md << " - **" << typeName(obj.type) << "** "
|
||
<< obj.targetName << " ×" << obj.targetCount;
|
||
if (!obj.description.empty()) {
|
||
md << " — *" << obj.description << "*";
|
||
}
|
||
md << "\n";
|
||
}
|
||
}
|
||
if (!q.reward.itemRewards.empty()) {
|
||
md << "- Item rewards:\n";
|
||
for (const auto& it : q.reward.itemRewards) {
|
||
md << " - `" << it << "`\n";
|
||
}
|
||
}
|
||
md << "\n";
|
||
}
|
||
}
|
||
md.close();
|
||
std::printf("Wrote %s\n", outPath.c_str());
|
||
std::printf(" zone=%s, %zu tiles, %zu creatures, %zu objects, %zu quests\n",
|
||
zm.mapName.c_str(), zm.tiles.size(), sp.spawnCount(),
|
||
op.getObjects().size(), qe.questCount());
|
||
return 0;
|
||
}
|
||
|
||
int handleExportZoneCsv(int& i, int argc, char** argv) {
|
||
// Emit creatures.csv / objects.csv / quests.csv for designers
|
||
// who prefer spreadsheets over JSON. Round-trip back into the
|
||
// editor isn't supported yet, but for read-only analysis (sort
|
||
// by XP, group by faction, pivot tables in LibreOffice) CSV is
|
||
// the lingua franca of design data.
|
||
std::string zoneDir = argv[++i];
|
||
std::string outDir;
|
||
if (i + 1 < argc && argv[i + 1][0] != '-') outDir = argv[++i];
|
||
namespace fs = std::filesystem;
|
||
if (!fs::exists(zoneDir + "/zone.json")) {
|
||
std::fprintf(stderr,
|
||
"export-zone-csv: %s has no zone.json\n", zoneDir.c_str());
|
||
return 1;
|
||
}
|
||
if (outDir.empty()) outDir = zoneDir;
|
||
// CSV-escape: wrap any field containing comma/quote/newline in
|
||
// double quotes; double up internal quotes per RFC 4180.
|
||
auto csvEsc = [](const std::string& s) {
|
||
bool needs = s.find(',') != std::string::npos ||
|
||
s.find('"') != std::string::npos ||
|
||
s.find('\n') != std::string::npos;
|
||
if (!needs) return s;
|
||
std::string out = "\"";
|
||
for (char c : s) {
|
||
if (c == '"') out += "\"\"";
|
||
else out += c;
|
||
}
|
||
out += "\"";
|
||
return out;
|
||
};
|
||
int filesWritten = 0;
|
||
// Creatures
|
||
wowee::editor::NpcSpawner sp;
|
||
if (sp.loadFromFile(zoneDir + "/creatures.json")) {
|
||
std::string out = outDir + "/creatures.csv";
|
||
std::ofstream f(out);
|
||
if (!f) {
|
||
std::fprintf(stderr, "cannot write %s\n", out.c_str());
|
||
return 1;
|
||
}
|
||
f << "index,id,name,displayId,level,health,mana,faction,"
|
||
"x,y,z,orientation,scale,hostile,questgiver,vendor,trainer\n";
|
||
for (size_t k = 0; k < sp.spawnCount(); ++k) {
|
||
const auto& s = sp.getSpawns()[k];
|
||
f << k << "," << s.id << "," << csvEsc(s.name) << ","
|
||
<< s.displayId << "," << s.level << ","
|
||
<< s.health << "," << s.mana << "," << s.faction << ","
|
||
<< s.position.x << "," << s.position.y << ","
|
||
<< s.position.z << "," << s.orientation << ","
|
||
<< s.scale << ","
|
||
<< (s.hostile ? 1 : 0) << ","
|
||
<< (s.questgiver ? 1 : 0) << ","
|
||
<< (s.vendor ? 1 : 0) << ","
|
||
<< (s.trainer ? 1 : 0) << "\n";
|
||
}
|
||
std::printf(" wrote %s (%zu rows)\n", out.c_str(), sp.spawnCount());
|
||
filesWritten++;
|
||
}
|
||
// Objects
|
||
wowee::editor::ObjectPlacer op;
|
||
if (op.loadFromFile(zoneDir + "/objects.json")) {
|
||
std::string out = outDir + "/objects.csv";
|
||
std::ofstream f(out);
|
||
if (!f) return 1;
|
||
f << "index,type,path,x,y,z,rotX,rotY,rotZ,scale\n";
|
||
for (size_t k = 0; k < op.getObjects().size(); ++k) {
|
||
const auto& o = op.getObjects()[k];
|
||
f << k << ","
|
||
<< (o.type == wowee::editor::PlaceableType::M2 ? "m2" : "wmo") << ","
|
||
<< csvEsc(o.path) << ","
|
||
<< o.position.x << "," << o.position.y << "," << o.position.z << ","
|
||
<< o.rotation.x << "," << o.rotation.y << "," << o.rotation.z << ","
|
||
<< o.scale << "\n";
|
||
}
|
||
std::printf(" wrote %s (%zu rows)\n", out.c_str(),
|
||
op.getObjects().size());
|
||
filesWritten++;
|
||
}
|
||
// Quests — flatten to one row per quest. Objectives + items
|
||
// are joined into a single semicolon-separated cell so the
|
||
// CSV stays one-row-per-quest (designer-friendly for sorting).
|
||
wowee::editor::QuestEditor qe;
|
||
if (qe.loadFromFile(zoneDir + "/quests.json")) {
|
||
std::string out = outDir + "/quests.csv";
|
||
std::ofstream f(out);
|
||
if (!f) return 1;
|
||
f << "index,id,title,requiredLevel,giverNpcId,turnInNpcId,"
|
||
"xp,gold,silver,copper,nextQuestId,objectiveCount,"
|
||
"objectives,itemRewards\n";
|
||
using OT = wowee::editor::QuestObjectiveType;
|
||
auto typeName = [](OT t) {
|
||
switch (t) {
|
||
case OT::KillCreature: return "kill";
|
||
case OT::CollectItem: return "collect";
|
||
case OT::TalkToNPC: return "talk";
|
||
case OT::ExploreArea: return "explore";
|
||
case OT::EscortNPC: return "escort";
|
||
case OT::UseObject: return "use";
|
||
}
|
||
return "?";
|
||
};
|
||
for (size_t k = 0; k < qe.questCount(); ++k) {
|
||
const auto& q = qe.getQuests()[k];
|
||
std::string objs;
|
||
for (size_t o = 0; o < q.objectives.size(); ++o) {
|
||
if (o) objs += "; ";
|
||
objs += std::string(typeName(q.objectives[o].type)) + ":" +
|
||
q.objectives[o].targetName + "x" +
|
||
std::to_string(q.objectives[o].targetCount);
|
||
}
|
||
std::string items;
|
||
for (size_t r = 0; r < q.reward.itemRewards.size(); ++r) {
|
||
if (r) items += "; ";
|
||
items += q.reward.itemRewards[r];
|
||
}
|
||
f << k << "," << q.id << "," << csvEsc(q.title) << ","
|
||
<< q.requiredLevel << ","
|
||
<< q.questGiverNpcId << "," << q.turnInNpcId << ","
|
||
<< q.reward.xp << "," << q.reward.gold << ","
|
||
<< q.reward.silver << "," << q.reward.copper << ","
|
||
<< q.nextQuestId << ","
|
||
<< q.objectives.size() << ","
|
||
<< csvEsc(objs) << "," << csvEsc(items) << "\n";
|
||
}
|
||
std::printf(" wrote %s (%zu rows)\n", out.c_str(), qe.questCount());
|
||
filesWritten++;
|
||
}
|
||
// Items — read items.json inline since the items pipeline
|
||
// doesn't have a dedicated editor class yet.
|
||
std::string itemsPath = zoneDir + "/items.json";
|
||
if (fs::exists(itemsPath)) {
|
||
nlohmann::json doc;
|
||
try {
|
||
std::ifstream in(itemsPath);
|
||
in >> doc;
|
||
} catch (...) {}
|
||
if (doc.contains("items") && doc["items"].is_array()) {
|
||
std::string out = outDir + "/items.csv";
|
||
std::ofstream f(out);
|
||
if (f) {
|
||
f << "index,id,name,quality,itemLevel,displayId,stackable\n";
|
||
const auto& arr = doc["items"];
|
||
for (size_t k = 0; k < arr.size(); ++k) {
|
||
const auto& it = arr[k];
|
||
f << k << ","
|
||
<< it.value("id", 0u) << ","
|
||
<< csvEsc(it.value("name", std::string())) << ","
|
||
<< it.value("quality", 1u) << ","
|
||
<< it.value("itemLevel", 1u) << ","
|
||
<< it.value("displayId", 0u) << ","
|
||
<< it.value("stackable", 1u) << "\n";
|
||
}
|
||
std::printf(" wrote %s (%zu rows)\n", out.c_str(), arr.size());
|
||
filesWritten++;
|
||
}
|
||
}
|
||
}
|
||
if (filesWritten == 0) {
|
||
std::fprintf(stderr,
|
||
"export-zone-csv: zone has no creatures/objects/quests/items to emit\n");
|
||
return 1;
|
||
}
|
||
std::printf("Exported %d CSV file(s) to %s\n", filesWritten, outDir.c_str());
|
||
return 0;
|
||
}
|
||
|
||
int handleExportZoneChecksum(int& i, int argc, char** argv) {
|
||
// SHA-256 manifest of every source file in a zone, in the
|
||
// standard sha256sum format ('<hex> <relpath>'). Lets users
|
||
// verify zone integrity after a download or transfer with the
|
||
// standard system tool:
|
||
// wowee_editor --export-zone-checksum custom_zones/MyZone
|
||
// sha256sum -c custom_zones/MyZone/SHA256SUMS
|
||
std::string zoneDir = argv[++i];
|
||
std::string outPath;
|
||
if (i + 1 < argc && argv[i + 1][0] != '-') outPath = argv[++i];
|
||
namespace fs = std::filesystem;
|
||
if (!fs::exists(zoneDir + "/zone.json")) {
|
||
std::fprintf(stderr,
|
||
"export-zone-checksum: %s has no zone.json\n", zoneDir.c_str());
|
||
return 1;
|
||
}
|
||
if (outPath.empty()) outPath = zoneDir + "/SHA256SUMS";
|
||
// Source files only — derived outputs (.glb/.obj/.stl/.html/
|
||
// ZONE.md/DEPS.md/quests.dot/SHA256SUMS itself) are excluded
|
||
// since they're regeneratable and would invalidate the
|
||
// checksum on every rebuild.
|
||
auto isDerived = [](const fs::path& p) {
|
||
std::string ext = p.extension().string();
|
||
std::string name = p.filename().string();
|
||
if (ext == ".glb" || ext == ".obj" || ext == ".stl" ||
|
||
ext == ".html" || ext == ".dot" || ext == ".csv") return true;
|
||
if (name == "ZONE.md" || name == "DEPS.md" ||
|
||
name == "SHA256SUMS" || name == "Makefile") return true;
|
||
if (ext == ".png") return true; // BLP→PNG renders at root
|
||
return false;
|
||
};
|
||
std::vector<std::pair<std::string, std::string>> entries;
|
||
std::error_code ec;
|
||
for (const auto& e : fs::recursive_directory_iterator(zoneDir, ec)) {
|
||
if (!e.is_regular_file()) continue;
|
||
if (isDerived(e.path())) continue;
|
||
std::string hex = wowee_sha256::fileHex(e.path().string());
|
||
if (hex.empty()) continue;
|
||
std::string rel = fs::relative(e.path(), zoneDir, ec).string();
|
||
if (ec) rel = e.path().string();
|
||
entries.push_back({hex, rel});
|
||
}
|
||
std::sort(entries.begin(), entries.end(),
|
||
[](const auto& a, const auto& b) { return a.second < b.second; });
|
||
std::ofstream out(outPath);
|
||
if (!out) {
|
||
std::fprintf(stderr,
|
||
"export-zone-checksum: cannot write %s\n", outPath.c_str());
|
||
return 1;
|
||
}
|
||
for (const auto& [hash, path] : entries) {
|
||
// sha256sum format: 64-char hex, two spaces, path.
|
||
out << hash << " " << path << "\n";
|
||
}
|
||
out.close();
|
||
std::printf("Wrote %s\n", outPath.c_str());
|
||
std::printf(" %zu file(s) hashed (source only, derived excluded)\n",
|
||
entries.size());
|
||
std::printf(" verify with: sha256sum -c %s\n", outPath.c_str());
|
||
return 0;
|
||
}
|
||
|
||
int handleExportProjectChecksum(int& i, int argc, char** argv) {
|
||
// Project-wide manifest in the same sha256sum format, with
|
||
// paths kept relative to <projectDir> (so entries look like
|
||
// "<hex> <zoneName>/<file>"). Also emits a single SHA-256
|
||
// fingerprint over the manifest itself — a one-line
|
||
// identity for the whole project, handy for CI release
|
||
// gates and reproducibility checks.
|
||
//
|
||
// wowee_editor --export-project-checksum custom_zones
|
||
// sha256sum -c custom_zones/PROJECT_SHA256SUMS
|
||
std::string projectDir = argv[++i];
|
||
std::string outPath;
|
||
if (i + 1 < argc && argv[i + 1][0] != '-') outPath = argv[++i];
|
||
namespace fs = std::filesystem;
|
||
if (!fs::exists(projectDir) || !fs::is_directory(projectDir)) {
|
||
std::fprintf(stderr,
|
||
"export-project-checksum: %s is not a directory\n",
|
||
projectDir.c_str());
|
||
return 1;
|
||
}
|
||
if (outPath.empty()) outPath = projectDir + "/PROJECT_SHA256SUMS";
|
||
// Same derived-output filter as --export-zone-checksum.
|
||
auto isDerived = [](const fs::path& p) {
|
||
std::string ext = p.extension().string();
|
||
std::string name = p.filename().string();
|
||
if (ext == ".glb" || ext == ".obj" || ext == ".stl" ||
|
||
ext == ".html" || ext == ".dot" || ext == ".csv") return true;
|
||
if (name == "ZONE.md" || name == "DEPS.md" ||
|
||
name == "SHA256SUMS" || name == "PROJECT_SHA256SUMS" ||
|
||
name == "Makefile") return true;
|
||
if (ext == ".png") return true;
|
||
return false;
|
||
};
|
||
std::vector<std::string> zones;
|
||
for (const auto& entry : fs::directory_iterator(projectDir)) {
|
||
if (!entry.is_directory()) continue;
|
||
if (!fs::exists(entry.path() / "zone.json")) continue;
|
||
zones.push_back(entry.path().string());
|
||
}
|
||
std::sort(zones.begin(), zones.end());
|
||
std::vector<std::pair<std::string, std::string>> entries;
|
||
for (const auto& zoneDir : zones) {
|
||
std::error_code ec;
|
||
for (const auto& e : fs::recursive_directory_iterator(zoneDir, ec)) {
|
||
if (!e.is_regular_file()) continue;
|
||
if (isDerived(e.path())) continue;
|
||
std::string hex = wowee_sha256::fileHex(e.path().string());
|
||
if (hex.empty()) continue;
|
||
std::string rel = fs::relative(e.path(), projectDir, ec).string();
|
||
if (ec) rel = e.path().string();
|
||
entries.push_back({hex, rel});
|
||
}
|
||
}
|
||
std::sort(entries.begin(), entries.end(),
|
||
[](const auto& a, const auto& b) { return a.second < b.second; });
|
||
std::ofstream out(outPath);
|
||
if (!out) {
|
||
std::fprintf(stderr,
|
||
"export-project-checksum: cannot write %s\n", outPath.c_str());
|
||
return 1;
|
||
}
|
||
// Hash the manifest body inline so the project fingerprint
|
||
// is byte-identical to what `sha256sum PROJECT_SHA256SUMS`
|
||
// would yield on the written file.
|
||
std::string body;
|
||
body.reserve(entries.size() * 80);
|
||
for (const auto& [hash, path] : entries) {
|
||
body += hash;
|
||
body += " ";
|
||
body += path;
|
||
body += "\n";
|
||
}
|
||
out << body;
|
||
out.close();
|
||
std::string fingerprint = wowee_sha256::hex(
|
||
reinterpret_cast<const uint8_t*>(body.data()), body.size());
|
||
std::printf("Wrote %s\n", outPath.c_str());
|
||
std::printf(" zones : %zu\n", zones.size());
|
||
std::printf(" files hashed : %zu\n", entries.size());
|
||
std::printf(" fingerprint : %s\n", fingerprint.c_str());
|
||
std::printf(" verify with : sha256sum -c %s\n", outPath.c_str());
|
||
return 0;
|
||
}
|
||
|
||
int handleValidateProjectChecksum(int& i, int argc, char** argv) {
|
||
// In-tool verification of the manifest produced by
|
||
// --export-project-checksum. Equivalent to 'sha256sum -c
|
||
// PROJECT_SHA256SUMS' but cross-platform — Windows and
|
||
// CI runners without coreutils don't need an external tool.
|
||
// Exit 1 if any file is missing or its hash drifted.
|
||
std::string projectDir = argv[++i];
|
||
std::string inPath;
|
||
if (i + 1 < argc && argv[i + 1][0] != '-') inPath = argv[++i];
|
||
namespace fs = std::filesystem;
|
||
if (!fs::exists(projectDir) || !fs::is_directory(projectDir)) {
|
||
std::fprintf(stderr,
|
||
"validate-project-checksum: %s is not a directory\n",
|
||
projectDir.c_str());
|
||
return 1;
|
||
}
|
||
if (inPath.empty()) inPath = projectDir + "/PROJECT_SHA256SUMS";
|
||
std::ifstream in(inPath);
|
||
if (!in) {
|
||
std::fprintf(stderr,
|
||
"validate-project-checksum: cannot read %s\n", inPath.c_str());
|
||
return 1;
|
||
}
|
||
int ok = 0, missing = 0, mismatched = 0;
|
||
std::vector<std::string> failures;
|
||
std::string line;
|
||
while (std::getline(in, line)) {
|
||
if (line.empty()) continue;
|
||
// sha256sum format: 64-char hex, two spaces, path.
|
||
if (line.size() < 66 || line[64] != ' ' || line[65] != ' ') {
|
||
std::fprintf(stderr,
|
||
" malformed line (skipped): %s\n", line.c_str());
|
||
continue;
|
||
}
|
||
std::string expected = line.substr(0, 64);
|
||
std::string rel = line.substr(66);
|
||
std::string full = projectDir + "/" + rel;
|
||
if (!fs::exists(full)) {
|
||
missing++;
|
||
failures.push_back(rel + " (missing)");
|
||
continue;
|
||
}
|
||
std::string actual = wowee_sha256::fileHex(full);
|
||
if (actual != expected) {
|
||
mismatched++;
|
||
failures.push_back(rel + " (hash mismatch)");
|
||
continue;
|
||
}
|
||
ok++;
|
||
}
|
||
std::printf("validate-project-checksum: %s\n", inPath.c_str());
|
||
std::printf(" ok : %d\n", ok);
|
||
std::printf(" missing : %d\n", missing);
|
||
std::printf(" mismatched : %d\n", mismatched);
|
||
if (!failures.empty()) {
|
||
std::printf("\n Failures:\n");
|
||
for (const auto& f : failures) std::printf(" - %s\n", f.c_str());
|
||
}
|
||
return (missing == 0 && mismatched == 0) ? 0 : 1;
|
||
}
|
||
|
||
int handleExportZoneHtml(int& i, int argc, char** argv) {
|
||
// Generate a single-file HTML viewer next to the zone .glb.
|
||
// Anyone with a modern browser can open it — no installs, no
|
||
// CDN-mining the user's network. Uses model-viewer (Google's
|
||
// web component) bundled from the unpkg CDN since it's
|
||
// standards-based and doesn't require a build step.
|
||
//
|
||
// Usage flow:
|
||
// wowee_editor --bake-zone-glb custom_zones/MyZone
|
||
// wowee_editor --export-zone-html custom_zones/MyZone
|
||
// open custom_zones/MyZone/MyZone.html # opens in browser
|
||
std::string zoneDir = argv[++i];
|
||
std::string outPath;
|
||
if (i + 1 < argc && argv[i + 1][0] != '-') outPath = argv[++i];
|
||
namespace fs = std::filesystem;
|
||
std::string manifestPath = zoneDir + "/zone.json";
|
||
if (!fs::exists(manifestPath)) {
|
||
std::fprintf(stderr,
|
||
"export-zone-html: %s has no zone.json\n", zoneDir.c_str());
|
||
return 1;
|
||
}
|
||
wowee::editor::ZoneManifest zm;
|
||
if (!zm.load(manifestPath)) {
|
||
std::fprintf(stderr, "export-zone-html: parse failed\n");
|
||
return 1;
|
||
}
|
||
std::string glbName = zm.mapName + ".glb";
|
||
std::string glbPath = zoneDir + "/" + glbName;
|
||
if (!fs::exists(glbPath)) {
|
||
std::fprintf(stderr,
|
||
"export-zone-html: %s does not exist — run --bake-zone-glb first\n",
|
||
glbPath.c_str());
|
||
return 1;
|
||
}
|
||
if (outPath.empty()) outPath = zoneDir + "/" + zm.mapName + ".html";
|
||
std::ofstream out(outPath);
|
||
if (!out) {
|
||
std::fprintf(stderr,
|
||
"export-zone-html: cannot write %s\n", outPath.c_str());
|
||
return 1;
|
||
}
|
||
// Compute relative path from html file's parent dir to the
|
||
// .glb so the viewer loads it. Default same-dir → just basename.
|
||
std::string glbHref = glbName;
|
||
// If outPath is in a different dir than the .glb, the user is
|
||
// responsible for moving things; leaving glbHref as the
|
||
// basename is a sensible default that fails loudly in the
|
||
// browser console rather than producing a wrong-but-silent
|
||
// page.
|
||
std::string title = zm.displayName.empty()
|
||
? zm.mapName : zm.displayName;
|
||
// Single-file template with model-viewer. The version pin
|
||
// (^4.0.0) keeps the page from breaking when the unpkg
|
||
// 'latest' silently bumps a major version.
|
||
out << "<!doctype html>\n"
|
||
"<html lang=\"en\">\n"
|
||
"<head>\n"
|
||
" <meta charset=\"utf-8\">\n"
|
||
" <title>" << title << " — Wowee Zone Viewer</title>\n"
|
||
" <script type=\"module\" "
|
||
"src=\"https://unpkg.com/@google/model-viewer@^4.0.0/dist/model-viewer.min.js\">"
|
||
"</script>\n"
|
||
" <style>\n"
|
||
" body { margin:0; font-family: sans-serif; background:#1a1a1a; color:#eee; }\n"
|
||
" header { padding:12px 20px; background:#2a2a2a; border-bottom:1px solid #444; }\n"
|
||
" h1 { margin:0; font-size:18px; font-weight:500; }\n"
|
||
" .meta { color:#aaa; font-size:13px; margin-top:4px; }\n"
|
||
" model-viewer { width:100vw; height:calc(100vh - 60px); background:#1a1a1a; }\n"
|
||
" .footer { position:fixed; bottom:8px; right:12px; color:#666; font-size:11px; }\n"
|
||
" </style>\n"
|
||
"</head>\n"
|
||
"<body>\n"
|
||
" <header>\n"
|
||
" <h1>" << title << "</h1>\n"
|
||
" <div class=\"meta\">Map: <code>" << zm.mapName
|
||
<< "</code> · Tiles: " << zm.tiles.size()
|
||
<< " · MapId: " << zm.mapId << "</div>\n"
|
||
" </header>\n"
|
||
" <model-viewer\n"
|
||
" src=\"" << glbHref << "\"\n"
|
||
" alt=\"" << title << " terrain\"\n"
|
||
" camera-controls\n"
|
||
" auto-rotate\n"
|
||
" rotation-per-second=\"15deg\"\n"
|
||
" shadow-intensity=\"1\"\n"
|
||
" exposure=\"1.2\"\n"
|
||
" environment-image=\"neutral\">\n"
|
||
" </model-viewer>\n"
|
||
" <div class=\"footer\">Generated by wowee_editor --export-zone-html</div>\n"
|
||
"</body>\n"
|
||
"</html>\n";
|
||
out.close();
|
||
std::printf("Wrote %s\n", outPath.c_str());
|
||
std::printf(" references %s (must sit next to .html)\n", glbHref.c_str());
|
||
std::printf(" open in any modern browser — no install required\n");
|
||
return 0;
|
||
}
|
||
|
||
int handleExportProjectHtml(int& i, int argc, char** argv) {
|
||
// Project-level index page linking every zone's HTML viewer.
|
||
// Pairs with --export-zone-html (single zone) and
|
||
// --bake-zone-glb (terrain bake). Designed for github-pages
|
||
// style 'all my zones' showcase.
|
||
std::string projectDir = argv[++i];
|
||
std::string outPath;
|
||
if (i + 1 < argc && argv[i + 1][0] != '-') outPath = argv[++i];
|
||
namespace fs = std::filesystem;
|
||
if (!fs::exists(projectDir) || !fs::is_directory(projectDir)) {
|
||
std::fprintf(stderr,
|
||
"export-project-html: %s is not a directory\n",
|
||
projectDir.c_str());
|
||
return 1;
|
||
}
|
||
if (outPath.empty()) outPath = projectDir + "/index.html";
|
||
// Walk for zones (dirs with zone.json). For each, record:
|
||
// - display name
|
||
// - relative path to its .html viewer (or null if not generated)
|
||
// - tile count, content counts
|
||
struct ZoneEntry {
|
||
std::string name, dirRel, htmlRel, glbRel;
|
||
bool htmlExists = false, glbExists = false;
|
||
int tiles = 0, creatures = 0, objects = 0, quests = 0;
|
||
};
|
||
std::vector<ZoneEntry> entries;
|
||
for (const auto& entry : fs::directory_iterator(projectDir)) {
|
||
if (!entry.is_directory()) continue;
|
||
if (!fs::exists(entry.path() / "zone.json")) continue;
|
||
wowee::editor::ZoneManifest zm;
|
||
if (!zm.load((entry.path() / "zone.json").string())) continue;
|
||
ZoneEntry ze;
|
||
ze.name = zm.displayName.empty() ? zm.mapName : zm.displayName;
|
||
ze.dirRel = entry.path().filename().string();
|
||
ze.htmlRel = ze.dirRel + "/" + zm.mapName + ".html";
|
||
ze.glbRel = ze.dirRel + "/" + zm.mapName + ".glb";
|
||
ze.htmlExists = fs::exists(entry.path() / (zm.mapName + ".html"));
|
||
ze.glbExists = fs::exists(entry.path() / (zm.mapName + ".glb"));
|
||
ze.tiles = static_cast<int>(zm.tiles.size());
|
||
wowee::editor::NpcSpawner sp;
|
||
if (sp.loadFromFile((entry.path() / "creatures.json").string())) {
|
||
ze.creatures = static_cast<int>(sp.spawnCount());
|
||
}
|
||
wowee::editor::ObjectPlacer op;
|
||
if (op.loadFromFile((entry.path() / "objects.json").string())) {
|
||
ze.objects = static_cast<int>(op.getObjects().size());
|
||
}
|
||
wowee::editor::QuestEditor qe;
|
||
if (qe.loadFromFile((entry.path() / "quests.json").string())) {
|
||
ze.quests = static_cast<int>(qe.questCount());
|
||
}
|
||
entries.push_back(ze);
|
||
}
|
||
std::sort(entries.begin(), entries.end(),
|
||
[](const ZoneEntry& a, const ZoneEntry& b) {
|
||
return a.name < b.name;
|
||
});
|
||
std::ofstream out(outPath);
|
||
if (!out) {
|
||
std::fprintf(stderr,
|
||
"export-project-html: cannot write %s\n", outPath.c_str());
|
||
return 1;
|
||
}
|
||
out << "<!doctype html>\n"
|
||
"<html lang=\"en\">\n"
|
||
"<head>\n"
|
||
" <meta charset=\"utf-8\">\n"
|
||
" <title>Wowee Project — Zone Index</title>\n"
|
||
" <style>\n"
|
||
" body { margin:0; font-family: sans-serif; background:#1a1a1a; color:#eee; padding:20px; }\n"
|
||
" h1 { margin:0 0 8px; font-size:22px; }\n"
|
||
" .count { color:#aaa; font-size:14px; margin-bottom:24px; }\n"
|
||
" .zones { display:grid; grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); gap:16px; }\n"
|
||
" .zone { background:#2a2a2a; border:1px solid #444; border-radius:6px; padding:14px; }\n"
|
||
" .zone h3 { margin:0 0 6px; font-size:16px; }\n"
|
||
" .zone .stats { color:#aaa; font-size:13px; }\n"
|
||
" .zone a { color:#7af; text-decoration:none; font-size:13px; display:inline-block; margin-top:8px; }\n"
|
||
" .zone a:hover { text-decoration:underline; }\n"
|
||
" .zone .nolink { color:#666; font-style:italic; font-size:13px; margin-top:8px; }\n"
|
||
" .footer { margin-top:30px; color:#666; font-size:11px; }\n"
|
||
" </style>\n"
|
||
"</head>\n"
|
||
"<body>\n"
|
||
" <h1>Wowee Project — Zone Index</h1>\n"
|
||
" <div class=\"count\">" << entries.size() << " zone(s) found in <code>"
|
||
<< projectDir << "</code></div>\n"
|
||
" <div class=\"zones\">\n";
|
||
for (const auto& z : entries) {
|
||
out << " <div class=\"zone\">\n"
|
||
" <h3>" << z.name << "</h3>\n"
|
||
" <div class=\"stats\">"
|
||
<< z.tiles << " tile" << (z.tiles == 1 ? "" : "s") << " · "
|
||
<< z.creatures << " creature" << (z.creatures == 1 ? "" : "s") << " · "
|
||
<< z.objects << " object" << (z.objects == 1 ? "" : "s") << " · "
|
||
<< z.quests << " quest" << (z.quests == 1 ? "" : "s") << "</div>\n";
|
||
if (z.htmlExists) {
|
||
out << " <a href=\"" << z.htmlRel << "\">Open viewer →</a>\n";
|
||
} else if (z.glbExists) {
|
||
out << " <div class=\"nolink\">No HTML viewer (run --export-zone-html)</div>\n";
|
||
} else {
|
||
out << " <div class=\"nolink\">No .glb (run --bake-zone-glb)</div>\n";
|
||
}
|
||
out << " </div>\n";
|
||
}
|
||
out << " </div>\n"
|
||
" <div class=\"footer\">Generated by wowee_editor --export-project-html</div>\n"
|
||
"</body>\n"
|
||
"</html>\n";
|
||
out.close();
|
||
int withViewer = 0;
|
||
for (const auto& z : entries) if (z.htmlExists) withViewer++;
|
||
std::printf("Wrote %s\n", outPath.c_str());
|
||
std::printf(" %zu zone(s) listed, %d with viewable HTML\n",
|
||
entries.size(), withViewer);
|
||
return 0;
|
||
}
|
||
|
||
int handleExportProjectMd(int& i, int argc, char** argv) {
|
||
// Markdown counterpart to --export-project-html. Generates a
|
||
// README.md indexing every zone with counts + bake/viewer
|
||
// status. GitHub renders it natively at the project root.
|
||
// Pairs with --export-zone-summary-md (per-zone) — the project
|
||
// README links to each zone's per-zone .md.
|
||
std::string projectDir = argv[++i];
|
||
std::string outPath;
|
||
if (i + 1 < argc && argv[i + 1][0] != '-') outPath = argv[++i];
|
||
namespace fs = std::filesystem;
|
||
if (!fs::exists(projectDir) || !fs::is_directory(projectDir)) {
|
||
std::fprintf(stderr,
|
||
"export-project-md: %s is not a directory\n",
|
||
projectDir.c_str());
|
||
return 1;
|
||
}
|
||
if (outPath.empty()) outPath = projectDir + "/README.md";
|
||
// Per-zone collection: name + counts + which artifacts exist.
|
||
struct Row {
|
||
std::string name, dirRel, mapName;
|
||
int tiles = 0, creatures = 0, objects = 0, quests = 0;
|
||
bool hasGlb = false, hasHtml = false, hasZoneMd = false;
|
||
};
|
||
std::vector<Row> rows;
|
||
for (const auto& entry : fs::directory_iterator(projectDir)) {
|
||
if (!entry.is_directory()) continue;
|
||
if (!fs::exists(entry.path() / "zone.json")) continue;
|
||
wowee::editor::ZoneManifest zm;
|
||
if (!zm.load((entry.path() / "zone.json").string())) continue;
|
||
Row r;
|
||
r.name = zm.displayName.empty() ? zm.mapName : zm.displayName;
|
||
r.dirRel = entry.path().filename().string();
|
||
r.mapName = zm.mapName;
|
||
r.tiles = static_cast<int>(zm.tiles.size());
|
||
wowee::editor::NpcSpawner sp;
|
||
if (sp.loadFromFile((entry.path() / "creatures.json").string())) {
|
||
r.creatures = static_cast<int>(sp.spawnCount());
|
||
}
|
||
wowee::editor::ObjectPlacer op;
|
||
if (op.loadFromFile((entry.path() / "objects.json").string())) {
|
||
r.objects = static_cast<int>(op.getObjects().size());
|
||
}
|
||
wowee::editor::QuestEditor qe;
|
||
if (qe.loadFromFile((entry.path() / "quests.json").string())) {
|
||
r.quests = static_cast<int>(qe.questCount());
|
||
}
|
||
r.hasGlb = fs::exists(entry.path() / (zm.mapName + ".glb"));
|
||
r.hasHtml = fs::exists(entry.path() / (zm.mapName + ".html"));
|
||
r.hasZoneMd = fs::exists(entry.path() / "ZONE.md");
|
||
rows.push_back(std::move(r));
|
||
}
|
||
std::sort(rows.begin(), rows.end(),
|
||
[](const Row& a, const Row& b) { return a.name < b.name; });
|
||
int totalT = 0, totalC = 0, totalO = 0, totalQ = 0;
|
||
for (const auto& r : rows) {
|
||
totalT += r.tiles; totalC += r.creatures;
|
||
totalO += r.objects; totalQ += r.quests;
|
||
}
|
||
std::ofstream out(outPath);
|
||
if (!out) {
|
||
std::fprintf(stderr,
|
||
"export-project-md: cannot write %s\n", outPath.c_str());
|
||
return 1;
|
||
}
|
||
out << "# Wowee Project — Zone Index\n\n";
|
||
out << "*Auto-generated. " << rows.size()
|
||
<< " zone(s) discovered in `" << projectDir << "`.*\n\n";
|
||
out << "## Summary\n\n";
|
||
out << "| Metric | Total |\n|---|---:|\n";
|
||
out << "| Zones | " << rows.size() << " |\n";
|
||
out << "| Tiles | " << totalT << " |\n";
|
||
out << "| Creatures | " << totalC << " |\n";
|
||
out << "| Objects | " << totalO << " |\n";
|
||
out << "| Quests | " << totalQ << " |\n\n";
|
||
out << "## Zones\n\n";
|
||
out << "| Zone | Tiles | Creatures | Objects | Quests | Bake | Viewer | Docs |\n";
|
||
out << "|---|---:|---:|---:|---:|:---:|:---:|:---:|\n";
|
||
for (const auto& r : rows) {
|
||
out << "| ";
|
||
if (r.hasZoneMd) {
|
||
out << "[" << r.name << "](" << r.dirRel << "/ZONE.md)";
|
||
} else {
|
||
out << r.name;
|
||
}
|
||
out << " | " << r.tiles << " | " << r.creatures << " | "
|
||
<< r.objects << " | " << r.quests << " | "
|
||
<< (r.hasGlb ? "✓" : "—") << " | "
|
||
<< (r.hasHtml ? "[view](" + r.dirRel + "/" + r.mapName + ".html)" : "—") << " | "
|
||
<< (r.hasZoneMd ? "[md](" + r.dirRel + "/ZONE.md)" : "—") << " |\n";
|
||
}
|
||
out.close();
|
||
std::printf("Wrote %s\n", outPath.c_str());
|
||
std::printf(" %zu zone(s) indexed (%d tiles, %d creatures, %d objects, %d quests)\n",
|
||
rows.size(), totalT, totalC, totalO, totalQ);
|
||
return 0;
|
||
}
|
||
|
||
int handleExportQuestGraph(int& i, int argc, char** argv) {
|
||
// Render quest chains as a Graphviz DOT graph. Visualizing
|
||
// quest dependencies in plain text rapidly becomes unreadable
|
||
// past ~10 quests; piping this through 'dot -Tpng -o q.png'
|
||
// makes complex chains immediately legible.
|
||
//
|
||
// wowee_editor --export-quest-graph custom_zones/MyZone
|
||
// dot -Tpng custom_zones/MyZone/quests.dot -o quests.png
|
||
std::string zoneDir = argv[++i];
|
||
std::string outPath;
|
||
if (i + 1 < argc && argv[i + 1][0] != '-') {
|
||
outPath = argv[++i];
|
||
}
|
||
std::string path = zoneDir + "/quests.json";
|
||
if (!std::filesystem::exists(path)) {
|
||
std::fprintf(stderr,
|
||
"export-quest-graph: %s not found\n", path.c_str());
|
||
return 1;
|
||
}
|
||
if (outPath.empty()) outPath = zoneDir + "/quests.dot";
|
||
wowee::editor::QuestEditor qe;
|
||
if (!qe.loadFromFile(path)) {
|
||
std::fprintf(stderr,
|
||
"export-quest-graph: failed to parse %s\n", path.c_str());
|
||
return 1;
|
||
}
|
||
std::ofstream out(outPath);
|
||
if (!out) {
|
||
std::fprintf(stderr,
|
||
"export-quest-graph: cannot write %s\n", outPath.c_str());
|
||
return 1;
|
||
}
|
||
// DOT-escape strings (just quotes and backslashes) — quest
|
||
// titles can include arbitrary punctuation that breaks DOT
|
||
// parsing if not escaped.
|
||
auto dotEsc = [](const std::string& s) {
|
||
std::string out;
|
||
for (char c : s) {
|
||
if (c == '"' || c == '\\') out += '\\';
|
||
out += c;
|
||
}
|
||
return out;
|
||
};
|
||
const auto& quests = qe.getQuests();
|
||
// Build an index of valid quest IDs so dangling chain
|
||
// pointers can be styled differently (red, dashed).
|
||
std::unordered_set<uint32_t> validIds;
|
||
for (const auto& q : quests) validIds.insert(q.id);
|
||
out << "digraph QuestChains {\n";
|
||
out << " // Generated by wowee_editor --export-quest-graph\n";
|
||
out << " rankdir=LR;\n";
|
||
out << " node [shape=box, style=filled, fontname=\"sans-serif\"];\n";
|
||
// Nodes: one per quest, colored by completion-readiness:
|
||
// green = has objectives + reward + valid NPCs
|
||
// yellow = missing some non-fatal field (description, etc.)
|
||
// gray = no objectives (won't actually complete in-game)
|
||
for (const auto& q : quests) {
|
||
bool hasObjs = !q.objectives.empty();
|
||
bool hasReward = (q.reward.xp > 0 || !q.reward.itemRewards.empty());
|
||
std::string color = hasObjs ? (hasReward ? "lightgreen" : "lightyellow")
|
||
: "lightgray";
|
||
std::string label = "[" + std::to_string(q.id) + "] " + dotEsc(q.title);
|
||
if (q.requiredLevel > 1) {
|
||
label += "\\nlvl " + std::to_string(q.requiredLevel);
|
||
}
|
||
if (q.reward.xp > 0) {
|
||
label += " " + std::to_string(q.reward.xp) + " XP";
|
||
}
|
||
out << " q" << q.id << " [label=\"" << label
|
||
<< "\", fillcolor=" << color << "];\n";
|
||
}
|
||
// Edges: quest -> nextQuestId. Style chain-pointers to
|
||
// missing quests differently so they stand out visually.
|
||
int chainEdges = 0, brokenEdges = 0;
|
||
for (const auto& q : quests) {
|
||
if (q.nextQuestId == 0) continue;
|
||
if (validIds.count(q.nextQuestId) == 0) {
|
||
out << " q" << q.id << " -> q" << q.nextQuestId
|
||
<< " [color=red, style=dashed, label=\"missing\"];\n";
|
||
out << " q" << q.nextQuestId
|
||
<< " [label=\"<missing> [" << q.nextQuestId
|
||
<< "]\", fillcolor=mistyrose, style=\"filled,dashed\"];\n";
|
||
brokenEdges++;
|
||
} else {
|
||
out << " q" << q.id << " -> q" << q.nextQuestId << ";\n";
|
||
chainEdges++;
|
||
}
|
||
}
|
||
out << "}\n";
|
||
out.close();
|
||
std::printf("Wrote %s\n", outPath.c_str());
|
||
std::printf(" %zu quests, %d chain edges, %d broken (red/dashed)\n",
|
||
quests.size(), chainEdges, brokenEdges);
|
||
std::printf(" next: dot -Tpng %s -o quests.png\n", outPath.c_str());
|
||
return 0;
|
||
}
|
||
|
||
|
||
} // namespace
|
||
|
||
bool handleExport(int& i, int argc, char** argv, int& outRc) {
|
||
if (std::strcmp(argv[i], "--export-zone-summary-md") == 0 && i + 1 < argc) {
|
||
outRc = handleExportZoneSummaryMd(i, argc, argv); return true;
|
||
}
|
||
if (std::strcmp(argv[i], "--export-zone-csv") == 0 && i + 1 < argc) {
|
||
outRc = handleExportZoneCsv(i, argc, argv); return true;
|
||
}
|
||
if (std::strcmp(argv[i], "--export-zone-checksum") == 0 && i + 1 < argc) {
|
||
outRc = handleExportZoneChecksum(i, argc, argv); return true;
|
||
}
|
||
if (std::strcmp(argv[i], "--export-project-checksum") == 0 && i + 1 < argc) {
|
||
outRc = handleExportProjectChecksum(i, argc, argv); return true;
|
||
}
|
||
if (std::strcmp(argv[i], "--validate-project-checksum") == 0 && i + 1 < argc) {
|
||
outRc = handleValidateProjectChecksum(i, argc, argv); return true;
|
||
}
|
||
if (std::strcmp(argv[i], "--export-zone-html") == 0 && i + 1 < argc) {
|
||
outRc = handleExportZoneHtml(i, argc, argv); return true;
|
||
}
|
||
if (std::strcmp(argv[i], "--export-project-html") == 0 && i + 1 < argc) {
|
||
outRc = handleExportProjectHtml(i, argc, argv); return true;
|
||
}
|
||
if (std::strcmp(argv[i], "--export-project-md") == 0 && i + 1 < argc) {
|
||
outRc = handleExportProjectMd(i, argc, argv); return true;
|
||
}
|
||
if (std::strcmp(argv[i], "--export-quest-graph") == 0 && i + 1 < argc) {
|
||
outRc = handleExportQuestGraph(i, argc, argv); return true;
|
||
}
|
||
return false;
|
||
}
|
||
|
||
} // namespace cli
|
||
} // namespace editor
|
||
} // namespace wowee
|