From 492d626c3be774fa5b3de7cbc52c91be1269f0f5 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Sat, 9 May 2026 17:41:03 -0700 Subject: [PATCH] feat(pipeline): add WMAL (Wowee Mail Template) format MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Novel open replacement for AzerothCore-style mail_loot_template SQL + the in-game mail subset of the inventory + currency systems. The 34th open format added to the editor. Defines templated mail messages with currency + item attachments. Triggered by quest reward delivery (overflow when bag is full), auction house bid wins / sales, achievement reward attachments, GM correspondence, holiday event mailings (Brewfest samples, Hallow's End candy), and returned-mail-on-rejection. Cross-references with previously-added formats: WMAL.entry.senderNpcId -> WCRT.entry.creatureId WMAL.entry.attachments.itemId -> WIT.entry.itemId Format: • magic "WMAL", version 1, little-endian • per template: templateId / senderNpcId / subject / body / senderName / moneyCopperAttached / categoryId / cod / returnable / expiryDays / attachments[] (each: itemId + quantity) Enums: • Category (8): QuestReward / Auction / GmCorrespondence / AchievementReward / EventMailing / Raffle / ScriptDelivery / ReturnedMail API: WoweeMailLoader::save / load / exists / findById. Three preset emitters showcase typical mail templates: • makeStarter — 3 templates (quest overflow / auction won / GM gift) covering the 3 most common categories • makeHoliday — 4 holiday samples that cross-reference the WTKN seasonal token IDs (200=Tricky Treats, 201=Brewfest, 202=Coin of Ancestry, 203=Stranger's Gift) so the demo content stack ships a full holiday onboarding experience • makeAuction — 5-template auction-house family (outbid / won / sold / expired / cancelled) — runtime fills in actual bid amounts / sold items at send time CLI added (5 flags, 635 documented total now): --gen-mail / --gen-mail-holiday / --gen-mail-auction --info-wmal / --validate-wmal Validator catches: templateId=0 + duplicates, empty subject, neither senderNpcId nor senderName set (no displayable sender), unknown category, expiryDays=0 (mail expires immediately), cod=1 with no money attached (free COD), empty mail in categories where the runtime doesn't fill in content (skips Auction / GmCorrespondence / ReturnedMail where empty templates are intentional). Two bugs caught + fixed during smoke-test on the auction preset: • print formatting glued the `0` from senderNpcId after the senderName when no NPC was set (rendered as "Postmaster0" instead of "Postmaster") — fixed with an explicit if/else split • validator's "no money + no items" warning was too aggressive for the Auction category, where templates are intentionally informational and the runtime fills in the real values — added Auction + ReturnedMail to the skip list --- CMakeLists.txt | 3 + include/pipeline/wowee_mail.hpp | 109 ++++++++++++ src/pipeline/wowee_mail.cpp | 261 ++++++++++++++++++++++++++++ tools/editor/cli_arg_required.cpp | 2 + tools/editor/cli_dispatch.cpp | 2 + tools/editor/cli_help.cpp | 10 ++ tools/editor/cli_mail_catalog.cpp | 271 ++++++++++++++++++++++++++++++ tools/editor/cli_mail_catalog.hpp | 11 ++ 8 files changed, 669 insertions(+) create mode 100644 include/pipeline/wowee_mail.hpp create mode 100644 src/pipeline/wowee_mail.cpp create mode 100644 tools/editor/cli_mail_catalog.cpp create mode 100644 tools/editor/cli_mail_catalog.hpp diff --git a/CMakeLists.txt b/CMakeLists.txt index 4c6d9d0c..9834279b 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -621,6 +621,7 @@ set(WOWEE_SOURCES src/pipeline/wowee_events.cpp src/pipeline/wowee_mounts.cpp src/pipeline/wowee_battlegrounds.cpp + src/pipeline/wowee_mail.cpp src/pipeline/custom_zone_discovery.cpp src/pipeline/dbc_layout.cpp @@ -1388,6 +1389,7 @@ add_executable(wowee_editor tools/editor/cli_events_catalog.cpp tools/editor/cli_mounts_catalog.cpp tools/editor/cli_battlegrounds_catalog.cpp + tools/editor/cli_mail_catalog.cpp tools/editor/cli_quest_objective.cpp tools/editor/cli_quest_reward.cpp tools/editor/cli_clone.cpp @@ -1487,6 +1489,7 @@ add_executable(wowee_editor src/pipeline/wowee_events.cpp src/pipeline/wowee_mounts.cpp src/pipeline/wowee_battlegrounds.cpp + src/pipeline/wowee_mail.cpp src/pipeline/custom_zone_discovery.cpp src/pipeline/terrain_mesh.cpp diff --git a/include/pipeline/wowee_mail.hpp b/include/pipeline/wowee_mail.hpp new file mode 100644 index 00000000..8221c271 --- /dev/null +++ b/include/pipeline/wowee_mail.hpp @@ -0,0 +1,109 @@ +#pragma once + +#include +#include +#include + +namespace wowee { +namespace pipeline { + +// Wowee Open Mail Template catalog (.wmal) — novel +// replacement for AzerothCore-style mail_loot_template SQL +// + the in-game mail subset of the inventory + currency +// systems. The 34th open format added to the editor. +// +// Defines templated mail messages with currency + item +// attachments. Triggered by: +// • quest reward delivery (overflow mail when bag is full) +// • auction house bid wins / sales completion +// • achievement reward attachments +// • GM correspondence +// • holiday event mailings (Brewfest samples, Hallow's End +// candy, anniversary thank-you notes) +// • returned-mail-on-rejection +// +// Cross-references with previously-added formats: +// WMAL.entry.senderNpcId → WCRT.entry.creatureId +// WMAL.entry.attachments.itemId → WIT.entry.itemId +// +// Binary layout (little-endian): +// magic[4] = "WMAL" +// version (uint32) = current 1 +// nameLen + name (catalog label) +// entryCount (uint32) +// entries (each): +// templateId (uint32) +// senderNpcId (uint32) +// subjectLen + subject +// bodyLen + body +// senderLen + senderName +// moneyCopperAttached (uint32) +// attachmentCount (uint8) / categoryId (uint8) / +// cod (uint8) / returnable (uint8) +// expiryDays (uint16) / pad[2] +// attachments (each: itemId (uint32) + quantity (uint16) + pad[2]) +struct WoweeMail { + enum Category : uint8_t { + QuestReward = 0, + Auction = 1, + GmCorrespondence = 2, + AchievementReward = 3, + EventMailing = 4, + Raffle = 5, + ScriptDelivery = 6, + ReturnedMail = 7, + }; + + struct Attachment { + uint32_t itemId = 0; + uint16_t quantity = 1; + }; + + struct Entry { + uint32_t templateId = 0; + uint32_t senderNpcId = 0; // 0 = system / no NPC + std::string subject; + std::string body; + std::string senderName; // fallback when senderNpcId=0 + uint32_t moneyCopperAttached = 0; + uint8_t categoryId = QuestReward; + uint8_t cod = 0; // 1 = cash on delivery + uint8_t returnable = 1; // 1 = unread mail returns + uint16_t expiryDays = 30; + std::vector attachments; + }; + + std::string name; + std::vector entries; + + bool isValid() const { return !entries.empty(); } + + const Entry* findById(uint32_t templateId) const; + + static const char* categoryName(uint8_t c); +}; + +class WoweeMailLoader { +public: + static bool save(const WoweeMail& cat, + const std::string& basePath); + static WoweeMail load(const std::string& basePath); + static bool exists(const std::string& basePath); + + // Preset emitters used by --gen-mail* variants. + // + // makeStarter — 3 templates covering quest reward + // overflow / auction house / GM gift. + // makeHoliday — 4 holiday-event mailings tied to WSEA + // yearly events (Tricky Treats sample + // pack, Brewfest sampler, Lunar Festival + // blessing, Winter's Veil gift box). + // makeAuction — full auction-house template family: + // outbid / won / sold / expired / cancelled. + static WoweeMail makeStarter(const std::string& catalogName); + static WoweeMail makeHoliday(const std::string& catalogName); + static WoweeMail makeAuction(const std::string& catalogName); +}; + +} // namespace pipeline +} // namespace wowee diff --git a/src/pipeline/wowee_mail.cpp b/src/pipeline/wowee_mail.cpp new file mode 100644 index 00000000..1281bc37 --- /dev/null +++ b/src/pipeline/wowee_mail.cpp @@ -0,0 +1,261 @@ +#include "pipeline/wowee_mail.hpp" + +#include +#include +#include + +namespace wowee { +namespace pipeline { + +namespace { + +constexpr char kMagic[4] = {'W', 'M', 'A', 'L'}; +constexpr uint32_t kVersion = 1; + +template +void writePOD(std::ofstream& os, const T& v) { + os.write(reinterpret_cast(&v), sizeof(T)); +} + +template +bool readPOD(std::ifstream& is, T& v) { + is.read(reinterpret_cast(&v), sizeof(T)); + return is.gcount() == static_cast(sizeof(T)); +} + +void writeStr(std::ofstream& os, const std::string& s) { + uint32_t n = static_cast(s.size()); + writePOD(os, n); + if (n > 0) os.write(s.data(), n); +} + +bool readStr(std::ifstream& is, std::string& s) { + uint32_t n = 0; + if (!readPOD(is, n)) return false; + if (n > (1u << 20)) return false; + s.resize(n); + if (n > 0) { + is.read(s.data(), n); + if (is.gcount() != static_cast(n)) { + s.clear(); + return false; + } + } + return true; +} + +std::string normalizePath(std::string base) { + if (base.size() < 5 || base.substr(base.size() - 5) != ".wmal") { + base += ".wmal"; + } + return base; +} + +} // namespace + +const WoweeMail::Entry* WoweeMail::findById(uint32_t templateId) const { + for (const auto& e : entries) if (e.templateId == templateId) return &e; + return nullptr; +} + +const char* WoweeMail::categoryName(uint8_t c) { + switch (c) { + case QuestReward: return "quest"; + case Auction: return "auction"; + case GmCorrespondence: return "gm"; + case AchievementReward: return "achievement"; + case EventMailing: return "event"; + case Raffle: return "raffle"; + case ScriptDelivery: return "script"; + case ReturnedMail: return "returned"; + default: return "unknown"; + } +} + +bool WoweeMailLoader::save(const WoweeMail& cat, + const std::string& basePath) { + std::ofstream os(normalizePath(basePath), std::ios::binary); + if (!os) return false; + os.write(kMagic, 4); + writePOD(os, kVersion); + writeStr(os, cat.name); + uint32_t entryCount = static_cast(cat.entries.size()); + writePOD(os, entryCount); + for (const auto& e : cat.entries) { + writePOD(os, e.templateId); + writePOD(os, e.senderNpcId); + writeStr(os, e.subject); + writeStr(os, e.body); + writeStr(os, e.senderName); + writePOD(os, e.moneyCopperAttached); + uint8_t attCount = static_cast( + e.attachments.size() > 255 ? 255 : e.attachments.size()); + writePOD(os, attCount); + writePOD(os, e.categoryId); + writePOD(os, e.cod); + writePOD(os, e.returnable); + writePOD(os, e.expiryDays); + uint8_t pad2[2] = {0, 0}; + os.write(reinterpret_cast(pad2), 2); + for (uint8_t k = 0; k < attCount; ++k) { + const auto& a = e.attachments[k]; + writePOD(os, a.itemId); + writePOD(os, a.quantity); + os.write(reinterpret_cast(pad2), 2); + } + } + return os.good(); +} + +WoweeMail WoweeMailLoader::load(const std::string& basePath) { + WoweeMail out; + std::ifstream is(normalizePath(basePath), std::ios::binary); + if (!is) return out; + char magic[4]; + is.read(magic, 4); + if (std::memcmp(magic, kMagic, 4) != 0) return out; + uint32_t version = 0; + if (!readPOD(is, version) || version != kVersion) return out; + if (!readStr(is, out.name)) return out; + uint32_t entryCount = 0; + if (!readPOD(is, entryCount)) return out; + if (entryCount > (1u << 20)) return out; + out.entries.resize(entryCount); + for (auto& e : out.entries) { + if (!readPOD(is, e.templateId) || + !readPOD(is, e.senderNpcId)) { + out.entries.clear(); return out; + } + if (!readStr(is, e.subject) || !readStr(is, e.body) || + !readStr(is, e.senderName)) { + out.entries.clear(); return out; + } + if (!readPOD(is, e.moneyCopperAttached)) { + out.entries.clear(); return out; + } + uint8_t attCount = 0; + if (!readPOD(is, attCount) || + !readPOD(is, e.categoryId) || + !readPOD(is, e.cod) || + !readPOD(is, e.returnable) || + !readPOD(is, e.expiryDays)) { + out.entries.clear(); return out; + } + uint8_t pad2[2]; + is.read(reinterpret_cast(pad2), 2); + if (is.gcount() != 2) { out.entries.clear(); return out; } + e.attachments.resize(attCount); + for (uint8_t k = 0; k < attCount; ++k) { + auto& a = e.attachments[k]; + if (!readPOD(is, a.itemId) || + !readPOD(is, a.quantity)) { + out.entries.clear(); return out; + } + is.read(reinterpret_cast(pad2), 2); + if (is.gcount() != 2) { out.entries.clear(); return out; } + } + } + return out; +} + +bool WoweeMailLoader::exists(const std::string& basePath) { + std::ifstream is(normalizePath(basePath), std::ios::binary); + return is.good(); +} + +WoweeMail WoweeMailLoader::makeStarter(const std::string& catalogName) { + WoweeMail c; + c.name = catalogName; + { + WoweeMail::Entry e; + e.templateId = 1; + e.subject = "Quest Reward Overflow"; + e.body = "Your bag was full so we mailed your reward. Enjoy!"; + e.senderName = "Postmaster"; + e.categoryId = WoweeMail::QuestReward; + e.attachments.push_back({3, 5}); // 5 healing potions (WIT 3) + c.entries.push_back(e); + } + { + WoweeMail::Entry e; + e.templateId = 2; + e.subject = "Auction Won"; + e.body = "Congratulations, you won the auction. Your item is attached."; + e.senderName = "Auction House"; + e.categoryId = WoweeMail::Auction; + e.attachments.push_back({1001, 1}); // apprentice sword + c.entries.push_back(e); + } + { + WoweeMail::Entry e; + e.templateId = 3; + e.subject = "A small gift"; + e.body = + "We're sorry for the recent server downtime. " + "Please accept this token of our appreciation."; + e.senderName = "GameMaster"; + e.categoryId = WoweeMail::GmCorrespondence; + e.moneyCopperAttached = 100000; // 10g + c.entries.push_back(e); + } + return c; +} + +WoweeMail WoweeMailLoader::makeHoliday(const std::string& catalogName) { + WoweeMail c; + c.name = catalogName; + auto add = [&](uint32_t id, const char* subject, const char* body, + const char* sender, uint32_t itemId, uint16_t qty) { + WoweeMail::Entry e; + e.templateId = id; e.subject = subject; e.body = body; + e.senderName = sender; + e.categoryId = WoweeMail::EventMailing; + e.attachments.push_back({itemId, qty}); + c.entries.push_back(e); + }; + // The itemIds (200-203) shadow WTKN.makeSeasonal token IDs + // so a holiday event arrives with its matching currency + // sample as a free starter pack. + add(100, "Hallow's End Sample Pack", + "Hallow's End is upon us. Here are some Tricky Treats to start.", + "Headless Horseman", 200, 25); + add(101, "Brewfest Sampler", + "The kegs are open. Have a few tokens on us.", + "Brewmaster Drohn", 201, 10); + add(102, "Lunar Blessing", + "May the new year bring you fortune. A coin to begin.", + "Elder Skygleam", 202, 5); + add(103, "Winter's Veil Gift", + "Greatfather Winter sends his regards. Enjoy this gift.", + "Greatfather Winter", 203, 1); + return c; +} + +WoweeMail WoweeMailLoader::makeAuction(const std::string& catalogName) { + WoweeMail c; + c.name = catalogName; + auto add = [&](uint32_t id, const char* subject, const char* body, + uint8_t cod = 0) { + WoweeMail::Entry e; + e.templateId = id; e.subject = subject; e.body = body; + e.senderName = "Auction House"; + e.categoryId = WoweeMail::Auction; + e.cod = cod; + e.returnable = 1; + c.entries.push_back(e); + }; + add(200, "Auction outbid", + "You have been outbid on your recent auction."); + add(201, "Auction won", + "Congratulations on winning your auction."); + add(202, "Auction sold", + "Your auction has sold. Payment is attached."); + add(203, "Auction expired", + "Your auction expired without selling. Item returned."); + add(204, "Auction cancelled", + "Your auction was cancelled. Listing fee refunded."); + return c; +} + +} // namespace pipeline +} // namespace wowee diff --git a/tools/editor/cli_arg_required.cpp b/tools/editor/cli_arg_required.cpp index 484a048a..f176953d 100644 --- a/tools/editor/cli_arg_required.cpp +++ b/tools/editor/cli_arg_required.cpp @@ -97,6 +97,8 @@ const char* const kArgRequired[] = { "--export-wmou-json", "--import-wmou-json", "--gen-bg", "--gen-bg-classic", "--gen-bg-arena", "--info-wbgd", "--validate-wbgd", + "--gen-mail", "--gen-mail-holiday", "--gen-mail-auction", + "--info-wmal", "--validate-wmal", "--gen-weather-temperate", "--gen-weather-arctic", "--gen-weather-desert", "--gen-weather-stormy", "--gen-zone-atmosphere", diff --git a/tools/editor/cli_dispatch.cpp b/tools/editor/cli_dispatch.cpp index d96f3e29..6b6b9310 100644 --- a/tools/editor/cli_dispatch.cpp +++ b/tools/editor/cli_dispatch.cpp @@ -61,6 +61,7 @@ #include "cli_events_catalog.hpp" #include "cli_mounts_catalog.hpp" #include "cli_battlegrounds_catalog.hpp" +#include "cli_mail_catalog.hpp" #include "cli_quest_objective.hpp" #include "cli_quest_reward.hpp" #include "cli_clone.hpp" @@ -163,6 +164,7 @@ constexpr DispatchFn kDispatchTable[] = { handleEventsCatalog, handleMountsCatalog, handleBattlegroundsCatalog, + handleMailCatalog, handleQuestObjective, handleQuestReward, handleClone, diff --git a/tools/editor/cli_help.cpp b/tools/editor/cli_help.cpp index 27b4062e..b820b4bc 100644 --- a/tools/editor/cli_help.cpp +++ b/tools/editor/cli_help.cpp @@ -1175,6 +1175,16 @@ void printUsage(const char* argv0) { std::printf(" Print WBGD entries (id / map / objective / player counts / level range / score / token reward)\n"); std::printf(" --validate-wbgd [--json]\n"); std::printf(" Static checks: id>0+unique, name not empty, player counts>0+min<=max, level range valid, scoreToWin>0\n"); + std::printf(" --gen-mail [name]\n"); + std::printf(" Emit .wmal starter: 3 templates (quest overflow / auction won / GM gift) covering main mail categories\n"); + std::printf(" --gen-mail-holiday [name]\n"); + std::printf(" Emit .wmal 4 holiday samples (Tricky Treats / Brewfest / Lunar / Winter's Veil) with WTKN cross-refs\n"); + std::printf(" --gen-mail-auction [name]\n"); + std::printf(" Emit .wmal 5-template auction-house family (outbid / won / sold / expired / cancelled)\n"); + std::printf(" --info-wmal [--json]\n"); + std::printf(" Print WMAL templates (id / category / sender / subject + body / money + items / cod / expiry)\n"); + std::printf(" --validate-wmal [--json]\n"); + std::printf(" Static checks: id>0+unique, subject not empty, sender set, attachments valid, no money+no items info-only\n"); std::printf(" --gen-weather-temperate [zoneName]\n"); std::printf(" Emit .wow weather schedule: clear-dominant + occasional rain + fog (forest / grassland)\n"); std::printf(" --gen-weather-arctic [zoneName]\n"); diff --git a/tools/editor/cli_mail_catalog.cpp b/tools/editor/cli_mail_catalog.cpp new file mode 100644 index 00000000..97ecfdb0 --- /dev/null +++ b/tools/editor/cli_mail_catalog.cpp @@ -0,0 +1,271 @@ +#include "cli_mail_catalog.hpp" +#include "cli_arg_parse.hpp" +#include "cli_box_emitter.hpp" + +#include "pipeline/wowee_mail.hpp" +#include + +#include +#include +#include +#include +#include +#include + +namespace wowee { +namespace editor { +namespace cli { + +namespace { + +std::string stripWmalExt(std::string base) { + stripExt(base, ".wmal"); + return base; +} + +bool saveOrError(const wowee::pipeline::WoweeMail& c, + const std::string& base, const char* cmd) { + if (!wowee::pipeline::WoweeMailLoader::save(c, base)) { + std::fprintf(stderr, "%s: failed to save %s.wmal\n", + cmd, base.c_str()); + return false; + } + return true; +} + +void printGenSummary(const wowee::pipeline::WoweeMail& c, + const std::string& base) { + std::printf("Wrote %s.wmal\n", base.c_str()); + std::printf(" catalog : %s\n", c.name.c_str()); + std::printf(" templates : %zu\n", c.entries.size()); +} + +int handleGenStarter(int& i, int argc, char** argv) { + std::string base = argv[++i]; + std::string name = "StarterMail"; + if (parseOptArg(i, argc, argv)) name = argv[++i]; + base = stripWmalExt(base); + auto c = wowee::pipeline::WoweeMailLoader::makeStarter(name); + if (!saveOrError(c, base, "gen-mail")) return 1; + printGenSummary(c, base); + return 0; +} + +int handleGenHoliday(int& i, int argc, char** argv) { + std::string base = argv[++i]; + std::string name = "HolidayMail"; + if (parseOptArg(i, argc, argv)) name = argv[++i]; + base = stripWmalExt(base); + auto c = wowee::pipeline::WoweeMailLoader::makeHoliday(name); + if (!saveOrError(c, base, "gen-mail-holiday")) return 1; + printGenSummary(c, base); + return 0; +} + +int handleGenAuction(int& i, int argc, char** argv) { + std::string base = argv[++i]; + std::string name = "AuctionMail"; + if (parseOptArg(i, argc, argv)) name = argv[++i]; + base = stripWmalExt(base); + auto c = wowee::pipeline::WoweeMailLoader::makeAuction(name); + if (!saveOrError(c, base, "gen-mail-auction")) return 1; + printGenSummary(c, base); + return 0; +} + +int handleInfo(int& i, int argc, char** argv) { + std::string base = argv[++i]; + bool jsonOut = consumeJsonFlag(i, argc, argv); + base = stripWmalExt(base); + if (!wowee::pipeline::WoweeMailLoader::exists(base)) { + std::fprintf(stderr, "WMAL not found: %s.wmal\n", base.c_str()); + return 1; + } + auto c = wowee::pipeline::WoweeMailLoader::load(base); + if (jsonOut) { + nlohmann::json j; + j["wmal"] = base + ".wmal"; + j["name"] = c.name; + j["count"] = c.entries.size(); + nlohmann::json arr = nlohmann::json::array(); + for (const auto& e : c.entries) { + nlohmann::json je; + je["templateId"] = e.templateId; + je["senderNpcId"] = e.senderNpcId; + je["subject"] = e.subject; + je["body"] = e.body; + je["senderName"] = e.senderName; + je["moneyCopperAttached"] = e.moneyCopperAttached; + je["categoryId"] = e.categoryId; + je["categoryName"] = wowee::pipeline::WoweeMail::categoryName(e.categoryId); + je["cod"] = e.cod; + je["returnable"] = e.returnable; + je["expiryDays"] = e.expiryDays; + nlohmann::json att = nlohmann::json::array(); + for (const auto& a : e.attachments) { + att.push_back({{"itemId", a.itemId}, + {"quantity", a.quantity}}); + } + je["attachments"] = att; + arr.push_back(je); + } + j["entries"] = arr; + std::printf("%s\n", j.dump(2).c_str()); + return 0; + } + std::printf("WMAL: %s.wmal\n", base.c_str()); + std::printf(" catalog : %s\n", c.name.c_str()); + std::printf(" templates : %zu\n", c.entries.size()); + if (c.entries.empty()) return 0; + for (const auto& e : c.entries) { + std::printf("\n templateId=%u category=%s expires=%ud cod=%u return=%u\n", + e.templateId, + wowee::pipeline::WoweeMail::categoryName(e.categoryId), + e.expiryDays, e.cod, e.returnable); + if (e.senderNpcId) { + std::printf(" sender : %s (npcId=%u)\n", + e.senderName.c_str(), e.senderNpcId); + } else { + std::printf(" sender : %s\n", e.senderName.c_str()); + } + std::printf(" subject : %s\n", e.subject.c_str()); + if (!e.body.empty()) { + std::printf(" body : %s\n", e.body.c_str()); + } + if (e.moneyCopperAttached) { + std::printf(" money : %uc\n", e.moneyCopperAttached); + } + if (!e.attachments.empty()) { + std::printf(" items :"); + for (const auto& a : e.attachments) { + std::printf(" item%u x%u", a.itemId, a.quantity); + } + std::printf("\n"); + } + } + return 0; +} + +int handleValidate(int& i, int argc, char** argv) { + std::string base = argv[++i]; + bool jsonOut = consumeJsonFlag(i, argc, argv); + base = stripWmalExt(base); + if (!wowee::pipeline::WoweeMailLoader::exists(base)) { + std::fprintf(stderr, + "validate-wmal: WMAL not found: %s.wmal\n", base.c_str()); + return 1; + } + auto c = wowee::pipeline::WoweeMailLoader::load(base); + std::vector errors; + std::vector warnings; + if (c.entries.empty()) { + warnings.push_back("catalog has zero entries"); + } + std::vector idsSeen; + for (size_t k = 0; k < c.entries.size(); ++k) { + const auto& e = c.entries[k]; + std::string ctx = "entry " + std::to_string(k) + + " (id=" + std::to_string(e.templateId) + ")"; + if (e.templateId == 0) errors.push_back(ctx + ": templateId is 0"); + if (e.subject.empty()) errors.push_back(ctx + ": subject is empty"); + if (e.senderNpcId == 0 && e.senderName.empty()) { + errors.push_back(ctx + + ": neither senderNpcId nor senderName set (no displayable sender)"); + } + if (e.categoryId > wowee::pipeline::WoweeMail::ReturnedMail) { + errors.push_back(ctx + ": categoryId " + + std::to_string(e.categoryId) + " not in 0..7"); + } + if (e.expiryDays == 0) { + warnings.push_back(ctx + + ": expiryDays=0 (mail expires immediately)"); + } + if (e.cod && e.moneyCopperAttached == 0) { + warnings.push_back(ctx + + ": cod=1 but moneyCopperAttached=0 (free COD)"); + } + // Mail with no money + no items is informational only. + // Legitimate for GM correspondence (text-only notices) + // and for the Auction category where the runtime fills + // in the real outcome (winning bid amount / sold item) + // at send time. Flag only for the categories where + // empty mail is genuinely a typo. + if (e.moneyCopperAttached == 0 && e.attachments.empty() && + e.categoryId != wowee::pipeline::WoweeMail::GmCorrespondence && + e.categoryId != wowee::pipeline::WoweeMail::Auction && + e.categoryId != wowee::pipeline::WoweeMail::ReturnedMail) { + warnings.push_back(ctx + + ": no money + no items (informational mail only)"); + } + for (size_t ai = 0; ai < e.attachments.size(); ++ai) { + const auto& a = e.attachments[ai]; + if (a.itemId == 0) { + errors.push_back(ctx + " attachment " + std::to_string(ai) + + ": itemId is 0"); + } + if (a.quantity == 0) { + errors.push_back(ctx + " attachment " + std::to_string(ai) + + ": quantity is 0"); + } + } + for (uint32_t prev : idsSeen) { + if (prev == e.templateId) { + errors.push_back(ctx + ": duplicate templateId"); + break; + } + } + idsSeen.push_back(e.templateId); + } + bool ok = errors.empty(); + if (jsonOut) { + nlohmann::json j; + j["wmal"] = base + ".wmal"; + j["ok"] = ok; + j["errors"] = errors; + j["warnings"] = warnings; + std::printf("%s\n", j.dump(2).c_str()); + return ok ? 0 : 1; + } + std::printf("validate-wmal: %s.wmal\n", base.c_str()); + if (ok && warnings.empty()) { + std::printf(" OK — %zu templates, all templateIds unique\n", + c.entries.size()); + return 0; + } + if (!warnings.empty()) { + std::printf(" warnings (%zu):\n", warnings.size()); + for (const auto& w : warnings) + std::printf(" - %s\n", w.c_str()); + } + if (!errors.empty()) { + std::printf(" ERRORS (%zu):\n", errors.size()); + for (const auto& e : errors) + std::printf(" - %s\n", e.c_str()); + } + return ok ? 0 : 1; +} + +} // namespace + +bool handleMailCatalog(int& i, int argc, char** argv, int& outRc) { + if (std::strcmp(argv[i], "--gen-mail") == 0 && i + 1 < argc) { + outRc = handleGenStarter(i, argc, argv); return true; + } + if (std::strcmp(argv[i], "--gen-mail-holiday") == 0 && i + 1 < argc) { + outRc = handleGenHoliday(i, argc, argv); return true; + } + if (std::strcmp(argv[i], "--gen-mail-auction") == 0 && i + 1 < argc) { + outRc = handleGenAuction(i, argc, argv); return true; + } + if (std::strcmp(argv[i], "--info-wmal") == 0 && i + 1 < argc) { + outRc = handleInfo(i, argc, argv); return true; + } + if (std::strcmp(argv[i], "--validate-wmal") == 0 && i + 1 < argc) { + outRc = handleValidate(i, argc, argv); return true; + } + return false; +} + +} // namespace cli +} // namespace editor +} // namespace wowee diff --git a/tools/editor/cli_mail_catalog.hpp b/tools/editor/cli_mail_catalog.hpp new file mode 100644 index 00000000..a7c8df03 --- /dev/null +++ b/tools/editor/cli_mail_catalog.hpp @@ -0,0 +1,11 @@ +#pragma once + +namespace wowee { +namespace editor { +namespace cli { + +bool handleMailCatalog(int& i, int argc, char** argv, int& outRc); + +} // namespace cli +} // namespace editor +} // namespace wowee