From e3746aa50e0b5d2e29c0bd55c4f1197b9565f94e Mon Sep 17 00:00:00 2001 From: James Huston Date: Wed, 31 Dec 2025 01:54:46 +0000 Subject: [PATCH] feat(agent-chat): Add Agent Chat Panel with MCP integration - Add AgentChatPanel UI with agent selector, chat history, message input - Implement message send/receive flow via AMS and MCP bridge - Add 'Sending...' / 'Delivered' status indicator for message confirmation - Add message history persistence via SavedVariables - Persist subscriptions in ElunaSharedData (survives .reload eluna) - Add online/offline status display in agent dropdown - Fix JSON content parsing for escaped quotes - Add server-side handlers for agent messaging (ad_admin_handlers.lua) - Add AMS_Server for client-server communication - Update AGENTS.md with 'never defer documentation' policy - Update file editing locations documentation --- AGENTS.md | 18 + .../UI/Panels/AgentChatPanel.lua | 116 +- araxiaonline/lua_scripts/AGENTS.md | 43 +- araxiaonline/lua_scripts/Smallfolk.lua | 207 +++ araxiaonline/lua_scripts/aa_Smallfolk.lua | 207 +++ .../lua_scripts/ab_AMS_Server/AMS_Server.lua | 446 +++++ .../lua_scripts/ab_AMS_Server/README.md | 94 ++ .../lua_scripts/ab_AMS_Server/smallfolk.lua | 203 +++ .../lua_scripts/ac_ams_test_handlers.lua | 359 ++++ .../lua_scripts/ad_admin_handlers.lua | 1475 +++++++++++++++++ araxiaonline/lua_scripts/ae_mcp_bridge.lua | 196 +++ .../lua_scripts/af_spawn_validator.lua | 260 +++ araxiaonline/lua_scripts/ag_reload_helper.lua | 19 + araxiaonline/lua_scripts/zz_init.lua | 40 + 14 files changed, 3657 insertions(+), 26 deletions(-) create mode 100644 araxiaonline/lua_scripts/Smallfolk.lua create mode 100644 araxiaonline/lua_scripts/aa_Smallfolk.lua create mode 100755 araxiaonline/lua_scripts/ab_AMS_Server/AMS_Server.lua create mode 100755 araxiaonline/lua_scripts/ab_AMS_Server/README.md create mode 100755 araxiaonline/lua_scripts/ab_AMS_Server/smallfolk.lua create mode 100755 araxiaonline/lua_scripts/ac_ams_test_handlers.lua create mode 100755 araxiaonline/lua_scripts/ad_admin_handlers.lua create mode 100755 araxiaonline/lua_scripts/ae_mcp_bridge.lua create mode 100644 araxiaonline/lua_scripts/af_spawn_validator.lua create mode 100755 araxiaonline/lua_scripts/ag_reload_helper.lua create mode 100755 araxiaonline/lua_scripts/zz_init.lua diff --git a/AGENTS.md b/AGENTS.md index bc29313c51..83336ba8a3 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -18,6 +18,24 @@ Documentation with status can be found in [ELUNA_INTEGRATION_COMPLETE.md](ELUNA_ --- +## ⚠️ NEVER Defer Documentation + +**Documentation is CRITICAL and must never be skipped or deferred.** + +Why: +- AI assistants have limited context windows +- Documentation is how future sessions understand past decisions +- Without docs, work gets duplicated or misunderstood +- Code comments, READMEs, and wiki pages are essential deliverables + +**Every feature/change must include:** +1. Code comments explaining non-obvious decisions +2. Updated README if user-facing +3. Wiki/AGENTS.md updates for AI context +4. Inline examples where helpful + +--- + ## Code Commenting Guidelines **ALWAYS add comments when modifying C++ code in this project.** diff --git a/araxiaonline/client_addons/AraxiaTrinityAdmin/UI/Panels/AgentChatPanel.lua b/araxiaonline/client_addons/AraxiaTrinityAdmin/UI/Panels/AgentChatPanel.lua index e34ca9b642..5370cf8f19 100644 --- a/araxiaonline/client_addons/AraxiaTrinityAdmin/UI/Panels/AgentChatPanel.lua +++ b/araxiaonline/client_addons/AraxiaTrinityAdmin/UI/Panels/AgentChatPanel.lua @@ -17,10 +17,13 @@ initFrame:SetScript("OnEvent", function(self, event, loadedAddon) local chatPanel = CreateFrame("Frame", "AraxiaTrinityAdminAgentChatPanel", UIParent) chatPanel:Hide() --- Chat history storage +-- Chat history storage (persisted via SavedVariables) local chatHistory = {} -- { {from="player"|"agent", agent_name, content, timestamp, message_id}, ... } local MAX_HISTORY = 200 +-- Message send status tracking +local pendingMessageId = nil + -- Current selected agent local selectedAgent = nil local availableAgents = {} @@ -28,6 +31,9 @@ local availableAgents = {} -- Polling state local isSubscribed = false +-- Status indicator for message sending +local sendStatusText = nil -- Will be created after chatPanel is set up + -- ============================================================================ -- Header Section -- ============================================================================ @@ -40,9 +46,18 @@ title:SetText("Agent Chat") local agentDropdown = CreateFrame("Frame", "AgentChatAgentDropdown", chatPanel, "UIDropDownMenuTemplate") agentDropdown:SetPoint("LEFT", title, "RIGHT", 10, -2) +local function GetAgentStatusText(agentName) + for _, agent in ipairs(availableAgents) do + if agent.name == agentName then + return agentName .. (agent.online and " |cFF00FF00(online)|r" or " |cFF888888(offline)|r") + end + end + return agentName or "Select Agent" +end + local function AgentDropdown_OnClick(self, arg1, arg2, checked) selectedAgent = arg1 - UIDropDownMenu_SetText(agentDropdown, arg1 or "Select Agent") + UIDropDownMenu_SetText(agentDropdown, GetAgentStatusText(arg1)) end local function AgentDropdown_Initialize(self, level) @@ -72,9 +87,9 @@ UIDropDownMenu_Initialize(agentDropdown, AgentDropdown_Initialize) -- Refresh agents button local refreshAgentsBtn = CreateFrame("Button", nil, chatPanel, "UIPanelButtonTemplate") -refreshAgentsBtn:SetSize(24, 24) +refreshAgentsBtn:SetSize(60, 24) refreshAgentsBtn:SetPoint("LEFT", agentDropdown, "RIGHT", -10, 2) -refreshAgentsBtn:SetText("R") +refreshAgentsBtn:SetText("Reload") refreshAgentsBtn:SetScript("OnClick", function() if AMS then AMS.Send("AGENT_LIST_REQUEST", {}) @@ -87,11 +102,17 @@ refreshAgentsBtn:SetScript("OnEnter", function(self) end) refreshAgentsBtn:SetScript("OnLeave", function() GameTooltip:Hide() end) --- Status indicator +-- Status indicator (connection status) local statusText = chatPanel:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall") statusText:SetPoint("TOPRIGHT", chatPanel, "TOPRIGHT", -10, -14) statusText:SetText("|cFF888888Disconnected|r") +-- Message send status indicator (shows Sending.../Delivered) +sendStatusText = chatPanel:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall") +sendStatusText:SetPoint("TOPRIGHT", chatPanel, "TOPRIGHT", -10, -28) +sendStatusText:SetText("") +sendStatusText:Hide() + -- ============================================================================ -- Chat History Display -- ============================================================================ @@ -109,7 +130,7 @@ chatContainer:SetBackdropColor(0.05, 0.05, 0.05, 0.9) chatContainer:SetBackdropBorderColor(0.4, 0.4, 0.4, 1) local chatScroll = CreateFrame("ScrollFrame", "AgentChatScrollFrame", chatContainer, "UIPanelScrollFrameTemplate") -chatScroll:SetPoint("TOPLEFT", chatContainer, "TOPLEFT", 8, -8) +chatScroll:SetPoint("TOPLEFT", chatContainer, "TOPLEFT", 5, -8) chatScroll:SetPoint("BOTTOMRIGHT", chatContainer, "BOTTOMRIGHT", -28, 8) local chatScrollChild = CreateFrame("Frame", nil, chatScroll) @@ -127,11 +148,13 @@ end local function AddMessageToDisplay(from, agentName, content, timestamp, isFromPlayer) local frame = CreateFrame("Frame", nil, chatScrollChild) - frame:SetWidth(chatScrollChild:GetWidth() - 10) + local frameWidth = chatScrollChild:GetWidth() - 20 + if frameWidth < 100 then frameWidth = 400 end -- Fallback width + frame:SetWidth(frameWidth) -- Header line (name + timestamp) local header = frame:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall") - header:SetPoint("TOPLEFT", frame, "TOPLEFT", 5, -5) + header:SetPoint("TOPLEFT", frame, "TOPLEFT", 15, -5) if isFromPlayer then header:SetText("|cFF00CCFF" .. UnitName("player") .. "|r |cFF888888" .. FormatTimestamp(timestamp) .. "|r") @@ -139,13 +162,15 @@ local function AddMessageToDisplay(from, agentName, content, timestamp, isFromPl header:SetText("|cFF00FF00" .. (agentName or "Agent") .. "|r |cFF888888" .. FormatTimestamp(timestamp) .. "|r") end - -- Content + -- Content - displayed below header local contentText = frame:CreateFontString(nil, "OVERLAY", "GameFontHighlight") - contentText:SetPoint("TOPLEFT", header, "BOTTOMLEFT", 0, -3) - contentText:SetPoint("RIGHT", frame, "RIGHT", -5, 0) + contentText:SetPoint("TOPLEFT", header, "BOTTOMLEFT", 0, -2) + contentText:SetWidth(frameWidth - 20) contentText:SetJustifyH("LEFT") - contentText:SetText(content) contentText:SetWordWrap(true) + local displayContent = content or "" + if displayContent == "" then displayContent = "[empty]" end + contentText:SetText(displayContent) -- Calculate height local contentHeight = contentText:GetStringHeight() @@ -159,6 +184,7 @@ local function AddMessageToDisplay(from, agentName, content, timestamp, isFromPl frame:SetPoint("TOPLEFT", chatScrollChild, "TOPLEFT", 0, -yOffset) table.insert(messageFrames, frame) + frame:Show() -- Update scroll child height chatScrollChild:SetHeight(yOffset + frame:GetHeight() + 10) @@ -238,6 +264,11 @@ local function SendMessage() } end + -- Show sending status + pendingMessageId = "local_" .. time() + sendStatusText:SetText("|cFFFFFF00Sending...|r") + sendStatusText:Show() + -- Send message AMS.Send("AGENT_SEND_MESSAGE", { agent_name = selectedAgent, @@ -251,7 +282,7 @@ local function SendMessage() agent_name = selectedAgent, content = text, timestamp = time(), - message_id = "local_" .. time() + message_id = pendingMessageId } table.insert(chatHistory, msg) @@ -298,10 +329,12 @@ local function InitAMSHandlers() availableAgents = data.agents or {} UIDropDownMenu_Initialize(agentDropdown, AgentDropdown_Initialize) - -- Auto-select first agent if none selected + -- Auto-select first agent if none selected, or update status display if not selectedAgent and #availableAgents > 0 then selectedAgent = availableAgents[1].name - UIDropDownMenu_SetText(agentDropdown, selectedAgent) + end + if selectedAgent then + UIDropDownMenu_SetText(agentDropdown, GetAgentStatusText(selectedAgent)) end print("|cFF00FF00[Agent Chat]|r Found " .. #availableAgents .. " agent(s)") @@ -310,9 +343,22 @@ local function InitAMSHandlers() -- Handle send confirmation AMS.RegisterHandler("AGENT_SEND_MESSAGE_RESPONSE", function(data) - if not data.success then + if data.success then + -- Show delivered status briefly + sendStatusText:SetText("|cFF00FF00Delivered ✓|r") + C_Timer.After(3, function() + sendStatusText:SetText("") + sendStatusText:Hide() + end) + else + sendStatusText:SetText("|cFFFF0000Failed|r") print("|cFFFF0000[Agent Chat]|r Failed to send: " .. (data.error or "Unknown error")) + C_Timer.After(5, function() + sendStatusText:SetText("") + sendStatusText:Hide() + end) end + pendingMessageId = nil end) -- Handle agent responses (push delivery) @@ -402,16 +448,48 @@ pollBtn:SetScript("OnClick", function() end end) --- Clear chat button +-- Clear chat button (positioned left of status text) local clearBtn = CreateFrame("Button", nil, chatPanel, "UIPanelButtonTemplate") clearBtn:SetSize(50, 22) -clearBtn:SetPoint("TOPRIGHT", chatPanel, "TOPRIGHT", -10, -35) +clearBtn:SetPoint("RIGHT", statusText, "LEFT", -10, 0) clearBtn:SetText("Clear") clearBtn:SetScript("OnClick", function() chatHistory = {} ClearMessageDisplay() end) +-- ============================================================================ +-- SavedVariables Persistence +-- ============================================================================ + +-- Load chat history from SavedVariables on login +local function LoadChatHistory() + if AraxiaTrinityAdminDB and AraxiaTrinityAdminDB.AgentChatHistory then + chatHistory = AraxiaTrinityAdminDB.AgentChatHistory + -- Refresh display with loaded history + C_Timer.After(0.5, function() + RefreshChatDisplay() + end) + end +end + +-- Save chat history to SavedVariables on logout +local function SaveChatHistory() + if not AraxiaTrinityAdminDB then + AraxiaTrinityAdminDB = {} + end + AraxiaTrinityAdminDB.AgentChatHistory = chatHistory +end + +-- Register for logout event to save history +local saveFrame = CreateFrame("Frame") +saveFrame:RegisterEvent("PLAYER_LOGOUT") +saveFrame:SetScript("OnEvent", function(self, event) + if event == "PLAYER_LOGOUT" then + SaveChatHistory() + end +end) + -- ============================================================================ -- Register with MainWindow -- ============================================================================ @@ -420,6 +498,7 @@ local function InitPanel() if ATA.MainWindow then ATA.MainWindow:RegisterPanel("AgentChat", "Agent Chat", chatPanel) InitAMSHandlers() + LoadChatHistory() -- Load saved history after init else C_Timer.After(0.1, InitPanel) end @@ -430,5 +509,6 @@ C_Timer.After(0.1, InitPanel) -- Make available globally for debugging ATA.AgentChatPanel = chatPanel ATA.AgentChatHistory = chatHistory +ATA.SaveAgentChatHistory = SaveChatHistory -- Allow manual save end) -- End ADDON_LOADED handler diff --git a/araxiaonline/lua_scripts/AGENTS.md b/araxiaonline/lua_scripts/AGENTS.md index 1ac6ecfb3f..dca8f379d1 100644 --- a/araxiaonline/lua_scripts/AGENTS.md +++ b/araxiaonline/lua_scripts/AGENTS.md @@ -1,19 +1,46 @@ # Eluna Lua Scripts - Agent Guidelines -## ⚠️ IMPORTANT: Commit Workflow +## ⚠️ CRITICAL: File Editing Locations -**This directory (`/opt/trinitycore/server/lua_scripts/`) is NOT a git repo.** +### Server-Side Lua Scripts +- **EDIT HERE (running server):** `/opt/trinitycore/lua_scripts/` +- **COMMIT FROM:** `/opt/trinitycore/TrinityCore/araxiaonline/lua_scripts/` -To commit changes to lua_scripts: -1. Copy modified files to: `/opt/trinitycore/TrinityCore/araxiaonline/lua_scripts/` -2. Commit from the TrinityCore repo +### Client-Side Addons (WoW Client) +- **EDIT HERE (running client):** `/opt/trinitycore/Interface/AddOns/` +- **COMMIT FROM:** `/opt/trinitycore/TrinityCore/araxiaonline/client_addons/` + +### Commit Workflow +Before committing, sync files from running locations to repo locations: ```bash -# Example sync command -cp -r /opt/trinitycore/server/lua_scripts/* /opt/trinitycore/TrinityCore/araxiaonline/lua_scripts/ +# Sync lua_scripts +cp -r /opt/trinitycore/lua_scripts/* /opt/trinitycore/TrinityCore/araxiaonline/lua_scripts/ + +# Sync client addons +cp -r /opt/trinitycore/Interface/AddOns/AraxiaTrinityAdmin/* /opt/trinitycore/TrinityCore/araxiaonline/client_addons/AraxiaTrinityAdmin/ +cp -r /opt/trinitycore/Interface/AddOns/AMS_Client/* /opt/trinitycore/TrinityCore/araxiaonline/client_addons/AMS_Client/ ``` -**AI assistants: Do this automatically when committing session work.** +**AI assistants: ALWAYS edit the running locations, then sync before committing.** + +--- + +## ⚠️ NEVER Defer Documentation + +**Documentation is CRITICAL and must never be skipped or deferred.** + +Why: +- AI assistants have limited context windows +- Documentation is how future sessions understand past decisions +- Without docs, work gets duplicated or misunderstood +- Code comments, READMEs, and wiki pages are essential deliverables + +**Every feature/change must include:** +1. Code comments explaining non-obvious decisions +2. Updated README if user-facing +3. Wiki/AGENTS.md updates for AI context +4. Inline examples where helpful --- diff --git a/araxiaonline/lua_scripts/Smallfolk.lua b/araxiaonline/lua_scripts/Smallfolk.lua new file mode 100644 index 0000000000..93f9564fb2 --- /dev/null +++ b/araxiaonline/lua_scripts/Smallfolk.lua @@ -0,0 +1,207 @@ +--[[ +Smallfolk - A Lua serialization library +https://github.com/gvx/Smallfolk +License: ISC +]] + +local Smallfolk = {} + +local floor = math.floor +local pairs = pairs +local type = type +local concat = table.concat +local tostring = tostring +local tonumber = tonumber +local s_byte = string.byte +local s_char = string.char +local s_sub = string.sub +local s_gsub = string.gsub +local s_match = string.match + +local function is_array(t) + local n = #t + for k in pairs(t) do + if type(k) ~= "number" or k < 1 or k > n or floor(k) ~= k then + return false + end + end + return true +end + +local function escape_string(s) + return s_gsub(s, '[%c"\\]', function(c) + local n = s_byte(c) + if c == '"' then return '\\"' + elseif c == '\\' then return '\\\\' + elseif c == '\n' then return '\\n' + elseif c == '\r' then return '\\r' + elseif c == '\t' then return '\\t' + else return '\\' .. n + end + end) +end + +local function dump_value(value, tables) + local t = type(value) + if t == "number" then + if value ~= value then + return "nan" + elseif value == 1/0 then + return "inf" + elseif value == -1/0 then + return "-inf" + else + return tostring(value) + end + elseif t == "string" then + return '"' .. escape_string(value) .. '"' + elseif t == "boolean" then + return value and "true" or "false" + elseif t == "nil" then + return "nil" + elseif t == "table" then + if tables[value] then + return "@" .. tables[value] + end + local n = (tables.n or 0) + 1 + tables[value] = n + tables.n = n + + local parts = {} + if is_array(value) then + for i = 1, #value do + parts[i] = dump_value(value[i], tables) + end + return "[" .. concat(parts, ",") .. "]" + else + local i = 1 + for k, v in pairs(value) do + parts[i] = dump_value(k, tables) .. ":" .. dump_value(v, tables) + i = i + 1 + end + return "{" .. concat(parts, ",") .. "}" + end + else + error("cannot serialize type " .. t) + end +end + +function Smallfolk.dumps(value) + return dump_value(value, {}) +end + +local function unescape_string(s) + return s_gsub(s, '\\(.)', function(c) + if c == 'n' then return '\n' + elseif c == 'r' then return '\r' + elseif c == 't' then return '\t' + elseif c == '"' then return '"' + elseif c == '\\' then return '\\' + else + local n = tonumber(c) + if n then return s_char(n) end + return c + end + end) +end + +local function parse_value(str, pos, tables) + local c = s_sub(str, pos, pos) + + if c == '"' then + local end_pos = pos + 1 + while true do + local ch = s_sub(str, end_pos, end_pos) + if ch == '"' then break + elseif ch == '\\' then end_pos = end_pos + 1 + elseif ch == '' then error("unterminated string") + end + end_pos = end_pos + 1 + end + return unescape_string(s_sub(str, pos + 1, end_pos - 1)), end_pos + 1 + elseif c == '[' then + local arr = {} + local n = #tables + 1 + tables[n] = arr + pos = pos + 1 + if s_sub(str, pos, pos) == ']' then + return arr, pos + 1 + end + local i = 1 + while true do + local val + val, pos = parse_value(str, pos, tables) + arr[i] = val + i = i + 1 + c = s_sub(str, pos, pos) + if c == ']' then return arr, pos + 1 + elseif c == ',' then pos = pos + 1 + else error("expected ',' or ']'") + end + end + elseif c == '{' then + local obj = {} + local n = #tables + 1 + tables[n] = obj + pos = pos + 1 + if s_sub(str, pos, pos) == '}' then + return obj, pos + 1 + end + while true do + local key, val + key, pos = parse_value(str, pos, tables) + if s_sub(str, pos, pos) ~= ':' then error("expected ':'") end + val, pos = parse_value(str, pos + 1, tables) + obj[key] = val + c = s_sub(str, pos, pos) + if c == '}' then return obj, pos + 1 + elseif c == ',' then pos = pos + 1 + else error("expected ',' or '}'") + end + end + elseif c == '@' then + local end_pos = pos + 1 + while s_match(s_sub(str, end_pos, end_pos), '%d') do + end_pos = end_pos + 1 + end + local ref = tonumber(s_sub(str, pos + 1, end_pos - 1)) + return tables[ref], end_pos + elseif s_sub(str, pos, pos + 3) == "true" then + return true, pos + 4 + elseif s_sub(str, pos, pos + 4) == "false" then + return false, pos + 5 + elseif s_sub(str, pos, pos + 2) == "nil" then + return nil, pos + 3 + elseif s_sub(str, pos, pos + 2) == "nan" then + return 0/0, pos + 3 + elseif s_sub(str, pos, pos + 2) == "inf" then + return 1/0, pos + 3 + elseif s_sub(str, pos, pos + 3) == "-inf" then + return -1/0, pos + 4 + else + local end_pos = pos + if s_sub(str, end_pos, end_pos) == '-' then + end_pos = end_pos + 1 + end + while s_match(s_sub(str, end_pos, end_pos), '[%d%.eE%+%-]') do + end_pos = end_pos + 1 + end + local num_str = s_sub(str, pos, end_pos - 1) + local num = tonumber(num_str) + if num then + return num, end_pos + else + error("unexpected character at position " .. pos .. ": " .. c) + end + end +end + +function Smallfolk.loads(str) + if str == nil or str == "" then + return nil + end + local val, _ = parse_value(str, 1, {}) + return val +end + +return Smallfolk diff --git a/araxiaonline/lua_scripts/aa_Smallfolk.lua b/araxiaonline/lua_scripts/aa_Smallfolk.lua new file mode 100644 index 0000000000..93f9564fb2 --- /dev/null +++ b/araxiaonline/lua_scripts/aa_Smallfolk.lua @@ -0,0 +1,207 @@ +--[[ +Smallfolk - A Lua serialization library +https://github.com/gvx/Smallfolk +License: ISC +]] + +local Smallfolk = {} + +local floor = math.floor +local pairs = pairs +local type = type +local concat = table.concat +local tostring = tostring +local tonumber = tonumber +local s_byte = string.byte +local s_char = string.char +local s_sub = string.sub +local s_gsub = string.gsub +local s_match = string.match + +local function is_array(t) + local n = #t + for k in pairs(t) do + if type(k) ~= "number" or k < 1 or k > n or floor(k) ~= k then + return false + end + end + return true +end + +local function escape_string(s) + return s_gsub(s, '[%c"\\]', function(c) + local n = s_byte(c) + if c == '"' then return '\\"' + elseif c == '\\' then return '\\\\' + elseif c == '\n' then return '\\n' + elseif c == '\r' then return '\\r' + elseif c == '\t' then return '\\t' + else return '\\' .. n + end + end) +end + +local function dump_value(value, tables) + local t = type(value) + if t == "number" then + if value ~= value then + return "nan" + elseif value == 1/0 then + return "inf" + elseif value == -1/0 then + return "-inf" + else + return tostring(value) + end + elseif t == "string" then + return '"' .. escape_string(value) .. '"' + elseif t == "boolean" then + return value and "true" or "false" + elseif t == "nil" then + return "nil" + elseif t == "table" then + if tables[value] then + return "@" .. tables[value] + end + local n = (tables.n or 0) + 1 + tables[value] = n + tables.n = n + + local parts = {} + if is_array(value) then + for i = 1, #value do + parts[i] = dump_value(value[i], tables) + end + return "[" .. concat(parts, ",") .. "]" + else + local i = 1 + for k, v in pairs(value) do + parts[i] = dump_value(k, tables) .. ":" .. dump_value(v, tables) + i = i + 1 + end + return "{" .. concat(parts, ",") .. "}" + end + else + error("cannot serialize type " .. t) + end +end + +function Smallfolk.dumps(value) + return dump_value(value, {}) +end + +local function unescape_string(s) + return s_gsub(s, '\\(.)', function(c) + if c == 'n' then return '\n' + elseif c == 'r' then return '\r' + elseif c == 't' then return '\t' + elseif c == '"' then return '"' + elseif c == '\\' then return '\\' + else + local n = tonumber(c) + if n then return s_char(n) end + return c + end + end) +end + +local function parse_value(str, pos, tables) + local c = s_sub(str, pos, pos) + + if c == '"' then + local end_pos = pos + 1 + while true do + local ch = s_sub(str, end_pos, end_pos) + if ch == '"' then break + elseif ch == '\\' then end_pos = end_pos + 1 + elseif ch == '' then error("unterminated string") + end + end_pos = end_pos + 1 + end + return unescape_string(s_sub(str, pos + 1, end_pos - 1)), end_pos + 1 + elseif c == '[' then + local arr = {} + local n = #tables + 1 + tables[n] = arr + pos = pos + 1 + if s_sub(str, pos, pos) == ']' then + return arr, pos + 1 + end + local i = 1 + while true do + local val + val, pos = parse_value(str, pos, tables) + arr[i] = val + i = i + 1 + c = s_sub(str, pos, pos) + if c == ']' then return arr, pos + 1 + elseif c == ',' then pos = pos + 1 + else error("expected ',' or ']'") + end + end + elseif c == '{' then + local obj = {} + local n = #tables + 1 + tables[n] = obj + pos = pos + 1 + if s_sub(str, pos, pos) == '}' then + return obj, pos + 1 + end + while true do + local key, val + key, pos = parse_value(str, pos, tables) + if s_sub(str, pos, pos) ~= ':' then error("expected ':'") end + val, pos = parse_value(str, pos + 1, tables) + obj[key] = val + c = s_sub(str, pos, pos) + if c == '}' then return obj, pos + 1 + elseif c == ',' then pos = pos + 1 + else error("expected ',' or '}'") + end + end + elseif c == '@' then + local end_pos = pos + 1 + while s_match(s_sub(str, end_pos, end_pos), '%d') do + end_pos = end_pos + 1 + end + local ref = tonumber(s_sub(str, pos + 1, end_pos - 1)) + return tables[ref], end_pos + elseif s_sub(str, pos, pos + 3) == "true" then + return true, pos + 4 + elseif s_sub(str, pos, pos + 4) == "false" then + return false, pos + 5 + elseif s_sub(str, pos, pos + 2) == "nil" then + return nil, pos + 3 + elseif s_sub(str, pos, pos + 2) == "nan" then + return 0/0, pos + 3 + elseif s_sub(str, pos, pos + 2) == "inf" then + return 1/0, pos + 3 + elseif s_sub(str, pos, pos + 3) == "-inf" then + return -1/0, pos + 4 + else + local end_pos = pos + if s_sub(str, end_pos, end_pos) == '-' then + end_pos = end_pos + 1 + end + while s_match(s_sub(str, end_pos, end_pos), '[%d%.eE%+%-]') do + end_pos = end_pos + 1 + end + local num_str = s_sub(str, pos, end_pos - 1) + local num = tonumber(num_str) + if num then + return num, end_pos + else + error("unexpected character at position " .. pos .. ": " .. c) + end + end +end + +function Smallfolk.loads(str) + if str == nil or str == "" then + return nil + end + local val, _ = parse_value(str, 1, {}) + return val +end + +return Smallfolk diff --git a/araxiaonline/lua_scripts/ab_AMS_Server/AMS_Server.lua b/araxiaonline/lua_scripts/ab_AMS_Server/AMS_Server.lua new file mode 100755 index 0000000000..49ca8d0334 --- /dev/null +++ b/araxiaonline/lua_scripts/ab_AMS_Server/AMS_Server.lua @@ -0,0 +1,446 @@ +--[[ + Araxia Messaging System (AMS) - Server Side + + A lightweight, modern client-server messaging library for TrinityCore 11.2.5 + Inspired by Rochet2's AIO but built for modern WoW and simplified for our needs. + + Features: + - Handler registration system + - Smallfolk serialization + - Message splitting for long messages + - Request/response pattern + - Error isolation via pcall + + Usage: + -- Register a handler + AMS.RegisterHandler("NPC_SEARCH", function(player, data) + local results = QueryDatabase(data.searchTerm) + AMS.Send(player, "NPC_SEARCH_RESULT", results) + end) + + -- Send a message (fluent API) + AMS.Msg():Add("UPDATE_NPC", {npcID = 1234, hp = 5000}):Send(player) +]] + +-- ============================================================================ +-- Configuration +-- ============================================================================ + +local AMS_VERSION = "1.0.0-alpha" +local AMS_PREFIX = "AMS" +local AMS_DEBUG = true -- Set to true for debugging + +-- Message limits (server can send larger messages than client) +-- Client: 255 bytes max, Server: ~2560 bytes safe on most patches +local AMS_MAX_MSG_LENGTH = 2500 - #AMS_PREFIX - 10 -- Reserve space for overhead + +-- Message ID tracking +local AMS_MSG_MIN_ID = 1 +local AMS_MSG_MAX_ID = 65535 -- 16-bit ID + +-- ============================================================================ +-- Dependencies +-- ============================================================================ + +-- Smallfolk for serialization +local Smallfolk = require("ab_AMS_Server.smallfolk") + +-- ============================================================================ +-- Core AMS Table +-- ============================================================================ + +-- NOTE: Cross-state data persistence is now handled by C++ ElunaSharedData +-- using SetSharedData()/GetSharedData() functions. The AMS table here is +-- only for local state within this Eluna instance. + +AMS = { + version = AMS_VERSION, + handlers = {}, + playerData = {}, -- Local cache (C++ shared data is authoritative) + nextMessageID = {}, -- Per-player message ID counter +} + +-- ============================================================================ +-- Utility Functions +-- ============================================================================ + +-- Debug logging (verbose) +local function Debug(...) + if AMS_DEBUG then + print("[AMS Server]", ...) + end +end + +-- Info logging (always shown) +local function Info(...) + print("[AMS Server]", ...) +end + +-- Error logging (always shown) +local function Error(...) + print("[AMS Server] ERROR:", ...) +end + +-- Encode number as 4-character hex string (text-safe) +local function NumberToHex(num) + return string.format("%04X", num) +end + +-- Decode hex string to number +local function HexToNumber(str) + if #str < 4 then return 0 end + return tonumber(str:sub(1, 4), 16) or 0 +end + +-- Get next message ID for a player +local function GetNextMessageID(playerGUID) + if not AMS.nextMessageID[playerGUID] then + AMS.nextMessageID[playerGUID] = AMS_MSG_MIN_ID + end + + local msgID = AMS.nextMessageID[playerGUID] + + -- Increment and wrap around if needed + if msgID >= AMS_MSG_MAX_ID then + AMS.nextMessageID[playerGUID] = AMS_MSG_MIN_ID + else + AMS.nextMessageID[playerGUID] = msgID + 1 + end + + return msgID +end + +-- ============================================================================ +-- Message Sending +-- ============================================================================ + +-- Send a raw addon message (handles splitting if needed) +local function SendAddonMessage(player, message) + local playerGUID = player:GetGUIDLow() + + Debug("Sending message to", player:GetName(), "length:", #message) + + -- Short message - send directly + if #message <= AMS_MAX_MSG_LENGTH then + -- Prefix with marker for short message (ID = 0000, parts = 0000, partID = 0000) + local packet = NumberToHex(0) .. NumberToHex(0) .. NumberToHex(0) .. message + player:SendAddonMessage(AMS_PREFIX, packet, 7, player) + return + end + + -- Long message - split into chunks + local msgID = GetNextMessageID(playerGUID) + local chunkSize = AMS_MAX_MSG_LENGTH - 12 -- Reserve 12 bytes for hex header (3 * 4 chars) + local totalParts = math.ceil(#message / chunkSize) + + Debug("Splitting message ID", msgID, "into", totalParts, "parts") + + for partID = 1, totalParts do + local startPos = (partID - 1) * chunkSize + 1 + local endPos = math.min(partID * chunkSize, #message) + local chunk = message:sub(startPos, endPos) + + -- Header: msgID (4 hex) + totalParts (4 hex) + partID (4 hex) = 12 chars + local header = NumberToHex(msgID) .. + NumberToHex(totalParts) .. + NumberToHex(partID) + + local packet = header .. chunk + player:SendAddonMessage(AMS_PREFIX, packet, 7, player) + end +end + +-- Serialize and send data +function AMS.Send(player, handlerName, data) + if type(player) ~= 'userdata' then + Debug("ERROR: Send requires a player object") + return + end + + -- Create message block + local messageBlock = {handlerName, data} + + -- Serialize using Smallfolk + local serialized = Smallfolk.dumps({messageBlock}) + + if not serialized then + Debug("ERROR: Failed to serialize message for", handlerName) + return + end + + SendAddonMessage(player, serialized) +end + +-- ============================================================================ +-- Message Class (Fluent API) +-- ============================================================================ + +local MessageMT = {} +MessageMT.__index = MessageMT + +-- Add a handler call to the message +function MessageMT:Add(handlerName, data) + table.insert(self.blocks, {handlerName, data}) + return self -- Fluent API +end + +-- Send the message to player(s) +function MessageMT:Send(player, ...) + if #self.blocks == 0 then + Error("Attempted to send empty message") + return + end + + -- Serialize all blocks + local serialized = Smallfolk.dumps(self.blocks) + + if not serialized then + Error("Failed to serialize message") + return + end + + -- Send to primary player + SendAddonMessage(player, serialized) + + -- Send to additional players if provided + for i = 1, select('#', ...) do + local additionalPlayer = select(i, ...) + SendAddonMessage(additionalPlayer, serialized) + end +end + +-- Check if message has content +function MessageMT:HasContent() + return #self.blocks > 0 +end + +-- Create a new message +function AMS.Msg() + local msg = { + blocks = {} + } + setmetatable(msg, MessageMT) + return msg +end + +-- ============================================================================ +-- Message Receiving & Reassembly +-- ============================================================================ + +-- Handle incoming message part (reassemble if split) +local function HandleIncomingMessage(player, rawMessage) + local playerGUID = tostring(player:GetGUIDLow()) + local dataKey = "AMS_PLAYER_" .. playerGUID + + -- Use C++ shared data for cross-state persistence + -- C++ stores strings, so we serialize with Smallfolk + local serializedData = GetSharedData(dataKey) + local playerData + if serializedData then + local success, decoded = pcall(Smallfolk.loads, serializedData) + if success and type(decoded) == 'table' then + playerData = decoded + else + playerData = { pendingMessages = {} } + end + else + playerData = { pendingMessages = {} } + end + + -- Parse header (12 chars hex: msgID + totalParts + partID) + if #rawMessage < 12 then + Error("Message too short for header, length:", #rawMessage) + return nil + end + + local hexMsgID = rawMessage:sub(1, 4) + local hexTotalParts = rawMessage:sub(5, 8) + local hexPartID = rawMessage:sub(9, 12) + + local msgID = HexToNumber(hexMsgID) + local totalParts = HexToNumber(hexTotalParts) + local partID = HexToNumber(hexPartID) + local payload = rawMessage:sub(13) + + Debug(string.format("Parsed: msgID=%d, totalParts=%d, partID=%d", msgID, totalParts, partID)) + Debug("Received part", partID, "of", totalParts, "for message ID", msgID) + + -- Short message (msgID = 0, totalParts = 0, partID = 0) + if msgID == 0 and totalParts == 0 and partID == 0 then + Debug("Received short message, length:", #payload) + return payload -- Return payload for processing + end + + -- Long message part + Debug("Received part", partID, "of", totalParts, "for message ID", msgID) + + -- Initialize message tracking + if not playerData.pendingMessages[msgID] then + Debug("Creating new pendingMessages entry for msgID", msgID) + playerData.pendingMessages[msgID] = { + parts = {}, + totalParts = totalParts, + receivedParts = 0 + } + end + + local msgData = playerData.pendingMessages[msgID] + + -- Store the part + if not msgData.parts[partID] then + msgData.parts[partID] = payload + msgData.receivedParts = msgData.receivedParts + 1 + Debug(string.format("Stored part %d, now have %d of %d parts", partID, msgData.receivedParts, msgData.totalParts)) + else + Debug(string.format("Duplicate part %d received, ignoring", partID)) + end + + -- Save updated data back to C++ shared storage (serialize first) + SetSharedData(dataKey, Smallfolk.dumps(playerData)) + + -- Check if we have all parts + if msgData.receivedParts == msgData.totalParts then + Debug("Message ID", msgID, "complete, reassembling...") + + -- Reassemble message (parts are 1-indexed from client) + local completeParts = {} + for i = 1, msgData.totalParts do + if msgData.parts[i] then + table.insert(completeParts, msgData.parts[i]) + else + Error("Missing part", i, "for message", msgID) + playerData.pendingMessages[msgID] = nil + SetSharedData(dataKey, Smallfolk.dumps(playerData)) + return nil + end + end + local completeMessage = table.concat(completeParts) + + Debug("Reassembled message length:", #completeMessage) + + -- Clean up this message from pending + playerData.pendingMessages[msgID] = nil + SetSharedData(dataKey, Smallfolk.dumps(playerData)) + + return completeMessage -- Return complete message for processing + end + + -- Message incomplete, wait for more parts + return nil +end + +-- Process a complete message (deserialize and dispatch handlers) +local function ProcessMessage(player, serializedMessage) + -- Deserialize using Smallfolk + local success, blocks = pcall(Smallfolk.loads, serializedMessage) + + if not success or type(blocks) ~= 'table' then + Error("Failed to deserialize message:", blocks) + return + end + + Debug("Processing", #blocks, "message block(s)") + + -- Process each block + for i, block in ipairs(blocks) do + if type(block) == 'table' and #block >= 1 then + local handlerName = block[1] + local data = block[2] + + -- Find and call handler + local handler = AMS.handlers[handlerName] + if handler then + Debug("Calling handler:", handlerName) + + -- Use pcall to isolate errors + local success, err = pcall(handler, player, data) + if not success then + Error("Handler", handlerName, "failed:", err) + end + else + Error("No handler registered for", handlerName) + end + end + end +end + +-- ============================================================================ +-- Handler Registration +-- ============================================================================ + +-- Register a message handler +function AMS.RegisterHandler(handlerName, callback) + if type(handlerName) ~= 'string' then + Error("Handler name must be a string") + return + end + + if type(callback) ~= 'function' then + Error("Handler callback must be a function") + return + end + + if AMS.handlers[handlerName] then + Info("Overwriting handler:", handlerName) + end + + AMS.handlers[handlerName] = callback + Info("Registered handler:", handlerName) +end + +-- ============================================================================ +-- Event Hooks (using shared global data across all states) +-- ============================================================================ + +local stateMapId = GetStateMapId() +print(string.format("[AMS Server] Registering event handlers in state %d", stateMapId)) + +-- Handle incoming addon messages from clients +RegisterServerEvent(30, function(event, player, msgType, prefix, message, target) + if prefix ~= AMS_PREFIX then + return -- Not our message + end + + Debug(string.format("Received addon message from %s, length: %d", player:GetName(), #message)) + + -- Handle message reassembly (uses C++ ElunaSharedData) + local completeMessage = HandleIncomingMessage(player, message) + + if completeMessage then + Debug("Message reassembled, length:", #completeMessage) + -- Process the complete message + ProcessMessage(player, completeMessage) + else + Debug("Waiting for more message parts...") + end +end) + +-- Clean up player data on logout +RegisterPlayerEvent(4, function(event, player) + local playerGUID = tostring(player:GetGUIDLow()) + local dataKey = "AMS_PLAYER_" .. playerGUID + + -- Clean up C++ shared data + if HasSharedData(dataKey) then + Debug("Cleaning up shared data for", player:GetName()) + ClearSharedData(dataKey) + end + + -- Also clean up local references (legacy) + if AMS.playerData[playerGUID] then + AMS.playerData[playerGUID] = nil + AMS.nextMessageID[playerGUID] = nil + end +end) + +print(string.format("[AMS Server] Event handlers registered successfully in state %d", stateMapId)) + +-- ============================================================================ +-- Initialization +-- ============================================================================ + +print("[AMS Server] Initialization complete - AMS Server v" .. AMS_VERSION) +Info("AMS Server v" .. AMS_VERSION .. " initialized") + +-- Export for testing +return AMS diff --git a/araxiaonline/lua_scripts/ab_AMS_Server/README.md b/araxiaonline/lua_scripts/ab_AMS_Server/README.md new file mode 100755 index 0000000000..76a7f95396 --- /dev/null +++ b/araxiaonline/lua_scripts/ab_AMS_Server/README.md @@ -0,0 +1,94 @@ +# AMS Server - Araxia Messaging System (Server Side) + +**Version:** 1.0.0-alpha + +A lightweight, modern client-server messaging library for TrinityCore 11.2.5 with Eluna. + +## Folder Structure + +``` +AMS_Server/ +├── AMS_Server.lua - Main AMS server implementation +├── smallfolk.lua - Serialization library dependency +└── README.md - This file +``` + +## Usage + +The AMS_Server module is loaded automatically via: + +```lua +require("AMS_Server.AMS_Server") -- Loads AMS_Server/AMS_Server.lua +``` + +Note: Named `AMS_Server.lua` instead of `init.lua` to avoid name collision with the main `init.lua` file. + +## Components + +### AMS_Server.lua +Main AMS server implementation with: +- Handler registration system +- Message splitting/reassembly +- Smallfolk serialization integration +- Request/response patterns +- Error isolation via pcall + +### smallfolk.lua +Lightweight Lua serialization library used for encoding/decoding messages between client and server. + +## API + +### Registering Handlers + +```lua +AMS.RegisterHandler("HANDLER_NAME", function(player, data) + -- Process request + local response = ProcessData(data) + + -- Send response + AMS.Send(player, "RESPONSE_NAME", response) +end) +``` + +### Sending Messages + +**Simple send:** +```lua +AMS.Send(player, "UPDATE_NPC", {npcID = 1234, hp = 5000}) +``` + +**Fluent API (multiple handlers in one message):** +```lua +AMS.Msg() + :Add("UPDATE_NPC", {npcID = 1234}) + :Add("UPDATE_QUEST", {questID = 5678}) + :Send(player) +``` + +## Features + +- ✅ Automatic message splitting for long payloads +- ✅ Message reassembly on both ends +- ✅ Handler registration system +- ✅ Error isolation with pcall +- ✅ Player disconnect cleanup +- ✅ Debug logging (toggle via AMS_DEBUG) + +## Dependencies + +- **TrinityCore 11.2.5** with Eluna +- **Smallfolk** - Included in this folder + +## Related + +- **Client:** `Interface/AddOns/AMS_Client/` +- **Documentation:** `araxiaonline/araxia_docs/ams_system/` + +## Version History + +**1.0.0-alpha** (Current) +- Initial release +- Handler registration +- Message splitting/reassembly +- Smallfolk serialization +- Error handling diff --git a/araxiaonline/lua_scripts/ab_AMS_Server/smallfolk.lua b/araxiaonline/lua_scripts/ab_AMS_Server/smallfolk.lua new file mode 100755 index 0000000000..daff23bdac --- /dev/null +++ b/araxiaonline/lua_scripts/ab_AMS_Server/smallfolk.lua @@ -0,0 +1,203 @@ +-- Smallfolk serialization library +-- Embedded dependency for AMS + +local M = {} + +local expect_object, dump_object +local error, tostring, pairs, type, floor, huge, concat = error, tostring, pairs, type, math.floor, math.huge, table.concat + +local dump_type = {} + +function dump_type:string(nmemo, memo, acc) + local nacc = #acc + acc[nacc + 1] = '"' + acc[nacc + 2] = self:gsub('"', '""') + acc[nacc + 3] = '"' + return nmemo +end + +function dump_type:number(nmemo, memo, acc) + acc[#acc + 1] = ("%.17g"):format(self) + return nmemo +end + +function dump_type:table(nmemo, memo, acc) + memo[self] = nmemo + acc[#acc + 1] = '{' + local nself = #self + for i = 1, nself do + nmemo = dump_object(self[i], nmemo, memo, acc) + acc[#acc + 1] = ',' + end + for k, v in pairs(self) do + if type(k) ~= 'number' or floor(k) ~= k or k < 1 or k > nself then + nmemo = dump_object(k, nmemo, memo, acc) + acc[#acc + 1] = ':' + nmemo = dump_object(v, nmemo, memo, acc) + acc[#acc + 1] = ',' + end + end + acc[#acc] = acc[#acc] == '{' and '{}' or '}' + return nmemo +end + +function dump_object(object, nmemo, memo, acc) + if object == true then + acc[#acc + 1] = 't' + elseif object == false then + acc[#acc + 1] = 'f' + elseif object == nil then + acc[#acc + 1] = 'n' + elseif object ~= object then + if (''..object):sub(1,1) == '-' then + acc[#acc + 1] = 'N' + else + acc[#acc + 1] = 'Q' + end + elseif object == huge then + acc[#acc + 1] = 'I' + elseif object == -huge then + acc[#acc + 1] = 'i' + else + local t = type(object) + if not dump_type[t] then + error('cannot dump type ' .. t) + end + return dump_type[t](object, nmemo, memo, acc) + end + return nmemo +end + +function M.dumps(object) + local nmemo = 0 + local memo = {} + local acc = {} + dump_object(object, nmemo, memo, acc) + return concat(acc) +end + +local function invalid(i) + error('invalid input at position ' .. i) +end + +local nonzero_digit = {['1'] = true, ['2'] = true, ['3'] = true, ['4'] = true, ['5'] = true, ['6'] = true, ['7'] = true, ['8'] = true, ['9'] = true} +local is_digit = {['0'] = true, ['1'] = true, ['2'] = true, ['3'] = true, ['4'] = true, ['5'] = true, ['6'] = true, ['7'] = true, ['8'] = true, ['9'] = true} +local function expect_number(string, start) + local i = start + local head = string:sub(i, i) + if head == '-' then + i = i + 1 + head = string:sub(i, i) + end + if nonzero_digit[head] then + repeat + i = i + 1 + head = string:sub(i, i) + until not is_digit[head] + elseif head == '0' then + i = i + 1 + head = string:sub(i, i) + else + invalid(i) + end + if head == '.' then + local oldi = i + repeat + i = i + 1 + head = string:sub(i, i) + until not is_digit[head] + if i == oldi + 1 then + invalid(i) + end + end + if head == 'e' or head == 'E' then + i = i + 1 + head = string:sub(i, i) + if head == '+' or head == '-' then + i = i + 1 + head = string:sub(i, i) + end + if not is_digit[head] then + invalid(i) + end + repeat + i = i + 1 + head = string:sub(i, i) + until not is_digit[head] + end + return tonumber(string:sub(start, i - 1)), i +end + +local expect_object_head = { + t = function(string, i) return true, i end, + f = function(string, i) return false, i end, + n = function(string, i) return nil, i end, + Q = function(string, i) return -(0/0), i end, + N = function(string, i) return 0/0, i end, + I = function(string, i) return 1/0, i end, + i = function(string, i) return -1/0, i end, + ['"'] = function(string, i) + local nexti = i - 1 + repeat + nexti = string:find('"', nexti + 1, true) + 1 + until string:sub(nexti, nexti) ~= '"' + return string:sub(i, nexti - 2):gsub('""', '"'), nexti + end, + ['0'] = function(string, i) + return expect_number(string, i - 1) + end, + ['{'] = function(string, i, tables) + local nt, k, v = {} + local j = 1 + tables[#tables + 1] = nt + if string:sub(i, i) == '}' then + return nt, i + 1 + end + while true do + k, i = expect_object(string, i, tables) + if string:sub(i, i) == ':' then + v, i = expect_object(string, i + 1, tables) + nt[k] = v + else + nt[j] = k + j = j + 1 + end + local head = string:sub(i, i) + if head == ',' then + i = i + 1 + elseif head == '}' then + return nt, i + 1 + else + invalid(i) + end + end + end, +} +expect_object_head['1'] = expect_object_head['0'] +expect_object_head['2'] = expect_object_head['0'] +expect_object_head['3'] = expect_object_head['0'] +expect_object_head['4'] = expect_object_head['0'] +expect_object_head['5'] = expect_object_head['0'] +expect_object_head['6'] = expect_object_head['0'] +expect_object_head['7'] = expect_object_head['0'] +expect_object_head['8'] = expect_object_head['0'] +expect_object_head['9'] = expect_object_head['0'] +expect_object_head['-'] = expect_object_head['0'] +expect_object_head['.'] = expect_object_head['0'] + +expect_object = function(string, i, tables) + local head = string:sub(i, i) + if expect_object_head[head] then + return expect_object_head[head](string, i + 1, tables) + end + invalid(i) +end + +function M.loads(string, maxsize) + if #string > (maxsize or 10000) then + error 'input too large' + end + return (expect_object(string, 1, {})) +end + +return M diff --git a/araxiaonline/lua_scripts/ac_ams_test_handlers.lua b/araxiaonline/lua_scripts/ac_ams_test_handlers.lua new file mode 100755 index 0000000000..cd8b549264 --- /dev/null +++ b/araxiaonline/lua_scripts/ac_ams_test_handlers.lua @@ -0,0 +1,359 @@ +--[[ + AMS Test Handlers - Server Side + + Comprehensive test suite for validating AMS client-server messaging. + Handlers for various test scenarios including echo, data types, + performance, error handling, and server-push tests. + + See: araxiaonline/araxia_docs/admin_npcdata/AMS_TESTSUITE.md +]] + +print("[AMS] Loading test handlers...") + +-- Ensure AMS is loaded +if not AMS then + print("[AMS] ERROR: AMS not loaded! Test handlers will not work.") + return +end + +-- Test statistics +local testStats = { + echoCount = 0, + typeTestCount = 0, + errorTestCount = 0, + largePayloadCount = 0, + totalMessagesReceived = 0, + startTime = os.time() +} + +-- ============================================================================ +-- Helper Functions +-- ============================================================================ + +-- Generate a large test payload +local function GenerateLargePayload(size) + local chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789" + local payload = {} + for i = 1, size do + local idx = math.random(1, #chars) + payload[i] = chars:sub(idx, idx) + end + return table.concat(payload) +end + +-- Calculate simple hash of string (for comparison) +local function SimpleHash(str) + local hash = 0 + for i = 1, #str do + hash = (hash * 31 + string.byte(str, i)) % 0xFFFFFFFF + end + return hash +end + +-- Validate data types +local function ValidateDataTypes(data) + local results = { + success = true, + validations = {} + } + + if type(data.string) ~= "string" then + results.success = false + table.insert(results.validations, "string type failed") + end + + if type(data.number) ~= "number" then + results.success = false + table.insert(results.validations, "number type failed") + end + + if type(data.float) ~= "number" then + results.success = false + table.insert(results.validations, "float type failed") + end + + if type(data.boolean) ~= "boolean" then + results.success = false + table.insert(results.validations, "boolean type failed") + end + + if data.nilValue ~= nil then + results.success = false + table.insert(results.validations, "nil type failed") + end + + if type(data.table) ~= "table" then + results.success = false + table.insert(results.validations, "table type failed") + end + + if type(data.array) ~= "table" then + results.success = false + table.insert(results.validations, "array type failed") + end + + if results.success then + table.insert(results.validations, "All data types validated successfully") + end + + return results +end + +-- ============================================================================ +-- Test Handlers +-- ============================================================================ + +-- TEST_ECHO: Simple echo test (round-trip) +AMS.RegisterHandler("TEST_ECHO", function(player, data) + testStats.echoCount = testStats.echoCount + 1 + testStats.totalMessagesReceived = testStats.totalMessagesReceived + 1 + + print("[AMS] ECHO test from", player:GetName(), "- Message:", data.message) + + -- Echo the data back with server timestamp + local response = { + message = data.message, + clientTimestamp = data.timestamp, + serverTimestamp = os.time(), + echoCount = testStats.echoCount + } + + AMS.Send(player, "TEST_ECHO_RESPONSE", response) +end) + +-- TEST_TYPES: Test all Lua data types +AMS.RegisterHandler("TEST_TYPES", function(player, data) + testStats.typeTestCount = testStats.typeTestCount + 1 + testStats.totalMessagesReceived = testStats.totalMessagesReceived + 1 + + print("[AMS] TYPE test from", player:GetName()) + + -- Validate the received data types + local validation = ValidateDataTypes(data) + + -- Echo back the data with validation results + local response = { + receivedData = data, + validation = validation, + serverTimestamp = os.time() + } + + AMS.Send(player, "TEST_TYPES_RESPONSE", response) +end) + +-- TEST_LARGE_PAYLOAD: Test message splitting and reassembly +AMS.RegisterHandler("TEST_LARGE_PAYLOAD", function(player, data) + testStats.largePayloadCount = testStats.largePayloadCount + 1 + testStats.totalMessagesReceived = testStats.totalMessagesReceived + 1 + + print("[AMS] LARGE_PAYLOAD test from", player:GetName(), "- Size:", #data.payload, "bytes") + + -- Calculate hash of received payload + local receivedHash = SimpleHash(data.payload) + + -- Generate a large response payload + local responsePayload = GenerateLargePayload(data.responseSize or 3000) + local responseHash = SimpleHash(responsePayload) + + local response = { + receivedSize = #data.payload, + receivedHash = receivedHash, + expectedHash = data.expectedHash, + hashMatch = (receivedHash == data.expectedHash), + responsePayload = responsePayload, + responseHash = responseHash, + serverTimestamp = os.time() + } + + AMS.Send(player, "TEST_LARGE_PAYLOAD_RESPONSE", response) +end) + +-- TEST_RAPID_FIRE: Receive many messages rapidly +AMS.RegisterHandler("TEST_RAPID_FIRE", function(player, data) + testStats.totalMessagesReceived = testStats.totalMessagesReceived + 1 + + -- Only log every 10th message to avoid spam + if data.messageId % 10 == 0 then + print("[AMS] RAPID_FIRE progress:", data.messageId, "of", data.totalMessages) + end + + -- Send acknowledgment back + local response = { + messageId = data.messageId, + serverTimestamp = os.time() + } + + AMS.Send(player, "TEST_RAPID_FIRE_ACK", response) + + -- If this was the last message, send completion + if data.messageId == data.totalMessages then + print("[AMS] RAPID_FIRE complete:", data.totalMessages, "messages received") + AMS.Send(player, "TEST_RAPID_FIRE_COMPLETE", { + totalMessages = data.totalMessages, + totalReceived = testStats.totalMessagesReceived + }) + end +end) + +-- TEST_REQUEST_PUSH: Client requests server to push data +AMS.RegisterHandler("TEST_REQUEST_PUSH", function(player, data) + testStats.totalMessagesReceived = testStats.totalMessagesReceived + 1 + + print("[AMS] REQUEST_PUSH from", player:GetName(), "- Count:", data.pushCount) + + -- Send multiple server-initiated messages + for i = 1, data.pushCount do + local pushData = { + pushNumber = i, + totalPushes = data.pushCount, + serverTimestamp = os.time(), + randomData = math.random(1, 1000), + message = "Server push #" .. i + } + + AMS.Send(player, "TEST_SERVER_PUSH", pushData) + end + + -- Send completion message + AMS.Send(player, "TEST_REQUEST_PUSH_COMPLETE", { + pushesSent = data.pushCount + }) +end) + +-- TEST_ERROR_HANDLING: Test various error scenarios +AMS.RegisterHandler("TEST_ERROR_HANDLING", function(player, data) + testStats.errorTestCount = testStats.errorTestCount + 1 + testStats.totalMessagesReceived = testStats.totalMessagesReceived + 1 + + print("[AMS] ERROR_HANDLING test from", player:GetName(), "- Type:", data.errorType) + + if data.errorType == "throw" then + -- This should be caught by AMS's pcall wrapper + error("Intentional test error!") + + elseif data.errorType == "invalid" then + -- Send back invalid data (nil handler name) + AMS.Send(player, nil, {error = "invalid handler name"}) + + elseif data.errorType == "timeout" then + -- Simulate slow processing (don't send response immediately) + print("[AMS] Simulating timeout (no response)") + -- Don't send response + + else + -- Unknown error type - send response + AMS.Send(player, "TEST_ERROR_HANDLING_RESPONSE", { + success = false, + error = "Unknown error type: " .. tostring(data.errorType) + }) + end +end) + +-- TEST_PERFORMANCE: Measure round-trip performance +AMS.RegisterHandler("TEST_PERFORMANCE", function(player, data) + testStats.totalMessagesReceived = testStats.totalMessagesReceived + 1 + + local serverTime = os.time() + + local response = { + clientStartTime = data.startTime, + serverReceiveTime = serverTime, + testIteration = data.iteration, + totalIterations = data.totalIterations + } + + AMS.Send(player, "TEST_PERFORMANCE_RESPONSE", response) + + -- Log every 25th iteration + if data.iteration % 25 == 0 then + print("[AMS] PERFORMANCE test progress:", data.iteration, "of", data.totalIterations) + end +end) + +-- TEST_NESTED_DATA: Test deeply nested table structures +AMS.RegisterHandler("TEST_NESTED_DATA", function(player, data) + testStats.totalMessagesReceived = testStats.totalMessagesReceived + 1 + + print("[AMS] NESTED_DATA test from", player:GetName(), "- Depth:", data.depth) + + -- Validate nested structure + local function countDepth(tbl, currentDepth) + currentDepth = currentDepth or 0 + if type(tbl) ~= "table" then + return currentDepth + end + local maxDepth = currentDepth + for k, v in pairs(tbl) do + if type(v) == "table" then + local depth = countDepth(v, currentDepth + 1) + if depth > maxDepth then + maxDepth = depth + end + end + end + return maxDepth + end + + -- countDepth counts levels below root, add 1 to include root table + local measuredDepth = countDepth(data.nestedData) + 1 + + local response = { + expectedDepth = data.depth, + measuredDepth = measuredDepth, + depthMatch = (measuredDepth == data.depth), + receivedData = data.nestedData + } + + AMS.Send(player, "TEST_NESTED_DATA_RESPONSE", response) +end) + +-- TEST_GET_STATS: Get test statistics +AMS.RegisterHandler("TEST_GET_STATS", function(player, data) + print("[AMS] GET_STATS request from", player:GetName()) + + local uptime = os.time() - testStats.startTime + + local response = { + stats = testStats, + uptime = uptime, + messagesPerMinute = uptime > 0 and (testStats.totalMessagesReceived / (uptime / 60)) or 0 + } + + AMS.Send(player, "TEST_GET_STATS_RESPONSE", response) +end) + +-- TEST_RESET_STATS: Reset test statistics +AMS.RegisterHandler("TEST_RESET_STATS", function(player, data) + print("[AMS] RESET_STATS request from", player:GetName()) + + testStats = { + echoCount = 0, + typeTestCount = 0, + errorTestCount = 0, + largePayloadCount = 0, + totalMessagesReceived = 0, + startTime = os.time() + } + + AMS.Send(player, "TEST_RESET_STATS_RESPONSE", { + success = true, + message = "Test statistics reset" + }) +end) + +-- ============================================================================ +-- Initialization +-- ============================================================================ + +print("[AMS] Registered test handlers:") +print(" - TEST_ECHO") +print(" - TEST_TYPES") +print(" - TEST_LARGE_PAYLOAD") +print(" - TEST_RAPID_FIRE") +print(" - TEST_REQUEST_PUSH") +print(" - TEST_ERROR_HANDLING") +print(" - TEST_PERFORMANCE") +print(" - TEST_NESTED_DATA") +print(" - TEST_GET_STATS") +print(" - TEST_RESET_STATS") +print("[AMS] Test suite ready!") diff --git a/araxiaonline/lua_scripts/ad_admin_handlers.lua b/araxiaonline/lua_scripts/ad_admin_handlers.lua new file mode 100755 index 0000000000..fec8713360 --- /dev/null +++ b/araxiaonline/lua_scripts/ad_admin_handlers.lua @@ -0,0 +1,1475 @@ +--[[ + AraxiaTrinityAdmin Server Handlers + + Provides server-side data access for the AraxiaTrinityAdmin addon. + Uses AMS (Araxia Messaging System) for client-server communication. +]] + +print("[Admin Handlers] Loading...") +SetSharedData("debug_admin_handlers", "step1_loading") + +-- Check if AMS is available +if not AMS then + print("[Admin Handlers] AMS not available yet (will be loaded via init.lua dofile)") + SetSharedData("debug_admin_handlers", "step2_no_ams_returning") + return +end + +print("[Admin Handlers] AMS found, registering handlers...") +SetSharedData("debug_admin_handlers", "step3_ams_found") + +-- Load Smallfolk for serialization (used by shared data) +local Smallfolk = require("aa_Smallfolk") + +-- ============================================================================ +-- Configurable Display IDs (can be changed via shared data without recompile) +-- ============================================================================ + +-- Default display IDs - these can be overridden via SetSharedData +local DEFAULT_DISPLAYS = { + waypoint_marker = 1824, -- Elven Wisp (works in 11.2.5) + waypoint_highlight = 1824, -- Same as marker but scaled up (highlight via size) + spawn_marker = 31366, -- Green targeting circle +} + +-- Get a display ID, checking shared data first, then falling back to default +local function GetDisplayId(key) + local sharedKey = "config_display_" .. key + local value = GetSharedData(sharedKey) + if value and value ~= "" then + local num = tonumber(value) + if num then + return num + end + end + return DEFAULT_DISPLAYS[key] or 17188 +end + +-- Set a display ID in shared data (persists until server restart) +local function SetDisplayIdConfig(key, displayId) + local sharedKey = "config_display_" .. key + SetSharedData(sharedKey, tostring(displayId)) + print("[Admin Handlers] Display config updated: " .. key .. " = " .. displayId) +end + +-- Initialize display configs from defaults (only if not already set) +local function InitDisplayConfigs() + for key, defaultValue in pairs(DEFAULT_DISPLAYS) do + local sharedKey = "config_display_" .. key + if not HasSharedData(sharedKey) then + SetSharedData(sharedKey, tostring(defaultValue)) + end + end + print("[Admin Handlers] Display configs initialized") +end + +-- Initialize on load +InitDisplayConfigs() + +-- ============================================================================ +-- Helper Functions +-- ============================================================================ + +-- Convert copper to formatted gold string +local function FormatGold(copper) + if not copper or copper == 0 then + return "0 copper" + end + + local gold = math.floor(copper / 10000) + local silver = math.floor((copper % 10000) / 100) + local copperLeft = copper % 100 + + local parts = {} + if gold > 0 then table.insert(parts, gold .. "g") end + if silver > 0 then table.insert(parts, silver .. "s") end + if copperLeft > 0 then table.insert(parts, copperLeft .. "c") end + + return table.concat(parts, " ") +end + +-- Get spell school name +local function GetSpellSchoolName(school) + local schools = { + [0] = "Physical", + [1] = "Holy", + [2] = "Fire", + [3] = "Nature", + [4] = "Frost", + [5] = "Shadow", + [6] = "Arcane" + } + return schools[school] or "Unknown" +end + +-- Get stat name +local function GetStatName(stat) + local stats = { + [0] = "Strength", + [1] = "Agility", + [2] = "Stamina", + [3] = "Intellect", + [4] = "Spirit" + } + return stats[stat] or "Unknown" +end + +-- Get rank name +local function GetRankName(rank) + local ranks = { + [0] = "Normal", + [1] = "Elite", + [2] = "Rare Elite", + [3] = "Boss", + [4] = "Rare" + } + return ranks[rank] or "Unknown" +end + +-- ============================================================================ +-- NPC Data Handler +-- ============================================================================ + +AMS.RegisterHandler("GET_NPC_DATA", function(player, data) + local npcGUID = data.npcGUID + + if not npcGUID then + print("[Admin Handlers] GET_NPC_DATA: No GUID provided") + AMS.Send(player, "NPC_DATA_RESPONSE", { + success = false, + error = "No NPC GUID provided" + }) + return + end + + print("[Admin Handlers] GET_NPC_DATA: Fetching data for GUID:", npcGUID) + + local creature = player:GetSelection() + + if not creature then + print("[Admin Handlers] GET_NPC_DATA: No creature selected") + AMS.Send(player, "NPC_DATA_RESPONSE", { + success = false, + error = "No creature selected", + guid = npcGUID + }) + return + end + + -- Verify it's a creature (not a player or gameobject) + creature = creature:ToCreature() + if not creature then + print("[Admin Handlers] GET_NPC_DATA: Selected object is not a creature") + AMS.Send(player, "NPC_DATA_RESPONSE", { + success = false, + error = "Selected object is not a creature", + guid = npcGUID + }) + return + end + + -- Helper to safely call creature methods (converts userdata to string) + local function SafeGet(fn, default) + local success, result = pcall(fn) + if success then + -- Convert userdata to string to avoid serialization issues + if type(result) == "userdata" then + return tostring(result) + end + return result + else + print("[Admin Handlers] SafeGet failed:", result) + return default + end + end + + print("[Admin Handlers] Building response data...") + + -- Build response data using available Eluna methods + local response = { + success = true, + guid = npcGUID, + timestamp = os.time(), + + -- Basic Information + basic = { + entry = SafeGet(function() return creature:GetEntry() end, 0), + name = SafeGet(function() return creature:GetName() end, "Unknown"), + level = SafeGet(function() return creature:GetLevel() end, 0), + displayId = SafeGet(function() return creature:GetDisplayId() end, 0), + nativeDisplayId = SafeGet(function() return creature:GetNativeDisplayId() end, 0), + scale = SafeGet(function() return creature:GetScale() end, 1.0), + faction = SafeGet(function() return creature:GetFaction() end, 0), + creatureType = SafeGet(function() return creature:GetCreatureType() end, 0), + rank = SafeGet(function() return creature:GetRank() end, 0), + rankName = SafeGet(function() return GetRankName(creature:GetRank()) end, "Normal") + }, + + -- Health & Power + vitals = { + health = SafeGet(function() return creature:GetHealth() end, 0), + maxHealth = SafeGet(function() return creature:GetMaxHealth() end, 1), + healthPercent = SafeGet(function() return (creature:GetHealth() / creature:GetMaxHealth()) * 100 end, 0), + power = SafeGet(function() return creature:GetPower(0) end, 0), + maxPower = SafeGet(function() return creature:GetMaxPower(0) end, 0), + powerType = SafeGet(function() return creature:GetPowerType() end, 0) + }, + + -- Base Stats (STR, AGI, STA, INT, SPI) + stats = {}, + + -- Movement Speeds + speeds = { + walk = SafeGet(function() return creature:GetSpeed(0) end, 0), + run = SafeGet(function() return creature:GetSpeed(1) end, 0), + runBack = SafeGet(function() return creature:GetSpeed(2) end, 0), + swim = SafeGet(function() return creature:GetSpeed(3) end, 0), + swimBack = SafeGet(function() return creature:GetSpeed(4) end, 0), + fly = SafeGet(function() return creature:GetSpeed(6) end, 0), + flyBack = SafeGet(function() return creature:GetSpeed(7) end, 0) + }, + + -- Spell Power per school + spellPower = {}, + + -- AI & Scripts + scripts = { + aiName = SafeGet(function() return creature:GetAIName() end, ""), + scriptName = SafeGet(function() return creature:GetScriptName() end, ""), + scriptId = SafeGet(function() return creature:GetScriptId() end, 0) + }, + + -- Behavior + behavior = { + respawnDelay = SafeGet(function() return creature:GetRespawnDelay() end, 0), + wanderRadius = SafeGet(function() return creature:GetWanderRadius() end, 0), + isInCombat = SafeGet(function() return creature:IsInCombat() end, false), + isRegeneratingHealth = SafeGet(function() return creature:IsRegeneratingHealth() end, false), + isElite = SafeGet(function() return creature:IsElite() end, false), + isWorldBoss = SafeGet(function() return creature:IsWorldBoss() end, false), + isCivilian = SafeGet(function() return creature:IsCivilian() end, false) + }, + + -- Movement info (0=Idle, 1=Random, 2=Waypoint) + movement = { + defaultType = SafeGet(function() return creature:GetDefaultMovementType() end, 0), + currentType = SafeGet(function() return creature:GetMovementType() end, 0), + currentWaypointId = SafeGet(function() return creature:GetCurrentWaypointId() end, 0), + respawnTime = SafeGet(function() return creature:GetRespawnDelay() end, 0), + waypointPath = SafeGet(function() return creature:GetWaypointPathData() end, nil) + } + } + + print("[Admin Handlers] Response data built successfully") + + -- Use new safe C++ methods for combat stats (Phase 2) + print("[Admin Handlers] Getting combat stats via safe C++ methods...") + + -- Get armor using safe C++ method + response.combat = { + armor = SafeGet(function() return creature:GetArmor() end, 0), + baseAttackTime = SafeGet(function() return creature:GetBaseAttackTime(0) end, 0), + offhandAttackTime = SafeGet(function() return creature:GetBaseAttackTime(1) end, 0), + rangedAttackTime = SafeGet(function() return creature:GetBaseAttackTime(2) end, 0) + } + + -- Get resistances using safe C++ method (0=Physical/Armor, 1=Holy, 2=Fire, 3=Nature, 4=Frost, 5=Shadow, 6=Arcane) + response.resistances = {} + for i = 0, 6 do + response.resistances[GetSpellSchoolName(i)] = SafeGet(function() return creature:GetResistance(i) end, 0) + end + + -- Get creature template data using safe C++ method + -- Note: This can be large for some creatures (several KB), contributing to payload size + response.template = SafeGet(function() return creature:GetCreatureTemplateData() end, nil) + + -- Stats - using safe C++ GetStat method + for i = 0, 4 do + response.stats[GetStatName(i)] = SafeGet(function() return creature:GetStat(i) end, 0) + end + + -- Spell power - still skip as it crashes on creatures + for i = 0, 6 do + response.spellPower[GetSpellSchoolName(i)] = 0 + end + + -- Add note about available data + response.notes = { + "Phase 2: Stats, armor, resistances, attack times, template data available", + "Spell power disabled - crashes on creatures" + } + + local creatureName = SafeGet(function() return creature:GetName() end, "Unknown") + print("[Admin Handlers] GET_NPC_DATA: Sending response for", creatureName) + + -- Send response back to client + print("[Admin Handlers] Calling AMS.Send...") + AMS.Send(player, "NPC_DATA_RESPONSE", response) + print("[Admin Handlers] AMS.Send completed") +end) + +-- ============================================================================ +-- Waypoint Visualization Handlers +-- ============================================================================ + +-- Show waypoint markers in 3D space +AMS.RegisterHandler("SHOW_WAYPOINTS", function(player, data) + print("[Admin Handlers] SHOW_WAYPOINTS request received") + + -- Get creature from player's current selection + local creature = player:GetSelection() + if not creature then + print("[Admin Handlers] SHOW_WAYPOINTS: No target selected") + AMS.Send(player, "WAYPOINTS_RESPONSE", { success = false, error = "No target selected" }) + return + end + + creature = creature:ToCreature() + if not creature then + print("[Admin Handlers] SHOW_WAYPOINTS: Target is not a creature") + AMS.Send(player, "WAYPOINTS_RESPONSE", { success = false, error = "Target is not a creature" }) + return + end + + -- Check if creature has waypoints + local pathId = creature:GetWaypointPath() + if not pathId or pathId == 0 then + print("[Admin Handlers] SHOW_WAYPOINTS: Creature has no waypoint path") + AMS.Send(player, "WAYPOINTS_RESPONSE", { success = false, error = "No waypoint path" }) + return + end + + -- Clear any existing visualization first (fixes state after Clear All) + pcall(function() creature:DevisualizeWaypointPath() end) + + -- Get configurable display ID for waypoint markers + local displayId = GetDisplayId("waypoint_marker") + print("[Admin Handlers] SHOW_WAYPOINTS: Using displayId " .. tostring(displayId)) + + -- Visualize the path (spawns marker creatures at each waypoint) + -- Pass player to inherit their phase, and displayId for marker appearance + local success, err = pcall(function() return creature:VisualizeWaypointPath(player, displayId) end) + if not success then + print("[Admin Handlers] SHOW_WAYPOINTS: Error calling VisualizeWaypointPath:", err) + end + + if success then + print("[Admin Handlers] SHOW_WAYPOINTS: Visualization created for path", pathId) + AMS.Send(player, "WAYPOINTS_RESPONSE", { + success = true, + pathId = pathId, + message = "Waypoint markers spawned" + }) + else + print("[Admin Handlers] SHOW_WAYPOINTS: Failed to create visualization") + AMS.Send(player, "WAYPOINTS_RESPONSE", { success = false, error = "Failed to visualize (C++ method not available - rebuild required)" }) + end +end) + +-- Hide waypoint markers +AMS.RegisterHandler("HIDE_WAYPOINTS", function(player, data) + print("[Admin Handlers] HIDE_WAYPOINTS request received") + + -- Get creature from player's current selection + local creature = player:GetSelection() + if not creature then + print("[Admin Handlers] HIDE_WAYPOINTS: No target selected") + AMS.Send(player, "WAYPOINTS_RESPONSE", { success = false, error = "No target selected" }) + return + end + + creature = creature:ToCreature() + if not creature then + print("[Admin Handlers] HIDE_WAYPOINTS: Target is not a creature") + AMS.Send(player, "WAYPOINTS_RESPONSE", { success = false, error = "Target is not a creature" }) + return + end + + -- Devisualize the path (removes marker creatures) + local success, err = pcall(function() return creature:DevisualizeWaypointPath() end) + if not success then + print("[Admin Handlers] HIDE_WAYPOINTS: Error calling DevisualizeWaypointPath:", err) + end + + if success then + print("[Admin Handlers] HIDE_WAYPOINTS: Visualization removed") + AMS.Send(player, "WAYPOINTS_RESPONSE", { + success = true, + message = "Waypoint markers removed" + }) + else + print("[Admin Handlers] HIDE_WAYPOINTS: Failed to remove visualization") + AMS.Send(player, "WAYPOINTS_RESPONSE", { success = false, error = "Failed to hide" }) + end +end) + +-- Hide waypoints by GUID (for Clear All / Paths tab) +-- Note: Markers are tied to creatures - they'll despawn when creature respawns or server restarts +-- This handler logs the request for debugging +AMS.RegisterHandler("HIDE_WAYPOINTS_BY_GUID", function(player, data) + if not data or not data.guid then + print("[Admin Handlers] HIDE_WAYPOINTS_BY_GUID: No GUID provided") + return + end + + print("[Admin Handlers] HIDE_WAYPOINTS_BY_GUID: Acknowledged clear request for GUID:", data.guid) + -- Note: If the creature is not the player's current target, we can't easily devisualize + -- The client tracker is the source of truth; markers will despawn naturally +end) + +-- Clear ALL waypoint markers and reset WaypointManager tracking state +AMS.RegisterHandler("CLEAR_ALL_WAYPOINT_MARKERS", function(player, data) + print("[Admin Handlers] CLEAR_ALL_WAYPOINT_MARKERS: Clearing all waypoint visualizations") + + -- Use the C++ method to properly clear tracking state and despawn markers + player:ClearAllWaypointVisualizations() + + print("[Admin Handlers] CLEAR_ALL_WAYPOINT_MARKERS: All visualizations cleared") + AMS.Send(player, "CLEAR_WAYPOINTS_RESPONSE", { success = true }) +end) + +-- ============================================================================ +-- Spawn Point Marker Operations +-- ============================================================================ + +--[[ + SHOW_SPAWN_MARKER - Show a marker at the selected creature's spawn point + Request: { displayId = optional number } + Response: { success = bool, x, y, z, o } +]] +AMS.RegisterHandler("SHOW_SPAWN_MARKER", function(player, data) + print("[Admin Handlers] SHOW_SPAWN_MARKER request received") + + local creature = player:GetSelection() + if not creature then + AMS.Send(player, "SPAWN_MARKER_RESPONSE", { success = false, error = "No target selected" }) + return + end + + creature = creature:ToCreature() + if not creature then + AMS.Send(player, "SPAWN_MARKER_RESPONSE", { success = false, error = "Target is not a creature" }) + return + end + + -- Get home position for response + local x, y, z, o = creature:GetHomePosition() + + -- Show spawn marker (pass player for phase, optional displayId) + local displayId = data and data.displayId or nil + local success, marker = pcall(function() + if displayId then + return creature:ShowSpawnPointMarker(player, displayId) + else + return creature:ShowSpawnPointMarker(player) + end + end) + + if success and marker then + print("[Admin Handlers] SHOW_SPAWN_MARKER: Marker created at " .. x .. ", " .. y .. ", " .. z) + AMS.Send(player, "SPAWN_MARKER_RESPONSE", { + success = true, + x = x, y = y, z = z, o = o, + message = "Spawn marker shown" + }) + else + print("[Admin Handlers] SHOW_SPAWN_MARKER: Failed to create marker") + AMS.Send(player, "SPAWN_MARKER_RESPONSE", { + success = false, + error = "Failed to show marker (rebuild server required)" + }) + end +end) + +--[[ + HIDE_SPAWN_MARKER - Hide the spawn point marker for the selected creature + Request: {} + Response: { success = bool } +]] +AMS.RegisterHandler("HIDE_SPAWN_MARKER", function(player, data) + print("[Admin Handlers] HIDE_SPAWN_MARKER request received") + + local creature = player:GetSelection() + if not creature then + AMS.Send(player, "SPAWN_MARKER_RESPONSE", { success = false, error = "No target selected" }) + return + end + + creature = creature:ToCreature() + if not creature then + AMS.Send(player, "SPAWN_MARKER_RESPONSE", { success = false, error = "Target is not a creature" }) + return + end + + local success = pcall(function() return creature:HideSpawnPointMarker() end) + + if success then + print("[Admin Handlers] HIDE_SPAWN_MARKER: Marker hidden") + AMS.Send(player, "SPAWN_MARKER_RESPONSE", { success = true, message = "Spawn marker hidden" }) + else + print("[Admin Handlers] HIDE_SPAWN_MARKER: Failed to hide marker") + AMS.Send(player, "SPAWN_MARKER_RESPONSE", { success = false, error = "Failed to hide marker" }) + end +end) + +-- Local cache for wander radius markers (for same-session cleanup) +-- Also stored in ElunaSharedData for visibility, but Lua object refs only work same-session +local wanderRadiusMarkers = {} + +--[[ + SHOW_WANDER_RADIUS - Show visual markers in a circle at the creature's wander radius + Request: { segments = optional number (default 12) } + Response: { success = bool, radius = number, x, y, z } +]] +AMS.RegisterHandler("SHOW_WANDER_RADIUS", function(player, data) + print("[Admin Handlers] SHOW_WANDER_RADIUS request received") + + local creature = player:GetSelection() + if not creature then + AMS.Send(player, "WANDER_RADIUS_RESPONSE", { success = false, error = "No target selected" }) + return + end + + creature = creature:ToCreature() + if not creature then + AMS.Send(player, "WANDER_RADIUS_RESPONSE", { success = false, error = "Target is not a creature" }) + return + end + + local spawnId = creature:GetDBTableGUIDLow() + local spawnIdStr = tostring(spawnId) -- Convert to string for table key + local wanderRadius = creature:GetWanderRadius() + + if wanderRadius <= 0 then + AMS.Send(player, "WANDER_RADIUS_RESPONSE", { success = false, error = "Creature has no wander radius (0)" }) + return + end + + -- Get spawn position + local homeX, homeY, homeZ, homeO = creature:GetHomePosition() + local markerKey = "wander_markers_" .. spawnIdStr + + -- Clear any existing markers for this creature (from local cache) + if wanderRadiusMarkers[spawnIdStr] then + for _, marker in ipairs(wanderRadiusMarkers[spawnIdStr]) do + if marker and marker:IsInWorld() then + marker:DespawnOrUnsummon() + end + end + wanderRadiusMarkers[spawnIdStr] = nil + end + ClearSharedData(markerKey) -- Also clear shared data + + -- Number of markers around the circle (default 12 = every 30 degrees) + local segments = (data and data.segments) or 12 + if segments < 4 then segments = 4 end + if segments > 36 then segments = 36 end + + local markers = {} + local markerGuids = {} + local angleStep = (2 * math.pi) / segments + + -- Spawn markers around the circle perimeter + for i = 0, segments - 1 do + local angle = i * angleStep + local markerX = homeX + wanderRadius * math.cos(angle) + local markerY = homeY + wanderRadius * math.sin(angle) + local markerZ = homeZ -- Keep at spawn height + + -- Spawn a visual marker at this position + -- Using entry 1 (VISUAL_WAYPOINT) with a distinct display + local marker = creature:SpawnCreature(1, markerX, markerY, markerZ, 0, 8) -- 8 = TEMPSUMMON_MANUAL_DESPAWN + if marker then + marker:SetDisplayId(31366) -- Green circle like spawn marker + marker:SetScale(0.3) -- Smaller for radius markers + table.insert(markers, marker) + table.insert(markerGuids, tostring(marker:GetGUIDLow())) + end + end + + -- Also spawn a marker at the center (spawn point) + local centerMarker = creature:SpawnCreature(1, homeX, homeY, homeZ, homeO, 8) + if centerMarker then + centerMarker:SetDisplayId(31366) -- Green circle + centerMarker:SetScale(1.0) -- Larger for center + table.insert(markers, centerMarker) + table.insert(markerGuids, tostring(centerMarker:GetGUIDLow())) + end + + -- Store in local cache for same-session cleanup (use string key) + wanderRadiusMarkers[spawnIdStr] = markers + + -- Also store GUIDs in shared data (for debugging/tracking) + SetSharedData(markerKey, Smallfolk.dumps(markerGuids)) + + print("[Admin Handlers] SHOW_WANDER_RADIUS: Created " .. #markers .. " markers for radius " .. wanderRadius) + AMS.Send(player, "WANDER_RADIUS_RESPONSE", { + success = true, + radius = wanderRadius, + segments = segments, + markerCount = #markers, + x = homeX, y = homeY, z = homeZ, + message = "Wander radius shown (" .. wanderRadius .. " yards)" + }) +end) + +--[[ + HIDE_WANDER_RADIUS - Hide the wander radius markers for the selected creature + Request: {} + Response: { success = bool } +]] +AMS.RegisterHandler("HIDE_WANDER_RADIUS", function(player, data) + print("[Admin Handlers] HIDE_WANDER_RADIUS request received") + + local creature = player:GetSelection() + if not creature then + AMS.Send(player, "WANDER_RADIUS_RESPONSE", { success = false, error = "No target selected" }) + return + end + + creature = creature:ToCreature() + if not creature then + AMS.Send(player, "WANDER_RADIUS_RESPONSE", { success = false, error = "Target is not a creature" }) + return + end + + local spawnId = creature:GetDBTableGUIDLow() + local spawnIdStr = tostring(spawnId) + local markerKey = "wander_markers_" .. spawnIdStr + + if wanderRadiusMarkers[spawnIdStr] then + local count = #wanderRadiusMarkers[spawnIdStr] + for _, marker in ipairs(wanderRadiusMarkers[spawnIdStr]) do + if marker and marker:IsInWorld() then + marker:DespawnOrUnsummon() + end + end + wanderRadiusMarkers[spawnIdStr] = nil + ClearSharedData(markerKey) + print("[Admin Handlers] HIDE_WANDER_RADIUS: Removed " .. count .. " markers") + AMS.Send(player, "WANDER_RADIUS_RESPONSE", { success = true, message = "Wander radius hidden" }) + else + -- No local cache - markers may have been lost on reload + -- They'll despawn naturally, just clear any tracking data + ClearSharedData(markerKey) + AMS.Send(player, "WANDER_RADIUS_RESPONSE", { success = true, message = "Wander radius markers cleared (may already be despawned)" }) + end +end) + +--[[ + CLEAR_WANDER_MARKERS - Clear both spawn marker and wander radius markers + Request: {} + Response: { success = bool } +]] +AMS.RegisterHandler("CLEAR_WANDER_MARKERS", function(player, data) + print("[Admin Handlers] CLEAR_WANDER_MARKERS request received") + + local creature = player:GetSelection() + if not creature then + AMS.Send(player, "CLEAR_WANDER_MARKERS_RESPONSE", { success = false, error = "No target selected" }) + return + end + + creature = creature:ToCreature() + if not creature then + AMS.Send(player, "CLEAR_WANDER_MARKERS_RESPONSE", { success = false, error = "Target is not a creature" }) + return + end + + local spawnId = creature:GetDBTableGUIDLow() + local spawnIdStr = tostring(spawnId) + local cleared = 0 + local messages = {} + + -- Clear spawn marker (via C++ method if available) + local spawnSuccess, spawnResult = pcall(function() return creature:HideSpawnPointMarker() end) + if spawnSuccess and spawnResult then + cleared = cleared + 1 + table.insert(messages, "spawn marker") + end + + -- Clear wander radius markers (from local cache, use string key) + if wanderRadiusMarkers[spawnIdStr] then + local count = #wanderRadiusMarkers[spawnIdStr] + for _, marker in ipairs(wanderRadiusMarkers[spawnIdStr]) do + if marker and marker:IsInWorld() then + marker:DespawnOrUnsummon() + cleared = cleared + 1 + end + end + wanderRadiusMarkers[spawnIdStr] = nil + table.insert(messages, count .. " radius markers") + end + + -- Clear shared data + ClearSharedData("wander_markers_" .. spawnIdStr) + + local msg = cleared > 0 and ("Cleared: " .. table.concat(messages, ", ")) or "No active markers in cache (may have been lost on reload)" + print("[Admin Handlers] CLEAR_WANDER_MARKERS: " .. msg .. " for creature " .. tostring(spawnId)) + AMS.Send(player, "CLEAR_WANDER_MARKERS_RESPONSE", { success = true, cleared = cleared, message = msg }) +end) + +--[[ + CLEAR_NEARBY_MARKERS - Emergency clear of all VISUAL_WAYPOINT creatures near player + This clears orphaned markers from before tracking was implemented + Request: { range = optional number (default 100) } + Response: { success = bool, cleared = number } +]] +AMS.RegisterHandler("CLEAR_NEARBY_MARKERS", function(player, data) + print("[Admin Handlers] CLEAR_NEARBY_MARKERS request received") + + local range = (data and data.range) or 100 + local cleared = 0 + + -- Get all creatures near player with entry 1 (VISUAL_WAYPOINT) + local creatures = player:GetCreaturesInRange(range, 1) -- entry 1 = VISUAL_WAYPOINT + if creatures then + for _, creature in ipairs(creatures) do + if creature and creature:IsInWorld() then + creature:DespawnOrUnsummon() + cleared = cleared + 1 + end + end + end + + print("[Admin Handlers] CLEAR_NEARBY_MARKERS: Cleared " .. cleared .. " markers within " .. range .. " yards") + AMS.Send(player, "CLEAR_NEARBY_MARKERS_RESPONSE", { success = true, cleared = cleared, message = "Cleared " .. cleared .. " orphaned markers" }) +end) + +-- Get detailed waypoint data for a path +AMS.RegisterHandler("GET_WAYPOINT_DETAILS", function(player, data) + if not data or not data.pathId then + print("[Admin Handlers] GET_WAYPOINT_DETAILS: No pathId provided") + AMS.Send(player, "WAYPOINT_DETAILS_RESPONSE", { success = false, error = "No pathId" }) + return + end + + local pathId = data.pathId + print("[Admin Handlers] GET_WAYPOINT_DETAILS: Fetching details for path " .. pathId) + + -- Get the waypoint path using the C++ binding + local path = GetWaypointPath(pathId) + if not path or not path.nodes then + print("[Admin Handlers] GET_WAYPOINT_DETAILS: Path not found") + AMS.Send(player, "WAYPOINT_DETAILS_RESPONSE", { success = false, error = "Path not found" }) + return + end + + print("[Admin Handlers] GET_WAYPOINT_DETAILS: Sending " .. #path.nodes .. " nodes") + AMS.Send(player, "WAYPOINT_DETAILS_RESPONSE", { + success = true, + pathId = pathId, + nodes = path.nodes + }) +end) + +-- Highlight a specific waypoint in the world +AMS.RegisterHandler("SELECT_WAYPOINT", function(player, data) + if not data or not data.pathId or not data.nodeId then + print("[Admin Handlers] SELECT_WAYPOINT: Missing pathId or nodeId") + return + end + + local pathId = data.pathId + local nodeId = data.nodeId + print("[Admin Handlers] SELECT_WAYPOINT: Highlighting path " .. pathId .. " node " .. nodeId) + + -- Get the waypoint path + local path = GetWaypointPath(pathId) + if not path or not path.nodes then + print("[Admin Handlers] SELECT_WAYPOINT: Path not found") + return + end + + -- Find the node + local node = nil + for _, n in ipairs(path.nodes) do + if n.id == nodeId then + node = n + break + end + end + + if not node then + print("[Admin Handlers] SELECT_WAYPOINT: Node not found") + return + end + + -- Notify player of selection + print("[Admin Handlers] SELECT_WAYPOINT: Node " .. nodeId .. " at " .. node.x .. ", " .. node.y .. ", " .. node.z) + + -- Clear previous highlight if any (stored in shared data per player) + local playerKey = "waypoint_highlight_" .. tostring(player:GetGUIDLow()) + local prevData = GetSharedData(playerKey) + if prevData then + local prev = Smallfolk.loads(prevData) + if prev and prev.pathId and prev.nodeId then + ClearWaypointMarkerAuras(player, prev.pathId, prev.nodeId) + end + end + + -- Highlight the new waypoint marker by changing its display + -- Display ID is configurable via shared data (key: config_display_waypoint_highlight) + local highlightDisplayId = GetDisplayId("waypoint_highlight") + local success = HighlightWaypointMarker(player, pathId, nodeId, highlightDisplayId) + + if success then + print("[Admin Handlers] SELECT_WAYPOINT: Highlighted marker with displayId " .. highlightDisplayId) + -- Store current selection for later cleanup + SetSharedData(playerKey, Smallfolk.dumps({ pathId = pathId, nodeId = nodeId })) + else + print("[Admin Handlers] SELECT_WAYPOINT: Could not find marker to highlight") + end + + player:SendBroadcastMessage("Waypoint " .. nodeId .. " selected") + + -- Send response with node position + AMS.Send(player, "WAYPOINT_SELECTED_RESPONSE", { + success = true, + pathId = pathId, + nodeId = nodeId, + x = node.x, + y = node.y, + z = node.z + }) +end) + +-- Get waypoint node info from a visual waypoint GUID (when targeting a waypoint marker) +AMS.RegisterHandler("GET_WAYPOINT_FOR_GUID", function(player, data) + if not data or not data.guid then + print("[Admin Handlers] GET_WAYPOINT_FOR_GUID: No GUID provided") + return + end + + local pathId, nodeId = GetWaypointNodeForVisualGUID(data.guid) + if pathId == 0 or nodeId == 0 then + print("[Admin Handlers] GET_WAYPOINT_FOR_GUID: GUID not found in waypoint system") + AMS.Send(player, "WAYPOINT_FOR_GUID_RESPONSE", { success = false }) + return + end + + print("[Admin Handlers] GET_WAYPOINT_FOR_GUID: Found path " .. pathId .. " node " .. nodeId) + AMS.Send(player, "WAYPOINT_FOR_GUID_RESPONSE", { + success = true, + pathId = pathId, + nodeId = nodeId + }) +end) + +-- Get player data (GM state, etc.) +AMS.RegisterHandler("GET_PLAYER_DATA", function(player, data) + if not player then + return + end + + -- Send player data including GM state (IsGM is the correct Eluna method) + AMS.Send(player, "PLAYER_DATA_RESPONSE", { + success = true, + isGM = player:IsGM() + }) +end) + +-- Set display config (for MCP to change waypoint marker appearance) +-- Keys: waypoint_marker, waypoint_highlight, spawn_marker +AMS.RegisterHandler("SET_DISPLAY_CONFIG", function(player, data) + if not data or not data.key or not data.displayId then + print("[Admin Handlers] SET_DISPLAY_CONFIG: Missing key or displayId") + return + end + + local key = data.key + local displayId = tonumber(data.displayId) + + if not displayId then + print("[Admin Handlers] SET_DISPLAY_CONFIG: Invalid displayId") + return + end + + SetDisplayIdConfig(key, displayId) + + print("[Admin Handlers] SET_DISPLAY_CONFIG: Set " .. key .. " = " .. displayId) + AMS.Send(player, "DISPLAY_CONFIG_RESPONSE", { + success = true, + key = key, + displayId = displayId, + message = "Display config updated. Re-show waypoints to see changes." + }) +end) + +-- Get current display configs +AMS.RegisterHandler("GET_DISPLAY_CONFIGS", function(player, data) + local configs = {} + for key, _ in pairs(DEFAULT_DISPLAYS) do + configs[key] = GetDisplayId(key) + end + + AMS.Send(player, "DISPLAY_CONFIGS_RESPONSE", { + success = true, + configs = configs + }) +end) + +-- Teleport player to a waypoint location +AMS.RegisterHandler("TELEPORT_TO_WAYPOINT", function(player, data) + if not data or not data.x or not data.y or not data.z then + print("[Admin Handlers] TELEPORT_TO_WAYPOINT: Missing coordinates") + return + end + + local x = data.x + local y = data.y + local z = data.z + local orientation = data.orientation or 0 + + print("[Admin Handlers] TELEPORT_TO_WAYPOINT: Teleporting to " .. x .. ", " .. y .. ", " .. z .. " facing " .. orientation) + + -- Teleport player to the waypoint location with correct orientation + player:Teleport(player:GetMapId(), x, y, z, orientation) + + player:SendBroadcastMessage("Teleported to waypoint") +end) + +-- ============================================================================ +-- Creature Edit Operations (Araxia Write Operations) +-- ============================================================================ + +--[[ + SET_WANDER_DISTANCE - Set the wander distance for the selected creature + Request: { distance = 10.0 } + Response: { success = bool, message = string, newDistance = number } + Note: Creature must be selected (same as GET_NPC_DATA) +]] +AMS.RegisterHandler("SET_WANDER_DISTANCE", function(player, data) + if not data or data.distance == nil then + print("[Admin Handlers] SET_WANDER_DISTANCE: Missing distance") + AMS.Send(player, "SET_WANDER_DISTANCE_RESPONSE", { + success = false, + message = "Missing distance parameter" + }) + return + end + + print("[Admin Handlers] SET_WANDER_DISTANCE: Setting distance to " .. data.distance) + + -- Use player's selection (same pattern as GET_NPC_DATA) + local creature = player:GetSelection() + if not creature then + AMS.Send(player, "SET_WANDER_DISTANCE_RESPONSE", { + success = false, + message = "No creature selected" + }) + return + end + + creature = creature:ToCreature() + if not creature then + AMS.Send(player, "SET_WANDER_DISTANCE_RESPONSE", { + success = false, + message = "Selected object is not a creature" + }) + return + end + + local spawnId = creature:GetDBTableGUIDLow() + local spawnIdStr = tostring(spawnId) -- Convert to string for table key + local homeX, homeY, homeZ, homeO = creature:GetHomePosition() + + -- Check if markers were visible and hide them + local hadSpawnMarker = false + local hadRadiusMarkers = false + + -- Try to hide spawn marker (via C++) - check if it returns true + local spawnHideSuccess, spawnHideResult = pcall(function() return creature:HideSpawnPointMarker() end) + if spawnHideSuccess and spawnHideResult then + hadSpawnMarker = true + print("[Admin Handlers] SET_WANDER_DISTANCE: Hid spawn marker") + end + + -- Hide wander radius markers if they exist (use string key) + if wanderRadiusMarkers[spawnIdStr] then + hadRadiusMarkers = true + for _, marker in ipairs(wanderRadiusMarkers[spawnIdStr]) do + if marker and marker:IsInWorld() then + marker:DespawnOrUnsummon() + end + end + wanderRadiusMarkers[spawnIdStr] = nil + ClearSharedData("wander_markers_" .. spawnIdStr) + print("[Admin Handlers] SET_WANDER_DISTANCE: Hid radius markers") + end + + -- Use the Araxia writer method to save to database + local success, message = creature:SaveWanderDistance(data.distance, player) + + print("[Admin Handlers] SET_WANDER_DISTANCE: Result - " .. tostring(success) .. ", " .. message) + + -- Despawn and respawn the creature so the new settings take effect immediately + if success then + -- Set a very short respawn delay (1 second), then despawn + creature:SetRespawnDelay(1) + creature:Respawn() -- Mark for respawn + creature:DespawnOrUnsummon(0) -- Remove from world + print("[Admin Handlers] SET_WANDER_DISTANCE: Creature despawned, respawning in 1 second") + + -- Tell client markers need to be re-shown (client will handle the delay) + if hadSpawnMarker or hadRadiusMarkers then + print("[Admin Handlers] SET_WANDER_DISTANCE: Markers will need to be re-shown after respawn") + end + end + + AMS.Send(player, "SET_WANDER_DISTANCE_RESPONSE", { + success = success, + message = message, + newDistance = data.distance, + markersCleared = hadSpawnMarker or hadRadiusMarkers, + hadSpawnMarker = hadSpawnMarker, + hadRadiusMarkers = hadRadiusMarkers + }) +end) + +--[[ + SET_MOVEMENT_TYPE - Set the movement type for the selected creature + Request: { movementType = 0|1|2 } + Response: { success = bool, message = string, newMovementType = number } + Note: Creature must be selected +]] +AMS.RegisterHandler("SET_MOVEMENT_TYPE", function(player, data) + if not data or data.movementType == nil then + print("[Admin Handlers] SET_MOVEMENT_TYPE: Missing movementType") + AMS.Send(player, "SET_MOVEMENT_TYPE_RESPONSE", { + success = false, + message = "Missing movementType parameter" + }) + return + end + + print("[Admin Handlers] SET_MOVEMENT_TYPE: Setting type to " .. data.movementType) + + local creature = player:GetSelection() + if not creature then + AMS.Send(player, "SET_MOVEMENT_TYPE_RESPONSE", { + success = false, + message = "No creature selected" + }) + return + end + + creature = creature:ToCreature() + if not creature then + AMS.Send(player, "SET_MOVEMENT_TYPE_RESPONSE", { + success = false, + message = "Selected object is not a creature" + }) + return + end + + local success, message = creature:SaveMovementType(data.movementType, player) + + print("[Admin Handlers] SET_MOVEMENT_TYPE: Result - " .. tostring(success) .. ", " .. message) + + AMS.Send(player, "SET_MOVEMENT_TYPE_RESPONSE", { + success = success, + message = message, + newMovementType = data.movementType + }) +end) + +-- ============================================================================ +-- Agent Chat Handlers +-- ============================================================================ + +-- Subscribed players for agent response delivery (guid -> player_name) +-- NOTE: Stored in ElunaSharedData to persist across .reload eluna +local SUBSCRIBERS_KEY = "agent_chat_subscribers" + +local function getSubscribers() + local data = GetSharedData(SUBSCRIBERS_KEY) + if not data or data == "" then return {} end + -- Parse simple format: "guid1:name1,guid2:name2" + local subs = {} + for pair in data:gmatch("([^,]+)") do + local guid, name = pair:match("(%d+):(.+)") + if guid and name then + subs[tonumber(guid)] = name + end + end + return subs +end + +local function saveSubscribers(subs) + local parts = {} + for guid, name in pairs(subs) do + table.insert(parts, tostring(guid) .. ":" .. name) + end + SetSharedData(SUBSCRIBERS_KEY, table.concat(parts, ",")) +end + +-- Response delivery timer (checks every 2 seconds) +local RESPONSE_CHECK_INTERVAL = 2000 -- milliseconds + +--[[ + AGENT_LIST_REQUEST - Get list of registered AI agents + Request: {} + Response: { success = bool, agents = [{name, online, owner, description}] } +]] +-- Simple JSON helpers for agent chat (MCP uses JSON, not Smallfolk) +local function parseJsonAgentRegistry(jsonStr) + -- Parse: {"AgentName":{"owner":"X","description":"Y","last_poll":123,...},...} + local agents = {} + if not jsonStr or jsonStr == "" or jsonStr == "{}" then return agents end + + -- Extract each agent entry: "Name":{...} + for name, content in jsonStr:gmatch('"([^"]+)":%s*{([^}]*)}') do + local agent = { name = name } + agent.owner = content:match('"owner":"([^"]*)"') or "Unknown" + agent.description = content:match('"description":"([^"]*)"') or "" + agent.last_poll = tonumber(content:match('"last_poll":(%d+)')) or 0 + agent.status = content:match('"status":"([^"]*)"') or "offline" + table.insert(agents, agent) + end + return agents +end + +local function escapeJsonString(s) + if not s then return "" end + s = tostring(s) + s = s:gsub('\\', '\\\\') + s = s:gsub('"', '\\"') + s = s:gsub('\n', '\\n') + s = s:gsub('\r', '\\r') + s = s:gsub('\t', '\\t') + return s +end + +local function encodeJsonMessage(msg) + -- Encode a message as JSON object + local parts = {} + table.insert(parts, '"message_id":"' .. escapeJsonString(msg.message_id) .. '"') + table.insert(parts, '"from_player_guid":' .. tostring(msg.from_player_guid or 0)) + table.insert(parts, '"from_player_name":"' .. escapeJsonString(msg.from_player_name) .. '"') + table.insert(parts, '"content":"' .. escapeJsonString(msg.content) .. '"') + table.insert(parts, '"timestamp":' .. (msg.timestamp or 0)) + if msg.context then + -- Simple context encoding + local ctxParts = {} + for k, v in pairs(msg.context) do + if type(v) == "string" then + table.insert(ctxParts, '"' .. escapeJsonString(k) .. '":"' .. escapeJsonString(v) .. '"') + elseif type(v) == "number" then + table.insert(ctxParts, '"' .. escapeJsonString(k) .. '":' .. v) + end + end + table.insert(parts, '"context":{' .. table.concat(ctxParts, ",") .. '}') + end + return "{" .. table.concat(parts, ",") .. "}" +end + +local function parseJsonArray(jsonStr) + -- Parse JSON array of messages: [{...},{...}] + local messages = {} + if not jsonStr or jsonStr == "" or jsonStr == "[]" then return messages end + + -- Extract content field more carefully (handles escaped quotes) + -- Pattern explanation: match "content":" then capture everything until unescaped quote + local function extractContent(json) + local start = json:find('"content":"') + if not start then return nil end + local contentStart = start + 11 -- length of '"content":"' + local result = {} + local i = contentStart + while i <= #json do + local c = json:sub(i, i) + if c == '\\' and i < #json then + -- Escaped character - include the next char + local nextC = json:sub(i+1, i+1) + if nextC == '"' then + table.insert(result, '"') + elseif nextC == 'n' then + table.insert(result, '\n') + elseif nextC == '\\' then + table.insert(result, '\\') + else + table.insert(result, nextC) + end + i = i + 2 + elseif c == '"' then + -- End of content string + break + else + table.insert(result, c) + i = i + 1 + end + end + return table.concat(result) + end + + -- Simple extraction of message objects (handles both player->agent and agent->player formats) + for msgJson in jsonStr:gmatch('{[^{}]+}') do + local msg = {} + msg.message_id = msgJson:match('"message_id":"([^"]*)"') + msg.from_player_guid = tonumber(msgJson:match('"from_player_guid":(%d+)')) + msg.from_player_name = msgJson:match('"from_player_name":"([^"]*)"') + msg.from_agent = msgJson:match('"from_agent":"([^"]*)"') -- For agent->player messages + msg.content = extractContent(msgJson) -- Use proper extraction for content + msg.timestamp = tonumber(msgJson:match('"timestamp":(%d+)')) + table.insert(messages, msg) + end + return messages +end + +local function encodeJsonArray(messages) + local parts = {} + for _, msg in ipairs(messages) do + table.insert(parts, encodeJsonMessage(msg)) + end + return "[" .. table.concat(parts, ",") .. "]" +end + +AMS.RegisterHandler("AGENT_LIST_REQUEST", function(player, data) + print("[Admin Handlers] AGENT_LIST_REQUEST received") + + local agents = {} + local registryData = GetSharedData("agent_registry") + + if registryData and registryData ~= "" then + agents = parseJsonAgentRegistry(registryData) + + -- Update online status based on last_poll + local now = os.time() + for _, agent in ipairs(agents) do + agent.online = (now - (agent.last_poll or 0)) < 60 + end + + print("[Admin Handlers] AGENT_LIST_REQUEST: Found " .. #agents .. " agents") + else + print("[Admin Handlers] AGENT_LIST_REQUEST: No agent registry found") + end + + AMS.Send(player, "AGENT_LIST_RESPONSE", { + success = true, + agents = agents + }) +end) + +--[[ + AGENT_SEND_MESSAGE - Player sends message to an AI agent + Request: { agent_name = string, content = string, context = optional table } + Response: { success = bool, message_id = string, error = string } +]] +AMS.RegisterHandler("AGENT_SEND_MESSAGE", function(player, data) + SetSharedData("debug_agent_send", "step1") + + if not data or not data.agent_name or not data.content then + SetSharedData("debug_agent_send", "step2_missing_data") + AMS.Send(player, "AGENT_SEND_MESSAGE_RESPONSE", { + success = false, + error = "Missing agent_name or content" + }) + return + end + + SetSharedData("debug_agent_send", "step3_" .. data.agent_name) + local agentName = data.agent_name + local content = data.content + local context = data.context + + -- Check if agent exists (parse JSON registry) + local registryData = GetSharedData("agent_registry") + SetSharedData("debug_agent_send", "step4_reg") + if not registryData or registryData == "" then + SetSharedData("debug_agent_send", "step4_no_registry") + AMS.Send(player, "AGENT_SEND_MESSAGE_RESPONSE", { + success = false, + error = "No agents registered" + }) + return + end + + SetSharedData("debug_agent_send", "step5_parsing") + local agents = parseJsonAgentRegistry(registryData) + SetSharedData("debug_agent_send", "step6_found_" .. #agents) + + -- Case-insensitive agent lookup + local foundName = nil + for _, agent in ipairs(agents) do + if agent.name:lower() == agentName:lower() then + foundName = agent.name + break + end + end + + SetSharedData("debug_agent_send", "step7_lookup_" .. tostring(foundName)) + + if not foundName then + AMS.Send(player, "AGENT_SEND_MESSAGE_RESPONSE", { + success = false, + error = "Agent '" .. agentName .. "' not found" + }) + return + end + + -- Generate message ID + local messageId = "msg_" .. tostring(os.time()) .. "_" .. tostring(player:GetGUIDLow()) + SetSharedData("debug_agent_send", "step8_" .. messageId) + + -- Create message + local message = { + message_id = messageId, + from_player_guid = player:GetGUIDLow(), + from_player_name = player:GetName(), + content = content, + timestamp = os.time(), + context = context + } + + -- Add to agent's inbox (using JSON format) + local inboxKey = "agent_inbox_" .. foundName + local inboxData = GetSharedData(inboxKey) + local inbox = {} + + if inboxData and inboxData ~= "" then + inbox = parseJsonArray(inboxData) + end + + table.insert(inbox, message) + SetSharedData("debug_agent_send", "step9_queued_" .. #inbox) + + -- Cap inbox size (100 messages max) + while #inbox > 100 do + table.remove(inbox, 1) + end + + SetSharedData("debug_agent_send", "step9b_encoding") + local ok, encoded = pcall(encodeJsonArray, inbox) + if not ok then + SetSharedData("debug_agent_send", "step9b_error_" .. tostring(encoded)) + return + end + SetSharedData("debug_agent_send", "step9c_encoded_len_" .. #encoded) + SetSharedData(inboxKey, encoded) + SetSharedData("debug_agent_send", "step10_saved_" .. inboxKey) + + AMS.Send(player, "AGENT_SEND_MESSAGE_RESPONSE", { + success = true, + message_id = messageId + }) +end) + +--[[ + AGENT_CHAT_SUBSCRIBE - Subscribe to agent response delivery + Request: {} + Response: (none - just tracks subscription) +]] +AMS.RegisterHandler("AGENT_CHAT_SUBSCRIBE", function(player, data) + local guid = player:GetGUIDLow() + local subs = getSubscribers() + subs[guid] = player:GetName() -- Store name for lookup + saveSubscribers(subs) + print("[Admin Handlers] AGENT_CHAT_SUBSCRIBE: Player " .. player:GetName() .. " subscribed") +end) + +--[[ + AGENT_CHAT_UNSUBSCRIBE - Unsubscribe from agent response delivery + Request: {} + Response: (none) +]] +AMS.RegisterHandler("AGENT_CHAT_UNSUBSCRIBE", function(player, data) + local guid = player:GetGUIDLow() + local subs = getSubscribers() + subs[guid] = nil + saveSubscribers(subs) + print("[Admin Handlers] AGENT_CHAT_UNSUBSCRIBE: Player " .. player:GetName() .. " unsubscribed") +end) + +--[[ + AGENT_POLL_RESPONSES - Manual poll for agent responses (fallback) + Request: {} + Response: { success = bool, messages = [...] } +]] +AMS.RegisterHandler("AGENT_POLL_RESPONSES", function(player, data) + local guid = player:GetGUIDLow() + local inboxKey = "player_inbox_" .. tostring(guid) + local inboxData = GetSharedData(inboxKey) + + local messages = {} + if inboxData and inboxData ~= "" then + -- Parse JSON array from MCP + messages = parseJsonArray(inboxData) + -- Clear after reading + ClearSharedData(inboxKey) + end + + AMS.Send(player, "AGENT_POLL_RESPONSES_RESULT", { + success = true, + messages = messages + }) +end) + +-- Timer to check for and deliver agent responses to subscribed players +local function CheckAndDeliverAgentResponses() + local agentChatSubscribers = getSubscribers() + local subsChanged = false + for guid, playerName in pairs(agentChatSubscribers) do + local inboxKey = "player_inbox_" .. tostring(guid) + local inboxData = GetSharedData(inboxKey) + + if inboxData and inboxData ~= "" then + -- Parse JSON array from MCP + local messages = parseJsonArray(inboxData) + if #messages > 0 then + -- Find the player by name + local player = GetPlayerByName(playerName) + if player then + print("[Admin Handlers] Delivering " .. #messages .. " agent responses to " .. playerName) + AMS.Send(player, "AGENT_MESSAGE_RESPONSE", { + success = true, + messages = messages + }) + -- Clear delivered messages + ClearSharedData(inboxKey) + else + -- Player offline, remove from subscribers + agentChatSubscribers[guid] = nil + subsChanged = true + end + end + end + end + if subsChanged then + saveSubscribers(agentChatSubscribers) + end +end + +-- Start response delivery timer +CreateLuaEvent(CheckAndDeliverAgentResponses, RESPONSE_CHECK_INTERVAL, 0) -- 0 = repeat forever +print("[Admin Handlers] Agent response delivery timer started (every " .. (RESPONSE_CHECK_INTERVAL/1000) .. "s)") + +-- ============================================================================ +-- Initialization +-- ============================================================================ + +print("[Admin Handlers] Loaded successfully!") +print("[Admin Handlers] Registered handlers:") +print(" - GET_NPC_DATA") +print(" - SHOW_WAYPOINTS") +print(" - HIDE_WAYPOINTS") +print(" - HIDE_WAYPOINTS_BY_GUID") +print(" - CLEAR_ALL_WAYPOINT_MARKERS") +print(" - SHOW_SPAWN_MARKER") +print(" - HIDE_SPAWN_MARKER") +print(" - SHOW_WANDER_RADIUS") +print(" - HIDE_WANDER_RADIUS") +print(" - CLEAR_WANDER_MARKERS") +print(" - CLEAR_NEARBY_MARKERS") +print(" - GET_WAYPOINT_DETAILS") +print(" - SELECT_WAYPOINT") +print(" - GET_WAYPOINT_FOR_GUID") +print(" - GET_PLAYER_DATA") +print(" - TELEPORT_TO_WAYPOINT") +print(" - SET_WANDER_DISTANCE") +print(" - SET_MOVEMENT_TYPE") +print(" - AGENT_LIST_REQUEST") +print(" - AGENT_SEND_MESSAGE") +print(" - AGENT_CHAT_SUBSCRIBE") +print(" - AGENT_CHAT_UNSUBSCRIBE") +print(" - AGENT_POLL_RESPONSES") diff --git a/araxiaonline/lua_scripts/ae_mcp_bridge.lua b/araxiaonline/lua_scripts/ae_mcp_bridge.lua new file mode 100755 index 0000000000..2a1509ab28 --- /dev/null +++ b/araxiaonline/lua_scripts/ae_mcp_bridge.lua @@ -0,0 +1,196 @@ +--[[ + MCP Bridge - Server Side + + Provides a communication bridge between the WoW client and the MCP server. + Client sends messages via AMS, server stores them in ElunaSharedData, + MCP tools can read/write to ElunaSharedData. + + Keys used: + - mcp_client_chat: Last N chat messages from client + - mcp_client_logs: Client-side addon errors/logs + - mcp_to_client: Messages from MCP to display on client +]] + +local Smallfolk = require("aa_Smallfolk") + +-- Configuration +local MAX_CHAT_MESSAGES = 50 +local MAX_LOG_MESSAGES = 100 + +-- Initialize storage keys if not present +local function InitSharedData() + if not HasSharedData("mcp_client_chat") then + SetSharedData("mcp_client_chat", Smallfolk.dumps({})) + end + if not HasSharedData("mcp_client_logs") then + SetSharedData("mcp_client_logs", Smallfolk.dumps({})) + end + if not HasSharedData("mcp_to_client") then + SetSharedData("mcp_to_client", Smallfolk.dumps({})) + end +end + +-- Add a message to a shared data list (FIFO) +local function AddToSharedList(key, message, maxItems) + local data = GetSharedData(key) + local list = data and Smallfolk.loads(data) or {} + + table.insert(list, { + timestamp = os.time(), + message = message + }) + + -- Trim to max size + while #list > maxItems do + table.remove(list, 1) + end + + SetSharedData(key, Smallfolk.dumps(list)) +end + +-- AMS Handler: Client sends chat message for MCP to see +local function HandleMCPChatMessage(player, data) + local playerName = player:GetName() + local message = data.message or "" + local chatType = data.chatType or "CHAT" + + local formatted = string.format("[%s] %s: %s", chatType, playerName, message) + AddToSharedList("mcp_client_chat", formatted, MAX_CHAT_MESSAGES) + + print("[MCP Bridge] Chat captured: " .. formatted) +end + +-- AMS Handler: Client sends log/error for MCP to see +local function HandleMCPClientLog(player, data) + local playerName = player:GetName() + local logLevel = data.level or "INFO" + local message = data.message or "" + local source = data.source or "unknown" + + local formatted = string.format("[%s] [%s] %s: %s", logLevel, source, playerName, message) + AddToSharedList("mcp_client_logs", formatted, MAX_LOG_MESSAGES) + + print("[MCP Bridge] Client log: " .. formatted) +end + +-- AMS Handler: Client requests any pending MCP messages +local function HandleMCPGetMessages(player, data) + local messagesData = GetSharedData("mcp_to_client") + + -- MCP writes plain JSON, not Smallfolk format + -- Just send the raw string - client can parse or display it + local messages = {} + + if messagesData and messagesData ~= "" and messagesData ~= "{}" and messagesData ~= "[]" then + -- Try to extract simple messages from JSON-like format + -- Format: [{"message":"text"},{"message":"text2"}] + for msg in messagesData:gmatch('"message":"([^"]*)"') do + table.insert(messages, {message = msg}) + end + end + + -- Clear after reading + SetSharedData("mcp_to_client", "") + + if #messages > 0 then + AMS.Send(player, "MCP_MESSAGES_RESPONSE", { + messages = messages + }) + print("[MCP Bridge] Sent " .. #messages .. " messages to client") + end +end + +-- AMS Handler: Client sends DB query result or other structured data +local function HandleMCPClientData(player, data) + local playerName = player:GetName() + local dataType = data.dataType or "generic" + local payload = data.payload or {} + + -- Store in a type-specific key + local key = "mcp_client_" .. dataType + SetSharedData(key, Smallfolk.dumps({ + player = playerName, + timestamp = os.time(), + data = payload + })) + + print("[MCP Bridge] Client data received: " .. dataType) +end + +-- AMS Handler: Client sends UI state ("semantic screenshot") +local function HandleMCPUIState(player, data) + local playerName = player:GetName() + + -- Store the UI state for MCP to read + -- Convert to JSON-like string for MCP compatibility + local uiState = { + player = playerName, + capturedAt = os.time(), + target = data.target, + playerInfo = data.player, + mouseover = data.mouseover, + tooltip = data.tooltip, + openFrames = data.openFrames, + mcpBridgeStatus = data.mcpBridgeStatus + } + + -- Store as JSON-ish format (simple key-value for MCP) + local stateStr = string.format( + '{"player":"%s","capturedAt":%d,"hasTarget":%s,"targetName":"%s","targetGuid":"%s","targetLevel":%s,"zone":"%s","openFrames":[%s]}', + playerName, + os.time(), + data.target and "true" or "false", + data.target and (data.target.name or "none") or "none", + data.target and (data.target.guid or "") or "", + data.target and tostring(data.target.level or 0) or "0", + data.player and (data.player.zone or "unknown") or "unknown", + data.openFrames and ('"' .. table.concat(data.openFrames, '","') .. '"') or "" + ) + + SetSharedData("mcp_ui_state", stateStr) + + -- Also store detailed target info separately if available + if data.target then + local targetStr = string.format( + '{"name":"%s","guid":"%s","level":%d,"health":%d,"healthMax":%d,"creatureType":"%s","isDead":%s}', + data.target.name or "unknown", + data.target.guid or "", + data.target.level or 0, + data.target.health or 0, + data.target.healthMax or 0, + data.target.creatureType or "unknown", + data.target.isDead and "true" or "false" + ) + SetSharedData("mcp_current_target", targetStr) + else + SetSharedData("mcp_current_target", '{"hasTarget":false}') + end + + print("[MCP Bridge] UI state captured for " .. playerName) + if data.target then + print("[MCP Bridge] Target: " .. (data.target.name or "none") .. " (Level " .. (data.target.level or "?") .. ")") + end +end + +-- Register AMS handlers +local function RegisterAMSHandlers() + -- Check if AMS is available + if not AMS or not AMS.RegisterHandler then + print("[MCP Bridge] Warning: AMS not available, skipping handler registration") + return + end + + AMS.RegisterHandler("MCP_CHAT", HandleMCPChatMessage) + AMS.RegisterHandler("MCP_CLIENT_LOG", HandleMCPClientLog) + AMS.RegisterHandler("MCP_GET_MESSAGES", HandleMCPGetMessages) + AMS.RegisterHandler("MCP_CLIENT_DATA", HandleMCPClientData) + AMS.RegisterHandler("MCP_UI_STATE", HandleMCPUIState) + + print("[MCP Bridge] AMS handlers registered (including UI state)") +end + +-- Initialize on load +InitSharedData() +RegisterAMSHandlers() + +print("[MCP Bridge] Server-side bridge loaded") diff --git a/araxiaonline/lua_scripts/af_spawn_validator.lua b/araxiaonline/lua_scripts/af_spawn_validator.lua new file mode 100644 index 0000000000..8b02d16c58 --- /dev/null +++ b/araxiaonline/lua_scripts/af_spawn_validator.lua @@ -0,0 +1,260 @@ +--[[ + Spawn Validator - Headless creature spawn validation for MCP + + Allows MCP to validate creature spawns without requiring a player in-game. + Uses server-side APIs to check if creatures exist and are spawned correctly. + + Shared Data Keys: + - mcp_spawn_validation_request: Request from MCP to validate spawns + - mcp_spawn_validation_result: Results of validation + - mcp_spawn_query_result: Results of spawn queries +]] + +local Smallfolk = require("aa_Smallfolk") + +-- Configuration +local VALIDATION_INTERVAL = 1000 -- Check for requests every 1 second + +-- Initialize shared data keys +local function InitSharedData() + if not HasSharedData("mcp_spawn_validation_request") then + SetSharedData("mcp_spawn_validation_request", "") + end + if not HasSharedData("mcp_spawn_validation_result") then + SetSharedData("mcp_spawn_validation_result", "") + end + if not HasSharedData("mcp_spawn_query_result") then + SetSharedData("mcp_spawn_query_result", "") + end + if not HasSharedData("mcp_force_spawn_request") then + SetSharedData("mcp_force_spawn_request", "") + end +end + +-- Get all creatures in a specific area +local function GetCreaturesInArea(mapId, centerX, centerY, centerZ, radius) + local creatures = {} + + -- Use GetCreaturesInWorld to find creatures + local allCreatures = GetCreaturesInWorld(mapId) + + if allCreatures then + for _, creature in ipairs(allCreatures) do + local cx, cy, cz = creature:GetLocation() + local dx = cx - centerX + local dy = cy - centerY + local dz = cz - centerZ + local dist = math.sqrt(dx*dx + dy*dy + dz*dz) + + if dist <= radius then + table.insert(creatures, { + guid = creature:GetGUIDLow(), + entry = creature:GetEntry(), + name = creature:GetName(), + x = cx, + y = cy, + z = cz, + distance = dist, + isAlive = creature:IsAlive(), + level = creature:GetLevel() + }) + end + end + end + + return creatures +end + +-- Get creature by entry ID +local function GetCreaturesByEntry(mapId, entryId) + local creatures = {} + local allCreatures = GetCreaturesInWorld(mapId) + + if allCreatures then + for _, creature in ipairs(allCreatures) do + if creature:GetEntry() == entryId then + local x, y, z = creature:GetLocation() + table.insert(creatures, { + guid = creature:GetGUIDLow(), + entry = creature:GetEntry(), + name = creature:GetName(), + x = x, + y = y, + z = z, + isAlive = creature:IsAlive(), + level = creature:GetLevel() + }) + end + end + end + + return creatures +end + +-- Count all creatures on a map +local function CountCreaturesOnMap(mapId) + local allCreatures = GetCreaturesInWorld(mapId) + return allCreatures and #allCreatures or 0 +end + +-- Force spawn a creature at a location +local function ForceSpawnCreature(mapId, entryId, x, y, z, orientation) + -- SpawnCreature(entry, map, x, y, z, o, despawnType, despawnTime) + -- despawnType: 0 = manual, 1 = timed + local creature = PerformIngameSpawn(1, entryId, mapId, 0, x, y, z, orientation or 0, false, 0) + + if creature then + return { + success = true, + guid = creature:GetGUIDLow(), + entry = creature:GetEntry(), + name = creature:GetName(), + x = x, + y = y, + z = z + } + else + return { + success = false, + error = "Failed to spawn creature " .. entryId + } + end +end + +-- Process validation requests from MCP +local function ProcessValidationRequest() + local requestData = GetSharedData("mcp_spawn_validation_request") + + if not requestData or requestData == "" then + return + end + + -- Clear the request + SetSharedData("mcp_spawn_validation_request", "") + + -- Parse request (simple JSON-like format) + -- Format: {"action":"query_area","mapId":870,"x":1606,"y":-1733,"z":274,"radius":50} + local action = requestData:match('"action":"([^"]*)"') + local mapId = tonumber(requestData:match('"mapId":(%d+)')) + + local result = {} + + if action == "query_area" then + local x = tonumber(requestData:match('"x":([%-%.%d]+)')) + local y = tonumber(requestData:match('"y":([%-%.%d]+)')) + local z = tonumber(requestData:match('"z":([%-%.%d]+)')) + local radius = tonumber(requestData:match('"radius":(%d+)')) or 50 + + if mapId and x and y and z then + local creatures = GetCreaturesInArea(mapId, x, y, z, radius) + result = { + success = true, + action = "query_area", + mapId = mapId, + center = {x = x, y = y, z = z}, + radius = radius, + count = #creatures, + creatures = creatures + } + print("[Spawn Validator] Found " .. #creatures .. " creatures in area") + else + result = {success = false, error = "Missing coordinates"} + end + + elseif action == "query_entry" then + local entryId = tonumber(requestData:match('"entryId":(%d+)')) + + if mapId and entryId then + local creatures = GetCreaturesByEntry(mapId, entryId) + result = { + success = true, + action = "query_entry", + mapId = mapId, + entryId = entryId, + count = #creatures, + creatures = creatures + } + print("[Spawn Validator] Found " .. #creatures .. " creatures with entry " .. entryId) + else + result = {success = false, error = "Missing mapId or entryId"} + end + + elseif action == "count_map" then + if mapId then + local count = CountCreaturesOnMap(mapId) + result = { + success = true, + action = "count_map", + mapId = mapId, + count = count + } + print("[Spawn Validator] Map " .. mapId .. " has " .. count .. " creatures") + else + result = {success = false, error = "Missing mapId"} + end + + elseif action == "force_spawn" then + local entryId = tonumber(requestData:match('"entryId":(%d+)')) + local x = tonumber(requestData:match('"x":([%-%.%d]+)')) + local y = tonumber(requestData:match('"y":([%-%.%d]+)')) + local z = tonumber(requestData:match('"z":([%-%.%d]+)')) + local o = tonumber(requestData:match('"orientation":([%-%.%d]+)')) or 0 + + if mapId and entryId and x and y and z then + result = ForceSpawnCreature(mapId, entryId, x, y, z, o) + result.action = "force_spawn" + print("[Spawn Validator] Force spawn result: " .. (result.success and "success" or "failed")) + else + result = {success = false, error = "Missing spawn parameters"} + end + + else + result = {success = false, error = "Unknown action: " .. (action or "nil")} + end + + -- Store result as JSON + local resultJson = string.format( + '{"success":%s,"action":"%s","mapId":%d,"count":%d,"error":"%s","timestamp":%d}', + result.success and "true" or "false", + result.action or "unknown", + result.mapId or 0, + result.count or 0, + result.error or "", + os.time() + ) + + -- For creature lists, append them + if result.creatures and #result.creatures > 0 then + local creatureStrs = {} + for _, c in ipairs(result.creatures) do + table.insert(creatureStrs, string.format( + '{"guid":%d,"entry":%d,"name":"%s","x":%.2f,"y":%.2f,"z":%.2f,"level":%d,"alive":%s}', + c.guid or 0, + c.entry or 0, + c.name or "unknown", + c.x or 0, + c.y or 0, + c.z or 0, + c.level or 0, + c.isAlive and "true" or "false" + )) + end + resultJson = resultJson:gsub('}$', ',"creatures":[' .. table.concat(creatureStrs, ',') .. ']}') + end + + SetSharedData("mcp_spawn_validation_result", resultJson) +end + +-- Register a world update hook to check for requests +local function OnWorldUpdate(event, diff) + ProcessValidationRequest() +end + +-- Initialize +InitSharedData() + +-- Register world update event (fires every server tick) +RegisterServerEvent(14, OnWorldUpdate) -- WORLD_EVENT_ON_UPDATE + +print("[Spawn Validator] Loaded - MCP can now validate spawns without a player") +print("[Spawn Validator] Actions: query_area, query_entry, count_map, force_spawn") diff --git a/araxiaonline/lua_scripts/ag_reload_helper.lua b/araxiaonline/lua_scripts/ag_reload_helper.lua new file mode 100755 index 0000000000..ddb08ac7ea --- /dev/null +++ b/araxiaonline/lua_scripts/ag_reload_helper.lua @@ -0,0 +1,19 @@ +-- Eluna Reload Helper +-- Provides an in-game command to reload Eluna scripts + +local function ReloadElunaCommand(event, player, command) + if command == "elunareload" then + if player:GetGMRank() >= 3 then + player:SendBroadcastMessage("Reloading Eluna scripts...") + ReloadEluna() + player:SendBroadcastMessage("Eluna scripts reloaded!") + else + player:SendBroadcastMessage("You don't have permission to reload Eluna.") + end + return false -- Prevent command from going to server + end +end + +RegisterPlayerEvent(42, ReloadElunaCommand) -- PLAYER_EVENT_ON_COMMAND + +print("[Eluna] Reload helper loaded. Use '.elunareload' in-game to reload scripts.") diff --git a/araxiaonline/lua_scripts/zz_init.lua b/araxiaonline/lua_scripts/zz_init.lua new file mode 100755 index 0000000000..b6fc7888ee --- /dev/null +++ b/araxiaonline/lua_scripts/zz_init.lua @@ -0,0 +1,40 @@ +-- Eluna Integration Tests Initialization +-- This script is loaded at server startup and runs the integration test suite + +-- Only run tests in the global/world Eluna instance (mapId will be max uint32 for global) +-- Each map instance will also load this script, but we skip execution for those +local mapId = GetStateMapId() +-- Global/world state is -1 (or 4294967295 as unsigned). Skip for map instances. +if mapId ~= -1 and mapId ~= 4294967295 then + return +end + +print("\n" .. string.rep("=", 80)) +print("ELUNA INTEGRATION TEST SUITE - AUTO-RUNNING AT STARTUP") +print(string.rep("=", 80) .. "\n") + +-- Load the test runner +local testRunner = require("integration_tests/test_runner") + +-- Load all test modules (they will register with TestRunner) +require("integration_tests/test_core_functionality") +require("integration_tests/test_events") +require("integration_tests/test_data_types") +require("integration_tests/test_bindings") + +-- Run all registered tests +testRunner:RunAll() + +print("\n" .. string.rep("=", 80)) +print("ELUNA INTEGRATION TEST SUITE - COMPLETE") +print("Loading AMS Server...") +require("ab_AMS_Server.AMS_Server") +print("AMS Server loaded successfully") + +-- Load AMS test handlers +require("ac_ams_test_handlers") + +-- Load AraxiaTrinityAdmin server handlers (use dofile to force re-execution after AMS is loaded) +dofile("/opt/trinitycore/lua_scripts/ad_admin_handlers.lua") + +print(string.rep("=", 80) .. "\n")