From a05161d2cb7b6c90eda14253d6953217c0352269 Mon Sep 17 00:00:00 2001 From: Josh Anderson Date: Tue, 7 Jul 2026 12:52:38 -0500 Subject: [PATCH 001/103] Add headless world chat client --- .gitignore | 1 + CMakeLists.txt | 69 ++ include/game/entity_controller.hpp | 3 + include/game/game_handler.hpp | 3 + include/game/game_utils.hpp | 5 + include/game/protocol_constants.hpp | 3 +- include/game/world_packets.hpp | 7 +- include/pipeline/asset_manager.hpp | 9 + .../rendering/animation/emote_registry.hpp | 2 + src/game/chat_handler.cpp | 91 +- src/game/entity_controller.cpp | 47 + src/game/game_handler.cpp | 6 +- src/game/game_handler_callbacks.cpp | 27 +- src/game/game_handler_packets.cpp | 62 ++ src/game/inventory_handler.cpp | 23 + src/game/packet_parsers_tbc.cpp | 103 ++- src/game/social_handler.cpp | 64 +- src/game/spell_handler.cpp | 11 + src/game/world_packets_social.cpp | 15 +- src/network/world_socket.cpp | 15 +- src/pipeline/asset_manager.cpp | 19 + src/rendering/animation/emote_registry.cpp | 5 +- tools/headless_client/README.md | 91 ++ tools/headless_client/main.cpp | 840 ++++++++++++++++++ tools/headless_client/settings.example.json | 44 + 25 files changed, 1528 insertions(+), 37 deletions(-) create mode 100644 tools/headless_client/README.md create mode 100644 tools/headless_client/main.cpp create mode 100644 tools/headless_client/settings.example.json diff --git a/.gitignore b/.gitignore index 1eea712d9..18f2bf60f 100644 --- a/.gitignore +++ b/.gitignore @@ -83,6 +83,7 @@ imgui.ini # Config files config.ini config.json +tools/headless_client/settings.json # Runtime cache (floor heights, etc.) cache/ diff --git a/CMakeLists.txt b/CMakeLists.txt index f53cd4678..344ca967c 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -1420,6 +1420,75 @@ set_target_properties(auth_login_probe PROPERTIES ) install(TARGETS auth_login_probe RUNTIME DESTINATION bin) +set(WOWEE_HEADLESS_SOURCES ${WOWEE_SOURCES}) +list(REMOVE_ITEM WOWEE_HEADLESS_SOURCES src/main.cpp) +add_executable(wowee_headless + tools/headless_client/main.cpp + ${WOWEE_HEADLESS_SOURCES} +) +target_compile_definitions(wowee_headless PRIVATE WOWEE_HEADLESS_DEFAULT=1) +if(TARGET opcodes-generate) + add_dependencies(wowee_headless opcodes-generate) +endif() +target_include_directories(wowee_headless PRIVATE + ${CMAKE_CURRENT_SOURCE_DIR}/include + ${CMAKE_CURRENT_SOURCE_DIR}/src +) +target_include_directories(wowee_headless SYSTEM PRIVATE + ${CMAKE_CURRENT_SOURCE_DIR}/extern + ${CMAKE_CURRENT_SOURCE_DIR}/extern/vk-bootstrap/src +) +if(HAVE_FFMPEG) + target_compile_definitions(wowee_headless PRIVATE HAVE_FFMPEG) + target_include_directories(wowee_headless PRIVATE ${FFMPEG_INCLUDE_DIRS}) + target_link_libraries(wowee_headless PRIVATE ${FFMPEG_LIBRARIES}) + if(FFMPEG_LIBRARY_DIRS) + target_link_directories(wowee_headless PRIVATE ${FFMPEG_LIBRARY_DIRS}) + endif() +endif() +target_link_libraries(wowee_headless PRIVATE + SDL2::SDL2 + Vulkan::Vulkan + OpenSSL::SSL + OpenSSL::Crypto + Threads::Threads + ZLIB::ZLIB + lua51 + ${CMAKE_DL_LIBS} +) +if(UNIX AND NOT APPLE) + target_link_libraries(wowee_headless PRIVATE X11) +endif() +if(WIN32) + target_link_libraries(wowee_headless PRIVATE ws2_32) +endif() +if(TARGET imgui) + target_link_libraries(wowee_headless PRIVATE imgui) +endif() +if(TARGET vk-bootstrap) + target_link_libraries(wowee_headless PRIVATE vk-bootstrap) +endif() +if(TARGET wowee_fsr2_amd_vk) + target_link_libraries(wowee_headless PRIVATE wowee_fsr2_amd_vk) +endif() +if(TARGET wowee_fsr3_framegen_amd_vk_probe) + target_link_libraries(wowee_headless PRIVATE wowee_fsr3_framegen_amd_vk_probe) +endif() +if(HAVE_UNICORN) + target_link_libraries(wowee_headless PRIVATE ${UNICORN_LIBRARY}) + target_include_directories(wowee_headless PRIVATE ${UNICORN_INCLUDE_DIR}) + target_compile_definitions(wowee_headless PRIVATE HAVE_UNICORN) +endif() +if(TARGET glm::glm) + target_link_libraries(wowee_headless PRIVATE glm::glm) +elseif(glm_FOUND) + target_include_directories(wowee_headless PRIVATE ${GLM_INCLUDE_DIRS}) +endif() +set_target_properties(wowee_headless PROPERTIES + RUNTIME_OUTPUT_DIRECTORY ${CMAKE_BINARY_DIR}/bin +) +install(TARGETS wowee_headless RUNTIME DESTINATION bin) + # ---- Tool: blp_convert (BLP ↔ PNG) ---- add_executable(blp_convert tools/blp_convert/main.cpp diff --git a/include/game/entity_controller.hpp b/include/game/entity_controller.hpp index a94c40ddb..be5cf7461 100644 --- a/include/game/entity_controller.hpp +++ b/include/game/entity_controller.hpp @@ -35,6 +35,9 @@ class EntityController { void queryPlayerName(uint64_t guid); void queryCreatureInfo(uint32_t entry, uint64_t guid); void queryGameObjectInfo(uint32_t entry, uint64_t guid); + void cachePlayerName(uint64_t guid, const std::string& name) { + if (guid != 0 && !name.empty()) playerNameCache[guid] = name; + } std::string getCachedPlayerName(uint64_t guid) const; std::string getCachedCreatureName(uint32_t entry) const; void invalidatePlayerName(uint64_t guid) { playerNameCache.erase(guid); } diff --git a/include/game/game_handler.hpp b/include/game/game_handler.hpp index abc841dbf..dc30fc312 100644 --- a/include/game/game_handler.hpp +++ b/include/game/game_handler.hpp @@ -644,6 +644,9 @@ class GameHandler : public IConnectionState, void queryPlayerName(uint64_t guid); void queryCreatureInfo(uint32_t entry, uint64_t guid); void queryGameObjectInfo(uint32_t entry, uint64_t guid); + void cachePlayerName(uint64_t guid, const std::string& name) { + entityController_->cachePlayerName(guid, name); + } const GameObjectQueryResponseData* getCachedGameObjectInfo(uint32_t entry) const { return entityController_->getCachedGameObjectInfo(entry); } diff --git a/include/game/game_utils.hpp b/include/game/game_utils.hpp index e0f0ac740..f92cbf60e 100644 --- a/include/game/game_utils.hpp +++ b/include/game/game_utils.hpp @@ -2,11 +2,16 @@ #include "game/expansion_profile.hpp" #include "core/application.hpp" +#include +#include namespace wowee { namespace game { inline bool isActiveExpansion(const char* expansionId) { + if (const char* env = std::getenv("WOWEE_ACTIVE_EXPANSION")) { + if (*env) return std::string(env) == expansionId; + } auto& app = core::Application::getInstance(); auto* registry = app.getExpansionRegistry(); if (!registry) return false; diff --git a/include/game/protocol_constants.hpp b/include/game/protocol_constants.hpp index 8d8e1d030..80effd55e 100644 --- a/include/game/protocol_constants.hpp +++ b/include/game/protocol_constants.hpp @@ -83,7 +83,8 @@ constexpr uint32_t SPELL_SHADOWFORM = 15473; constexpr uint32_t RX_SILENCE_WARNING_MS = 10000; // 10 s constexpr uint32_t RX_SILENCE_CRITICAL_MS = 15000; // 15 s constexpr float WARDEN_GATE_LOG_INTERVAL_SEC = 30.0f; -constexpr float CLASSIC_PING_INTERVAL_SEC = 10.0f; +// CMaNGOS classic/TBC treats pings under ~27s apart as overspeed pings. +constexpr float CLASSIC_PING_INTERVAL_SEC = 30.0f; // --------------------------------------------------------------------------- // Heartbeat / area-trigger intervals (seconds) diff --git a/include/game/world_packets.hpp b/include/game/world_packets.hpp index 6bde25d52..3556b7a63 100644 --- a/include/game/world_packets.hpp +++ b/include/game/world_packets.hpp @@ -1154,8 +1154,11 @@ namespace GuildEvent { constexpr uint8_t LEADER_IS = 6; constexpr uint8_t LEADER_CHANGED = 7; constexpr uint8_t DISBANDED = 8; - constexpr uint8_t SIGNED_ON = 14; - constexpr uint8_t SIGNED_OFF = 15; + constexpr uint8_t TABARD_CHANGED = 9; + constexpr uint8_t SIGNED_ON = 12; + constexpr uint8_t SIGNED_OFF = 13; + constexpr uint8_t GUILD_BANK_BAG_SLOTS_CHANGED = 14; + constexpr uint8_t BANK_TAB_PURCHASED = 15; } /** SMSG_GUILD_QUERY_RESPONSE data */ diff --git a/include/pipeline/asset_manager.hpp b/include/pipeline/asset_manager.hpp index fbcb7bccb..cc6a0bc64 100644 --- a/include/pipeline/asset_manager.hpp +++ b/include/pipeline/asset_manager.hpp @@ -37,6 +37,15 @@ class AssetManager { */ bool initialize(const std::string& dataPath); + /** + * Initialize only enough of the asset manager to load loose DBC files. + * This is intended for headless/tooling paths that do not need a full + * manifest-backed asset extraction. + * @param dataPath Path checked for db/ and DBFilesClient/ DBC files + * @return true if initialization succeeded + */ + bool initializeDbcOnly(const std::string& dataPath); + /** * Shutdown and cleanup */ diff --git a/include/rendering/animation/emote_registry.hpp b/include/rendering/animation/emote_registry.hpp index 2794fb251..af52b3744 100644 --- a/include/rendering/animation/emote_registry.hpp +++ b/include/rendering/animation/emote_registry.hpp @@ -7,6 +7,7 @@ #include namespace wowee { +namespace pipeline { class AssetManager; } namespace rendering { // ============================================================================ @@ -33,6 +34,7 @@ class EmoteRegistry { /// Load emotes from DBC files (called once on first use). void loadFromDbc(); + void loadFromDbc(pipeline::AssetManager* assetManager); struct EmoteResult { uint32_t animId; bool loop; }; diff --git a/src/game/chat_handler.cpp b/src/game/chat_handler.cpp index 60d54fcf9..51ad2d2df 100644 --- a/src/game/chat_handler.cpp +++ b/src/game/chat_handler.cpp @@ -7,14 +7,63 @@ #include "network/world_socket.hpp" #include "rendering/renderer.hpp" #include "rendering/animation_controller.hpp" +#include "rendering/animation/emote_registry.hpp" #include "core/logger.hpp" #include #include +#include #include namespace wowee { namespace game { +namespace { + +bool headlessMode() { + static const bool enabled = []() { +#ifdef WOWEE_HEADLESS_DEFAULT + return true; +#else + const char* raw = std::getenv("WOWEE_HEADLESS"); + return raw && *raw && raw[0] != '0'; +#endif + }(); + return enabled; +} + +bool equalNameIgnoreCase(const std::string& a, const std::string& b) { + return a.size() == b.size() && + std::equal(a.begin(), a.end(), b.begin(), [](unsigned char ca, unsigned char cb) { + return std::tolower(ca) == std::tolower(cb); + }); +} + +uint64_t findGuidByKnownName(GameHandler& owner, const std::string& name) { + if (name.empty()) return 0; + + if (const auto* active = owner.getActiveCharacter()) { + if (equalNameIgnoreCase(active->name, name)) { + return owner.getPlayerGuid(); + } + } + + for (const auto& [guid, cachedName] : owner.getPlayerNameCache()) { + if (equalNameIgnoreCase(cachedName, name)) { + return guid; + } + } + + for (const auto& member : owner.getPartyData().members) { + if (equalNameIgnoreCase(member.name, name)) { + return member.guid; + } + } + + return 0; +} + +} // namespace + ChatHandler::ChatHandler(GameHandler& owner) : owner_(owner) {} @@ -197,6 +246,19 @@ void ChatHandler::handleMessageChat(network::Packet& packet) { " (", getChatTypeString(data.type), ") sender=0x", std::hex, data.senderGuid, std::dec, " '", data.senderName, "' msg='", data.message.substr(0, 60), "'"); + const bool monsterChat = + data.type == ChatType::MONSTER_SAY || + data.type == ChatType::MONSTER_YELL || + data.type == ChatType::MONSTER_EMOTE || + data.type == ChatType::MONSTER_WHISPER || + data.type == ChatType::MONSTER_PARTY || + data.type == ChatType::RAID_BOSS_EMOTE || + data.type == ChatType::RAID_BOSS_WHISPER; + if (monsterChat && data.message.empty()) { + LOG_DEBUG("Skipping empty monster chat packet"); + return; + } + // Skip server echo of our own messages (we already added a local echo) if (data.senderGuid == owner_.getPlayerGuid() && data.senderGuid != 0) { if (data.type == ChatType::WHISPER && !data.senderName.empty()) { @@ -227,7 +289,7 @@ void ChatHandler::handleMessageChat(network::Packet& packet) { } } - if (data.senderName.empty()) { + if (data.senderName.empty() && !headlessMode()) { owner_.queryPlayerName(data.senderGuid); } } @@ -423,7 +485,7 @@ void ChatHandler::sendTextEmote(uint32_t textEmoteId, uint64_t targetGuid) { } void ChatHandler::handleTextEmote(network::Packet& packet) { - const bool legacyFormat = isPreWotlk(); + const bool legacyFormat = isClassicLikeExpansion(); TextEmoteData data; if (!TextEmoteParser::parse(packet, data, legacyFormat)) { LOG_WARNING("Failed to parse SMSG_TEXT_EMOTE"); @@ -447,15 +509,25 @@ void ChatHandler::handleTextEmote(network::Packet& packet) { } if (senderName.empty()) { senderName = "Unknown"; - owner_.queryPlayerName(data.senderGuid); + if (!headlessMode()) { + owner_.queryPlayerName(data.senderGuid); + } } const std::string* targetPtr = data.targetName.empty() ? nullptr : &data.targetName; - std::string emoteText = rendering::AnimationController::getEmoteTextByDbcId(data.textEmoteId, senderName, targetPtr); + std::string emoteText; + uint32_t animId = 0; + if (!headlessMode()) { + emoteText = rendering::AnimationController::getEmoteTextByDbcId(data.textEmoteId, senderName, targetPtr); + animId = rendering::AnimationController::getEmoteAnimByDbcId(data.textEmoteId); + } else { + emoteText = rendering::EmoteRegistry::instance().textByDbcId(data.textEmoteId, senderName, targetPtr); + animId = rendering::EmoteRegistry::instance().animByDbcId(data.textEmoteId); + } if (emoteText.empty()) { emoteText = data.targetName.empty() - ? senderName + " performs an emote." - : senderName + " performs an emote at " + data.targetName + "."; + ? senderName + " performs text emote " + std::to_string(data.textEmoteId) + "." + : senderName + " performs text emote " + std::to_string(data.textEmoteId) + " at " + data.targetName + "."; } MessageChatData chatMsg; @@ -463,16 +535,19 @@ void ChatHandler::handleTextEmote(network::Packet& packet) { chatMsg.language = ChatLanguage::COMMON; chatMsg.senderGuid = data.senderGuid; chatMsg.senderName = senderName; + chatMsg.receiverGuid = findGuidByKnownName(owner_, data.targetName); + chatMsg.receiverName = data.targetName; chatMsg.message = emoteText; + chatMsg.channelName = "TEXT_EMOTE:" + std::to_string(data.textEmoteId); addLocalChatMessage(chatMsg); - uint32_t animId = rendering::AnimationController::getEmoteAnimByDbcId(data.textEmoteId); if (animId != 0 && owner_.emoteAnimCallbackRef()) { owner_.emoteAnimCallbackRef()(data.senderGuid, animId); } - LOG_INFO("TEXT_EMOTE from ", senderName, " (emoteId=", data.textEmoteId, ", anim=", animId, ")"); + LOG_INFO("TEXT_EMOTE from ", senderName, " (emoteId=", data.textEmoteId, + ", emoteNum=", data.emoteNum, ", anim=", animId, ")"); } void ChatHandler::joinChannel(const std::string& channelName, const std::string& password) { diff --git a/src/game/entity_controller.cpp b/src/game/entity_controller.cpp index 1afd10091..42366dac1 100644 --- a/src/game/entity_controller.cpp +++ b/src/game/entity_controller.cpp @@ -44,6 +44,17 @@ bool envFlagEnabled(const char* key, bool defaultValue = false) { raw[0] == 'n' || raw[0] == 'N'); } +bool headlessModeEnabled() { + static const bool enabled = []() { +#ifdef WOWEE_HEADLESS_DEFAULT + return true; +#else + return envFlagEnabled("WOWEE_HEADLESS", false); +#endif + }(); + return enabled; +} + int parseEnvIntClamped(const char* key, int defaultValue, int minValue, int maxValue) { const char* raw = std::getenv(key); if (!raw || !*raw) return defaultValue; @@ -394,6 +405,15 @@ void EntityController::maybeDetectCoinageIndex(const FlatFieldMap& oldFields, // ============================================================ void EntityController::applyUpdateObjectBlock(const UpdateBlock& block, bool& newItemCreated) { + if (headlessModeEnabled() && + block.guid != owner_.getPlayerGuid() && + block.objectType == ObjectType::PLAYER && + (block.updateType == UpdateType::CREATE_OBJECT || + block.updateType == UpdateType::CREATE_OBJECT2)) { + LOG_INFO("Headless skipped remote player create guid=0x", std::hex, block.guid, std::dec); + return; + } + switch (block.updateType) { case UpdateType::CREATE_OBJECT: case UpdateType::CREATE_OBJECT2: @@ -1986,6 +2006,21 @@ void EntityController::handleDestroyObject(network::Packet& packet) { // ============================================================ void EntityController::queryPlayerName(uint64_t guid) { + static const bool headlessAllowRemoteNameQueries = + envFlagEnabled("WOWEE_HEADLESS_QUERY_REMOTE_NAMES", false); + if (headlessModeEnabled() && !headlessAllowRemoteNameQueries && + guid != owner_.getPlayerGuid()) { + LOG_INFO("queryPlayerName: headless skipped remote guid=0x", std::hex, guid, std::dec); + return; + } + + static const bool headlessAllowHighGuidNameQueries = + envFlagEnabled("WOWEE_HEADLESS_QUERY_HIGH_GUIDS", false); + if (headlessModeEnabled() && !headlessAllowHighGuidNameQueries && (guid >> 32) != 0) { + LOG_INFO("queryPlayerName: headless skipped high guid=0x", std::hex, guid, std::dec); + return; + } + // If already cached, apply the name to the entity (handles entity recreation after // moving out/in range — the entity object is new but the cached name is valid). auto cacheIt = playerNameCache.find(guid); @@ -2013,6 +2048,7 @@ void EntityController::queryPlayerName(uint64_t guid) { } void EntityController::queryCreatureInfo(uint32_t entry, uint64_t guid) { + if (headlessModeEnabled()) return; if (creatureInfoCache.count(entry) || pendingCreatureQueries.count(entry)) return; if (!owner_.isInWorld()) return; @@ -2022,6 +2058,7 @@ void EntityController::queryCreatureInfo(uint32_t entry, uint64_t guid) { } void EntityController::queryGameObjectInfo(uint32_t entry, uint64_t guid) { + if (headlessModeEnabled()) return; if (gameObjectInfoCache_.count(entry) || pendingGameObjectQueries_.count(entry)) return; if (!owner_.isInWorld()) return; @@ -2110,6 +2147,11 @@ void EntityController::handleNameQueryResponse(network::Packet& packet) { } void EntityController::handleCreatureQueryResponse(network::Packet& packet) { + if (headlessModeEnabled()) { + packet.skipAll(); + return; + } + CreatureQueryResponseData data; if (!owner_.getPacketParsers()->parseCreatureQueryResponse(packet, data)) return; @@ -2134,6 +2176,11 @@ void EntityController::handleCreatureQueryResponse(network::Packet& packet) { // ============================================================ void EntityController::handleGameObjectQueryResponse(network::Packet& packet) { + if (headlessModeEnabled()) { + packet.skipAll(); + return; + } + GameObjectQueryResponseData data; bool ok = owner_.getPacketParsers() ? owner_.getPacketParsers()->parseGameObjectQueryResponse(packet, data) : GameObjectQueryResponseParser::parse(packet, data); diff --git a/src/game/game_handler.cpp b/src/game/game_handler.cpp index 683b8abc8..9f7537b66 100644 --- a/src/game/game_handler.cpp +++ b/src/game/game_handler.cpp @@ -669,7 +669,11 @@ void GameHandler::updateTimers(float deltaTime) { // Periodically re-query names for players whose initial CMSG_NAME_QUERY was // lost (server didn't respond) or whose entity was recreated while the query // was still pending. Runs every 5 seconds to keep overhead minimal. - if (isInWorld()) { + static const bool headlessMode = []() { + const char* raw = std::getenv("WOWEE_HEADLESS"); + return raw && *raw && raw[0] != '0'; + }(); + if (!headlessMode && isInWorld()) { static float nameResyncTimer = 0.0f; nameResyncTimer += deltaTime; if (nameResyncTimer >= 5.0f) { diff --git a/src/game/game_handler_callbacks.cpp b/src/game/game_handler_callbacks.cpp index 328072296..bdf3089b1 100644 --- a/src/game/game_handler_callbacks.cpp +++ b/src/game/game_handler_callbacks.cpp @@ -422,9 +422,12 @@ void GameHandler::selectCharacter(uint64_t characterGuid) { LOG_INFO("========================================"); LOG_INFO("Character GUID: 0x", std::hex, characterGuid, std::dec); + std::string selectedCharacterName; + // Find character name for logging for (const auto& character : characters) { if (character.guid == characterGuid) { + selectedCharacterName = character.name; LOG_INFO("Character: ", character.name); LOG_INFO("Level ", static_cast(character.level), " ", getRaceName(character.race), " ", @@ -499,6 +502,7 @@ void GameHandler::selectCharacter(uint64_t characterGuid) { lastTargetGuid = 0; tabCycleStale = true; entityController_->clearAll(); + cachePlayerName(characterGuid, selectedCharacterName); // Build CMSG_PLAYER_LOGIN packet auto packet = PlayerLoginPacket::build(characterGuid); @@ -679,8 +683,19 @@ void GameHandler::handleLoginVerifyWorld(network::Packet& packet) { // correctly sets the active spec (static locals don't reset across logins). if (spellHandler_) spellHandler_->resetTalentState(); - // Auto-join default chat channels only on first world entry. - autoJoinDefaultChannels(); + // Auto-join default chat channels only on first world entry. The + // headless client opts into channels from its own settings instead. + const bool headlessMode = []() { +#ifdef WOWEE_HEADLESS_DEFAULT + return true; +#else + const char* raw = std::getenv("WOWEE_HEADLESS"); + return raw && *raw && raw[0] != '0'; +#endif + }(); + if (!headlessMode) { + autoJoinDefaultChannels(); + } // Auto-query guild info on login. const Character* activeChar = getActiveCharacter(); @@ -803,8 +818,8 @@ void GameHandler::sendPing() { // Increment sequence number pingSequence++; - LOG_DEBUG("Sending CMSG_PING: sequence=", pingSequence, - " latencyHintMs=", lastLatency); + LOG_INFO("Sending CMSG_PING: sequence=", pingSequence, + " latencyHintMs=", lastLatency); // Record send time for RTT measurement pingTimestamp_ = std::chrono::steady_clock::now(); @@ -1117,8 +1132,8 @@ void GameHandler::handlePong(network::Packet& packet) { lastLatency = static_cast( std::chrono::duration_cast(rtt).count()); - LOG_DEBUG("SMSG_PONG acknowledged: sequence=", data.sequence, - " latencyMs=", lastLatency); + LOG_INFO("SMSG_PONG acknowledged: sequence=", data.sequence, + " latencyMs=", lastLatency); } bool GameHandler::isServerMovementAllowed() const { diff --git a/src/game/game_handler_packets.cpp b/src/game/game_handler_packets.cpp index a35b64d78..a601ce2d6 100644 --- a/src/game/game_handler_packets.cpp +++ b/src/game/game_handler_packets.cpp @@ -125,6 +125,49 @@ float slowPacketLogThresholdMs() { return static_cast(thresholdMs); } +bool headlessTracePackets() { + static const bool enabled = []() { + const char* raw = std::getenv("WOWEE_HEADLESS_TRACE_PACKETS"); + return raw && *raw && raw[0] != '0'; + }(); + return enabled; +} + +bool headlessMode() { + static const bool enabled = []() { +#ifdef WOWEE_HEADLESS_DEFAULT + return true; +#else + const char* raw = std::getenv("WOWEE_HEADLESS"); + return raw && *raw && raw[0] != '0'; +#endif + }(); + return enabled; +} + +bool shouldSkipHeadlessWorldSimulationPacket(LogicalOpcode op) { + switch (op) { + case Opcode::SMSG_UPDATE_OBJECT: + case Opcode::SMSG_COMPRESSED_UPDATE_OBJECT: + case Opcode::SMSG_DESTROY_OBJECT: + case Opcode::SMSG_MONSTER_MOVE: + case Opcode::MSG_MOVE_HEARTBEAT: + case Opcode::SMSG_EMOTE: + case Opcode::SMSG_SPELL_START: + case Opcode::SMSG_SPELL_GO: + case Opcode::SMSG_SET_EXTRA_AURA_INFO_OBSOLETE: + case Opcode::SMSG_INIT_EXTRA_AURA_INFO_OBSOLETE: + case Opcode::SMSG_SET_EXTRA_AURA_INFO_NEED_UPDATE_OBSOLETE: + case Opcode::SMSG_CREATURE_QUERY_RESPONSE: + case Opcode::SMSG_GAMEOBJECT_QUERY_RESPONSE: + case Opcode::SMSG_ITEM_QUERY_SINGLE_RESPONSE: + case Opcode::SMSG_ITEM_QUERY_MULTIPLE_RESPONSE: + return true; + default: + return false; + } +} + constexpr size_t kMaxQueuedInboundPackets = 4096; } // end anonymous namespace @@ -2820,9 +2863,28 @@ void GameHandler::handlePacket(network::Packet& packet) { } // Dispatch via the opcode handler table + if (headlessMode() && state == WorldState::IN_WORLD && + shouldSkipHeadlessWorldSimulationPacket(*logicalOp)) { + packet.skipAll(); + return; + } + auto it = dispatchTable_.find(*logicalOp); if (it != dispatchTable_.end()) { + if (headlessTracePackets()) { + LOG_INFO("HEADLESS DISPATCH begin wire=0x", std::hex, opcode, std::dec, + " logical=", OpcodeTable::logicalToName(*logicalOp), + " size=", packet.getSize(), + " readPos=", packet.getReadPos(), + " state=", worldStateName(state)); + } it->second(packet); + if (headlessTracePackets()) { + LOG_INFO("HEADLESS DISPATCH end wire=0x", std::hex, opcode, std::dec, + " logical=", OpcodeTable::logicalToName(*logicalOp), + " readPos=", packet.getReadPos(), "/", packet.getSize(), + " state=", worldStateName(state)); + } } else { // In pre-world states we need full visibility (char create/login handshakes). // In-world we keep de-duplication to avoid heavy log I/O in busy areas. diff --git a/src/game/inventory_handler.cpp b/src/game/inventory_handler.cpp index 14bf9b820..2911c171e 100644 --- a/src/game/inventory_handler.cpp +++ b/src/game/inventory_handler.cpp @@ -16,11 +16,28 @@ #include #include #include +#include #include namespace wowee { namespace game { +namespace { + +bool headlessMode() { + static const bool enabled = []() { +#ifdef WOWEE_HEADLESS_DEFAULT + return true; +#else + const char* raw = std::getenv("WOWEE_HEADLESS"); + return raw && *raw && raw[0] != '0'; +#endif + }(); + return enabled; +} + +} // namespace + std::string formatCopperAmount(uint32_t amount); InventoryHandler::InventoryHandler(GameHandler& owner) @@ -2495,6 +2512,7 @@ void InventoryHandler::handleEquipmentSetList(network::Packet& packet) { // ============================================================ void InventoryHandler::queryItemInfo(uint32_t entry, uint64_t guid) { + if (headlessMode()) return; if (owner_.itemInfoCacheRef().count(entry) || owner_.pendingItemQueriesRef().count(entry)) return; if (!owner_.isInWorld()) return; @@ -2512,6 +2530,11 @@ void InventoryHandler::queryItemInfo(uint32_t entry, uint64_t guid) { } void InventoryHandler::handleItemQueryResponse(network::Packet& packet) { + if (headlessMode()) { + packet.skipAll(); + return; + } + ItemQueryResponseData data; bool parsed = owner_.getPacketParsers() ? owner_.getPacketParsers()->parseItemQueryResponse(packet, data) diff --git a/src/game/packet_parsers_tbc.cpp b/src/game/packet_parsers_tbc.cpp index fc5f63a5c..85f015397 100644 --- a/src/game/packet_parsers_tbc.cpp +++ b/src/game/packet_parsers_tbc.cpp @@ -2,9 +2,91 @@ #include "game/spline_packet.hpp" #include "core/logger.hpp" +#include +#include + namespace wowee { namespace game { +namespace { + +bool looksLikeChatText(const std::vector& bytes, size_t offset, uint32_t len) { + if (len == 0 || len >= 8192) return false; + if (offset + 4 + len > bytes.size()) return false; + + const size_t textStart = offset + 4; + const size_t textEnd = textStart + len; + size_t printable = 0; + for (size_t i = textStart; i < textEnd; ++i) { + const uint8_t c = bytes[i]; + if (c == 0) { + return i + 1 == textEnd; + } + if (c == '\t' || c == '\n' || c == '\r' || (c >= 0x20 && c < 0x7f) || c >= 0x80) { + ++printable; + continue; + } + return false; + } + return printable > 0; +} + +bool recoverTbcChatTail(network::Packet& packet, MessageChatData& data) { + const auto& bytes = packet.getData(); + for (size_t pos = 5; pos + 5 <= bytes.size(); ++pos) { + const uint32_t len = + static_cast(bytes[pos]) | + (static_cast(bytes[pos + 1]) << 8) | + (static_cast(bytes[pos + 2]) << 16) | + (static_cast(bytes[pos + 3]) << 24); + if (!looksLikeChatText(bytes, pos, len)) continue; + + std::string recovered; + recovered.reserve(len); + const size_t textStart = pos + 4; + const size_t textEnd = textStart + len; + for (size_t i = textStart; i < textEnd; ++i) { + if (bytes[i] == 0) break; + recovered.push_back(static_cast(bytes[i])); + } + if (recovered.empty()) continue; + + data.message = std::move(recovered); + const size_t tagPos = textStart + len; + if (tagPos < bytes.size()) { + data.chatTag = bytes[tagPos]; + } + packet.setReadPos(std::min(bytes.size(), tagPos + 1)); + LOG_INFO("[TBC] Recovered SMSG_MESSAGECHAT text at offset ", pos, + " type=", getChatTypeString(data.type), + " senderGuid=0x", std::hex, data.senderGuid, std::dec); + return true; + } + return false; +} + +bool canRecoverTbcChatTail(ChatType type) { + switch (type) { + case ChatType::SYSTEM: + case ChatType::SAY: + case ChatType::PARTY: + case ChatType::YELL: + case ChatType::WHISPER: + case ChatType::WHISPER_INFORM: + case ChatType::GUILD: + case ChatType::OFFICER: + case ChatType::RAID: + case ChatType::RAID_LEADER: + case ChatType::RAID_WARNING: + case ChatType::CHANNEL: + return true; + default: + return false; + } +} + +} // namespace + // ============================================================================ // TBC 2.4.3 movement flag constants (shifted relative to WotLK 3.3.5a) // ============================================================================ @@ -1656,7 +1738,12 @@ bool TbcPacketParsers::parseMessageChat(network::Packet& packet, MessageChatData uint32_t langVal = packet.readUInt32(); data.language = static_cast(langVal); - // TBC: NO senderGuid or unknown field here (WotLK has senderGuid(u64) + unk(u32)) + if (!packet.hasRemaining(12)) { + LOG_ERROR("[TBC] SMSG_MESSAGECHAT missing sender header: ", packet.getSize(), " bytes"); + return false; + } + data.senderGuid = packet.readUInt64(); + /*uint32_t chatGroup =*/ packet.readUInt32(); switch (data.type) { case ChatType::MONSTER_SAY: @@ -1666,7 +1753,6 @@ bool TbcPacketParsers::parseMessageChat(network::Packet& packet, MessageChatData case ChatType::MONSTER_PARTY: case ChatType::RAID_BOSS_EMOTE: { // senderGuid(u64) + nameLen(u32) + name + targetGuid(u64) - data.senderGuid = packet.readUInt64(); uint32_t nameLen = packet.readUInt32(); if (nameLen > 0 && nameLen < 256) { data.senderName.resize(nameLen); @@ -1694,23 +1780,20 @@ bool TbcPacketParsers::parseMessageChat(network::Packet& packet, MessageChatData case ChatType::EMOTE: case ChatType::TEXT_EMOTE: { // senderGuid(u64) + senderGuid(u64) — written twice by server - data.senderGuid = packet.readUInt64(); - /*duplicateGuid*/ packet.readUInt64(); + data.receiverGuid = packet.readUInt64(); break; } case ChatType::CHANNEL: { // channelName(string) + rank(u32) + senderGuid(u64) data.channelName = packet.readString(); - /*uint32_t rank =*/ packet.readUInt32(); - data.senderGuid = packet.readUInt64(); + data.receiverGuid = packet.readUInt64(); break; } default: { // All other types: senderGuid(u64) + senderGuid(u64) — written twice - data.senderGuid = packet.readUInt64(); - /*duplicateGuid*/ packet.readUInt64(); + data.receiverGuid = packet.readUInt64(); break; } } @@ -1732,6 +1815,10 @@ bool TbcPacketParsers::parseMessageChat(network::Packet& packet, MessageChatData data.chatTag = packet.readUInt8(); } + if (data.message.empty() && canRecoverTbcChatTail(data.type)) { + recoverTbcChatTail(packet, data); + } + LOG_DEBUG("[TBC] SMSG_MESSAGECHAT: type=", getChatTypeString(data.type), " sender=", data.senderName.empty() ? std::to_string(data.senderGuid) : data.senderName); diff --git a/src/game/social_handler.cpp b/src/game/social_handler.cpp index 6cc40b928..23cd7d1bf 100644 --- a/src/game/social_handler.cpp +++ b/src/game/social_handler.cpp @@ -36,9 +36,39 @@ std::filesystem::path guildNameCachePath() { return std::filesystem::path("guild_names.tsv"); } -} // namespace - +bool headlessMode() { + static const bool enabled = []() { +#ifdef WOWEE_HEADLESS_DEFAULT + return true; +#else + const char* raw = std::getenv("WOWEE_HEADLESS"); + return raw && *raw && raw[0] != '0'; +#endif + }(); + return enabled; +} + +std::string guildEventName(uint8_t eventType) { + switch (eventType) { + case GuildEvent::PROMOTION: return "PROMOTION"; + case GuildEvent::DEMOTION: return "DEMOTION"; + case GuildEvent::MOTD: return "MOTD"; + case GuildEvent::JOINED: return "JOINED"; + case GuildEvent::LEFT: return "LEFT"; + case GuildEvent::REMOVED: return "REMOVED"; + case GuildEvent::LEADER_IS: return "LEADER_IS"; + case GuildEvent::LEADER_CHANGED: return "LEADER_CHANGED"; + case GuildEvent::DISBANDED: return "DISBANDED"; + case GuildEvent::TABARD_CHANGED: return "TABARD_CHANGED"; + case GuildEvent::SIGNED_ON: return "SIGNED_ON"; + case GuildEvent::SIGNED_OFF: return "SIGNED_OFF"; + case GuildEvent::GUILD_BANK_BAG_SLOTS_CHANGED: return "GUILD_BANK_BAG_SLOTS_CHANGED"; + case GuildEvent::BANK_TAB_PURCHASED: return "BANK_TAB_PURCHASED"; + default: return "EVENT_" + std::to_string(eventType); + } +} +} // namespace // LFG join result codes from LFGJoinResult enum (WotLK 3.3.5a). @@ -1323,9 +1353,13 @@ void SocialHandler::handleGroupList(network::Packet& packet) { partyData = GroupListData{}; if (!GroupListParser::parse(packet, partyData, hasRoles, hasBattleGroupFlag)) return; + for (const auto& member : partyData.members) { + owner_.cachePlayerName(member.guid, member.name); + } + const bool nowInGroup = !partyData.isEmpty(); if (!nowInGroup && wasInGroup) { - owner_.addSystemChatMessage("You are no longer in a group."); + if (!headlessMode()) owner_.addSystemChatMessage("You are no longer in a group."); LOG_INFO("Left group"); } else if (nowInGroup && !wasInGroup) { std::string members; @@ -1333,8 +1367,8 @@ void SocialHandler::handleGroupList(network::Packet& packet) { if (!members.empty()) members += ", "; members += m.name.empty() ? "?" : m.name; } - owner_.addSystemChatMessage("You joined a group with: " + members); - LOG_INFO("Joined group with ", partyData.memberCount, " members: ", members); + if (!headlessMode()) owner_.addSystemChatMessage("You joined a group with: " + members); + LOG_INFO("Joined group with ", partyData.members.size(), " parsed members: ", members); } // Loot method change notification if (wasInGroup && nowInGroup && partyData.lootMethod != prevLootMethod) { @@ -1342,9 +1376,9 @@ void SocialHandler::handleGroupList(network::Packet& packet) { "Free for All", "Round Robin", "Master Looter", "Group Loot", "Need Before Greed" }; const char* methodName = (partyData.lootMethod < 5) ? kLootMethods[partyData.lootMethod] : "Unknown"; - owner_.addSystemChatMessage(std::string("Loot method changed to ") + methodName + "."); + if (!headlessMode()) owner_.addSystemChatMessage(std::string("Loot method changed to ") + methodName + "."); } - if (owner_.addonEventCallbackRef()) { + if (!headlessMode() && owner_.addonEventCallbackRef()) { owner_.addonEventCallbackRef()("GROUP_ROSTER_UPDATE", {}); owner_.addonEventCallbackRef()("PARTY_MEMBERS_CHANGED", {}); if (partyData.groupType == 1) @@ -1606,16 +1640,29 @@ void SocialHandler::handleGuildEvent(network::Packet& packet) { hasGuildRoster_ = false; if (owner_.addonEventCallbackRef()) owner_.addonEventCallbackRef()("PLAYER_GUILD_UPDATE", {}); break; + case GuildEvent::TABARD_CHANGED: + msg = "Guild tabard changed."; + break; case GuildEvent::SIGNED_ON: if (data.numStrings >= 1) msg = "[Guild] " + data.strings[0] + " has come online."; break; case GuildEvent::SIGNED_OFF: if (data.numStrings >= 1) msg = "[Guild] " + data.strings[0] + " has gone offline."; break; + case GuildEvent::GUILD_BANK_BAG_SLOTS_CHANGED: + msg = "Guild bank bag slots changed."; + break; + case GuildEvent::BANK_TAB_PURCHASED: + msg = "Guild bank tab purchased."; + break; default: msg = "Guild event " + std::to_string(data.eventType); // Was `!numStrings && numStrings >= 1` — always false (0 can't be ≥1). - if (data.numStrings >= 1) msg += ": " + data.strings[0]; + for (uint8_t i = 0; i < data.numStrings && i < 3; ++i) { + if (i == 0) msg += ": "; + else msg += " | "; + msg += data.strings[i]; + } break; } @@ -1624,6 +1671,7 @@ void SocialHandler::handleGuildEvent(network::Packet& packet) { chatMsg.type = ChatType::GUILD; chatMsg.language = ChatLanguage::UNIVERSAL; chatMsg.message = msg; + chatMsg.channelName = guildEventName(data.eventType); owner_.addLocalChatMessage(chatMsg); } diff --git a/src/game/spell_handler.cpp b/src/game/spell_handler.cpp index 09d26a3be..dbd17108e 100644 --- a/src/game/spell_handler.cpp +++ b/src/game/spell_handler.cpp @@ -18,6 +18,7 @@ #include #include #include +#include #include namespace wowee { @@ -113,6 +114,14 @@ void SpellHandler::playSpellImpactSound(uint32_t spellId) { // ---- Spell visual effect helpers ---- +static bool headlessMode() { + static const bool enabled = []() { + const char* raw = std::getenv("WOWEE_HEADLESS"); + return raw && *raw && raw[0] != '0'; + }(); + return enabled; +} + uint32_t SpellHandler::resolveSpellVisualId(uint32_t spellId) { owner_.loadSpellNameCache(); auto it = owner_.spellNameCacheRef().find(spellId); @@ -134,6 +143,7 @@ bool SpellHandler::resolveUnitPosition(uint64_t guid, glm::vec3& outPos) { } void SpellHandler::triggerCastVisual(uint32_t spellId, uint64_t casterGuid, uint32_t castTimeMs) { + if (headlessMode()) return; LOG_INFO("SpellVisual: triggerCastVisual spellId=", spellId, " casterGuid=0x", std::hex, casterGuid, std::dec); auto* renderer = owner_.services().renderer; if (!renderer) { LOG_WARNING("SpellVisual: triggerCastVisual — no renderer"); return; } @@ -148,6 +158,7 @@ void SpellHandler::triggerCastVisual(uint32_t spellId, uint64_t casterGuid, uint } void SpellHandler::triggerImpactVisual(uint32_t spellId, uint64_t targetGuid) { + if (headlessMode()) return; LOG_INFO("SpellVisual: triggerImpactVisual spellId=", spellId, " targetGuid=0x", std::hex, targetGuid, std::dec); auto* renderer = owner_.services().renderer; if (!renderer) return; diff --git a/src/game/world_packets_social.cpp b/src/game/world_packets_social.cpp index 7dbd8d492..e512d6fe2 100644 --- a/src/game/world_packets_social.cpp +++ b/src/game/world_packets_social.cpp @@ -11,6 +11,7 @@ #include #include #include +#include #include namespace wowee { @@ -337,7 +338,19 @@ bool TextEmoteParser::parse(network::Packet& packet, TextEmoteData& data, bool l uint32_t nameLen = packet.readUInt32(); if (nameLen > 0 && nameLen <= 256) { - data.targetName = packet.readString(); + if (!packet.hasRemaining(nameLen)) { + LOG_WARNING("SMSG_TEXT_EMOTE target name truncated: len=", nameLen, + " remaining=", packet.getRemainingSize()); + return false; + } + std::string targetName; + targetName.reserve(nameLen); + for (uint32_t i = 0; i < nameLen; ++i) { + const uint8_t c = packet.readUInt8(); + if (c == 0) break; + targetName.push_back(static_cast(c)); + } + data.targetName = std::move(targetName); } else if (nameLen > 0) { // Implausible name length — misaligned read return false; diff --git a/src/network/world_socket.cpp b/src/network/world_socket.cpp index 1971b105b..5afbe3722 100644 --- a/src/network/world_socket.cpp +++ b/src/network/world_socket.cpp @@ -33,6 +33,19 @@ constexpr size_t kRecentPacketHistoryLimit = 96; constexpr auto kRecentPacketHistoryWindow = std::chrono::seconds(15); constexpr const char* kCloseTraceEnv = "WOWEE_NET_CLOSE_TRACE"; +inline int headerTracePacketCount() { + static int count = []() { + const char* raw = std::getenv("WOWEE_NET_HEADER_TRACE_PACKETS"); + if (!raw || !*raw) return 0; + char* end = nullptr; + long parsed = std::strtol(raw, &end, 10); + if (end == raw || parsed <= 0) return 0; + if (parsed > 256) return 256; + return static_cast(parsed); + }(); + return count; +} + inline int parsedPacketsBudgetPerUpdate() { static int budget = []() { const char* raw = std::getenv("WOWEE_NET_MAX_PARSED_PACKETS"); @@ -893,7 +906,7 @@ void WorldSocket::initEncryption(const std::vector& sessionKey, uint32_ } encryptionEnabled = true; - headerTracePacketsLeft = 24; + headerTracePacketsLeft = headerTracePacketCount(); LOG_INFO("World server encryption initialized successfully"); } diff --git a/src/pipeline/asset_manager.cpp b/src/pipeline/asset_manager.cpp index d3cdef8ef..b197ce652 100644 --- a/src/pipeline/asset_manager.cpp +++ b/src/pipeline/asset_manager.cpp @@ -85,6 +85,22 @@ bool AssetManager::initialize(const std::string& dataPath_) { return true; } +bool AssetManager::initializeDbcOnly(const std::string& dataPath_) { + if (initialized) { + LOG_WARNING("AssetManager already initialized"); + return true; + } + + dataPath = dataPath_; + overridePath_ = dataPath + "/override"; + LOG_WARNING("Initializing asset manager in DBC-only mode with data path: ", dataPath); + + setupFileCacheBudget(); + + initialized = true; + return true; +} + void AssetManager::setupFileCacheBudget() { auto& memMonitor = core::MemoryMonitor::getInstance(); size_t recommendedBudget = memMonitor.getRecommendedCacheBudget(); @@ -339,7 +355,10 @@ std::shared_ptr AssetManager::loadDBC(const std::string& name) { if (dbcData.empty()) { // dataPath is expansion-specific (e.g. Data/expansions/wotlk/); go up to Data/ for (const std::string& base : {dataPath + "/db/" + name, + dataPath + "/DBFilesClient/" + name, dataPath + "/../../db/" + name, + dataPath + "/../../DBFilesClient/" + name, + "Data/DBFilesClient/" + name, "Data/db/" + name}) { if (std::filesystem::exists(base)) { std::ifstream f(base, std::ios::binary | std::ios::ate); diff --git a/src/rendering/animation/emote_registry.cpp b/src/rendering/animation/emote_registry.cpp index 3e743bad8..2ced2e1fc 100644 --- a/src/rendering/animation/emote_registry.cpp +++ b/src/rendering/animation/emote_registry.cpp @@ -78,10 +78,13 @@ EmoteRegistry& EmoteRegistry::instance() { } void EmoteRegistry::loadFromDbc() { + loadFromDbc(core::Application::getInstance().getAssetManager()); +} + +void EmoteRegistry::loadFromDbc(pipeline::AssetManager* assetManager) { if (loaded_) return; loaded_ = true; - auto* assetManager = core::Application::getInstance().getAssetManager(); if (!assetManager) { LOG_WARNING("Emotes: no AssetManager"); loadFallbackEmotes(); diff --git a/tools/headless_client/README.md b/tools/headless_client/README.md new file mode 100644 index 000000000..a3c7e3049 --- /dev/null +++ b/tools/headless_client/README.md @@ -0,0 +1,91 @@ +# WoWee Headless Client + +`wowee_headless` is a minimal terminal/world client for automation. It logs into a WoW-compatible auth server, selects a realm and character, enters the world, then exposes a small localhost HTTP API for chat. + +## Settings + +Copy `settings.example.json` to `settings.json` and fill in: + +- `auth.host`, `auth.port`, `auth.account`, `auth.password` +- `client.*` for the client version/build. WotLK 3.3.5a defaults are already set. +- `client.expansion`: `classic`, `tbc`, `wotlk`, or `turtle` +- `realm.name` or `realm.index` +- `character.name` +- `bots.names` for automatic `.bot add ` commands after world entry +- `automation.onEnterWorldCommands` for arbitrary chat/GM commands after world entry +- `api.bind` and `api.port` + +Run: + +```bash +wowee_headless tools/headless_client/settings.json +``` + +While running, press `Esc` to request an in-game logout and exit after the server responds. Press `Ctrl-C` to quit the process immediately. + +Example bot startup: + +```json +{ + "bots": { + "enabled": true, + "names": ["Soulweaver", "Leatherfang"] + }, + "automation": { + "commandDelaySeconds": 0.25, + "onEnterWorldCommands": [".bot add Wildbrew"] + } +} +``` + +Both sections are optional. `bots.names` expands to `.bot add `, while `automation.onEnterWorldCommands` sends each command exactly as written. + +## Emote Text + +For friendlier `TEXT_EMOTE` messages, the headless client can load emote strings from extracted client DBC files. Provide these files in either `Data/DBFilesClient/` or `Data/db/`: + +- `EmotesText.dbc` +- `EmotesTextData.dbc` +- `Emotes.dbc` + +If `Data/manifest.json` exists from `asset_extract`, the normal asset manifest is used. If not, `wowee_headless` falls back to a DBC-only mode and still checks the loose DBC paths above. Without these files, it uses a small built-in fallback table. + +## API + +Status: + +```bash +curl http://127.0.0.1:8787/status +``` + +Read chat: + +```bash +curl "http://127.0.0.1:8787/chat?after=0&limit=50" +``` + +Send say: + +```bash +curl -X POST http://127.0.0.1:8787/chat \ + -H "Content-Type: application/json" \ + -d "{\"type\":\"say\",\"message\":\"hello from automation\"}" +``` + +Send whisper: + +```bash +curl -X POST http://127.0.0.1:8787/chat \ + -H "Content-Type: application/json" \ + -d "{\"type\":\"whisper\",\"target\":\"Playername\",\"message\":\"hello\"}" +``` + +Send channel: + +```bash +curl -X POST http://127.0.0.1:8787/chat \ + -H "Content-Type: application/json" \ + -d "{\"type\":\"channel\",\"target\":\"world\",\"message\":\"hello\"}" +``` + +Supported send types: `say`, `yell`, `whisper`, `channel`, `party`, `guild`, `raid`, `officer`. diff --git a/tools/headless_client/main.cpp b/tools/headless_client/main.cpp new file mode 100644 index 000000000..4402daede --- /dev/null +++ b/tools/headless_client/main.cpp @@ -0,0 +1,840 @@ +#include "auth/auth_handler.hpp" +#include "auth/auth_packets.hpp" +#include "game/game_handler.hpp" +#include "game/game_services.hpp" +#include "game/packet_parsers.hpp" +#include "game/world_packets.hpp" +#include "network/net_platform.hpp" +#include "pipeline/asset_manager.hpp" +#include "pipeline/dbc_layout.hpp" +#include "rendering/animation/emote_registry.hpp" +#include "core/logger.hpp" + +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#ifdef _WIN32 +#include +#endif + +using json = nlohmann::json; +using namespace wowee; + +namespace { + +std::atomic g_running{true}; + +constexpr int kEscKey = 27; +constexpr auto kLogoutSilentExitTimeout = std::chrono::seconds(12); + +bool consumeEscKeypress() { +#ifdef _WIN32 + while (_kbhit()) { + const int ch = _getch(); + if (ch == kEscKey) return true; + if (ch == 0 || ch == 0xE0) { + if (_kbhit()) (void)_getch(); + } + } +#endif + return false; +} + +void setDefaultEnv(const char* key, const char* value) { + if (std::getenv(key)) return; +#ifdef _WIN32 + _putenv_s(key, value); +#else + setenv(key, value, 0); +#endif +} + +struct Settings { + std::string authHost = "127.0.0.1"; + uint16_t authPort = 3724; + std::string account; + std::string password; + + auth::ClientInfo clientInfo; + std::string expansion = "wotlk"; + + std::string realmName; + size_t realmIndex = 0; + uint32_t realmId = 0; + std::string characterName; + + bool autoJoinDefaultChannels = false; + bool apiEnabled = true; + std::string apiBind = "127.0.0.1"; + uint16_t apiPort = 8787; + size_t apiMaxMessages = 200; + + std::vector onEnterWorldCommands; + float onEnterCommandDelaySeconds = 0.25f; +}; + +struct PendingChat { + game::ChatType type = game::ChatType::SAY; + std::string message; + std::string target; +}; + +std::string lowerAscii(std::string value) { + std::transform(value.begin(), value.end(), value.begin(), + [](unsigned char c) { return static_cast(std::tolower(c)); }); + return value; +} + +std::string trim(std::string value) { + auto isSpace = [](unsigned char c) { return std::isspace(c) != 0; }; + value.erase(value.begin(), std::find_if(value.begin(), value.end(), [&](char c) { return !isSpace(c); })); + value.erase(std::find_if(value.rbegin(), value.rend(), [&](char c) { return !isSpace(c); }).base(), value.end()); + return value; +} + +std::string detectExpansion(uint32_t build) { + if (build <= 5875) return "classic"; + if (build <= 8606) return "tbc"; + return "wotlk"; +} + +template +T jsonValue(const json& obj, const char* key, T fallback) { + if (!obj.is_object() || !obj.contains(key) || obj.at(key).is_null()) return fallback; + return obj.at(key).get(); +} + +Settings loadSettings(const std::string& path) { + std::ifstream in(path); + if (!in) { + throw std::runtime_error("Cannot open settings file: " + path); + } + + json doc = json::parse(in); + Settings s; + + const auto authObj = doc.value("auth", json::object()); + s.authHost = jsonValue(authObj, "host", s.authHost); + s.authPort = static_cast(jsonValue(authObj, "port", s.authPort)); + s.account = jsonValue(authObj, "account", s.account); + s.password = jsonValue(authObj, "password", s.password); + + const auto clientObj = doc.value("client", json::object()); + s.clientInfo.majorVersion = static_cast(jsonValue(clientObj, "major", 3)); + s.clientInfo.minorVersion = static_cast(jsonValue(clientObj, "minor", 3)); + s.clientInfo.patchVersion = static_cast(jsonValue(clientObj, "patch", 5)); + s.clientInfo.build = static_cast(jsonValue(clientObj, "build", 12340)); + s.clientInfo.protocolVersion = static_cast(jsonValue(clientObj, "protocol", 8)); + s.clientInfo.locale = jsonValue(clientObj, "locale", "enUS"); + s.clientInfo.platform = jsonValue(clientObj, "platform", "x86"); + s.clientInfo.os = jsonValue(clientObj, "os", "Win"); + s.expansion = jsonValue(clientObj, "expansion", detectExpansion(s.clientInfo.build)); + + const auto realmObj = doc.value("realm", json::object()); + s.realmName = jsonValue(realmObj, "name", ""); + s.realmIndex = static_cast(std::max(0, jsonValue(realmObj, "index", 0))); + s.realmId = static_cast(std::max(0, jsonValue(realmObj, "id", 0))); + + const auto characterObj = doc.value("character", json::object()); + s.characterName = jsonValue(characterObj, "name", ""); + + const auto chatObj = doc.value("chat", json::object()); + s.autoJoinDefaultChannels = jsonValue(chatObj, "autoJoinDefaultChannels", false); + + const auto apiObj = doc.value("api", json::object()); + s.apiEnabled = jsonValue(apiObj, "enabled", true); + s.apiBind = jsonValue(apiObj, "bind", "127.0.0.1"); + s.apiPort = static_cast(jsonValue(apiObj, "port", 8787)); + s.apiMaxMessages = static_cast(std::max(1, jsonValue(apiObj, "maxMessages", 200))); + + if (s.account.empty()) throw std::runtime_error("settings auth.account is required"); + if (s.password.empty()) throw std::runtime_error("settings auth.password is required"); + + const auto botsObj = doc.value("bots", json::object()); + if (botsObj.is_object() && botsObj.value("enabled", false)) { + const auto names = botsObj.value("names", json::array()); + if (!names.is_array()) { + throw std::runtime_error("settings bots.names must be an array"); + } + for (const auto& nameValue : names) { + if (!nameValue.is_string()) continue; + const std::string name = trim(nameValue.get()); + if (!name.empty()) { + s.onEnterWorldCommands.push_back(".bot add " + name); + } + } + } + + const auto automationObj = doc.value("automation", json::object()); + if (automationObj.is_object()) { + s.onEnterCommandDelaySeconds = std::max(0.0f, + jsonValue(automationObj, "commandDelaySeconds", s.onEnterCommandDelaySeconds)); + const auto commands = automationObj.value("onEnterWorldCommands", json::array()); + if (!commands.is_array()) { + throw std::runtime_error("settings automation.onEnterWorldCommands must be an array"); + } + for (const auto& commandValue : commands) { + if (!commandValue.is_string()) continue; + const std::string command = trim(commandValue.get()); + if (!command.empty()) { + s.onEnterWorldCommands.push_back(command); + } + } + } + + return s; +} + +bool splitHostPort(const std::string& address, std::string& host, uint16_t& port) { + const size_t pos = address.rfind(':'); + if (pos == std::string::npos) return false; + host = address.substr(0, pos); + port = static_cast(std::stoi(address.substr(pos + 1))); + return !host.empty() && port != 0; +} + +std::optional chatTypeFromString(const std::string& value) { + const std::string t = lowerAscii(value); + if (t == "say") return game::ChatType::SAY; + if (t == "yell") return game::ChatType::YELL; + if (t == "whisper" || t == "w") return game::ChatType::WHISPER; + if (t == "channel") return game::ChatType::CHANNEL; + if (t == "party") return game::ChatType::PARTY; + if (t == "guild") return game::ChatType::GUILD; + if (t == "raid") return game::ChatType::RAID; + if (t == "officer") return game::ChatType::OFFICER; + return std::nullopt; +} + +std::string chatTypeName(game::ChatType type) { + return game::getChatTypeString(type); +} + +std::string timeToIso(std::chrono::system_clock::time_point tp) { + auto time = std::chrono::system_clock::to_time_t(tp); + std::tm tm{}; +#ifdef _WIN32 + gmtime_s(&tm, &time); +#else + gmtime_r(&time, &tm); +#endif + char buf[32]{}; + std::strftime(buf, sizeof(buf), "%Y-%m-%dT%H:%M:%SZ", &tm); + return buf; +} + +json chatToJson(const game::MessageChatData& msg, size_t id) { + return { + {"id", id}, + {"type", chatTypeName(msg.type)}, + {"from", msg.senderName}, + {"fromGuid", msg.senderGuid}, + {"to", msg.receiverName}, + {"toGuid", msg.receiverGuid}, + {"channel", msg.channelName}, + {"message", msg.message}, + {"timestamp", timeToIso(msg.timestamp)} + }; +} + +class HeadlessSession { +public: + explicit HeadlessSession(Settings settings) + : settings_(std::move(settings)), game_(services_) { + configureProtocol(); + } + + bool start() { + auth_.setClientInfo(settings_.clientInfo); + auth_.setOnSuccess([this](const std::vector&) { + std::cout << "Auth succeeded; requesting realms\n"; + auth_.requestRealmList(); + }); + auth_.setOnFailure([this](const std::string& reason) { + fail("Auth failed: " + reason); + }); + auth_.setOnRealmList([this](const std::vector& realms) { + onRealmList(realms); + }); + + game_.setOnFailure([this](const std::string& reason) { + fail("World failed: " + reason); + }); + + if (!auth_.connect(settings_.authHost, settings_.authPort)) { + fail("Could not connect to auth server"); + return false; + } + auth_.authenticate(settings_.account, settings_.password); + return true; + } + + void update(float deltaSeconds) { + auth_.update(deltaSeconds); + const auto beforeWorldState = game_.getState(); + game_.update(deltaSeconds); + const auto afterWorldState = game_.getState(); + if (afterWorldState != beforeWorldState) { + std::cout << "World state changed: " + << static_cast(beforeWorldState) << " -> " + << static_cast(afterWorldState) << "\n"; + } + inWorldForApi_ = (game_.getState() == game::WorldState::IN_WORLD); + + if (!selectedCharacter_ && game_.getState() == game::WorldState::CHAR_LIST_RECEIVED) { + selectConfiguredCharacter(); + } + + if (!enteredWorld_ && game_.getState() == game::WorldState::IN_WORLD) { + enteredWorld_ = true; + inWorldForApi_ = true; + std::cout << "Entered world"; + if (!settings_.characterName.empty()) std::cout << " as " << settings_.characterName; + std::cout << "\n"; + if (settings_.autoJoinDefaultChannels) { + game_.autoJoinDefaultChannels(); + } + queueOnEnterWorldCommands(); + } + + if (!logoutRequested_) { + drainScheduledCommands(deltaSeconds); + drainPendingChat(); + } + syncChatSnapshot(); + updateLogoutExit(); + } + + bool isFailed() const { return failed_.load(); } + bool isInWorld() const { return inWorldForApi_.load(); } + bool isLogoutRequested() const { return logoutRequested_.load(); } + std::string status() const { + if (failed_) { + std::lock_guard lock(stateMutex_); + return "failed: " + failureReason_; + } + if (logoutRequested_) return "logging_out"; + if (isInWorld()) return "in_world"; + return "connecting"; + } + + void requestGracefulLogout() { + if (logoutRequested_.exchange(true)) return; + if (game_.getState() == game::WorldState::IN_WORLD) { + std::cout << "Esc pressed; requesting logout. Press Ctrl-C to quit immediately.\n"; + game_.requestLogout(); + logoutRequestedAt_ = std::chrono::steady_clock::now(); + } else { + std::cout << "Esc pressed before entering world; exiting.\n"; + g_running = false; + } + } + + void enqueueChat(PendingChat msg) { + std::lock_guard lock(pendingMutex_); + pendingChat_.push_back(std::move(msg)); + } + + json readChat(size_t afterId, size_t limit) { + std::lock_guard lock(chatMutex_); + json arr = json::array(); + for (size_t i = 0; i < chatMirror_.size(); ++i) { + const size_t id = chatBaseId_ + i; + if (id <= afterId) continue; + arr.push_back(chatToJson(resolveChatSender(chatMirror_[i]), id)); + if (arr.size() >= limit) break; + } + return { + {"status", status()}, + {"inWorld", isInWorld()}, + {"nextId", chatBaseId_ + chatMirror_.size()}, + {"messages", arr} + }; + } + +private: + game::MessageChatData resolveChatSender(const game::MessageChatData& msg) const { + if (!msg.senderName.empty() || msg.senderGuid == 0) return msg; + + game::MessageChatData resolved = msg; + const auto& cache = game_.getPlayerNameCache(); + auto it = cache.find(msg.senderGuid); + if (it != cache.end() && !it->second.empty()) { + resolved.senderName = it->second; + } + return resolved; + } + + void configureProtocol() { + const std::filesystem::path dataPath = std::getenv("WOW_DATA_PATH") ? std::getenv("WOW_DATA_PATH") : "Data"; + const auto expansionPath = dataPath / "expansions" / settings_.expansion; + + if (!game_.getOpcodeTable().loadFromJson((expansionPath / "opcodes.json").string())) { + std::cerr << "Warning: failed to load opcode table from " << (expansionPath / "opcodes.json").string() << "\n"; + } + game::setActiveOpcodeTable(&game_.getOpcodeTable()); + + if (!game_.getUpdateFieldTable().loadFromJson((expansionPath / "update_fields.json").string())) { + std::cerr << "Warning: failed to load update field table from " << (expansionPath / "update_fields.json").string() << "\n"; + } + game::setActiveUpdateFieldTable(&game_.getUpdateFieldTable()); + game_.setPacketParsers(game::createPacketParsers(settings_.expansion)); + + if (dbcLayout_.loadFromJson((expansionPath / "dbc_layouts.json").string())) { + pipeline::setActiveDBCLayout(&dbcLayout_); + } + + if (assetManager_.initialize(dataPath.string()) || assetManager_.initializeDbcOnly(dataPath.string())) { + assetManager_.setExpansionDataPath(expansionPath.string()); + rendering::EmoteRegistry::instance().loadFromDbc(&assetManager_); + } else { + std::cerr << "Warning: asset manager not initialized; headless emotes will use fallback text\n"; + } + } + + void onRealmList(const std::vector& realms) { + if (realms.empty()) { + fail("Realm list is empty"); + return; + } + + size_t index = settings_.realmIndex; + if (!settings_.realmName.empty()) { + const std::string want = lowerAscii(settings_.realmName); + auto it = std::find_if(realms.begin(), realms.end(), [&](const auth::Realm& realm) { + return lowerAscii(realm.name) == want; + }); + if (it == realms.end()) { + fail("Configured realm not found: " + settings_.realmName); + return; + } + index = static_cast(std::distance(realms.begin(), it)); + } + if (index >= realms.size()) { + fail("Configured realm index is out of range"); + return; + } + + const auto& realm = realms[index]; + std::string worldHost; + uint16_t worldPort = 0; + if (!splitHostPort(realm.address, worldHost, worldPort)) { + fail("Realm address is not host:port: " + realm.address); + return; + } + + const uint32_t realmId = settings_.realmId != 0 ? settings_.realmId : realm.id; + std::cout << "Connecting to realm " << realm.name << " at " << worldHost << ":" << worldPort << "\n"; + game_.connect(worldHost, worldPort, auth_.getSessionKey(), settings_.account, + settings_.clientInfo.build, realmId); + } + + void selectConfiguredCharacter() { + const auto& characters = game_.getCharacters(); + if (characters.empty()) { + fail("No characters found on selected realm"); + return; + } + + const game::Character* chosen = nullptr; + if (!settings_.characterName.empty()) { + const std::string want = lowerAscii(settings_.characterName); + for (const auto& ch : characters) { + if (lowerAscii(ch.name) == want) { + chosen = &ch; + break; + } + } + if (!chosen) { + fail("Configured character not found: " + settings_.characterName); + return; + } + } else { + chosen = &characters.front(); + settings_.characterName = chosen->name; + } + + selectedCharacter_ = true; + std::cout << "Selecting character " << chosen->name << "\n"; + game_.selectCharacter(chosen->guid); + } + + void drainPendingChat() { + if (!isInWorld()) return; + + std::deque local; + { + std::lock_guard lock(pendingMutex_); + local.swap(pendingChat_); + } + + for (const auto& msg : local) { + game_.sendChatMessage(msg.type, msg.message, msg.target); + } + } + + void queueOnEnterWorldCommands() { + for (const auto& command : settings_.onEnterWorldCommands) { + scheduledCommands_.push_back(command); + } + if (!scheduledCommands_.empty()) { + nextScheduledCommandDelay_ = 0.0f; + std::cout << "Queued " << scheduledCommands_.size() << " on-enter-world command(s)\n"; + } + } + + void drainScheduledCommands(float deltaSeconds) { + if (!isInWorld() || scheduledCommands_.empty()) return; + + if (nextScheduledCommandDelay_ > 0.0f) { + nextScheduledCommandDelay_ -= deltaSeconds; + if (nextScheduledCommandDelay_ > 0.0f) return; + } + + PendingChat msg; + msg.type = game::ChatType::SAY; + msg.message = scheduledCommands_.front(); + scheduledCommands_.pop_front(); + enqueueChat(std::move(msg)); + nextScheduledCommandDelay_ = settings_.onEnterCommandDelaySeconds; + } + + void syncChatSnapshot() { + std::lock_guard lock(chatMutex_); + syncChatSnapshotLocked(); + } + + void syncChatSnapshotLocked() { + const auto& history = game_.getChatHistory(); + if (history.size() < lastGameHistorySize_) { + chatMirror_.clear(); + chatBaseId_ = nextChatId_; + lastGameHistorySize_ = 0; + } + + for (size_t i = lastGameHistorySize_; i < history.size(); ++i) { + if (history[i].message.empty()) continue; + chatMirror_.push_back(history[i]); + ++nextChatId_; + while (chatMirror_.size() > settings_.apiMaxMessages) { + chatMirror_.pop_front(); + ++chatBaseId_; + } + } + lastGameHistorySize_ = history.size(); + } + + void updateLogoutExit() { + if (!logoutRequested_) return; + const auto elapsed = std::chrono::steady_clock::now() - logoutRequestedAt_; + const bool inWorld = game_.getState() == game::WorldState::IN_WORLD; + if (!inWorld && !game_.isLoggingOut()) { + g_running = false; + return; + } + + if (!game_.isLoggingOut() && elapsed >= std::chrono::seconds(2)) { + std::cout << "Logout complete; exiting process.\n"; + g_running = false; + return; + } + + if (elapsed >= kLogoutSilentExitTimeout) { + std::cout << "Logout wait reached " + << std::chrono::duration_cast(kLogoutSilentExitTimeout).count() + << "s; exiting process.\n"; + g_running = false; + } + } + + void fail(const std::string& reason) { + if (!failed_) { + std::lock_guard lock(stateMutex_); + failed_ = true; + failureReason_ = reason; + std::cerr << reason << "\n"; + } + } + + Settings settings_; + game::GameServices services_; + auth::AuthHandler auth_; + game::GameHandler game_; + pipeline::DBCLayout dbcLayout_; + pipeline::AssetManager assetManager_; + + std::atomic failed_{false}; + std::atomic inWorldForApi_{false}; + mutable std::mutex stateMutex_; + std::string failureReason_; + bool selectedCharacter_ = false; + bool enteredWorld_ = false; + + std::mutex pendingMutex_; + std::deque pendingChat_; + std::deque scheduledCommands_; + float nextScheduledCommandDelay_ = 0.0f; + + std::mutex chatMutex_; + std::deque chatMirror_; + size_t chatBaseId_ = 1; + size_t nextChatId_ = 1; + size_t lastGameHistorySize_ = 0; + std::atomic logoutRequested_{false}; + std::chrono::steady_clock::time_point logoutRequestedAt_{}; +}; + +std::string urlDecode(const std::string& s) { + std::string out; + out.reserve(s.size()); + for (size_t i = 0; i < s.size(); ++i) { + if (s[i] == '%' && i + 2 < s.size()) { + const std::string hex = s.substr(i + 1, 2); + out.push_back(static_cast(std::strtol(hex.c_str(), nullptr, 16))); + i += 2; + } else if (s[i] == '+') { + out.push_back(' '); + } else { + out.push_back(s[i]); + } + } + return out; +} + +std::string queryParam(const std::string& query, const std::string& key) { + size_t pos = 0; + while (pos <= query.size()) { + size_t amp = query.find('&', pos); + std::string part = query.substr(pos, amp == std::string::npos ? std::string::npos : amp - pos); + size_t eq = part.find('='); + std::string k = urlDecode(eq == std::string::npos ? part : part.substr(0, eq)); + if (k == key) return urlDecode(eq == std::string::npos ? "" : part.substr(eq + 1)); + if (amp == std::string::npos) break; + pos = amp + 1; + } + return ""; +} + +void sendHttp(socket_t client, int status, const json& body) { + const std::string payload = body.dump(); + std::ostringstream out; + out << "HTTP/1.1 " << status << (status == 200 ? " OK" : " Error") << "\r\n" + << "Content-Type: application/json\r\n" + << "Access-Control-Allow-Origin: *\r\n" + << "Content-Length: " << payload.size() << "\r\n" + << "Connection: close\r\n\r\n" + << payload; + const std::string response = out.str(); + wowee::net::portableSend(client, reinterpret_cast(response.data()), response.size()); +} + +void handleHttpClient(socket_t client, HeadlessSession& session) { + std::string req; + std::array buf{}; + while (req.find("\r\n\r\n") == std::string::npos && req.size() < 65536) { + ssize_t n = wowee::net::portableRecv(client, buf.data(), buf.size()); + if (n <= 0) break; + req.append(reinterpret_cast(buf.data()), static_cast(n)); + } + + const size_t headerEnd = req.find("\r\n\r\n"); + if (headerEnd == std::string::npos) { + sendHttp(client, 400, {{"error", "bad request"}}); + wowee::net::closeSocket(client); + return; + } + + const std::string headers = req.substr(0, headerEnd); + std::istringstream hs(headers); + std::string method; + std::string target; + std::string version; + hs >> method >> target >> version; + + size_t contentLength = 0; + std::string line; + std::getline(hs, line); + while (std::getline(hs, line)) { + line = trim(line); + const std::string lower = lowerAscii(line); + if (lower.rfind("content-length:", 0) == 0) { + contentLength = static_cast(std::stoul(trim(line.substr(15)))); + } + } + + std::string body = req.substr(headerEnd + 4); + while (body.size() < contentLength) { + ssize_t n = wowee::net::portableRecv(client, buf.data(), buf.size()); + if (n <= 0) break; + body.append(reinterpret_cast(buf.data()), static_cast(n)); + } + + const size_t qpos = target.find('?'); + const std::string path = qpos == std::string::npos ? target : target.substr(0, qpos); + const std::string query = qpos == std::string::npos ? "" : target.substr(qpos + 1); + + try { + if (method == "GET" && path == "/status") { + sendHttp(client, 200, {{"status", session.status()}, {"inWorld", session.isInWorld()}}); + } else if (method == "GET" && path == "/chat") { + const size_t after = queryParam(query, "after").empty() ? 0 : static_cast(std::stoull(queryParam(query, "after"))); + const size_t limit = queryParam(query, "limit").empty() ? 100 : static_cast(std::stoull(queryParam(query, "limit"))); + sendHttp(client, 200, session.readChat(after, limit)); + } else if (method == "POST" && path == "/chat") { + json payload = json::parse(body.empty() ? "{}" : body); + auto type = chatTypeFromString(payload.value("type", "say")); + if (!type) { + sendHttp(client, 400, {{"error", "unsupported chat type"}}); + } else { + PendingChat msg; + msg.type = *type; + msg.message = payload.value("message", ""); + msg.target = payload.value("target", payload.value("channel", "")); + if (msg.message.empty()) { + sendHttp(client, 400, {{"error", "message is required"}}); + } else if ((msg.type == game::ChatType::WHISPER || msg.type == game::ChatType::CHANNEL) && msg.target.empty()) { + sendHttp(client, 400, {{"error", "target/channel is required for this chat type"}}); + } else { + session.enqueueChat(std::move(msg)); + sendHttp(client, 200, {{"ok", true}}); + } + } + } else { + sendHttp(client, 404, {{"error", "not found"}}); + } + } catch (const std::exception& ex) { + sendHttp(client, 400, {{"error", ex.what()}}); + } + + wowee::net::closeSocket(client); +} + +void runHttpServer(const Settings& settings, HeadlessSession& session) { + wowee::net::ensureInit(); + socket_t server = ::socket(AF_INET, SOCK_STREAM, IPPROTO_TCP); + if (server == INVALID_SOCK) { + std::cerr << "API socket() failed\n"; + return; + } + + int yes = 1; + setsockopt(server, SOL_SOCKET, SO_REUSEADDR, reinterpret_cast(&yes), sizeof(yes)); + + sockaddr_in addr{}; + addr.sin_family = AF_INET; + addr.sin_port = htons(settings.apiPort); + if (inet_pton(AF_INET, settings.apiBind.c_str(), &addr.sin_addr) != 1) { + std::cerr << "API bind address must be an IPv4 address: " << settings.apiBind << "\n"; + wowee::net::closeSocket(server); + return; + } + + if (::bind(server, reinterpret_cast(&addr), sizeof(addr)) != 0) { + std::cerr << "API bind failed on " << settings.apiBind << ":" << settings.apiPort << "\n"; + wowee::net::closeSocket(server); + return; + } + if (::listen(server, 16) != 0) { + std::cerr << "API listen failed\n"; + wowee::net::closeSocket(server); + return; + } + + wowee::net::setNonBlocking(server); + std::cout << "Chat API listening on http://" << settings.apiBind << ":" << settings.apiPort << "\n"; + + while (g_running) { + sockaddr_in clientAddr{}; +#ifdef _WIN32 + int len = sizeof(clientAddr); +#else + socklen_t len = sizeof(clientAddr); +#endif + socket_t client = ::accept(server, reinterpret_cast(&clientAddr), &len); + if (client == INVALID_SOCK) { + std::this_thread::sleep_for(std::chrono::milliseconds(20)); + continue; + } + handleHttpClient(client, session); + } + + wowee::net::closeSocket(server); +} + +void signalHandler(int) { + g_running = false; +} + +void usage() { + std::cerr << "Usage: wowee_headless [settings.json]\n"; +} + +} // namespace + +int main(int argc, char** argv) { + std::signal(SIGINT, signalHandler); + std::signal(SIGTERM, signalHandler); + setDefaultEnv("WOWEE_HEADLESS", "1"); + setDefaultEnv("WOWEE_NET_ASYNC_PUMP", "0"); + wowee::net::ensureInit(); + + const std::string settingsPath = argc >= 2 ? argv[1] : "tools/headless_client/settings.json"; + if (argc > 2) { + usage(); + return 2; + } + + try { + Settings settings = loadSettings(settingsPath); + setDefaultEnv("WOWEE_ACTIVE_EXPANSION", settings.expansion.c_str()); + std::cout << "WoWee headless client using " << settingsPath << "\n"; + std::cout << "Expansion profile: " << settings.expansion << "\n"; + + HeadlessSession session(settings); + if (!session.start()) return 1; + + std::thread apiThread; + if (settings.apiEnabled) { + apiThread = std::thread([&]() { runHttpServer(settings, session); }); + } + + auto last = std::chrono::steady_clock::now(); + while (g_running && !session.isFailed()) { + if (consumeEscKeypress()) { + session.requestGracefulLogout(); + } + + const auto now = std::chrono::steady_clock::now(); + const float dt = std::chrono::duration(now - last).count(); + last = now; + session.update(dt); + std::this_thread::sleep_for(std::chrono::milliseconds(10)); + } + + g_running = false; + if (apiThread.joinable()) apiThread.join(); + return session.isFailed() ? 1 : 0; + } catch (const std::exception& ex) { + std::cerr << ex.what() << "\n"; + return 1; + } +} diff --git a/tools/headless_client/settings.example.json b/tools/headless_client/settings.example.json new file mode 100644 index 000000000..c3e3a33c0 --- /dev/null +++ b/tools/headless_client/settings.example.json @@ -0,0 +1,44 @@ +{ + "auth": { + "host": "127.0.0.1", + "port": 3724, + "account": "ACCOUNT", + "password": "PASSWORD" + }, + "client": { + "major": 3, + "minor": 3, + "patch": 5, + "build": 12340, + "protocol": 8, + "locale": "enUS", + "platform": "x86", + "os": "Win", + "expansion": "wotlk" + }, + "realm": { + "name": "", + "index": 0, + "id": 0 + }, + "character": { + "name": "" + }, + "chat": { + "autoJoinDefaultChannels": false + }, + "bots": { + "enabled": false, + "names": [] + }, + "automation": { + "commandDelaySeconds": 0.25, + "onEnterWorldCommands": [] + }, + "api": { + "enabled": true, + "bind": "127.0.0.1", + "port": 8787, + "maxMessages": 200 + } +} From e4957be5fe9a5d90c0b649fdaaf78fb8643b57b8 Mon Sep 17 00:00:00 2001 From: Josh Anderson Date: Wed, 8 Jul 2026 11:57:35 -0500 Subject: [PATCH 002/103] Add fleet visibility dashboard --- .gitignore | 7 + BOT_PLAN.md | 117 ++++ tools/automation_examples/README.md | 66 +++ tools/automation_examples/party_demo.py | 54 ++ tools/automation_examples/travel_demo.py | 184 +++++++ tools/bot_fleet_manager/README.md | 60 ++ tools/bot_fleet_manager/bot_fleet_manager.py | 416 ++++++++++++++ .../fleet.settings.example.json | 61 ++ .../import_headless_settings.py | 146 +++++ tools/bot_fleet_manager/team_status_server.py | 213 +++++++ tools/headless_client/README.md | 87 +++ tools/headless_client/main.cpp | 520 +++++++++++++++++- tools/provisioning/README.md | 241 ++++++++ .../create_account_direct_soap.py | 204 +++++++ tools/provisioning/create_account_ssh.py | 257 +++++++++ tools/provisioning/create_character.py | 125 +++++ tools/provisioning/provision_roster.py | 270 +++++++++ tools/provisioning/roster.example.json | 34 ++ 18 files changed, 3055 insertions(+), 7 deletions(-) create mode 100644 BOT_PLAN.md create mode 100644 tools/automation_examples/README.md create mode 100644 tools/automation_examples/party_demo.py create mode 100644 tools/automation_examples/travel_demo.py create mode 100644 tools/bot_fleet_manager/README.md create mode 100644 tools/bot_fleet_manager/bot_fleet_manager.py create mode 100644 tools/bot_fleet_manager/fleet.settings.example.json create mode 100644 tools/bot_fleet_manager/import_headless_settings.py create mode 100644 tools/bot_fleet_manager/team_status_server.py create mode 100644 tools/provisioning/README.md create mode 100644 tools/provisioning/create_account_direct_soap.py create mode 100644 tools/provisioning/create_account_ssh.py create mode 100644 tools/provisioning/create_character.py create mode 100644 tools/provisioning/provision_roster.py create mode 100644 tools/provisioning/roster.example.json diff --git a/.gitignore b/.gitignore index 023a4db5a..61146a469 100644 --- a/.gitignore +++ b/.gitignore @@ -84,7 +84,12 @@ imgui.ini config.ini config.json .env +tools/.env tools/headless_client/settings.json +tools/bot_fleet_manager/fleet.settings.json +tools/bot_fleet_manager/runtime/ +tools/provisioning/*.local.json +tools/provisioning/roster.json # Runtime cache (floor heights, etc.) cache/ @@ -105,9 +110,11 @@ asset_pipeline/ # Local texture dumps / extracted art should never be committed assets/textures/ node_modules/ +vcpkg_installed/ # Python cache artifacts tools/__pycache__/ +**/__pycache__/ *.pyc # artifacts diff --git a/BOT_PLAN.md b/BOT_PLAN.md new file mode 100644 index 000000000..cfbd56407 --- /dev/null +++ b/BOT_PLAN.md @@ -0,0 +1,117 @@ +# Bot Fleet Manager Plan + +## Goal + +Turn `wowee_headless` into a bot automation foundation that can supervise multiple player-character party leaders. Each leader can log in, form a CMaNGOS bot party, receive automation commands, and report world/chat/party state. The first practical scale target is 10 leader sessions, each with up to 4 server-side follower bots. + +## Current Strategy + +- [x] Use client-side orchestration first; do not require CMaNGOS server changes for v1. +- [x] Treat each logged-in headless character as a leader agent. +- [x] Use CMaNGOS/playerbot follower behavior for party members. +- [x] Keep Python/Lua automation external through HTTP APIs for the first implementation. +- [x] Pull current upstream `master` into the headless branch before continuing. +- [ ] Add WebSocket or Server-Sent Events after the request/response APIs stabilize. +- [ ] Revisit optional CMaNGOS server hooks after the client-side fleet model proves useful. + +## Phase 1: Headless Leader Agent + +- [x] Log in, select a character, enter world, and expose localhost chat APIs. +- [x] Add startup bot commands through `bots.names` and `automation.onEnterWorldCommands`. +- [x] Preserve local `settings.json` outside git. +- [x] Expose `GET /world/self` with map, position, orientation, movement state, and character identity. +- [x] Expose `GET /party` with parsed party members and leader GUID. +- [x] Expose `POST /commands` for raw queued chat/GM/playerbot commands. +- [x] Expose `POST /movement/goto` for direct leader travel. +- [x] Expose `POST /movement/goto/waypoints` for explicit waypoint-list travel. +- [x] Expose `POST /movement/stop` to cancel leader travel. +- [x] Add movement task status to `GET /status`. +- [x] Add death/combat detection fields to HTTP API (`combat.inCombat`, `health.current/max/isDead/isPlayerDead`). + +## Phase 2: Leader Travel MVP + +- [x] Implement direct-line steering to a coordinate: `mapId`, `x`, `y`, `z`, optional `arrivalRadius`. +- [x] Face destination, send movement start/stop/facing/heartbeat packets, and update local position based on server run speed. +- [x] Stop when within arrival radius. +- [x] Fail cleanly on map mismatch, not-in-world, or invalid coordinates. +- [x] Detect and report server movement lock as a distinct travel failure. +- [x] Add waypoint-list travel for hand-authored route segments. +- [ ] Validate direct travel against live CMaNGOS after the upstream merge. +- [ ] Add runtime collision checks during movement. +- [ ] Decide whether pathfinding should come from CMaNGOS/Detour data, MiniManager-style map assets, or a separate extracted navmesh. + +## Phase 3: Bot Fleet Manager + +- [x] Add `tools/bot_fleet_manager/`. +- [x] Add `fleet.settings.example.json`. +- [x] Launch multiple `wowee_headless` child processes with distinct settings and API ports. +- [x] Stagger logins/startup commands to avoid server spikes. +- [x] Track leader process state and restart failures with backoff. +- [x] Poll and report leader API status. +- [x] Expose or document aggregate fleet commands: start, stop, status, command, goto. +- [x] Add explicit fleet assignment semantics. +- [x] Support multiple fleet managers by making ports, settings paths, and process labels explicit. + +## Phase 3b: Textual Fleet Visibility + +- [x] Add a web dashboard that lists configured teams without a map dependency. +- [x] Show leader online/offline state, API base, textual map/position, activity, party members, and recent chat. +- [x] Add `dashboard` subcommand for already-running leaders. +- [x] Add `supervise --dashboard` for running the supervisor and dashboard together. +- [ ] Add richer activity inference from recent chat, movement transitions, combat, death, and command queue state. +- [ ] Add team filtering and a compact JSON endpoint suitable for external automation. +- [ ] Add stale-data timestamps per endpoint so partial API failures are obvious. + +## Phase 3c: Map Viewer (Later) + +- [ ] Review MiniManager's map/data approach and adapt the useful pieces into Python. +- [ ] Build on top of the textual dashboard once leader status data is stable. +- [ ] Avoid committing the experimental static WHM/Leaflet map server until its coordinate and asset assumptions are validated. + +## Phase 4: External Automation + +- [x] Add Python examples that call the manager/headless HTTP APIs. +- [ ] Add Lua examples that call the HTTP APIs externally. +- [x] Provide examples for fleet startup, party formation, and party chat. +- [x] Provide examples for direct leader travel and explicit waypoint travel. +- [ ] Keep durable state in the manager, not in scripts. + +## Phase 5: Provisioning + +- [x] Add a direct SOAP account creation script. +- [x] Add an SSH-based account creation script that reads CMaNGOS host credentials from `tools/.env` and runs SOAP locally on the server host. +- [x] Add `wowee_headless` provisioning mode for creating a character from settings JSON. +- [x] Add a character creation wrapper script that generates temporary headless settings and exits after creation. +- [x] Document the account and character provisioning flow. +- [x] Add a batch/fleet provisioning script that creates many accounts and characters from one roster file. +- [ ] Add live integration tests against a local CMaNGOS instance. + +## Asset Extraction Follow-Up + +- [x] Identify the restored asset extraction fixes in `tools/asset_extract/open_format_emitter.cpp`. +- [x] Validate that the changes are isolated from bot automation work. +- [x] Build `asset_extract` after the upstream merge. +- [ ] Split the asset extraction fix into a standalone branch/PR. +- [ ] Validate on real extracted ADT inputs before treating WOC/WHM output as pathfinding-grade data. + +## Future Work + +- [ ] Hostile detection and target selection. +- [ ] Combat behavior for party leaders. +- [ ] Loot handling. +- [ ] Patrol routes and behavior trees. +- [ ] Event streaming for lower-latency automation. +- [ ] Optional server-side bot takeover hooks. +- [ ] Lua automation examples. +- [ ] Runtime tests for `/movement/goto` against a live local CMaNGOS server. + +## Notes + +- First implementation target is CMaNGOS TBC, matching the active test environment. +- Scale should be measured in tiers: 1 leader, 2 leaders, 5 leaders, 10 leaders, then beyond. +- Coordinate conventions from `include/core/coordinates.hpp`: + - Canonical: `X=north, Y=west, Z=up`. + - Server/wire: `X=canonical.Y (west), Y=canonical.X (north)`. + - Engine render: `X=west, Y=north`. + - Headless `/world/self` API returns canonical `position.x = north`, `position.y = west`. +- First demo path: import `tools/headless_client/settings.json` into a one-leader fleet config, run fleet `supervise --dashboard`, open `http://127.0.0.1:8780`, then use `tools/automation_examples/party_demo.py`. diff --git a/tools/automation_examples/README.md b/tools/automation_examples/README.md new file mode 100644 index 000000000..29cea37df --- /dev/null +++ b/tools/automation_examples/README.md @@ -0,0 +1,66 @@ +# Automation Examples + +These scripts exercise the `wowee_headless` HTTP APIs from outside the client. + +## First Party Demo + +Create a local one-leader fleet config from your already-working headless settings: + +```bash +python tools/bot_fleet_manager/import_headless_settings.py +``` + +Start the supervised leader: + +```bash +python tools/bot_fleet_manager/bot_fleet_manager.py tools/bot_fleet_manager/fleet.settings.json supervise +``` + +To start the textual team dashboard at the same time: + +```bash +python tools/bot_fleet_manager/bot_fleet_manager.py tools/bot_fleet_manager/fleet.settings.json supervise --dashboard +``` + +Then open `http://127.0.0.1:8780`. + +In another terminal, inspect the leader, party, and chat APIs: + +```bash +python tools/automation_examples/party_demo.py +``` + +Send a party message and then read recent chat after a short wait: + +```bash +python tools/automation_examples/party_demo.py --message "hello from the first automation demo" +``` + +The default API URL is `http://127.0.0.1:8787`. Use `--base-url` if your fleet config uses a different port. + +## Leader Travel Demo + +Send the leader to a named landmark, exact coordinate, or multi-waypoint route, polling status until arrival: + +```bash +# Go to Goldshire (direct line) +python tools/automation_examples/travel_demo.py --landmark goldshire + +# Go to an exact coordinate +python tools/automation_examples/travel_demo.py --coord -9465 62 56 + +# Follow a named waypoint route (Stormwind -> Goldshire, 7 waypoints) +python tools/automation_examples/travel_demo.py --route stormwind_to_goldshire + +# Check current position without moving +python tools/automation_examples/travel_demo.py --here + +# Stop the current movement task +python tools/automation_examples/travel_demo.py --stop +``` + +Available landmarks: `goldshire`, `stormwind`, `elwynn_forest`. + +Available named routes: `stormwind_to_goldshire` (7 waypoints). + +The script polls `GET /status` every second and prints state transitions (`moving` to `arrived`, `failed`, or `movement_locked`) and waypoint progress. Press Ctrl-C to interrupt and stop the leader. diff --git a/tools/automation_examples/party_demo.py b/tools/automation_examples/party_demo.py new file mode 100644 index 000000000..093b83b1f --- /dev/null +++ b/tools/automation_examples/party_demo.py @@ -0,0 +1,54 @@ +#!/usr/bin/env python3 +"""Small demo client for a running wowee_headless leader API.""" + +from __future__ import annotations + +import argparse +import json +import time +import urllib.request +from typing import Any + + +def request_json(method: str, url: str, payload: dict[str, Any] | None = None, timeout: float = 5.0) -> dict[str, Any]: + data = None + headers = {} + if payload is not None: + data = json.dumps(payload).encode("utf-8") + headers["Content-Type"] = "application/json" + req = urllib.request.Request(url, data=data, headers=headers, method=method) + with urllib.request.urlopen(req, timeout=timeout) as response: + return json.loads(response.read().decode("utf-8")) + + +def print_json(label: str, doc: dict[str, Any]) -> None: + print(f"\n== {label} ==") + print(json.dumps(doc, indent=2, sort_keys=True)) + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--base-url", default="http://127.0.0.1:8787") + parser.add_argument("--message", default="") + parser.add_argument("--after", type=int, default=0) + parser.add_argument("--wait-seconds", type=float, default=4.0) + args = parser.parse_args() + + base = args.base_url.rstrip("/") + print_json("status", request_json("GET", base + "/status")) + print_json("self", request_json("GET", base + "/world/self")) + print_json("party", request_json("GET", base + "/party")) + + if args.message: + print_json("send party chat", request_json("POST", base + "/chat", { + "type": "party", + "message": args.message, + })) + time.sleep(max(0.0, args.wait_seconds)) + + print_json("chat", request_json("GET", f"{base}/chat?after={args.after}&limit=50")) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tools/automation_examples/travel_demo.py b/tools/automation_examples/travel_demo.py new file mode 100644 index 000000000..496af9985 --- /dev/null +++ b/tools/automation_examples/travel_demo.py @@ -0,0 +1,184 @@ +#!/usr/bin/env python3 +"""Demo leader travel: goto a coordinate, follow waypoints, poll status until arrival.""" + +from __future__ import annotations + +import argparse +import json +import sys +import time +import urllib.error +import urllib.request +from typing import Any + + +def request_json(method: str, url: str, payload: dict[str, Any] | None = None, timeout: float = 5.0) -> dict[str, Any]: + data = None + headers = {} + if payload is not None: + data = json.dumps(payload).encode("utf-8") + headers["Content-Type"] = "application/json" + req = urllib.request.Request(url, data=data, headers=headers, method=method) + with urllib.request.urlopen(req, timeout=timeout) as response: + return json.loads(response.read().decode("utf-8")) + + +def print_json(label: str, doc: dict[str, Any]) -> None: + print(f"\n== {label} ==") + print(json.dumps(doc, indent=2, sort_keys=True)) + + +def resolve_destination(name: str) -> dict[str, float]: + """Return {mapId, x, y, z} for a named landmark on the Eastern Kingdoms map.""" + landmarks: dict[str, dict[str, float]] = { + "goldshire": {"mapId": 0, "x": -9465.0, "y": 62.0, "z": 56.0}, + "stormwind": {"mapId": 0, "x": -8833.0, "y": 626.0, "z": 94.0}, + "elwynn_forest": {"mapId": 0, "x": -9200.0, "y": -50.0, "z": 73.0}, + } + key = name.lower().replace(" ", "_") + if key in landmarks: + return landmarks[key] + raise ValueError(f"Unknown landmark: {name}. Known: {', '.join(sorted(landmarks.keys()))}") + + +def resolve_route(name: str) -> list[dict[str, float]]: + """Return a list of {x, y, z} waypoints for a named route.""" + routes: dict[str, list[dict[str, float]]] = { + "stormwind_to_goldshire": [ + {"x": -8833.0, "y": 626.0, "z": 94.0}, + {"x": -8970.0, "y": 610.0, "z": 95.0}, + {"x": -9050.0, "y": 520.0, "z": 92.0}, + {"x": -9150.0, "y": 350.0, "z": 82.0}, + {"x": -9260.0, "y": 220.0, "z": 75.0}, + {"x": -9360.0, "y": 130.0, "z": 65.0}, + {"x": -9465.0, "y": 62.0, "z": 56.0}, + ], + } + key = name.lower().replace(" ", "_") + if key in routes: + return routes[key] + raise ValueError(f"Unknown route: {name}. Known: {', '.join(sorted(routes.keys()))}") + + +STATE_ARRIVED = "arrived" +STATE_FAILED = "failed" +STATE_MOVING = "moving" +STATE_IDLE = "idle" +STATE_LOCKED = "movement_locked" + +TERMINAL_STATES = {STATE_ARRIVED, STATE_FAILED, STATE_LOCKED} + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--base-url", default="http://127.0.0.1:8787") + parser.add_argument("--poll-interval", type=float, default=1.0, help="Seconds between status polls") + parser.add_argument("--timeout", type=float, default=120.0, help="Max seconds to wait for arrival") + parser.add_argument("--arrival-radius", type=float, default=5.0) + + dest = parser.add_mutually_exclusive_group(required=True) + dest.add_argument("--landmark", help="Named destination (goldshire, stormwind, elwynn_forest)") + dest.add_argument("--coord", nargs=3, metavar=("X", "Y", "Z"), type=float, help="Exact coordinates") + dest.add_argument("--route", help="Named multi-waypoint route (stormwind_to_goldshire)") + dest.add_argument("--here", action="store_true", help="Read current position and use as destination (no-op, just reports)") + + parser.add_argument("--stop", action="store_true", help="Send stop command and exit") + parser.add_argument("--stop-reason", default="travel demo stop", help="Reason for stop") + + args = parser.parse_args() + base = args.base_url.rstrip("/") + + if args.stop: + result = request_json("POST", base + "/movement/stop", {"reason": args.stop_reason}) + print_json("stop result", result) + return 0 + + status = request_json("GET", base + "/status") + print_json("initial status", status) + + if args.here: + self_info = request_json("GET", base + "/world/self") + pos = self_info.get("position", {}) + print(f"\nCurrent position: x={pos.get('x')}, y={pos.get('y')}, z={pos.get('z')}") + print(f"Map ID: {self_info.get('mapId')}") + return 0 + + if args.route: + waypoints = resolve_route(args.route) + print(f"\nSending waypoint route '{args.route}' with {len(waypoints)} waypoints") + goto = request_json("POST", base + "/movement/goto/waypoints", { + "mapId": 0, + "waypoints": waypoints, + "arrivalRadius": args.arrival_radius, + }) + elif args.landmark: + coords = resolve_destination(args.landmark) + print(f"\nSending goto: mapId={coords['mapId']}, " + f"x={coords['x']}, y={coords['y']}, z={coords['z']}") + goto = request_json("POST", base + "/movement/goto", { + "mapId": coords["mapId"], + "x": coords["x"], + "y": coords["y"], + "z": coords["z"], + "arrivalRadius": args.arrival_radius, + }) + else: + print(f"\nSending goto: x={args.coord[0]}, y={args.coord[1]}, z={args.coord[2]}") + goto = request_json("POST", base + "/movement/goto", { + "mapId": 0, + "x": args.coord[0], + "y": args.coord[1], + "z": args.coord[2], + "arrivalRadius": args.arrival_radius, + }) + print_json("goto result", goto) + + if not goto.get("ok", False): + print(f"Error: {goto.get('error', 'unknown')}", file=sys.stderr) + return 1 + + deadline = time.monotonic() + args.timeout + last_state = "" + last_waypoint = 0 + + print(f"\nPolling status every {args.poll_interval}s (timeout {args.timeout}s)...") + try: + while time.monotonic() < deadline: + status = request_json("GET", base + "/status", timeout=args.poll_interval + 1.0) + movement = status.get("movement", {}) + state = movement.get("state", STATE_IDLE) + wp_index = movement.get("waypointIndex", 0) + wp_count = movement.get("waypointCount", 0) + + if wp_index != last_waypoint: + print(f" [{time.strftime('%H:%M:%S')}] waypoint {wp_index}/{wp_count}") + last_waypoint = wp_index + + if state != last_state: + print(f" [{time.strftime('%H:%M:%S')}] movement state: {state}") + last_state = state + + if state in TERMINAL_STATES: + print_json("final status", status) + if state == STATE_ARRIVED: + print("\nArrived at destination!") + return 0 + else: + error = movement.get("error", "unknown") + print(f"\nMovement failed: {error}", file=sys.stderr) + return 1 + + time.sleep(args.poll_interval) + except KeyboardInterrupt: + print("\nInterrupted; stopping movement...") + request_json("POST", base + "/movement/stop", {"reason": "interrupt"}) + print_json("final status", request_json("GET", base + "/status")) + return 130 + + print(f"\nTimed out after {args.timeout}s.", file=sys.stderr) + print_json("timeout status", request_json("GET", base + "/status")) + return 1 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tools/bot_fleet_manager/README.md b/tools/bot_fleet_manager/README.md new file mode 100644 index 000000000..5b6a46ba7 --- /dev/null +++ b/tools/bot_fleet_manager/README.md @@ -0,0 +1,60 @@ +# Bot Fleet Manager + +`bot_fleet_manager.py` supervises multiple `wowee_headless` leader clients. Each leader logs in as one character, can add CMaNGOS follower bots through startup commands, and exposes its own localhost automation API. + +## Run + +Copy `fleet.settings.example.json` to a local config and fill in accounts, passwords, characters, and party bot names. + +If you already have a working `tools/headless_client/settings.json`, generate a one-leader demo fleet config from it: + +```bash +python tools/bot_fleet_manager/import_headless_settings.py +``` + +```bash +python tools/bot_fleet_manager/bot_fleet_manager.py tools/bot_fleet_manager/fleet.settings.json start +``` + +`start` launches the leaders and exits. For longer runs, use `supervise` so the manager keeps running, writes per-leader logs, and restarts crashed clients with backoff: + +```bash +python tools/bot_fleet_manager/bot_fleet_manager.py tools/bot_fleet_manager/fleet.settings.json supervise +``` + +Start the textual team dashboard while supervising: + +```bash +python tools/bot_fleet_manager/bot_fleet_manager.py tools/bot_fleet_manager/fleet.settings.json supervise --dashboard +``` + +Or run only the dashboard against already-running leaders: + +```bash +python tools/bot_fleet_manager/bot_fleet_manager.py tools/bot_fleet_manager/fleet.settings.json dashboard +``` + +The dashboard listens on `http://127.0.0.1:8780` by default and shows leader status, textual position, current activity, party members, and recent chat. Use `--dashboard-port` with `supervise` or `--port` with `dashboard` to change the port. + +Common commands: + +```bash +python tools/bot_fleet_manager/bot_fleet_manager.py tools/bot_fleet_manager/fleet.settings.json status +python tools/bot_fleet_manager/bot_fleet_manager.py tools/bot_fleet_manager/fleet.settings.json command ".bot follow" +python tools/bot_fleet_manager/bot_fleet_manager.py tools/bot_fleet_manager/fleet.settings.json goto 530 -248.0 951.0 84.0 +python tools/bot_fleet_manager/bot_fleet_manager.py tools/bot_fleet_manager/fleet.settings.json stop +``` + +Target one group or one leader: + +```bash +python tools/bot_fleet_manager/bot_fleet_manager.py tools/bot_fleet_manager/fleet.settings.json status --fleet alpha +python tools/bot_fleet_manager/bot_fleet_manager.py tools/bot_fleet_manager/fleet.settings.json command --leader leader-1 ".bot follow" +python tools/bot_fleet_manager/bot_fleet_manager.py tools/bot_fleet_manager/fleet.settings.json goto --fleet beta 530 -248.0 951.0 84.0 +``` + +Set each leader's `fleet` field in `fleet.settings.json` to create assignment groups. + +The manager writes generated per-leader settings under `tools/bot_fleet_manager/runtime/`, which is ignored by git. + +Supervisor logs are written under `tools/bot_fleet_manager/runtime/logs/`. diff --git a/tools/bot_fleet_manager/bot_fleet_manager.py b/tools/bot_fleet_manager/bot_fleet_manager.py new file mode 100644 index 000000000..1e38cbe13 --- /dev/null +++ b/tools/bot_fleet_manager/bot_fleet_manager.py @@ -0,0 +1,416 @@ +#!/usr/bin/env python3 +"""Supervise multiple wowee_headless leader clients.""" + +from __future__ import annotations + +import argparse +import json +import os +import subprocess +import sys +import threading +import time +import urllib.error +import urllib.request +from pathlib import Path +from typing import Any + + +ROOT = Path(__file__).resolve().parents[2] + + +def load_json(path: Path) -> dict[str, Any]: + with path.open("r", encoding="utf-8") as handle: + return json.load(handle) + + +def write_json(path: Path, data: dict[str, Any]) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + with path.open("w", encoding="utf-8") as handle: + json.dump(data, handle, indent=2) + handle.write("\n") + + +def resolve_path(raw: str) -> Path: + path = Path(raw) + return path if path.is_absolute() else ROOT / path + + +def request_json(method: str, url: str, payload: dict[str, Any] | None = None, timeout: float = 3.0) -> dict[str, Any]: + data = None + headers = {} + if payload is not None: + data = json.dumps(payload).encode("utf-8") + headers["Content-Type"] = "application/json" + req = urllib.request.Request(url, data=data, headers=headers, method=method) + with urllib.request.urlopen(req, timeout=timeout) as response: + return json.loads(response.read().decode("utf-8")) + + +class FleetConfig: + def __init__(self, path: Path): + self.path = path + self.doc = load_json(path) + self.defaults = self.doc.get("defaults", {}) + self.leaders = self.doc.get("leaders", []) + self.runtime_dir = resolve_path(self.doc.get("runtimeDir", "tools/bot_fleet_manager/runtime")) + self.headless = resolve_path(self.doc.get("woweeHeadless", "build/bin/wowee_headless.exe")) + self.launch_delay = float(self.doc.get("launchDelaySeconds", 2.0)) + self.supervision = self.doc.get("supervision", {}) + if not self.leaders: + raise ValueError("fleet config must contain at least one leader") + + @property + def log_dir(self) -> Path: + return self.runtime_dir / "logs" + + @property + def initial_restart_backoff(self) -> float: + return float(self.supervision.get("initialRestartBackoffSeconds", 5.0)) + + @property + def max_restart_backoff(self) -> float: + return float(self.supervision.get("maxRestartBackoffSeconds", 60.0)) + + @property + def max_restarts(self) -> int: + return int(self.supervision.get("maxRestarts", 0)) + + def api_port_for(self, leader: dict[str, Any], index: int) -> int: + api_defaults = self.defaults.get("api", {}) + return int(leader.get("apiPort", int(api_defaults.get("basePort", 8787)) + index)) + + def api_base_for(self, leader: dict[str, Any], index: int) -> str: + bind = self.defaults.get("api", {}).get("bind", "127.0.0.1") + return f"http://{bind}:{self.api_port_for(leader, index)}" + + def leader_settings(self, leader: dict[str, Any], index: int) -> dict[str, Any]: + api_defaults = self.defaults.get("api", {}) + automation_defaults = self.defaults.get("automation", {}) + startup_commands = list(automation_defaults.get("onEnterWorldCommands", [])) + startup_commands.extend(leader.get("startupCommands", [])) + + return { + "auth": { + **self.defaults.get("auth", {}), + "account": leader["account"], + "password": leader["password"], + }, + "client": self.defaults.get("client", {}), + "realm": leader.get("realm", self.defaults.get("realm", {})), + "character": {"name": leader["character"]}, + "chat": {"autoJoinDefaultChannels": bool(leader.get("autoJoinDefaultChannels", False))}, + "bots": { + "enabled": bool(leader.get("partyBots")), + "names": leader.get("partyBots", []), + }, + "automation": { + "commandDelaySeconds": float(leader.get( + "commandDelaySeconds", + automation_defaults.get("commandDelaySeconds", 0.25), + )), + "onEnterWorldCommands": startup_commands, + }, + "api": { + "enabled": True, + "bind": api_defaults.get("bind", "127.0.0.1"), + "port": self.api_port_for(leader, index), + "maxMessages": int(api_defaults.get("maxMessages", 500)), + }, + } + + def write_runtime_settings(self) -> list[tuple[dict[str, Any], Path]]: + written: list[tuple[dict[str, Any], Path]] = [] + for index, leader in enumerate(self.leaders): + leader_id = leader.get("id", f"leader-{index + 1}") + path = self.runtime_dir / f"{leader_id}.settings.json" + write_json(path, self.leader_settings(leader, index)) + written.append((leader, path)) + return written + + def selected_leaders(self, fleet: str = "", leader_ids: list[str] | None = None) -> list[tuple[int, dict[str, Any]]]: + want_ids = set(leader_ids or []) + selected: list[tuple[int, dict[str, Any]]] = [] + for index, leader in enumerate(self.leaders): + leader_id = leader.get("id", f"leader-{index + 1}") + if fleet and leader.get("fleet", "") != fleet: + continue + if want_ids and leader_id not in want_ids: + continue + selected.append((index, leader)) + return selected + + +def runtime_env() -> dict[str, str]: + env = os.environ.copy() + path_entries: list[str] = [] + msys_ucrt = Path("C:/msys64/ucrt64/bin") + if sys.platform == "win32" and msys_ucrt.exists(): + path_entries.append(str(msys_ucrt)) + if path_entries: + env["PATH"] = os.pathsep.join(path_entries + [env.get("PATH", "")]) + return env + + +def creation_flags() -> int: + return subprocess.CREATE_NEW_PROCESS_GROUP if sys.platform == "win32" else 0 + + +class ManagedLeader: + def __init__(self, config: FleetConfig, leader: dict[str, Any], index: int, settings_path: Path): + self.config = config + self.leader = leader + self.index = index + self.settings_path = settings_path + self.leader_id = leader.get("id", f"leader-{index + 1}") + self.process: subprocess.Popen[bytes] | None = None + self.log_handle: Any = None + self.restart_count = 0 + self.next_start_at = 0.0 + self.backoff = config.initial_restart_backoff + self.disabled = False + + def start(self) -> None: + self.config.log_dir.mkdir(parents=True, exist_ok=True) + if self.log_handle: + self.log_handle.close() + log_path = self.config.log_dir / f"{self.leader_id}.log" + self.log_handle = log_path.open("ab") + print(f"Starting {self.leader_id} with {self.settings_path}; log={log_path}") + self.process = subprocess.Popen( + [str(self.config.headless), str(self.settings_path)], + cwd=str(ROOT), + stdout=self.log_handle, + stderr=subprocess.STDOUT, + creationflags=creation_flags(), + env=runtime_env(), + ) + + def poll(self) -> None: + if self.disabled: + return + now = time.monotonic() + if self.process is None: + if now >= self.next_start_at: + self.start() + return + + rc = self.process.poll() + if rc is None: + return + + print(f"{self.leader_id}: exited with code {rc}") + self.process = None + if self.log_handle: + self.log_handle.close() + self.log_handle = None + + self.restart_count += 1 + if self.config.max_restarts > 0 and self.restart_count > self.config.max_restarts: + print(f"{self.leader_id}: restart limit reached; disabled") + self.disabled = True + return + + delay = self.backoff + self.backoff = min(self.backoff * 2.0, self.config.max_restart_backoff) + self.next_start_at = now + delay + print(f"{self.leader_id}: restart {self.restart_count} scheduled in {delay:.1f}s") + + def stop(self) -> None: + self.disabled = True + if self.process and self.process.poll() is None: + print(f"Stopping {self.leader_id}") + self.process.terminate() + try: + self.process.wait(timeout=8) + except subprocess.TimeoutExpired: + print(f"{self.leader_id}: terminate timed out; killing") + self.process.kill() + if self.log_handle: + self.log_handle.close() + self.log_handle = None + + +def cmd_start(config: FleetConfig) -> int: + if not config.headless.exists(): + print(f"Missing wowee_headless executable: {config.headless}", file=sys.stderr) + return 2 + + for index, (leader, settings_path) in enumerate(config.write_runtime_settings()): + leader_id = leader.get("id", f"leader-{index + 1}") + print(f"Starting {leader_id} with {settings_path}") + subprocess.Popen( + [str(config.headless), str(settings_path)], + cwd=str(ROOT), + creationflags=creation_flags(), + env=runtime_env(), + ) + time.sleep(config.launch_delay) + return 0 + + +def dashboard_targets(config: FleetConfig) -> list[dict[str, str]]: + return [ + { + "id": leader.get("id", f"leader-{index + 1}"), + "fleet": leader.get("fleet", ""), + "base": config.api_base_for(leader, index), + } + for index, leader in enumerate(config.leaders) + ] + + +def start_dashboard_thread(config: FleetConfig, host: str, port: int) -> threading.Thread: + try: + from team_status_server import run_team_status_server + except ImportError: + from bot_fleet_manager.team_status_server import run_team_status_server + + thread = threading.Thread( + target=run_team_status_server, + args=(dashboard_targets(config), host, port), + daemon=True, + ) + thread.start() + return thread + + +def cmd_supervise(config: FleetConfig, dashboard: bool = False, dashboard_host: str = "127.0.0.1", dashboard_port: int = 8780) -> int: + if not config.headless.exists(): + print(f"Missing wowee_headless executable: {config.headless}", file=sys.stderr) + return 2 + + managed = [ + ManagedLeader(config, leader, index, settings_path) + for index, (leader, settings_path) in enumerate(config.write_runtime_settings()) + ] + + if dashboard: + start_dashboard_thread(config, dashboard_host, dashboard_port) + + try: + for leader in managed: + leader.poll() + time.sleep(config.launch_delay) + + print("Supervisor running. Press Ctrl-C to stop leader processes.") + while True: + for leader in managed: + leader.poll() + time.sleep(1.0) + except KeyboardInterrupt: + print("\nSupervisor stopping") + for leader in managed: + leader.stop() + return 0 + + +def cmd_status(config: FleetConfig, fleet: str = "", leader_ids: list[str] | None = None) -> int: + selected = config.selected_leaders(fleet, leader_ids) + if not selected: + print("No matching leaders") + return 1 + for index, leader in selected: + leader_id = leader.get("id", f"leader-{index + 1}") + url = config.api_base_for(leader, index) + "/status" + try: + status = request_json("GET", url) + print(f"{leader_id}: {json.dumps(status, sort_keys=True)}") + except (urllib.error.URLError, TimeoutError, json.JSONDecodeError) as exc: + print(f"{leader_id}: unavailable ({exc})") + return 0 + + +def post_all( + config: FleetConfig, + path: str, + payload: dict[str, Any], + fleet: str = "", + leader_ids: list[str] | None = None, +) -> int: + ok = True + selected = config.selected_leaders(fleet, leader_ids) + if not selected: + print("No matching leaders") + return 1 + for index, leader in selected: + leader_id = leader.get("id", f"leader-{index + 1}") + url = config.api_base_for(leader, index) + path + try: + result = request_json("POST", url, payload) + print(f"{leader_id}: {json.dumps(result, sort_keys=True)}") + except (urllib.error.URLError, TimeoutError, json.JSONDecodeError) as exc: + ok = False + print(f"{leader_id}: failed ({exc})") + return 0 if ok else 1 + + +def cmd_dashboard(config: FleetConfig, host: str, port: int) -> int: + try: + from team_status_server import run_team_status_server + except ImportError: + from bot_fleet_manager.team_status_server import run_team_status_server + + run_team_status_server(dashboard_targets(config), host=host, port=port) + return 0 + + +def main(argv: list[str]) -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("config", type=Path) + sub = parser.add_subparsers(dest="command", required=True) + sub.add_parser("start") + supervise_parser = sub.add_parser("supervise") + supervise_parser.add_argument("--dashboard", action="store_true", help="Also start the team status dashboard") + supervise_parser.add_argument("--dashboard-port", type=int, default=8780, help="Dashboard port") + supervise_parser.add_argument("--dashboard-host", default="127.0.0.1", help="Dashboard bind address") + status_parser = sub.add_parser("status") + status_parser.add_argument("--fleet", default="") + status_parser.add_argument("--leader", action="append", default=[]) + stop_parser = sub.add_parser("stop") + stop_parser.add_argument("--fleet", default="") + stop_parser.add_argument("--leader", action="append", default=[]) + command_parser = sub.add_parser("command") + command_parser.add_argument("--fleet", default="") + command_parser.add_argument("--leader", action="append", default=[]) + command_parser.add_argument("text") + goto_parser = sub.add_parser("goto") + goto_parser.add_argument("--fleet", default="") + goto_parser.add_argument("--leader", action="append", default=[]) + goto_parser.add_argument("mapId", type=int) + goto_parser.add_argument("x", type=float) + goto_parser.add_argument("y", type=float) + goto_parser.add_argument("z", type=float) + goto_parser.add_argument("--arrival-radius", type=float, default=3.0) + dashboard_parser = sub.add_parser("dashboard", help="Start textual team status dashboard") + dashboard_parser.add_argument("--port", type=int, default=8780, help="Dashboard port") + dashboard_parser.add_argument("--host", default="127.0.0.1", help="Dashboard bind address") + + args = parser.parse_args(argv) + config = FleetConfig(resolve_path(str(args.config))) + + if args.command == "start": + return cmd_start(config) + if args.command == "supervise": + return cmd_supervise(config, dashboard=args.dashboard, dashboard_host=args.dashboard_host, dashboard_port=args.dashboard_port) + if args.command == "status": + return cmd_status(config, args.fleet, args.leader) + if args.command == "stop": + return post_all(config, "/movement/stop", {"reason": "fleet stop"}, args.fleet, args.leader) + if args.command == "command": + return post_all(config, "/commands", {"command": args.text}, args.fleet, args.leader) + if args.command == "goto": + return post_all(config, "/movement/goto", { + "mapId": args.mapId, + "x": args.x, + "y": args.y, + "z": args.z, + "arrivalRadius": args.arrival_radius, + }, args.fleet, args.leader) + if args.command == "dashboard": + return cmd_dashboard(config, args.host, args.port) + return 2 + + +if __name__ == "__main__": + raise SystemExit(main(sys.argv[1:])) diff --git a/tools/bot_fleet_manager/fleet.settings.example.json b/tools/bot_fleet_manager/fleet.settings.example.json new file mode 100644 index 000000000..6e718a642 --- /dev/null +++ b/tools/bot_fleet_manager/fleet.settings.example.json @@ -0,0 +1,61 @@ +{ + "woweeHeadless": "build/bin/wowee_headless.exe", + "runtimeDir": "tools/bot_fleet_manager/runtime", + "launchDelaySeconds": 2.0, + "supervision": { + "initialRestartBackoffSeconds": 5.0, + "maxRestartBackoffSeconds": 60.0, + "maxRestarts": 0 + }, + "defaults": { + "auth": { + "host": "127.0.0.1", + "port": 3724 + }, + "client": { + "major": 2, + "minor": 4, + "patch": 3, + "build": 8606, + "protocol": 8, + "locale": "enUS", + "platform": "x86", + "os": "Win", + "expansion": "tbc" + }, + "realm": { + "name": "", + "index": 0, + "id": 0 + }, + "api": { + "bind": "127.0.0.1", + "basePort": 8787, + "maxMessages": 500 + }, + "automation": { + "commandDelaySeconds": 0.25, + "onEnterWorldCommands": [] + } + }, + "leaders": [ + { + "id": "leader-1", + "fleet": "alpha", + "account": "ACCOUNT_1", + "password": "PASSWORD_1", + "character": "Leaderone", + "apiPort": 8787, + "partyBots": ["Botone", "Bottwo", "Botthree", "Botfour"] + }, + { + "id": "leader-2", + "fleet": "beta", + "account": "ACCOUNT_2", + "password": "PASSWORD_2", + "character": "Leadertwo", + "apiPort": 8788, + "partyBots": [] + } + ] +} diff --git a/tools/bot_fleet_manager/import_headless_settings.py b/tools/bot_fleet_manager/import_headless_settings.py new file mode 100644 index 000000000..1e50814f4 --- /dev/null +++ b/tools/bot_fleet_manager/import_headless_settings.py @@ -0,0 +1,146 @@ +#!/usr/bin/env python3 +"""Create a one-leader fleet config from an existing wowee_headless settings file.""" + +from __future__ import annotations + +import argparse +import json +from pathlib import Path +from typing import Any + + +ROOT = Path(__file__).resolve().parents[2] + + +def load_json(path: Path) -> dict[str, Any]: + with path.open("r", encoding="utf-8") as handle: + doc = json.load(handle) + if not isinstance(doc, dict): + raise ValueError("settings root must be a JSON object") + return doc + + +def write_json(path: Path, doc: dict[str, Any]) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + with path.open("w", encoding="utf-8") as handle: + json.dump(doc, handle, indent=2) + handle.write("\n") + + +def resolve_path(raw: str) -> Path: + path = Path(raw) + return path if path.is_absolute() else ROOT / path + + +def bot_name_from_command(command: str) -> str: + lowered = command.strip().lower() + if not lowered.startswith(".bot add "): + return "" + return command.strip()[len(".bot add "):].strip() + + +def build_fleet_config(settings: dict[str, Any], args: argparse.Namespace) -> dict[str, Any]: + auth = settings.get("auth", {}) + client = settings.get("client", {}) + realm = settings.get("realm", {}) + character = settings.get("character", {}) + api = settings.get("api", {}) + bots = settings.get("bots", {}) + automation = settings.get("automation", {}) + + party_bots: list[str] = [] + if isinstance(bots, dict) and bots.get("enabled", False): + for name in bots.get("names", []): + if isinstance(name, str) and name.strip(): + party_bots.append(name.strip()) + + startup_commands: list[str] = [] + for command in automation.get("onEnterWorldCommands", []) if isinstance(automation, dict) else []: + if not isinstance(command, str) or not command.strip(): + continue + bot_name = bot_name_from_command(command) + if bot_name: + party_bots.append(bot_name) + else: + startup_commands.append(command.strip()) + + seen: set[str] = set() + unique_party_bots = [] + for name in party_bots: + key = name.lower() + if key in seen: + continue + seen.add(key) + unique_party_bots.append(name) + + return { + "woweeHeadless": args.wowee_headless, + "runtimeDir": "tools/bot_fleet_manager/runtime", + "launchDelaySeconds": 2.0, + "supervision": { + "initialRestartBackoffSeconds": 5.0, + "maxRestartBackoffSeconds": 60.0, + "maxRestarts": 0, + }, + "defaults": { + "auth": { + "host": auth.get("host", "127.0.0.1"), + "port": int(auth.get("port", 3724)), + }, + "client": client, + "realm": realm, + "api": { + "bind": api.get("bind", "127.0.0.1"), + "basePort": int(api.get("port", 8787)), + "maxMessages": int(api.get("maxMessages", 500)), + }, + "automation": { + "commandDelaySeconds": float(automation.get("commandDelaySeconds", 0.25)) if isinstance(automation, dict) else 0.25, + "onEnterWorldCommands": [], + }, + }, + "leaders": [ + { + "id": args.leader_id, + "fleet": args.fleet, + "account": auth.get("account", ""), + "password": auth.get("password", ""), + "character": character.get("name", ""), + "apiPort": int(api.get("port", 8787)), + "partyBots": unique_party_bots, + "startupCommands": startup_commands, + } + ], + } + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--input", default="tools/headless_client/settings.json", help="Existing headless settings JSON") + parser.add_argument("--output", default="tools/bot_fleet_manager/fleet.settings.json", help="Local fleet config to write") + parser.add_argument("--wowee-headless", default="build/bin/wowee_headless.exe") + parser.add_argument("--leader-id", default="demo-leader-1") + parser.add_argument("--fleet", default="demo") + parser.add_argument("--dry-run", action="store_true") + args = parser.parse_args() + + fleet = build_fleet_config(load_json(resolve_path(args.input)), args) + if args.dry_run: + redacted = json.loads(json.dumps(fleet)) + for leader in redacted.get("leaders", []): + if "password" in leader: + leader["password"] = "" + if "account" in leader: + leader["account"] = "" + print(json.dumps(redacted, indent=2)) + return 0 + + output = resolve_path(args.output) + write_json(output, fleet) + print(f"Wrote {output}") + print("This file may contain account credentials and is ignored by git.") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tools/bot_fleet_manager/team_status_server.py b/tools/bot_fleet_manager/team_status_server.py new file mode 100644 index 000000000..38919b29b --- /dev/null +++ b/tools/bot_fleet_manager/team_status_server.py @@ -0,0 +1,213 @@ +#!/usr/bin/env python3 +"""Small web dashboard for textual WoWee fleet visibility.""" + +from __future__ import annotations + +import html +import json +import time +import urllib.error +import urllib.request +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer +from typing import Any + + +def _request_json(url: str, timeout: float = 1.5) -> dict[str, Any]: + with urllib.request.urlopen(url, timeout=timeout) as response: + return json.loads(response.read().decode("utf-8")) + + +def _safe_get(doc: dict[str, Any], *path: str, default: Any = "") -> Any: + value: Any = doc + for key in path: + if not isinstance(value, dict): + return default + value = value.get(key, default) + return value + + +def _summarize_activity(status: dict[str, Any]) -> str: + movement = status.get("movement", {}) + state = str(movement.get("state") or status.get("status") or "unknown") + if state == "moving": + index = movement.get("waypointIndex", 0) + count = movement.get("waypointCount", 0) + target = movement.get("target", {}) + return f"moving to {target.get('x', '?')}, {target.get('y', '?')}, {target.get('z', '?')} ({index}/{count})" + if movement.get("error"): + return f"{state}: {movement.get('error')}" + combat = status.get("combat", {}) + if combat.get("inCombat"): + return "in combat" + health = status.get("health", {}) + if health.get("isDead") or health.get("isPlayerDead"): + return "dead" + return state + + +def collect_team_status(api_bases: list[dict[str, str]]) -> dict[str, Any]: + teams: list[dict[str, Any]] = [] + collected_at = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()) + + for item in api_bases: + leader_id = item["id"] + base = item["base"].rstrip("/") + team: dict[str, Any] = { + "id": leader_id, + "fleet": item.get("fleet", ""), + "apiBase": base, + "ok": False, + "activity": "unavailable", + } + try: + status = _request_json(base + "/status") + world = _request_json(base + "/world/self") + party = _request_json(base + "/party") + chat = _request_json(base + "/chat?after=0&limit=8") + team.update({ + "ok": True, + "status": status, + "world": world, + "party": party, + "recentChat": chat.get("messages", [])[-8:], + "activity": _summarize_activity(status), + }) + except (OSError, urllib.error.URLError, TimeoutError, json.JSONDecodeError) as exc: + team["error"] = str(exc) + teams.append(team) + + return {"collectedAt": collected_at, "teams": teams} + + +def render_dashboard(title: str) -> bytes: + escaped_title = html.escape(title) + page = f""" + + + + + {escaped_title} + + + +
+

