mirror of
https://github.com/araxiaonline/TrinityCore.git
synced 2026-06-13 03:32:28 -04:00
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:
270
araxiaonline/server_scripts/test_mcp_integration.py
Executable file
270
araxiaonline/server_scripts/test_mcp_integration.py
Executable 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())
|
||||
195
araxiaonline/server_scripts/test_mcp_player.sh
Executable file
195
araxiaonline/server_scripts/test_mcp_player.sh
Executable 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}"
|
||||
86
src/araxiaonline/AraxiaCore.cpp
Normal file
86
src/araxiaonline/AraxiaCore.cpp
Normal 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
|
||||
76
src/araxiaonline/AraxiaCore.h
Normal file
76
src/araxiaonline/AraxiaCore.h
Normal 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
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
949
src/araxiaonline/mcp/MCPPlayerManager.cpp
Normal file
949
src/araxiaonline/mcp/MCPPlayerManager.cpp
Normal 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
|
||||
241
src/araxiaonline/mcp/MCPPlayerManager.h
Normal file
241
src/araxiaonline/mcp/MCPPlayerManager.h
Normal 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
|
||||
644
src/araxiaonline/mcp/MCPPlayerTools.cpp
Normal file
644
src/araxiaonline/mcp/MCPPlayerTools.cpp
Normal 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
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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)
|
||||
{
|
||||
|
||||
@@ -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
|
||||
|
||||
119
tests/araxiaonline/MCPPlayerManagerTest.cpp
Normal file
119
tests/araxiaonline/MCPPlayerManagerTest.cpp
Normal 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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user