diff --git a/CMakeLists.txt b/CMakeLists.txt index 03f7fdc6..0f59e339 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -686,6 +686,7 @@ set(WOWEE_SOURCES src/pipeline/wowee_action_bars.cpp src/pipeline/wowee_group_compositions.cpp src/pipeline/wowee_hearth_binds.cpp + src/pipeline/wowee_server_broadcasts.cpp src/pipeline/custom_zone_discovery.cpp src/pipeline/dbc_layout.cpp @@ -1535,6 +1536,7 @@ add_executable(wowee_editor tools/editor/cli_action_bars_catalog.cpp tools/editor/cli_group_compositions_catalog.cpp tools/editor/cli_hearth_binds_catalog.cpp + tools/editor/cli_server_broadcasts_catalog.cpp tools/editor/cli_quest_objective.cpp tools/editor/cli_quest_reward.cpp tools/editor/cli_clone.cpp @@ -1699,6 +1701,7 @@ add_executable(wowee_editor src/pipeline/wowee_action_bars.cpp src/pipeline/wowee_group_compositions.cpp src/pipeline/wowee_hearth_binds.cpp + src/pipeline/wowee_server_broadcasts.cpp src/pipeline/custom_zone_discovery.cpp src/pipeline/terrain_mesh.cpp diff --git a/include/pipeline/wowee_server_broadcasts.hpp b/include/pipeline/wowee_server_broadcasts.hpp new file mode 100644 index 00000000..d3be509b --- /dev/null +++ b/include/pipeline/wowee_server_broadcasts.hpp @@ -0,0 +1,129 @@ +#pragma once + +#include +#include +#include + +namespace wowee { +namespace pipeline { + +// Wowee Open Server Channel Broadcast catalog (.wscb) — +// novel replacement for the hardcoded login-MOTD, +// server-restart-warning, and rotating-help-tip messages +// that vanilla servers nail into source. Each entry is +// one scheduled or event-triggered broadcast: a one-shot +// MOTD shown on login, a periodic system message +// ("server restart in 10 minutes"), a raid-wide warning, +// or a rotating gameplay tip displayed in the /help +// channel. +// +// Cross-references with previously-added formats: +// WCHN: channelKind values 0..4 map to dispatch sinks; +// the SystemChannel/HelpTip variants land in the +// WCHN-defined system / help channels. +// WCHC: factionFilter uses the WCHC faction-mask bits +// (1=Alliance, 2=Horde, 3=Both, 0=neither which +// means "no broadcast" — validator warns). +// +// Binary layout (little-endian): +// magic[4] = "WSCB" +// version (uint32) = current 1 +// nameLen + name (catalog label) +// entryCount (uint32) +// entries (each): +// broadcastId (uint32) +// nameLen + name +// descLen + description +// msgLen + messageText +// intervalSeconds (uint32) — 0 = login-only / once +// channelKind (uint8) — Login / SystemChannel / +// RaidWarning / MOTD / +// HelpTip +// factionFilter (uint8) — WCHC faction-mask bits +// minLevel (uint8) — earliest level player +// must be to receive (0 +// = any) +// maxLevel (uint8) — latest level (0 = any) +// iconColorRGBA (uint32) +struct WoweeServerBroadcasts { + enum ChannelKind : uint8_t { + Login = 0, // shown once on character + // entering world + SystemChannel = 1, // WCHN system channel + // (server-side) + RaidWarning = 2, // SMSG_RAID_WARNING (red + // banner across screen) + MOTD = 3, // appended to existing + // server MOTD chain + HelpTip = 4, // rotating tip shown in + // /help channel + }; + + enum FactionFilter : uint8_t { + AllianceOnly = 1, + HordeOnly = 2, + Both = 3, + }; + + struct Entry { + uint32_t broadcastId = 0; + std::string name; + std::string description; + std::string messageText; // body shown to player + uint32_t intervalSeconds = 0; + uint8_t channelKind = MOTD; + uint8_t factionFilter = Both; + uint8_t minLevel = 0; + uint8_t maxLevel = 0; + uint32_t iconColorRGBA = 0xFFFFFFFFu; + }; + + std::string name; + std::vector entries; + + bool isValid() const { return !entries.empty(); } + + const Entry* findById(uint32_t broadcastId) const; + + // Returns all broadcasts that should fire for a player + // of the given faction and level. Used by the + // BroadcastTicker to build the per-player message + // queue on login. + std::vector findFor(uint8_t playerFaction, + uint8_t playerLevel) const; + + // Returns all broadcasts of one channel kind (used by + // the periodic ticker to schedule SystemChannel / + // HelpTip rotations independently from login MOTDs). + std::vector findByChannel(uint8_t channelKind) const; +}; + +class WoweeServerBroadcastsLoader { +public: + static bool save(const WoweeServerBroadcasts& cat, + const std::string& basePath); + static WoweeServerBroadcasts load(const std::string& basePath); + static bool exists(const std::string& basePath); + + // Preset emitters used by --gen-scb* variants. + // + // makeMotd — 4 login MOTD entries (welcome / + // patch notes summary / Discord + // link / forum link). + // makeMaintenance — 3 raid-wide maintenance warnings + // at decreasing intervals (15min + // / 5min / 1min before restart), + // each with intervalSeconds=0 + // (one-shot). + // makeHelpTips — 6 rotating help-channel tips on + // a 600s (10min) cycle covering + // core gameplay (talents, mounts, + // auction, professions, dungeon + // finder, hearthstone). + static WoweeServerBroadcasts makeMotd(const std::string& catalogName); + static WoweeServerBroadcasts makeMaintenance(const std::string& catalogName); + static WoweeServerBroadcasts makeHelpTips(const std::string& catalogName); +}; + +} // namespace pipeline +} // namespace wowee diff --git a/src/pipeline/wowee_server_broadcasts.cpp b/src/pipeline/wowee_server_broadcasts.cpp new file mode 100644 index 00000000..847b8609 --- /dev/null +++ b/src/pipeline/wowee_server_broadcasts.cpp @@ -0,0 +1,286 @@ +#include "pipeline/wowee_server_broadcasts.hpp" + +#include +#include +#include + +namespace wowee { +namespace pipeline { + +namespace { + +constexpr char kMagic[4] = {'W', 'S', 'C', 'B'}; +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) != ".wscb") { + base += ".wscb"; + } + 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 WoweeServerBroadcasts::Entry* +WoweeServerBroadcasts::findById(uint32_t broadcastId) const { + for (const auto& e : entries) + if (e.broadcastId == broadcastId) return &e; + return nullptr; +} + +std::vector +WoweeServerBroadcasts::findFor(uint8_t playerFaction, + uint8_t playerLevel) const { + std::vector out; + for (const auto& e : entries) { + if (!(e.factionFilter & playerFaction)) continue; + if (e.minLevel > 0 && playerLevel < e.minLevel) continue; + if (e.maxLevel > 0 && playerLevel > e.maxLevel) continue; + out.push_back(&e); + } + return out; +} + +std::vector +WoweeServerBroadcasts::findByChannel(uint8_t channelKind) const { + std::vector out; + for (const auto& e : entries) + if (e.channelKind == channelKind) out.push_back(&e); + return out; +} + +bool WoweeServerBroadcastsLoader::save(const WoweeServerBroadcasts& 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.broadcastId); + writeStr(os, e.name); + writeStr(os, e.description); + writeStr(os, e.messageText); + writePOD(os, e.intervalSeconds); + writePOD(os, e.channelKind); + writePOD(os, e.factionFilter); + writePOD(os, e.minLevel); + writePOD(os, e.maxLevel); + writePOD(os, e.iconColorRGBA); + } + return os.good(); +} + +WoweeServerBroadcasts WoweeServerBroadcastsLoader::load( + const std::string& basePath) { + WoweeServerBroadcasts 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.broadcastId)) { + 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.intervalSeconds) || + !readPOD(is, e.channelKind) || + !readPOD(is, e.factionFilter) || + !readPOD(is, e.minLevel) || + !readPOD(is, e.maxLevel) || + !readPOD(is, e.iconColorRGBA)) { + out.entries.clear(); return out; + } + } + return out; +} + +bool WoweeServerBroadcastsLoader::exists(const std::string& basePath) { + std::ifstream is(normalizePath(basePath), std::ios::binary); + return is.good(); +} + +WoweeServerBroadcasts WoweeServerBroadcastsLoader::makeMotd( + const std::string& catalogName) { + using S = WoweeServerBroadcasts; + WoweeServerBroadcasts c; + c.name = catalogName; + auto add = [&](uint32_t id, const char* name, const char* msg, + const char* desc) { + S::Entry e; + e.broadcastId = id; e.name = name; + e.description = desc; + e.messageText = msg; + e.intervalSeconds = 0; // login-only + e.channelKind = S::MOTD; + e.factionFilter = S::Both; + e.iconColorRGBA = packRgba(255, 220, 100); // MOTD gold + c.entries.push_back(e); + }; + add(1, "WelcomeBanner", + "Welcome to the server! Type /help for assistance.", + "Top-of-MOTD welcome banner shown on every login."); + add(2, "PatchNotesSummary", + "Patch 3.3.5b: ICC 25H tuning + bugfixes. See " + "/forum for full notes.", + "One-line patch notes summary; updated each " + "release. Replaces the hardcoded WoW 3.3.5a " + "client-side default MOTD entry."); + add(3, "DiscordInvite", + "Join our community Discord: discord.gg/example " + "for live support and groupfinder.", + "Discord invite footer. Server-custom; not in " + "stock WoW."); + add(4, "ForumLink", + "Bug reports + feature requests: forum.example.com", + "Forum URL footer. Server-custom; not in stock " + "WoW."); + return c; +} + +WoweeServerBroadcasts WoweeServerBroadcastsLoader::makeMaintenance( + const std::string& catalogName) { + using S = WoweeServerBroadcasts; + WoweeServerBroadcasts c; + c.name = catalogName; + auto add = [&](uint32_t id, const char* name, const char* msg, + const char* desc) { + S::Entry e; + e.broadcastId = id; e.name = name; + e.description = desc; + e.messageText = msg; + e.intervalSeconds = 0; // one-shot, scheduled + // externally + e.channelKind = S::RaidWarning; + e.factionFilter = S::Both; + e.iconColorRGBA = packRgba(220, 60, 60); // warning red + c.entries.push_back(e); + }; + add(100, "Restart15Min", + "[SERVER] Restart in 15 minutes. Please complete " + "your current activity and find a safe location.", + "First maintenance warning — fired by the cron " + "scheduler 15min before a planned restart."); + add(101, "Restart5Min", + "[SERVER] Restart in 5 minutes. World will save " + "and shut down shortly.", + "Second maintenance warning — fired 5min before " + "restart."); + add(102, "Restart1Min", + "[SERVER] Restart in 60 SECONDS. Disconnect now " + "to avoid character rollback.", + "Final maintenance warning — fired 60s before " + "restart. RaidWarning channel ensures the red " + "banner appears center-screen."); + return c; +} + +WoweeServerBroadcasts WoweeServerBroadcastsLoader::makeHelpTips( + const std::string& catalogName) { + using S = WoweeServerBroadcasts; + WoweeServerBroadcasts c; + c.name = catalogName; + auto add = [&](uint32_t id, const char* name, const char* msg, + uint8_t minLvl, const char* desc) { + S::Entry e; + e.broadcastId = id; e.name = name; + e.description = desc; + e.messageText = msg; + e.intervalSeconds = 600; // 10-min cycle + e.channelKind = S::HelpTip; + e.factionFilter = S::Both; + e.minLevel = minLvl; + e.iconColorRGBA = packRgba(140, 200, 255); // help blue + c.entries.push_back(e); + }; + add(200, "TalentTip", + "[Tip] Your first talent point unlocks at level " + "10. Press 'N' to open the talent tree.", + 10, "Help tip about talent tree, level-gated to " + "10+ so it doesn't confuse new characters."); + add(201, "MountTip", + "[Tip] Your first mount becomes available at " + "level 20. Visit the riding trainer in your " + "capital city.", + 20, "Help tip about mount training, level-gated " + "to 20+."); + add(202, "AuctionTip", + "[Tip] Selling unwanted gear at the Auction House " + "is the fastest way to fund your next mount or " + "training.", + 15, "Help tip about auction house economy."); + add(203, "ProfessionTip", + "[Tip] You can learn 2 primary professions and " + "all 3 secondary skills (Cooking / First Aid / " + "Fishing).", + 5, "Help tip about profession slots; fires for " + "any character past tutorial."); + add(204, "DungeonFinderTip", + "[Tip] Press 'I' to open the Dungeon Finder. " + "Queue as your role to get faster pops.", + 15, "Help tip about Dungeon Finder UI; level-" + "gated to 15+ when LFG becomes available."); + add(205, "HearthstoneTip", + "[Tip] Right-click your Hearthstone to teleport " + "to your bound inn. Speak to any innkeeper to " + "rebind.", + 1, "Help tip about hearthstone mechanic; " + "applies from level 1."); + return c; +} + +} // namespace pipeline +} // namespace wowee diff --git a/tools/editor/cli_arg_required.cpp b/tools/editor/cli_arg_required.cpp index 6a544bf3..50cba01c 100644 --- a/tools/editor/cli_arg_required.cpp +++ b/tools/editor/cli_arg_required.cpp @@ -300,6 +300,8 @@ const char* const kArgRequired[] = { "--gen-hrt", "--gen-hrt-capitals", "--gen-hrt-inns", "--info-whrt", "--validate-whrt", "--export-whrt-json", "--import-whrt-json", + "--gen-scb", "--gen-scb-maintenance", "--gen-scb-helptips", + "--info-wscb", "--validate-wscb", "--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 0e5b253f..3827fd8c 100644 --- a/tools/editor/cli_dispatch.cpp +++ b/tools/editor/cli_dispatch.cpp @@ -142,6 +142,7 @@ #include "cli_action_bars_catalog.hpp" #include "cli_group_compositions_catalog.hpp" #include "cli_hearth_binds_catalog.hpp" +#include "cli_server_broadcasts_catalog.hpp" #include "cli_quest_objective.hpp" #include "cli_quest_reward.hpp" #include "cli_clone.hpp" @@ -325,6 +326,7 @@ constexpr DispatchFn kDispatchTable[] = { handleActionBarsCatalog, handleGroupCompositionsCatalog, handleHearthBindsCatalog, + handleServerBroadcastsCatalog, handleQuestObjective, handleQuestReward, handleClone, diff --git a/tools/editor/cli_format_table.cpp b/tools/editor/cli_format_table.cpp index 1da99103..61b05afa 100644 --- a/tools/editor/cli_format_table.cpp +++ b/tools/editor/cli_format_table.cpp @@ -100,6 +100,7 @@ constexpr FormatMagicEntry kFormats[] = { {{'W','A','C','T'}, ".wact", "ui", "--info-wact", "Action bar layout catalog"}, {{'W','G','R','P'}, ".wgrp", "social", "--info-wgrp", "Group composition catalog"}, {{'W','H','R','T'}, ".whrt", "social", "--info-whrt", "Hearthstone bind point catalog"}, + {{'W','S','C','B'}, ".wscb", "server", "--info-wscb", "Server channel broadcast 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 07661050..1de095d4 100644 --- a/tools/editor/cli_help.cpp +++ b/tools/editor/cli_help.cpp @@ -2125,6 +2125,16 @@ void printUsage(const char* argv0) { std::printf(" Export binary .whrt to a human-editable JSON sidecar (defaults to .whrt.json; emits both bindKind/factionMask ints AND name strings)\n"); std::printf(" --import-whrt-json [out-base]\n"); std::printf(" Import a .whrt.json sidecar back into binary .whrt (accepts bindKind int OR \"inn\"/\"capital\"/\"quest\"/\"guild\"/\"specialport\"/\"faction\"; factionMask int OR \"alliance\"/\"horde\"/\"both\")\n"); + std::printf(" --gen-scb [name]\n"); + std::printf(" Emit .wscb 4 login-MOTD entries (welcome banner / patch notes summary / Discord link / forum link)\n"); + std::printf(" --gen-scb-maintenance [name]\n"); + std::printf(" Emit .wscb 3 RaidWarning entries for restart countdown (15min / 5min / 60sec, intervalSeconds=0 — fired by external scheduler)\n"); + std::printf(" --gen-scb-helptips [name]\n"); + std::printf(" Emit .wscb 6 HelpTip channel rotating tips on 600s cycle (talents / mounts / auction / professions / dungeon finder / hearthstone, level-gated)\n"); + std::printf(" --info-wscb [--json]\n"); + std::printf(" Print WSCB entries (id / channel / faction / interval / level range / name)\n"); + std::printf(" --validate-wscb [--json]\n"); + std::printf(" Static checks: id+name+messageText required, factionFilter 1..3, channelKind 0..4, min<=max level, no duplicate ids, intervalSeconds>=10 (errors below); warns on interval>0 with login/MOTD (timer ignored), interval<60 (spammy), text>255 chars (truncation)\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_list_formats.cpp b/tools/editor/cli_list_formats.cpp index f7e99426..f79efc2f 100644 --- a/tools/editor/cli_list_formats.cpp +++ b/tools/editor/cli_list_formats.cpp @@ -122,6 +122,7 @@ constexpr FormatRow kFormats[] = { {"WACT", ".wact", "ui", "Hardcoded class default action bar","Action bar layout catalog"}, {"WGRP", ".wgrp", "social", "LFG group-composition rules", "Group composition catalog (role quotas)"}, {"WHRT", ".whrt", "social", "SMSG_BINDPOINTUPDATE bind list", "Hearthstone bind point catalog"}, + {"WSCB", ".wscb", "server", "MOTD + scheduled SMSG_NOTIFICATION","Server channel broadcast catalog"}, // Additional pipeline catalogs without the alternating // gen/info/validate CLI surface (loaded by the engine diff --git a/tools/editor/cli_server_broadcasts_catalog.cpp b/tools/editor/cli_server_broadcasts_catalog.cpp new file mode 100644 index 00000000..4249b7b3 --- /dev/null +++ b/tools/editor/cli_server_broadcasts_catalog.cpp @@ -0,0 +1,298 @@ +#include "cli_server_broadcasts_catalog.hpp" +#include "cli_arg_parse.hpp" +#include "cli_box_emitter.hpp" + +#include "pipeline/wowee_server_broadcasts.hpp" +#include + +#include +#include +#include +#include +#include +#include + +namespace wowee { +namespace editor { +namespace cli { + +namespace { + +std::string stripWscbExt(std::string base) { + stripExt(base, ".wscb"); + return base; +} + +const char* channelKindName(uint8_t k) { + using S = wowee::pipeline::WoweeServerBroadcasts; + switch (k) { + case S::Login: return "login"; + case S::SystemChannel: return "system"; + case S::RaidWarning: return "raidwarning"; + case S::MOTD: return "motd"; + case S::HelpTip: return "helptip"; + default: return "unknown"; + } +} + +const char* factionFilterName(uint8_t f) { + using S = wowee::pipeline::WoweeServerBroadcasts; + switch (f) { + case S::AllianceOnly: return "alliance"; + case S::HordeOnly: return "horde"; + case S::Both: return "both"; + default: return "unknown"; + } +} + +bool saveOrError(const wowee::pipeline::WoweeServerBroadcasts& c, + const std::string& base, const char* cmd) { + if (!wowee::pipeline::WoweeServerBroadcastsLoader::save(c, base)) { + std::fprintf(stderr, "%s: failed to save %s.wscb\n", + cmd, base.c_str()); + return false; + } + return true; +} + +void printGenSummary(const wowee::pipeline::WoweeServerBroadcasts& c, + const std::string& base) { + std::printf("Wrote %s.wscb\n", base.c_str()); + std::printf(" catalog : %s\n", c.name.c_str()); + std::printf(" broadcasts : %zu\n", c.entries.size()); +} + +int handleGenMotd(int& i, int argc, char** argv) { + std::string base = argv[++i]; + std::string name = "ServerMOTD"; + if (parseOptArg(i, argc, argv)) name = argv[++i]; + base = stripWscbExt(base); + auto c = wowee::pipeline::WoweeServerBroadcastsLoader::makeMotd(name); + if (!saveOrError(c, base, "gen-scb")) return 1; + printGenSummary(c, base); + return 0; +} + +int handleGenMaintenance(int& i, int argc, char** argv) { + std::string base = argv[++i]; + std::string name = "MaintenanceWarnings"; + if (parseOptArg(i, argc, argv)) name = argv[++i]; + base = stripWscbExt(base); + auto c = wowee::pipeline::WoweeServerBroadcastsLoader::makeMaintenance(name); + if (!saveOrError(c, base, "gen-scb-maintenance")) return 1; + printGenSummary(c, base); + return 0; +} + +int handleGenHelpTips(int& i, int argc, char** argv) { + std::string base = argv[++i]; + std::string name = "HelpChannelTips"; + if (parseOptArg(i, argc, argv)) name = argv[++i]; + base = stripWscbExt(base); + auto c = wowee::pipeline::WoweeServerBroadcastsLoader::makeHelpTips(name); + if (!saveOrError(c, base, "gen-scb-helptips")) 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 = stripWscbExt(base); + if (!wowee::pipeline::WoweeServerBroadcastsLoader::exists(base)) { + std::fprintf(stderr, "WSCB not found: %s.wscb\n", base.c_str()); + return 1; + } + auto c = wowee::pipeline::WoweeServerBroadcastsLoader::load(base); + if (jsonOut) { + nlohmann::json j; + j["wscb"] = base + ".wscb"; + j["name"] = c.name; + j["count"] = c.entries.size(); + nlohmann::json arr = nlohmann::json::array(); + for (const auto& e : c.entries) { + arr.push_back({ + {"broadcastId", e.broadcastId}, + {"name", e.name}, + {"description", e.description}, + {"messageText", e.messageText}, + {"intervalSeconds", e.intervalSeconds}, + {"channelKind", e.channelKind}, + {"channelKindName", channelKindName(e.channelKind)}, + {"factionFilter", e.factionFilter}, + {"factionFilterName", + factionFilterName(e.factionFilter)}, + {"minLevel", e.minLevel}, + {"maxLevel", e.maxLevel}, + {"iconColorRGBA", e.iconColorRGBA}, + }); + } + j["entries"] = arr; + std::printf("%s\n", j.dump(2).c_str()); + return 0; + } + std::printf("WSCB: %s.wscb\n", base.c_str()); + std::printf(" catalog : %s\n", c.name.c_str()); + std::printf(" broadcasts : %zu\n", c.entries.size()); + if (c.entries.empty()) return 0; + std::printf(" id channel faction interval(s) lvls name\n"); + for (const auto& e : c.entries) { + char lvls[16]; + std::snprintf(lvls, sizeof(lvls), "%u-%u", + e.minLevel, e.maxLevel); + std::printf(" %4u %-12s %-9s %10u %-6s %s\n", + e.broadcastId, + channelKindName(e.channelKind), + factionFilterName(e.factionFilter), + e.intervalSeconds, lvls, 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 = stripWscbExt(base); + if (!wowee::pipeline::WoweeServerBroadcastsLoader::exists(base)) { + std::fprintf(stderr, + "validate-wscb: WSCB not found: %s.wscb\n", base.c_str()); + return 1; + } + auto c = wowee::pipeline::WoweeServerBroadcastsLoader::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.broadcastId); + if (!e.name.empty()) ctx += " " + e.name; + ctx += ")"; + if (e.broadcastId == 0) + errors.push_back(ctx + ": broadcastId is 0"); + if (e.name.empty()) + errors.push_back(ctx + ": name is empty"); + if (e.messageText.empty()) + errors.push_back(ctx + ": messageText is empty " + "— broadcast would deliver no payload"); + 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)"); + } + if (e.channelKind > 4) { + errors.push_back(ctx + ": channelKind " + + std::to_string(e.channelKind) + + " out of range (must be 0..4)"); + } + if (e.minLevel > 0 && e.maxLevel > 0 && + e.minLevel > e.maxLevel) { + errors.push_back(ctx + ": minLevel " + + std::to_string(e.minLevel) + + " > maxLevel " + std::to_string(e.maxLevel)); + } + // Periodic broadcasts (interval>0) make sense + // mainly on SystemChannel and HelpTip. Login/MOTD + // with interval>0 is a configuration mistake — + // those fire on session enter, not on a timer. + using S = wowee::pipeline::WoweeServerBroadcasts; + if (e.intervalSeconds > 0 && + (e.channelKind == S::Login || + e.channelKind == S::MOTD)) { + warnings.push_back(ctx + + ": intervalSeconds=" + + std::to_string(e.intervalSeconds) + + " on " + channelKindName(e.channelKind) + + " channel — login/MOTD fire on session " + "enter, not on a timer; interval likely " + "ignored"); + } + // Very short intervals would spam players. Warn + // below 60 seconds; reject below 10 seconds. + if (e.intervalSeconds > 0 && e.intervalSeconds < 10) { + errors.push_back(ctx + ": intervalSeconds " + + std::to_string(e.intervalSeconds) + + " < 10 — would spam players faster than " + "they can read"); + } else if (e.intervalSeconds > 0 && + e.intervalSeconds < 60) { + warnings.push_back(ctx + ": intervalSeconds " + + std::to_string(e.intervalSeconds) + + " < 60 — broadcast fires more than once " + "per minute; verify if intentional"); + } + // Message length sanity: WoW chat message buffer + // is ~255 chars; over that, server may truncate. + if (e.messageText.size() > 255) { + warnings.push_back(ctx + ": messageText is " + + std::to_string(e.messageText.size()) + + " chars (>255) — server may truncate on " + "delivery"); + } + for (uint32_t prev : idsSeen) { + if (prev == e.broadcastId) { + errors.push_back(ctx + ": duplicate broadcastId"); + break; + } + } + idsSeen.push_back(e.broadcastId); + } + bool ok = errors.empty(); + if (jsonOut) { + nlohmann::json j; + j["wscb"] = base + ".wscb"; + 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-wscb: %s.wscb\n", base.c_str()); + if (ok && warnings.empty()) { + std::printf(" OK — %zu broadcasts, all ids 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 handleServerBroadcastsCatalog(int& i, int argc, char** argv, + int& outRc) { + if (std::strcmp(argv[i], "--gen-scb") == 0 && i + 1 < argc) { + outRc = handleGenMotd(i, argc, argv); return true; + } + if (std::strcmp(argv[i], "--gen-scb-maintenance") == 0 && + i + 1 < argc) { + outRc = handleGenMaintenance(i, argc, argv); return true; + } + if (std::strcmp(argv[i], "--gen-scb-helptips") == 0 && + i + 1 < argc) { + outRc = handleGenHelpTips(i, argc, argv); return true; + } + if (std::strcmp(argv[i], "--info-wscb") == 0 && i + 1 < argc) { + outRc = handleInfo(i, argc, argv); return true; + } + if (std::strcmp(argv[i], "--validate-wscb") == 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_server_broadcasts_catalog.hpp b/tools/editor/cli_server_broadcasts_catalog.hpp new file mode 100644 index 00000000..100a8d4e --- /dev/null +++ b/tools/editor/cli_server_broadcasts_catalog.hpp @@ -0,0 +1,12 @@ +#pragma once + +namespace wowee { +namespace editor { +namespace cli { + +bool handleServerBroadcastsCatalog(int& i, int argc, char** argv, + int& outRc); + +} // namespace cli +} // namespace editor +} // namespace wowee