| """FastAPI application for the OpenRA-RL environment. |
| |
| Creates the OpenEnv-compatible server using create_app(). |
| """ |
|
|
| import asyncio |
| import json |
| import os |
| import time |
|
|
| from fastapi import Query |
| from fastapi.responses import HTMLResponse, StreamingResponse |
| from openenv.core.env_server import create_app |
|
|
| from openra_env.models import OpenRAAction, OpenRAObservation |
| from openra_env.server.openra_environment import OpenRAEnvironment |
|
|
| app = create_app( |
| OpenRAEnvironment, |
| OpenRAAction, |
| OpenRAObservation, |
| env_name="openra_env", |
| ) |
|
|
|
|
| |
|
|
| _TRY_MAX_TURNS = 30 |
| _TRY_MAX_TIME = 300 |
|
|
| _COMMENTARY_SYSTEM_PROMPT = ( |
| "You are a real-time commentator for an AI playing Command & Conquer: Red Alert. " |
| "Given the AI's recent actions and current game state, write 1-2 sentences " |
| "explaining what the AI is doing and why, in an engaging style. " |
| "Keep it concise and accessible to viewers who may not know RTS games well." |
| ) |
| _COMMENTARY_MAX_TOKENS = 150 |
|
|
|
|
| def _sse(event_type: str, data: dict) -> str: |
| """Format a Server-Sent Event.""" |
| return f"event: {event_type}\ndata: {json.dumps(data)}\n\n" |
|
|
|
|
| async def _generate_commentary(user_content: str, llm_config, broadcaster) -> None: |
| """Generate commentary in the background and broadcast it.""" |
| import httpx as _httpx |
|
|
| try: |
| headers = dict(llm_config.extra_headers) |
| if llm_config.api_key: |
| headers["Authorization"] = f"Bearer {llm_config.api_key}" |
|
|
| payload = { |
| "model": llm_config.model, |
| "messages": [ |
| {"role": "system", "content": _COMMENTARY_SYSTEM_PROMPT}, |
| {"role": "user", "content": user_content}, |
| ], |
| "max_tokens": 400, |
| "reasoning": {"effort": "low"}, |
| "temperature": 0.6, |
| "top_p": 0.95, |
| } |
|
|
| async with _httpx.AsyncClient() as client: |
| resp = await client.post( |
| llm_config.base_url, |
| headers=headers, |
| json=payload, |
| timeout=llm_config.request_timeout_s, |
| ) |
|
|
| if resp.status_code != 200: |
| return |
| data = resp.json() |
| msg = data["choices"][0]["message"] |
| text = msg.get("content") or "" |
| if not text: |
| |
| text = msg.get("reasoning") or "" |
| if text: |
| sentences = [s.strip() for s in text.replace("\n", " ").split(".") if s.strip()] |
| text = ". ".join(sentences[-2:]) + "." if sentences else "" |
| if text: |
| broadcaster._broadcast(_sse("commentary", {"text": text.strip()})) |
| except Exception: |
| pass |
|
|
|
|
| class TryGameBroadcaster: |
| """Manages a single game broadcast to multiple SSE subscribers.""" |
|
|
| def __init__(self): |
| self._event_history: list[str] = [] |
| self._subscribers: set[asyncio.Queue] = set() |
| self._game_running: bool = False |
| self._game_task: asyncio.Task | None = None |
| self._opponent: str = "" |
| self._start_lock = asyncio.Lock() |
|
|
| @property |
| def game_running(self) -> bool: |
| return self._game_running |
|
|
| def subscribe(self) -> asyncio.Queue: |
| queue: asyncio.Queue = asyncio.Queue() |
| self._subscribers.add(queue) |
| return queue |
|
|
| def unsubscribe(self, queue: asyncio.Queue) -> None: |
| self._subscribers.discard(queue) |
|
|
| def _broadcast(self, event: str) -> None: |
| self._event_history.append(event) |
| for q in self._subscribers: |
| q.put_nowait(event) |
|
|
| async def replay_to(self, queue: asyncio.Queue) -> None: |
| for event in list(self._event_history): |
| await queue.put(event) |
|
|
| async def start_game(self, opponent: str) -> None: |
| async with self._start_lock: |
| if self._game_running: |
| return |
| self._event_history.clear() |
| self._opponent = opponent |
| self._game_running = True |
| self._game_task = asyncio.create_task(self._run_game(opponent)) |
|
|
| async def _run_game(self, opponent: str) -> None: |
| try: |
| async for event in _run_try_agent(opponent): |
| self._broadcast(event) |
| finally: |
| self._game_running = False |
| sentinel = _sse("_stream_end", {}) |
| for q in self._subscribers: |
| q.put_nowait(sentinel) |
|
|
|
|
| _broadcaster = TryGameBroadcaster() |
|
|
|
|
| async def _run_try_agent(opponent: str): |
| """Run LLM agent for one demo game, yielding SSE events.""" |
| from openra_env.agent import ( |
| SYSTEM_PROMPT, |
| chat_completion, |
| compose_pregame_briefing, |
| compress_history, |
| format_state_briefing, |
| mcp_tools_to_openai, |
| ) |
| from openra_env.config import LLMConfig |
| from openra_env.mcp_ws_client import OpenRAMCPClient |
|
|
| api_key = os.environ.get("OPENROUTER_API_KEY", "") |
| if not api_key: |
| yield _sse("error_event", {"message": "Server not configured for demo play (no API key)."}) |
| return |
|
|
| llm_config = LLMConfig( |
| api_key=api_key, |
| model="stepfun/step-3.5-flash", |
| base_url="https://openrouter.ai/api/v1/chat/completions", |
| max_tokens=1500, |
| temperature=1.0, |
| top_p=0.95, |
| reasoning_effort="low", |
| extra_headers={ |
| "HTTP-Referer": "https://openra-rl.dev", |
| "X-Title": "OpenRA-RL Try Agent", |
| }, |
| ) |
| commentary_config = LLMConfig( |
| api_key=api_key, |
| model="stepfun/step-3.5-flash", |
| base_url="https://openrouter.ai/api/v1/chat/completions", |
| max_tokens=_COMMENTARY_MAX_TOKENS, |
| request_timeout_s=15.0, |
| extra_headers={ |
| "HTTP-Referer": "https://openra-rl.dev", |
| "X-Title": "OpenRA-RL Commentary", |
| }, |
| ) |
|
|
| |
| os.environ["BOT_TYPE"] = opponent.lower() |
|
|
| yield _sse("status", {"message": f"Launching game vs {opponent} AI..."}) |
|
|
| try: |
| async with OpenRAMCPClient( |
| base_url="http://localhost:8000", message_timeout_s=300.0 |
| ) as env: |
| yield _sse("status", {"message": "Resetting environment..."}) |
| await env.reset() |
|
|
| |
| mcp_tools = await env.list_tools() |
| openai_tools = mcp_tools_to_openai(mcp_tools) |
|
|
| |
| yield _sse("status", {"message": "Starting game session..."}) |
| await env.call_tool("start_planning_phase") |
| await env.call_tool("end_planning_phase", strategy="Demo game - aggressive rush") |
| yield _sse("status", {"message": f"Game started. {len(mcp_tools)} tools available."}) |
|
|
| |
| messages = [{"role": "system", "content": SYSTEM_PROMPT}] |
|
|
| |
| state = await env.call_tool("get_game_state") |
| briefing = compose_pregame_briefing(state) |
|
|
| messages.append({ |
| "role": "user", |
| "content": ( |
| f"Game started!\n\n{briefing}\n\n" |
| f"## Current State\n```json\n{json.dumps(state, indent=2)}\n```\n\n" |
| f"ACT NOW! Deploy your MCV immediately, then start building power plant + barracks. " |
| f"Expand fast — every idle second costs you. Use plan() to chain: " |
| f"deploy MCV → build power plant → build barracks → build refinery. " |
| f"Then focus on economy (3+ refineries) and defense turrets toward the enemy." |
| ), |
| }) |
|
|
| yield _sse("game_state", { |
| "tick": state.get("tick", 0), |
| "units": state.get("own_units", 0), |
| "buildings": state.get("own_buildings", 0), |
| "cash": state.get("economy", {}).get("cash", 0), |
| }) |
|
|
| total_tool_calls = 0 |
| total_api_calls = 0 |
| start_time = time.time() |
| game_done = False |
| consecutive_errors = 0 |
|
|
| for turn in range(1, _TRY_MAX_TURNS + 1): |
| elapsed = time.time() - start_time |
| if elapsed >= _TRY_MAX_TIME: |
| yield _sse("status", {"message": f"Time limit reached ({_TRY_MAX_TIME}s)."}) |
| break |
|
|
| |
| messages = compress_history(messages, keep_last=40) |
|
|
| |
| if total_api_calls > 0: |
| try: |
| briefing_state = await env.call_tool("get_game_state") |
| brief = format_state_briefing(briefing_state) |
| if brief: |
| messages.append({"role": "user", "content": brief}) |
| if isinstance(briefing_state, dict) and briefing_state.get("done"): |
| game_done = True |
| yield _sse("done", { |
| "result": briefing_state.get("result", "?"), |
| "tick": briefing_state.get("tick", 0), |
| }) |
| break |
| except Exception: |
| pass |
|
|
| |
| try: |
| response = await chat_completion(messages, openai_tools, llm_config) |
| except Exception as e: |
| yield _sse("error_event", {"message": f"LLM error: {e}"}) |
| break |
|
|
| total_api_calls += 1 |
| choice = response["choices"][0] |
| assistant_msg = choice["message"] |
| messages.append(assistant_msg) |
|
|
| |
| if assistant_msg.get("content"): |
| yield _sse("llm", {"content": assistant_msg["content"][:500]}) |
|
|
| yield _sse("turn", { |
| "turn": turn, |
| "api_calls": total_api_calls, |
| "elapsed": round(elapsed), |
| }) |
|
|
| |
| tool_calls = assistant_msg.get("tool_calls", []) |
| if not tool_calls: |
| messages.append({ |
| "role": "user", |
| "content": "Please use the game tools to take action.", |
| }) |
| continue |
|
|
| for tc in tool_calls: |
| fn_name = tc["function"]["name"] |
| try: |
| fn_args = json.loads(tc["function"].get("arguments", "{}")) |
| except (json.JSONDecodeError, TypeError): |
| fn_args = {} |
|
|
| total_tool_calls += 1 |
|
|
| args_str = json.dumps(fn_args) |
| if len(args_str) > 120: |
| args_str = args_str[:120] + "..." |
| yield _sse("tool_call", {"name": fn_name, "args": args_str}) |
|
|
| try: |
| result = await env.call_tool(fn_name, **fn_args) |
| consecutive_errors = 0 |
| except Exception as e: |
| result = {"error": str(e)} |
|
|
| |
| if isinstance(result, dict) and "connection lost" in str( |
| result.get("error", "") |
| ).lower(): |
| consecutive_errors += 1 |
| if consecutive_errors >= 3: |
| yield _sse("error_event", {"message": "Game connection lost."}) |
| game_done = True |
|
|
| result_str = ( |
| json.dumps(result) if not isinstance(result, str) else result |
| ) |
| messages.append({ |
| "role": "tool", |
| "tool_call_id": tc["id"], |
| "content": result_str, |
| }) |
|
|
| |
| if isinstance(result, dict): |
| if result.get("done"): |
| game_done = True |
| yield _sse("done", { |
| "result": result.get("result", "?"), |
| "tick": result.get("tick", 0), |
| }) |
| elif "tick" in result and "economy" in result: |
| yield _sse("game_state", { |
| "tick": result.get("tick", 0), |
| "units": result.get("own_units", 0), |
| "buildings": result.get("own_buildings", 0), |
| "cash": result.get("economy", {}).get("cash", 0), |
| }) |
|
|
| |
| if tool_calls and not game_done: |
| action_summaries = [] |
| for tc in tool_calls: |
| fn = tc["function"]["name"] |
| try: |
| fa = json.loads(tc["function"].get("arguments", "{}")) |
| except (json.JSONDecodeError, TypeError): |
| fa = {} |
| action_summaries.append(f"{fn}({json.dumps(fa)})") |
|
|
| commentary_user = ( |
| f"Turn {turn} actions:\n" |
| + "\n".join(f"- {a}" for a in action_summaries[:8]) |
| ) |
| asyncio.create_task(_generate_commentary( |
| commentary_user, commentary_config, _broadcaster, |
| )) |
|
|
| if game_done: |
| break |
|
|
| if choice.get("finish_reason") == "stop" and not tool_calls: |
| messages.append({ |
| "role": "user", |
| "content": "Continue playing. Use game tools to check state and take actions.", |
| }) |
|
|
| |
| if not game_done: |
| try: |
| await env.call_tool("surrender") |
| except Exception: |
| pass |
|
|
| |
| try: |
| final = await env.call_tool("get_game_state") |
| mil = final.get("military", {}) |
| eco = final.get("economy", {}) |
| yield _sse("final", { |
| "result": final.get("result", "ongoing"), |
| "tick": final.get("tick", 0), |
| "turns": total_api_calls, |
| "tool_calls": total_tool_calls, |
| "elapsed": round(time.time() - start_time), |
| "kills_cost": mil.get("kills_cost", 0), |
| "deaths_cost": mil.get("deaths_cost", 0), |
| "units_killed": mil.get("units_killed", 0), |
| "units_lost": mil.get("units_lost", 0), |
| "cash": eco.get("cash", 0), |
| "units": final.get("own_units", 0), |
| "buildings": final.get("own_buildings", 0), |
| }) |
| except Exception: |
| pass |
|
|
| except Exception as e: |
| yield _sse("error_event", {"message": str(e)}) |
|
|
|
|
| @app.get("/try-agent") |
| async def try_agent( |
| opponent: str = Query("Normal", pattern="^(Easy|Normal|Hard)$"), |
| ): |
| """SSE stream of an LLM agent playing Red Alert. |
| |
| Multiple viewers can watch simultaneously. The first request starts |
| a new game; subsequent requests join as spectators of the ongoing game. |
| """ |
| queue = _broadcaster.subscribe() |
|
|
| if _broadcaster.game_running: |
| await queue.put(_sse("status", {"message": "Joining ongoing game as spectator..."})) |
| await _broadcaster.replay_to(queue) |
| else: |
| await _broadcaster.start_game(opponent) |
|
|
| async def stream(): |
| try: |
| while True: |
| event = await asyncio.wait_for(queue.get(), timeout=360) |
| if '"_stream_end"' in event: |
| break |
| yield event |
| except asyncio.TimeoutError: |
| pass |
| finally: |
| _broadcaster.unsubscribe(queue) |
|
|
| return StreamingResponse( |
| stream(), |
| media_type="text/event-stream", |
| headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"}, |
| ) |
|
|
|
|
| LANDING_PAGE = """\ |
| <!DOCTYPE html> |
| <html lang="en"> |
| <head> |
| <meta charset="utf-8"> |
| <meta name="viewport" content="width=device-width, initial-scale=1.0"> |
| <title>OpenRA-RL — OpenEnv Environment</title> |
| <link rel="preconnect" href="https://fonts.googleapis.com"> |
| <link href="https://fonts.googleapis.com/css2?family=Share+Tech+Mono&family=Teko:wght@400;600;700&display=swap" rel="stylesheet"> |
| <style> |
| /* === Reset & base === */ |
| *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; } |
| body { |
| font-family: 'Share Tech Mono', monospace; |
| background: radial-gradient(circle at center, #1a0505 0%, #050505 100%); |
| color: #d1d5db; |
| min-height: 100vh; |
| } |
| a { color: #d1d5db; text-decoration: none; transition: color .2s; } |
| a:hover { color: #fff; } |
| h1, h2, h3, h4, .font-teko { |
| font-family: 'Teko', sans-serif; |
| letter-spacing: 1px; |
| text-transform: uppercase; |
| } |
| |
| /* === Scanlines === */ |
| .scanlines { |
| background: linear-gradient( |
| to bottom, |
| rgba(255,255,255,0), |
| rgba(255,255,255,0) 50%, |
| rgba(0,0,0,0.2) 50%, |
| rgba(0,0,0,0.2) |
| ); |
| background-size: 100% 4px; |
| position: fixed; inset: 0; |
| pointer-events: none; z-index: 50; |
| } |
| |
| /* === CRT flicker === */ |
| .crt-flicker { animation: crt .15s infinite; } |
| @keyframes crt { |
| 0% { opacity: .98; } 50% { opacity: 1; } 100% { opacity: .99; } |
| } |
| |
| /* === Text effects === */ |
| .terminal-text { color: #84cc16; text-shadow: 0 0 5px rgba(132,204,22,.5); } |
| .alert-text { color: #ef4444; text-shadow: 0 0 8px rgba(239,68,68,.8); } |
| |
| /* === Buttons === */ |
| .btn-soviet { |
| display: inline-flex; align-items: center; gap: .5rem; |
| background: #dc2626; border: 2px solid #f87171; |
| box-shadow: 4px 4px 0 #000; transition: all .1s; |
| color: #fff; font-family: 'Teko', sans-serif; |
| font-size: 1.6rem; padding: .4rem 1.8rem; |
| text-transform: uppercase; cursor: pointer; |
| } |
| .btn-soviet:hover { transform: translate(2px,2px); box-shadow: 2px 2px 0 #000; background: #ef4444; color: #fff; } |
| |
| .btn-ghost { |
| display: inline-flex; align-items: center; gap: .5rem; |
| background: #171717; border: 2px solid #525252; |
| box-shadow: 4px 4px 0 #000; transition: all .1s; |
| color: #a3a3a3; font-family: 'Teko', sans-serif; |
| font-size: 1.6rem; padding: .4rem 1.8rem; |
| text-transform: uppercase; cursor: pointer; |
| } |
| .btn-ghost:hover { transform: translate(2px,2px); box-shadow: 2px 2px 0 #000; border-color: #737373; color: #fff; } |
| |
| /* === Military card === */ |
| .card-military { |
| background: #121212; border: 2px solid #262626; |
| border-left: 4px solid #dc2626; |
| box-shadow: 6px 6px 0 rgba(0,0,0,.8); |
| transition: all .2s; padding: 1.5rem; |
| } |
| .card-military:hover { border-color: #dc2626; transform: translateY(-4px); box-shadow: 6px 10px 0 rgba(0,0,0,.8); } |
| .card-military h3 { color: #fff; font-size: 1.6rem; margin-bottom: .3rem; } |
| .card-military p { color: #9ca3af; font-size: .85rem; margin-bottom: .8rem; } |
| .card-military a { color: #ef4444; font-size: .85rem; } |
| .card-military a:hover { color: #f87171; } |
| |
| /* === Nav === */ |
| nav { |
| border-bottom: 2px solid #991b1b; |
| background: rgba(0,0,0,.9); |
| position: sticky; top: 0; z-index: 40; |
| backdrop-filter: blur(4px); |
| } |
| .nav-inner { |
| max-width: 72rem; margin: 0 auto; |
| padding: 0 1.5rem; |
| display: flex; align-items: center; justify-content: space-between; |
| height: 4rem; |
| } |
| .nav-logo { display: flex; align-items: center; gap: .6rem; } |
| .nav-logo svg { width: 2rem; height: 2rem; color: #dc2626; animation: spin 4s linear infinite; } |
| @keyframes spin { to { transform: rotate(360deg); } } |
| .nav-logo span { font-family: 'Teko', sans-serif; font-size: 1.8rem; font-weight: 700; color: #fff; letter-spacing: .15em; } |
| .nav-logo .rl { color: #dc2626; } |
| .nav-links { display: flex; gap: 1.5rem; align-items: center; } |
| .nav-links a { |
| font-family: 'Teko', sans-serif; font-size: 1.15rem; |
| letter-spacing: .1em; color: #9ca3af; |
| text-transform: uppercase; |
| } |
| .nav-links a:hover { color: #fff; } |
| |
| /* === Hero === */ |
| .hero { |
| border-bottom: 4px solid #991b1b; |
| padding: 5rem 1rem 6rem; |
| text-align: center; |
| position: relative; |
| } |
| .hero::before { |
| content: ''; position: absolute; inset: 0; opacity: .5; |
| mix-blend-mode: overlay; pointer-events: none; |
| background-image: url('https://www.transparenttextures.com/patterns/carbon-fibre.png'); |
| } |
| .hero > * { position: relative; z-index: 1; } |
| .hero .subtitle { |
| font-size: 1.1rem; margin-bottom: 1rem; |
| font-weight: 700; display: flex; align-items: center; justify-content: center; gap: .5rem; |
| } |
| .hero h1 { font-size: clamp(3rem, 10vw, 5.5rem); font-weight: 700; line-height: 1; margin-bottom: 1.2rem; } |
| .hero .desc { |
| font-size: 1.1rem; max-width: 40rem; margin: 0 auto 2.5rem; |
| line-height: 1.7; background: rgba(0,0,0,.5); |
| padding: 1.2rem 1.5rem; border: 1px solid #262626; border-radius: 4px; |
| } |
| .hero .desc strong { color: #fff; display: block; margin-top: .5rem; } |
| .hero .buttons { display: flex; flex-wrap: wrap; gap: 1.2rem; justify-content: center; } |
| |
| /* === Sections === */ |
| .section { padding: 4rem 1rem; max-width: 72rem; margin: 0 auto; } |
| .section-dark { background: #050505; border-top: 1px solid #262626; border-bottom: 1px solid #262626; } |
| .section h2 { color: #fff; font-size: 2.4rem; margin-bottom: 1.5rem; } |
| .cards-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); gap: 1.5rem; } |
| |
| /* === Terminal === */ |
| .terminal { border: 1px solid #404040; border-radius: 8px; overflow: hidden; box-shadow: 0 0 30px rgba(0,0,0,1); } |
| .terminal-bar { |
| background: #1a1a1a; padding: .5rem 1rem; border-bottom: 1px solid #404040; |
| display: flex; align-items: center; gap: .5rem; |
| } |
| .terminal-dot { width: .7rem; height: .7rem; border-radius: 50%; } |
| .terminal-bar span { margin-left: .5rem; font-size: .75rem; color: #9ca3af; } |
| .terminal-body { background: #0d0d0d; padding: 1.5rem; overflow-x: auto; font-size: .85rem; line-height: 1.8; } |
| .terminal-body pre { margin: 0; white-space: pre; } |
| .t-prompt { color: #6b7280; } |
| .t-cmd { color: #4ade80; } |
| .t-kw { color: #c084fc; } |
| .t-str { color: #facc15; } |
| .t-fn { color: #38bdf8; } |
| .t-comment { color: #6b7280; } |
| .t-plain { color: #d1d5db; } |
| |
| /* === Footer === */ |
| footer { |
| background: #000; border-top: 2px solid #7f1d1d; |
| margin-top: 4rem; padding: 3rem 1.5rem; |
| } |
| .footer-inner { max-width: 72rem; margin: 0 auto; display: flex; flex-wrap: wrap; gap: 2rem; justify-content: space-between; } |
| .footer-brand { max-width: 24rem; } |
| .footer-brand .logo { font-family: 'Teko', sans-serif; font-size: 2rem; font-weight: 700; color: #fff; letter-spacing: .15em; display: flex; align-items: center; gap: .5rem; } |
| .footer-brand .logo .rl { color: #dc2626; } |
| .footer-status { margin-top: 1rem; font-size: .8rem; color: #84cc16; text-shadow: 0 0 5px rgba(132,204,22,.5); line-height: 1.8; } |
| .footer-col h3 { font-family: 'Teko', sans-serif; font-size: 1.4rem; color: #fff; margin-bottom: .8rem; } |
| .footer-col ul { list-style: none; } |
| .footer-col li { margin-bottom: .5rem; } |
| .footer-col a { font-size: .85rem; display: flex; align-items: center; gap: .4rem; } |
| .footer-col a:hover { color: #ef4444; } |
| .chevron::before { content: '\\203A'; margin-right: .3rem; } |
| |
| /* === Fade in === */ |
| .fade-in { animation: fadeIn .7s ease-in-out; } |
| @keyframes fadeIn { from { opacity: 0; } to { opacity: 1; } } |
| |
| /* === Responsive === */ |
| @media (max-width: 640px) { |
| .nav-links { display: none; } |
| .cards-grid { grid-template-columns: 1fr; } |
| .terminal-section { grid-template-columns: 1fr; } |
| } |
| .terminal-section { |
| display: grid; grid-template-columns: 1fr 1fr; gap: 3rem; align-items: center; |
| } |
| @media (max-width: 900px) { .terminal-section { grid-template-columns: 1fr; } } |
| </style> |
| </head> |
| <body> |
| <div class="scanlines"></div> |
| |
| <!-- Nav --> |
| <nav> |
| <div class="nav-inner"> |
| <a href="/" class="nav-logo"> |
| <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="square" stroke-linejoin="miter"> |
| <circle cx="12" cy="12" r="10"/> |
| <circle cx="12" cy="12" r="6" stroke-opacity="0.5"/> |
| <circle cx="12" cy="12" r="2" fill="currentColor"/> |
| <path d="M12 12l8.5-8.5"/> |
| <path d="M12 2v10H2" stroke-opacity="0.5" stroke-dasharray="2 2"/> |
| </svg> |
| <span>OPENRA<span class="rl">-RL</span></span> |
| </a> |
| <div class="nav-links"> |
| <a href="/try" style="color:#ef4444;font-weight:700;">TRY</a> |
| <a href="https://openra-rl.dev/docs/getting-started">DOCS</a> |
| <a href="/docs">API</a> |
| <a href="https://github.com/yxc20089/OpenRA-RL">GITHUB</a> |
| </div> |
| </div> |
| </nav> |
| |
| <!-- Hero --> |
| <section class="hero crt-flicker fade-in"> |
| <div class="subtitle terminal-text"> |
| <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M4 17l6-6-6-6"/><path d="M12 19h8"/></svg> |
| SYSTEM OVERRIDE ACTIVE |
| </div> |
| <h1 class="alert-text">OPENRA-RL</h1> |
| <div class="desc"> |
| OpenEnv environment for training AI agents to play |
| <strong>Red Alert</strong> through the OpenRA engine. |
| Connect via WebSocket or HTTP, send actions, observe the battlefield. |
| </div> |
| <div class="buttons"> |
| <a href="/try" class="btn-soviet"> |
| WATCH AI PLAY |
| <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polygon points="5 3 19 12 5 21 5 3"/></svg> |
| </a> |
| <a href="https://openra-rl.dev/docs/getting-started" class="btn-ghost"> |
| DOCUMENTATION |
| <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M9 18l6-6-6-6"/></svg> |
| </a> |
| <a href="https://openra-rl-openra-bench.hf.space" class="btn-ghost"> |
| LEADERBOARD |
| <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M6 9l6-6 6 6"/><path d="M12 3v18"/><path d="M3 21h18"/></svg> |
| </a> |
| </div> |
| </section> |
| |
| <!-- Endpoint cards --> |
| <div class="section"> |
| <h2>Endpoints</h2> |
| <div class="cards-grid"> |
| <div class="card-military"> |
| <h3>API DOCS</h3> |
| <p>Interactive Swagger UI with all REST and WebSocket endpoints.</p> |
| <a href="/docs">/docs →</a> |
| </div> |
| <div class="card-military"> |
| <h3>HEALTH CHECK</h3> |
| <p>Server status and readiness probe for monitoring.</p> |
| <a href="/health">/health →</a> |
| </div> |
| <div class="card-military"> |
| <h3>ENV SCHEMA</h3> |
| <p>JSON schemas for actions, observations, and game state.</p> |
| <a href="/schema">/schema →</a> |
| </div> |
| </div> |
| </div> |
| |
| <!-- Terminal / code section --> |
| <div class="section-dark"> |
| <div class="section"> |
| <div class="terminal-section"> |
| <div> |
| <h2>Connect to Environment</h2> |
| <p style="color:#9ca3af;line-height:1.7;margin-bottom:1.5rem;"> |
| Use the Python client to connect, reset the environment, |
| and step through the game loop. Works with both local |
| Docker and this HuggingFace-hosted server. |
| </p> |
| <a href="https://openra-rl.dev/docs/api-reference" class="btn-soviet" style="font-size:1.3rem;"> |
| <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M4 19V5a2 2 0 012-2h13l3 3v13a2 2 0 01-2 2H6a2 2 0 01-2-2z"/><path d="M9 3v4h4"/></svg> |
| API REFERENCE |
| </a> |
| </div> |
| <div class="terminal"> |
| <div class="terminal-bar"> |
| <div class="terminal-dot" style="background:#ef4444;"></div> |
| <div class="terminal-dot" style="background:#eab308;"></div> |
| <div class="terminal-dot" style="background:#22c55e;"></div> |
| <span>terminal</span> |
| </div> |
| <div class="terminal-body"> |
| <pre><span class="t-prompt">$ </span><span class="t-cmd">pip install</span><span class="t-plain"> openra-rl</span> |
| |
| <span class="t-kw">from</span><span class="t-plain"> openra_env.client </span><span class="t-kw">import</span><span class="t-plain"> OpenRAEnv</span> |
| <span class="t-kw">from</span><span class="t-plain"> openra_env.models </span><span class="t-kw">import</span><span class="t-plain"> OpenRAAction</span> |
| |
| <span class="t-plain">url = </span><span class="t-str">"https://openra-rl-openra-rl.hf.space"</span> |
| |
| <span class="t-kw">async with</span><span class="t-plain"> </span><span class="t-fn">OpenRAEnv</span><span class="t-plain">(url) </span><span class="t-kw">as</span><span class="t-plain"> env:</span> |
| <span class="t-plain"> obs = </span><span class="t-kw">await</span><span class="t-plain"> env.</span><span class="t-fn">reset</span><span class="t-plain">()</span> |
| <span class="t-plain"> </span><span class="t-kw">while not</span><span class="t-plain"> obs.done:</span> |
| <span class="t-plain"> action = your_agent(obs)</span> |
| <span class="t-plain"> obs = </span><span class="t-kw">await</span><span class="t-plain"> env.</span><span class="t-fn">step</span><span class="t-plain">(action)</span></pre> |
| </div> |
| </div> |
| </div> |
| </div> |
| </div> |
| |
| <!-- Footer --> |
| <footer> |
| <div class="footer-inner"> |
| <div class="footer-brand"> |
| <div class="logo"> |
| <svg viewBox="0 0 24 24" fill="none" stroke="#dc2626" stroke-width="1.5" stroke-linecap="square" stroke-linejoin="miter" width="28" height="28"> |
| <circle cx="12" cy="12" r="10"/> |
| <circle cx="12" cy="12" r="6" stroke-opacity="0.5"/> |
| <circle cx="12" cy="12" r="2" fill="#dc2626"/> |
| <path d="M12 12l8.5-8.5"/> |
| </svg> |
| OPENRA<span class="rl">-RL</span> |
| </div> |
| <div class="footer-status"> |
| > SYSTEM STATUS: OPERATIONAL<br> |
| > MISSION: TRAIN AI TO CONQUER<br> |
| > © 2025 OPENRA-RL CONTRIBUTORS. |
| </div> |
| </div> |
| <div class="footer-col"> |
| <h3>Intel</h3> |
| <ul> |
| <li><a href="https://openra-rl.dev/docs/getting-started"><span class="chevron"></span>Getting Started</a></li> |
| <li><a href="https://openra-rl.dev/docs/architecture"><span class="chevron"></span>Architecture</a></li> |
| <li><a href="https://openra-rl.dev/docs/api-reference"><span class="chevron"></span>API Reference</a></li> |
| </ul> |
| </div> |
| <div class="footer-col"> |
| <h3>Alliances</h3> |
| <ul> |
| <li><a href="https://www.openra.net/"><span class="chevron"></span>OpenRA Engine</a></li> |
| <li><a href="https://huggingface.co/openra-rl"><span class="chevron"></span>HuggingFace</a></li> |
| <li><a href="https://openra-rl-openra-bench.hf.space"><span class="chevron"></span>Leaderboard</a></li> |
| </ul> |
| </div> |
| </div> |
| </footer> |
| </body> |
| </html>""" |
|
|
|
|
| @app.get("/", response_class=HTMLResponse) |
| async def root(): |
| """Landing page for the HuggingFace Space.""" |
| return LANDING_PAGE |
|
|
|
|
| |
|
|
| TRY_PAGE = """\ |
| <!DOCTYPE html> |
| <html lang="en"> |
| <head> |
| <meta charset="utf-8"> |
| <meta name="viewport" content="width=device-width, initial-scale=1.0"> |
| <title>Try — Watch AI Play Red Alert</title> |
| <link rel="preconnect" href="https://fonts.googleapis.com"> |
| <link href="https://fonts.googleapis.com/css2?family=Share+Tech+Mono&family=Teko:wght@400;600;700&display=swap" rel="stylesheet"> |
| <style> |
| *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; } |
| body { |
| font-family: 'Share Tech Mono', monospace; |
| background: radial-gradient(circle at center, #1a0505 0%, #050505 100%); |
| color: #d1d5db; |
| min-height: 100vh; |
| } |
| a { color: #d1d5db; text-decoration: none; transition: color .2s; } |
| a:hover { color: #fff; } |
| h1, h2, h3, .font-teko { |
| font-family: 'Teko', sans-serif; |
| letter-spacing: 1px; |
| text-transform: uppercase; |
| } |
| .scanlines { |
| background: linear-gradient(to bottom, rgba(255,255,255,0), rgba(255,255,255,0) 50%, rgba(0,0,0,0.2) 50%, rgba(0,0,0,0.2)); |
| background-size: 100% 4px; |
| position: fixed; inset: 0; pointer-events: none; z-index: 50; |
| } |
| .terminal-text { color: #84cc16; text-shadow: 0 0 5px rgba(132,204,22,.5); } |
| .alert-text { color: #ef4444; text-shadow: 0 0 8px rgba(239,68,68,.8); } |
| |
| nav { |
| border-bottom: 2px solid #991b1b; |
| background: rgba(0,0,0,.9); |
| position: sticky; top: 0; z-index: 40; |
| backdrop-filter: blur(4px); |
| } |
| .nav-inner { |
| max-width: 72rem; margin: 0 auto; padding: 0 1.5rem; |
| display: flex; align-items: center; justify-content: space-between; height: 4rem; |
| } |
| .nav-logo { display: flex; align-items: center; gap: .6rem; } |
| .nav-logo svg { width: 2rem; height: 2rem; color: #dc2626; animation: spin 4s linear infinite; } |
| @keyframes spin { to { transform: rotate(360deg); } } |
| .nav-logo span { font-family: 'Teko', sans-serif; font-size: 1.8rem; font-weight: 700; color: #fff; letter-spacing: .15em; } |
| .nav-logo .rl { color: #dc2626; } |
| .nav-links { display: flex; gap: 1.5rem; align-items: center; } |
| .nav-links a { font-family: 'Teko', sans-serif; font-size: 1.15rem; letter-spacing: .1em; color: #9ca3af; text-transform: uppercase; } |
| .nav-links a:hover { color: #fff; } |
| |
| .container { max-width: 56rem; margin: 0 auto; padding: 2rem 1.5rem; } |
| .header { text-align: center; margin-bottom: 2rem; } |
| .header h1 { font-size: 2.5rem; color: #fff; margin-bottom: .5rem; } |
| .header p { color: #9ca3af; font-size: .95rem; } |
| |
| .controls { |
| display: flex; gap: 1rem; align-items: center; justify-content: center; |
| margin-bottom: 1.5rem; flex-wrap: wrap; |
| } |
| .controls select { |
| font-family: 'Share Tech Mono', monospace; |
| background: #121212; color: #d1d5db; border: 2px solid #525252; |
| padding: .5rem 1rem; font-size: 1rem; cursor: pointer; |
| } |
| .controls select:hover { border-color: #737373; } |
| .btn-soviet { |
| display: inline-flex; align-items: center; gap: .5rem; |
| background: #dc2626; border: 2px solid #f87171; |
| box-shadow: 4px 4px 0 #000; transition: all .1s; |
| color: #fff; font-family: 'Teko', sans-serif; |
| font-size: 1.6rem; padding: .4rem 1.8rem; |
| text-transform: uppercase; cursor: pointer; |
| } |
| .btn-soviet:hover { transform: translate(2px,2px); box-shadow: 2px 2px 0 #000; background: #ef4444; } |
| .btn-soviet:disabled { opacity: .5; cursor: not-allowed; transform: none; box-shadow: 4px 4px 0 #000; } |
| |
| .game-log { |
| background: #0a0a0a; border: 2px solid #262626; border-left: 4px solid #dc2626; |
| padding: 1rem 1.2rem; font-size: .82rem; line-height: 1.7; |
| height: 420px; overflow-y: auto; white-space: pre-wrap; word-break: break-word; |
| margin-bottom: 1.5rem; |
| } |
| .game-log .log-status { color: #84cc16; } |
| .game-log .log-turn { color: #facc15; } |
| .game-log .log-llm { color: #c084fc; } |
| .game-log .log-tool { color: #38bdf8; } |
| .game-log .log-state { color: #6b7280; } |
| .game-log .log-done { color: #ef4444; font-weight: bold; } |
| .game-log .log-error { color: #ef4444; } |
| .game-log .log-commentary { color: #f59e0b; font-style: italic; padding-left: 1em; border-left: 2px solid #f59e0b; margin: 2px 0; } |
| |
| .scorecard { |
| background: #121212; border: 2px solid #262626; padding: 1.5rem; |
| display: none; |
| } |
| .scorecard h2 { color: #fff; font-size: 1.8rem; margin-bottom: 1rem; } |
| .scorecard table { width: 100%; border-collapse: collapse; } |
| .scorecard td { padding: .4rem .8rem; border-bottom: 1px solid #1a1a1a; font-size: .85rem; } |
| .scorecard td:first-child { color: #9ca3af; } |
| .scorecard td:last-child { color: #fff; text-align: right; } |
| .scorecard .result-win { color: #22c55e; font-size: 1.2rem; font-weight: bold; } |
| .scorecard .result-loss { color: #ef4444; font-size: 1.2rem; font-weight: bold; } |
| |
| footer { |
| background: #000; border-top: 2px solid #7f1d1d; |
| margin-top: 3rem; padding: 1.5rem; |
| text-align: center; font-size: .8rem; color: #6b7280; |
| } |
| </style> |
| </head> |
| <body> |
| <div class="scanlines"></div> |
| |
| <nav> |
| <div class="nav-inner"> |
| <a href="/" class="nav-logo"> |
| <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="square" stroke-linejoin="miter"> |
| <circle cx="12" cy="12" r="10"/><circle cx="12" cy="12" r="6" stroke-opacity="0.5"/> |
| <circle cx="12" cy="12" r="2" fill="currentColor"/><path d="M12 12l8.5-8.5"/> |
| <path d="M12 2v10H2" stroke-opacity="0.5" stroke-dasharray="2 2"/> |
| </svg> |
| <span>OPENRA<span class="rl">-RL</span></span> |
| </a> |
| <div class="nav-links"> |
| <a href="/try" style="color:#ef4444;font-weight:700;">TRY</a> |
| <a href="https://openra-rl.dev/docs/getting-started">DOCS</a> |
| <a href="/docs">API</a> |
| <a href="https://github.com/yxc20089/OpenRA-RL">GITHUB</a> |
| </div> |
| </div> |
| </nav> |
| |
| <div class="container"> |
| <div class="header"> |
| <h1 class="alert-text">Watch AI Play</h1> |
| <p>A pre-configured LLM agent plays Red Alert against the built-in AI. No setup needed.</p> |
| </div> |
| |
| <div class="controls"> |
| <select id="opponent"> |
| <option value="Easy">Easy</option> |
| <option value="Normal" selected>Normal</option> |
| <option value="Hard">Hard</option> |
| </select> |
| <button id="playBtn" class="btn-soviet" onclick="startGame()"> |
| <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polygon points="5 3 19 12 5 21 5 3"/></svg> |
| WATCH AI PLAY |
| </button> |
| </div> |
| |
| <div id="gameLog" class="game-log">Waiting to start...\n</div> |
| |
| <div id="scorecard" class="scorecard"> |
| <h2>Scorecard</h2> |
| <table id="scorecardTable"></table> |
| </div> |
| </div> |
| |
| <footer>© 2025 OpenRA-RL Contributors — <a href="/">Home</a></footer> |
| |
| <script> |
| let eventSource = null; |
| |
| function log(msg, cls) { |
| const el = document.getElementById('gameLog'); |
| const span = document.createElement('span'); |
| span.className = cls || ''; |
| span.textContent = msg + '\\n'; |
| el.appendChild(span); |
| el.scrollTop = el.scrollHeight; |
| } |
| |
| function startGame() { |
| const btn = document.getElementById('playBtn'); |
| const logEl = document.getElementById('gameLog'); |
| const scorecard = document.getElementById('scorecard'); |
| const opponent = document.getElementById('opponent').value; |
| |
| // Reset UI |
| logEl.innerHTML = ''; |
| scorecard.style.display = 'none'; |
| btn.disabled = true; |
| document.getElementById('opponent').disabled = true; |
| btn.innerHTML = '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><path d="M12 6v6l4 2"/></svg> WATCHING...'; |
| |
| log('Connecting to game server...', 'log-status'); |
| |
| fetch('/try-agent?opponent=' + encodeURIComponent(opponent)) |
| .then(response => { |
| if (!response.ok) { |
| log('Server error: ' + response.status, 'log-error'); |
| resetBtn(); |
| return; |
| } |
| |
| const reader = response.body.getReader(); |
| const decoder = new TextDecoder(); |
| let buffer = ''; |
| let currentEvent = ''; |
| |
| function processChunk() { |
| reader.read().then(({done, value}) => { |
| if (done) { |
| resetBtn(); |
| return; |
| } |
| |
| buffer += decoder.decode(value, {stream: true}); |
| const lines = buffer.split('\\n'); |
| buffer = lines.pop(); |
| |
| for (const line of lines) { |
| if (line.startsWith('event: ')) { |
| currentEvent = line.slice(7).trim(); |
| } else if (line.startsWith('data: ')) { |
| try { |
| const data = JSON.parse(line.slice(6)); |
| handleEvent(currentEvent, data); |
| } catch(e) {} |
| } |
| } |
| |
| processChunk(); |
| }).catch(err => { |
| log('Connection lost: ' + err.message, 'log-error'); |
| resetBtn(); |
| }); |
| } |
| |
| processChunk(); |
| }) |
| .catch(err => { |
| log('Failed to connect: ' + err.message, 'log-error'); |
| resetBtn(); |
| }); |
| } |
| |
| function handleEvent(type, data) { |
| switch(type) { |
| case 'status': |
| log(data.message, 'log-status'); |
| break; |
| case 'turn': |
| log('[Turn ' + data.turn + '] API calls: ' + data.api_calls + ' | ' + data.elapsed + 's', 'log-turn'); |
| break; |
| case 'llm': |
| if (data.content) { |
| const text = data.content.length > 300 ? data.content.slice(0, 300) + '...' : data.content; |
| log(' AI: ' + text, 'log-llm'); |
| } |
| break; |
| case 'tool_call': |
| log(' >> ' + data.name + '(' + (data.args || '') + ')', 'log-tool'); |
| break; |
| case 'game_state': |
| log(' tick=' + data.tick + ' units=' + data.units + ' buildings=' + data.buildings + ' $' + data.cash, 'log-state'); |
| break; |
| case 'done': |
| log('\\nGAME OVER: ' + (data.result || '?').toUpperCase() + ' (tick ' + data.tick + ')', 'log-done'); |
| break; |
| case 'final': |
| showScorecard(data); |
| resetBtn(); |
| break; |
| case 'commentary': |
| if (data.text) { |
| log(' [COMMENTARY] ' + data.text, 'log-commentary'); |
| } |
| break; |
| case 'error_event': |
| log('Error: ' + (data.message || 'Unknown'), 'log-error'); |
| break; |
| } |
| } |
| |
| function showScorecard(data) { |
| const sc = document.getElementById('scorecard'); |
| const tbl = document.getElementById('scorecardTable'); |
| const result = (data.result || 'ongoing').toUpperCase(); |
| const cls = result === 'WIN' ? 'result-win' : 'result-loss'; |
| |
| tbl.innerHTML = |
| '<tr><td>Result</td><td class="' + cls + '">' + result + '</td></tr>' + |
| '<tr><td>Game Ticks</td><td>' + data.tick + '</td></tr>' + |
| '<tr><td>LLM Turns</td><td>' + data.turns + '</td></tr>' + |
| '<tr><td>Tool Calls</td><td>' + data.tool_calls + '</td></tr>' + |
| '<tr><td>Duration</td><td>' + data.elapsed + 's</td></tr>' + |
| '<tr><td>Units Killed</td><td>' + data.units_killed + '</td></tr>' + |
| '<tr><td>Units Lost</td><td>' + data.units_lost + '</td></tr>' + |
| '<tr><td>Kill Value</td><td>$' + data.kills_cost + '</td></tr>' + |
| '<tr><td>Death Value</td><td>$' + data.deaths_cost + '</td></tr>' + |
| '<tr><td>Cash Remaining</td><td>$' + data.cash + '</td></tr>' + |
| '<tr><td>Own Units</td><td>' + data.units + '</td></tr>' + |
| '<tr><td>Own Buildings</td><td>' + data.buildings + '</td></tr>'; |
| |
| sc.style.display = 'block'; |
| } |
| |
| function resetBtn() { |
| const btn = document.getElementById('playBtn'); |
| btn.disabled = false; |
| btn.innerHTML = '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polygon points="5 3 19 12 5 21 5 3"/></svg> WATCH AI PLAY'; |
| document.getElementById('opponent').disabled = false; |
| } |
| |
| // Auto-connect if a game is currently running |
| fetch('/try-status') |
| .then(r => r.json()) |
| .then(status => { |
| if (status.game_running) { |
| if (status.opponent) { |
| document.getElementById('opponent').value = status.opponent; |
| } |
| startGame(); |
| } |
| }) |
| .catch(() => {}); |
| </script> |
| </body> |
| </html>""" |
|
|
|
|
| @app.get("/try-status") |
| async def try_status(): |
| """Check if a game is currently running.""" |
| return { |
| "game_running": _broadcaster.game_running, |
| "opponent": _broadcaster._opponent if _broadcaster.game_running else "", |
| } |
|
|
|
|
| @app.get("/try", response_class=HTMLResponse) |
| async def try_page(): |
| """Interactive page to watch an LLM agent play Red Alert.""" |
| return TRY_PAGE |
|
|
|
|
| def main(): |
| import uvicorn |
|
|
| uvicorn.run( |
| app, |
| host="0.0.0.0", |
| port=8000, |
| ws_ping_interval=None, |
| ws_ping_timeout=None, |
| ) |
|
|
|
|
| if __name__ == "__main__": |
| main() |
|
|