From 42f1bb98ea00f00b88d5434e9f32cb4256bb0555 Mon Sep 17 00:00:00 2001 From: Pavel Okhlopkov Date: Sun, 12 Apr 2026 14:59:56 +0300 Subject: [PATCH 1/2] refactor(chat): decompose into modular architecture, add GM commands, fix protocol - Extract ChatPanel monolith into 15+ focused modules under ui/chat/ (ChatInput, ChatTabManager, ChatTabCompleter, ChatMarkupParser, ChatMarkupRenderer, ChatCommandRegistry, ChatBubbleManager, ChatSettings, MacroEvaluator, GameStateAdapter, InputModifierAdapter) - Split 2700-line chat_panel_commands.cpp into 11 command modules - Add GM command handling: 190-command data table, dot-prefix interception, tab-completion, /gmhelp with category filter - Fix ChatType enum to match WoW wire protocol (SAY=0x01 not 0x00); values 0x00-0x1B shared across Vanilla/TBC/WotLK - Fix BG_SYSTEM_* values from 82-84 (UB in bitmask shifts) to 0x24-0x26 - Fix infinite Enter key loop after teleport (disable TOGGLE_CHAT repeat, add 2-frame input cooldown) - Add tests: chat_markup_parser, chat_tab_completer, gm_commands, macro_evaluator Signed-off-by: Pavel Okhlopkov --- CMakeLists.txt | 22 + include/game/world_packets.hpp | 84 +- include/ui/chat/cast_sequence_tracker.hpp | 37 + include/ui/chat/chat_bubble_manager.hpp | 46 + include/ui/chat/chat_command_registry.hpp | 50 + include/ui/chat/chat_fwd.hpp | 37 + include/ui/chat/chat_input.hpp | 95 + include/ui/chat/chat_markup_parser.hpp | 55 + include/ui/chat/chat_markup_renderer.hpp | 62 + include/ui/chat/chat_settings.hpp | 40 + include/ui/chat/chat_tab_completer.hpp | 53 + include/ui/chat/chat_tab_manager.hpp | 54 + include/ui/chat/chat_utils.hpp | 43 + include/ui/chat/game_state_adapter.hpp | 63 + include/ui/chat/gm_command_data.hpp | 272 ++ include/ui/chat/i_chat_command.hpp | 57 + include/ui/chat/i_game_state.hpp | 63 + include/ui/chat/i_modifier_state.hpp | 21 + include/ui/chat/input_modifier_adapter.hpp | 22 + include/ui/chat/macro_evaluator.hpp | 50 + include/ui/chat_panel.hpp | 112 +- src/game/world_packets_social.cpp | 15 +- src/ui/action_bar_panel.cpp | 6 +- src/ui/chat/chat_bubble_manager.cpp | 128 + src/ui/chat/chat_command_registry.cpp | 55 + src/ui/chat/chat_input.cpp | 68 + src/ui/chat/chat_markup_parser.cpp | 206 ++ src/ui/chat/chat_markup_renderer.cpp | 704 ++++++ src/ui/chat/chat_settings.cpp | 63 + src/ui/chat/chat_tab_completer.cpp | 35 + src/ui/chat/chat_tab_manager.cpp | 198 ++ src/ui/chat/commands/channel_commands.cpp | 341 +++ src/ui/chat/commands/combat_commands.cpp | 543 ++++ src/ui/chat/commands/emote_commands.cpp | 230 ++ src/ui/chat/commands/gm_commands.cpp | 127 + src/ui/chat/commands/group_commands.cpp | 395 +++ src/ui/chat/commands/guild_commands.cpp | 203 ++ src/ui/chat/commands/help_commands.cpp | 124 + src/ui/chat/commands/misc_commands.cpp | 404 +++ src/ui/chat/commands/social_commands.cpp | 211 ++ src/ui/chat/commands/system_commands.cpp | 195 ++ src/ui/chat/commands/target_commands.cpp | 252 ++ src/ui/chat/game_state_adapter.cpp | 114 + src/ui/chat/input_modifier_adapter.cpp | 28 + src/ui/chat/macro_evaluator.cpp | 223 ++ src/ui/chat_panel.cpp | 1102 +------- src/ui/chat_panel_commands.cpp | 2618 +------------------- src/ui/chat_panel_utils.cpp | 263 +- src/ui/game_screen.cpp | 13 +- tests/CMakeLists.txt | 50 + tests/test_chat_markup_parser.cpp | 207 ++ tests/test_chat_tab_completer.cpp | 104 + tests/test_gm_commands.cpp | 128 + tests/test_macro_evaluator.cpp | 528 ++++ 54 files changed, 7363 insertions(+), 3856 deletions(-) create mode 100644 include/ui/chat/cast_sequence_tracker.hpp create mode 100644 include/ui/chat/chat_bubble_manager.hpp create mode 100644 include/ui/chat/chat_command_registry.hpp create mode 100644 include/ui/chat/chat_fwd.hpp create mode 100644 include/ui/chat/chat_input.hpp create mode 100644 include/ui/chat/chat_markup_parser.hpp create mode 100644 include/ui/chat/chat_markup_renderer.hpp create mode 100644 include/ui/chat/chat_settings.hpp create mode 100644 include/ui/chat/chat_tab_completer.hpp create mode 100644 include/ui/chat/chat_tab_manager.hpp create mode 100644 include/ui/chat/chat_utils.hpp create mode 100644 include/ui/chat/game_state_adapter.hpp create mode 100644 include/ui/chat/gm_command_data.hpp create mode 100644 include/ui/chat/i_chat_command.hpp create mode 100644 include/ui/chat/i_game_state.hpp create mode 100644 include/ui/chat/i_modifier_state.hpp create mode 100644 include/ui/chat/input_modifier_adapter.hpp create mode 100644 include/ui/chat/macro_evaluator.hpp create mode 100644 src/ui/chat/chat_bubble_manager.cpp create mode 100644 src/ui/chat/chat_command_registry.cpp create mode 100644 src/ui/chat/chat_input.cpp create mode 100644 src/ui/chat/chat_markup_parser.cpp create mode 100644 src/ui/chat/chat_markup_renderer.cpp create mode 100644 src/ui/chat/chat_settings.cpp create mode 100644 src/ui/chat/chat_tab_completer.cpp create mode 100644 src/ui/chat/chat_tab_manager.cpp create mode 100644 src/ui/chat/commands/channel_commands.cpp create mode 100644 src/ui/chat/commands/combat_commands.cpp create mode 100644 src/ui/chat/commands/emote_commands.cpp create mode 100644 src/ui/chat/commands/gm_commands.cpp create mode 100644 src/ui/chat/commands/group_commands.cpp create mode 100644 src/ui/chat/commands/guild_commands.cpp create mode 100644 src/ui/chat/commands/help_commands.cpp create mode 100644 src/ui/chat/commands/misc_commands.cpp create mode 100644 src/ui/chat/commands/social_commands.cpp create mode 100644 src/ui/chat/commands/system_commands.cpp create mode 100644 src/ui/chat/commands/target_commands.cpp create mode 100644 src/ui/chat/game_state_adapter.cpp create mode 100644 src/ui/chat/input_modifier_adapter.cpp create mode 100644 src/ui/chat/macro_evaluator.cpp create mode 100644 tests/test_chat_markup_parser.cpp create mode 100644 tests/test_chat_tab_completer.cpp create mode 100644 tests/test_gm_commands.cpp create mode 100644 tests/test_macro_evaluator.cpp diff --git a/CMakeLists.txt b/CMakeLists.txt index 66750846..540d86a0 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -687,6 +687,28 @@ set(WOWEE_SOURCES src/ui/chat_panel.cpp src/ui/chat_panel_commands.cpp src/ui/chat_panel_utils.cpp + src/ui/chat/chat_settings.cpp + src/ui/chat/chat_input.cpp + src/ui/chat/chat_tab_manager.cpp + src/ui/chat/chat_bubble_manager.cpp + src/ui/chat/chat_markup_parser.cpp + src/ui/chat/chat_markup_renderer.cpp + src/ui/chat/chat_command_registry.cpp + src/ui/chat/chat_tab_completer.cpp + src/ui/chat/macro_evaluator.cpp + src/ui/chat/game_state_adapter.cpp + src/ui/chat/input_modifier_adapter.cpp + src/ui/chat/commands/system_commands.cpp + src/ui/chat/commands/social_commands.cpp + src/ui/chat/commands/channel_commands.cpp + src/ui/chat/commands/combat_commands.cpp + src/ui/chat/commands/group_commands.cpp + src/ui/chat/commands/guild_commands.cpp + src/ui/chat/commands/target_commands.cpp + src/ui/chat/commands/emote_commands.cpp + src/ui/chat/commands/misc_commands.cpp + src/ui/chat/commands/help_commands.cpp + src/ui/chat/commands/gm_commands.cpp src/ui/toast_manager.cpp src/ui/dialog_manager.cpp src/ui/settings_panel.cpp diff --git a/include/game/world_packets.hpp b/include/game/world_packets.hpp index 8734a815..ed19f6d8 100644 --- a/include/game/world_packets.hpp +++ b/include/game/world_packets.hpp @@ -570,48 +570,52 @@ public: }; /** - * Chat message types + * Chat message types — wire values shared across Vanilla 1.12, TBC 2.4.3, and WotLK 3.3.5a. + * Core types (0x00–0x1B) are identical in all expansions. + * WotLK adds: ACHIEVEMENT(0x30), GUILD_ACHIEVEMENT(0x31), PARTY_LEADER(0x33). */ enum class ChatType : uint8_t { - SAY = 0, - PARTY = 1, - RAID = 2, - GUILD = 3, - OFFICER = 4, - YELL = 5, - WHISPER = 6, - WHISPER_INFORM = 7, - EMOTE = 8, - TEXT_EMOTE = 9, - SYSTEM = 10, - MONSTER_SAY = 11, - MONSTER_YELL = 12, - MONSTER_EMOTE = 13, - CHANNEL = 14, - CHANNEL_JOIN = 15, - CHANNEL_LEAVE = 16, - CHANNEL_LIST = 17, - CHANNEL_NOTICE = 18, - CHANNEL_NOTICE_USER = 19, - AFK = 20, - DND = 21, - IGNORED = 22, - SKILL = 23, - LOOT = 24, - BATTLEGROUND = 25, - BATTLEGROUND_LEADER = 26, - RAID_LEADER = 27, - RAID_WARNING = 28, - ACHIEVEMENT = 29, - GUILD_ACHIEVEMENT = 30, - MONSTER_WHISPER = 42, - RAID_BOSS_WHISPER = 43, - RAID_BOSS_EMOTE = 44, - MONSTER_PARTY = 50, - // BG/Arena system messages (WoW 3.3.5a — no sender, treated as SYSTEM in display) - BG_SYSTEM_NEUTRAL = 82, - BG_SYSTEM_ALLIANCE = 83, - BG_SYSTEM_HORDE = 84 + SYSTEM = 0x00, + SAY = 0x01, + PARTY = 0x02, + RAID = 0x03, + GUILD = 0x04, + OFFICER = 0x05, + YELL = 0x06, + WHISPER = 0x07, + WHISPER_FOREIGN = 0x08, + WHISPER_INFORM = 0x09, + EMOTE = 0x0A, + TEXT_EMOTE = 0x0B, + MONSTER_SAY = 0x0C, + MONSTER_PARTY = 0x0D, + MONSTER_YELL = 0x0E, + MONSTER_WHISPER = 0x0F, + MONSTER_EMOTE = 0x10, + CHANNEL = 0x11, + CHANNEL_JOIN = 0x12, + CHANNEL_LEAVE = 0x13, + CHANNEL_LIST = 0x14, + CHANNEL_NOTICE = 0x15, + CHANNEL_NOTICE_USER = 0x16, + AFK = 0x17, + DND = 0x18, + IGNORED = 0x19, + SKILL = 0x1A, + LOOT = 0x1B, + // 0x24–0x26: BG system messages + BG_SYSTEM_NEUTRAL = 0x24, + BG_SYSTEM_ALLIANCE = 0x25, + BG_SYSTEM_HORDE = 0x26, + RAID_LEADER = 0x27, + RAID_WARNING = 0x28, + RAID_BOSS_EMOTE = 0x29, + RAID_BOSS_WHISPER = 0x2A, + BATTLEGROUND = 0x2C, + BATTLEGROUND_LEADER = 0x2D, + ACHIEVEMENT = 0x30, + GUILD_ACHIEVEMENT = 0x31, + PARTY_LEADER = 0x33, }; /** diff --git a/include/ui/chat/cast_sequence_tracker.hpp b/include/ui/chat/cast_sequence_tracker.hpp new file mode 100644 index 00000000..6c52846f --- /dev/null +++ b/include/ui/chat/cast_sequence_tracker.hpp @@ -0,0 +1,37 @@ +#pragma once + +#include +#include +#include +#include + +namespace wowee { +namespace ui { + +/** + * /castsequence persistent state — shared across all macros using the same spell list. + * + * Extracted from chat_panel_commands.cpp static global (Phase 1.5 of chat_panel_ref.md). + * Keyed by the normalized (lowercase, comma-joined) spell sequence string. + */ +class CastSequenceTracker { +public: + struct State { + size_t index = 0; + float lastPressSec = 0.0f; + uint64_t lastTargetGuid = 0; + bool lastInCombat = false; + }; + + /** Get (or create) the state for a given sequence key. */ + State& get(const std::string& seqKey) { return states_[seqKey]; } + + /** Reset all tracked sequences. */ + void clear() { states_.clear(); } + +private: + std::unordered_map states_; +}; + +} // namespace ui +} // namespace wowee diff --git a/include/ui/chat/chat_bubble_manager.hpp b/include/ui/chat/chat_bubble_manager.hpp new file mode 100644 index 00000000..42fd3344 --- /dev/null +++ b/include/ui/chat/chat_bubble_manager.hpp @@ -0,0 +1,46 @@ +#pragma once + +#include "ui/ui_services.hpp" +#include +#include +#include + +namespace wowee { +namespace game { class GameHandler; } +namespace ui { + +/** + * Manages 3D-projected chat bubbles above entities. + * + * Extracted from ChatPanel (Phase 1.4 of chat_panel_ref.md). + * Owns bubble lifecycle: add, update (tick), render (ImGui overlay). + */ +class ChatBubbleManager { +public: + /** Add or replace a bubble for the given entity. */ + void addBubble(uint64_t senderGuid, const std::string& message, bool isYell); + + /** Render and tick all active bubbles (projects to screen via camera). */ + void render(game::GameHandler& gameHandler, const UIServices& services); + + /** Register the chat-bubble callback on GameHandler (call once per session). */ + void setupCallback(game::GameHandler& gameHandler); + + bool empty() const { return bubbles_.empty(); } + +private: + struct ChatBubble { + uint64_t senderGuid = 0; + std::string message; + float timeRemaining = 0.0f; + float totalDuration = 0.0f; + bool isYell = false; + }; + std::vector bubbles_; + bool callbackSet_ = false; + + static constexpr size_t kMaxBubbles = 10; +}; + +} // namespace ui +} // namespace wowee diff --git a/include/ui/chat/chat_command_registry.hpp b/include/ui/chat/chat_command_registry.hpp new file mode 100644 index 00000000..8733da8d --- /dev/null +++ b/include/ui/chat/chat_command_registry.hpp @@ -0,0 +1,50 @@ +// ChatCommandRegistry — command registration + dispatch. +// Replaces the 500-line if/else chain in sendChatMessage() (Phase 3.1). +#pragma once + +#include "ui/chat/i_chat_command.hpp" +#include +#include +#include +#include + +namespace wowee { +namespace ui { + +/** + * Registry of all slash commands. + * + * dispatch() looks up the command by alias and calls execute(). + * getCompletions() provides tab-completion for /command prefixes. + */ +class ChatCommandRegistry { +public: + /** Register a command (takes ownership). All aliases are mapped. */ + void registerCommand(std::unique_ptr cmd); + + /** + * Dispatch a slash command. + * @param cmdLower lowercase command name (e.g. "cast", "whisper") + * @param ctx context with args, gameHandler, services, etc. + * @return result indicating if handled and whether to clear input + */ + ChatCommandResult dispatch(const std::string& cmdLower, ChatCommandContext& ctx); + + /** Get all command aliases matching a prefix (for tab completion). */ + std::vector getCompletions(const std::string& prefix) const; + + /** Get help entries: (alias, helpText) for all registered commands. */ + std::vector> getHelpEntries() const; + + /** Check if a command alias is registered. */ + bool hasCommand(const std::string& alias) const; + +private: + // alias → raw pointer (non-owning, commands_ owns the objects) + std::unordered_map commandMap_; + // Ownership of all registered commands + std::vector> commands_; +}; + +} // namespace ui +} // namespace wowee diff --git a/include/ui/chat/chat_fwd.hpp b/include/ui/chat/chat_fwd.hpp new file mode 100644 index 00000000..45d7e297 --- /dev/null +++ b/include/ui/chat/chat_fwd.hpp @@ -0,0 +1,37 @@ +// Forward declarations for the chat subsystem. +// Include this instead of the full headers when only pointers/references are needed. +// Extracted in Phase 6.6 of chat_panel_ref.md. +#pragma once + +namespace wowee { + +namespace game { class GameHandler; } + +namespace ui { + +class ChatPanel; +class InventoryScreen; +class SpellbookScreen; +class QuestLogScreen; + +// Chat subsystem types (under include/ui/chat/) +class ChatSettings; +class ChatInput; +class ChatTabManager; +class ChatBubbleManager; +class ChatMarkupParser; +class ChatMarkupRenderer; +class ChatCommandRegistry; +class ChatTabCompleter; +class CastSequenceTracker; +class MacroEvaluator; +class IGameState; +class IModifierState; +class IChatCommand; + +struct ChatCommandContext; +struct ChatCommandResult; +struct UIServices; + +} // namespace ui +} // namespace wowee diff --git a/include/ui/chat/chat_input.hpp b/include/ui/chat/chat_input.hpp new file mode 100644 index 00000000..e0294040 --- /dev/null +++ b/include/ui/chat/chat_input.hpp @@ -0,0 +1,95 @@ +#pragma once + +#include +#include +#include + +namespace wowee { +namespace ui { + +/** + * Chat input state: buffer, whisper target, sent-history, focus management. + * + * Extracted from ChatPanel (Phase 1.2 of chat_panel_ref.md). + * No UI or network dependencies — pure state management. + */ +class ChatInput { +public: + // ---- Buffer access ---- + char* getBuffer() { return buffer_; } + const char* getBuffer() const { return buffer_; } + size_t getBufferSize() const { return sizeof(buffer_); } + + char* getWhisperBuffer() { return whisperBuffer_; } + const char* getWhisperBuffer() const { return whisperBuffer_; } + size_t getWhisperBufferSize() const { return sizeof(whisperBuffer_); } + + void clear() { buffer_[0] = '\0'; } + bool isEmpty() const { return buffer_[0] == '\0'; } + std::string getText() const { return std::string(buffer_); } + void setText(const std::string& text) { + strncpy(buffer_, text.c_str(), sizeof(buffer_) - 1); + buffer_[sizeof(buffer_) - 1] = '\0'; + } + + // ---- Whisper target ---- + std::string getWhisperTarget() const { return std::string(whisperBuffer_); } + void setWhisperTarget(const std::string& name) { + strncpy(whisperBuffer_, name.c_str(), sizeof(whisperBuffer_) - 1); + whisperBuffer_[sizeof(whisperBuffer_) - 1] = '\0'; + } + + // ---- Sent-message history (Up/Down arrow recall) ---- + void pushToHistory(const std::string& msg); + std::string historyUp(); + std::string historyDown(); + void resetHistoryIndex() { historyIdx_ = -1; } + int getHistoryIndex() const { return historyIdx_; } + const std::vector& getSentHistory() const { return sentHistory_; } + + // ---- Focus state ---- + bool isActive() const { return active_; } + void setActive(bool v) { active_ = v; } + + bool shouldFocus() const { return focusRequested_; } + void requestFocus() { focusRequested_ = true; } + void clearFocusRequest() { focusRequested_ = false; } + + bool shouldMoveCursorToEnd() const { return moveCursorToEnd_; } + void requestMoveCursorToEnd() { moveCursorToEnd_ = true; } + void clearMoveCursorToEnd() { moveCursorToEnd_ = false; } + + // ---- Chat type selection ---- + int getSelectedChatType() const { return selectedChatType_; } + void setSelectedChatType(int t) { selectedChatType_ = t; } + int getLastChatType() const { return lastChatType_; } + void setLastChatType(int t) { lastChatType_ = t; } + int getSelectedChannelIdx() const { return selectedChannelIdx_; } + void setSelectedChannelIdx(int i) { selectedChannelIdx_ = i; } + + // ---- Link insertion (shift-click) ---- + void insertLink(const std::string& link); + + // ---- Slash key activation ---- + void activateSlashInput(); + +private: + char buffer_[512] = ""; + char whisperBuffer_[256] = ""; + bool active_ = false; + bool focusRequested_ = false; + bool moveCursorToEnd_ = false; + + // Chat type dropdown state + int selectedChatType_ = 0; // 0=SAY .. 10=CHANNEL + int lastChatType_ = 0; + int selectedChannelIdx_ = 0; + + // Sent-message history + std::vector sentHistory_; + int historyIdx_ = -1; + static constexpr int kMaxHistory = 50; +}; + +} // namespace ui +} // namespace wowee diff --git a/include/ui/chat/chat_markup_parser.hpp b/include/ui/chat/chat_markup_parser.hpp new file mode 100644 index 00000000..23a8bb85 --- /dev/null +++ b/include/ui/chat/chat_markup_parser.hpp @@ -0,0 +1,55 @@ +#pragma once + +#include +#include +#include +#include + +namespace wowee { +namespace ui { + +/** + * Segment types produced by ChatMarkupParser. + * + * Each segment represents a contiguous piece of a chat message + * after WoW markup (|c...|r, |Hitem:...|h[...]|h, URLs) has been decoded. + */ +enum class SegmentType { + Text, // Plain text (render with base message color) + ColoredText, // Text with explicit |cAARRGGBB color + ItemLink, // |Hitem:ID:...|h[Name]|h + SpellLink, // |Hspell:ID:...|h[Name]|h + QuestLink, // |Hquest:ID:LEVEL|h[Name]|h + AchievementLink, // |Hachievement:ID:...|h[Name]|h + Url, // https://... URL +}; + +/** + * A single parsed segment of a chat message. + */ +struct ChatSegment { + SegmentType type = SegmentType::Text; + std::string text; // display text (or URL) + ImVec4 color = ImVec4(1, 1, 1, 1); // explicit color (for ColoredText / links) + uint32_t id = 0; // itemId / spellId / questId / achievementId + uint32_t extra = 0; // quest level (for QuestLink) + std::string rawLink; // full original markup for shift-click insertion +}; + +/** + * Parses raw WoW-markup text into a flat list of typed segments. + * + * Extracted from ChatPanel::render() inline lambdas (Phase 2.1 of chat_panel_ref.md). + * Pure logic — no ImGui calls, no game-state access. Fully unit-testable. + */ +class ChatMarkupParser { +public: + /** Parse a raw chat message string into ordered segments. */ + std::vector parse(const std::string& rawMessage) const; + + /** Parse |cAARRGGBB color code at given position. */ + static ImVec4 parseWowColor(const std::string& text, size_t pos); +}; + +} // namespace ui +} // namespace wowee diff --git a/include/ui/chat/chat_markup_renderer.hpp b/include/ui/chat/chat_markup_renderer.hpp new file mode 100644 index 00000000..69f62da5 --- /dev/null +++ b/include/ui/chat/chat_markup_renderer.hpp @@ -0,0 +1,62 @@ +#pragma once + +#include "ui/chat/chat_markup_parser.hpp" +#include "ui/ui_services.hpp" +#include +#include +#include +#include +#include + +namespace wowee { +namespace game { class GameHandler; } +namespace pipeline { class AssetManager; } +namespace ui { + +class InventoryScreen; +class SpellbookScreen; +class QuestLogScreen; + +/** + * Context needed by the renderer to display links, tooltips, and icons. + */ +struct MarkupRenderContext { + game::GameHandler* gameHandler = nullptr; + InventoryScreen* inventory = nullptr; + SpellbookScreen* spellbook = nullptr; + QuestLogScreen* questLog = nullptr; + pipeline::AssetManager* assetMgr = nullptr; + // Spell icon callback — same as ChatPanel::getSpellIcon + std::function getSpellIcon; + // Chat input buffer for shift-click link insertion + char* chatInputBuffer = nullptr; + size_t chatInputBufSize = 0; + bool* moveCursorToEnd = nullptr; +}; + +/** + * Renders parsed ChatSegments via ImGui. + * + * Extracted from ChatPanel::render() inline lambdas (Phase 2.2 of chat_panel_ref.md). + * Handles: colored text, item/spell/quest/achievement link tooltips+icons, + * URL click-to-open, shift-click link insertion. + */ +class ChatMarkupRenderer { +public: + /** Render a list of segments with ImGui. baseColor is the message-type color. */ + void render(const std::vector& segments, + const ImVec4& baseColor, + const MarkupRenderContext& ctx) const; + + /** + * Render a full item tooltip for the given item entry. + * Extracted from the renderItemLinkTooltip inline lambda. + */ + static void renderItemTooltip(uint32_t itemEntry, + game::GameHandler& gameHandler, + InventoryScreen& inventoryScreen, + pipeline::AssetManager* assetMgr); +}; + +} // namespace ui +} // namespace wowee diff --git a/include/ui/chat/chat_settings.hpp b/include/ui/chat/chat_settings.hpp new file mode 100644 index 00000000..ff4fa37a --- /dev/null +++ b/include/ui/chat/chat_settings.hpp @@ -0,0 +1,40 @@ +#pragma once + +#include + +// Forward declaration for ImGui (avoid pulling full imgui header) +struct ImGuiContext; + +namespace wowee { +namespace ui { + +/** + * Chat appearance and auto-join settings. + * + * Extracted from ChatPanel (Phase 1.1 of chat_panel_ref.md). + * Pure data + settings UI; no dependency on GameHandler or network. + */ +struct ChatSettings { + // Appearance + bool showTimestamps = false; + int fontSize = 1; // 0=small, 1=medium, 2=large + + // Auto-join channels + bool autoJoinGeneral = true; + bool autoJoinTrade = true; + bool autoJoinLocalDefense = true; + bool autoJoinLFG = true; + bool autoJoinLocal = true; + + // Window state + bool windowLocked = true; + + /** Reset all chat settings to defaults. */ + void restoreDefaults(); + + /** Render the "Chat" tab inside the Settings window. */ + void renderSettingsTab(std::function saveSettingsFn); +}; + +} // namespace ui +} // namespace wowee diff --git a/include/ui/chat/chat_tab_completer.hpp b/include/ui/chat/chat_tab_completer.hpp new file mode 100644 index 00000000..15d16c63 --- /dev/null +++ b/include/ui/chat/chat_tab_completer.hpp @@ -0,0 +1,53 @@ +// ChatTabCompleter — cycling tab-completion state machine. +// Extracted from scattered vars in ChatPanel (Phase 5.1). +#pragma once + +#include +#include + +namespace wowee { +namespace ui { + +/** + * Stateful tab-completion engine. + * + * The caller gathers candidates and calls startCompletion() or cycle(). + * The completer stores the prefix, sorted matches, and current index. + */ +class ChatTabCompleter { +public: + /** + * Start a new completion session with the given candidates. + * Resets the index to 0. + */ + void startCompletion(const std::string& prefix, std::vector candidates); + + /** + * Cycle to the next match. Returns true if there are matches. + * If the prefix changed, the caller should call startCompletion() instead. + */ + bool next(); + + /** Get the current match, or "" if no matches. */ + std::string getCurrentMatch() const; + + /** Get the number of matches in the current session. */ + size_t matchCount() const { return matches_.size(); } + + /** Check if a completion session is active. */ + bool isActive() const { return matchIdx_ >= 0; } + + /** Get the current prefix. */ + const std::string& getPrefix() const { return prefix_; } + + /** Reset the completer (e.g. on text change or arrow key). */ + void reset(); + +private: + std::string prefix_; + std::vector matches_; + int matchIdx_ = -1; +}; + +} // namespace ui +} // namespace wowee diff --git a/include/ui/chat/chat_tab_manager.hpp b/include/ui/chat/chat_tab_manager.hpp new file mode 100644 index 00000000..02a5faa0 --- /dev/null +++ b/include/ui/chat/chat_tab_manager.hpp @@ -0,0 +1,54 @@ +#pragma once + +#include "game/world_packets.hpp" +#include +#include +#include +#include +#include + +namespace wowee { +namespace ui { + +/** + * Chat tab definitions, unread tracking, type colors, and type names. + * + * Extracted from ChatPanel (Phase 1.3 of chat_panel_ref.md). + * Owns the tab configuration, unread badge counts, and message filtering. + */ +class ChatTabManager { +public: + ChatTabManager(); + + // ---- Tab access ---- + int getTabCount() const { return static_cast(tabs_.size()); } + const std::string& getTabName(int idx) const { return tabs_[idx].name; } + uint64_t getTabTypeMask(int idx) const { return tabs_[idx].typeMask; } + + // ---- Unread tracking ---- + int getUnreadCount(int idx) const; + void clearUnread(int idx); + /** Scan new messages since last call and increment unread counters for non-active tabs. */ + void updateUnread(const std::deque& history, int activeTab); + + // ---- Message filtering ---- + bool shouldShowMessage(const game::MessageChatData& msg, int tabIndex) const; + + // ---- Chat type helpers (static, no state needed) ---- + static const char* getChatTypeName(game::ChatType type); + static ImVec4 getChatTypeColor(game::ChatType type); + +private: + struct ChatTab { + std::string name; + uint64_t typeMask; + }; + std::vector tabs_; + std::vector unread_; + size_t seenCount_ = 0; + + void initTabs(); +}; + +} // namespace ui +} // namespace wowee diff --git a/include/ui/chat/chat_utils.hpp b/include/ui/chat/chat_utils.hpp new file mode 100644 index 00000000..5e288961 --- /dev/null +++ b/include/ui/chat/chat_utils.hpp @@ -0,0 +1,43 @@ +#pragma once + +#include "game/world_packets.hpp" +#include +#include +#include + +namespace wowee { + +// Forward declaration +namespace game { class GameHandler; } + +namespace ui { +namespace chat_utils { + +/** Create a system-type chat message (used 15+ times throughout commands). */ +inline game::MessageChatData makeSystemMessage(const std::string& text) { + game::MessageChatData msg; + msg.type = game::ChatType::SYSTEM; + msg.language = game::ChatLanguage::UNIVERSAL; + msg.message = text; + return msg; +} + +/** Trim leading/trailing whitespace from a string. */ +inline std::string trim(const std::string& s) { + size_t first = s.find_first_not_of(" \t\r\n"); + if (first == std::string::npos) return ""; + size_t last = s.find_last_not_of(" \t\r\n"); + return s.substr(first, last - first + 1); +} + +/** Convert string to lowercase (returns copy). */ +inline std::string toLower(std::string s) { + std::transform(s.begin(), s.end(), s.begin(), [](unsigned char c) { + return static_cast(std::tolower(c)); + }); + return s; +} + +} // namespace chat_utils +} // namespace ui +} // namespace wowee diff --git a/include/ui/chat/game_state_adapter.hpp b/include/ui/chat/game_state_adapter.hpp new file mode 100644 index 00000000..e7d80900 --- /dev/null +++ b/include/ui/chat/game_state_adapter.hpp @@ -0,0 +1,63 @@ +// GameStateAdapter — wraps GameHandler + Renderer to implement IGameState. +// Phase 4.2 of chat_panel_ref.md. +#pragma once + +#include "ui/chat/i_game_state.hpp" + +namespace wowee { +namespace game { class GameHandler; } +namespace rendering { class Renderer; } + +namespace ui { + +/** + * Concrete adapter from GameHandler + Renderer → IGameState. + * Flatten complex entity/aura queries into the simple IGameState interface. + */ +class GameStateAdapter : public IGameState { +public: + GameStateAdapter(game::GameHandler& gameHandler, rendering::Renderer* renderer); + + // --- GUIDs --- + uint64_t getPlayerGuid() const override; + uint64_t getTargetGuid() const override; + uint64_t getFocusGuid() const override; + uint64_t getPetGuid() const override; + uint64_t getMouseoverGuid() const override; + + // --- Player state --- + bool isInCombat() const override; + bool isMounted() const override; + bool isSwimming() const override; + bool isFlying() const override; + bool isCasting() const override; + bool isChanneling() const override; + bool isStealthed() const override; + bool hasPet() const override; + bool isInGroup() const override; + bool isInRaid() const override; + bool isIndoors() const override; + + // --- Numeric --- + uint8_t getActiveTalentSpec() const override; + uint32_t getVehicleId() const override; + uint32_t getCurrentCastSpellId() const override; + + // --- Spell/aura --- + std::string getSpellName(uint32_t spellId) const override; + bool hasAuraByName(uint64_t targetGuid, const std::string& spellName, + bool wantDebuff) const override; + bool hasFormAura() const override; + + // --- Entity queries --- + bool entityExists(uint64_t guid) const override; + bool entityIsDead(uint64_t guid) const override; + bool entityIsHostile(uint64_t guid) const override; + +private: + game::GameHandler& gameHandler_; + rendering::Renderer* renderer_; +}; + +} // namespace ui +} // namespace wowee diff --git a/include/ui/chat/gm_command_data.hpp b/include/ui/chat/gm_command_data.hpp new file mode 100644 index 00000000..8254bc86 --- /dev/null +++ b/include/ui/chat/gm_command_data.hpp @@ -0,0 +1,272 @@ +// GM command metadata — names, security levels, syntax, help text. +// Sourced from AzerothCore GM commands wiki. +// Used for tab-completion and .gmhelp display; the server handles execution. +#pragma once + +#include +#include +#include + +namespace wowee { +namespace ui { + +struct GmCommandEntry { + std::string_view name; // e.g. "gm on" + uint8_t security; // 0=player, 1=mod, 2=gm, 3=admin, 4=console + std::string_view syntax; // e.g. ".gm [on/off]" + std::string_view help; // short description +}; + +// Curated list of the most useful GM commands for a client emulator. +// The full AzerothCore list has 500+ commands — this table covers the +// ones players/GMs actually type regularly, organized by category. +inline constexpr std::array kGmCommands = { + + // ── GM mode & info ────────────────────────────────────── + GmCommandEntry{"gm", 1, ".gm [on/off]", "Toggle GM mode or show state"}, + GmCommandEntry{"gm on", 1, ".gm on", "Enable GM mode"}, + GmCommandEntry{"gm off", 1, ".gm off", "Disable GM mode"}, + GmCommandEntry{"gm fly", 2, ".gm fly [on/off]", "Toggle fly mode"}, + GmCommandEntry{"gm visible", 2, ".gm visible [on/off]", "Toggle GM visibility"}, + GmCommandEntry{"gm chat", 2, ".gm chat [on/off]", "Toggle GM chat badge"}, + GmCommandEntry{"gm ingame", 0, ".gm ingame", "List online GMs"}, + GmCommandEntry{"gm list", 3, ".gm list", "List all GM accounts"}, + + // ── Teleportation ─────────────────────────────────────── + GmCommandEntry{"tele", 1, ".tele #location", "Teleport to location"}, + GmCommandEntry{"tele group", 2, ".tele group #location", "Teleport group to location"}, + GmCommandEntry{"tele name", 2, ".tele name $player #location", "Teleport player to location"}, + GmCommandEntry{"go xyz", 1, ".go xyz #x #y [#z [#map [#o]]]", "Teleport to coordinates"}, + GmCommandEntry{"go creature", 1, ".go creature #guid", "Teleport to creature by GUID"}, + GmCommandEntry{"go creature id", 1, ".go creature id #entry", "Teleport to creature by entry"}, + GmCommandEntry{"go gameobject", 1, ".go gameobject #guid", "Teleport to gameobject"}, + GmCommandEntry{"go graveyard", 1, ".go graveyard #id", "Teleport to graveyard"}, + GmCommandEntry{"go taxinode", 1, ".go taxinode #id", "Teleport to taxinode"}, + GmCommandEntry{"go trigger", 1, ".go trigger #id", "Teleport to areatrigger"}, + GmCommandEntry{"go zonexy", 1, ".go zonexy #x #y [#zone]", "Teleport to zone coordinates"}, + GmCommandEntry{"appear", 1, ".appear $player", "Teleport to player"}, + GmCommandEntry{"summon", 2, ".summon $player", "Summon player to you"}, + GmCommandEntry{"groupsummon", 2, ".groupsummon $player", "Summon player and group"}, + GmCommandEntry{"recall", 2, ".recall [$player]", "Return to pre-teleport location"}, + GmCommandEntry{"unstuck", 2, ".unstuck $player [inn/graveyard]", "Unstuck player"}, + GmCommandEntry{"gps", 1, ".gps", "Show current position info"}, + + // ── Character & level ─────────────────────────────────── + GmCommandEntry{"levelup", 2, ".levelup [$player] [#levels]", "Increase player level"}, + GmCommandEntry{"character level", 3, ".character level [$player] [#lvl]", "Set character level"}, + GmCommandEntry{"character rename", 2, ".character rename [$name]", "Flag character for rename"}, + GmCommandEntry{"character changefaction", 2, ".character changefaction $name", "Flag for faction change"}, + GmCommandEntry{"character changerace", 2, ".character changerace $name", "Flag for race change"}, + GmCommandEntry{"character customize", 2, ".character customize [$name]", "Flag for customization"}, + GmCommandEntry{"character reputation", 2, ".character reputation [$name]", "Show reputation info"}, + GmCommandEntry{"character titles", 2, ".character titles [$name]", "Show known titles"}, + GmCommandEntry{"pinfo", 2, ".pinfo [$player]", "Show player account info"}, + GmCommandEntry{"guid", 2, ".guid", "Show target GUID"}, + + // ── Items & inventory ─────────────────────────────────── + GmCommandEntry{"additem", 2, ".additem #id [#count]", "Add item to inventory"}, + GmCommandEntry{"additem set", 2, ".additem set #setid", "Add item set to inventory"}, + + // ── Spells & auras ────────────────────────────────────── + GmCommandEntry{"learn", 2, ".learn #spell [all]", "Learn a spell"}, + GmCommandEntry{"unlearn", 2, ".unlearn #spell [all]", "Unlearn a spell"}, + GmCommandEntry{"learn all my class", 2, ".learn all my class", "Learn all class spells"}, + GmCommandEntry{"learn all my spells", 2, ".learn all my spells", "Learn all available spells"}, + GmCommandEntry{"learn all my talents", 2, ".learn all my talents", "Learn all talents"}, + GmCommandEntry{"learn all crafts", 2, ".learn all crafts", "Learn all professions"}, + GmCommandEntry{"learn all lang", 2, ".learn all lang", "Learn all languages"}, + GmCommandEntry{"learn all recipes", 2, ".learn all recipes [$prof]", "Learn all recipes"}, + GmCommandEntry{"cast", 2, ".cast #spell [triggered]", "Cast spell on target"}, + GmCommandEntry{"cast self", 2, ".cast self #spell", "Cast spell on self"}, + GmCommandEntry{"aura", 2, ".aura #spell", "Add aura to target"}, + GmCommandEntry{"unaura", 2, ".unaura #spell", "Remove aura from target"}, + GmCommandEntry{"cooldown", 2, ".cooldown [#spell]", "Remove cooldowns"}, + GmCommandEntry{"maxskill", 2, ".maxskill", "Max all skills for target"}, + GmCommandEntry{"setskill", 2, ".setskill #skill #level [#max]", "Set skill level"}, + + // ── Modify stats ──────────────────────────────────────── + GmCommandEntry{"modify money", 2, ".modify money #amount", "Add/remove money"}, + GmCommandEntry{"modify hp", 2, ".modify hp #value", "Set current HP"}, + GmCommandEntry{"modify mana", 2, ".modify mana #value", "Set current mana"}, + GmCommandEntry{"modify energy", 2, ".modify energy #value", "Set current energy"}, + GmCommandEntry{"modify speed all", 2, ".modify speed all #rate", "Set all movement speeds"}, + GmCommandEntry{"modify speed fly", 2, ".modify speed fly #rate", "Set fly speed"}, + GmCommandEntry{"modify speed walk", 2, ".modify speed walk #rate", "Set walk speed"}, + GmCommandEntry{"modify speed swim", 2, ".modify speed swim #rate", "Set swim speed"}, + GmCommandEntry{"modify mount", 2, ".modify mount #id #speed", "Display as mounted"}, + GmCommandEntry{"modify scale", 2, ".modify scale #rate", "Set model scale"}, + GmCommandEntry{"modify honor", 2, ".modify honor #amount", "Add honor points"}, + GmCommandEntry{"modify reputation", 2, ".modify reputation #faction #val", "Set faction reputation"}, + GmCommandEntry{"modify talentpoints", 2, ".modify talentpoints #amount", "Set talent points"}, + GmCommandEntry{"modify gender", 2, ".modify gender male/female", "Change gender"}, + + // ── Cheats ────────────────────────────────────────────── + GmCommandEntry{"cheat god", 2, ".cheat god [on/off]", "Toggle god mode"}, + GmCommandEntry{"cheat casttime", 2, ".cheat casttime [on/off]", "Toggle no cast time"}, + GmCommandEntry{"cheat cooldown", 2, ".cheat cooldown [on/off]", "Toggle no cooldowns"}, + GmCommandEntry{"cheat power", 2, ".cheat power [on/off]", "Toggle no mana cost"}, + GmCommandEntry{"cheat explore", 2, ".cheat explore #flag", "Reveal/hide all maps"}, + GmCommandEntry{"cheat taxi", 2, ".cheat taxi on/off", "Toggle all taxi routes"}, + GmCommandEntry{"cheat waterwalk", 2, ".cheat waterwalk on/off", "Toggle waterwalk"}, + GmCommandEntry{"cheat status", 2, ".cheat status", "Show active cheats"}, + + // ── NPC ───────────────────────────────────────────────── + GmCommandEntry{"npc add", 3, ".npc add #entry", "Spawn creature"}, + GmCommandEntry{"npc delete", 3, ".npc delete [#guid]", "Delete creature"}, + GmCommandEntry{"npc info", 1, ".npc info", "Show NPC details"}, + GmCommandEntry{"npc guid", 1, ".npc guid", "Show NPC GUID"}, + GmCommandEntry{"npc near", 2, ".npc near [#dist]", "List nearby NPCs"}, + GmCommandEntry{"npc say", 2, ".npc say $message", "Make NPC say text"}, + GmCommandEntry{"npc yell", 2, ".npc yell $message", "Make NPC yell text"}, + GmCommandEntry{"npc move", 3, ".npc move [#guid]", "Move NPC to your position"}, + GmCommandEntry{"npc set level", 3, ".npc set level #level", "Set NPC level"}, + GmCommandEntry{"npc set model", 3, ".npc set model #displayid", "Set NPC model"}, + GmCommandEntry{"npc tame", 2, ".npc tame", "Tame selected creature"}, + + // ── Game objects ──────────────────────────────────────── + GmCommandEntry{"gobject add", 3, ".gobject add #entry", "Spawn gameobject"}, + GmCommandEntry{"gobject delete", 3, ".gobject delete #guid", "Delete gameobject"}, + GmCommandEntry{"gobject info", 1, ".gobject info [#entry]", "Show gameobject info"}, + GmCommandEntry{"gobject near", 3, ".gobject near [#dist]", "List nearby objects"}, + GmCommandEntry{"gobject move", 3, ".gobject move #guid [#x #y #z]", "Move gameobject"}, + GmCommandEntry{"gobject target", 1, ".gobject target [#id]", "Find nearest gameobject"}, + GmCommandEntry{"gobject activate", 2, ".gobject activate #guid", "Activate object (door/button)"}, + + // ── Combat & death ────────────────────────────────────── + GmCommandEntry{"revive", 2, ".revive", "Revive selected/self"}, + GmCommandEntry{"die", 2, ".die", "Kill selected/self"}, + GmCommandEntry{"damage", 2, ".damage #amount [#school [#spell]]", "Deal damage to target"}, + GmCommandEntry{"combatstop", 2, ".combatstop [$player]", "Stop combat for target"}, + GmCommandEntry{"freeze", 2, ".freeze [$player]", "Freeze player"}, + GmCommandEntry{"unfreeze", 2, ".unfreeze [$player]", "Unfreeze player"}, + GmCommandEntry{"dismount", 0, ".dismount", "Dismount if mounted"}, + GmCommandEntry{"respawn", 2, ".respawn", "Respawn nearby creatures/GOs"}, + GmCommandEntry{"respawn all", 2, ".respawn all", "Respawn all nearby"}, + + // ── Quests ────────────────────────────────────────────── + GmCommandEntry{"quest add", 2, ".quest add #id", "Add quest to log"}, + GmCommandEntry{"quest complete", 2, ".quest complete #id", "Complete quest objectives"}, + GmCommandEntry{"quest remove", 2, ".quest remove #id", "Remove quest from log"}, + GmCommandEntry{"quest reward", 2, ".quest reward #id", "Grant quest reward"}, + GmCommandEntry{"quest status", 2, ".quest status #id [$name]", "Show quest status"}, + + // ── Honor & arena ─────────────────────────────────────── + GmCommandEntry{"honor add", 2, ".honor add #amount", "Add honor points"}, + GmCommandEntry{"honor update", 2, ".honor update", "Force honor field update"}, + GmCommandEntry{"achievement add", 2, ".achievement add #id", "Add achievement to target"}, + + // ── Group & guild ─────────────────────────────────────── + GmCommandEntry{"group list", 2, ".group list [$player]", "List group members"}, + GmCommandEntry{"group revive", 2, ".group revive $player", "Revive group members"}, + GmCommandEntry{"group disband", 2, ".group disband [$player]", "Disband player's group"}, + GmCommandEntry{"guild create", 2, ".guild create $leader \"$name\"", "Create guild"}, + GmCommandEntry{"guild delete", 2, ".guild delete \"$name\"", "Delete guild"}, + GmCommandEntry{"guild invite", 2, ".guild invite $player \"$guild\"", "Add player to guild"}, + GmCommandEntry{"guild info", 2, ".guild info", "Show guild info"}, + + // ── Lookup & search ───────────────────────────────────── + GmCommandEntry{"lookup item", 1, ".lookup item $name", "Search item by name"}, + GmCommandEntry{"lookup spell", 1, ".lookup spell $name", "Search spell by name"}, + GmCommandEntry{"lookup spell id", 1, ".lookup spell id #id", "Look up spell by ID"}, + GmCommandEntry{"lookup creature", 1, ".lookup creature $name", "Search creature by name"}, + GmCommandEntry{"lookup quest", 1, ".lookup quest $name", "Search quest by name"}, + GmCommandEntry{"lookup gobject", 1, ".lookup gobject $name", "Search gameobject by name"}, + GmCommandEntry{"lookup area", 1, ".lookup area $name", "Search area by name"}, + GmCommandEntry{"lookup taxinode", 1, ".lookup taxinode $name", "Search taxinode by name"}, + GmCommandEntry{"lookup teleport", 1, ".lookup teleport $name", "Search teleport by name"}, + GmCommandEntry{"lookup faction", 1, ".lookup faction $name", "Search faction by name"}, + GmCommandEntry{"lookup title", 1, ".lookup title $name", "Search title by name"}, + GmCommandEntry{"lookup event", 1, ".lookup event $name", "Search event by name"}, + GmCommandEntry{"lookup map", 1, ".lookup map $name", "Search map by name"}, + GmCommandEntry{"lookup skill", 1, ".lookup skill $name", "Search skill by name"}, + + // ── Titles ────────────────────────────────────────────── + GmCommandEntry{"titles add", 2, ".titles add #id", "Add title to target"}, + GmCommandEntry{"titles remove", 2, ".titles remove #id", "Remove title from target"}, + GmCommandEntry{"titles current", 2, ".titles current #id", "Set current title"}, + + // ── Morph & display ───────────────────────────────────── + GmCommandEntry{"morph", 1, ".morph #displayid", "Change your model"}, + GmCommandEntry{"morph target", 1, ".morph target #displayid", "Change target model"}, + GmCommandEntry{"morph mount", 1, ".morph mount #displayid", "Change mount model"}, + GmCommandEntry{"morph reset", 1, ".morph reset", "Reset target model"}, + + // ── Debug & info ──────────────────────────────────────── + GmCommandEntry{"debug anim", 3, ".debug anim", "Debug animation"}, + GmCommandEntry{"debug arena", 3, ".debug arena", "Toggle arena debug"}, + GmCommandEntry{"debug bg", 3, ".debug bg", "Toggle BG debug"}, + GmCommandEntry{"debug los", 3, ".debug los", "Show line of sight info"}, + GmCommandEntry{"debug loot", 2, ".debug loot $type $id [#count]", "Simulate loot generation"}, + GmCommandEntry{"list auras", 1, ".list auras", "List auras on target"}, + GmCommandEntry{"list creature", 1, ".list creature #id [#max]", "List creature spawns"}, + GmCommandEntry{"list item", 1, ".list item #id [#max]", "List item locations"}, + + // ── Server & system ───────────────────────────────────── + GmCommandEntry{"announce", 2, ".announce $message", "Broadcast to all players"}, + GmCommandEntry{"gmannounce", 2, ".gmannounce $message", "Broadcast to online GMs"}, + GmCommandEntry{"notify", 2, ".notify $message", "On-screen broadcast"}, + GmCommandEntry{"server info", 0, ".server info", "Show server version/players"}, + GmCommandEntry{"server motd", 0, ".server motd", "Show message of the day"}, + GmCommandEntry{"commands", 0, ".commands", "List available commands"}, + GmCommandEntry{"help", 0, ".help [$cmd]", "Show command help"}, + GmCommandEntry{"save", 0, ".save", "Save your character"}, + GmCommandEntry{"saveall", 2, ".saveall", "Save all characters"}, + + // ── Account & bans ────────────────────────────────────── + GmCommandEntry{"account", 0, ".account", "Show account info"}, + GmCommandEntry{"account set gmlevel", 4, ".account set gmlevel $acct #lvl", "Set GM security level"}, + GmCommandEntry{"ban account", 2, ".ban account $name $time $reason", "Ban account"}, + GmCommandEntry{"ban character", 2, ".ban character $name $time $reason", "Ban character"}, + GmCommandEntry{"ban ip", 2, ".ban ip $ip $time $reason", "Ban IP address"}, + GmCommandEntry{"unban account", 3, ".unban account $name", "Unban account"}, + GmCommandEntry{"unban character", 3, ".unban character $name", "Unban character"}, + GmCommandEntry{"unban ip", 3, ".unban ip $ip", "Unban IP address"}, + GmCommandEntry{"kick", 2, ".kick [$player] [$reason]", "Kick player from world"}, + GmCommandEntry{"mute", 2, ".mute $player $minutes [$reason]", "Mute player chat"}, + GmCommandEntry{"unmute", 2, ".unmute [$player]", "Unmute player"}, + + // ── Misc ──────────────────────────────────────────────── + GmCommandEntry{"distance", 3, ".distance", "Distance to selected target"}, + GmCommandEntry{"wchange", 3, ".wchange #type #grade", "Change weather"}, + GmCommandEntry{"mailbox", 1, ".mailbox", "Open mailbox"}, + GmCommandEntry{"played", 0, ".played", "Show time played"}, + GmCommandEntry{"gear repair", 2, ".gear repair", "Repair all gear"}, + GmCommandEntry{"gear stats", 0, ".gear stats", "Show avg item level"}, + GmCommandEntry{"reset talents", 3, ".reset talents [$player]", "Reset talents"}, + GmCommandEntry{"reset spells", 3, ".reset spells [$player]", "Reset spells"}, + GmCommandEntry{"pet create", 2, ".pet create", "Create pet from target"}, + GmCommandEntry{"pet learn", 2, ".pet learn #spell", "Teach spell to pet"}, + + // ── Waypoints ─────────────────────────────────────────── + GmCommandEntry{"wp add", 3, ".wp add", "Add waypoint at your position"}, + GmCommandEntry{"wp show", 3, ".wp show on/off", "Toggle waypoint display"}, + GmCommandEntry{"wp load", 3, ".wp load #pathid", "Load path for creature"}, + GmCommandEntry{"wp unload", 3, ".wp unload", "Unload creature path"}, + + // ── Instance ──────────────────────────────────────────── + GmCommandEntry{"instance listbinds", 1, ".instance listbinds", "Show instance binds"}, + GmCommandEntry{"instance unbind", 2, ".instance unbind ", "Clear instance binds"}, + GmCommandEntry{"instance stats", 1, ".instance stats", "Show instance stats"}, + + // ── Events ────────────────────────────────────────────── + GmCommandEntry{"event activelist", 2, ".event activelist", "Show active events"}, + GmCommandEntry{"event start", 2, ".event start #id", "Start event"}, + GmCommandEntry{"event stop", 2, ".event stop #id", "Stop event"}, + GmCommandEntry{"event info", 2, ".event info #id", "Show event info"}, + + // ── Reload (common) ───────────────────────────────────── + GmCommandEntry{"reload all", 3, ".reload all", "Reload all tables"}, + GmCommandEntry{"reload creature_template", 3, ".reload creature_template #entry", "Reload creature template"}, + GmCommandEntry{"reload quest_template", 3, ".reload quest_template", "Reload quest templates"}, + GmCommandEntry{"reload config", 3, ".reload config", "Reload server config"}, + GmCommandEntry{"reload game_tele", 3, ".reload game_tele", "Reload teleport locations"}, + + // ── Ticket ────────────────────────────────────────────── + GmCommandEntry{"ticket list", 2, ".ticket list", "List open GM tickets"}, + GmCommandEntry{"ticket close", 2, ".ticket close #id", "Close ticket"}, + GmCommandEntry{"ticket delete", 3, ".ticket delete #id", "Delete ticket permanently"}, + GmCommandEntry{"ticket viewid", 2, ".ticket viewid #id", "View ticket details"}, +}; + +} // namespace ui +} // namespace wowee diff --git a/include/ui/chat/i_chat_command.hpp b/include/ui/chat/i_chat_command.hpp new file mode 100644 index 00000000..10694d41 --- /dev/null +++ b/include/ui/chat/i_chat_command.hpp @@ -0,0 +1,57 @@ +// IChatCommand — interface for all slash commands. +// Phase 3.1 of chat_panel_ref.md. +#pragma once + +#include +#include + +namespace wowee { + +// Forward declarations +namespace game { class GameHandler; } +namespace ui { struct UIServices; class ChatPanel; } + +namespace ui { + +/** + * Context passed to every command's execute() method. + * Provides everything a command needs without coupling to ChatPanel. + */ +struct ChatCommandContext { + game::GameHandler& gameHandler; + UIServices& services; + ChatPanel& panel; // for input buffer access, macro state + std::string args; // everything after "/cmd " + std::string fullCommand; // the original command name (lowercase) +}; + +/** + * Result returned by a command to tell the dispatcher what to do next. + */ +struct ChatCommandResult { + bool handled = true; // false → command not recognized, fall through + bool clearInput = true; // clear the input buffer after execution +}; + +/** + * Interface for all chat slash commands. + * + * Adding a new command = create a class implementing this interface, + * register it in ChatCommandRegistry. Zero edits to existing code. (OCP) + */ +class IChatCommand { +public: + virtual ~IChatCommand() = default; + + /** Execute the command. */ + virtual ChatCommandResult execute(ChatCommandContext& ctx) = 0; + + /** Return all aliases for this command (e.g. {"w", "whisper", "tell", "t"}). */ + virtual std::vector aliases() const = 0; + + /** Optional help text shown by /help. */ + virtual std::string helpText() const { return ""; } +}; + +} // namespace ui +} // namespace wowee diff --git a/include/ui/chat/i_game_state.hpp b/include/ui/chat/i_game_state.hpp new file mode 100644 index 00000000..abbf09e9 --- /dev/null +++ b/include/ui/chat/i_game_state.hpp @@ -0,0 +1,63 @@ +// IGameState — abstract interface for game state queries used by macro evaluation. +// Allows unit testing with mock state. Phase 4.1 of chat_panel_ref.md. +#pragma once + +#include +#include + +namespace wowee { +namespace ui { + +/** + * Read-only view of game state for macro conditional evaluation. + * + * All entity/aura queries are flattened to simple types so callers + * don't need to depend on game::Entity, game::Unit, etc. + */ +class IGameState { +public: + virtual ~IGameState() = default; + + // --- GUIDs --- + virtual uint64_t getPlayerGuid() const = 0; + virtual uint64_t getTargetGuid() const = 0; + virtual uint64_t getFocusGuid() const = 0; + virtual uint64_t getPetGuid() const = 0; + virtual uint64_t getMouseoverGuid() const = 0; + + // --- Player state booleans --- + virtual bool isInCombat() const = 0; + virtual bool isMounted() const = 0; + virtual bool isSwimming() const = 0; + virtual bool isFlying() const = 0; + virtual bool isCasting() const = 0; + virtual bool isChanneling() const = 0; + virtual bool isStealthed() const = 0; + virtual bool hasPet() const = 0; + virtual bool isInGroup() const = 0; + virtual bool isInRaid() const = 0; + virtual bool isIndoors() const = 0; + + // --- Numeric state --- + virtual uint8_t getActiveTalentSpec() const = 0; // 0-based index + virtual uint32_t getVehicleId() const = 0; + virtual uint32_t getCurrentCastSpellId() const = 0; + + // --- Spell/aura queries --- + virtual std::string getSpellName(uint32_t spellId) const = 0; + + /** Check if target (or player if guid==playerGuid) has a buff/debuff by name. */ + virtual bool hasAuraByName(uint64_t targetGuid, const std::string& spellName, + bool wantDebuff) const = 0; + + /** Check if player has a form/stance aura (permanent aura, maxDurationMs == -1). */ + virtual bool hasFormAura() const = 0; + + // --- Entity queries (flattened, no Entity* exposure) --- + virtual bool entityExists(uint64_t guid) const = 0; + virtual bool entityIsDead(uint64_t guid) const = 0; + virtual bool entityIsHostile(uint64_t guid) const = 0; +}; + +} // namespace ui +} // namespace wowee diff --git a/include/ui/chat/i_modifier_state.hpp b/include/ui/chat/i_modifier_state.hpp new file mode 100644 index 00000000..9bdba388 --- /dev/null +++ b/include/ui/chat/i_modifier_state.hpp @@ -0,0 +1,21 @@ +// IModifierState — abstract interface for keyboard modifier queries. +// Allows unit testing macro conditionals without real input system. Phase 4.1. +#pragma once + +namespace wowee { +namespace ui { + +/** + * Read-only view of keyboard modifier state for macro conditional evaluation. + */ +class IModifierState { +public: + virtual ~IModifierState() = default; + + virtual bool isShiftHeld() const = 0; + virtual bool isCtrlHeld() const = 0; + virtual bool isAltHeld() const = 0; +}; + +} // namespace ui +} // namespace wowee diff --git a/include/ui/chat/input_modifier_adapter.hpp b/include/ui/chat/input_modifier_adapter.hpp new file mode 100644 index 00000000..29a4d0af --- /dev/null +++ b/include/ui/chat/input_modifier_adapter.hpp @@ -0,0 +1,22 @@ +// InputModifierAdapter — wraps core::Input to implement IModifierState. +// Phase 4.3 of chat_panel_ref.md. +#pragma once + +#include "ui/chat/i_modifier_state.hpp" + +namespace wowee { +namespace ui { + +/** + * Concrete adapter from core::Input → IModifierState. + * Reads real keyboard state from SDL. + */ +class InputModifierAdapter : public IModifierState { +public: + bool isShiftHeld() const override; + bool isCtrlHeld() const override; + bool isAltHeld() const override; +}; + +} // namespace ui +} // namespace wowee diff --git a/include/ui/chat/macro_evaluator.hpp b/include/ui/chat/macro_evaluator.hpp new file mode 100644 index 00000000..00c07610 --- /dev/null +++ b/include/ui/chat/macro_evaluator.hpp @@ -0,0 +1,50 @@ +// MacroEvaluator — WoW macro conditional parser and evaluator. +// Extracted from evaluateMacroConditionals() in chat_panel_commands.cpp. +// Phase 4.4 of chat_panel_ref.md. +#pragma once + +#include +#include + +namespace wowee { +namespace ui { + +class IGameState; +class IModifierState; + +/** + * Evaluates WoW-style macro conditional expressions. + * + * Syntax: [cond1,cond2] Spell1; [cond3] Spell2; DefaultSpell + * + * The first alternative whose conditions all evaluate true is returned. + * If no conditions match, returns "". + * + * @p targetOverride is set to a specific GUID if [target=X] or [@X] + * was in the matching conditions, or left as UINT64_MAX for "use normal target". + */ +class MacroEvaluator { +public: + MacroEvaluator(IGameState& gameState, IModifierState& modState); + + /** + * Evaluate a macro conditional string. + * @param rawArg The conditional text (e.g. "[combat] Spell1; Spell2") + * @param targetOverride Output: set to target GUID if specified, or -1 + * @return The matched argument text, or "" if nothing matched + */ + std::string evaluate(const std::string& rawArg, uint64_t& targetOverride) const; + +private: + /** Evaluate a single condition token (e.g. "combat", "mod:shift", "@focus"). */ + bool evalCondition(const std::string& cond, uint64_t& tgt) const; + + /** Resolve effective target GUID (follows @/target= overrides). */ + uint64_t resolveEffectiveTarget(uint64_t tgt) const; + + IGameState& gameState_; + IModifierState& modState_; +}; + +} // namespace ui +} // namespace wowee diff --git a/include/ui/chat_panel.hpp b/include/ui/chat_panel.hpp index d1217fc7..954def48 100644 --- a/include/ui/chat_panel.hpp +++ b/include/ui/chat_panel.hpp @@ -2,6 +2,15 @@ #include "game/game_handler.hpp" #include "ui/ui_services.hpp" +#include "ui/chat/chat_settings.hpp" +#include "ui/chat/chat_input.hpp" +#include "ui/chat/chat_tab_manager.hpp" +#include "ui/chat/chat_bubble_manager.hpp" +#include "ui/chat/cast_sequence_tracker.hpp" +#include "ui/chat/chat_markup_parser.hpp" +#include "ui/chat/chat_markup_renderer.hpp" +#include "ui/chat/chat_command_registry.hpp" +#include "ui/chat/chat_tab_completer.hpp" #include #include #include @@ -69,9 +78,6 @@ public: /** Execute a macro body (one line per 'click'). */ void executeMacroText(game::GameHandler& gameHandler, - InventoryScreen& inventoryScreen, - SpellbookScreen& spellbookScreen, - QuestLogScreen& questLogScreen, const std::string& macroText); // ---- Slash-command side-effects ---- @@ -90,25 +96,31 @@ public: /** Return accumulated slash-command flags and reset them. */ SlashCommands consumeSlashCommands(); - // ---- Chat settings (read/written by GameScreen save/load & settings tab) ---- + // ---- Chat settings (delegated to ChatSettings) ---- - bool chatShowTimestamps = false; - int chatFontSize = 1; // 0=small, 1=medium, 2=large - bool chatAutoJoinGeneral = true; - bool chatAutoJoinTrade = true; - bool chatAutoJoinLocalDefense = true; - bool chatAutoJoinLFG = true; - bool chatAutoJoinLocal = true; + ChatSettings settings; int activeChatTab = 0; + // Legacy accessors — forward to settings struct for external code + // (GameScreen save/load reads these directly) + bool& chatShowTimestamps = settings.showTimestamps; + int& chatFontSize = settings.fontSize; + bool& chatAutoJoinGeneral = settings.autoJoinGeneral; + bool& chatAutoJoinTrade = settings.autoJoinTrade; + bool& chatAutoJoinLocalDefense = settings.autoJoinLocalDefense; + bool& chatAutoJoinLFG = settings.autoJoinLFG; + bool& chatAutoJoinLocal = settings.autoJoinLocal; + /** Spell icon lookup callback — set by GameScreen each frame before render(). */ std::function getSpellIcon; - /** Render the "Chat" tab inside the Settings window. */ - void renderSettingsTab(std::function saveSettingsFn); + /** Render the "Chat" tab inside the Settings window (delegates to settings). */ + void renderSettingsTab(std::function saveSettingsFn) { + settings.renderSettingsTab(std::move(saveSettingsFn)); + } - /** Reset all chat settings to defaults. */ - void restoreDefaults(); + /** Reset all chat settings to defaults (delegates to settings). */ + void restoreDefaults() { settings.restoreDefaults(); } // UIServices injection (Phase B singleton breaking) void setServices(const UIServices& services) { services_ = services; } @@ -116,14 +128,31 @@ public: /** Replace $g/$G and $n/$N gender/name placeholders in quest/chat text. */ std::string replaceGenderPlaceholders(const std::string& text, game::GameHandler& gameHandler); + // ---- Accessors for command system (Phase 3) ---- + char* getChatInputBuffer() { return chatInputBuffer_; } + size_t getChatInputBufferSize() const { return sizeof(chatInputBuffer_); } + char* getWhisperTargetBuffer() { return whisperTargetBuffer_; } + size_t getWhisperTargetBufferSize() const { return sizeof(whisperTargetBuffer_); } + int getSelectedChatType() const { return selectedChatType_; } + void setSelectedChatType(int t) { selectedChatType_ = t; } + int getSelectedChannelIdx() const { return selectedChannelIdx_; } + bool& macroStopped() { return macroStopped_; } + CastSequenceTracker& getCastSeqTracker() { return castSeqTracker_; } + SlashCommands& getSlashCmds() { return slashCmds_; } + UIServices& getServices() { return services_; } + ChatCommandRegistry& getCommandRegistry() { return commandRegistry_; } + private: // Injected UI services (Phase B singleton breaking) UIServices services_; // ---- Chat input state ---- + // NOTE: These will migrate to ChatInput in Phase 6 (slim ChatPanel). + // ChatInput class is ready at include/ui/chat/chat_input.hpp. char chatInputBuffer_[512] = ""; char whisperTargetBuffer_[256] = ""; bool chatInputActive_ = false; + int chatInputCooldown_ = 0; // frames to suppress re-activation after send int selectedChatType_ = 0; // 0=SAY .. 10=CHANNEL int lastChatType_ = 0; int selectedChannelIdx_ = 0; @@ -137,55 +166,44 @@ private: // Macro stop flag bool macroStopped_ = false; - // Tab-completion state - std::string chatTabPrefix_; - std::vector chatTabMatches_; - int chatTabMatchIdx_ = -1; + // /castsequence state (delegated to CastSequenceTracker, Phase 1.5) + CastSequenceTracker castSeqTracker_; + + // Command registry (Phase 3 — replaces if/else chain) + ChatCommandRegistry commandRegistry_; + void registerAllCommands(); + + // Markup parser + renderer (Phase 2) + ChatMarkupParser markupParser_; + ChatMarkupRenderer markupRenderer_; + + // Tab-completion (Phase 5 — delegated to ChatTabCompleter) + ChatTabCompleter tabCompleter_; // Mention notification size_t chatMentionSeenCount_ = 0; - // ---- Chat tabs ---- - struct ChatTab { - std::string name; - uint64_t typeMask; - }; - std::vector chatTabs_; - std::vector chatTabUnread_; - size_t chatTabSeenCount_ = 0; - - void initChatTabs(); - bool shouldShowMessage(const game::MessageChatData& msg, int tabIndex) const; + // ---- Chat tabs (delegated to ChatTabManager) ---- + ChatTabManager tabManager_; // ---- Chat window visual state ---- bool chatScrolledUp_ = false; bool chatForceScrollToBottom_ = false; - bool chatWindowLocked_ = true; + // windowLocked is in settings.windowLocked (kept in sync via reference) + bool& chatWindowLocked_ = settings.windowLocked; ImVec2 chatWindowPos_ = ImVec2(0.0f, 0.0f); bool chatWindowPosInit_ = false; - // ---- Chat bubbles ---- - struct ChatBubble { - uint64_t senderGuid = 0; - std::string message; - float timeRemaining = 0.0f; - float totalDuration = 0.0f; - bool isYell = false; - }; - std::vector chatBubbles_; - bool chatBubbleCallbackSet_ = false; + // ---- Chat bubbles (delegated to ChatBubbleManager) ---- + ChatBubbleManager bubbleManager_; // ---- Whisper toast state (populated in render, rendered by GameScreen/ToastManager) ---- // Whisper scanning lives here because it's tightly coupled to chat history iteration. size_t whisperSeenCount_ = 0; // ---- Helpers ---- - void sendChatMessage(game::GameHandler& gameHandler, - InventoryScreen& inventoryScreen, - SpellbookScreen& spellbookScreen, - QuestLogScreen& questLogScreen); - const char* getChatTypeName(game::ChatType type) const; - ImVec4 getChatTypeColor(game::ChatType type) const; + void sendChatMessage(game::GameHandler& gameHandler); + // getChatTypeName / getChatTypeColor now static in ChatTabManager // Cached game handler for input callback (set each frame in render) game::GameHandler* cachedGameHandler_ = nullptr; diff --git a/src/game/world_packets_social.cpp b/src/game/world_packets_social.cpp index 27366631..7dbd8d49 100644 --- a/src/game/world_packets_social.cpp +++ b/src/game/world_packets_social.cpp @@ -259,6 +259,7 @@ bool MessageChatParser::parse(network::Packet& packet, MessageChatData& data) { const char* getChatTypeString(ChatType type) { switch (type) { + case ChatType::SYSTEM: return "SYSTEM"; case ChatType::SAY: return "SAY"; case ChatType::PARTY: return "PARTY"; case ChatType::RAID: return "RAID"; @@ -266,12 +267,14 @@ const char* getChatTypeString(ChatType type) { case ChatType::OFFICER: return "OFFICER"; case ChatType::YELL: return "YELL"; case ChatType::WHISPER: return "WHISPER"; + case ChatType::WHISPER_FOREIGN: return "WHISPER_FOREIGN"; case ChatType::WHISPER_INFORM: return "WHISPER_INFORM"; case ChatType::EMOTE: return "EMOTE"; case ChatType::TEXT_EMOTE: return "TEXT_EMOTE"; - case ChatType::SYSTEM: return "SYSTEM"; case ChatType::MONSTER_SAY: return "MONSTER_SAY"; + case ChatType::MONSTER_PARTY: return "MONSTER_PARTY"; case ChatType::MONSTER_YELL: return "MONSTER_YELL"; + case ChatType::MONSTER_WHISPER: return "MONSTER_WHISPER"; case ChatType::MONSTER_EMOTE: return "MONSTER_EMOTE"; case ChatType::CHANNEL: return "CHANNEL"; case ChatType::CHANNEL_JOIN: return "CHANNEL_JOIN"; @@ -284,12 +287,18 @@ const char* getChatTypeString(ChatType type) { case ChatType::IGNORED: return "IGNORED"; case ChatType::SKILL: return "SKILL"; case ChatType::LOOT: return "LOOT"; - case ChatType::BATTLEGROUND: return "BATTLEGROUND"; - case ChatType::BATTLEGROUND_LEADER: return "BATTLEGROUND_LEADER"; + case ChatType::BG_SYSTEM_NEUTRAL: return "BG_SYSTEM_NEUTRAL"; + case ChatType::BG_SYSTEM_ALLIANCE: return "BG_SYSTEM_ALLIANCE"; + case ChatType::BG_SYSTEM_HORDE: return "BG_SYSTEM_HORDE"; case ChatType::RAID_LEADER: return "RAID_LEADER"; case ChatType::RAID_WARNING: return "RAID_WARNING"; + case ChatType::RAID_BOSS_EMOTE: return "RAID_BOSS_EMOTE"; + case ChatType::RAID_BOSS_WHISPER: return "RAID_BOSS_WHISPER"; + case ChatType::BATTLEGROUND: return "BATTLEGROUND"; + case ChatType::BATTLEGROUND_LEADER: return "BATTLEGROUND_LEADER"; case ChatType::ACHIEVEMENT: return "ACHIEVEMENT"; case ChatType::GUILD_ACHIEVEMENT: return "GUILD_ACHIEVEMENT"; + case ChatType::PARTY_LEADER: return "PARTY_LEADER"; default: return "UNKNOWN"; } } diff --git a/src/ui/action_bar_panel.cpp b/src/ui/action_bar_panel.cpp index 9785a105..da58004d 100644 --- a/src/ui/action_bar_panel.cpp +++ b/src/ui/action_bar_panel.cpp @@ -166,7 +166,7 @@ void ActionBarPanel::renderActionBar(game::GameHandler& gameHandler, ChatPanel& chatPanel, InventoryScreen& inventoryScreen, SpellbookScreen& spellbookScreen, - QuestLogScreen& questLogScreen, + QuestLogScreen& /*questLogScreen*/, SpellIconFn getSpellIcon) { // Use ImGui's display size — always in sync with the current swap-chain/frame, // whereas window->getWidth/Height() can lag by one frame on resize events. @@ -539,7 +539,7 @@ void ActionBarPanel::renderActionBar(game::GameHandler& gameHandler, } else if (slot.type == game::ActionBarSlot::ITEM && slot.id != 0) { gameHandler.useItemById(slot.id); } else if (slot.type == game::ActionBarSlot::MACRO) { - chatPanel.executeMacroText(gameHandler, inventoryScreen, spellbookScreen, questLogScreen, gameHandler.getMacroText(slot.id)); + chatPanel.executeMacroText(gameHandler, gameHandler.getMacroText(slot.id)); } } @@ -572,7 +572,7 @@ void ActionBarPanel::renderActionBar(game::GameHandler& gameHandler, ImGui::TextDisabled("Macro #%u", slot.id); ImGui::Separator(); if (ImGui::MenuItem("Execute")) { - chatPanel.executeMacroText(gameHandler, inventoryScreen, spellbookScreen, questLogScreen, gameHandler.getMacroText(slot.id)); + chatPanel.executeMacroText(gameHandler, gameHandler.getMacroText(slot.id)); } if (ImGui::MenuItem("Edit")) { const std::string& txt = gameHandler.getMacroText(slot.id); diff --git a/src/ui/chat/chat_bubble_manager.cpp b/src/ui/chat/chat_bubble_manager.cpp new file mode 100644 index 00000000..a8cef29b --- /dev/null +++ b/src/ui/chat/chat_bubble_manager.cpp @@ -0,0 +1,128 @@ +// ChatBubbleManager — 3D-projected chat bubbles above entities. +// Moved from ChatPanel::renderBubbles / setupCallbacks (Phase 1.4). +#include "ui/chat/chat_bubble_manager.hpp" +#include "game/game_handler.hpp" +#include "rendering/renderer.hpp" +#include "rendering/camera.hpp" +#include "core/coordinates.hpp" +#include "core/window.hpp" +#include +#include + +namespace wowee { namespace ui { + +void ChatBubbleManager::addBubble(uint64_t senderGuid, const std::string& message, bool isYell) { + float duration = 8.0f + static_cast(message.size()) * 0.06f; + if (isYell) duration += 2.0f; + if (duration > 15.0f) duration = 15.0f; + + // Replace existing bubble for same sender + for (auto& b : bubbles_) { + if (b.senderGuid == senderGuid) { + b.message = message; + b.timeRemaining = duration; + b.totalDuration = duration; + b.isYell = isYell; + return; + } + } + // Evict oldest if too many + if (bubbles_.size() >= kMaxBubbles) { + bubbles_.erase(bubbles_.begin()); + } + bubbles_.push_back({senderGuid, message, duration, duration, isYell}); +} + +void ChatBubbleManager::render(game::GameHandler& gameHandler, const UIServices& services) { + if (bubbles_.empty()) return; + + auto* renderer = services.renderer; + auto* camera = renderer ? renderer->getCamera() : nullptr; + if (!camera) return; + + auto* window = services.window; + float screenW = window ? static_cast(window->getWidth()) : 1280.0f; + float screenH = window ? static_cast(window->getHeight()) : 720.0f; + + // Get delta time from ImGui + float dt = ImGui::GetIO().DeltaTime; + + glm::mat4 viewProj = camera->getProjectionMatrix() * camera->getViewMatrix(); + + // Update and render bubbles + for (int i = static_cast(bubbles_.size()) - 1; i >= 0; --i) { + auto& bubble = bubbles_[i]; + bubble.timeRemaining -= dt; + if (bubble.timeRemaining <= 0.0f) { + bubbles_.erase(bubbles_.begin() + i); + continue; + } + + // Get entity position + auto entity = gameHandler.getEntityManager().getEntity(bubble.senderGuid); + if (!entity) continue; + + // Convert canonical → render coordinates, offset up by 2.5 units for bubble above head + glm::vec3 canonical(entity->getX(), entity->getY(), entity->getZ() + 2.5f); + glm::vec3 renderPos = core::coords::canonicalToRender(canonical); + + // Project to screen + glm::vec4 clipPos = viewProj * glm::vec4(renderPos, 1.0f); + if (clipPos.w <= 0.0f) continue; // Behind camera + + glm::vec2 ndc(clipPos.x / clipPos.w, clipPos.y / clipPos.w); + float screenX = (ndc.x * 0.5f + 0.5f) * screenW; + // Camera bakes the Vulkan Y-flip into the projection matrix: + // NDC y=-1 is top, y=1 is bottom — same convention as nameplate/minimap projection. + float screenY = (ndc.y * 0.5f + 0.5f) * screenH; + + // Skip if off-screen + if (screenX < -200.0f || screenX > screenW + 200.0f || + screenY < -100.0f || screenY > screenH + 100.0f) continue; + + // Fade alpha over last 2 seconds + float alpha = 1.0f; + if (bubble.timeRemaining < 2.0f) { + alpha = bubble.timeRemaining / 2.0f; + } + + // Draw bubble window + std::string winId = "##ChatBubble" + std::to_string(bubble.senderGuid); + ImGui::SetNextWindowPos(ImVec2(screenX, screenY), ImGuiCond_Always, ImVec2(0.5f, 1.0f)); + ImGui::SetNextWindowBgAlpha(0.7f * alpha); + ImGuiWindowFlags flags = ImGuiWindowFlags_NoTitleBar | ImGuiWindowFlags_NoResize | + ImGuiWindowFlags_NoMove | ImGuiWindowFlags_NoScrollbar | + ImGuiWindowFlags_AlwaysAutoResize | ImGuiWindowFlags_NoInputs | + ImGuiWindowFlags_NoFocusOnAppearing | ImGuiWindowFlags_NoNav; + + ImGui::PushStyleVar(ImGuiStyleVar_WindowRounding, 8.0f); + ImGui::PushStyleVar(ImGuiStyleVar_WindowPadding, ImVec2(8, 4)); + + ImGui::Begin(winId.c_str(), nullptr, flags); + + ImVec4 textColor = bubble.isYell + ? ImVec4(1.0f, 0.2f, 0.2f, alpha) + : ImVec4(1.0f, 1.0f, 1.0f, alpha); + + ImGui::PushStyleColor(ImGuiCol_Text, textColor); + ImGui::PushTextWrapPos(200.0f); + ImGui::TextWrapped("%s", bubble.message.c_str()); + ImGui::PopTextWrapPos(); + ImGui::PopStyleColor(); + + ImGui::End(); + ImGui::PopStyleVar(2); + } +} + +void ChatBubbleManager::setupCallback(game::GameHandler& gameHandler) { + if (!callbackSet_) { + gameHandler.setChatBubbleCallback([this](uint64_t guid, const std::string& msg, bool isYell) { + addBubble(guid, msg, isYell); + }); + callbackSet_ = true; + } +} + +} // namespace ui +} // namespace wowee diff --git a/src/ui/chat/chat_command_registry.cpp b/src/ui/chat/chat_command_registry.cpp new file mode 100644 index 00000000..7a1f6b66 --- /dev/null +++ b/src/ui/chat/chat_command_registry.cpp @@ -0,0 +1,55 @@ +// ChatCommandRegistry — command registration + dispatch. +// Moved from the if/else chain in ChatPanel::sendChatMessage() (Phase 3.1). +#include "ui/chat/chat_command_registry.hpp" +#include + +namespace wowee { namespace ui { + +void ChatCommandRegistry::registerCommand(std::unique_ptr cmd) { + IChatCommand* raw = cmd.get(); + for (const auto& alias : raw->aliases()) { + commandMap_[alias] = raw; + } + commands_.push_back(std::move(cmd)); +} + +ChatCommandResult ChatCommandRegistry::dispatch(const std::string& cmdLower, + ChatCommandContext& ctx) { + auto it = commandMap_.find(cmdLower); + if (it == commandMap_.end()) { + return {false, false}; // not handled + } + return it->second->execute(ctx); +} + +std::vector ChatCommandRegistry::getCompletions(const std::string& prefix) const { + std::vector results; + for (const auto& [alias, cmd] : commandMap_) { + if (alias.size() >= prefix.size() && + alias.compare(0, prefix.size(), prefix) == 0) { + results.push_back(alias); + } + } + std::sort(results.begin(), results.end()); + return results; +} + +std::vector> ChatCommandRegistry::getHelpEntries() const { + std::vector> entries; + for (const auto& cmd : commands_) { + const auto& aliases = cmd->aliases(); + std::string helpText = cmd->helpText(); + if (!aliases.empty() && !helpText.empty()) { + entries.emplace_back("/" + aliases[0], helpText); + } + } + std::sort(entries.begin(), entries.end()); + return entries; +} + +bool ChatCommandRegistry::hasCommand(const std::string& alias) const { + return commandMap_.find(alias) != commandMap_.end(); +} + +} // namespace ui +} // namespace wowee diff --git a/src/ui/chat/chat_input.cpp b/src/ui/chat/chat_input.cpp new file mode 100644 index 00000000..2d67c453 --- /dev/null +++ b/src/ui/chat/chat_input.cpp @@ -0,0 +1,68 @@ +#include "ui/chat/chat_input.hpp" + +namespace wowee { namespace ui { + +// Push a message to sent-history (skip pure whitespace, cap at kMaxHistory). +void ChatInput::pushToHistory(const std::string& msg) { + bool allSpace = true; + for (char c : msg) { + if (!std::isspace(static_cast(c))) { allSpace = false; break; } + } + if (allSpace) return; + + // Remove duplicate of last entry if identical + if (sentHistory_.empty() || sentHistory_.back() != msg) { + sentHistory_.push_back(msg); + if (static_cast(sentHistory_.size()) > kMaxHistory) + sentHistory_.erase(sentHistory_.begin()); + } + historyIdx_ = -1; // reset browsing position after send +} + +// Navigate up through sent-history. Returns the entry or "" if at start. +std::string ChatInput::historyUp() { + const int histSize = static_cast(sentHistory_.size()); + if (histSize == 0) return ""; + + if (historyIdx_ == -1) + historyIdx_ = histSize - 1; + else if (historyIdx_ > 0) + --historyIdx_; + + return sentHistory_[historyIdx_]; +} + +// Navigate down through sent-history. Returns the entry or "" if past end. +std::string ChatInput::historyDown() { + const int histSize = static_cast(sentHistory_.size()); + if (histSize == 0 || historyIdx_ == -1) return ""; + + ++historyIdx_; + if (historyIdx_ >= histSize) { + historyIdx_ = -1; + return ""; + } + return sentHistory_[historyIdx_]; +} + +// Insert a spell / item link into the chat input buffer (shift-click). +void ChatInput::insertLink(const std::string& link) { + if (link.empty()) return; + size_t curLen = std::strlen(buffer_); + if (curLen + link.size() + 1 < sizeof(buffer_)) { + strncat(buffer_, link.c_str(), sizeof(buffer_) - curLen - 1); + moveCursorToEnd_ = true; + focusRequested_ = true; + } +} + +// Activate the input field with a leading '/' (slash key). +void ChatInput::activateSlashInput() { + focusRequested_ = true; + buffer_[0] = '/'; + buffer_[1] = '\0'; + moveCursorToEnd_ = true; +} + +} // namespace ui +} // namespace wowee diff --git a/src/ui/chat/chat_markup_parser.cpp b/src/ui/chat/chat_markup_parser.cpp new file mode 100644 index 00000000..5479715e --- /dev/null +++ b/src/ui/chat/chat_markup_parser.cpp @@ -0,0 +1,206 @@ +// ChatMarkupParser — parse WoW markup into typed segments. +// Moved from inline lambdas in ChatPanel::render() (Phase 2.1). +#include "ui/chat/chat_markup_parser.hpp" +#include +#include + +namespace wowee { namespace ui { + +ImVec4 ChatMarkupParser::parseWowColor(const std::string& text, size_t pos) { + // |cAARRGGBB (10 chars total: |c + 8 hex) + if (pos + 10 > text.size()) return ImVec4(1, 1, 1, 1); + auto hexByte = [&](size_t offset) -> float { + const char* s = text.c_str() + pos + offset; + char buf[3] = {s[0], s[1], '\0'}; + return static_cast(strtol(buf, nullptr, 16)) / 255.0f; + }; + float a = hexByte(2); + float r = hexByte(4); + float g = hexByte(6); + float b = hexByte(8); + return ImVec4(r, g, b, a); +} + +std::vector ChatMarkupParser::parse(const std::string& text) const { + std::vector segments; + size_t pos = 0; + + while (pos < text.size()) { + // Find next special element: URL or WoW link + size_t urlStart = text.find("https://", pos); + + // Find next WoW link (may be colored with |c prefix or bare |H) + size_t linkStart = text.find("|c", pos); + // Also handle bare |H links without color prefix + size_t bareItem = text.find("|Hitem:", pos); + size_t bareSpell = text.find("|Hspell:", pos); + size_t bareQuest = text.find("|Hquest:", pos); + size_t bareLinkStart = std::min({bareItem, bareSpell, bareQuest}); + + // Determine which comes first + size_t nextSpecial = std::min({urlStart, linkStart, bareLinkStart}); + + if (nextSpecial == std::string::npos) { + // No more special elements — remainder is plain text + std::string remaining = text.substr(pos); + if (!remaining.empty()) { + segments.push_back({SegmentType::Text, std::move(remaining)}); + } + break; + } + + // Emit plain text before the special element + if (nextSpecial > pos) { + segments.push_back({SegmentType::Text, text.substr(pos, nextSpecial - pos)}); + } + + // Handle WoW link (|c... or bare |H...) + if (nextSpecial == linkStart || nextSpecial == bareLinkStart) { + ImVec4 linkColor(1, 1, 1, 1); + size_t hStart = std::string::npos; + + if (nextSpecial == linkStart && text.size() > linkStart + 10) { + // Parse |cAARRGGBB color + linkColor = parseWowColor(text, linkStart); + // Find the nearest |H link of any supported type + size_t hItem = text.find("|Hitem:", linkStart + 10); + size_t hSpell = text.find("|Hspell:", linkStart + 10); + size_t hQuest = text.find("|Hquest:", linkStart + 10); + size_t hAch = text.find("|Hachievement:", linkStart + 10); + hStart = std::min({hItem, hSpell, hQuest, hAch}); + } else if (nextSpecial == bareLinkStart) { + hStart = bareLinkStart; + } + + if (hStart != std::string::npos) { + // Determine link type + const bool isSpellLink = (text.compare(hStart, 8, "|Hspell:") == 0); + const bool isQuestLink = (text.compare(hStart, 8, "|Hquest:") == 0); + const bool isAchievLink = (text.compare(hStart, 14, "|Hachievement:") == 0); + + // Parse the first numeric ID after |Htype: + size_t idOffset = isSpellLink ? 8 : (isQuestLink ? 8 : (isAchievLink ? 14 : 7)); + size_t entryStart = hStart + idOffset; + size_t entryEnd = text.find(':', entryStart); + uint32_t linkId = 0; + if (entryEnd != std::string::npos) { + linkId = static_cast(strtoul( + text.substr(entryStart, entryEnd - entryStart).c_str(), nullptr, 10)); + } + + // Parse quest level (second field after questId) + uint32_t questLevel = 0; + if (isQuestLink && entryEnd != std::string::npos) { + size_t lvlEnd = text.find(':', entryEnd + 1); + if (lvlEnd == std::string::npos) lvlEnd = text.find('|', entryEnd + 1); + if (lvlEnd != std::string::npos) { + questLevel = static_cast(strtoul( + text.substr(entryEnd + 1, lvlEnd - entryEnd - 1).c_str(), nullptr, 10)); + } + } + + // Find display name: |h[Name]|h + size_t nameTagStart = text.find("|h[", hStart); + size_t nameTagEnd = (nameTagStart != std::string::npos) + ? text.find("]|h", nameTagStart + 3) : std::string::npos; + + std::string linkName = isSpellLink ? "Unknown Spell" + : isQuestLink ? "Unknown Quest" + : isAchievLink ? "Unknown Achievement" + : "Unknown Item"; + if (nameTagStart != std::string::npos && nameTagEnd != std::string::npos) { + linkName = text.substr(nameTagStart + 3, nameTagEnd - nameTagStart - 3); + } + + // Find end of entire link sequence (|r or after ]|h) + size_t linkEnd = (nameTagEnd != std::string::npos) ? nameTagEnd + 3 : hStart + idOffset; + size_t resetPos = text.find("|r", linkEnd); + if (resetPos != std::string::npos && resetPos <= linkEnd + 2) { + linkEnd = resetPos + 2; + } + + // Build raw link text for shift-click re-insertion + std::string rawLink = text.substr(nextSpecial, linkEnd - nextSpecial); + + // Emit appropriate segment type + SegmentType stype = isSpellLink ? SegmentType::SpellLink + : isQuestLink ? SegmentType::QuestLink + : isAchievLink ? SegmentType::AchievementLink + : SegmentType::ItemLink; + + ChatSegment seg; + seg.type = stype; + seg.text = std::move(linkName); + seg.color = linkColor; + seg.id = linkId; + seg.extra = questLevel; + seg.rawLink = std::move(rawLink); + segments.push_back(std::move(seg)); + + pos = linkEnd; + continue; + } + + // Not an item link — treat as colored text: |cAARRGGBB...text...|r + if (nextSpecial == linkStart && text.size() > linkStart + 10) { + ImVec4 cColor = parseWowColor(text, linkStart); + size_t textStart = linkStart + 10; // after |cAARRGGBB + size_t resetPos2 = text.find("|r", textStart); + std::string coloredText; + if (resetPos2 != std::string::npos) { + coloredText = text.substr(textStart, resetPos2 - textStart); + pos = resetPos2 + 2; // skip |r + } else { + coloredText = text.substr(textStart); + pos = text.size(); + } + // Strip any remaining WoW markup from the colored segment + // (e.g. |H...|h pairs that aren't item links) + std::string clean; + for (size_t i = 0; i < coloredText.size(); i++) { + if (coloredText[i] == '|' && i + 1 < coloredText.size()) { + char next = coloredText[i + 1]; + if (next == 'H') { + // Skip |H...|h + size_t hEnd = coloredText.find("|h", i + 2); + if (hEnd != std::string::npos) { i = hEnd + 1; continue; } + } else if (next == 'h') { + i += 1; continue; // skip |h + } else if (next == 'r') { + i += 1; continue; // skip |r + } + } + clean += coloredText[i]; + } + if (!clean.empty()) { + ChatSegment seg; + seg.type = SegmentType::ColoredText; + seg.text = std::move(clean); + seg.color = cColor; + segments.push_back(std::move(seg)); + } + } else { + // Bare |c without enough chars for color — render literally + segments.push_back({SegmentType::Text, "|c"}); + pos = nextSpecial + 2; + } + continue; + } + + // Handle URL + if (nextSpecial == urlStart) { + size_t urlEnd = text.find_first_of(" \t\n\r", urlStart); + if (urlEnd == std::string::npos) urlEnd = text.size(); + std::string url = text.substr(urlStart, urlEnd - urlStart); + + segments.push_back({SegmentType::Url, std::move(url)}); + pos = urlEnd; + continue; + } + } + + return segments; +} + +} // namespace ui +} // namespace wowee diff --git a/src/ui/chat/chat_markup_renderer.cpp b/src/ui/chat/chat_markup_renderer.cpp new file mode 100644 index 00000000..dc380c83 --- /dev/null +++ b/src/ui/chat/chat_markup_renderer.cpp @@ -0,0 +1,704 @@ +// ChatMarkupRenderer — render parsed ChatSegments via ImGui. +// Moved from ChatPanel::render() inline lambdas (Phase 2.2). +#include "ui/chat/chat_markup_renderer.hpp" +#include "ui/ui_colors.hpp" +#include "ui/inventory_screen.hpp" +#include "ui/spellbook_screen.hpp" +#include "ui/quest_log_screen.hpp" +#include "game/game_handler.hpp" +#include "pipeline/asset_manager.hpp" +#include "pipeline/dbc_loader.hpp" +#include "pipeline/dbc_layout.hpp" +#include +#include +#include +#include + +namespace wowee { namespace ui { + +// ---- renderItemTooltip (moved from renderItemLinkTooltip lambda) ---- + +void ChatMarkupRenderer::renderItemTooltip( + uint32_t itemEntry, + game::GameHandler& gameHandler, + InventoryScreen& inventoryScreen, + pipeline::AssetManager* assetMgr) +{ + const auto* info = gameHandler.getItemInfo(itemEntry); + if (!info || !info->valid) return; + + auto findComparableEquipped = [&](uint8_t inventoryType) -> const game::ItemSlot* { + using ES = game::EquipSlot; + const auto& inv = gameHandler.getInventory(); + auto slotPtr = [&](ES slot) -> const game::ItemSlot* { + const auto& s = inv.getEquipSlot(slot); + return s.empty() ? nullptr : &s; + }; + switch (inventoryType) { + case 1: return slotPtr(ES::HEAD); + case 2: return slotPtr(ES::NECK); + case 3: return slotPtr(ES::SHOULDERS); + case 4: return slotPtr(ES::SHIRT); + case 5: + case 20: return slotPtr(ES::CHEST); + case 6: return slotPtr(ES::WAIST); + case 7: return slotPtr(ES::LEGS); + case 8: return slotPtr(ES::FEET); + case 9: return slotPtr(ES::WRISTS); + case 10: return slotPtr(ES::HANDS); + case 11: { + if (auto* s = slotPtr(ES::RING1)) return s; + return slotPtr(ES::RING2); + } + case 12: { + if (auto* s = slotPtr(ES::TRINKET1)) return s; + return slotPtr(ES::TRINKET2); + } + case 13: + if (auto* s = slotPtr(ES::MAIN_HAND)) return s; + return slotPtr(ES::OFF_HAND); + case 14: + case 22: + case 23: return slotPtr(ES::OFF_HAND); + case 15: + case 25: + case 26: return slotPtr(ES::RANGED); + case 16: return slotPtr(ES::BACK); + case 17: + case 21: return slotPtr(ES::MAIN_HAND); + case 18: + for (int i = 0; i < game::Inventory::NUM_BAG_SLOTS; ++i) { + auto slot = static_cast(static_cast(ES::BAG1) + i); + if (auto* s = slotPtr(slot)) return s; + } + return nullptr; + case 19: return slotPtr(ES::TABARD); + default: return nullptr; + } + }; + + ImGui::BeginTooltip(); + // Quality color for name + auto qColor = ui::getQualityColor(static_cast(info->quality)); + ImGui::TextColored(qColor, "%s", info->name.c_str()); + + // Heroic indicator (green, matches WoW tooltip style) + constexpr uint32_t kFlagHeroic = 0x8; + constexpr uint32_t kFlagUniqueEquipped = 0x1000000; + if (info->itemFlags & kFlagHeroic) + ImGui::TextColored(ImVec4(0.0f, 0.8f, 0.0f, 1.0f), "Heroic"); + + // Bind type (appears right under name in WoW) + switch (info->bindType) { + case 1: ImGui::TextDisabled("Binds when picked up"); break; + case 2: ImGui::TextDisabled("Binds when equipped"); break; + case 3: ImGui::TextDisabled("Binds when used"); break; + case 4: ImGui::TextDisabled("Quest Item"); break; + } + // Unique / Unique-Equipped + if (info->maxCount == 1) + ImGui::TextColored(ui::colors::kTooltipGold, "Unique"); + else if (info->itemFlags & kFlagUniqueEquipped) + ImGui::TextColored(ui::colors::kTooltipGold, "Unique-Equipped"); + + // Slot type + if (info->inventoryType > 0) { + const char* slotName = ui::getInventorySlotName(info->inventoryType); + if (slotName[0]) { + if (!info->subclassName.empty()) + ImGui::TextColored(ui::colors::kLightGray, "%s %s", slotName, info->subclassName.c_str()); + else + ImGui::TextColored(ui::colors::kLightGray, "%s", slotName); + } + } + auto isWeaponInventoryType = [](uint32_t invType) { + switch (invType) { + case 13: // One-Hand + case 15: // Ranged + case 17: // Two-Hand + case 21: // Main Hand + case 25: // Thrown + case 26: // Ranged Right + return true; + default: + return false; + } + }; + const bool isWeapon = isWeaponInventoryType(info->inventoryType); + + // Item level (after slot/subclass) + if (info->itemLevel > 0) + ImGui::TextDisabled("Item Level %u", info->itemLevel); + + if (isWeapon && info->damageMax > 0.0f && info->delayMs > 0) { + float speed = static_cast(info->delayMs) / 1000.0f; + float dps = ((info->damageMin + info->damageMax) * 0.5f) / speed; + char dmgBuf[64], spdBuf[32]; + std::snprintf(dmgBuf, sizeof(dmgBuf), "%d - %d Damage", + static_cast(info->damageMin), static_cast(info->damageMax)); + std::snprintf(spdBuf, sizeof(spdBuf), "Speed %.2f", speed); + float spdW = ImGui::CalcTextSize(spdBuf).x; + ImGui::Text("%s", dmgBuf); + ImGui::SameLine(ImGui::GetWindowWidth() - spdW - 16.0f); + ImGui::Text("%s", spdBuf); + ImGui::TextDisabled("(%.1f damage per second)", dps); + } + ImVec4 green(0.0f, 1.0f, 0.0f, 1.0f); + auto appendBonus = [](std::string& out, int32_t val, const char* shortName) { + if (val <= 0) return; + if (!out.empty()) out += " "; + out += "+" + std::to_string(val) + " "; + out += shortName; + }; + std::string bonusLine; + appendBonus(bonusLine, info->strength, "Str"); + appendBonus(bonusLine, info->agility, "Agi"); + appendBonus(bonusLine, info->stamina, "Sta"); + appendBonus(bonusLine, info->intellect, "Int"); + appendBonus(bonusLine, info->spirit, "Spi"); + if (!bonusLine.empty()) { + ImGui::TextColored(green, "%s", bonusLine.c_str()); + } + if (info->armor > 0) { + ImGui::Text("%d Armor", info->armor); + } + // Elemental resistances + { + const int32_t resVals[6] = { + info->holyRes, info->fireRes, info->natureRes, + info->frostRes, info->shadowRes, info->arcaneRes + }; + static constexpr const char* resLabels[6] = { + "Holy Resistance", "Fire Resistance", "Nature Resistance", + "Frost Resistance", "Shadow Resistance", "Arcane Resistance" + }; + for (int ri = 0; ri < 6; ++ri) + if (resVals[ri] > 0) ImGui::Text("+%d %s", resVals[ri], resLabels[ri]); + } + // Extra stats (hit/crit/haste/sp/ap/expertise/resilience/etc.) + if (!info->extraStats.empty()) { + auto statName = [](uint32_t t) -> const char* { + switch (t) { + case 12: return "Defense Rating"; + case 13: return "Dodge Rating"; + case 14: return "Parry Rating"; + case 15: return "Block Rating"; + case 16: case 17: case 18: case 31: return "Hit Rating"; + case 19: case 20: case 21: case 32: return "Critical Strike Rating"; + case 28: case 29: case 30: case 35: return "Haste Rating"; + case 34: return "Resilience Rating"; + case 36: return "Expertise Rating"; + case 37: return "Attack Power"; + case 38: return "Ranged Attack Power"; + case 45: return "Spell Power"; + case 46: return "Healing Power"; + case 47: return "Spell Damage"; + case 49: return "Mana per 5 sec."; + case 43: return "Spell Penetration"; + case 44: return "Block Value"; + default: return nullptr; + } + }; + for (const auto& es : info->extraStats) { + const char* nm = statName(es.statType); + if (nm && es.statValue > 0) + ImGui::TextColored(green, "+%d %s", es.statValue, nm); + } + } + // Gem sockets + { + const auto& kSocketTypes = ui::kSocketTypes; + bool hasSocket = false; + for (int s = 0; s < 3; ++s) { + if (info->socketColor[s] == 0) continue; + if (!hasSocket) { ImGui::Spacing(); hasSocket = true; } + for (const auto& st : kSocketTypes) { + if (info->socketColor[s] & st.mask) { + ImGui::TextColored(st.col, "%s", st.label); + break; + } + } + } + if (hasSocket && info->socketBonus != 0) { + static std::unordered_map s_enchantNames; + static bool s_enchantNamesLoaded = false; + if (!s_enchantNamesLoaded && assetMgr) { + s_enchantNamesLoaded = true; + auto dbc = assetMgr->loadDBC("SpellItemEnchantment.dbc"); + if (dbc && dbc->isLoaded()) { + const auto* lay = pipeline::getActiveDBCLayout() + ? pipeline::getActiveDBCLayout()->getLayout("SpellItemEnchantment") : nullptr; + uint32_t nameField = lay ? lay->field("Name") : 8u; + if (nameField == 0xFFFFFFFF) nameField = 8; + uint32_t fc = dbc->getFieldCount(); + for (uint32_t r = 0; r < dbc->getRecordCount(); ++r) { + uint32_t eid = dbc->getUInt32(r, 0); + if (eid == 0 || nameField >= fc) continue; + std::string ename = dbc->getString(r, nameField); + if (!ename.empty()) s_enchantNames[eid] = std::move(ename); + } + } + } + auto enchIt = s_enchantNames.find(info->socketBonus); + if (enchIt != s_enchantNames.end()) + ImGui::TextColored(colors::kSocketGreen, "Socket Bonus: %s", enchIt->second.c_str()); + else + ImGui::TextColored(colors::kSocketGreen, "Socket Bonus: (id %u)", info->socketBonus); + } + } + // Item set membership + if (info->itemSetId != 0) { + struct SetEntry { + std::string name; + std::array itemIds{}; + std::array spellIds{}; + std::array thresholds{}; + }; + static std::unordered_map s_setData; + static bool s_setDataLoaded = false; + if (!s_setDataLoaded && assetMgr) { + s_setDataLoaded = true; + auto dbc = assetMgr->loadDBC("ItemSet.dbc"); + if (dbc && dbc->isLoaded()) { + const auto* layout = pipeline::getActiveDBCLayout() + ? pipeline::getActiveDBCLayout()->getLayout("ItemSet") : nullptr; + auto lf = [&](const char* k, uint32_t def) -> uint32_t { + return layout ? (*layout)[k] : def; + }; + uint32_t idF = lf("ID", 0), nameF = lf("Name", 1); + const auto& itemKeys = ui::kItemSetItemKeys; + const auto& spellKeys = ui::kItemSetSpellKeys; + const auto& thrKeys = ui::kItemSetThresholdKeys; + for (uint32_t r = 0; r < dbc->getRecordCount(); ++r) { + uint32_t id = dbc->getUInt32(r, idF); + if (!id) continue; + SetEntry e; + e.name = dbc->getString(r, nameF); + for (int i = 0; i < 10; ++i) { + e.itemIds[i] = dbc->getUInt32(r, layout ? (*layout)[itemKeys[i]] : uint32_t(18 + i)); + e.spellIds[i] = dbc->getUInt32(r, layout ? (*layout)[spellKeys[i]] : uint32_t(28 + i)); + e.thresholds[i] = dbc->getUInt32(r, layout ? (*layout)[thrKeys[i]] : uint32_t(38 + i)); + } + s_setData[id] = std::move(e); + } + } + } + ImGui::Spacing(); + const auto& inv = gameHandler.getInventory(); + auto setIt = s_setData.find(info->itemSetId); + if (setIt != s_setData.end()) { + const SetEntry& se = setIt->second; + int equipped = 0, total = 0; + for (int i = 0; i < 10; ++i) { + if (se.itemIds[i] == 0) continue; + ++total; + for (int sl = 0; sl < game::Inventory::NUM_EQUIP_SLOTS; sl++) { + const auto& eq = inv.getEquipSlot(static_cast(sl)); + if (!eq.empty() && eq.item.itemId == se.itemIds[i]) { ++equipped; break; } + } + } + if (total > 0) + ImGui::TextColored(ui::colors::kTooltipGold, + "%s (%d/%d)", se.name.empty() ? "Set" : se.name.c_str(), equipped, total); + else if (!se.name.empty()) + ImGui::TextColored(ui::colors::kTooltipGold, "%s", se.name.c_str()); + for (int i = 0; i < 10; ++i) { + if (se.spellIds[i] == 0 || se.thresholds[i] == 0) continue; + const std::string& bname = gameHandler.getSpellName(se.spellIds[i]); + bool active = (equipped >= static_cast(se.thresholds[i])); + ImVec4 col = active ? colors::kActiveGreen : colors::kInactiveGray; + if (!bname.empty()) + ImGui::TextColored(col, "(%u) %s", se.thresholds[i], bname.c_str()); + else + ImGui::TextColored(col, "(%u) Set Bonus", se.thresholds[i]); + } + } else { + ImGui::TextColored(ui::colors::kTooltipGold, "Set (id %u)", info->itemSetId); + } + } + // Item spell effects (Use / Equip / Chance on Hit / Teaches) + for (const auto& sp : info->spells) { + if (sp.spellId == 0) continue; + const char* triggerLabel = nullptr; + switch (sp.spellTrigger) { + case 0: triggerLabel = "Use"; break; + case 1: triggerLabel = "Equip"; break; + case 2: triggerLabel = "Chance on Hit"; break; + case 5: triggerLabel = "Teaches"; break; + } + if (!triggerLabel) continue; + const std::string& spDesc = gameHandler.getSpellDescription(sp.spellId); + const std::string& spText = !spDesc.empty() ? spDesc + : gameHandler.getSpellName(sp.spellId); + if (!spText.empty()) { + ImGui::PushTextWrapPos(ImGui::GetCursorPosX() + 300.0f); + ImGui::TextColored(colors::kCyan, + "%s: %s", triggerLabel, spText.c_str()); + ImGui::PopTextWrapPos(); + } + } + // Required level + if (info->requiredLevel > 1) + ImGui::TextDisabled("Requires Level %u", info->requiredLevel); + // Required skill + if (info->requiredSkill != 0 && info->requiredSkillRank > 0) { + static std::unordered_map s_skillNames; + static bool s_skillNamesLoaded = false; + if (!s_skillNamesLoaded && assetMgr) { + s_skillNamesLoaded = true; + auto dbc = assetMgr->loadDBC("SkillLine.dbc"); + if (dbc && dbc->isLoaded()) { + const auto* layout = pipeline::getActiveDBCLayout() + ? pipeline::getActiveDBCLayout()->getLayout("SkillLine") : nullptr; + uint32_t idF = layout ? (*layout)["ID"] : 0u; + uint32_t nameF = layout ? (*layout)["Name"] : 2u; + for (uint32_t r = 0; r < dbc->getRecordCount(); ++r) { + uint32_t sid = dbc->getUInt32(r, idF); + if (!sid) continue; + std::string sname = dbc->getString(r, nameF); + if (!sname.empty()) s_skillNames[sid] = std::move(sname); + } + } + } + uint32_t playerSkillVal = 0; + const auto& skills = gameHandler.getPlayerSkills(); + auto skPit = skills.find(info->requiredSkill); + if (skPit != skills.end()) playerSkillVal = skPit->second.effectiveValue(); + bool meetsSkill = (playerSkillVal == 0 || playerSkillVal >= info->requiredSkillRank); + ImVec4 skColor = meetsSkill ? ImVec4(1.0f, 1.0f, 1.0f, 0.75f) : colors::kPaleRed; + auto skIt = s_skillNames.find(info->requiredSkill); + if (skIt != s_skillNames.end()) + ImGui::TextColored(skColor, "Requires %s (%u)", skIt->second.c_str(), info->requiredSkillRank); + else + ImGui::TextColored(skColor, "Requires Skill %u (%u)", info->requiredSkill, info->requiredSkillRank); + } + // Required reputation + if (info->requiredReputationFaction != 0 && info->requiredReputationRank > 0) { + static std::unordered_map s_factionNames; + static bool s_factionNamesLoaded = false; + if (!s_factionNamesLoaded && assetMgr) { + s_factionNamesLoaded = true; + auto dbc = assetMgr->loadDBC("Faction.dbc"); + if (dbc && dbc->isLoaded()) { + const auto* layout = pipeline::getActiveDBCLayout() + ? pipeline::getActiveDBCLayout()->getLayout("Faction") : nullptr; + uint32_t idF = layout ? (*layout)["ID"] : 0u; + uint32_t nameF = layout ? (*layout)["Name"] : 20u; + for (uint32_t r = 0; r < dbc->getRecordCount(); ++r) { + uint32_t fid = dbc->getUInt32(r, idF); + if (!fid) continue; + std::string fname = dbc->getString(r, nameF); + if (!fname.empty()) s_factionNames[fid] = std::move(fname); + } + } + } + static constexpr const char* kRepRankNames[] = { + "Hated", "Hostile", "Unfriendly", "Neutral", + "Friendly", "Honored", "Revered", "Exalted" + }; + const char* rankName = (info->requiredReputationRank < 8) + ? kRepRankNames[info->requiredReputationRank] : "Unknown"; + auto fIt = s_factionNames.find(info->requiredReputationFaction); + ImGui::TextColored(ImVec4(1.0f, 1.0f, 1.0f, 0.75f), "Requires %s with %s", + rankName, + fIt != s_factionNames.end() ? fIt->second.c_str() : "Unknown Faction"); + } + // Class restriction + if (info->allowableClass != 0) { + const auto& kClasses = ui::kClassMasks; + int matchCount = 0; + for (const auto& kc : kClasses) + if (info->allowableClass & kc.mask) ++matchCount; + if (matchCount > 0 && matchCount < 10) { + char classBuf[128] = "Classes: "; + bool first = true; + for (const auto& kc : kClasses) { + if (!(info->allowableClass & kc.mask)) continue; + if (!first) strncat(classBuf, ", ", sizeof(classBuf) - strlen(classBuf) - 1); + strncat(classBuf, kc.name, sizeof(classBuf) - strlen(classBuf) - 1); + first = false; + } + uint8_t pc = gameHandler.getPlayerClass(); + uint32_t pmask = (pc > 0 && pc <= 10) ? (1u << (pc - 1)) : 0u; + bool playerAllowed = (pmask == 0 || (info->allowableClass & pmask)); + ImVec4 clColor = playerAllowed ? ImVec4(1.0f, 1.0f, 1.0f, 0.75f) : colors::kPaleRed; + ImGui::TextColored(clColor, "%s", classBuf); + } + } + // Race restriction + if (info->allowableRace != 0) { + const auto& kRaces = ui::kRaceMasks; + constexpr uint32_t kAllPlayable = 1|2|4|8|16|32|64|128|512|1024; + if ((info->allowableRace & kAllPlayable) != kAllPlayable) { + int matchCount = 0; + for (const auto& kr : kRaces) + if (info->allowableRace & kr.mask) ++matchCount; + if (matchCount > 0) { + char raceBuf[160] = "Races: "; + bool first = true; + for (const auto& kr : kRaces) { + if (!(info->allowableRace & kr.mask)) continue; + if (!first) strncat(raceBuf, ", ", sizeof(raceBuf) - strlen(raceBuf) - 1); + strncat(raceBuf, kr.name, sizeof(raceBuf) - strlen(raceBuf) - 1); + first = false; + } + uint8_t pr = gameHandler.getPlayerRace(); + uint32_t pmask = (pr > 0 && pr <= 11) ? (1u << (pr - 1)) : 0u; + bool playerAllowed = (pmask == 0 || (info->allowableRace & pmask)); + ImVec4 rColor = playerAllowed ? ImVec4(1.0f, 1.0f, 1.0f, 0.75f) : colors::kPaleRed; + ImGui::TextColored(rColor, "%s", raceBuf); + } + } + } + // Flavor text + if (!info->description.empty()) { + ImGui::Spacing(); + ImGui::PushTextWrapPos(300.0f); + ImGui::TextColored(ImVec4(1.0f, 0.82f, 0.0f, 0.85f), "\"%s\"", info->description.c_str()); + ImGui::PopTextWrapPos(); + } + if (info->sellPrice > 0) { + ImGui::TextDisabled("Sell:"); ImGui::SameLine(0, 4); + renderCoinsFromCopper(info->sellPrice); + } + + if (ImGui::GetIO().KeyShift && info->inventoryType > 0) { + if (const auto* eq = findComparableEquipped(static_cast(info->inventoryType))) { + ImGui::Separator(); + ImGui::TextDisabled("Equipped:"); + VkDescriptorSet eqIcon = inventoryScreen.getItemIcon(eq->item.displayInfoId); + if (eqIcon) { + ImGui::Image((ImTextureID)(uintptr_t)eqIcon, ImVec2(18.0f, 18.0f)); + ImGui::SameLine(); + } + ImGui::TextColored(InventoryScreen::getQualityColor(eq->item.quality), "%s", eq->item.name.c_str()); + if (isWeaponInventoryType(eq->item.inventoryType) && + eq->item.damageMax > 0.0f && eq->item.delayMs > 0) { + float speed = static_cast(eq->item.delayMs) / 1000.0f; + float dps = ((eq->item.damageMin + eq->item.damageMax) * 0.5f) / speed; + char eqDmg[64], eqSpd[32]; + std::snprintf(eqDmg, sizeof(eqDmg), "%d - %d Damage", + static_cast(eq->item.damageMin), static_cast(eq->item.damageMax)); + std::snprintf(eqSpd, sizeof(eqSpd), "Speed %.2f", speed); + float eqSpdW = ImGui::CalcTextSize(eqSpd).x; + ImGui::Text("%s", eqDmg); + ImGui::SameLine(ImGui::GetWindowWidth() - eqSpdW - 16.0f); + ImGui::Text("%s", eqSpd); + ImGui::TextDisabled("(%.1f damage per second)", dps); + } + if (eq->item.armor > 0) { + ImGui::Text("%d Armor", eq->item.armor); + } + std::string eqBonusLine; + appendBonus(eqBonusLine, eq->item.strength, "Str"); + appendBonus(eqBonusLine, eq->item.agility, "Agi"); + appendBonus(eqBonusLine, eq->item.stamina, "Sta"); + appendBonus(eqBonusLine, eq->item.intellect, "Int"); + appendBonus(eqBonusLine, eq->item.spirit, "Spi"); + if (!eqBonusLine.empty()) { + ImGui::TextColored(green, "%s", eqBonusLine.c_str()); + } + // Extra stats for the equipped item + for (const auto& es : eq->item.extraStats) { + const char* nm = nullptr; + switch (es.statType) { + case 12: nm = "Defense Rating"; break; + case 13: nm = "Dodge Rating"; break; + case 14: nm = "Parry Rating"; break; + case 16: case 17: case 18: case 31: nm = "Hit Rating"; break; + case 19: case 20: case 21: case 32: nm = "Critical Strike Rating"; break; + case 28: case 29: case 30: case 35: nm = "Haste Rating"; break; + case 34: nm = "Resilience Rating"; break; + case 36: nm = "Expertise Rating"; break; + case 37: nm = "Attack Power"; break; + case 38: nm = "Ranged Attack Power"; break; + case 45: nm = "Spell Power"; break; + case 46: nm = "Healing Power"; break; + case 49: nm = "Mana per 5 sec."; break; + default: break; + } + if (nm && es.statValue > 0) + ImGui::TextColored(green, "+%d %s", es.statValue, nm); + } + } + } + ImGui::EndTooltip(); +} + + +// ---- Main segment renderer ---- + +void ChatMarkupRenderer::render( + const std::vector& segments, + const ImVec4& baseColor, + const MarkupRenderContext& ctx) const +{ + for (size_t i = 0; i < segments.size(); ++i) { + const auto& seg = segments[i]; + bool needSameLine = (i + 1 < segments.size()); + + switch (seg.type) { + case SegmentType::Text: { + if (!seg.text.empty()) { + ImGui::PushStyleColor(ImGuiCol_Text, baseColor); + ImGui::TextWrapped("%s", seg.text.c_str()); + ImGui::PopStyleColor(); + if (needSameLine) ImGui::SameLine(0, 0); + } + break; + } + case SegmentType::ColoredText: { + if (!seg.text.empty()) { + ImGui::PushStyleColor(ImGuiCol_Text, seg.color); + ImGui::TextWrapped("%s", seg.text.c_str()); + ImGui::PopStyleColor(); + if (needSameLine) ImGui::SameLine(0, 0); + } + break; + } + case SegmentType::ItemLink: { + uint32_t itemEntry = seg.id; + if (itemEntry > 0 && ctx.gameHandler) { + ctx.gameHandler->ensureItemInfo(itemEntry); + } + // Show small icon before item link if available + if (itemEntry > 0 && ctx.gameHandler && ctx.inventory) { + const auto* chatInfo = ctx.gameHandler->getItemInfo(itemEntry); + if (chatInfo && chatInfo->valid && chatInfo->displayInfoId != 0) { + VkDescriptorSet chatIcon = ctx.inventory->getItemIcon(chatInfo->displayInfoId); + if (chatIcon) { + ImGui::Image((ImTextureID)(uintptr_t)chatIcon, ImVec2(12, 12)); + if (ImGui::IsItemHovered()) { + ImGui::SetMouseCursor(ImGuiMouseCursor_Hand); + renderItemTooltip(itemEntry, *ctx.gameHandler, *ctx.inventory, ctx.assetMgr); + } + ImGui::SameLine(0, 2); + } + } + } + std::string display = "[" + seg.text + "]"; + ImGui::PushStyleColor(ImGuiCol_Text, seg.color); + ImGui::TextWrapped("%s", display.c_str()); + ImGui::PopStyleColor(); + if (ImGui::IsItemHovered()) { + ImGui::SetMouseCursor(ImGuiMouseCursor_Hand); + if (itemEntry > 0 && ctx.gameHandler && ctx.inventory) { + renderItemTooltip(itemEntry, *ctx.gameHandler, *ctx.inventory, ctx.assetMgr); + } + } + // Shift-click: insert entire link back into chat input + if (ImGui::IsItemClicked() && ImGui::GetIO().KeyShift && + ctx.chatInputBuffer && ctx.moveCursorToEnd) { + size_t curLen = strlen(ctx.chatInputBuffer); + if (curLen + seg.rawLink.size() + 1 < ctx.chatInputBufSize) { + strncat(ctx.chatInputBuffer, seg.rawLink.c_str(), ctx.chatInputBufSize - curLen - 1); + *ctx.moveCursorToEnd = true; + } + } + if (needSameLine) ImGui::SameLine(0, 0); + break; + } + case SegmentType::SpellLink: { + // Small icon (use spell icon cache if available) + VkDescriptorSet spellIcon = VK_NULL_HANDLE; + if (seg.id > 0 && ctx.getSpellIcon && ctx.assetMgr) { + spellIcon = ctx.getSpellIcon(seg.id, ctx.assetMgr); + } + if (spellIcon) { + ImGui::Image((ImTextureID)(uintptr_t)spellIcon, ImVec2(12, 12)); + if (ImGui::IsItemHovered() && ctx.spellbook && ctx.gameHandler && ctx.assetMgr) { + ImGui::SetMouseCursor(ImGuiMouseCursor_Hand); + ctx.spellbook->renderSpellInfoTooltip(seg.id, *ctx.gameHandler, ctx.assetMgr); + } + ImGui::SameLine(0, 2); + } + std::string display = "[" + seg.text + "]"; + ImGui::PushStyleColor(ImGuiCol_Text, seg.color); + ImGui::TextWrapped("%s", display.c_str()); + ImGui::PopStyleColor(); + if (ImGui::IsItemHovered() && ctx.spellbook && ctx.gameHandler && ctx.assetMgr) { + ImGui::SetMouseCursor(ImGuiMouseCursor_Hand); + ctx.spellbook->renderSpellInfoTooltip(seg.id, *ctx.gameHandler, ctx.assetMgr); + } + // Shift-click: insert link + if (ImGui::IsItemClicked() && ImGui::GetIO().KeyShift && + ctx.chatInputBuffer && ctx.moveCursorToEnd) { + size_t curLen = strlen(ctx.chatInputBuffer); + if (curLen + seg.rawLink.size() + 1 < ctx.chatInputBufSize) { + strncat(ctx.chatInputBuffer, seg.rawLink.c_str(), ctx.chatInputBufSize - curLen - 1); + *ctx.moveCursorToEnd = true; + } + } + if (needSameLine) ImGui::SameLine(0, 0); + break; + } + case SegmentType::QuestLink: { + std::string display = "[" + seg.text + "]"; + ImGui::PushStyleColor(ImGuiCol_Text, colors::kWarmGold); + ImGui::TextWrapped("%s", display.c_str()); + ImGui::PopStyleColor(); + if (ImGui::IsItemHovered()) { + ImGui::SetMouseCursor(ImGuiMouseCursor_Hand); + ImGui::BeginTooltip(); + ImGui::TextColored(colors::kWarmGold, "%s", seg.text.c_str()); + if (seg.extra > 0) ImGui::TextDisabled("Level %u Quest", seg.extra); + ImGui::TextDisabled("Click quest log to view details"); + ImGui::EndTooltip(); + } + if (ImGui::IsItemClicked() && seg.id > 0 && ctx.questLog) { + ctx.questLog->openAndSelectQuest(seg.id); + } + // Shift-click: insert link + if (ImGui::IsItemClicked() && ImGui::GetIO().KeyShift && + ctx.chatInputBuffer && ctx.moveCursorToEnd) { + size_t curLen = strlen(ctx.chatInputBuffer); + if (curLen + seg.rawLink.size() + 1 < ctx.chatInputBufSize) { + strncat(ctx.chatInputBuffer, seg.rawLink.c_str(), ctx.chatInputBufSize - curLen - 1); + *ctx.moveCursorToEnd = true; + } + } + if (needSameLine) ImGui::SameLine(0, 0); + break; + } + case SegmentType::AchievementLink: { + std::string display = "[" + seg.text + "]"; + ImGui::PushStyleColor(ImGuiCol_Text, colors::kBrightGold); + ImGui::TextWrapped("%s", display.c_str()); + ImGui::PopStyleColor(); + if (ImGui::IsItemHovered()) { + ImGui::SetMouseCursor(ImGuiMouseCursor_Hand); + ImGui::SetTooltip("Achievement: %s", seg.text.c_str()); + } + // Shift-click: insert link + if (ImGui::IsItemClicked() && ImGui::GetIO().KeyShift && + ctx.chatInputBuffer && ctx.moveCursorToEnd) { + size_t curLen = strlen(ctx.chatInputBuffer); + if (curLen + seg.rawLink.size() + 1 < ctx.chatInputBufSize) { + strncat(ctx.chatInputBuffer, seg.rawLink.c_str(), ctx.chatInputBufSize - curLen - 1); + *ctx.moveCursorToEnd = true; + } + } + if (needSameLine) ImGui::SameLine(0, 0); + break; + } + case SegmentType::Url: { + ImGui::PushStyleColor(ImGuiCol_Text, ImVec4(0.4f, 0.7f, 1.0f, 1.0f)); + ImGui::TextWrapped("%s", seg.text.c_str()); + if (ImGui::IsItemHovered()) { + ImGui::SetMouseCursor(ImGuiMouseCursor_Hand); + ImGui::SetTooltip("Open: %s", seg.text.c_str()); + } + if (ImGui::IsItemClicked()) { + std::string cmd = "xdg-open '" + seg.text + "' &"; + [[maybe_unused]] int result = system(cmd.c_str()); + } + ImGui::PopStyleColor(); + if (needSameLine) ImGui::SameLine(0, 0); + break; + } + } // switch + } +} + +} // namespace ui +} // namespace wowee diff --git a/src/ui/chat/chat_settings.cpp b/src/ui/chat/chat_settings.cpp new file mode 100644 index 00000000..69d9d3e1 --- /dev/null +++ b/src/ui/chat/chat_settings.cpp @@ -0,0 +1,63 @@ +#include "ui/chat/chat_settings.hpp" +#include + +namespace wowee { namespace ui { + +// Reset all chat settings to defaults. +void ChatSettings::restoreDefaults() { + showTimestamps = false; + fontSize = 1; + autoJoinGeneral = true; + autoJoinTrade = true; + autoJoinLocalDefense = true; + autoJoinLFG = true; + autoJoinLocal = true; +} + +// Render the "Chat" tab inside the Settings window. +void ChatSettings::renderSettingsTab(std::function saveSettingsFn) { + ImGui::Spacing(); + + ImGui::Text("Appearance"); + ImGui::Separator(); + + if (ImGui::Checkbox("Show Timestamps", &showTimestamps)) { + saveSettingsFn(); + } + ImGui::SetItemTooltip("Show [HH:MM] before each chat message"); + + const char* fontSizes[] = { "Small", "Medium", "Large" }; + if (ImGui::Combo("Chat Font Size", &fontSize, fontSizes, 3)) { + saveSettingsFn(); + } + + ImGui::Spacing(); + ImGui::Spacing(); + ImGui::Text("Auto-Join Channels"); + ImGui::Separator(); + + if (ImGui::Checkbox("General", &autoJoinGeneral)) saveSettingsFn(); + if (ImGui::Checkbox("Trade", &autoJoinTrade)) saveSettingsFn(); + if (ImGui::Checkbox("LocalDefense", &autoJoinLocalDefense)) saveSettingsFn(); + if (ImGui::Checkbox("LookingForGroup", &autoJoinLFG)) saveSettingsFn(); + if (ImGui::Checkbox("Local", &autoJoinLocal)) saveSettingsFn(); + + ImGui::Spacing(); + ImGui::Spacing(); + ImGui::Text("Joined Channels"); + ImGui::Separator(); + + ImGui::TextDisabled("Use /join and /leave commands in chat to manage channels."); + + ImGui::Spacing(); + ImGui::Separator(); + ImGui::Spacing(); + + if (ImGui::Button("Restore Chat Defaults", ImVec2(-1, 0))) { + restoreDefaults(); + saveSettingsFn(); + } +} + +} // namespace ui +} // namespace wowee diff --git a/src/ui/chat/chat_tab_completer.cpp b/src/ui/chat/chat_tab_completer.cpp new file mode 100644 index 00000000..c0626f95 --- /dev/null +++ b/src/ui/chat/chat_tab_completer.cpp @@ -0,0 +1,35 @@ +// ChatTabCompleter — cycling tab-completion state machine. +// Extracted from ChatPanel tab-completion logic (Phase 5.1). +#include "ui/chat/chat_tab_completer.hpp" + +namespace wowee { namespace ui { + +void ChatTabCompleter::startCompletion(const std::string& prefix, + std::vector candidates) { + prefix_ = prefix; + matches_ = std::move(candidates); + matchIdx_ = matches_.empty() ? -1 : 0; +} + +bool ChatTabCompleter::next() { + if (matches_.empty()) return false; + ++matchIdx_; + if (matchIdx_ >= static_cast(matches_.size())) + matchIdx_ = 0; + return true; +} + +std::string ChatTabCompleter::getCurrentMatch() const { + if (matchIdx_ < 0 || matchIdx_ >= static_cast(matches_.size())) + return ""; + return matches_[matchIdx_]; +} + +void ChatTabCompleter::reset() { + prefix_.clear(); + matches_.clear(); + matchIdx_ = -1; +} + +} // namespace ui +} // namespace wowee diff --git a/src/ui/chat/chat_tab_manager.cpp b/src/ui/chat/chat_tab_manager.cpp new file mode 100644 index 00000000..8c7e85b7 --- /dev/null +++ b/src/ui/chat/chat_tab_manager.cpp @@ -0,0 +1,198 @@ +#include "ui/chat/chat_tab_manager.hpp" +#include "ui/ui_colors.hpp" +#include + +namespace { + using namespace wowee::ui::colors; +} // namespace + +namespace wowee { namespace ui { + +ChatTabManager::ChatTabManager() { + initTabs(); +} + +void ChatTabManager::initTabs() { + tabs_.clear(); + // General tab: shows everything + tabs_.push_back({"General", ~0ULL}); + // Combat tab: system, loot, skills, achievements, and NPC speech/emotes + tabs_.push_back({"Combat", (1ULL << static_cast(game::ChatType::SYSTEM)) | + (1ULL << static_cast(game::ChatType::LOOT)) | + (1ULL << static_cast(game::ChatType::SKILL)) | + (1ULL << static_cast(game::ChatType::ACHIEVEMENT)) | + (1ULL << static_cast(game::ChatType::GUILD_ACHIEVEMENT)) | + (1ULL << static_cast(game::ChatType::MONSTER_SAY)) | + (1ULL << static_cast(game::ChatType::MONSTER_YELL)) | + (1ULL << static_cast(game::ChatType::MONSTER_EMOTE)) | + (1ULL << static_cast(game::ChatType::MONSTER_WHISPER)) | + (1ULL << static_cast(game::ChatType::MONSTER_PARTY)) | + (1ULL << static_cast(game::ChatType::RAID_BOSS_WHISPER)) | + (1ULL << static_cast(game::ChatType::RAID_BOSS_EMOTE))}); + // Whispers tab + tabs_.push_back({"Whispers", (1ULL << static_cast(game::ChatType::WHISPER)) | + (1ULL << static_cast(game::ChatType::WHISPER_INFORM))}); + // Guild tab: guild and officer chat + tabs_.push_back({"Guild", (1ULL << static_cast(game::ChatType::GUILD)) | + (1ULL << static_cast(game::ChatType::OFFICER)) | + (1ULL << static_cast(game::ChatType::GUILD_ACHIEVEMENT))}); + // Trade/LFG tab: channel messages + tabs_.push_back({"Trade/LFG", (1ULL << static_cast(game::ChatType::CHANNEL))}); + + unread_.assign(tabs_.size(), 0); + seenCount_ = 0; +} + +int ChatTabManager::getUnreadCount(int idx) const { + if (idx < 0 || idx >= static_cast(unread_.size())) return 0; + return unread_[idx]; +} + +void ChatTabManager::clearUnread(int idx) { + if (idx >= 0 && idx < static_cast(unread_.size())) + unread_[idx] = 0; +} + +void ChatTabManager::updateUnread(const std::deque& history, int activeTab) { + // Ensure unread array is sized correctly (guards against late init) + if (unread_.size() != tabs_.size()) + unread_.assign(tabs_.size(), 0); + // If history shrank (e.g. cleared), reset + if (seenCount_ > history.size()) seenCount_ = 0; + for (size_t mi = seenCount_; mi < history.size(); ++mi) { + const auto& msg = history[mi]; + // For each non-General (non-0) tab that isn't currently active, check visibility + for (int ti = 1; ti < static_cast(tabs_.size()); ++ti) { + if (ti == activeTab) continue; + if (shouldShowMessage(msg, ti)) { + unread_[ti]++; + } + } + } + seenCount_ = history.size(); +} + +bool ChatTabManager::shouldShowMessage(const game::MessageChatData& msg, int tabIndex) const { + if (tabIndex < 0 || tabIndex >= static_cast(tabs_.size())) return true; + const auto& tab = tabs_[tabIndex]; + if (tab.typeMask == ~0ULL) return true; // General tab shows all + + uint64_t typeBit = 1ULL << static_cast(msg.type); + + // For Trade/LFG tab (index 4), also filter by channel name + if (tabIndex == 4 && msg.type == game::ChatType::CHANNEL) { + const std::string& ch = msg.channelName; + if (ch.find("Trade") == std::string::npos && + ch.find("General") == std::string::npos && + ch.find("LookingForGroup") == std::string::npos && + ch.find("Local") == std::string::npos) { + return false; + } + return true; + } + + return (tab.typeMask & typeBit) != 0; +} + +// ---- Static chat type helpers ---- + +const char* ChatTabManager::getChatTypeName(game::ChatType type) { + switch (type) { + case game::ChatType::SAY: return "Say"; + case game::ChatType::YELL: return "Yell"; + case game::ChatType::EMOTE: return "Emote"; + case game::ChatType::TEXT_EMOTE: return "Emote"; + case game::ChatType::PARTY: return "Party"; + case game::ChatType::GUILD: return "Guild"; + case game::ChatType::OFFICER: return "Officer"; + case game::ChatType::RAID: return "Raid"; + case game::ChatType::RAID_LEADER: return "Raid Leader"; + case game::ChatType::RAID_WARNING: return "Raid Warning"; + case game::ChatType::BATTLEGROUND: return "Battleground"; + case game::ChatType::BATTLEGROUND_LEADER: return "Battleground Leader"; + case game::ChatType::WHISPER: return "Whisper"; + case game::ChatType::WHISPER_INFORM: return "To"; + case game::ChatType::SYSTEM: return "System"; + case game::ChatType::MONSTER_SAY: return "Say"; + case game::ChatType::MONSTER_YELL: return "Yell"; + case game::ChatType::MONSTER_EMOTE: return "Emote"; + case game::ChatType::CHANNEL: return "Channel"; + case game::ChatType::ACHIEVEMENT: return "Achievement"; + case game::ChatType::DND: return "DND"; + case game::ChatType::AFK: return "AFK"; + case game::ChatType::BG_SYSTEM_NEUTRAL: + case game::ChatType::BG_SYSTEM_ALLIANCE: + case game::ChatType::BG_SYSTEM_HORDE: return "System"; + default: return "Unknown"; + } +} + +ImVec4 ChatTabManager::getChatTypeColor(game::ChatType type) { + switch (type) { + case game::ChatType::SAY: + return kWhite; + case game::ChatType::YELL: + return kRed; + case game::ChatType::EMOTE: + case game::ChatType::TEXT_EMOTE: + return ImVec4(1.0f, 0.7f, 0.3f, 1.0f); // Orange + case game::ChatType::PARTY: + return ImVec4(0.5f, 0.5f, 1.0f, 1.0f); // Light blue + case game::ChatType::GUILD: + return kBrightGreen; + case game::ChatType::OFFICER: + return ImVec4(0.3f, 0.8f, 0.3f, 1.0f); // Dark green + case game::ChatType::RAID: + return ImVec4(1.0f, 0.5f, 0.0f, 1.0f); // Orange + case game::ChatType::RAID_LEADER: + return ImVec4(1.0f, 0.4f, 0.0f, 1.0f); // Darker orange + case game::ChatType::RAID_WARNING: + return ImVec4(1.0f, 0.0f, 0.0f, 1.0f); // Red + case game::ChatType::BATTLEGROUND: + return ImVec4(1.0f, 0.6f, 0.0f, 1.0f); // Orange-gold + case game::ChatType::BATTLEGROUND_LEADER: + return ImVec4(1.0f, 0.5f, 0.0f, 1.0f); // Orange + case game::ChatType::WHISPER: + case game::ChatType::WHISPER_INFORM: + return ImVec4(1.0f, 0.5f, 1.0f, 1.0f); // Pink + case game::ChatType::SYSTEM: + return kYellow; + case game::ChatType::MONSTER_SAY: + return kWhite; + case game::ChatType::MONSTER_YELL: + return kRed; + case game::ChatType::MONSTER_EMOTE: + return ImVec4(1.0f, 0.7f, 0.3f, 1.0f); // Orange + case game::ChatType::CHANNEL: + return ImVec4(1.0f, 0.7f, 0.7f, 1.0f); // Light pink + case game::ChatType::ACHIEVEMENT: + return ImVec4(1.0f, 1.0f, 0.0f, 1.0f); // Bright yellow + case game::ChatType::GUILD_ACHIEVEMENT: + return kWarmGold; + case game::ChatType::SKILL: + return kCyan; + case game::ChatType::LOOT: + return ImVec4(0.8f, 0.5f, 1.0f, 1.0f); // Light purple + case game::ChatType::MONSTER_WHISPER: + case game::ChatType::RAID_BOSS_WHISPER: + return ImVec4(1.0f, 0.5f, 1.0f, 1.0f); // Pink + case game::ChatType::RAID_BOSS_EMOTE: + return ImVec4(1.0f, 0.7f, 0.3f, 1.0f); // Orange + case game::ChatType::MONSTER_PARTY: + return ImVec4(0.5f, 0.5f, 1.0f, 1.0f); // Light blue + case game::ChatType::BG_SYSTEM_NEUTRAL: + return kWarmGold; + case game::ChatType::BG_SYSTEM_ALLIANCE: + return ImVec4(0.3f, 0.6f, 1.0f, 1.0f); // Blue + case game::ChatType::BG_SYSTEM_HORDE: + return kRed; + case game::ChatType::AFK: + case game::ChatType::DND: + return ImVec4(0.85f, 0.85f, 0.85f, 0.8f); // Light gray + default: + return kLightGray; + } +} + +} // namespace ui +} // namespace wowee diff --git a/src/ui/chat/commands/channel_commands.cpp b/src/ui/chat/commands/channel_commands.cpp new file mode 100644 index 00000000..76275ef8 --- /dev/null +++ b/src/ui/chat/commands/channel_commands.cpp @@ -0,0 +1,341 @@ +// Channel commands: /s, /y, /p, /g, /raid, /rw, /o, /bg, /i, /join, /leave, /wts, /wtb, /1-9, /w, /r +// Moved from ChatPanel::sendChatMessage() channel dispatch section (Phase 3). +// These commands send messages to specific chat channels and/or switch the +// chat-type dropdown on the panel. +#include "ui/chat/i_chat_command.hpp" +#include "ui/chat_panel.hpp" +#include "ui/chat/chat_utils.hpp" +#include "game/game_handler.hpp" +#include +#include + +using wowee::ui::chat_utils::trim; +using wowee::ui::chat_utils::toLower; + +namespace { + +bool isPortBotTarget(const std::string& target) { + std::string t = toLower(trim(target)); + return t == "portbot" || t == "gmbot" || t == "telebot"; +} + +std::string buildPortBotCommand(const std::string& rawInput) { + std::string input = trim(rawInput); + if (input.empty()) return ""; + std::string lower = toLower(input); + if (lower == "help" || lower == "?") return "__help__"; + if (lower.rfind(".tele ", 0) == 0 || lower.rfind(".go ", 0) == 0) return input; + if (lower.rfind("xyz ", 0) == 0) return ".go " + input; + if (lower == "sw" || lower == "stormwind") return ".tele stormwind"; + if (lower == "if" || lower == "ironforge") return ".tele ironforge"; + if (lower == "darn" || lower == "darnassus") return ".tele darnassus"; + if (lower == "org" || lower == "orgrimmar") return ".tele orgrimmar"; + if (lower == "tb" || lower == "thunderbluff") return ".tele thunderbluff"; + if (lower == "uc" || lower == "undercity") return ".tele undercity"; + if (lower == "shatt" || lower == "shattrath") return ".tele shattrath"; + if (lower == "dal" || lower == "dalaran") return ".tele dalaran"; + return ".tele " + input; +} + +// Send a whisper, intercepting PortBot targets for GM teleport commands. +// Returns true if the whisper was handled (PortBot or normal send), false if empty. +bool sendWhisperOrPortBot(wowee::game::GameHandler& gameHandler, + const std::string& target, + const std::string& message) { + if (isPortBotTarget(target)) { + std::string cmd = buildPortBotCommand(message); + wowee::game::MessageChatData msg; + msg.type = wowee::game::ChatType::SYSTEM; + msg.language = wowee::game::ChatLanguage::UNIVERSAL; + if (cmd.empty() || cmd == "__help__") { + msg.message = "PortBot: /w PortBot . Aliases: sw if darn org tb uc shatt dal. Also supports '.tele ...' or 'xyz x y z [map [o]]'."; + gameHandler.addLocalChatMessage(msg); + return true; + } + gameHandler.sendChatMessage(wowee::game::ChatType::SAY, cmd, ""); + msg.message = "PortBot executed: " + cmd; + gameHandler.addLocalChatMessage(msg); + return true; + } + if (!message.empty()) { + gameHandler.sendChatMessage(wowee::game::ChatType::WHISPER, message, target); + } + return true; +} + +} // anonymous namespace + +namespace wowee { namespace ui { + +// --- Helper: send a message via a specific chat type + optionally switch dropdown --- +static ChatCommandResult sendAndSwitch(ChatCommandContext& ctx, + game::ChatType chatType, + int switchIdx, + const std::string& target = "") { + if (!ctx.args.empty()) + ctx.gameHandler.sendChatMessage(chatType, ctx.args, target); + ctx.panel.setSelectedChatType(switchIdx); + return {}; +} + +// --- /s, /say --- +class SayCommand : public IChatCommand { +public: + ChatCommandResult execute(ChatCommandContext& ctx) override { + return sendAndSwitch(ctx, game::ChatType::SAY, 0); + } + std::vector aliases() const override { return {"s", "say"}; } + std::string helpText() const override { return "Say to nearby players"; } +}; + +// --- /y, /yell, /shout --- +class YellCommand : public IChatCommand { +public: + ChatCommandResult execute(ChatCommandContext& ctx) override { + return sendAndSwitch(ctx, game::ChatType::YELL, 1); + } + std::vector aliases() const override { return {"y", "yell", "shout"}; } + std::string helpText() const override { return "Yell to a wider area"; } +}; + +// --- /p, /party --- +class PartyCommand : public IChatCommand { +public: + ChatCommandResult execute(ChatCommandContext& ctx) override { + return sendAndSwitch(ctx, game::ChatType::PARTY, 2); + } + std::vector aliases() const override { return {"p", "party"}; } + std::string helpText() const override { return "Party chat"; } +}; + +// --- /g, /guild --- +class GuildChatCommand : public IChatCommand { +public: + ChatCommandResult execute(ChatCommandContext& ctx) override { + return sendAndSwitch(ctx, game::ChatType::GUILD, 3); + } + std::vector aliases() const override { return {"g", "guild"}; } + std::string helpText() const override { return "Guild chat"; } +}; + +// --- /raid, /rsay, /ra --- +class RaidChatCommand : public IChatCommand { +public: + ChatCommandResult execute(ChatCommandContext& ctx) override { + return sendAndSwitch(ctx, game::ChatType::RAID, 5); + } + std::vector aliases() const override { return {"raid", "rsay", "ra"}; } + std::string helpText() const override { return "Raid chat"; } +}; + +// --- /raidwarning, /rw --- +class RaidWarningCommand : public IChatCommand { +public: + ChatCommandResult execute(ChatCommandContext& ctx) override { + return sendAndSwitch(ctx, game::ChatType::RAID_WARNING, 8); + } + std::vector aliases() const override { return {"raidwarning", "rw"}; } + std::string helpText() const override { return "Raid warning"; } +}; + +// --- /officer, /o, /osay --- +class OfficerCommand : public IChatCommand { +public: + ChatCommandResult execute(ChatCommandContext& ctx) override { + return sendAndSwitch(ctx, game::ChatType::OFFICER, 6); + } + std::vector aliases() const override { return {"officer", "o", "osay"}; } + std::string helpText() const override { return "Guild officer chat"; } +}; + +// --- /battleground, /bg --- +class BattlegroundChatCommand : public IChatCommand { +public: + ChatCommandResult execute(ChatCommandContext& ctx) override { + return sendAndSwitch(ctx, game::ChatType::BATTLEGROUND, 7); + } + std::vector aliases() const override { return {"battleground", "bg"}; } + std::string helpText() const override { return "Battleground chat"; } +}; + +// --- /instance, /i --- +class InstanceChatCommand : public IChatCommand { +public: + ChatCommandResult execute(ChatCommandContext& ctx) override { + return sendAndSwitch(ctx, game::ChatType::PARTY, 9); + } + std::vector aliases() const override { return {"instance", "i"}; } + std::string helpText() const override { return "Instance chat"; } +}; + +// --- /join --- +class JoinCommand : public IChatCommand { +public: + ChatCommandResult execute(ChatCommandContext& ctx) override { + if (ctx.args.empty() && ctx.gameHandler.hasPendingBgInvite()) { + ctx.gameHandler.acceptBattlefield(); + return {}; + } + if (!ctx.args.empty()) { + size_t pwStart = ctx.args.find(' '); + std::string channelName = (pwStart != std::string::npos) ? ctx.args.substr(0, pwStart) : ctx.args; + std::string password = (pwStart != std::string::npos) ? ctx.args.substr(pwStart + 1) : ""; + ctx.gameHandler.joinChannel(channelName, password); + } + return {}; + } + std::vector aliases() const override { return {"join"}; } + std::string helpText() const override { return "Join a chat channel"; } +}; + +// --- /leave (channel) --- +// Note: /leave without args is handled by group_commands (leave party). +// This command only triggers with args (channel name). +class LeaveChannelCommand : public IChatCommand { +public: + ChatCommandResult execute(ChatCommandContext& ctx) override { + if (!ctx.args.empty()) { + ctx.gameHandler.leaveChannel(ctx.args); + } + // If no args, the group LeaveCommand will handle /leave (leave party) + // so we return not-handled to allow fallthrough + if (ctx.args.empty()) return {false, false}; + return {}; + } + std::vector aliases() const override { return {"leavechannel"}; } + std::string helpText() const override { return "Leave a chat channel"; } +}; + +// --- /wts, /wtb --- +class TradeChannelCommand : public IChatCommand { +public: + ChatCommandResult execute(ChatCommandContext& ctx) override { + if (ctx.args.empty()) return {false, false}; + const std::string tag = (ctx.fullCommand == "wts") ? "[WTS] " : "[WTB] "; + std::string tradeChan; + for (const auto& ch : ctx.gameHandler.getJoinedChannels()) { + std::string chLow = ch; + for (char& c : chLow) c = static_cast(std::tolower(static_cast(c))); + if (chLow.rfind("trade", 0) == 0) { tradeChan = ch; break; } + } + if (tradeChan.empty()) { + game::MessageChatData errMsg; + errMsg.type = game::ChatType::SYSTEM; + errMsg.language = game::ChatLanguage::UNIVERSAL; + errMsg.message = "You are not in the Trade channel."; + ctx.gameHandler.addLocalChatMessage(errMsg); + return {}; + } + ctx.gameHandler.sendChatMessage(game::ChatType::CHANNEL, tag + ctx.args, tradeChan); + return {}; + } + std::vector aliases() const override { return {"wts", "wtb"}; } + std::string helpText() const override { return "Send to Trade channel ([WTS]/[WTB] prefix)"; } +}; + +// --- /1 through /9 — channel shortcuts --- +class ChannelNumberCommand : public IChatCommand { +public: + explicit ChannelNumberCommand(int num) : num_(num), alias_(std::to_string(num)) {} + ChatCommandResult execute(ChatCommandContext& ctx) override { + std::string channelName = ctx.gameHandler.getChannelByIndex(num_); + if (channelName.empty()) { + game::MessageChatData errMsg; + errMsg.type = game::ChatType::SYSTEM; + errMsg.message = "You are not in channel " + std::to_string(num_) + "."; + ctx.gameHandler.addLocalChatMessage(errMsg); + return {}; + } + if (!ctx.args.empty()) { + ctx.gameHandler.sendChatMessage(game::ChatType::CHANNEL, ctx.args, channelName); + } + return {}; + } + std::vector aliases() const override { return {alias_}; } + std::string helpText() const override { return "Send to channel " + alias_; } +private: + int num_; + std::string alias_; +}; + +// --- /w, /whisper, /tell, /t --- +class WhisperCommand : public IChatCommand { +public: + ChatCommandResult execute(ChatCommandContext& ctx) override { + ctx.panel.setSelectedChatType(4); // Switch to whisper mode + if (!ctx.args.empty()) { + size_t msgStart = ctx.args.find(' '); + if (msgStart != std::string::npos) { + // /w PlayerName message — send whisper immediately (PortBot-aware) + std::string target = ctx.args.substr(0, msgStart); + std::string message = ctx.args.substr(msgStart + 1); + sendWhisperOrPortBot(ctx.gameHandler, target, message); + // Set whisper target for future messages + char* buf = ctx.panel.getWhisperTargetBuffer(); + size_t sz = ctx.panel.getWhisperTargetBufferSize(); + strncpy(buf, target.c_str(), sz - 1); + buf[sz - 1] = '\0'; + } else { + // /w PlayerName — switch to whisper mode with target set + char* buf = ctx.panel.getWhisperTargetBuffer(); + size_t sz = ctx.panel.getWhisperTargetBufferSize(); + strncpy(buf, ctx.args.c_str(), sz - 1); + buf[sz - 1] = '\0'; + } + } + return {}; + } + std::vector aliases() const override { return {"w", "whisper", "tell", "t"}; } + std::string helpText() const override { return "Whisper to a player"; } +}; + +// --- /r, /reply --- +class ReplyCommand : public IChatCommand { +public: + ChatCommandResult execute(ChatCommandContext& ctx) override { + ctx.panel.setSelectedChatType(4); + std::string lastSender = ctx.gameHandler.getLastWhisperSender(); + if (lastSender.empty()) { + game::MessageChatData sysMsg; + sysMsg.type = game::ChatType::SYSTEM; + sysMsg.language = game::ChatLanguage::UNIVERSAL; + sysMsg.message = "No one has whispered you yet."; + ctx.gameHandler.addLocalChatMessage(sysMsg); + return {}; + } + char* buf = ctx.panel.getWhisperTargetBuffer(); + size_t sz = ctx.panel.getWhisperTargetBufferSize(); + strncpy(buf, lastSender.c_str(), sz - 1); + buf[sz - 1] = '\0'; + if (!ctx.args.empty()) { + // PortBot-aware whisper send + sendWhisperOrPortBot(ctx.gameHandler, lastSender, ctx.args); + } + return {}; + } + std::vector aliases() const override { return {"r", "reply"}; } + std::string helpText() const override { return "Reply to last whisper"; } +}; + +// --- Registration --- +void registerChannelCommands(ChatCommandRegistry& reg) { + reg.registerCommand(std::make_unique()); + reg.registerCommand(std::make_unique()); + reg.registerCommand(std::make_unique()); + reg.registerCommand(std::make_unique()); + reg.registerCommand(std::make_unique()); + reg.registerCommand(std::make_unique()); + reg.registerCommand(std::make_unique()); + reg.registerCommand(std::make_unique()); + reg.registerCommand(std::make_unique()); + reg.registerCommand(std::make_unique()); + reg.registerCommand(std::make_unique()); + reg.registerCommand(std::make_unique()); + for (int n = 1; n <= 9; ++n) + reg.registerCommand(std::make_unique(n)); + reg.registerCommand(std::make_unique()); + reg.registerCommand(std::make_unique()); +} + +} // namespace ui +} // namespace wowee diff --git a/src/ui/chat/commands/combat_commands.cpp b/src/ui/chat/commands/combat_commands.cpp new file mode 100644 index 00000000..01060e7b --- /dev/null +++ b/src/ui/chat/commands/combat_commands.cpp @@ -0,0 +1,543 @@ +// Combat commands: /cast, /castsequence, /use, /equip, /equipset, +// /startattack, /stopattack, /stopcasting, /cancelqueuedspell +// Moved from ChatPanel::sendChatMessage() if/else chain (Phase 3). +#include "ui/chat/i_chat_command.hpp" +#include "ui/chat_panel.hpp" +#include "game/game_handler.hpp" +#include "game/inventory.hpp" +#include +#include +#include +#include + +namespace wowee { namespace ui { + +// Forward declaration of evaluateMacroConditionals (still in chat_panel_commands.cpp) +std::string evaluateMacroConditionals(const std::string& rawArg, + game::GameHandler& gameHandler, + uint64_t& targetOverride); + +// --------------- helpers (local to this TU) --------------- +namespace { + +// Trim leading/trailing whitespace from a string in-place. +inline void trimInPlace(std::string& s) { + while (!s.empty() && s.front() == ' ') s.erase(s.begin()); + while (!s.empty() && s.back() == ' ') s.pop_back(); +} + +// Try to parse a spell by name among known spells, optionally with a specific +// rank. Returns the best matching spell ID, or 0 if none found. +uint32_t resolveSpellByName(game::GameHandler& gh, const std::string& spellArg, int requestedRank = -1) { + std::string spellName = spellArg; + // Parse optional "(Rank N)" suffix + { + auto rankPos = spellArg.find('('); + if (rankPos != std::string::npos) { + std::string rankStr = spellArg.substr(rankPos + 1); + auto closePos = rankStr.find(')'); + if (closePos != std::string::npos) rankStr = rankStr.substr(0, closePos); + for (char& c : rankStr) c = static_cast(std::tolower(static_cast(c))); + if (rankStr.rfind("rank ", 0) == 0) { + try { requestedRank = std::stoi(rankStr.substr(5)); } catch (...) {} + } + spellName = spellArg.substr(0, rankPos); + while (!spellName.empty() && spellName.back() == ' ') spellName.pop_back(); + } + } + + std::string spellNameLower = spellName; + for (char& c : spellNameLower) c = static_cast(std::tolower(static_cast(c))); + + uint32_t bestId = 0; + int bestRank = -1; + for (uint32_t sid : gh.getKnownSpells()) { + const std::string& sName = gh.getSpellName(sid); + if (sName.empty()) continue; + std::string sLow = sName; + for (char& c : sLow) c = static_cast(std::tolower(static_cast(c))); + if (sLow != spellNameLower) continue; + + int sRank = 0; + const std::string& rk = gh.getSpellRank(sid); + if (!rk.empty()) { + std::string rLow = rk; + for (char& c : rLow) c = static_cast(std::tolower(static_cast(c))); + if (rLow.rfind("rank ", 0) == 0) { + try { sRank = std::stoi(rLow.substr(5)); } catch (...) {} + } + } + if (requestedRank >= 0) { + if (sRank == requestedRank) return sid; + } else { + if (sRank > bestRank) { bestRank = sRank; bestId = sid; } + } + } + return bestId; +} + +// Try to parse numeric spell ID (with optional '#' prefix). Returns 0 if not numeric. +uint32_t parseNumericSpellId(const std::string& str) { + std::string numStr = str; + if (!numStr.empty() && numStr.front() == '#') numStr.erase(numStr.begin()); + bool isNumeric = !numStr.empty() && + std::all_of(numStr.begin(), numStr.end(), + [](unsigned char c){ return std::isdigit(c); }); + if (!isNumeric) return 0; + try { return static_cast(std::stoul(numStr)); } catch (...) { return 0; } +} + +} // anon namespace + +// --- /cast --- +class CastCommand : public IChatCommand { +public: + ChatCommandResult execute(ChatCommandContext& ctx) override { + if (ctx.args.empty()) return {false, false}; + + std::string spellArg = ctx.args; + trimInPlace(spellArg); + + // Evaluate WoW macro conditionals + uint64_t castTargetOverride = static_cast(-1); + if (!spellArg.empty() && spellArg.front() == '[') { + spellArg = evaluateMacroConditionals(spellArg, ctx.gameHandler, castTargetOverride); + if (spellArg.empty()) return {}; // no conditional matched + trimInPlace(spellArg); + } + + // Strip leading '!' (force recast) + if (!spellArg.empty() && spellArg.front() == '!') spellArg.erase(spellArg.begin()); + + // Numeric spell ID + uint32_t numId = parseNumericSpellId(spellArg); + if (numId) { + uint64_t targetGuid = (castTargetOverride != static_cast(-1)) + ? castTargetOverride + : (ctx.gameHandler.hasTarget() ? ctx.gameHandler.getTargetGuid() : 0); + ctx.gameHandler.castSpell(numId, targetGuid); + return {}; + } + + // Name-based lookup + uint32_t bestSpellId = resolveSpellByName(ctx.gameHandler, spellArg); + if (bestSpellId) { + uint64_t targetGuid = (castTargetOverride != static_cast(-1)) + ? castTargetOverride + : (ctx.gameHandler.hasTarget() ? ctx.gameHandler.getTargetGuid() : 0); + ctx.gameHandler.castSpell(bestSpellId, targetGuid); + } else { + // Build error message + std::string spellName = spellArg; + auto rp = spellArg.find('('); + if (rp != std::string::npos) { + spellName = spellArg.substr(0, rp); + while (!spellName.empty() && spellName.back() == ' ') spellName.pop_back(); + } + // Check if a specific rank was requested for the error message + int reqRank = -1; + if (rp != std::string::npos) { + std::string rs = spellArg.substr(rp + 1); + auto cp = rs.find(')'); + if (cp != std::string::npos) rs = rs.substr(0, cp); + for (char& c : rs) c = static_cast(std::tolower(static_cast(c))); + if (rs.rfind("rank ", 0) == 0) { + try { reqRank = std::stoi(rs.substr(5)); } catch (...) {} + } + } + game::MessageChatData sysMsg; + sysMsg.type = game::ChatType::SYSTEM; + sysMsg.language = game::ChatLanguage::UNIVERSAL; + sysMsg.message = (reqRank >= 0) + ? "You don't know '" + spellName + "' (Rank " + std::to_string(reqRank) + ")." + : "Unknown spell: '" + spellName + "'."; + ctx.gameHandler.addLocalChatMessage(sysMsg); + } + return {}; + } + std::vector aliases() const override { return {"cast"}; } + std::string helpText() const override { return "Cast a spell by name or ID"; } +}; + +// --- /castsequence --- +class CastSequenceCommand : public IChatCommand { +public: + ChatCommandResult execute(ChatCommandContext& ctx) override { + if (ctx.args.empty()) return {false, false}; + + std::string seqArg = ctx.args; + trimInPlace(seqArg); + + // Macro conditionals + uint64_t seqTgtOver = static_cast(-1); + if (!seqArg.empty() && seqArg.front() == '[') { + seqArg = evaluateMacroConditionals(seqArg, ctx.gameHandler, seqTgtOver); + if (seqArg.empty() && seqTgtOver == static_cast(-1)) return {}; + trimInPlace(seqArg); + } + + // Optional reset= spec + std::string resetSpec; + if (seqArg.rfind("reset=", 0) == 0) { + size_t spAfter = seqArg.find(' '); + if (spAfter != std::string::npos) { + resetSpec = seqArg.substr(6, spAfter - 6); + seqArg = seqArg.substr(spAfter + 1); + trimInPlace(seqArg); + } + } + + // Parse comma-separated spell list + std::vector seqSpells; + { + std::string cur; + for (char c : seqArg) { + if (c == ',') { + trimInPlace(cur); + if (!cur.empty()) seqSpells.push_back(cur); + cur.clear(); + } else { cur += c; } + } + trimInPlace(cur); + if (!cur.empty()) seqSpells.push_back(cur); + } + if (seqSpells.empty()) return {}; + + // Build stable key from lowercase spell list + std::string seqKey; + for (size_t k = 0; k < seqSpells.size(); ++k) { + if (k) seqKey += ','; + std::string sl = seqSpells[k]; + for (char& c : sl) c = static_cast(std::tolower(static_cast(c))); + seqKey += sl; + } + + auto& seqState = ctx.panel.getCastSeqTracker().get(seqKey); + + // Check reset conditions + float nowSec = static_cast(ImGui::GetTime()); + bool shouldReset = false; + if (!resetSpec.empty()) { + size_t rpos = 0; + while (rpos <= resetSpec.size()) { + size_t slash = resetSpec.find('/', rpos); + std::string part = (slash != std::string::npos) + ? resetSpec.substr(rpos, slash - rpos) + : resetSpec.substr(rpos); + std::string plow = part; + for (char& c : plow) c = static_cast(std::tolower(static_cast(c))); + bool isNum = !plow.empty() && std::all_of(plow.begin(), plow.end(), + [](unsigned char c){ return std::isdigit(c) || c == '.'; }); + if (isNum) { + float rSec = 0.0f; + try { rSec = std::stof(plow); } catch (...) {} + if (rSec > 0.0f && nowSec - seqState.lastPressSec > rSec) shouldReset = true; + } else if (plow == "target") { + if (ctx.gameHandler.getTargetGuid() != seqState.lastTargetGuid) shouldReset = true; + } else if (plow == "combat") { + if (ctx.gameHandler.isInCombat() != seqState.lastInCombat) shouldReset = true; + } + if (slash == std::string::npos) break; + rpos = slash + 1; + } + } + if (shouldReset || seqState.index >= seqSpells.size()) seqState.index = 0; + + const std::string& seqSpell = seqSpells[seqState.index]; + seqState.index = (seqState.index + 1) % seqSpells.size(); + seqState.lastPressSec = nowSec; + seqState.lastTargetGuid = ctx.gameHandler.getTargetGuid(); + seqState.lastInCombat = ctx.gameHandler.isInCombat(); + + // Cast the selected spell + std::string ssLow = seqSpell; + for (char& c : ssLow) c = static_cast(std::tolower(static_cast(c))); + if (!ssLow.empty() && ssLow.front() == '!') ssLow.erase(ssLow.begin()); + + uint64_t seqTargetGuid = (seqTgtOver != static_cast(-1) && seqTgtOver != 0) + ? seqTgtOver : (ctx.gameHandler.hasTarget() ? ctx.gameHandler.getTargetGuid() : 0); + + uint32_t numId = parseNumericSpellId(ssLow); + if (numId) { + ctx.gameHandler.castSpell(numId, seqTargetGuid); + } else { + uint32_t best = resolveSpellByName(ctx.gameHandler, ssLow); + if (best) ctx.gameHandler.castSpell(best, seqTargetGuid); + } + return {}; + } + std::vector aliases() const override { return {"castsequence"}; } + std::string helpText() const override { return "Cycle through a spell sequence"; } +}; + +// --- /use --- +class UseCommand : public IChatCommand { +public: + ChatCommandResult execute(ChatCommandContext& ctx) override { + if (ctx.args.empty()) return {false, false}; + + std::string useArg = ctx.args; + trimInPlace(useArg); + + // Handle macro conditionals + if (!useArg.empty() && useArg.front() == '[') { + uint64_t dummy = static_cast(-1); + useArg = evaluateMacroConditionals(useArg, ctx.gameHandler, dummy); + if (useArg.empty()) return {}; + trimInPlace(useArg); + } + + // Check for bag/slot notation: two numbers separated by whitespace + { + std::istringstream iss(useArg); + int bagNum = -1, slotNum = -1; + iss >> bagNum >> slotNum; + if (!iss.fail() && slotNum >= 1) { + if (bagNum == 0) { + ctx.gameHandler.useItemBySlot(slotNum - 1); + return {}; + } else if (bagNum >= 1 && bagNum <= game::Inventory::NUM_BAG_SLOTS) { + ctx.gameHandler.useItemInBag(bagNum - 1, slotNum - 1); + return {}; + } + } + } + + // Numeric equip slot + { + uint32_t numId = parseNumericSpellId(useArg); // Reuse: strips '#', checks all digits + if (numId && numId >= 1 && numId <= static_cast(game::EquipSlot::BAG4) + 1) { + auto eslot = static_cast(numId - 1); + const auto& esl = ctx.gameHandler.getInventory().getEquipSlot(eslot); + if (!esl.empty()) + ctx.gameHandler.useItemById(esl.item.itemId); + return {}; + } + } + + // Name-based search + std::string useArgLower = useArg; + for (char& c : useArgLower) c = static_cast(std::tolower(static_cast(c))); + + bool found = false; + const auto& inv = ctx.gameHandler.getInventory(); + // Search backpack + for (int s = 0; s < inv.getBackpackSize() && !found; ++s) { + const auto& slot = inv.getBackpackSlot(s); + if (slot.empty()) continue; + const auto* info = ctx.gameHandler.getItemInfo(slot.item.itemId); + if (!info) continue; + std::string nameLow = info->name; + for (char& c : nameLow) c = static_cast(std::tolower(static_cast(c))); + if (nameLow == useArgLower) { + ctx.gameHandler.useItemBySlot(s); + found = true; + } + } + // Search bags + for (int b = 0; b < game::Inventory::NUM_BAG_SLOTS && !found; ++b) { + for (int s = 0; s < inv.getBagSize(b) && !found; ++s) { + const auto& slot = inv.getBagSlot(b, s); + if (slot.empty()) continue; + const auto* info = ctx.gameHandler.getItemInfo(slot.item.itemId); + if (!info) continue; + std::string nameLow = info->name; + for (char& c : nameLow) c = static_cast(std::tolower(static_cast(c))); + if (nameLow == useArgLower) { + ctx.gameHandler.useItemInBag(b, s); + found = true; + } + } + } + if (!found) { + game::MessageChatData sysMsg; + sysMsg.type = game::ChatType::SYSTEM; + sysMsg.language = game::ChatLanguage::UNIVERSAL; + sysMsg.message = "Item not found: '" + useArg + "'."; + ctx.gameHandler.addLocalChatMessage(sysMsg); + } + return {}; + } + std::vector aliases() const override { return {"use"}; } + std::string helpText() const override { return "Use an item by name, ID, or bag/slot"; } +}; + +// --- /equip --- +class EquipCommand : public IChatCommand { +public: + ChatCommandResult execute(ChatCommandContext& ctx) override { + if (ctx.args.empty()) return {false, false}; + + std::string equipArg = ctx.args; + trimInPlace(equipArg); + std::string equipArgLower = equipArg; + for (char& c : equipArgLower) c = static_cast(std::tolower(static_cast(c))); + + bool found = false; + const auto& inv = ctx.gameHandler.getInventory(); + for (int s = 0; s < inv.getBackpackSize() && !found; ++s) { + const auto& slot = inv.getBackpackSlot(s); + if (slot.empty()) continue; + const auto* info = ctx.gameHandler.getItemInfo(slot.item.itemId); + if (!info) continue; + std::string nameLow = info->name; + for (char& c : nameLow) c = static_cast(std::tolower(static_cast(c))); + if (nameLow == equipArgLower) { + ctx.gameHandler.autoEquipItemBySlot(s); + found = true; + } + } + for (int b = 0; b < game::Inventory::NUM_BAG_SLOTS && !found; ++b) { + for (int s = 0; s < inv.getBagSize(b) && !found; ++s) { + const auto& slot = inv.getBagSlot(b, s); + if (slot.empty()) continue; + const auto* info = ctx.gameHandler.getItemInfo(slot.item.itemId); + if (!info) continue; + std::string nameLow = info->name; + for (char& c : nameLow) c = static_cast(std::tolower(static_cast(c))); + if (nameLow == equipArgLower) { + ctx.gameHandler.autoEquipItemInBag(b, s); + found = true; + } + } + } + if (!found) { + game::MessageChatData sysMsg; + sysMsg.type = game::ChatType::SYSTEM; + sysMsg.language = game::ChatLanguage::UNIVERSAL; + sysMsg.message = "Item not found: '" + equipArg + "'."; + ctx.gameHandler.addLocalChatMessage(sysMsg); + } + return {}; + } + std::vector aliases() const override { return {"equip"}; } + std::string helpText() const override { return "Auto-equip an item from inventory"; } +}; + +// --- /equipset --- +class EquipSetCommand : public IChatCommand { +public: + ChatCommandResult execute(ChatCommandContext& ctx) override { + const auto& sets = ctx.gameHandler.getEquipmentSets(); + auto sysSay = [&](const std::string& msg) { + game::MessageChatData m; + m.type = game::ChatType::SYSTEM; + m.language = game::ChatLanguage::UNIVERSAL; + m.message = msg; + ctx.gameHandler.addLocalChatMessage(m); + }; + if (ctx.args.empty()) { + if (sets.empty()) { + sysSay("[System] No equipment sets saved."); + } else { + sysSay("[System] Equipment sets:"); + for (const auto& es : sets) + sysSay(" " + es.name); + } + return {}; + } + std::string setName = ctx.args; + trimInPlace(setName); + std::string setLower = setName; + std::transform(setLower.begin(), setLower.end(), setLower.begin(), ::tolower); + const game::GameHandler::EquipmentSetInfo* found = nullptr; + for (const auto& es : sets) { + std::string nameLow = es.name; + std::transform(nameLow.begin(), nameLow.end(), nameLow.begin(), ::tolower); + if (nameLow == setLower || nameLow.find(setLower) == 0) { + found = &es; + break; + } + } + if (found) { + ctx.gameHandler.useEquipmentSet(found->setId); + } else { + sysSay("[System] No equipment set matching '" + setName + "'."); + } + return {}; + } + std::vector aliases() const override { return {"equipset"}; } + std::string helpText() const override { return "Equip a saved equipment set"; } +}; + +// --- /startattack --- +class StartAttackCommand : public IChatCommand { +public: + ChatCommandResult execute(ChatCommandContext& ctx) override { + bool condPass = true; + uint64_t saOverride = static_cast(-1); + if (!ctx.args.empty()) { + std::string saArg = ctx.args; + trimInPlace(saArg); + if (!saArg.empty() && saArg.front() == '[') { + std::string result = evaluateMacroConditionals(saArg, ctx.gameHandler, saOverride); + condPass = !(result.empty() && saOverride == static_cast(-1)); + } + } + if (condPass) { + uint64_t atkTarget = (saOverride != static_cast(-1) && saOverride != 0) + ? saOverride : (ctx.gameHandler.hasTarget() ? ctx.gameHandler.getTargetGuid() : 0); + if (atkTarget != 0) { + ctx.gameHandler.startAutoAttack(atkTarget); + } else { + game::MessageChatData msg; + msg.type = game::ChatType::SYSTEM; + msg.language = game::ChatLanguage::UNIVERSAL; + msg.message = "You have no target."; + ctx.gameHandler.addLocalChatMessage(msg); + } + } + return {}; + } + std::vector aliases() const override { return {"startattack"}; } + std::string helpText() const override { return "Start auto-attack"; } +}; + +// --- /stopattack --- +class StopAttackCommand : public IChatCommand { +public: + ChatCommandResult execute(ChatCommandContext& ctx) override { + ctx.gameHandler.stopAutoAttack(); + return {}; + } + std::vector aliases() const override { return {"stopattack"}; } + std::string helpText() const override { return "Stop auto-attack"; } +}; + +// --- /stopcasting --- +class StopCastingCommand : public IChatCommand { +public: + ChatCommandResult execute(ChatCommandContext& ctx) override { + ctx.gameHandler.stopCasting(); + return {}; + } + std::vector aliases() const override { return {"stopcasting"}; } + std::string helpText() const override { return "Stop current cast"; } +}; + +// --- /cancelqueuedspell, /stopspellqueue --- +class CancelQueuedSpellCommand : public IChatCommand { +public: + ChatCommandResult execute(ChatCommandContext& ctx) override { + ctx.gameHandler.cancelQueuedSpell(); + return {}; + } + std::vector aliases() const override { return {"cancelqueuedspell", "stopspellqueue"}; } + std::string helpText() const override { return "Cancel queued spell"; } +}; + +// --- Registration --- +void registerCombatCommands(ChatCommandRegistry& reg) { + reg.registerCommand(std::make_unique()); + reg.registerCommand(std::make_unique()); + reg.registerCommand(std::make_unique()); + reg.registerCommand(std::make_unique()); + reg.registerCommand(std::make_unique()); + reg.registerCommand(std::make_unique()); + reg.registerCommand(std::make_unique()); + reg.registerCommand(std::make_unique()); + reg.registerCommand(std::make_unique()); +} + +} // namespace ui +} // namespace wowee diff --git a/src/ui/chat/commands/emote_commands.cpp b/src/ui/chat/commands/emote_commands.cpp new file mode 100644 index 00000000..b672a4db --- /dev/null +++ b/src/ui/chat/commands/emote_commands.cpp @@ -0,0 +1,230 @@ +// Emote/stance commands: /sit, /stand, /kneel, /dismount, /cancelform, +// /cancelaura, /cancellogout, /logout, /camp, /quit, /exit, +// pet commands (/petattack, /petfollow, etc.) +// Moved from ChatPanel::sendChatMessage() if/else chain (Phase 3). +#include "ui/chat/i_chat_command.hpp" +#include "ui/chat_panel.hpp" +#include "game/game_handler.hpp" +#include "rendering/renderer.hpp" +#include "rendering/animation_controller.hpp" +#include +#include + +namespace wowee { namespace ui { + +// --- /sit --- +class SitCommand : public IChatCommand { +public: + ChatCommandResult execute(ChatCommandContext& ctx) override { + ctx.gameHandler.setStandState(1); + return {}; + } + std::vector aliases() const override { return {"sit"}; } + std::string helpText() const override { return "Sit down"; } +}; + +// --- /stand --- +class StandCommand : public IChatCommand { +public: + ChatCommandResult execute(ChatCommandContext& ctx) override { + ctx.gameHandler.setStandState(0); + return {}; + } + std::vector aliases() const override { return {"stand"}; } + std::string helpText() const override { return "Stand up"; } +}; + +// --- /kneel --- +class KneelCommand : public IChatCommand { +public: + ChatCommandResult execute(ChatCommandContext& ctx) override { + ctx.gameHandler.setStandState(8); + return {}; + } + std::vector aliases() const override { return {"kneel"}; } + std::string helpText() const override { return "Kneel"; } +}; + +// --- /logout, /camp, /quit, /exit --- +class LogoutEmoteCommand : public IChatCommand { +public: + ChatCommandResult execute(ChatCommandContext& ctx) override { + ctx.gameHandler.requestLogout(); + return {}; + } + std::vector aliases() const override { return {"camp", "quit", "exit"}; } + std::string helpText() const override { return "Logout / quit game"; } +}; + +// --- /cancellogout --- +class CancelLogoutCommand : public IChatCommand { +public: + ChatCommandResult execute(ChatCommandContext& ctx) override { + ctx.gameHandler.cancelLogout(); + return {}; + } + std::vector aliases() const override { return {"cancellogout"}; } + std::string helpText() const override { return "Cancel pending logout"; } +}; + +// --- /dismount --- +class DismountCommand : public IChatCommand { +public: + ChatCommandResult execute(ChatCommandContext& ctx) override { + ctx.gameHandler.dismount(); + return {}; + } + std::vector aliases() const override { return {"dismount"}; } + std::string helpText() const override { return "Dismount"; } +}; + +// --- /cancelform, /cancelshapeshift --- +class CancelFormCommand : public IChatCommand { +public: + ChatCommandResult execute(ChatCommandContext& ctx) override { + for (const auto& aura : ctx.gameHandler.getPlayerAuras()) { + if (aura.spellId == 0) continue; + if (aura.flags & 0x20) { + ctx.gameHandler.cancelAura(aura.spellId); + break; + } + } + return {}; + } + std::vector aliases() const override { return {"cancelform", "cancelshapeshift"}; } + std::string helpText() const override { return "Cancel shapeshift form"; } +}; + +// --- /cancelaura --- +class CancelAuraCommand : public IChatCommand { +public: + ChatCommandResult execute(ChatCommandContext& ctx) override { + if (ctx.args.empty()) return {false, false}; + std::string auraArg = ctx.args; + while (!auraArg.empty() && auraArg.front() == ' ') auraArg.erase(auraArg.begin()); + while (!auraArg.empty() && auraArg.back() == ' ') auraArg.pop_back(); + // Try numeric ID first + { + std::string numStr = auraArg; + if (!numStr.empty() && numStr.front() == '#') numStr.erase(numStr.begin()); + bool isNum = !numStr.empty() && + std::all_of(numStr.begin(), numStr.end(), + [](unsigned char c){ return std::isdigit(c); }); + if (isNum) { + uint32_t spellId = 0; + try { spellId = static_cast(std::stoul(numStr)); } catch (...) {} + if (spellId) ctx.gameHandler.cancelAura(spellId); + return {}; + } + } + // Name match against player auras + std::string argLow = auraArg; + for (char& c : argLow) c = static_cast(std::tolower(static_cast(c))); + for (const auto& aura : ctx.gameHandler.getPlayerAuras()) { + if (aura.spellId == 0) continue; + std::string sn = ctx.gameHandler.getSpellName(aura.spellId); + for (char& c : sn) c = static_cast(std::tolower(static_cast(c))); + if (sn == argLow) { + ctx.gameHandler.cancelAura(aura.spellId); + break; + } + } + return {}; + } + std::vector aliases() const override { return {"cancelaura"}; } + std::string helpText() const override { return "Cancel a specific aura/buff"; } +}; + +// --- Pet commands --- +class PetAttackCommand : public IChatCommand { +public: + ChatCommandResult execute(ChatCommandContext& ctx) override { + uint64_t target = ctx.gameHandler.hasTarget() ? ctx.gameHandler.getTargetGuid() : 0; + ctx.gameHandler.sendPetAction(5, target); + return {}; + } + std::vector aliases() const override { return {"petattack"}; } + std::string helpText() const override { return "Pet: attack target"; } +}; + +class PetFollowCommand : public IChatCommand { +public: + ChatCommandResult execute(ChatCommandContext& ctx) override { + ctx.gameHandler.sendPetAction(2, 0); + return {}; + } + std::vector aliases() const override { return {"petfollow"}; } + std::string helpText() const override { return "Pet: follow owner"; } +}; + +class PetStayCommand : public IChatCommand { +public: + ChatCommandResult execute(ChatCommandContext& ctx) override { + ctx.gameHandler.sendPetAction(3, 0); + return {}; + } + std::vector aliases() const override { return {"petstay", "pethalt"}; } + std::string helpText() const override { return "Pet: stay"; } +}; + +class PetPassiveCommand : public IChatCommand { +public: + ChatCommandResult execute(ChatCommandContext& ctx) override { + ctx.gameHandler.sendPetAction(1, 0); + return {}; + } + std::vector aliases() const override { return {"petpassive"}; } + std::string helpText() const override { return "Pet: passive mode"; } +}; + +class PetDefensiveCommand : public IChatCommand { +public: + ChatCommandResult execute(ChatCommandContext& ctx) override { + ctx.gameHandler.sendPetAction(4, 0); + return {}; + } + std::vector aliases() const override { return {"petdefensive"}; } + std::string helpText() const override { return "Pet: defensive mode"; } +}; + +class PetAggressiveCommand : public IChatCommand { +public: + ChatCommandResult execute(ChatCommandContext& ctx) override { + ctx.gameHandler.sendPetAction(6, 0); + return {}; + } + std::vector aliases() const override { return {"petaggressive"}; } + std::string helpText() const override { return "Pet: aggressive mode"; } +}; + +class PetDismissCommand : public IChatCommand { +public: + ChatCommandResult execute(ChatCommandContext& ctx) override { + ctx.gameHandler.dismissPet(); + return {}; + } + std::vector aliases() const override { return {"petdismiss"}; } + std::string helpText() const override { return "Dismiss pet"; } +}; + +// --- Registration --- +void registerEmoteCommands(ChatCommandRegistry& reg) { + reg.registerCommand(std::make_unique()); + reg.registerCommand(std::make_unique()); + reg.registerCommand(std::make_unique()); + reg.registerCommand(std::make_unique()); + reg.registerCommand(std::make_unique()); + reg.registerCommand(std::make_unique()); + reg.registerCommand(std::make_unique()); + reg.registerCommand(std::make_unique()); + reg.registerCommand(std::make_unique()); + reg.registerCommand(std::make_unique()); + reg.registerCommand(std::make_unique()); + reg.registerCommand(std::make_unique()); + reg.registerCommand(std::make_unique()); + reg.registerCommand(std::make_unique()); + reg.registerCommand(std::make_unique()); +} + +} // namespace ui +} // namespace wowee diff --git a/src/ui/chat/commands/gm_commands.cpp b/src/ui/chat/commands/gm_commands.cpp new file mode 100644 index 00000000..80abc1ad --- /dev/null +++ b/src/ui/chat/commands/gm_commands.cpp @@ -0,0 +1,127 @@ +// GM commands: /gmhelp, /gmcommands — local help for server-side dot-prefix commands. +// Also provides the gm_commands::getCompletions() function used by tab-completion. +// The actual GM commands (.gm, .tele, etc.) are sent to the server as SAY messages; +// the server (AzerothCore) does the real work. This file just adds discoverability. +#include "ui/chat/i_chat_command.hpp" +#include "ui/chat/chat_command_registry.hpp" +#include "ui/chat/gm_command_data.hpp" +#include "ui/chat/chat_utils.hpp" +#include "game/game_handler.hpp" +#include +#include +#include +#include + +namespace wowee { namespace ui { + +// --------------------------------------------------------------------------- +// gm_commands namespace — GM command lookup helpers used by tab-completion +// and the /gmhelp command. +// --------------------------------------------------------------------------- +namespace gm_commands { + +std::vector getCompletions(const std::string& prefix) { + std::vector results; + for (const auto& cmd : kGmCommands) { + std::string dotName = "." + std::string(cmd.name); + if (dotName.size() >= prefix.size() && + dotName.compare(0, prefix.size(), prefix) == 0) { + results.push_back(dotName); + } + } + std::sort(results.begin(), results.end()); + return results; +} + +const GmCommandEntry* find(const std::string& name) { + for (const auto& cmd : kGmCommands) { + if (cmd.name == name) return &cmd; + } + return nullptr; +} + +} // namespace gm_commands + +// --------------------------------------------------------------------------- +// /gmhelp [filter] — display GM command reference locally. +// --------------------------------------------------------------------------- +class GmHelpCommand : public IChatCommand { +public: + ChatCommandResult execute(ChatCommandContext& ctx) override { + std::string filter = ctx.args; + for (char& c : filter) c = static_cast(std::tolower(static_cast(c))); + + // Trim leading/trailing whitespace + while (!filter.empty() && std::isspace(static_cast(filter.front()))) filter.erase(filter.begin()); + while (!filter.empty() && std::isspace(static_cast(filter.back()))) filter.pop_back(); + + // If filter matches a specific command name, show detailed help + if (!filter.empty()) { + // Strip leading dot if user typed /gmhelp .gm + if (filter.front() == '.') filter = filter.substr(1); + + bool found = false; + for (const auto& cmd : kGmCommands) { + std::string name(cmd.name); + if (name == filter || name.compare(0, filter.size(), filter) == 0) { + std::string line = std::string(cmd.syntax) + " — " + std::string(cmd.help) + + " [sec:" + std::to_string(cmd.security) + "]"; + ctx.gameHandler.addLocalChatMessage(chat_utils::makeSystemMessage(line)); + found = true; + } + } + if (!found) { + ctx.gameHandler.addLocalChatMessage( + chat_utils::makeSystemMessage("No GM commands matching '" + filter + "'.")); + } + return {}; + } + + // No filter — print category overview + ctx.gameHandler.addLocalChatMessage( + chat_utils::makeSystemMessage("--- GM Commands (dot-prefix, sent to server) ---")); + ctx.gameHandler.addLocalChatMessage( + chat_utils::makeSystemMessage("GM Mode: .gm on/off .gm fly .gm visible")); + ctx.gameHandler.addLocalChatMessage( + chat_utils::makeSystemMessage("Teleport: .tele .go xyz .appear .summon")); + ctx.gameHandler.addLocalChatMessage( + chat_utils::makeSystemMessage("Character: .levelup .additem .learn .maxskill .pinfo")); + ctx.gameHandler.addLocalChatMessage( + chat_utils::makeSystemMessage("Combat: .revive .die .damage .freeze .respawn")); + ctx.gameHandler.addLocalChatMessage( + chat_utils::makeSystemMessage("Modify: .modify money/hp/mana/speed .morph .modify scale")); + ctx.gameHandler.addLocalChatMessage( + chat_utils::makeSystemMessage("Cheats: .cheat god/casttime/cooldown/power/taxi/explore")); + ctx.gameHandler.addLocalChatMessage( + chat_utils::makeSystemMessage("Spells: .cast .aura .unaura .cooldown .setskill")); + ctx.gameHandler.addLocalChatMessage( + chat_utils::makeSystemMessage("Quests: .quest add/complete/remove/reward/status")); + ctx.gameHandler.addLocalChatMessage( + chat_utils::makeSystemMessage("NPC: .npc add/delete/info/near/say/move")); + ctx.gameHandler.addLocalChatMessage( + chat_utils::makeSystemMessage("Objects: .gobject add/delete/info/near/target")); + ctx.gameHandler.addLocalChatMessage( + chat_utils::makeSystemMessage("Lookup: .lookup item/spell/creature/quest/area/teleport")); + ctx.gameHandler.addLocalChatMessage( + chat_utils::makeSystemMessage("Admin: .ban .kick .mute .announce .reload")); + ctx.gameHandler.addLocalChatMessage( + chat_utils::makeSystemMessage("Server: .server info .server motd .save .commands .help")); + ctx.gameHandler.addLocalChatMessage( + chat_utils::makeSystemMessage("Use /gmhelp for details (e.g. /gmhelp tele).")); + ctx.gameHandler.addLocalChatMessage( + chat_utils::makeSystemMessage("Tab-complete works with dot-prefix (type .te).")); + return {}; + } + std::vector aliases() const override { return {"gmhelp", "gmcommands"}; } + std::string helpText() const override { return "List GM dot-commands (server-side)"; } +}; + +// --------------------------------------------------------------------------- +// Registration +// --------------------------------------------------------------------------- +void registerGmCommands(ChatCommandRegistry& reg) { + reg.registerCommand(std::make_unique()); +} + +} // namespace ui +} // namespace wowee diff --git a/src/ui/chat/commands/group_commands.cpp b/src/ui/chat/commands/group_commands.cpp new file mode 100644 index 00000000..e6e3f4de --- /dev/null +++ b/src/ui/chat/commands/group_commands.cpp @@ -0,0 +1,395 @@ +// Group commands: /readycheck, /ready, /notready, /yield, /afk, /dnd, +// /uninvite, /leave, /maintank, /mainassist, /clearmaintank, +// /clearmainassist, /raidinfo, /raidconvert, /lootmethod, +// /lootthreshold, /mark, /roll +// Moved from ChatPanel::sendChatMessage() if/else chain (Phase 3). +#include "ui/chat/i_chat_command.hpp" +#include "ui/chat_panel.hpp" +#include "game/game_handler.hpp" +#include +#include + +namespace wowee { namespace ui { + +// --- /readycheck, /rc --- +class ReadyCheckCommand : public IChatCommand { +public: + ChatCommandResult execute(ChatCommandContext& ctx) override { + ctx.gameHandler.initiateReadyCheck(); + return {}; + } + std::vector aliases() const override { return {"readycheck", "rc"}; } + std::string helpText() const override { return "Initiate ready check"; } +}; + +// --- /ready --- +class ReadyCommand : public IChatCommand { +public: + ChatCommandResult execute(ChatCommandContext& ctx) override { + ctx.gameHandler.respondToReadyCheck(true); + return {}; + } + std::vector aliases() const override { return {"ready"}; } + std::string helpText() const override { return "Respond yes to ready check"; } +}; + +// --- /notready, /nr --- +class NotReadyCommand : public IChatCommand { +public: + ChatCommandResult execute(ChatCommandContext& ctx) override { + ctx.gameHandler.respondToReadyCheck(false); + return {}; + } + std::vector aliases() const override { return {"notready", "nr"}; } + std::string helpText() const override { return "Respond no to ready check"; } +}; + +// --- /yield, /forfeit, /surrender --- +class YieldCommand : public IChatCommand { +public: + ChatCommandResult execute(ChatCommandContext& ctx) override { + ctx.gameHandler.forfeitDuel(); + return {}; + } + std::vector aliases() const override { return {"yield", "forfeit", "surrender"}; } + std::string helpText() const override { return "Forfeit current duel"; } +}; + +// --- /afk, /away --- +class AfkCommand : public IChatCommand { +public: + ChatCommandResult execute(ChatCommandContext& ctx) override { + ctx.gameHandler.toggleAfk(ctx.args); + return {}; + } + std::vector aliases() const override { return {"afk", "away"}; } + std::string helpText() const override { return "Toggle AFK status"; } +}; + +// --- /dnd, /busy --- +class DndCommand : public IChatCommand { +public: + ChatCommandResult execute(ChatCommandContext& ctx) override { + ctx.gameHandler.toggleDnd(ctx.args); + return {}; + } + std::vector aliases() const override { return {"dnd", "busy"}; } + std::string helpText() const override { return "Toggle Do Not Disturb"; } +}; + +// --- /uninvite, /kick --- +class UninviteCommand : public IChatCommand { +public: + ChatCommandResult execute(ChatCommandContext& ctx) override { + if (!ctx.args.empty()) { + ctx.gameHandler.uninvitePlayer(ctx.args); + } else { + game::MessageChatData msg; + msg.type = game::ChatType::SYSTEM; + msg.language = game::ChatLanguage::UNIVERSAL; + msg.message = "Usage: /uninvite "; + ctx.gameHandler.addLocalChatMessage(msg); + } + return {}; + } + std::vector aliases() const override { return {"uninvite", "kick"}; } + std::string helpText() const override { return "Remove player from group"; } +}; + +// --- /leave, /leaveparty --- +// /leave — leave party (no args) or leave channel (with args, WoW-style overload) +class LeavePartyCommand : public IChatCommand { +public: + ChatCommandResult execute(ChatCommandContext& ctx) override { + if (!ctx.args.empty()) { + // /leave ChannelName — leave a chat channel + ctx.gameHandler.leaveChannel(ctx.args); + } else { + ctx.gameHandler.leaveParty(); + } + return {}; + } + std::vector aliases() const override { return {"leave", "leaveparty"}; } + std::string helpText() const override { return "Leave party/raid or channel"; } +}; + +// --- /maintank, /mt --- +class MainTankCommand : public IChatCommand { +public: + ChatCommandResult execute(ChatCommandContext& ctx) override { + if (ctx.gameHandler.hasTarget()) { + ctx.gameHandler.setMainTank(ctx.gameHandler.getTargetGuid()); + } else { + game::MessageChatData msg; + msg.type = game::ChatType::SYSTEM; + msg.language = game::ChatLanguage::UNIVERSAL; + msg.message = "You must target a player to set as main tank."; + ctx.gameHandler.addLocalChatMessage(msg); + } + return {}; + } + std::vector aliases() const override { return {"maintank", "mt"}; } + std::string helpText() const override { return "Set target as main tank"; } +}; + +// --- /mainassist, /ma --- +class MainAssistCommand : public IChatCommand { +public: + ChatCommandResult execute(ChatCommandContext& ctx) override { + if (ctx.gameHandler.hasTarget()) { + ctx.gameHandler.setMainAssist(ctx.gameHandler.getTargetGuid()); + } else { + game::MessageChatData msg; + msg.type = game::ChatType::SYSTEM; + msg.language = game::ChatLanguage::UNIVERSAL; + msg.message = "You must target a player to set as main assist."; + ctx.gameHandler.addLocalChatMessage(msg); + } + return {}; + } + std::vector aliases() const override { return {"mainassist", "ma"}; } + std::string helpText() const override { return "Set target as main assist"; } +}; + +// --- /clearmaintank --- +class ClearMainTankCommand : public IChatCommand { +public: + ChatCommandResult execute(ChatCommandContext& ctx) override { + ctx.gameHandler.clearMainTank(); + return {}; + } + std::vector aliases() const override { return {"clearmaintank"}; } + std::string helpText() const override { return "Clear main tank assignment"; } +}; + +// --- /clearmainassist --- +class ClearMainAssistCommand : public IChatCommand { +public: + ChatCommandResult execute(ChatCommandContext& ctx) override { + ctx.gameHandler.clearMainAssist(); + return {}; + } + std::vector aliases() const override { return {"clearmainassist"}; } + std::string helpText() const override { return "Clear main assist assignment"; } +}; + +// --- /raidinfo --- +class RaidInfoCommand : public IChatCommand { +public: + ChatCommandResult execute(ChatCommandContext& ctx) override { + ctx.gameHandler.requestRaidInfo(); + return {}; + } + std::vector aliases() const override { return {"raidinfo"}; } + std::string helpText() const override { return "Show raid instance lockouts"; } +}; + +// --- /raidconvert --- +class RaidConvertCommand : public IChatCommand { +public: + ChatCommandResult execute(ChatCommandContext& ctx) override { + ctx.gameHandler.convertToRaid(); + return {}; + } + std::vector aliases() const override { return {"raidconvert"}; } + std::string helpText() const override { return "Convert party to raid"; } +}; + +// --- /lootmethod, /grouploot, /setloot --- +class LootMethodCommand : public IChatCommand { +public: + ChatCommandResult execute(ChatCommandContext& ctx) override { + if (!ctx.gameHandler.isInGroup()) { + ctx.gameHandler.addUIError("You are not in a group."); + return {}; + } + if (ctx.args.empty()) { + static constexpr const char* kMethodNames[] = { + "Free for All", "Round Robin", "Master Looter", "Group Loot", "Need Before Greed" + }; + const auto& pd = ctx.gameHandler.getPartyData(); + const char* cur = (pd.lootMethod < 5) ? kMethodNames[pd.lootMethod] : "Unknown"; + game::MessageChatData msg; + msg.type = game::ChatType::SYSTEM; + msg.language = game::ChatLanguage::UNIVERSAL; + msg.message = std::string("Current loot method: ") + cur; + ctx.gameHandler.addLocalChatMessage(msg); + msg.message = "Usage: /lootmethod ffa|roundrobin|master|group|needbeforegreed"; + ctx.gameHandler.addLocalChatMessage(msg); + return {}; + } + std::string arg = ctx.args; + for (auto& c : arg) c = static_cast(std::tolower(static_cast(c))); + uint32_t method = 0xFFFFFFFF; + if (arg == "ffa" || arg == "freeforall") method = 0; + else if (arg == "roundrobin" || arg == "rr") method = 1; + else if (arg == "master" || arg == "masterloot") method = 2; + else if (arg == "group" || arg == "grouploot") method = 3; + else if (arg == "needbeforegreed" || arg == "nbg" || arg == "need") method = 4; + + if (method == 0xFFFFFFFF) { + ctx.gameHandler.addUIError("Unknown loot method. Use: ffa, roundrobin, master, group, needbeforegreed"); + } else { + const auto& pd = ctx.gameHandler.getPartyData(); + uint64_t masterGuid = (method == 2) ? ctx.gameHandler.getPlayerGuid() : 0; + ctx.gameHandler.sendSetLootMethod(method, pd.lootThreshold, masterGuid); + } + return {}; + } + std::vector aliases() const override { return {"lootmethod", "grouploot", "setloot"}; } + std::string helpText() const override { return "Set loot method"; } +}; + +// --- /lootthreshold --- +class LootThresholdCommand : public IChatCommand { +public: + ChatCommandResult execute(ChatCommandContext& ctx) override { + if (!ctx.gameHandler.isInGroup()) { + ctx.gameHandler.addUIError("You are not in a group."); + return {}; + } + if (ctx.args.empty()) { + const auto& pd = ctx.gameHandler.getPartyData(); + static constexpr const char* kQualityNames[] = { + "Poor (grey)", "Common (white)", "Uncommon (green)", + "Rare (blue)", "Epic (purple)", "Legendary (orange)" + }; + const char* cur = (pd.lootThreshold < 6) ? kQualityNames[pd.lootThreshold] : "Unknown"; + game::MessageChatData msg; + msg.type = game::ChatType::SYSTEM; + msg.language = game::ChatLanguage::UNIVERSAL; + msg.message = std::string("Current loot threshold: ") + cur; + ctx.gameHandler.addLocalChatMessage(msg); + msg.message = "Usage: /lootthreshold <0-5> (0=Poor, 1=Common, 2=Uncommon, 3=Rare, 4=Epic, 5=Legendary)"; + ctx.gameHandler.addLocalChatMessage(msg); + return {}; + } + std::string arg = ctx.args; + while (!arg.empty() && arg.front() == ' ') arg.erase(arg.begin()); + uint32_t threshold = 0xFFFFFFFF; + if (arg.size() == 1 && arg[0] >= '0' && arg[0] <= '5') { + threshold = static_cast(arg[0] - '0'); + } else { + for (auto& c : arg) c = static_cast(std::tolower(static_cast(c))); + if (arg == "poor" || arg == "grey" || arg == "gray") threshold = 0; + else if (arg == "common" || arg == "white") threshold = 1; + else if (arg == "uncommon" || arg == "green") threshold = 2; + else if (arg == "rare" || arg == "blue") threshold = 3; + else if (arg == "epic" || arg == "purple") threshold = 4; + else if (arg == "legendary" || arg == "orange") threshold = 5; + } + if (threshold == 0xFFFFFFFF) { + ctx.gameHandler.addUIError("Invalid threshold. Use 0-5 or: poor, common, uncommon, rare, epic, legendary"); + } else { + const auto& pd = ctx.gameHandler.getPartyData(); + uint64_t masterGuid = (pd.lootMethod == 2) ? ctx.gameHandler.getPlayerGuid() : 0; + ctx.gameHandler.sendSetLootMethod(pd.lootMethod, threshold, masterGuid); + } + return {}; + } + std::vector aliases() const override { return {"lootthreshold"}; } + std::string helpText() const override { return "Set loot quality threshold"; } +}; + +// --- /mark, /marktarget, /raidtarget --- +class MarkCommand : public IChatCommand { +public: + ChatCommandResult execute(ChatCommandContext& ctx) override { + if (!ctx.gameHandler.hasTarget()) { + game::MessageChatData noTgt; + noTgt.type = game::ChatType::SYSTEM; + noTgt.language = game::ChatLanguage::UNIVERSAL; + noTgt.message = "No target selected."; + ctx.gameHandler.addLocalChatMessage(noTgt); + return {}; + } + static constexpr const char* kMarkWords[] = { + "star", "circle", "diamond", "triangle", "moon", "square", "cross", "skull" + }; + uint8_t icon = 7; // default: skull + if (!ctx.args.empty()) { + std::string argLow = ctx.args; + for (auto& c : argLow) c = static_cast(std::tolower(c)); + while (!argLow.empty() && argLow.front() == ' ') argLow.erase(argLow.begin()); + if (argLow == "clear" || argLow == "0" || argLow == "none") { + ctx.gameHandler.setRaidMark(ctx.gameHandler.getTargetGuid(), 0xFF); + return {}; + } + bool found = false; + for (int mi = 0; mi < 8; ++mi) { + if (argLow == kMarkWords[mi]) { icon = static_cast(mi); found = true; break; } + } + if (!found && !argLow.empty() && argLow[0] >= '1' && argLow[0] <= '8') { + icon = static_cast(argLow[0] - '1'); + found = true; + } + if (!found) { + game::MessageChatData badArg; + badArg.type = game::ChatType::SYSTEM; + badArg.language = game::ChatLanguage::UNIVERSAL; + badArg.message = "Unknown mark. Use: star circle diamond triangle moon square cross skull"; + ctx.gameHandler.addLocalChatMessage(badArg); + return {}; + } + } + ctx.gameHandler.setRaidMark(ctx.gameHandler.getTargetGuid(), icon); + return {}; + } + std::vector aliases() const override { return {"mark", "marktarget", "raidtarget"}; } + std::string helpText() const override { return "Set raid target mark on target"; } +}; + +// --- /roll, /random, /rnd --- +class RollCommand : public IChatCommand { +public: + ChatCommandResult execute(ChatCommandContext& ctx) override { + uint32_t minRoll = 1; + uint32_t maxRoll = 100; + if (!ctx.args.empty()) { + size_t dashPos = ctx.args.find('-'); + size_t spacePos = ctx.args.find(' '); + if (dashPos != std::string::npos) { + try { + minRoll = std::stoul(ctx.args.substr(0, dashPos)); + maxRoll = std::stoul(ctx.args.substr(dashPos + 1)); + } catch (...) {} + } else if (spacePos != std::string::npos) { + try { + minRoll = std::stoul(ctx.args.substr(0, spacePos)); + maxRoll = std::stoul(ctx.args.substr(spacePos + 1)); + } catch (...) {} + } else { + try { maxRoll = std::stoul(ctx.args); } catch (...) {} + } + } + ctx.gameHandler.randomRoll(minRoll, maxRoll); + return {}; + } + std::vector aliases() const override { return {"roll", "random", "rnd"}; } + std::string helpText() const override { return "Random dice roll"; } +}; + +// --- Registration --- +void registerGroupCommands(ChatCommandRegistry& reg) { + reg.registerCommand(std::make_unique()); + reg.registerCommand(std::make_unique()); + reg.registerCommand(std::make_unique()); + reg.registerCommand(std::make_unique()); + reg.registerCommand(std::make_unique()); + reg.registerCommand(std::make_unique()); + reg.registerCommand(std::make_unique()); + reg.registerCommand(std::make_unique()); + reg.registerCommand(std::make_unique()); + reg.registerCommand(std::make_unique()); + reg.registerCommand(std::make_unique()); + reg.registerCommand(std::make_unique()); + reg.registerCommand(std::make_unique()); + reg.registerCommand(std::make_unique()); + reg.registerCommand(std::make_unique()); + reg.registerCommand(std::make_unique()); + reg.registerCommand(std::make_unique()); + reg.registerCommand(std::make_unique()); +} + +} // namespace ui +} // namespace wowee diff --git a/src/ui/chat/commands/guild_commands.cpp b/src/ui/chat/commands/guild_commands.cpp new file mode 100644 index 00000000..38732bb7 --- /dev/null +++ b/src/ui/chat/commands/guild_commands.cpp @@ -0,0 +1,203 @@ +// Guild commands: /ginfo, /groster, /gmotd, /gpromote, /gdemote, /gquit, +// /ginvite, /gkick, /gcreate, /gdisband, /gleader +// Moved from ChatPanel::sendChatMessage() if/else chain (Phase 3). +#include "ui/chat/i_chat_command.hpp" +#include "ui/chat_panel.hpp" +#include "game/game_handler.hpp" + +namespace wowee { namespace ui { + +// --- /ginfo, /guildinfo --- +class GuildInfoCommand : public IChatCommand { +public: + ChatCommandResult execute(ChatCommandContext& ctx) override { + ctx.gameHandler.requestGuildInfo(); + return {}; + } + std::vector aliases() const override { return {"ginfo", "guildinfo"}; } + std::string helpText() const override { return "Show guild info"; } +}; + +// --- /groster, /guildroster --- +class GuildRosterCommand : public IChatCommand { +public: + ChatCommandResult execute(ChatCommandContext& ctx) override { + ctx.gameHandler.requestGuildRoster(); + return {}; + } + std::vector aliases() const override { return {"groster", "guildroster"}; } + std::string helpText() const override { return "Show guild roster"; } +}; + +// --- /gmotd, /guildmotd --- +class GuildMotdCommand : public IChatCommand { +public: + ChatCommandResult execute(ChatCommandContext& ctx) override { + if (!ctx.args.empty()) { + ctx.gameHandler.setGuildMotd(ctx.args); + return {}; + } + game::MessageChatData msg; + msg.type = game::ChatType::SYSTEM; + msg.language = game::ChatLanguage::UNIVERSAL; + msg.message = "Usage: /gmotd "; + ctx.gameHandler.addLocalChatMessage(msg); + return {}; + } + std::vector aliases() const override { return {"gmotd", "guildmotd"}; } + std::string helpText() const override { return "Set guild message of the day"; } +}; + +// --- /gpromote, /guildpromote --- +class GuildPromoteCommand : public IChatCommand { +public: + ChatCommandResult execute(ChatCommandContext& ctx) override { + if (!ctx.args.empty()) { + ctx.gameHandler.promoteGuildMember(ctx.args); + return {}; + } + game::MessageChatData msg; + msg.type = game::ChatType::SYSTEM; + msg.language = game::ChatLanguage::UNIVERSAL; + msg.message = "Usage: /gpromote "; + ctx.gameHandler.addLocalChatMessage(msg); + return {}; + } + std::vector aliases() const override { return {"gpromote", "guildpromote"}; } + std::string helpText() const override { return "Promote guild member"; } +}; + +// --- /gdemote, /guilddemote --- +class GuildDemoteCommand : public IChatCommand { +public: + ChatCommandResult execute(ChatCommandContext& ctx) override { + if (!ctx.args.empty()) { + ctx.gameHandler.demoteGuildMember(ctx.args); + return {}; + } + game::MessageChatData msg; + msg.type = game::ChatType::SYSTEM; + msg.language = game::ChatLanguage::UNIVERSAL; + msg.message = "Usage: /gdemote "; + ctx.gameHandler.addLocalChatMessage(msg); + return {}; + } + std::vector aliases() const override { return {"gdemote", "guilddemote"}; } + std::string helpText() const override { return "Demote guild member"; } +}; + +// --- /gquit, /guildquit, /leaveguild --- +class GuildQuitCommand : public IChatCommand { +public: + ChatCommandResult execute(ChatCommandContext& ctx) override { + ctx.gameHandler.leaveGuild(); + return {}; + } + std::vector aliases() const override { return {"gquit", "guildquit", "leaveguild"}; } + std::string helpText() const override { return "Leave guild"; } +}; + +// --- /ginvite, /guildinvite --- +class GuildInviteCommand : public IChatCommand { +public: + ChatCommandResult execute(ChatCommandContext& ctx) override { + if (!ctx.args.empty()) { + ctx.gameHandler.inviteToGuild(ctx.args); + return {}; + } + game::MessageChatData msg; + msg.type = game::ChatType::SYSTEM; + msg.language = game::ChatLanguage::UNIVERSAL; + msg.message = "Usage: /ginvite "; + ctx.gameHandler.addLocalChatMessage(msg); + return {}; + } + std::vector aliases() const override { return {"ginvite", "guildinvite"}; } + std::string helpText() const override { return "Invite player to guild"; } +}; + +// --- /gkick, /guildkick --- +class GuildKickCommand : public IChatCommand { +public: + ChatCommandResult execute(ChatCommandContext& ctx) override { + if (!ctx.args.empty()) { + ctx.gameHandler.kickGuildMember(ctx.args); + return {}; + } + game::MessageChatData msg; + msg.type = game::ChatType::SYSTEM; + msg.language = game::ChatLanguage::UNIVERSAL; + msg.message = "Usage: /gkick "; + ctx.gameHandler.addLocalChatMessage(msg); + return {}; + } + std::vector aliases() const override { return {"gkick", "guildkick"}; } + std::string helpText() const override { return "Kick player from guild"; } +}; + +// --- /gcreate, /guildcreate --- +class GuildCreateCommand : public IChatCommand { +public: + ChatCommandResult execute(ChatCommandContext& ctx) override { + if (!ctx.args.empty()) { + ctx.gameHandler.createGuild(ctx.args); + return {}; + } + game::MessageChatData msg; + msg.type = game::ChatType::SYSTEM; + msg.language = game::ChatLanguage::UNIVERSAL; + msg.message = "Usage: /gcreate "; + ctx.gameHandler.addLocalChatMessage(msg); + return {}; + } + std::vector aliases() const override { return {"gcreate", "guildcreate"}; } + std::string helpText() const override { return "Create a guild"; } +}; + +// --- /gdisband, /guilddisband --- +class GuildDisbandCommand : public IChatCommand { +public: + ChatCommandResult execute(ChatCommandContext& ctx) override { + ctx.gameHandler.disbandGuild(); + return {}; + } + std::vector aliases() const override { return {"gdisband", "guilddisband"}; } + std::string helpText() const override { return "Disband guild"; } +}; + +// --- /gleader, /guildleader --- +class GuildLeaderCommand : public IChatCommand { +public: + ChatCommandResult execute(ChatCommandContext& ctx) override { + if (!ctx.args.empty()) { + ctx.gameHandler.setGuildLeader(ctx.args); + return {}; + } + game::MessageChatData msg; + msg.type = game::ChatType::SYSTEM; + msg.language = game::ChatLanguage::UNIVERSAL; + msg.message = "Usage: /gleader "; + ctx.gameHandler.addLocalChatMessage(msg); + return {}; + } + std::vector aliases() const override { return {"gleader", "guildleader"}; } + std::string helpText() const override { return "Transfer guild leadership"; } +}; + +// --- Registration --- +void registerGuildCommands(ChatCommandRegistry& reg) { + reg.registerCommand(std::make_unique()); + reg.registerCommand(std::make_unique()); + reg.registerCommand(std::make_unique()); + reg.registerCommand(std::make_unique()); + reg.registerCommand(std::make_unique()); + reg.registerCommand(std::make_unique()); + reg.registerCommand(std::make_unique()); + reg.registerCommand(std::make_unique()); + reg.registerCommand(std::make_unique()); + reg.registerCommand(std::make_unique()); + reg.registerCommand(std::make_unique()); +} + +} // namespace ui +} // namespace wowee diff --git a/src/ui/chat/commands/help_commands.cpp b/src/ui/chat/commands/help_commands.cpp new file mode 100644 index 00000000..77203a67 --- /dev/null +++ b/src/ui/chat/commands/help_commands.cpp @@ -0,0 +1,124 @@ +// Help commands: /help, /chathelp, /macrohelp +// Moved from ChatPanel::sendChatMessage() if/else chain (Phase 3). +#include "ui/chat/i_chat_command.hpp" +#include "ui/chat_panel.hpp" +#include "game/game_handler.hpp" + +namespace wowee { namespace ui { + +// --- /help, /? --- +class HelpCommand : public IChatCommand { +public: + ChatCommandResult execute(ChatCommandContext& ctx) override { + static constexpr const char* kHelpLines[] = { + "--- Wowee Slash Commands ---", + "Chat: /s /y /p /g /raid /rw /o /bg /w /r /join /leave", + "Social: /who /friend add/remove /ignore /unignore", + "Party: /invite /uninvite /leave /readycheck /mark /roll", + " /maintank /mainassist /raidconvert /raidinfo", + " /lootmethod /lootthreshold", + "Guild: /ginvite /gkick /gquit /gpromote /gdemote /gmotd", + " /gleader /groster /ginfo /gcreate /gdisband", + "Combat: /cast /castsequence /use /startattack /stopattack", + " /stopcasting /duel /forfeit /pvp /assist", + " /follow /stopfollow /threat /combatlog", + "Items: /use /equip /equipset [name]", + "Target: /target /cleartarget /focus /clearfocus /inspect", + "Movement: /sit /stand /kneel /dismount", + "Misc: /played /time /zone /loc /afk /dnd /helm /cloak", + " /trade /score /unstuck /logout /quit /exit /ticket", + " /screenshot /difficulty", + " /macrohelp /chathelp /help /gmhelp", + "GM: .command (dot-prefix sent to server, /gmhelp for list)", + }; + for (const char* line : kHelpLines) { + game::MessageChatData helpMsg; + helpMsg.type = game::ChatType::SYSTEM; + helpMsg.language = game::ChatLanguage::UNIVERSAL; + helpMsg.message = line; + ctx.gameHandler.addLocalChatMessage(helpMsg); + } + return {}; + } + std::vector aliases() const override { return {"help", "?"}; } + std::string helpText() const override { return "List all slash commands"; } +}; + +// --- /chathelp --- +class ChatHelpCommand : public IChatCommand { +public: + ChatCommandResult execute(ChatCommandContext& ctx) override { + static constexpr const char* kChatHelp[] = { + "--- Chat Channel Commands ---", + "/s [msg] Say to nearby players", + "/y [msg] Yell to a wider area", + "/w [msg] Whisper to player", + "/r [msg] Reply to last whisper", + "/p [msg] Party chat", + "/g [msg] Guild chat", + "/o [msg] Guild officer chat", + "/raid [msg] Raid chat", + "/rw [msg] Raid warning", + "/bg [msg] Battleground chat", + "/1 [msg] General channel", + "/2 [msg] Trade channel (also /wts /wtb)", + "/ [msg] Channel by number", + "/join Join a channel", + "/leave Leave a channel", + "/afk [msg] Set AFK status", + "/dnd [msg] Set Do Not Disturb", + }; + for (const char* line : kChatHelp) { + game::MessageChatData helpMsg; + helpMsg.type = game::ChatType::SYSTEM; + helpMsg.language = game::ChatLanguage::UNIVERSAL; + helpMsg.message = line; + ctx.gameHandler.addLocalChatMessage(helpMsg); + } + return {}; + } + std::vector aliases() const override { return {"chathelp"}; } + std::string helpText() const override { return "List chat channel commands"; } +}; + +// --- /macrohelp --- +class MacroHelpCommand : public IChatCommand { +public: + ChatCommandResult execute(ChatCommandContext& ctx) override { + static constexpr const char* kMacroHelp[] = { + "--- Macro Conditionals ---", + "Usage: /cast [cond1,cond2] Spell1; [cond3] Spell2; Default", + "State: [combat] [mounted] [swimming] [flying] [stealthed]", + " [channeling] [pet] [group] [raid] [indoors] [outdoors]", + "Spec: [spec:1] [spec:2] (active talent spec, 1-based)", + " (prefix no- to negate any condition)", + "Target: [harm] [help] [exists] [noexists] [dead] [nodead]", + " [target=focus] [target=pet] [target=mouseover] [target=player]", + " (also: @focus, @pet, @mouseover, @player, @target)", + "Form: [noform] [nostance] [form:0]", + "Keys: [mod:shift] [mod:ctrl] [mod:alt]", + "Aura: [buff:Name] [nobuff:Name] [debuff:Name] [nodebuff:Name]", + "Other: #showtooltip, /stopmacro [cond], /castsequence", + }; + for (const char* line : kMacroHelp) { + game::MessageChatData m; + m.type = game::ChatType::SYSTEM; + m.language = game::ChatLanguage::UNIVERSAL; + m.message = line; + ctx.gameHandler.addLocalChatMessage(m); + } + return {}; + } + std::vector aliases() const override { return {"macrohelp"}; } + std::string helpText() const override { return "List macro conditionals"; } +}; + +// --- Registration --- +void registerHelpCommands(ChatCommandRegistry& reg) { + reg.registerCommand(std::make_unique()); + reg.registerCommand(std::make_unique()); + reg.registerCommand(std::make_unique()); +} + +} // namespace ui +} // namespace wowee diff --git a/src/ui/chat/commands/misc_commands.cpp b/src/ui/chat/commands/misc_commands.cpp new file mode 100644 index 00000000..f049f11b --- /dev/null +++ b/src/ui/chat/commands/misc_commands.cpp @@ -0,0 +1,404 @@ +// Misc commands: /time, /loc, /zone, /played, /screenshot, /ticket, /score, +// /threat, /combatlog, /helm, /cloak, /follow, /stopfollow, +// /assist, /pvp, /unstuck*, /transport +// Moved from ChatPanel::sendChatMessage() if/else chain (Phase 3). +#include "ui/chat/i_chat_command.hpp" +#include "ui/chat_panel.hpp" +#include "game/game_handler.hpp" +#include "game/entity.hpp" +#include "rendering/renderer.hpp" +#include +#include +#include +#include +#include +#include + +namespace wowee { namespace ui { + +// Forward declaration of evaluateMacroConditionals (still in chat_panel_commands.cpp) +std::string evaluateMacroConditionals(const std::string& rawArg, + game::GameHandler& gameHandler, + uint64_t& targetOverride); + +namespace { + +inline std::string getEntityName(const std::shared_ptr& entity) { + if (entity->getType() == game::ObjectType::PLAYER) { + auto player = std::static_pointer_cast(entity); + if (!player->getName().empty()) return player->getName(); + } else if (entity->getType() == game::ObjectType::UNIT) { + auto unit = std::static_pointer_cast(entity); + if (!unit->getName().empty()) return unit->getName(); + } else if (entity->getType() == game::ObjectType::GAMEOBJECT) { + auto go = std::static_pointer_cast(entity); + if (!go->getName().empty()) return go->getName(); + } + return "Unknown"; +} + +} // anon namespace + +// --- /time --- +class TimeCommand : public IChatCommand { +public: + ChatCommandResult execute(ChatCommandContext& ctx) override { + ctx.gameHandler.queryServerTime(); + return {}; + } + std::vector aliases() const override { return {"time"}; } + std::string helpText() const override { return "Query server time"; } +}; + +// --- /loc, /coords, /whereami --- +class LocCommand : public IChatCommand { +public: + ChatCommandResult execute(ChatCommandContext& ctx) override { + const auto& pmi = ctx.gameHandler.getMovementInfo(); + std::string zoneName; + if (auto* rend = ctx.services.renderer) + zoneName = rend->getCurrentZoneName(); + char buf[256]; + snprintf(buf, sizeof(buf), "%.1f, %.1f, %.1f%s%s", + pmi.x, pmi.y, pmi.z, + zoneName.empty() ? "" : " — ", + zoneName.c_str()); + game::MessageChatData sysMsg; + sysMsg.type = game::ChatType::SYSTEM; + sysMsg.language = game::ChatLanguage::UNIVERSAL; + sysMsg.message = buf; + ctx.gameHandler.addLocalChatMessage(sysMsg); + return {}; + } + std::vector aliases() const override { return {"loc", "coords", "whereami"}; } + std::string helpText() const override { return "Print player coordinates"; } +}; + +// --- /zone --- +class ZoneCommand : public IChatCommand { +public: + ChatCommandResult execute(ChatCommandContext& ctx) override { + std::string zoneName; + if (auto* rend = ctx.services.renderer) + zoneName = rend->getCurrentZoneName(); + game::MessageChatData sysMsg; + sysMsg.type = game::ChatType::SYSTEM; + sysMsg.language = game::ChatLanguage::UNIVERSAL; + sysMsg.message = zoneName.empty() ? "You are not in a known zone." : "You are in: " + zoneName; + ctx.gameHandler.addLocalChatMessage(sysMsg); + return {}; + } + std::vector aliases() const override { return {"zone"}; } + std::string helpText() const override { return "Show current zone"; } +}; + +// --- /played --- +class PlayedCommand : public IChatCommand { +public: + ChatCommandResult execute(ChatCommandContext& ctx) override { + ctx.gameHandler.requestPlayedTime(); + return {}; + } + std::vector aliases() const override { return {"played"}; } + std::string helpText() const override { return "Show time played"; } +}; + +// --- /screenshot, /ss --- +class ScreenshotCommand : public IChatCommand { +public: + ChatCommandResult execute(ChatCommandContext& ctx) override { + ctx.panel.getSlashCmds().takeScreenshot = true; + return {}; + } + std::vector aliases() const override { return {"screenshot", "ss"}; } + std::string helpText() const override { return "Take a screenshot"; } +}; + +// --- /ticket, /gmticket, /gm --- +class TicketCommand : public IChatCommand { +public: + ChatCommandResult execute(ChatCommandContext& ctx) override { + ctx.panel.getSlashCmds().showGmTicket = true; + return {}; + } + std::vector aliases() const override { return {"ticket", "gmticket", "gm"}; } + std::string helpText() const override { return "Open GM ticket"; } +}; + +// --- /score --- +class ScoreCommand : public IChatCommand { +public: + ChatCommandResult execute(ChatCommandContext& ctx) override { + ctx.gameHandler.requestPvpLog(); + ctx.panel.getSlashCmds().showBgScore = true; + return {}; + } + std::vector aliases() const override { return {"score"}; } + std::string helpText() const override { return "Show BG scoreboard"; } +}; + +// --- /threat --- +class ThreatCommand : public IChatCommand { +public: + ChatCommandResult execute(ChatCommandContext& ctx) override { + ctx.panel.getSlashCmds().toggleThreat = true; + return {}; + } + std::vector aliases() const override { return {"threat"}; } + std::string helpText() const override { return "Toggle threat display"; } +}; + +// --- /combatlog, /cl --- +class CombatLogCommand : public IChatCommand { +public: + ChatCommandResult execute(ChatCommandContext& ctx) override { + ctx.panel.getSlashCmds().toggleCombatLog = true; + return {}; + } + std::vector aliases() const override { return {"combatlog", "cl"}; } + std::string helpText() const override { return "Toggle combat log"; } +}; + +// --- /helm, /helmet, /showhelm --- +class HelmCommand : public IChatCommand { +public: + ChatCommandResult execute(ChatCommandContext& ctx) override { + ctx.gameHandler.toggleHelm(); + return {}; + } + std::vector aliases() const override { return {"helm", "helmet", "showhelm"}; } + std::string helpText() const override { return "Toggle helmet visibility"; } +}; + +// --- /cloak, /showcloak --- +class CloakCommand : public IChatCommand { +public: + ChatCommandResult execute(ChatCommandContext& ctx) override { + ctx.gameHandler.toggleCloak(); + return {}; + } + std::vector aliases() const override { return {"cloak", "showcloak"}; } + std::string helpText() const override { return "Toggle cloak visibility"; } +}; + +// --- /follow, /f --- +class FollowCommand : public IChatCommand { +public: + ChatCommandResult execute(ChatCommandContext& ctx) override { + ctx.gameHandler.followTarget(); + return {}; + } + std::vector aliases() const override { return {"follow", "f"}; } + std::string helpText() const override { return "Follow target"; } +}; + +// --- /stopfollow --- +class StopFollowCommand : public IChatCommand { +public: + ChatCommandResult execute(ChatCommandContext& ctx) override { + ctx.gameHandler.cancelFollow(); + return {}; + } + std::vector aliases() const override { return {"stopfollow"}; } + std::string helpText() const override { return "Stop following"; } +}; + +// --- /assist --- +class AssistCommand : public IChatCommand { +public: + ChatCommandResult execute(ChatCommandContext& ctx) override { + auto assistEntityTarget = [&](uint64_t srcGuid) { + auto srcEnt = ctx.gameHandler.getEntityManager().getEntity(srcGuid); + if (!srcEnt) { ctx.gameHandler.assistTarget(); return; } + uint64_t atkGuid = 0; + const auto& flds = srcEnt->getFields(); + auto iLo = flds.find(game::fieldIndex(game::UF::UNIT_FIELD_TARGET_LO)); + if (iLo != flds.end()) { + atkGuid = iLo->second; + auto iHi = flds.find(game::fieldIndex(game::UF::UNIT_FIELD_TARGET_HI)); + if (iHi != flds.end()) atkGuid |= (static_cast(iHi->second) << 32); + } + if (atkGuid != 0) { + ctx.gameHandler.setTarget(atkGuid); + } else { + std::string sn = getEntityName(srcEnt); + game::MessageChatData msg; + msg.type = game::ChatType::SYSTEM; + msg.language = game::ChatLanguage::UNIVERSAL; + msg.message = (sn.empty() ? "Target" : sn) + " has no target."; + ctx.gameHandler.addLocalChatMessage(msg); + } + }; + + if (!ctx.args.empty()) { + std::string assistArg = ctx.args; + while (!assistArg.empty() && assistArg.front() == ' ') assistArg.erase(assistArg.begin()); + + // Evaluate conditionals if present + uint64_t assistOver = static_cast(-1); + if (!assistArg.empty() && assistArg.front() == '[') { + assistArg = evaluateMacroConditionals(assistArg, ctx.gameHandler, assistOver); + if (assistArg.empty() && assistOver == static_cast(-1)) return {}; + while (!assistArg.empty() && assistArg.front() == ' ') assistArg.erase(assistArg.begin()); + while (!assistArg.empty() && assistArg.back() == ' ') assistArg.pop_back(); + } + + if (assistOver != static_cast(-1) && assistOver != 0) { + assistEntityTarget(assistOver); + } else if (!assistArg.empty()) { + // Name search + std::string argLow = assistArg; + for (char& c : argLow) c = static_cast(std::tolower(static_cast(c))); + uint64_t bestGuid = 0; float bestDist = std::numeric_limits::max(); + const auto& pmi = ctx.gameHandler.getMovementInfo(); + for (const auto& [guid, ent] : ctx.gameHandler.getEntityManager().getEntities()) { + if (!ent || ent->getType() == game::ObjectType::OBJECT) continue; + std::string nm = getEntityName(ent); + std::string nml = nm; + for (char& c : nml) c = static_cast(std::tolower(static_cast(c))); + if (nml.find(argLow) != 0) continue; + float d2 = (ent->getX()-pmi.x)*(ent->getX()-pmi.x) + + (ent->getY()-pmi.y)*(ent->getY()-pmi.y); + if (d2 < bestDist) { bestDist = d2; bestGuid = guid; } + } + if (bestGuid) assistEntityTarget(bestGuid); + else { + game::MessageChatData msg; + msg.type = game::ChatType::SYSTEM; + msg.language = game::ChatLanguage::UNIVERSAL; + msg.message = "No unit matching '" + assistArg + "' found."; + ctx.gameHandler.addLocalChatMessage(msg); + } + } else { + ctx.gameHandler.assistTarget(); + } + } else { + ctx.gameHandler.assistTarget(); + } + return {}; + } + std::vector aliases() const override { return {"assist"}; } + std::string helpText() const override { return "Assist target (target their target)"; } +}; + +// --- /pvp --- +class PvpCommand : public IChatCommand { +public: + ChatCommandResult execute(ChatCommandContext& ctx) override { + ctx.gameHandler.togglePvp(); + return {}; + } + std::vector aliases() const override { return {"pvp"}; } + std::string helpText() const override { return "Toggle PvP flag"; } +}; + +// --- /unstuck --- +class UnstuckCommand : public IChatCommand { +public: + ChatCommandResult execute(ChatCommandContext& ctx) override { + ctx.gameHandler.unstuck(); + return {}; + } + std::vector aliases() const override { return {"unstuck"}; } + std::string helpText() const override { return "Reset position to floor height"; } +}; + +// --- /unstuckgy --- +class UnstuckGyCommand : public IChatCommand { +public: + ChatCommandResult execute(ChatCommandContext& ctx) override { + ctx.gameHandler.unstuckGy(); + return {}; + } + std::vector aliases() const override { return {"unstuckgy"}; } + std::string helpText() const override { return "Move to nearest graveyard"; } +}; + +// --- /unstuckhearth --- +class UnstuckHearthCommand : public IChatCommand { +public: + ChatCommandResult execute(ChatCommandContext& ctx) override { + ctx.gameHandler.unstuckHearth(); + return {}; + } + std::vector aliases() const override { return {"unstuckhearth"}; } + std::string helpText() const override { return "Teleport to hearthstone bind point"; } +}; + +// --- /transport board --- +class TransportBoardCommand : public IChatCommand { +public: + ChatCommandResult execute(ChatCommandContext& ctx) override { + // This is invoked via the "transport" alias. Check args for sub-command. + std::string sub = ctx.args; + while (!sub.empty() && sub.front() == ' ') sub.erase(sub.begin()); + for (char& c : sub) c = static_cast(std::tolower(static_cast(c))); + + if (sub == "board") { + auto* tm = ctx.gameHandler.getTransportManager(); + if (tm) { + uint64_t testTransportGuid = 0x1000000000000001ULL; + glm::vec3 deckCenter(0.0f, 0.0f, 5.0f); + ctx.gameHandler.setPlayerOnTransport(testTransportGuid, deckCenter); + game::MessageChatData msg; + msg.type = game::ChatType::SYSTEM; + msg.language = game::ChatLanguage::UNIVERSAL; + msg.message = "Boarded test transport. Use '/transport leave' to disembark."; + ctx.gameHandler.addLocalChatMessage(msg); + } else { + game::MessageChatData msg; + msg.type = game::ChatType::SYSTEM; + msg.language = game::ChatLanguage::UNIVERSAL; + msg.message = "Transport system not available."; + ctx.gameHandler.addLocalChatMessage(msg); + } + return {}; + } else if (sub == "leave") { + if (ctx.gameHandler.isOnTransport()) { + ctx.gameHandler.clearPlayerTransport(); + game::MessageChatData msg; + msg.type = game::ChatType::SYSTEM; + msg.language = game::ChatLanguage::UNIVERSAL; + msg.message = "Disembarked from transport."; + ctx.gameHandler.addLocalChatMessage(msg); + } else { + game::MessageChatData msg; + msg.type = game::ChatType::SYSTEM; + msg.language = game::ChatLanguage::UNIVERSAL; + msg.message = "You are not on a transport."; + ctx.gameHandler.addLocalChatMessage(msg); + } + return {}; + } + // Unrecognized sub-command + return {false, false}; + } + std::vector aliases() const override { return {"transport"}; } + std::string helpText() const override { return "Transport: /transport board|leave"; } +}; + +// --- Registration --- +void registerMiscCommands(ChatCommandRegistry& reg) { + reg.registerCommand(std::make_unique()); + reg.registerCommand(std::make_unique()); + reg.registerCommand(std::make_unique()); + reg.registerCommand(std::make_unique()); + reg.registerCommand(std::make_unique()); + reg.registerCommand(std::make_unique()); + reg.registerCommand(std::make_unique()); + reg.registerCommand(std::make_unique()); + reg.registerCommand(std::make_unique()); + reg.registerCommand(std::make_unique()); + reg.registerCommand(std::make_unique()); + reg.registerCommand(std::make_unique()); + reg.registerCommand(std::make_unique()); + reg.registerCommand(std::make_unique()); + reg.registerCommand(std::make_unique()); + reg.registerCommand(std::make_unique()); + reg.registerCommand(std::make_unique()); + reg.registerCommand(std::make_unique()); + reg.registerCommand(std::make_unique()); +} + +} // namespace ui +} // namespace wowee diff --git a/src/ui/chat/commands/social_commands.cpp b/src/ui/chat/commands/social_commands.cpp new file mode 100644 index 00000000..af272848 --- /dev/null +++ b/src/ui/chat/commands/social_commands.cpp @@ -0,0 +1,211 @@ +// Social commands: /friend, /removefriend, /ignore, /unignore, /invite, /inspect, /who +// Moved from ChatPanel::sendChatMessage() if/else chain (Phase 3). +#include "ui/chat/i_chat_command.hpp" +#include "ui/chat_panel.hpp" +#include "game/game_handler.hpp" +#include +#include + +namespace wowee { namespace ui { + +// --- /invite --- +class InviteCommand : public IChatCommand { +public: + ChatCommandResult execute(ChatCommandContext& ctx) override { + if (ctx.args.empty()) return {false, false}; + ctx.gameHandler.inviteToGroup(ctx.args); + return {}; + } + std::vector aliases() const override { return {"invite"}; } + std::string helpText() const override { return "Invite player to group"; } +}; + +// --- /inspect --- +class InspectCommand : public IChatCommand { +public: + ChatCommandResult execute(ChatCommandContext& ctx) override { + ctx.gameHandler.inspectTarget(); + ctx.panel.getSlashCmds().showInspect = true; + return {}; + } + std::vector aliases() const override { return {"inspect"}; } + std::string helpText() const override { return "Inspect target's equipment"; } +}; + +// --- /friend, /addfriend --- +class FriendCommand : public IChatCommand { +public: + ChatCommandResult execute(ChatCommandContext& ctx) override { + if (!ctx.args.empty()) { + size_t subCmdSpace = ctx.args.find(' '); + if (ctx.fullCommand == "friend" && subCmdSpace != std::string::npos) { + std::string subCmd = ctx.args.substr(0, subCmdSpace); + std::transform(subCmd.begin(), subCmd.end(), subCmd.begin(), ::tolower); + if (subCmd == "add") { + ctx.gameHandler.addFriend(ctx.args.substr(subCmdSpace + 1)); + return {}; + } else if (subCmd == "remove" || subCmd == "delete" || subCmd == "rem") { + ctx.gameHandler.removeFriend(ctx.args.substr(subCmdSpace + 1)); + return {}; + } + } else { + // /addfriend name or /friend name (assume add) + ctx.gameHandler.addFriend(ctx.args); + return {}; + } + } + game::MessageChatData msg; + msg.type = game::ChatType::SYSTEM; + msg.language = game::ChatLanguage::UNIVERSAL; + msg.message = "Usage: /friend add or /friend remove "; + ctx.gameHandler.addLocalChatMessage(msg); + return {}; + } + std::vector aliases() const override { return {"friend", "addfriend"}; } + std::string helpText() const override { return "Add/remove friend"; } +}; + +// --- /removefriend, /delfriend, /remfriend --- +class RemoveFriendCommand : public IChatCommand { +public: + ChatCommandResult execute(ChatCommandContext& ctx) override { + if (!ctx.args.empty()) { + ctx.gameHandler.removeFriend(ctx.args); + return {}; + } + game::MessageChatData msg; + msg.type = game::ChatType::SYSTEM; + msg.language = game::ChatLanguage::UNIVERSAL; + msg.message = "Usage: /removefriend "; + ctx.gameHandler.addLocalChatMessage(msg); + return {}; + } + std::vector aliases() const override { return {"removefriend", "delfriend", "remfriend"}; } + std::string helpText() const override { return "Remove friend"; } +}; + +// --- /ignore --- +class IgnoreCommand : public IChatCommand { +public: + ChatCommandResult execute(ChatCommandContext& ctx) override { + if (!ctx.args.empty()) { + ctx.gameHandler.addIgnore(ctx.args); + return {}; + } + game::MessageChatData msg; + msg.type = game::ChatType::SYSTEM; + msg.language = game::ChatLanguage::UNIVERSAL; + msg.message = "Usage: /ignore "; + ctx.gameHandler.addLocalChatMessage(msg); + return {}; + } + std::vector aliases() const override { return {"ignore"}; } + std::string helpText() const override { return "Ignore player messages"; } +}; + +// --- /unignore --- +class UnignoreCommand : public IChatCommand { +public: + ChatCommandResult execute(ChatCommandContext& ctx) override { + if (!ctx.args.empty()) { + ctx.gameHandler.removeIgnore(ctx.args); + return {}; + } + game::MessageChatData msg; + msg.type = game::ChatType::SYSTEM; + msg.language = game::ChatLanguage::UNIVERSAL; + msg.message = "Usage: /unignore "; + ctx.gameHandler.addLocalChatMessage(msg); + return {}; + } + std::vector aliases() const override { return {"unignore"}; } + std::string helpText() const override { return "Unignore player"; } +}; + +// --- /who, /whois, /online, /players --- +class WhoCommand : public IChatCommand { +public: + ChatCommandResult execute(ChatCommandContext& ctx) override { + std::string query = ctx.args; + // Trim + size_t first = query.find_first_not_of(" \t\r\n"); + if (first == std::string::npos) query.clear(); + else { size_t last = query.find_last_not_of(" \t\r\n"); query = query.substr(first, last - first + 1); } + + if (ctx.fullCommand == "whois" && query.empty()) { + game::MessageChatData msg; + msg.type = game::ChatType::SYSTEM; + msg.language = game::ChatLanguage::UNIVERSAL; + msg.message = "Usage: /whois "; + ctx.gameHandler.addLocalChatMessage(msg); + return {}; + } + if (ctx.fullCommand == "who" && (query == "help" || query == "?")) { + game::MessageChatData msg; + msg.type = game::ChatType::SYSTEM; + msg.language = game::ChatLanguage::UNIVERSAL; + msg.message = "Who commands: /who [name/filter], /whois , /online"; + ctx.gameHandler.addLocalChatMessage(msg); + return {}; + } + ctx.gameHandler.queryWho(query); + ctx.panel.getSlashCmds().showWho = true; + return {}; + } + std::vector aliases() const override { return {"who", "whois", "online", "players"}; } + std::string helpText() const override { return "List online players"; } +}; + +// --- /duel --- +class DuelCommand : public IChatCommand { +public: + ChatCommandResult execute(ChatCommandContext& ctx) override { + if (ctx.gameHandler.hasTarget()) { + ctx.gameHandler.proposeDuel(ctx.gameHandler.getTargetGuid()); + } else { + game::MessageChatData msg; + msg.type = game::ChatType::SYSTEM; + msg.language = game::ChatLanguage::UNIVERSAL; + msg.message = "You must target a player to challenge to a duel."; + ctx.gameHandler.addLocalChatMessage(msg); + } + return {}; + } + std::vector aliases() const override { return {"duel"}; } + std::string helpText() const override { return "Challenge target to duel"; } +}; + +// --- /trade --- +class TradeCommand : public IChatCommand { +public: + ChatCommandResult execute(ChatCommandContext& ctx) override { + if (ctx.gameHandler.hasTarget()) { + ctx.gameHandler.initiateTrade(ctx.gameHandler.getTargetGuid()); + } else { + game::MessageChatData msg; + msg.type = game::ChatType::SYSTEM; + msg.language = game::ChatLanguage::UNIVERSAL; + msg.message = "You must target a player to trade with."; + ctx.gameHandler.addLocalChatMessage(msg); + } + return {}; + } + std::vector aliases() const override { return {"trade"}; } + std::string helpText() const override { return "Initiate trade with target"; } +}; + +// --- Registration --- +void registerSocialCommands(ChatCommandRegistry& reg) { + reg.registerCommand(std::make_unique()); + reg.registerCommand(std::make_unique()); + reg.registerCommand(std::make_unique()); + reg.registerCommand(std::make_unique()); + reg.registerCommand(std::make_unique()); + reg.registerCommand(std::make_unique()); + reg.registerCommand(std::make_unique()); + reg.registerCommand(std::make_unique()); + reg.registerCommand(std::make_unique()); +} + +} // namespace ui +} // namespace wowee diff --git a/src/ui/chat/commands/system_commands.cpp b/src/ui/chat/commands/system_commands.cpp new file mode 100644 index 00000000..1051c20c --- /dev/null +++ b/src/ui/chat/commands/system_commands.cpp @@ -0,0 +1,195 @@ +// System commands: /run, /dump, /reload, /stopmacro, /clear, /logout +// Moved from ChatPanel::sendChatMessage() if/else chain (Phase 3). +#include "ui/chat/i_chat_command.hpp" +#include "ui/chat_panel.hpp" +#include "ui/ui_services.hpp" +#include "game/game_handler.hpp" +#include "addons/addon_manager.hpp" +#include "core/application.hpp" +#include +#include + +namespace wowee { namespace ui { + +// Forward declaration of evaluateMacroConditionals (still in chat_panel_commands.cpp) +std::string evaluateMacroConditionals(const std::string& rawArg, + game::GameHandler& gameHandler, + uint64_t& targetOverride); + +// --- /run, /script --- +class RunCommand : public IChatCommand { +public: + ChatCommandResult execute(ChatCommandContext& ctx) override { + if (ctx.args.empty()) return {false, false}; + auto* am = ctx.services.addonManager; + if (am) { + am->runScript(ctx.args); + } else { + ctx.gameHandler.addUIError("Addon system not initialized."); + } + return {}; + } + std::vector aliases() const override { return {"run", "script"}; } + std::string helpText() const override { return "Execute Lua code"; } +}; + +// --- /dump, /print --- +class DumpCommand : public IChatCommand { +public: + ChatCommandResult execute(ChatCommandContext& ctx) override { + if (ctx.args.empty()) return {false, false}; + auto* am = ctx.services.addonManager; + if (am && am->isInitialized()) { + // Wrap expression in print(tostring(...)) to display the value + std::string wrapped = "local __v = " + ctx.args + + "; if type(__v) == 'table' then " + " local parts = {} " + " for k,v in pairs(__v) do parts[#parts+1] = tostring(k)..'='..tostring(v) end " + " print('{' .. table.concat(parts, ', ') .. '}') " + "else print(tostring(__v)) end"; + am->runScript(wrapped); + } else { + game::MessageChatData errMsg; + errMsg.type = game::ChatType::SYSTEM; + errMsg.language = game::ChatLanguage::UNIVERSAL; + errMsg.message = "Addon system not initialized."; + ctx.gameHandler.addLocalChatMessage(errMsg); + } + return {}; + } + std::vector aliases() const override { return {"dump", "print"}; } + std::string helpText() const override { return "Evaluate & print Lua expression"; } +}; + +// --- /reload, /reloadui, /rl --- +class ReloadCommand : public IChatCommand { +public: + ChatCommandResult execute(ChatCommandContext& ctx) override { + auto* am = ctx.services.addonManager; + if (am) { + am->reload(); + am->fireEvent("VARIABLES_LOADED"); + am->fireEvent("PLAYER_LOGIN"); + am->fireEvent("PLAYER_ENTERING_WORLD"); + game::MessageChatData rlMsg; + rlMsg.type = game::ChatType::SYSTEM; + rlMsg.language = game::ChatLanguage::UNIVERSAL; + rlMsg.message = "Interface reloaded."; + ctx.gameHandler.addLocalChatMessage(rlMsg); + } else { + game::MessageChatData rlMsg; + rlMsg.type = game::ChatType::SYSTEM; + rlMsg.language = game::ChatLanguage::UNIVERSAL; + rlMsg.message = "Addon system not available."; + ctx.gameHandler.addLocalChatMessage(rlMsg); + } + return {}; + } + std::vector aliases() const override { return {"reload", "reloadui", "rl"}; } + std::string helpText() const override { return "Reload all addons"; } +}; + +// --- /stopmacro [conditions] --- +class StopMacroCommand : public IChatCommand { +public: + ChatCommandResult execute(ChatCommandContext& ctx) override { + bool shouldStop = true; + if (!ctx.args.empty()) { + std::string condArg = ctx.args; + while (!condArg.empty() && condArg.front() == ' ') condArg.erase(condArg.begin()); + if (!condArg.empty() && condArg.front() == '[') { + // Append a sentinel action so evaluateMacroConditionals can signal a match. + uint64_t tgtOver = static_cast(-1); + std::string hit = evaluateMacroConditionals(condArg + " __stop__", ctx.gameHandler, tgtOver); + shouldStop = !hit.empty(); + } + } + if (shouldStop) ctx.panel.macroStopped() = true; + return {}; + } + std::vector aliases() const override { return {"stopmacro"}; } + std::string helpText() const override { return "Stop macro execution"; } +}; + +// --- /clear --- +class ClearCommand : public IChatCommand { +public: + ChatCommandResult execute(ChatCommandContext& ctx) override { + ctx.gameHandler.clearChatHistory(); + return {}; + } + std::vector aliases() const override { return {"clear"}; } + std::string helpText() const override { return "Clear chat history"; } +}; + +// --- /logout --- +class LogoutCommand : public IChatCommand { +public: + ChatCommandResult execute(ChatCommandContext& /*ctx*/) override { + core::Application::getInstance().logoutToLogin(); + return {}; + } + std::vector aliases() const override { return {"logout"}; } + std::string helpText() const override { return "Logout to login screen"; } +}; + +// --- /difficulty --- +class DifficultyCommand : public IChatCommand { +public: + ChatCommandResult execute(ChatCommandContext& ctx) override { + std::string arg = ctx.args; + // Trim whitespace + size_t first = arg.find_first_not_of(" \t"); + size_t last = arg.find_last_not_of(" \t"); + if (first != std::string::npos) + arg = arg.substr(first, last - first + 1); + else + arg.clear(); + for (auto& ch : arg) ch = static_cast(std::tolower(static_cast(ch))); + + uint32_t diff = 0; + bool valid = true; + if (arg == "normal" || arg == "0") diff = 0; + else if (arg == "heroic" || arg == "1") diff = 1; + else if (arg == "25" || arg == "25normal" || arg == "25man" || arg == "2") + diff = 2; + else if (arg == "25heroic" || arg == "25manheroic" || arg == "3") + diff = 3; + else valid = false; + + if (!valid || arg.empty()) { + game::MessageChatData msg; + msg.type = game::ChatType::SYSTEM; + msg.language = game::ChatLanguage::UNIVERSAL; + msg.message = "Usage: /difficulty normal|heroic|25|25heroic (0-3)"; + ctx.gameHandler.addLocalChatMessage(msg); + } else { + static constexpr const char* kDiffNames[] = { + "Normal (5-man)", "Heroic (5-man)", "Normal (25-man)", "Heroic (25-man)" + }; + game::MessageChatData msg; + msg.type = game::ChatType::SYSTEM; + msg.language = game::ChatLanguage::UNIVERSAL; + msg.message = std::string("Setting difficulty to: ") + kDiffNames[diff]; + ctx.gameHandler.addLocalChatMessage(msg); + ctx.gameHandler.sendSetDifficulty(diff); + } + return {}; + } + std::vector aliases() const override { return {"difficulty"}; } + std::string helpText() const override { return "Set dungeon difficulty"; } +}; + +// --- Registration --- +void registerSystemCommands(ChatCommandRegistry& reg) { + reg.registerCommand(std::make_unique()); + reg.registerCommand(std::make_unique()); + reg.registerCommand(std::make_unique()); + reg.registerCommand(std::make_unique()); + reg.registerCommand(std::make_unique()); + reg.registerCommand(std::make_unique()); + reg.registerCommand(std::make_unique()); +} + +} // namespace ui +} // namespace wowee diff --git a/src/ui/chat/commands/target_commands.cpp b/src/ui/chat/commands/target_commands.cpp new file mode 100644 index 00000000..23cdb88f --- /dev/null +++ b/src/ui/chat/commands/target_commands.cpp @@ -0,0 +1,252 @@ +// Target commands: /target, /cleartarget, /targetenemy, /targetfriend, +// /targetlasttarget, /targetlastenemy, /targetlastfriend, +// /focus, /clearfocus +// Moved from ChatPanel::sendChatMessage() if/else chain (Phase 3). +#include "ui/chat/i_chat_command.hpp" +#include "ui/chat_panel.hpp" +#include "game/game_handler.hpp" +#include "game/entity.hpp" +#include +#include +#include +#include + +namespace wowee { namespace ui { + +// Forward declaration of evaluateMacroConditionals (still in chat_panel_commands.cpp) +std::string evaluateMacroConditionals(const std::string& rawArg, + game::GameHandler& gameHandler, + uint64_t& targetOverride); + +namespace { + +// Trim leading/trailing whitespace. +inline void trimInPlace(std::string& s) { + while (!s.empty() && s.front() == ' ') s.erase(s.begin()); + while (!s.empty() && s.back() == ' ') s.pop_back(); +} + +// Search nearby visible entities by name (case-insensitive prefix match). +// Returns the GUID of the nearest matching unit, or 0 if none found. +uint64_t findNearestByName(game::GameHandler& gh, const std::string& targetArgLower) { + uint64_t bestGuid = 0; + float bestDist = std::numeric_limits::max(); + const auto& pmi = gh.getMovementInfo(); + for (const auto& [guid, entity] : gh.getEntityManager().getEntities()) { + if (!entity || entity->getType() == game::ObjectType::OBJECT) continue; + std::string name; + if (entity->getType() == game::ObjectType::PLAYER || + entity->getType() == game::ObjectType::UNIT) { + auto unit = std::static_pointer_cast(entity); + name = unit->getName(); + } + if (name.empty()) continue; + std::string nameLower = name; + for (char& c : nameLower) c = static_cast(std::tolower(static_cast(c))); + if (nameLower.find(targetArgLower) == 0) { + float dx = entity->getX() - pmi.x; + float dy = entity->getY() - pmi.y; + float dz = entity->getZ() - pmi.z; + float dist = dx*dx + dy*dy + dz*dz; + if (dist < bestDist) { + bestDist = dist; + bestGuid = guid; + } + } + } + return bestGuid; +} + +} // anon namespace + +// --- /target --- +class TargetCommand : public IChatCommand { +public: + ChatCommandResult execute(ChatCommandContext& ctx) override { + if (ctx.args.empty()) return {false, false}; + + std::string targetArg = ctx.args; + + // Evaluate conditionals if present + uint64_t targetCmdOverride = static_cast(-1); + if (!targetArg.empty() && targetArg.front() == '[') { + targetArg = evaluateMacroConditionals(targetArg, ctx.gameHandler, targetCmdOverride); + if (targetArg.empty() && targetCmdOverride == static_cast(-1)) return {}; + trimInPlace(targetArg); + } + + // If conditionals resolved to a specific GUID, target it directly + if (targetCmdOverride != static_cast(-1) && targetCmdOverride != 0) { + ctx.gameHandler.setTarget(targetCmdOverride); + return {}; + } + + if (targetArg.empty()) return {}; + + std::string targetArgLower = targetArg; + for (char& c : targetArgLower) c = static_cast(std::tolower(static_cast(c))); + + uint64_t bestGuid = findNearestByName(ctx.gameHandler, targetArgLower); + if (bestGuid) { + ctx.gameHandler.setTarget(bestGuid); + } else { + game::MessageChatData sysMsg; + sysMsg.type = game::ChatType::SYSTEM; + sysMsg.language = game::ChatLanguage::UNIVERSAL; + sysMsg.message = "No target matching '" + targetArg + "' found."; + ctx.gameHandler.addLocalChatMessage(sysMsg); + } + return {}; + } + std::vector aliases() const override { return {"target"}; } + std::string helpText() const override { return "Target unit by name"; } +}; + +// --- /cleartarget --- +class ClearTargetCommand : public IChatCommand { +public: + ChatCommandResult execute(ChatCommandContext& ctx) override { + bool condPass = true; + if (!ctx.args.empty()) { + std::string ctArg = ctx.args; + trimInPlace(ctArg); + if (!ctArg.empty() && ctArg.front() == '[') { + uint64_t ctOver = static_cast(-1); + std::string res = evaluateMacroConditionals(ctArg, ctx.gameHandler, ctOver); + condPass = !(res.empty() && ctOver == static_cast(-1)); + } + } + if (condPass) ctx.gameHandler.clearTarget(); + return {}; + } + std::vector aliases() const override { return {"cleartarget"}; } + std::string helpText() const override { return "Clear current target"; } +}; + +// --- /targetenemy --- +class TargetEnemyCommand : public IChatCommand { +public: + ChatCommandResult execute(ChatCommandContext& ctx) override { + ctx.gameHandler.targetEnemy(false); + return {}; + } + std::vector aliases() const override { return {"targetenemy"}; } + std::string helpText() const override { return "Cycle to next enemy"; } +}; + +// --- /targetfriend --- +class TargetFriendCommand : public IChatCommand { +public: + ChatCommandResult execute(ChatCommandContext& ctx) override { + ctx.gameHandler.targetFriend(false); + return {}; + } + std::vector aliases() const override { return {"targetfriend"}; } + std::string helpText() const override { return "Cycle to next friendly unit"; } +}; + +// --- /targetlasttarget, /targetlast --- +class TargetLastTargetCommand : public IChatCommand { +public: + ChatCommandResult execute(ChatCommandContext& ctx) override { + ctx.gameHandler.targetLastTarget(); + return {}; + } + std::vector aliases() const override { return {"targetlasttarget", "targetlast"}; } + std::string helpText() const override { return "Target previous target"; } +}; + +// --- /targetlastenemy --- +class TargetLastEnemyCommand : public IChatCommand { +public: + ChatCommandResult execute(ChatCommandContext& ctx) override { + ctx.gameHandler.targetEnemy(true); + return {}; + } + std::vector aliases() const override { return {"targetlastenemy"}; } + std::string helpText() const override { return "Cycle to previous enemy"; } +}; + +// --- /targetlastfriend --- +class TargetLastFriendCommand : public IChatCommand { +public: + ChatCommandResult execute(ChatCommandContext& ctx) override { + ctx.gameHandler.targetFriend(true); + return {}; + } + std::vector aliases() const override { return {"targetlastfriend"}; } + std::string helpText() const override { return "Cycle to previous friendly unit"; } +}; + +// --- /focus --- +class FocusCommand : public IChatCommand { +public: + ChatCommandResult execute(ChatCommandContext& ctx) override { + if (!ctx.args.empty()) { + std::string focusArg = ctx.args; + + // Evaluate conditionals if present + uint64_t focusCmdOverride = static_cast(-1); + if (!focusArg.empty() && focusArg.front() == '[') { + focusArg = evaluateMacroConditionals(focusArg, ctx.gameHandler, focusCmdOverride); + if (focusArg.empty() && focusCmdOverride == static_cast(-1)) return {}; + trimInPlace(focusArg); + } + + if (focusCmdOverride != static_cast(-1) && focusCmdOverride != 0) { + ctx.gameHandler.setFocus(focusCmdOverride); + } else if (!focusArg.empty()) { + std::string focusArgLower = focusArg; + for (char& c : focusArgLower) c = static_cast(std::tolower(static_cast(c))); + uint64_t bestGuid = findNearestByName(ctx.gameHandler, focusArgLower); + if (bestGuid) { + ctx.gameHandler.setFocus(bestGuid); + } else { + game::MessageChatData msg; + msg.type = game::ChatType::SYSTEM; + msg.language = game::ChatLanguage::UNIVERSAL; + msg.message = "No unit matching '" + focusArg + "' found."; + ctx.gameHandler.addLocalChatMessage(msg); + } + } + } else if (ctx.gameHandler.hasTarget()) { + ctx.gameHandler.setFocus(ctx.gameHandler.getTargetGuid()); + } else { + game::MessageChatData msg; + msg.type = game::ChatType::SYSTEM; + msg.language = game::ChatLanguage::UNIVERSAL; + msg.message = "You must target a unit to set as focus."; + ctx.gameHandler.addLocalChatMessage(msg); + } + return {}; + } + std::vector aliases() const override { return {"focus"}; } + std::string helpText() const override { return "Set focus target"; } +}; + +// --- /clearfocus --- +class ClearFocusCommand : public IChatCommand { +public: + ChatCommandResult execute(ChatCommandContext& ctx) override { + ctx.gameHandler.clearFocus(); + return {}; + } + std::vector aliases() const override { return {"clearfocus"}; } + std::string helpText() const override { return "Clear focus target"; } +}; + +// --- Registration --- +void registerTargetCommands(ChatCommandRegistry& reg) { + reg.registerCommand(std::make_unique()); + reg.registerCommand(std::make_unique()); + reg.registerCommand(std::make_unique()); + reg.registerCommand(std::make_unique()); + reg.registerCommand(std::make_unique()); + reg.registerCommand(std::make_unique()); + reg.registerCommand(std::make_unique()); + reg.registerCommand(std::make_unique()); + reg.registerCommand(std::make_unique()); +} + +} // namespace ui +} // namespace wowee diff --git a/src/ui/chat/game_state_adapter.cpp b/src/ui/chat/game_state_adapter.cpp new file mode 100644 index 00000000..8d9cdf24 --- /dev/null +++ b/src/ui/chat/game_state_adapter.cpp @@ -0,0 +1,114 @@ +// GameStateAdapter — concrete IGameState wrapping GameHandler + Renderer. +// Phase 4.2 of chat_panel_ref.md. +#include "ui/chat/game_state_adapter.hpp" +#include "game/game_handler.hpp" +#include "game/character.hpp" +#include "rendering/renderer.hpp" +#include +#include + +namespace wowee { namespace ui { + +GameStateAdapter::GameStateAdapter(game::GameHandler& gameHandler, + rendering::Renderer* renderer) + : gameHandler_(gameHandler), renderer_(renderer) {} + +// --- GUIDs --- +uint64_t GameStateAdapter::getPlayerGuid() const { return gameHandler_.getPlayerGuid(); } +uint64_t GameStateAdapter::getTargetGuid() const { return gameHandler_.getTargetGuid(); } +uint64_t GameStateAdapter::getFocusGuid() const { return gameHandler_.getFocusGuid(); } +uint64_t GameStateAdapter::getPetGuid() const { return gameHandler_.getPetGuid(); } +uint64_t GameStateAdapter::getMouseoverGuid() const { return gameHandler_.getMouseoverGuid(); } + +// --- Player state --- +bool GameStateAdapter::isInCombat() const { return gameHandler_.isInCombat(); } +bool GameStateAdapter::isMounted() const { return gameHandler_.isMounted(); } +bool GameStateAdapter::isSwimming() const { return gameHandler_.isSwimming(); } +bool GameStateAdapter::isFlying() const { return gameHandler_.isPlayerFlying(); } +bool GameStateAdapter::isCasting() const { return gameHandler_.isCasting(); } +bool GameStateAdapter::isChanneling() const { return gameHandler_.isChanneling(); } + +bool GameStateAdapter::isStealthed() const { + // Check UNIT_FLAG_SNEAKING (0x02000000) on player's Unit + auto pe = gameHandler_.getEntityManager().getEntity(gameHandler_.getPlayerGuid()); + if (!pe) return false; + auto pu = std::dynamic_pointer_cast(pe); + return pu && (pu->getUnitFlags() & 0x02000000u) != 0; +} + +bool GameStateAdapter::hasPet() const { return gameHandler_.hasPet(); } +bool GameStateAdapter::isInGroup() const { return gameHandler_.isInGroup(); } + +bool GameStateAdapter::isInRaid() const { + return gameHandler_.isInGroup() && gameHandler_.getPartyData().groupType == 1; +} + +bool GameStateAdapter::isIndoors() const { + return renderer_ && renderer_->isPlayerIndoors(); +} + +// --- Numeric --- +uint8_t GameStateAdapter::getActiveTalentSpec() const { return gameHandler_.getActiveTalentSpec(); } +uint32_t GameStateAdapter::getVehicleId() const { return gameHandler_.getVehicleId(); } +uint32_t GameStateAdapter::getCurrentCastSpellId() const { return gameHandler_.getCurrentCastSpellId(); } + +// --- Spell/aura --- +std::string GameStateAdapter::getSpellName(uint32_t spellId) const { + return gameHandler_.getSpellName(spellId); +} + +bool GameStateAdapter::hasAuraByName(uint64_t targetGuid, const std::string& spellName, + bool wantDebuff) const { + // If targetGuid is player or invalid, check player auras; otherwise target auras + const std::vector* auras = nullptr; + uint64_t playerGuid = gameHandler_.getPlayerGuid(); + if (targetGuid != static_cast(-1) && targetGuid != 0 && + targetGuid != playerGuid) { + auras = &gameHandler_.getTargetAuras(); + } else { + auras = &gameHandler_.getPlayerAuras(); + } + + std::string nameLow = spellName; + for (char& ch : nameLow) ch = static_cast(std::tolower(static_cast(ch))); + + for (const auto& a : *auras) { + if (a.isEmpty() || a.spellId == 0) continue; + bool isDebuff = (a.flags & 0x80) != 0; + if (wantDebuff ? !isDebuff : isDebuff) continue; + std::string sn = gameHandler_.getSpellName(a.spellId); + for (char& ch : sn) ch = static_cast(std::tolower(static_cast(ch))); + if (sn == nameLow) return true; + } + return false; +} + +bool GameStateAdapter::hasFormAura() const { + for (const auto& a : gameHandler_.getPlayerAuras()) { + if (!a.isEmpty() && a.maxDurationMs == -1) return true; + } + return false; +} + +// --- Entity queries --- +bool GameStateAdapter::entityExists(uint64_t guid) const { + if (guid == 0 || guid == static_cast(-1)) return false; + return gameHandler_.getEntityManager().getEntity(guid) != nullptr; +} + +bool GameStateAdapter::entityIsDead(uint64_t guid) const { + auto entity = gameHandler_.getEntityManager().getEntity(guid); + if (!entity) return false; + auto unit = std::dynamic_pointer_cast(entity); + return unit && unit->getHealth() == 0; +} + +bool GameStateAdapter::entityIsHostile(uint64_t guid) const { + auto entity = gameHandler_.getEntityManager().getEntity(guid); + if (!entity) return false; + auto unit = std::dynamic_pointer_cast(entity); + return unit && gameHandler_.isHostileFactionPublic(unit->getFactionTemplate()); +} + +} // namespace ui +} // namespace wowee diff --git a/src/ui/chat/input_modifier_adapter.cpp b/src/ui/chat/input_modifier_adapter.cpp new file mode 100644 index 00000000..f0b6aa4a --- /dev/null +++ b/src/ui/chat/input_modifier_adapter.cpp @@ -0,0 +1,28 @@ +// InputModifierAdapter — concrete IModifierState wrapping core::Input. +// Phase 4.3 of chat_panel_ref.md. +#include "ui/chat/input_modifier_adapter.hpp" +#include "core/input.hpp" +#include + +namespace wowee { namespace ui { + +bool InputModifierAdapter::isShiftHeld() const { + auto& input = core::Input::getInstance(); + return input.isKeyPressed(SDL_SCANCODE_LSHIFT) || + input.isKeyPressed(SDL_SCANCODE_RSHIFT); +} + +bool InputModifierAdapter::isCtrlHeld() const { + auto& input = core::Input::getInstance(); + return input.isKeyPressed(SDL_SCANCODE_LCTRL) || + input.isKeyPressed(SDL_SCANCODE_RCTRL); +} + +bool InputModifierAdapter::isAltHeld() const { + auto& input = core::Input::getInstance(); + return input.isKeyPressed(SDL_SCANCODE_LALT) || + input.isKeyPressed(SDL_SCANCODE_RALT); +} + +} // namespace ui +} // namespace wowee diff --git a/src/ui/chat/macro_evaluator.cpp b/src/ui/chat/macro_evaluator.cpp new file mode 100644 index 00000000..37c7a679 --- /dev/null +++ b/src/ui/chat/macro_evaluator.cpp @@ -0,0 +1,223 @@ +// MacroEvaluator — WoW macro conditional parser and evaluator. +// Moved from evaluateMacroConditionals() in chat_panel_commands.cpp (Phase 4.4). +#include "ui/chat/macro_evaluator.hpp" +#include "ui/chat/i_game_state.hpp" +#include "ui/chat/i_modifier_state.hpp" +#include +#include +#include + +namespace wowee { namespace ui { + +MacroEvaluator::MacroEvaluator(IGameState& gameState, IModifierState& modState) + : gameState_(gameState), modState_(modState) {} + +uint64_t MacroEvaluator::resolveEffectiveTarget(uint64_t tgt) const { + if (tgt != static_cast(-1) && tgt != 0) + return tgt; + return gameState_.getTargetGuid(); +} + +bool MacroEvaluator::evalCondition(const std::string& raw, uint64_t& tgt) const { + // Trim + std::string c = raw; + size_t s = c.find_first_not_of(" \t"); + c = (s != std::string::npos) ? c.substr(s) : ""; + size_t e = c.find_last_not_of(" \t"); + if (e != std::string::npos) c.resize(e + 1); + if (c.empty()) return true; + + // --- @target specifiers --- + if (c[0] == '@') { + std::string spec = c.substr(1); + if (spec == "player") tgt = gameState_.getPlayerGuid(); + else if (spec == "focus") tgt = gameState_.getFocusGuid(); + else if (spec == "target") tgt = gameState_.getTargetGuid(); + else if (spec == "pet") { + uint64_t pg = gameState_.getPetGuid(); + if (pg != 0) tgt = pg; else return false; + } + else if (spec == "mouseover") { + uint64_t mo = gameState_.getMouseoverGuid(); + if (mo != 0) tgt = mo; else return false; + } + return true; + } + + // --- target=X specifiers --- + if (c.rfind("target=", 0) == 0) { + std::string spec = c.substr(7); + if (spec == "player") tgt = gameState_.getPlayerGuid(); + else if (spec == "focus") tgt = gameState_.getFocusGuid(); + else if (spec == "target") tgt = gameState_.getTargetGuid(); + else if (spec == "pet") { + uint64_t pg = gameState_.getPetGuid(); + if (pg != 0) tgt = pg; else return false; + } + else if (spec == "mouseover") { + uint64_t mo = gameState_.getMouseoverGuid(); + if (mo != 0) tgt = mo; else return false; + } + return true; + } + + // --- Modifier keys --- + const bool shiftHeld = modState_.isShiftHeld(); + const bool ctrlHeld = modState_.isCtrlHeld(); + const bool altHeld = modState_.isAltHeld(); + const bool anyMod = shiftHeld || ctrlHeld || altHeld; + + if (c == "nomod" || c == "mod:none") return !anyMod; + if (c.rfind("mod:", 0) == 0) { + std::string mods = c.substr(4); + bool ok = true; + if (mods.find("shift") != std::string::npos && !shiftHeld) ok = false; + if (mods.find("ctrl") != std::string::npos && !ctrlHeld) ok = false; + if (mods.find("alt") != std::string::npos && !altHeld) ok = false; + return ok; + } + + // --- Combat --- + if (c == "combat") return gameState_.isInCombat(); + if (c == "nocombat") return !gameState_.isInCombat(); + + // --- Effective target for exists/dead/help/harm --- + uint64_t eff = resolveEffectiveTarget(tgt); + + if (c == "exists") return gameState_.entityExists(eff); + if (c == "noexists") return !gameState_.entityExists(eff); + + if (c == "dead") return gameState_.entityIsDead(eff); + if (c == "nodead") return !gameState_.entityIsDead(eff); + + if (c == "harm" || c == "nohelp") return gameState_.entityIsHostile(eff); + if (c == "help" || c == "noharm") return !gameState_.entityIsHostile(eff); + + // --- Mounted / swimming / flying --- + if (c == "mounted") return gameState_.isMounted(); + if (c == "nomounted") return !gameState_.isMounted(); + if (c == "swimming") return gameState_.isSwimming(); + if (c == "noswimming") return !gameState_.isSwimming(); + if (c == "flying") return gameState_.isFlying(); + if (c == "noflying") return !gameState_.isFlying(); + + // --- Channeling / casting --- + if (c == "channeling") return gameState_.isCasting() && gameState_.isChanneling(); + if (c == "nochanneling") return !(gameState_.isCasting() && gameState_.isChanneling()); + if (c.rfind("channeling:", 0) == 0 && c.size() > 11) { + if (!gameState_.isChanneling()) return false; + std::string want = c.substr(11); + for (char& ch : want) ch = static_cast(std::tolower(static_cast(ch))); + uint32_t castSpellId = gameState_.getCurrentCastSpellId(); + std::string sn = gameState_.getSpellName(castSpellId); + for (char& ch : sn) ch = static_cast(std::tolower(static_cast(ch))); + return sn == want; + } + if (c == "casting") return gameState_.isCasting(); + if (c == "nocasting") return !gameState_.isCasting(); + + // --- Stealthed --- + if (c == "stealthed") return gameState_.isStealthed(); + if (c == "nostealthed") return !gameState_.isStealthed(); + + // --- Pet --- + if (c == "pet") return gameState_.hasPet(); + if (c == "nopet") return !gameState_.hasPet(); + + // --- Indoors / outdoors --- + if (c == "indoors" || c == "nooutdoors") return gameState_.isIndoors(); + if (c == "outdoors" || c == "noindoors") return !gameState_.isIndoors(); + + // --- Group / raid --- + if (c == "group" || c == "party") return gameState_.isInGroup(); + if (c == "nogroup") return !gameState_.isInGroup(); + if (c == "raid") return gameState_.isInRaid(); + if (c == "noraid") return !gameState_.isInRaid(); + + // --- Talent spec --- + if (c.rfind("spec:", 0) == 0) { + uint8_t wantSpec = 0; + try { wantSpec = static_cast(std::stoul(c.substr(5))); } catch (...) {} + return wantSpec > 0 && gameState_.getActiveTalentSpec() == (wantSpec - 1); + } + + // --- Form / stance --- + if (c == "noform" || c == "nostance" || c == "form:0" || c == "stance:0") + return !gameState_.hasFormAura(); + + // --- Buff / debuff --- + if (c.rfind("buff:", 0) == 0 && c.size() > 5) + return gameState_.hasAuraByName(tgt, c.substr(5), false); + if (c.rfind("nobuff:", 0) == 0 && c.size() > 7) + return !gameState_.hasAuraByName(tgt, c.substr(7), false); + if (c.rfind("debuff:", 0) == 0 && c.size() > 7) + return gameState_.hasAuraByName(tgt, c.substr(7), true); + if (c.rfind("nodebuff:", 0) == 0 && c.size() > 9) + return !gameState_.hasAuraByName(tgt, c.substr(9), true); + + // --- Vehicle --- + if (c == "vehicle") return gameState_.getVehicleId() != 0; + if (c == "novehicle") return gameState_.getVehicleId() == 0; + + // Unknown → permissive (don't block) + return true; +} + +std::string MacroEvaluator::evaluate(const std::string& rawArg, + uint64_t& targetOverride) const { + targetOverride = static_cast(-1); + + // Split rawArg on ';' → alternatives + std::vector alts; + { + std::string cur; + for (char ch : rawArg) { + if (ch == ';') { alts.push_back(cur); cur.clear(); } + else cur += ch; + } + alts.push_back(cur); + } + + for (auto& alt : alts) { + // Trim + size_t fs = alt.find_first_not_of(" \t"); + if (fs == std::string::npos) continue; + alt = alt.substr(fs); + size_t ls = alt.find_last_not_of(" \t"); + if (ls != std::string::npos) alt.resize(ls + 1); + + if (!alt.empty() && alt[0] == '[') { + size_t close = alt.find(']'); + if (close == std::string::npos) continue; + std::string condStr = alt.substr(1, close - 1); + std::string argPart = alt.substr(close + 1); + // Trim argPart + size_t as = argPart.find_first_not_of(" \t"); + argPart = (as != std::string::npos) ? argPart.substr(as) : ""; + + // Evaluate comma-separated conditions + uint64_t tgt = static_cast(-1); + bool pass = true; + size_t cp = 0; + while (pass) { + size_t comma = condStr.find(',', cp); + std::string tok = condStr.substr(cp, + comma == std::string::npos ? std::string::npos : comma - cp); + if (!evalCondition(tok, tgt)) { pass = false; break; } + if (comma == std::string::npos) break; + cp = comma + 1; + } + if (pass) { + if (tgt != static_cast(-1)) targetOverride = tgt; + return argPart; + } + } else { + // No condition block — default fallback always matches + return alt; + } + } + return {}; +} + +} // namespace ui +} // namespace wowee diff --git a/src/ui/chat_panel.cpp b/src/ui/chat_panel.cpp index c5d9fadb..632dac66 100644 --- a/src/ui/chat_panel.cpp +++ b/src/ui/chat_panel.cpp @@ -3,6 +3,7 @@ #include "ui/spellbook_screen.hpp" #include "ui/quest_log_screen.hpp" #include "ui/ui_colors.hpp" +#include "ui/chat/gm_command_data.hpp" #include "rendering/vk_context.hpp" #include "core/application.hpp" #include "addons/addon_manager.hpp" @@ -51,67 +52,20 @@ namespace { namespace wowee { namespace ui { ChatPanel::ChatPanel() { - initChatTabs(); + // ChatTabManager constructor handles tab initialization + registerAllCommands(); } -void ChatPanel::initChatTabs() { - chatTabs_.clear(); - // General tab: shows everything - chatTabs_.push_back({"General", ~0ULL}); - // Combat tab: system, loot, skills, achievements, and NPC speech/emotes - chatTabs_.push_back({"Combat", (1ULL << static_cast(game::ChatType::SYSTEM)) | - (1ULL << static_cast(game::ChatType::LOOT)) | - (1ULL << static_cast(game::ChatType::SKILL)) | - (1ULL << static_cast(game::ChatType::ACHIEVEMENT)) | - (1ULL << static_cast(game::ChatType::GUILD_ACHIEVEMENT)) | - (1ULL << static_cast(game::ChatType::MONSTER_SAY)) | - (1ULL << static_cast(game::ChatType::MONSTER_YELL)) | - (1ULL << static_cast(game::ChatType::MONSTER_EMOTE)) | - (1ULL << static_cast(game::ChatType::MONSTER_WHISPER)) | - (1ULL << static_cast(game::ChatType::MONSTER_PARTY)) | - (1ULL << static_cast(game::ChatType::RAID_BOSS_WHISPER)) | - (1ULL << static_cast(game::ChatType::RAID_BOSS_EMOTE))}); - // Whispers tab - chatTabs_.push_back({"Whispers", (1ULL << static_cast(game::ChatType::WHISPER)) | - (1ULL << static_cast(game::ChatType::WHISPER_INFORM))}); - // Guild tab: guild and officer chat - chatTabs_.push_back({"Guild", (1ULL << static_cast(game::ChatType::GUILD)) | - (1ULL << static_cast(game::ChatType::OFFICER)) | - (1ULL << static_cast(game::ChatType::GUILD_ACHIEVEMENT))}); - // Trade/LFG tab: channel messages - chatTabs_.push_back({"Trade/LFG", (1ULL << static_cast(game::ChatType::CHANNEL))}); - // Reset unread counts to match new tab list - chatTabUnread_.assign(chatTabs_.size(), 0); - chatTabSeenCount_ = 0; -} - -bool ChatPanel::shouldShowMessage(const game::MessageChatData& msg, int tabIndex) const { - if (tabIndex < 0 || tabIndex >= static_cast(chatTabs_.size())) return true; - const auto& tab = chatTabs_[tabIndex]; - if (tab.typeMask == ~0ULL) return true; // General tab shows all - - uint64_t typeBit = 1ULL << static_cast(msg.type); - - // For Trade/LFG tab (now index 4), also filter by channel name - if (tabIndex == 4 && msg.type == game::ChatType::CHANNEL) { - const std::string& ch = msg.channelName; - if (ch.find("Trade") == std::string::npos && - ch.find("General") == std::string::npos && - ch.find("LookingForGroup") == std::string::npos && - ch.find("Local") == std::string::npos) { - return false; - } - return true; - } - - return (tab.typeMask & typeBit) != 0; -} +// Tab init and filtering moved to ChatTabManager (Phase 1.3) void ChatPanel::render(game::GameHandler& gameHandler, InventoryScreen& inventoryScreen, SpellbookScreen& spellbookScreen, QuestLogScreen& questLogScreen) { + // Cache game handler for input callback lambda (player name tab-completion) + cachedGameHandler_ = &gameHandler; + auto* window = services_.window; auto* assetMgr = services_.assetManager; float screenW = window ? static_cast(window->getWidth()) : 1280.0f; @@ -133,7 +87,7 @@ void ChatPanel::render(game::GameHandler& gameHandler, ImGui::SetNextWindowSize(ImVec2(chatW, chatH), ImGuiCond_FirstUseEver); ImGui::SetNextWindowPos(chatWindowPos_, ImGuiCond_FirstUseEver); } - ImGuiWindowFlags flags = kDialogFlags; + ImGuiWindowFlags flags = kDialogFlags | ImGuiWindowFlags_NoNavInputs; if (chatWindowLocked_) { flags |= ImGuiWindowFlags_NoMove | ImGuiWindowFlags_NoTitleBar; } @@ -143,37 +97,20 @@ void ChatPanel::render(game::GameHandler& gameHandler, chatWindowPos_ = ImGui::GetWindowPos(); } - // Update unread counts: scan any new messages since last frame - { - const auto& history = gameHandler.getChatHistory(); - // Ensure unread array is sized correctly (guards against late init) - if (chatTabUnread_.size() != chatTabs_.size()) - chatTabUnread_.assign(chatTabs_.size(), 0); - // If history shrank (e.g. cleared), reset - if (chatTabSeenCount_ > history.size()) chatTabSeenCount_ = 0; - for (size_t mi = chatTabSeenCount_; mi < history.size(); ++mi) { - const auto& msg = history[mi]; - // For each non-General (non-0) tab that isn't currently active, check visibility - for (int ti = 1; ti < static_cast(chatTabs_.size()); ++ti) { - if (ti == activeChatTab) continue; - if (shouldShowMessage(msg, ti)) { - chatTabUnread_[ti]++; - } - } - } - chatTabSeenCount_ = history.size(); - } + // Update unread counts via ChatTabManager (Phase 1.3) + tabManager_.updateUnread(gameHandler.getChatHistory(), activeChatTab); - // Chat tabs + // Chat tabs (rendered via ChatTabManager) if (ImGui::BeginTabBar("ChatTabs")) { - for (int i = 0; i < static_cast(chatTabs_.size()); ++i) { + for (int i = 0; i < tabManager_.getTabCount(); ++i) { // Build label with unread count suffix for non-General tabs - std::string tabLabel = chatTabs_[i].name; - if (i > 0 && i < static_cast(chatTabUnread_.size()) && chatTabUnread_[i] > 0) { - tabLabel += " (" + std::to_string(chatTabUnread_[i]) + ")"; + std::string tabLabel = tabManager_.getTabName(i); + int unread = tabManager_.getUnreadCount(i); + if (i > 0 && unread > 0) { + tabLabel += " (" + std::to_string(unread) + ")"; } // Flash tab text color when unread messages exist - bool hasUnread = (i > 0 && i < static_cast(chatTabUnread_.size()) && chatTabUnread_[i] > 0); + bool hasUnread = (i > 0 && unread > 0); if (hasUnread) { float pulse = 0.6f + 0.4f * std::sin(static_cast(ImGui::GetTime()) * 4.0f); ImGui::PushStyleColor(ImGuiCol_Text, ImVec4(1.0f, 0.85f * pulse, 0.2f * pulse, 1.0f)); @@ -182,8 +119,7 @@ void ChatPanel::render(game::GameHandler& gameHandler, if (activeChatTab != i) { activeChatTab = i; // Clear unread count when tab becomes active - if (i < static_cast(chatTabUnread_.size())) - chatTabUnread_[i] = 0; + tabManager_.clearUnread(i); } ImGui::EndTabItem(); } @@ -202,806 +138,17 @@ void ChatPanel::render(game::GameHandler& gameHandler, ImGui::BeginChild("ChatHistory", ImVec2(0, -70), true, ImGuiWindowFlags_HorizontalScrollbar); bool chatHistoryHovered = ImGui::IsWindowHovered(ImGuiHoveredFlags_AllowWhenBlockedByActiveItem); - // Helper: parse WoW color code |cAARRGGBB → ImVec4 - auto parseWowColor = [](const std::string& text, size_t pos) -> ImVec4 { - // |cAARRGGBB (10 chars total: |c + 8 hex) - if (pos + 10 > text.size()) return colors::kWhite; - auto hexByte = [&](size_t offset) -> float { - const char* s = text.c_str() + pos + offset; - char buf[3] = {s[0], s[1], '\0'}; - return static_cast(strtol(buf, nullptr, 16)) / 255.0f; - }; - float a = hexByte(2); - float r = hexByte(4); - float g = hexByte(6); - float b = hexByte(8); - return ImVec4(r, g, b, a); - }; - - // Helper: render an item tooltip from ItemQueryResponseData - auto renderItemLinkTooltip = [&](uint32_t itemEntry) { - const auto* info = gameHandler.getItemInfo(itemEntry); - if (!info || !info->valid) return; - auto findComparableEquipped = [&](uint8_t inventoryType) -> const game::ItemSlot* { - using ES = game::EquipSlot; - const auto& inv = gameHandler.getInventory(); - auto slotPtr = [&](ES slot) -> const game::ItemSlot* { - const auto& s = inv.getEquipSlot(slot); - return s.empty() ? nullptr : &s; - }; - switch (inventoryType) { - case 1: return slotPtr(ES::HEAD); - case 2: return slotPtr(ES::NECK); - case 3: return slotPtr(ES::SHOULDERS); - case 4: return slotPtr(ES::SHIRT); - case 5: - case 20: return slotPtr(ES::CHEST); - case 6: return slotPtr(ES::WAIST); - case 7: return slotPtr(ES::LEGS); - case 8: return slotPtr(ES::FEET); - case 9: return slotPtr(ES::WRISTS); - case 10: return slotPtr(ES::HANDS); - case 11: { - if (auto* s = slotPtr(ES::RING1)) return s; - return slotPtr(ES::RING2); - } - case 12: { - if (auto* s = slotPtr(ES::TRINKET1)) return s; - return slotPtr(ES::TRINKET2); - } - case 13: - if (auto* s = slotPtr(ES::MAIN_HAND)) return s; - return slotPtr(ES::OFF_HAND); - case 14: - case 22: - case 23: return slotPtr(ES::OFF_HAND); - case 15: - case 25: - case 26: return slotPtr(ES::RANGED); - case 16: return slotPtr(ES::BACK); - case 17: - case 21: return slotPtr(ES::MAIN_HAND); - case 18: - for (int i = 0; i < game::Inventory::NUM_BAG_SLOTS; ++i) { - auto slot = static_cast(static_cast(ES::BAG1) + i); - if (auto* s = slotPtr(slot)) return s; - } - return nullptr; - case 19: return slotPtr(ES::TABARD); - default: return nullptr; - } - }; - - ImGui::BeginTooltip(); - // Quality color for name - auto qColor = ui::getQualityColor(static_cast(info->quality)); - ImGui::TextColored(qColor, "%s", info->name.c_str()); - - // Heroic indicator (green, matches WoW tooltip style) - constexpr uint32_t kFlagHeroic = 0x8; - constexpr uint32_t kFlagUniqueEquipped = 0x1000000; - if (info->itemFlags & kFlagHeroic) - ImGui::TextColored(ImVec4(0.0f, 0.8f, 0.0f, 1.0f), "Heroic"); - - // Bind type (appears right under name in WoW) - switch (info->bindType) { - case 1: ImGui::TextDisabled("Binds when picked up"); break; - case 2: ImGui::TextDisabled("Binds when equipped"); break; - case 3: ImGui::TextDisabled("Binds when used"); break; - case 4: ImGui::TextDisabled("Quest Item"); break; - } - // Unique / Unique-Equipped - if (info->maxCount == 1) - ImGui::TextColored(ui::colors::kTooltipGold, "Unique"); - else if (info->itemFlags & kFlagUniqueEquipped) - ImGui::TextColored(ui::colors::kTooltipGold, "Unique-Equipped"); - - // Slot type - if (info->inventoryType > 0) { - const char* slotName = ui::getInventorySlotName(info->inventoryType); - if (slotName[0]) { - if (!info->subclassName.empty()) - ImGui::TextColored(ui::colors::kLightGray, "%s %s", slotName, info->subclassName.c_str()); - else - ImGui::TextColored(ui::colors::kLightGray, "%s", slotName); - } - } - auto isWeaponInventoryType = [](uint32_t invType) { - switch (invType) { - case 13: // One-Hand - case 15: // Ranged - case 17: // Two-Hand - case 21: // Main Hand - case 25: // Thrown - case 26: // Ranged Right - return true; - default: - return false; - } - }; - const bool isWeapon = isWeaponInventoryType(info->inventoryType); - - // Item level (after slot/subclass) - if (info->itemLevel > 0) - ImGui::TextDisabled("Item Level %u", info->itemLevel); - - if (isWeapon && info->damageMax > 0.0f && info->delayMs > 0) { - float speed = static_cast(info->delayMs) / 1000.0f; - float dps = ((info->damageMin + info->damageMax) * 0.5f) / speed; - // WoW-style: "22 - 41 Damage" with speed right-aligned on same row - char dmgBuf[64], spdBuf[32]; - std::snprintf(dmgBuf, sizeof(dmgBuf), "%d - %d Damage", - static_cast(info->damageMin), static_cast(info->damageMax)); - std::snprintf(spdBuf, sizeof(spdBuf), "Speed %.2f", speed); - float spdW = ImGui::CalcTextSize(spdBuf).x; - ImGui::Text("%s", dmgBuf); - ImGui::SameLine(ImGui::GetWindowWidth() - spdW - 16.0f); - ImGui::Text("%s", spdBuf); - ImGui::TextDisabled("(%.1f damage per second)", dps); - } - ImVec4 green(0.0f, 1.0f, 0.0f, 1.0f); - auto appendBonus = [](std::string& out, int32_t val, const char* shortName) { - if (val <= 0) return; - if (!out.empty()) out += " "; - out += "+" + std::to_string(val) + " "; - out += shortName; - }; - std::string bonusLine; - appendBonus(bonusLine, info->strength, "Str"); - appendBonus(bonusLine, info->agility, "Agi"); - appendBonus(bonusLine, info->stamina, "Sta"); - appendBonus(bonusLine, info->intellect, "Int"); - appendBonus(bonusLine, info->spirit, "Spi"); - if (!bonusLine.empty()) { - ImGui::TextColored(green, "%s", bonusLine.c_str()); - } - if (info->armor > 0) { - ImGui::Text("%d Armor", info->armor); - } - // Elemental resistances (fire resist gear, nature resist gear, etc.) - { - const int32_t resVals[6] = { - info->holyRes, info->fireRes, info->natureRes, - info->frostRes, info->shadowRes, info->arcaneRes - }; - static constexpr const char* resLabels[6] = { - "Holy Resistance", "Fire Resistance", "Nature Resistance", - "Frost Resistance", "Shadow Resistance", "Arcane Resistance" - }; - for (int ri = 0; ri < 6; ++ri) - if (resVals[ri] > 0) ImGui::Text("+%d %s", resVals[ri], resLabels[ri]); - } - // Extra stats (hit/crit/haste/sp/ap/expertise/resilience/etc.) - if (!info->extraStats.empty()) { - auto statName = [](uint32_t t) -> const char* { - switch (t) { - case 12: return "Defense Rating"; - case 13: return "Dodge Rating"; - case 14: return "Parry Rating"; - case 15: return "Block Rating"; - case 16: case 17: case 18: case 31: return "Hit Rating"; - case 19: case 20: case 21: case 32: return "Critical Strike Rating"; - case 28: case 29: case 30: case 35: return "Haste Rating"; - case 34: return "Resilience Rating"; - case 36: return "Expertise Rating"; - case 37: return "Attack Power"; - case 38: return "Ranged Attack Power"; - case 45: return "Spell Power"; - case 46: return "Healing Power"; - case 47: return "Spell Damage"; - case 49: return "Mana per 5 sec."; - case 43: return "Spell Penetration"; - case 44: return "Block Value"; - default: return nullptr; - } - }; - for (const auto& es : info->extraStats) { - const char* nm = statName(es.statType); - if (nm && es.statValue > 0) - ImGui::TextColored(green, "+%d %s", es.statValue, nm); - } - } - // Gem sockets (WotLK only — socketColor != 0 means socket present) - // socketColor bitmask: 1=Meta, 2=Red, 4=Yellow, 8=Blue - { - const auto& kSocketTypes = ui::kSocketTypes; - bool hasSocket = false; - for (int s = 0; s < 3; ++s) { - if (info->socketColor[s] == 0) continue; - if (!hasSocket) { ImGui::Spacing(); hasSocket = true; } - for (const auto& st : kSocketTypes) { - if (info->socketColor[s] & st.mask) { - ImGui::TextColored(st.col, "%s", st.label); - break; - } - } - } - if (hasSocket && info->socketBonus != 0) { - // Socket bonus ID maps to SpellItemEnchantment.dbc — lazy-load names - static std::unordered_map s_enchantNames; - static bool s_enchantNamesLoaded = false; - if (!s_enchantNamesLoaded && assetMgr) { - s_enchantNamesLoaded = true; - auto dbc = assetMgr->loadDBC("SpellItemEnchantment.dbc"); - if (dbc && dbc->isLoaded()) { - const auto* lay = pipeline::getActiveDBCLayout() - ? pipeline::getActiveDBCLayout()->getLayout("SpellItemEnchantment") : nullptr; - uint32_t nameField = lay ? lay->field("Name") : 8u; - if (nameField == 0xFFFFFFFF) nameField = 8; - uint32_t fc = dbc->getFieldCount(); - for (uint32_t r = 0; r < dbc->getRecordCount(); ++r) { - uint32_t eid = dbc->getUInt32(r, 0); - if (eid == 0 || nameField >= fc) continue; - std::string ename = dbc->getString(r, nameField); - if (!ename.empty()) s_enchantNames[eid] = std::move(ename); - } - } - } - auto enchIt = s_enchantNames.find(info->socketBonus); - if (enchIt != s_enchantNames.end()) - ImGui::TextColored(colors::kSocketGreen, "Socket Bonus: %s", enchIt->second.c_str()); - else - ImGui::TextColored(colors::kSocketGreen, "Socket Bonus: (id %u)", info->socketBonus); - } - } - // Item set membership - if (info->itemSetId != 0) { - struct SetEntry { - std::string name; - std::array itemIds{}; - std::array spellIds{}; - std::array thresholds{}; - }; - static std::unordered_map s_setData; - static bool s_setDataLoaded = false; - if (!s_setDataLoaded && assetMgr) { - s_setDataLoaded = true; - auto dbc = assetMgr->loadDBC("ItemSet.dbc"); - if (dbc && dbc->isLoaded()) { - const auto* layout = pipeline::getActiveDBCLayout() - ? pipeline::getActiveDBCLayout()->getLayout("ItemSet") : nullptr; - auto lf = [&](const char* k, uint32_t def) -> uint32_t { - return layout ? (*layout)[k] : def; - }; - uint32_t idF = lf("ID", 0), nameF = lf("Name", 1); - const auto& itemKeys = ui::kItemSetItemKeys; - const auto& spellKeys = ui::kItemSetSpellKeys; - const auto& thrKeys = ui::kItemSetThresholdKeys; - for (uint32_t r = 0; r < dbc->getRecordCount(); ++r) { - uint32_t id = dbc->getUInt32(r, idF); - if (!id) continue; - SetEntry e; - e.name = dbc->getString(r, nameF); - for (int i = 0; i < 10; ++i) { - e.itemIds[i] = dbc->getUInt32(r, layout ? (*layout)[itemKeys[i]] : uint32_t(18 + i)); - e.spellIds[i] = dbc->getUInt32(r, layout ? (*layout)[spellKeys[i]] : uint32_t(28 + i)); - e.thresholds[i] = dbc->getUInt32(r, layout ? (*layout)[thrKeys[i]] : uint32_t(38 + i)); - } - s_setData[id] = std::move(e); - } - } - } - ImGui::Spacing(); - const auto& inv = gameHandler.getInventory(); - auto setIt = s_setData.find(info->itemSetId); - if (setIt != s_setData.end()) { - const SetEntry& se = setIt->second; - int equipped = 0, total = 0; - for (int i = 0; i < 10; ++i) { - if (se.itemIds[i] == 0) continue; - ++total; - for (int sl = 0; sl < game::Inventory::NUM_EQUIP_SLOTS; sl++) { - const auto& eq = inv.getEquipSlot(static_cast(sl)); - if (!eq.empty() && eq.item.itemId == se.itemIds[i]) { ++equipped; break; } - } - } - if (total > 0) - ImGui::TextColored(ui::colors::kTooltipGold, - "%s (%d/%d)", se.name.empty() ? "Set" : se.name.c_str(), equipped, total); - else if (!se.name.empty()) - ImGui::TextColored(ui::colors::kTooltipGold, "%s", se.name.c_str()); - for (int i = 0; i < 10; ++i) { - if (se.spellIds[i] == 0 || se.thresholds[i] == 0) continue; - const std::string& bname = gameHandler.getSpellName(se.spellIds[i]); - bool active = (equipped >= static_cast(se.thresholds[i])); - ImVec4 col = active ? colors::kActiveGreen : colors::kInactiveGray; - if (!bname.empty()) - ImGui::TextColored(col, "(%u) %s", se.thresholds[i], bname.c_str()); - else - ImGui::TextColored(col, "(%u) Set Bonus", se.thresholds[i]); - } - } else { - ImGui::TextColored(ui::colors::kTooltipGold, "Set (id %u)", info->itemSetId); - } - } - // Item spell effects (Use / Equip / Chance on Hit / Teaches) - for (const auto& sp : info->spells) { - if (sp.spellId == 0) continue; - const char* triggerLabel = nullptr; - switch (sp.spellTrigger) { - case 0: triggerLabel = "Use"; break; - case 1: triggerLabel = "Equip"; break; - case 2: triggerLabel = "Chance on Hit"; break; - case 5: triggerLabel = "Teaches"; break; - } - if (!triggerLabel) continue; - // Use full spell description if available (matches inventory tooltip style) - const std::string& spDesc = gameHandler.getSpellDescription(sp.spellId); - const std::string& spText = !spDesc.empty() ? spDesc - : gameHandler.getSpellName(sp.spellId); - if (!spText.empty()) { - ImGui::PushTextWrapPos(ImGui::GetCursorPosX() + 300.0f); - ImGui::TextColored(colors::kCyan, - "%s: %s", triggerLabel, spText.c_str()); - ImGui::PopTextWrapPos(); - } - } - // Required level - if (info->requiredLevel > 1) - ImGui::TextDisabled("Requires Level %u", info->requiredLevel); - // Required skill (e.g. "Requires Blacksmithing (300)") - if (info->requiredSkill != 0 && info->requiredSkillRank > 0) { - static std::unordered_map s_skillNames; - static bool s_skillNamesLoaded = false; - if (!s_skillNamesLoaded && assetMgr) { - s_skillNamesLoaded = true; - auto dbc = assetMgr->loadDBC("SkillLine.dbc"); - if (dbc && dbc->isLoaded()) { - const auto* layout = pipeline::getActiveDBCLayout() - ? pipeline::getActiveDBCLayout()->getLayout("SkillLine") : nullptr; - uint32_t idF = layout ? (*layout)["ID"] : 0u; - uint32_t nameF = layout ? (*layout)["Name"] : 2u; - for (uint32_t r = 0; r < dbc->getRecordCount(); ++r) { - uint32_t sid = dbc->getUInt32(r, idF); - if (!sid) continue; - std::string sname = dbc->getString(r, nameF); - if (!sname.empty()) s_skillNames[sid] = std::move(sname); - } - } - } - uint32_t playerSkillVal = 0; - const auto& skills = gameHandler.getPlayerSkills(); - auto skPit = skills.find(info->requiredSkill); - if (skPit != skills.end()) playerSkillVal = skPit->second.effectiveValue(); - bool meetsSkill = (playerSkillVal == 0 || playerSkillVal >= info->requiredSkillRank); - ImVec4 skColor = meetsSkill ? ImVec4(1.0f, 1.0f, 1.0f, 0.75f) : colors::kPaleRed; - auto skIt = s_skillNames.find(info->requiredSkill); - if (skIt != s_skillNames.end()) - ImGui::TextColored(skColor, "Requires %s (%u)", skIt->second.c_str(), info->requiredSkillRank); - else - ImGui::TextColored(skColor, "Requires Skill %u (%u)", info->requiredSkill, info->requiredSkillRank); - } - // Required reputation (e.g. "Requires Exalted with Argent Dawn") - if (info->requiredReputationFaction != 0 && info->requiredReputationRank > 0) { - static std::unordered_map s_factionNames; - static bool s_factionNamesLoaded = false; - if (!s_factionNamesLoaded && assetMgr) { - s_factionNamesLoaded = true; - auto dbc = assetMgr->loadDBC("Faction.dbc"); - if (dbc && dbc->isLoaded()) { - const auto* layout = pipeline::getActiveDBCLayout() - ? pipeline::getActiveDBCLayout()->getLayout("Faction") : nullptr; - uint32_t idF = layout ? (*layout)["ID"] : 0u; - uint32_t nameF = layout ? (*layout)["Name"] : 20u; - for (uint32_t r = 0; r < dbc->getRecordCount(); ++r) { - uint32_t fid = dbc->getUInt32(r, idF); - if (!fid) continue; - std::string fname = dbc->getString(r, nameF); - if (!fname.empty()) s_factionNames[fid] = std::move(fname); - } - } - } - static constexpr const char* kRepRankNames[] = { - "Hated", "Hostile", "Unfriendly", "Neutral", - "Friendly", "Honored", "Revered", "Exalted" - }; - const char* rankName = (info->requiredReputationRank < 8) - ? kRepRankNames[info->requiredReputationRank] : "Unknown"; - auto fIt = s_factionNames.find(info->requiredReputationFaction); - ImGui::TextColored(ImVec4(1.0f, 1.0f, 1.0f, 0.75f), "Requires %s with %s", - rankName, - fIt != s_factionNames.end() ? fIt->second.c_str() : "Unknown Faction"); - } - // Class restriction (e.g. "Classes: Paladin, Warrior") - if (info->allowableClass != 0) { - const auto& kClasses = ui::kClassMasks; - int matchCount = 0; - for (const auto& kc : kClasses) - if (info->allowableClass & kc.mask) ++matchCount; - if (matchCount > 0 && matchCount < 10) { - char classBuf[128] = "Classes: "; - bool first = true; - for (const auto& kc : kClasses) { - if (!(info->allowableClass & kc.mask)) continue; - if (!first) strncat(classBuf, ", ", sizeof(classBuf) - strlen(classBuf) - 1); - strncat(classBuf, kc.name, sizeof(classBuf) - strlen(classBuf) - 1); - first = false; - } - uint8_t pc = gameHandler.getPlayerClass(); - uint32_t pmask = (pc > 0 && pc <= 10) ? (1u << (pc - 1)) : 0u; - bool playerAllowed = (pmask == 0 || (info->allowableClass & pmask)); - ImVec4 clColor = playerAllowed ? ImVec4(1.0f, 1.0f, 1.0f, 0.75f) : colors::kPaleRed; - ImGui::TextColored(clColor, "%s", classBuf); - } - } - // Race restriction (e.g. "Races: Night Elf, Human") - if (info->allowableRace != 0) { - const auto& kRaces = ui::kRaceMasks; - constexpr uint32_t kAllPlayable = 1|2|4|8|16|32|64|128|512|1024; - if ((info->allowableRace & kAllPlayable) != kAllPlayable) { - int matchCount = 0; - for (const auto& kr : kRaces) - if (info->allowableRace & kr.mask) ++matchCount; - if (matchCount > 0) { - char raceBuf[160] = "Races: "; - bool first = true; - for (const auto& kr : kRaces) { - if (!(info->allowableRace & kr.mask)) continue; - if (!first) strncat(raceBuf, ", ", sizeof(raceBuf) - strlen(raceBuf) - 1); - strncat(raceBuf, kr.name, sizeof(raceBuf) - strlen(raceBuf) - 1); - first = false; - } - uint8_t pr = gameHandler.getPlayerRace(); - uint32_t pmask = (pr > 0 && pr <= 11) ? (1u << (pr - 1)) : 0u; - bool playerAllowed = (pmask == 0 || (info->allowableRace & pmask)); - ImVec4 rColor = playerAllowed ? ImVec4(1.0f, 1.0f, 1.0f, 0.75f) : colors::kPaleRed; - ImGui::TextColored(rColor, "%s", raceBuf); - } - } - } - // Flavor / lore text (shown in gold italic in WoW, use a yellow-ish dim color here) - if (!info->description.empty()) { - ImGui::Spacing(); - ImGui::PushTextWrapPos(300.0f); - ImGui::TextColored(ImVec4(1.0f, 0.82f, 0.0f, 0.85f), "\"%s\"", info->description.c_str()); - ImGui::PopTextWrapPos(); - } - if (info->sellPrice > 0) { - ImGui::TextDisabled("Sell:"); ImGui::SameLine(0, 4); - renderCoinsFromCopper(info->sellPrice); - } - - if (ImGui::GetIO().KeyShift && info->inventoryType > 0) { - if (const auto* eq = findComparableEquipped(static_cast(info->inventoryType))) { - ImGui::Separator(); - ImGui::TextDisabled("Equipped:"); - VkDescriptorSet eqIcon = inventoryScreen.getItemIcon(eq->item.displayInfoId); - if (eqIcon) { - ImGui::Image((ImTextureID)(uintptr_t)eqIcon, ImVec2(18.0f, 18.0f)); - ImGui::SameLine(); - } - ImGui::TextColored(InventoryScreen::getQualityColor(eq->item.quality), "%s", eq->item.name.c_str()); - if (isWeaponInventoryType(eq->item.inventoryType) && - eq->item.damageMax > 0.0f && eq->item.delayMs > 0) { - float speed = static_cast(eq->item.delayMs) / 1000.0f; - float dps = ((eq->item.damageMin + eq->item.damageMax) * 0.5f) / speed; - char eqDmg[64], eqSpd[32]; - std::snprintf(eqDmg, sizeof(eqDmg), "%d - %d Damage", - static_cast(eq->item.damageMin), static_cast(eq->item.damageMax)); - std::snprintf(eqSpd, sizeof(eqSpd), "Speed %.2f", speed); - float eqSpdW = ImGui::CalcTextSize(eqSpd).x; - ImGui::Text("%s", eqDmg); - ImGui::SameLine(ImGui::GetWindowWidth() - eqSpdW - 16.0f); - ImGui::Text("%s", eqSpd); - ImGui::TextDisabled("(%.1f damage per second)", dps); - } - if (eq->item.armor > 0) { - ImGui::Text("%d Armor", eq->item.armor); - } - std::string eqBonusLine; - appendBonus(eqBonusLine, eq->item.strength, "Str"); - appendBonus(eqBonusLine, eq->item.agility, "Agi"); - appendBonus(eqBonusLine, eq->item.stamina, "Sta"); - appendBonus(eqBonusLine, eq->item.intellect, "Int"); - appendBonus(eqBonusLine, eq->item.spirit, "Spi"); - if (!eqBonusLine.empty()) { - ImGui::TextColored(green, "%s", eqBonusLine.c_str()); - } - // Extra stats for the equipped item - for (const auto& es : eq->item.extraStats) { - const char* nm = nullptr; - switch (es.statType) { - case 12: nm = "Defense Rating"; break; - case 13: nm = "Dodge Rating"; break; - case 14: nm = "Parry Rating"; break; - case 16: case 17: case 18: case 31: nm = "Hit Rating"; break; - case 19: case 20: case 21: case 32: nm = "Critical Strike Rating"; break; - case 28: case 29: case 30: case 35: nm = "Haste Rating"; break; - case 34: nm = "Resilience Rating"; break; - case 36: nm = "Expertise Rating"; break; - case 37: nm = "Attack Power"; break; - case 38: nm = "Ranged Attack Power"; break; - case 45: nm = "Spell Power"; break; - case 46: nm = "Healing Power"; break; - case 49: nm = "Mana per 5 sec."; break; - default: break; - } - if (nm && es.statValue > 0) - ImGui::TextColored(green, "+%d %s", es.statValue, nm); - } - } - } - ImGui::EndTooltip(); - }; - - // Helper: render text with clickable URLs and WoW item links - auto renderTextWithLinks = [&](const std::string& text, const ImVec4& color) { - size_t pos = 0; - while (pos < text.size()) { - // Find next special element: URL or WoW link - size_t urlStart = text.find("https://", pos); - - // Find next WoW link (may be colored with |c prefix or bare |H) - size_t linkStart = text.find("|c", pos); - // Also handle bare |H links without color prefix - size_t bareItem = text.find("|Hitem:", pos); - size_t bareSpell = text.find("|Hspell:", pos); - size_t bareQuest = text.find("|Hquest:", pos); - size_t bareLinkStart = std::min({bareItem, bareSpell, bareQuest}); - - // Determine which comes first - size_t nextSpecial = std::min({urlStart, linkStart, bareLinkStart}); - - if (nextSpecial == std::string::npos) { - // No more special elements, render remaining text - std::string remaining = text.substr(pos); - if (!remaining.empty()) { - ImGui::PushStyleColor(ImGuiCol_Text, color); - ImGui::TextWrapped("%s", remaining.c_str()); - ImGui::PopStyleColor(); - } - break; - } - - // Render plain text before special element - if (nextSpecial > pos) { - std::string before = text.substr(pos, nextSpecial - pos); - ImGui::PushStyleColor(ImGuiCol_Text, color); - ImGui::TextWrapped("%s", before.c_str()); - ImGui::PopStyleColor(); - ImGui::SameLine(0, 0); - } - - // Handle WoW item link - if (nextSpecial == linkStart || nextSpecial == bareLinkStart) { - ImVec4 linkColor = color; - size_t hStart = std::string::npos; - - if (nextSpecial == linkStart && text.size() > linkStart + 10) { - // Parse |cAARRGGBB color - linkColor = parseWowColor(text, linkStart); - // Find the nearest |H link of any supported type - size_t hItem = text.find("|Hitem:", linkStart + 10); - size_t hSpell = text.find("|Hspell:", linkStart + 10); - size_t hQuest = text.find("|Hquest:", linkStart + 10); - size_t hAch = text.find("|Hachievement:", linkStart + 10); - hStart = std::min({hItem, hSpell, hQuest, hAch}); - } else if (nextSpecial == bareLinkStart) { - hStart = bareLinkStart; - } - - if (hStart != std::string::npos) { - // Determine link type - const bool isSpellLink = (text.compare(hStart, 8, "|Hspell:") == 0); - const bool isQuestLink = (text.compare(hStart, 8, "|Hquest:") == 0); - const bool isAchievLink = (text.compare(hStart, 14, "|Hachievement:") == 0); - // Default: item link - - // Parse the first numeric ID after |Htype: - size_t idOffset = isSpellLink ? 8 : (isQuestLink ? 8 : (isAchievLink ? 14 : 7)); - size_t entryStart = hStart + idOffset; - size_t entryEnd = text.find(':', entryStart); - uint32_t linkId = 0; - if (entryEnd != std::string::npos) { - linkId = static_cast(strtoul( - text.substr(entryStart, entryEnd - entryStart).c_str(), nullptr, 10)); - } - - // Find display name: |h[Name]|h - size_t nameTagStart = text.find("|h[", hStart); - size_t nameTagEnd = (nameTagStart != std::string::npos) - ? text.find("]|h", nameTagStart + 3) : std::string::npos; - - std::string linkName = isSpellLink ? "Unknown Spell" - : isQuestLink ? "Unknown Quest" - : isAchievLink ? "Unknown Achievement" - : "Unknown Item"; - if (nameTagStart != std::string::npos && nameTagEnd != std::string::npos) { - linkName = text.substr(nameTagStart + 3, nameTagEnd - nameTagStart - 3); - } - - // Find end of entire link sequence (|r or after ]|h) - size_t linkEnd = (nameTagEnd != std::string::npos) ? nameTagEnd + 3 : hStart + idOffset; - size_t resetPos = text.find("|r", linkEnd); - if (resetPos != std::string::npos && resetPos <= linkEnd + 2) { - linkEnd = resetPos + 2; - } - - if (!isSpellLink && !isQuestLink && !isAchievLink) { - // --- Item link --- - uint32_t itemEntry = linkId; - if (itemEntry > 0) { - gameHandler.ensureItemInfo(itemEntry); - } - - // Show small icon before item link if available - if (itemEntry > 0) { - const auto* chatInfo = gameHandler.getItemInfo(itemEntry); - if (chatInfo && chatInfo->valid && chatInfo->displayInfoId != 0) { - VkDescriptorSet chatIcon = inventoryScreen.getItemIcon(chatInfo->displayInfoId); - if (chatIcon) { - ImGui::Image((ImTextureID)(uintptr_t)chatIcon, ImVec2(12, 12)); - if (ImGui::IsItemHovered()) { - ImGui::SetMouseCursor(ImGuiMouseCursor_Hand); - renderItemLinkTooltip(itemEntry); - } - ImGui::SameLine(0, 2); - } - } - } - - // Render bracketed item name in quality color - std::string display = "[" + linkName + "]"; - ImGui::PushStyleColor(ImGuiCol_Text, linkColor); - ImGui::TextWrapped("%s", display.c_str()); - ImGui::PopStyleColor(); - - if (ImGui::IsItemHovered()) { - ImGui::SetMouseCursor(ImGuiMouseCursor_Hand); - if (itemEntry > 0) { - renderItemLinkTooltip(itemEntry); - } - } - } else if (isSpellLink) { - // --- Spell link: |Hspell:SPELLID:RANK|h[Name]|h --- - // Small icon (use spell icon cache if available) - VkDescriptorSet spellIcon = (linkId > 0) ? getSpellIcon(linkId, assetMgr) : VK_NULL_HANDLE; - if (spellIcon) { - ImGui::Image((ImTextureID)(uintptr_t)spellIcon, ImVec2(12, 12)); - if (ImGui::IsItemHovered()) { - ImGui::SetMouseCursor(ImGuiMouseCursor_Hand); - spellbookScreen.renderSpellInfoTooltip(linkId, gameHandler, assetMgr); - } - ImGui::SameLine(0, 2); - } - - std::string display = "[" + linkName + "]"; - ImGui::PushStyleColor(ImGuiCol_Text, linkColor); - ImGui::TextWrapped("%s", display.c_str()); - ImGui::PopStyleColor(); - - if (ImGui::IsItemHovered()) { - ImGui::SetMouseCursor(ImGuiMouseCursor_Hand); - if (linkId > 0) { - spellbookScreen.renderSpellInfoTooltip(linkId, gameHandler, assetMgr); - } - } - } else if (isQuestLink) { - // --- Quest link: |Hquest:QUESTID:QUESTLEVEL|h[Name]|h --- - std::string display = "[" + linkName + "]"; - ImGui::PushStyleColor(ImGuiCol_Text, colors::kWarmGold); // gold - ImGui::TextWrapped("%s", display.c_str()); - ImGui::PopStyleColor(); - - if (ImGui::IsItemHovered()) { - ImGui::SetMouseCursor(ImGuiMouseCursor_Hand); - ImGui::BeginTooltip(); - ImGui::TextColored(colors::kWarmGold, "%s", linkName.c_str()); - // Parse quest level (second field after questId) - if (entryEnd != std::string::npos) { - size_t lvlEnd = text.find(':', entryEnd + 1); - if (lvlEnd == std::string::npos) lvlEnd = text.find('|', entryEnd + 1); - if (lvlEnd != std::string::npos) { - uint32_t qLvl = static_cast(strtoul( - text.substr(entryEnd + 1, lvlEnd - entryEnd - 1).c_str(), nullptr, 10)); - if (qLvl > 0) ImGui::TextDisabled("Level %u Quest", qLvl); - } - } - ImGui::TextDisabled("Click quest log to view details"); - ImGui::EndTooltip(); - } - // Click: open quest log and select this quest if we have it - if (ImGui::IsItemClicked() && linkId > 0) { - questLogScreen.openAndSelectQuest(linkId); - } - } else { - // --- Achievement link --- - std::string display = "[" + linkName + "]"; - ImGui::PushStyleColor(ImGuiCol_Text, colors::kBrightGold); // gold - ImGui::TextWrapped("%s", display.c_str()); - ImGui::PopStyleColor(); - - if (ImGui::IsItemHovered()) { - ImGui::SetMouseCursor(ImGuiMouseCursor_Hand); - ImGui::SetTooltip("Achievement: %s", linkName.c_str()); - } - } - - // Shift-click: insert entire link back into chat input - if (ImGui::IsItemClicked() && ImGui::GetIO().KeyShift) { - std::string linkText = text.substr(nextSpecial, linkEnd - nextSpecial); - size_t curLen = strlen(chatInputBuffer_); - if (curLen + linkText.size() + 1 < sizeof(chatInputBuffer_)) { - strncat(chatInputBuffer_, linkText.c_str(), sizeof(chatInputBuffer_) - curLen - 1); - chatInputMoveCursorToEnd_ = true; - } - } - - pos = linkEnd; - continue; - } - - // Not an item link — treat as colored text: |cAARRGGBB...text...|r - if (nextSpecial == linkStart && text.size() > linkStart + 10) { - ImVec4 cColor = parseWowColor(text, linkStart); - size_t textStart = linkStart + 10; // after |cAARRGGBB - size_t resetPos2 = text.find("|r", textStart); - std::string coloredText; - if (resetPos2 != std::string::npos) { - coloredText = text.substr(textStart, resetPos2 - textStart); - pos = resetPos2 + 2; // skip |r - } else { - coloredText = text.substr(textStart); - pos = text.size(); - } - // Strip any remaining WoW markup from the colored segment - // (e.g. |H...|h pairs that aren't item links) - std::string clean; - for (size_t i = 0; i < coloredText.size(); i++) { - if (coloredText[i] == '|' && i + 1 < coloredText.size()) { - char next = coloredText[i + 1]; - if (next == 'H') { - // Skip |H...|h - size_t hEnd = coloredText.find("|h", i + 2); - if (hEnd != std::string::npos) { i = hEnd + 1; continue; } - } else if (next == 'h') { - i += 1; continue; // skip |h - } else if (next == 'r') { - i += 1; continue; // skip |r - } - } - clean += coloredText[i]; - } - if (!clean.empty()) { - ImGui::PushStyleColor(ImGuiCol_Text, cColor); - ImGui::TextWrapped("%s", clean.c_str()); - ImGui::PopStyleColor(); - ImGui::SameLine(0, 0); - } - } else { - // Bare |c without enough chars for color — render literally - ImGui::PushStyleColor(ImGuiCol_Text, color); - ImGui::TextWrapped("|c"); - ImGui::PopStyleColor(); - ImGui::SameLine(0, 0); - pos = nextSpecial + 2; - } - continue; - } - - // Handle URL - if (nextSpecial == urlStart) { - size_t urlEnd = text.find_first_of(" \t\n\r", urlStart); - if (urlEnd == std::string::npos) urlEnd = text.size(); - std::string url = text.substr(urlStart, urlEnd - urlStart); - - ImGui::PushStyleColor(ImGuiCol_Text, ImVec4(0.4f, 0.7f, 1.0f, 1.0f)); - ImGui::TextWrapped("%s", url.c_str()); - if (ImGui::IsItemHovered()) { - ImGui::SetMouseCursor(ImGuiMouseCursor_Hand); - ImGui::SetTooltip("Open: %s", url.c_str()); - } - if (ImGui::IsItemClicked()) { - std::string cmd = "xdg-open '" + url + "' &"; - [[maybe_unused]] int result = system(cmd.c_str()); - } - ImGui::PopStyleColor(); - - pos = urlEnd; - continue; - } - } - }; + // Markup parsing and rendering delegated to ChatMarkupParser / ChatMarkupRenderer (Phase 2) + MarkupRenderContext markupCtx; + markupCtx.gameHandler = &gameHandler; + markupCtx.inventory = &inventoryScreen; + markupCtx.spellbook = &spellbookScreen; + markupCtx.questLog = &questLogScreen; + markupCtx.assetMgr = assetMgr; + markupCtx.getSpellIcon = getSpellIcon; + markupCtx.chatInputBuffer = chatInputBuffer_; + markupCtx.chatInputBufSize = sizeof(chatInputBuffer_); + markupCtx.moveCursorToEnd = &chatInputMoveCursorToEnd_; // Determine local player name for mention detection (case-insensitive) std::string selfNameLower; @@ -1040,7 +187,7 @@ void ChatPanel::render(game::GameHandler& gameHandler, int chatMsgIdx = 0; for (const auto& msg : chatHistory) { - if (!shouldShowMessage(msg, activeChatTab)) continue; + if (!tabManager_.shouldShowMessage(msg, activeChatTab)) continue; std::string processedMessage = replaceGenderPlaceholders(msg.message, gameHandler); // Resolve sender name at render time in case it wasn't available at parse time. @@ -1053,7 +200,7 @@ void ChatPanel::render(game::GameHandler& gameHandler, return msg.senderName; }(); - ImVec4 color = getChatTypeColor(msg.type); + ImVec4 color = ChatTabManager::getChatTypeColor(msg.type); // Optional timestamp prefix std::string tsPrefix; @@ -1102,7 +249,7 @@ void ChatPanel::render(game::GameHandler& gameHandler, : "[" + msg.channelName + "]"; fullMsg = tsPrefix + chDisplay + " [" + tagPrefix + resolvedSenderName + "]: " + processedMessage; } else { - fullMsg = tsPrefix + "[" + std::string(getChatTypeName(msg.type)) + "] " + tagPrefix + resolvedSenderName + ": " + processedMessage; + fullMsg = tsPrefix + "[" + std::string(ChatTabManager::getChatTypeName(msg.type)) + "] " + tagPrefix + resolvedSenderName + ": " + processedMessage; } } else { bool isGroupType = @@ -1115,7 +262,7 @@ void ChatPanel::render(game::GameHandler& gameHandler, msg.type == game::ChatType::BATTLEGROUND || msg.type == game::ChatType::BATTLEGROUND_LEADER; if (isGroupType) { - fullMsg = tsPrefix + "[" + std::string(getChatTypeName(msg.type)) + "] " + processedMessage; + fullMsg = tsPrefix + "[" + std::string(ChatTabManager::getChatTypeName(msg.type)) + "] " + processedMessage; } else { fullMsg = tsPrefix + processedMessage; } @@ -1134,7 +281,10 @@ void ChatPanel::render(game::GameHandler& gameHandler, // Render message in a group so we can attach a right-click context menu ImGui::PushID(chatMsgIdx++); ImGui::BeginGroup(); - renderTextWithLinks(fullMsg, isMention ? ImVec4(1.0f, 0.9f, 0.35f, 1.0f) : color); + { + auto segments = markupParser_.parse(fullMsg); + markupRenderer_.render(segments, isMention ? ImVec4(1.0f, 0.9f, 0.35f, 1.0f) : color, markupCtx); + } ImGui::EndGroup(); if (isMention) { // Draw highlight AFTER rendering so the rect covers all wrapped lines, @@ -1380,7 +530,7 @@ void ChatPanel::render(game::GameHandler& gameHandler, self->chatInputMoveCursorToEnd_ = false; } - // Tab: slash-command autocomplete + // Tab: slash-command autocomplete (Phase 5 — uses ChatTabCompleter + ChatCommandRegistry) if (data->EventFlag == ImGuiInputTextFlags_CallbackCompletion) { if (data->BufTextLen > 0 && data->Buf[0] == '/') { // Split buffer into command word and trailing args @@ -1389,66 +539,59 @@ void ChatPanel::render(game::GameHandler& gameHandler, std::string word = (spacePos != std::string::npos) ? fullBuf.substr(0, spacePos) : fullBuf; std::string rest = (spacePos != std::string::npos) ? fullBuf.substr(spacePos) : ""; - // Normalize to lowercase for matching - std::string lowerWord = word; - for (auto& ch : lowerWord) ch = static_cast(std::tolower(static_cast(ch))); - - static const std::vector kCmds = { - "/afk", "/assist", "/away", - "/cancelaura", "/cancelform", "/cancellogout", "/cancelshapeshift", - "/cast", "/castsequence", "/chathelp", "/clear", "/clearfocus", - "/clearmainassist", "/clearmaintank", "/cleartarget", "/cloak", - "/combatlog", "/dance", "/difficulty", "/dismount", "/dnd", "/do", "/duel", "/dump", - "/e", "/emote", "/equip", "/equipset", "/exit", - "/focus", "/follow", "/forfeit", "/friend", - "/g", "/gdemote", "/ginvite", "/gkick", "/gleader", "/gmotd", - "/gmticket", "/gpromote", "/gquit", "/grouploot", "/groster", - "/guild", "/guildinfo", - "/helm", "/help", - "/i", "/ignore", "/inspect", "/instance", "/invite", - "/j", "/join", "/kick", "/kneel", - "/l", "/leave", "/leaveparty", "/loc", "/local", "/logout", - "/lootmethod", "/lootthreshold", - "/macrohelp", "/mainassist", "/maintank", "/mark", "/me", - "/notready", - "/p", "/party", "/petaggressive", "/petattack", "/petdefensive", - "/petdismiss", "/petfollow", "/pethalt", "/petpassive", "/petstay", - "/played", "/pvp", - "/quit", - "/r", "/raid", "/raidconvert", "/raidinfo", "/raidwarning", "/random", "/ready", - "/readycheck", "/reload", "/reloadui", "/removefriend", - "/reply", "/rl", "/roll", "/run", - "/s", "/say", "/score", "/screenshot", "/script", "/setloot", - "/shout", "/sit", "/stand", - "/startattack", "/stopattack", "/stopcasting", "/stopfollow", "/stopmacro", - "/t", "/target", "/targetenemy", "/targetfriend", "/targetlast", - "/threat", "/ticket", "/time", "/trade", - "/unignore", "/uninvite", "/unstuck", "/use", - "/w", "/whisper", "/who", "/wts", "/wtb", - "/y", "/yell", "/zone" - }; + // Normalize to lowercase for matching (strip leading /) + std::string lowerCmd = word.substr(1); + for (auto& ch : lowerCmd) ch = static_cast(std::tolower(static_cast(ch))); // New session if prefix changed - if (self->chatTabMatchIdx_ < 0 || self->chatTabPrefix_ != lowerWord) { - self->chatTabPrefix_ = lowerWord; - self->chatTabMatches_.clear(); - for (const auto& cmd : kCmds) { - if (cmd.size() >= lowerWord.size() && - cmd.compare(0, lowerWord.size(), lowerWord) == 0) - self->chatTabMatches_.push_back(cmd); - } - self->chatTabMatchIdx_ = 0; + if (!self->tabCompleter_.isActive() || self->tabCompleter_.getPrefix() != lowerCmd) { + auto candidates = self->commandRegistry_.getCompletions(lowerCmd); + // Prepend / to each candidate for display + for (auto& c : candidates) c = "/" + c; + self->tabCompleter_.startCompletion(lowerCmd, std::move(candidates)); } else { - // Cycle forward through matches - ++self->chatTabMatchIdx_; - if (self->chatTabMatchIdx_ >= static_cast(self->chatTabMatches_.size())) - self->chatTabMatchIdx_ = 0; + self->tabCompleter_.next(); } - if (!self->chatTabMatches_.empty()) { - std::string match = self->chatTabMatches_[self->chatTabMatchIdx_]; + std::string match = self->tabCompleter_.getCurrentMatch(); + if (!match.empty()) { // Append trailing space when match is unambiguous - if (self->chatTabMatches_.size() == 1 && rest.empty()) + if (self->tabCompleter_.matchCount() == 1 && rest.empty()) + match += ' '; + std::string newBuf = match + rest; + data->DeleteChars(0, data->BufTextLen); + data->InsertChars(0, newBuf.c_str()); + } + } else if (data->BufTextLen > 1 && data->Buf[0] == '.') { + // GM dot-command tab-completion (uses gm_command_data.hpp table) + std::string fullBuf(data->Buf, data->BufTextLen); + size_t spacePos = fullBuf.find(' '); + std::string word = (spacePos != std::string::npos) ? fullBuf.substr(0, spacePos) : fullBuf; + std::string rest = (spacePos != std::string::npos) ? fullBuf.substr(spacePos) : ""; + + // Lowercase the typed prefix (keep the dot) + std::string lowerDot = word; + for (auto& ch : lowerDot) ch = static_cast(std::tolower(static_cast(ch))); + + if (!self->tabCompleter_.isActive() || self->tabCompleter_.getPrefix() != lowerDot) { + // Gather candidates from the GM command table + std::vector candidates; + for (const auto& entry : kGmCommands) { + std::string dotName = "." + std::string(entry.name); + if (dotName.size() >= lowerDot.size() && + dotName.compare(0, lowerDot.size(), lowerDot) == 0) { + candidates.push_back(dotName); + } + } + std::sort(candidates.begin(), candidates.end()); + self->tabCompleter_.startCompletion(lowerDot, std::move(candidates)); + } else { + self->tabCompleter_.next(); + } + + std::string match = self->tabCompleter_.getCurrentMatch(); + if (!match.empty()) { + if (self->tabCompleter_.matchCount() == 1 && rest.empty()) match += ' '; std::string newBuf = match + rest; data->DeleteChars(0, data->BufTextLen); @@ -1456,7 +599,6 @@ void ChatPanel::render(game::GameHandler& gameHandler, } } else if (data->BufTextLen > 0) { // Player name tab-completion for commands like /w, /whisper, /invite, /trade, /duel - // Also works for plain text (completes nearby player names) std::string fullBuf(data->Buf, data->BufTextLen); size_t spacePos = fullBuf.find(' '); bool isNameCommand = false; @@ -1466,16 +608,13 @@ void ChatPanel::render(game::GameHandler& gameHandler, if (fullBuf[0] == '/' && spacePos != std::string::npos) { std::string cmd = fullBuf.substr(0, spacePos); for (char& c : cmd) c = static_cast(std::tolower(static_cast(c))); - // Commands that take a player name as the first argument after the command if (cmd == "/w" || cmd == "/whisper" || cmd == "/invite" || cmd == "/trade" || cmd == "/duel" || cmd == "/follow" || cmd == "/inspect" || cmd == "/friend" || cmd == "/removefriend" || cmd == "/ignore" || cmd == "/unignore" || cmd == "/who" || cmd == "/t" || cmd == "/target" || cmd == "/kick" || cmd == "/uninvite" || cmd == "/ginvite" || cmd == "/gkick") { - // Extract the partial name after the space namePrefix = fullBuf.substr(spacePos + 1); - // Only complete the first word after the command size_t nameSpace = namePrefix.find(' '); if (nameSpace == std::string::npos) { isNameCommand = true; @@ -1488,10 +627,8 @@ void ChatPanel::render(game::GameHandler& gameHandler, std::string lowerPrefix = namePrefix; for (char& c : lowerPrefix) c = static_cast(std::tolower(static_cast(c))); - if (self->chatTabMatchIdx_ < 0 || self->chatTabPrefix_ != lowerPrefix) { - self->chatTabPrefix_ = lowerPrefix; - self->chatTabMatches_.clear(); - // Search player name cache and nearby entities + if (!self->tabCompleter_.isActive() || self->tabCompleter_.getPrefix() != lowerPrefix) { + std::vector candidates; auto* gh = self->cachedGameHandler_; // Party/raid members for (const auto& m : gh->getPartyData().members) { @@ -1499,7 +636,7 @@ void ChatPanel::render(game::GameHandler& gameHandler, std::string lname = m.name; for (char& c : lname) c = static_cast(std::tolower(static_cast(c))); if (lname.compare(0, lowerPrefix.size(), lowerPrefix) == 0) - self->chatTabMatches_.push_back(m.name); + candidates.push_back(m.name); } // Friends for (const auto& c : gh->getContacts()) { @@ -1507,11 +644,10 @@ void ChatPanel::render(game::GameHandler& gameHandler, std::string lname = c.name; for (char& cc : lname) cc = static_cast(std::tolower(static_cast(cc))); if (lname.compare(0, lowerPrefix.size(), lowerPrefix) == 0) { - // Avoid duplicates from party bool dup = false; - for (const auto& em : self->chatTabMatches_) + for (const auto& em : candidates) if (em == c.name) { dup = true; break; } - if (!dup) self->chatTabMatches_.push_back(c.name); + if (!dup) candidates.push_back(c.name); } } // Nearby visible players @@ -1523,34 +659,32 @@ void ChatPanel::render(game::GameHandler& gameHandler, for (char& cc : lname) cc = static_cast(std::tolower(static_cast(cc))); if (lname.compare(0, lowerPrefix.size(), lowerPrefix) == 0) { bool dup = false; - for (const auto& em : self->chatTabMatches_) + for (const auto& em : candidates) if (em == player->getName()) { dup = true; break; } - if (!dup) self->chatTabMatches_.push_back(player->getName()); + if (!dup) candidates.push_back(player->getName()); } } - // Last whisper sender + // Last whisper sender (insert at front for priority) if (!gh->getLastWhisperSender().empty()) { std::string lname = gh->getLastWhisperSender(); for (char& cc : lname) cc = static_cast(std::tolower(static_cast(cc))); if (lname.compare(0, lowerPrefix.size(), lowerPrefix) == 0) { bool dup = false; - for (const auto& em : self->chatTabMatches_) + for (const auto& em : candidates) if (em == gh->getLastWhisperSender()) { dup = true; break; } - if (!dup) self->chatTabMatches_.insert(self->chatTabMatches_.begin(), gh->getLastWhisperSender()); + if (!dup) candidates.insert(candidates.begin(), gh->getLastWhisperSender()); } } - self->chatTabMatchIdx_ = 0; + self->tabCompleter_.startCompletion(lowerPrefix, std::move(candidates)); } else { - ++self->chatTabMatchIdx_; - if (self->chatTabMatchIdx_ >= static_cast(self->chatTabMatches_.size())) - self->chatTabMatchIdx_ = 0; + self->tabCompleter_.next(); } - if (!self->chatTabMatches_.empty()) { - std::string match = self->chatTabMatches_[self->chatTabMatchIdx_]; + std::string match = self->tabCompleter_.getCurrentMatch(); + if (!match.empty()) { std::string prefix = fullBuf.substr(0, replaceStart); std::string newBuf = prefix + match; - if (self->chatTabMatches_.size() == 1) newBuf += ' '; + if (self->tabCompleter_.matchCount() == 1) newBuf += ' '; data->DeleteChars(0, data->BufTextLen); data->InsertChars(0, newBuf.c_str()); } @@ -1562,8 +696,7 @@ void ChatPanel::render(game::GameHandler& gameHandler, // Up/Down arrow: cycle through sent message history if (data->EventFlag == ImGuiInputTextFlags_CallbackHistory) { // Any history navigation resets autocomplete - self->chatTabMatchIdx_ = -1; - self->chatTabMatches_.clear(); + self->tabCompleter_.reset(); const int histSize = static_cast(self->chatSentHistory_.size()); if (histSize == 0) return 0; @@ -1598,13 +731,16 @@ void ChatPanel::render(game::GameHandler& gameHandler, ImGuiInputTextFlags_CallbackHistory | ImGuiInputTextFlags_CallbackCompletion; if (ImGui::InputText("##ChatInput", chatInputBuffer_, sizeof(chatInputBuffer_), inputFlags, inputCallback, this)) { - sendChatMessage(gameHandler, inventoryScreen, spellbookScreen, questLogScreen); + sendChatMessage(gameHandler); // Close chat input on send so movement keys work immediately. refocusChatInput_ = false; + chatInputCooldown_ = 2; // suppress Enter re-opening chat for 2 frames ImGui::ClearActiveID(); } ImGui::PopStyleColor(); + if (chatInputCooldown_ > 0) --chatInputCooldown_; + if (ImGui::IsItemActive()) { chatInputActive_ = true; } else { @@ -1621,6 +757,34 @@ void ChatPanel::render(game::GameHandler& gameHandler, ImGui::End(); } +// --- Command registration (calls into each command group file) --- +// Forward declarations of registration functions from command files +void registerSystemCommands(ChatCommandRegistry& reg); +void registerSocialCommands(ChatCommandRegistry& reg); +void registerChannelCommands(ChatCommandRegistry& reg); +void registerCombatCommands(ChatCommandRegistry& reg); +void registerGroupCommands(ChatCommandRegistry& reg); +void registerGuildCommands(ChatCommandRegistry& reg); +void registerTargetCommands(ChatCommandRegistry& reg); +void registerEmoteCommands(ChatCommandRegistry& reg); +void registerMiscCommands(ChatCommandRegistry& reg); +void registerHelpCommands(ChatCommandRegistry& reg); +void registerGmCommands(ChatCommandRegistry& reg); + +void ChatPanel::registerAllCommands() { + registerSystemCommands(commandRegistry_); + registerSocialCommands(commandRegistry_); + registerChannelCommands(commandRegistry_); + registerCombatCommands(commandRegistry_); + registerGroupCommands(commandRegistry_); + registerGuildCommands(commandRegistry_); + registerTargetCommands(commandRegistry_); + registerEmoteCommands(commandRegistry_); + registerMiscCommands(commandRegistry_); + registerHelpCommands(commandRegistry_); + registerGmCommands(commandRegistry_); +} + // Collect all non-comment, non-empty lines from a macro body. diff --git a/src/ui/chat_panel_commands.cpp b/src/ui/chat_panel_commands.cpp index d82ea32e..0fdca8c9 100644 --- a/src/ui/chat_panel_commands.cpp +++ b/src/ui/chat_panel_commands.cpp @@ -1,56 +1,22 @@ #include "ui/chat_panel.hpp" -#include "ui/inventory_screen.hpp" -#include "ui/spellbook_screen.hpp" -#include "ui/quest_log_screen.hpp" -#include "ui/ui_colors.hpp" -#include "rendering/vk_context.hpp" +#include "ui/chat/macro_evaluator.hpp" +#include "ui/chat/game_state_adapter.hpp" +#include "ui/chat/input_modifier_adapter.hpp" +#include "ui/chat/chat_utils.hpp" +#include "ui/chat/gm_command_data.hpp" #include "core/application.hpp" +#include "core/logger.hpp" #include "addons/addon_manager.hpp" -#include "core/coordinates.hpp" -#include "core/input.hpp" #include "rendering/renderer.hpp" #include "rendering/animation_controller.hpp" -#include "rendering/camera.hpp" -#include "rendering/camera_controller.hpp" -#include "audio/audio_coordinator.hpp" -#include "audio/audio_engine.hpp" -#include "audio/ui_sound_manager.hpp" -#include "pipeline/asset_manager.hpp" -#include "pipeline/dbc_loader.hpp" -#include "pipeline/dbc_layout.hpp" -#include "game/expansion_profile.hpp" -#include "game/character.hpp" -#include "core/logger.hpp" -#include -#include #include -#include #include -#include -#include #include -#include -#include -#include -#include +using wowee::ui::chat_utils::trim; +using wowee::ui::chat_utils::toLower; namespace { - using namespace wowee::ui::colors; - - std::string trim(const std::string& s) { - size_t first = s.find_first_not_of(" \t\r\n"); - if (first == std::string::npos) return ""; - size_t last = s.find_last_not_of(" \t\r\n"); - return s.substr(first, last - first + 1); - } - - std::string toLower(std::string s) { - std::transform(s.begin(), s.end(), s.begin(), [](unsigned char c) { - return static_cast(std::tolower(c)); - }); - return s; - } bool isPortBotTarget(const std::string& target) { std::string t = toLower(trim(target)); @@ -121,309 +87,17 @@ static std::vector allMacroCommands(const std::string& macroText) { } // --------------------------------------------------------------------------- -// WoW macro conditional evaluator -// Parses: [cond1,cond2] Spell1; [cond3] Spell2; DefaultSpell -// Returns the first matching alternative's argument, or "" if none matches. -// targetOverride is set to a specific GUID if [target=X] was in the conditions, -// or left as UINT64_MAX to mean "use the normal target". +// evaluateMacroConditionals — thin wrapper over MacroEvaluator (Phase 4). +// Preserved for backward compatibility with command files that forward-declare it. // --------------------------------------------------------------------------- -static std::string evaluateMacroConditionals(const std::string& rawArg, +std::string evaluateMacroConditionals(const std::string& rawArg, game::GameHandler& gameHandler, uint64_t& targetOverride) { - targetOverride = static_cast(-1); - - auto& input = core::Input::getInstance(); - - const bool shiftHeld = input.isKeyPressed(SDL_SCANCODE_LSHIFT) || - input.isKeyPressed(SDL_SCANCODE_RSHIFT); - const bool ctrlHeld = input.isKeyPressed(SDL_SCANCODE_LCTRL) || - input.isKeyPressed(SDL_SCANCODE_RCTRL); - const bool altHeld = input.isKeyPressed(SDL_SCANCODE_LALT) || - input.isKeyPressed(SDL_SCANCODE_RALT); - const bool anyMod = shiftHeld || ctrlHeld || altHeld; - - // Split rawArg on ';' → alternatives - std::vector alts; - { - std::string cur; - for (char c : rawArg) { - if (c == ';') { alts.push_back(cur); cur.clear(); } - else cur += c; - } - alts.push_back(cur); - } - - // Evaluate a single comma-separated condition token. - // tgt is updated if a target= or @ specifier is found. - auto evalCond = [&](const std::string& raw, uint64_t& tgt) -> bool { - std::string c = raw; - // trim - size_t s = c.find_first_not_of(" \t"); if (s) c = (s != std::string::npos) ? c.substr(s) : ""; - size_t e = c.find_last_not_of(" \t"); if (e != std::string::npos) c.resize(e + 1); - if (c.empty()) return true; - - // @target specifiers: @player, @focus, @pet, @mouseover, @target - if (!c.empty() && c[0] == '@') { - std::string spec = c.substr(1); - if (spec == "player") tgt = gameHandler.getPlayerGuid(); - else if (spec == "focus") tgt = gameHandler.getFocusGuid(); - else if (spec == "target") tgt = gameHandler.getTargetGuid(); - else if (spec == "pet") { - uint64_t pg = gameHandler.getPetGuid(); - if (pg != 0) tgt = pg; - else return false; // no pet — skip this alternative - } - else if (spec == "mouseover") { - uint64_t mo = gameHandler.getMouseoverGuid(); - if (mo != 0) tgt = mo; - else return false; // no mouseover — skip this alternative - } - return true; - } - // target=X specifiers - if (c.rfind("target=", 0) == 0) { - std::string spec = c.substr(7); - if (spec == "player") tgt = gameHandler.getPlayerGuid(); - else if (spec == "focus") tgt = gameHandler.getFocusGuid(); - else if (spec == "target") tgt = gameHandler.getTargetGuid(); - else if (spec == "pet") { - uint64_t pg = gameHandler.getPetGuid(); - if (pg != 0) tgt = pg; - else return false; // no pet — skip this alternative - } - else if (spec == "mouseover") { - uint64_t mo = gameHandler.getMouseoverGuid(); - if (mo != 0) tgt = mo; - else return false; // no mouseover — skip this alternative - } - return true; - } - - // mod / nomod - if (c == "nomod" || c == "mod:none") return !anyMod; - if (c.rfind("mod:", 0) == 0) { - std::string mods = c.substr(4); - bool ok = true; - if (mods.find("shift") != std::string::npos && !shiftHeld) ok = false; - if (mods.find("ctrl") != std::string::npos && !ctrlHeld) ok = false; - if (mods.find("alt") != std::string::npos && !altHeld) ok = false; - return ok; - } - - // combat / nocombat - if (c == "combat") return gameHandler.isInCombat(); - if (c == "nocombat") return !gameHandler.isInCombat(); - - // Helper to get the effective target entity - auto effTarget = [&]() -> std::shared_ptr { - if (tgt != static_cast(-1) && tgt != 0) - return gameHandler.getEntityManager().getEntity(tgt); - return gameHandler.getTarget(); - }; - - // exists / noexists - if (c == "exists") return effTarget() != nullptr; - if (c == "noexists") return effTarget() == nullptr; - - // dead / nodead - if (c == "dead") { - auto t = effTarget(); - auto u = t ? std::dynamic_pointer_cast(t) : nullptr; - return u && u->getHealth() == 0; - } - if (c == "nodead") { - auto t = effTarget(); - auto u = t ? std::dynamic_pointer_cast(t) : nullptr; - return u && u->getHealth() > 0; - } - - // help (friendly) / harm (hostile) and their no- variants - auto unitHostile = [&](const std::shared_ptr& t) -> bool { - if (!t) return false; - auto u = std::dynamic_pointer_cast(t); - return u && gameHandler.isHostileFactionPublic(u->getFactionTemplate()); - }; - if (c == "harm" || c == "nohelp") { return unitHostile(effTarget()); } - if (c == "help" || c == "noharm") { return !unitHostile(effTarget()); } - - // mounted / nomounted - if (c == "mounted") return gameHandler.isMounted(); - if (c == "nomounted") return !gameHandler.isMounted(); - - // swimming / noswimming - if (c == "swimming") return gameHandler.isSwimming(); - if (c == "noswimming") return !gameHandler.isSwimming(); - - // flying / noflying (CAN_FLY + FLYING flags active) - if (c == "flying") return gameHandler.isPlayerFlying(); - if (c == "noflying") return !gameHandler.isPlayerFlying(); - - // channeling / nochanneling - if (c == "channeling") return gameHandler.isCasting() && gameHandler.isChanneling(); - if (c == "nochanneling") return !(gameHandler.isCasting() && gameHandler.isChanneling()); - - // stealthed / nostealthed (unit flag 0x02000000 = UNIT_FLAG_SNEAKING) - auto isStealthedFn = [&]() -> bool { - auto pe = gameHandler.getEntityManager().getEntity(gameHandler.getPlayerGuid()); - if (!pe) return false; - auto pu = std::dynamic_pointer_cast(pe); - return pu && (pu->getUnitFlags() & 0x02000000u) != 0; - }; - if (c == "stealthed") return isStealthedFn(); - if (c == "nostealthed") return !isStealthedFn(); - - // pet / nopet — player has an active pet (hunters, warlocks, DKs) - if (c == "pet") return gameHandler.hasPet(); - if (c == "nopet") return !gameHandler.hasPet(); - - // indoors / outdoors — WMO interior detection (affects mount type selection) - if (c == "indoors" || c == "nooutdoors") { - auto* r = core::Application::getInstance().getRenderer(); - return r && r->isPlayerIndoors(); - } - if (c == "outdoors" || c == "noindoors") { - auto* r = core::Application::getInstance().getRenderer(); - return !r || !r->isPlayerIndoors(); - } - - // group / nogroup — player is in a party or raid - if (c == "group" || c == "party") return gameHandler.isInGroup(); - if (c == "nogroup") return !gameHandler.isInGroup(); - - // raid / noraid — player is in a raid group (groupType == 1) - if (c == "raid") return gameHandler.isInGroup() && gameHandler.getPartyData().groupType == 1; - if (c == "noraid") return !gameHandler.isInGroup() || gameHandler.getPartyData().groupType != 1; - - // spec:N — active talent spec (1-based: spec:1 = primary, spec:2 = secondary) - if (c.rfind("spec:", 0) == 0) { - uint8_t wantSpec = 0; - try { wantSpec = static_cast(std::stoul(c.substr(5))); } catch (...) {} - return wantSpec > 0 && gameHandler.getActiveTalentSpec() == (wantSpec - 1); - } - - // noform / nostance — player is NOT in a shapeshift/stance - if (c == "noform" || c == "nostance") { - for (const auto& a : gameHandler.getPlayerAuras()) - if (!a.isEmpty() && a.maxDurationMs == -1) return false; - return true; - } - // form:0 same as noform - if (c == "form:0" || c == "stance:0") { - for (const auto& a : gameHandler.getPlayerAuras()) - if (!a.isEmpty() && a.maxDurationMs == -1) return false; - return true; - } - - // buff:SpellName / nobuff:SpellName — check if the effective target (or player - // if no target specified) has a buff with the given name. - // debuff:SpellName / nodebuff:SpellName — same for debuffs (harmful auras). - auto checkAuraByName = [&](const std::string& spellName, bool wantDebuff, - bool negate) -> bool { - // Determine which aura list to check: effective target or player - const std::vector* auras = nullptr; - if (tgt != static_cast(-1) && tgt != 0 && tgt != gameHandler.getPlayerGuid()) { - // Check target's auras - auras = &gameHandler.getTargetAuras(); - } else { - auras = &gameHandler.getPlayerAuras(); - } - std::string nameLow = spellName; - for (char& ch : nameLow) ch = static_cast(std::tolower(static_cast(ch))); - for (const auto& a : *auras) { - if (a.isEmpty() || a.spellId == 0) continue; - // Filter: debuffs have the HARMFUL flag (0x80) or spell has a dispel type - bool isDebuff = (a.flags & 0x80) != 0; - if (wantDebuff ? !isDebuff : isDebuff) continue; - std::string sn = gameHandler.getSpellName(a.spellId); - for (char& ch : sn) ch = static_cast(std::tolower(static_cast(ch))); - if (sn == nameLow) return !negate; - } - return negate; - }; - if (c.rfind("buff:", 0) == 0 && c.size() > 5) - return checkAuraByName(c.substr(5), false, false); - if (c.rfind("nobuff:", 0) == 0 && c.size() > 7) - return checkAuraByName(c.substr(7), false, true); - if (c.rfind("debuff:", 0) == 0 && c.size() > 7) - return checkAuraByName(c.substr(7), true, false); - if (c.rfind("nodebuff:", 0) == 0 && c.size() > 9) - return checkAuraByName(c.substr(9), true, true); - - // mounted / nomounted - if (c == "mounted") return gameHandler.isMounted(); - if (c == "nomounted") return !gameHandler.isMounted(); - - // group (any group) / nogroup / raid - if (c == "group") return !gameHandler.getPartyData().isEmpty(); - if (c == "nogroup") return gameHandler.getPartyData().isEmpty(); - if (c == "raid") { - const auto& pd = gameHandler.getPartyData(); - return pd.groupType >= 1; // groupType 1 = raid, 0 = party - } - - // channeling:SpellName — player is currently channeling that spell - if (c.rfind("channeling:", 0) == 0 && c.size() > 11) { - if (!gameHandler.isChanneling()) return false; - std::string want = c.substr(11); - for (char& ch : want) ch = static_cast(std::tolower(static_cast(ch))); - uint32_t castSpellId = gameHandler.getCurrentCastSpellId(); - std::string sn = gameHandler.getSpellName(castSpellId); - for (char& ch : sn) ch = static_cast(std::tolower(static_cast(ch))); - return sn == want; - } - if (c == "channeling") return gameHandler.isChanneling(); - if (c == "nochanneling") return !gameHandler.isChanneling(); - - // casting (any active cast or channel) - if (c == "casting") return gameHandler.isCasting(); - if (c == "nocasting") return !gameHandler.isCasting(); - - // vehicle / novehicle (WotLK) - if (c == "vehicle") return gameHandler.getVehicleId() != 0; - if (c == "novehicle") return gameHandler.getVehicleId() == 0; - - // Unknown → permissive (don't block) - return true; - }; - - for (auto& alt : alts) { - // trim - size_t fs = alt.find_first_not_of(" \t"); - if (fs == std::string::npos) continue; - alt = alt.substr(fs); - size_t ls = alt.find_last_not_of(" \t"); - if (ls != std::string::npos) alt.resize(ls + 1); - - if (!alt.empty() && alt[0] == '[') { - size_t close = alt.find(']'); - if (close == std::string::npos) continue; - std::string condStr = alt.substr(1, close - 1); - std::string argPart = alt.substr(close + 1); - // Trim argPart - size_t as = argPart.find_first_not_of(" \t"); - argPart = (as != std::string::npos) ? argPart.substr(as) : ""; - - // Evaluate comma-separated conditions - uint64_t tgt = static_cast(-1); - bool pass = true; - size_t cp = 0; - while (pass) { - size_t comma = condStr.find(',', cp); - std::string tok = condStr.substr(cp, comma == std::string::npos ? std::string::npos : comma - cp); - if (!evalCond(tok, tgt)) { pass = false; break; } - if (comma == std::string::npos) break; - cp = comma + 1; - } - if (pass) { - if (tgt != static_cast(-1)) targetOverride = tgt; - return argPart; - } - } else { - // No condition block — default fallback always matches - return alt; - } - } - return {}; + auto* renderer = core::Application::getInstance().getRenderer(); + GameStateAdapter gs(gameHandler, renderer); + InputModifierAdapter im; + MacroEvaluator eval(gs, im); + return eval.evaluate(rawArg, targetOverride); } // Execute all non-comment lines of a macro body in sequence. @@ -431,37 +105,21 @@ static std::string evaluateMacroConditionals(const std::string& rawArg, // /stopmacro (with optional conditionals) halts the remaining commands early. void ChatPanel::executeMacroText(game::GameHandler& gameHandler, - InventoryScreen& inventoryScreen, - SpellbookScreen& spellbookScreen, - QuestLogScreen& questLogScreen, const std::string& macroText) { macroStopped_ = false; for (const auto& cmd : allMacroCommands(macroText)) { strncpy(chatInputBuffer_, cmd.c_str(), sizeof(chatInputBuffer_) - 1); chatInputBuffer_[sizeof(chatInputBuffer_) - 1] = '\0'; - sendChatMessage(gameHandler, inventoryScreen, spellbookScreen, questLogScreen); + sendChatMessage(gameHandler); if (macroStopped_) break; } macroStopped_ = false; } -// /castsequence persistent state — shared across all macros using the same spell list. -// Keyed by the normalized (lowercase, comma-joined) spell sequence string. -namespace { -struct CastSeqState { - size_t index = 0; - float lastPressSec = 0.0f; - uint64_t lastTargetGuid = 0; - bool lastInCombat = false; -}; -std::unordered_map s_castSeqStates; -} // namespace +// /castsequence state moved to CastSequenceTracker member (Phase 1.5) -void ChatPanel::sendChatMessage(game::GameHandler& gameHandler, - InventoryScreen& /*inventoryScreen*/, - SpellbookScreen& /*spellbookScreen*/, - QuestLogScreen& /*questLogScreen*/) { +void ChatPanel::sendChatMessage(game::GameHandler& gameHandler) { if (strlen(chatInputBuffer_) > 0) { std::string input(chatInputBuffer_); @@ -484,8 +142,35 @@ void ChatPanel::sendChatMessage(game::GameHandler& gameHandler, std::string message = input; std::string target; - // Track if a channel shortcut should change the chat type dropdown - int switchChatType = -1; + // GM dot-prefix commands (.gm, .tele, .additem, etc.) + // Sent to server as SAY — the server interprets the dot-prefix. + // Requires GM security level on the server (account set gmlevel 3 -1). + if (input.size() > 1 && input[0] == '.') { + LOG_INFO("GM command: '", input, "' — sending as SAY to server"); + gameHandler.sendChatMessage(game::ChatType::SAY, input, ""); + + // Build feedback: check if this is a known command + std::string dotCmd = input; + size_t sp = dotCmd.find(' '); + std::string cmdPart = (sp != std::string::npos) + ? dotCmd.substr(1, sp - 1) : dotCmd.substr(1); + for (char& c : cmdPart) c = static_cast(std::tolower(static_cast(c))); + + // Look for a matching entry in the GM command table + std::string feedback; + for (const auto& entry : kGmCommands) { + if (entry.name == cmdPart) { + feedback = "Sent: " + input + " (" + std::string(entry.help) + ")"; + break; + } + } + if (feedback.empty()) + feedback = "Sent: " + input + + " (requires GM access — server console: account set gmlevel 3 -1)"; + gameHandler.addLocalChatMessage(chat_utils::makeSystemMessage(feedback)); + chatInputBuffer_[0] = '\0'; + return; + } // Check for slash commands if (input.size() > 1 && input[0] == '/') { @@ -548,2117 +233,23 @@ void ChatPanel::sendChatMessage(game::GameHandler& gameHandler, } } - // Special commands - if (cmdLower == "logout") { - core::Application::getInstance().logoutToLogin(); - chatInputBuffer_[0] = '\0'; - return; - } + // Dispatch through command registry (Phase 3.11) + std::string args; + if (spacePos != std::string::npos) + args = command.substr(spacePos + 1); - if (cmdLower == "clear") { - gameHandler.clearChatHistory(); - chatInputBuffer_[0] = '\0'; - return; - } + ChatCommandContext ctx{gameHandler, services_, *this, args, cmdLower}; + ChatCommandResult result = commandRegistry_.dispatch(cmdLower, ctx); - // /reload or /reloadui — reload all addons (save variables, re-init Lua, re-scan .toc files) - if (cmdLower == "reload" || cmdLower == "reloadui" || cmdLower == "rl") { - auto* am = services_.addonManager; - if (am) { - am->reload(); - am->fireEvent("VARIABLES_LOADED"); - am->fireEvent("PLAYER_LOGIN"); - am->fireEvent("PLAYER_ENTERING_WORLD"); - game::MessageChatData rlMsg; - rlMsg.type = game::ChatType::SYSTEM; - rlMsg.language = game::ChatLanguage::UNIVERSAL; - rlMsg.message = "Interface reloaded."; - gameHandler.addLocalChatMessage(rlMsg); - } else { - game::MessageChatData rlMsg; - rlMsg.type = game::ChatType::SYSTEM; - rlMsg.language = game::ChatLanguage::UNIVERSAL; - rlMsg.message = "Addon system not available."; - gameHandler.addLocalChatMessage(rlMsg); - } - chatInputBuffer_[0] = '\0'; - return; - } - - // /stopmacro [conditions] - // Halts execution of the current macro (remaining lines are skipped). - // With a condition block, only stops if the conditions evaluate to true. - // /stopmacro → always stops - // /stopmacro [combat] → stops only while in combat - // /stopmacro [nocombat] → stops only when not in combat - if (cmdLower == "stopmacro") { - bool shouldStop = true; - if (spacePos != std::string::npos) { - std::string condArg = command.substr(spacePos + 1); - while (!condArg.empty() && condArg.front() == ' ') condArg.erase(condArg.begin()); - if (!condArg.empty() && condArg.front() == '[') { - // Append a sentinel action so evaluateMacroConditionals can signal a match. - uint64_t tgtOver = static_cast(-1); - std::string hit = evaluateMacroConditionals(condArg + " __stop__", gameHandler, tgtOver); - shouldStop = !hit.empty(); - } - } - if (shouldStop) macroStopped_ = true; - chatInputBuffer_[0] = '\0'; - return; - } - - // /invite command - if (cmdLower == "invite" && spacePos != std::string::npos) { - std::string targetName = command.substr(spacePos + 1); - gameHandler.inviteToGroup(targetName); - chatInputBuffer_[0] = '\0'; - return; - } - - // /inspect command - if (cmdLower == "inspect") { - gameHandler.inspectTarget(); - slashCmds_.showInspect = true; - chatInputBuffer_[0] = '\0'; - return; - } - - // /threat command - if (cmdLower == "threat") { - slashCmds_.toggleThreat = true; - chatInputBuffer_[0] = '\0'; - return; - } - - // /score command — BG scoreboard - if (cmdLower == "score") { - gameHandler.requestPvpLog(); - slashCmds_.showBgScore = true; - chatInputBuffer_[0] = '\0'; - return; - } - - // /time command - if (cmdLower == "time") { - gameHandler.queryServerTime(); - chatInputBuffer_[0] = '\0'; - return; - } - - // /loc command — print player coordinates and zone name - if (cmdLower == "loc" || cmdLower == "coords" || cmdLower == "whereami") { - const auto& pmi = gameHandler.getMovementInfo(); - std::string zoneName; - if (auto* rend = services_.renderer) - zoneName = rend->getCurrentZoneName(); - char buf[256]; - snprintf(buf, sizeof(buf), "%.1f, %.1f, %.1f%s%s", - pmi.x, pmi.y, pmi.z, - zoneName.empty() ? "" : " — ", - zoneName.c_str()); - game::MessageChatData sysMsg; - sysMsg.type = game::ChatType::SYSTEM; - sysMsg.language = game::ChatLanguage::UNIVERSAL; - sysMsg.message = buf; - gameHandler.addLocalChatMessage(sysMsg); - chatInputBuffer_[0] = '\0'; - return; - } - - // /screenshot command — capture current frame to PNG - if (cmdLower == "screenshot" || cmdLower == "ss") { - slashCmds_.takeScreenshot = true; - chatInputBuffer_[0] = '\0'; - return; - } - - // /zone command — print current zone name - if (cmdLower == "zone") { - std::string zoneName; - if (auto* rend = services_.renderer) - zoneName = rend->getCurrentZoneName(); - game::MessageChatData sysMsg; - sysMsg.type = game::ChatType::SYSTEM; - sysMsg.language = game::ChatLanguage::UNIVERSAL; - sysMsg.message = zoneName.empty() ? "You are not in a known zone." : "You are in: " + zoneName; - gameHandler.addLocalChatMessage(sysMsg); - chatInputBuffer_[0] = '\0'; - return; - } - - // /played command - if (cmdLower == "played") { - gameHandler.requestPlayedTime(); - chatInputBuffer_[0] = '\0'; - return; - } - - // /ticket command — open GM ticket window - if (cmdLower == "ticket" || cmdLower == "gmticket" || cmdLower == "gm") { - slashCmds_.showGmTicket = true; - chatInputBuffer_[0] = '\0'; - return; - } - - // /chathelp command — list chat-channel slash commands - if (cmdLower == "chathelp") { - static constexpr const char* kChatHelp[] = { - "--- Chat Channel Commands ---", - "/s [msg] Say to nearby players", - "/y [msg] Yell to a wider area", - "/w [msg] Whisper to player", - "/r [msg] Reply to last whisper", - "/p [msg] Party chat", - "/g [msg] Guild chat", - "/o [msg] Guild officer chat", - "/raid [msg] Raid chat", - "/rw [msg] Raid warning", - "/bg [msg] Battleground chat", - "/1 [msg] General channel", - "/2 [msg] Trade channel (also /wts /wtb)", - "/ [msg] Channel by number", - "/join Join a channel", - "/leave Leave a channel", - "/afk [msg] Set AFK status", - "/dnd [msg] Set Do Not Disturb", - }; - for (const char* line : kChatHelp) { - game::MessageChatData helpMsg; - helpMsg.type = game::ChatType::SYSTEM; - helpMsg.language = game::ChatLanguage::UNIVERSAL; - helpMsg.message = line; - gameHandler.addLocalChatMessage(helpMsg); - } - chatInputBuffer_[0] = '\0'; - return; - } - - // /macrohelp command — list available macro conditionals - if (cmdLower == "macrohelp") { - static constexpr const char* kMacroHelp[] = { - "--- Macro Conditionals ---", - "Usage: /cast [cond1,cond2] Spell1; [cond3] Spell2; Default", - "State: [combat] [mounted] [swimming] [flying] [stealthed]", - " [channeling] [pet] [group] [raid] [indoors] [outdoors]", - "Spec: [spec:1] [spec:2] (active talent spec, 1-based)", - " (prefix no- to negate any condition)", - "Target: [harm] [help] [exists] [noexists] [dead] [nodead]", - " [target=focus] [target=pet] [target=mouseover] [target=player]", - " (also: @focus, @pet, @mouseover, @player, @target)", - "Form: [noform] [nostance] [form:0]", - "Keys: [mod:shift] [mod:ctrl] [mod:alt]", - "Aura: [buff:Name] [nobuff:Name] [debuff:Name] [nodebuff:Name]", - "Other: #showtooltip, /stopmacro [cond], /castsequence", - }; - for (const char* line : kMacroHelp) { - game::MessageChatData m; - m.type = game::ChatType::SYSTEM; - m.language = game::ChatLanguage::UNIVERSAL; - m.message = line; - gameHandler.addLocalChatMessage(m); - } - chatInputBuffer_[0] = '\0'; - return; - } - - // /help command — list available slash commands - if (cmdLower == "help" || cmdLower == "?") { - static constexpr const char* kHelpLines[] = { - "--- Wowee Slash Commands ---", - "Chat: /s /y /p /g /raid /rw /o /bg /w /r /join /leave", - "Social: /who /friend add/remove /ignore /unignore", - "Party: /invite /uninvite /leave /readycheck /mark /roll", - " /maintank /mainassist /raidconvert /raidinfo", - " /lootmethod /lootthreshold", - "Guild: /ginvite /gkick /gquit /gpromote /gdemote /gmotd", - " /gleader /groster /ginfo /gcreate /gdisband", - "Combat: /cast /castsequence /use /startattack /stopattack", - " /stopcasting /duel /forfeit /pvp /assist", - " /follow /stopfollow /threat /combatlog", - "Items: /use /equip /equipset [name]", - "Target: /target /cleartarget /focus /clearfocus /inspect", - "Movement: /sit /stand /kneel /dismount", - "Misc: /played /time /zone /loc /afk /dnd /helm /cloak", - " /trade /score /unstuck /logout /quit /exit /ticket", - " /screenshot /difficulty", - " /macrohelp /chathelp /help", - }; - for (const char* line : kHelpLines) { - game::MessageChatData helpMsg; - helpMsg.type = game::ChatType::SYSTEM; - helpMsg.language = game::ChatLanguage::UNIVERSAL; - helpMsg.message = line; - gameHandler.addLocalChatMessage(helpMsg); - } - chatInputBuffer_[0] = '\0'; - return; - } - - // /who commands - if (cmdLower == "who" || cmdLower == "whois" || cmdLower == "online" || cmdLower == "players") { - std::string query; - if (spacePos != std::string::npos) { - query = command.substr(spacePos + 1); - // Trim leading/trailing whitespace - size_t first = query.find_first_not_of(" \t\r\n"); - if (first == std::string::npos) { - query.clear(); - } else { - size_t last = query.find_last_not_of(" \t\r\n"); - query = query.substr(first, last - first + 1); - } - } - - if ((cmdLower == "whois") && query.empty()) { - game::MessageChatData msg; - msg.type = game::ChatType::SYSTEM; - msg.language = game::ChatLanguage::UNIVERSAL; - msg.message = "Usage: /whois "; - gameHandler.addLocalChatMessage(msg); + if (result.handled) { + if (result.clearInput) chatInputBuffer_[0] = '\0'; - return; - } - - if (cmdLower == "who" && (query == "help" || query == "?")) { - game::MessageChatData msg; - msg.type = game::ChatType::SYSTEM; - msg.language = game::ChatLanguage::UNIVERSAL; - msg.message = "Who commands: /who [name/filter], /whois , /online"; - gameHandler.addLocalChatMessage(msg); - chatInputBuffer_[0] = '\0'; - return; - } - - gameHandler.queryWho(query); - slashCmds_.showWho = true; - chatInputBuffer_[0] = '\0'; - return; - } - - // /combatlog command - if (cmdLower == "combatlog" || cmdLower == "cl") { - slashCmds_.toggleCombatLog = true; - chatInputBuffer_[0] = '\0'; - return; - } - - // /roll command - if (cmdLower == "roll" || cmdLower == "random" || cmdLower == "rnd") { - uint32_t minRoll = 1; - uint32_t maxRoll = 100; - - if (spacePos != std::string::npos) { - std::string args = command.substr(spacePos + 1); - size_t dashPos = args.find('-'); - size_t spacePos2 = args.find(' '); - - if (dashPos != std::string::npos) { - // Format: /roll 1-100 - try { - minRoll = std::stoul(args.substr(0, dashPos)); - maxRoll = std::stoul(args.substr(dashPos + 1)); - } catch (...) {} - } else if (spacePos2 != std::string::npos) { - // Format: /roll 1 100 - try { - minRoll = std::stoul(args.substr(0, spacePos2)); - maxRoll = std::stoul(args.substr(spacePos2 + 1)); - } catch (...) {} - } else { - // Format: /roll 100 (means 1-100) - try { - maxRoll = std::stoul(args); - } catch (...) {} - } - } - - gameHandler.randomRoll(minRoll, maxRoll); - chatInputBuffer_[0] = '\0'; - return; - } - - // /friend or /addfriend command - if (cmdLower == "friend" || cmdLower == "addfriend") { - if (spacePos != std::string::npos) { - std::string args = command.substr(spacePos + 1); - size_t subCmdSpace = args.find(' '); - - if (cmdLower == "friend" && subCmdSpace != std::string::npos) { - std::string subCmd = args.substr(0, subCmdSpace); - std::transform(subCmd.begin(), subCmd.end(), subCmd.begin(), ::tolower); - - if (subCmd == "add") { - std::string playerName = args.substr(subCmdSpace + 1); - gameHandler.addFriend(playerName); - chatInputBuffer_[0] = '\0'; - return; - } else if (subCmd == "remove" || subCmd == "delete" || subCmd == "rem") { - std::string playerName = args.substr(subCmdSpace + 1); - gameHandler.removeFriend(playerName); - chatInputBuffer_[0] = '\0'; - return; - } - } else { - // /addfriend name or /friend name (assume add) - gameHandler.addFriend(args); - chatInputBuffer_[0] = '\0'; - return; - } - } - - game::MessageChatData msg; - msg.type = game::ChatType::SYSTEM; - msg.language = game::ChatLanguage::UNIVERSAL; - msg.message = "Usage: /friend add or /friend remove "; - gameHandler.addLocalChatMessage(msg); - chatInputBuffer_[0] = '\0'; - return; - } - - // /removefriend or /delfriend command - if (cmdLower == "removefriend" || cmdLower == "delfriend" || cmdLower == "remfriend") { - if (spacePos != std::string::npos) { - std::string playerName = command.substr(spacePos + 1); - gameHandler.removeFriend(playerName); - chatInputBuffer_[0] = '\0'; - return; - } - - game::MessageChatData msg; - msg.type = game::ChatType::SYSTEM; - msg.language = game::ChatLanguage::UNIVERSAL; - msg.message = "Usage: /removefriend "; - gameHandler.addLocalChatMessage(msg); - chatInputBuffer_[0] = '\0'; - return; - } - - // /ignore command - if (cmdLower == "ignore") { - if (spacePos != std::string::npos) { - std::string playerName = command.substr(spacePos + 1); - gameHandler.addIgnore(playerName); - chatInputBuffer_[0] = '\0'; - return; - } - - game::MessageChatData msg; - msg.type = game::ChatType::SYSTEM; - msg.language = game::ChatLanguage::UNIVERSAL; - msg.message = "Usage: /ignore "; - gameHandler.addLocalChatMessage(msg); - chatInputBuffer_[0] = '\0'; - return; - } - - // /unignore command - if (cmdLower == "unignore") { - if (spacePos != std::string::npos) { - std::string playerName = command.substr(spacePos + 1); - gameHandler.removeIgnore(playerName); - chatInputBuffer_[0] = '\0'; - return; - } - - game::MessageChatData msg; - msg.type = game::ChatType::SYSTEM; - msg.language = game::ChatLanguage::UNIVERSAL; - msg.message = "Usage: /unignore "; - gameHandler.addLocalChatMessage(msg); - chatInputBuffer_[0] = '\0'; - return; - } - - // /dismount command - if (cmdLower == "dismount") { - gameHandler.dismount(); - chatInputBuffer_[0] = '\0'; - return; - } - - // Pet control commands (common macro use) - // Action IDs: 1=passive, 2=follow, 3=stay, 4=defensive, 5=attack, 6=aggressive - if (cmdLower == "petattack") { - uint64_t target = gameHandler.hasTarget() ? gameHandler.getTargetGuid() : 0; - gameHandler.sendPetAction(5, target); - chatInputBuffer_[0] = '\0'; - return; - } - if (cmdLower == "petfollow") { - gameHandler.sendPetAction(2, 0); - chatInputBuffer_[0] = '\0'; - return; - } - if (cmdLower == "petstay" || cmdLower == "pethalt") { - gameHandler.sendPetAction(3, 0); - chatInputBuffer_[0] = '\0'; - return; - } - if (cmdLower == "petpassive") { - gameHandler.sendPetAction(1, 0); - chatInputBuffer_[0] = '\0'; - return; - } - if (cmdLower == "petdefensive") { - gameHandler.sendPetAction(4, 0); - chatInputBuffer_[0] = '\0'; - return; - } - if (cmdLower == "petaggressive") { - gameHandler.sendPetAction(6, 0); - chatInputBuffer_[0] = '\0'; - return; - } - if (cmdLower == "petdismiss") { - gameHandler.dismissPet(); - chatInputBuffer_[0] = '\0'; - return; - } - - // /cancelform / /cancelshapeshift — leave current shapeshift/stance - if (cmdLower == "cancelform" || cmdLower == "cancelshapeshift") { - // Cancel the first permanent shapeshift aura the player has - for (const auto& aura : gameHandler.getPlayerAuras()) { - if (aura.spellId == 0) continue; - // Permanent shapeshift auras have the permanent flag (0x20) set - if (aura.flags & 0x20) { - gameHandler.cancelAura(aura.spellId); - break; - } - } - chatInputBuffer_[0] = '\0'; - return; - } - - // /cancelaura — cancel a specific buff by name or ID - if (cmdLower == "cancelaura" && spacePos != std::string::npos) { - std::string auraArg = command.substr(spacePos + 1); - while (!auraArg.empty() && auraArg.front() == ' ') auraArg.erase(auraArg.begin()); - while (!auraArg.empty() && auraArg.back() == ' ') auraArg.pop_back(); - // Try numeric ID first - { - std::string numStr = auraArg; - if (!numStr.empty() && numStr.front() == '#') numStr.erase(numStr.begin()); - bool isNum = !numStr.empty() && - std::all_of(numStr.begin(), numStr.end(), - [](unsigned char c){ return std::isdigit(c); }); - if (isNum) { - uint32_t spellId = 0; - try { spellId = static_cast(std::stoul(numStr)); } catch (...) {} - if (spellId) gameHandler.cancelAura(spellId); - chatInputBuffer_[0] = '\0'; - return; - } - } - // Name match against player auras - std::string argLow = auraArg; - for (char& c : argLow) c = static_cast(std::tolower(static_cast(c))); - for (const auto& aura : gameHandler.getPlayerAuras()) { - if (aura.spellId == 0) continue; - std::string sn = gameHandler.getSpellName(aura.spellId); - for (char& c : sn) c = static_cast(std::tolower(static_cast(c))); - if (sn == argLow) { - gameHandler.cancelAura(aura.spellId); - break; - } - } - chatInputBuffer_[0] = '\0'; - return; - } - - // /sit command - if (cmdLower == "sit") { - gameHandler.setStandState(1); // 1 = sit - chatInputBuffer_[0] = '\0'; - return; - } - - // /stand command - if (cmdLower == "stand") { - gameHandler.setStandState(0); // 0 = stand - chatInputBuffer_[0] = '\0'; - return; - } - - // /kneel command - if (cmdLower == "kneel") { - gameHandler.setStandState(8); // 8 = kneel - chatInputBuffer_[0] = '\0'; - return; - } - - // /logout command (also /camp, /quit, /exit) - if (cmdLower == "logout" || cmdLower == "camp" || cmdLower == "quit" || cmdLower == "exit") { - gameHandler.requestLogout(); - chatInputBuffer_[0] = '\0'; - return; - } - - // /cancellogout command - if (cmdLower == "cancellogout") { - gameHandler.cancelLogout(); - chatInputBuffer_[0] = '\0'; - return; - } - - // /difficulty command — set dungeon/raid difficulty (WotLK) - if (cmdLower == "difficulty") { - std::string arg; - if (spacePos != std::string::npos) { - arg = command.substr(spacePos + 1); - // Trim whitespace - size_t first = arg.find_first_not_of(" \t"); - size_t last = arg.find_last_not_of(" \t"); - if (first != std::string::npos) - arg = arg.substr(first, last - first + 1); - else - arg.clear(); - for (auto& ch : arg) ch = static_cast(std::tolower(static_cast(ch))); - } - - uint32_t diff = 0; - bool valid = true; - if (arg == "normal" || arg == "0") diff = 0; - else if (arg == "heroic" || arg == "1") diff = 1; - else if (arg == "25" || arg == "25normal" || arg == "25man" || arg == "2") - diff = 2; - else if (arg == "25heroic" || arg == "25manheroic" || arg == "3") - diff = 3; - else valid = false; - - if (!valid || arg.empty()) { - game::MessageChatData msg; - msg.type = game::ChatType::SYSTEM; - msg.language = game::ChatLanguage::UNIVERSAL; - msg.message = "Usage: /difficulty normal|heroic|25|25heroic (0-3)"; - gameHandler.addLocalChatMessage(msg); - } else { - static constexpr const char* kDiffNames[] = { - "Normal (5-man)", "Heroic (5-man)", "Normal (25-man)", "Heroic (25-man)" - }; - game::MessageChatData msg; - msg.type = game::ChatType::SYSTEM; - msg.language = game::ChatLanguage::UNIVERSAL; - msg.message = std::string("Setting difficulty to: ") + kDiffNames[diff]; - gameHandler.addLocalChatMessage(msg); - gameHandler.sendSetDifficulty(diff); - } - chatInputBuffer_[0] = '\0'; - return; - } - - // /helm command - if (cmdLower == "helm" || cmdLower == "helmet" || cmdLower == "showhelm") { - gameHandler.toggleHelm(); - chatInputBuffer_[0] = '\0'; - return; - } - - // /cloak command - if (cmdLower == "cloak" || cmdLower == "showcloak") { - gameHandler.toggleCloak(); - chatInputBuffer_[0] = '\0'; - return; - } - - // /follow command - if (cmdLower == "follow" || cmdLower == "f") { - gameHandler.followTarget(); - chatInputBuffer_[0] = '\0'; - return; - } - - // /stopfollow command - if (cmdLower == "stopfollow") { - gameHandler.cancelFollow(); - chatInputBuffer_[0] = '\0'; - return; - } - - // /assist command - if (cmdLower == "assist") { - // /assist → assist current target (use their target) - // /assist PlayerName → find PlayerName, target their target - // /assist [target=X] → evaluate conditional, target that entity's target - auto assistEntityTarget = [&](uint64_t srcGuid) { - auto srcEnt = gameHandler.getEntityManager().getEntity(srcGuid); - if (!srcEnt) { gameHandler.assistTarget(); return; } - uint64_t atkGuid = 0; - const auto& flds = srcEnt->getFields(); - auto iLo = flds.find(game::fieldIndex(game::UF::UNIT_FIELD_TARGET_LO)); - if (iLo != flds.end()) { - atkGuid = iLo->second; - auto iHi = flds.find(game::fieldIndex(game::UF::UNIT_FIELD_TARGET_HI)); - if (iHi != flds.end()) atkGuid |= (static_cast(iHi->second) << 32); - } - if (atkGuid != 0) { - gameHandler.setTarget(atkGuid); - } else { - std::string sn = getEntityName(srcEnt); - game::MessageChatData msg; - msg.type = game::ChatType::SYSTEM; - msg.language = game::ChatLanguage::UNIVERSAL; - msg.message = (sn.empty() ? "Target" : sn) + " has no target."; - gameHandler.addLocalChatMessage(msg); - } - }; - - if (spacePos != std::string::npos) { - std::string assistArg = command.substr(spacePos + 1); - while (!assistArg.empty() && assistArg.front() == ' ') assistArg.erase(assistArg.begin()); - - // Evaluate conditionals if present - uint64_t assistOver = static_cast(-1); - if (!assistArg.empty() && assistArg.front() == '[') { - assistArg = evaluateMacroConditionals(assistArg, gameHandler, assistOver); - if (assistArg.empty() && assistOver == static_cast(-1)) { - chatInputBuffer_[0] = '\0'; return; // no condition matched - } - while (!assistArg.empty() && assistArg.front() == ' ') assistArg.erase(assistArg.begin()); - while (!assistArg.empty() && assistArg.back() == ' ') assistArg.pop_back(); - } - - if (assistOver != static_cast(-1) && assistOver != 0) { - assistEntityTarget(assistOver); - } else if (!assistArg.empty()) { - // Name search - std::string argLow = assistArg; - for (char& c : argLow) c = static_cast(std::tolower(static_cast(c))); - uint64_t bestGuid = 0; float bestDist = std::numeric_limits::max(); - const auto& pmi = gameHandler.getMovementInfo(); - for (const auto& [guid, ent] : gameHandler.getEntityManager().getEntities()) { - if (!ent || ent->getType() == game::ObjectType::OBJECT) continue; - std::string nm = getEntityName(ent); - std::string nml = nm; - for (char& c : nml) c = static_cast(std::tolower(static_cast(c))); - if (nml.find(argLow) != 0) continue; - float d2 = (ent->getX()-pmi.x)*(ent->getX()-pmi.x) - + (ent->getY()-pmi.y)*(ent->getY()-pmi.y); - if (d2 < bestDist) { bestDist = d2; bestGuid = guid; } - } - if (bestGuid) assistEntityTarget(bestGuid); - else { - game::MessageChatData msg; - msg.type = game::ChatType::SYSTEM; - msg.language = game::ChatLanguage::UNIVERSAL; - msg.message = "No unit matching '" + assistArg + "' found."; - gameHandler.addLocalChatMessage(msg); - } - } else { - gameHandler.assistTarget(); - } - } else { - gameHandler.assistTarget(); - } - chatInputBuffer_[0] = '\0'; - return; - } - - // /pvp command - if (cmdLower == "pvp") { - gameHandler.togglePvp(); - chatInputBuffer_[0] = '\0'; - return; - } - - // /ginfo command - if (cmdLower == "ginfo" || cmdLower == "guildinfo") { - gameHandler.requestGuildInfo(); - chatInputBuffer_[0] = '\0'; - return; - } - - // /groster command - if (cmdLower == "groster" || cmdLower == "guildroster") { - gameHandler.requestGuildRoster(); - chatInputBuffer_[0] = '\0'; - return; - } - - // /gmotd command - if (cmdLower == "gmotd" || cmdLower == "guildmotd") { - if (spacePos != std::string::npos) { - std::string motd = command.substr(spacePos + 1); - gameHandler.setGuildMotd(motd); - chatInputBuffer_[0] = '\0'; - return; - } - - game::MessageChatData msg; - msg.type = game::ChatType::SYSTEM; - msg.language = game::ChatLanguage::UNIVERSAL; - msg.message = "Usage: /gmotd "; - gameHandler.addLocalChatMessage(msg); - chatInputBuffer_[0] = '\0'; - return; - } - - // /gpromote command - if (cmdLower == "gpromote" || cmdLower == "guildpromote") { - if (spacePos != std::string::npos) { - std::string playerName = command.substr(spacePos + 1); - gameHandler.promoteGuildMember(playerName); - chatInputBuffer_[0] = '\0'; - return; - } - - game::MessageChatData msg; - msg.type = game::ChatType::SYSTEM; - msg.language = game::ChatLanguage::UNIVERSAL; - msg.message = "Usage: /gpromote "; - gameHandler.addLocalChatMessage(msg); - chatInputBuffer_[0] = '\0'; - return; - } - - // /gdemote command - if (cmdLower == "gdemote" || cmdLower == "guilddemote") { - if (spacePos != std::string::npos) { - std::string playerName = command.substr(spacePos + 1); - gameHandler.demoteGuildMember(playerName); - chatInputBuffer_[0] = '\0'; - return; - } - - game::MessageChatData msg; - msg.type = game::ChatType::SYSTEM; - msg.language = game::ChatLanguage::UNIVERSAL; - msg.message = "Usage: /gdemote "; - gameHandler.addLocalChatMessage(msg); - chatInputBuffer_[0] = '\0'; - return; - } - - // /gquit command - if (cmdLower == "gquit" || cmdLower == "guildquit" || cmdLower == "leaveguild") { - gameHandler.leaveGuild(); - chatInputBuffer_[0] = '\0'; - return; - } - - // /ginvite command - if (cmdLower == "ginvite" || cmdLower == "guildinvite") { - if (spacePos != std::string::npos) { - std::string playerName = command.substr(spacePos + 1); - gameHandler.inviteToGuild(playerName); - chatInputBuffer_[0] = '\0'; - return; - } - - game::MessageChatData msg; - msg.type = game::ChatType::SYSTEM; - msg.language = game::ChatLanguage::UNIVERSAL; - msg.message = "Usage: /ginvite "; - gameHandler.addLocalChatMessage(msg); - chatInputBuffer_[0] = '\0'; - return; - } - - // /gkick command - if (cmdLower == "gkick" || cmdLower == "guildkick") { - if (spacePos != std::string::npos) { - std::string playerName = command.substr(spacePos + 1); - gameHandler.kickGuildMember(playerName); - chatInputBuffer_[0] = '\0'; - return; - } - - game::MessageChatData msg; - msg.type = game::ChatType::SYSTEM; - msg.language = game::ChatLanguage::UNIVERSAL; - msg.message = "Usage: /gkick "; - gameHandler.addLocalChatMessage(msg); - chatInputBuffer_[0] = '\0'; - return; - } - - // /gcreate command - if (cmdLower == "gcreate" || cmdLower == "guildcreate") { - if (spacePos != std::string::npos) { - std::string guildName = command.substr(spacePos + 1); - gameHandler.createGuild(guildName); - chatInputBuffer_[0] = '\0'; - return; - } - - game::MessageChatData msg; - msg.type = game::ChatType::SYSTEM; - msg.language = game::ChatLanguage::UNIVERSAL; - msg.message = "Usage: /gcreate "; - gameHandler.addLocalChatMessage(msg); - chatInputBuffer_[0] = '\0'; - return; - } - - // /gdisband command - if (cmdLower == "gdisband" || cmdLower == "guilddisband") { - gameHandler.disbandGuild(); - chatInputBuffer_[0] = '\0'; - return; - } - - // /gleader command - if (cmdLower == "gleader" || cmdLower == "guildleader") { - if (spacePos != std::string::npos) { - std::string playerName = command.substr(spacePos + 1); - gameHandler.setGuildLeader(playerName); - chatInputBuffer_[0] = '\0'; - return; - } - - game::MessageChatData msg; - msg.type = game::ChatType::SYSTEM; - msg.language = game::ChatLanguage::UNIVERSAL; - msg.message = "Usage: /gleader "; - gameHandler.addLocalChatMessage(msg); - chatInputBuffer_[0] = '\0'; - return; - } - - // /readycheck command - if (cmdLower == "readycheck" || cmdLower == "rc") { - gameHandler.initiateReadyCheck(); - chatInputBuffer_[0] = '\0'; - return; - } - - // /ready command (respond yes to ready check) - if (cmdLower == "ready") { - gameHandler.respondToReadyCheck(true); - chatInputBuffer_[0] = '\0'; - return; - } - - // /notready command (respond no to ready check) - if (cmdLower == "notready" || cmdLower == "nr") { - gameHandler.respondToReadyCheck(false); - chatInputBuffer_[0] = '\0'; - return; - } - - // /yield or /forfeit command - if (cmdLower == "yield" || cmdLower == "forfeit" || cmdLower == "surrender") { - gameHandler.forfeitDuel(); - chatInputBuffer_[0] = '\0'; - return; - } - - // AFK command - if (cmdLower == "afk" || cmdLower == "away") { - std::string afkMsg = (spacePos != std::string::npos) ? command.substr(spacePos + 1) : ""; - gameHandler.toggleAfk(afkMsg); - chatInputBuffer_[0] = '\0'; - return; - } - - // DND command - if (cmdLower == "dnd" || cmdLower == "busy") { - std::string dndMsg = (spacePos != std::string::npos) ? command.substr(spacePos + 1) : ""; - gameHandler.toggleDnd(dndMsg); - chatInputBuffer_[0] = '\0'; - return; - } - - // Reply command - if (cmdLower == "r" || cmdLower == "reply") { - std::string lastSender = gameHandler.getLastWhisperSender(); - if (lastSender.empty()) { - game::MessageChatData errMsg; - errMsg.type = game::ChatType::SYSTEM; - errMsg.language = game::ChatLanguage::UNIVERSAL; - errMsg.message = "No one has whispered you yet."; - gameHandler.addLocalChatMessage(errMsg); - chatInputBuffer_[0] = '\0'; - return; - } - // Set whisper target to last whisper sender - strncpy(whisperTargetBuffer_, lastSender.c_str(), sizeof(whisperTargetBuffer_) - 1); - whisperTargetBuffer_[sizeof(whisperTargetBuffer_) - 1] = '\0'; - if (spacePos != std::string::npos) { - // /r message — send reply immediately - std::string replyMsg = command.substr(spacePos + 1); - gameHandler.sendChatMessage(game::ChatType::WHISPER, replyMsg, lastSender); - } - // Switch to whisper tab - selectedChatType_ = 4; - chatInputBuffer_[0] = '\0'; - return; - } - - // Party/Raid management commands - if (cmdLower == "uninvite" || cmdLower == "kick") { - if (spacePos != std::string::npos) { - std::string playerName = command.substr(spacePos + 1); - gameHandler.uninvitePlayer(playerName); - } else { - game::MessageChatData msg; - msg.type = game::ChatType::SYSTEM; - msg.language = game::ChatLanguage::UNIVERSAL; - msg.message = "Usage: /uninvite "; - gameHandler.addLocalChatMessage(msg); - } - chatInputBuffer_[0] = '\0'; - return; - } - - if (cmdLower == "leave" || cmdLower == "leaveparty") { - gameHandler.leaveParty(); - chatInputBuffer_[0] = '\0'; - return; - } - - if (cmdLower == "maintank" || cmdLower == "mt") { - if (gameHandler.hasTarget()) { - gameHandler.setMainTank(gameHandler.getTargetGuid()); - } else { - game::MessageChatData msg; - msg.type = game::ChatType::SYSTEM; - msg.language = game::ChatLanguage::UNIVERSAL; - msg.message = "You must target a player to set as main tank."; - gameHandler.addLocalChatMessage(msg); - } - chatInputBuffer_[0] = '\0'; - return; - } - - if (cmdLower == "mainassist" || cmdLower == "ma") { - if (gameHandler.hasTarget()) { - gameHandler.setMainAssist(gameHandler.getTargetGuid()); - } else { - game::MessageChatData msg; - msg.type = game::ChatType::SYSTEM; - msg.language = game::ChatLanguage::UNIVERSAL; - msg.message = "You must target a player to set as main assist."; - gameHandler.addLocalChatMessage(msg); - } - chatInputBuffer_[0] = '\0'; - return; - } - - if (cmdLower == "clearmaintank") { - gameHandler.clearMainTank(); - chatInputBuffer_[0] = '\0'; - return; - } - - if (cmdLower == "clearmainassist") { - gameHandler.clearMainAssist(); - chatInputBuffer_[0] = '\0'; - return; - } - - if (cmdLower == "raidinfo") { - gameHandler.requestRaidInfo(); - chatInputBuffer_[0] = '\0'; - return; - } - - if (cmdLower == "raidconvert") { - gameHandler.convertToRaid(); - chatInputBuffer_[0] = '\0'; - return; - } - - // /lootmethod (or /grouploot, /setloot) — set party/raid loot method - if (cmdLower == "lootmethod" || cmdLower == "grouploot" || cmdLower == "setloot") { - if (!gameHandler.isInGroup()) { - gameHandler.addUIError("You are not in a group."); - } else if (spacePos == std::string::npos) { - // No argument — show current method and usage - static constexpr const char* kMethodNames[] = { - "Free for All", "Round Robin", "Master Looter", "Group Loot", "Need Before Greed" - }; - const auto& pd = gameHandler.getPartyData(); - const char* cur = (pd.lootMethod < 5) ? kMethodNames[pd.lootMethod] : "Unknown"; - game::MessageChatData msg; - msg.type = game::ChatType::SYSTEM; - msg.language = game::ChatLanguage::UNIVERSAL; - msg.message = std::string("Current loot method: ") + cur; - gameHandler.addLocalChatMessage(msg); - msg.message = "Usage: /lootmethod ffa|roundrobin|master|group|needbeforegreed"; - gameHandler.addLocalChatMessage(msg); - } else { - std::string arg = command.substr(spacePos + 1); - // Lowercase the argument - for (auto& c : arg) c = static_cast(std::tolower(static_cast(c))); - uint32_t method = 0xFFFFFFFF; - if (arg == "ffa" || arg == "freeforall") method = 0; - else if (arg == "roundrobin" || arg == "rr") method = 1; - else if (arg == "master" || arg == "masterloot") method = 2; - else if (arg == "group" || arg == "grouploot") method = 3; - else if (arg == "needbeforegreed" || arg == "nbg" || arg == "need") method = 4; - - if (method == 0xFFFFFFFF) { - gameHandler.addUIError("Unknown loot method. Use: ffa, roundrobin, master, group, needbeforegreed"); - } else { - const auto& pd = gameHandler.getPartyData(); - // Master loot uses player guid as master looter; otherwise 0 - uint64_t masterGuid = (method == 2) ? gameHandler.getPlayerGuid() : 0; - gameHandler.sendSetLootMethod(method, pd.lootThreshold, masterGuid); - } - } - chatInputBuffer_[0] = '\0'; - return; - } - - // /lootthreshold — set minimum item quality for group loot rolls - if (cmdLower == "lootthreshold") { - if (!gameHandler.isInGroup()) { - gameHandler.addUIError("You are not in a group."); - } else if (spacePos == std::string::npos) { - const auto& pd = gameHandler.getPartyData(); - static constexpr const char* kQualityNames[] = { - "Poor (grey)", "Common (white)", "Uncommon (green)", - "Rare (blue)", "Epic (purple)", "Legendary (orange)" - }; - const char* cur = (pd.lootThreshold < 6) ? kQualityNames[pd.lootThreshold] : "Unknown"; - game::MessageChatData msg; - msg.type = game::ChatType::SYSTEM; - msg.language = game::ChatLanguage::UNIVERSAL; - msg.message = std::string("Current loot threshold: ") + cur; - gameHandler.addLocalChatMessage(msg); - msg.message = "Usage: /lootthreshold <0-5> (0=Poor, 1=Common, 2=Uncommon, 3=Rare, 4=Epic, 5=Legendary)"; - gameHandler.addLocalChatMessage(msg); - } else { - std::string arg = command.substr(spacePos + 1); - // Trim whitespace - while (!arg.empty() && arg.front() == ' ') arg.erase(arg.begin()); - uint32_t threshold = 0xFFFFFFFF; - if (arg.size() == 1 && arg[0] >= '0' && arg[0] <= '5') { - threshold = static_cast(arg[0] - '0'); - } else { - // Accept quality names - for (auto& c : arg) c = static_cast(std::tolower(static_cast(c))); - if (arg == "poor" || arg == "grey" || arg == "gray") threshold = 0; - else if (arg == "common" || arg == "white") threshold = 1; - else if (arg == "uncommon" || arg == "green") threshold = 2; - else if (arg == "rare" || arg == "blue") threshold = 3; - else if (arg == "epic" || arg == "purple") threshold = 4; - else if (arg == "legendary" || arg == "orange") threshold = 5; - } - - if (threshold == 0xFFFFFFFF) { - gameHandler.addUIError("Invalid threshold. Use 0-5 or: poor, common, uncommon, rare, epic, legendary"); - } else { - const auto& pd = gameHandler.getPartyData(); - uint64_t masterGuid = (pd.lootMethod == 2) ? gameHandler.getPlayerGuid() : 0; - gameHandler.sendSetLootMethod(pd.lootMethod, threshold, masterGuid); - } - } - chatInputBuffer_[0] = '\0'; - return; - } - - // /mark [icon] — set or clear a raid target mark on the current target. - // Icon names (case-insensitive): star, circle, diamond, triangle, moon, square, cross, skull - // /mark clear | /mark 0 — remove all marks (sets icon 0xFF = clear) - // /mark — no arg marks with skull (icon 7) - if (cmdLower == "mark" || cmdLower == "marktarget" || cmdLower == "raidtarget") { - if (!gameHandler.hasTarget()) { - game::MessageChatData noTgt; - noTgt.type = game::ChatType::SYSTEM; - noTgt.language = game::ChatLanguage::UNIVERSAL; - noTgt.message = "No target selected."; - gameHandler.addLocalChatMessage(noTgt); - chatInputBuffer_[0] = '\0'; - return; - } - static constexpr const char* kMarkWords[] = { - "star", "circle", "diamond", "triangle", "moon", "square", "cross", "skull" - }; - uint8_t icon = 7; // default: skull - if (spacePos != std::string::npos) { - std::string arg = command.substr(spacePos + 1); - while (!arg.empty() && arg.front() == ' ') arg.erase(arg.begin()); - std::string argLow = arg; - for (auto& c : argLow) c = static_cast(std::tolower(c)); - if (argLow == "clear" || argLow == "0" || argLow == "none") { - gameHandler.setRaidMark(gameHandler.getTargetGuid(), 0xFF); - chatInputBuffer_[0] = '\0'; - return; - } - bool found = false; - for (int mi = 0; mi < 8; ++mi) { - if (argLow == kMarkWords[mi]) { icon = static_cast(mi); found = true; break; } - } - if (!found && !argLow.empty() && argLow[0] >= '1' && argLow[0] <= '8') { - icon = static_cast(argLow[0] - '1'); - found = true; - } - if (!found) { - game::MessageChatData badArg; - badArg.type = game::ChatType::SYSTEM; - badArg.language = game::ChatLanguage::UNIVERSAL; - badArg.message = "Unknown mark. Use: star circle diamond triangle moon square cross skull"; - gameHandler.addLocalChatMessage(badArg); - chatInputBuffer_[0] = '\0'; - return; - } - } - gameHandler.setRaidMark(gameHandler.getTargetGuid(), icon); - chatInputBuffer_[0] = '\0'; - return; - } - - // Combat and Trade commands - if (cmdLower == "duel") { - if (gameHandler.hasTarget()) { - gameHandler.proposeDuel(gameHandler.getTargetGuid()); - } else if (spacePos != std::string::npos) { - // Target player by name (would need name-to-GUID lookup) - game::MessageChatData msg; - msg.type = game::ChatType::SYSTEM; - msg.language = game::ChatLanguage::UNIVERSAL; - msg.message = "You must target a player to challenge to a duel."; - gameHandler.addLocalChatMessage(msg); - } else { - game::MessageChatData msg; - msg.type = game::ChatType::SYSTEM; - msg.language = game::ChatLanguage::UNIVERSAL; - msg.message = "You must target a player to challenge to a duel."; - gameHandler.addLocalChatMessage(msg); - } - chatInputBuffer_[0] = '\0'; - return; - } - - if (cmdLower == "trade") { - if (gameHandler.hasTarget()) { - gameHandler.initiateTrade(gameHandler.getTargetGuid()); - } else { - game::MessageChatData msg; - msg.type = game::ChatType::SYSTEM; - msg.language = game::ChatLanguage::UNIVERSAL; - msg.message = "You must target a player to trade with."; - gameHandler.addLocalChatMessage(msg); - } - chatInputBuffer_[0] = '\0'; - return; - } - - if (cmdLower == "startattack") { - // Support macro conditionals: /startattack [harm,nodead] - bool condPass = true; - uint64_t saOverride = static_cast(-1); - if (spacePos != std::string::npos) { - std::string saArg = command.substr(spacePos + 1); - while (!saArg.empty() && saArg.front() == ' ') saArg.erase(saArg.begin()); - if (!saArg.empty() && saArg.front() == '[') { - std::string result = evaluateMacroConditionals(saArg, gameHandler, saOverride); - condPass = !(result.empty() && saOverride == static_cast(-1)); - } - } - if (condPass) { - uint64_t atkTarget = (saOverride != static_cast(-1) && saOverride != 0) - ? saOverride : (gameHandler.hasTarget() ? gameHandler.getTargetGuid() : 0); - if (atkTarget != 0) { - gameHandler.startAutoAttack(atkTarget); - } else { - game::MessageChatData msg; - msg.type = game::ChatType::SYSTEM; - msg.language = game::ChatLanguage::UNIVERSAL; - msg.message = "You have no target."; - gameHandler.addLocalChatMessage(msg); - } - } - chatInputBuffer_[0] = '\0'; - return; - } - - if (cmdLower == "stopattack") { - gameHandler.stopAutoAttack(); - chatInputBuffer_[0] = '\0'; - return; - } - - if (cmdLower == "stopcasting") { - gameHandler.stopCasting(); - chatInputBuffer_[0] = '\0'; - return; - } - - if (cmdLower == "cancelqueuedspell" || cmdLower == "stopspellqueue") { - gameHandler.cancelQueuedSpell(); - chatInputBuffer_[0] = '\0'; - return; - } - - // /equipset [name] — equip a saved equipment set by name (partial match, case-insensitive) - // /equipset — list available sets in chat - if (cmdLower == "equipset") { - const auto& sets = gameHandler.getEquipmentSets(); - auto sysSay = [&](const std::string& msg) { - game::MessageChatData m; - m.type = game::ChatType::SYSTEM; - m.language = game::ChatLanguage::UNIVERSAL; - m.message = msg; - gameHandler.addLocalChatMessage(m); - }; - if (spacePos == std::string::npos) { - // No argument: list available sets - if (sets.empty()) { - sysSay("[System] No equipment sets saved."); - } else { - sysSay("[System] Equipment sets:"); - for (const auto& es : sets) - sysSay(" " + es.name); - } - } else { - std::string setName = command.substr(spacePos + 1); - while (!setName.empty() && setName.front() == ' ') setName.erase(setName.begin()); - while (!setName.empty() && setName.back() == ' ') setName.pop_back(); - // Case-insensitive prefix match - std::string setLower = setName; - std::transform(setLower.begin(), setLower.end(), setLower.begin(), ::tolower); - const game::GameHandler::EquipmentSetInfo* found = nullptr; - for (const auto& es : sets) { - std::string nameLow = es.name; - std::transform(nameLow.begin(), nameLow.end(), nameLow.begin(), ::tolower); - if (nameLow == setLower || nameLow.find(setLower) == 0) { - found = &es; - break; - } - } - if (found) { - gameHandler.useEquipmentSet(found->setId); - } else { - sysSay("[System] No equipment set matching '" + setName + "'."); - } - } - chatInputBuffer_[0] = '\0'; - return; - } - - // /castsequence [conds] [reset=N/target/combat] Spell1, Spell2, ... - // Cycles through the spell list on successive presses; resets per the reset= spec. - if (cmdLower == "castsequence" && spacePos != std::string::npos) { - std::string seqArg = command.substr(spacePos + 1); - while (!seqArg.empty() && seqArg.front() == ' ') seqArg.erase(seqArg.begin()); - - // Macro conditionals - uint64_t seqTgtOver = static_cast(-1); - if (!seqArg.empty() && seqArg.front() == '[') { - seqArg = evaluateMacroConditionals(seqArg, gameHandler, seqTgtOver); - if (seqArg.empty() && seqTgtOver == static_cast(-1)) { - chatInputBuffer_[0] = '\0'; return; - } - while (!seqArg.empty() && seqArg.front() == ' ') seqArg.erase(seqArg.begin()); - while (!seqArg.empty() && seqArg.back() == ' ') seqArg.pop_back(); - } - - // Optional reset= spec (may contain slash-separated conditions: reset=5/target) - std::string resetSpec; - if (seqArg.rfind("reset=", 0) == 0) { - size_t spAfter = seqArg.find(' '); - if (spAfter != std::string::npos) { - resetSpec = seqArg.substr(6, spAfter - 6); - seqArg = seqArg.substr(spAfter + 1); - while (!seqArg.empty() && seqArg.front() == ' ') seqArg.erase(seqArg.begin()); - } - } - - // Parse comma-separated spell list - std::vector seqSpells; - { - std::string cur; - for (char c : seqArg) { - if (c == ',') { - while (!cur.empty() && cur.front() == ' ') cur.erase(cur.begin()); - while (!cur.empty() && cur.back() == ' ') cur.pop_back(); - if (!cur.empty()) seqSpells.push_back(cur); - cur.clear(); - } else { cur += c; } - } - while (!cur.empty() && cur.front() == ' ') cur.erase(cur.begin()); - while (!cur.empty() && cur.back() == ' ') cur.pop_back(); - if (!cur.empty()) seqSpells.push_back(cur); - } - if (seqSpells.empty()) { chatInputBuffer_[0] = '\0'; return; } - - // Build stable key from lowercase spell list - std::string seqKey; - for (size_t k = 0; k < seqSpells.size(); ++k) { - if (k) seqKey += ','; - std::string sl = seqSpells[k]; - for (char& c : sl) c = static_cast(std::tolower(static_cast(c))); - seqKey += sl; - } - - auto& seqState = s_castSeqStates[seqKey]; - - // Check reset conditions (slash-separated: e.g. "5/target") - float nowSec = static_cast(ImGui::GetTime()); - bool shouldReset = false; - if (!resetSpec.empty()) { - size_t rpos = 0; - while (rpos <= resetSpec.size()) { - size_t slash = resetSpec.find('/', rpos); - std::string part = (slash != std::string::npos) - ? resetSpec.substr(rpos, slash - rpos) - : resetSpec.substr(rpos); - std::string plow = part; - for (char& c : plow) c = static_cast(std::tolower(static_cast(c))); - bool isNum = !plow.empty() && std::all_of(plow.begin(), plow.end(), - [](unsigned char c){ return std::isdigit(c) || c == '.'; }); - if (isNum) { - float rSec = 0.0f; - try { rSec = std::stof(plow); } catch (...) {} - if (rSec > 0.0f && nowSec - seqState.lastPressSec > rSec) shouldReset = true; - } else if (plow == "target") { - if (gameHandler.getTargetGuid() != seqState.lastTargetGuid) shouldReset = true; - } else if (plow == "combat") { - if (gameHandler.isInCombat() != seqState.lastInCombat) shouldReset = true; - } - if (slash == std::string::npos) break; - rpos = slash + 1; - } - } - if (shouldReset || seqState.index >= seqSpells.size()) seqState.index = 0; - - const std::string& seqSpell = seqSpells[seqState.index]; - seqState.index = (seqState.index + 1) % seqSpells.size(); - seqState.lastPressSec = nowSec; - seqState.lastTargetGuid = gameHandler.getTargetGuid(); - seqState.lastInCombat = gameHandler.isInCombat(); - - // Cast the selected spell — mirrors /cast spell lookup - std::string ssLow = seqSpell; - for (char& c : ssLow) c = static_cast(std::tolower(static_cast(c))); - if (!ssLow.empty() && ssLow.front() == '!') ssLow.erase(ssLow.begin()); - - uint64_t seqTargetGuid = (seqTgtOver != static_cast(-1) && seqTgtOver != 0) - ? seqTgtOver : (gameHandler.hasTarget() ? gameHandler.getTargetGuid() : 0); - - // Numeric ID - if (!ssLow.empty() && ssLow.front() == '#') ssLow.erase(ssLow.begin()); - bool ssNumeric = !ssLow.empty() && std::all_of(ssLow.begin(), ssLow.end(), - [](unsigned char c){ return std::isdigit(c); }); - if (ssNumeric) { - uint32_t ssId = 0; - try { ssId = static_cast(std::stoul(ssLow)); } catch (...) {} - if (ssId) gameHandler.castSpell(ssId, seqTargetGuid); - } else { - uint32_t ssBest = 0; int ssBestRank = -1; - for (uint32_t sid : gameHandler.getKnownSpells()) { - const std::string& sn = gameHandler.getSpellName(sid); - if (sn.empty()) continue; - std::string snl = sn; - for (char& c : snl) c = static_cast(std::tolower(static_cast(c))); - if (snl != ssLow) continue; - int sRnk = 0; - const std::string& rk = gameHandler.getSpellRank(sid); - if (!rk.empty()) { - std::string rkl = rk; - for (char& c : rkl) c = static_cast(std::tolower(static_cast(c))); - if (rkl.rfind("rank ", 0) == 0) { try { sRnk = std::stoi(rkl.substr(5)); } catch (...) {} } - } - if (sRnk > ssBestRank) { ssBestRank = sRnk; ssBest = sid; } - } - if (ssBest) gameHandler.castSpell(ssBest, seqTargetGuid); - } - chatInputBuffer_[0] = '\0'; - return; - } - - if (cmdLower == "cast" && spacePos != std::string::npos) { - std::string spellArg = command.substr(spacePos + 1); - // Trim leading/trailing whitespace - while (!spellArg.empty() && spellArg.front() == ' ') spellArg.erase(spellArg.begin()); - while (!spellArg.empty() && spellArg.back() == ' ') spellArg.pop_back(); - - // Evaluate WoW macro conditionals: /cast [mod:shift] Greater Heal; Flash Heal - uint64_t castTargetOverride = static_cast(-1); - if (!spellArg.empty() && spellArg.front() == '[') { - spellArg = evaluateMacroConditionals(spellArg, gameHandler, castTargetOverride); - if (spellArg.empty()) { - chatInputBuffer_[0] = '\0'; - return; // No conditional matched — skip cast - } - while (!spellArg.empty() && spellArg.front() == ' ') spellArg.erase(spellArg.begin()); - while (!spellArg.empty() && spellArg.back() == ' ') spellArg.pop_back(); - } - - // Strip leading '!' (WoW /cast !Spell forces recast without toggling off) - if (!spellArg.empty() && spellArg.front() == '!') spellArg.erase(spellArg.begin()); - - // Support numeric spell ID: /cast 133 or /cast #133 - { - std::string numStr = spellArg; - if (!numStr.empty() && numStr.front() == '#') numStr.erase(numStr.begin()); - bool isNumeric = !numStr.empty() && - std::all_of(numStr.begin(), numStr.end(), - [](unsigned char c){ return std::isdigit(c); }); - if (isNumeric) { - uint32_t spellId = 0; - try { spellId = static_cast(std::stoul(numStr)); } catch (...) {} - if (spellId != 0) { - uint64_t targetGuid = (castTargetOverride != static_cast(-1)) - ? castTargetOverride - : (gameHandler.hasTarget() ? gameHandler.getTargetGuid() : 0); - gameHandler.castSpell(spellId, targetGuid); - } - chatInputBuffer_[0] = '\0'; - return; - } - } - - // Parse optional "(Rank N)" suffix: "Fireball(Rank 3)" or "Fireball (Rank 3)" - int requestedRank = -1; // -1 = highest rank - std::string spellName = spellArg; - { - auto rankPos = spellArg.find('('); - if (rankPos != std::string::npos) { - std::string rankStr = spellArg.substr(rankPos + 1); - // Strip closing paren and whitespace - auto closePos = rankStr.find(')'); - if (closePos != std::string::npos) rankStr = rankStr.substr(0, closePos); - for (char& c : rankStr) c = static_cast(std::tolower(static_cast(c))); - // Expect "rank N" - if (rankStr.rfind("rank ", 0) == 0) { - try { requestedRank = std::stoi(rankStr.substr(5)); } catch (...) {} - } - spellName = spellArg.substr(0, rankPos); - while (!spellName.empty() && spellName.back() == ' ') spellName.pop_back(); - } - } - - std::string spellNameLower = spellName; - for (char& c : spellNameLower) c = static_cast(std::tolower(static_cast(c))); - - // Search known spells for a name match; pick highest rank (or specific rank) - uint32_t bestSpellId = 0; - int bestRank = -1; - for (uint32_t sid : gameHandler.getKnownSpells()) { - const std::string& sName = gameHandler.getSpellName(sid); - if (sName.empty()) continue; - std::string sNameLower = sName; - for (char& c : sNameLower) c = static_cast(std::tolower(static_cast(c))); - if (sNameLower != spellNameLower) continue; - - // Parse numeric rank from rank string ("Rank 3" → 3, "" → 0) - int sRank = 0; - const std::string& rankStr = gameHandler.getSpellRank(sid); - if (!rankStr.empty()) { - std::string rLow = rankStr; - for (char& c : rLow) c = static_cast(std::tolower(static_cast(c))); - if (rLow.rfind("rank ", 0) == 0) { - try { sRank = std::stoi(rLow.substr(5)); } catch (...) {} - } - } - - if (requestedRank >= 0) { - if (sRank == requestedRank) { bestSpellId = sid; break; } - } else { - if (sRank > bestRank) { bestRank = sRank; bestSpellId = sid; } - } - } - - if (bestSpellId) { - uint64_t targetGuid = (castTargetOverride != static_cast(-1)) - ? castTargetOverride - : (gameHandler.hasTarget() ? gameHandler.getTargetGuid() : 0); - gameHandler.castSpell(bestSpellId, targetGuid); - } else { - game::MessageChatData sysMsg; - sysMsg.type = game::ChatType::SYSTEM; - sysMsg.language = game::ChatLanguage::UNIVERSAL; - sysMsg.message = requestedRank >= 0 - ? "You don't know '" + spellName + "' (Rank " + std::to_string(requestedRank) + ")." - : "Unknown spell: '" + spellName + "'."; - gameHandler.addLocalChatMessage(sysMsg); - } - chatInputBuffer_[0] = '\0'; - return; - } - - // /use - // Supports: item name, numeric item ID (#N or N), bag/slot (/use 0 1 = backpack slot 1, - // /use 1-4 slot = bag slot), equipment slot number (/use 16 = main hand) - if (cmdLower == "use" && spacePos != std::string::npos) { - std::string useArg = command.substr(spacePos + 1); - while (!useArg.empty() && useArg.front() == ' ') useArg.erase(useArg.begin()); - while (!useArg.empty() && useArg.back() == ' ') useArg.pop_back(); - - // Handle macro conditionals: /use [mod:shift] ItemName; OtherItem - if (!useArg.empty() && useArg.front() == '[') { - uint64_t dummy = static_cast(-1); - useArg = evaluateMacroConditionals(useArg, gameHandler, dummy); - if (useArg.empty()) { chatInputBuffer_[0] = '\0'; return; } - while (!useArg.empty() && useArg.front() == ' ') useArg.erase(useArg.begin()); - while (!useArg.empty() && useArg.back() == ' ') useArg.pop_back(); - } - - // Check for bag/slot notation: two numbers separated by whitespace - { - std::istringstream iss(useArg); - int bagNum = -1, slotNum = -1; - iss >> bagNum >> slotNum; - if (!iss.fail() && slotNum >= 1) { - if (bagNum == 0) { - // Backpack: bag=0, slot 1-based → 0-based - gameHandler.useItemBySlot(slotNum - 1); - chatInputBuffer_[0] = '\0'; - return; - } else if (bagNum >= 1 && bagNum <= game::Inventory::NUM_BAG_SLOTS) { - // Equip bag: bags are 1-indexed (bag 1 = bagIndex 0) - gameHandler.useItemInBag(bagNum - 1, slotNum - 1); - chatInputBuffer_[0] = '\0'; - return; - } - } - } - - // Numeric equip slot: /use 16 = slot 16 (1-based, WoW equip slot enum) - { - std::string numStr = useArg; - if (!numStr.empty() && numStr.front() == '#') numStr.erase(numStr.begin()); - bool isNumeric = !numStr.empty() && - std::all_of(numStr.begin(), numStr.end(), - [](unsigned char c){ return std::isdigit(c); }); - if (isNumeric) { - // Treat as equip slot (1-based, maps to EquipSlot enum 0-based) - int slotNum = 0; - try { slotNum = std::stoi(numStr); } catch (...) {} - if (slotNum >= 1 && slotNum <= static_cast(game::EquipSlot::BAG4) + 1) { - auto eslot = static_cast(slotNum - 1); - const auto& esl = gameHandler.getInventory().getEquipSlot(eslot); - if (!esl.empty()) - gameHandler.useItemById(esl.item.itemId); - } - chatInputBuffer_[0] = '\0'; - return; - } - } - - std::string useArgLower = useArg; - for (char& c : useArgLower) c = static_cast(std::tolower(static_cast(c))); - - bool found = false; - const auto& inv = gameHandler.getInventory(); - // Search backpack - for (int s = 0; s < inv.getBackpackSize() && !found; ++s) { - const auto& slot = inv.getBackpackSlot(s); - if (slot.empty()) continue; - const auto* info = gameHandler.getItemInfo(slot.item.itemId); - if (!info) continue; - std::string nameLow = info->name; - for (char& c : nameLow) c = static_cast(std::tolower(static_cast(c))); - if (nameLow == useArgLower) { - gameHandler.useItemBySlot(s); - found = true; - } - } - // Search bags - for (int b = 0; b < game::Inventory::NUM_BAG_SLOTS && !found; ++b) { - for (int s = 0; s < inv.getBagSize(b) && !found; ++s) { - const auto& slot = inv.getBagSlot(b, s); - if (slot.empty()) continue; - const auto* info = gameHandler.getItemInfo(slot.item.itemId); - if (!info) continue; - std::string nameLow = info->name; - for (char& c : nameLow) c = static_cast(std::tolower(static_cast(c))); - if (nameLow == useArgLower) { - gameHandler.useItemInBag(b, s); - found = true; - } - } - } - if (!found) { - game::MessageChatData sysMsg; - sysMsg.type = game::ChatType::SYSTEM; - sysMsg.language = game::ChatLanguage::UNIVERSAL; - sysMsg.message = "Item not found: '" + useArg + "'."; - gameHandler.addLocalChatMessage(sysMsg); - } - chatInputBuffer_[0] = '\0'; - return; - } - - // /equip — auto-equip an item from backpack/bags by name - if (cmdLower == "equip" && spacePos != std::string::npos) { - std::string equipArg = command.substr(spacePos + 1); - while (!equipArg.empty() && equipArg.front() == ' ') equipArg.erase(equipArg.begin()); - while (!equipArg.empty() && equipArg.back() == ' ') equipArg.pop_back(); - std::string equipArgLower = equipArg; - for (char& c : equipArgLower) c = static_cast(std::tolower(static_cast(c))); - - bool found = false; - const auto& inv = gameHandler.getInventory(); - // Search backpack - for (int s = 0; s < inv.getBackpackSize() && !found; ++s) { - const auto& slot = inv.getBackpackSlot(s); - if (slot.empty()) continue; - const auto* info = gameHandler.getItemInfo(slot.item.itemId); - if (!info) continue; - std::string nameLow = info->name; - for (char& c : nameLow) c = static_cast(std::tolower(static_cast(c))); - if (nameLow == equipArgLower) { - gameHandler.autoEquipItemBySlot(s); - found = true; - } - } - // Search bags - for (int b = 0; b < game::Inventory::NUM_BAG_SLOTS && !found; ++b) { - for (int s = 0; s < inv.getBagSize(b) && !found; ++s) { - const auto& slot = inv.getBagSlot(b, s); - if (slot.empty()) continue; - const auto* info = gameHandler.getItemInfo(slot.item.itemId); - if (!info) continue; - std::string nameLow = info->name; - for (char& c : nameLow) c = static_cast(std::tolower(static_cast(c))); - if (nameLow == equipArgLower) { - gameHandler.autoEquipItemInBag(b, s); - found = true; - } - } - } - if (!found) { - game::MessageChatData sysMsg; - sysMsg.type = game::ChatType::SYSTEM; - sysMsg.language = game::ChatLanguage::UNIVERSAL; - sysMsg.message = "Item not found: '" + equipArg + "'."; - gameHandler.addLocalChatMessage(sysMsg); - } - chatInputBuffer_[0] = '\0'; - return; - } - - // Targeting commands - if (cmdLower == "cleartarget") { - // Support macro conditionals: /cleartarget [dead] clears only if target is dead - bool ctCondPass = true; - if (spacePos != std::string::npos) { - std::string ctArg = command.substr(spacePos + 1); - while (!ctArg.empty() && ctArg.front() == ' ') ctArg.erase(ctArg.begin()); - if (!ctArg.empty() && ctArg.front() == '[') { - uint64_t ctOver = static_cast(-1); - std::string res = evaluateMacroConditionals(ctArg, gameHandler, ctOver); - ctCondPass = !(res.empty() && ctOver == static_cast(-1)); - } - } - if (ctCondPass) gameHandler.clearTarget(); - chatInputBuffer_[0] = '\0'; - return; - } - - if (cmdLower == "target" && spacePos != std::string::npos) { - // Search visible entities for name match (case-insensitive prefix). - // Among all matches, pick the nearest living unit to the player. - // Supports WoW macro conditionals: /target [target=mouseover]; /target [mod:shift] Boss - std::string targetArg = command.substr(spacePos + 1); - - // Evaluate conditionals if present - uint64_t targetCmdOverride = static_cast(-1); - if (!targetArg.empty() && targetArg.front() == '[') { - targetArg = evaluateMacroConditionals(targetArg, gameHandler, targetCmdOverride); - if (targetArg.empty() && targetCmdOverride == static_cast(-1)) { - // No condition matched — silently skip (macro fallthrough) - chatInputBuffer_[0] = '\0'; - return; - } - while (!targetArg.empty() && targetArg.front() == ' ') targetArg.erase(targetArg.begin()); - while (!targetArg.empty() && targetArg.back() == ' ') targetArg.pop_back(); - } - - // If conditionals resolved to a specific GUID, target it directly - if (targetCmdOverride != static_cast(-1) && targetCmdOverride != 0) { - gameHandler.setTarget(targetCmdOverride); - chatInputBuffer_[0] = '\0'; - return; - } - - // If no name remains (bare conditional like [target=mouseover] with 0 guid), skip silently - if (targetArg.empty()) { - chatInputBuffer_[0] = '\0'; - return; - } - - std::string targetArgLower = targetArg; - for (char& c : targetArgLower) c = static_cast(std::tolower(static_cast(c))); - uint64_t bestGuid = 0; - float bestDist = std::numeric_limits::max(); - const auto& pmi = gameHandler.getMovementInfo(); - const float playerX = pmi.x; - const float playerY = pmi.y; - const float playerZ = pmi.z; - for (const auto& [guid, entity] : gameHandler.getEntityManager().getEntities()) { - if (!entity || entity->getType() == game::ObjectType::OBJECT) continue; - std::string name; - if (entity->getType() == game::ObjectType::PLAYER || - entity->getType() == game::ObjectType::UNIT) { - auto unit = std::static_pointer_cast(entity); - name = unit->getName(); - } - if (name.empty()) continue; - std::string nameLower = name; - for (char& c : nameLower) c = static_cast(std::tolower(static_cast(c))); - if (nameLower.find(targetArgLower) == 0) { - float dx = entity->getX() - playerX; - float dy = entity->getY() - playerY; - float dz = entity->getZ() - playerZ; - float dist = dx*dx + dy*dy + dz*dz; - if (dist < bestDist) { - bestDist = dist; - bestGuid = guid; - } - } - } - if (bestGuid) { - gameHandler.setTarget(bestGuid); - } else { - game::MessageChatData sysMsg; - sysMsg.type = game::ChatType::SYSTEM; - sysMsg.language = game::ChatLanguage::UNIVERSAL; - sysMsg.message = "No target matching '" + targetArg + "' found."; - gameHandler.addLocalChatMessage(sysMsg); - } - chatInputBuffer_[0] = '\0'; - return; - } - - if (cmdLower == "targetenemy") { - gameHandler.targetEnemy(false); - chatInputBuffer_[0] = '\0'; - return; - } - - if (cmdLower == "targetfriend") { - gameHandler.targetFriend(false); - chatInputBuffer_[0] = '\0'; - return; - } - - if (cmdLower == "targetlasttarget" || cmdLower == "targetlast") { - gameHandler.targetLastTarget(); - chatInputBuffer_[0] = '\0'; - return; - } - - if (cmdLower == "targetlastenemy") { - gameHandler.targetEnemy(true); // Reverse direction - chatInputBuffer_[0] = '\0'; - return; - } - - if (cmdLower == "targetlastfriend") { - gameHandler.targetFriend(true); // Reverse direction - chatInputBuffer_[0] = '\0'; - return; - } - - if (cmdLower == "focus") { - // /focus → set current target as focus - // /focus PlayerName → search for entity by name and set as focus - // /focus [target=X] Name → macro conditional: set focus to resolved target - if (spacePos != std::string::npos) { - std::string focusArg = command.substr(spacePos + 1); - - // Evaluate conditionals if present - uint64_t focusCmdOverride = static_cast(-1); - if (!focusArg.empty() && focusArg.front() == '[') { - focusArg = evaluateMacroConditionals(focusArg, gameHandler, focusCmdOverride); - if (focusArg.empty() && focusCmdOverride == static_cast(-1)) { - chatInputBuffer_[0] = '\0'; - return; - } - while (!focusArg.empty() && focusArg.front() == ' ') focusArg.erase(focusArg.begin()); - while (!focusArg.empty() && focusArg.back() == ' ') focusArg.pop_back(); - } - - if (focusCmdOverride != static_cast(-1) && focusCmdOverride != 0) { - // Conditional resolved to a specific GUID (e.g. [target=mouseover]) - gameHandler.setFocus(focusCmdOverride); - } else if (!focusArg.empty()) { - // Name search — same logic as /target - std::string focusArgLower = focusArg; - for (char& c : focusArgLower) c = static_cast(std::tolower(static_cast(c))); - uint64_t bestGuid = 0; - float bestDist = std::numeric_limits::max(); - const auto& pmi = gameHandler.getMovementInfo(); - for (const auto& [guid, entity] : gameHandler.getEntityManager().getEntities()) { - if (!entity || entity->getType() == game::ObjectType::OBJECT) continue; - std::string name; - if (entity->getType() == game::ObjectType::PLAYER || - entity->getType() == game::ObjectType::UNIT) { - auto unit = std::static_pointer_cast(entity); - name = unit->getName(); - } - if (name.empty()) continue; - std::string nameLower = name; - for (char& c : nameLower) c = static_cast(std::tolower(static_cast(c))); - if (nameLower.find(focusArgLower) == 0) { - float dx = entity->getX() - pmi.x; - float dy = entity->getY() - pmi.y; - float dz = entity->getZ() - pmi.z; - float dist = dx*dx + dy*dy + dz*dz; - if (dist < bestDist) { bestDist = dist; bestGuid = guid; } - } - } - if (bestGuid) { - gameHandler.setFocus(bestGuid); - } else { - game::MessageChatData msg; - msg.type = game::ChatType::SYSTEM; - msg.language = game::ChatLanguage::UNIVERSAL; - msg.message = "No unit matching '" + focusArg + "' found."; - gameHandler.addLocalChatMessage(msg); - } - } - } else if (gameHandler.hasTarget()) { - gameHandler.setFocus(gameHandler.getTargetGuid()); - } else { - game::MessageChatData msg; - msg.type = game::ChatType::SYSTEM; - msg.language = game::ChatLanguage::UNIVERSAL; - msg.message = "You must target a unit to set as focus."; - gameHandler.addLocalChatMessage(msg); - } - chatInputBuffer_[0] = '\0'; - return; - } - - if (cmdLower == "clearfocus") { - gameHandler.clearFocus(); - chatInputBuffer_[0] = '\0'; - return; - } - - // /unstuck command — resets player position to floor height - if (cmdLower == "unstuck") { - gameHandler.unstuck(); - chatInputBuffer_[0] = '\0'; - return; - } - // /unstuckgy command — move to nearest graveyard - if (cmdLower == "unstuckgy") { - gameHandler.unstuckGy(); - chatInputBuffer_[0] = '\0'; - return; - } - // /unstuckhearth command — teleport to hearthstone bind point - if (cmdLower == "unstuckhearth") { - gameHandler.unstuckHearth(); - chatInputBuffer_[0] = '\0'; - return; - } - - // /transport board — board test transport - if (cmdLower == "transport board") { - auto* tm = gameHandler.getTransportManager(); - if (tm) { - // Test transport GUID - uint64_t testTransportGuid = 0x1000000000000001ULL; - // Place player at center of deck (rough estimate) - glm::vec3 deckCenter(0.0f, 0.0f, 5.0f); - gameHandler.setPlayerOnTransport(testTransportGuid, deckCenter); - game::MessageChatData msg; - msg.type = game::ChatType::SYSTEM; - msg.language = game::ChatLanguage::UNIVERSAL; - msg.message = "Boarded test transport. Use '/transport leave' to disembark."; - gameHandler.addLocalChatMessage(msg); - } else { - game::MessageChatData msg; - msg.type = game::ChatType::SYSTEM; - msg.language = game::ChatLanguage::UNIVERSAL; - msg.message = "Transport system not available."; - gameHandler.addLocalChatMessage(msg); - } - chatInputBuffer_[0] = '\0'; - return; - } - - // /transport leave — disembark from transport - if (cmdLower == "transport leave") { - if (gameHandler.isOnTransport()) { - gameHandler.clearPlayerTransport(); - game::MessageChatData msg; - msg.type = game::ChatType::SYSTEM; - msg.language = game::ChatLanguage::UNIVERSAL; - msg.message = "Disembarked from transport."; - gameHandler.addLocalChatMessage(msg); - } else { - game::MessageChatData msg; - msg.type = game::ChatType::SYSTEM; - msg.language = game::ChatLanguage::UNIVERSAL; - msg.message = "You are not on a transport."; - gameHandler.addLocalChatMessage(msg); - } - chatInputBuffer_[0] = '\0'; - return; - } - - // Chat channel slash commands - // If used without a message (e.g. just "/s"), switch the chat type dropdown - bool isChannelCommand = false; - if (cmdLower == "s" || cmdLower == "say") { - type = game::ChatType::SAY; - message = (spacePos != std::string::npos) ? command.substr(spacePos + 1) : ""; - isChannelCommand = true; - switchChatType = 0; - } else if (cmdLower == "y" || cmdLower == "yell" || cmdLower == "shout") { - type = game::ChatType::YELL; - message = (spacePos != std::string::npos) ? command.substr(spacePos + 1) : ""; - isChannelCommand = true; - switchChatType = 1; - } else if (cmdLower == "p" || cmdLower == "party") { - type = game::ChatType::PARTY; - message = (spacePos != std::string::npos) ? command.substr(spacePos + 1) : ""; - isChannelCommand = true; - switchChatType = 2; - } else if (cmdLower == "g" || cmdLower == "guild") { - type = game::ChatType::GUILD; - message = (spacePos != std::string::npos) ? command.substr(spacePos + 1) : ""; - isChannelCommand = true; - switchChatType = 3; - } else if (cmdLower == "raid" || cmdLower == "rsay" || cmdLower == "ra") { - type = game::ChatType::RAID; - message = (spacePos != std::string::npos) ? command.substr(spacePos + 1) : ""; - isChannelCommand = true; - switchChatType = 5; - } else if (cmdLower == "raidwarning" || cmdLower == "rw") { - type = game::ChatType::RAID_WARNING; - message = (spacePos != std::string::npos) ? command.substr(spacePos + 1) : ""; - isChannelCommand = true; - switchChatType = 8; - } else if (cmdLower == "officer" || cmdLower == "o" || cmdLower == "osay") { - type = game::ChatType::OFFICER; - message = (spacePos != std::string::npos) ? command.substr(spacePos + 1) : ""; - isChannelCommand = true; - switchChatType = 6; - } else if (cmdLower == "battleground" || cmdLower == "bg") { - type = game::ChatType::BATTLEGROUND; - message = (spacePos != std::string::npos) ? command.substr(spacePos + 1) : ""; - isChannelCommand = true; - switchChatType = 7; - } else if (cmdLower == "instance" || cmdLower == "i") { - // Instance chat uses PARTY chat type - type = game::ChatType::PARTY; - message = (spacePos != std::string::npos) ? command.substr(spacePos + 1) : ""; - isChannelCommand = true; - switchChatType = 9; - } else if (cmdLower == "join") { - // /join with no args: accept pending BG invite if any - if (spacePos == std::string::npos && gameHandler.hasPendingBgInvite()) { - gameHandler.acceptBattlefield(); - chatInputBuffer_[0] = '\0'; - return; - } - // /join ChannelName [password] - if (spacePos != std::string::npos) { - std::string rest = command.substr(spacePos + 1); - size_t pwStart = rest.find(' '); - std::string channelName = (pwStart != std::string::npos) ? rest.substr(0, pwStart) : rest; - std::string password = (pwStart != std::string::npos) ? rest.substr(pwStart + 1) : ""; - gameHandler.joinChannel(channelName, password); - } - chatInputBuffer_[0] = '\0'; - return; - } else if (cmdLower == "leave") { - // /leave ChannelName - if (spacePos != std::string::npos) { - std::string channelName = command.substr(spacePos + 1); - gameHandler.leaveChannel(channelName); - } - chatInputBuffer_[0] = '\0'; return; - } else if ((cmdLower == "wts" || cmdLower == "wtb") && spacePos != std::string::npos) { - // /wts and /wtb — send to Trade channel - // Prefix with [WTS] / [WTB] and route to the Trade channel - const std::string tag = (cmdLower == "wts") ? "[WTS] " : "[WTB] "; - const std::string body = command.substr(spacePos + 1); - // Find the Trade channel among joined channels (case-insensitive prefix match) - std::string tradeChan; - for (const auto& ch : gameHandler.getJoinedChannels()) { - std::string chLow = ch; - for (char& c : chLow) c = static_cast(std::tolower(static_cast(c))); - if (chLow.rfind("trade", 0) == 0) { tradeChan = ch; break; } - } - if (tradeChan.empty()) { - game::MessageChatData errMsg; - errMsg.type = game::ChatType::SYSTEM; - errMsg.language = game::ChatLanguage::UNIVERSAL; - errMsg.message = "You are not in the Trade channel."; - gameHandler.addLocalChatMessage(errMsg); - chatInputBuffer_[0] = '\0'; - return; - } - message = tag + body; - type = game::ChatType::CHANNEL; - target = tradeChan; - isChannelCommand = true; - } else if (cmdLower.size() == 1 && cmdLower[0] >= '1' && cmdLower[0] <= '9') { - // /1 msg, /2 msg — channel shortcuts - int channelIdx = cmdLower[0] - '0'; - std::string channelName = gameHandler.getChannelByIndex(channelIdx); - if (!channelName.empty() && spacePos != std::string::npos) { - message = command.substr(spacePos + 1); - type = game::ChatType::CHANNEL; - target = channelName; - isChannelCommand = true; - } else if (channelName.empty()) { - game::MessageChatData errMsg; - errMsg.type = game::ChatType::SYSTEM; - errMsg.message = "You are not in channel " + std::to_string(channelIdx) + "."; - gameHandler.addLocalChatMessage(errMsg); - chatInputBuffer_[0] = '\0'; - return; - } else { - chatInputBuffer_[0] = '\0'; - return; - } - } else if (cmdLower == "w" || cmdLower == "whisper" || cmdLower == "tell" || cmdLower == "t") { - switchChatType = 4; - if (spacePos != std::string::npos) { - std::string rest = command.substr(spacePos + 1); - size_t msgStart = rest.find(' '); - if (msgStart != std::string::npos) { - // /w PlayerName message — send whisper immediately - target = rest.substr(0, msgStart); - message = rest.substr(msgStart + 1); - type = game::ChatType::WHISPER; - isChannelCommand = true; - // Set whisper target for future messages - strncpy(whisperTargetBuffer_, target.c_str(), sizeof(whisperTargetBuffer_) - 1); - whisperTargetBuffer_[sizeof(whisperTargetBuffer_) - 1] = '\0'; - } else { - // /w PlayerName — switch to whisper mode with target set - strncpy(whisperTargetBuffer_, rest.c_str(), sizeof(whisperTargetBuffer_) - 1); - whisperTargetBuffer_[sizeof(whisperTargetBuffer_) - 1] = '\0'; - message = ""; - isChannelCommand = true; - } - } else { - // Just "/w" — switch to whisper mode - message = ""; - isChannelCommand = true; - } - } else if (cmdLower == "r" || cmdLower == "reply") { - switchChatType = 4; - std::string lastSender = gameHandler.getLastWhisperSender(); - if (lastSender.empty()) { - game::MessageChatData sysMsg; - sysMsg.type = game::ChatType::SYSTEM; - sysMsg.language = game::ChatLanguage::UNIVERSAL; - sysMsg.message = "No one has whispered you yet."; - gameHandler.addLocalChatMessage(sysMsg); - chatInputBuffer_[0] = '\0'; - return; - } - target = lastSender; - strncpy(whisperTargetBuffer_, target.c_str(), sizeof(whisperTargetBuffer_) - 1); - whisperTargetBuffer_[sizeof(whisperTargetBuffer_) - 1] = '\0'; - if (spacePos != std::string::npos) { - message = command.substr(spacePos + 1); - type = game::ChatType::WHISPER; - } else { - message = ""; - } - isChannelCommand = true; } - // Check for emote commands - if (!isChannelCommand) { + // Emote fallthrough — dynamic DBC lookup for emote text (catch-all). + // Not registered in the command registry because emote names are data-driven. + { std::string targetName; const std::string* targetNamePtr = nullptr; if (gameHandler.hasTarget()) { @@ -2671,20 +262,17 @@ void ChatPanel::sendChatMessage(game::GameHandler& gameHandler, std::string emoteText = rendering::AnimationController::getEmoteText(cmdLower, targetNamePtr); if (!emoteText.empty()) { - // Play the emote animation auto* renderer = services_.renderer; if (renderer) { if (auto* ac = renderer->getAnimationController()) ac->playEmote(cmdLower); } - // Send CMSG_TEXT_EMOTE to server uint32_t dbcId = rendering::AnimationController::getEmoteDbcId(cmdLower); if (dbcId != 0) { uint64_t targetGuid = gameHandler.hasTarget() ? gameHandler.getTargetGuid() : 0; gameHandler.sendTextEmote(dbcId, targetGuid); } - // Add local chat message game::MessageChatData msg; msg.type = game::ChatType::TEXT_EMOTE; msg.language = game::ChatLanguage::COMMON; @@ -2694,64 +282,37 @@ void ChatPanel::sendChatMessage(game::GameHandler& gameHandler, chatInputBuffer_[0] = '\0'; return; } - - // Not a recognized command — fall through and send as normal chat - if (!isChannelCommand) { - message = input; - } } - // If no valid command found and starts with /, just send as-is - if (!isChannelCommand && message == input) { - // Use the selected chat type from dropdown - switch (selectedChatType_) { - case 0: type = game::ChatType::SAY; break; - case 1: type = game::ChatType::YELL; break; - case 2: type = game::ChatType::PARTY; break; - case 3: type = game::ChatType::GUILD; break; - case 4: type = game::ChatType::WHISPER; target = whisperTargetBuffer_; break; - case 5: type = game::ChatType::RAID; break; - case 6: type = game::ChatType::OFFICER; break; - case 7: type = game::ChatType::BATTLEGROUND; break; - case 8: type = game::ChatType::RAID_WARNING; break; - case 9: type = game::ChatType::PARTY; break; // INSTANCE uses PARTY - case 10: { // CHANNEL - const auto& chans = gameHandler.getJoinedChannels(); - if (!chans.empty() && selectedChannelIdx_ < static_cast(chans.size())) { - type = game::ChatType::CHANNEL; - target = chans[selectedChannelIdx_]; - } else { type = game::ChatType::SAY; } - break; - } - default: type = game::ChatType::SAY; break; - } - } - } else { - // No slash command, use the selected chat type from dropdown - switch (selectedChatType_) { - case 0: type = game::ChatType::SAY; break; - case 1: type = game::ChatType::YELL; break; - case 2: type = game::ChatType::PARTY; break; - case 3: type = game::ChatType::GUILD; break; - case 4: type = game::ChatType::WHISPER; target = whisperTargetBuffer_; break; - case 5: type = game::ChatType::RAID; break; - case 6: type = game::ChatType::OFFICER; break; - case 7: type = game::ChatType::BATTLEGROUND; break; - case 8: type = game::ChatType::RAID_WARNING; break; - case 9: type = game::ChatType::PARTY; break; // INSTANCE uses PARTY - case 10: { // CHANNEL - const auto& chans = gameHandler.getJoinedChannels(); - if (!chans.empty() && selectedChannelIdx_ < static_cast(chans.size())) { - type = game::ChatType::CHANNEL; - target = chans[selectedChannelIdx_]; - } else { type = game::ChatType::SAY; } - break; - } - default: type = game::ChatType::SAY; break; - } + // Unrecognized slash command — fall through to dropdown chat type + message = input; } - // Whisper shortcuts to PortBot/GMBot: translate to GM teleport commands. + // Determine chat type from dropdown selection + // (reached when: no slash prefix, OR unrecognized slash command) + switch (selectedChatType_) { + case 0: type = game::ChatType::SAY; break; + case 1: type = game::ChatType::YELL; break; + case 2: type = game::ChatType::PARTY; break; + case 3: type = game::ChatType::GUILD; break; + case 4: type = game::ChatType::WHISPER; target = whisperTargetBuffer_; break; + case 5: type = game::ChatType::RAID; break; + case 6: type = game::ChatType::OFFICER; break; + case 7: type = game::ChatType::BATTLEGROUND; break; + case 8: type = game::ChatType::RAID_WARNING; break; + case 9: type = game::ChatType::PARTY; break; // INSTANCE uses PARTY + case 10: { // CHANNEL + const auto& chans = gameHandler.getJoinedChannels(); + if (!chans.empty() && selectedChannelIdx_ < static_cast(chans.size())) { + type = game::ChatType::CHANNEL; + target = chans[selectedChannelIdx_]; + } else { type = game::ChatType::SAY; } + break; + } + default: type = game::ChatType::SAY; break; + } + + // PortBot whisper interception (for dropdown-typed whispers, not /w command) if (type == game::ChatType::WHISPER && isPortBotTarget(target)) { std::string cmd = buildPortBotCommand(message); game::MessageChatData msg; @@ -2782,16 +343,11 @@ void ChatPanel::sendChatMessage(game::GameHandler& gameHandler, return; } - // Don't send empty messages — but switch chat type if a channel shortcut was used + // Don't send empty messages if (!message.empty()) { gameHandler.sendChatMessage(type, message, target); } - // Switch chat type dropdown when channel shortcut used (with or without message) - if (switchChatType >= 0) { - selectedChatType_ = switchChatType; - } - // Clear input chatInputBuffer_[0] = '\0'; } diff --git a/src/ui/chat_panel_utils.cpp b/src/ui/chat_panel_utils.cpp index 642a68c6..bef47243 100644 --- a/src/ui/chat_panel_utils.cpp +++ b/src/ui/chat_panel_utils.cpp @@ -31,106 +31,7 @@ namespace { namespace wowee { namespace ui { -const char* ChatPanel::getChatTypeName(game::ChatType type) const { - switch (type) { - case game::ChatType::SAY: return "Say"; - case game::ChatType::YELL: return "Yell"; - case game::ChatType::EMOTE: return "Emote"; - case game::ChatType::TEXT_EMOTE: return "Emote"; - case game::ChatType::PARTY: return "Party"; - case game::ChatType::GUILD: return "Guild"; - case game::ChatType::OFFICER: return "Officer"; - case game::ChatType::RAID: return "Raid"; - case game::ChatType::RAID_LEADER: return "Raid Leader"; - case game::ChatType::RAID_WARNING: return "Raid Warning"; - case game::ChatType::BATTLEGROUND: return "Battleground"; - case game::ChatType::BATTLEGROUND_LEADER: return "Battleground Leader"; - case game::ChatType::WHISPER: return "Whisper"; - case game::ChatType::WHISPER_INFORM: return "To"; - case game::ChatType::SYSTEM: return "System"; - case game::ChatType::MONSTER_SAY: return "Say"; - case game::ChatType::MONSTER_YELL: return "Yell"; - case game::ChatType::MONSTER_EMOTE: return "Emote"; - case game::ChatType::CHANNEL: return "Channel"; - case game::ChatType::ACHIEVEMENT: return "Achievement"; - case game::ChatType::DND: return "DND"; - case game::ChatType::AFK: return "AFK"; - case game::ChatType::BG_SYSTEM_NEUTRAL: - case game::ChatType::BG_SYSTEM_ALLIANCE: - case game::ChatType::BG_SYSTEM_HORDE: return "System"; - default: return "Unknown"; - } -} - - -ImVec4 ChatPanel::getChatTypeColor(game::ChatType type) const { - switch (type) { - case game::ChatType::SAY: - return ui::colors::kWhite; // White - case game::ChatType::YELL: - return kColorRed; // Red - case game::ChatType::EMOTE: - return ImVec4(1.0f, 0.7f, 0.3f, 1.0f); // Orange - case game::ChatType::TEXT_EMOTE: - return ImVec4(1.0f, 0.7f, 0.3f, 1.0f); // Orange - case game::ChatType::PARTY: - return ImVec4(0.5f, 0.5f, 1.0f, 1.0f); // Light blue - case game::ChatType::GUILD: - return kColorBrightGreen; // Green - case game::ChatType::OFFICER: - return ImVec4(0.3f, 0.8f, 0.3f, 1.0f); // Dark green - case game::ChatType::RAID: - return ImVec4(1.0f, 0.5f, 0.0f, 1.0f); // Orange - case game::ChatType::RAID_LEADER: - return ImVec4(1.0f, 0.4f, 0.0f, 1.0f); // Darker orange - case game::ChatType::RAID_WARNING: - return ImVec4(1.0f, 0.0f, 0.0f, 1.0f); // Red - case game::ChatType::BATTLEGROUND: - return ImVec4(1.0f, 0.6f, 0.0f, 1.0f); // Orange-gold - case game::ChatType::BATTLEGROUND_LEADER: - return ImVec4(1.0f, 0.5f, 0.0f, 1.0f); // Orange - case game::ChatType::WHISPER: - return ImVec4(1.0f, 0.5f, 1.0f, 1.0f); // Pink - case game::ChatType::WHISPER_INFORM: - return ImVec4(1.0f, 0.5f, 1.0f, 1.0f); // Pink - case game::ChatType::SYSTEM: - return kColorYellow; // Yellow - case game::ChatType::MONSTER_SAY: - return ui::colors::kWhite; // White (same as SAY) - case game::ChatType::MONSTER_YELL: - return kColorRed; // Red (same as YELL) - case game::ChatType::MONSTER_EMOTE: - return ImVec4(1.0f, 0.7f, 0.3f, 1.0f); // Orange (same as EMOTE) - case game::ChatType::CHANNEL: - return ImVec4(1.0f, 0.7f, 0.7f, 1.0f); // Light pink - case game::ChatType::ACHIEVEMENT: - return ImVec4(1.0f, 1.0f, 0.0f, 1.0f); // Bright yellow - case game::ChatType::GUILD_ACHIEVEMENT: - return colors::kWarmGold; // Gold - case game::ChatType::SKILL: - return colors::kCyan; // Cyan - case game::ChatType::LOOT: - return ImVec4(0.8f, 0.5f, 1.0f, 1.0f); // Light purple - case game::ChatType::MONSTER_WHISPER: - case game::ChatType::RAID_BOSS_WHISPER: - return ImVec4(1.0f, 0.5f, 1.0f, 1.0f); // Pink (same as WHISPER) - case game::ChatType::RAID_BOSS_EMOTE: - return ImVec4(1.0f, 0.7f, 0.3f, 1.0f); // Orange (same as EMOTE) - case game::ChatType::MONSTER_PARTY: - return ImVec4(0.5f, 0.5f, 1.0f, 1.0f); // Light blue (same as PARTY) - case game::ChatType::BG_SYSTEM_NEUTRAL: - return colors::kWarmGold; // Gold - case game::ChatType::BG_SYSTEM_ALLIANCE: - return ImVec4(0.3f, 0.6f, 1.0f, 1.0f); // Blue - case game::ChatType::BG_SYSTEM_HORDE: - return kColorRed; // Red - case game::ChatType::AFK: - case game::ChatType::DND: - return ImVec4(0.85f, 0.85f, 0.85f, 0.8f); // Light gray - default: - return ui::colors::kLightGray; // Gray - } -} +// getChatTypeName / getChatTypeColor moved to ChatTabManager (Phase 1.3) std::string ChatPanel::replaceGenderPlaceholders(const std::string& text, game::GameHandler& gameHandler) { @@ -276,116 +177,17 @@ std::string ChatPanel::replaceGenderPlaceholders(const std::string& text, game:: return result; } +// renderBubbles delegates to ChatBubbleManager (Phase 1.4) void ChatPanel::renderBubbles(game::GameHandler& gameHandler) { - if (chatBubbles_.empty()) return; - - auto* renderer = services_.renderer; - auto* camera = renderer ? renderer->getCamera() : nullptr; - if (!camera) return; - - auto* window = services_.window; - float screenW = window ? static_cast(window->getWidth()) : 1280.0f; - float screenH = window ? static_cast(window->getHeight()) : 720.0f; - - // Get delta time from ImGui - float dt = ImGui::GetIO().DeltaTime; - - glm::mat4 viewProj = camera->getProjectionMatrix() * camera->getViewMatrix(); - - // Update and render bubbles - for (int i = static_cast(chatBubbles_.size()) - 1; i >= 0; --i) { - auto& bubble = chatBubbles_[i]; - bubble.timeRemaining -= dt; - if (bubble.timeRemaining <= 0.0f) { - chatBubbles_.erase(chatBubbles_.begin() + i); - continue; - } - - // Get entity position - auto entity = gameHandler.getEntityManager().getEntity(bubble.senderGuid); - if (!entity) continue; - - // Convert canonical → render coordinates, offset up by 2.5 units for bubble above head - glm::vec3 canonical(entity->getX(), entity->getY(), entity->getZ() + 2.5f); - glm::vec3 renderPos = core::coords::canonicalToRender(canonical); - - // Project to screen - glm::vec4 clipPos = viewProj * glm::vec4(renderPos, 1.0f); - if (clipPos.w <= 0.0f) continue; // Behind camera - - glm::vec2 ndc(clipPos.x / clipPos.w, clipPos.y / clipPos.w); - float screenX = (ndc.x * 0.5f + 0.5f) * screenW; - // Camera bakes the Vulkan Y-flip into the projection matrix: - // NDC y=-1 is top, y=1 is bottom — same convention as nameplate/minimap projection. - float screenY = (ndc.y * 0.5f + 0.5f) * screenH; - - // Skip if off-screen - if (screenX < -200.0f || screenX > screenW + 200.0f || - screenY < -100.0f || screenY > screenH + 100.0f) continue; - - // Fade alpha over last 2 seconds - float alpha = 1.0f; - if (bubble.timeRemaining < 2.0f) { - alpha = bubble.timeRemaining / 2.0f; - } - - // Draw bubble window - std::string winId = "##ChatBubble" + std::to_string(bubble.senderGuid); - ImGui::SetNextWindowPos(ImVec2(screenX, screenY), ImGuiCond_Always, ImVec2(0.5f, 1.0f)); - ImGui::SetNextWindowBgAlpha(0.7f * alpha); - ImGuiWindowFlags flags = ImGuiWindowFlags_NoTitleBar | ImGuiWindowFlags_NoResize | - ImGuiWindowFlags_NoMove | ImGuiWindowFlags_NoScrollbar | - ImGuiWindowFlags_AlwaysAutoResize | ImGuiWindowFlags_NoInputs | - ImGuiWindowFlags_NoFocusOnAppearing | ImGuiWindowFlags_NoNav; - - ImGui::PushStyleVar(ImGuiStyleVar_WindowRounding, 8.0f); - ImGui::PushStyleVar(ImGuiStyleVar_WindowPadding, ImVec2(8, 4)); - - ImGui::Begin(winId.c_str(), nullptr, flags); - - ImVec4 textColor = bubble.isYell - ? ImVec4(1.0f, 0.2f, 0.2f, alpha) - : ImVec4(1.0f, 1.0f, 1.0f, alpha); - - ImGui::PushStyleColor(ImGuiCol_Text, textColor); - ImGui::PushTextWrapPos(200.0f); - ImGui::TextWrapped("%s", bubble.message.c_str()); - ImGui::PopTextWrapPos(); - ImGui::PopStyleColor(); - - ImGui::End(); - ImGui::PopStyleVar(2); - } + bubbleManager_.render(gameHandler, services_); } // ---- Public interface methods ---- +// setupCallbacks delegates to ChatBubbleManager (Phase 1.4) void ChatPanel::setupCallbacks(game::GameHandler& gameHandler) { - if (!chatBubbleCallbackSet_) { - gameHandler.setChatBubbleCallback([this](uint64_t guid, const std::string& msg, bool isYell) { - float duration = 8.0f + static_cast(msg.size()) * 0.06f; - if (isYell) duration += 2.0f; - if (duration > 15.0f) duration = 15.0f; - - // Replace existing bubble for same sender - for (auto& b : chatBubbles_) { - if (b.senderGuid == guid) { - b.message = msg; - b.timeRemaining = duration; - b.totalDuration = duration; - b.isYell = isYell; - return; - } - } - // Evict oldest if too many - if (chatBubbles_.size() >= 10) { - chatBubbles_.erase(chatBubbles_.begin()); - } - chatBubbles_.push_back({guid, msg, duration, duration, isYell}); - }); - chatBubbleCallbackSet_ = true; - } + bubbleManager_.setupCallback(gameHandler); } void ChatPanel::insertChatLink(const std::string& link) { @@ -406,6 +208,7 @@ void ChatPanel::activateSlashInput() { } void ChatPanel::activateInput() { + if (chatInputCooldown_ > 0) return; // suppress re-activation right after send refocusChatInput_ = true; } @@ -422,59 +225,5 @@ ChatPanel::SlashCommands ChatPanel::consumeSlashCommands() { return result; } -void ChatPanel::renderSettingsTab(std::function saveSettingsFn) { - ImGui::Spacing(); - - ImGui::Text("Appearance"); - ImGui::Separator(); - - if (ImGui::Checkbox("Show Timestamps", &chatShowTimestamps)) { - saveSettingsFn(); - } - ImGui::SetItemTooltip("Show [HH:MM] before each chat message"); - - const char* fontSizes[] = { "Small", "Medium", "Large" }; - if (ImGui::Combo("Chat Font Size", &chatFontSize, fontSizes, 3)) { - saveSettingsFn(); - } - - ImGui::Spacing(); - ImGui::Spacing(); - ImGui::Text("Auto-Join Channels"); - ImGui::Separator(); - - if (ImGui::Checkbox("General", &chatAutoJoinGeneral)) saveSettingsFn(); - if (ImGui::Checkbox("Trade", &chatAutoJoinTrade)) saveSettingsFn(); - if (ImGui::Checkbox("LocalDefense", &chatAutoJoinLocalDefense)) saveSettingsFn(); - if (ImGui::Checkbox("LookingForGroup", &chatAutoJoinLFG)) saveSettingsFn(); - if (ImGui::Checkbox("Local", &chatAutoJoinLocal)) saveSettingsFn(); - - ImGui::Spacing(); - ImGui::Spacing(); - ImGui::Text("Joined Channels"); - ImGui::Separator(); - - ImGui::TextDisabled("Use /join and /leave commands in chat to manage channels."); - - ImGui::Spacing(); - ImGui::Separator(); - ImGui::Spacing(); - - if (ImGui::Button("Restore Chat Defaults", ImVec2(-1, 0))) { - restoreDefaults(); - saveSettingsFn(); - } -} - -void ChatPanel::restoreDefaults() { - chatShowTimestamps = false; - chatFontSize = 1; - chatAutoJoinGeneral = true; - chatAutoJoinTrade = true; - chatAutoJoinLocalDefense = true; - chatAutoJoinLFG = true; - chatAutoJoinLocal = true; -} - } // namespace ui } // namespace wowee diff --git a/src/ui/game_screen.cpp b/src/ui/game_screen.cpp index 15014c90..29255700 100644 --- a/src/ui/game_screen.cpp +++ b/src/ui/game_screen.cpp @@ -895,15 +895,16 @@ void GameScreen::processTargetInput(game::GameHandler& gameHandler) { chatPanel_.activateSlashInput(); } if (!io.WantTextInput && !chatPanel_.isChatInputActive() && - KeybindingManager::getInstance().isActionPressed(KeybindingManager::Action::TOGGLE_CHAT, true)) { + KeybindingManager::getInstance().isActionPressed(KeybindingManager::Action::TOGGLE_CHAT, false)) { chatPanel_.activateInput(); } const bool textFocus = chatPanel_.isChatInputActive() || io.WantTextInput; - // Tab targeting (when keyboard not captured by UI) - if (!io.WantCaptureKeyboard) { - // When typing in chat (or any text input), never treat keys as gameplay/UI shortcuts. + // Game hotkeys — gate on textFocus (chat/text-input active) rather than + // WantCaptureKeyboard so that toggle keys like M, C, I still work when an + // ImGui window (character panel, map, etc.) happens to have focus. + { if (!textFocus && input.isKeyJustPressed(SDL_SCANCODE_TAB)) { const auto& movement = gameHandler.getMovementInfo(); gameHandler.tabTarget(movement.x, movement.y, movement.z); @@ -1005,7 +1006,7 @@ void GameScreen::processTargetInput(game::GameHandler& gameHandler) { } // Toggle Titles window with H (hero/title screen — no conflicting keybinding) - if (input.isKeyJustPressed(SDL_SCANCODE_H) && !ImGui::GetIO().WantCaptureKeyboard) { + if (input.isKeyJustPressed(SDL_SCANCODE_H)) { windowManager_.showTitlesWindow_ = !windowManager_.showTitlesWindow_; } @@ -1065,7 +1066,7 @@ void GameScreen::processTargetInput(game::GameHandler& gameHandler) { } else if (bar[slotIdx].type == game::ActionBarSlot::ITEM && bar[slotIdx].id != 0) { gameHandler.useItemById(bar[slotIdx].id); } else if (bar[slotIdx].type == game::ActionBarSlot::MACRO) { - chatPanel_.executeMacroText(gameHandler, inventoryScreen, spellbookScreen, questLogScreen, gameHandler.getMacroText(bar[slotIdx].id)); + chatPanel_.executeMacroText(gameHandler, gameHandler.getMacroText(bar[slotIdx].id)); } } } diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 8ac5b138..3ddcc1ec 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -349,6 +349,56 @@ endif() add_test(NAME world_map_zone_metadata COMMAND test_world_map_zone_metadata) register_test_target(test_world_map_zone_metadata) +# ── test_chat_markup_parser ────────────────────────────────── +add_executable(test_chat_markup_parser + test_chat_markup_parser.cpp + ${TEST_COMMON_SOURCES} + ${CMAKE_SOURCE_DIR}/src/ui/chat/chat_markup_parser.cpp +) +target_include_directories(test_chat_markup_parser PRIVATE ${TEST_INCLUDE_DIRS}) +target_include_directories(test_chat_markup_parser SYSTEM PRIVATE + ${TEST_SYSTEM_INCLUDE_DIRS} + ${CMAKE_SOURCE_DIR}/extern/imgui +) +target_link_libraries(test_chat_markup_parser PRIVATE catch2_main) +add_test(NAME chat_markup_parser COMMAND test_chat_markup_parser) +register_test_target(test_chat_markup_parser) + +# ── test_macro_evaluator ───────────────────────────────────── +add_executable(test_macro_evaluator + test_macro_evaluator.cpp + ${TEST_COMMON_SOURCES} + ${CMAKE_SOURCE_DIR}/src/ui/chat/macro_evaluator.cpp +) +target_include_directories(test_macro_evaluator PRIVATE ${TEST_INCLUDE_DIRS}) +target_include_directories(test_macro_evaluator SYSTEM PRIVATE ${TEST_SYSTEM_INCLUDE_DIRS}) +target_link_libraries(test_macro_evaluator PRIVATE catch2_main) +add_test(NAME macro_evaluator COMMAND test_macro_evaluator) +register_test_target(test_macro_evaluator) + +# ── test_chat_tab_completer ────────────────────────────────── +add_executable(test_chat_tab_completer + test_chat_tab_completer.cpp + ${TEST_COMMON_SOURCES} + ${CMAKE_SOURCE_DIR}/src/ui/chat/chat_tab_completer.cpp +) +target_include_directories(test_chat_tab_completer PRIVATE ${TEST_INCLUDE_DIRS}) +target_include_directories(test_chat_tab_completer SYSTEM PRIVATE ${TEST_SYSTEM_INCLUDE_DIRS}) +target_link_libraries(test_chat_tab_completer PRIVATE catch2_main) +add_test(NAME chat_tab_completer COMMAND test_chat_tab_completer) +register_test_target(test_chat_tab_completer) + +# ── test_gm_commands ───────────────────────────────────────── +add_executable(test_gm_commands + test_gm_commands.cpp + ${TEST_COMMON_SOURCES} +) +target_include_directories(test_gm_commands PRIVATE ${TEST_INCLUDE_DIRS}) +target_include_directories(test_gm_commands SYSTEM PRIVATE ${TEST_SYSTEM_INCLUDE_DIRS}) +target_link_libraries(test_gm_commands PRIVATE catch2_main) +add_test(NAME gm_commands COMMAND test_gm_commands) +register_test_target(test_gm_commands) + # ── ASAN / UBSan for test targets ──────────────────────────── if(WOWEE_ENABLE_ASAN AND NOT MSVC) foreach(_t IN LISTS ALL_TEST_TARGETS) diff --git a/tests/test_chat_markup_parser.cpp b/tests/test_chat_markup_parser.cpp new file mode 100644 index 00000000..a8e91759 --- /dev/null +++ b/tests/test_chat_markup_parser.cpp @@ -0,0 +1,207 @@ +// Tests for ChatMarkupParser — WoW markup parsing into typed segments. +// Phase 2.3 of chat_panel_ref.md. + +#include +#include "ui/chat/chat_markup_parser.hpp" + +using namespace wowee::ui; + +// ── Plain text ────────────────────────────────────────────── + +TEST_CASE("Plain text produces single Text segment", "[chat_markup]") { + ChatMarkupParser parser; + auto segs = parser.parse("Hello world"); + REQUIRE(segs.size() == 1); + CHECK(segs[0].type == SegmentType::Text); + CHECK(segs[0].text == "Hello world"); +} + +TEST_CASE("Empty string produces no segments", "[chat_markup]") { + ChatMarkupParser parser; + auto segs = parser.parse(""); + CHECK(segs.empty()); +} + +// ── Color codes ───────────────────────────────────────────── + +TEST_CASE("parseWowColor extracts AARRGGBB correctly", "[chat_markup]") { + // |cFF00FF00 → alpha=1.0, red=0.0, green=1.0, blue=0.0 + std::string s = "|cFF00FF00some text|r"; + ImVec4 c = ChatMarkupParser::parseWowColor(s, 0); + CHECK(c.x == Catch::Approx(0.0f).margin(0.01f)); // red + CHECK(c.y == Catch::Approx(1.0f).margin(0.01f)); // green + CHECK(c.z == Catch::Approx(0.0f).margin(0.01f)); // blue + CHECK(c.w == Catch::Approx(1.0f).margin(0.01f)); // alpha +} + +TEST_CASE("parseWowColor with half values", "[chat_markup]") { + // |cFF808080 → gray (128/255 ≈ 0.502) + std::string s = "|cFF808080"; + ImVec4 c = ChatMarkupParser::parseWowColor(s, 0); + CHECK(c.x == Catch::Approx(0.502f).margin(0.01f)); + CHECK(c.y == Catch::Approx(0.502f).margin(0.01f)); + CHECK(c.z == Catch::Approx(0.502f).margin(0.01f)); +} + +TEST_CASE("Colored text segment: |cAARRGGBB...text...|r", "[chat_markup]") { + ChatMarkupParser parser; + auto segs = parser.parse("|cFFFF0000Red text|r"); + REQUIRE(segs.size() == 1); + CHECK(segs[0].type == SegmentType::ColoredText); + CHECK(segs[0].text == "Red text"); + CHECK(segs[0].color.x == Catch::Approx(1.0f).margin(0.01f)); // red + CHECK(segs[0].color.y == Catch::Approx(0.0f).margin(0.01f)); // green + CHECK(segs[0].color.z == Catch::Approx(0.0f).margin(0.01f)); // blue +} + +TEST_CASE("Colored text without |r includes rest of string", "[chat_markup]") { + ChatMarkupParser parser; + auto segs = parser.parse("|cFF00FF00Green forever"); + REQUIRE(segs.size() == 1); + CHECK(segs[0].type == SegmentType::ColoredText); + CHECK(segs[0].text == "Green forever"); +} + +TEST_CASE("Mixed plain and colored text", "[chat_markup]") { + ChatMarkupParser parser; + auto segs = parser.parse("Hello |cFFFF0000world|r!"); + REQUIRE(segs.size() == 3); + CHECK(segs[0].type == SegmentType::Text); + CHECK(segs[0].text == "Hello "); + CHECK(segs[1].type == SegmentType::ColoredText); + CHECK(segs[1].text == "world"); + CHECK(segs[2].type == SegmentType::Text); + CHECK(segs[2].text == "!"); +} + +// ── Item links ────────────────────────────────────────────── + +TEST_CASE("Item link: |cFFAARRGG|Hitem:ID:...|h[Name]|h|r", "[chat_markup]") { + ChatMarkupParser parser; + std::string raw = "|cff1eff00|Hitem:19019:0:0:0:0:0:0:0:80|h[Thunderfury]|h|r"; + auto segs = parser.parse(raw); + REQUIRE(segs.size() == 1); + CHECK(segs[0].type == SegmentType::ItemLink); + CHECK(segs[0].text == "Thunderfury"); + CHECK(segs[0].id == 19019); + CHECK_FALSE(segs[0].rawLink.empty()); +} + +TEST_CASE("Bare item link without color prefix", "[chat_markup]") { + ChatMarkupParser parser; + std::string raw = "|Hitem:12345:0:0:0|h[Some Item]|h"; + auto segs = parser.parse(raw); + REQUIRE(segs.size() == 1); + CHECK(segs[0].type == SegmentType::ItemLink); + CHECK(segs[0].text == "Some Item"); + CHECK(segs[0].id == 12345); +} + +TEST_CASE("Item link with text before and after", "[chat_markup]") { + ChatMarkupParser parser; + std::string raw = "Check this: |cff0070dd|Hitem:50000:0:0:0|h[Cool Sword]|h|r nice!"; + auto segs = parser.parse(raw); + REQUIRE(segs.size() == 3); + CHECK(segs[0].type == SegmentType::Text); + CHECK(segs[0].text == "Check this: "); + CHECK(segs[1].type == SegmentType::ItemLink); + CHECK(segs[1].text == "Cool Sword"); + CHECK(segs[1].id == 50000); + CHECK(segs[2].type == SegmentType::Text); + CHECK(segs[2].text == " nice!"); +} + +// ── Spell links ───────────────────────────────────────────── + +TEST_CASE("Spell link: |Hspell:ID:RANK|h[Name]|h", "[chat_markup]") { + ChatMarkupParser parser; + std::string raw = "|cff71d5ff|Hspell:48461:0|h[Wrath]|h|r"; + auto segs = parser.parse(raw); + REQUIRE(segs.size() == 1); + CHECK(segs[0].type == SegmentType::SpellLink); + CHECK(segs[0].text == "Wrath"); + CHECK(segs[0].id == 48461); +} + +// ── Quest links ───────────────────────────────────────────── + +TEST_CASE("Quest link with level extraction", "[chat_markup]") { + ChatMarkupParser parser; + std::string raw = "|cff808080|Hquest:9876:70|h[The Last Stand]|h|r"; + auto segs = parser.parse(raw); + REQUIRE(segs.size() == 1); + CHECK(segs[0].type == SegmentType::QuestLink); + CHECK(segs[0].text == "The Last Stand"); + CHECK(segs[0].id == 9876); + CHECK(segs[0].extra == 70); // quest level +} + +// ── Achievement links ─────────────────────────────────────── + +TEST_CASE("Achievement link", "[chat_markup]") { + ChatMarkupParser parser; + std::string raw = "|cffffff00|Hachievement:2136:0:0:0:0:0:0:0:0:0|h[Glory of the Hero]|h|r"; + auto segs = parser.parse(raw); + REQUIRE(segs.size() == 1); + CHECK(segs[0].type == SegmentType::AchievementLink); + CHECK(segs[0].text == "Glory of the Hero"); + CHECK(segs[0].id == 2136); +} + +// ── URLs ──────────────────────────────────────────────────── + +TEST_CASE("URL is detected as Url segment", "[chat_markup]") { + ChatMarkupParser parser; + auto segs = parser.parse("Visit https://example.com for info"); + REQUIRE(segs.size() == 3); + CHECK(segs[0].type == SegmentType::Text); + CHECK(segs[0].text == "Visit "); + CHECK(segs[1].type == SegmentType::Url); + CHECK(segs[1].text == "https://example.com"); + CHECK(segs[2].type == SegmentType::Text); + CHECK(segs[2].text == " for info"); +} + +TEST_CASE("URL at end of message", "[chat_markup]") { + ChatMarkupParser parser; + auto segs = parser.parse("Link: https://example.com/path?q=1"); + REQUIRE(segs.size() == 2); + CHECK(segs[1].type == SegmentType::Url); + CHECK(segs[1].text == "https://example.com/path?q=1"); +} + +// ── Edge cases ────────────────────────────────────────────── + +TEST_CASE("Short |c without enough hex digits treated as literal", "[chat_markup]") { + ChatMarkupParser parser; + auto segs = parser.parse("|c short"); + REQUIRE(segs.size() == 2); + CHECK(segs[0].type == SegmentType::Text); + CHECK(segs[0].text == "|c"); + CHECK(segs[1].type == SegmentType::Text); + CHECK(segs[1].text == " short"); +} + +TEST_CASE("Multiple item links in one message", "[chat_markup]") { + ChatMarkupParser parser; + std::string raw = + "|cff1eff00|Hitem:100:0|h[Sword]|h|r and |cff0070dd|Hitem:200:0|h[Shield]|h|r"; + auto segs = parser.parse(raw); + REQUIRE(segs.size() == 3); + CHECK(segs[0].type == SegmentType::ItemLink); + CHECK(segs[0].text == "Sword"); + CHECK(segs[0].id == 100); + CHECK(segs[1].type == SegmentType::Text); + CHECK(segs[1].text == " and "); + CHECK(segs[2].type == SegmentType::ItemLink); + CHECK(segs[2].text == "Shield"); + CHECK(segs[2].id == 200); +} + +TEST_CASE("parseWowColor with truncated string returns white", "[chat_markup]") { + ImVec4 c = ChatMarkupParser::parseWowColor("|cFF", 0); + CHECK(c.x == 1.0f); + CHECK(c.y == 1.0f); + CHECK(c.z == 1.0f); + CHECK(c.w == 1.0f); +} diff --git a/tests/test_chat_tab_completer.cpp b/tests/test_chat_tab_completer.cpp new file mode 100644 index 00000000..34c44b56 --- /dev/null +++ b/tests/test_chat_tab_completer.cpp @@ -0,0 +1,104 @@ +// Unit tests for ChatTabCompleter (Phase 5). +#include +#include "ui/chat/chat_tab_completer.hpp" + +using wowee::ui::ChatTabCompleter; + +TEST_CASE("ChatTabCompleter — initial state", "[chat][tab]") { + ChatTabCompleter tc; + CHECK_FALSE(tc.isActive()); + CHECK(tc.matchCount() == 0); + CHECK(tc.getCurrentMatch().empty()); + CHECK(tc.getPrefix().empty()); +} + +TEST_CASE("ChatTabCompleter — start with empty candidates", "[chat][tab]") { + ChatTabCompleter tc; + tc.startCompletion("foo", {}); + CHECK_FALSE(tc.isActive()); + CHECK(tc.matchCount() == 0); + CHECK(tc.getCurrentMatch().empty()); + CHECK(tc.getPrefix() == "foo"); +} + +TEST_CASE("ChatTabCompleter — single candidate", "[chat][tab]") { + ChatTabCompleter tc; + tc.startCompletion("h", {"/help"}); + REQUIRE(tc.isActive()); + CHECK(tc.matchCount() == 1); + CHECK(tc.getCurrentMatch() == "/help"); + // Cycling wraps to the same entry + tc.next(); + CHECK(tc.getCurrentMatch() == "/help"); +} + +TEST_CASE("ChatTabCompleter — multiple candidates cycle", "[chat][tab]") { + ChatTabCompleter tc; + tc.startCompletion("s", {"/say", "/sit", "/stand"}); + REQUIRE(tc.isActive()); + CHECK(tc.matchCount() == 3); + CHECK(tc.getCurrentMatch() == "/say"); + tc.next(); + CHECK(tc.getCurrentMatch() == "/sit"); + tc.next(); + CHECK(tc.getCurrentMatch() == "/stand"); + // Wraps around + tc.next(); + CHECK(tc.getCurrentMatch() == "/say"); +} + +TEST_CASE("ChatTabCompleter — reset clears state", "[chat][tab]") { + ChatTabCompleter tc; + tc.startCompletion("s", {"/say", "/sit"}); + REQUIRE(tc.isActive()); + tc.reset(); + CHECK_FALSE(tc.isActive()); + CHECK(tc.matchCount() == 0); + CHECK(tc.getCurrentMatch().empty()); + CHECK(tc.getPrefix().empty()); +} + +TEST_CASE("ChatTabCompleter — new prefix restarts", "[chat][tab]") { + ChatTabCompleter tc; + tc.startCompletion("s", {"/say", "/sit"}); + tc.next(); // now at /sit + CHECK(tc.getCurrentMatch() == "/sit"); + // Start new session with different prefix + tc.startCompletion("h", {"/help", "/helm"}); + CHECK(tc.getPrefix() == "h"); + CHECK(tc.getCurrentMatch() == "/help"); + CHECK(tc.matchCount() == 2); +} + +TEST_CASE("ChatTabCompleter — next on empty returns false", "[chat][tab]") { + ChatTabCompleter tc; + CHECK_FALSE(tc.next()); + tc.startCompletion("x", {}); + CHECK_FALSE(tc.next()); +} + +TEST_CASE("ChatTabCompleter — next returns true when cycling", "[chat][tab]") { + ChatTabCompleter tc; + tc.startCompletion("a", {"/afk", "/assist"}); + CHECK(tc.next()); + CHECK(tc.getCurrentMatch() == "/assist"); +} + +TEST_CASE("ChatTabCompleter — prefix is preserved after cycling", "[chat][tab]") { + ChatTabCompleter tc; + tc.startCompletion("inv", {"/invite"}); + tc.next(); + tc.next(); + CHECK(tc.getPrefix() == "inv"); +} + +TEST_CASE("ChatTabCompleter — start same prefix resets index", "[chat][tab]") { + ChatTabCompleter tc; + tc.startCompletion("s", {"/say", "/sit", "/stand"}); + tc.next(); // /sit + tc.next(); // /stand + // Re-start with same prefix but new candidate list + tc.startCompletion("s", {"/say", "/shout"}); + CHECK(tc.getCurrentMatch() == "/say"); + CHECK(tc.matchCount() == 2); +} diff --git a/tests/test_gm_commands.cpp b/tests/test_gm_commands.cpp new file mode 100644 index 00000000..78d19607 --- /dev/null +++ b/tests/test_gm_commands.cpp @@ -0,0 +1,128 @@ +// Tests for GM command data table and completion helpers. +#include +#include "ui/chat/gm_command_data.hpp" +#include +#include +#include +#include + +using namespace wowee::ui; + +// --------------------------------------------------------------------------- +// Data table sanity checks +// --------------------------------------------------------------------------- + +TEST_CASE("GM command table is non-empty", "[gm_commands]") { + REQUIRE(kGmCommands.size() > 50); +} + +TEST_CASE("GM command entries have required fields", "[gm_commands]") { + for (const auto& cmd : kGmCommands) { + INFO("command: " << cmd.name); + REQUIRE(!cmd.name.empty()); + REQUIRE(!cmd.syntax.empty()); + REQUIRE(!cmd.help.empty()); + REQUIRE(cmd.security <= 4); + } +} + +TEST_CASE("GM command names are unique", "[gm_commands]") { + std::set seen; + for (const auto& cmd : kGmCommands) { + std::string name(cmd.name); + INFO("duplicate: " << name); + REQUIRE(seen.insert(name).second); + } +} + +TEST_CASE("GM command syntax starts with dot-prefix", "[gm_commands]") { + for (const auto& cmd : kGmCommands) { + INFO("command: " << cmd.name << " syntax: " << cmd.syntax); + REQUIRE(cmd.syntax.size() > 1); + REQUIRE(cmd.syntax[0] == '.'); + } +} + +// --------------------------------------------------------------------------- +// Completion helper (inline, matches chat_panel.cpp logic) +// --------------------------------------------------------------------------- + +static std::vector getGmCompletions(const std::string& prefix) { + std::vector results; + for (const auto& cmd : kGmCommands) { + std::string dotName = "." + std::string(cmd.name); + if (dotName.size() >= prefix.size() && + dotName.compare(0, prefix.size(), prefix) == 0) { + results.push_back(dotName); + } + } + std::sort(results.begin(), results.end()); + return results; +} + +TEST_CASE("GM completions for '.gm' include gm subcommands", "[gm_commands]") { + auto results = getGmCompletions(".gm"); + REQUIRE(!results.empty()); + // Should contain .gm, .gm on, .gm off, .gm fly, etc. + REQUIRE(std::find(results.begin(), results.end(), ".gm") != results.end()); + REQUIRE(std::find(results.begin(), results.end(), ".gm on") != results.end()); + REQUIRE(std::find(results.begin(), results.end(), ".gm off") != results.end()); +} + +TEST_CASE("GM completions for '.tele' include teleport commands", "[gm_commands]") { + auto results = getGmCompletions(".tele"); + REQUIRE(!results.empty()); + REQUIRE(std::find(results.begin(), results.end(), ".tele") != results.end()); +} + +TEST_CASE("GM completions for '.add' include additem", "[gm_commands]") { + auto results = getGmCompletions(".add"); + REQUIRE(!results.empty()); + REQUIRE(std::find(results.begin(), results.end(), ".additem") != results.end()); +} + +TEST_CASE("GM completions for '.xyz' returns empty (no match)", "[gm_commands]") { + auto results = getGmCompletions(".xyz"); + REQUIRE(results.empty()); +} + +TEST_CASE("GM completions are sorted", "[gm_commands]") { + auto results = getGmCompletions(".ch"); + REQUIRE(results.size() > 1); + REQUIRE(std::is_sorted(results.begin(), results.end())); +} + +TEST_CASE("GM completions for '.' returns all commands", "[gm_commands]") { + auto results = getGmCompletions("."); + REQUIRE(results.size() == kGmCommands.size()); +} + +TEST_CASE("Key GM commands exist in table", "[gm_commands]") { + std::set names; + for (const auto& cmd : kGmCommands) { + names.insert(std::string(cmd.name)); + } + // Check essential commands + CHECK(names.count("gm")); + CHECK(names.count("gm on")); + CHECK(names.count("tele")); + CHECK(names.count("go xyz")); + CHECK(names.count("additem")); + CHECK(names.count("levelup")); + CHECK(names.count("revive")); + CHECK(names.count("die")); + CHECK(names.count("cheat god")); + CHECK(names.count("cast")); + CHECK(names.count("learn")); + CHECK(names.count("modify money")); + CHECK(names.count("lookup item")); + CHECK(names.count("npc add")); + CHECK(names.count("ban account")); + CHECK(names.count("kick")); + CHECK(names.count("server info")); + CHECK(names.count("help")); + CHECK(names.count("commands")); + CHECK(names.count("save")); + CHECK(names.count("summon")); + CHECK(names.count("appear")); +} diff --git a/tests/test_macro_evaluator.cpp b/tests/test_macro_evaluator.cpp new file mode 100644 index 00000000..696c34c7 --- /dev/null +++ b/tests/test_macro_evaluator.cpp @@ -0,0 +1,528 @@ +// Tests for MacroEvaluator — WoW macro conditional parsing and evaluation. +// Phase 4.5 of chat_panel_ref.md. + +#include +#include "ui/chat/macro_evaluator.hpp" +#include "ui/chat/i_game_state.hpp" +#include "ui/chat/i_modifier_state.hpp" +#include +#include + +using namespace wowee::ui; + +// ── Mock IGameState ───────────────────────────────────────── + +class MockGameState : public IGameState { +public: + // GUIDs + uint64_t playerGuid = 1; + uint64_t targetGuid = 2; + uint64_t focusGuid = 0; + uint64_t petGuid = 0; + uint64_t mouseoverGuid = 0; + + // State flags + bool inCombat = false; + bool mounted = false; + bool swimming = false; + bool flying = false; + bool casting = false; + bool channeling = false; + bool stealthed = false; + bool pet = false; + bool inGroup = false; + bool inRaid = false; + bool indoors = false; + + // Numeric + uint8_t talentSpec = 0; + uint32_t vehicleId = 0; + uint32_t castSpellId = 0; + std::string castSpellName; + + // Entity states (guid → exists, dead, hostile) + struct EntityInfo { bool exists = true; bool dead = false; bool hostile = false; }; + std::unordered_map entities; + + // Form / aura + bool formAura = false; + // Aura checks: map of "spellname_debuff" → true + std::unordered_set auras; + + // IGameState implementation + uint64_t getPlayerGuid() const override { return playerGuid; } + uint64_t getTargetGuid() const override { return targetGuid; } + uint64_t getFocusGuid() const override { return focusGuid; } + uint64_t getPetGuid() const override { return petGuid; } + uint64_t getMouseoverGuid() const override { return mouseoverGuid; } + + bool isInCombat() const override { return inCombat; } + bool isMounted() const override { return mounted; } + bool isSwimming() const override { return swimming; } + bool isFlying() const override { return flying; } + bool isCasting() const override { return casting; } + bool isChanneling() const override { return channeling; } + bool isStealthed() const override { return stealthed; } + bool hasPet() const override { return pet; } + bool isInGroup() const override { return inGroup; } + bool isInRaid() const override { return inRaid; } + bool isIndoors() const override { return indoors; } + + uint8_t getActiveTalentSpec() const override { return talentSpec; } + uint32_t getVehicleId() const override { return vehicleId; } + uint32_t getCurrentCastSpellId() const override { return castSpellId; } + + std::string getSpellName(uint32_t /*spellId*/) const override { return castSpellName; } + + bool hasAuraByName(uint64_t /*targetGuid*/, const std::string& spellName, + bool wantDebuff) const override { + std::string key = spellName + (wantDebuff ? "_debuff" : "_buff"); + // Lowercase for comparison + for (char& c : key) c = static_cast(std::tolower(static_cast(c))); + return auras.count(key) > 0; + } + + bool hasFormAura() const override { return formAura; } + + bool entityExists(uint64_t guid) const override { + if (guid == 0 || guid == static_cast(-1)) return false; + auto it = entities.find(guid); + return it != entities.end() && it->second.exists; + } + bool entityIsDead(uint64_t guid) const override { + auto it = entities.find(guid); + return it != entities.end() && it->second.dead; + } + bool entityIsHostile(uint64_t guid) const override { + auto it = entities.find(guid); + return it != entities.end() && it->second.hostile; + } + +private: + // Need these for unordered_set/map + struct StringHash { size_t operator()(const std::string& s) const { return std::hash{}(s); } }; +}; + +// ── Mock IModifierState ───────────────────────────────────── + +class MockModState : public IModifierState { +public: + bool shift = false; + bool ctrl = false; + bool alt = false; + + bool isShiftHeld() const override { return shift; } + bool isCtrlHeld() const override { return ctrl; } + bool isAltHeld() const override { return alt; } +}; + +// ── Helper ────────────────────────────────────────────────── + +struct TestFixture { + MockGameState gs; + MockModState ms; + MacroEvaluator eval{gs, ms}; + + std::string run(const std::string& input) { + uint64_t tgt; + return eval.evaluate(input, tgt); + } + + std::pair runWithTarget(const std::string& input) { + uint64_t tgt; + std::string result = eval.evaluate(input, tgt); + return {result, tgt}; + } +}; + +// ── Basic parsing tests ───────────────────────────────────── + +TEST_CASE("No conditionals returns input as-is", "[macro_eval]") { + TestFixture f; + CHECK(f.run("Fireball") == "Fireball"); +} + +TEST_CASE("Empty string returns empty", "[macro_eval]") { + TestFixture f; + CHECK(f.run("") == ""); +} + +TEST_CASE("Whitespace-only returns empty", "[macro_eval]") { + TestFixture f; + CHECK(f.run(" ") == ""); +} + +TEST_CASE("Semicolon-separated alternatives: first matches", "[macro_eval]") { + TestFixture f; + CHECK(f.run("Fireball; Frostbolt") == "Fireball"); +} + +TEST_CASE("Default fallback after conditions", "[macro_eval]") { + TestFixture f; + f.gs.inCombat = false; + CHECK(f.run("[combat] Attack; Heal") == "Heal"); +} + +// ── Combat conditions ─────────────────────────────────────── + +TEST_CASE("[combat] true when in combat", "[macro_eval]") { + TestFixture f; + f.gs.inCombat = true; + CHECK(f.run("[combat] Attack; Heal") == "Attack"); +} + +TEST_CASE("[combat] false when not in combat", "[macro_eval]") { + TestFixture f; + f.gs.inCombat = false; + CHECK(f.run("[combat] Attack; Heal") == "Heal"); +} + +TEST_CASE("[nocombat] true when not in combat", "[macro_eval]") { + TestFixture f; + f.gs.inCombat = false; + CHECK(f.run("[nocombat] Heal; Attack") == "Heal"); +} + +TEST_CASE("[nocombat] false when in combat", "[macro_eval]") { + TestFixture f; + f.gs.inCombat = true; + CHECK(f.run("[nocombat] Heal; Attack") == "Attack"); +} + +// ── Modifier conditions ───────────────────────────────────── + +TEST_CASE("[mod:shift] true when shift held", "[macro_eval]") { + TestFixture f; + f.ms.shift = true; + CHECK(f.run("[mod:shift] Polymorph; Fireball") == "Polymorph"); +} + +TEST_CASE("[mod:shift] false when shift not held", "[macro_eval]") { + TestFixture f; + f.ms.shift = false; + CHECK(f.run("[mod:shift] Polymorph; Fireball") == "Fireball"); +} + +TEST_CASE("[mod:ctrl] true when ctrl held", "[macro_eval]") { + TestFixture f; + f.ms.ctrl = true; + CHECK(f.run("[mod:ctrl] Decurse; Fireball") == "Decurse"); +} + +TEST_CASE("[mod:alt] true when alt held", "[macro_eval]") { + TestFixture f; + f.ms.alt = true; + CHECK(f.run("[mod:alt] Special; Normal") == "Special"); +} + +TEST_CASE("[nomod] true when no modifier held", "[macro_eval]") { + TestFixture f; + CHECK(f.run("[nomod] Normal; Modified") == "Normal"); +} + +TEST_CASE("[nomod] false when shift held", "[macro_eval]") { + TestFixture f; + f.ms.shift = true; + CHECK(f.run("[nomod] Normal; Modified") == "Modified"); +} + +// ── Target specifiers ─────────────────────────────────────── + +TEST_CASE("[@player] sets target override to player guid", "[macro_eval]") { + TestFixture f; + f.gs.playerGuid = 42; + auto [result, tgt] = f.runWithTarget("[@player] Heal"); + CHECK(result == "Heal"); + CHECK(tgt == 42); +} + +TEST_CASE("[@focus] sets target override to focus guid", "[macro_eval]") { + TestFixture f; + f.gs.focusGuid = 99; + auto [result, tgt] = f.runWithTarget("[@focus] Heal"); + CHECK(result == "Heal"); + CHECK(tgt == 99); +} + +TEST_CASE("[@pet] fails when no pet", "[macro_eval]") { + TestFixture f; + f.gs.petGuid = 0; + CHECK(f.run("[@pet] Mend Pet; Steady Shot") == "Steady Shot"); +} + +TEST_CASE("[@pet] succeeds when pet exists", "[macro_eval]") { + TestFixture f; + f.gs.petGuid = 50; + auto [result, tgt] = f.runWithTarget("[@pet] Mend Pet; Steady Shot"); + CHECK(result == "Mend Pet"); + CHECK(tgt == 50); +} + +TEST_CASE("[@mouseover] fails when no mouseover", "[macro_eval]") { + TestFixture f; + f.gs.mouseoverGuid = 0; + CHECK(f.run("[@mouseover] Heal; Flash Heal") == "Flash Heal"); +} + +TEST_CASE("[@mouseover] succeeds when mouseover exists", "[macro_eval]") { + TestFixture f; + f.gs.mouseoverGuid = 77; + auto [result, tgt] = f.runWithTarget("[@mouseover] Heal; Flash Heal"); + CHECK(result == "Heal"); + CHECK(tgt == 77); +} + +TEST_CASE("[target=focus] sets target override", "[macro_eval]") { + TestFixture f; + f.gs.focusGuid = 33; + auto [result, tgt] = f.runWithTarget("[target=focus] Polymorph"); + CHECK(result == "Polymorph"); + CHECK(tgt == 33); +} + +// ── Entity conditions (exists/dead/help/harm) ─────────────── + +TEST_CASE("[exists] true when target entity exists", "[macro_eval]") { + TestFixture f; + f.gs.targetGuid = 10; + f.gs.entities[10] = {true, false, false}; + CHECK(f.run("[exists] Attack; Buff") == "Attack"); +} + +TEST_CASE("[exists] false when no target", "[macro_eval]") { + TestFixture f; + f.gs.targetGuid = 0; + CHECK(f.run("[exists] Attack; Buff") == "Buff"); +} + +TEST_CASE("[dead] true when target is dead", "[macro_eval]") { + TestFixture f; + f.gs.targetGuid = 10; + f.gs.entities[10] = {true, true, false}; + CHECK(f.run("[dead] Resurrect; Heal") == "Resurrect"); +} + +TEST_CASE("[nodead] true when target is alive", "[macro_eval]") { + TestFixture f; + f.gs.targetGuid = 10; + f.gs.entities[10] = {true, false, false}; + CHECK(f.run("[nodead] Heal; Resurrect") == "Heal"); +} + +TEST_CASE("[harm] true when target is hostile", "[macro_eval]") { + TestFixture f; + f.gs.targetGuid = 10; + f.gs.entities[10] = {true, false, true}; + CHECK(f.run("[harm] Attack; Heal") == "Attack"); +} + +TEST_CASE("[help] true when target is friendly", "[macro_eval]") { + TestFixture f; + f.gs.targetGuid = 10; + f.gs.entities[10] = {true, false, false}; + CHECK(f.run("[help] Heal; Attack") == "Heal"); +} + +// ── Chained conditions ────────────────────────────────────── + +TEST_CASE("Chained conditions all must pass", "[macro_eval]") { + TestFixture f; + f.gs.inCombat = true; + f.ms.shift = true; + CHECK(f.run("[combat,mod:shift] Special; Normal") == "Special"); +} + +TEST_CASE("Chained conditions: one fails → whole group fails", "[macro_eval]") { + TestFixture f; + f.gs.inCombat = true; + f.ms.shift = false; + CHECK(f.run("[combat,mod:shift] Special; Normal") == "Normal"); +} + +// ── State conditions ──────────────────────────────────────── + +TEST_CASE("[mounted] true when mounted", "[macro_eval]") { + TestFixture f; + f.gs.mounted = true; + CHECK(f.run("[mounted] Dismount; Mount") == "Dismount"); +} + +TEST_CASE("[swimming] true when swimming", "[macro_eval]") { + TestFixture f; + f.gs.swimming = true; + CHECK(f.run("[swimming] Swim; Walk") == "Swim"); +} + +TEST_CASE("[flying] true when flying", "[macro_eval]") { + TestFixture f; + f.gs.flying = true; + CHECK(f.run("[flying] Land; Fly") == "Land"); +} + +TEST_CASE("[stealthed] true when stealthed", "[macro_eval]") { + TestFixture f; + f.gs.stealthed = true; + CHECK(f.run("[stealthed] Ambush; Sinister Strike") == "Ambush"); +} + +TEST_CASE("[indoors] true when indoors", "[macro_eval]") { + TestFixture f; + f.gs.indoors = true; + CHECK(f.run("[indoors] Walk; Mount") == "Walk"); +} + +TEST_CASE("[outdoors] true when not indoors", "[macro_eval]") { + TestFixture f; + f.gs.indoors = false; + CHECK(f.run("[outdoors] Mount; Walk") == "Mount"); +} + +TEST_CASE("[group] true when in group", "[macro_eval]") { + TestFixture f; + f.gs.inGroup = true; + CHECK(f.run("[group] Party Spell; Solo Spell") == "Party Spell"); +} + +TEST_CASE("[nogroup] true when not in group", "[macro_eval]") { + TestFixture f; + f.gs.inGroup = false; + CHECK(f.run("[nogroup] Solo; Party") == "Solo"); +} + +TEST_CASE("[raid] true when in raid", "[macro_eval]") { + TestFixture f; + f.gs.inRaid = true; + CHECK(f.run("[raid] Raid Spell; Normal") == "Raid Spell"); +} + +TEST_CASE("[pet] true when has pet", "[macro_eval]") { + TestFixture f; + f.gs.pet = true; + CHECK(f.run("[pet] Mend Pet; Steady Shot") == "Mend Pet"); +} + +TEST_CASE("[nopet] true when no pet", "[macro_eval]") { + TestFixture f; + f.gs.pet = false; + CHECK(f.run("[nopet] Steady Shot; Mend Pet") == "Steady Shot"); +} + +// ── Spec conditions ───────────────────────────────────────── + +TEST_CASE("[spec:1] matches primary spec (0-based index 0)", "[macro_eval]") { + TestFixture f; + f.gs.talentSpec = 0; + CHECK(f.run("[spec:1] Heal; DPS") == "Heal"); +} + +TEST_CASE("[spec:2] matches secondary spec (0-based index 1)", "[macro_eval]") { + TestFixture f; + f.gs.talentSpec = 1; + CHECK(f.run("[spec:2] DPS; Heal") == "DPS"); +} + +TEST_CASE("[spec:1] fails when spec is secondary", "[macro_eval]") { + TestFixture f; + f.gs.talentSpec = 1; + CHECK(f.run("[spec:1] Heal; DPS") == "DPS"); +} + +// ── Form / stance ─────────────────────────────────────────── + +TEST_CASE("[noform] true when no form aura", "[macro_eval]") { + TestFixture f; + f.gs.formAura = false; + CHECK(f.run("[noform] Cast; Shift") == "Cast"); +} + +TEST_CASE("[noform] false when in form", "[macro_eval]") { + TestFixture f; + f.gs.formAura = true; + CHECK(f.run("[noform] Cast; Shift") == "Shift"); +} + +// ── Vehicle ───────────────────────────────────────────────── + +TEST_CASE("[vehicle] true when in vehicle", "[macro_eval]") { + TestFixture f; + f.gs.vehicleId = 100; + CHECK(f.run("[vehicle] Vehicle Spell; Normal") == "Vehicle Spell"); +} + +TEST_CASE("[novehicle] true when not in vehicle", "[macro_eval]") { + TestFixture f; + f.gs.vehicleId = 0; + CHECK(f.run("[novehicle] Normal; Vehicle Spell") == "Normal"); +} + +// ── Casting / channeling ──────────────────────────────────── + +TEST_CASE("[casting] true when casting", "[macro_eval]") { + TestFixture f; + f.gs.casting = true; + CHECK(f.run("[casting] Interrupt; Cast") == "Interrupt"); +} + +TEST_CASE("[channeling] requires both casting and channeling", "[macro_eval]") { + TestFixture f; + f.gs.casting = true; + f.gs.channeling = true; + CHECK(f.run("[channeling] Stop; Continue") == "Stop"); +} + +TEST_CASE("[channeling] false when not channeling", "[macro_eval]") { + TestFixture f; + f.gs.casting = true; + f.gs.channeling = false; + CHECK(f.run("[channeling] Stop; Continue") == "Continue"); +} + +// ── Multiple alternatives with conditions ─────────────────── + +TEST_CASE("Three alternatives: first condition fails, second matches", "[macro_eval]") { + TestFixture f; + f.gs.inCombat = false; + f.gs.mounted = true; + CHECK(f.run("[combat] Attack; [mounted] Dismount; Idle") == "Dismount"); +} + +TEST_CASE("Three alternatives: all conditions fail, default used", "[macro_eval]") { + TestFixture f; + f.gs.inCombat = false; + f.gs.mounted = false; + CHECK(f.run("[combat] Attack; [mounted] Dismount; Idle") == "Idle"); +} + +TEST_CASE("No matching conditions and no default returns empty", "[macro_eval]") { + TestFixture f; + f.gs.inCombat = false; + CHECK(f.run("[combat] Attack") == ""); +} + +// ── Unknown conditions are permissive ─────────────────────── + +TEST_CASE("Unknown condition passes (permissive)", "[macro_eval]") { + TestFixture f; + CHECK(f.run("[unknowncond] Spell") == "Spell"); +} + +// ── targetOverride is -1 when no target specifier ─────────── + +TEST_CASE("No target specifier leaves override as -1", "[macro_eval]") { + TestFixture f; + auto [result, tgt] = f.runWithTarget("[combat] Spell"); + CHECK(tgt == static_cast(-1)); +} + +// ── Malformed input ────────────────────────────────────────── + +TEST_CASE("Missing closing bracket skips alternative", "[macro_eval]") { + TestFixture f; + // [combat without ] → skip, then "Fallback" matches + CHECK(f.run("[combat Spell; Fallback") == "Fallback"); +} + +TEST_CASE("Empty brackets match (empty condition = true)", "[macro_eval]") { + TestFixture f; + CHECK(f.run("[] Spell") == "Spell"); +} From ada019e0d4cb8b51ec40311847f923754c85f3b6 Mon Sep 17 00:00:00 2001 From: Pavel Okhlopkov Date: Sun, 12 Apr 2026 15:46:03 +0300 Subject: [PATCH 2/2] refactor(chat): extract ItemTooltipRenderer, slim render(), consolidate utils MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Extract renderItemTooltip() (510 LOC) from ChatMarkupRenderer into dedicated ItemTooltipRenderer class; chat_markup_renderer.cpp 766→192 LOC - Extract formatChatMessage(), detectChannelPrefix(), inputTextCallback() from render(); render() 711→376 LOC - Consolidate replaceGenderPlaceholders() from 3 copies into chat_utils::replaceGenderPlaceholders(); remove 118 LOC duplicate from quest_log_screen.cpp, update 8 call sites in window_manager.cpp - Delete chat_panel_commands.cpp (359 LOC) — absorb sendChatMessage, executeMacroText, PortBot helpers into chat_panel.cpp; move evaluateMacroConditionals to macro_eval_convenience.cpp - Delete chat_panel_utils.cpp (229 LOC) — absorb small utilities into chat_panel.cpp - Replace 3 forward declarations of evaluateMacroConditionals with #include "ui/chat/macro_evaluator.hpp" Signed-off-by: Pavel Okhlopkov --- CMakeLists.txt | 5 +- include/ui/chat/chat_markup_renderer.hpp | 9 - include/ui/chat/chat_utils.hpp | 19 +- include/ui/chat/item_tooltip_renderer.hpp | 28 + include/ui/chat/macro_evaluator.hpp | 7 + include/ui/chat_panel.hpp | 6 +- src/ui/chat/chat_markup_renderer.cpp | 520 +--------- .../chat_utils.cpp} | 130 +-- src/ui/chat/commands/combat_commands.cpp | 6 +- src/ui/chat/commands/system_commands.cpp | 6 +- src/ui/chat/commands/target_commands.cpp | 6 +- src/ui/chat/item_tooltip_renderer.cpp | 521 ++++++++++ src/ui/chat/macro_eval_convenience.cpp | 24 + src/ui/chat_panel.cpp | 978 ++++++++++++------ src/ui/chat_panel_commands.cpp | 359 ------- src/ui/quest_log_screen.cpp | 122 +-- src/ui/window_manager.cpp | 19 +- 17 files changed, 1302 insertions(+), 1463 deletions(-) create mode 100644 include/ui/chat/item_tooltip_renderer.hpp rename src/ui/{chat_panel_utils.cpp => chat/chat_utils.cpp} (54%) create mode 100644 src/ui/chat/item_tooltip_renderer.cpp create mode 100644 src/ui/chat/macro_eval_convenience.cpp delete mode 100644 src/ui/chat_panel_commands.cpp diff --git a/CMakeLists.txt b/CMakeLists.txt index 540d86a0..b392a992 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -685,17 +685,18 @@ set(WOWEE_SOURCES src/ui/game_screen_hud.cpp src/ui/game_screen_minimap.cpp src/ui/chat_panel.cpp - src/ui/chat_panel_commands.cpp - src/ui/chat_panel_utils.cpp src/ui/chat/chat_settings.cpp src/ui/chat/chat_input.cpp + src/ui/chat/chat_utils.cpp src/ui/chat/chat_tab_manager.cpp src/ui/chat/chat_bubble_manager.cpp src/ui/chat/chat_markup_parser.cpp src/ui/chat/chat_markup_renderer.cpp + src/ui/chat/item_tooltip_renderer.cpp src/ui/chat/chat_command_registry.cpp src/ui/chat/chat_tab_completer.cpp src/ui/chat/macro_evaluator.cpp + src/ui/chat/macro_eval_convenience.cpp src/ui/chat/game_state_adapter.cpp src/ui/chat/input_modifier_adapter.cpp src/ui/chat/commands/system_commands.cpp diff --git a/include/ui/chat/chat_markup_renderer.hpp b/include/ui/chat/chat_markup_renderer.hpp index 69f62da5..1702b543 100644 --- a/include/ui/chat/chat_markup_renderer.hpp +++ b/include/ui/chat/chat_markup_renderer.hpp @@ -47,15 +47,6 @@ public: void render(const std::vector& segments, const ImVec4& baseColor, const MarkupRenderContext& ctx) const; - - /** - * Render a full item tooltip for the given item entry. - * Extracted from the renderItemLinkTooltip inline lambda. - */ - static void renderItemTooltip(uint32_t itemEntry, - game::GameHandler& gameHandler, - InventoryScreen& inventoryScreen, - pipeline::AssetManager* assetMgr); }; } // namespace ui diff --git a/include/ui/chat/chat_utils.hpp b/include/ui/chat/chat_utils.hpp index 5e288961..3310ff8f 100644 --- a/include/ui/chat/chat_utils.hpp +++ b/include/ui/chat/chat_utils.hpp @@ -1,14 +1,18 @@ #pragma once #include "game/world_packets.hpp" +#include "game/entity.hpp" #include #include +#include #include namespace wowee { -// Forward declaration -namespace game { class GameHandler; } +// Forward declarations +namespace game { + class GameHandler; +} namespace ui { namespace chat_utils { @@ -38,6 +42,17 @@ inline std::string toLower(std::string s) { return s; } +/** + * Replace $g/$G gender, $n/$N name, $c/$C class, $r/$R race, + * $p/$o/$s/$S pronoun, $b/$B linebreak, and |n linebreak placeholders. + * Extracted from ChatPanel::replaceGenderPlaceholders (Phase 6.6). + */ +std::string replaceGenderPlaceholders(const std::string& text, + game::GameHandler& gameHandler); + +/** Get display name for any entity (Player/Unit/GameObject). */ +std::string getEntityDisplayName(const std::shared_ptr& entity); + } // namespace chat_utils } // namespace ui } // namespace wowee diff --git a/include/ui/chat/item_tooltip_renderer.hpp b/include/ui/chat/item_tooltip_renderer.hpp new file mode 100644 index 00000000..14486910 --- /dev/null +++ b/include/ui/chat/item_tooltip_renderer.hpp @@ -0,0 +1,28 @@ +#pragma once + +#include + +namespace wowee { +namespace game { class GameHandler; } +namespace pipeline { class AssetManager; } +namespace ui { + +class InventoryScreen; + +/** + * Renders a full WoW-style item tooltip via ImGui. + * + * Extracted from ChatMarkupRenderer::renderItemTooltip (Phase 6.7). + * Handles: item name/quality color, armor/DPS, stats, sockets, set bonuses, + * durability, sell price, required level, comparison tooltip. + */ +class ItemTooltipRenderer { +public: + static void render(uint32_t itemEntry, + game::GameHandler& gameHandler, + InventoryScreen& inventoryScreen, + pipeline::AssetManager* assetMgr); +}; + +} // namespace ui +} // namespace wowee diff --git a/include/ui/chat/macro_evaluator.hpp b/include/ui/chat/macro_evaluator.hpp index 00c07610..de493a95 100644 --- a/include/ui/chat/macro_evaluator.hpp +++ b/include/ui/chat/macro_evaluator.hpp @@ -7,6 +7,7 @@ #include namespace wowee { +namespace game { class GameHandler; } namespace ui { class IGameState; @@ -46,5 +47,11 @@ private: IModifierState& modState_; }; +// Convenience free function — thin wrapper over MacroEvaluator. +// Used by command modules (combat_commands, system_commands, target_commands). +std::string evaluateMacroConditionals(const std::string& rawArg, + game::GameHandler& gameHandler, + uint64_t& targetOverride); + } // namespace ui } // namespace wowee diff --git a/include/ui/chat_panel.hpp b/include/ui/chat_panel.hpp index 954def48..657f011b 100644 --- a/include/ui/chat_panel.hpp +++ b/include/ui/chat_panel.hpp @@ -125,9 +125,6 @@ public: // UIServices injection (Phase B singleton breaking) void setServices(const UIServices& services) { services_ = services; } - /** Replace $g/$G and $n/$N gender/name placeholders in quest/chat text. */ - std::string replaceGenderPlaceholders(const std::string& text, game::GameHandler& gameHandler); - // ---- Accessors for command system (Phase 3) ---- char* getChatInputBuffer() { return chatInputBuffer_; } size_t getChatInputBufferSize() const { return sizeof(chatInputBuffer_); } @@ -203,7 +200,8 @@ private: // ---- Helpers ---- void sendChatMessage(game::GameHandler& gameHandler); - // getChatTypeName / getChatTypeColor now static in ChatTabManager + static int inputTextCallback(ImGuiInputTextCallbackData* data); + void detectChannelPrefix(game::GameHandler& gameHandler); // Cached game handler for input callback (set each frame in render) game::GameHandler* cachedGameHandler_ = nullptr; diff --git a/src/ui/chat/chat_markup_renderer.cpp b/src/ui/chat/chat_markup_renderer.cpp index dc380c83..dfe25e37 100644 --- a/src/ui/chat/chat_markup_renderer.cpp +++ b/src/ui/chat/chat_markup_renderer.cpp @@ -1,531 +1,19 @@ // ChatMarkupRenderer — render parsed ChatSegments via ImGui. // Moved from ChatPanel::render() inline lambdas (Phase 2.2). +// Item tooltip rendering extracted to ItemTooltipRenderer (Phase 6.7). #include "ui/chat/chat_markup_renderer.hpp" +#include "ui/chat/item_tooltip_renderer.hpp" #include "ui/ui_colors.hpp" #include "ui/inventory_screen.hpp" #include "ui/spellbook_screen.hpp" #include "ui/quest_log_screen.hpp" #include "game/game_handler.hpp" #include "pipeline/asset_manager.hpp" -#include "pipeline/dbc_loader.hpp" -#include "pipeline/dbc_layout.hpp" #include #include -#include -#include namespace wowee { namespace ui { -// ---- renderItemTooltip (moved from renderItemLinkTooltip lambda) ---- - -void ChatMarkupRenderer::renderItemTooltip( - uint32_t itemEntry, - game::GameHandler& gameHandler, - InventoryScreen& inventoryScreen, - pipeline::AssetManager* assetMgr) -{ - const auto* info = gameHandler.getItemInfo(itemEntry); - if (!info || !info->valid) return; - - auto findComparableEquipped = [&](uint8_t inventoryType) -> const game::ItemSlot* { - using ES = game::EquipSlot; - const auto& inv = gameHandler.getInventory(); - auto slotPtr = [&](ES slot) -> const game::ItemSlot* { - const auto& s = inv.getEquipSlot(slot); - return s.empty() ? nullptr : &s; - }; - switch (inventoryType) { - case 1: return slotPtr(ES::HEAD); - case 2: return slotPtr(ES::NECK); - case 3: return slotPtr(ES::SHOULDERS); - case 4: return slotPtr(ES::SHIRT); - case 5: - case 20: return slotPtr(ES::CHEST); - case 6: return slotPtr(ES::WAIST); - case 7: return slotPtr(ES::LEGS); - case 8: return slotPtr(ES::FEET); - case 9: return slotPtr(ES::WRISTS); - case 10: return slotPtr(ES::HANDS); - case 11: { - if (auto* s = slotPtr(ES::RING1)) return s; - return slotPtr(ES::RING2); - } - case 12: { - if (auto* s = slotPtr(ES::TRINKET1)) return s; - return slotPtr(ES::TRINKET2); - } - case 13: - if (auto* s = slotPtr(ES::MAIN_HAND)) return s; - return slotPtr(ES::OFF_HAND); - case 14: - case 22: - case 23: return slotPtr(ES::OFF_HAND); - case 15: - case 25: - case 26: return slotPtr(ES::RANGED); - case 16: return slotPtr(ES::BACK); - case 17: - case 21: return slotPtr(ES::MAIN_HAND); - case 18: - for (int i = 0; i < game::Inventory::NUM_BAG_SLOTS; ++i) { - auto slot = static_cast(static_cast(ES::BAG1) + i); - if (auto* s = slotPtr(slot)) return s; - } - return nullptr; - case 19: return slotPtr(ES::TABARD); - default: return nullptr; - } - }; - - ImGui::BeginTooltip(); - // Quality color for name - auto qColor = ui::getQualityColor(static_cast(info->quality)); - ImGui::TextColored(qColor, "%s", info->name.c_str()); - - // Heroic indicator (green, matches WoW tooltip style) - constexpr uint32_t kFlagHeroic = 0x8; - constexpr uint32_t kFlagUniqueEquipped = 0x1000000; - if (info->itemFlags & kFlagHeroic) - ImGui::TextColored(ImVec4(0.0f, 0.8f, 0.0f, 1.0f), "Heroic"); - - // Bind type (appears right under name in WoW) - switch (info->bindType) { - case 1: ImGui::TextDisabled("Binds when picked up"); break; - case 2: ImGui::TextDisabled("Binds when equipped"); break; - case 3: ImGui::TextDisabled("Binds when used"); break; - case 4: ImGui::TextDisabled("Quest Item"); break; - } - // Unique / Unique-Equipped - if (info->maxCount == 1) - ImGui::TextColored(ui::colors::kTooltipGold, "Unique"); - else if (info->itemFlags & kFlagUniqueEquipped) - ImGui::TextColored(ui::colors::kTooltipGold, "Unique-Equipped"); - - // Slot type - if (info->inventoryType > 0) { - const char* slotName = ui::getInventorySlotName(info->inventoryType); - if (slotName[0]) { - if (!info->subclassName.empty()) - ImGui::TextColored(ui::colors::kLightGray, "%s %s", slotName, info->subclassName.c_str()); - else - ImGui::TextColored(ui::colors::kLightGray, "%s", slotName); - } - } - auto isWeaponInventoryType = [](uint32_t invType) { - switch (invType) { - case 13: // One-Hand - case 15: // Ranged - case 17: // Two-Hand - case 21: // Main Hand - case 25: // Thrown - case 26: // Ranged Right - return true; - default: - return false; - } - }; - const bool isWeapon = isWeaponInventoryType(info->inventoryType); - - // Item level (after slot/subclass) - if (info->itemLevel > 0) - ImGui::TextDisabled("Item Level %u", info->itemLevel); - - if (isWeapon && info->damageMax > 0.0f && info->delayMs > 0) { - float speed = static_cast(info->delayMs) / 1000.0f; - float dps = ((info->damageMin + info->damageMax) * 0.5f) / speed; - char dmgBuf[64], spdBuf[32]; - std::snprintf(dmgBuf, sizeof(dmgBuf), "%d - %d Damage", - static_cast(info->damageMin), static_cast(info->damageMax)); - std::snprintf(spdBuf, sizeof(spdBuf), "Speed %.2f", speed); - float spdW = ImGui::CalcTextSize(spdBuf).x; - ImGui::Text("%s", dmgBuf); - ImGui::SameLine(ImGui::GetWindowWidth() - spdW - 16.0f); - ImGui::Text("%s", spdBuf); - ImGui::TextDisabled("(%.1f damage per second)", dps); - } - ImVec4 green(0.0f, 1.0f, 0.0f, 1.0f); - auto appendBonus = [](std::string& out, int32_t val, const char* shortName) { - if (val <= 0) return; - if (!out.empty()) out += " "; - out += "+" + std::to_string(val) + " "; - out += shortName; - }; - std::string bonusLine; - appendBonus(bonusLine, info->strength, "Str"); - appendBonus(bonusLine, info->agility, "Agi"); - appendBonus(bonusLine, info->stamina, "Sta"); - appendBonus(bonusLine, info->intellect, "Int"); - appendBonus(bonusLine, info->spirit, "Spi"); - if (!bonusLine.empty()) { - ImGui::TextColored(green, "%s", bonusLine.c_str()); - } - if (info->armor > 0) { - ImGui::Text("%d Armor", info->armor); - } - // Elemental resistances - { - const int32_t resVals[6] = { - info->holyRes, info->fireRes, info->natureRes, - info->frostRes, info->shadowRes, info->arcaneRes - }; - static constexpr const char* resLabels[6] = { - "Holy Resistance", "Fire Resistance", "Nature Resistance", - "Frost Resistance", "Shadow Resistance", "Arcane Resistance" - }; - for (int ri = 0; ri < 6; ++ri) - if (resVals[ri] > 0) ImGui::Text("+%d %s", resVals[ri], resLabels[ri]); - } - // Extra stats (hit/crit/haste/sp/ap/expertise/resilience/etc.) - if (!info->extraStats.empty()) { - auto statName = [](uint32_t t) -> const char* { - switch (t) { - case 12: return "Defense Rating"; - case 13: return "Dodge Rating"; - case 14: return "Parry Rating"; - case 15: return "Block Rating"; - case 16: case 17: case 18: case 31: return "Hit Rating"; - case 19: case 20: case 21: case 32: return "Critical Strike Rating"; - case 28: case 29: case 30: case 35: return "Haste Rating"; - case 34: return "Resilience Rating"; - case 36: return "Expertise Rating"; - case 37: return "Attack Power"; - case 38: return "Ranged Attack Power"; - case 45: return "Spell Power"; - case 46: return "Healing Power"; - case 47: return "Spell Damage"; - case 49: return "Mana per 5 sec."; - case 43: return "Spell Penetration"; - case 44: return "Block Value"; - default: return nullptr; - } - }; - for (const auto& es : info->extraStats) { - const char* nm = statName(es.statType); - if (nm && es.statValue > 0) - ImGui::TextColored(green, "+%d %s", es.statValue, nm); - } - } - // Gem sockets - { - const auto& kSocketTypes = ui::kSocketTypes; - bool hasSocket = false; - for (int s = 0; s < 3; ++s) { - if (info->socketColor[s] == 0) continue; - if (!hasSocket) { ImGui::Spacing(); hasSocket = true; } - for (const auto& st : kSocketTypes) { - if (info->socketColor[s] & st.mask) { - ImGui::TextColored(st.col, "%s", st.label); - break; - } - } - } - if (hasSocket && info->socketBonus != 0) { - static std::unordered_map s_enchantNames; - static bool s_enchantNamesLoaded = false; - if (!s_enchantNamesLoaded && assetMgr) { - s_enchantNamesLoaded = true; - auto dbc = assetMgr->loadDBC("SpellItemEnchantment.dbc"); - if (dbc && dbc->isLoaded()) { - const auto* lay = pipeline::getActiveDBCLayout() - ? pipeline::getActiveDBCLayout()->getLayout("SpellItemEnchantment") : nullptr; - uint32_t nameField = lay ? lay->field("Name") : 8u; - if (nameField == 0xFFFFFFFF) nameField = 8; - uint32_t fc = dbc->getFieldCount(); - for (uint32_t r = 0; r < dbc->getRecordCount(); ++r) { - uint32_t eid = dbc->getUInt32(r, 0); - if (eid == 0 || nameField >= fc) continue; - std::string ename = dbc->getString(r, nameField); - if (!ename.empty()) s_enchantNames[eid] = std::move(ename); - } - } - } - auto enchIt = s_enchantNames.find(info->socketBonus); - if (enchIt != s_enchantNames.end()) - ImGui::TextColored(colors::kSocketGreen, "Socket Bonus: %s", enchIt->second.c_str()); - else - ImGui::TextColored(colors::kSocketGreen, "Socket Bonus: (id %u)", info->socketBonus); - } - } - // Item set membership - if (info->itemSetId != 0) { - struct SetEntry { - std::string name; - std::array itemIds{}; - std::array spellIds{}; - std::array thresholds{}; - }; - static std::unordered_map s_setData; - static bool s_setDataLoaded = false; - if (!s_setDataLoaded && assetMgr) { - s_setDataLoaded = true; - auto dbc = assetMgr->loadDBC("ItemSet.dbc"); - if (dbc && dbc->isLoaded()) { - const auto* layout = pipeline::getActiveDBCLayout() - ? pipeline::getActiveDBCLayout()->getLayout("ItemSet") : nullptr; - auto lf = [&](const char* k, uint32_t def) -> uint32_t { - return layout ? (*layout)[k] : def; - }; - uint32_t idF = lf("ID", 0), nameF = lf("Name", 1); - const auto& itemKeys = ui::kItemSetItemKeys; - const auto& spellKeys = ui::kItemSetSpellKeys; - const auto& thrKeys = ui::kItemSetThresholdKeys; - for (uint32_t r = 0; r < dbc->getRecordCount(); ++r) { - uint32_t id = dbc->getUInt32(r, idF); - if (!id) continue; - SetEntry e; - e.name = dbc->getString(r, nameF); - for (int i = 0; i < 10; ++i) { - e.itemIds[i] = dbc->getUInt32(r, layout ? (*layout)[itemKeys[i]] : uint32_t(18 + i)); - e.spellIds[i] = dbc->getUInt32(r, layout ? (*layout)[spellKeys[i]] : uint32_t(28 + i)); - e.thresholds[i] = dbc->getUInt32(r, layout ? (*layout)[thrKeys[i]] : uint32_t(38 + i)); - } - s_setData[id] = std::move(e); - } - } - } - ImGui::Spacing(); - const auto& inv = gameHandler.getInventory(); - auto setIt = s_setData.find(info->itemSetId); - if (setIt != s_setData.end()) { - const SetEntry& se = setIt->second; - int equipped = 0, total = 0; - for (int i = 0; i < 10; ++i) { - if (se.itemIds[i] == 0) continue; - ++total; - for (int sl = 0; sl < game::Inventory::NUM_EQUIP_SLOTS; sl++) { - const auto& eq = inv.getEquipSlot(static_cast(sl)); - if (!eq.empty() && eq.item.itemId == se.itemIds[i]) { ++equipped; break; } - } - } - if (total > 0) - ImGui::TextColored(ui::colors::kTooltipGold, - "%s (%d/%d)", se.name.empty() ? "Set" : se.name.c_str(), equipped, total); - else if (!se.name.empty()) - ImGui::TextColored(ui::colors::kTooltipGold, "%s", se.name.c_str()); - for (int i = 0; i < 10; ++i) { - if (se.spellIds[i] == 0 || se.thresholds[i] == 0) continue; - const std::string& bname = gameHandler.getSpellName(se.spellIds[i]); - bool active = (equipped >= static_cast(se.thresholds[i])); - ImVec4 col = active ? colors::kActiveGreen : colors::kInactiveGray; - if (!bname.empty()) - ImGui::TextColored(col, "(%u) %s", se.thresholds[i], bname.c_str()); - else - ImGui::TextColored(col, "(%u) Set Bonus", se.thresholds[i]); - } - } else { - ImGui::TextColored(ui::colors::kTooltipGold, "Set (id %u)", info->itemSetId); - } - } - // Item spell effects (Use / Equip / Chance on Hit / Teaches) - for (const auto& sp : info->spells) { - if (sp.spellId == 0) continue; - const char* triggerLabel = nullptr; - switch (sp.spellTrigger) { - case 0: triggerLabel = "Use"; break; - case 1: triggerLabel = "Equip"; break; - case 2: triggerLabel = "Chance on Hit"; break; - case 5: triggerLabel = "Teaches"; break; - } - if (!triggerLabel) continue; - const std::string& spDesc = gameHandler.getSpellDescription(sp.spellId); - const std::string& spText = !spDesc.empty() ? spDesc - : gameHandler.getSpellName(sp.spellId); - if (!spText.empty()) { - ImGui::PushTextWrapPos(ImGui::GetCursorPosX() + 300.0f); - ImGui::TextColored(colors::kCyan, - "%s: %s", triggerLabel, spText.c_str()); - ImGui::PopTextWrapPos(); - } - } - // Required level - if (info->requiredLevel > 1) - ImGui::TextDisabled("Requires Level %u", info->requiredLevel); - // Required skill - if (info->requiredSkill != 0 && info->requiredSkillRank > 0) { - static std::unordered_map s_skillNames; - static bool s_skillNamesLoaded = false; - if (!s_skillNamesLoaded && assetMgr) { - s_skillNamesLoaded = true; - auto dbc = assetMgr->loadDBC("SkillLine.dbc"); - if (dbc && dbc->isLoaded()) { - const auto* layout = pipeline::getActiveDBCLayout() - ? pipeline::getActiveDBCLayout()->getLayout("SkillLine") : nullptr; - uint32_t idF = layout ? (*layout)["ID"] : 0u; - uint32_t nameF = layout ? (*layout)["Name"] : 2u; - for (uint32_t r = 0; r < dbc->getRecordCount(); ++r) { - uint32_t sid = dbc->getUInt32(r, idF); - if (!sid) continue; - std::string sname = dbc->getString(r, nameF); - if (!sname.empty()) s_skillNames[sid] = std::move(sname); - } - } - } - uint32_t playerSkillVal = 0; - const auto& skills = gameHandler.getPlayerSkills(); - auto skPit = skills.find(info->requiredSkill); - if (skPit != skills.end()) playerSkillVal = skPit->second.effectiveValue(); - bool meetsSkill = (playerSkillVal == 0 || playerSkillVal >= info->requiredSkillRank); - ImVec4 skColor = meetsSkill ? ImVec4(1.0f, 1.0f, 1.0f, 0.75f) : colors::kPaleRed; - auto skIt = s_skillNames.find(info->requiredSkill); - if (skIt != s_skillNames.end()) - ImGui::TextColored(skColor, "Requires %s (%u)", skIt->second.c_str(), info->requiredSkillRank); - else - ImGui::TextColored(skColor, "Requires Skill %u (%u)", info->requiredSkill, info->requiredSkillRank); - } - // Required reputation - if (info->requiredReputationFaction != 0 && info->requiredReputationRank > 0) { - static std::unordered_map s_factionNames; - static bool s_factionNamesLoaded = false; - if (!s_factionNamesLoaded && assetMgr) { - s_factionNamesLoaded = true; - auto dbc = assetMgr->loadDBC("Faction.dbc"); - if (dbc && dbc->isLoaded()) { - const auto* layout = pipeline::getActiveDBCLayout() - ? pipeline::getActiveDBCLayout()->getLayout("Faction") : nullptr; - uint32_t idF = layout ? (*layout)["ID"] : 0u; - uint32_t nameF = layout ? (*layout)["Name"] : 20u; - for (uint32_t r = 0; r < dbc->getRecordCount(); ++r) { - uint32_t fid = dbc->getUInt32(r, idF); - if (!fid) continue; - std::string fname = dbc->getString(r, nameF); - if (!fname.empty()) s_factionNames[fid] = std::move(fname); - } - } - } - static constexpr const char* kRepRankNames[] = { - "Hated", "Hostile", "Unfriendly", "Neutral", - "Friendly", "Honored", "Revered", "Exalted" - }; - const char* rankName = (info->requiredReputationRank < 8) - ? kRepRankNames[info->requiredReputationRank] : "Unknown"; - auto fIt = s_factionNames.find(info->requiredReputationFaction); - ImGui::TextColored(ImVec4(1.0f, 1.0f, 1.0f, 0.75f), "Requires %s with %s", - rankName, - fIt != s_factionNames.end() ? fIt->second.c_str() : "Unknown Faction"); - } - // Class restriction - if (info->allowableClass != 0) { - const auto& kClasses = ui::kClassMasks; - int matchCount = 0; - for (const auto& kc : kClasses) - if (info->allowableClass & kc.mask) ++matchCount; - if (matchCount > 0 && matchCount < 10) { - char classBuf[128] = "Classes: "; - bool first = true; - for (const auto& kc : kClasses) { - if (!(info->allowableClass & kc.mask)) continue; - if (!first) strncat(classBuf, ", ", sizeof(classBuf) - strlen(classBuf) - 1); - strncat(classBuf, kc.name, sizeof(classBuf) - strlen(classBuf) - 1); - first = false; - } - uint8_t pc = gameHandler.getPlayerClass(); - uint32_t pmask = (pc > 0 && pc <= 10) ? (1u << (pc - 1)) : 0u; - bool playerAllowed = (pmask == 0 || (info->allowableClass & pmask)); - ImVec4 clColor = playerAllowed ? ImVec4(1.0f, 1.0f, 1.0f, 0.75f) : colors::kPaleRed; - ImGui::TextColored(clColor, "%s", classBuf); - } - } - // Race restriction - if (info->allowableRace != 0) { - const auto& kRaces = ui::kRaceMasks; - constexpr uint32_t kAllPlayable = 1|2|4|8|16|32|64|128|512|1024; - if ((info->allowableRace & kAllPlayable) != kAllPlayable) { - int matchCount = 0; - for (const auto& kr : kRaces) - if (info->allowableRace & kr.mask) ++matchCount; - if (matchCount > 0) { - char raceBuf[160] = "Races: "; - bool first = true; - for (const auto& kr : kRaces) { - if (!(info->allowableRace & kr.mask)) continue; - if (!first) strncat(raceBuf, ", ", sizeof(raceBuf) - strlen(raceBuf) - 1); - strncat(raceBuf, kr.name, sizeof(raceBuf) - strlen(raceBuf) - 1); - first = false; - } - uint8_t pr = gameHandler.getPlayerRace(); - uint32_t pmask = (pr > 0 && pr <= 11) ? (1u << (pr - 1)) : 0u; - bool playerAllowed = (pmask == 0 || (info->allowableRace & pmask)); - ImVec4 rColor = playerAllowed ? ImVec4(1.0f, 1.0f, 1.0f, 0.75f) : colors::kPaleRed; - ImGui::TextColored(rColor, "%s", raceBuf); - } - } - } - // Flavor text - if (!info->description.empty()) { - ImGui::Spacing(); - ImGui::PushTextWrapPos(300.0f); - ImGui::TextColored(ImVec4(1.0f, 0.82f, 0.0f, 0.85f), "\"%s\"", info->description.c_str()); - ImGui::PopTextWrapPos(); - } - if (info->sellPrice > 0) { - ImGui::TextDisabled("Sell:"); ImGui::SameLine(0, 4); - renderCoinsFromCopper(info->sellPrice); - } - - if (ImGui::GetIO().KeyShift && info->inventoryType > 0) { - if (const auto* eq = findComparableEquipped(static_cast(info->inventoryType))) { - ImGui::Separator(); - ImGui::TextDisabled("Equipped:"); - VkDescriptorSet eqIcon = inventoryScreen.getItemIcon(eq->item.displayInfoId); - if (eqIcon) { - ImGui::Image((ImTextureID)(uintptr_t)eqIcon, ImVec2(18.0f, 18.0f)); - ImGui::SameLine(); - } - ImGui::TextColored(InventoryScreen::getQualityColor(eq->item.quality), "%s", eq->item.name.c_str()); - if (isWeaponInventoryType(eq->item.inventoryType) && - eq->item.damageMax > 0.0f && eq->item.delayMs > 0) { - float speed = static_cast(eq->item.delayMs) / 1000.0f; - float dps = ((eq->item.damageMin + eq->item.damageMax) * 0.5f) / speed; - char eqDmg[64], eqSpd[32]; - std::snprintf(eqDmg, sizeof(eqDmg), "%d - %d Damage", - static_cast(eq->item.damageMin), static_cast(eq->item.damageMax)); - std::snprintf(eqSpd, sizeof(eqSpd), "Speed %.2f", speed); - float eqSpdW = ImGui::CalcTextSize(eqSpd).x; - ImGui::Text("%s", eqDmg); - ImGui::SameLine(ImGui::GetWindowWidth() - eqSpdW - 16.0f); - ImGui::Text("%s", eqSpd); - ImGui::TextDisabled("(%.1f damage per second)", dps); - } - if (eq->item.armor > 0) { - ImGui::Text("%d Armor", eq->item.armor); - } - std::string eqBonusLine; - appendBonus(eqBonusLine, eq->item.strength, "Str"); - appendBonus(eqBonusLine, eq->item.agility, "Agi"); - appendBonus(eqBonusLine, eq->item.stamina, "Sta"); - appendBonus(eqBonusLine, eq->item.intellect, "Int"); - appendBonus(eqBonusLine, eq->item.spirit, "Spi"); - if (!eqBonusLine.empty()) { - ImGui::TextColored(green, "%s", eqBonusLine.c_str()); - } - // Extra stats for the equipped item - for (const auto& es : eq->item.extraStats) { - const char* nm = nullptr; - switch (es.statType) { - case 12: nm = "Defense Rating"; break; - case 13: nm = "Dodge Rating"; break; - case 14: nm = "Parry Rating"; break; - case 16: case 17: case 18: case 31: nm = "Hit Rating"; break; - case 19: case 20: case 21: case 32: nm = "Critical Strike Rating"; break; - case 28: case 29: case 30: case 35: nm = "Haste Rating"; break; - case 34: nm = "Resilience Rating"; break; - case 36: nm = "Expertise Rating"; break; - case 37: nm = "Attack Power"; break; - case 38: nm = "Ranged Attack Power"; break; - case 45: nm = "Spell Power"; break; - case 46: nm = "Healing Power"; break; - case 49: nm = "Mana per 5 sec."; break; - default: break; - } - if (nm && es.statValue > 0) - ImGui::TextColored(green, "+%d %s", es.statValue, nm); - } - } - } - ImGui::EndTooltip(); -} - - // ---- Main segment renderer ---- void ChatMarkupRenderer::render( @@ -570,7 +58,7 @@ void ChatMarkupRenderer::render( ImGui::Image((ImTextureID)(uintptr_t)chatIcon, ImVec2(12, 12)); if (ImGui::IsItemHovered()) { ImGui::SetMouseCursor(ImGuiMouseCursor_Hand); - renderItemTooltip(itemEntry, *ctx.gameHandler, *ctx.inventory, ctx.assetMgr); + ItemTooltipRenderer::render(itemEntry, *ctx.gameHandler, *ctx.inventory, ctx.assetMgr); } ImGui::SameLine(0, 2); } @@ -583,7 +71,7 @@ void ChatMarkupRenderer::render( if (ImGui::IsItemHovered()) { ImGui::SetMouseCursor(ImGuiMouseCursor_Hand); if (itemEntry > 0 && ctx.gameHandler && ctx.inventory) { - renderItemTooltip(itemEntry, *ctx.gameHandler, *ctx.inventory, ctx.assetMgr); + ItemTooltipRenderer::render(itemEntry, *ctx.gameHandler, *ctx.inventory, ctx.assetMgr); } } // Shift-click: insert entire link back into chat input diff --git a/src/ui/chat_panel_utils.cpp b/src/ui/chat/chat_utils.cpp similarity index 54% rename from src/ui/chat_panel_utils.cpp rename to src/ui/chat/chat_utils.cpp index bef47243..5681d8ff 100644 --- a/src/ui/chat_panel_utils.cpp +++ b/src/ui/chat/chat_utils.cpp @@ -1,40 +1,15 @@ -#include "ui/chat_panel.hpp" -#include "ui/ui_colors.hpp" -#include "rendering/vk_context.hpp" -#include "core/application.hpp" -#include "rendering/renderer.hpp" -#include "rendering/camera.hpp" -#include "rendering/camera_controller.hpp" -#include "audio/audio_coordinator.hpp" -#include "audio/ui_sound_manager.hpp" -#include "pipeline/asset_manager.hpp" -#include "pipeline/dbc_loader.hpp" -#include "pipeline/dbc_layout.hpp" -#include "game/expansion_profile.hpp" +// chat_utils.cpp — Shared chat utility functions. +// Extracted from chat_panel_utils.cpp (Phase 6.6 of chat_panel_ref.md). + +#include "ui/chat/chat_utils.hpp" +#include "game/game_handler.hpp" #include "game/character.hpp" -#include "core/logger.hpp" -#include -#include -#include "core/coordinates.hpp" -#include -#include -#include -#include -#include +#include -namespace { - using namespace wowee::ui::colors; - constexpr auto& kColorRed = kRed; - constexpr auto& kColorBrightGreen= kBrightGreen; - constexpr auto& kColorYellow = kYellow; -} // namespace +namespace wowee { namespace ui { namespace chat_utils { -namespace wowee { namespace ui { - -// getChatTypeName / getChatTypeColor moved to ChatTabManager (Phase 1.3) - - -std::string ChatPanel::replaceGenderPlaceholders(const std::string& text, game::GameHandler& gameHandler) { +std::string replaceGenderPlaceholders(const std::string& text, + game::GameHandler& gameHandler) { // Get player gender, pronouns, and name game::Gender gender = game::Gender::NONBINARY; std::string playerName = "Adventurer"; @@ -50,7 +25,7 @@ std::string ChatPanel::replaceGenderPlaceholders(const std::string& text, game:: std::string result = text; // Helper to trim whitespace - auto trim = [](std::string& s) { + auto trimStr = [](std::string& s) { const char* ws = " \t\n\r"; size_t start = s.find_first_not_of(ws); if (start == std::string::npos) { s.clear(); return; } @@ -76,46 +51,32 @@ std::string ChatPanel::replaceGenderPlaceholders(const std::string& text, game:: size_t colonPos; while ((colonPos = placeholder.find(':', start)) != std::string::npos) { std::string part = placeholder.substr(start, colonPos - start); - trim(part); + trimStr(part); parts.push_back(part); start = colonPos + 1; } // Add the last part std::string lastPart = placeholder.substr(start); - trim(lastPart); + trimStr(lastPart); parts.push_back(lastPart); // Select appropriate text based on gender std::string replacement; if (parts.size() >= 3) { - // Three options: male, female, nonbinary switch (gender) { - case game::Gender::MALE: - replacement = parts[0]; - break; - case game::Gender::FEMALE: - replacement = parts[1]; - break; - case game::Gender::NONBINARY: - replacement = parts[2]; - break; + case game::Gender::MALE: replacement = parts[0]; break; + case game::Gender::FEMALE: replacement = parts[1]; break; + case game::Gender::NONBINARY: replacement = parts[2]; break; } } else if (parts.size() >= 2) { - // Two options: male, female (use first for nonbinary) switch (gender) { - case game::Gender::MALE: - replacement = parts[0]; - break; - case game::Gender::FEMALE: - replacement = parts[1]; - break; + case game::Gender::MALE: replacement = parts[0]; break; + case game::Gender::FEMALE: replacement = parts[1]; break; case game::Gender::NONBINARY: - // Default to gender-neutral: use the shorter/simpler option replacement = parts[0].length() <= parts[1].length() ? parts[0] : parts[1]; break; } } else { - // Malformed placeholder pos = endPos + 1; continue; } @@ -177,53 +138,20 @@ std::string ChatPanel::replaceGenderPlaceholders(const std::string& text, game:: return result; } -// renderBubbles delegates to ChatBubbleManager (Phase 1.4) -void ChatPanel::renderBubbles(game::GameHandler& gameHandler) { - bubbleManager_.render(gameHandler, services_); -} - - -// ---- Public interface methods ---- - -// setupCallbacks delegates to ChatBubbleManager (Phase 1.4) -void ChatPanel::setupCallbacks(game::GameHandler& gameHandler) { - bubbleManager_.setupCallback(gameHandler); -} - -void ChatPanel::insertChatLink(const std::string& link) { - if (link.empty()) return; - size_t curLen = strlen(chatInputBuffer_); - if (curLen + link.size() + 1 < sizeof(chatInputBuffer_)) { - strncat(chatInputBuffer_, link.c_str(), sizeof(chatInputBuffer_) - curLen - 1); - chatInputMoveCursorToEnd_ = true; - refocusChatInput_ = true; +std::string getEntityDisplayName(const std::shared_ptr& entity) { + if (entity->getType() == game::ObjectType::PLAYER) { + auto player = std::static_pointer_cast(entity); + if (!player->getName().empty()) return player->getName(); + } else if (entity->getType() == game::ObjectType::UNIT) { + auto unit = std::static_pointer_cast(entity); + if (!unit->getName().empty()) return unit->getName(); + } else if (entity->getType() == game::ObjectType::GAMEOBJECT) { + auto go = std::static_pointer_cast(entity); + if (!go->getName().empty()) return go->getName(); } + return "Unknown"; } -void ChatPanel::activateSlashInput() { - refocusChatInput_ = true; - chatInputBuffer_[0] = '/'; - chatInputBuffer_[1] = '\0'; - chatInputMoveCursorToEnd_ = true; -} - -void ChatPanel::activateInput() { - if (chatInputCooldown_ > 0) return; // suppress re-activation right after send - refocusChatInput_ = true; -} - -void ChatPanel::setWhisperTarget(const std::string& name) { - selectedChatType_ = 4; // WHISPER - strncpy(whisperTargetBuffer_, name.c_str(), sizeof(whisperTargetBuffer_) - 1); - whisperTargetBuffer_[sizeof(whisperTargetBuffer_) - 1] = '\0'; - refocusChatInput_ = true; -} - -ChatPanel::SlashCommands ChatPanel::consumeSlashCommands() { - SlashCommands result = slashCmds_; - slashCmds_ = {}; - return result; -} - +} // namespace chat_utils } // namespace ui } // namespace wowee diff --git a/src/ui/chat/commands/combat_commands.cpp b/src/ui/chat/commands/combat_commands.cpp index 01060e7b..c824c458 100644 --- a/src/ui/chat/commands/combat_commands.cpp +++ b/src/ui/chat/commands/combat_commands.cpp @@ -2,6 +2,7 @@ // /startattack, /stopattack, /stopcasting, /cancelqueuedspell // Moved from ChatPanel::sendChatMessage() if/else chain (Phase 3). #include "ui/chat/i_chat_command.hpp" +#include "ui/chat/macro_evaluator.hpp" #include "ui/chat_panel.hpp" #include "game/game_handler.hpp" #include "game/inventory.hpp" @@ -12,11 +13,6 @@ namespace wowee { namespace ui { -// Forward declaration of evaluateMacroConditionals (still in chat_panel_commands.cpp) -std::string evaluateMacroConditionals(const std::string& rawArg, - game::GameHandler& gameHandler, - uint64_t& targetOverride); - // --------------- helpers (local to this TU) --------------- namespace { diff --git a/src/ui/chat/commands/system_commands.cpp b/src/ui/chat/commands/system_commands.cpp index 1051c20c..cf9f73bd 100644 --- a/src/ui/chat/commands/system_commands.cpp +++ b/src/ui/chat/commands/system_commands.cpp @@ -1,6 +1,7 @@ // System commands: /run, /dump, /reload, /stopmacro, /clear, /logout // Moved from ChatPanel::sendChatMessage() if/else chain (Phase 3). #include "ui/chat/i_chat_command.hpp" +#include "ui/chat/macro_evaluator.hpp" #include "ui/chat_panel.hpp" #include "ui/ui_services.hpp" #include "game/game_handler.hpp" @@ -11,11 +12,6 @@ namespace wowee { namespace ui { -// Forward declaration of evaluateMacroConditionals (still in chat_panel_commands.cpp) -std::string evaluateMacroConditionals(const std::string& rawArg, - game::GameHandler& gameHandler, - uint64_t& targetOverride); - // --- /run, /script --- class RunCommand : public IChatCommand { public: diff --git a/src/ui/chat/commands/target_commands.cpp b/src/ui/chat/commands/target_commands.cpp index 23cdb88f..bc1cec1a 100644 --- a/src/ui/chat/commands/target_commands.cpp +++ b/src/ui/chat/commands/target_commands.cpp @@ -3,6 +3,7 @@ // /focus, /clearfocus // Moved from ChatPanel::sendChatMessage() if/else chain (Phase 3). #include "ui/chat/i_chat_command.hpp" +#include "ui/chat/macro_evaluator.hpp" #include "ui/chat_panel.hpp" #include "game/game_handler.hpp" #include "game/entity.hpp" @@ -13,11 +14,6 @@ namespace wowee { namespace ui { -// Forward declaration of evaluateMacroConditionals (still in chat_panel_commands.cpp) -std::string evaluateMacroConditionals(const std::string& rawArg, - game::GameHandler& gameHandler, - uint64_t& targetOverride); - namespace { // Trim leading/trailing whitespace. diff --git a/src/ui/chat/item_tooltip_renderer.cpp b/src/ui/chat/item_tooltip_renderer.cpp new file mode 100644 index 00000000..360ee43b --- /dev/null +++ b/src/ui/chat/item_tooltip_renderer.cpp @@ -0,0 +1,521 @@ +// ItemTooltipRenderer — renders full WoW-style item tooltips via ImGui. +// Extracted from ChatMarkupRenderer::renderItemTooltip (Phase 6.7). +#include "ui/chat/item_tooltip_renderer.hpp" +#include "ui/ui_colors.hpp" +#include "ui/inventory_screen.hpp" +#include "game/game_handler.hpp" +#include "pipeline/asset_manager.hpp" +#include "pipeline/dbc_loader.hpp" +#include "pipeline/dbc_layout.hpp" +#include +#include +#include +#include + +namespace wowee { namespace ui { + +void ItemTooltipRenderer::render( + uint32_t itemEntry, + game::GameHandler& gameHandler, + InventoryScreen& inventoryScreen, + pipeline::AssetManager* assetMgr) +{ + const auto* info = gameHandler.getItemInfo(itemEntry); + if (!info || !info->valid) return; + + auto findComparableEquipped = [&](uint8_t inventoryType) -> const game::ItemSlot* { + using ES = game::EquipSlot; + const auto& inv = gameHandler.getInventory(); + auto slotPtr = [&](ES slot) -> const game::ItemSlot* { + const auto& s = inv.getEquipSlot(slot); + return s.empty() ? nullptr : &s; + }; + switch (inventoryType) { + case 1: return slotPtr(ES::HEAD); + case 2: return slotPtr(ES::NECK); + case 3: return slotPtr(ES::SHOULDERS); + case 4: return slotPtr(ES::SHIRT); + case 5: + case 20: return slotPtr(ES::CHEST); + case 6: return slotPtr(ES::WAIST); + case 7: return slotPtr(ES::LEGS); + case 8: return slotPtr(ES::FEET); + case 9: return slotPtr(ES::WRISTS); + case 10: return slotPtr(ES::HANDS); + case 11: { + if (auto* s = slotPtr(ES::RING1)) return s; + return slotPtr(ES::RING2); + } + case 12: { + if (auto* s = slotPtr(ES::TRINKET1)) return s; + return slotPtr(ES::TRINKET2); + } + case 13: + if (auto* s = slotPtr(ES::MAIN_HAND)) return s; + return slotPtr(ES::OFF_HAND); + case 14: + case 22: + case 23: return slotPtr(ES::OFF_HAND); + case 15: + case 25: + case 26: return slotPtr(ES::RANGED); + case 16: return slotPtr(ES::BACK); + case 17: + case 21: return slotPtr(ES::MAIN_HAND); + case 18: + for (int i = 0; i < game::Inventory::NUM_BAG_SLOTS; ++i) { + auto slot = static_cast(static_cast(ES::BAG1) + i); + if (auto* s = slotPtr(slot)) return s; + } + return nullptr; + case 19: return slotPtr(ES::TABARD); + default: return nullptr; + } + }; + + auto isWeaponInventoryType = [](uint32_t invType) { + switch (invType) { + case 13: case 15: case 17: case 21: case 25: case 26: return true; + default: return false; + } + }; + + auto appendBonus = [](std::string& out, int32_t val, const char* shortName) { + if (val <= 0) return; + if (!out.empty()) out += " "; + out += "+" + std::to_string(val) + " "; + out += shortName; + }; + + ImGui::BeginTooltip(); + // Quality color for name + auto qColor = ui::getQualityColor(static_cast(info->quality)); + ImGui::TextColored(qColor, "%s", info->name.c_str()); + + // Heroic indicator (green, matches WoW tooltip style) + constexpr uint32_t kFlagHeroic = 0x8; + constexpr uint32_t kFlagUniqueEquipped = 0x1000000; + if (info->itemFlags & kFlagHeroic) + ImGui::TextColored(ImVec4(0.0f, 0.8f, 0.0f, 1.0f), "Heroic"); + + // Bind type (appears right under name in WoW) + switch (info->bindType) { + case 1: ImGui::TextDisabled("Binds when picked up"); break; + case 2: ImGui::TextDisabled("Binds when equipped"); break; + case 3: ImGui::TextDisabled("Binds when used"); break; + case 4: ImGui::TextDisabled("Quest Item"); break; + } + // Unique / Unique-Equipped + if (info->maxCount == 1) + ImGui::TextColored(ui::colors::kTooltipGold, "Unique"); + else if (info->itemFlags & kFlagUniqueEquipped) + ImGui::TextColored(ui::colors::kTooltipGold, "Unique-Equipped"); + + // Slot type + if (info->inventoryType > 0) { + const char* slotName = ui::getInventorySlotName(info->inventoryType); + if (slotName[0]) { + if (!info->subclassName.empty()) + ImGui::TextColored(ui::colors::kLightGray, "%s %s", slotName, info->subclassName.c_str()); + else + ImGui::TextColored(ui::colors::kLightGray, "%s", slotName); + } + } + + const bool isWeapon = isWeaponInventoryType(info->inventoryType); + + // Item level (after slot/subclass) + if (info->itemLevel > 0) + ImGui::TextDisabled("Item Level %u", info->itemLevel); + + if (isWeapon && info->damageMax > 0.0f && info->delayMs > 0) { + float speed = static_cast(info->delayMs) / 1000.0f; + float dps = ((info->damageMin + info->damageMax) * 0.5f) / speed; + char dmgBuf[64], spdBuf[32]; + std::snprintf(dmgBuf, sizeof(dmgBuf), "%d - %d Damage", + static_cast(info->damageMin), static_cast(info->damageMax)); + std::snprintf(spdBuf, sizeof(spdBuf), "Speed %.2f", speed); + float spdW = ImGui::CalcTextSize(spdBuf).x; + ImGui::Text("%s", dmgBuf); + ImGui::SameLine(ImGui::GetWindowWidth() - spdW - 16.0f); + ImGui::Text("%s", spdBuf); + ImGui::TextDisabled("(%.1f damage per second)", dps); + } + ImVec4 green(0.0f, 1.0f, 0.0f, 1.0f); + std::string bonusLine; + appendBonus(bonusLine, info->strength, "Str"); + appendBonus(bonusLine, info->agility, "Agi"); + appendBonus(bonusLine, info->stamina, "Sta"); + appendBonus(bonusLine, info->intellect, "Int"); + appendBonus(bonusLine, info->spirit, "Spi"); + if (!bonusLine.empty()) { + ImGui::TextColored(green, "%s", bonusLine.c_str()); + } + if (info->armor > 0) { + ImGui::Text("%d Armor", info->armor); + } + // Elemental resistances + { + const int32_t resVals[6] = { + info->holyRes, info->fireRes, info->natureRes, + info->frostRes, info->shadowRes, info->arcaneRes + }; + static constexpr const char* resLabels[6] = { + "Holy Resistance", "Fire Resistance", "Nature Resistance", + "Frost Resistance", "Shadow Resistance", "Arcane Resistance" + }; + for (int ri = 0; ri < 6; ++ri) + if (resVals[ri] > 0) ImGui::Text("+%d %s", resVals[ri], resLabels[ri]); + } + // Extra stats (hit/crit/haste/sp/ap/expertise/resilience/etc.) + if (!info->extraStats.empty()) { + auto statName = [](uint32_t t) -> const char* { + switch (t) { + case 12: return "Defense Rating"; + case 13: return "Dodge Rating"; + case 14: return "Parry Rating"; + case 15: return "Block Rating"; + case 16: case 17: case 18: case 31: return "Hit Rating"; + case 19: case 20: case 21: case 32: return "Critical Strike Rating"; + case 28: case 29: case 30: case 35: return "Haste Rating"; + case 34: return "Resilience Rating"; + case 36: return "Expertise Rating"; + case 37: return "Attack Power"; + case 38: return "Ranged Attack Power"; + case 45: return "Spell Power"; + case 46: return "Healing Power"; + case 47: return "Spell Damage"; + case 49: return "Mana per 5 sec."; + case 43: return "Spell Penetration"; + case 44: return "Block Value"; + default: return nullptr; + } + }; + for (const auto& es : info->extraStats) { + const char* nm = statName(es.statType); + if (nm && es.statValue > 0) + ImGui::TextColored(green, "+%d %s", es.statValue, nm); + } + } + // Gem sockets + { + const auto& kSocketTypes = ui::kSocketTypes; + bool hasSocket = false; + for (int s = 0; s < 3; ++s) { + if (info->socketColor[s] == 0) continue; + if (!hasSocket) { ImGui::Spacing(); hasSocket = true; } + for (const auto& st : kSocketTypes) { + if (info->socketColor[s] & st.mask) { + ImGui::TextColored(st.col, "%s", st.label); + break; + } + } + } + if (hasSocket && info->socketBonus != 0) { + static std::unordered_map s_enchantNames; + static bool s_enchantNamesLoaded = false; + if (!s_enchantNamesLoaded && assetMgr) { + s_enchantNamesLoaded = true; + auto dbc = assetMgr->loadDBC("SpellItemEnchantment.dbc"); + if (dbc && dbc->isLoaded()) { + const auto* lay = pipeline::getActiveDBCLayout() + ? pipeline::getActiveDBCLayout()->getLayout("SpellItemEnchantment") : nullptr; + uint32_t nameField = lay ? lay->field("Name") : 8u; + if (nameField == 0xFFFFFFFF) nameField = 8; + uint32_t fc = dbc->getFieldCount(); + for (uint32_t r = 0; r < dbc->getRecordCount(); ++r) { + uint32_t eid = dbc->getUInt32(r, 0); + if (eid == 0 || nameField >= fc) continue; + std::string ename = dbc->getString(r, nameField); + if (!ename.empty()) s_enchantNames[eid] = std::move(ename); + } + } + } + auto enchIt = s_enchantNames.find(info->socketBonus); + if (enchIt != s_enchantNames.end()) + ImGui::TextColored(colors::kSocketGreen, "Socket Bonus: %s", enchIt->second.c_str()); + else + ImGui::TextColored(colors::kSocketGreen, "Socket Bonus: (id %u)", info->socketBonus); + } + } + // Item set membership + if (info->itemSetId != 0) { + struct SetEntry { + std::string name; + std::array itemIds{}; + std::array spellIds{}; + std::array thresholds{}; + }; + static std::unordered_map s_setData; + static bool s_setDataLoaded = false; + if (!s_setDataLoaded && assetMgr) { + s_setDataLoaded = true; + auto dbc = assetMgr->loadDBC("ItemSet.dbc"); + if (dbc && dbc->isLoaded()) { + const auto* layout = pipeline::getActiveDBCLayout() + ? pipeline::getActiveDBCLayout()->getLayout("ItemSet") : nullptr; + auto lf = [&](const char* k, uint32_t def) -> uint32_t { + return layout ? (*layout)[k] : def; + }; + uint32_t idF = lf("ID", 0), nameF = lf("Name", 1); + const auto& itemKeys = ui::kItemSetItemKeys; + const auto& spellKeys = ui::kItemSetSpellKeys; + const auto& thrKeys = ui::kItemSetThresholdKeys; + for (uint32_t r = 0; r < dbc->getRecordCount(); ++r) { + uint32_t id = dbc->getUInt32(r, idF); + if (!id) continue; + SetEntry e; + e.name = dbc->getString(r, nameF); + for (int i = 0; i < 10; ++i) { + e.itemIds[i] = dbc->getUInt32(r, layout ? (*layout)[itemKeys[i]] : uint32_t(18 + i)); + e.spellIds[i] = dbc->getUInt32(r, layout ? (*layout)[spellKeys[i]] : uint32_t(28 + i)); + e.thresholds[i] = dbc->getUInt32(r, layout ? (*layout)[thrKeys[i]] : uint32_t(38 + i)); + } + s_setData[id] = std::move(e); + } + } + } + ImGui::Spacing(); + const auto& inv = gameHandler.getInventory(); + auto setIt = s_setData.find(info->itemSetId); + if (setIt != s_setData.end()) { + const SetEntry& se = setIt->second; + int equipped = 0, total = 0; + for (int i = 0; i < 10; ++i) { + if (se.itemIds[i] == 0) continue; + ++total; + for (int sl = 0; sl < game::Inventory::NUM_EQUIP_SLOTS; sl++) { + const auto& eq = inv.getEquipSlot(static_cast(sl)); + if (!eq.empty() && eq.item.itemId == se.itemIds[i]) { ++equipped; break; } + } + } + if (total > 0) + ImGui::TextColored(ui::colors::kTooltipGold, + "%s (%d/%d)", se.name.empty() ? "Set" : se.name.c_str(), equipped, total); + else if (!se.name.empty()) + ImGui::TextColored(ui::colors::kTooltipGold, "%s", se.name.c_str()); + for (int i = 0; i < 10; ++i) { + if (se.spellIds[i] == 0 || se.thresholds[i] == 0) continue; + const std::string& bname = gameHandler.getSpellName(se.spellIds[i]); + bool active = (equipped >= static_cast(se.thresholds[i])); + ImVec4 col = active ? colors::kActiveGreen : colors::kInactiveGray; + if (!bname.empty()) + ImGui::TextColored(col, "(%u) %s", se.thresholds[i], bname.c_str()); + else + ImGui::TextColored(col, "(%u) Set Bonus", se.thresholds[i]); + } + } else { + ImGui::TextColored(ui::colors::kTooltipGold, "Set (id %u)", info->itemSetId); + } + } + // Item spell effects (Use / Equip / Chance on Hit / Teaches) + for (const auto& sp : info->spells) { + if (sp.spellId == 0) continue; + const char* triggerLabel = nullptr; + switch (sp.spellTrigger) { + case 0: triggerLabel = "Use"; break; + case 1: triggerLabel = "Equip"; break; + case 2: triggerLabel = "Chance on Hit"; break; + case 5: triggerLabel = "Teaches"; break; + } + if (!triggerLabel) continue; + const std::string& spDesc = gameHandler.getSpellDescription(sp.spellId); + const std::string& spText = !spDesc.empty() ? spDesc + : gameHandler.getSpellName(sp.spellId); + if (!spText.empty()) { + ImGui::PushTextWrapPos(ImGui::GetCursorPosX() + 300.0f); + ImGui::TextColored(colors::kCyan, + "%s: %s", triggerLabel, spText.c_str()); + ImGui::PopTextWrapPos(); + } + } + // Required level + if (info->requiredLevel > 1) + ImGui::TextDisabled("Requires Level %u", info->requiredLevel); + // Required skill + if (info->requiredSkill != 0 && info->requiredSkillRank > 0) { + static std::unordered_map s_skillNames; + static bool s_skillNamesLoaded = false; + if (!s_skillNamesLoaded && assetMgr) { + s_skillNamesLoaded = true; + auto dbc = assetMgr->loadDBC("SkillLine.dbc"); + if (dbc && dbc->isLoaded()) { + const auto* layout = pipeline::getActiveDBCLayout() + ? pipeline::getActiveDBCLayout()->getLayout("SkillLine") : nullptr; + uint32_t idF = layout ? (*layout)["ID"] : 0u; + uint32_t nameF = layout ? (*layout)["Name"] : 2u; + for (uint32_t r = 0; r < dbc->getRecordCount(); ++r) { + uint32_t sid = dbc->getUInt32(r, idF); + if (!sid) continue; + std::string sname = dbc->getString(r, nameF); + if (!sname.empty()) s_skillNames[sid] = std::move(sname); + } + } + } + uint32_t playerSkillVal = 0; + const auto& skills = gameHandler.getPlayerSkills(); + auto skPit = skills.find(info->requiredSkill); + if (skPit != skills.end()) playerSkillVal = skPit->second.effectiveValue(); + bool meetsSkill = (playerSkillVal == 0 || playerSkillVal >= info->requiredSkillRank); + ImVec4 skColor = meetsSkill ? ImVec4(1.0f, 1.0f, 1.0f, 0.75f) : colors::kPaleRed; + auto skIt = s_skillNames.find(info->requiredSkill); + if (skIt != s_skillNames.end()) + ImGui::TextColored(skColor, "Requires %s (%u)", skIt->second.c_str(), info->requiredSkillRank); + else + ImGui::TextColored(skColor, "Requires Skill %u (%u)", info->requiredSkill, info->requiredSkillRank); + } + // Required reputation + if (info->requiredReputationFaction != 0 && info->requiredReputationRank > 0) { + static std::unordered_map s_factionNames; + static bool s_factionNamesLoaded = false; + if (!s_factionNamesLoaded && assetMgr) { + s_factionNamesLoaded = true; + auto dbc = assetMgr->loadDBC("Faction.dbc"); + if (dbc && dbc->isLoaded()) { + const auto* layout = pipeline::getActiveDBCLayout() + ? pipeline::getActiveDBCLayout()->getLayout("Faction") : nullptr; + uint32_t idF = layout ? (*layout)["ID"] : 0u; + uint32_t nameF = layout ? (*layout)["Name"] : 20u; + for (uint32_t r = 0; r < dbc->getRecordCount(); ++r) { + uint32_t fid = dbc->getUInt32(r, idF); + if (!fid) continue; + std::string fname = dbc->getString(r, nameF); + if (!fname.empty()) s_factionNames[fid] = std::move(fname); + } + } + } + static constexpr const char* kRepRankNames[] = { + "Hated", "Hostile", "Unfriendly", "Neutral", + "Friendly", "Honored", "Revered", "Exalted" + }; + const char* rankName = (info->requiredReputationRank < 8) + ? kRepRankNames[info->requiredReputationRank] : "Unknown"; + auto fIt = s_factionNames.find(info->requiredReputationFaction); + ImGui::TextColored(ImVec4(1.0f, 1.0f, 1.0f, 0.75f), "Requires %s with %s", + rankName, + fIt != s_factionNames.end() ? fIt->second.c_str() : "Unknown Faction"); + } + // Class restriction + if (info->allowableClass != 0) { + const auto& kClasses = ui::kClassMasks; + int matchCount = 0; + for (const auto& kc : kClasses) + if (info->allowableClass & kc.mask) ++matchCount; + if (matchCount > 0 && matchCount < 10) { + char classBuf[128] = "Classes: "; + bool first = true; + for (const auto& kc : kClasses) { + if (!(info->allowableClass & kc.mask)) continue; + if (!first) strncat(classBuf, ", ", sizeof(classBuf) - strlen(classBuf) - 1); + strncat(classBuf, kc.name, sizeof(classBuf) - strlen(classBuf) - 1); + first = false; + } + uint8_t pc = gameHandler.getPlayerClass(); + uint32_t pmask = (pc > 0 && pc <= 10) ? (1u << (pc - 1)) : 0u; + bool playerAllowed = (pmask == 0 || (info->allowableClass & pmask)); + ImVec4 clColor = playerAllowed ? ImVec4(1.0f, 1.0f, 1.0f, 0.75f) : colors::kPaleRed; + ImGui::TextColored(clColor, "%s", classBuf); + } + } + // Race restriction + if (info->allowableRace != 0) { + const auto& kRaces = ui::kRaceMasks; + constexpr uint32_t kAllPlayable = 1|2|4|8|16|32|64|128|512|1024; + if ((info->allowableRace & kAllPlayable) != kAllPlayable) { + int matchCount = 0; + for (const auto& kr : kRaces) + if (info->allowableRace & kr.mask) ++matchCount; + if (matchCount > 0) { + char raceBuf[160] = "Races: "; + bool first = true; + for (const auto& kr : kRaces) { + if (!(info->allowableRace & kr.mask)) continue; + if (!first) strncat(raceBuf, ", ", sizeof(raceBuf) - strlen(raceBuf) - 1); + strncat(raceBuf, kr.name, sizeof(raceBuf) - strlen(raceBuf) - 1); + first = false; + } + uint8_t pr = gameHandler.getPlayerRace(); + uint32_t pmask = (pr > 0 && pr <= 11) ? (1u << (pr - 1)) : 0u; + bool playerAllowed = (pmask == 0 || (info->allowableRace & pmask)); + ImVec4 rColor = playerAllowed ? ImVec4(1.0f, 1.0f, 1.0f, 0.75f) : colors::kPaleRed; + ImGui::TextColored(rColor, "%s", raceBuf); + } + } + } + // Flavor text + if (!info->description.empty()) { + ImGui::Spacing(); + ImGui::PushTextWrapPos(300.0f); + ImGui::TextColored(ImVec4(1.0f, 0.82f, 0.0f, 0.85f), "\"%s\"", info->description.c_str()); + ImGui::PopTextWrapPos(); + } + if (info->sellPrice > 0) { + ImGui::TextDisabled("Sell:"); ImGui::SameLine(0, 4); + renderCoinsFromCopper(info->sellPrice); + } + + if (ImGui::GetIO().KeyShift && info->inventoryType > 0) { + if (const auto* eq = findComparableEquipped(static_cast(info->inventoryType))) { + ImGui::Separator(); + ImGui::TextDisabled("Equipped:"); + VkDescriptorSet eqIcon = inventoryScreen.getItemIcon(eq->item.displayInfoId); + if (eqIcon) { + ImGui::Image((ImTextureID)(uintptr_t)eqIcon, ImVec2(18.0f, 18.0f)); + ImGui::SameLine(); + } + ImGui::TextColored(InventoryScreen::getQualityColor(eq->item.quality), "%s", eq->item.name.c_str()); + if (isWeaponInventoryType(eq->item.inventoryType) && + eq->item.damageMax > 0.0f && eq->item.delayMs > 0) { + float speed = static_cast(eq->item.delayMs) / 1000.0f; + float dps = ((eq->item.damageMin + eq->item.damageMax) * 0.5f) / speed; + char eqDmg[64], eqSpd[32]; + std::snprintf(eqDmg, sizeof(eqDmg), "%d - %d Damage", + static_cast(eq->item.damageMin), static_cast(eq->item.damageMax)); + std::snprintf(eqSpd, sizeof(eqSpd), "Speed %.2f", speed); + float eqSpdW = ImGui::CalcTextSize(eqSpd).x; + ImGui::Text("%s", eqDmg); + ImGui::SameLine(ImGui::GetWindowWidth() - eqSpdW - 16.0f); + ImGui::Text("%s", eqSpd); + ImGui::TextDisabled("(%.1f damage per second)", dps); + } + if (eq->item.armor > 0) { + ImGui::Text("%d Armor", eq->item.armor); + } + std::string eqBonusLine; + appendBonus(eqBonusLine, eq->item.strength, "Str"); + appendBonus(eqBonusLine, eq->item.agility, "Agi"); + appendBonus(eqBonusLine, eq->item.stamina, "Sta"); + appendBonus(eqBonusLine, eq->item.intellect, "Int"); + appendBonus(eqBonusLine, eq->item.spirit, "Spi"); + if (!eqBonusLine.empty()) { + ImGui::TextColored(green, "%s", eqBonusLine.c_str()); + } + // Extra stats for the equipped item + for (const auto& es : eq->item.extraStats) { + const char* nm = nullptr; + switch (es.statType) { + case 12: nm = "Defense Rating"; break; + case 13: nm = "Dodge Rating"; break; + case 14: nm = "Parry Rating"; break; + case 16: case 17: case 18: case 31: nm = "Hit Rating"; break; + case 19: case 20: case 21: case 32: nm = "Critical Strike Rating"; break; + case 28: case 29: case 30: case 35: nm = "Haste Rating"; break; + case 34: nm = "Resilience Rating"; break; + case 36: nm = "Expertise Rating"; break; + case 37: nm = "Attack Power"; break; + case 38: nm = "Ranged Attack Power"; break; + case 45: nm = "Spell Power"; break; + case 46: nm = "Healing Power"; break; + case 49: nm = "Mana per 5 sec."; break; + default: break; + } + if (nm && es.statValue > 0) + ImGui::TextColored(green, "+%d %s", es.statValue, nm); + } + } + } + ImGui::EndTooltip(); +} + +} // namespace ui +} // namespace wowee diff --git a/src/ui/chat/macro_eval_convenience.cpp b/src/ui/chat/macro_eval_convenience.cpp new file mode 100644 index 00000000..b38cfa26 --- /dev/null +++ b/src/ui/chat/macro_eval_convenience.cpp @@ -0,0 +1,24 @@ +// evaluateMacroConditionals — convenience free function. +// Thin wrapper over MacroEvaluator with concrete adapters. +// Separate TU to avoid pulling Application/Renderer into macro_evaluator unit tests. +#include "ui/chat/macro_evaluator.hpp" +#include "ui/chat/game_state_adapter.hpp" +#include "ui/chat/input_modifier_adapter.hpp" +#include "game/game_handler.hpp" +#include "core/application.hpp" +#include "rendering/renderer.hpp" + +namespace wowee { namespace ui { + +std::string evaluateMacroConditionals(const std::string& rawArg, + game::GameHandler& gameHandler, + uint64_t& targetOverride) { + auto* renderer = core::Application::getInstance().getRenderer(); + GameStateAdapter gs(gameHandler, renderer); + InputModifierAdapter im; + MacroEvaluator eval(gs, im); + return eval.evaluate(rawArg, targetOverride); +} + +} // namespace ui +} // namespace wowee diff --git a/src/ui/chat_panel.cpp b/src/ui/chat_panel.cpp index 632dac66..77db55b3 100644 --- a/src/ui/chat_panel.cpp +++ b/src/ui/chat_panel.cpp @@ -1,9 +1,13 @@ #include "ui/chat_panel.hpp" +#include "ui/chat/chat_utils.hpp" +#include "ui/chat/macro_evaluator.hpp" +#include "ui/chat/game_state_adapter.hpp" +#include "ui/chat/input_modifier_adapter.hpp" +#include "ui/chat/gm_command_data.hpp" #include "ui/inventory_screen.hpp" #include "ui/spellbook_screen.hpp" #include "ui/quest_log_screen.hpp" #include "ui/ui_colors.hpp" -#include "ui/chat/gm_command_data.hpp" #include "rendering/vk_context.hpp" #include "core/application.hpp" #include "addons/addon_manager.hpp" @@ -27,6 +31,7 @@ #include #include #include +#include #include #include #include @@ -47,6 +52,61 @@ namespace { // Common ImGui window flags for popup dialogs const ImGuiWindowFlags kDialogFlags = ImGuiWindowFlags_NoCollapse | ImGuiWindowFlags_NoResize; + + // --------------------------------------------------------------------------- + // formatChatMessage — build the display string for a single chat message. + // Extracted from ChatPanel::render() message loop (Phase 6.2). + // --------------------------------------------------------------------------- + std::string formatChatMessage( + const wowee::game::MessageChatData& msg, + const std::string& processedMessage, + const std::string& resolvedSenderName, + const std::string& tsPrefix, + wowee::game::GameHandler& gameHandler) + { + using CT = wowee::game::ChatType; + + // Build chat tag prefix: , , + std::string tagPrefix; + if (msg.chatTag & 0x04) tagPrefix = " "; + else if (msg.chatTag & 0x01) tagPrefix = " "; + else if (msg.chatTag & 0x02) tagPrefix = " "; + + if (msg.type == CT::SYSTEM || msg.type == CT::TEXT_EMOTE) + return tsPrefix + processedMessage; + + if (!resolvedSenderName.empty()) { + if (msg.type == CT::SAY || msg.type == CT::MONSTER_SAY || msg.type == CT::MONSTER_PARTY) + return tsPrefix + tagPrefix + resolvedSenderName + " says: " + processedMessage; + if (msg.type == CT::YELL || msg.type == CT::MONSTER_YELL) + return tsPrefix + tagPrefix + resolvedSenderName + " yells: " + processedMessage; + if (msg.type == CT::WHISPER || msg.type == CT::MONSTER_WHISPER || msg.type == CT::RAID_BOSS_WHISPER) + return tsPrefix + tagPrefix + resolvedSenderName + " whispers: " + processedMessage; + if (msg.type == CT::WHISPER_INFORM) { + const std::string& target = !msg.receiverName.empty() ? msg.receiverName : resolvedSenderName; + return tsPrefix + "To " + target + ": " + processedMessage; + } + if (msg.type == CT::EMOTE || msg.type == CT::MONSTER_EMOTE || msg.type == CT::RAID_BOSS_EMOTE) + return tsPrefix + tagPrefix + resolvedSenderName + " " + processedMessage; + if (msg.type == CT::CHANNEL && !msg.channelName.empty()) { + int chIdx = gameHandler.getChannelIndex(msg.channelName); + std::string chDisplay = chIdx > 0 + ? "[" + std::to_string(chIdx) + ". " + msg.channelName + "]" + : "[" + msg.channelName + "]"; + return tsPrefix + chDisplay + " [" + tagPrefix + resolvedSenderName + "]: " + processedMessage; + } + return tsPrefix + "[" + std::string(wowee::ui::ChatTabManager::getChatTypeName(msg.type)) + "] " + tagPrefix + resolvedSenderName + ": " + processedMessage; + } + + bool isGroupType = + msg.type == CT::PARTY || msg.type == CT::GUILD || + msg.type == CT::OFFICER || msg.type == CT::RAID || + msg.type == CT::RAID_LEADER || msg.type == CT::RAID_WARNING || + msg.type == CT::BATTLEGROUND || msg.type == CT::BATTLEGROUND_LEADER; + if (isGroupType) + return tsPrefix + "[" + std::string(wowee::ui::ChatTabManager::getChatTypeName(msg.type)) + "] " + processedMessage; + return tsPrefix + processedMessage; + } } namespace wowee { namespace ui { @@ -188,7 +248,7 @@ void ChatPanel::render(game::GameHandler& gameHandler, int chatMsgIdx = 0; for (const auto& msg : chatHistory) { if (!tabManager_.shouldShowMessage(msg, activeChatTab)) continue; - std::string processedMessage = replaceGenderPlaceholders(msg.message, gameHandler); + std::string processedMessage = chat_utils::replaceGenderPlaceholders(msg.message, gameHandler); // Resolve sender name at render time in case it wasn't available at parse time. // This handles the race where SMSG_MESSAGECHAT arrives before the entity spawns. @@ -217,56 +277,7 @@ void ChatPanel::render(game::GameHandler& gameHandler, tsPrefix = tsBuf; } - // Build chat tag prefix: , , from chatTag bitmask - std::string tagPrefix; - if (msg.chatTag & 0x04) tagPrefix = " "; - else if (msg.chatTag & 0x01) tagPrefix = " "; - else if (msg.chatTag & 0x02) tagPrefix = " "; - - // Build full message string for this entry - std::string fullMsg; - if (msg.type == game::ChatType::SYSTEM || msg.type == game::ChatType::TEXT_EMOTE) { - fullMsg = tsPrefix + processedMessage; - } else if (!resolvedSenderName.empty()) { - if (msg.type == game::ChatType::SAY || - msg.type == game::ChatType::MONSTER_SAY || msg.type == game::ChatType::MONSTER_PARTY) { - fullMsg = tsPrefix + tagPrefix + resolvedSenderName + " says: " + processedMessage; - } else if (msg.type == game::ChatType::YELL || msg.type == game::ChatType::MONSTER_YELL) { - fullMsg = tsPrefix + tagPrefix + resolvedSenderName + " yells: " + processedMessage; - } else if (msg.type == game::ChatType::WHISPER || - msg.type == game::ChatType::MONSTER_WHISPER || msg.type == game::ChatType::RAID_BOSS_WHISPER) { - fullMsg = tsPrefix + tagPrefix + resolvedSenderName + " whispers: " + processedMessage; - } else if (msg.type == game::ChatType::WHISPER_INFORM) { - const std::string& target = !msg.receiverName.empty() ? msg.receiverName : resolvedSenderName; - fullMsg = tsPrefix + "To " + target + ": " + processedMessage; - } else if (msg.type == game::ChatType::EMOTE || - msg.type == game::ChatType::MONSTER_EMOTE || msg.type == game::ChatType::RAID_BOSS_EMOTE) { - fullMsg = tsPrefix + tagPrefix + resolvedSenderName + " " + processedMessage; - } else if (msg.type == game::ChatType::CHANNEL && !msg.channelName.empty()) { - int chIdx = gameHandler.getChannelIndex(msg.channelName); - std::string chDisplay = chIdx > 0 - ? "[" + std::to_string(chIdx) + ". " + msg.channelName + "]" - : "[" + msg.channelName + "]"; - fullMsg = tsPrefix + chDisplay + " [" + tagPrefix + resolvedSenderName + "]: " + processedMessage; - } else { - fullMsg = tsPrefix + "[" + std::string(ChatTabManager::getChatTypeName(msg.type)) + "] " + tagPrefix + resolvedSenderName + ": " + processedMessage; - } - } else { - bool isGroupType = - msg.type == game::ChatType::PARTY || - msg.type == game::ChatType::GUILD || - msg.type == game::ChatType::OFFICER || - msg.type == game::ChatType::RAID || - msg.type == game::ChatType::RAID_LEADER || - msg.type == game::ChatType::RAID_WARNING || - msg.type == game::ChatType::BATTLEGROUND || - msg.type == game::ChatType::BATTLEGROUND_LEADER; - if (isGroupType) { - fullMsg = tsPrefix + "[" + std::string(ChatTabManager::getChatTypeName(msg.type)) + "] " + processedMessage; - } else { - fullMsg = tsPrefix + processedMessage; - } - } + std::string fullMsg = formatChatMessage(msg, processedMessage, resolvedSenderName, tsPrefix, gameHandler); // Detect mention: does this message contain the local player's name? bool isMention = false; @@ -435,70 +446,7 @@ void ChatPanel::render(game::GameHandler& gameHandler, } // Detect chat channel prefix as user types and switch the dropdown - { - std::string buf(chatInputBuffer_); - if (buf.size() >= 2 && buf[0] == '/') { - // Find the command and check if there's a space after it - size_t sp = buf.find(' ', 1); - if (sp != std::string::npos) { - std::string cmd = buf.substr(1, sp - 1); - for (char& c : cmd) c = static_cast(std::tolower(static_cast(c))); - int detected = -1; - bool isReply = false; - if (cmd == "s" || cmd == "say") detected = 0; - else if (cmd == "y" || cmd == "yell" || cmd == "shout") detected = 1; - else if (cmd == "p" || cmd == "party") detected = 2; - else if (cmd == "g" || cmd == "guild") detected = 3; - else if (cmd == "w" || cmd == "whisper" || cmd == "tell" || cmd == "t") detected = 4; - else if (cmd == "r" || cmd == "reply") { detected = 4; isReply = true; } - else if (cmd == "raid" || cmd == "rsay" || cmd == "ra") detected = 5; - else if (cmd == "o" || cmd == "officer" || cmd == "osay") detected = 6; - else if (cmd == "bg" || cmd == "battleground") detected = 7; - else if (cmd == "rw" || cmd == "raidwarning") detected = 8; - else if (cmd == "i" || cmd == "instance") detected = 9; - else if (cmd.size() == 1 && cmd[0] >= '1' && cmd[0] <= '9') detected = 10; // /1, /2 etc. - if (detected >= 0 && (selectedChatType_ != detected || detected == 10 || isReply)) { - // For channel shortcuts, also update selectedChannelIdx_ - if (detected == 10) { - int chanIdx = cmd[0] - '1'; // /1 -> index 0, /2 -> index 1, etc. - const auto& chans = gameHandler.getJoinedChannels(); - if (chanIdx >= 0 && chanIdx < static_cast(chans.size())) { - selectedChannelIdx_ = chanIdx; - } - } - selectedChatType_ = detected; - // Strip the prefix, keep only the message part - std::string remaining = buf.substr(sp + 1); - // /r reply: pre-fill whisper target from last whisper sender - if (detected == 4 && isReply) { - std::string lastSender = gameHandler.getLastWhisperSender(); - if (!lastSender.empty()) { - strncpy(whisperTargetBuffer_, lastSender.c_str(), sizeof(whisperTargetBuffer_) - 1); - whisperTargetBuffer_[sizeof(whisperTargetBuffer_) - 1] = '\0'; - } - // remaining is the message — don't extract a target from it - } else if (detected == 4) { - // For whisper, first word after /w is the target - size_t msgStart = remaining.find(' '); - if (msgStart != std::string::npos) { - std::string wTarget = remaining.substr(0, msgStart); - strncpy(whisperTargetBuffer_, wTarget.c_str(), sizeof(whisperTargetBuffer_) - 1); - whisperTargetBuffer_[sizeof(whisperTargetBuffer_) - 1] = '\0'; - remaining = remaining.substr(msgStart + 1); - } else { - // Just the target name so far, no message yet - strncpy(whisperTargetBuffer_, remaining.c_str(), sizeof(whisperTargetBuffer_) - 1); - whisperTargetBuffer_[sizeof(whisperTargetBuffer_) - 1] = '\0'; - remaining = ""; - } - } - strncpy(chatInputBuffer_, remaining.c_str(), sizeof(chatInputBuffer_) - 1); - chatInputBuffer_[sizeof(chatInputBuffer_) - 1] = '\0'; - chatInputMoveCursorToEnd_ = true; - } - } - } - } + detectChannelPrefix(gameHandler); // Color the input text based on current chat type ImVec4 inputColor; @@ -517,220 +465,11 @@ void ChatPanel::render(game::GameHandler& gameHandler, } ImGui::PushStyleColor(ImGuiCol_Text, inputColor); - auto inputCallback = [](ImGuiInputTextCallbackData* data) -> int { - auto* self = static_cast(data->UserData); - if (!self) return 0; - - // Cursor-to-end after channel switch - if (self->chatInputMoveCursorToEnd_) { - int len = static_cast(std::strlen(data->Buf)); - data->CursorPos = len; - data->SelectionStart = len; - data->SelectionEnd = len; - self->chatInputMoveCursorToEnd_ = false; - } - - // Tab: slash-command autocomplete (Phase 5 — uses ChatTabCompleter + ChatCommandRegistry) - if (data->EventFlag == ImGuiInputTextFlags_CallbackCompletion) { - if (data->BufTextLen > 0 && data->Buf[0] == '/') { - // Split buffer into command word and trailing args - std::string fullBuf(data->Buf, data->BufTextLen); - size_t spacePos = fullBuf.find(' '); - std::string word = (spacePos != std::string::npos) ? fullBuf.substr(0, spacePos) : fullBuf; - std::string rest = (spacePos != std::string::npos) ? fullBuf.substr(spacePos) : ""; - - // Normalize to lowercase for matching (strip leading /) - std::string lowerCmd = word.substr(1); - for (auto& ch : lowerCmd) ch = static_cast(std::tolower(static_cast(ch))); - - // New session if prefix changed - if (!self->tabCompleter_.isActive() || self->tabCompleter_.getPrefix() != lowerCmd) { - auto candidates = self->commandRegistry_.getCompletions(lowerCmd); - // Prepend / to each candidate for display - for (auto& c : candidates) c = "/" + c; - self->tabCompleter_.startCompletion(lowerCmd, std::move(candidates)); - } else { - self->tabCompleter_.next(); - } - - std::string match = self->tabCompleter_.getCurrentMatch(); - if (!match.empty()) { - // Append trailing space when match is unambiguous - if (self->tabCompleter_.matchCount() == 1 && rest.empty()) - match += ' '; - std::string newBuf = match + rest; - data->DeleteChars(0, data->BufTextLen); - data->InsertChars(0, newBuf.c_str()); - } - } else if (data->BufTextLen > 1 && data->Buf[0] == '.') { - // GM dot-command tab-completion (uses gm_command_data.hpp table) - std::string fullBuf(data->Buf, data->BufTextLen); - size_t spacePos = fullBuf.find(' '); - std::string word = (spacePos != std::string::npos) ? fullBuf.substr(0, spacePos) : fullBuf; - std::string rest = (spacePos != std::string::npos) ? fullBuf.substr(spacePos) : ""; - - // Lowercase the typed prefix (keep the dot) - std::string lowerDot = word; - for (auto& ch : lowerDot) ch = static_cast(std::tolower(static_cast(ch))); - - if (!self->tabCompleter_.isActive() || self->tabCompleter_.getPrefix() != lowerDot) { - // Gather candidates from the GM command table - std::vector candidates; - for (const auto& entry : kGmCommands) { - std::string dotName = "." + std::string(entry.name); - if (dotName.size() >= lowerDot.size() && - dotName.compare(0, lowerDot.size(), lowerDot) == 0) { - candidates.push_back(dotName); - } - } - std::sort(candidates.begin(), candidates.end()); - self->tabCompleter_.startCompletion(lowerDot, std::move(candidates)); - } else { - self->tabCompleter_.next(); - } - - std::string match = self->tabCompleter_.getCurrentMatch(); - if (!match.empty()) { - if (self->tabCompleter_.matchCount() == 1 && rest.empty()) - match += ' '; - std::string newBuf = match + rest; - data->DeleteChars(0, data->BufTextLen); - data->InsertChars(0, newBuf.c_str()); - } - } else if (data->BufTextLen > 0) { - // Player name tab-completion for commands like /w, /whisper, /invite, /trade, /duel - std::string fullBuf(data->Buf, data->BufTextLen); - size_t spacePos = fullBuf.find(' '); - bool isNameCommand = false; - std::string namePrefix; - size_t replaceStart = 0; - - if (fullBuf[0] == '/' && spacePos != std::string::npos) { - std::string cmd = fullBuf.substr(0, spacePos); - for (char& c : cmd) c = static_cast(std::tolower(static_cast(c))); - if (cmd == "/w" || cmd == "/whisper" || cmd == "/invite" || - cmd == "/trade" || cmd == "/duel" || cmd == "/follow" || - cmd == "/inspect" || cmd == "/friend" || cmd == "/removefriend" || - cmd == "/ignore" || cmd == "/unignore" || cmd == "/who" || - cmd == "/t" || cmd == "/target" || cmd == "/kick" || - cmd == "/uninvite" || cmd == "/ginvite" || cmd == "/gkick") { - namePrefix = fullBuf.substr(spacePos + 1); - size_t nameSpace = namePrefix.find(' '); - if (nameSpace == std::string::npos) { - isNameCommand = true; - replaceStart = spacePos + 1; - } - } - } - - if (isNameCommand && !namePrefix.empty()) { - std::string lowerPrefix = namePrefix; - for (char& c : lowerPrefix) c = static_cast(std::tolower(static_cast(c))); - - if (!self->tabCompleter_.isActive() || self->tabCompleter_.getPrefix() != lowerPrefix) { - std::vector candidates; - auto* gh = self->cachedGameHandler_; - // Party/raid members - for (const auto& m : gh->getPartyData().members) { - if (m.name.empty()) continue; - std::string lname = m.name; - for (char& c : lname) c = static_cast(std::tolower(static_cast(c))); - if (lname.compare(0, lowerPrefix.size(), lowerPrefix) == 0) - candidates.push_back(m.name); - } - // Friends - for (const auto& c : gh->getContacts()) { - if (!c.isFriend() || c.name.empty()) continue; - std::string lname = c.name; - for (char& cc : lname) cc = static_cast(std::tolower(static_cast(cc))); - if (lname.compare(0, lowerPrefix.size(), lowerPrefix) == 0) { - bool dup = false; - for (const auto& em : candidates) - if (em == c.name) { dup = true; break; } - if (!dup) candidates.push_back(c.name); - } - } - // Nearby visible players - for (const auto& [guid, entity] : gh->getEntityManager().getEntities()) { - if (!entity || entity->getType() != game::ObjectType::PLAYER) continue; - auto player = std::static_pointer_cast(entity); - if (player->getName().empty()) continue; - std::string lname = player->getName(); - for (char& cc : lname) cc = static_cast(std::tolower(static_cast(cc))); - if (lname.compare(0, lowerPrefix.size(), lowerPrefix) == 0) { - bool dup = false; - for (const auto& em : candidates) - if (em == player->getName()) { dup = true; break; } - if (!dup) candidates.push_back(player->getName()); - } - } - // Last whisper sender (insert at front for priority) - if (!gh->getLastWhisperSender().empty()) { - std::string lname = gh->getLastWhisperSender(); - for (char& cc : lname) cc = static_cast(std::tolower(static_cast(cc))); - if (lname.compare(0, lowerPrefix.size(), lowerPrefix) == 0) { - bool dup = false; - for (const auto& em : candidates) - if (em == gh->getLastWhisperSender()) { dup = true; break; } - if (!dup) candidates.insert(candidates.begin(), gh->getLastWhisperSender()); - } - } - self->tabCompleter_.startCompletion(lowerPrefix, std::move(candidates)); - } else { - self->tabCompleter_.next(); - } - - std::string match = self->tabCompleter_.getCurrentMatch(); - if (!match.empty()) { - std::string prefix = fullBuf.substr(0, replaceStart); - std::string newBuf = prefix + match; - if (self->tabCompleter_.matchCount() == 1) newBuf += ' '; - data->DeleteChars(0, data->BufTextLen); - data->InsertChars(0, newBuf.c_str()); - } - } - } - return 0; - } - - // Up/Down arrow: cycle through sent message history - if (data->EventFlag == ImGuiInputTextFlags_CallbackHistory) { - // Any history navigation resets autocomplete - self->tabCompleter_.reset(); - - const int histSize = static_cast(self->chatSentHistory_.size()); - if (histSize == 0) return 0; - - if (data->EventKey == ImGuiKey_UpArrow) { - // Go back in history - if (self->chatHistoryIdx_ == -1) - self->chatHistoryIdx_ = histSize - 1; - else if (self->chatHistoryIdx_ > 0) - --self->chatHistoryIdx_; - } else if (data->EventKey == ImGuiKey_DownArrow) { - if (self->chatHistoryIdx_ == -1) return 0; - ++self->chatHistoryIdx_; - if (self->chatHistoryIdx_ >= histSize) { - self->chatHistoryIdx_ = -1; - data->DeleteChars(0, data->BufTextLen); - return 0; - } - } - - if (self->chatHistoryIdx_ >= 0 && self->chatHistoryIdx_ < histSize) { - const std::string& entry = self->chatSentHistory_[self->chatHistoryIdx_]; - data->DeleteChars(0, data->BufTextLen); - data->InsertChars(0, entry.c_str()); - } - } - return 0; - }; - ImGuiInputTextFlags inputFlags = ImGuiInputTextFlags_EnterReturnsTrue | ImGuiInputTextFlags_CallbackAlways | ImGuiInputTextFlags_CallbackHistory | ImGuiInputTextFlags_CallbackCompletion; - if (ImGui::InputText("##ChatInput", chatInputBuffer_, sizeof(chatInputBuffer_), inputFlags, inputCallback, this)) { + if (ImGui::InputText("##ChatInput", chatInputBuffer_, sizeof(chatInputBuffer_), inputFlags, &ChatPanel::inputTextCallback, this)) { sendChatMessage(gameHandler); // Close chat input on send so movement keys work immediately. refocusChatInput_ = false; @@ -757,6 +496,272 @@ void ChatPanel::render(game::GameHandler& gameHandler, ImGui::End(); } +// --------------------------------------------------------------------------- +// detectChannelPrefix — auto-detect /say, /party, /whisper etc. prefixes +// and switch the chat type dropdown + strip the prefix from the input buffer. +// Extracted from render() (Phase 6.2). +// --------------------------------------------------------------------------- +void ChatPanel::detectChannelPrefix(game::GameHandler& gameHandler) { + std::string buf(chatInputBuffer_); + if (buf.size() < 2 || buf[0] != '/') return; + + size_t sp = buf.find(' ', 1); + if (sp == std::string::npos) return; + + std::string cmd = buf.substr(1, sp - 1); + for (char& c : cmd) c = static_cast(std::tolower(static_cast(c))); + + int detected = -1; + bool isReply = false; + if (cmd == "s" || cmd == "say") detected = 0; + else if (cmd == "y" || cmd == "yell" || cmd == "shout") detected = 1; + else if (cmd == "p" || cmd == "party") detected = 2; + else if (cmd == "g" || cmd == "guild") detected = 3; + else if (cmd == "w" || cmd == "whisper" || cmd == "tell" || cmd == "t") detected = 4; + else if (cmd == "r" || cmd == "reply") { detected = 4; isReply = true; } + else if (cmd == "raid" || cmd == "rsay" || cmd == "ra") detected = 5; + else if (cmd == "o" || cmd == "officer" || cmd == "osay") detected = 6; + else if (cmd == "bg" || cmd == "battleground") detected = 7; + else if (cmd == "rw" || cmd == "raidwarning") detected = 8; + else if (cmd == "i" || cmd == "instance") detected = 9; + else if (cmd.size() == 1 && cmd[0] >= '1' && cmd[0] <= '9') detected = 10; + + if (detected < 0 || (selectedChatType_ == detected && detected != 10 && !isReply)) return; + + if (detected == 10) { + int chanIdx = cmd[0] - '1'; + const auto& chans = gameHandler.getJoinedChannels(); + if (chanIdx >= 0 && chanIdx < static_cast(chans.size())) + selectedChannelIdx_ = chanIdx; + } + selectedChatType_ = detected; + std::string remaining = buf.substr(sp + 1); + + if (detected == 4 && isReply) { + std::string lastSender = gameHandler.getLastWhisperSender(); + if (!lastSender.empty()) { + strncpy(whisperTargetBuffer_, lastSender.c_str(), sizeof(whisperTargetBuffer_) - 1); + whisperTargetBuffer_[sizeof(whisperTargetBuffer_) - 1] = '\0'; + } + } else if (detected == 4) { + size_t msgStart = remaining.find(' '); + if (msgStart != std::string::npos) { + std::string wTarget = remaining.substr(0, msgStart); + strncpy(whisperTargetBuffer_, wTarget.c_str(), sizeof(whisperTargetBuffer_) - 1); + whisperTargetBuffer_[sizeof(whisperTargetBuffer_) - 1] = '\0'; + remaining = remaining.substr(msgStart + 1); + } else { + strncpy(whisperTargetBuffer_, remaining.c_str(), sizeof(whisperTargetBuffer_) - 1); + whisperTargetBuffer_[sizeof(whisperTargetBuffer_) - 1] = '\0'; + remaining = ""; + } + } + strncpy(chatInputBuffer_, remaining.c_str(), sizeof(chatInputBuffer_) - 1); + chatInputBuffer_[sizeof(chatInputBuffer_) - 1] = '\0'; + chatInputMoveCursorToEnd_ = true; +} + +// --------------------------------------------------------------------------- +// inputTextCallback — static ImGui input text callback for tab-completion, +// cursor management, and sent-message history (Up/Down arrows). +// Extracted from render() inline lambda (Phase 6.2). +// --------------------------------------------------------------------------- +int ChatPanel::inputTextCallback(ImGuiInputTextCallbackData* data) { + auto* self = static_cast(data->UserData); + if (!self) return 0; + + // Cursor-to-end after channel switch + if (self->chatInputMoveCursorToEnd_) { + int len = static_cast(std::strlen(data->Buf)); + data->CursorPos = len; + data->SelectionStart = len; + data->SelectionEnd = len; + self->chatInputMoveCursorToEnd_ = false; + } + + // Tab: slash-command autocomplete (Phase 5) + if (data->EventFlag == ImGuiInputTextFlags_CallbackCompletion) { + if (data->BufTextLen > 0 && data->Buf[0] == '/') { + std::string fullBuf(data->Buf, data->BufTextLen); + size_t spacePos = fullBuf.find(' '); + std::string word = (spacePos != std::string::npos) ? fullBuf.substr(0, spacePos) : fullBuf; + std::string rest = (spacePos != std::string::npos) ? fullBuf.substr(spacePos) : ""; + + std::string lowerCmd = word.substr(1); + for (auto& ch : lowerCmd) ch = static_cast(std::tolower(static_cast(ch))); + + if (!self->tabCompleter_.isActive() || self->tabCompleter_.getPrefix() != lowerCmd) { + auto candidates = self->commandRegistry_.getCompletions(lowerCmd); + for (auto& c : candidates) c = "/" + c; + self->tabCompleter_.startCompletion(lowerCmd, std::move(candidates)); + } else { + self->tabCompleter_.next(); + } + + std::string match = self->tabCompleter_.getCurrentMatch(); + if (!match.empty()) { + if (self->tabCompleter_.matchCount() == 1 && rest.empty()) + match += ' '; + std::string newBuf = match + rest; + data->DeleteChars(0, data->BufTextLen); + data->InsertChars(0, newBuf.c_str()); + } + } else if (data->BufTextLen > 1 && data->Buf[0] == '.') { + // GM dot-command tab-completion + std::string fullBuf(data->Buf, data->BufTextLen); + size_t spacePos = fullBuf.find(' '); + std::string word = (spacePos != std::string::npos) ? fullBuf.substr(0, spacePos) : fullBuf; + std::string rest = (spacePos != std::string::npos) ? fullBuf.substr(spacePos) : ""; + + std::string lowerDot = word; + for (auto& ch : lowerDot) ch = static_cast(std::tolower(static_cast(ch))); + + if (!self->tabCompleter_.isActive() || self->tabCompleter_.getPrefix() != lowerDot) { + std::vector candidates; + for (const auto& entry : kGmCommands) { + std::string dotName = "." + std::string(entry.name); + if (dotName.size() >= lowerDot.size() && + dotName.compare(0, lowerDot.size(), lowerDot) == 0) { + candidates.push_back(dotName); + } + } + std::sort(candidates.begin(), candidates.end()); + self->tabCompleter_.startCompletion(lowerDot, std::move(candidates)); + } else { + self->tabCompleter_.next(); + } + + std::string match = self->tabCompleter_.getCurrentMatch(); + if (!match.empty()) { + if (self->tabCompleter_.matchCount() == 1 && rest.empty()) + match += ' '; + std::string newBuf = match + rest; + data->DeleteChars(0, data->BufTextLen); + data->InsertChars(0, newBuf.c_str()); + } + } else if (data->BufTextLen > 0) { + // Player name tab-completion + std::string fullBuf(data->Buf, data->BufTextLen); + size_t spacePos = fullBuf.find(' '); + bool isNameCommand = false; + std::string namePrefix; + size_t replaceStart = 0; + + if (fullBuf[0] == '/' && spacePos != std::string::npos) { + std::string cmd = fullBuf.substr(0, spacePos); + for (char& c : cmd) c = static_cast(std::tolower(static_cast(c))); + if (cmd == "/w" || cmd == "/whisper" || cmd == "/invite" || + cmd == "/trade" || cmd == "/duel" || cmd == "/follow" || + cmd == "/inspect" || cmd == "/friend" || cmd == "/removefriend" || + cmd == "/ignore" || cmd == "/unignore" || cmd == "/who" || + cmd == "/t" || cmd == "/target" || cmd == "/kick" || + cmd == "/uninvite" || cmd == "/ginvite" || cmd == "/gkick") { + namePrefix = fullBuf.substr(spacePos + 1); + size_t nameSpace = namePrefix.find(' '); + if (nameSpace == std::string::npos) { + isNameCommand = true; + replaceStart = spacePos + 1; + } + } + } + + if (isNameCommand && !namePrefix.empty()) { + std::string lowerPrefix = namePrefix; + for (char& c : lowerPrefix) c = static_cast(std::tolower(static_cast(c))); + + if (!self->tabCompleter_.isActive() || self->tabCompleter_.getPrefix() != lowerPrefix) { + std::vector candidates; + auto* gh = self->cachedGameHandler_; + for (const auto& m : gh->getPartyData().members) { + if (m.name.empty()) continue; + std::string lname = m.name; + for (char& c : lname) c = static_cast(std::tolower(static_cast(c))); + if (lname.compare(0, lowerPrefix.size(), lowerPrefix) == 0) + candidates.push_back(m.name); + } + for (const auto& c : gh->getContacts()) { + if (!c.isFriend() || c.name.empty()) continue; + std::string lname = c.name; + for (char& cc : lname) cc = static_cast(std::tolower(static_cast(cc))); + if (lname.compare(0, lowerPrefix.size(), lowerPrefix) == 0) { + bool dup = false; + for (const auto& em : candidates) + if (em == c.name) { dup = true; break; } + if (!dup) candidates.push_back(c.name); + } + } + for (const auto& [guid, entity] : gh->getEntityManager().getEntities()) { + if (!entity || entity->getType() != game::ObjectType::PLAYER) continue; + auto player = std::static_pointer_cast(entity); + if (player->getName().empty()) continue; + std::string lname = player->getName(); + for (char& cc : lname) cc = static_cast(std::tolower(static_cast(cc))); + if (lname.compare(0, lowerPrefix.size(), lowerPrefix) == 0) { + bool dup = false; + for (const auto& em : candidates) + if (em == player->getName()) { dup = true; break; } + if (!dup) candidates.push_back(player->getName()); + } + } + if (!gh->getLastWhisperSender().empty()) { + std::string lname = gh->getLastWhisperSender(); + for (char& cc : lname) cc = static_cast(std::tolower(static_cast(cc))); + if (lname.compare(0, lowerPrefix.size(), lowerPrefix) == 0) { + bool dup = false; + for (const auto& em : candidates) + if (em == gh->getLastWhisperSender()) { dup = true; break; } + if (!dup) candidates.insert(candidates.begin(), gh->getLastWhisperSender()); + } + } + self->tabCompleter_.startCompletion(lowerPrefix, std::move(candidates)); + } else { + self->tabCompleter_.next(); + } + + std::string match = self->tabCompleter_.getCurrentMatch(); + if (!match.empty()) { + std::string prefix = fullBuf.substr(0, replaceStart); + std::string newBuf = prefix + match; + if (self->tabCompleter_.matchCount() == 1) newBuf += ' '; + data->DeleteChars(0, data->BufTextLen); + data->InsertChars(0, newBuf.c_str()); + } + } + } + return 0; + } + + // Up/Down arrow: cycle through sent message history + if (data->EventFlag == ImGuiInputTextFlags_CallbackHistory) { + self->tabCompleter_.reset(); + + const int histSize = static_cast(self->chatSentHistory_.size()); + if (histSize == 0) return 0; + + if (data->EventKey == ImGuiKey_UpArrow) { + if (self->chatHistoryIdx_ == -1) + self->chatHistoryIdx_ = histSize - 1; + else if (self->chatHistoryIdx_ > 0) + --self->chatHistoryIdx_; + } else if (data->EventKey == ImGuiKey_DownArrow) { + if (self->chatHistoryIdx_ == -1) return 0; + ++self->chatHistoryIdx_; + if (self->chatHistoryIdx_ >= histSize) { + self->chatHistoryIdx_ = -1; + data->DeleteChars(0, data->BufTextLen); + return 0; + } + } + + if (self->chatHistoryIdx_ >= 0 && self->chatHistoryIdx_ < histSize) { + const std::string& entry = self->chatSentHistory_[self->chatHistoryIdx_]; + data->DeleteChars(0, data->BufTextLen); + data->InsertChars(0, entry.c_str()); + } + } + return 0; +} + // --- Command registration (calls into each command group file) --- // Forward declarations of registration functions from command files void registerSystemCommands(ChatCommandRegistry& reg); @@ -785,8 +790,329 @@ void ChatPanel::registerAllCommands() { registerGmCommands(commandRegistry_); } +// renderBubbles delegates to ChatBubbleManager (Phase 1.4) +void ChatPanel::renderBubbles(game::GameHandler& gameHandler) { + bubbleManager_.render(gameHandler, services_); +} + +// setupCallbacks delegates to ChatBubbleManager (Phase 1.4) +void ChatPanel::setupCallbacks(game::GameHandler& gameHandler) { + bubbleManager_.setupCallback(gameHandler); +} + +void ChatPanel::insertChatLink(const std::string& link) { + if (link.empty()) return; + size_t curLen = strlen(chatInputBuffer_); + if (curLen + link.size() + 1 < sizeof(chatInputBuffer_)) { + strncat(chatInputBuffer_, link.c_str(), sizeof(chatInputBuffer_) - curLen - 1); + chatInputMoveCursorToEnd_ = true; + refocusChatInput_ = true; + } +} + +void ChatPanel::activateSlashInput() { + refocusChatInput_ = true; + chatInputBuffer_[0] = '/'; + chatInputBuffer_[1] = '\0'; + chatInputMoveCursorToEnd_ = true; +} + +void ChatPanel::activateInput() { + if (chatInputCooldown_ > 0) return; + refocusChatInput_ = true; +} + +void ChatPanel::setWhisperTarget(const std::string& name) { + selectedChatType_ = 4; // WHISPER + strncpy(whisperTargetBuffer_, name.c_str(), sizeof(whisperTargetBuffer_) - 1); + whisperTargetBuffer_[sizeof(whisperTargetBuffer_) - 1] = '\0'; + refocusChatInput_ = true; +} + +ChatPanel::SlashCommands ChatPanel::consumeSlashCommands() { + SlashCommands result = slashCmds_; + slashCmds_ = {}; + return result; +} + +namespace { + bool isPortBotTarget(const std::string& target) { + std::string t = chat_utils::toLower(chat_utils::trim(target)); + return t == "portbot" || t == "gmbot" || t == "telebot"; + } + + std::string buildPortBotCommand(const std::string& rawInput) { + std::string input = chat_utils::trim(rawInput); + if (input.empty()) return ""; + + std::string lower = chat_utils::toLower(input); + if (lower == "help" || lower == "?") return "__help__"; + + if (lower.rfind(".tele ", 0) == 0 || lower.rfind(".go ", 0) == 0) return input; + if (lower.rfind("xyz ", 0) == 0) return ".go " + input; + + if (lower == "sw" || lower == "stormwind") return ".tele stormwind"; + if (lower == "if" || lower == "ironforge") return ".tele ironforge"; + if (lower == "darn" || lower == "darnassus") return ".tele darnassus"; + if (lower == "org" || lower == "orgrimmar") return ".tele orgrimmar"; + if (lower == "tb" || lower == "thunderbluff") return ".tele thunderbluff"; + if (lower == "uc" || lower == "undercity") return ".tele undercity"; + if (lower == "shatt" || lower == "shattrath") return ".tele shattrath"; + if (lower == "dal" || lower == "dalaran") return ".tele dalaran"; + + return ".tele " + input; + } +} // anonymous namespace // Collect all non-comment, non-empty lines from a macro body. +static std::vector allMacroCommands(const std::string& macroText) { + std::vector cmds; + size_t pos = 0; + while (pos <= macroText.size()) { + size_t nl = macroText.find('\n', pos); + std::string line = (nl != std::string::npos) ? macroText.substr(pos, nl - pos) : macroText.substr(pos); + if (!line.empty() && line.back() == '\r') line.pop_back(); + size_t start = line.find_first_not_of(" \t"); + if (start != std::string::npos) line = line.substr(start); + if (!line.empty() && line.front() != '#') + cmds.push_back(std::move(line)); + if (nl == std::string::npos) break; + pos = nl + 1; + } + return cmds; +} + +// Execute all non-comment lines of a macro body in sequence. +void ChatPanel::executeMacroText(game::GameHandler& gameHandler, + const std::string& macroText) { + macroStopped_ = false; + for (const auto& cmd : allMacroCommands(macroText)) { + strncpy(chatInputBuffer_, cmd.c_str(), sizeof(chatInputBuffer_) - 1); + chatInputBuffer_[sizeof(chatInputBuffer_) - 1] = '\0'; + sendChatMessage(gameHandler); + if (macroStopped_) break; + } + macroStopped_ = false; +} + +void ChatPanel::sendChatMessage(game::GameHandler& gameHandler) { + if (strlen(chatInputBuffer_) == 0) return; + std::string input(chatInputBuffer_); + + // Save to sent-message history (skip pure whitespace, cap at 50 entries) + { + bool allSpace = true; + for (char c : input) { if (!std::isspace(static_cast(c))) { allSpace = false; break; } } + if (!allSpace) { + if (chatSentHistory_.empty() || chatSentHistory_.back() != input) { + chatSentHistory_.push_back(input); + if (chatSentHistory_.size() > 50) + chatSentHistory_.erase(chatSentHistory_.begin()); + } + } + } + chatHistoryIdx_ = -1; + + game::ChatType type = game::ChatType::SAY; + std::string message = input; + std::string target; + + // GM dot-prefix commands (.gm, .tele, .additem, etc.) + if (input.size() > 1 && input[0] == '.') { + LOG_INFO("GM command: '", input, "' — sending as SAY to server"); + gameHandler.sendChatMessage(game::ChatType::SAY, input, ""); + + std::string dotCmd = input; + size_t sp = dotCmd.find(' '); + std::string cmdPart = (sp != std::string::npos) + ? dotCmd.substr(1, sp - 1) : dotCmd.substr(1); + for (char& c : cmdPart) c = static_cast(std::tolower(static_cast(c))); + + std::string feedback; + for (const auto& entry : kGmCommands) { + if (entry.name == cmdPart) { + feedback = "Sent: " + input + " (" + std::string(entry.help) + ")"; + break; + } + } + if (feedback.empty()) + feedback = "Sent: " + input + + " (requires GM access — server console: account set gmlevel 3 -1)"; + gameHandler.addLocalChatMessage(chat_utils::makeSystemMessage(feedback)); + chatInputBuffer_[0] = '\0'; + return; + } + + // Slash commands + if (input.size() > 1 && input[0] == '/') { + std::string command = input.substr(1); + size_t spacePos = command.find(' '); + std::string cmd = (spacePos != std::string::npos) ? command.substr(0, spacePos) : command; + std::string cmdLower = cmd; + for (char& c : cmdLower) c = static_cast(std::tolower(static_cast(c))); + + // /run + if ((cmdLower == "run" || cmdLower == "script") && spacePos != std::string::npos) { + std::string luaCode = command.substr(spacePos + 1); + auto* am = services_.addonManager; + if (am) { + am->runScript(luaCode); + } else { + gameHandler.addUIError("Addon system not initialized."); + } + chatInputBuffer_[0] = '\0'; + return; + } + + // /dump + if ((cmdLower == "dump" || cmdLower == "print") && spacePos != std::string::npos) { + std::string expr = command.substr(spacePos + 1); + auto* am = services_.addonManager; + if (am && am->isInitialized()) { + std::string wrapped = "local __v = " + expr + + "; if type(__v) == 'table' then " + " local parts = {} " + " for k,v in pairs(__v) do parts[#parts+1] = tostring(k)..'='..tostring(v) end " + " print('{' .. table.concat(parts, ', ') .. '}') " + "else print(tostring(__v)) end"; + am->runScript(wrapped); + } else { + game::MessageChatData errMsg; + errMsg.type = game::ChatType::SYSTEM; + errMsg.language = game::ChatLanguage::UNIVERSAL; + errMsg.message = "Addon system not initialized."; + gameHandler.addLocalChatMessage(errMsg); + } + chatInputBuffer_[0] = '\0'; + return; + } + + // Addon slash commands (SlashCmdList) + { + auto* am = services_.addonManager; + if (am && am->isInitialized()) { + std::string slashCmd = "/" + cmdLower; + std::string slashArgs; + if (spacePos != std::string::npos) slashArgs = command.substr(spacePos + 1); + if (am->getLuaEngine()->dispatchSlashCommand(slashCmd, slashArgs)) { + chatInputBuffer_[0] = '\0'; + return; + } + } + } + + // Dispatch through command registry (Phase 3.11) + std::string args; + if (spacePos != std::string::npos) + args = command.substr(spacePos + 1); + + ChatCommandContext ctx{gameHandler, services_, *this, args, cmdLower}; + ChatCommandResult result = commandRegistry_.dispatch(cmdLower, ctx); + if (result.handled) { + if (result.clearInput) + chatInputBuffer_[0] = '\0'; + return; + } + + // Emote fallthrough — dynamic DBC lookup for emote text. + { + std::string targetName; + const std::string* targetNamePtr = nullptr; + if (gameHandler.hasTarget()) { + auto targetEntity = gameHandler.getTarget(); + if (targetEntity) { + targetName = chat_utils::getEntityDisplayName(targetEntity); + if (!targetName.empty()) targetNamePtr = &targetName; + } + } + + std::string emoteText = rendering::AnimationController::getEmoteText(cmdLower, targetNamePtr); + if (!emoteText.empty()) { + auto* renderer = services_.renderer; + if (renderer) { + if (auto* ac = renderer->getAnimationController()) ac->playEmote(cmdLower); + } + + uint32_t dbcId = rendering::AnimationController::getEmoteDbcId(cmdLower); + if (dbcId != 0) { + uint64_t targetGuid = gameHandler.hasTarget() ? gameHandler.getTargetGuid() : 0; + gameHandler.sendTextEmote(dbcId, targetGuid); + } + + game::MessageChatData msg; + msg.type = game::ChatType::TEXT_EMOTE; + msg.language = game::ChatLanguage::COMMON; + msg.message = emoteText; + gameHandler.addLocalChatMessage(msg); + + chatInputBuffer_[0] = '\0'; + return; + } + } + + // Unrecognized slash command — fall through to dropdown chat type + message = input; + } + + // Determine chat type from dropdown selection + switch (selectedChatType_) { + case 0: type = game::ChatType::SAY; break; + case 1: type = game::ChatType::YELL; break; + case 2: type = game::ChatType::PARTY; break; + case 3: type = game::ChatType::GUILD; break; + case 4: type = game::ChatType::WHISPER; target = whisperTargetBuffer_; break; + case 5: type = game::ChatType::RAID; break; + case 6: type = game::ChatType::OFFICER; break; + case 7: type = game::ChatType::BATTLEGROUND; break; + case 8: type = game::ChatType::RAID_WARNING; break; + case 9: type = game::ChatType::PARTY; break; + case 10: { + const auto& chans = gameHandler.getJoinedChannels(); + if (!chans.empty() && selectedChannelIdx_ < static_cast(chans.size())) { + type = game::ChatType::CHANNEL; + target = chans[selectedChannelIdx_]; + } else { type = game::ChatType::SAY; } + break; + } + default: type = game::ChatType::SAY; break; + } + + // PortBot whisper interception + if (type == game::ChatType::WHISPER && isPortBotTarget(target)) { + std::string cmd = buildPortBotCommand(message); + game::MessageChatData msg; + msg.type = game::ChatType::SYSTEM; + msg.language = game::ChatLanguage::UNIVERSAL; + if (cmd.empty() || cmd == "__help__") { + msg.message = "PortBot: /w PortBot . Aliases: sw if darn org tb uc shatt dal. Also supports '.tele ...' or 'xyz x y z [map [o]]'."; + gameHandler.addLocalChatMessage(msg); + chatInputBuffer_[0] = '\0'; + return; + } + + gameHandler.sendChatMessage(game::ChatType::SAY, cmd, ""); + msg.message = "PortBot executed: " + cmd; + gameHandler.addLocalChatMessage(msg); + chatInputBuffer_[0] = '\0'; + return; + } + + // Validate whisper has a target + if (type == game::ChatType::WHISPER && target.empty()) { + game::MessageChatData msg; + msg.type = game::ChatType::SYSTEM; + msg.language = game::ChatLanguage::UNIVERSAL; + msg.message = "You must specify a player name for whisper."; + gameHandler.addLocalChatMessage(msg); + chatInputBuffer_[0] = '\0'; + return; + } + + if (!message.empty()) { + gameHandler.sendChatMessage(type, message, target); + } + chatInputBuffer_[0] = '\0'; +} } // namespace ui } // namespace wowee diff --git a/src/ui/chat_panel_commands.cpp b/src/ui/chat_panel_commands.cpp deleted file mode 100644 index 0fdca8c9..00000000 --- a/src/ui/chat_panel_commands.cpp +++ /dev/null @@ -1,359 +0,0 @@ -#include "ui/chat_panel.hpp" -#include "ui/chat/macro_evaluator.hpp" -#include "ui/chat/game_state_adapter.hpp" -#include "ui/chat/input_modifier_adapter.hpp" -#include "ui/chat/chat_utils.hpp" -#include "ui/chat/gm_command_data.hpp" -#include "core/application.hpp" -#include "core/logger.hpp" -#include "addons/addon_manager.hpp" -#include "rendering/renderer.hpp" -#include "rendering/animation_controller.hpp" -#include -#include -#include - -using wowee::ui::chat_utils::trim; -using wowee::ui::chat_utils::toLower; - -namespace { - - bool isPortBotTarget(const std::string& target) { - std::string t = toLower(trim(target)); - return t == "portbot" || t == "gmbot" || t == "telebot"; - } - - std::string buildPortBotCommand(const std::string& rawInput) { - std::string input = trim(rawInput); - if (input.empty()) return ""; - - std::string lower = toLower(input); - if (lower == "help" || lower == "?") { - return "__help__"; - } - - if (lower.rfind(".tele ", 0) == 0 || lower.rfind(".go ", 0) == 0) { - return input; - } - - if (lower.rfind("xyz ", 0) == 0) { - return ".go " + input; - } - - if (lower == "sw" || lower == "stormwind") return ".tele stormwind"; - if (lower == "if" || lower == "ironforge") return ".tele ironforge"; - if (lower == "darn" || lower == "darnassus") return ".tele darnassus"; - if (lower == "org" || lower == "orgrimmar") return ".tele orgrimmar"; - if (lower == "tb" || lower == "thunderbluff") return ".tele thunderbluff"; - if (lower == "uc" || lower == "undercity") return ".tele undercity"; - if (lower == "shatt" || lower == "shattrath") return ".tele shattrath"; - if (lower == "dal" || lower == "dalaran") return ".tele dalaran"; - - return ".tele " + input; - } - - std::string getEntityName(const std::shared_ptr& entity) { - if (entity->getType() == wowee::game::ObjectType::PLAYER) { - auto player = std::static_pointer_cast(entity); - if (!player->getName().empty()) return player->getName(); - } else if (entity->getType() == wowee::game::ObjectType::UNIT) { - auto unit = std::static_pointer_cast(entity); - if (!unit->getName().empty()) return unit->getName(); - } else if (entity->getType() == wowee::game::ObjectType::GAMEOBJECT) { - auto go = std::static_pointer_cast(entity); - if (!go->getName().empty()) return go->getName(); - } - return "Unknown"; - } -} // namespace - -namespace wowee { namespace ui { - -static std::vector allMacroCommands(const std::string& macroText) { - std::vector cmds; - size_t pos = 0; - while (pos <= macroText.size()) { - size_t nl = macroText.find('\n', pos); - std::string line = (nl != std::string::npos) ? macroText.substr(pos, nl - pos) : macroText.substr(pos); - if (!line.empty() && line.back() == '\r') line.pop_back(); - size_t start = line.find_first_not_of(" \t"); - if (start != std::string::npos) line = line.substr(start); - if (!line.empty() && line.front() != '#') - cmds.push_back(std::move(line)); - if (nl == std::string::npos) break; - pos = nl + 1; - } - return cmds; -} - -// --------------------------------------------------------------------------- -// evaluateMacroConditionals — thin wrapper over MacroEvaluator (Phase 4). -// Preserved for backward compatibility with command files that forward-declare it. -// --------------------------------------------------------------------------- -std::string evaluateMacroConditionals(const std::string& rawArg, - game::GameHandler& gameHandler, - uint64_t& targetOverride) { - auto* renderer = core::Application::getInstance().getRenderer(); - GameStateAdapter gs(gameHandler, renderer); - InputModifierAdapter im; - MacroEvaluator eval(gs, im); - return eval.evaluate(rawArg, targetOverride); -} - -// Execute all non-comment lines of a macro body in sequence. -// In WoW, every line executes per click; the server enforces spell-cast limits. -// /stopmacro (with optional conditionals) halts the remaining commands early. - -void ChatPanel::executeMacroText(game::GameHandler& gameHandler, - const std::string& macroText) { - macroStopped_ = false; - for (const auto& cmd : allMacroCommands(macroText)) { - strncpy(chatInputBuffer_, cmd.c_str(), sizeof(chatInputBuffer_) - 1); - chatInputBuffer_[sizeof(chatInputBuffer_) - 1] = '\0'; - sendChatMessage(gameHandler); - if (macroStopped_) break; - } - macroStopped_ = false; -} - -// /castsequence state moved to CastSequenceTracker member (Phase 1.5) - - -void ChatPanel::sendChatMessage(game::GameHandler& gameHandler) { - if (strlen(chatInputBuffer_) > 0) { - std::string input(chatInputBuffer_); - - // Save to sent-message history (skip pure whitespace, cap at 50 entries) - { - bool allSpace = true; - for (char c : input) { if (!std::isspace(static_cast(c))) { allSpace = false; break; } } - if (!allSpace) { - // Remove duplicate of last entry if identical - if (chatSentHistory_.empty() || chatSentHistory_.back() != input) { - chatSentHistory_.push_back(input); - if (chatSentHistory_.size() > 50) - chatSentHistory_.erase(chatSentHistory_.begin()); - } - } - } - chatHistoryIdx_ = -1; // reset browsing position after send - - game::ChatType type = game::ChatType::SAY; - std::string message = input; - std::string target; - - // GM dot-prefix commands (.gm, .tele, .additem, etc.) - // Sent to server as SAY — the server interprets the dot-prefix. - // Requires GM security level on the server (account set gmlevel 3 -1). - if (input.size() > 1 && input[0] == '.') { - LOG_INFO("GM command: '", input, "' — sending as SAY to server"); - gameHandler.sendChatMessage(game::ChatType::SAY, input, ""); - - // Build feedback: check if this is a known command - std::string dotCmd = input; - size_t sp = dotCmd.find(' '); - std::string cmdPart = (sp != std::string::npos) - ? dotCmd.substr(1, sp - 1) : dotCmd.substr(1); - for (char& c : cmdPart) c = static_cast(std::tolower(static_cast(c))); - - // Look for a matching entry in the GM command table - std::string feedback; - for (const auto& entry : kGmCommands) { - if (entry.name == cmdPart) { - feedback = "Sent: " + input + " (" + std::string(entry.help) + ")"; - break; - } - } - if (feedback.empty()) - feedback = "Sent: " + input - + " (requires GM access — server console: account set gmlevel 3 -1)"; - gameHandler.addLocalChatMessage(chat_utils::makeSystemMessage(feedback)); - chatInputBuffer_[0] = '\0'; - return; - } - - // Check for slash commands - if (input.size() > 1 && input[0] == '/') { - std::string command = input.substr(1); - size_t spacePos = command.find(' '); - std::string cmd = (spacePos != std::string::npos) ? command.substr(0, spacePos) : command; - - // Convert command to lowercase for comparison - std::string cmdLower = cmd; - for (char& c : cmdLower) c = static_cast(std::tolower(static_cast(c))); - - // /run — execute Lua script via addon system - if ((cmdLower == "run" || cmdLower == "script") && spacePos != std::string::npos) { - std::string luaCode = command.substr(spacePos + 1); - auto* am = services_.addonManager; - if (am) { - am->runScript(luaCode); - } else { - gameHandler.addUIError("Addon system not initialized."); - } - chatInputBuffer_[0] = '\0'; - return; - } - - // /dump — evaluate Lua expression and print result - if ((cmdLower == "dump" || cmdLower == "print") && spacePos != std::string::npos) { - std::string expr = command.substr(spacePos + 1); - auto* am = services_.addonManager; - if (am && am->isInitialized()) { - // Wrap expression in print(tostring(...)) to display the value - std::string wrapped = "local __v = " + expr + - "; if type(__v) == 'table' then " - " local parts = {} " - " for k,v in pairs(__v) do parts[#parts+1] = tostring(k)..'='..tostring(v) end " - " print('{' .. table.concat(parts, ', ') .. '}') " - "else print(tostring(__v)) end"; - am->runScript(wrapped); - } else { - game::MessageChatData errMsg; - errMsg.type = game::ChatType::SYSTEM; - errMsg.language = game::ChatLanguage::UNIVERSAL; - errMsg.message = "Addon system not initialized."; - gameHandler.addLocalChatMessage(errMsg); - } - chatInputBuffer_[0] = '\0'; - return; - } - - // Check addon slash commands (SlashCmdList) before built-in commands - { - auto* am = services_.addonManager; - if (am && am->isInitialized()) { - std::string slashCmd = "/" + cmdLower; - std::string slashArgs; - if (spacePos != std::string::npos) slashArgs = command.substr(spacePos + 1); - if (am->getLuaEngine()->dispatchSlashCommand(slashCmd, slashArgs)) { - chatInputBuffer_[0] = '\0'; - return; - } - } - } - - // Dispatch through command registry (Phase 3.11) - std::string args; - if (spacePos != std::string::npos) - args = command.substr(spacePos + 1); - - ChatCommandContext ctx{gameHandler, services_, *this, args, cmdLower}; - ChatCommandResult result = commandRegistry_.dispatch(cmdLower, ctx); - - if (result.handled) { - if (result.clearInput) - chatInputBuffer_[0] = '\0'; - return; - } - - // Emote fallthrough — dynamic DBC lookup for emote text (catch-all). - // Not registered in the command registry because emote names are data-driven. - { - std::string targetName; - const std::string* targetNamePtr = nullptr; - if (gameHandler.hasTarget()) { - auto targetEntity = gameHandler.getTarget(); - if (targetEntity) { - targetName = getEntityName(targetEntity); - if (!targetName.empty()) targetNamePtr = &targetName; - } - } - - std::string emoteText = rendering::AnimationController::getEmoteText(cmdLower, targetNamePtr); - if (!emoteText.empty()) { - auto* renderer = services_.renderer; - if (renderer) { - if (auto* ac = renderer->getAnimationController()) ac->playEmote(cmdLower); - } - - uint32_t dbcId = rendering::AnimationController::getEmoteDbcId(cmdLower); - if (dbcId != 0) { - uint64_t targetGuid = gameHandler.hasTarget() ? gameHandler.getTargetGuid() : 0; - gameHandler.sendTextEmote(dbcId, targetGuid); - } - - game::MessageChatData msg; - msg.type = game::ChatType::TEXT_EMOTE; - msg.language = game::ChatLanguage::COMMON; - msg.message = emoteText; - gameHandler.addLocalChatMessage(msg); - - chatInputBuffer_[0] = '\0'; - return; - } - } - - // Unrecognized slash command — fall through to dropdown chat type - message = input; - } - - // Determine chat type from dropdown selection - // (reached when: no slash prefix, OR unrecognized slash command) - switch (selectedChatType_) { - case 0: type = game::ChatType::SAY; break; - case 1: type = game::ChatType::YELL; break; - case 2: type = game::ChatType::PARTY; break; - case 3: type = game::ChatType::GUILD; break; - case 4: type = game::ChatType::WHISPER; target = whisperTargetBuffer_; break; - case 5: type = game::ChatType::RAID; break; - case 6: type = game::ChatType::OFFICER; break; - case 7: type = game::ChatType::BATTLEGROUND; break; - case 8: type = game::ChatType::RAID_WARNING; break; - case 9: type = game::ChatType::PARTY; break; // INSTANCE uses PARTY - case 10: { // CHANNEL - const auto& chans = gameHandler.getJoinedChannels(); - if (!chans.empty() && selectedChannelIdx_ < static_cast(chans.size())) { - type = game::ChatType::CHANNEL; - target = chans[selectedChannelIdx_]; - } else { type = game::ChatType::SAY; } - break; - } - default: type = game::ChatType::SAY; break; - } - - // PortBot whisper interception (for dropdown-typed whispers, not /w command) - if (type == game::ChatType::WHISPER && isPortBotTarget(target)) { - std::string cmd = buildPortBotCommand(message); - game::MessageChatData msg; - msg.type = game::ChatType::SYSTEM; - msg.language = game::ChatLanguage::UNIVERSAL; - if (cmd.empty() || cmd == "__help__") { - msg.message = "PortBot: /w PortBot . Aliases: sw if darn org tb uc shatt dal. Also supports '.tele ...' or 'xyz x y z [map [o]]'."; - gameHandler.addLocalChatMessage(msg); - chatInputBuffer_[0] = '\0'; - return; - } - - gameHandler.sendChatMessage(game::ChatType::SAY, cmd, ""); - msg.message = "PortBot executed: " + cmd; - gameHandler.addLocalChatMessage(msg); - chatInputBuffer_[0] = '\0'; - return; - } - - // Validate whisper has a target - if (type == game::ChatType::WHISPER && target.empty()) { - game::MessageChatData msg; - msg.type = game::ChatType::SYSTEM; - msg.language = game::ChatLanguage::UNIVERSAL; - msg.message = "You must specify a player name for whisper."; - gameHandler.addLocalChatMessage(msg); - chatInputBuffer_[0] = '\0'; - return; - } - - // Don't send empty messages - if (!message.empty()) { - gameHandler.sendChatMessage(type, message, target); - } - - // Clear input - chatInputBuffer_[0] = '\0'; - } -} - - - -} // namespace ui -} // namespace wowee diff --git a/src/ui/quest_log_screen.cpp b/src/ui/quest_log_screen.cpp index 1ce53ed7..5bbb8ea7 100644 --- a/src/ui/quest_log_screen.cpp +++ b/src/ui/quest_log_screen.cpp @@ -2,6 +2,7 @@ #include "ui/ui_colors.hpp" #include "ui/inventory_screen.hpp" #include "ui/keybinding_manager.hpp" +#include "ui/chat/chat_utils.hpp" #include "core/application.hpp" #include "core/input.hpp" #include @@ -10,125 +11,6 @@ namespace wowee { namespace ui { namespace { -// Helper function to replace gender placeholders, pronouns, and name -std::string replaceGenderPlaceholders(const std::string& text, game::GameHandler& gameHandler) { - game::Gender gender = game::Gender::NONBINARY; - std::string playerName = "Adventurer"; - const auto* character = gameHandler.getActiveCharacter(); - if (character) { - gender = character->gender; - if (!character->name.empty()) { - playerName = character->name; - } - } - game::Pronouns pronouns = game::Pronouns::forGender(gender); - - std::string result = text; - - auto trim = [](std::string& s) { - const char* ws = " \t\n\r"; - size_t start = s.find_first_not_of(ws); - if (start == std::string::npos) { s.clear(); return; } - size_t end = s.find_last_not_of(ws); - s = s.substr(start, end - start + 1); - }; - - // Replace $g placeholders - size_t pos = 0; - while ((pos = result.find('$', pos)) != std::string::npos) { - if (pos + 1 >= result.length()) break; - char marker = result[pos + 1]; - if (marker != 'g' && marker != 'G') { pos++; continue; } - - size_t endPos = result.find(';', pos); - if (endPos == std::string::npos) { pos += 2; continue; } - - std::string placeholder = result.substr(pos + 2, endPos - pos - 2); - - std::vector parts; - size_t start = 0; - size_t colonPos; - while ((colonPos = placeholder.find(':', start)) != std::string::npos) { - std::string part = placeholder.substr(start, colonPos - start); - trim(part); - parts.push_back(part); - start = colonPos + 1; - } - std::string lastPart = placeholder.substr(start); - trim(lastPart); - parts.push_back(lastPart); - - std::string replacement; - if (parts.size() >= 3) { - switch (gender) { - case game::Gender::MALE: replacement = parts[0]; break; - case game::Gender::FEMALE: replacement = parts[1]; break; - case game::Gender::NONBINARY: replacement = parts[2]; break; - } - } else if (parts.size() >= 2) { - switch (gender) { - case game::Gender::MALE: replacement = parts[0]; break; - case game::Gender::FEMALE: replacement = parts[1]; break; - case game::Gender::NONBINARY: - replacement = parts[0].length() <= parts[1].length() ? parts[0] : parts[1]; - break; - } - } else { - pos = endPos + 1; - continue; - } - - result.replace(pos, endPos - pos + 1, replacement); - pos += replacement.length(); - } - - // Resolve class and race names for $C and $R placeholders - std::string className = "Adventurer"; - std::string raceName = "Unknown"; - if (character) { - className = game::getClassName(character->characterClass); - raceName = game::getRaceName(character->race); - } - - // Replace simple placeholders - pos = 0; - while ((pos = result.find('$', pos)) != std::string::npos) { - if (pos + 1 >= result.length()) break; - - char code = result[pos + 1]; - std::string replacement; - - switch (code) { - case 'n': case 'N': replacement = playerName; break; - case 'c': case 'C': replacement = className; break; - case 'r': case 'R': replacement = raceName; break; - case 'p': replacement = pronouns.subject; break; - case 'o': replacement = pronouns.object; break; - case 's': replacement = pronouns.possessive; break; - case 'S': replacement = pronouns.possessiveP; break; - case 'b': case 'B': replacement = "\n"; break; - case 'g': case 'G': pos++; continue; - default: pos++; continue; - } - - result.replace(pos, 2, replacement); - pos += replacement.length(); - } - - // WoW markup linebreak token - pos = 0; - while ((pos = result.find("|n", pos)) != std::string::npos) { - result.replace(pos, 2, "\n"); - pos += 1; - } - pos = 0; - while ((pos = result.find("|N", pos)) != std::string::npos) { - result.replace(pos, 2, "\n"); - pos += 1; - } - - return result; -} std::string cleanQuestTitleForUi(const std::string& raw, uint32_t questId) { std::string s = raw; @@ -446,7 +328,7 @@ void QuestLogScreen::render(game::GameHandler& gameHandler, InventoryScreen& inv if (lastDetailRequestQuestId_ == sel.questId) lastDetailRequestQuestId_ = 0; questDetailQueryNoResponse_.erase(sel.questId); ImGui::TextColored(ImVec4(0.82f, 0.9f, 1.0f, 1.0f), "Summary"); - std::string processedObjectives = replaceGenderPlaceholders(sel.objectives, gameHandler); + std::string processedObjectives = chat_utils::replaceGenderPlaceholders(sel.objectives, gameHandler); float textHeight = ImGui::GetContentRegionAvail().y * 0.45f; if (textHeight < 120.0f) textHeight = 120.0f; ImGui::BeginChild("QuestObjectiveText", ImVec2(0, textHeight), true); diff --git a/src/ui/window_manager.cpp b/src/ui/window_manager.cpp index 767e2806..74bb994f 100644 --- a/src/ui/window_manager.cpp +++ b/src/ui/window_manager.cpp @@ -4,6 +4,7 @@ // ============================================================ #include "ui/window_manager.hpp" #include "ui/chat_panel.hpp" +#include "ui/chat/chat_utils.hpp" #include "ui/settings_panel.hpp" #include "ui/spellbook_screen.hpp" #include "ui/inventory_screen.hpp" @@ -252,7 +253,7 @@ void WindowManager::renderLootWindow(game::GameHandler& gameHandler, } void WindowManager::renderGossipWindow(game::GameHandler& gameHandler, - ChatPanel& chatPanel) { + ChatPanel& /*chatPanel*/) { if (!gameHandler.isGossipWindowOpen()) return; auto* window = services_.window; @@ -330,7 +331,7 @@ void WindowManager::renderGossipWindow(game::GameHandler& gameHandler, displayText = placeholderIt->second; } - std::string processedText = chatPanel.replaceGenderPlaceholders(displayText, gameHandler); + std::string processedText = chat_utils::replaceGenderPlaceholders(displayText, gameHandler); std::string label = std::string(icon) + " " + processedText; if (ImGui::Selectable(label.c_str())) { if (opt.text == "GOSSIP_OPTION_ARMORER") { @@ -436,11 +437,11 @@ void WindowManager::renderQuestDetailsWindow(game::GameHandler& gameHandler, bool open = true; const auto& quest = gameHandler.getQuestDetails(); - std::string processedTitle = chatPanel.replaceGenderPlaceholders(quest.title, gameHandler); + std::string processedTitle = chat_utils::replaceGenderPlaceholders(quest.title, gameHandler); if (ImGui::Begin(processedTitle.c_str(), &open)) { // Quest description if (!quest.details.empty()) { - std::string processedDetails = chatPanel.replaceGenderPlaceholders(quest.details, gameHandler); + std::string processedDetails = chat_utils::replaceGenderPlaceholders(quest.details, gameHandler); ImGui::TextWrapped("%s", processedDetails.c_str()); } @@ -449,7 +450,7 @@ void WindowManager::renderQuestDetailsWindow(game::GameHandler& gameHandler, ImGui::Spacing(); ImGui::Separator(); ImGui::TextColored(ui::colors::kTooltipGold, "Objectives:"); - std::string processedObjectives = chatPanel.replaceGenderPlaceholders(quest.objectives, gameHandler); + std::string processedObjectives = chat_utils::replaceGenderPlaceholders(quest.objectives, gameHandler); ImGui::TextWrapped("%s", processedObjectives.c_str()); } @@ -577,10 +578,10 @@ void WindowManager::renderQuestRequestItemsWindow(game::GameHandler& gameHandler return total; }; - std::string processedTitle = chatPanel.replaceGenderPlaceholders(quest.title, gameHandler); + std::string processedTitle = chat_utils::replaceGenderPlaceholders(quest.title, gameHandler); if (ImGui::Begin(processedTitle.c_str(), &open, ImGuiWindowFlags_NoCollapse)) { if (!quest.completionText.empty()) { - std::string processedCompletionText = chatPanel.replaceGenderPlaceholders(quest.completionText, gameHandler); + std::string processedCompletionText = chat_utils::replaceGenderPlaceholders(quest.completionText, gameHandler); ImGui::TextWrapped("%s", processedCompletionText.c_str()); } @@ -670,10 +671,10 @@ void WindowManager::renderQuestOfferRewardWindow(game::GameHandler& gameHandler, selectedChoice = 0; } - std::string processedTitle = chatPanel.replaceGenderPlaceholders(quest.title, gameHandler); + std::string processedTitle = chat_utils::replaceGenderPlaceholders(quest.title, gameHandler); if (ImGui::Begin(processedTitle.c_str(), &open, ImGuiWindowFlags_NoCollapse)) { if (!quest.rewardText.empty()) { - std::string processedRewardText = chatPanel.replaceGenderPlaceholders(quest.rewardText, gameHandler); + std::string processedRewardText = chat_utils::replaceGenderPlaceholders(quest.rewardText, gameHandler); ImGui::TextWrapped("%s", processedRewardText.c_str()); }