feat(mcp): Implement MCP Player system for headless AI control

- Add MCPPlayerManager for multi-session AI player management
- Add Player::InitializeForBot() for minimal bot player initialization
- Add AraxiaCore hook system for World::Update callbacks
- Add packet capture callback for headless WorldSession
- Add 14 MCP tools for session/player control

Key fixes:
- Async login/logout to ensure map operations run on world thread
- Manual player cleanup to avoid RemoveFromWorld() hangs
- Null socket handling in WorldSession for headless sessions
- Safe shutdown without mutex issues during static destruction

Test scripts:
- test_mcp_player.sh - Interactive bash test
- test_mcp_integration.py - Python integration tests

Files:
- src/araxiaonline/mcp/MCPPlayerManager.{h,cpp}
- src/araxiaonline/mcp/MCPPlayerTools.cpp
- src/araxiaonline/AraxiaCore.{h,cpp}
- Modified: Player.{h,cpp}, WorldSession.{h,cpp}, World.cpp
This commit is contained in:
2025-12-13 10:57:15 -05:00
parent f8bd3e9a97
commit f6239f35d6
17 changed files with 2664 additions and 3 deletions

View File

View File

@@ -0,0 +1,270 @@
#!/usr/bin/env python3
"""
MCP Player Integration Tests
Tests the MCP player lifecycle without requiring manual interaction.
Designed to help debug login/logout/shutdown issues.
Usage:
python3 test_mcp_integration.py [--host HOST] [--port PORT] [--character NAME]
"""
import argparse
import json
import requests
import time
import sys
class MCPClient:
def __init__(self, host="localhost", port=8765):
self.url = f"http://{host}:{port}/mcp"
self.session_id = None
def call(self, tool_name: str, args: dict) -> dict:
"""Make an MCP tool call and return the parsed result."""
payload = {
"jsonrpc": "2.0",
"id": 1,
"method": "tools/call",
"params": {
"name": tool_name,
"arguments": args
}
}
try:
response = requests.post(self.url, json=payload, timeout=5)
data = response.json()
if "result" in data and "content" in data["result"]:
text = data["result"]["content"][0]["text"]
return json.loads(text)
elif "error" in data:
return {"success": False, "error": data["error"]}
else:
return {"success": False, "error": "Unknown response format", "raw": data}
except requests.exceptions.Timeout:
return {"success": False, "error": "Request timed out (5s)"}
except requests.exceptions.ConnectionError as e:
return {"success": False, "error": f"Connection failed: {e}"}
except Exception as e:
return {"success": False, "error": str(e)}
def create_session(self, owner_name: str = "IntegrationTest") -> dict:
"""Create a new MCP session."""
result = self.call("mcp_session_create", {"owner_name": owner_name})
if result.get("success"):
self.session_id = result.get("session_id")
return result
def destroy_session(self) -> dict:
"""Destroy the current session."""
if not self.session_id:
return {"success": False, "error": "No session"}
result = self.call("mcp_session_destroy", {"session_id": self.session_id})
if result.get("success"):
self.session_id = None
return result
def login(self, character_name: str) -> dict:
"""Login a character."""
if not self.session_id:
return {"success": False, "error": "No session"}
return self.call("mcp_player_login", {
"session_id": self.session_id,
"character_name": character_name
})
def logout(self) -> dict:
"""Logout the current character."""
if not self.session_id:
return {"success": False, "error": "No session"}
return self.call("mcp_player_logout", {"session_id": self.session_id})
def status(self) -> dict:
"""Get player status."""
if not self.session_id:
return {"success": False, "error": "No session"}
return self.call("mcp_player_status", {"session_id": self.session_id})
def list_sessions(self) -> dict:
"""List all sessions."""
return self.call("mcp_session_list", {})
def print_result(name: str, result: dict, verbose: bool = True):
"""Print a test result."""
success = result.get("success", False)
icon = "" if success else ""
color = "\033[92m" if success else "\033[91m"
reset = "\033[0m"
print(f" {color}{icon}{reset} {name}")
if verbose and not success:
print(f" Error: {result.get('error', 'Unknown')}")
return success
def test_session_lifecycle(client: MCPClient, verbose: bool = True):
"""Test basic session create/destroy."""
print("\n[Test] Session Lifecycle")
# Create session
result = client.create_session("LifecycleTest")
if not print_result("Create session", result, verbose):
return False
session_id = result.get("session_id")
print(f" Session ID: {session_id}")
# List sessions
result = client.list_sessions()
print_result("List sessions", result, verbose)
# Destroy session
result = client.destroy_session()
if not print_result("Destroy session", result, verbose):
return False
return True
def test_login_status(client: MCPClient, character_name: str, verbose: bool = True):
"""Test login and status check."""
print("\n[Test] Login and Status")
# Create session
result = client.create_session("LoginTest")
if not print_result("Create session", result, verbose):
return False
# Login
result = client.login(character_name)
if not print_result(f"Login '{character_name}'", result, verbose):
client.destroy_session()
return False
# Wait for async login
print(" Waiting for login to complete...")
time.sleep(2)
# Check status
result = client.status()
if not print_result("Get status", result, verbose):
client.destroy_session()
return False
in_world = result.get("in_world", False)
print(f" In world: {in_world}")
if in_world:
char = result.get("character", {})
pos = result.get("position", {})
print(f" Character: {char.get('name')} (Level {char.get('level')})")
print(f" Position: ({pos.get('x', 0):.1f}, {pos.get('y', 0):.1f}, {pos.get('z', 0):.1f}) Map {pos.get('map')}")
# Cleanup - destroy session (which should handle logout)
result = client.destroy_session()
print_result("Destroy session", result, verbose)
return in_world
def test_logout(client: MCPClient, character_name: str, verbose: bool = True):
"""Test explicit logout (the problematic operation)."""
print("\n[Test] Explicit Logout")
# Create session
result = client.create_session("LogoutTest")
if not print_result("Create session", result, verbose):
return False
# Login
result = client.login(character_name)
if not print_result(f"Login '{character_name}'", result, verbose):
client.destroy_session()
return False
# Wait for async login
print(" Waiting for login to complete...")
time.sleep(2)
# Verify in world
result = client.status()
if not result.get("in_world"):
print_result("Verify in world", {"success": False, "error": "Not in world"}, verbose)
client.destroy_session()
return False
print_result("Verify in world", {"success": True}, verbose)
# Logout (this is the problematic call)
print(" Calling logout (may hang if buggy)...")
result = client.logout()
if not print_result("Logout", result, verbose):
# Even if logout fails, try to cleanup
client.destroy_session()
return False
# Wait for async logout
print(" Waiting for logout to complete...")
time.sleep(2)
# Verify logged out
result = client.status()
in_world = result.get("in_world", True)
print_result("Verify logged out", {"success": not in_world}, verbose)
# Cleanup
result = client.destroy_session()
print_result("Destroy session", result, verbose)
return not in_world
def main():
parser = argparse.ArgumentParser(description="MCP Player Integration Tests")
parser.add_argument("--host", default="localhost", help="MCP server host")
parser.add_argument("--port", type=int, default=8765, help="MCP server port")
parser.add_argument("--character", default="Scarletseer", help="Character name to test with")
parser.add_argument("--test", choices=["all", "session", "login", "logout"], default="all",
help="Which test to run")
parser.add_argument("-v", "--verbose", action="store_true", help="Verbose output")
args = parser.parse_args()
client = MCPClient(args.host, args.port)
print(f"MCP Integration Tests")
print(f"Server: {client.url}")
print(f"Character: {args.character}")
print("=" * 50)
results = {}
if args.test in ["all", "session"]:
results["session"] = test_session_lifecycle(client, args.verbose)
if args.test in ["all", "login"]:
results["login"] = test_login_status(client, args.character, args.verbose)
if args.test in ["all", "logout"]:
results["logout"] = test_logout(client, args.character, args.verbose)
# Summary
print("\n" + "=" * 50)
print("Summary:")
passed = sum(1 for v in results.values() if v)
total = len(results)
for name, success in results.items():
icon = "" if success else ""
color = "\033[92m" if success else "\033[91m"
reset = "\033[0m"
print(f" {color}{icon}{reset} {name}")
print(f"\nPassed: {passed}/{total}")
return 0 if passed == total else 1
if __name__ == "__main__":
sys.exit(main())

View File

