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()