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
This commit is contained in:
2025-12-31 01:54:46 +00:00
parent f6d5dba20b
commit e3746aa50e
14 changed files with 3657 additions and 26 deletions

View File

@@ -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.**

View File

@@ -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

View File

@@ -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
---

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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!")

File diff suppressed because it is too large Load Diff

View File

@@ -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")

View File

@@ -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")

View File

@@ -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.")

View File

@@ -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")