@@ -0,0 +1,195 @@
#!/bin/bash
# MCP Player Login Test Script
# Tests the full MCP player login flow and shutdown behavior
MCP_HOST="${MCP_HOST:-localhost}"
MCP_PORT="${MCP_PORT:-8765}"
MCP_URL="http://${MCP_HOST}:${MCP_PORT}/mcp"
# Colors for output
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
BLUE='\033[0;34m'
CYAN='\033[0;36m'
GRAY='\033[0;90m'
BOLD='\033[1m'
NC='\033[0m' # No Color
# Helper function to make MCP calls
mcp_call() {
local method="$1"
local tool_name="$2"
local args="$3"
local payload="{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"${method}\",\"params\":{\"name\":\"${tool_name}\",\"arguments\":${args}}}"
curl -s -X POST "$MCP_URL" \
-H "Content-Type: application/json" \
-d "$payload"
}
# Pretty print MCP response - extracts and formats the inner JSON
format_response() {
python3 -c "
import sys, json
try:
data = json.load(sys.stdin)
if 'result' in data and 'content' in data['result']:
text = data['result']['content'][0]['text']
inner = json.loads(text)
print(json.dumps(inner, indent=2))
else:
print(json.dumps(data, indent=2))
except Exception as e:
print(f'Parse error: {e}')
" 2>/dev/null
}
# Extract a value from the inner JSON response
extract_value() {
local key="$1"
python3 -c "
import sys, json
try:
data = json.load(sys.stdin)
text = data['result']['content'][0]['text']
inner = json.loads(text)
print(inner.get('$key', ''))
except:
print('')
" 2>/dev/null
}
echo -e "${BOLD}${BLUE}╔════════════════════════════════════════╗${NC}"
echo -e "${BOLD}${BLUE}║ MCP Player Login Test Script ║${NC}"
echo -e "${BOLD}${BLUE}╚════════════════════════════════════════╝${NC}"
echo ""
echo -e " ${GRAY}Server:${NC} ${CYAN}${MCP_URL}${NC}"
echo ""
# Step 1: Create session
echo -e "${BOLD}${BLUE}▶ Step 1:${NC} Creating MCP session..."
RESPONSE=$(mcp_call "tools/call" "mcp_session_create" '{"owner_name":"TestScript"}')
SESSION_ID=$(echo "$RESPONSE" | extract_value "session_id")
if [ -z "$SESSION_ID" ]; then
echo -e " ${RED}✗ Failed to create session. Is the server running?${NC}"
exit 1
fi
echo -e " ${GREEN}${NC} Session created: ${BOLD}${SESSION_ID}${NC}"
echo ""
# Step 2: Login player
CHARACTER_NAME="${1:-Scarletseer}"
echo -e "${BOLD}${BLUE}▶ Step 2:${NC} Logging in character: ${YELLOW}${CHARACTER_NAME}${NC}..."
RESPONSE=$(mcp_call "tools/call" "mcp_player_login" "{\"session_id\":${SESSION_ID},\"character_name\":\"${CHARACTER_NAME}\"}")
SUCCESS=$(echo "$RESPONSE" | extract_value "success")
if [ "$SUCCESS" = "True" ] || [ "$SUCCESS" = "true" ]; then
echo -e " ${GREEN}${NC} Login initiated"
else
echo -e " ${RED}${NC} Login failed"
echo "$RESPONSE" | format_response
fi
echo ""
# Step 3: Wait a moment for async login
echo -e "${BOLD}${BLUE}▶ Step 3:${NC} Waiting for login to complete..."
sleep 1
echo -e " ${GREEN}${NC} Done"
echo ""
# Step 4: Check player status
echo -e "${BOLD}${BLUE}▶ Step 4:${NC} Checking player status..."
RESPONSE=$(mcp_call "tools/call" "mcp_player_status" "{\"session_id\":${SESSION_ID}}")
# Parse and display player info nicely
python3 -c "
import sys, json
try:
data = json.load(sys.stdin)
text = data['result']['content'][0]['text']
info = json.loads(text)
if info.get('in_world'):
print(' \033[0;32m✓\033[0m Player is in world!')
print()
char = info.get('character', {})
pos = info.get('position', {})
print(' \033[1m┌─ Character ─────────────────────────┐\033[0m')
print(f' │ Name: \033[1;33m{char.get(\"name\", \"?\")}\033[0m')
print(f' │ Level: {char.get(\"level\", \"?\")}')
print(f' │ Race: {char.get(\"race\", \"?\")} Class: {char.get(\"class\", \"?\")}')
print(f' │ Health: {char.get(\"health\", \"?\")}/{char.get(\"max_health\", \"?\")}')
print(f' │ Alive: {\"Yes\" if char.get(\"alive\") else \"No\"}')
print(' \033[1m└──────────────────────────────────────┘\033[0m')
print()
print(' \033[1m┌─ Position ──────────────────────────┐\033[0m')
print(f' │ Map: {pos.get(\"map\", \"?\")}')
print(f' │ Zone: {pos.get(\"zone\", \"?\")} Area: {pos.get(\"area\", \"?\")}')
print(f' │ X: {pos.get(\"x\", 0):.2f}')
print(f' │ Y: {pos.get(\"y\", 0):.2f}')
print(f' │ Z: {pos.get(\"z\", 0):.2f}')
print(' \033[1m└──────────────────────────────────────┘\033[0m')
else:
print(' \033[0;31m✗\033[0m Player not in world yet')
except Exception as e:
print(f' Error parsing response: {e}')
" <<< "$RESPONSE"
echo ""
# Step 5: List all sessions
echo -e "${BOLD}${BLUE}▶ Step 5:${NC} Listing active sessions..."
RESPONSE=$(mcp_call "tools/call" "mcp_session_list" "{}")
python3 -c "
import sys, json
try:
data = json.load(sys.stdin)
text = data['result']['content'][0]['text']
info = json.loads(text)
sessions = info.get('sessions', [])
print(f' Found {len(sessions)} session(s):')
for s in sessions:
status = '\033[0;32m●\033[0m' if s.get('online') else '\033[0;90m○\033[0m'
char = s.get('character') or '(no character)'
print(f' {status} Session {s.get(\"session_id\")}: {char} ({s.get(\"owner_name\")})')
except Exception as e:
print(f' Error: {e}')
" <<< "$RESPONSE"
echo ""
# Interactive mode
echo -e "${BOLD}${BLUE}╔════════════════════════════════════════╗${NC}"
echo -e "${BOLD}${BLUE}║ Player Ready! ║${NC}"
echo -e "${BOLD}${BLUE}╚════════════════════════════════════════╝${NC}"
echo ""
echo -e " ${BOLD}Session ID:${NC} ${YELLOW}${SESSION_ID}${NC}"
echo -e " ${BOLD}Character:${NC} ${YELLOW}${CHARACTER_NAME}${NC}"
echo ""
echo -e " ${BOLD}${CYAN}To test shutdown crash fix:${NC}"
echo -e " 1. Keep this player logged in"
echo -e " 2. In the server console, run: ${YELLOW}server shutdown 1${NC}"
echo -e " 3. Server should shut down ${GREEN}cleanly${NC} without crash"
echo ""
echo -e "${BLUE}──────────────────────────────────────────${NC}"
# Optional: Wait for user input to cleanup
read -p "Press Enter to logout and destroy session (or Ctrl+C to keep active)..."
# Cleanup
echo ""
echo -e "${BOLD}${BLUE}▶ Cleanup${NC}"
echo -n " Logging out... "
mcp_call "tools/call" "mcp_player_logout" "{\"session_id\":${SESSION_ID}}" > /dev/null 2>&1
echo -e "${GREEN}${NC}"
echo -n " Destroying session... "
mcp_call "tools/call" "mcp_session_destroy" "{\"session_id\":${SESSION_ID}}" > /dev/null 2>&1
echo -e "${GREEN}${NC}"
echo ""
echo -e "${GREEN}${BOLD}✓ Test complete!${NC}"

View File

@@ -0,0 +1,86 @@
/*
* AraxiaCore Implementation
*/
#include "AraxiaCore.h"
#include "Log.h"
namespace Araxia
{
AraxiaCore* AraxiaCore::Instance()
{
static AraxiaCore instance;
return &instance;
}
void AraxiaCore::Initialize()
{
if (_initialized)
return;
TC_LOG_INFO("araxia", "[AraxiaCore] Initializing Araxia systems hook...");
_initialized = true;
}
void AraxiaCore::Shutdown()
{
TC_LOG_INFO("araxia", "[AraxiaCore] Shutting down (%zu callbacks registered)...", _callbacks.size());
std::lock_guard<std::mutex> lock(_callbackMutex);
_callbacks.clear();
_initialized = false;
}
void AraxiaCore::Update(uint32 diff)
{
if (!_initialized)
return;
std::lock_guard<std::mutex> lock(_callbackMutex);
for (auto& [name, callback] : _callbacks)
{
try
{
callback(diff);
}
catch (const std::exception& e)
{
TC_LOG_ERROR("araxia", "[AraxiaCore] Exception in callback '%s': %s", name.c_str(), e.what());
}
}
}
void AraxiaCore::RegisterUpdateCallback(const std::string& name, UpdateCallback callback)
{
std::lock_guard<std::mutex> lock(_callbackMutex);
if (_callbacks.find(name) != _callbacks.end())
{
TC_LOG_WARN("araxia", "[AraxiaCore] Replacing existing callback: %s", name.c_str());
}
_callbacks[name] = std::move(callback);
TC_LOG_INFO("araxia", "[AraxiaCore] Registered callback: %s (total: %zu)", name.c_str(), _callbacks.size());
}
void AraxiaCore::UnregisterUpdateCallback(const std::string& name)
{
std::lock_guard<std::mutex> lock(_callbackMutex);
auto it = _callbacks.find(name);
if (it != _callbacks.end())
{
_callbacks.erase(it);
TC_LOG_INFO("araxia", "[AraxiaCore] Unregistered callback: %s (remaining: %zu)", name.c_str(), _callbacks.size());
}
}
bool AraxiaCore::HasCallback(const std::string& name) const
{
std::lock_guard<std::mutex> lock(_callbackMutex);
return _callbacks.find(name) != _callbacks.end();
}
} // namespace Araxia

View File

@@ -0,0 +1,76 @@
/*
* AraxiaCore - Central update hook for all Araxia systems
*
* This singleton provides a single integration point with World::Update().
* Other Araxia systems register callbacks here instead of each needing
* their own hook into the core engine.
*
* Usage:
* // During initialization
* sAraxiaCore->RegisterUpdateCallback("MySystem", [](uint32 diff) {
* // Called every world update tick
* });
*
* // During shutdown
* sAraxiaCore->UnregisterUpdateCallback("MySystem");
*/
#ifndef ARAXIA_CORE_H
#define ARAXIA_CORE_H
#include "Define.h"
#include <functional>
#include <string>
#include <map>
#include <mutex>
namespace Araxia
{
// Callback signature for update hooks
using UpdateCallback = std::function<void(uint32 diff)>;
/**
* AraxiaCore - Singleton that provides World::Update hooks for Araxia systems.
*
* Systems can register callbacks that will be called every world tick.
* This is useful for:
* - Processing async database callbacks
* - Timed events
* - Session management
* - Any periodic updates
*/
class TC_GAME_API AraxiaCore
{
public:
static AraxiaCore* Instance();
// Lifecycle
void Initialize();
void Shutdown();
// Called from World::Update() - dispatches to all registered callbacks
void Update(uint32 diff);
// Callback registration
void RegisterUpdateCallback(const std::string& name, UpdateCallback callback);
void UnregisterUpdateCallback(const std::string& name);
bool HasCallback(const std::string& name) const;
// Stats
size_t GetCallbackCount() const { return _callbacks.size(); }
private:
AraxiaCore() = default;
~AraxiaCore() = default;
std::map<std::string, UpdateCallback> _callbacks;
mutable std::mutex _callbackMutex;
bool _initialized{false};
};
} // namespace Araxia
#define sAraxiaCore Araxia::AraxiaCore::Instance()
#endif // ARAXIA_CORE_H

View File

@@ -3,6 +3,8 @@
*/
#include "AraxiaMCPServer.h"
#include "AraxiaCore.h"
#include "MCPPlayerManager.h"
#include "Config.h"
#include "Log.h"
#include "World.h"
@@ -71,6 +73,14 @@ bool MCPServer::Initialize()
RegisterDatabaseTools();
RegisterWorldScanTools(); // LIDAR-style spatial awareness
RegisterSpawnTools(); // Headless spawn management
// Initialize AraxiaCore (provides World::Update hook for all Araxia systems)
sAraxiaCore->Initialize();
// Initialize and register MCP Player tools
sMCPPlayerMgr->Initialize();
RegisterMCPPlayerTools(); // AI player session management
// RegisterElunaTools(); // Phase 3
// RegisterWorldTools(); // Phase 4
@@ -134,6 +144,12 @@ void MCPServer::Shutdown()
if (_shutdownRequested.exchange(true))
return;
// Shutdown MCPPlayerManager first (logs out all AI players)
sMCPPlayerMgr->Shutdown();
// Shutdown AraxiaCore
sAraxiaCore->Shutdown();
// Stop the HTTP server first (this will cause listen() to return)
// httplib::Server::stop() is thread-safe
if (_impl)

