| |
| """MCP tool-based Red Alert bot that plays entirely through MCP tools. |
| |
| Validates the full MCP integration path: tool discovery, game knowledge |
| lookups, read tools for state, and action tools for commands. Uses |
| OpenRAMCPClient to interact with the OpenRA-RL server via WebSocket. |
| |
| Exercises ALL 30 MCP tools: |
| - Read tools: get_game_state, get_economy, get_units, get_buildings, |
| get_enemies, get_production, get_map_info |
| - Knowledge tools: lookup_unit, lookup_building, lookup_tech_tree, lookup_faction, |
| get_faction_briefing, get_map_analysis, batch_lookup |
| - Action tools: advance, deploy_unit, build_structure, place_building, |
| build_unit, move_units, attack_move, attack_target, stop_units, |
| set_rally_point, guard_target, set_stance, sell_building, repair_building, |
| harvest, power_down, set_primary |
| - Replay tool: get_replay_path |
| |
| Usage: |
| docker run -p 8000:8000 openra-rl |
| python examples/mcp_bot.py --verbose |
| """ |
|
|
| import argparse |
| import asyncio |
| import json |
| import sys |
| from typing import Any, Optional |
|
|
| |
| sys.stdout.reconfigure(line_buffering=True) |
|
|
| from openra_env.mcp_ws_client import OpenRAMCPClient |
|
|
|
|
| class MCPBot: |
| """State-machine bot that plays Red Alert using MCP tool calls. |
| |
| Phases: |
| startup - Look up tech tree and faction info |
| deploy_mcv - Find and deploy MCV |
| build_base - Build power/barracks/refinery/war factory |
| train_army - Train infantry + vehicles, set rally points |
| attack - Attack-move toward enemy |
| sustain - Repair, sell damaged, power management |
| """ |
|
|
| BARRACKS_TYPES = {"tent", "barr"} |
| WAR_FACTORY_TYPES = {"weap"} |
| BUILD_ORDER = ["powr", "barracks", "proc", "weap", "powr"] |
| INFANTRY_TARGET = 6 |
| GUARD_COUNT = 2 |
| COMBAT_TYPES = {"e1", "e2", "e3", "e4", "1tnk", "2tnk", "3tnk", "arty", "jeep", "apc"} |
| INFANTRY_TYPES = {"e1", "e2", "e3", "e4"} |
|
|
| def __init__(self, env: OpenRAMCPClient, verbose: bool = False, no_planning: bool = False): |
| self.env = env |
| self.verbose = verbose |
| self.no_planning = no_planning |
| self.phase = "startup" |
| self.build_index = 0 |
| self.placement_count = 0 |
| self.deploy_issued = False |
| self._guards_assigned: set[int] = set() |
| self._stances_set: set[int] = set() |
| self._rally_set: set[int] = set() |
| self._repair_issued: set[int] = set() |
| self._sold: set[int] = set() |
| self._powered_down: set[int] = set() |
| self._primary_set: set[int] = set() |
| self._apc_trained = False |
| self._tools_exercised: set[str] = set() |
|
|
| async def call(self, tool_name: str, **kwargs: Any) -> Any: |
| """Call an MCP tool and track which tools have been exercised.""" |
| self._tools_exercised.add(tool_name) |
| result = await self.env.call_tool(tool_name, **kwargs) |
| return result |
|
|
| def _log(self, msg: str): |
| if self.verbose: |
| print(f" [MCPBot] {msg}") |
|
|
| |
|
|
| async def run(self, max_turns: int) -> dict: |
| """Run the bot for up to max_turns.""" |
| |
| await self._startup() |
|
|
| turn = 0 |
| while turn < max_turns: |
| state = await self.call("get_game_state") |
| if state.get("done"): |
| self._log(f"Game over: {state.get('result', '?')}") |
| break |
|
|
| turn += 1 |
| await self._tick(state, turn) |
|
|
| if turn % 100 == 0: |
| self._print_status(turn, state) |
|
|
| |
| final_state = await self.call("get_game_state") |
| replay = await self.call("get_replay_path") |
| self._log(f"Replay: {replay}") |
|
|
| return { |
| "turns": turn, |
| "final_state": final_state, |
| "replay": replay, |
| "tools_exercised": sorted(self._tools_exercised), |
| "tools_count": len(self._tools_exercised), |
| "planning_strategy": getattr(self, "_planning_strategy", ""), |
| } |
|
|
| |
|
|
| async def _startup(self): |
| """Run planning phase and look up game knowledge at game start.""" |
| if self.no_planning: |
| self._log("=== Startup: Planning DISABLED ===") |
| |
| briefing = await self.call("get_faction_briefing") |
| self._log(f"Faction briefing: {briefing.get('side', '?')}, " |
| f"{len(briefing.get('units', {}))} units, " |
| f"{len(briefing.get('buildings', {}))} buildings") |
| else: |
| self._log("=== Startup: Planning Phase ===") |
|
|
| |
| planning = await self.call("start_planning_phase") |
| if planning.get("planning_active"): |
| self._log(f"Planning active โ opponent: {planning.get('opponent_summary', '')[:120]}") |
|
|
| |
| briefing = await self.call("get_faction_briefing") |
| self._log(f"Faction briefing: {briefing.get('side', '?')}, " |
| f"{len(briefing.get('units', {}))} units, " |
| f"{len(briefing.get('buildings', {}))} buildings") |
|
|
| map_analysis = await self.call("get_map_analysis") |
| self._log(f"Map analysis: {map_analysis.get('map_type', '?')}, " |
| f"{len(map_analysis.get('resource_patches', []))} resource patches") |
|
|
| intel = await self.call("get_opponent_intel") |
| aggressiveness = intel.get("aggressiveness", "unknown") |
| self._log(f"Opponent aggressiveness: {aggressiveness}") |
|
|
| |
| if aggressiveness in ("high", "very_high"): |
| strategy = ( |
| "Defensive opening: power plant, barracks, turrets at base entrance, " |
| "then ore refinery for economy. Build war factory for tanks once stable. " |
| "Scout early to find and deny enemy expansion." |
| ) |
| else: |
| strategy = ( |
| "Rush opening: power plant, barracks, infantry rush while building " |
| "ore refinery. Transition to tanks from war factory." |
| ) |
|
|
| result = await self.call("end_planning_phase", strategy=strategy) |
| self._planning_strategy = strategy |
| self._log(f"Planning complete: {result.get('planning_duration_seconds', '?')}s, strategy: {strategy[:80]}") |
| else: |
| |
| self._log(f"Planning: {planning.get('message', 'disabled')}") |
| briefing = await self.call("get_faction_briefing") |
| self._log(f"Faction briefing: {briefing.get('side', '?')}, " |
| f"{len(briefing.get('units', {}))} units, " |
| f"{len(briefing.get('buildings', {}))} buildings") |
|
|
| map_info = await self.call("get_map_info") |
| self._log(f"Map: {map_info.get('map_name', '?')} ({map_info.get('width')}x{map_info.get('height')})") |
|
|
| self.phase = "deploy_mcv" |
| self._log("Phase โ deploy_mcv") |
|
|
| |
|
|
| async def _tick(self, state: dict, turn: int): |
| """Make decisions for one game tick.""" |
| |
| await self._update_phase() |
|
|
| if self.phase == "deploy_mcv": |
| await self._do_deploy() |
| elif self.phase == "build_base": |
| await self._do_build() |
| elif self.phase == "train_army": |
| await self._do_build() |
| await self._do_train() |
| elif self.phase == "attack": |
| await self._do_build() |
| await self._do_train() |
| await self._do_combat() |
| await self._do_sustain() |
|
|
| |
| await self.call("advance", ticks=1) |
|
|
| async def _update_phase(self): |
| """Transition phases based on game state.""" |
| buildings = await self.call("get_buildings") |
| units = await self.call("get_units") |
|
|
| has_cy = any(b["type"] == "fact" for b in buildings) |
| has_barracks = any(b["type"] in self.BARRACKS_TYPES for b in buildings) |
| combat_units = [u for u in units if u["type"] in self.COMBAT_TYPES] |
| non_guard = [u for u in combat_units if u["actor_id"] not in self._guards_assigned] |
|
|
| if self.phase == "deploy_mcv" and has_cy: |
| self.phase = "build_base" |
| self._log("Phase โ build_base") |
| elif self.phase == "build_base" and self.build_index >= len(self.BUILD_ORDER): |
| self.phase = "train_army" |
| self._log("Phase โ train_army") |
| elif self.phase == "train_army" and len(non_guard) >= self.INFANTRY_TARGET: |
| self.phase = "attack" |
| self._log(f"Phase โ attack ({len(non_guard)} combat units)") |
|
|
| |
|
|
| async def _do_deploy(self): |
| """Find and deploy MCV.""" |
| if self.deploy_issued: |
| return |
|
|
| units = await self.call("get_units") |
| mcv = next((u for u in units if u["type"] == "mcv"), None) |
| if mcv: |
| self._log(f"Deploying MCV (actor {mcv['actor_id']})") |
| await self.call("deploy_unit", unit_id=mcv["actor_id"]) |
| self.deploy_issued = True |
|
|
| |
|
|
| async def _do_build(self): |
| """Handle building construction and placement.""" |
| |
| production = await self.call("get_production") |
| buildings = await self.call("get_buildings") |
|
|
| for p in production.get("queue", []): |
| if p["queue_type"] == "Building" and p["progress"] >= 0.99: |
| cy = next((b for b in buildings if b["type"] == "fact"), None) |
| if cy: |
| x, y = self._placement_offset(cy) |
| self._log(f"Placing {p['item']} at ({x}, {y})") |
| await self.call("place_building", building_type=p["item"], cell_x=x, cell_y=y) |
| self.placement_count += 1 |
|
|
| |
| if self.build_index >= len(self.BUILD_ORDER): |
| return |
|
|
| building_in_queue = any(p["queue_type"] == "Building" for p in production.get("queue", [])) |
| if building_in_queue: |
| return |
|
|
| item = self.BUILD_ORDER[self.build_index] |
| |
| if item == "barracks": |
| available = production.get("available", []) |
| if "tent" in available: |
| item = "tent" |
| elif "barr" in available: |
| item = "barr" |
| else: |
| return |
|
|
| |
| already = sum(1 for b in buildings if b["type"] == item) |
| if already > 0 and self.build_index < len(self.BUILD_ORDER) - 1: |
| |
| count_in_order = sum(1 for x in self.BUILD_ORDER[:self.build_index + 1] |
| if x == item or (x == "barracks" and item in self.BARRACKS_TYPES)) |
| if already >= count_in_order: |
| self.build_index += 1 |
| return |
|
|
| available = production.get("available", []) |
| if item in available: |
| economy = await self.call("get_economy") |
| building_stats = await self.call("lookup_building", building_type=item) |
| cost = building_stats.get("cost", 0) |
| if economy.get("cash", 0) >= cost: |
| self._log(f"Building {item} (#{self.build_index + 1}/{len(self.BUILD_ORDER)}, cost=${cost})") |
| await self.call("build_structure", building_type=item) |
| self.build_index += 1 |
|
|
| |
| await self._do_rally_points(buildings) |
|
|
| async def _do_rally_points(self, buildings: list[dict]): |
| """Set rally points on barracks and war factories.""" |
| cy = next((b for b in buildings if b["type"] == "fact"), None) |
| if not cy: |
| return |
|
|
| for b in buildings: |
| if b["type"] in ("tent", "barr", "weap") and b["actor_id"] not in self._rally_set: |
| rally_x = cy["cell_x"] if cy["cell_x"] > 0 else cy.get("pos_x", 0) // 1024 |
| rally_y = cy["cell_y"] if cy["cell_y"] > 0 else cy.get("pos_y", 0) // 1024 |
| self._log(f"Setting rally on {b['type']} (actor {b['actor_id']}) โ ({rally_x}, {rally_y})") |
| await self.call("set_rally_point", building_id=b["actor_id"], cell_x=rally_x, cell_y=rally_y) |
| self._rally_set.add(b["actor_id"]) |
|
|
| def _placement_offset(self, cy: dict) -> tuple[int, int]: |
| """Calculate placement position relative to CY.""" |
| cx = cy.get("pos_x", 0) // 1024 if cy.get("cell_x", 0) == 0 else cy["cell_x"] |
| cy_y = cy.get("pos_y", 0) // 1024 if cy.get("cell_y", 0) == 0 else cy["cell_y"] |
| offsets = [ |
| (3, 0), (-3, 0), (0, 3), (0, -3), |
| (3, 3), (-3, 3), (3, -3), (-3, -3), |
| (6, 0), (-6, 0), (0, 6), (0, -6), |
| ] |
| idx = self.placement_count % len(offsets) |
| dx, dy = offsets[idx] |
| return cx + dx, cy_y + dy |
|
|
| |
|
|
| async def _do_train(self): |
| """Train infantry and vehicles.""" |
| production = await self.call("get_production") |
| buildings = await self.call("get_buildings") |
| units = await self.call("get_units") |
| economy = await self.call("get_economy") |
|
|
| has_barracks = any(b["type"] in self.BARRACKS_TYPES for b in buildings) |
| infantry_training = any( |
| p["queue_type"] == "Infantry" and p["progress"] < 0.99 |
| for p in production.get("queue", []) |
| ) |
| infantry = [u for u in units if u["type"] in self.INFANTRY_TYPES] |
| total_target = self.INFANTRY_TARGET + self.GUARD_COUNT |
|
|
| |
| if has_barracks and not infantry_training and len(infantry) < total_target: |
| available = production.get("available", []) |
| if "e1" in available and economy.get("cash", 0) >= 100: |
| self._log(f"Training e1 ({len(infantry)}/{total_target})") |
| await self.call("build_unit", unit_type="e1") |
|
|
| |
| has_weap = any(b["type"] == "weap" for b in buildings) |
| vehicle_training = any( |
| p["queue_type"] == "Vehicle" and p["progress"] < 0.99 |
| for p in production.get("queue", []) |
| ) |
| if has_weap and not vehicle_training and not self._apc_trained: |
| available = production.get("available", []) |
| if "apc" in available and economy.get("cash", 0) >= 800: |
| self._log("Training APC") |
| await self.call("build_unit", unit_type="apc") |
| self._apc_trained = True |
|
|
| |
| if self.phase == "attack" and has_weap and not vehicle_training: |
| available = production.get("available", []) |
| if "1tnk" in available and economy.get("cash", 0) >= 700: |
| self._log("Training 1tnk (continuous)") |
| await self.call("build_unit", unit_type="1tnk") |
|
|
| |
| for u in units: |
| if u["actor_id"] in self._stances_set: |
| continue |
| if u["type"] not in self.COMBAT_TYPES: |
| continue |
| stance = "defend" if u["actor_id"] in self._guards_assigned else "attack_anything" |
| await self.call("set_stance", unit_ids=str(u["actor_id"]), stance=stance) |
| self._stances_set.add(u["actor_id"]) |
|
|
| |
| if len(self._guards_assigned) < self.GUARD_COUNT: |
| cy = next((b for b in buildings if b["type"] == "fact"), None) |
| if cy: |
| for u in units: |
| if len(self._guards_assigned) >= self.GUARD_COUNT: |
| break |
| if (u["type"] in self.INFANTRY_TYPES |
| and u["is_idle"] |
| and u["actor_id"] not in self._guards_assigned): |
| self._log(f"Assigning {u['type']} (actor {u['actor_id']}) to guard CY") |
| await self.call("guard_target", unit_ids=str(u["actor_id"]), target_actor_id=cy["actor_id"]) |
| self._guards_assigned.add(u["actor_id"]) |
|
|
| |
| for btype_set in [self.BARRACKS_TYPES, self.WAR_FACTORY_TYPES]: |
| bldgs_of_type = [b for b in buildings if b["type"] in btype_set] |
| if len(bldgs_of_type) >= 2: |
| newest = max(bldgs_of_type, key=lambda b: b["actor_id"]) |
| if newest["actor_id"] not in self._primary_set: |
| self._log(f"Setting primary: {newest['type']} (actor {newest['actor_id']})") |
| await self.call("set_primary", building_id=newest["actor_id"]) |
| self._primary_set.add(newest["actor_id"]) |
|
|
| |
|
|
| async def _do_combat(self): |
| """Attack-move idle combat units toward enemies.""" |
| units = await self.call("get_units") |
| enemies = await self.call("get_enemies") |
|
|
| idle_fighters = [ |
| u for u in units |
| if (u["type"] in self.COMBAT_TYPES |
| and u["is_idle"] |
| and u["actor_id"] not in self._guards_assigned) |
| ] |
|
|
| if len(idle_fighters) < 2: |
| return |
|
|
| |
| target_x, target_y = self._find_attack_target(enemies, units) |
|
|
| unit_id_list = [u["actor_id"] for u in idle_fighters] |
| unit_ids = ",".join(str(i) for i in unit_id_list) |
| self._log(f"Attacking with {len(unit_id_list)} units toward ({target_x}, {target_y})") |
| await self.call("attack_move", unit_ids=unit_ids, target_x=target_x, target_y=target_y) |
|
|
| |
| if enemies.get("units"): |
| enemy = enemies["units"][0] |
| nearby = [u for u in idle_fighters[:3] if u["can_attack"]] |
| if nearby: |
| nearby_ids = ",".join(str(u["actor_id"]) for u in nearby) |
| await self.call( |
| "attack_target", |
| unit_ids=nearby_ids, |
| target_actor_id=enemy["actor_id"], |
| ) |
|
|
| def _find_attack_target(self, enemies: dict, units: list[dict]) -> tuple[int, int]: |
| """Find best attack target: enemy buildings > units > map center.""" |
| if enemies.get("buildings"): |
| b = enemies["buildings"][0] |
| return b["cell_x"], b["cell_y"] |
| if enemies.get("units"): |
| u = enemies["units"][0] |
| return u["cell_x"], u["cell_y"] |
| return 64, 64 |
|
|
| |
|
|
| async def _do_sustain(self): |
| """Repair, sell, and manage power.""" |
| buildings = await self.call("get_buildings") |
| economy = await self.call("get_economy") |
|
|
| for b in buildings: |
| |
| if (b["hp_percent"] < 0.7 |
| and not b.get("is_repairing", False) |
| and b["actor_id"] not in self._repair_issued |
| and economy.get("cash", 0) >= 500): |
| self._log(f"Repairing {b['type']} (actor {b['actor_id']}, hp={b['hp_percent']:.0%})") |
| await self.call("repair_building", building_id=b["actor_id"]) |
| self._repair_issued.add(b["actor_id"]) |
|
|
| |
| if (b["hp_percent"] < 0.2 |
| and b["type"] != "fact" |
| and b["actor_id"] not in self._sold): |
| self._log(f"Selling {b['type']} (actor {b['actor_id']}, hp={b['hp_percent']:.0%})") |
| await self.call("sell_building", building_id=b["actor_id"]) |
| self._sold.add(b["actor_id"]) |
|
|
| |
| power_balance = economy.get("power_provided", 0) - economy.get("power_drained", 0) |
| if power_balance < 0: |
| power_down_priority = ["dome", "spen", "syrd", "hpad", "afld", "fix"] |
| for btype in power_down_priority: |
| for b in buildings: |
| if (b["type"] == btype |
| and b.get("is_powered", True) |
| and b["actor_id"] not in self._powered_down): |
| self._log(f"Powering down {b['type']} (actor {b['actor_id']}) โ power: {power_balance}") |
| await self.call("power_down", building_id=b["actor_id"]) |
| self._powered_down.add(b["actor_id"]) |
| return |
|
|
| |
| units = await self.call("get_units") |
| for u in units: |
| if u["type"] == "harv" and u["is_idle"]: |
| self._log(f"Sending harvester {u['actor_id']} to harvest") |
| await self.call("harvest", unit_id=u["actor_id"]) |
| break |
|
|
| |
| fleeing = [u for u in units if u["type"] in self.COMBAT_TYPES |
| and u.get("current_activity") == "Flee"] |
| if fleeing: |
| await self.call("stop_units", unit_ids=",".join(str(u["actor_id"]) for u in fleeing[:3])) |
|
|
| |
| idle_scouts = [u for u in units |
| if u["type"] in ("jeep", "e1") and u["is_idle"] |
| and u["actor_id"] not in self._guards_assigned] |
| if idle_scouts and len(idle_scouts) > 3: |
| scout = idle_scouts[0] |
| await self.call("move_units", unit_ids=str(scout["actor_id"]), target_x=64, target_y=64) |
|
|
| |
|
|
| def _print_status(self, turn: int, state: dict): |
| eco = state.get("economy", {}) |
| power = eco.get("power_provided", 0) - eco.get("power_drained", 0) |
| print( |
| f"Turn {turn:4d} | Tick {state.get('tick', 0):5d} | " |
| f"${eco.get('cash', 0):5d} | Pwr:{power:+d} | " |
| f"Units:{state.get('own_units', 0)} | " |
| f"Enemy:{state.get('visible_enemies', 0)} | " |
| f"Bldgs:{state.get('own_buildings', 0)} | {self.phase}" |
| ) |
|
|
|
|
| |
|
|
|
|
| async def run_mcp_bot(url: str, max_turns: int, verbose: bool, no_planning: bool = False): |
| """Connect to the OpenRA-RL server and play using MCP tools.""" |
| print(f"Connecting to {url}...") |
|
|
| async with OpenRAMCPClient(base_url=url, message_timeout_s=300.0) as env: |
| print("Resetting environment (launching OpenRA)...") |
| await env.reset() |
|
|
| |
| tools = await env.list_tools() |
| tool_names = sorted(t.name for t in tools) |
| print(f"Discovered {len(tools)} MCP tools: {tool_names}") |
|
|
| |
| bot = MCPBot(env, verbose=verbose, no_planning=no_planning) |
| result = bot.run(max_turns) |
| if asyncio.iscoroutine(result): |
| result = await result |
|
|
| |
| print() |
| print("=" * 70) |
| final = result["final_state"] |
| print(f"Game finished after {result['turns']} turns") |
| if final.get("done"): |
| print(f"Result: {final.get('result', '?').upper()}") |
|
|
| |
| mil = final.get("military", {}) |
| eco = final.get("economy", {}) |
| planning = result.get("planning_strategy", "") |
| print() |
| print("--- SCORECARD ---") |
| print(f" Planning: {'ON โ ' + planning if planning else 'OFF'}") |
| print(f" Ticks played: {final.get('tick', '?')}") |
| print(f" Units killed: {mil.get('units_killed', 0)} (value: ${mil.get('kills_cost', 0)})") |
| print(f" Units lost: {mil.get('units_lost', 0)} (value: ${mil.get('deaths_cost', 0)})") |
| print(f" Buildings killed: {mil.get('buildings_killed', 0)}") |
| print(f" Buildings lost: {mil.get('buildings_lost', 0)}") |
| print(f" Army value: ${mil.get('army_value', 0)}") |
| print(f" Assets value: ${mil.get('assets_value', 0)}") |
| print(f" Experience: {mil.get('experience', 0)}") |
| print(f" Orders issued: {mil.get('order_count', 0)}") |
| print(f" Cash remaining: ${eco.get('cash', 0)}") |
| print(f" K/D cost ratio: {mil.get('kills_cost', 0) / max(mil.get('deaths_cost', 1), 1):.2f}") |
| print() |
|
|
| print(f"Tools exercised: {result['tools_count']}/{len(tools)}") |
| print(f" {result['tools_exercised']}") |
| if result.get("replay", {}).get("path"): |
| print(f"Replay: {result['replay']['path']}") |
| print("=" * 70) |
|
|
|
|
| def main(): |
| parser = argparse.ArgumentParser(description="MCP tool-based Red Alert bot") |
| parser.add_argument( |
| "--url", |
| default="http://localhost:8000", |
| help="OpenRA-RL server URL (default: http://localhost:8000)", |
| ) |
| parser.add_argument( |
| "--max-turns", |
| type=int, |
| default=3000, |
| help="Maximum turns before stopping (default: 3000)", |
| ) |
| parser.add_argument( |
| "--verbose", |
| action="store_true", |
| help="Print detailed bot decisions", |
| ) |
| parser.add_argument( |
| "--no-planning", |
| action="store_true", |
| help="Disable planning phase (for comparison runs)", |
| ) |
| args = parser.parse_args() |
|
|
| try: |
| asyncio.run(run_mcp_bot(args.url, args.max_turns, args.verbose, no_planning=args.no_planning)) |
| except KeyboardInterrupt: |
| print("\nInterrupted by user") |
| sys.exit(0) |
| except ConnectionRefusedError: |
| print(f"\nCould not connect to {args.url}") |
| print("Is the OpenRA-RL server running?") |
| print(" docker run -p 8000:8000 openra-rl") |
| sys.exit(1) |
|
|
|
|
| if __name__ == "__main__": |
| main() |
|
|