Spaces:
Running
Running
File size: 11,565 Bytes
b85cf68 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 | import os
import glob
import re
import json
import logging
from typing import Optional
from mcp.server.fastmcp import FastMCP
# Initialize logger
logging.basicConfig(level=logging.INFO, format="%(asctime)s - %(levelname)s - %(message)s")
logger = logging.getLogger("XCOM2-Tactical-Wingman-MCP")
# Define the FastMCP server
mcp = FastMCP("XCOM 2 Tactical Wingman")
# Bounded directories for security (Sandbox Check)
PROJECT_DIR = os.path.dirname(os.path.abspath(__file__))
GUIDE_DIR = os.path.join(PROJECT_DIR, "data", "XcomClear")
CONFIG_FILE = os.path.join(PROJECT_DIR, "data", "DefaultGameData_COMBINADO.txt")
COMPENDIUM_FILE = os.path.join(PROJECT_DIR, "difficulty_compendium.json")
# In-memory caches for fast, read-only queries
_GUIDE_DOCS = None
_CONFIG_LINES = None
def verify_sandbox_path(path: str, base_directory: str = PROJECT_DIR) -> bool:
"""Verifies that the target path resolves inside the base directory sandbox."""
real_base = os.path.realpath(base_directory)
real_target = os.path.realpath(path)
return real_target.startswith(real_base)
def load_compendium() -> dict:
"""Loads the difficulty compendium database safely."""
if not os.path.exists(COMPENDIUM_FILE) or not verify_sandbox_path(COMPENDIUM_FILE):
logger.error(f"Compendium file path invalid or not found: {COMPENDIUM_FILE}")
return {}
try:
with open(COMPENDIUM_FILE, 'r', encoding='utf-8') as f:
return json.load(f)
except Exception as e:
logger.error(f"Failed to read difficulty compendium: {str(e)}")
return {}
def get_documents() -> list:
"""Lazy-loads and caches strategic guides from the cleaned directory."""
global _GUIDE_DOCS
if _GUIDE_DOCS is not None:
return _GUIDE_DOCS
documents = []
# 1. Load Xcom2 Missions Wiki if present
missions_path = os.path.join(PROJECT_DIR, "data", "Xcom2 Missions.txt")
if os.path.exists(missions_path) and verify_sandbox_path(missions_path):
try:
with open(missions_path, 'r', encoding='utf-8', errors='ignore') as f:
content = f.read()
paragraphs = content.split('\n\n')
for i, para in enumerate(paragraphs):
para_clean = para.strip()
if len(para_clean) > 30:
documents.append({
"source": "Xcom2 Missions Wiki",
"text": para_clean,
"index": i
})
except Exception as e:
logger.error(f"Error loading missions file: {str(e)}")
if not os.path.exists(GUIDE_DIR) or not verify_sandbox_path(GUIDE_DIR):
logger.warning(f"Guide directory path invalid or not found: {GUIDE_DIR}")
_GUIDE_DOCS = documents
return _GUIDE_DOCS
# 2. Find and load all Clean_*.txt files
files = glob.glob(os.path.join(GUIDE_DIR, "Clean_*.txt"))
for filepath in files:
if not verify_sandbox_path(filepath):
continue
filename = os.path.basename(filepath)
if "DefaultGameData" in filename or "COMBINADO" in filename:
continue
part_match = re.search(r"Clean_(.+)\.txt", filename)
part_name = part_match.group(1) if part_match else filename
try:
with open(filepath, 'r', encoding='utf-8', errors='ignore') as f:
content = f.read()
paragraphs = content.split('\n\n')
for i, para in enumerate(paragraphs):
para_clean = para.strip()
if len(para_clean) > 30:
documents.append({
"source": part_name,
"text": para_clean,
"index": i
})
except Exception as e:
logger.error(f"Error reading guide file {filename}: {str(e)}")
_GUIDE_DOCS = documents
return _GUIDE_DOCS
def get_config_lines() -> list:
"""Lazy-loads and caches the main game config file to prevent high I/O overhead."""
global _CONFIG_LINES
if _CONFIG_LINES is not None:
return _CONFIG_LINES
if not os.path.exists(CONFIG_FILE) or not verify_sandbox_path(CONFIG_FILE):
logger.error(f"Config file invalid or not found: {CONFIG_FILE}")
_CONFIG_LINES = []
return _CONFIG_LINES
try:
with open(CONFIG_FILE, 'r', encoding='utf-8', errors='ignore') as f:
_CONFIG_LINES = f.readlines()
logger.info(f"Successfully cached {len(_CONFIG_LINES)} lines from config file.")
except Exception as e:
logger.error(f"Failed to read config file: {str(e)}")
_CONFIG_LINES = []
return _CONFIG_LINES
@mcp.tool()
def search_strategy_guide(query: str) -> str:
"""
Search the XCOM 2 strategy guides for tactical advice, research orders, build queues,
soldier classes, enemy weaknesses, and mission strategies.
Use this tool WHEN the user asks for combat tips, build suggestions, project timelines,
or gameplay strategies against specific aliens.
Args:
query: The search query containing keywords (e.g., 'Mec enemy', 'Specialist build').
Returns:
A list of up to 5 matching strategy guide excerpts sorted by relevance.
"""
# Sanitize query to prevent search parsing issues
query_clean = re.sub(r'[^\w\s\-\:]', '', query).strip()
if not query_clean:
return "ERROR: Please provide a query with alphanumeric characters."
docs = get_documents()
if not docs:
return "ERROR: No strategy guide files found in the source directory."
words = [w.lower() for w in re.findall(r'\w+', query_clean) if len(w) > 2]
if not words:
return "ERROR: Please provide specific keywords (longer than 2 characters)."
scored = []
for doc in docs:
score = 0
text_lower = doc["text"].lower()
for word in words:
if word in text_lower:
score += 2
score += text_lower.count(word) * 0.2
if query_clean.lower() in text_lower:
score += 10
if score > 0:
scored.append((score, doc))
scored.sort(key=lambda x: x[0], reverse=True)
results = []
for score, doc in scored[:5]:
results.append(
f"--- SOURCE: {doc['source']} (Paragraph {doc['index']}) (Relevance: {score:.1f}) ---\n{doc['text']}"
)
if not results:
return f"No tactical advice found in the guide for the query: '{query_clean}'."
return "\n\n".join(results)
@mcp.tool()
def search_game_config(query: str, difficulty: Optional[int] = None) -> str:
"""
Search the DefaultGameData_COMBINADO.txt file for internal XCOM 2 game rules,
values, or settings configurations.
Use this tool WHEN the user asks for exact game statistics, numeric values,
balancing variables, or game engine configurations.
Args:
query: Config variable name or section to find (e.g., 'AkEvent', 'TacticalCombatMusicSets').
difficulty: Optional difficulty level (0=Rookie, 1=Veteran, 2=Commander, 3=Legend) to filter difficulty-specific parameters.
Returns:
Up to 25 lines matching the config query, indicating line number and section.
"""
query_clean = re.sub(r'[^\w\s\-\_\[\]]', '', query).strip()
if not query_clean:
return "ERROR: Please provide a valid config parameter name."
lines = get_config_lines()
if not lines:
return "ERROR: Game configuration file is empty or could not be loaded."
query_lower = query_clean.lower()
matching_lines = []
current_section = "[No Section]"
for i, line in enumerate(lines):
line_strip = line.strip()
if line_strip.startswith('[') and line_strip.endswith(']'):
current_section = line_strip
if query_lower in line.lower():
if difficulty is not None and "difficulty=" in line_strip.lower():
diff_match = re.search(r'difficulty\s*=\s*(\d+)', line_strip, re.IGNORECASE)
if diff_match:
line_diff = int(diff_match.group(1))
if line_diff != difficulty:
continue
matching_lines.append((i + 1, current_section, line_strip))
if not matching_lines:
filter_str = f" with difficulty={difficulty}" if difficulty is not None else ""
return f"No configuration lines found matching '{query_clean}'{filter_str}."
output = []
for line_num, section, content in matching_lines[:25]:
output.append(f"Line {line_num} | Section: {section}\n Value: {content}")
return "\n".join(output)
@mcp.tool()
def get_difficulty_mechanics(difficulty_name: str) -> str:
"""
Get detailed information about game mechanics, player aim assist, hidden modifiers,
and enemy encounter tables for a specific difficulty level.
Use this tool WHEN the user asks about hidden aim assist percentages, the calendar
timeline of monthly enemy appearances, or possible Chosen traits (strengths/weaknesses).
Args:
difficulty_name: The difficulty name ('Rookie', 'Veteran', 'Commander', 'Legend').
Returns:
Structured breakdown of aim assist modifiers, monthly timelines, and Chosen traits.
"""
compendium = load_compendium()
if not compendium:
return "ERROR: Could not load the difficulty compendium database."
diffs = compendium.get("aim_assist", {})
matched_diff = None
for name in diffs.keys():
if name.lower() == difficulty_name.lower():
matched_diff = name
break
if not matched_diff:
return f"ERROR: Difficulty '{difficulty_name}' not found. Available options: {list(diffs.keys())}"
aim = diffs[matched_diff]
timeline = compendium.get("enemy_timeline", {})
timeline_str = []
for month, info in timeline.items():
enemies = ", ".join(info.get("enemigos_comunes", []))
timeline_str.append(
f"- **{month}**:\n"
f" * Enemies: {enemies}\n"
f" * Tactical Priority: {info.get('prioridad_tactica', '')}\n"
f" * Key Event: {info.get('evento_clave', '')}"
)
chosen = compendium.get("chosen_traits", {})
weaknesses = "\n".join([f"- **{w['nombre']}**: {w['descripcion']}" for w in chosen.get("debilidades", [])])
strengths = "\n".join([f"- **{s['nombre']}**: {s['descripcion']}" for s in chosen.get("fortalezas", [])])
res = (
f"=== MECHANICS & INTEL FOR DIFFICULTY: {matched_diff.upper()} ===\n\n"
f"--- AIM ASSIST ---\n"
f"- Base Multiplier: {aim.get('base_multiplier')}\n"
f"- Consecutive Miss Bonus: {aim.get('consecutive_miss_bonus')}\n"
f"- Enemy Consecutive Hit Penalty: {aim.get('enemy_consecutive_hit_penalty')}\n"
f"- Hidden Mechanics: {aim.get('hidden_mechanics')}\n\n"
f"--- ENEMY SPAWN TIMELINE ---\n"
f"{chr(10).join(timeline_str)}\n\n"
f"--- CHOSEN TRAITS POOL ---\n"
f"**Possible Weaknesses:**\n{weaknesses}\n\n"
f"**Possible Strengths:**\n{strengths}"
)
return res
if __name__ == "__main__":
mcp.run()
|