View File

@@ -107,6 +107,7 @@ void RegisterElunaTools();
void RegisterWorldTools();
void RegisterWorldScanTools(); // LIDAR-style spatial awareness
void RegisterSpawnTools(); // Headless spawn management (no player required)
void RegisterMCPPlayerTools(); // AI player session management
} // namespace Araxia

View File

@@ -0,0 +1,949 @@
/*
* Araxia MCP Player Manager Implementation
*
* Manages multiple AI-controlled player sessions.
* Each session represents one LLM controlling one player character.
*/
#include "MCPPlayerManager.h"
#include "AraxiaCore.h"
#include "Config.h"
#include "Log.h"
#include "World.h"
#include "WorldSession.h"
#include "Player.h"
#include "ObjectAccessor.h"
#include "ObjectMgr.h"
#include "CharacterCache.h"
#include "Map.h"
#include "MapManager.h"
#include "MotionMaster.h"
#include "SpellMgr.h"
#include "DatabaseEnv.h"
#include "QueryHolder.h"
#include "Chat.h"
#include "CharacterPackets.h"
#include <random>
#include <sstream>
#include <iomanip>
#include <thread>
#include <chrono>
namespace Araxia
{
// MCPPlayerSession::isOnline() - defined here since Player must be fully defined
bool MCPPlayerSession::isOnline() const
{
return player != nullptr && player->IsInWorld();
}
// ============================================================================
// Login Query Holder - Prepares DB queries for player login
// ============================================================================
class MCPPlayerLoginQueryHolder : public CharacterDatabaseQueryHolder
{
public:
MCPPlayerLoginQueryHolder(uint32 accountId, ObjectGuid guid, uint32 sessionId)
: _accountId(accountId), _guid(guid), _sessionId(sessionId) { }
ObjectGuid GetGuid() const { return _guid; }
uint32 GetAccountId() const { return _accountId; }
uint32 GetSessionId() const { return _sessionId; }
bool Initialize();
private:
uint32 _accountId;
ObjectGuid _guid;
uint32 _sessionId;
};
bool MCPPlayerLoginQueryHolder::Initialize()
{
SetSize(MAX_PLAYER_LOGIN_QUERY);
bool res = true;
ObjectGuid::LowType lowGuid = _guid.GetCounter();
// Core character data
CharacterDatabasePreparedStatement* stmt = CharacterDatabase.GetPreparedStatement(CHAR_SEL_CHARACTER);
stmt->setUInt64(0, lowGuid);
res &= SetPreparedQuery(PLAYER_LOGIN_QUERY_LOAD_FROM, stmt);
stmt = CharacterDatabase.GetPreparedStatement(CHAR_SEL_CHARACTER_CUSTOMIZATIONS);
stmt->setUInt64(0, lowGuid);
res &= SetPreparedQuery(PLAYER_LOGIN_QUERY_LOAD_CUSTOMIZATIONS, stmt);
stmt = CharacterDatabase.GetPreparedStatement(CHAR_SEL_GROUP_MEMBER);
stmt->setUInt64(0, lowGuid);
res &= SetPreparedQuery(PLAYER_LOGIN_QUERY_LOAD_GROUP, stmt);
stmt = CharacterDatabase.GetPreparedStatement(CHAR_SEL_CHARACTER_AURAS);
stmt->setUInt64(0, lowGuid);
res &= SetPreparedQuery(PLAYER_LOGIN_QUERY_LOAD_AURAS, stmt);
stmt = CharacterDatabase.GetPreparedStatement(CHAR_SEL_CHARACTER_AURA_EFFECTS);
stmt->setUInt64(0, lowGuid);
res &= SetPreparedQuery(PLAYER_LOGIN_QUERY_LOAD_AURA_EFFECTS, stmt);
stmt = CharacterDatabase.GetPreparedStatement(CHAR_SEL_CHARACTER_AURA_STORED_LOCATIONS);
stmt->setUInt64(0, lowGuid);
res &= SetPreparedQuery(PLAYER_LOGIN_QUERY_LOAD_AURA_STORED_LOCATIONS, stmt);
stmt = CharacterDatabase.GetPreparedStatement(CHAR_SEL_CHARACTER_SPELL);
stmt->setUInt64(0, lowGuid);
res &= SetPreparedQuery(PLAYER_LOGIN_QUERY_LOAD_SPELLS, stmt);
stmt = CharacterDatabase.GetPreparedStatement(CHAR_SEL_CHARACTER_SPELL_FAVORITES);
stmt->setUInt64(0, lowGuid);
res &= SetPreparedQuery(PLAYER_LOGIN_QUERY_LOAD_SPELL_FAVORITES, stmt);
stmt = CharacterDatabase.GetPreparedStatement(CHAR_SEL_CHARACTER_INVENTORY);
stmt->setUInt64(0, lowGuid);
res &= SetPreparedQuery(PLAYER_LOGIN_QUERY_LOAD_INVENTORY, stmt);
stmt = CharacterDatabase.GetPreparedStatement(CHAR_SEL_CHARACTER_REPUTATION);
stmt->setUInt64(0, lowGuid);
res &= SetPreparedQuery(PLAYER_LOGIN_QUERY_LOAD_REPUTATION, stmt);
// Add more queries as needed for full player functionality
// This is a minimal set for basic operation
return res;
}
// ============================================================================
// MCPPlayerManager Implementation
// ============================================================================
MCPPlayerManager::MCPPlayerManager() = default;
MCPPlayerManager::~MCPPlayerManager()
{
// Don't call Shutdown() from destructor during static destruction
// The mutex may already be destroyed. Shutdown should be called explicitly
// from AraxiaMCPServer::Shutdown() before process exit.
// If we get here with sessions still active, they'll leak but at least we won't crash.
if (_initialized)
{
TC_LOG_WARN("araxia.mcp", "[MCPPlayerManager] Destructor called while still initialized - sessions may leak");
}
}
MCPPlayerManager* MCPPlayerManager::Instance()
{
static MCPPlayerManager instance;
return &instance;
}
bool MCPPlayerManager::Initialize()
{
if (_initialized)
return true;
// Load configuration
_maxSessions = sConfigMgr->GetIntDefault("Araxia.MCP.Player.MaxSessions", 10);
_defaultAccountId = sConfigMgr->GetIntDefault("Araxia.MCP.Player.DefaultAccountId", 0);
_sessionTimeoutSeconds = sConfigMgr->GetIntDefault("Araxia.MCP.Player.SessionTimeout", 3600);
// Register with AraxiaCore for World::Update callbacks
sAraxiaCore->RegisterUpdateCallback("MCPPlayerManager", [this](uint32 diff) {
Update(diff);
});
TC_LOG_INFO("araxia.mcp", "[MCPPlayerManager] Initialized (maxSessions: {}, defaultAccount: {})",
_maxSessions, _defaultAccountId);
_initialized = true;
return true;
}
void MCPPlayerManager::Update(uint32 /*diff*/)
{
// Process async operations for all bot sessions on the world thread
std::lock_guard<std::mutex> lock(_sessionMutex);
for (auto& [sessionId, session] : _sessions)
{
// Process pending logouts first (must happen on world thread)
if (session->logoutPending && session->player)
{
LogoutInternal(session.get());
session->logoutPending = false;
}
// Process login callbacks
if (session->worldSession && session->loginPending)
{
// Process any pending database query callbacks
TC_LOG_DEBUG("araxia.mcp", "[MCPPlayerManager] Processing callbacks for session {} (loginPending={})",
sessionId, session->loginPending);
session->worldSession->GetQueryProcessor().ProcessReadyCallbacks();
}
}
}
void MCPPlayerManager::Shutdown()
{
if (!_initialized)
return;
// Mark as not initialized first to prevent re-entry
_initialized = false;
// Unregister from AraxiaCore
sAraxiaCore->UnregisterUpdateCallback("MCPPlayerManager");
TC_LOG_INFO("araxia.mcp", "[MCPPlayerManager] Shutting down, logging out all sessions...");
// Try to lock mutex - if it fails (during static destruction), just proceed without lock
std::unique_lock<std::mutex> lock(_sessionMutex, std::try_to_lock);
if (!lock.owns_lock())
{
TC_LOG_WARN("araxia.mcp", "[MCPPlayerManager] Could not acquire mutex during shutdown - proceeding anyway");
}
for (auto& [id, session] : _sessions)
{
if (!session)
continue;
TC_LOG_INFO("araxia.mcp", "[MCPPlayerManager] Cleaning up session {}", id);
// Always clean up player if it exists
if (session->player)
{
TC_LOG_INFO("araxia.mcp", "[MCPPlayerManager] Removing player from world...");
// Check if player is still in world and has a valid map
// Use IsInWorld() instead of GetMap() which asserts on null
if (session->player->IsInWorld())
{
// FindMap doesn't assert, returns nullptr if map is gone
Map* map = sMapMgr->FindMap(session->player->GetMapId(), session->player->GetInstanceId());
if (map)
{
// RemovePlayerFromMap with remove=true will call DeleteFromWorld
map->RemovePlayerFromMap(session->player, true);
session->player = nullptr; // Map deleted it
}
else
{
// Map already gone, just clean up the player object
TC_LOG_INFO("araxia.mcp", "[MCPPlayerManager] Map already unloaded, cleaning up player directly");
ObjectAccessor::RemoveObject(session->player);
delete session->player;
session->player = nullptr;
}
}
else
{
// Not in world, just delete directly
ObjectAccessor::RemoveObject(session->player);
delete session->player;
session->player = nullptr;
}
}
// Clean up WorldSession
if (session->worldSession)
{
// Clear the packet capture callback to avoid dangling references
session->worldSession->SetPacketCaptureCallback(nullptr);
delete session->worldSession;
session->worldSession = nullptr;
}
}
_sessions.clear();
_tokenToSession.clear();
TC_LOG_INFO("araxia.mcp", "[MCPPlayerManager] Shutdown complete");
_initialized = false;
}
// ============================================================================
// Session Management
// ============================================================================
std::string MCPPlayerManager::GenerateToken()
{
static std::random_device rd;
static std::mt19937 gen(rd());
static std::uniform_int_distribution<> dis(0, 15);
std::stringstream ss;
ss << std::hex;
for (int i = 0; i < 32; ++i)
ss << dis(gen);
return ss.str();
}
uint32 MCPPlayerManager::CreateSession(const std::string& ownerName)
{
std::lock_guard<std::mutex> lock(_sessionMutex);
if (_sessions.size() >= _maxSessions)
{
TC_LOG_WARN("araxia.mcp", "[MCPPlayerManager] Max sessions reached (%zu)", _sessions.size());
return 0;
}
auto session = std::make_unique<MCPPlayerSession>();
session->sessionId = _nextSessionId++;
session->sessionToken = GenerateToken();
session->ownerName = ownerName;
session->createdAt = time(nullptr);
session->lastActivity = time(nullptr);
session->accountId = _defaultAccountId;
uint32 sessionId = session->sessionId;
_tokenToSession[session->sessionToken] = sessionId;
_sessions[sessionId] = std::move(session);
TC_LOG_INFO("araxia.mcp", "[MCPPlayerManager] Created session {} for '{}'",
sessionId, ownerName);
return sessionId;
}
bool MCPPlayerManager::DestroySession(uint32 sessionId)
{
std::lock_guard<std::mutex> lock(_sessionMutex);
auto it = _sessions.find(sessionId);
if (it == _sessions.end())
return false;
MCPPlayerSession* session = it->second.get();
// Logout player if online
if (session->isOnline())
{
LogoutInternal(session);
}
// Remove token mapping
_tokenToSession.erase(session->sessionToken);
TC_LOG_INFO("araxia.mcp", "[MCPPlayerManager] Destroyed session {} ('{}')",
sessionId, session->ownerName);
_sessions.erase(it);
return true;
}
MCPPlayerSession* MCPPlayerManager::GetSession(uint32 sessionId)
{
std::lock_guard<std::mutex> lock(_sessionMutex);
auto it = _sessions.find(sessionId);
return (it != _sessions.end()) ? it->second.get() : nullptr;
}
MCPPlayerSession* MCPPlayerManager::GetSessionByToken(const std::string& token)
{
std::lock_guard<std::mutex> lock(_sessionMutex);
auto it = _tokenToSession.find(token);
if (it == _tokenToSession.end())
return nullptr;
auto sessionIt = _sessions.find(it->second);
return (sessionIt != _sessions.end()) ? sessionIt->second.get() : nullptr;
}
std::vector<uint32> MCPPlayerManager::GetActiveSessions() const
{
std::lock_guard<std::mutex> lock(_sessionMutex);
std::vector<uint32> result;
result.reserve(_sessions.size());
for (const auto& [id, session] : _sessions)
result.push_back(id);
return result;
}
size_t MCPPlayerManager::GetSessionCount() const
{
std::lock_guard<std::mutex> lock(_sessionMutex);
return _sessions.size();
}
// ============================================================================
// Player Lifecycle
// ============================================================================
WorldSession* MCPPlayerManager::CreateBotSession(uint32 sessionId, uint32 accountId)
{
// Create a WorldSession with NO socket (nullptr)
// This is the key to making a headless player work!
WorldSession* session = new WorldSession(
accountId, // Account ID
std::string("MCPBot"), // Account name
0, // Battlenet account ID
nullptr, // Socket = NULL (no network!)
SEC_GAMEMASTER, // Security level - GM for full access
EXPANSION_THE_WAR_WITHIN, // Expansion
0, // Mute time
"Bot", // OS string
Minutes(0), // Timezone offset
0, // Client build
{}, // Build variant
LOCALE_enUS, // Locale
0, // Recruiter
false // Is recruiter
);
// NOTE: We do NOT add to sWorld - we'll handle updates manually
// sWorld->AddSession() can cause issues with null-socket sessions
// Set up packet capture callback for this session
session->SetPacketCaptureCallback([sessionId, this](WorldPacket const& packet) {
CapturePacket(sessionId, packet);
});
TC_LOG_INFO("araxia.mcp", "[MCPPlayerManager] Created bot session for account {} with packet capture", accountId);
return session;
}
bool MCPPlayerManager::Login(uint32 sessionId, ObjectGuid playerGuid)
{
MCPPlayerSession* session = GetSession(sessionId);
if (!session)
{
TC_LOG_ERROR("araxia.mcp", "[MCPPlayerManager] Login failed - session %u not found", sessionId);
return false;
}
if (session->isOnline())
{
TC_LOG_WARN("araxia.mcp", "[MCPPlayerManager] Session %u already has player online", sessionId);
return false;
}
if (session->loginPending)
{
TC_LOG_WARN("araxia.mcp", "[MCPPlayerManager] Session %u login already pending", sessionId);
return false;
}
// Get account ID for this character
uint32 accountId = sCharacterCache->GetCharacterAccountIdByGuid(playerGuid);
if (!accountId)
{
TC_LOG_ERROR("araxia.mcp", "[MCPPlayerManager] Character %s not found", playerGuid.ToString().c_str());
return false;
}
// Use session's account ID if set, otherwise use character's account
if (session->accountId > 0)
accountId = session->accountId;
TC_LOG_INFO("araxia.mcp", "[MCPPlayerManager] Session {} initiating login for {} (account {})",
sessionId, playerGuid.ToString(), accountId);
// Create the WorldSession
session->worldSession = CreateBotSession(sessionId, accountId);
if (!session->worldSession)
{
TC_LOG_ERROR("araxia.mcp", "[MCPPlayerManager] Failed to create bot session");
return false;
}
session->playerGuid = playerGuid;
session->loginPending = true;
session->lastActivity = time(nullptr);
// First, query basic character info for caching
QueryResult result = CharacterDatabase.PQuery(
"SELECT guid, account, name, race, class, level, map, position_x, position_y, position_z "
"FROM characters WHERE guid = {}", playerGuid.GetCounter());
if (!result)
{
TC_LOG_ERROR("araxia.mcp", "[MCPPlayerManager] Character %s not found in database",
playerGuid.ToString().c_str());
delete session->worldSession;
session->worldSession = nullptr;
session->loginPending = false;
return false;
}
// Cache basic info
Field* fields = result->Fetch();
session->characterName = fields[2].GetString();
session->accountId = fields[1].GetUInt32();
session->level = fields[5].GetUInt8();
session->race = fields[3].GetUInt8();
session->playerClass = fields[4].GetUInt8();
session->mapId = fields[6].GetUInt32();
session->posX = fields[7].GetFloat();
session->posY = fields[8].GetFloat();
session->posZ = fields[9].GetFloat();
TC_LOG_INFO("araxia.mcp", "[MCPPlayerManager] Character '{}' found (Level {}, Map {})",
session->characterName, session->level, session->mapId);
// Phase 1: Proper player initialization using InitializeForBot
TC_LOG_INFO("araxia.mcp", "[MCPPlayerManager] Initializing player (Phase 1 - proper init)...");
// Create Player object
session->player = new Player(session->worldSession);
// Initialize using our new bot-specific method (handles GUID, race, class, stats, etc.)
session->player->InitializeForBot(
playerGuid,
session->characterName,
session->race,
session->playerClass,
GENDER_MALE, // Default, we didn't query gender
session->level
);
// Relocate to saved position
session->player->Relocate(session->posX, session->posY, session->posZ, session->orientation);
// Get/create the map
Map* map = sMapMgr->CreateMap(session->mapId, session->player);
if (!map)
{
TC_LOG_ERROR("araxia.mcp", "[MCPPlayerManager] Failed to create map {}", session->mapId);
delete session->player;
session->player = nullptr;
session->loginPending = false;
return true;
}
// Set map and update position data
session->player->SetMap(map);
session->player->UpdatePositionData();
// Link session to player
session->worldSession->SetPlayer(session->player);
// Add player to map
if (!map->AddPlayerToMap(session->player))
{
TC_LOG_ERROR("araxia.mcp", "[MCPPlayerManager] Failed to add player to map");
session->worldSession->SetPlayer(nullptr);
delete session->player;
session->player = nullptr;
session->loginPending = false;
return true;
}
// Add to ObjectAccessor so world can find us
ObjectAccessor::AddObject(session->player);
session->loginPending = false;
TC_LOG_INFO("araxia.mcp", "[MCPPlayerManager] Session {}: Player '{}' initialized and added to world!",
session->sessionId, session->characterName);
return true;
}
bool MCPPlayerManager::Login(uint32 sessionId, const std::string& characterName)
{
ObjectGuid guid = sCharacterCache->GetCharacterGuidByName(characterName);
if (guid.IsEmpty())
{
TC_LOG_ERROR("araxia.mcp", "[MCPPlayerManager] Character '%s' not found", characterName.c_str());
return false;
}
return Login(sessionId, guid);
}
void MCPPlayerManager::HandleLoginCallback(SQLQueryHolderBase const& holderBase)
{
TC_LOG_INFO("araxia.mcp", "[MCPPlayerManager] HandleLoginCallback fired!");
MCPPlayerLoginQueryHolder const& holder = static_cast<MCPPlayerLoginQueryHolder const&>(holderBase);
uint32 sessionId = holder.GetSessionId();
MCPPlayerSession* session = GetSession(sessionId);
if (!session)
{
TC_LOG_ERROR("araxia.mcp", "[MCPPlayerManager] Login callback but session %u gone!", sessionId);
return;
}
session->loginPending = false;
if (!session->worldSession)
{
TC_LOG_ERROR("araxia.mcp", "[MCPPlayerManager] Login callback but no WorldSession!");
return;
}
// Create the Player object
session->player = new Player(session->worldSession);
// Load player data from database
if (!session->player->LoadFromDB(session->playerGuid, holder))
{
TC_LOG_ERROR("araxia.mcp", "[MCPPlayerManager] Failed to load player from database");
delete session->player;
session->player = nullptr;
delete session->worldSession;
session->worldSession = nullptr;
return;
}
// Initialize motion master
session->player->GetMotionMaster()->Initialize();
// Add player to map
Map* map = sMapMgr->CreateMap(session->player->GetMapId(), session->player);
if (!map || !map->AddPlayerToMap(session->player))
{
TC_LOG_ERROR("araxia.mcp", "[MCPPlayerManager] Failed to add player to map %u", session->player->GetMapId());
// Try homebind
if (!session->player->TeleportTo(session->player->m_homebind))
{
TC_LOG_ERROR("araxia.mcp", "[MCPPlayerManager] Failed to teleport to homebind, aborting");
delete session->player;
session->player = nullptr;
delete session->worldSession;
session->worldSession = nullptr;
return;
}
}
// Add to ObjectAccessor
ObjectAccessor::AddObject(session->player);
// Mark online in database
CharacterDatabasePreparedStatement* stmt = CharacterDatabase.GetPreparedStatement(CHAR_UPD_CHAR_ONLINE);
stmt->setUInt64(0, session->player->GetGUID().GetCounter());
CharacterDatabase.Execute(stmt);
// Set player in session
session->worldSession->SetPlayer(session->player);
TC_LOG_INFO("araxia.mcp", "[MCPPlayerManager] Session {}: '{}' logged in at ({:.1f}, {:.1f}, {:.1f}) map {}",
sessionId, session->player->GetName(),
session->player->GetPositionX(), session->player->GetPositionY(),
session->player->GetPositionZ(), session->player->GetMapId());
}
void MCPPlayerManager::Logout(uint32 sessionId)
{
std::lock_guard<std::mutex> lock(_sessionMutex);
auto it = _sessions.find(sessionId);
if (it == _sessions.end())
return;
// Queue logout to happen on world thread during Update()
// Map operations are not thread-safe
it->second->logoutPending = true;
TC_LOG_INFO("araxia.mcp", "[MCPPlayerManager] Logout queued for session {}", sessionId);
}
void MCPPlayerManager::LogoutInternal(MCPPlayerSession* session)
{
if (!session || !session->player)
return;
TC_LOG_INFO("araxia.mcp", "[MCPPlayerManager] Logging out '{}' (session {})",
session->player->GetName(), session->sessionId);
// For bot players, we need to manually clean up without going through the full
// RemovePlayerFromMap path which calls RemoveFromWorld -> DoLootReleaseAll
// and can cause hangs due to uninitialized player state
Player* player = session->player;
session->player = nullptr; // Clear reference first
TC_LOG_DEBUG("araxia.mcp", "[MCPPlayerManager] Step 1: Clearing session player reference");
// Unlink from WorldSession
if (session->worldSession)
{
session->worldSession->SetPlayer(nullptr);
}
TC_LOG_DEBUG("araxia.mcp", "[MCPPlayerManager] Step 2: Unlinked from WorldSession");
// Remove from ObjectAccessor
ObjectAccessor::RemoveObject(player);
TC_LOG_DEBUG("araxia.mcp", "[MCPPlayerManager] Step 3: Removed from ObjectAccessor");
// Remove from map grid without full cleanup (avoid RemoveFromWorld)
if (player->IsInWorld())
{
TC_LOG_DEBUG("araxia.mcp", "[MCPPlayerManager] Step 4: Player is in world, removing from grid");
// Manually remove from grid if in grid
if (player->IsInGrid())
{
player->RemoveFromGrid();
TC_LOG_DEBUG("araxia.mcp", "[MCPPlayerManager] Step 4a: Removed from grid");
}
// Clear the in-world flag manually
player->Object::RemoveFromWorld();
TC_LOG_DEBUG("araxia.mcp", "[MCPPlayerManager] Step 4b: Cleared in-world flag");
}
TC_LOG_DEBUG("araxia.mcp", "[MCPPlayerManager] Step 5: Deleting player object");
// Delete the player
delete player;
TC_LOG_DEBUG("araxia.mcp", "[MCPPlayerManager] Step 6: Player deleted");
// Mark offline
CharacterDatabasePreparedStatement* stmt = CharacterDatabase.GetPreparedStatement(CHAR_UPD_CHAR_ONLINE);
stmt->setUInt64(0, session->playerGuid.GetCounter());
CharacterDatabase.Execute(stmt);
TC_LOG_INFO("araxia.mcp", "[MCPPlayerManager] Logout complete for session {}", session->sessionId);
if (session->worldSession)
{
delete session->worldSession;
session->worldSession = nullptr;
}
session->playerGuid.Clear();
}
bool MCPPlayerManager::IsOnline(uint32 sessionId) const
{
std::lock_guard<std::mutex> lock(_sessionMutex);
auto it = _sessions.find(sessionId);
return (it != _sessions.end()) && it->second->isOnline();
}
Player* MCPPlayerManager::GetPlayer(uint32 sessionId) const
{
std::lock_guard<std::mutex> lock(_sessionMutex);
auto it = _sessions.find(sessionId);
return (it != _sessions.end() && it->second->isOnline()) ? it->second->player : nullptr;
}
// ============================================================================
// Movement
// ============================================================================
bool MCPPlayerManager::TeleportTo(uint32 sessionId, uint32 mapId, float x, float y, float z, float o)
{
Player* player = GetPlayer(sessionId);
if (!player)
return false;
TC_LOG_INFO("araxia.mcp", "[MCPPlayerManager] Session {}: Teleporting to ({:.1f}, {:.1f}, {:.1f}) map {}",
sessionId, x, y, z, mapId);
return player->TeleportTo(mapId, x, y, z, o);
}
bool MCPPlayerManager::MoveTo(uint32 sessionId, float x, float y, float z)
{
Player* player = GetPlayer(sessionId);
if (!player)
return false;
TC_LOG_DEBUG("araxia.mcp", "[MCPPlayerManager] Session %u: Moving to (%.1f, %.1f, %.1f)",
sessionId, x, y, z);
player->GetMotionMaster()->MovePoint(0, x, y, z);
return true;
}
void MCPPlayerManager::StopMovement(uint32 sessionId)
{
Player* player = GetPlayer(sessionId);
if (!player)
return;
player->StopMoving();
player->GetMotionMaster()->Clear();
}
Position MCPPlayerManager::GetPosition(uint32 sessionId) const
{
Player* player = GetPlayer(sessionId);
return player ? player->GetPosition() : Position();
}
uint32 MCPPlayerManager::GetMapId(uint32 sessionId) const
{
Player* player = GetPlayer(sessionId);
return player ? player->GetMapId() : 0;
}
// ============================================================================
// Actions
// ============================================================================
bool MCPPlayerManager::SetTarget(uint32 sessionId, ObjectGuid targetGuid)
{
Player* player = GetPlayer(sessionId);
if (!player)
return false;
player->SetTarget(targetGuid);
return true;
}
bool MCPPlayerManager::CastSpell(uint32 sessionId, uint32 spellId, ObjectGuid targetGuid)
{
Player* player = GetPlayer(sessionId);
if (!player)
return false;
Unit* target = nullptr;
if (!targetGuid.IsEmpty())
target = ObjectAccessor::GetUnit(*player, targetGuid);
else
target = player->GetSelectedUnit();
if (!target)
target = player;
return player->CastSpell(target, spellId, false) == SPELL_CAST_OK;
}
bool MCPPlayerManager::InteractWith(uint32 sessionId, ObjectGuid targetGuid)
{
Player* player = GetPlayer(sessionId);
if (!player)
return false;
// TODO: Implement interaction logic based on target type
TC_LOG_WARN("araxia.mcp", "[MCPPlayerManager] InteractWith not fully implemented");
return false;
}
bool MCPPlayerManager::Attack(uint32 sessionId, ObjectGuid targetGuid)
{
Player* player = GetPlayer(sessionId);
if (!player)
return false;
Unit* target = ObjectAccessor::GetUnit(*player, targetGuid);
if (!target)
return false;
player->Attack(target, true);
return true;
}
void MCPPlayerManager::StopAttack(uint32 sessionId)
{
Player* player = GetPlayer(sessionId);
if (player)
player->AttackStop();
}
// ============================================================================
// GM Commands
// ============================================================================
std::string MCPPlayerManager::ExecuteCommand(uint32 sessionId, const std::string& command)
{
Player* player = GetPlayer(sessionId);
if (!player)
return "Error: Player not online";
// For now, just log and return placeholder
// Full implementation would use ChatHandler
TC_LOG_INFO("araxia.mcp", "[MCPPlayerManager] Session {} executing command: {}",
sessionId, command);
// TODO: Implement proper command execution via ChatHandler
return "Command execution logged (full implementation pending)";
}
// ============================================================================
// Perception
// ============================================================================
std::vector<ObjectGuid> MCPPlayerManager::GetNearbyEntities(uint32 sessionId, float range, uint32 typeMask) const
{
std::vector<ObjectGuid> result;
Player* player = GetPlayer(sessionId);
if (!player || !player->IsInWorld())
return result;
// TODO: Implement entity scanning based on typeMask
return result;
}
// ============================================================================
// Packet Capture
// ============================================================================
void MCPPlayerManager::CapturePacket(uint32 sessionId, WorldPacket const& packet)
{
std::lock_guard<std::mutex> lock(_sessionMutex);
auto it = _sessions.find(sessionId);
if (it == _sessions.end())
return;
MCPPlayerSession* session = it->second.get();
// Store packet data: opcode + raw data
std::lock_guard<std::mutex> pktLock(session->packetMutex);
std::vector<uint8> data;
data.resize(4 + packet.size()); // 4 bytes for opcode + payload
// Store opcode (4 bytes, little endian)
uint32 opcode = packet.GetOpcode();
data[0] = opcode & 0xFF;
data[1] = (opcode >> 8) & 0xFF;
data[2] = (opcode >> 16) & 0xFF;
data[3] = (opcode >> 24) & 0xFF;
// Copy packet data
if (packet.size() > 0)
memcpy(&data[4], packet.data(), packet.size());
session->outboundPackets.push(std::move(data));
// Limit queue size to prevent memory bloat
while (session->outboundPackets.size() > 1000)
session->outboundPackets.pop();
}
void MCPPlayerManager::QueueInboundPacket(uint32 sessionId, uint16 opcode, const std::vector<uint8>& data)
{
// TODO: Implement when we need to inject packets into the session
TC_LOG_DEBUG("araxia.mcp", "[MCPPlayerManager] QueueInboundPacket not yet implemented");
}
std::vector<std::pair<uint16, std::vector<uint8>>> MCPPlayerManager::GetOutboundPackets(uint32 sessionId)
{
std::lock_guard<std::mutex> lock(_sessionMutex);
auto it = _sessions.find(sessionId);
if (it == _sessions.end())
return {};
MCPPlayerSession* session = it->second.get();
std::lock_guard<std::mutex> pktLock(session->packetMutex);
std::vector<std::pair<uint16, std::vector<uint8>>> result;
while (!session->outboundPackets.empty())
{
auto& pkt = session->outboundPackets.front();
if (pkt.size() >= 4)
{
uint16 opcode = pkt[0] | (pkt[1] << 8);
std::vector<uint8> payload(pkt.begin() + 4, pkt.end());
result.emplace_back(opcode, std::move(payload));
}
session->outboundPackets.pop();
}
return result;
}
} // namespace Araxia

