diff --git a/CMakeLists.txt b/CMakeLists.txt index d2f57e8b..d8545542 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -693,6 +693,7 @@ set(WOWEE_SOURCES src/pipeline/wowee_buff_book.cpp src/pipeline/wowee_tabards.cpp src/pipeline/wowee_spell_markers.cpp + src/pipeline/wowee_learning_notifications.cpp src/pipeline/custom_zone_discovery.cpp src/pipeline/dbc_layout.cpp @@ -1549,6 +1550,7 @@ add_executable(wowee_editor tools/editor/cli_buff_book_catalog.cpp tools/editor/cli_tabards_catalog.cpp tools/editor/cli_spell_markers_catalog.cpp + tools/editor/cli_learning_notifications_catalog.cpp tools/editor/cli_catalog_pluck.cpp tools/editor/cli_catalog_find.cpp tools/editor/cli_quest_objective.cpp @@ -1722,6 +1724,7 @@ add_executable(wowee_editor src/pipeline/wowee_buff_book.cpp src/pipeline/wowee_tabards.cpp src/pipeline/wowee_spell_markers.cpp + src/pipeline/wowee_learning_notifications.cpp src/pipeline/custom_zone_discovery.cpp src/pipeline/terrain_mesh.cpp diff --git a/include/pipeline/wowee_learning_notifications.hpp b/include/pipeline/wowee_learning_notifications.hpp new file mode 100644 index 00000000..61abf646 --- /dev/null +++ b/include/pipeline/wowee_learning_notifications.hpp @@ -0,0 +1,160 @@ +#pragma once + +#include +#include +#include + +namespace wowee { +namespace pipeline { + +// Wowee Open Learning Notification catalog (.wldn) — +// novel replacement for the hardcoded server-side +// milestone messages that fire when a player crosses a +// progression threshold ("You can now learn Apprentice +// Riding" at level 20, "Dual specialization is now +// available" at level 40, "You have unlocked the +// auction house"). Each entry binds one trigger +// (LevelReach / FactionStanding / ItemAcquired / etc.) +// to a delivery channel (RaidWarning banner / +// SystemMsg / Subtitle / Tutorial popup) and an optional +// fanfare sound. +// +// Cross-references with previously-added formats: +// WSND: soundId references the WSND sound catalog +// (the per-notification fanfare). +// WSPL: when triggerKind=SpellLearned, triggerValue +// is a WSPL spellId. +// WIT: when triggerKind=ItemAcquired, triggerValue +// is a WIT itemId. +// WQTM: when triggerKind=QuestComplete, triggerValue +// is a WQTM questId. +// WMS: when triggerKind=ZoneEntered, triggerValue is +// a WMS areaId. +// +// The triggerValue field is polymorphic — its semantics +// depend on triggerKind. The validator can't fully +// cross-check without all referenced catalogs in the +// directory; it does range-check what it can per kind. +// +// Binary layout (little-endian): +// magic[4] = "WLDN" +// version (uint32) = current 1 +// nameLen + name (catalog label) +// entryCount (uint32) +// entries (each): +// notificationId (uint32) +// nameLen + name +// descLen + description +// msgLen + messageText +// triggerKind (uint8) — LevelReach / +// FactionStanding / +// ItemAcquired / +// QuestComplete / +// SpellLearned / +// ZoneEntered +// channelKind (uint8) — RaidWarning / +// SystemMsg / +// Subtitle / Tutorial / +// MOTD +// factionFilter (uint8) — 1=A / 2=H / 3=Both +// pad0 (uint8) +// triggerValue (int32) — level / standing / +// itemId / etc. +// soundId (uint32) — 0 if silent +// minTotalTimePlayed (uint32)— seconds; 0 = always +// fire (else only +// first-time players +// below threshold) +// iconColorRGBA (uint32) +struct WoweeLearningNotifications { + enum TriggerKind : uint8_t { + LevelReach = 0, // triggerValue = level + FactionStanding = 1, // triggerValue = standing + // value (Hated=-42000, + // Exalted=42000) + ItemAcquired = 2, // triggerValue = itemId + QuestComplete = 3, // triggerValue = questId + SpellLearned = 4, // triggerValue = spellId + ZoneEntered = 5, // triggerValue = areaId + }; + + enum ChannelKind : uint8_t { + RaidWarning = 0, // SMSG_RAID_WARNING red + // banner across center screen + SystemMsg = 1, // standard system channel + // chat line + Subtitle = 2, // bottom-of-screen tutorial + // subtitle (fade after 5s) + Tutorial = 3, // tutorial popup with image + // (player must dismiss) + MOTDAppend = 4, // appended to next session + // login MOTD chain + }; + + enum FactionFilter : uint8_t { + AllianceOnly = 1, + HordeOnly = 2, + Both = 3, + }; + + struct Entry { + uint32_t notificationId = 0; + std::string name; + std::string description; + std::string messageText; + uint8_t triggerKind = LevelReach; + uint8_t channelKind = SystemMsg; + uint8_t factionFilter = Both; + uint8_t pad0 = 0; + int32_t triggerValue = 0; + uint32_t soundId = 0; + uint32_t minTotalTimePlayed = 0; + uint32_t iconColorRGBA = 0xFFFFFFFFu; + }; + + std::string name; + std::vector entries; + + bool isValid() const { return !entries.empty(); } + + const Entry* findById(uint32_t notificationId) const; + + // Returns all notifications of one trigger kind. Used + // by the per-trigger dispatcher (level-up logic + // queries kind=LevelReach; quest complete logic + // queries kind=QuestComplete; etc.) to scope the + // search. + std::vector findByTrigger(uint8_t triggerKind) const; +}; + +class WoweeLearningNotificationsLoader { +public: + static bool save(const WoweeLearningNotifications& cat, + const std::string& basePath); + static WoweeLearningNotifications load(const std::string& basePath); + static bool exists(const std::string& basePath); + + // Preset emitters used by --gen-ldn* variants. + // + // makeLevelMilestones — 5 LevelReach notifications + // covering the canonical + // unlock thresholds (mounts + // at 20, talent reset gold + // at 30, epic mount at 40, + // dual spec at 40, flying + // mount at 60). + // makeAccountUnlocks — 4 ItemAcquired / SpellLearned + // notifications for major + // UI unlocks (mailbox usage, + // auction house, dual spec + // activation, transmog vendor). + // makeReputation — 3 FactionStanding milestones + // (Honored / Revered / Exalted + // with a major faction). + static WoweeLearningNotifications makeLevelMilestones(const std::string& catalogName); + static WoweeLearningNotifications makeAccountUnlocks(const std::string& catalogName); + static WoweeLearningNotifications makeReputation(const std::string& catalogName); +}; + +} // namespace pipeline +} // namespace wowee diff --git a/src/pipeline/wowee_learning_notifications.cpp b/src/pipeline/wowee_learning_notifications.cpp new file mode 100644 index 00000000..744d48a4 --- /dev/null +++ b/src/pipeline/wowee_learning_notifications.cpp @@ -0,0 +1,318 @@ +#include "pipeline/wowee_learning_notifications.hpp" + +#include +#include +#include + +namespace wowee { +namespace pipeline { + +namespace { + +constexpr char kMagic[4] = {'W', 'L', 'D', 'N'}; +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) != ".wldn") { + base += ".wldn"; + } + return base; +} + +uint32_t packRgba(uint8_t r, uint8_t g, uint8_t b, uint8_t a = 0xFF) { + return (static_cast(a) << 24) | + (static_cast(b) << 16) | + (static_cast(g) << 8) | + static_cast(r); +} + +} // namespace + +const WoweeLearningNotifications::Entry* +WoweeLearningNotifications::findById(uint32_t notificationId) const { + for (const auto& e : entries) + if (e.notificationId == notificationId) return &e; + return nullptr; +} + +std::vector +WoweeLearningNotifications::findByTrigger(uint8_t triggerKind) const { + std::vector out; + for (const auto& e : entries) + if (e.triggerKind == triggerKind) out.push_back(&e); + return out; +} + +bool WoweeLearningNotificationsLoader::save( + const WoweeLearningNotifications& 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.notificationId); + writeStr(os, e.name); + writeStr(os, e.description); + writeStr(os, e.messageText); + writePOD(os, e.triggerKind); + writePOD(os, e.channelKind); + writePOD(os, e.factionFilter); + writePOD(os, e.pad0); + writePOD(os, e.triggerValue); + writePOD(os, e.soundId); + writePOD(os, e.minTotalTimePlayed); + writePOD(os, e.iconColorRGBA); + } + return os.good(); +} + +WoweeLearningNotifications WoweeLearningNotificationsLoader::load( + const std::string& basePath) { + WoweeLearningNotifications 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.notificationId)) { + out.entries.clear(); return out; + } + if (!readStr(is, e.name) || + !readStr(is, e.description) || + !readStr(is, e.messageText)) { + out.entries.clear(); return out; + } + if (!readPOD(is, e.triggerKind) || + !readPOD(is, e.channelKind) || + !readPOD(is, e.factionFilter) || + !readPOD(is, e.pad0) || + !readPOD(is, e.triggerValue) || + !readPOD(is, e.soundId) || + !readPOD(is, e.minTotalTimePlayed) || + !readPOD(is, e.iconColorRGBA)) { + out.entries.clear(); return out; + } + } + return out; +} + +bool WoweeLearningNotificationsLoader::exists( + const std::string& basePath) { + std::ifstream is(normalizePath(basePath), std::ios::binary); + return is.good(); +} + +WoweeLearningNotifications +WoweeLearningNotificationsLoader::makeLevelMilestones( + const std::string& catalogName) { + using L = WoweeLearningNotifications; + WoweeLearningNotifications c; + c.name = catalogName; + auto add = [&](uint32_t id, const char* name, + int32_t level, uint8_t channel, + const char* msg, uint32_t soundId, + const char* desc) { + L::Entry e; + e.notificationId = id; e.name = name; + e.description = desc; + e.messageText = msg; + e.triggerKind = L::LevelReach; + e.triggerValue = level; + e.channelKind = channel; + e.factionFilter = L::Both; + e.soundId = soundId; + e.minTotalTimePlayed = 0; + e.iconColorRGBA = packRgba(220, 200, 80); // milestone gold + c.entries.push_back(e); + }; + add(1, "ApprenticeRidingUnlock", 20, L::Tutorial, + "You have reached level 20! You may now train " + "Apprentice Riding from any riding trainer in " + "your capital city.", + 12867, + "Level 20 milestone — first riding tier " + "unlock. Tutorial popup with mount icon."); + add(2, "TalentResetReminder", 30, L::SystemMsg, + "You have reached level 30. Visit a class " + "trainer to reset talents if you wish to try a " + "different specialization.", + 0, + "Level 30 reminder — silent system message, no " + "fanfare. Soft suggestion to try respec."); + add(3, "EpicGroundMountUnlock", 40, L::Tutorial, + "You have reached level 40! You may now train " + "Journeyman Riding (epic ground mount, +100% " + "speed) and Dual Specialization.", + 12867, + "Level 40 milestone — epic ground mount + dual " + "spec dual unlock."); + add(4, "FlightTrainingUnlock", 60, L::Tutorial, + "You have reached level 60! Visit a flight " + "trainer in Outland or Northrend to learn Expert " + "Riding (flying mount).", + 12867, + "Level 60 milestone — flying mount unlock."); + add(5, "EndgameRaidContent", 80, L::RaidWarning, + "Congratulations on reaching level 80! Endgame " + "raid content is now available — speak to your " + "Stormwind / Orgrimmar liaison for details.", + 12865, + "Level 80 milestone — endgame banner. " + "RaidWarning channel for max emphasis."); + return c; +} + +WoweeLearningNotifications +WoweeLearningNotificationsLoader::makeAccountUnlocks( + const std::string& catalogName) { + using L = WoweeLearningNotifications; + WoweeLearningNotifications c; + c.name = catalogName; + auto add = [&](uint32_t id, const char* name, + uint8_t kind, int32_t value, + uint8_t channel, const char* msg, + uint32_t timePlayed, const char* desc) { + L::Entry e; + e.notificationId = id; e.name = name; + e.description = desc; + e.messageText = msg; + e.triggerKind = kind; + e.triggerValue = value; + e.channelKind = channel; + e.factionFilter = L::Both; + e.soundId = 0; + e.minTotalTimePlayed = timePlayed; + e.iconColorRGBA = packRgba(140, 200, 255); // unlock blue + c.entries.push_back(e); + }; + // First-mailbox-use tutorial — fires once when player + // acquires their first mail item, but only for newbies + // (timePlayed < 7200 = 2 hours). + add(100, "FirstMailReceived", L::ItemAcquired, + 17, L::Tutorial, + "You received your first mail! Open the mailbox " + "to retrieve attached items. Mailboxes are at " + "every inn and capital.", + 7200, + "First-mail tutorial — gated to total time " + "played < 2hr to suppress for veterans."); + add(101, "AuctionHouseAvailable", L::ZoneEntered, + 1519, L::Subtitle, + "Welcome to Stormwind! The Auction House is " + "located in the Trade District near the front " + "gate.", + 0, + "Stormwind first-entry subtitle — explains " + "auction house location for newbies."); + add(102, "DualSpecActivated", L::SpellLearned, + 63645, L::SystemMsg, + "Dual Specialization is now active! Press 'N' " + "and click the secondary spec slot to switch.", + 0, + "Fires when player learns the dual-spec activator " + "spell (id 63645). System message channel."); + add(103, "TransmogVendorUnlock", L::QuestComplete, + 25000, L::Tutorial, + "You may now visit a Transmogrification vendor " + "to apply cosmetic appearances to your gear " + "without changing stats.", + 0, + "Fires when player completes the transmog intro " + "quest (placeholder questId 25000)."); + return c; +} + +WoweeLearningNotifications +WoweeLearningNotificationsLoader::makeReputation( + const std::string& catalogName) { + using L = WoweeLearningNotifications; + WoweeLearningNotifications c; + c.name = catalogName; + auto add = [&](uint32_t id, const char* name, + int32_t standing, const char* msg, + const char* desc) { + L::Entry e; + e.notificationId = id; e.name = name; + e.description = desc; + e.messageText = msg; + e.triggerKind = L::FactionStanding; + e.triggerValue = standing; + e.channelKind = L::SystemMsg; + e.factionFilter = L::Both; + e.soundId = 0; + e.minTotalTimePlayed = 0; + e.iconColorRGBA = packRgba(160, 220, 80); // rep green + c.entries.push_back(e); + }; + // Reputation-based notifications — generic, would + // need per-faction variants in a real deployment. + // standing values: Honored=9000, Revered=21000, + // Exalted=42000. + add(200, "HonoredRepReached", 9000, + "You have reached Honored standing with the " + "Argent Crusade. New quests and discounted " + "vendor prices are now available.", + "Generic Honored milestone — placeholder for " + "per-faction variants. 10% vendor discount kicks " + "in at this tier."); + add(201, "ReveredRepReached", 21000, + "You have reached Revered standing with the " + "Argent Crusade. Tabard and select rare items " + "are now purchasable.", + "Generic Revered milestone — tabard unlock " + "tier (15% vendor discount)."); + add(202, "ExaltedRepReached", 42000, + "You have reached Exalted standing with the " + "Argent Crusade. Maximum reputation reward " + "items unlocked. Achievement granted.", + "Generic Exalted milestone — maximum rep tier. " + "Triggers achievement system."); + return c; +} + +} // namespace pipeline +} // namespace wowee diff --git a/tools/editor/cli_arg_required.cpp b/tools/editor/cli_arg_required.cpp index 752a30cc..2ffdbbfe 100644 --- a/tools/editor/cli_arg_required.cpp +++ b/tools/editor/cli_arg_required.cpp @@ -322,6 +322,8 @@ const char* const kArgRequired[] = { "--gen-spm", "--gen-spm-raid", "--gen-spm-env", "--info-wspm", "--validate-wspm", "--export-wspm-json", "--import-wspm-json", + "--gen-ldn", "--gen-ldn-account", "--gen-ldn-rep", + "--info-wldn", "--validate-wldn", "--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 a02e7c46..a706a19f 100644 --- a/tools/editor/cli_dispatch.cpp +++ b/tools/editor/cli_dispatch.cpp @@ -149,6 +149,7 @@ #include "cli_buff_book_catalog.hpp" #include "cli_tabards_catalog.hpp" #include "cli_spell_markers_catalog.hpp" +#include "cli_learning_notifications_catalog.hpp" #include "cli_catalog_pluck.hpp" #include "cli_catalog_find.hpp" #include "cli_quest_objective.hpp" @@ -341,6 +342,7 @@ constexpr DispatchFn kDispatchTable[] = { handleBuffBookCatalog, handleTabardsCatalog, handleSpellMarkersCatalog, + handleLearningNotificationsCatalog, handleCatalogPluck, handleCatalogFind, handleQuestObjective, diff --git a/tools/editor/cli_format_table.cpp b/tools/editor/cli_format_table.cpp index 199fb147..c82df140 100644 --- a/tools/editor/cli_format_table.cpp +++ b/tools/editor/cli_format_table.cpp @@ -107,6 +107,7 @@ constexpr FormatMagicEntry kFormats[] = { {{'W','B','A','B'}, ".wbab", "spells", "--info-wbab", "Buff & Aura book (rank chains)"}, {{'W','T','B','D'}, ".wtbd", "guilds", "--info-wtbd", "Tabard design / heraldry catalog"}, {{'W','S','P','M'}, ".wspm", "spellfx", "--info-wspm", "Spell persistent marker catalog"}, + {{'W','L','D','N'}, ".wldn", "server", "--info-wldn", "Learning notification catalog"}, {{'W','F','A','C'}, ".wfac", "factions", nullptr, "Faction catalog"}, {{'W','L','C','K'}, ".wlck", "locks", nullptr, "Lock catalog"}, {{'W','S','K','L'}, ".wskl", "skills", nullptr, "Skill catalog"}, diff --git a/tools/editor/cli_help.cpp b/tools/editor/cli_help.cpp index 2ef80114..fadedc56 100644 --- a/tools/editor/cli_help.cpp +++ b/tools/editor/cli_help.cpp @@ -2223,6 +2223,16 @@ void printUsage(const char* argv0) { std::printf(" Export binary .wspm to a human-editable JSON sidecar (defaults to .wspm.json; emits edgeFadeMode as both int AND name string; radius+duration as floats; decalColor as 0xAARRGGBB uint32)\n"); std::printf(" --import-wspm-json [out-base]\n"); std::printf(" Import a .wspm.json sidecar back into binary .wspm (edgeFadeMode int OR \"hard\"/\"softedge\"/\"pulse\"; stackable/destroyOnCancel bool OR int)\n"); + std::printf(" --gen-ldn [name]\n"); + std::printf(" Emit .wldn 5 LevelReach milestone notifications (level 20 mounts / 30 talent reset / 40 epic mount / 60 flying / 80 endgame)\n"); + std::printf(" --gen-ldn-account [name]\n"); + std::printf(" Emit .wldn 4 ItemAcquired/ZoneEntered/SpellLearned/QuestComplete notifications (first mail tutorial / Stormwind auction tip / dual-spec activation / transmog vendor unlock)\n"); + std::printf(" --gen-ldn-rep [name]\n"); + std::printf(" Emit .wldn 3 FactionStanding milestone notifications (Honored 9000 / Revered 21000 / Exalted 42000 with the Argent Crusade)\n"); + std::printf(" --info-wldn [--json]\n"); + std::printf(" Print WLDN entries (id / triggerKind / triggerValue / channel / faction filter / minTimePlayed / name)\n"); + std::printf(" --validate-wldn [--json]\n"); + std::printf(" Static checks: id+name+messageText required, triggerKind 0..5, channelKind 0..4, factionFilter 1..3, no duplicate ids; per-trigger triggerValue ranges (LevelReach 1-80, FactionStanding +-42000, ItemAcquired/QuestComplete/SpellLearned/ZoneEntered > 0)\n"); std::printf(" --catalog-pluck [--json]\n"); std::printf(" Extract one entry by id from any registered catalog format. Auto-detects magic, dispatches to the per-format --info-* handler internally, then prints just the matching entry. Primary-key field is auto-detected (first *Id field, or first numeric)\n"); std::printf(" --catalog-find [--magic ] [--json]\n"); diff --git a/tools/editor/cli_learning_notifications_catalog.cpp b/tools/editor/cli_learning_notifications_catalog.cpp new file mode 100644 index 00000000..a0aa0b7c --- /dev/null +++ b/tools/editor/cli_learning_notifications_catalog.cpp @@ -0,0 +1,320 @@ +#include "cli_learning_notifications_catalog.hpp" +#include "cli_arg_parse.hpp" +#include "cli_box_emitter.hpp" + +#include "pipeline/wowee_learning_notifications.hpp" +#include + +#include +#include +#include +#include +#include +#include +#include + +namespace wowee { +namespace editor { +namespace cli { + +namespace { + +std::string stripWldnExt(std::string base) { + stripExt(base, ".wldn"); + return base; +} + +const char* triggerKindName(uint8_t k) { + using L = wowee::pipeline::WoweeLearningNotifications; + switch (k) { + case L::LevelReach: return "levelreach"; + case L::FactionStanding: return "factionstanding"; + case L::ItemAcquired: return "itemacquired"; + case L::QuestComplete: return "questcomplete"; + case L::SpellLearned: return "spelllearned"; + case L::ZoneEntered: return "zoneentered"; + default: return "unknown"; + } +} + +const char* channelKindName(uint8_t k) { + using L = wowee::pipeline::WoweeLearningNotifications; + switch (k) { + case L::RaidWarning: return "raidwarning"; + case L::SystemMsg: return "systemmsg"; + case L::Subtitle: return "subtitle"; + case L::Tutorial: return "tutorial"; + case L::MOTDAppend: return "motdappend"; + default: return "unknown"; + } +} + +const char* factionFilterName(uint8_t f) { + using L = wowee::pipeline::WoweeLearningNotifications; + switch (f) { + case L::AllianceOnly: return "alliance"; + case L::HordeOnly: return "horde"; + case L::Both: return "both"; + default: return "unknown"; + } +} + +bool saveOrError(const wowee::pipeline::WoweeLearningNotifications& c, + const std::string& base, const char* cmd) { + if (!wowee::pipeline::WoweeLearningNotificationsLoader::save( + c, base)) { + std::fprintf(stderr, "%s: failed to save %s.wldn\n", + cmd, base.c_str()); + return false; + } + return true; +} + +void printGenSummary(const wowee::pipeline::WoweeLearningNotifications& c, + const std::string& base) { + std::printf("Wrote %s.wldn\n", base.c_str()); + std::printf(" catalog : %s\n", c.name.c_str()); + std::printf(" notifications : %zu\n", c.entries.size()); +} + +int handleGenLevels(int& i, int argc, char** argv) { + std::string base = argv[++i]; + std::string name = "LevelMilestones"; + if (parseOptArg(i, argc, argv)) name = argv[++i]; + base = stripWldnExt(base); + auto c = wowee::pipeline::WoweeLearningNotificationsLoader:: + makeLevelMilestones(name); + if (!saveOrError(c, base, "gen-ldn")) return 1; + printGenSummary(c, base); + return 0; +} + +int handleGenAccount(int& i, int argc, char** argv) { + std::string base = argv[++i]; + std::string name = "AccountUnlocks"; + if (parseOptArg(i, argc, argv)) name = argv[++i]; + base = stripWldnExt(base); + auto c = wowee::pipeline::WoweeLearningNotificationsLoader:: + makeAccountUnlocks(name); + if (!saveOrError(c, base, "gen-ldn-account")) return 1; + printGenSummary(c, base); + return 0; +} + +int handleGenReputation(int& i, int argc, char** argv) { + std::string base = argv[++i]; + std::string name = "ReputationMilestones"; + if (parseOptArg(i, argc, argv)) name = argv[++i]; + base = stripWldnExt(base); + auto c = wowee::pipeline::WoweeLearningNotificationsLoader:: + makeReputation(name); + if (!saveOrError(c, base, "gen-ldn-rep")) 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 = stripWldnExt(base); + if (!wowee::pipeline::WoweeLearningNotificationsLoader::exists( + base)) { + std::fprintf(stderr, "WLDN not found: %s.wldn\n", base.c_str()); + return 1; + } + auto c = wowee::pipeline::WoweeLearningNotificationsLoader::load( + base); + if (jsonOut) { + nlohmann::json j; + j["wldn"] = base + ".wldn"; + j["name"] = c.name; + j["count"] = c.entries.size(); + nlohmann::json arr = nlohmann::json::array(); + for (const auto& e : c.entries) { + arr.push_back({ + {"notificationId", e.notificationId}, + {"name", e.name}, + {"description", e.description}, + {"messageText", e.messageText}, + {"triggerKind", e.triggerKind}, + {"triggerKindName", triggerKindName(e.triggerKind)}, + {"channelKind", e.channelKind}, + {"channelKindName", channelKindName(e.channelKind)}, + {"factionFilter", e.factionFilter}, + {"factionFilterName", + factionFilterName(e.factionFilter)}, + {"triggerValue", e.triggerValue}, + {"soundId", e.soundId}, + {"minTotalTimePlayed", e.minTotalTimePlayed}, + {"iconColorRGBA", e.iconColorRGBA}, + }); + } + j["entries"] = arr; + std::printf("%s\n", j.dump(2).c_str()); + return 0; + } + std::printf("WLDN: %s.wldn\n", base.c_str()); + std::printf(" catalog : %s\n", c.name.c_str()); + std::printf(" notifications : %zu\n", c.entries.size()); + if (c.entries.empty()) return 0; + std::printf(" id trigger val channel faction minPlayed name\n"); + for (const auto& e : c.entries) { + std::printf(" %4u %-15s %5d %-12s %-9s %8u %s\n", + e.notificationId, + triggerKindName(e.triggerKind), + e.triggerValue, + channelKindName(e.channelKind), + factionFilterName(e.factionFilter), + e.minTotalTimePlayed, + e.name.c_str()); + } + return 0; +} + +int handleValidate(int& i, int argc, char** argv) { + std::string base = argv[++i]; + bool jsonOut = consumeJsonFlag(i, argc, argv); + base = stripWldnExt(base); + if (!wowee::pipeline::WoweeLearningNotificationsLoader::exists( + base)) { + std::fprintf(stderr, + "validate-wldn: WLDN not found: %s.wldn\n", + base.c_str()); + return 1; + } + auto c = wowee::pipeline::WoweeLearningNotificationsLoader::load( + base); + std::vector errors; + std::vector warnings; + if (c.entries.empty()) { + warnings.push_back("catalog has zero entries"); + } + std::set 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.notificationId); + if (!e.name.empty()) ctx += " " + e.name; + ctx += ")"; + if (e.notificationId == 0) + errors.push_back(ctx + ": notificationId is 0"); + if (e.name.empty()) + errors.push_back(ctx + ": name is empty"); + if (e.messageText.empty()) + errors.push_back(ctx + + ": messageText is empty — notification " + "would deliver no payload"); + if (e.triggerKind > 5) { + errors.push_back(ctx + ": triggerKind " + + std::to_string(e.triggerKind) + + " out of range (must be 0..5)"); + } + if (e.channelKind > 4) { + errors.push_back(ctx + ": channelKind " + + std::to_string(e.channelKind) + + " out of range (must be 0..4)"); + } + if (e.factionFilter == 0 || e.factionFilter > 3) { + errors.push_back(ctx + ": factionFilter " + + std::to_string(e.factionFilter) + + " out of range (must be 1=A / 2=H / 3=Both)"); + } + // Per-trigger-kind validity of triggerValue. + using L = wowee::pipeline::WoweeLearningNotifications; + if (e.triggerKind == L::LevelReach) { + if (e.triggerValue < 1 || e.triggerValue > 80) { + warnings.push_back(ctx + + ": LevelReach triggerValue " + + std::to_string(e.triggerValue) + + " outside 1-80 range — current cap is " + "level 80 (WotLK)"); + } + } else if (e.triggerKind == L::FactionStanding) { + // Standing range: Hated=-42000, Exalted=42000. + if (e.triggerValue < -42000 || + e.triggerValue > 42000) { + errors.push_back(ctx + + ": FactionStanding triggerValue " + + std::to_string(e.triggerValue) + + " outside [-42000, 42000] valid range"); + } + } else if (e.triggerKind == L::ItemAcquired || + e.triggerKind == L::QuestComplete || + e.triggerKind == L::SpellLearned || + e.triggerKind == L::ZoneEntered) { + if (e.triggerValue <= 0) { + errors.push_back(ctx + + ": " + std::string(triggerKindName( + e.triggerKind)) + + " triggerValue " + + std::to_string(e.triggerValue) + + " <= 0 — must be a positive id"); + } + } + if (e.messageText.size() > 255) { + warnings.push_back(ctx + ": messageText is " + + std::to_string(e.messageText.size()) + + " chars (>255) — server may truncate on " + "delivery"); + } + if (!idsSeen.insert(e.notificationId).second) { + errors.push_back(ctx + ": duplicate notificationId"); + } + } + bool ok = errors.empty(); + if (jsonOut) { + nlohmann::json j; + j["wldn"] = base + ".wldn"; + 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-wldn: %s.wldn\n", base.c_str()); + if (ok && warnings.empty()) { + std::printf(" OK — %zu notifications, all " + "notificationIds unique, triggerValues " + "valid for kind\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 handleLearningNotificationsCatalog(int& i, int argc, char** argv, + int& outRc) { + if (std::strcmp(argv[i], "--gen-ldn") == 0 && i + 1 < argc) { + outRc = handleGenLevels(i, argc, argv); return true; + } + if (std::strcmp(argv[i], "--gen-ldn-account") == 0 && + i + 1 < argc) { + outRc = handleGenAccount(i, argc, argv); return true; + } + if (std::strcmp(argv[i], "--gen-ldn-rep") == 0 && i + 1 < argc) { + outRc = handleGenReputation(i, argc, argv); return true; + } + if (std::strcmp(argv[i], "--info-wldn") == 0 && i + 1 < argc) { + outRc = handleInfo(i, argc, argv); return true; + } + if (std::strcmp(argv[i], "--validate-wldn") == 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_learning_notifications_catalog.hpp b/tools/editor/cli_learning_notifications_catalog.hpp new file mode 100644 index 00000000..0eb768c2 --- /dev/null +++ b/tools/editor/cli_learning_notifications_catalog.hpp @@ -0,0 +1,12 @@ +#pragma once + +namespace wowee { +namespace editor { +namespace cli { + +bool handleLearningNotificationsCatalog(int& i, int argc, char** argv, + int& outRc); + +} // namespace cli +} // namespace editor +} // namespace wowee diff --git a/tools/editor/cli_list_formats.cpp b/tools/editor/cli_list_formats.cpp index a7b1a034..49ffb66c 100644 --- a/tools/editor/cli_list_formats.cpp +++ b/tools/editor/cli_list_formats.cpp @@ -129,6 +129,7 @@ constexpr FormatRow kFormats[] = { {"WBAB", ".wbab", "spells", "Spell.dbc nextRank/prevRank ptrs", "Buff & Aura book — long-duration class buffs with rank chains"}, {"WTBD", ".wtbd", "guilds", "guild_member tabard config blob", "Tabard design / heraldry catalog (3-color)"}, {"WSPM", ".wspm", "spellfx", "AreaTrigger.dbc + decal blob", "Spell persistent marker catalog (AoE ground decals)"}, + {"WLDN", ".wldn", "server", "TutorialPopup + LevelMilestone msgs","Learning notification catalog (level-up milestones)"}, // Additional pipeline catalogs without the alternating // gen/info/validate CLI surface (loaded by the engine