From f6239f35d69663f6dc9e00c6de7d4622b7b0d8d5 Mon Sep 17 00:00:00 2001 From: James Huston Date: Sat, 13 Dec 2025 10:57:15 -0500 Subject: [PATCH] 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 --- AGENT.md => AGENTS.md | 0 .../server_scripts/test_mcp_integration.py | 270 +++++ .../server_scripts/test_mcp_player.sh | 195 ++++ src/araxiaonline/AraxiaCore.cpp | 86 ++ src/araxiaonline/AraxiaCore.h | 76 ++ src/araxiaonline/mcp/AraxiaMCPServer.cpp | 16 + src/araxiaonline/mcp/AraxiaMCPServer.h | 1 + src/araxiaonline/mcp/MCPPlayerManager.cpp | 949 ++++++++++++++++++ src/araxiaonline/mcp/MCPPlayerManager.h | 241 +++++ src/araxiaonline/mcp/MCPPlayerTools.cpp | 644 ++++++++++++ src/server/game/Entities/Player/Player.cpp | 44 + src/server/game/Entities/Player/Player.h | 3 + src/server/game/Server/WorldSession.cpp | 11 +- src/server/game/Server/WorldSession.h | 6 + src/server/game/World/World.cpp | 4 + src/server/worldserver/CMakeLists.txt | 2 +- tests/araxiaonline/MCPPlayerManagerTest.cpp | 119 +++ 17 files changed, 2664 insertions(+), 3 deletions(-) rename AGENT.md => AGENTS.md (100%) create mode 100755 araxiaonline/server_scripts/test_mcp_integration.py create mode 100755 araxiaonline/server_scripts/test_mcp_player.sh create mode 100644 src/araxiaonline/AraxiaCore.cpp create mode 100644 src/araxiaonline/AraxiaCore.h create mode 100644 src/araxiaonline/mcp/MCPPlayerManager.cpp create mode 100644 src/araxiaonline/mcp/MCPPlayerManager.h create mode 100644 src/araxiaonline/mcp/MCPPlayerTools.cpp create mode 100644 tests/araxiaonline/MCPPlayerManagerTest.cpp diff --git a/AGENT.md b/AGENTS.md similarity index 100% rename from AGENT.md rename to AGENTS.md diff --git a/araxiaonline/server_scripts/test_mcp_integration.py b/araxiaonline/server_scripts/test_mcp_integration.py new file mode 100755 index 0000000000..7ce34bf577 --- /dev/null +++ b/araxiaonline/server_scripts/test_mcp_integration.py @@ -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()) diff --git a/araxiaonline/server_scripts/test_mcp_player.sh b/araxiaonline/server_scripts/test_mcp_player.sh new file mode 100755 index 0000000000..a61aaa998f --- /dev/null +++ b/araxiaonline/server_scripts/test_mcp_player.sh @@ -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}" diff --git a/src/araxiaonline/AraxiaCore.cpp b/src/araxiaonline/AraxiaCore.cpp new file mode 100644 index 0000000000..fba5de8e93 --- /dev/null +++ b/src/araxiaonline/AraxiaCore.cpp @@ -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 lock(_callbackMutex); + _callbacks.clear(); + _initialized = false; +} + +void AraxiaCore::Update(uint32 diff) +{ + if (!_initialized) + return; + + std::lock_guard 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 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 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 lock(_callbackMutex); + return _callbacks.find(name) != _callbacks.end(); +} + +} // namespace Araxia diff --git a/src/araxiaonline/AraxiaCore.h b/src/araxiaonline/AraxiaCore.h new file mode 100644 index 0000000000..1d05b28f83 --- /dev/null +++ b/src/araxiaonline/AraxiaCore.h @@ -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 +#include +#include +#include + +namespace Araxia +{ + +// Callback signature for update hooks +using UpdateCallback = std::function; + +/** + * 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 _callbacks; + mutable std::mutex _callbackMutex; + bool _initialized{false}; +}; + +} // namespace Araxia + +#define sAraxiaCore Araxia::AraxiaCore::Instance() + +#endif // ARAXIA_CORE_H diff --git a/src/araxiaonline/mcp/AraxiaMCPServer.cpp b/src/araxiaonline/mcp/AraxiaMCPServer.cpp index a45483c7c0..908cb07cf1 100644 --- a/src/araxiaonline/mcp/AraxiaMCPServer.cpp +++ b/src/araxiaonline/mcp/AraxiaMCPServer.cpp @@ -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) diff --git a/src/araxiaonline/mcp/AraxiaMCPServer.h b/src/araxiaonline/mcp/AraxiaMCPServer.h index 654a612ac5..9db222e270 100644 --- a/src/araxiaonline/mcp/AraxiaMCPServer.h +++ b/src/araxiaonline/mcp/AraxiaMCPServer.h @@ -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 diff --git a/src/araxiaonline/mcp/MCPPlayerManager.cpp b/src/araxiaonline/mcp/MCPPlayerManager.cpp new file mode 100644 index 0000000000..5a86cf1775 --- /dev/null +++ b/src/araxiaonline/mcp/MCPPlayerManager.cpp @@ -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 +#include +#include +#include +#include + +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 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 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 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(); + 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 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 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 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 MCPPlayerManager::GetActiveSessions() const +{ + std::lock_guard lock(_sessionMutex); + std::vector 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 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(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 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 lock(_sessionMutex); + auto it = _sessions.find(sessionId); + return (it != _sessions.end()) && it->second->isOnline(); +} + +Player* MCPPlayerManager::GetPlayer(uint32 sessionId) const +{ + std::lock_guard 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 MCPPlayerManager::GetNearbyEntities(uint32 sessionId, float range, uint32 typeMask) const +{ + std::vector 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 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 pktLock(session->packetMutex); + + std::vector 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& data) +{ + // TODO: Implement when we need to inject packets into the session + TC_LOG_DEBUG("araxia.mcp", "[MCPPlayerManager] QueueInboundPacket not yet implemented"); +} + +std::vector>> MCPPlayerManager::GetOutboundPackets(uint32 sessionId) +{ + std::lock_guard lock(_sessionMutex); + + auto it = _sessions.find(sessionId); + if (it == _sessions.end()) + return {}; + + MCPPlayerSession* session = it->second.get(); + std::lock_guard pktLock(session->packetMutex); + + std::vector>> result; + + while (!session->outboundPackets.empty()) + { + auto& pkt = session->outboundPackets.front(); + if (pkt.size() >= 4) + { + uint16 opcode = pkt[0] | (pkt[1] << 8); + std::vector payload(pkt.begin() + 4, pkt.end()); + result.emplace_back(opcode, std::move(payload)); + } + session->outboundPackets.pop(); + } + + return result; +} + +} // namespace Araxia diff --git a/src/araxiaonline/mcp/MCPPlayerManager.h b/src/araxiaonline/mcp/MCPPlayerManager.h new file mode 100644 index 0000000000..cdc011fd93 --- /dev/null +++ b/src/araxiaonline/mcp/MCPPlayerManager.h @@ -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 +#include +#include +#include +#include + +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> 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 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 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& data); + + /** + * Get packets that the server has sent to the player. + * Returns and clears the outbound queue. + */ + std::vector>> 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> _sessions; + std::unordered_map _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 diff --git a/src/araxiaonline/mcp/MCPPlayerTools.cpp b/src/araxiaonline/mcp/MCPPlayerTools.cpp new file mode 100644 index 0000000000..b6930bd238 --- /dev/null +++ b/src/araxiaonline/mcp/MCPPlayerTools.cpp @@ -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(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(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 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 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 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 diff --git a/src/server/game/Entities/Player/Player.cpp b/src/server/game/Entities/Player/Player.cpp index 3014b1b19f..e23d7babb2 100644 --- a/src/server/game/Entities/Player/Player.cpp +++ b/src/server/game/Entities/Player/Player.cpp @@ -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 diff --git a/src/server/game/Entities/Player/Player.h b/src/server/game/Entities/Player/Player.h index d6abd1c410..c4a899bfeb 100644 --- a/src/server/game/Entities/Player/Player.h +++ b/src/server/game/Entities/Player/Player.h @@ -1199,6 +1199,9 @@ class TC_GAME_API Player final : public Unit, public GridObject 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; diff --git a/src/server/game/Server/WorldSession.cpp b/src/server/game/Server/WorldSession.cpp index e0daa6751d..d151c106f0 100644 --- a/src/server/game/Server/WorldSession.cpp +++ b/src/server/game/Server/WorldSession.cpp @@ -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(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(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 diff --git a/src/server/game/Server/WorldSession.h b/src/server/game/Server/WorldSession.h index 409696dafe..1cb705448c 100644 --- a/src/server/game/Server/WorldSession.h +++ b/src/server/game/Server/WorldSession.h @@ -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 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 _packetCaptureCallback; + std::unique_ptr>> _timeSyncClockDeltaQueue; // first member: clockDelta. Second member: latency of the packet exchange that was used to compute that clockDelta. int64 _timeSyncClockDelta; void ComputeNewClockDelta(); diff --git a/src/server/game/World/World.cpp b/src/server/game/World/World.cpp index d8e194c9f2..3ec187055c 100644 --- a/src/server/game/World/World.cpp +++ b/src/server/game/World/World.cpp @@ -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) { diff --git a/src/server/worldserver/CMakeLists.txt b/src/server/worldserver/CMakeLists.txt index 04977f55de..e984d22b32 100644 --- a/src/server/worldserver/CMakeLists.txt +++ b/src/server/worldserver/CMakeLists.txt @@ -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 diff --git a/tests/araxiaonline/MCPPlayerManagerTest.cpp b/tests/araxiaonline/MCPPlayerManagerTest.cpp new file mode 100644 index 0000000000..fed359ecf8 --- /dev/null +++ b/tests/araxiaonline/MCPPlayerManagerTest.cpp @@ -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); + } +}