View File

@@ -0,0 +1,241 @@
/*
* Araxia MCP Player Manager
*
* Multi-session manager for AI-controlled players.
* Allows multiple LLMs to each control their own player character via MCP.
*
* Architecture:
* LLM (Cascade/Claude/etc.)
* ↓ MCP HTTP Request
* MCPPlayerManager
* ↓ session_id lookup
* MCPPlayerSession
* ↓
* WorldSession + Player
*/
#ifndef ARAXIA_MCP_PLAYER_MANAGER_H
#define ARAXIA_MCP_PLAYER_MANAGER_H
#include "Define.h"
#include "ObjectGuid.h"
#include "Position.h"
#include <string>
#include <memory>
#include <unordered_map>
#include <queue>
#include <mutex>
class Player;
class WorldSession;
class WorldPacket;
class SQLQueryHolderBase;
namespace Araxia
{
/**
* Represents a single AI-controlled player session.
* Each LLM that connects gets their own session.
*/
struct MCPPlayerSession
{
uint32 sessionId{0}; // Unique session identifier
std::string sessionToken; // Auth token for this session
std::string ownerName; // "Cascade", "Claude", "GPT-4", etc.
WorldSession* worldSession{nullptr}; // The WoW session (with virtual socket)
Player* player{nullptr}; // The player entity in world
ObjectGuid playerGuid; // Character GUID
std::string characterName; // Character name (for display)
uint32 accountId{0}; // WoW account ID
// Cached character info (from DB query, before full load)
uint8 level{0};
uint8 race{0};
uint8 playerClass{0};
uint32 mapId{0};
float posX{0}, posY{0}, posZ{0}, orientation{0};
// Timestamps
time_t createdAt{0}; // When session was created
time_t lastActivity{0}; // Last MCP activity
// Packet queues for advanced packet-level control
std::queue<std::vector<uint8>> outboundPackets; // Packets FROM server TO LLM
std::mutex packetMutex;
// State
bool loginPending{false};
bool logoutPending{false};
// Note: isOnline() is defined in .cpp since Player must be fully defined
bool isOnline() const;
};
/**
* MCPPlayerManager - Singleton that manages all AI player sessions.
*
* Workflow:
* 1. LLM calls mcp_session_create → gets session_id and token
* 2. LLM calls mcp_player_login with session_id → player loads into world
* 3. LLM calls various mcp_player_* tools with session_id
* 4. LLM calls mcp_session_destroy when done
*/
class MCPPlayerManager
{
public:
static MCPPlayerManager* Instance();
// ========== Initialization ==========
bool Initialize();
void Shutdown();
void Update(uint32 diff); // Called from world thread
// ========== Session Management ==========
/**
* Create a new AI player session.
* @param ownerName Identifier for the LLM (e.g., "Cascade", "Claude")
* @return Session ID (0 if failed)
*/
uint32 CreateSession(const std::string& ownerName);
/**
* Destroy a session, logging out the player if online.
* @param sessionId The session to destroy
* @return true if session was found and destroyed
*/
bool DestroySession(uint32 sessionId);
/**
* Get a session by ID.
*/
MCPPlayerSession* GetSession(uint32 sessionId);
/**
* Get a session by its token.
*/
MCPPlayerSession* GetSessionByToken(const std::string& token);
/**
* Get all active session IDs.
*/
std::vector<uint32> GetActiveSessions() const;
/**
* Get count of active sessions.
*/
size_t GetSessionCount() const;
// ========== Player Lifecycle ==========
/**
* Log in a character for a session.
* @param sessionId The session
* @param playerGuid Character GUID to log in
* @return true if login initiated (async)
*/
bool Login(uint32 sessionId, ObjectGuid playerGuid);
/**
* Log in by character name.
*/
bool Login(uint32 sessionId, const std::string& characterName);
/**
* Log out the player for a session (keeps session alive).
*/
void Logout(uint32 sessionId);
/**
* Check if a session's player is online.
*/
bool IsOnline(uint32 sessionId) const;
/**
* Get the Player object for a session.
*/
Player* GetPlayer(uint32 sessionId) const;
// ========== Movement ==========
bool TeleportTo(uint32 sessionId, uint32 mapId, float x, float y, float z, float o = 0.0f);
bool MoveTo(uint32 sessionId, float x, float y, float z);
void StopMovement(uint32 sessionId);
Position GetPosition(uint32 sessionId) const;
uint32 GetMapId(uint32 sessionId) const;
// ========== Actions ==========
bool SetTarget(uint32 sessionId, ObjectGuid targetGuid);
bool CastSpell(uint32 sessionId, uint32 spellId, ObjectGuid target = ObjectGuid::Empty);
bool InteractWith(uint32 sessionId, ObjectGuid targetGuid);
bool Attack(uint32 sessionId, ObjectGuid targetGuid);
void StopAttack(uint32 sessionId);
// ========== GM Commands ==========
std::string ExecuteCommand(uint32 sessionId, const std::string& command);
// ========== Perception ==========
std::vector<ObjectGuid> GetNearbyEntities(uint32 sessionId, float range, uint32 typeMask) const;
// ========== Packet Access (Advanced) ==========
/**
* Queue a packet to be sent to the session's player.
* This is for advanced LLMs that want raw packet control.
*/
void QueueInboundPacket(uint32 sessionId, uint16 opcode, const std::vector<uint8>& data);
/**
* Get packets that the server has sent to the player.
* Returns and clears the outbound queue.
*/
std::vector<std::pair<uint16, std::vector<uint8>>> GetOutboundPackets(uint32 sessionId);
// ========== Configuration ==========
uint32 GetMaxSessions() const { return _maxSessions; }
void SetMaxSessions(uint32 max) { _maxSessions = max; }
private:
MCPPlayerManager();
~MCPPlayerManager();
// Non-copyable
MCPPlayerManager(const MCPPlayerManager&) = delete;
MCPPlayerManager& operator=(const MCPPlayerManager&) = delete;
// Internal helpers
std::string GenerateToken();
WorldSession* CreateBotSession(uint32 sessionId, uint32 accountId);
void LogoutInternal(MCPPlayerSession* session);
void HandleLoginCallback(SQLQueryHolderBase const& holder);
void CapturePacket(uint32 sessionId, WorldPacket const& packet);
// Members
std::unordered_map<uint32, std::unique_ptr<MCPPlayerSession>> _sessions;
std::unordered_map<std::string, uint32> _tokenToSession; // token → session_id
mutable std::mutex _sessionMutex;
uint32 _nextSessionId{1};
uint32 _maxSessions{10}; // Configurable limit
bool _initialized{false};
// Configuration
uint32 _defaultAccountId{0};
uint32 _sessionTimeoutSeconds{3600}; // 1 hour default
};
#define sMCPPlayerMgr Araxia::MCPPlayerManager::Instance()
// Tool registration
void RegisterMCPPlayerTools();
} // namespace Araxia
#endif // ARAXIA_MCP_PLAYER_MANAGER_H

View File

@@ -0,0 +1,644 @@
/*
* Araxia MCP Player Tools
*
* Registers MCP tools for controlling AI players.
* Supports multiple sessions - each LLM can control their own player.
*
* All player-related tools take a session_id parameter.
*/
#include "MCPPlayerManager.h"
#include "AraxiaMCPServer.h"
#include "Log.h"
#include "ObjectAccessor.h"
#include "Player.h"
#include "Map.h"
#include "Creature.h"
#include "GameObject.h"
namespace Araxia
{
void RegisterMCPPlayerTools()
{
TC_LOG_INFO("araxia.mcp", "[MCP] Registering MCP Player tools (multi-session)...");
// ========================================================================
// SESSION MANAGEMENT TOOLS
// ========================================================================
// mcp_session_create - Create a new AI player session
sMCPServer->RegisterTool(
"mcp_session_create",
"Create a new AI player session. Returns a session_id to use with other player tools. "
"Each LLM should create their own session to control their own player.",
{
{"type", "object"},
{"properties", {
{"owner_name", {
{"type", "string"},
{"description", "Name identifying the LLM/client (e.g., 'Cascade', 'Claude', 'GPT-4')"}
}}
}},
{"required", {"owner_name"}}
},
[](const json& params) -> json {
std::string ownerName = params["owner_name"];
uint32 sessionId = sMCPPlayerMgr->CreateSession(ownerName);
if (sessionId == 0)
{
return {
{"success", false},
{"error", "Failed to create session - max sessions reached?"}
};
}
return {
{"success", true},
{"session_id", sessionId},
{"owner_name", ownerName},
{"message", "Session created. Use this session_id for all subsequent player commands."}
};
}
);
// mcp_session_destroy - Destroy a session
sMCPServer->RegisterTool(
"mcp_session_destroy",
"Destroy an AI player session. Logs out the player if online.",
{
{"type", "object"},
{"properties", {
{"session_id", {
{"type", "integer"},
{"description", "Session ID to destroy"}
}}
}},
{"required", {"session_id"}}
},
[](const json& params) -> json {
uint32 sessionId = params["session_id"];
bool result = sMCPPlayerMgr->DestroySession(sessionId);
return {
{"success", result},
{"session_id", sessionId},
{"message", result ? "Session destroyed" : "Session not found"}
};
}
);
// mcp_session_list - List all active sessions
sMCPServer->RegisterTool(
"mcp_session_list",
"List all active AI player sessions.",
{
{"type", "object"},
{"properties", json::object()}
},
[](const json& /*params*/) -> json {
auto sessionIds = sMCPPlayerMgr->GetActiveSessions();
json sessions = json::array();
for (uint32 id : sessionIds)
{
MCPPlayerSession* session = sMCPPlayerMgr->GetSession(id);
if (session)
{
sessions.push_back({
{"session_id", session->sessionId},
{"owner_name", session->ownerName},
{"online", session->isOnline()},
{"character", session->player ? session->player->GetName() : session->characterName},
{"character_guid", session->playerGuid.GetCounter()}
});
}
}
return {
{"success", true},
{"session_count", sessions.size()},
{"sessions", sessions}
};
}
);
// ========================================================================
// PLAYER LIFECYCLE TOOLS
// ========================================================================
// mcp_player_login - Log in a character for a session
sMCPServer->RegisterTool(
"mcp_player_login",
"Log in a character for an AI player session. Specify by name or GUID.",
{
{"type", "object"},
{"properties", {
{"session_id", {
{"type", "integer"},
{"description", "Session ID to log in for"}
}},
{"character_name", {
{"type", "string"},
{"description", "Name of the character to log in"}
}},
{"character_guid", {
{"type", "integer"},
{"description", "GUID of the character to log in (alternative to name)"}
}}
}},
{"required", {"session_id"}}
},
[](const json& params) -> json {
uint32 sessionId = params["session_id"];
std::string charName = params.value("character_name", "");
uint64 charGuid = params.value("character_guid", 0ULL);
MCPPlayerSession* session = sMCPPlayerMgr->GetSession(sessionId);
if (!session)
{
return {
{"success", false},
{"error", "Session not found"},
{"session_id", sessionId}
};
}
if (session->isOnline())
{
return {
{"success", false},
{"error", "Session already has a player logged in"},
{"character", session->player->GetName()}
};
}
bool result = false;
if (!charName.empty())
{
result = sMCPPlayerMgr->Login(sessionId, charName);
}
else if (charGuid > 0)
{
ObjectGuid guid = ObjectGuid::Create<HighGuid::Player>(charGuid);
result = sMCPPlayerMgr->Login(sessionId, guid);
}
else
{
return {
{"success", false},
{"error", "Must specify character_name or character_guid"}
};
}
return {
{"success", result},
{"session_id", sessionId},
{"message", result ? "Login initiated (async)" : "Login failed"},
{"note", "Use mcp_player_status to check if login completed"}
};
}
);
// mcp_player_logout - Log out the character (keeps session)
sMCPServer->RegisterTool(
"mcp_player_logout",
"Log out the character for a session. The session remains active for re-login.",
{
{"type", "object"},
{"properties", {
{"session_id", {
{"type", "integer"},
{"description", "Session ID to log out"}
}}
}},
{"required", {"session_id"}}
},
[](const json& params) -> json {
uint32 sessionId = params["session_id"];
MCPPlayerSession* session = sMCPPlayerMgr->GetSession(sessionId);
if (!session)
{
return {{"success", false}, {"error", "Session not found"}};
}
if (!session->isOnline())
{
return {{"success", false}, {"error", "No player logged in for this session"}};
}
std::string charName = session->player->GetName();
sMCPPlayerMgr->Logout(sessionId);
return {
{"success", true},
{"session_id", sessionId},
{"character", charName},
{"message", "Player logged out. Session still active."}
};
}
);
// mcp_player_status - Get player status for a session
sMCPServer->RegisterTool(
"mcp_player_status",
"Get the current status of the player for a session.",
{
{"type", "object"},
{"properties", {
{"session_id", {
{"type", "integer"},
{"description", "Session ID to get status for"}
}}
}},
{"required", {"session_id"}}
},
[](const json& params) -> json {
uint32 sessionId = params["session_id"];
MCPPlayerSession* session = sMCPPlayerMgr->GetSession(sessionId);
if (!session)
{
return {{"success", false}, {"error", "Session not found"}};
}
if (!session->isOnline())
{
// Not fully online, but may have cached character info
if (!session->characterName.empty())
{
return {
{"success", true},
{"session_id", sessionId},
{"online", false},
{"in_world", false},
{"message", "Character verified but not fully loaded into world"},
{"character", {
{"name", session->characterName},
{"guid", session->playerGuid.GetCounter()},
{"level", session->level},
{"class", session->playerClass},
{"race", session->race}
}},
{"position", {
{"map", session->mapId},
{"x", session->posX},
{"y", session->posY},
{"z", session->posZ}
}}
};
}
return {
{"success", true},
{"session_id", sessionId},
{"online", false},
{"message", "No player logged in for this session"}
};
}
Player* player = session->player;
Position pos = player->GetPosition();
return {
{"success", true},
{"session_id", sessionId},
{"online", true},
{"in_world", true},
{"character", {
{"name", player->GetName()},
{"guid", player->GetGUID().GetCounter()},
{"level", player->GetLevel()},
{"class", player->GetClass()},
{"race", player->GetRace()},
{"health", player->GetHealth()},
{"max_health", player->GetMaxHealth()},
{"alive", player->IsAlive()}
}},
{"position", {
{"map", player->GetMapId()},
{"zone", player->GetZoneId()},
{"area", player->GetAreaId()},
{"x", pos.GetPositionX()},
{"y", pos.GetPositionY()},
{"z", pos.GetPositionZ()},
{"o", pos.GetOrientation()}
}},
{"target", player->GetTarget().GetCounter()}
};
}
);
// ========================================================================
// MOVEMENT TOOLS
// ========================================================================
// mcp_player_teleport
sMCPServer->RegisterTool(
"mcp_player_teleport",
"Teleport the session's player to specified coordinates.",
{
{"type", "object"},
{"properties", {
{"session_id", {{"type", "integer"}, {"description", "Session ID"}}},
{"map", {{"type", "integer"}, {"description", "Map ID to teleport to"}}},
{"x", {{"type", "number"}, {"description", "X coordinate"}}},
{"y", {{"type", "number"}, {"description", "Y coordinate"}}},
{"z", {{"type", "number"}, {"description", "Z coordinate"}}},
{"o", {{"type", "number"}, {"description", "Orientation (radians)"}}}
}},
{"required", {"session_id", "map", "x", "y", "z"}}
},
[](const json& params) -> json {
uint32 sessionId = params["session_id"];
uint32 mapId = params["map"];
float x = params["x"];
float y = params["y"];
float z = params["z"];
float o = params.value("o", 0.0f);
bool result = sMCPPlayerMgr->TeleportTo(sessionId, mapId, x, y, z, o);
return {
{"success", result},
{"session_id", sessionId},
{"teleported_to", {{"map", mapId}, {"x", x}, {"y", y}, {"z", z}}}
};
}
);
// mcp_player_move
sMCPServer->RegisterTool(
"mcp_player_move",
"Move the session's player to a location using pathfinding.",
{
{"type", "object"},
{"properties", {
{"session_id", {{"type", "integer"}, {"description", "Session ID"}}},
{"x", {{"type", "number"}, {"description", "Target X coordinate"}}},
{"y", {{"type", "number"}, {"description", "Target Y coordinate"}}},
{"z", {{"type", "number"}, {"description", "Target Z coordinate"}}}
}},
{"required", {"session_id", "x", "y", "z"}}
},
[](const json& params) -> json {
uint32 sessionId = params["session_id"];
float x = params["x"];
float y = params["y"];
float z = params["z"];
bool result = sMCPPlayerMgr->MoveTo(sessionId, x, y, z);
return {
{"success", result},
{"session_id", sessionId},
{"moving_to", {{"x", x}, {"y", y}, {"z", z}}}
};
}
);
// mcp_player_stop
sMCPServer->RegisterTool(
"mcp_player_stop",
"Stop all movement for the session's player.",
{
{"type", "object"},
{"properties", {
{"session_id", {{"type", "integer"}, {"description", "Session ID"}}}
}},
{"required", {"session_id"}}
},
[](const json& params) -> json {
uint32 sessionId = params["session_id"];
sMCPPlayerMgr->StopMovement(sessionId);
return {{"success", true}, {"session_id", sessionId}, {"message", "Movement stopped"}};
}
);
// ========================================================================
// ACTION TOOLS
// ========================================================================
// mcp_player_cast
sMCPServer->RegisterTool(
"mcp_player_cast",
"Have the session's player cast a spell. For self-cast, omit target.",
{
{"type", "object"},
{"properties", {
{"session_id", {{"type", "integer"}, {"description", "Session ID"}}},
{"spell_id", {{"type", "integer"}, {"description", "Spell ID to cast"}}},
{"target_guid_low", {{"type", "integer"}, {"description", "Low part of target GUID (from mcp_player_look)"}}},
{"target_is_player", {{"type", "boolean"}, {"description", "True if target is a player, false for creature"}}}
}},
{"required", {"session_id", "spell_id"}}
},
[](const json& params) -> json {
uint32 sessionId = params["session_id"];
uint32 spellId = params["spell_id"];
// For now, cast on self or current target (GUID handling is complex)
// TODO: Implement proper GUID creation from mcp_player_look data
bool result = sMCPPlayerMgr->CastSpell(sessionId, spellId, ObjectGuid::Empty);
return {{"success", result}, {"session_id", sessionId}, {"spell_id", spellId}};
}
);
// mcp_player_target
sMCPServer->RegisterTool(
"mcp_player_target",
"Set the session's player's current target. Use GUID from mcp_player_look.",
{
{"type", "object"},
{"properties", {
{"session_id", {{"type", "integer"}, {"description", "Session ID"}}},
{"target_guid_low", {{"type", "integer"}, {"description", "Low GUID counter from mcp_player_look"}}},
{"target_is_player", {{"type", "boolean"}, {"description", "True if target is player, false for creature"}}}
}},
{"required", {"session_id", "target_guid_low"}}
},
[](const json& params) -> json {
uint32 sessionId = params["session_id"];
uint64 guidLow = params["target_guid_low"];
bool isPlayer = params.value("target_is_player", false);
ObjectGuid targetGuid;
if (isPlayer)
{
targetGuid = ObjectGuid::Create<HighGuid::Player>(guidLow);
}
else
{
// For creatures, we need more info. For now, try to find by searching.
// TODO: Store full GUID info in mcp_player_look results
return {{"success", false}, {"error", "Creature targeting requires mcp_player_look GUID - feature in progress"}};
}
bool result = sMCPPlayerMgr->SetTarget(sessionId, targetGuid);
return {{"success", result}, {"session_id", sessionId}, {"target_guid_low", guidLow}};
}
);
// ========================================================================
// PERCEPTION TOOLS
// ========================================================================
// mcp_player_look
sMCPServer->RegisterTool(
"mcp_player_look",
"Get what the session's player can see around them.",
{
{"type", "object"},
{"properties", {
{"session_id", {{"type", "integer"}, {"description", "Session ID"}}},
{"range", {{"type", "number"}, {"description", "Range to scan (default 50)"}}},
{"entity_type", {
{"type", "string"},
{"enum", {"all", "creatures", "players", "gameobjects"}},
{"description", "Type of entities to look for"}
}}
}},
{"required", {"session_id"}}
},
[](const json& params) -> json {
uint32 sessionId = params["session_id"];
float range = params.value("range", 50.0f);
std::string typeFilter = params.value("entity_type", "all");
Player* player = sMCPPlayerMgr->GetPlayer(sessionId);
if (!player)
{
return {{"success", false}, {"error", "Player not online for this session"}};
}
json creatures = json::array();
json players_arr = json::array();
json gameobjects = json::array();
// Scan for creatures
if (typeFilter == "all" || typeFilter == "creatures")
{
std::list<Creature*> creatureList;
player->GetCreatureListWithEntryInGrid(creatureList, 0, range);
for (Creature* creature : creatureList)
{
creatures.push_back({
{"guid", creature->GetGUID().GetCounter()},
{"entry", creature->GetEntry()},
{"name", creature->GetName()},
{"level", creature->GetLevel()},
{"health", creature->GetHealth()},
{"max_health", creature->GetMaxHealth()},
{"alive", creature->IsAlive()},
{"distance", player->GetDistance(creature)},
{"x", creature->GetPositionX()},
{"y", creature->GetPositionY()},
{"z", creature->GetPositionZ()}
});
}
}
// Scan for players
if (typeFilter == "all" || typeFilter == "players")
{
std::list<Player*> playerList;
player->GetPlayerListInGrid(playerList, range);
for (Player* other : playerList)
{
if (other == player) continue;
players_arr.push_back({
{"guid", other->GetGUID().GetCounter()},
{"name", other->GetName()},
{"level", other->GetLevel()},
{"class", other->GetClass()},
{"race", other->GetRace()},
{"distance", player->GetDistance(other)},
{"x", other->GetPositionX()},
{"y", other->GetPositionY()},
{"z", other->GetPositionZ()}
});
}
}
// Scan for game objects
if (typeFilter == "all" || typeFilter == "gameobjects")
{
std::list<GameObject*> goList;
player->GetGameObjectListWithEntryInGrid(goList, 0, range);
for (GameObject* go : goList)
{
gameobjects.push_back({
{"guid", go->GetGUID().GetCounter()},
{"entry", go->GetEntry()},
{"name", go->GetName()},
{"distance", player->GetDistance(go)},
{"x", go->GetPositionX()},
{"y", go->GetPositionY()},
{"z", go->GetPositionZ()}
});
}
}
return {
{"success", true},
{"session_id", sessionId},
{"position", {
{"x", player->GetPositionX()},
{"y", player->GetPositionY()},
{"z", player->GetPositionZ()},
{"map", player->GetMapId()}
}},
{"creatures", creatures},
{"players", players_arr},
{"gameobjects", gameobjects},
{"creature_count", creatures.size()},
{"player_count", players_arr.size()},
{"gameobject_count", gameobjects.size()}
};
}
);
// ========================================================================
// GM COMMAND TOOLS
// ========================================================================
// mcp_player_gm_command
sMCPServer->RegisterTool(
"mcp_player_gm_command",
"Execute a GM command as the session's player.",
{
{"type", "object"},
{"properties", {
{"session_id", {{"type", "integer"}, {"description", "Session ID"}}},
{"command", {{"type", "string"}, {"description", "GM command (without leading dot)"}}}
}},
{"required", {"session_id", "command"}}
},
[](const json& params) -> json {
uint32 sessionId = params["session_id"];
std::string command = params["command"];
std::string result = sMCPPlayerMgr->ExecuteCommand(sessionId, command);
return {
{"success", true},
{"session_id", sessionId},
{"command", command},
{"result", result}
};
}
);
TC_LOG_INFO("araxia.mcp", "[MCP] Registered 14 MCP Player tools (multi-session)");
}
} // namespace Araxia

View File

@@ -383,6 +383,50 @@ void Player::CleanupsBeforeDelete(bool finalCleanup)
Unit::CleanupsBeforeDelete(finalCleanup);
}
void Player::InitializeForBot(ObjectGuid const& guid, std::string const& name, uint8 race, uint8 class_, uint8 gender, uint8 level)
{
// Minimal initialization for bot/AI players (MCP)
// Based on LoadFromDB but without async queries
// 1. Create GUID
Object::_Create(guid);
// 2. Set name
m_name = name;
// 3. Set race/class/gender
SetRace(race);
SetClass(class_);
SetGender(Gender(gender));
// 4. Set level
SetLevel(level, false);
// 5. Set object scale
SetObjectScale(1.0f);
// 6. Initialize display IDs
InitDisplayIds();
// 7. Set faction for race
SetFactionForRace(race);
// 8. Initialize item slots to null
for (uint8 i = 0; i < PLAYER_SLOTS_COUNT; i++)
m_items[i] = nullptr;
// 9. Set native gender
SetNativeGender(Gender(gender));
// 10. Initialize stats for level - CRITICAL for proper cleanup
InitStatsForLevel();
// 11. Set full health
SetFullHealth();
TC_LOG_DEBUG("entities.player", "Player::InitializeForBot: Initialized bot player '{}' ({})", name, guid.ToString());
}
bool Player::Create(ObjectGuid::LowType guidlow, WorldPackets::Character::CharacterCreateInfo const* createInfo)
{
//FIXME: outfitId not used in player creating

View File

@@ -1199,6 +1199,9 @@ class TC_GAME_API Player final : public Unit, public GridObject<Player>
bool Create(ObjectGuid::LowType guidlow, WorldPackets::Character::CharacterCreateInfo const* createInfo);
// Initialize player for bot/AI use (MCP) - minimal initialization without full LoadFromDB
void InitializeForBot(ObjectGuid const& guid, std::string const& name, uint8 race, uint8 class_, uint8 gender, uint8 level);
void Update(uint32 time) override;
void Heartbeat() override;

View File

@@ -246,7 +246,13 @@ void WorldSession::SendPacket(WorldPacket const* packet, bool forced /*= false*/
if (!m_Socket[conIdx])
{
TC_LOG_ERROR("network.opcode", "Prevented sending of {} to non existent socket {} to {}", GetOpcodeNameForLogging(static_cast<OpcodeServer>(packet->GetOpcode())), uint32(conIdx), GetPlayerInfo());
// For headless sessions (MCP bots), queue packets instead of dropping
if (_packetCaptureCallback)
{
_packetCaptureCallback(*packet);
return;
}
TC_LOG_DEBUG("network.opcode", "Prevented sending of {} to non existent socket {} to {}", GetOpcodeNameForLogging(static_cast<OpcodeServer>(packet->GetOpcode())), uint32(conIdx), GetPlayerInfo());
return;
}
@@ -347,7 +353,8 @@ bool WorldSession::Update(uint32 diff, PacketFilter& updater)
///- Before we process anything:
/// If necessary, kick the player because the client didn't send anything for too long
/// (or they've been idling in character select)
if (IsConnectionIdle() && !HasPermission(rbac::RBAC_PERM_IGNORE_IDLE_CONNECTION))
/// Note: Check for null socket to support headless sessions (MCP bots)
if (IsConnectionIdle() && !HasPermission(rbac::RBAC_PERM_IGNORE_IDLE_CONNECTION) && m_Socket[CONNECTION_TYPE_REALM])
m_Socket[CONNECTION_TYPE_REALM]->CloseSocket();
///- Retrieve packets from the receive queue and call the appropriate handlers

View File

@@ -1016,6 +1016,9 @@ class TC_GAME_API WorldSession
void SetSecurity(AccountTypes security) { _security = security; }
std::string const& GetRemoteAddress() const { return m_Address; }
void SetPlayer(Player* player);
// Packet capture for headless sessions (MCP bots)
void SetPacketCaptureCallback(std::function<void(WorldPacket const&)> callback) { _packetCaptureCallback = std::move(callback); }
uint8 GetAccountExpansion() const { return m_accountExpansion; }
uint8 GetExpansion() const { return m_expansion; }
std::string const& GetOS() const { return _os; }
@@ -2015,6 +2018,9 @@ class TC_GAME_API WorldSession
uint32 expireTime;
bool forceExit;
// Packet capture for headless sessions (MCP bots)
std::function<void(WorldPacket const&)> _packetCaptureCallback;
std::unique_ptr<boost::circular_buffer<std::pair<int64, uint32>>> _timeSyncClockDeltaQueue; // first member: clockDelta. Second member: latency of the packet exchange that was used to compute that clockDelta.
int64 _timeSyncClockDelta;
void ComputeNewClockDelta();

View File

@@ -28,6 +28,7 @@
#include "AccountMgr.h"
#include "AchievementMgr.h"
#include "AreaTriggerDataStore.h"
#include "AraxiaCore.h"
#include "ArenaTeamMgr.h"
#include "AuctionHouseBot.h"
#include "AuctionHouseMgr.h"
@@ -2174,6 +2175,9 @@ void World::Update(uint32 diff)
if (Eluna* e = GetEluna())
e->UpdateEluna(diff);
///- Update Araxia systems (MCP player sessions, etc.)
sAraxiaCore->Update(diff);
///- Update the different timers
for (int i = 0; i < WUPDATE_COUNT; ++i)
{

View File

@@ -40,7 +40,7 @@ endif()
target_link_libraries(worldserver
PRIVATE
trinity-core-interface
efsw
# efsw removed - already linked transitively via game -> lualib
PUBLIC
scripts
game

View File

@@ -0,0 +1,119 @@
/*
* Araxia MCP Player Manager Tests
* Unit tests for multi-session AI player manager.
*/
#include "tc_catch2.h"
#include "MCPPlayerManager.h"
using namespace Araxia;
TEST_CASE("MCPPlayerManager Session Creation", "[araxia][mcp][session]")
{
auto* mgr = MCPPlayerManager::Instance();
SECTION("Create session returns valid ID")
{
uint32 sessionId = mgr->CreateSession("TestOwner");
REQUIRE(sessionId > 0);
mgr->DestroySession(sessionId);
}
SECTION("Created session is retrievable")
{
uint32 sessionId = mgr->CreateSession("TestOwner");
MCPPlayerSession* session = mgr->GetSession(sessionId);
REQUIRE(session != nullptr);
REQUIRE(session->sessionId == sessionId);
REQUIRE(session->ownerName == "TestOwner");
mgr->DestroySession(sessionId);
}
SECTION("Session has valid token")
{
uint32 sessionId = mgr->CreateSession("TestOwner");
MCPPlayerSession* session = mgr->GetSession(sessionId);
REQUIRE(!session->sessionToken.empty());
REQUIRE(session->sessionToken.length() == 32);
mgr->DestroySession(sessionId);
}
SECTION("Multiple sessions have unique IDs")
{
uint32 id1 = mgr->CreateSession("Owner1");
uint32 id2 = mgr->CreateSession("Owner2");
uint32 id3 = mgr->CreateSession("Owner3");
REQUIRE(id1 != id2);
REQUIRE(id2 != id3);
REQUIRE(id1 != id3);
mgr->DestroySession(id1);
mgr->DestroySession(id2);
mgr->DestroySession(id3);
}
}
TEST_CASE("MCPPlayerManager Session Destruction", "[araxia][mcp][session]")
{
auto* mgr = MCPPlayerManager::Instance();
SECTION("Destroy session returns true for valid session")
{
uint32 sessionId = mgr->CreateSession("TestOwner");
REQUIRE(mgr->DestroySession(sessionId) == true);
}
SECTION("Destroy session returns false for invalid session")
{
REQUIRE(mgr->DestroySession(999999) == false);
}
SECTION("Session not retrievable after destruction")
{
uint32 sessionId = mgr->CreateSession("TestOwner");
mgr->DestroySession(sessionId);
REQUIRE(mgr->GetSession(sessionId) == nullptr);
}
}
TEST_CASE("MCPPlayerManager Session Listing", "[araxia][mcp][session]")
{
auto* mgr = MCPPlayerManager::Instance();
SECTION("GetActiveSessions returns created sessions")
{
size_t initialCount = mgr->GetSessionCount();
uint32 id1 = mgr->CreateSession("Owner1");
uint32 id2 = mgr->CreateSession("Owner2");
auto sessions = mgr->GetActiveSessions();
REQUIRE(sessions.size() == initialCount + 2);
mgr->DestroySession(id1);
mgr->DestroySession(id2);
}
}
TEST_CASE("MCPPlayerManager Session State", "[araxia][mcp][session]")
{
auto* mgr = MCPPlayerManager::Instance();
SECTION("New session is not online")
{
uint32 sessionId = mgr->CreateSession("TestOwner");
REQUIRE(mgr->IsOnline(sessionId) == false);
REQUIRE(mgr->GetPlayer(sessionId) == nullptr);
mgr->DestroySession(sessionId);
}
SECTION("Session timestamps are set")
{
uint32 sessionId = mgr->CreateSession("TestOwner");
MCPPlayerSession* session = mgr->GetSession(sessionId);
REQUIRE(session->createdAt > 0);
REQUIRE(session->lastActivity > 0);
mgr->DestroySession(sessionId);
}
}