{escaped_title}

+
loading...
+
+
+ + + + + + + + + + + + +
TeamStatusLocationActivityPartyRecent Chat
+
+ + +""" + return page.encode("utf-8") + + +def run_team_status_server(api_bases: list[dict[str, str]], host: str = "127.0.0.1", port: int = 8780) -> None: + class Handler(BaseHTTPRequestHandler): + def log_message(self, fmt: str, *args: Any) -> None: + return + + def _send(self, status: int, body: bytes, content_type: str) -> None: + self.send_response(status) + self.send_header("Content-Type", content_type) + self.send_header("Content-Length", str(len(body))) + self.end_headers() + self.wfile.write(body) + + def do_GET(self) -> None: + if self.path == "/" or self.path.startswith("/?"): + self._send(200, render_dashboard("WoWee Team Status"), "text/html; charset=utf-8") + return + if self.path == "/api/teams": + body = json.dumps(collect_team_status(api_bases)).encode("utf-8") + self._send(200, body, "application/json") + return + self._send(404, b"not found", "text/plain; charset=utf-8") + + server = ThreadingHTTPServer((host, port), Handler) + print(f"Team status dashboard listening on http://{host}:{port}") + server.serve_forever() diff --git a/tools/headless_client/README.md b/tools/headless_client/README.md index a3c7e3049..784e47405 100644 --- a/tools/headless_client/README.md +++ b/tools/headless_client/README.md @@ -58,6 +58,93 @@ Status: curl http://127.0.0.1:8787/status ``` +World self: + +```bash +curl http://127.0.0.1:8787/world/self +``` + +Party: + +```bash +curl http://127.0.0.1:8787/party +``` + +Queue a raw command: + +```bash +curl -X POST http://127.0.0.1:8787/commands \ + -H "Content-Type: application/json" \ + -d "{\"command\":\".bot follow\"}" +``` + +Move the leader toward coordinates on the current map: + +```bash +curl -X POST http://127.0.0.1:8787/movement/goto \ + -H "Content-Type: application/json" \ + -d "{\"mapId\":530,\"x\":-248.0,\"y\":951.0,\"z\":84.0,\"arrivalRadius\":3.0}" +``` + +Move through a sequence of waypoints: + +```bash +curl -X POST http://127.0.0.1:8787/movement/goto/waypoints \ + -H "Content-Type: application/json" \ + -d '{ + "mapId": 0, + "arrivalRadius": 5.0, + "waypoints": [ + {"x": -8970, "y": 610, "z": 95}, + {"x": -9050, "y": 520, "z": 92}, + {"x": -9465, "y": 62, "z": 56} + ] + }' +``` + +### Coordinate Convention + +The headless client's position and movement API use **game-world coordinates `(game.x, game.y)`**: +- `game.x` = north-south axis (positive = north) +- `game.y` = east-west axis (positive = east) + +The `tools.pathfinding.pathfinder` uses **canonical ADT coordinates `(wx, wy)`** where: +- `wx` = east-west (= headless `position.y`) +- `wy` = north-south (= headless `position.x`) + +When using the pathfinder output, swap axes before sending to the movement API: +```python +# pathfinder returns (wx, wy) +headless_waypoint = {"x": wy, "y": wx, "z": wz} +``` + +The `travel_demo.py --pathfind` flag handles this conversion automatically. + +Each waypoint can optionally override the arrival radius: + +```json +{"x": -8970, "y": 610, "z": 95, "arrivalRadius": 8.0} +``` + +Stop leader movement: + +```bash +curl -X POST http://127.0.0.1:8787/movement/stop \ + -H "Content-Type: application/json" \ + -d "{\"reason\":\"manual stop\"}" +``` + +The `GET /status` endpoint reports waypoint progress: + +```json +"movement": { + "state": "moving", + "waypointIndex": 2, + "waypointCount": 3, + ... +} +``` + Read chat: ```bash diff --git a/tools/headless_client/main.cpp b/tools/headless_client/main.cpp index 4402daede..49cd222d6 100644 --- a/tools/headless_client/main.cpp +++ b/tools/headless_client/main.cpp @@ -31,6 +31,7 @@ #include #include #include +#include #ifdef _WIN32 #include @@ -90,6 +91,9 @@ struct Settings { std::vector onEnterWorldCommands; float onEnterCommandDelaySeconds = 0.25f; + + std::optional createCharacter; + bool exitAfterCreateCharacter = false; }; struct PendingChat { @@ -98,6 +102,43 @@ struct PendingChat { std::string target; }; +struct Waypoint { + float x = 0.0f; + float y = 0.0f; + float z = 0.0f; + float arrivalRadius = 0.0f; +}; + +struct MovementTask { + bool active = false; + bool started = false; + uint32_t mapId = 0; + std::vector waypoints; + size_t currentWaypoint = 0; + float arrivalRadius = 3.0f; + std::string status = "idle"; + std::string error; + float lastSetX = 0.0f; + float lastSetY = 0.0f; + float lastSetZ = 0.0f; + bool lastSetValid = false; + + float targetX() const { + return currentWaypoint < waypoints.size() ? waypoints[currentWaypoint].x : 0.0f; + } + float targetY() const { + return currentWaypoint < waypoints.size() ? waypoints[currentWaypoint].y : 0.0f; + } + float targetZ() const { + return currentWaypoint < waypoints.size() ? waypoints[currentWaypoint].z : 0.0f; + } + float targetRadius() const { + if (currentWaypoint < waypoints.size() && waypoints[currentWaypoint].arrivalRadius > 0.0f) + return waypoints[currentWaypoint].arrivalRadius; + return arrivalRadius; + } +}; + std::string lowerAscii(std::string value) { std::transform(value.begin(), value.end(), value.begin(), [](unsigned char c) { return static_cast(std::tolower(c)); }); @@ -117,6 +158,10 @@ std::string detectExpansion(uint32_t build) { return "wotlk"; } +std::optional raceFromString(std::string value); +std::optional classFromString(std::string value); +std::optional genderFromString(std::string value); + template T jsonValue(const json& obj, const char* key, T fallback) { if (!obj.is_object() || !obj.contains(key) || obj.at(key).is_null()) return fallback; @@ -201,6 +246,32 @@ Settings loadSettings(const std::string& path) { } } + const auto provisionObj = doc.value("provision", json::object()); + if (provisionObj.is_object()) { + const auto createObj = provisionObj.value("createCharacter", json::object()); + if (createObj.is_object() && jsonValue(createObj, "enabled", false)) { + game::CharCreateData data; + data.name = trim(jsonValue(createObj, "name", "")); + const auto race = raceFromString(jsonValue(createObj, "race", "")); + const auto cls = classFromString(jsonValue(createObj, "class", "")); + const auto gender = genderFromString(jsonValue(createObj, "gender", "male")); + if (data.name.empty()) throw std::runtime_error("provision.createCharacter.name is required"); + if (!race) throw std::runtime_error("provision.createCharacter.race is invalid"); + if (!cls) throw std::runtime_error("provision.createCharacter.class is invalid"); + if (!gender) throw std::runtime_error("provision.createCharacter.gender is invalid"); + data.race = *race; + data.characterClass = *cls; + data.gender = *gender; + data.skin = static_cast(std::clamp(jsonValue(createObj, "skin", 0), 0, 255)); + data.face = static_cast(std::clamp(jsonValue(createObj, "face", 0), 0, 255)); + data.hairStyle = static_cast(std::clamp(jsonValue(createObj, "hairStyle", 0), 0, 255)); + data.hairColor = static_cast(std::clamp(jsonValue(createObj, "hairColor", 0), 0, 255)); + data.facialHair = static_cast(std::clamp(jsonValue(createObj, "facialHair", 0), 0, 255)); + s.createCharacter = data; + s.exitAfterCreateCharacter = jsonValue(createObj, "exitAfterCreate", true); + } + } + return s; } @@ -229,6 +300,47 @@ std::string chatTypeName(game::ChatType type) { return game::getChatTypeString(type); } +std::optional raceFromString(std::string value) { + value = lowerAscii(trim(std::move(value))); + if (value == "human" || value == "1") return game::Race::HUMAN; + if (value == "orc" || value == "2") return game::Race::ORC; + if (value == "dwarf" || value == "3") return game::Race::DWARF; + if (value == "night_elf" || value == "nightelf" || value == "night elf" || value == "4") return game::Race::NIGHT_ELF; + if (value == "undead" || value == "5") return game::Race::UNDEAD; + if (value == "tauren" || value == "6") return game::Race::TAUREN; + if (value == "gnome" || value == "7") return game::Race::GNOME; + if (value == "troll" || value == "8") return game::Race::TROLL; + if (value == "blood_elf" || value == "bloodelf" || value == "blood elf" || value == "10") return game::Race::BLOOD_ELF; + if (value == "draenei" || value == "11") return game::Race::DRAENEI; + return std::nullopt; +} + +std::optional classFromString(std::string value) { + value = lowerAscii(trim(std::move(value))); + if (value == "warrior" || value == "1") return game::Class::WARRIOR; + if (value == "paladin" || value == "2") return game::Class::PALADIN; + if (value == "hunter" || value == "3") return game::Class::HUNTER; + if (value == "rogue" || value == "4") return game::Class::ROGUE; + if (value == "priest" || value == "5") return game::Class::PRIEST; + if (value == "shaman" || value == "7") return game::Class::SHAMAN; + if (value == "mage" || value == "8") return game::Class::MAGE; + if (value == "warlock" || value == "9") return game::Class::WARLOCK; + if (value == "druid" || value == "11") return game::Class::DRUID; + return std::nullopt; +} + +std::optional genderFromString(std::string value) { + value = lowerAscii(trim(std::move(value))); + if (value == "male" || value == "m" || value == "0") return game::Gender::MALE; + if (value == "female" || value == "f" || value == "1") return game::Gender::FEMALE; + return std::nullopt; +} + +std::string movementTaskStateName(const MovementTask& task) { + if (task.active) return "moving"; + return task.status.empty() ? "idle" : task.status; +} + std::string timeToIso(std::chrono::system_clock::time_point tp) { auto time = std::chrono::system_clock::to_time_t(tp); std::tm tm{}; @@ -279,6 +391,17 @@ class HeadlessSession { game_.setOnFailure([this](const std::string& reason) { fail("World failed: " + reason); }); + game_.setCharCreateCallback([this](bool success, const std::string& message) { + std::cout << "Character create result: " << message << "\n"; + if (success) { + charCreateDone_ = true; + if (settings_.exitAfterCreateCharacter) { + g_running = false; + } + } else { + fail("Character create failed: " + message); + } + }); if (!auth_.connect(settings_.authHost, settings_.authPort)) { fail("Could not connect to auth server"); @@ -301,6 +424,10 @@ class HeadlessSession { inWorldForApi_ = (game_.getState() == game::WorldState::IN_WORLD); if (!selectedCharacter_ && game_.getState() == game::WorldState::CHAR_LIST_RECEIVED) { + if (settings_.createCharacter) { + provisionConfiguredCharacter(); + return; + } selectConfiguredCharacter(); } @@ -319,6 +446,7 @@ class HeadlessSession { if (!logoutRequested_) { drainScheduledCommands(deltaSeconds); drainPendingChat(); + updateMovementTask(deltaSeconds); } syncChatSnapshot(); updateLogoutExit(); @@ -327,14 +455,188 @@ class HeadlessSession { bool isFailed() const { return failed_.load(); } bool isInWorld() const { return inWorldForApi_.load(); } bool isLogoutRequested() const { return logoutRequested_.load(); } + json statusJson() const { + std::lock_guard lock(stateMutex_); + bool inCombat = false, isDead = false, isPlayerDead = false; + uint32_t hp = 0, maxHp = 0; + if (isInWorld()) { + auto playerEntity = game_.getEntityManager().getEntity(game_.getPlayerGuid()); + if (playerEntity) { + if (auto* unit = dynamic_cast(playerEntity.get())) { + hp = unit->getHealth(); + maxHp = unit->getMaxHealth(); + } + } + inCombat = game_.isInCombat(); + isDead = game_.isDead(); + isPlayerDead = game_.isPlayerDead(); + } + return { + {"status", statusUnlocked()}, + {"inWorld", isInWorld()}, + {"movement", movementTaskToJsonLocked()}, + {"combat", {{"inCombat", inCombat}}}, + {"health", {{"current", hp}, {"max", maxHp}, {"isDead", isDead}, {"isPlayerDead", isPlayerDead}}} + }; + } std::string status() const { - if (failed_) { - std::lock_guard lock(stateMutex_); - return "failed: " + failureReason_; + std::lock_guard lock(stateMutex_); + return statusUnlocked(); + } + + json worldSelf() const { + const auto& move = game_.getMovementInfo(); + json character = json::object(); + if (const auto* ch = game_.getActiveCharacter()) { + character = { + {"name", ch->name}, + {"guid", ch->guid}, + {"race", static_cast(ch->race)}, + {"class", static_cast(ch->characterClass)}, + {"level", ch->level} + }; } - if (logoutRequested_) return "logging_out"; - if (isInWorld()) return "in_world"; - return "connecting"; + +uint32_t hp = 0, maxHp = 0; + { + auto playerEntity = game_.getEntityManager().getEntity(game_.getPlayerGuid()); + if (playerEntity) { + if (auto* unit = dynamic_cast(playerEntity.get())) { + hp = unit->getHealth(); + maxHp = unit->getMaxHealth(); + } + } + } + + std::lock_guard lock(stateMutex_); + return { + {"status", statusUnlocked()}, + {"inWorld", isInWorld()}, + {"character", character}, + {"playerGuid", game_.getPlayerGuid()}, + {"mapId", game_.getCurrentMapId()}, + {"position", {{"x", move.x}, {"y", move.y}, {"z", move.z}}}, + {"orientation", move.orientation}, + {"movementFlags", move.flags}, + {"movementFlags2", move.flags2}, + {"runSpeed", game_.getServerRunSpeed()}, + {"movement", movementTaskToJsonLocked()}, + {"combat", {{"inCombat", game_.isInCombat()}}}, + {"health", {{"current", hp}, {"max", maxHp}, {"isDead", game_.isDead()}, {"isPlayerDead", game_.isPlayerDead()}}} + }; + } + + json partyJson() const { + const auto& party = game_.getPartyData(); + json members = json::array(); + for (const auto& member : party.members) { + members.push_back({ + {"name", member.name}, + {"guid", member.guid}, + {"isOnline", member.isOnline != 0}, + {"subGroup", member.subGroup}, + {"flags", member.flags}, + {"roles", member.roles}, + {"level", member.level}, + {"zoneId", member.zoneId}, + {"hasPartyStats", member.hasPartyStats} + }); + } + return { + {"status", status()}, + {"inGroup", !party.isEmpty()}, + {"groupType", party.groupType}, + {"memberCount", party.memberCount}, + {"leaderGuid", party.leaderGuid}, + {"members", members} + }; + } + + json queueCommand(const std::string& command) { + const std::string trimmed = trim(command); + if (trimmed.empty()) { + return {{"ok", false}, {"error", "command is required"}}; + } + PendingChat msg; + msg.type = game::ChatType::SAY; + msg.message = trimmed; + enqueueChat(std::move(msg)); + return {{"ok", true}}; + } + + json startGoto(uint32_t mapId, float x, float y, float z, float arrivalRadius) { + std::lock_guard lock(stateMutex_); + if (!isInWorld()) { + return {{"ok", false}, {"error", "not in world"}}; + } + if (mapId != 0 && mapId != game_.getCurrentMapId()) { + return {{"ok", false}, {"error", "map mismatch"}, {"currentMapId", game_.getCurrentMapId()}}; + } + if (!std::isfinite(x) || !std::isfinite(y) || !std::isfinite(z)) { + return {{"ok", false}, {"error", "coordinates must be finite"}}; + } + if (!std::isfinite(arrivalRadius) || arrivalRadius <= 0.0f) { + arrivalRadius = 3.0f; + } + if (game_.isPlayerRooted()) { + return {{"ok", false}, {"error", "movement_locked: player is rooted"}}; + } + if (!game_.isServerMovementAllowed()) { + return {{"ok", false}, {"error", "movement_locked: server movement not allowed"}}; + } + if (game_.isOnTaxiFlight()) { + return {{"ok", false}, {"error", "movement_locked: on taxi flight"}}; + } + + Waypoint wp; + wp.x = x; + wp.y = y; + wp.z = z; + wp.arrivalRadius = std::max(0.5f, arrivalRadius); + + movementTask_ = MovementTask{}; + movementTask_.active = true; + movementTask_.mapId = mapId == 0 ? game_.getCurrentMapId() : mapId; + movementTask_.arrivalRadius = std::max(0.5f, arrivalRadius); + movementTask_.waypoints.push_back(wp); + movementTask_.status = "moving"; + return {{"ok", true}, {"movement", movementTaskToJsonLocked()}}; + } + + json startGotoWaypoints(uint32_t mapId, const std::vector& waypoints, float defaultRadius) { + std::lock_guard lock(stateMutex_); + if (!isInWorld()) { + return {{"ok", false}, {"error", "not in world"}}; + } + if (waypoints.empty()) { + return {{"ok", false}, {"error", "waypoints list is empty"}}; + } + if (!std::isfinite(defaultRadius) || defaultRadius <= 0.0f) { + defaultRadius = 3.0f; + } + if (game_.isPlayerRooted()) { + return {{"ok", false}, {"error", "movement_locked: player is rooted"}}; + } + if (!game_.isServerMovementAllowed()) { + return {{"ok", false}, {"error", "movement_locked: server movement not allowed"}}; + } + if (game_.isOnTaxiFlight()) { + return {{"ok", false}, {"error", "movement_locked: on taxi flight"}}; + } + + movementTask_ = MovementTask{}; + movementTask_.active = true; + movementTask_.mapId = mapId == 0 ? game_.getCurrentMapId() : mapId; + movementTask_.arrivalRadius = std::max(0.5f, defaultRadius); + movementTask_.waypoints = waypoints; + movementTask_.status = "moving"; + return {{"ok", true}, {"movement", movementTaskToJsonLocked()}}; + } + + json stopMovementTask(const std::string& reason = "stopped") { + std::lock_guard lock(stateMutex_); + stopMovementTaskLocked(reason, ""); + return {{"ok", true}, {"movement", movementTaskToJsonLocked()}}; } void requestGracefulLogout() { @@ -372,6 +674,30 @@ class HeadlessSession { } private: + std::string statusUnlocked() const { + if (failed_) return "failed: " + failureReason_; + if (logoutRequested_) return "logging_out"; + if (isInWorld()) return "in_world"; + return "connecting"; + } + + json movementTaskToJsonLocked() const { + const size_t total = movementTask_.waypoints.size(); + const size_t index = movementTask_.currentWaypoint; + return { + {"state", movementTaskStateName(movementTask_)}, + {"active", movementTask_.active}, + {"mapId", movementTask_.mapId}, + {"x", movementTask_.targetX()}, + {"y", movementTask_.targetY()}, + {"z", movementTask_.targetZ()}, + {"arrivalRadius", movementTask_.targetRadius()}, + {"error", movementTask_.error}, + {"waypointIndex", total > 0 ? static_cast(index + 1) : 0ull}, + {"waypointCount", static_cast(total)} + }; + } + game::MessageChatData resolveChatSender(const game::MessageChatData& msg) const { if (!msg.senderName.empty() || msg.senderGuid == 0) return msg; @@ -478,6 +804,26 @@ class HeadlessSession { game_.selectCharacter(chosen->guid); } + void provisionConfiguredCharacter() { + if (charCreateRequested_ || charCreateDone_) return; + const auto& data = *settings_.createCharacter; + const std::string want = lowerAscii(data.name); + for (const auto& ch : game_.getCharacters()) { + if (lowerAscii(ch.name) == want) { + std::cout << "Character already exists: " << data.name << "\n"; + charCreateDone_ = true; + if (settings_.exitAfterCreateCharacter) { + g_running = false; + } + return; + } + } + + charCreateRequested_ = true; + std::cout << "Creating character " << data.name << "\n"; + game_.createCharacter(data); + } + void drainPendingChat() { if (!isInWorld()) return; @@ -518,6 +864,117 @@ class HeadlessSession { nextScheduledCommandDelay_ = settings_.onEnterCommandDelaySeconds; } + void stopMovementTaskLocked(const std::string& status, const std::string& error) { + if (movementTask_.active || movementTask_.started) { + game_.sendMovement(game::Opcode::MSG_MOVE_STOP); + game_.sendMovement(game::Opcode::MSG_MOVE_HEARTBEAT); + } + movementTask_.active = false; + movementTask_.started = false; + movementTask_.status = status; + movementTask_.error = error; + movementTask_.lastSetValid = false; + } + + void advanceToNextWaypointLocked() { + if (movementTask_.currentWaypoint + 1 < movementTask_.waypoints.size()) { + ++movementTask_.currentWaypoint; + movementTask_.lastSetValid = false; + if (movementTask_.currentWaypoint + 1 == movementTask_.waypoints.size()) { + std::cout << "Waypoint " << (movementTask_.currentWaypoint + 1) + << "/" << movementTask_.waypoints.size() << " (final)\n"; + } else { + std::cout << "Waypoint " << (movementTask_.currentWaypoint + 1) + << "/" << movementTask_.waypoints.size() << "\n"; + } + } else { + stopMovementTaskLocked("arrived", ""); + } + } + + void updateMovementTask(float deltaSeconds) { + std::lock_guard lock(stateMutex_); + if (!movementTask_.active) return; + + if (!isInWorld()) { + stopMovementTaskLocked("failed", "not in world"); + return; + } + if (movementTask_.mapId != 0 && movementTask_.mapId != game_.getCurrentMapId()) { + stopMovementTaskLocked("failed", "map changed"); + return; + } + if (game_.isPlayerRooted()) { + stopMovementTaskLocked("movement_locked", "player is rooted"); + return; + } + if (!game_.isServerMovementAllowed()) { + stopMovementTaskLocked("movement_locked", "server movement not allowed"); + return; + } + if (game_.isOnTaxiFlight()) { + stopMovementTaskLocked("movement_locked", "on taxi flight"); + return; + } + + if (movementTask_.waypoints.empty()) { + stopMovementTaskLocked("failed", "no waypoints"); + return; + } + + const auto& move = game_.getMovementInfo(); + + if (movementTask_.lastSetValid) { + const float resetDx = move.x - movementTask_.lastSetX; + const float resetDy = move.y - movementTask_.lastSetY; + const float resetDz = move.z - movementTask_.lastSetZ; + const float resetDist = std::sqrt(resetDx * resetDx + resetDy * resetDy + resetDz * resetDz); + if (resetDist > 1.0f) { + stopMovementTaskLocked("movement_locked", "server reset position"); + return; + } + } + + const float tx = movementTask_.targetX(); + const float ty = movementTask_.targetY(); + const float tz = movementTask_.targetZ(); + const float radius = movementTask_.targetRadius(); + + const float dx = tx - move.x; + const float dy = ty - move.y; + const float dz = tz - move.z; + const float horizontalDist = std::sqrt(dx * dx + dy * dy); + const float distance = std::sqrt(dx * dx + dy * dy + dz * dz); + + if (distance <= radius) { + game_.setPosition(tx, ty, tz); + advanceToNextWaypointLocked(); + return; + } + + if (horizontalDist > 0.001f) { + game_.setOrientation(std::atan2(-dy, dx)); + game_.sendMovement(game::Opcode::MSG_MOVE_SET_FACING); + } + if (!movementTask_.started) { + game_.sendMovement(game::Opcode::MSG_MOVE_START_FORWARD); + movementTask_.started = true; + } + + const float speed = std::max(0.1f, game_.getServerRunSpeed()); + const float step = std::min(distance, speed * std::max(0.0f, deltaSeconds)); + const float t = distance > 0.001f ? (step / distance) : 1.0f; + const float newX = move.x + dx * t; + const float newY = move.y + dy * t; + const float newZ = move.z + dz * t; + game_.setPosition(newX, newY, newZ); + movementTask_.lastSetX = newX; + movementTask_.lastSetY = newY; + movementTask_.lastSetZ = newZ; + movementTask_.lastSetValid = true; + game_.sendMovement(game::Opcode::MSG_MOVE_HEARTBEAT); + } + void syncChatSnapshot() { std::lock_guard lock(chatMutex_); syncChatSnapshotLocked(); @@ -588,6 +1045,8 @@ class HeadlessSession { std::string failureReason_; bool selectedCharacter_ = false; bool enteredWorld_ = false; + bool charCreateRequested_ = false; + bool charCreateDone_ = false; std::mutex pendingMutex_; std::deque pendingChat_; @@ -599,6 +1058,7 @@ class HeadlessSession { size_t chatBaseId_ = 1; size_t nextChatId_ = 1; size_t lastGameHistorySize_ = 0; + MovementTask movementTask_; std::atomic logoutRequested_{false}; std::chrono::steady_clock::time_point logoutRequestedAt_{}; }; @@ -694,7 +1154,11 @@ void handleHttpClient(socket_t client, HeadlessSession& session) { try { if (method == "GET" && path == "/status") { - sendHttp(client, 200, {{"status", session.status()}, {"inWorld", session.isInWorld()}}); + sendHttp(client, 200, session.statusJson()); + } else if (method == "GET" && path == "/world/self") { + sendHttp(client, 200, session.worldSelf()); + } else if (method == "GET" && path == "/party") { + sendHttp(client, 200, session.partyJson()); } else if (method == "GET" && path == "/chat") { const size_t after = queryParam(query, "after").empty() ? 0 : static_cast(std::stoull(queryParam(query, "after"))); const size_t limit = queryParam(query, "limit").empty() ? 100 : static_cast(std::stoull(queryParam(query, "limit"))); @@ -718,6 +1182,48 @@ void handleHttpClient(socket_t client, HeadlessSession& session) { sendHttp(client, 200, {{"ok", true}}); } } + } else if (method == "POST" && path == "/commands") { + json payload = json::parse(body.empty() ? "{}" : body); + if (payload.contains("commands") && payload["commands"].is_array()) { + json results = json::array(); + for (const auto& item : payload["commands"]) { + results.push_back(session.queueCommand(item.is_string() ? item.get() : "")); + } + sendHttp(client, 200, {{"ok", true}, {"results", results}}); + } else { + const auto result = session.queueCommand(payload.value("command", payload.value("message", ""))); + sendHttp(client, result.value("ok", false) ? 200 : 400, result); + } + } else if (method == "POST" && path == "/movement/goto/waypoints") { + json payload = json::parse(body.empty() ? "{}" : body); + const uint32_t mapId = static_cast(std::max(0, payload.value("mapId", 0))); + const float defaultRadius = payload.value("arrivalRadius", 3.0f); + std::vector waypoints; + for (const auto& wpJson : payload.value("waypoints", json::array())) { + if (!wpJson.is_object()) continue; + Waypoint wp; + wp.x = wpJson.value("x", 0.0f); + wp.y = wpJson.value("y", 0.0f); + wp.z = wpJson.value("z", 0.0f); + wp.arrivalRadius = wpJson.value("arrivalRadius", 0.0f); + if (std::isfinite(wp.x) && std::isfinite(wp.y) && std::isfinite(wp.z)) { + waypoints.push_back(wp); + } + } + const auto result = session.startGotoWaypoints(mapId, waypoints, defaultRadius); + sendHttp(client, result.value("ok", false) ? 200 : 400, result); + } else if (method == "POST" && path == "/movement/goto") { + json payload = json::parse(body.empty() ? "{}" : body); + const uint32_t mapId = static_cast(std::max(0, payload.value("mapId", 0))); + const float x = payload.value("x", 0.0f); + const float y = payload.value("y", 0.0f); + const float z = payload.value("z", 0.0f); + const float arrivalRadius = payload.value("arrivalRadius", 3.0f); + const auto result = session.startGoto(mapId, x, y, z, arrivalRadius); + sendHttp(client, result.value("ok", false) ? 200 : 400, result); + } else if (method == "POST" && path == "/movement/stop") { + json payload = json::parse(body.empty() ? "{}" : body); + sendHttp(client, 200, session.stopMovementTask(payload.value("reason", "stopped"))); } else { sendHttp(client, 404, {{"error", "not found"}}); } diff --git a/tools/provisioning/README.md b/tools/provisioning/README.md new file mode 100644 index 000000000..0a30e31ea --- /dev/null +++ b/tools/provisioning/README.md @@ -0,0 +1,241 @@ +# WoWee Provisioning Tools + +These scripts create CMaNGOS test accounts and characters for headless automation. + +## Account Creation + +Use CMaNGOS SOAP/server commands instead of direct SQL inserts. Modern CMaNGOS account rows include SRP verifier/salt fields, and the server already knows how to generate them correctly. + +There are two supported account creation paths: + +- Preferred for a private Linux CMaNGOS host: SSH to the server and call the server-local SOAP endpoint from there. +- Useful for local/dev setups: call SOAP directly from this machine or run the older tiny HTTP provisioning service. + +### SSH + Server-Local SOAP + +`create_account_ssh.py` reads server connection details from `tools/.env`, opens SSH, and runs the SOAP request locally on the CMaNGOS host. This lets `SOAP.IP` stay bound to `127.0.0.1` on the Linux box. + +Create `tools/.env` locally: + +```dotenv +MANGOS_HOST=10.0.0.10 +MANGOS_PORT=22 +MANGOS_USER=mangos +MANGOS_SSH_KEY_PATH=C:\Users\admin\.ssh\mangos_codex +MANGOS_SOAP_URL=http://127.0.0.1:7878/ +MANGOS_SOAP_USERNAME=GMACCOUNT +MANGOS_SOAP_PASSWORD=GMPASSWORD +``` + +`tools/.env` is ignored by git. + +Create or update an account: + +```bash +python tools/provisioning/create_account_ssh.py BOT001 \ + --password bot-password \ + --expansion 1 +``` + +For bot accounts that need to use `.bot add` commands, set GM level to 1 (Moderator): + +```bash +python tools/provisioning/create_account_ssh.py BOT001 \ + --password bot-password \ + --expansion 1 \ + --gmlevel 1 +``` + +The script runs: + +```text +account create BOT001 ******** +account set addon BOT001 1 +account set gmlevel BOT001 1 -1 +``` + +Use `--dry-run` to confirm the SSH target and commands without connecting: + +```bash +python tools/provisioning/create_account_ssh.py BOT001 \ + --password bot-password \ + --expansion 1 \ + --gmlevel 1 \ + --dry-run +``` + +Useful options: + +```bash +python tools/provisioning/create_account_ssh.py BOT001 \ + --password bot-password \ + --expansion 1 \ + --gmlevel 1 \ + --env tools/.env \ + --soap-url http://127.0.0.1:7878/ \ + --verbose +``` + +If the account already exists and you only need to set the expansion: + +```bash +python tools/provisioning/create_account_ssh.py BOT001 \ + --password existing-password \ + --expansion 1 \ + --skip-create +``` + +For TBC, `--expansion 1` is normally the useful account expansion value. + +### GM Level for Bot Accounts + +Bot fleet accounts need GM level 1+ to run `.bot add` commands. Pass `--gmlevel 1` during account creation. The `--realm-id` argument (default `-1`, all realms) controls which realm gets the GM assignment. Level 0 (default) keeps the account as a regular player. + +### Direct SOAP / Provisioning Service + +`create_account_direct_soap.py` is kept for cases where the SOAP endpoint is reachable from the machine running WoWee, or where you want to run a small HTTP provisioning service that forwards to SOAP. + +Enable SOAP in your CMaNGOS world server config, create or choose a GM account that is allowed to run SOAP commands, then run: + +```bash +python tools/provisioning/create_account_direct_soap.py create \ + --soap-url http://127.0.0.1:7878/ \ + --admin-user GMACCOUNT \ + --admin-pass GMPASSWORD \ + --account BOT001 \ + --password bot-password \ + --expansion 1 +``` + +If the CMaNGOS host should expose a tiny provisioning endpoint, run this on the Linux box: + +```bash +python tools/provisioning/create_account_direct_soap.py serve \ + --bind 127.0.0.1 \ + --port 8790 \ + --soap-url http://127.0.0.1:7878/ \ + --admin-user GMACCOUNT \ + --admin-pass GMPASSWORD \ + --token choose-a-long-random-token +``` + +Then create an account with: + +```bash +curl -X POST http://127.0.0.1:8790/accounts \ + -H "Content-Type: application/json" \ + -H "Authorization: Bearer choose-a-long-random-token" \ + -d '{"account":"BOT001","password":"bot-password","expansion":1}' +``` + +Keep this service bound to localhost or behind your own trusted network boundary. + +## Character Creation + +Character creation uses `wowee_headless` against the normal auth/world servers. Start from a working headless settings file so realm, client build, expansion profile, and auth host already match your server: + +```bash +python tools/provisioning/create_character.py \ + --settings tools/headless_client/settings.json \ + --wowee-headless build/bin/wowee_headless.exe \ + --account BOT001 \ + --password bot-password \ + --name Codexbotone \ + --race human \ + --class warrior \ + --gender male +``` + +On Windows, the wrapper automatically adds `C:/msys64/ucrt64/bin` to `PATH` when it exists so MSYS2-built runtime DLLs such as `libgcc_s_seh-1.dll`, `libstdc++-6.dll`, and OpenSSL DLLs can be found. If your MSYS2 install is somewhere else, pass: + +```bash +python tools/provisioning/create_character.py \ + --settings tools/headless_client/settings.json \ + --wowee-headless build/bin/wowee_headless.exe \ + --msys-ucrt-bin C:/path/to/msys64/ucrt64/bin \ + --account BOT001 \ + --password bot-password \ + --name Codexbotone \ + --race human \ + --class warrior +``` + +The script writes a temporary settings file with: + +```json +{ + "provision": { + "createCharacter": { + "enabled": true, + "exitAfterCreate": true + } + } +} +``` + +`wowee_headless` logs in, reaches the character list, creates the character if it does not already exist, and exits. + +Supported race names: `human`, `orc`, `dwarf`, `night_elf`, `undead`, `tauren`, `gnome`, `troll`, `blood_elf`, `draenei`. + +Supported TBC class names: `warrior`, `paladin`, `hunter`, `rogue`, `priest`, `shaman`, `mage`, `warlock`, `druid`. + +## Batch Roster Provisioning + +Use `provision_roster.py` when you are ready to create more than one account/character pair. Start by copying the example roster: + +```bash +copy tools\provisioning\roster.example.json tools\provisioning\roster.json +``` + +`tools/provisioning/roster.json` is ignored by git because it may contain account passwords. + +Example roster shape: + +```json +{ + "defaults": { + "settings": "tools/headless_client/settings.json", + "woweeHeadless": "build/bin/wowee_headless.exe", + "accountPassword": "change-me", + "expansion": 1, + "gmlevel": 1, + "race": "human", + "class": "warrior", + "gender": "male" + }, + "accounts": [ + { + "account": "BOT001", + "password": "change-me", + "gmlevel": 1, + "characters": [ + { "name": "Botone" } + ] + } + ] +} +``` + +Set `gmlevel` in `defaults` for all accounts, or override per-account. Set `realmId` (default `-1`, all realms) to target a specific realm for the GM assignment. Omit or set to `0` to keep the account as a regular player. + +Dry-run the roster first: + +```bash +python tools/provisioning/provision_roster.py tools/provisioning/roster.json --dry-run +``` + +Then run it: + +```bash +python tools/provisioning/provision_roster.py tools/provisioning/roster.json +``` + +Useful switches: + +```bash +python tools/provisioning/provision_roster.py tools/provisioning/roster.json --skip-accounts +python tools/provisioning/provision_roster.py tools/provisioning/roster.json --skip-characters +python tools/provisioning/provision_roster.py tools/provisioning/roster.json --continue-on-error +``` + +By default, roster provisioning uses `create_account_ssh.py` for account creation and `create_character.py` for character creation. diff --git a/tools/provisioning/create_account_direct_soap.py b/tools/provisioning/create_account_direct_soap.py new file mode 100644 index 000000000..c41600c98 --- /dev/null +++ b/tools/provisioning/create_account_direct_soap.py @@ -0,0 +1,204 @@ +#!/usr/bin/env python3 +"""Create CMaNGOS accounts through the built-in SOAP command endpoint.""" + +from __future__ import annotations + +import argparse +import base64 +import json +import sys +import urllib.error +import urllib.request +import xml.etree.ElementTree as ET +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer +from typing import Any + + +SOAP_NS = "urn:MaNGOS" +SOAP_ENV = "http://schemas.xmlsoap.org/soap/envelope/" + + +def soap_execute_command(soap_url: str, admin_user: str, admin_pass: str, command: str, timeout: float = 15.0) -> str: + envelope = f""" + + + + {xml_escape(command)} + + + +""" + auth = base64.b64encode(f"{admin_user}:{admin_pass}".encode("utf-8")).decode("ascii") + req = urllib.request.Request( + soap_url, + data=envelope.encode("utf-8"), + headers={ + "Authorization": f"Basic {auth}", + "Content-Type": 'text/xml; charset="utf-8"', + "SOAPAction": "executeCommand", + }, + method="POST", + ) + + try: + with urllib.request.urlopen(req, timeout=timeout) as resp: + body = resp.read().decode("utf-8", errors="replace") + except urllib.error.HTTPError as exc: + detail = exc.read().decode("utf-8", errors="replace") + raise RuntimeError(f"SOAP HTTP {exc.code}: {detail}") from exc + except urllib.error.URLError as exc: + raise RuntimeError(f"SOAP request failed: {exc}") from exc + + return parse_soap_result(body) + + +def xml_escape(value: str) -> str: + return ( + value.replace("&", "&") + .replace("<", "<") + .replace(">", ">") + .replace('"', """) + .replace("'", "'") + ) + + +def parse_soap_result(body: str) -> str: + try: + root = ET.fromstring(body) + except ET.ParseError: + return body.strip() + + for elem in root.iter(): + if elem.tag.endswith("result"): + return (elem.text or "").strip() + return body.strip() + + +def create_account(args: argparse.Namespace) -> int: + validate_command_arg(args.account, "account") + validate_command_arg(args.password, "password") + expansion = f" {args.expansion}" if args.expansion is not None else "" + command = f"account create {args.account} {args.password}{expansion}" + result = soap_execute_command(args.soap_url, args.admin_user, args.admin_pass, command, args.timeout) + print(result or "Account created.") + + if args.gmlevel > 0: + gm_cmd = f"account set gmlevel {args.account} {args.gmlevel} {args.realm_id}" + gm_result = soap_execute_command(args.soap_url, args.admin_user, args.admin_pass, gm_cmd, args.timeout) + print(gm_result or f"GM level set to {args.gmlevel}.") + + return 0 + + +class AccountService(BaseHTTPRequestHandler): + server_version = "WoWeeProvisioning/1.0" + + def do_POST(self) -> None: + if self.path != "/accounts": + self.send_json(404, {"ok": False, "error": "unknown endpoint"}) + return + + try: + length = int(self.headers.get("Content-Length", "0")) + payload = json.loads(self.rfile.read(length).decode("utf-8")) + token = self.server.provision_token # type: ignore[attr-defined] + if payload.get("token") != token and self.headers.get("Authorization") != f"Bearer {token}": + self.send_json(401, {"ok": False, "error": "invalid token"}) + return + + account = require_string(payload, "account") + password = require_string(payload, "password") + validate_command_arg(account, "account") + validate_command_arg(password, "password") + expansion = payload.get("expansion") + command = f"account create {account} {password}" + if expansion is not None: + command += f" {int(expansion)}" + + result = soap_execute_command( + self.server.soap_url, # type: ignore[attr-defined] + self.server.admin_user, # type: ignore[attr-defined] + self.server.admin_pass, # type: ignore[attr-defined] + command, + self.server.soap_timeout, # type: ignore[attr-defined] + ) + self.send_json(200, {"ok": True, "result": result}) + except Exception as exc: + self.send_json(400, {"ok": False, "error": str(exc)}) + + def log_message(self, fmt: str, *args: Any) -> None: + print(f"{self.address_string()} - {fmt % args}", file=sys.stderr) + + def send_json(self, status: int, doc: dict[str, Any]) -> None: + data = json.dumps(doc).encode("utf-8") + self.send_response(status) + self.send_header("Content-Type", "application/json") + self.send_header("Content-Length", str(len(data))) + self.end_headers() + self.wfile.write(data) + + +def require_string(doc: dict[str, Any], key: str) -> str: + value = doc.get(key) + if not isinstance(value, str) or not value.strip(): + raise ValueError(f"{key} is required") + return value.strip() + + +def validate_command_arg(value: str, label: str) -> None: + if any(ch.isspace() or ord(ch) < 32 for ch in value): + raise ValueError(f"{label} must not contain whitespace or control characters") + + +def serve(args: argparse.Namespace) -> int: + if not args.token: + raise SystemExit("--token is required for service mode") + + httpd = ThreadingHTTPServer((args.bind, args.port), AccountService) + httpd.soap_url = args.soap_url # type: ignore[attr-defined] + httpd.admin_user = args.admin_user # type: ignore[attr-defined] + httpd.admin_pass = args.admin_pass # type: ignore[attr-defined] + httpd.soap_timeout = args.timeout # type: ignore[attr-defined] + httpd.provision_token = args.token # type: ignore[attr-defined] + + print(f"CMaNGOS account provisioning service listening on http://{args.bind}:{args.port}") + try: + httpd.serve_forever() + except KeyboardInterrupt: + print("\nStopping account provisioning service") + return 0 + + +def add_common_args(parser: argparse.ArgumentParser) -> None: + parser.add_argument("--soap-url", required=True, help="CMaNGOS SOAP URL, for example http://127.0.0.1:7878/") + parser.add_argument("--admin-user", required=True, help="GM account with SOAP command access") + parser.add_argument("--admin-pass", required=True, help="GM account password") + parser.add_argument("--timeout", type=float, default=15.0, help="SOAP timeout in seconds") + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser(description=__doc__) + sub = parser.add_subparsers(dest="cmd", required=True) + + create = sub.add_parser("create", help="Create one account through CMaNGOS SOAP") + add_common_args(create) + create.add_argument("--account", required=True) + create.add_argument("--password", required=True) + create.add_argument("--expansion", type=int, help="Optional CMaNGOS expansion value, such as 1 for TBC") + create.add_argument("--gmlevel", type=int, default=0, choices=(0, 1, 2, 3, 4), help="GM security level (1+ enables .bot add)") + create.add_argument("--realm-id", type=int, default=-1, help="Realm ID for gmlevel assignment (-1 = all realms)") + create.set_defaults(func=create_account) + + service = sub.add_parser("serve", help="Expose POST /accounts as a small provisioning service") + add_common_args(service) + service.add_argument("--bind", default="127.0.0.1") + service.add_argument("--port", type=int, default=8790) + service.add_argument("--token", required=True) + service.set_defaults(func=serve) + + args = parser.parse_args(argv) + return int(args.func(args)) + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tools/provisioning/create_account_ssh.py b/tools/provisioning/create_account_ssh.py new file mode 100644 index 000000000..0370e3197 --- /dev/null +++ b/tools/provisioning/create_account_ssh.py @@ -0,0 +1,257 @@ +#!/usr/bin/env python3 +"""Create a CMaNGOS account over SSH using the server-local SOAP endpoint.""" + +from __future__ import annotations + +import argparse +import getpass +import json +import pathlib +import re +import shlex +import subprocess +import sys +from typing import Any + + +ROOT = pathlib.Path(__file__).resolve().parents[1] +DEFAULT_ENV = ROOT / ".env" + + +USERNAME_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9_.-]{2,31}$") + + +REMOTE_SCRIPT = r""" +import base64 +import http.client +import json +import sys +import urllib.parse +import xml.etree.ElementTree as ET + +payload = json.loads(sys.stdin.read()) +soap_url = payload.get("soap_url") or "http://127.0.0.1:7878/" +soap_user = payload["soap_user"] +soap_password = payload["soap_password"] +commands = payload["commands"] +verbose = bool(payload.get("verbose")) + +parsed = urllib.parse.urlparse(soap_url) +if parsed.scheme != "http": + raise SystemExit("Only plain HTTP SOAP endpoints are supported by this helper.") +host = parsed.hostname or "127.0.0.1" +port = parsed.port or 7878 +path = parsed.path or "/" + + +def call_soap(command): + body = ''' + + + + {command} + + +'''.format(command=command.replace("&", "&").replace("<", "<").replace(">", ">")) + auth = base64.b64encode((soap_user + ":" + soap_password).encode("utf-8")).decode("ascii") + headers = { + "Authorization": "Basic " + auth, + "Content-Type": "text/xml; charset=utf-8", + "SOAPAction": "executeCommand", + } + conn = http.client.HTTPConnection(host, port, timeout=10) + try: + conn.request("POST", path, body.encode("utf-8"), headers) + response = conn.getresponse() + raw = response.read().decode("utf-8", errors="replace") + finally: + conn.close() + + if response.status >= 400: + raise RuntimeError("SOAP HTTP {0}: {1}".format(response.status, raw[:500])) + + try: + root = ET.fromstring(raw) + except ET.ParseError: + raise RuntimeError("SOAP returned non-XML response: " + raw[:500]) + + fault = root.find(".//{http://schemas.xmlsoap.org/soap/envelope/}Fault") + if fault is not None: + text = "".join(fault.itertext()).strip() + raise RuntimeError("SOAP fault: " + text) + + result = "" + for element in root.iter(): + if element.tag.endswith("result") or element.tag.endswith("return"): + result = element.text or "" + break + return result.strip() + + +for command in commands: + if verbose: + shown = command + if command.lower().startswith("account create "): + parts = command.split(" ", 3) + if len(parts) == 4: + shown = " ".join(parts[:3] + ["********"]) + print("SOAP:", shown) + result = call_soap(command) + lowered = result.lower() + failed_markers = (" syntax", "error", "not exist", "not found", "incorrect", "already exist", "already exists") + if any(marker in lowered for marker in failed_markers): + raise RuntimeError("Command failed: " + result) + if result: + print(result) +""" + + +def load_env(path: pathlib.Path) -> dict[str, str]: + values: dict[str, str] = {} + if not path.exists(): + raise SystemExit(f"Missing env file: {path}") + + for raw_line in path.read_text(encoding="utf-8").splitlines(): + line = raw_line.strip() + if not line or line.startswith("#") or "=" not in line: + continue + key, value = line.split("=", 1) + values[key.strip()] = value.strip().strip('"').strip("'") + return values + + +def require_env(env: dict[str, str], key: str) -> str: + value = env.get(key, "").strip() + if not value: + raise SystemExit(f"{key} must be set in .env") + return value + + +def validate_account_username(username: str) -> str: + username = username.strip() + if not USERNAME_RE.match(username): + raise SystemExit( + "Account username must be 3-32 characters and use only letters, numbers, dot, underscore, or dash." + ) + return username.upper() + + +def validate_account_password(password: str) -> str: + if not password: + raise SystemExit("Account password cannot be empty.") + if any(ch.isspace() for ch in password): + raise SystemExit("Account password cannot contain whitespace because CMaNGOS account commands are space-delimited.") + if len(password) > 64: + raise SystemExit("Account password is too long for this helper; use 64 characters or fewer.") + return password + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser( + description="Create a WoW account on the MaNGOS server through SSH + server-local SOAP.", + formatter_class=argparse.ArgumentDefaultsHelpFormatter, + ) + parser.add_argument("username", help="WoW account username to create") + parser.add_argument("--password", help="WoW account password. If omitted, prompt securely.") + parser.add_argument("--expansion", type=int, default=1, choices=(0, 1, 2), help="0=Classic, 1=TBC, 2=WotLK") + parser.add_argument("--gmlevel", type=int, default=0, choices=(0, 1, 2, 3, 4), help="GM security level (1+ enables .bot add)") + parser.add_argument("--realm-id", type=int, default=-1, help="Realm ID for gmlevel assignment (-1 = all realms)") + parser.add_argument("--env", type=pathlib.Path, default=DEFAULT_ENV, help="Path to .env") + parser.add_argument("--soap-url", default="", help="Override SOAP URL on the server") + parser.add_argument("--soap-user", default="", help="SOAP admin username. Defaults to MANGOS_SOAP_USERNAME.") + parser.add_argument("--soap-password", default="", help="SOAP admin password. Defaults to MANGOS_SOAP_PASSWORD or prompt.") + parser.add_argument("--skip-create", action="store_true", help="Only run the expansion command for an existing account.") + parser.add_argument("--dry-run", action="store_true", help="Print the remote commands without connecting.") + parser.add_argument("--verbose", action="store_true", help="Print SOAP commands before executing them on the server.") + return parser.parse_args() + + +def main() -> int: + args = parse_args() + env = load_env(args.env) + + account_name = validate_account_username(args.username) + account_password = validate_account_password(args.password or getpass.getpass("New WoW account password: ")) + + soap_user = args.soap_user or env.get("MANGOS_SOAP_USERNAME", "").strip() + if not soap_user: + soap_user = input("SOAP admin username: ").strip() + if not soap_user: + raise SystemExit("SOAP admin username is required.") + + soap_password = args.soap_password or env.get("MANGOS_SOAP_PASSWORD", "").strip() + if not soap_password: + soap_password = getpass.getpass("SOAP admin password: ") + if not soap_password: + raise SystemExit("SOAP admin password is required.") + + ssh_host = require_env(env, "MANGOS_HOST") + ssh_port = env.get("MANGOS_PORT", "22").strip() or "22" + ssh_user = require_env(env, "MANGOS_USER") + ssh_key = require_env(env, "MANGOS_SSH_KEY_PATH") + soap_url = args.soap_url or env.get("MANGOS_SOAP_URL", "http://127.0.0.1:7878/").strip() + + commands = [] + if not args.skip_create: + commands.append(f"account create {account_name} {account_password}") + commands.append(f"account set addon {account_name} {args.expansion}") + if args.gmlevel > 0: + commands.append(f"account set gmlevel {account_name} {args.gmlevel} {args.realm_id}") + + payload: dict[str, Any] = { + "soap_url": soap_url, + "soap_user": soap_user, + "soap_password": soap_password, + "commands": commands, + "verbose": args.verbose, + } + + if args.dry_run: + print(f"SSH target: {ssh_user}@{ssh_host}:{ssh_port}") + print(f"SOAP URL on server: {soap_url}") + for command in commands: + scrubbed = command.replace(account_password, "********") + print(f"Would run: {scrubbed}") + return 0 + + ssh_command = [ + "ssh", + "-i", + ssh_key, + "-p", + ssh_port, + "-o", + "StrictHostKeyChecking=accept-new", + f"{ssh_user}@{ssh_host}", + "python3 -c " + shlex.quote(REMOTE_SCRIPT), + ] + + completed = subprocess.run( + ssh_command, + input=json.dumps(payload), + text=True, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + check=False, + ) + + if completed.stdout: + print(completed.stdout.strip()) + if completed.returncode != 0: + if completed.stderr: + print(completed.stderr.strip(), file=sys.stderr) + return completed.returncode + + parts = [f"Created/updated account {account_name} with expansion {args.expansion}"] + if args.gmlevel > 0: + parts.append(f"GM level set to {args.gmlevel} (realm {args.realm_id})") + print(".".join(parts) + ".") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tools/provisioning/create_character.py b/tools/provisioning/create_character.py new file mode 100644 index 000000000..42e251c31 --- /dev/null +++ b/tools/provisioning/create_character.py @@ -0,0 +1,125 @@ +#!/usr/bin/env python3 +"""Create a WoW character by driving wowee_headless in provisioning mode.""" + +from __future__ import annotations + +import argparse +import json +import os +import subprocess +import sys +import tempfile +from pathlib import Path +from typing import Any + + +def load_settings(path: Path) -> dict[str, Any]: + with path.open("r", encoding="utf-8") as f: + doc = json.load(f) + if not isinstance(doc, dict): + raise ValueError("settings root must be a JSON object") + return doc + + +def set_nested(doc: dict[str, Any], section: str, key: str, value: Any) -> None: + obj = doc.setdefault(section, {}) + if not isinstance(obj, dict): + raise ValueError(f"{section} must be a JSON object") + obj[key] = value + + +def build_settings(args: argparse.Namespace) -> dict[str, Any]: + doc = load_settings(args.settings) + + if args.account: + set_nested(doc, "auth", "account", args.account) + if args.password: + set_nested(doc, "auth", "password", args.password) + if args.auth_host: + set_nested(doc, "auth", "host", args.auth_host) + if args.auth_port: + set_nested(doc, "auth", "port", args.auth_port) + if args.realm: + set_nested(doc, "realm", "name", args.realm) + + set_nested(doc, "character", "name", args.name) + doc["provision"] = { + "createCharacter": { + "enabled": True, + "name": args.name, + "race": args.race, + "class": args.character_class, + "gender": args.gender, + "skin": args.skin, + "face": args.face, + "hairStyle": args.hair_style, + "hairColor": args.hair_color, + "facialHair": args.facial_hair, + "exitAfterCreate": True, + } + } + return doc + + +def build_runtime_env(args: argparse.Namespace) -> dict[str, str]: + env = os.environ.copy() + path_parts: list[str] = [] + + exe_dir = args.wowee_headless.resolve().parent + if exe_dir.exists(): + path_parts.append(str(exe_dir)) + + if args.msys_ucrt_bin: + msys_bin = args.msys_ucrt_bin + if msys_bin.exists(): + path_parts.append(str(msys_bin.resolve())) + else: + print(f"Warning: --msys-ucrt-bin does not exist: {msys_bin}", file=sys.stderr) + elif os.name == "nt": + default_msys_bin = Path("C:/msys64/ucrt64/bin") + if default_msys_bin.exists(): + path_parts.append(str(default_msys_bin)) + + if path_parts: + env["PATH"] = os.pathsep.join(path_parts + [env.get("PATH", "")]) + print("Added runtime PATH entries:", os.pathsep.join(path_parts)) + return env + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--settings", required=True, type=Path, help="Base wowee_headless settings JSON") + parser.add_argument("--wowee-headless", default="build/bin/wowee_headless.exe", type=Path) + parser.add_argument("--msys-ucrt-bin", type=Path, help="MSYS2 UCRT runtime bin directory; defaults to C:/msys64/ucrt64/bin on Windows if present") + parser.add_argument("--account", help="Override auth.account") + parser.add_argument("--password", help="Override auth.password") + parser.add_argument("--auth-host", help="Override auth.host") + parser.add_argument("--auth-port", type=int, help="Override auth.port") + parser.add_argument("--realm", help="Override realm.name") + parser.add_argument("--name", required=True, help="Character name to create") + parser.add_argument("--race", required=True, help="Race name or numeric race id") + parser.add_argument("--class", required=True, dest="character_class", help="Class name or numeric class id") + parser.add_argument("--gender", default="male", help="male/female or 0/1") + parser.add_argument("--skin", type=int, default=0) + parser.add_argument("--face", type=int, default=0) + parser.add_argument("--hair-style", type=int, default=0) + parser.add_argument("--hair-color", type=int, default=0) + parser.add_argument("--facial-hair", type=int, default=0) + parser.add_argument("--dry-run", action="store_true", help="Print generated settings and do not run the client") + args = parser.parse_args(argv) + + generated = build_settings(args) + if args.dry_run: + print(json.dumps(generated, indent=2)) + return 0 + + with tempfile.TemporaryDirectory(prefix="wowee-create-character-") as tmp: + settings_path = Path(tmp) / "settings.json" + settings_path.write_text(json.dumps(generated, indent=2), encoding="utf-8") + cmd = [str(args.wowee_headless), str(settings_path)] + print("Running:", " ".join(cmd)) + return subprocess.call(cmd, env=build_runtime_env(args)) + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tools/provisioning/provision_roster.py b/tools/provisioning/provision_roster.py new file mode 100644 index 000000000..b413b2692 --- /dev/null +++ b/tools/provisioning/provision_roster.py @@ -0,0 +1,270 @@ +#!/usr/bin/env python3 +"""Provision many CMaNGOS accounts and characters from one roster JSON file.""" + +from __future__ import annotations + +import argparse +import json +import subprocess +import sys +from pathlib import Path +from typing import Any + + +ROOT = Path(__file__).resolve().parents[2] +TOOLS = ROOT / "tools" +PROVISIONING = TOOLS / "provisioning" + + +def load_json(path: Path) -> dict[str, Any]: + with path.open("r", encoding="utf-8") as f: + doc = json.load(f) + if not isinstance(doc, dict): + raise ValueError("roster root must be a JSON object") + return doc + + +def bool_value(value: Any, fallback: bool) -> bool: + if value is None: + return fallback + return bool(value) + + +def string_value(value: Any, fallback: str = "") -> str: + if value is None: + return fallback + if not isinstance(value, str): + raise ValueError(f"expected string, got {type(value).__name__}") + return value + + +def path_from(value: str) -> Path: + path = Path(value) + return path if path.is_absolute() else ROOT / path + + +def scrub_command(cmd: list[str]) -> str: + scrubbed: list[str] = [] + hide_next = False + for item in cmd: + if hide_next: + scrubbed.append("********") + hide_next = False + continue + scrubbed.append(item) + if item in {"--password", "--soap-password", "--admin-pass"}: + hide_next = True + return " ".join(scrubbed) + + +def run_step(cmd: list[str], dry_run: bool) -> int: + print(("Would run: " if dry_run else "Running: ") + scrub_command(cmd)) + if dry_run: + return 0 + return subprocess.call(cmd, cwd=ROOT) + + +def account_command(args: argparse.Namespace, account: str, password: str, expansion: int, gmlevel: int = 0, realm_id: int = -1) -> list[str]: + if args.account_mode == "ssh": + cmd = [ + sys.executable, + str(args.account_ssh_script), + account, + "--password", + password, + "--expansion", + str(expansion), + ] + if gmlevel > 0: + cmd.extend(["--gmlevel", str(gmlevel), "--realm-id", str(realm_id)]) + if args.env: + cmd.extend(["--env", str(args.env)]) + return cmd + + cmd = [ + sys.executable, + str(args.account_direct_script), + "create", + "--soap-url", + args.soap_url, + "--admin-user", + args.admin_user, + "--admin-pass", + args.admin_pass, + "--account", + account, + "--password", + password, + "--expansion", + str(expansion), + ] + if gmlevel > 0: + cmd.extend(["--gmlevel", str(gmlevel), "--realm-id", str(realm_id)]) + return cmd + + +def character_command( + args: argparse.Namespace, + defaults: dict[str, Any], + account_doc: dict[str, Any], + character_doc: dict[str, Any], + account: str, + password: str, +) -> list[str]: + settings = string_value(character_doc.get("settings"), string_value(account_doc.get("settings"), string_value(defaults.get("settings")))) + wowee = string_value( + character_doc.get("woweeHeadless"), + string_value(account_doc.get("woweeHeadless"), string_value(defaults.get("woweeHeadless"), "build/bin/wowee_headless.exe")), + ) + name = string_value(character_doc.get("name")) + race = string_value(character_doc.get("race"), string_value(account_doc.get("race"), string_value(defaults.get("race"), "human"))) + cls = string_value(character_doc.get("class"), string_value(account_doc.get("class"), string_value(defaults.get("class"), "warrior"))) + gender = string_value(character_doc.get("gender"), string_value(account_doc.get("gender"), string_value(defaults.get("gender"), "male"))) + + if not settings: + raise ValueError(f"{account}/{name}: settings is required") + if not name: + raise ValueError(f"{account}: character name is required") + + cmd = [ + sys.executable, + str(args.character_script), + "--settings", + str(path_from(settings)), + "--wowee-headless", + str(path_from(wowee)), + "--account", + account, + "--password", + password, + "--name", + name, + "--race", + race, + "--class", + cls, + "--gender", + gender, + ] + + for option_name, cli_name in ( + ("skin", "--skin"), + ("face", "--face"), + ("hairStyle", "--hair-style"), + ("hairColor", "--hair-color"), + ("facialHair", "--facial-hair"), + ): + value = character_doc.get(option_name, account_doc.get(option_name, defaults.get(option_name))) + if value is not None: + cmd.extend([cli_name, str(int(value))]) + + return cmd + + +def iter_characters(account_doc: dict[str, Any]) -> list[dict[str, Any]]: + if "characters" in account_doc: + characters = account_doc["characters"] + if not isinstance(characters, list): + raise ValueError("account.characters must be an array") + return [ch for ch in characters if isinstance(ch, dict)] + if "character" in account_doc: + character = account_doc["character"] + if isinstance(character, str): + return [{"name": character}] + if isinstance(character, dict): + return [character] + return [] + + +def provision(args: argparse.Namespace) -> int: + roster = load_json(args.roster) + defaults = roster.get("defaults", {}) + if not isinstance(defaults, dict): + raise ValueError("defaults must be an object") + accounts = roster.get("accounts", []) + if not isinstance(accounts, list): + raise ValueError("accounts must be an array") + + failures = 0 + for raw_account in accounts: + if not isinstance(raw_account, dict): + continue + account = string_value(raw_account.get("account")).strip() + password = string_value(raw_account.get("password"), string_value(defaults.get("accountPassword"))).strip() + expansion = int(raw_account.get("expansion", defaults.get("expansion", 1))) + gmlevel = int(raw_account.get("gmlevel", defaults.get("gmlevel", 0))) + realm_id = int(raw_account.get("realmId", defaults.get("realmId", -1))) + if not account: + raise ValueError("account is required") + if not password: + raise ValueError(f"{account}: password is required") + + create_account = bool_value(raw_account.get("createAccount"), bool_value(defaults.get("createAccount"), True)) + create_character = bool_value(raw_account.get("createCharacter"), bool_value(defaults.get("createCharacter"), True)) + + if create_account and not args.skip_accounts: + rc = run_step(account_command(args, account, password, expansion, gmlevel, realm_id), args.dry_run) + if rc != 0: + failures += 1 + print(f"Account step failed for {account} with exit code {rc}", file=sys.stderr) + if not args.continue_on_error: + return rc + + if create_character and not args.skip_characters: + for character in iter_characters(raw_account): + try: + cmd = character_command(args, defaults, raw_account, character, account, password) + except Exception as exc: + failures += 1 + print(f"Character step setup failed for {account}: {exc}", file=sys.stderr) + if not args.continue_on_error: + return 2 + continue + rc = run_step(cmd, args.dry_run) + if rc != 0: + failures += 1 + print(f"Character step failed for {account} with exit code {rc}", file=sys.stderr) + if not args.continue_on_error: + return rc + + if failures: + print(f"Provisioning finished with {failures} failure(s).") + return 1 + print("Provisioning finished successfully.") + return 0 + + +def parse_args(argv: list[str] | None = None) -> argparse.Namespace: + parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.ArgumentDefaultsHelpFormatter) + parser.add_argument("roster", type=Path, help="Roster JSON file") + parser.add_argument("--account-mode", choices=("ssh", "direct-soap"), default="ssh") + parser.add_argument("--env", type=Path, default=TOOLS / ".env", help="Env file for SSH account mode") + parser.add_argument("--account-ssh-script", type=Path, default=PROVISIONING / "create_account_ssh.py") + parser.add_argument("--account-direct-script", type=Path, default=PROVISIONING / "create_account_direct_soap.py") + parser.add_argument("--character-script", type=Path, default=PROVISIONING / "create_character.py") + parser.add_argument("--soap-url", default="", help="Direct SOAP mode URL") + parser.add_argument("--admin-user", default="", help="Direct SOAP mode admin user") + parser.add_argument("--admin-pass", default="", help="Direct SOAP mode admin password") + parser.add_argument("--skip-accounts", action="store_true") + parser.add_argument("--skip-characters", action="store_true") + parser.add_argument("--continue-on-error", action="store_true") + parser.add_argument("--dry-run", action="store_true") + args = parser.parse_args(argv) + + if args.account_mode == "direct-soap" and not args.skip_accounts: + missing = [name for name in ("soap_url", "admin_user", "admin_pass") if not getattr(args, name)] + if missing: + parser.error("--account-mode direct-soap requires --soap-url, --admin-user, and --admin-pass") + return args + + +def main(argv: list[str] | None = None) -> int: + try: + return provision(parse_args(argv)) + except Exception as exc: + print(f"error: {exc}", file=sys.stderr) + return 2 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tools/provisioning/roster.example.json b/tools/provisioning/roster.example.json new file mode 100644 index 000000000..9bfb6929c --- /dev/null +++ b/tools/provisioning/roster.example.json @@ -0,0 +1,34 @@ +{ + "defaults": { + "settings": "tools/headless_client/settings.json", + "woweeHeadless": "build/bin/wowee_headless.exe", + "accountPassword": "change-me", + "expansion": 1, + "race": "human", + "class": "warrior", + "gender": "male", + "createAccount": true, + "createCharacter": true + }, + "accounts": [ + { + "account": "BOT001", + "password": "change-me", + "characters": [ + { + "name": "Botone" + } + ] + }, + { + "account": "BOT002", + "password": "change-me", + "characters": [ + { + "name": "Bottwo", + "class": "paladin" + } + ] + } + ] +} From 8f88576cd638d3264b4388cd0da32f6e0189f84d Mon Sep 17 00:00:00 2001 From: Josh Anderson Date: Wed, 8 Jul 2026 12:07:34 -0500 Subject: [PATCH 003/103] Clarify fleet dashboard connection states --- tools/bot_fleet_manager/team_status_server.py | 36 ++++++++++++++----- 1 file changed, 27 insertions(+), 9 deletions(-) diff --git a/tools/bot_fleet_manager/team_status_server.py b/tools/bot_fleet_manager/team_status_server.py index 38919b29b..aba1334fc 100644 --- a/tools/bot_fleet_manager/team_status_server.py +++ b/tools/bot_fleet_manager/team_status_server.py @@ -28,21 +28,30 @@ def _safe_get(doc: dict[str, Any], *path: str, default: Any = "") -> Any: def _summarize_activity(status: dict[str, Any]) -> str: movement = status.get("movement", {}) - state = str(movement.get("state") or status.get("status") or "unknown") - if state == "moving": + movement_state = str(movement.get("state") or "idle") + session_state = str(status.get("status") or "unknown") + if movement_state == "moving": index = movement.get("waypointIndex", 0) count = movement.get("waypointCount", 0) target = movement.get("target", {}) return f"moving to {target.get('x', '?')}, {target.get('y', '?')}, {target.get('z', '?')} ({index}/{count})" if movement.get("error"): - return f"{state}: {movement.get('error')}" + return f"{movement_state}: {movement.get('error')}" combat = status.get("combat", {}) if combat.get("inCombat"): return "in combat" health = status.get("health", {}) if health.get("isDead") or health.get("isPlayerDead"): return "dead" - return state + return session_state + + +def _team_state(status: dict[str, Any] | None) -> str: + if not status: + return "offline" + if status.get("inWorld"): + return "in_world" + return str(status.get("status") or "connecting") def collect_team_status(api_bases: list[dict[str, str]]) -> dict[str, Any]: @@ -56,7 +65,8 @@ def collect_team_status(api_bases: list[dict[str, str]]) -> dict[str, Any]: "id": leader_id, "fleet": item.get("fleet", ""), "apiBase": base, - "ok": False, + "apiReachable": False, + "state": "offline", "activity": "unavailable", } try: @@ -65,7 +75,8 @@ def collect_team_status(api_bases: list[dict[str, str]]) -> dict[str, Any]: party = _request_json(base + "/party") chat = _request_json(base + "/chat?after=0&limit=8") team.update({ - "ok": True, + "apiReachable": True, + "state": _team_state(status), "status": status, "world": world, "party": party, @@ -113,6 +124,7 @@ def render_dashboard(title: str) -> bytes: td {{ font-size: 14px; }} .name {{ font-weight: 650; }} .ok {{ color: #85d996; }} + .warn {{ color: #ffd27a; }} .bad {{ color: #ff9a8b; }} .muted {{ color: #aab2bd; }} .chat {{ display: grid; gap: 3px; max-height: 140px; overflow: auto; }} @@ -152,8 +164,9 @@ def render_dashboard(title: str) -> bytes: function locationText(team) {{ const world = team.world || {{}}; const pos = world.position || {{}}; - if (!team.ok) return team.error || "unavailable"; - return `map ${{esc(world.mapId)}} · x=${{Number(pos.x || 0).toFixed(1)}} y=${{Number(pos.y || 0).toFixed(1)}} z=${{Number(pos.z || 0).toFixed(1)}}`; + if (!team.apiReachable) return team.error || "unavailable"; + if (team.state !== "in_world") return "not in world"; + return `map ${{esc(world.mapId)}} - x=${{Number(pos.x || 0).toFixed(1)}} y=${{Number(pos.y || 0).toFixed(1)}} z=${{Number(pos.z || 0).toFixed(1)}}`; }} function partyText(team) {{ const members = ((team.party || {{}}).members || []).map(m => esc(m.name || m.guid || "?")); @@ -164,6 +177,11 @@ def render_dashboard(title: str) -> bytes: if (!messages.length) return "no recent chat"; return `
${{messages.map(m => `
${{esc(m.type)}} ${{esc(m.from || "")}}: ${{esc(m.message || "")}}
`).join("")}}
`; }} + function statusClass(team) {{ + if (!team.apiReachable || team.state === "offline") return "bad"; + if (team.state === "in_world") return "ok"; + return "warn"; + }} async function refresh() {{ const res = await fetch("/api/teams", {{cache: "no-store"}}); const data = await res.json(); @@ -171,7 +189,7 @@ def render_dashboard(title: str) -> bytes: document.getElementById("teams").innerHTML = data.teams.map(team => `
${{esc(team.id)}}
${{esc(team.fleet || "default")}}
${{esc(team.apiBase)}}
- ${{team.ok ? "online" : "offline"}} + ${{esc(team.state || "offline")}} ${{locationText(team)}} ${{esc(team.activity)}} ${{partyText(team)}} From f849b31090c3915819bb1476ee194557fc1817d6 Mon Sep 17 00:00:00 2001 From: Josh Anderson Date: Wed, 8 Jul 2026 12:18:08 -0500 Subject: [PATCH 004/103] Tolerate partial fleet dashboard polling failures --- tools/bot_fleet_manager/team_status_server.py | 63 ++++++++++++++----- 1 file changed, 46 insertions(+), 17 deletions(-) diff --git a/tools/bot_fleet_manager/team_status_server.py b/tools/bot_fleet_manager/team_status_server.py index aba1334fc..4cc66833e 100644 --- a/tools/bot_fleet_manager/team_status_server.py +++ b/tools/bot_fleet_manager/team_status_server.py @@ -12,11 +12,18 @@ from typing import Any -def _request_json(url: str, timeout: float = 1.5) -> dict[str, Any]: +def _request_json(url: str, timeout: float = 3.0) -> dict[str, Any]: with urllib.request.urlopen(url, timeout=timeout) as response: return json.loads(response.read().decode("utf-8")) +def _try_request_json(url: str) -> tuple[dict[str, Any] | None, str]: + try: + return _request_json(url), "" + except (OSError, urllib.error.URLError, TimeoutError, json.JSONDecodeError) as exc: + return None, str(exc) + + def _safe_get(doc: dict[str, Any], *path: str, default: Any = "") -> Any: value: Any = doc for key in path: @@ -68,23 +75,42 @@ def collect_team_status(api_bases: list[dict[str, str]]) -> dict[str, Any]: "apiReachable": False, "state": "offline", "activity": "unavailable", + "endpointErrors": {}, } - try: - status = _request_json(base + "/status") - world = _request_json(base + "/world/self") - party = _request_json(base + "/party") - chat = _request_json(base + "/chat?after=0&limit=8") - team.update({ - "apiReachable": True, - "state": _team_state(status), - "status": status, - "world": world, - "party": party, - "recentChat": chat.get("messages", [])[-8:], - "activity": _summarize_activity(status), - }) - except (OSError, urllib.error.URLError, TimeoutError, json.JSONDecodeError) as exc: - team["error"] = str(exc) + status, status_error = _try_request_json(base + "/status") + if status is None: + team["error"] = status_error + teams.append(team) + continue + + team.update({ + "apiReachable": True, + "state": _team_state(status), + "status": status, + "activity": _summarize_activity(status), + }) + + endpoint_errors: dict[str, str] = {} + world, world_error = _try_request_json(base + "/world/self") + if world is not None: + team["world"] = world + elif world_error: + endpoint_errors["world"] = world_error + + party, party_error = _try_request_json(base + "/party") + if party is not None: + team["party"] = party + elif party_error: + endpoint_errors["party"] = party_error + + chat, chat_error = _try_request_json(base + "/chat?after=0&limit=8") + if chat is not None: + team["recentChat"] = chat.get("messages", [])[-8:] + elif chat_error: + endpoint_errors["chat"] = chat_error + + if endpoint_errors: + team["endpointErrors"] = endpoint_errors teams.append(team) return {"collectedAt": collected_at, "teams": teams} @@ -165,14 +191,17 @@ def render_dashboard(title: str) -> bytes: const world = team.world || {{}}; const pos = world.position || {{}}; if (!team.apiReachable) return team.error || "unavailable"; + if (team.endpointErrors && team.endpointErrors.world) return "world endpoint stale"; if (team.state !== "in_world") return "not in world"; return `map ${{esc(world.mapId)}} - x=${{Number(pos.x || 0).toFixed(1)}} y=${{Number(pos.y || 0).toFixed(1)}} z=${{Number(pos.z || 0).toFixed(1)}}`; }} function partyText(team) {{ + if (team.endpointErrors && team.endpointErrors.party) return "party endpoint stale"; const members = ((team.party || {{}}).members || []).map(m => esc(m.name || m.guid || "?")); return members.length ? members.join(", ") : "none reported"; }} function chatText(team) {{ + if (team.endpointErrors && team.endpointErrors.chat) return "chat endpoint stale"; const messages = team.recentChat || []; if (!messages.length) return "no recent chat"; return `
${{messages.map(m => `
${{esc(m.type)}} ${{esc(m.from || "")}}: ${{esc(m.message || "")}}
`).join("")}}
`; From 77c03af6cfc9f5b14a4caa58f35628ff088657b7 Mon Sep 17 00:00:00 2001 From: Josh Anderson Date: Wed, 8 Jul 2026 12:37:33 -0500 Subject: [PATCH 005/103] Stream fleet leader logs in debug mode --- tools/bot_fleet_manager/README.md | 8 ++ tools/bot_fleet_manager/bot_fleet_manager.py | 86 ++++++++++++++++---- 2 files changed, 80 insertions(+), 14 deletions(-) diff --git a/tools/bot_fleet_manager/README.md b/tools/bot_fleet_manager/README.md index 5b6a46ba7..fc07cff46 100644 --- a/tools/bot_fleet_manager/README.md +++ b/tools/bot_fleet_manager/README.md @@ -28,6 +28,14 @@ Start the textual team dashboard while supervising: python tools/bot_fleet_manager/bot_fleet_manager.py tools/bot_fleet_manager/fleet.settings.json supervise --dashboard ``` +Stream each leader's log output to the same console while supervising: + +```bash +python tools/bot_fleet_manager/bot_fleet_manager.py tools/bot_fleet_manager/fleet.settings.json supervise --dashboard --debug +``` + +The same flag is also available as `--verbose`, `--v`, or `-v`. Logs are still written under `tools/bot_fleet_manager/runtime/logs/`. + Or run only the dashboard against already-running leaders: ```bash diff --git a/tools/bot_fleet_manager/bot_fleet_manager.py b/tools/bot_fleet_manager/bot_fleet_manager.py index 1e38cbe13..ed60c72c5 100644 --- a/tools/bot_fleet_manager/bot_fleet_manager.py +++ b/tools/bot_fleet_manager/bot_fleet_manager.py @@ -157,34 +157,66 @@ def creation_flags() -> int: class ManagedLeader: - def __init__(self, config: FleetConfig, leader: dict[str, Any], index: int, settings_path: Path): + def __init__(self, config: FleetConfig, leader: dict[str, Any], index: int, settings_path: Path, verbose: bool = False): self.config = config self.leader = leader self.index = index self.settings_path = settings_path self.leader_id = leader.get("id", f"leader-{index + 1}") - self.process: subprocess.Popen[bytes] | None = None + self.process: subprocess.Popen[Any] | None = None self.log_handle: Any = None + self.stream_thread: threading.Thread | None = None self.restart_count = 0 self.next_start_at = 0.0 self.backoff = config.initial_restart_backoff self.disabled = False + self.verbose = verbose + + def stream_output(self) -> None: + if not self.process or not self.process.stdout: + return + try: + for line in self.process.stdout: + if self.log_handle: + self.log_handle.write(line) + self.log_handle.flush() + print(f"[{self.leader_id}] {line}", end="", flush=True) + except ValueError: + return def start(self) -> None: self.config.log_dir.mkdir(parents=True, exist_ok=True) if self.log_handle: self.log_handle.close() log_path = self.config.log_dir / f"{self.leader_id}.log" - self.log_handle = log_path.open("ab") + log_mode = "a" if self.verbose else "ab" + log_kwargs = {"encoding": "utf-8", "errors": "replace"} if self.verbose else {} + self.log_handle = log_path.open(log_mode, **log_kwargs) print(f"Starting {self.leader_id} with {self.settings_path}; log={log_path}") - self.process = subprocess.Popen( - [str(self.config.headless), str(self.settings_path)], - cwd=str(ROOT), - stdout=self.log_handle, - stderr=subprocess.STDOUT, - creationflags=creation_flags(), - env=runtime_env(), - ) + if self.verbose: + self.process = subprocess.Popen( + [str(self.config.headless), str(self.settings_path)], + cwd=str(ROOT), + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + creationflags=creation_flags(), + env=runtime_env(), + text=True, + encoding="utf-8", + errors="replace", + bufsize=1, + ) + self.stream_thread = threading.Thread(target=self.stream_output, daemon=True) + self.stream_thread.start() + else: + self.process = subprocess.Popen( + [str(self.config.headless), str(self.settings_path)], + cwd=str(ROOT), + stdout=self.log_handle, + stderr=subprocess.STDOUT, + creationflags=creation_flags(), + env=runtime_env(), + ) def poll(self) -> None: if self.disabled: @@ -201,6 +233,9 @@ def poll(self) -> None: print(f"{self.leader_id}: exited with code {rc}") self.process = None + if self.stream_thread: + self.stream_thread.join(timeout=2.0) + self.stream_thread = None if self.log_handle: self.log_handle.close() self.log_handle = None @@ -226,6 +261,9 @@ def stop(self) -> None: except subprocess.TimeoutExpired: print(f"{self.leader_id}: terminate timed out; killing") self.process.kill() + if self.stream_thread: + self.stream_thread.join(timeout=2.0) + self.stream_thread = None if self.log_handle: self.log_handle.close() self.log_handle = None @@ -275,13 +313,19 @@ def start_dashboard_thread(config: FleetConfig, host: str, port: int) -> threadi return thread -def cmd_supervise(config: FleetConfig, dashboard: bool = False, dashboard_host: str = "127.0.0.1", dashboard_port: int = 8780) -> int: +def cmd_supervise( + config: FleetConfig, + dashboard: bool = False, + dashboard_host: str = "127.0.0.1", + dashboard_port: int = 8780, + verbose: bool = False, +) -> int: if not config.headless.exists(): print(f"Missing wowee_headless executable: {config.headless}", file=sys.stderr) return 2 managed = [ - ManagedLeader(config, leader, index, settings_path) + ManagedLeader(config, leader, index, settings_path, verbose=verbose) for index, (leader, settings_path) in enumerate(config.write_runtime_settings()) ] @@ -364,6 +408,14 @@ def main(argv: list[str]) -> int: supervise_parser.add_argument("--dashboard", action="store_true", help="Also start the team status dashboard") supervise_parser.add_argument("--dashboard-port", type=int, default=8780, help="Dashboard port") supervise_parser.add_argument("--dashboard-host", default="127.0.0.1", help="Dashboard bind address") + supervise_parser.add_argument( + "-v", + "--verbose", + "--debug", + "--v", + action="store_true", + help="Stream each leader's log output to the supervisor console", + ) status_parser = sub.add_parser("status") status_parser.add_argument("--fleet", default="") status_parser.add_argument("--leader", action="append", default=[]) @@ -392,7 +444,13 @@ def main(argv: list[str]) -> int: if args.command == "start": return cmd_start(config) if args.command == "supervise": - return cmd_supervise(config, dashboard=args.dashboard, dashboard_host=args.dashboard_host, dashboard_port=args.dashboard_port) + return cmd_supervise( + config, + dashboard=args.dashboard, + dashboard_host=args.dashboard_host, + dashboard_port=args.dashboard_port, + verbose=args.verbose, + ) if args.command == "status": return cmd_status(config, args.fleet, args.leader) if args.command == "stop": From 52f7fffddc0573dab24037867c038cb756ccbc62 Mon Sep 17 00:00:00 2001 From: Josh Anderson Date: Wed, 8 Jul 2026 12:39:08 -0500 Subject: [PATCH 006/103] Show newest dashboard chat first --- tools/bot_fleet_manager/team_status_server.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tools/bot_fleet_manager/team_status_server.py b/tools/bot_fleet_manager/team_status_server.py index 4cc66833e..5e93ab92c 100644 --- a/tools/bot_fleet_manager/team_status_server.py +++ b/tools/bot_fleet_manager/team_status_server.py @@ -202,7 +202,7 @@ def render_dashboard(title: str) -> bytes: }} function chatText(team) {{ if (team.endpointErrors && team.endpointErrors.chat) return "chat endpoint stale"; - const messages = team.recentChat || []; + const messages = [...(team.recentChat || [])].reverse(); if (!messages.length) return "no recent chat"; return `
${{messages.map(m => `
${{esc(m.type)}} ${{esc(m.from || "")}}: ${{esc(m.message || "")}}
`).join("")}}
`; }} From 1407a074489f503908d310d2434a60c1daadde37 Mon Sep 17 00:00:00 2001 From: Josh Anderson Date: Wed, 8 Jul 2026 12:56:13 -0500 Subject: [PATCH 007/103] Flush logger stdout for live debug streaming --- src/core/logger.cpp | 1 + 1 file changed, 1 insertion(+) diff --git a/src/core/logger.cpp b/src/core/logger.cpp index 27162dfd1..e39692b19 100644 --- a/src/core/logger.cpp +++ b/src/core/logger.cpp @@ -95,6 +95,7 @@ void Logger::emitLineLocked(LogLevel level, const std::string& message) { if (echoToStdout_) { std::cout << line.str() << '\n'; + std::cout.flush(); } if (fileStream.is_open()) { fileStream << line.str() << '\n'; From 290d07a9e2a2a2be36c96306a65be3887b13f412 Mon Sep 17 00:00:00 2001 From: Josh Anderson Date: Wed, 8 Jul 2026 13:08:45 -0500 Subject: [PATCH 008/103] Add abstract fleet leader map --- BOT_PLAN.md | 6 +- tools/bot_fleet_manager/README.md | 2 +- tools/bot_fleet_manager/team_status_server.py | 194 ++++++++++++++++++ 3 files changed, 199 insertions(+), 3 deletions(-) diff --git a/BOT_PLAN.md b/BOT_PLAN.md index cfbd56407..432d6afc0 100644 --- a/BOT_PLAN.md +++ b/BOT_PLAN.md @@ -65,8 +65,10 @@ Turn `wowee_headless` into a bot automation foundation that can supervise multip ## Phase 3c: Map Viewer (Later) - [ ] Review MiniManager's map/data approach and adapt the useful pieces into Python. -- [ ] Build on top of the textual dashboard once leader status data is stable. -- [ ] Avoid committing the experimental static WHM/Leaflet map server until its coordinate and asset assumptions are validated. +- [x] Build on top of the textual dashboard once leader status data is stable. +- [x] Add an abstract dashboard map that plots online leaders from `/world/self` without requiring map assets. +- [ ] Replace or augment the abstract map with MiniManager-style map art/projection once that project is available. +- [x] Avoid committing the experimental static WHM/Leaflet map server until its coordinate and asset assumptions are validated. ## Phase 4: External Automation diff --git a/tools/bot_fleet_manager/README.md b/tools/bot_fleet_manager/README.md index fc07cff46..10c4fd721 100644 --- a/tools/bot_fleet_manager/README.md +++ b/tools/bot_fleet_manager/README.md @@ -42,7 +42,7 @@ Or run only the dashboard against already-running leaders: python tools/bot_fleet_manager/bot_fleet_manager.py tools/bot_fleet_manager/fleet.settings.json dashboard ``` -The dashboard listens on `http://127.0.0.1:8780` by default and shows leader status, textual position, current activity, party members, and recent chat. Use `--dashboard-port` with `supervise` or `--port` with `dashboard` to change the port. +The dashboard listens on `http://127.0.0.1:8780` by default and shows an abstract leader-position map, leader status, textual position, current activity, party members, and recent chat. Use `--dashboard-port` with `supervise` or `--port` with `dashboard` to change the port. Common commands: diff --git a/tools/bot_fleet_manager/team_status_server.py b/tools/bot_fleet_manager/team_status_server.py index 5e93ab92c..1ced47ce5 100644 --- a/tools/bot_fleet_manager/team_status_server.py +++ b/tools/bot_fleet_manager/team_status_server.py @@ -143,7 +143,48 @@ def render_dashboard(title: str) -> bytes: }} h1 {{ margin: 0; font-size: 20px; font-weight: 650; letter-spacing: 0; }} main {{ padding: 18px 24px 28px; }} + h2 {{ margin: 0; font-size: 14px; font-weight: 650; letter-spacing: 0; }} .meta {{ color: #aab2bd; font-size: 13px; }} + .map-shell {{ + border: 1px solid #2a3037; + border-radius: 6px; + background: #15191e; + margin-bottom: 18px; + overflow: hidden; + }} + .map-head {{ + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + padding: 10px 12px; + border-bottom: 1px solid #2a3037; + }} + .map-wrap {{ position: relative; height: min(52vh, 520px); min-height: 280px; }} + #leader-map {{ display: block; width: 100%; height: 100%; background: #0f1317; }} + .map-tabs {{ display: flex; gap: 6px; flex-wrap: wrap; justify-content: flex-end; }} + .map-tabs button {{ + border: 1px solid #3b4652; + background: #1e252d; + color: #dbe3ec; + border-radius: 5px; + padding: 5px 9px; + font: inherit; + font-size: 12px; + cursor: pointer; + }} + .map-tabs button.active {{ background: #d3e5ff; color: #10151a; border-color: #d3e5ff; }} + .map-legend {{ + display: flex; + gap: 12px; + flex-wrap: wrap; + padding: 8px 12px 10px; + border-top: 1px solid #2a3037; + color: #c5ced8; + font-size: 12px; + }} + .legend-item {{ display: inline-flex; align-items: center; gap: 6px; }} + .dot {{ width: 9px; height: 9px; border-radius: 50%; display: inline-block; }} table {{ width: 100%; border-collapse: collapse; table-layout: fixed; }} th, td {{ padding: 10px 8px; border-bottom: 1px solid #2a3037; vertical-align: top; text-align: left; }} th {{ color: #b8c0cc; font-size: 12px; text-transform: uppercase; letter-spacing: .04em; }} @@ -171,6 +212,16 @@ def render_dashboard(title: str) -> bytes:
loading...
+
+
+

Leader Map

+
+
+
+ +
+
+
@@ -187,6 +238,147 @@ def render_dashboard(title: str) -> bytes: """ From 1c37d7655de5eb5dbff3abf43f8d03e7c58df755 Mon Sep 17 00:00:00 2001 From: Josh Anderson Date: Wed, 8 Jul 2026 13:16:04 -0500 Subject: [PATCH 009/103] Use MiniManager zone art in fleet map --- BOT_PLAN.md | 5 +- tools/bot_fleet_manager/README.md | 10 +- .../fetch_minimanager_assets.py | 80 +++ .../map_data/zone_map_bounds.json | 642 ++++++++++++++++++ tools/bot_fleet_manager/team_status_server.py | 183 ++++- 5 files changed, 908 insertions(+), 12 deletions(-) create mode 100644 tools/bot_fleet_manager/fetch_minimanager_assets.py create mode 100644 tools/bot_fleet_manager/map_data/zone_map_bounds.json diff --git a/BOT_PLAN.md b/BOT_PLAN.md index 432d6afc0..0b063236b 100644 --- a/BOT_PLAN.md +++ b/BOT_PLAN.md @@ -64,10 +64,11 @@ Turn `wowee_headless` into a bot automation foundation that can supervise multip ## Phase 3c: Map Viewer (Later) -- [ ] Review MiniManager's map/data approach and adapt the useful pieces into Python. +- [x] Review MiniManager's map/data approach and adapt the useful pieces into Python. - [x] Build on top of the textual dashboard once leader status data is stable. - [x] Add an abstract dashboard map that plots online leaders from `/world/self` without requiring map assets. -- [ ] Replace or augment the abstract map with MiniManager-style map art/projection once that project is available. +- [x] Augment the abstract map with MiniManager-style zone bounds and optional zone art. +- [x] Add a scripted asset refresh command for pulling MiniManager zone PNGs into the ignored runtime asset directory. - [x] Avoid committing the experimental static WHM/Leaflet map server until its coordinate and asset assumptions are validated. ## Phase 4: External Automation diff --git a/tools/bot_fleet_manager/README.md b/tools/bot_fleet_manager/README.md index 10c4fd721..9eaf4f80c 100644 --- a/tools/bot_fleet_manager/README.md +++ b/tools/bot_fleet_manager/README.md @@ -42,7 +42,15 @@ Or run only the dashboard against already-running leaders: python tools/bot_fleet_manager/bot_fleet_manager.py tools/bot_fleet_manager/fleet.settings.json dashboard ``` -The dashboard listens on `http://127.0.0.1:8780` by default and shows an abstract leader-position map, leader status, textual position, current activity, party members, and recent chat. Use `--dashboard-port` with `supervise` or `--port` with `dashboard` to change the port. +The dashboard listens on `http://127.0.0.1:8780` by default and shows a leader-position map, leader status, textual position, current activity, party members, and recent chat. Use `--dashboard-port` with `supervise` or `--port` with `dashboard` to change the port. + +The map uses bundled MiniManager zone bounds and will draw client zone art when PNGs are available under `tools/bot_fleet_manager/runtime/map_assets/zone/`. That runtime directory is ignored by git. Set `WOWEE_MINIMANAGER_ZONE_DIR` to another `img/zone` folder if you want to serve the art from a different local MiniManager checkout. + +Refresh MiniManager zone art from the CMaNGOS host described by the external `wow_server` `.env`: + +```bash +python tools/bot_fleet_manager/fetch_minimanager_assets.py --env C:\Users\admin\code\wow_server\.env +``` Common commands: diff --git a/tools/bot_fleet_manager/fetch_minimanager_assets.py b/tools/bot_fleet_manager/fetch_minimanager_assets.py new file mode 100644 index 000000000..fec47d633 --- /dev/null +++ b/tools/bot_fleet_manager/fetch_minimanager_assets.py @@ -0,0 +1,80 @@ +#!/usr/bin/env python3 +"""Fetch MiniManager zone map art into the ignored fleet-manager runtime dir.""" + +from __future__ import annotations + +import argparse +import os +import subprocess +from pathlib import Path + + +ROOT = Path(__file__).resolve().parents[2] +DEFAULT_DEST = ROOT / "tools" / "bot_fleet_manager" / "runtime" / "map_assets" / "zone" +DEFAULT_REMOTE_DIR = "/var/www/minimanager/img/zone" +DEFAULT_EXTERNAL_ENV = Path(r"C:\Users\admin\code\wow_server\.env") + + +def read_env(path: Path) -> dict[str, str]: + values: dict[str, str] = {} + with path.open("r", encoding="utf-8") as handle: + for raw in handle: + line = raw.strip() + if not line or line.startswith("#") or "=" not in line: + continue + key, value = line.split("=", 1) + values[key.strip()] = value.strip().strip('"').strip("'") + return values + + +def require(values: dict[str, str], key: str, env_path: Path) -> str: + value = values.get(key, "").strip() + if not value: + raise SystemExit(f"{key} must be set in {env_path}") + return value + + +def main() -> int: + parser = argparse.ArgumentParser(description="Fetch MiniManager zone PNGs for the fleet dashboard") + parser.add_argument("--env", type=Path, default=DEFAULT_EXTERNAL_ENV, help="Path to the wow_server .env file") + parser.add_argument("--remote-dir", default=DEFAULT_REMOTE_DIR, help="Remote MiniManager img/zone directory") + parser.add_argument("--dest", type=Path, default=DEFAULT_DEST, help="Local destination directory") + args = parser.parse_args() + + env_path = args.env.expanduser() + if not env_path.exists(): + raise SystemExit(f"Missing env file: {env_path}") + + values = read_env(env_path) + host = require(values, "MANGOS_HOST", env_path) + user = require(values, "MANGOS_USER", env_path) + key_path = require(values, "MANGOS_SSH_KEY_PATH", env_path) + port = values.get("MANGOS_PORT", "22").strip() or "22" + + dest = args.dest.expanduser() + dest.mkdir(parents=True, exist_ok=True) + remote_glob = args.remote_dir.rstrip("/") + "/*.png" + command = [ + "scp", + "-i", + key_path, + "-P", + port, + "-o", + "StrictHostKeyChecking=accept-new", + f"{user}@{host}:{remote_glob}", + str(dest) + os.sep, + ] + + completed = subprocess.run(command, text=True) + if completed.returncode != 0: + return completed.returncode + + files = list(dest.glob("*.png")) + total_bytes = sum(path.stat().st_size for path in files) + print(f"Fetched {len(files)} zone map PNGs to {dest} ({total_bytes} bytes)") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tools/bot_fleet_manager/map_data/zone_map_bounds.json b/tools/bot_fleet_manager/map_data/zone_map_bounds.json new file mode 100644 index 000000000..fc422ec1c --- /dev/null +++ b/tools/bot_fleet_manager/map_data/zone_map_bounds.json @@ -0,0 +1,642 @@ +{ + "1": { + "asset": "DunMorogh.png", + "asset_height": 668, + "asset_width": 1002, + "bottom": -7160.41650391, + "left": 1802.08325195, + "map_id": 0, + "right": -3122.91650391, + "top": -3877.08325195 + }, + "10": { + "asset": "Duskwood.png", + "asset_height": 668, + "asset_width": 1002, + "bottom": -11516.66601562, + "left": 833.33331299, + "map_id": 0, + "right": -1866.66662598, + "top": -9716.66601562 + }, + "11": { + "asset": "Wetlands.png", + "asset_height": 668, + "asset_width": 1002, + "bottom": -4904.16650391, + "left": -389.58331299, + "map_id": 0, + "right": -4525.0, + "top": -2147.91650391 + }, + "12": { + "asset": "Elwynn.png", + "asset_height": 668, + "asset_width": 1002, + "bottom": -10254.16601562, + "left": 1535.41662598, + "map_id": 0, + "right": -1935.41662598, + "top": -7939.58300781 + }, + "130": { + "asset": "Silverpine.png", + "asset_height": 668, + "asset_width": 1002, + "bottom": -1133.33325195, + "left": 3449.99975586, + "map_id": 0, + "right": -750.0, + "top": 1666.66662598 + }, + "1377": { + "asset": "Silithus.png", + "asset_height": 669, + "asset_width": 1002, + "bottom": -8281.25, + "left": 2537.5, + "map_id": 1, + "right": -945.83398438, + "top": -5958.33398438 + }, + "139": { + "asset": "EasternPlaguelands.png", + "asset_height": 668, + "asset_width": 1002, + "bottom": 1218.75, + "left": -2185.41650391, + "map_id": 0, + "right": -6056.25, + "top": 3799.99975586 + }, + "14": { + "asset": "Durotar.png", + "asset_height": 668, + "asset_width": 1002, + "bottom": -1716.66662598, + "left": -1962.49987793, + "map_id": 1, + "right": -7249.99951172, + "top": 1808.33325195 + }, + "141": { + "asset": "Teldrassil.png", + "asset_height": 668, + "asset_width": 1002, + "bottom": 8437.5, + "left": 3814.58325195, + "map_id": 1, + "right": -1277.08325195, + "top": 11831.25 + }, + "148": { + "asset": "Darkshore.png", + "asset_height": 668, + "asset_width": 1002, + "bottom": 3966.66650391, + "left": 2941.66650391, + "map_id": 1, + "right": -3608.33325195, + "top": 8333.33300781 + }, + "1497": { + "asset": "Undercity.png", + "asset_height": 668, + "asset_width": 1002, + "bottom": 1237.84118652, + "left": 873.19262695, + "map_id": 0, + "right": -86.18240356, + "top": 1877.9453125 + }, + "15": { + "asset": "Dustwallow.png", + "asset_height": 668, + "asset_width": 1002, + "bottom": -5533.33300781, + "left": -974.99993896, + "map_id": 1, + "right": -6225.0, + "top": -2033.33325195 + }, + "1519": { + "asset": "Stormwind.png", + "asset_height": 668, + "asset_width": 1002, + "bottom": -9175.20507812, + "left": 1380.97143555, + "map_id": 0, + "right": 36.70063019, + "top": -8278.85058594 + }, + "1537": { + "asset": "Ironforge.png", + "asset_height": 668, + "asset_width": 1002, + "bottom": -5096.84570312, + "left": -713.59136963, + "map_id": 0, + "right": -1504.21643066, + "top": -4569.24121094 + }, + "16": { + "asset": "Aszhara.png", + "asset_height": 668, + "asset_width": 1002, + "bottom": 1960.41662598, + "left": -3277.08325195, + "map_id": 1, + "right": -8347.91601562, + "top": 5341.66650391 + }, + "1637": { + "asset": "Ogrimmar.png", + "asset_height": 668, + "asset_width": 1002, + "bottom": 1338.46057129, + "left": -3680.60107422, + "map_id": 1, + "right": -5083.20556641, + "top": 2273.87719727 + }, + "1638": { + "asset": "ThunderBluff.png", + "asset_height": 668, + "asset_width": 1002, + "bottom": -1545.83325195, + "left": 516.66662598, + "map_id": 1, + "right": -527.08331299, + "top": -849.99993896 + }, + "1657": { + "asset": "Darnassis.png", + "asset_height": 668, + "asset_width": 1002, + "bottom": 9532.58691406, + "left": 2938.36279297, + "map_id": 1, + "right": 1880.02954102, + "top": 10238.31640625 + }, + "17": { + "asset": "Barrens.png", + "asset_height": 668, + "asset_width": 1002, + "bottom": -5143.75, + "left": 2622.91650391, + "map_id": 1, + "right": -7510.41650391, + "top": 1612.49987793 + }, + "215": { + "asset": "Mulgore.png", + "asset_height": 668, + "asset_width": 1002, + "bottom": -3697.91650391, + "left": 2047.91662598, + "map_id": 1, + "right": -3089.58325195, + "top": -272.91665649 + }, + "2597": { + "asset": "AlteracValley.png", + "asset_height": 668, + "asset_width": 1002, + "bottom": -1739.58325195, + "left": 1781.24987793, + "map_id": 30, + "right": -2456.25, + "top": 1085.41662598 + }, + "267": { + "asset": "Hilsbrad.png", + "asset_height": 668, + "asset_width": 1002, + "bottom": -1733.33325195, + "left": 1066.66662598, + "map_id": 0, + "right": -2133.33325195, + "top": 400.0 + }, + "28": { + "asset": "WesternPlaguelands.png", + "asset_height": 668, + "asset_width": 1002, + "bottom": 499.99996948, + "left": 416.66665649, + "map_id": 0, + "right": -3883.33325195, + "top": 3366.66650391 + }, + "3": { + "asset": "Badlands.png", + "asset_height": 668, + "asset_width": 1002, + "bottom": -7547.91650391, + "left": -2079.16650391, + "map_id": 0, + "right": -4566.66650391, + "top": -5889.58300781 + }, + "3277": { + "asset": "WarsongGulch.png", + "asset_height": 668, + "asset_width": 1002, + "bottom": 862.49993896, + "left": 2041.66662598, + "map_id": 489, + "right": 895.83331299, + "top": 1627.08325195 + }, + "33": { + "asset": "Stranglethorn.png", + "asset_height": 668, + "asset_width": 1002, + "bottom": -15422.91601562, + "left": 2220.83325195, + "map_id": 0, + "right": -4160.41650391, + "top": -11168.75 + }, + "331": { + "asset": "Ashenvale.png", + "asset_height": 668, + "asset_width": 1002, + "bottom": 829.16662598, + "left": 1699.99987793, + "map_id": 1, + "right": -4066.66650391, + "top": 4672.91650391 + }, + "3358": { + "asset": "ArathiBasin.png", + "asset_height": 668, + "asset_width": 1002, + "bottom": 337.5, + "left": 1858.33325195, + "map_id": 529, + "right": 102.08332825, + "top": 1508.33325195 + }, + "3430": { + "asset": "EversongWoods.png", + "asset_height": 668, + "asset_width": 1002, + "bottom": 7758.33300781, + "left": -4487.5, + "map_id": 530, + "right": -9412.5, + "top": 11041.66601562 + }, + "3433": { + "asset": "Ghostlands.png", + "asset_height": 668, + "asset_width": 1002, + "bottom": 6066.66650391, + "left": -5283.33300781, + "map_id": 530, + "right": -8583.33300781, + "top": 8266.66601562 + }, + "3483": { + "asset": "Hellfire.png", + "asset_height": 668, + "asset_width": 1002, + "bottom": -1962.49987793, + "left": 5539.58300781, + "map_id": 530, + "right": 375.0, + "top": 1481.25 + }, + "3487": { + "asset": "SilvermoonCity.png", + "asset_height": 668, + "asset_width": 1002, + "bottom": 9346.93847656, + "left": -6400.75, + "map_id": 530, + "right": -7612.20849609, + "top": 10153.70898438 + }, + "3518": { + "asset": "Nagrand.png", + "asset_height": 668, + "asset_width": 1002, + "bottom": -3641.66650391, + "left": 10295.83300781, + "map_id": 530, + "right": 4770.83300781, + "top": 41.66666412 + }, + "3519": { + "asset": "TerokkarForest.png", + "asset_height": 668, + "asset_width": 1002, + "bottom": -4600.0, + "left": 7083.33300781, + "map_id": 530, + "right": 1683.33325195, + "top": -999.99993896 + }, + "3520": { + "asset": "ShadowmoonValley.png", + "asset_height": 668, + "asset_width": 1002, + "bottom": -5614.58300781, + "left": 4225.0, + "map_id": 530, + "right": -1275.0, + "top": -1947.91662598 + }, + "3521": { + "asset": "Zangarmarsh.png", + "asset_height": 668, + "asset_width": 1002, + "bottom": -1416.66662598, + "left": 9475.0, + "map_id": 530, + "right": 4447.91650391, + "top": 1935.41662598 + }, + "3522": { + "asset": "BladesEdgeMountains.png", + "asset_height": 668, + "asset_width": 1002, + "bottom": 791.66662598, + "left": 8845.83300781, + "map_id": 530, + "right": 3420.83325195, + "top": 4408.33300781 + }, + "3523": { + "asset": "Netherstorm.png", + "asset_height": 668, + "asset_width": 1002, + "bottom": 1739.58325195, + "left": 5483.33300781, + "map_id": 530, + "right": -91.66666412, + "top": 5456.25 + }, + "3524": { + "asset": "AzuremystIsle.png", + "asset_height": 668, + "asset_width": 1020, + "bottom": -5508.33300781, + "left": -10500.0, + "map_id": 530, + "right": -14570.83300781, + "top": -2793.75 + }, + "3525": { + "asset": "BloodmystIsle.png", + "asset_height": 668, + "asset_width": 1002, + "bottom": -2933.33325195, + "left": -10075.0, + "map_id": 530, + "right": -13337.49902344, + "top": -758.33331299 + }, + "3557": { + "asset": "TheExodar.png", + "asset_height": 668, + "asset_width": 1002, + "bottom": -4314.37109375, + "left": -11066.3671875, + "map_id": 530, + "right": -12123.13769531, + "top": -3609.68334961 + }, + "357": { + "asset": "Feralas.png", + "asset_height": 668, + "asset_width": 1002, + "bottom": -6999.99951172, + "left": 5441.66650391, + "map_id": 1, + "right": -1508.33325195, + "top": -2366.66650391 + }, + "36": { + "asset": "Alterac.png", + "asset_height": 668, + "asset_width": 1002, + "bottom": -366.66665649, + "left": 783.33331299, + "map_id": 0, + "right": -2016.66662598, + "top": 1500.0 + }, + "361": { + "asset": "Felwood.png", + "asset_height": 668, + "asset_width": 1002, + "bottom": 3299.99975586, + "left": 1641.66662598, + "map_id": 1, + "right": -4108.33300781, + "top": 7133.33300781 + }, + "3703": { + "asset": "ShattrathCity.png", + "asset_height": 668, + "asset_width": 1002, + "bottom": -2344.7878418, + "left": 6135.25878906, + "map_id": 530, + "right": 4829.00878906, + "top": -1473.95446777 + }, + "38": { + "asset": "LochModan.png", + "asset_height": 668, + "asset_width": 1002, + "bottom": -6327.08300781, + "left": -1993.74987793, + "map_id": 0, + "right": -4752.08300781, + "top": -4487.5 + }, + "3820": { + "asset": "NetherstormArena.png", + "asset_height": 668, + "asset_width": 1002, + "bottom": 1404.16662598, + "left": 2660.41650391, + "map_id": 566, + "right": 389.58331299, + "top": 2918.75 + }, + "4": { + "asset": "BlastedLands.png", + "asset_height": 668, + "asset_width": 1002, + "bottom": -12800.0, + "left": -1241.66662598, + "map_id": 0, + "right": -4591.66650391, + "top": -10566.66601562 + }, + "40": { + "asset": "Westfall.png", + "asset_height": 668, + "asset_width": 1002, + "bottom": -11733.33300781, + "left": 3016.66650391, + "map_id": 0, + "right": -483.33331299, + "top": -9400.0 + }, + "400": { + "asset": "ThousandNeedles.png", + "asset_height": 668, + "asset_width": 1002, + "bottom": -6899.99951172, + "left": -433.33331299, + "map_id": 1, + "right": -4833.33300781, + "top": -3966.66650391 + }, + "405": { + "asset": "Desolace.png", + "asset_height": 668, + "asset_width": 1002, + "bottom": -2545.83325195, + "left": 4233.33300781, + "map_id": 1, + "right": -262.5, + "top": 452.08331299 + }, + "406": { + "asset": "StonetalonMountains.png", + "asset_height": 668, + "asset_width": 1002, + "bottom": -339.58331299, + "left": 3245.83325195, + "map_id": 1, + "right": -1637.49987793, + "top": 2916.66650391 + }, + "41": { + "asset": "DeadwindPass.png", + "asset_height": 668, + "asset_width": 1002, + "bottom": -11533.33300781, + "left": -833.33331299, + "map_id": 0, + "right": -3333.33325195, + "top": -9866.66601562 + }, + "44": { + "asset": "Redridge.png", + "asset_height": 668, + "asset_width": 1002, + "bottom": -10022.91601562, + "left": -1570.83325195, + "map_id": 0, + "right": -3741.66650391, + "top": -8575.0 + }, + "440": { + "asset": "Tanaris.png", + "asset_height": 668, + "asset_width": 1002, + "bottom": -10475.0, + "left": -218.74998474, + "map_id": 1, + "right": -7118.74951172, + "top": -5875.0 + }, + "45": { + "asset": "Arathi.png", + "asset_height": 668, + "asset_width": 1002, + "bottom": -2533.33325195, + "left": -866.66662598, + "map_id": 0, + "right": -4466.66650391, + "top": -133.33332825 + }, + "46": { + "asset": "BurningSteppes.png", + "asset_height": 668, + "asset_width": 1002, + "bottom": -8983.33300781, + "left": -266.66665649, + "map_id": 0, + "right": -3195.83325195, + "top": -7031.24951172 + }, + "47": { + "asset": "Hinterlands.png", + "asset_height": 668, + "asset_width": 1002, + "bottom": -1100.0, + "left": -1575.0, + "map_id": 0, + "right": -5425.0, + "top": 1466.66662598 + }, + "490": { + "asset": "UngoroCrater.png", + "asset_height": 668, + "asset_width": 1002, + "bottom": -8433.33300781, + "left": 533.33331299, + "map_id": 1, + "right": -3166.66650391, + "top": -5966.66650391 + }, + "493": { + "asset": "Moonglade.png", + "asset_height": 668, + "asset_width": 1002, + "bottom": 6952.08300781, + "left": -1381.25, + "map_id": 1, + "right": -3689.58325195, + "top": 8491.66601562 + }, + "51": { + "asset": "SearingGorge.png", + "asset_height": 668, + "asset_width": 1002, + "bottom": -7587.49951172, + "left": -322.91665649, + "map_id": 0, + "right": -2554.16650391, + "top": -6100.0 + }, + "618": { + "asset": "Winterspring.png", + "asset_height": 668, + "asset_width": 1002, + "bottom": 3799.99975586, + "left": -316.66665649, + "map_id": 1, + "right": -7416.66650391, + "top": 8533.33300781 + }, + "8": { + "asset": "SwampOfSorrows.png", + "asset_height": 668, + "asset_width": 1002, + "bottom": -11150.0, + "left": -2222.91650391, + "map_id": 0, + "right": -4516.66650391, + "top": -9620.83300781 + }, + "85": { + "asset": "Tirisfal.png", + "asset_height": 668, + "asset_width": 1002, + "bottom": 824.99993896, + "left": 3033.33325195, + "map_id": 0, + "right": -1485.41662598, + "top": 3837.49975586 + } +} diff --git a/tools/bot_fleet_manager/team_status_server.py b/tools/bot_fleet_manager/team_status_server.py index 1ced47ce5..bf4d160aa 100644 --- a/tools/bot_fleet_manager/team_status_server.py +++ b/tools/bot_fleet_manager/team_status_server.py @@ -5,13 +5,38 @@ import html import json +import os +import posixpath import time import urllib.error +import urllib.parse import urllib.request from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer +from pathlib import Path from typing import Any +ROOT = Path(__file__).resolve().parent +MAP_DATA_PATH = ROOT / "map_data" / "zone_map_bounds.json" +RUNTIME_ZONE_ASSET_DIR = ROOT / "runtime" / "map_assets" / "zone" +DEFAULT_ZONE_ASSET_DIRS = [ + RUNTIME_ZONE_ASSET_DIR, + Path(os.environ.get("WOWEE_MINIMANAGER_ZONE_DIR", "")) if os.environ.get("WOWEE_MINIMANAGER_ZONE_DIR") else None, + Path(r"C:\Users\admin\code\wow_server\minimanager_remote\img\zone"), +] + + +def _load_zone_bounds() -> dict[str, dict[str, Any]]: + try: + with MAP_DATA_PATH.open("r", encoding="utf-8") as handle: + return json.load(handle) + except (OSError, json.JSONDecodeError): + return {} + + +ZONE_BOUNDS = _load_zone_bounds() + + def _request_json(url: str, timeout: float = 3.0) -> dict[str, Any]: with urllib.request.urlopen(url, timeout=timeout) as response: return json.loads(response.read().decode("utf-8")) @@ -61,6 +86,90 @@ def _team_state(status: dict[str, Any] | None) -> str: return str(status.get("status") or "connecting") +def _zone_score(x: float, y: float, bounds: dict[str, Any]) -> tuple[float, float]: + min_x = min(float(bounds["top"]), float(bounds["bottom"])) + max_x = max(float(bounds["top"]), float(bounds["bottom"])) + min_y = min(float(bounds["left"]), float(bounds["right"])) + max_y = max(float(bounds["left"]), float(bounds["right"])) + area = max(1.0, (max_x - min_x) * (max_y - min_y)) + center_x = (min_x + max_x) / 2.0 + center_y = (min_y + max_y) / 2.0 + half_height = max(1.0, (max_x - min_x) / 2.0) + half_width = max(1.0, (max_y - min_y) / 2.0) + score = ((x - center_x) / half_height) ** 2 + ((y - center_y) / half_width) ** 2 + return score, area + + +def _zone_contains(map_id: int, x: float, y: float, bounds: dict[str, Any]) -> bool: + if int(bounds.get("map_id", -1)) != map_id: + return False + min_x = min(float(bounds["top"]), float(bounds["bottom"])) + max_x = max(float(bounds["top"]), float(bounds["bottom"])) + min_y = min(float(bounds["left"]), float(bounds["right"])) + max_y = max(float(bounds["left"]), float(bounds["right"])) + return min_x <= x <= max_x and min_y <= y <= max_y + + +def _zone_for_position(map_id: int, x: float, y: float) -> tuple[str, dict[str, Any]] | None: + best: tuple[str, dict[str, Any], float, float] | None = None + for zone_id, bounds in ZONE_BOUNDS.items(): + if not _zone_contains(map_id, x, y, bounds): + continue + score, area = _zone_score(x, y, bounds) + if best is None or score < best[2] or (score <= best[2] + 0.10 and area < best[3]): + best = (zone_id, bounds, score, area) + if best is None: + return None + return best[0], best[1] + + +def _map_zone_for_world(world: dict[str, Any]) -> dict[str, Any] | None: + position = world.get("position", {}) + try: + map_id = int(world.get("mapId", -1)) + x = float(position.get("x")) + y = float(position.get("y")) + except (TypeError, ValueError): + return None + + explicit_zone = world.get("zoneId") + if explicit_zone is not None: + bounds = ZONE_BOUNDS.get(str(explicit_zone)) + if bounds and int(bounds.get("map_id", -1)) == map_id: + return {"zoneId": int(explicit_zone), **bounds} + + found = _zone_for_position(map_id, x, y) + if not found: + return None + zone_id, bounds = found + return {"zoneId": int(zone_id), **bounds} + + +def _zone_asset_dirs() -> list[Path]: + dirs: list[Path] = [] + for item in DEFAULT_ZONE_ASSET_DIRS: + if item is None: + continue + try: + path = item.expanduser().resolve() + except OSError: + continue + if path.exists() and path.is_dir() and path not in dirs: + dirs.append(path) + return dirs + + +def _zone_asset_path(asset_name: str) -> Path | None: + clean_name = posixpath.basename(asset_name) + if clean_name != asset_name or not clean_name.lower().endswith(".png"): + return None + for directory in _zone_asset_dirs(): + candidate = directory / clean_name + if candidate.exists() and candidate.is_file(): + return candidate + return None + + def collect_team_status(api_bases: list[dict[str, str]]) -> dict[str, Any]: teams: list[dict[str, Any]] = [] collected_at = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()) @@ -94,6 +203,21 @@ def collect_team_status(api_bases: list[dict[str, str]]) -> dict[str, Any]: world, world_error = _try_request_json(base + "/world/self") if world is not None: team["world"] = world + map_zone = _map_zone_for_world(world) + if map_zone is not None: + asset = str(map_zone.get("asset", "")) + team["mapZone"] = { + "zoneId": map_zone["zoneId"], + "mapId": int(map_zone["map_id"]), + "asset": asset, + "assetUrl": f"/map-assets/zone/{urllib.parse.quote(asset)}" if _zone_asset_path(asset) else "", + "left": float(map_zone["left"]), + "right": float(map_zone["right"]), + "top": float(map_zone["top"]), + "bottom": float(map_zone["bottom"]), + "assetWidth": int(map_zone["asset_width"]), + "assetHeight": int(map_zone["asset_height"]), + } elif world_error: endpoint_errors["world"] = world_error @@ -240,19 +364,37 @@ def render_dashboard(title: str) -> bytes: const esc = (value) => String(value ?? "").replace(/[&<>"']/g, c => ({{"&":"&","<":"<",">":">","\\"":""","'":"'"}}[c])); let selectedMapId = ""; const colors = ["#67d391", "#7db7ff", "#f2c166", "#ff8f8f", "#c49bff", "#58d6d1", "#f48bc2", "#a8d86d"]; + const zoneImages = new Map(); + function loadZoneImage(url) {{ + if (!url) return null; + if (zoneImages.has(url)) return zoneImages.get(url); + const image = new Image(); + image.onload = () => refresh(); + image.src = url; + zoneImages.set(url, image); + return image; + }} function leaderPositions(teams) {{ return teams.map((team, index) => {{ const world = team.world || {{}}; const pos = world.position || {{}}; + const zone = team.mapZone || null; const x = Number(pos.x); const y = Number(pos.y); const z = Number(pos.z); const mapId = String(world.mapId ?? ""); if (!team.apiReachable || team.state !== "in_world" || !mapId || !Number.isFinite(x) || !Number.isFinite(y)) return null; + const zoneId = zone ? String(zone.zoneId ?? "") : ""; + const assetUrl = zone ? String(zone.assetUrl || "") : ""; return {{ id: String(team.id || `leader-${{index + 1}}`), fleet: String(team.fleet || "default"), mapId, + zoneId, + groupId: zoneId && assetUrl ? `zone:${{zoneId}}` : `map:${{mapId}}`, + groupLabel: zoneId && assetUrl ? `zone ${{zoneId}}` : `map ${{mapId}}`, + zone, + assetUrl, x, y, z: Number.isFinite(z) ? z : 0, @@ -264,10 +406,10 @@ def render_dashboard(title: str) -> bytes: function groupedMaps(points) {{ const groups = new Map(); for (const point of points) {{ - if (!groups.has(point.mapId)) groups.set(point.mapId, []); - groups.get(point.mapId).push(point); + if (!groups.has(point.groupId)) groups.set(point.groupId, {{ label: point.groupLabel, points: [] }}); + groups.get(point.groupId).points.push(point); }} - return [...groups.entries()].sort((a, b) => Number(b[1].length) - Number(a[1].length) || a[0].localeCompare(b[0])); + return [...groups.entries()].sort((a, b) => Number(b[1].points.length) - Number(a[1].points.length) || a[1].label.localeCompare(b[1].label)); }} function mapBounds(points) {{ let minX = Math.min(...points.map(p => p.x)); @@ -311,12 +453,29 @@ def render_dashboard(title: str) -> bytes: return; }} - const bounds = mapBounds(points); - const toScreen = (point) => ({{ + const zone = points.find(point => point.zone && point.assetUrl)?.zone || null; + const zoneImage = zone ? loadZoneImage(String(zone.assetUrl || "")) : null; + const hasZoneImage = zone && zoneImage && zoneImage.complete && zoneImage.naturalWidth > 0; + const bounds = zone ? {{ + minX: Math.min(Number(zone.top), Number(zone.bottom)), + maxX: Math.max(Number(zone.top), Number(zone.bottom)), + minY: Math.min(Number(zone.left), Number(zone.right)), + maxY: Math.max(Number(zone.left), Number(zone.right)) + }} : mapBounds(points); + const toScreen = (point) => zone ? ({{ + x: (((point.y - Number(zone.left)) / (Number(zone.right) - Number(zone.left))) || 0) * width, + y: (((point.x - Number(zone.top)) / (Number(zone.bottom) - Number(zone.top))) || 0) * height + }}) : ({{ x: ((point.y - bounds.minY) / (bounds.maxY - bounds.minY)) * width, y: ((bounds.maxX - point.x) / (bounds.maxX - bounds.minX)) * height }}); + if (hasZoneImage) {{ + ctx.drawImage(zoneImage, 0, 0, width, height); + ctx.fillStyle = "rgba(15,19,23,0.22)"; + ctx.fillRect(0, 0, width, height); + }} + ctx.strokeStyle = "#202832"; ctx.lineWidth = 1; const grid = 8; @@ -334,7 +493,7 @@ def render_dashboard(title: str) -> bytes: ctx.fillStyle = "#7f8a96"; ctx.font = "12px Inter, Segoe UI, Arial, sans-serif"; ctx.textAlign = "left"; - ctx.fillText(`map ${{esc(mapId)}}`, 12, 20); + ctx.fillText(zone ? `${{points[0].groupLabel}} / map ${{points[0].mapId}} / ${{hasZoneImage ? "zone art" : "coordinate plot"}}` : `map ${{esc(mapId)}}`, 12, 20); ctx.textAlign = "right"; ctx.fillText(`N ${{bounds.maxX.toFixed(0)}} / W ${{bounds.maxY.toFixed(0)}}`, width - 12, 20); ctx.fillText(`S ${{bounds.minX.toFixed(0)}} / E ${{bounds.minY.toFixed(0)}}`, width - 12, height - 12); @@ -367,15 +526,15 @@ def render_dashboard(title: str) -> bytes: drawMap([], ""); return; }} - if (!selectedMapId || !groups.some(([mapId]) => mapId === selectedMapId)) selectedMapId = groups[0][0]; - tabs.innerHTML = groups.map(([mapId, mapPoints]) => ``).join(""); + if (!selectedMapId || !groups.some(([groupId]) => groupId === selectedMapId)) selectedMapId = groups[0][0]; + tabs.innerHTML = groups.map(([groupId, group]) => ``).join(""); for (const button of tabs.querySelectorAll("button")) {{ button.onclick = () => {{ selectedMapId = button.dataset.map || ""; renderMap(data); }}; }} - const selected = groups.find(([mapId]) => mapId === selectedMapId)?.[1] || []; + const selected = groups.find(([groupId]) => groupId === selectedMapId)?.[1]?.points || []; document.getElementById("map-legend").innerHTML = selected.map(p => `${{esc(p.id)}} ${{esc(p.fleet)}}`).join(""); drawMap(selected, selectedMapId); }} @@ -447,6 +606,12 @@ def do_GET(self) -> None: body = json.dumps(collect_team_status(api_bases)).encode("utf-8") self._send(200, body, "application/json") return + if self.path.startswith("/map-assets/zone/"): + asset_name = urllib.parse.unquote(self.path.removeprefix("/map-assets/zone/").split("?", 1)[0]) + asset_path = _zone_asset_path(asset_name) + if asset_path is not None: + self._send(200, asset_path.read_bytes(), "image/png") + return self._send(404, b"not found", "text/plain; charset=utf-8") server = ThreadingHTTPServer((host, port), Handler) From 62b5a4ddcada8084a626eaa4548b6c5ce5720870 Mon Sep 17 00:00:00 2001 From: Josh Anderson Date: Wed, 8 Jul 2026 16:42:43 -0500 Subject: [PATCH 010/103] Harden fleet dashboard polling and map view --- BOT_PLAN.md | 1 + src/game/chat_handler.cpp | 7 +- tools/bot_fleet_manager/README.md | 4 +- tools/bot_fleet_manager/bot_fleet_manager.py | 17 +- .../fetch_minimanager_assets.py | 29 +++ tools/bot_fleet_manager/team_status_server.py | 228 ++++++++++++++++-- tools/headless_client/main.cpp | 3 +- 7 files changed, 263 insertions(+), 26 deletions(-) diff --git a/BOT_PLAN.md b/BOT_PLAN.md index 0b063236b..8b898eb19 100644 --- a/BOT_PLAN.md +++ b/BOT_PLAN.md @@ -67,6 +67,7 @@ Turn `wowee_headless` into a bot automation foundation that can supervise multip - [x] Review MiniManager's map/data approach and adapt the useful pieces into Python. - [x] Build on top of the textual dashboard once leader status data is stable. - [x] Add an abstract dashboard map that plots online leaders from `/world/self` without requiring map assets. +- [x] Add a high-level MiniManager/POMM-style continental overview map. - [x] Augment the abstract map with MiniManager-style zone bounds and optional zone art. - [x] Add a scripted asset refresh command for pulling MiniManager zone PNGs into the ignored runtime asset directory. - [x] Avoid committing the experimental static WHM/Leaflet map server until its coordinate and asset assumptions are validated. diff --git a/src/game/chat_handler.cpp b/src/game/chat_handler.cpp index 624291bc7..94a049408 100644 --- a/src/game/chat_handler.cpp +++ b/src/game/chat_handler.cpp @@ -337,7 +337,12 @@ void ChatHandler::sendChatMessage(ChatType type, const std::string& message, con " target='", target, "' bytes=", raw.size(), " data=[", raw.empty() ? std::string{} : core::toHexString(raw.data(), raw.size(), true), "]"); } - owner_.getSocket()->send(packet); + auto* sock = owner_.getSocket(); + if (!sock) { + LOG_WARNING("Cannot send chat: socket is null"); + return; + } + sock->send(packet); // Add local echo so the player sees their own message immediately MessageChatData echo; diff --git a/tools/bot_fleet_manager/README.md b/tools/bot_fleet_manager/README.md index 9eaf4f80c..32559d54d 100644 --- a/tools/bot_fleet_manager/README.md +++ b/tools/bot_fleet_manager/README.md @@ -44,9 +44,9 @@ python tools/bot_fleet_manager/bot_fleet_manager.py tools/bot_fleet_manager/flee The dashboard listens on `http://127.0.0.1:8780` by default and shows a leader-position map, leader status, textual position, current activity, party members, and recent chat. Use `--dashboard-port` with `supervise` or `--port` with `dashboard` to change the port. -The map uses bundled MiniManager zone bounds and will draw client zone art when PNGs are available under `tools/bot_fleet_manager/runtime/map_assets/zone/`. That runtime directory is ignored by git. Set `WOWEE_MINIMANAGER_ZONE_DIR` to another `img/zone` folder if you want to serve the art from a different local MiniManager checkout. +The map uses MiniManager's continental projection for the high-level overview, and bundled MiniManager zone bounds for zone-level fallback views. It will draw client art when assets are available under `tools/bot_fleet_manager/runtime/map_assets/continent/` and `tools/bot_fleet_manager/runtime/map_assets/zone/`. That runtime directory is ignored by git. Set `WOWEE_MINIMANAGER_ZONE_DIR` to another `img/zone` folder if you want to serve the zone art from a different local MiniManager checkout. -Refresh MiniManager zone art from the CMaNGOS host described by the external `wow_server` `.env`: +Refresh MiniManager map art from the CMaNGOS host described by the external `wow_server` `.env`: ```bash python tools/bot_fleet_manager/fetch_minimanager_assets.py --env C:\Users\admin\code\wow_server\.env diff --git a/tools/bot_fleet_manager/bot_fleet_manager.py b/tools/bot_fleet_manager/bot_fleet_manager.py index ed60c72c5..752f598e3 100644 --- a/tools/bot_fleet_manager/bot_fleet_manager.py +++ b/tools/bot_fleet_manager/bot_fleet_manager.py @@ -57,6 +57,7 @@ def __init__(self, path: Path): self.headless = resolve_path(self.doc.get("woweeHeadless", "build/bin/wowee_headless.exe")) self.launch_delay = float(self.doc.get("launchDelaySeconds", 2.0)) self.supervision = self.doc.get("supervision", {}) + self.integrity_dir = self.doc.get("integrityDir", "") if not self.leaders: raise ValueError("fleet config must contain at least one leader") @@ -141,7 +142,7 @@ def selected_leaders(self, fleet: str = "", leader_ids: list[str] | None = None) return selected -def runtime_env() -> dict[str, str]: +def runtime_env(config: FleetConfig | None = None) -> dict[str, str]: env = os.environ.copy() path_entries: list[str] = [] msys_ucrt = Path("C:/msys64/ucrt64/bin") @@ -149,6 +150,8 @@ def runtime_env() -> dict[str, str]: path_entries.append(str(msys_ucrt)) if path_entries: env["PATH"] = os.pathsep.join(path_entries + [env.get("PATH", "")]) + if config and config.integrity_dir: + env["WOWEE_INTEGRITY_DIR"] = str(resolve_path(config.integrity_dir)) return env @@ -200,7 +203,7 @@ def start(self) -> None: stdout=subprocess.PIPE, stderr=subprocess.STDOUT, creationflags=creation_flags(), - env=runtime_env(), + env=runtime_env(self.config), text=True, encoding="utf-8", errors="replace", @@ -215,7 +218,7 @@ def start(self) -> None: stdout=self.log_handle, stderr=subprocess.STDOUT, creationflags=creation_flags(), - env=runtime_env(), + env=runtime_env(self.config), ) def poll(self) -> None: @@ -240,6 +243,12 @@ def poll(self) -> None: self.log_handle.close() self.log_handle = None + # Exit code 0 = clean logout/shutdown; don't restart + if rc == 0: + print(f"{self.leader_id}: clean exit; disabling auto-restart") + self.disabled = True + return + self.restart_count += 1 if self.config.max_restarts > 0 and self.restart_count > self.config.max_restarts: print(f"{self.leader_id}: restart limit reached; disabled") @@ -281,7 +290,7 @@ def cmd_start(config: FleetConfig) -> int: [str(config.headless), str(settings_path)], cwd=str(ROOT), creationflags=creation_flags(), - env=runtime_env(), + env=runtime_env(config), ) time.sleep(config.launch_delay) return 0 diff --git a/tools/bot_fleet_manager/fetch_minimanager_assets.py b/tools/bot_fleet_manager/fetch_minimanager_assets.py index fec47d633..a185b4631 100644 --- a/tools/bot_fleet_manager/fetch_minimanager_assets.py +++ b/tools/bot_fleet_manager/fetch_minimanager_assets.py @@ -11,8 +11,11 @@ ROOT = Path(__file__).resolve().parents[2] DEFAULT_DEST = ROOT / "tools" / "bot_fleet_manager" / "runtime" / "map_assets" / "zone" +DEFAULT_CONTINENT_DEST = ROOT / "tools" / "bot_fleet_manager" / "runtime" / "map_assets" / "continent" DEFAULT_REMOTE_DIR = "/var/www/minimanager/img/zone" +DEFAULT_REMOTE_MAP_DIR = "/var/www/minimanager/img/map" DEFAULT_EXTERNAL_ENV = Path(r"C:\Users\admin\code\wow_server\.env") +CONTINENT_ASSETS = ["azeroth.jpg", "outland.jpg", "northrend.jpg"] def read_env(path: Path) -> dict[str, str]: @@ -38,7 +41,10 @@ def main() -> int: parser = argparse.ArgumentParser(description="Fetch MiniManager zone PNGs for the fleet dashboard") parser.add_argument("--env", type=Path, default=DEFAULT_EXTERNAL_ENV, help="Path to the wow_server .env file") parser.add_argument("--remote-dir", default=DEFAULT_REMOTE_DIR, help="Remote MiniManager img/zone directory") + parser.add_argument("--remote-map-dir", default=DEFAULT_REMOTE_MAP_DIR, help="Remote MiniManager img/map directory") parser.add_argument("--dest", type=Path, default=DEFAULT_DEST, help="Local destination directory") + parser.add_argument("--continent-dest", type=Path, default=DEFAULT_CONTINENT_DEST, help="Local continent map destination directory") + parser.add_argument("--skip-continents", action="store_true", help="Only fetch zone PNGs") args = parser.parse_args() env_path = args.env.expanduser() @@ -73,6 +79,29 @@ def main() -> int: files = list(dest.glob("*.png")) total_bytes = sum(path.stat().st_size for path in files) print(f"Fetched {len(files)} zone map PNGs to {dest} ({total_bytes} bytes)") + + if not args.skip_continents: + continent_dest = args.continent_dest.expanduser() + continent_dest.mkdir(parents=True, exist_ok=True) + for asset in CONTINENT_ASSETS: + remote_asset = args.remote_map_dir.rstrip("/") + "/" + asset + command = [ + "scp", + "-i", + key_path, + "-P", + port, + "-o", + "StrictHostKeyChecking=accept-new", + f"{user}@{host}:{remote_asset}", + str(continent_dest) + os.sep, + ] + completed = subprocess.run(command, text=True) + if completed.returncode != 0: + return completed.returncode + continent_files = list(continent_dest.glob("*.*")) + continent_bytes = sum(path.stat().st_size for path in continent_files) + print(f"Fetched {len(continent_files)} continent map assets to {continent_dest} ({continent_bytes} bytes)") return 0 diff --git a/tools/bot_fleet_manager/team_status_server.py b/tools/bot_fleet_manager/team_status_server.py index bf4d160aa..caa66c452 100644 --- a/tools/bot_fleet_manager/team_status_server.py +++ b/tools/bot_fleet_manager/team_status_server.py @@ -19,11 +19,19 @@ ROOT = Path(__file__).resolve().parent MAP_DATA_PATH = ROOT / "map_data" / "zone_map_bounds.json" RUNTIME_ZONE_ASSET_DIR = ROOT / "runtime" / "map_assets" / "zone" +RUNTIME_CONTINENT_ASSET_DIR = ROOT / "runtime" / "map_assets" / "continent" +STALE_TEAM_GRACE_SECONDS = 20.0 DEFAULT_ZONE_ASSET_DIRS = [ RUNTIME_ZONE_ASSET_DIR, Path(os.environ.get("WOWEE_MINIMANAGER_ZONE_DIR", "")) if os.environ.get("WOWEE_MINIMANAGER_ZONE_DIR") else None, Path(r"C:\Users\admin\code\wow_server\minimanager_remote\img\zone"), ] +CONTINENT_ASSETS = { + "azeroth": {"label": "Azeroth", "asset": "azeroth.jpg", "width": 966, "height": 732}, + "outland": {"label": "Outland", "asset": "outland.jpg", "width": 966, "height": 732}, + "northrend": {"label": "Northrend", "asset": "northrend.jpg", "width": 966, "height": 732}, +} +TEAM_CACHE: dict[str, tuple[float, dict[str, Any]]] = {} def _load_zone_bounds() -> dict[str, dict[str, Any]]: @@ -42,10 +50,18 @@ def _request_json(url: str, timeout: float = 3.0) -> dict[str, Any]: return json.loads(response.read().decode("utf-8")) -def _try_request_json(url: str) -> tuple[dict[str, Any] | None, str]: +def _try_request_json(url: str, attempts: int = 2) -> tuple[dict[str, Any] | None, str]: + last_error = "" try: - return _request_json(url), "" - except (OSError, urllib.error.URLError, TimeoutError, json.JSONDecodeError) as exc: + for attempt in range(max(1, attempts)): + try: + return _request_json(url, timeout=1.8), "" + except (OSError, urllib.error.URLError, TimeoutError, json.JSONDecodeError) as exc: + last_error = str(exc) + if attempt + 1 < attempts: + time.sleep(0.08) + return None, last_error + except Exception as exc: return None, str(exc) @@ -170,6 +186,117 @@ def _zone_asset_path(asset_name: str) -> Path | None: return None +def _continent_asset_path(asset_name: str) -> Path | None: + clean_name = posixpath.basename(asset_name) + if clean_name != asset_name or not clean_name.lower().endswith((".jpg", ".jpeg", ".png", ".gif")): + return None + candidate = RUNTIME_CONTINENT_ASSET_DIR / clean_name + if candidate.exists() and candidate.is_file(): + return candidate + fallback = Path(r"C:\Users\admin\code\wow_server\minimanager_remote\img\map") / clean_name + if fallback.exists() and fallback.is_file(): + return fallback + return None + + +def _continent_for_world(world: dict[str, Any]) -> dict[str, Any] | None: + position = world.get("position", {}) + try: + map_id = int(world.get("mapId", -1)) + x = float(position.get("x")) + y = float(position.get("y")) + except (TypeError, ValueError): + return None + + continent_key = "azeroth" + working_x = round(x) + working_y = round(y) + if map_id == 530: + if working_y < -1000 and working_y > -10000 and working_x > 5000: + working_x -= 10349 + working_y += 6357 + where_530 = 1 + elif working_y < -7000 and working_x < 0: + working_x += 3961 + working_y += 13931 + where_530 = 2 + else: + working_x -= 3070 + working_y -= 1265 + where_530 = 3 + elif map_id == 609: + working_x -= 2355 + working_y += 5662 + where_530 = 0 + else: + where_530 = 0 + + if where_530 == 3: + x_pos = round(working_x * 0.051446) + y_pos = round(working_y * 0.051446) + elif map_id == 571: + x_pos = round(working_x * 0.047055) + y_pos = round(working_y * 0.047055) + else: + x_pos = round(working_x * 0.025140) + y_pos = round(working_y * 0.025140) + + if map_id == 530 and where_530 == 1: + pixel_x = 858 - y_pos + pixel_y = 84 - x_pos + elif map_id == 530 and where_530 == 2: + pixel_x = 103 - y_pos + pixel_y = 261 - x_pos + elif map_id == 530 and where_530 == 3: + continent_key = "outland" + pixel_x = 684 - y_pos + pixel_y = 229 - x_pos + elif map_id == 571: + continent_key = "northrend" + pixel_x = 515 - y_pos + pixel_y = 644 - x_pos + elif map_id == 609: + pixel_x = 896 - y_pos + pixel_y = 232 - x_pos + elif map_id == 1: + pixel_x = 194 - y_pos + pixel_y = 398 - x_pos + elif map_id == 0: + pixel_x = 752 - y_pos + pixel_y = 291 - x_pos + else: + return None + + meta = CONTINENT_ASSETS[continent_key] + asset = str(meta["asset"]) + return { + "id": continent_key, + "label": meta["label"], + "asset": asset, + "assetUrl": f"/map-assets/continent/{urllib.parse.quote(asset)}" if _continent_asset_path(asset) else "", + "x": max(0, min(int(meta["width"]), int(pixel_x))), + "y": max(0, min(int(meta["height"]), int(pixel_y))), + "width": int(meta["width"]), + "height": int(meta["height"]), + } + + +def _cached_team(leader_id: str, error: str) -> dict[str, Any] | None: + cached = TEAM_CACHE.get(leader_id) + if cached is None: + return None + cached_at, cached_team = cached + age = time.monotonic() - cached_at + if age > STALE_TEAM_GRACE_SECONDS: + return None + team = json.loads(json.dumps(cached_team)) + team["stale"] = True + team["staleSeconds"] = round(age, 1) + team.setdefault("endpointErrors", {})["status"] = error + team["error"] = error + return team + + def collect_team_status(api_bases: list[dict[str, str]]) -> dict[str, Any]: teams: list[dict[str, Any]] = [] collected_at = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()) @@ -188,6 +315,10 @@ def collect_team_status(api_bases: list[dict[str, str]]) -> dict[str, Any]: } status, status_error = _try_request_json(base + "/status") if status is None: + cached_team = _cached_team(leader_id, status_error) + if cached_team is not None: + teams.append(cached_team) + continue team["error"] = status_error teams.append(team) continue @@ -203,6 +334,9 @@ def collect_team_status(api_bases: list[dict[str, str]]) -> dict[str, Any]: world, world_error = _try_request_json(base + "/world/self") if world is not None: team["world"] = world + continent = _continent_for_world(world) + if continent is not None: + team["continentMap"] = continent map_zone = _map_zone_for_world(world) if map_zone is not None: asset = str(map_zone.get("asset", "")) @@ -219,22 +353,41 @@ def collect_team_status(api_bases: list[dict[str, str]]) -> dict[str, Any]: "assetHeight": int(map_zone["asset_height"]), } elif world_error: + cached = TEAM_CACHE.get(leader_id) + if cached is not None and time.monotonic() - cached[0] <= STALE_TEAM_GRACE_SECONDS: + cached_team = cached[1] + if "world" in cached_team: + team["world"] = cached_team["world"] + if "continentMap" in cached_team: + team["continentMap"] = cached_team["continentMap"] + if "mapZone" in cached_team: + team["mapZone"] = cached_team["mapZone"] endpoint_errors["world"] = world_error party, party_error = _try_request_json(base + "/party") if party is not None: team["party"] = party elif party_error: + cached = TEAM_CACHE.get(leader_id) + if cached is not None and time.monotonic() - cached[0] <= STALE_TEAM_GRACE_SECONDS and "party" in cached[1]: + team["party"] = cached[1]["party"] endpoint_errors["party"] = party_error chat, chat_error = _try_request_json(base + "/chat?after=0&limit=8") if chat is not None: team["recentChat"] = chat.get("messages", [])[-8:] elif chat_error: + cached = TEAM_CACHE.get(leader_id) + if cached is not None and time.monotonic() - cached[0] <= STALE_TEAM_GRACE_SECONDS and "recentChat" in cached[1]: + team["recentChat"] = cached[1]["recentChat"] endpoint_errors["chat"] = chat_error if endpoint_errors: team["endpointErrors"] = endpoint_errors + if any(key in endpoint_errors for key in ("world", "party", "chat")): + team["partialStale"] = True + else: + TEAM_CACHE[leader_id] = (time.monotonic(), json.loads(json.dumps(team))) teams.append(team) return {"collectedAt": collected_at, "teams": teams} @@ -318,6 +471,7 @@ def render_dashboard(title: str) -> bytes: .warn {{ color: #ffd27a; }} .bad {{ color: #ff9a8b; }} .muted {{ color: #aab2bd; }} + .stale-pill {{ color: #ffd27a; }} .chat {{ display: grid; gap: 3px; max-height: 140px; overflow: auto; }} .chat div {{ white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }} code {{ color: #d3e5ff; }} @@ -363,6 +517,8 @@ def render_dashboard(title: str) -> bytes: