github-actions[bot] commited on
Commit
02f4a63
ยท
0 Parent(s):

Sync from GitHub ac82c3e

Browse files
This view is limited to 50 files because it contains too many changes. ย  See raw diff
Files changed (50) hide show
  1. .dockerignore +21 -0
  2. .github/workflows/ci.yml +32 -0
  3. .github/workflows/docker-publish.yml +52 -0
  4. .github/workflows/pypi-publish.yml +26 -0
  5. .github/workflows/sync-to-hf.yml +25 -0
  6. .gitignore +19 -0
  7. .gitmodules +3 -0
  8. .openenvignore +28 -0
  9. Dockerfile +149 -0
  10. Dockerfile.agent +32 -0
  11. LICENSE +674 -0
  12. OpenRA +1 -0
  13. README.md +479 -0
  14. __init__.py +4 -0
  15. client.py +3 -0
  16. config.yaml +142 -0
  17. docker-compose.yaml +71 -0
  18. docker/build.sh +51 -0
  19. docker/entrypoint.sh +30 -0
  20. docker/replay-viewer.sh +89 -0
  21. examples/README.md +50 -0
  22. examples/config-lmstudio.yaml +14 -0
  23. examples/config-minimal.yaml +21 -0
  24. examples/config-ollama.yaml +14 -0
  25. examples/config-openrouter.yaml +13 -0
  26. examples/llm_agent.py +170 -0
  27. examples/mcp_bot.py +619 -0
  28. examples/scripted_bot.py +831 -0
  29. models.py +7 -0
  30. openenv.yaml +6 -0
  31. openra_env/__init__.py +6 -0
  32. openra_env/agent.py +1156 -0
  33. openra_env/bench_export.py +95 -0
  34. openra_env/bench_submit.py +167 -0
  35. openra_env/cli/__init__.py +0 -0
  36. openra_env/cli/commands.py +464 -0
  37. openra_env/cli/console.py +43 -0
  38. openra_env/cli/docker_manager.py +600 -0
  39. openra_env/cli/main.py +212 -0
  40. openra_env/cli/wizard.py +166 -0
  41. openra_env/client.py +113 -0
  42. openra_env/config.py +535 -0
  43. openra_env/game_data.py +984 -0
  44. openra_env/generated/__init__.py +0 -0
  45. openra_env/generated/rl_bridge_pb2.py +61 -0
  46. openra_env/generated/rl_bridge_pb2_grpc.py +148 -0
  47. openra_env/mcp_server.py +454 -0
  48. openra_env/mcp_ws_client.py +231 -0
  49. openra_env/models.py +222 -0
  50. openra_env/opponent_intel.py +263 -0
.dockerignore ADDED
@@ -0,0 +1,21 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ .git
2
+ .github
3
+ .pytest_cache
4
+ __pycache__
5
+ *.pyc
6
+ *.pyo
7
+ *.egg-info
8
+ .eggs
9
+ dist
10
+ build
11
+ .mypy_cache
12
+ .ruff_cache
13
+ .venv
14
+ venv
15
+ documents/
16
+ tests/
17
+ docs/
18
+ *.pdf
19
+ .claude/
20
+ # OpenRA submodule (cloned from GitHub during Docker build)
21
+ OpenRA/
.github/workflows/ci.yml ADDED
@@ -0,0 +1,32 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ name: CI
2
+
3
+ on:
4
+ push:
5
+ branches: [main]
6
+ pull_request:
7
+ branches: [main]
8
+
9
+ jobs:
10
+ test:
11
+ runs-on: ubuntu-latest
12
+ strategy:
13
+ matrix:
14
+ python-version: ["3.10", "3.11", "3.12"]
15
+
16
+ steps:
17
+ - uses: actions/checkout@v4
18
+ with:
19
+ submodules: recursive
20
+
21
+ - uses: actions/setup-python@v5
22
+ with:
23
+ python-version: ${{ matrix.python-version }}
24
+
25
+ - name: Install dependencies
26
+ run: pip install -e ".[dev]"
27
+
28
+ - name: Run tests
29
+ run: pytest tests/ -v
30
+
31
+ - name: Lint
32
+ run: ruff check openra_env/
.github/workflows/docker-publish.yml ADDED
@@ -0,0 +1,52 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ name: Docker Publish
2
+
3
+ on:
4
+ push:
5
+ tags: ["v*"]
6
+ release:
7
+ types: [published]
8
+
9
+ env:
10
+ REGISTRY: ghcr.io
11
+ IMAGE_NAME: ${{ github.repository }}
12
+
13
+ jobs:
14
+ build-and-push:
15
+ runs-on: ubuntu-latest
16
+ permissions:
17
+ contents: read
18
+ packages: write
19
+
20
+ steps:
21
+ - uses: actions/checkout@v4
22
+ with:
23
+ submodules: recursive
24
+
25
+ - uses: docker/setup-qemu-action@v3
26
+
27
+ - uses: docker/setup-buildx-action@v3
28
+
29
+ - uses: docker/login-action@v3
30
+ with:
31
+ registry: ${{ env.REGISTRY }}
32
+ username: ${{ github.actor }}
33
+ password: ${{ secrets.GITHUB_TOKEN }}
34
+
35
+ - uses: docker/metadata-action@v5
36
+ id: meta
37
+ with:
38
+ images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
39
+ tags: |
40
+ type=semver,pattern={{version}}
41
+ type=semver,pattern={{major}}.{{minor}}
42
+ type=raw,value=latest,enable={{is_default_branch}}
43
+
44
+ - uses: docker/build-push-action@v6
45
+ with:
46
+ context: .
47
+ platforms: linux/amd64,linux/arm64
48
+ push: true
49
+ tags: ${{ steps.meta.outputs.tags }}
50
+ labels: ${{ steps.meta.outputs.labels }}
51
+ cache-from: type=gha
52
+ cache-to: type=gha,mode=max
.github/workflows/pypi-publish.yml ADDED
@@ -0,0 +1,26 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ name: PyPI Publish
2
+
3
+ on:
4
+ release:
5
+ types: [published]
6
+
7
+ permissions:
8
+ id-token: write
9
+
10
+ jobs:
11
+ publish:
12
+ runs-on: ubuntu-latest
13
+
14
+ steps:
15
+ - uses: actions/checkout@v4
16
+
17
+ - uses: actions/setup-python@v5
18
+ with:
19
+ python-version: "3.12"
20
+
21
+ - name: Build package
22
+ run: |
23
+ pip install build
24
+ python -m build
25
+
26
+ - uses: pypa/gh-action-pypi-publish@release/v1
.github/workflows/sync-to-hf.yml ADDED
@@ -0,0 +1,25 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ name: Sync to Hugging Face Space
2
+ on:
3
+ push:
4
+ branches: [main]
5
+ workflow_dispatch:
6
+
7
+ jobs:
8
+ sync-to-hub:
9
+ runs-on: ubuntu-latest
10
+ steps:
11
+ - uses: actions/checkout@v4
12
+ with:
13
+ fetch-depth: 1
14
+ lfs: true
15
+ - name: Push to Hugging Face Space
16
+ env:
17
+ HF_TOKEN: ${{ secrets.HF_TOKEN }}
18
+ run: |
19
+ git config user.name "github-actions[bot]"
20
+ git config user.email "github-actions[bot]@users.noreply.github.com"
21
+ # Create an orphan branch with just the current tree (no history)
22
+ git checkout --orphan hf-sync
23
+ git commit -m "Sync from GitHub ${GITHUB_SHA::7}"
24
+ git remote add hf https://openra-rl:$HF_TOKEN@huggingface.co/spaces/openra-rl/OpenRA-RL
25
+ git push hf hf-sync:main --force
.gitignore ADDED
@@ -0,0 +1,19 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ __pycache__/
2
+ *.py[cod]
3
+ *$py.class
4
+ *.egg-info/
5
+ dist/
6
+ build/
7
+ .eggs/
8
+ *.egg
9
+ .venv/
10
+ venv/
11
+ .env
12
+ *.log
13
+ .pytest_cache/
14
+ .ruff_cache/
15
+ .mypy_cache/
16
+ .DS_Store
17
+ replays/
18
+ documents/
19
+ *.orarep
.gitmodules ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ [submodule "OpenRA"]
2
+ path = OpenRA
3
+ url = https://github.com/yxc20089/OpenRA.git
.openenvignore ADDED
@@ -0,0 +1,28 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Build artifacts (Dockerfile builds fresh from source)
2
+ OpenRA/bin/
3
+ OpenRA/obj/
4
+
5
+ # Replay files
6
+ *.orarep
7
+ replays/
8
+
9
+ # Log files
10
+ *.log
11
+
12
+ # Documents
13
+ documents/
14
+
15
+ # Dev/test artifacts
16
+ .pytest_cache/
17
+ .ruff_cache/
18
+ .mypy_cache/
19
+ .venv/
20
+ venv/
21
+ .env
22
+ .eggs/
23
+ *.egg-info/
24
+ dist/
25
+ build/
26
+
27
+ # IDE
28
+ .DS_Store
Dockerfile ADDED
@@ -0,0 +1,149 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # ==============================================================================
2
+ # Stage 1: Build OpenRA from source (C#/.NET 8.0)
3
+ # ==============================================================================
4
+ FROM mcr.microsoft.com/dotnet/sdk:8.0-bookworm-slim AS openra-build
5
+
6
+ RUN apt-get update && apt-get install -y --no-install-recommends \
7
+ make \
8
+ git \
9
+ libsdl2-dev \
10
+ libopenal-dev \
11
+ libfreetype-dev \
12
+ liblua5.1-0-dev \
13
+ ca-certificates \
14
+ && rm -rf /var/lib/apt/lists/*
15
+
16
+ # Clone OpenRA source from GitHub (works on HF Spaces where submodules aren't initialized)
17
+ ARG OPENRA_REPO=https://github.com/yxc20089/OpenRA.git
18
+ RUN git clone --depth=1 "$OPENRA_REPO" /src/openra
19
+ WORKDIR /src/openra
20
+
21
+ # Fix Windows CRLF line endings in shell scripts (git autocrlf on Windows adds \r)
22
+ RUN find . -name '*.sh' -exec sed -i 's/\r$//' {} + && \
23
+ find . -name '*.sh' -exec chmod +x {} +
24
+
25
+ # Build with system libraries (unix-generic avoids bundled native binaries)
26
+ # SKIP_PROTOC=true uses pre-generated protobuf C# files (avoids protoc arm64 crash in Docker)
27
+ ENV SKIP_PROTOC=true
28
+ RUN make TARGETPLATFORM=unix-generic CONFIGURATION=Release
29
+
30
+ # Verify critical output (includes Null platform for headless RL operation)
31
+ RUN test -f bin/OpenRA.dll && \
32
+ test -f bin/OpenRA.Game.dll && \
33
+ test -f bin/OpenRA.Mods.Common.dll && \
34
+ test -f bin/OpenRA.Platforms.Null.dll
35
+
36
+ # ==============================================================================
37
+ # Stage 2: Install Python dependencies
38
+ # ==============================================================================
39
+ FROM python:3.11-slim-bookworm AS python-build
40
+
41
+ RUN apt-get update && apt-get install -y --no-install-recommends \
42
+ build-essential \
43
+ && rm -rf /var/lib/apt/lists/*
44
+
45
+ WORKDIR /app
46
+ COPY pyproject.toml /app/
47
+ COPY openra_env/ /app/openra_env/
48
+ COPY proto/ /app/proto/
49
+ COPY README.md /app/
50
+
51
+ RUN pip install --upgrade pip && \
52
+ pip install --no-cache-dir .
53
+
54
+ # ==============================================================================
55
+ # Stage 3: Runtime image
56
+ # ==============================================================================
57
+ FROM mcr.microsoft.com/dotnet/aspnet:8.0-bookworm-slim AS dotnet-runtime
58
+
59
+ FROM python:3.11-slim-bookworm
60
+
61
+ LABEL maintainer="OpenRA-RL"
62
+ LABEL description="OpenRA RL Environment - headless game engine with gRPC bridge + OpenEnv API"
63
+
64
+ # Copy ASP.NET Core runtime from official Microsoft image
65
+ COPY --from=dotnet-runtime /usr/share/dotnet /usr/share/dotnet
66
+ RUN ln -s /usr/share/dotnet/dotnet /usr/bin/dotnet
67
+
68
+ # Install runtime dependencies
69
+ RUN apt-get update && apt-get install -y --no-install-recommends \
70
+ xvfb \
71
+ libgl1-mesa-dri \
72
+ libgl1-mesa-glx \
73
+ libegl-mesa0 \
74
+ mesa-vulkan-drivers \
75
+ libvulkan1 \
76
+ libsdl2-2.0-0 \
77
+ libopenal1 \
78
+ libfreetype6 \
79
+ liblua5.1-0 \
80
+ libicu72 \
81
+ curl procps \
82
+ x11vnc novnc websockify \
83
+ && rm -rf /var/lib/apt/lists/*
84
+
85
+ # Copy Python packages from builder
86
+ COPY --from=python-build /usr/local/lib/python3.11/site-packages /usr/local/lib/python3.11/site-packages
87
+ COPY --from=python-build /usr/local/bin /usr/local/bin
88
+
89
+ # Copy built OpenRA (bin, mods, glsl shaders, and global mix database for content resolution)
90
+ COPY --from=openra-build /src/openra/bin /opt/openra/bin
91
+ COPY --from=openra-build /src/openra/mods /opt/openra/mods
92
+ COPY --from=openra-build /src/openra/glsl /opt/openra/glsl
93
+ COPY --from=openra-build ["/src/openra/global mix database.dat", "/opt/openra/global mix database.dat"]
94
+
95
+ # Create native library symlinks that OpenRA expects
96
+ # (configure-system-libraries.sh points these to system lib paths)
97
+ RUN LIBDIR=$( [ "$(dpkg --print-architecture)" = "arm64" ] && echo "/usr/lib/aarch64-linux-gnu" || echo "/usr/lib/x86_64-linux-gnu" ) && \
98
+ ln -sf "$LIBDIR/libSDL2-2.0.so.0" /opt/openra/bin/SDL2.so && \
99
+ ln -sf "$LIBDIR/libopenal.so.1" /opt/openra/bin/soft_oal.so && \
100
+ ln -sf "$LIBDIR/libfreetype.so.6" /opt/openra/bin/freetype6.so && \
101
+ ln -sf "$LIBDIR/liblua5.1.so.0" /opt/openra/bin/lua51.so
102
+
103
+ # Copy Python application code
104
+ COPY openra_env/ /app/openra_env/
105
+ COPY proto/ /app/proto/
106
+ COPY pyproject.toml /app/
107
+
108
+ # Create OpenRA support directory and pre-install RA game content (best-effort).
109
+ # Only needed for the replay viewer (Game.Platform=Default with full UI).
110
+ # The RL environment works without this content (headless mode).
111
+ RUN mkdir -p /root/.config/openra/Content/ra/v2/expand /root/.config/openra/Content/ra/v2/cnc && \
112
+ ( curl -sfL --max-time 30 -o /tmp/ra-quickinstall.zip \
113
+ https://openra.baxxster.no/openra/ra-quickinstall.zip && \
114
+ apt-get update && apt-get install -y --no-install-recommends unzip && \
115
+ unzip -o /tmp/ra-quickinstall.zip -d /tmp/ra-content && \
116
+ cp /tmp/ra-content/*.mix /root/.config/openra/Content/ra/v2/ && \
117
+ cp /tmp/ra-content/expand/* /root/.config/openra/Content/ra/v2/expand/ && \
118
+ cp /tmp/ra-content/cnc/* /root/.config/openra/Content/ra/v2/cnc/ && \
119
+ rm -rf /tmp/ra-quickinstall.zip /tmp/ra-content && \
120
+ apt-get purge -y unzip && apt-get autoremove -y && rm -rf /var/lib/apt/lists/* \
121
+ ) || echo "WARNING: RA content download failed (replay viewer will be unavailable)"
122
+
123
+ # Copy entrypoints (fix Windows CRLF line endings)
124
+ COPY docker/entrypoint.sh /entrypoint.sh
125
+ COPY docker/replay-viewer.sh /replay-viewer.sh
126
+ RUN sed -i 's/\r$//' /entrypoint.sh /replay-viewer.sh && \
127
+ chmod +x /entrypoint.sh /replay-viewer.sh
128
+
129
+ # Environment
130
+ ENV OPENRA_PATH=/opt/openra
131
+ ENV PYTHONPATH=/app
132
+ ENV PYTHONUNBUFFERED=1
133
+ ENV DISPLAY=:99
134
+ ENV DOTNET_CLI_TELEMETRY_OPTOUT=1
135
+ ENV DOTNET_ROLL_FORWARD=LatestMajor
136
+ ENV LIBGL_ALWAYS_SOFTWARE=1
137
+ ENV MESA_GL_VERSION_OVERRIDE=3.3
138
+ # Game configuration (override at runtime with -e)
139
+ ENV AI_SLOT=Multi0
140
+ ENV BOT_TYPE=normal
141
+ ENV RECORD_REPLAYS=true
142
+
143
+ EXPOSE 8000
144
+
145
+ HEALTHCHECK --interval=30s --timeout=5s --start-period=30s --retries=3 \
146
+ CMD curl -f http://localhost:8000/health || exit 1
147
+
148
+ ENTRYPOINT ["/entrypoint.sh"]
149
+ CMD ["python", "-m", "openra_env.server.app"]
Dockerfile.agent ADDED
@@ -0,0 +1,32 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # ==============================================================================
2
+ # Lightweight agent container for OpenRA-RL
3
+ #
4
+ # Runs the LLM agent (or MCP bot) that connects to the OpenRA-RL game server.
5
+ # Does NOT include the game engine โ€” only the Python client and agent code.
6
+ #
7
+ # Usage:
8
+ # docker build -f Dockerfile.agent -t openra-rl-agent .
9
+ # docker run -e OPENROUTER_API_KEY=sk-or-... openra-rl-agent
10
+ # ==============================================================================
11
+ FROM python:3.11-slim-bookworm
12
+
13
+ LABEL description="OpenRA-RL Agent - LLM/MCP bot that plays Red Alert"
14
+
15
+ WORKDIR /app
16
+
17
+ # Install Python dependencies
18
+ COPY pyproject.toml README.md /app/
19
+ COPY openra_env/ /app/openra_env/
20
+ COPY proto/ /app/proto/
21
+
22
+ RUN pip install --no-cache-dir --upgrade pip && \
23
+ pip install --no-cache-dir . httpx
24
+
25
+ # Copy agent scripts
26
+ COPY examples/ /app/examples/
27
+
28
+ ENV PYTHONPATH=/app
29
+ ENV PYTHONUNBUFFERED=1
30
+
31
+ # Default: run LLM agent
32
+ CMD ["python", "examples/llm_agent.py"]
LICENSE ADDED
@@ -0,0 +1,674 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ GNU GENERAL PUBLIC LICENSE
2
+ Version 3, 29 June 2007
3
+
4
+ Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
5
+ Everyone is permitted to copy and distribute verbatim copies
6
+ of this license document, but changing it is not allowed.
7
+
8
+ Preamble
9
+
10
+ The GNU General Public License is a free, copyleft license for
11
+ software and other kinds of works.
12
+
13
+ The licenses for most software and other practical works are designed
14
+ to take away your freedom to share and change the works. By contrast,
15
+ the GNU General Public License is intended to guarantee your freedom to
16
+ share and change all versions of a program--to make sure it remains free
17
+ software for all its users. We, the Free Software Foundation, use the
18
+ GNU General Public License for most of our software; it applies also to
19
+ any other work released this way by its authors. You can apply it to
20
+ your programs, too.
21
+
22
+ When we speak of free software, we are referring to freedom, not
23
+ price. Our General Public Licenses are designed to make sure that you
24
+ have the freedom to distribute copies of free software (and charge for
25
+ them if you wish), that you receive source code or can get it if you
26
+ want it, that you can change the software or use pieces of it in new
27
+ free programs, and that you know you can do these things.
28
+
29
+ To protect your rights, we need to prevent others from denying you
30
+ these rights or asking you to surrender the rights. Therefore, you have
31
+ certain responsibilities if you distribute copies of the software, or if
32
+ you modify it: responsibilities to respect the freedom of others.
33
+
34
+ For example, if you distribute copies of such a program, whether
35
+ gratis or for a fee, you must pass on to the recipients the same
36
+ freedoms that you received. You must make sure that they, too, receive
37
+ or can get the source code. And you must show them these terms so they
38
+ know their rights.
39
+
40
+ Developers that use the GNU GPL protect your rights with two steps:
41
+ (1) assert copyright on the software, and (2) offer you this License
42
+ giving you legal permission to copy, distribute and/or modify it.
43
+
44
+ For the developers' and authors' protection, the GPL clearly explains
45
+ that there is no warranty for this free software. For both users' and
46
+ authors' sake, the GPL requires that modified versions be marked as
47
+ changed, so that their problems will not be attributed erroneously to
48
+ authors of previous versions.
49
+
50
+ Some devices are designed to deny users access to install or run
51
+ modified versions of the software inside them, although the manufacturer
52
+ can do so. This is fundamentally incompatible with the aim of
53
+ protecting users' freedom to change the software. The systematic
54
+ pattern of such abuse occurs in the area of products for individuals to
55
+ use, which is precisely where it is most unacceptable. Therefore, we
56
+ have designed this version of the GPL to prohibit the practice for those
57
+ products. If such problems arise substantially in other domains, we
58
+ stand ready to extend this provision to those domains in future versions
59
+ of the GPL, as needed to protect the freedom of users.
60
+
61
+ Finally, every program is threatened constantly by software patents.
62
+ States should not allow patents to restrict development and use of
63
+ software on general-purpose computers, but in those that do, we wish to
64
+ avoid the special danger that patents applied to a free program could
65
+ make it effectively proprietary. To prevent this, the GPL assures that
66
+ patents cannot be used to render the program non-free.
67
+
68
+ The precise terms and conditions for copying, distribution and
69
+ modification follow.
70
+
71
+ TERMS AND CONDITIONS
72
+
73
+ 0. Definitions.
74
+
75
+ "This License" refers to version 3 of the GNU General Public License.
76
+
77
+ "Copyright" also means copyright-like laws that apply to other kinds of
78
+ works, such as semiconductor masks.
79
+
80
+ "The Program" refers to any copyrightable work licensed under this
81
+ License. Each licensee is addressed as "you". "Licensees" and
82
+ "recipients" may be individuals or organizations.
83
+
84
+ To "modify" a work means to copy from or adapt all or part of the work
85
+ in a fashion requiring copyright permission, other than the making of an
86
+ exact copy. The resulting work is called a "modified version" of the
87
+ earlier work or a work "based on" the earlier work.
88
+
89
+ A "covered work" means either the unmodified Program or a work based
90
+ on the Program.
91
+
92
+ To "propagate" a work means to do anything with it that, without
93
+ permission, would make you directly or secondarily liable for
94
+ infringement under applicable copyright law, except executing it on a
95
+ computer or modifying a private copy. Propagation includes copying,
96
+ distribution (with or without modification), making available to the
97
+ public, and in some countries other activities as well.
98
+
99
+ To "convey" a work means any kind of propagation that enables other
100
+ parties to make or receive copies. Mere interaction with a user through
101
+ a computer network, with no transfer of a copy, is not conveying.
102
+
103
+ An interactive user interface displays "Appropriate Legal Notices"
104
+ to the extent that it includes a convenient and prominently visible
105
+ feature that (1) displays an appropriate copyright notice, and (2)
106
+ tells the user that there is no warranty for the work (except to the
107
+ extent that warranties are provided), that licensees may convey the
108
+ work under this License, and how to view a copy of this License. If
109
+ the interface presents a list of user commands or options, such as a
110
+ menu, a prominent item in the list meets this criterion.
111
+
112
+ 1. Source Code.
113
+
114
+ The "source code" for a work means the preferred form of the work
115
+ for making modifications to it. "Object code" means any non-source
116
+ form of a work.
117
+
118
+ A "Standard Interface" means an interface that either is an official
119
+ standard defined by a recognized standards body, or, in the case of
120
+ interfaces specified for a particular programming language, one that
121
+ is widely used among developers working in that language.
122
+
123
+ The "System Libraries" of an executable work include anything, other
124
+ than the work as a whole, that (a) is included in the normal form of
125
+ packaging a Major Component, but which is not part of that Major
126
+ Component, and (b) serves only to enable use of the work with that
127
+ Major Component, or to implement a Standard Interface for which an
128
+ implementation is available to the public in source code form. A
129
+ "Major Component", in this context, means a major essential component
130
+ (kernel, window system, and so on) of the specific operating system
131
+ (if any) on which the executable work runs, or a compiler used to
132
+ produce the work, or an object code interpreter used to run it.
133
+
134
+ The "Corresponding Source" for a work in object code form means all
135
+ the source code needed to generate, install, and (for an executable
136
+ work) run the object code and to modify the work, including scripts to
137
+ control those activities. However, it does not include the work's
138
+ System Libraries, or general-purpose tools or generally available free
139
+ programs which are used unmodified in performing those activities but
140
+ which are not part of the work. For example, Corresponding Source
141
+ includes interface definition files associated with source files for
142
+ the work, and the source code for shared libraries and dynamically
143
+ linked subprograms that the work is specifically designed to require,
144
+ such as by intimate data communication or control flow between those
145
+ subprograms and other parts of the work.
146
+
147
+ The Corresponding Source need not include anything that users
148
+ can regenerate automatically from other parts of the Corresponding
149
+ Source.
150
+
151
+ The Corresponding Source for a work in source code form is that
152
+ same work.
153
+
154
+ 2. Basic Permissions.
155
+
156
+ All rights granted under this License are granted for the term of
157
+ copyright on the Program, and are irrevocable provided the stated
158
+ conditions are met. This License explicitly affirms your unlimited
159
+ permission to run the unmodified Program. The output from running a
160
+ covered work is covered by this License only if the output, given its
161
+ content, constitutes a covered work. This License acknowledges your
162
+ rights of fair use or other equivalent, as provided by copyright law.
163
+
164
+ You may make, run and propagate covered works that you do not
165
+ convey, without conditions so long as your license otherwise remains
166
+ in force. You may convey covered works to others for the sole purpose
167
+ of having them make modifications exclusively for you, or provide you
168
+ with facilities for running those works, provided that you comply with
169
+ the terms of this License in conveying all material for which you do
170
+ not control copyright. Those thus making or running the covered works
171
+ for you must do so exclusively on your behalf, under your direction
172
+ and control, on terms that prohibit them from making any copies of
173
+ your copyrighted material outside their relationship with you.
174
+
175
+ Conveying under any other circumstances is permitted solely under
176
+ the conditions stated below. Sublicensing is not allowed; section 10
177
+ makes it unnecessary.
178
+
179
+ 3. Protecting Users' Legal Rights From Anti-Circumvention Law.
180
+
181
+ No covered work shall be deemed part of an effective technological
182
+ measure under any applicable law fulfilling obligations under article
183
+ 11 of the WIPO copyright treaty adopted on 20 December 1996, or
184
+ similar laws prohibiting or restricting circumvention of such
185
+ measures.
186
+
187
+ When you convey a covered work, you waive any legal power to forbid
188
+ circumvention of technological measures to the extent such circumvention
189
+ is effected by exercising rights under this License with respect to
190
+ the covered work, and you disclaim any intention to limit operation or
191
+ modification of the work as a means of enforcing, against the work's
192
+ users, your or third parties' legal rights to forbid circumvention of
193
+ technological measures.
194
+
195
+ 4. Conveying Verbatim Copies.
196
+
197
+ You may convey verbatim copies of the Program's source code as you
198
+ receive it, in any medium, provided that you conspicuously and
199
+ appropriately publish on each copy an appropriate copyright notice;
200
+ keep intact all notices stating that this License and any
201
+ non-permissive terms added in accord with section 7 apply to the code;
202
+ keep intact all notices of the absence of any warranty; and give all
203
+ recipients a copy of this License along with the Program.
204
+
205
+ You may charge any price or no price for each copy that you convey,
206
+ and you may offer support or warranty protection for a fee.
207
+
208
+ 5. Conveying Modified Source Versions.
209
+
210
+ You may convey a work based on the Program, or the modifications to
211
+ produce it from the Program, in the form of source code under the
212
+ terms of section 4, provided that you also meet all of these conditions:
213
+
214
+ a) The work must carry prominent notices stating that you modified
215
+ it, and giving a relevant date.
216
+
217
+ b) The work must carry prominent notices stating that it is
218
+ released under this License and any conditions added under section
219
+ 7. This requirement modifies the requirement in section 4 to
220
+ "keep intact all notices".
221
+
222
+ c) You must license the entire work, as a whole, under this
223
+ License to anyone who comes into possession of a copy. This
224
+ License will therefore apply, along with any applicable section 7
225
+ additional terms, to the whole of the work, and all its parts,
226
+ regardless of how they are packaged. This License gives no
227
+ permission to license the work in any other way, but it does not
228
+ invalidate such permission if you have separately received it.
229
+
230
+ d) If the work has interactive user interfaces, each must display
231
+ Appropriate Legal Notices; however, if the Program has interactive
232
+ interfaces that do not display Appropriate Legal Notices, your
233
+ work need not make them do so.
234
+
235
+ A compilation of a covered work with other separate and independent
236
+ works, which are not by their nature extensions of the covered work,
237
+ and which are not combined with it such as to form a larger program,
238
+ in or on a volume of a storage or distribution medium, is called an
239
+ "aggregate" if the compilation and its resulting copyright are not
240
+ used to limit the access or legal rights of the compilation's users
241
+ beyond what the individual works permit. Inclusion of a covered work
242
+ in an aggregate does not cause this License to apply to the other
243
+ parts of the aggregate.
244
+
245
+ 6. Conveying Non-Source Forms.
246
+
247
+ You may convey a covered work in object code form under the terms
248
+ of sections 4 and 5, provided that you also convey the
249
+ machine-readable Corresponding Source under the terms of this License,
250
+ in one of these ways:
251
+
252
+ a) Convey the object code in, or embodied in, a physical product
253
+ (including a physical distribution medium), accompanied by the
254
+ Corresponding Source fixed on a durable physical medium
255
+ customarily used for software interchange.
256
+
257
+ b) Convey the object code in, or embodied in, a physical product
258
+ (including a physical distribution medium), accompanied by a
259
+ written offer, valid for at least three years and valid for as
260
+ long as you offer spare parts or customer support for that product
261
+ model, to give anyone who possesses the object code either (1) a
262
+ copy of the Corresponding Source for all the software in the
263
+ product that is covered by this License, on a durable physical
264
+ medium customarily used for software interchange, for a price no
265
+ more than your reasonable cost of physically performing this
266
+ conveying of source, or (2) access to copy the
267
+ Corresponding Source from a network server at no charge.
268
+
269
+ c) Convey individual copies of the object code with a copy of the
270
+ written offer to provide the Corresponding Source. This
271
+ alternative is allowed only occasionally and noncommercially, and
272
+ only if you received the object code with such an offer, in accord
273
+ with subsection 6b.
274
+
275
+ d) Convey the object code by offering access from a designated
276
+ place (gratis or for a charge), and offer equivalent access to the
277
+ Corresponding Source in the same way through the same place at no
278
+ further charge. You need not require recipients to copy the
279
+ Corresponding Source along with the object code. If the place to
280
+ copy the object code is a network server, the Corresponding Source
281
+ may be on a different server (operated by you or a third party)
282
+ that supports equivalent copying facilities, provided you maintain
283
+ clear directions next to the object code saying where to find the
284
+ Corresponding Source. Regardless of what server hosts the
285
+ Corresponding Source, you remain obligated to ensure that it is
286
+ available for as long as needed to satisfy these requirements.
287
+
288
+ e) Convey the object code using peer-to-peer transmission, provided
289
+ you inform other peers where the object code and Corresponding
290
+ Source of the work are being offered to the general public at no
291
+ charge under subsection 6d.
292
+
293
+ A separable portion of the object code, whose source code is excluded
294
+ from the Corresponding Source as a System Library, need not be
295
+ included in conveying the object code work.
296
+
297
+ A "User Product" is either (1) a "consumer product", which means any
298
+ tangible personal property which is normally used for personal, family,
299
+ or household purposes, or (2) anything designed or sold for incorporation
300
+ into a dwelling. In determining whether a product is a consumer product,
301
+ doubtful cases shall be resolved in favor of coverage. For a particular
302
+ product received by a particular user, "normally used" refers to a
303
+ typical or common use of that class of product, regardless of the status
304
+ of the particular user or of the way in which the particular user
305
+ actually uses, or expects or is expected to use, the product. A product
306
+ is a consumer product regardless of whether the product has substantial
307
+ commercial, industrial or non-consumer uses, unless such uses represent
308
+ the only significant mode of use of the product.
309
+
310
+ "Installation Information" for a User Product means any methods,
311
+ procedures, authorization keys, or other information required to install
312
+ and execute modified versions of a covered work in that User Product from
313
+ a modified version of its Corresponding Source. The information must
314
+ suffice to ensure that the continued functioning of the modified object
315
+ code is in no case prevented or interfered with solely because
316
+ modification has been made.
317
+
318
+ If you convey an object code work under this section in, or with, or
319
+ specifically for use in, a User Product, and the conveying occurs as
320
+ part of a transaction in which the right of possession and use of the
321
+ User Product is transferred to the recipient in perpetuity or for a
322
+ fixed term (regardless of how the transaction is characterized), the
323
+ Corresponding Source conveyed under this section must be accompanied
324
+ by the Installation Information. But this requirement does not apply
325
+ if neither you nor any third party retains the ability to install
326
+ modified object code on the User Product (for example, the work has
327
+ been installed in ROM).
328
+
329
+ The requirement to provide Installation Information does not include a
330
+ requirement to continue to provide support service, warranty, or updates
331
+ for a work that has been modified or installed by the recipient, or for
332
+ the User Product in which it has been modified or installed. Access to a
333
+ network may be denied when the modification itself materially and
334
+ adversely affects the operation of the network or violates the rules and
335
+ protocols for communication across the network.
336
+
337
+ Corresponding Source conveyed, and Installation Information provided,
338
+ in accord with this section must be in a format that is publicly
339
+ documented (and with an implementation available to the public in
340
+ source code form), and must require no special password or key for
341
+ unpacking, reading or copying.
342
+
343
+ 7. Additional Terms.
344
+
345
+ "Additional permissions" are terms that supplement the terms of this
346
+ License by making exceptions from one or more of its conditions.
347
+ Additional permissions that are applicable to the entire Program shall
348
+ be treated as though they were included in this License, to the extent
349
+ that they are valid under applicable law. If additional permissions
350
+ apply only to part of the Program, that part may be used separately
351
+ under those permissions, but the entire Program remains governed by
352
+ this License without regard to the additional permissions.
353
+
354
+ When you convey a copy of a covered work, you may at your option
355
+ remove any additional permissions from that copy, or from any part of
356
+ it. (Additional permissions may be written to require their own
357
+ removal in certain cases when you modify the work.) You may place
358
+ additional permissions on material, added by you to a covered work,
359
+ for which you have or can give appropriate copyright permission.
360
+
361
+ Notwithstanding any other provision of this License, for material you
362
+ add to a covered work, you may (if authorized by the copyright holders of
363
+ that material) supplement the terms of this License with terms:
364
+
365
+ a) Disclaiming warranty or limiting liability differently from the
366
+ terms of sections 15 and 16 of this License; or
367
+
368
+ b) Requiring preservation of specified reasonable legal notices or
369
+ author attributions in that material or in the Appropriate Legal
370
+ Notices displayed by works containing it; or
371
+
372
+ c) Prohibiting misrepresentation of the origin of that material, or
373
+ requiring that modified versions of such material be marked in
374
+ reasonable ways as different from the original version; or
375
+
376
+ d) Limiting the use for publicity purposes of names of licensors or
377
+ authors of the material; or
378
+
379
+ e) Declining to grant rights under trademark law for use of some
380
+ trade names, trademarks, or service marks; or
381
+
382
+ f) Requiring indemnification of licensors and authors of that
383
+ material by anyone who conveys the material (or modified versions of
384
+ it) with contractual assumptions of liability to the recipient, for
385
+ any liability that these contractual assumptions directly impose on
386
+ those licensors and authors.
387
+
388
+ All other non-permissive additional terms are considered "further
389
+ restrictions" within the meaning of section 10. If the Program as you
390
+ received it, or any part of it, contains a notice stating that it is
391
+ governed by this License along with a term that is a further
392
+ restriction, you may remove that term. If a license document contains
393
+ a further restriction but permits relicensing or conveying under this
394
+ License, you may add to a covered work material governed by the terms
395
+ of that license document, provided that the further restriction does
396
+ not survive such relicensing or conveying.
397
+
398
+ If you add terms to a covered work in accord with this section, you
399
+ must place, in the relevant source files, a statement of the
400
+ additional terms that apply to those files, or a notice indicating
401
+ where to find the applicable terms.
402
+
403
+ Additional terms, permissive or non-permissive, may be stated in the
404
+ form of a separately written license, or stated as exceptions;
405
+ the above requirements apply either way.
406
+
407
+ 8. Termination.
408
+
409
+ You may not propagate or modify a covered work except as expressly
410
+ provided under this License. Any attempt otherwise to propagate or
411
+ modify it is void, and will automatically terminate your rights under
412
+ this License (including any patent licenses granted under the third
413
+ paragraph of section 11).
414
+
415
+ However, if you cease all violation of this License, then your
416
+ license from a particular copyright holder is reinstated (a)
417
+ provisionally, unless and until the copyright holder explicitly and
418
+ finally terminates your license, and (b) permanently, if the copyright
419
+ holder fails to notify you of the violation by some reasonable means
420
+ prior to 60 days after the cessation.
421
+
422
+ Moreover, your license from a particular copyright holder is
423
+ reinstated permanently if the copyright holder notifies you of the
424
+ violation by some reasonable means, this is the first time you have
425
+ received notice of violation of this License (for any work) from that
426
+ copyright holder, and you cure the violation prior to 30 days after
427
+ your receipt of the notice.
428
+
429
+ Termination of your rights under this section does not terminate the
430
+ licenses of parties who have received copies or rights from you under
431
+ this License. If your rights have been terminated and not permanently
432
+ reinstated, you do not qualify to receive new licenses for the same
433
+ material under section 10.
434
+
435
+ 9. Acceptance Not Required for Having Copies.
436
+
437
+ You are not required to accept this License in order to receive or
438
+ run a copy of the Program. Ancillary propagation of a covered work
439
+ occurring solely as a consequence of using peer-to-peer transmission
440
+ to receive a copy likewise does not require acceptance. However,
441
+ nothing other than this License grants you permission to propagate or
442
+ modify any covered work. These actions infringe copyright if you do
443
+ not accept this License. Therefore, by modifying or propagating a
444
+ covered work, you indicate your acceptance of this License to do so.
445
+
446
+ 10. Automatic Licensing of Downstream Recipients.
447
+
448
+ Each time you convey a covered work, the recipient automatically
449
+ receives a license from the original licensors, to run, modify and
450
+ propagate that work, subject to this License. You are not responsible
451
+ for enforcing compliance by third parties with this License.
452
+
453
+ An "entity transaction" is a transaction transferring control of an
454
+ organization, or substantially all assets of one, or subdividing an
455
+ organization, or merging organizations. If propagation of a covered
456
+ work results from an entity transaction, each party to that
457
+ transaction who receives a copy of the work also receives whatever
458
+ licenses to the work the party's predecessor in interest had or could
459
+ give under the previous paragraph, plus a right to possession of the
460
+ Corresponding Source of the work from the predecessor in interest, if
461
+ the predecessor has it or can get it with reasonable efforts.
462
+
463
+ You may not impose any further restrictions on the exercise of the
464
+ rights granted or affirmed under this License. For example, you may
465
+ not impose a license fee, royalty, or other charge for exercise of
466
+ rights granted under this License, and you may not initiate litigation
467
+ (including a cross-claim or counterclaim in a lawsuit) alleging that
468
+ any patent claim is infringed by making, using, selling, offering for
469
+ sale, or importing the Program or any portion of it.
470
+
471
+ 11. Patents.
472
+
473
+ A "contributor" is a copyright holder who authorizes use under this
474
+ License of the Program or a work on which the Program is based. The
475
+ work thus licensed is called the contributor's "contributor version".
476
+
477
+ A contributor's "essential patent claims" are all patent claims
478
+ owned or controlled by the contributor, whether already acquired or
479
+ hereafter acquired, that would be infringed by some manner, permitted
480
+ by this License, of making, using, or selling its contributor version,
481
+ but do not include claims that would be infringed only as a
482
+ consequence of further modification of the contributor version. For
483
+ purposes of this definition, "control" includes the right to grant
484
+ patent sublicenses in a manner consistent with the requirements of
485
+ this License.
486
+
487
+ Each contributor grants you a non-exclusive, worldwide, royalty-free
488
+ patent license under the contributor's essential patent claims, to
489
+ make, use, sell, offer for sale, import and otherwise run, modify and
490
+ propagate the contents of its contributor version.
491
+
492
+ In the following three paragraphs, a "patent license" is any express
493
+ agreement or commitment, however denominated, not to enforce a patent
494
+ (such as an express permission to practice a patent or covenant not to
495
+ sue for patent infringement). To "grant" such a patent license to a
496
+ party means to make such an agreement or commitment not to enforce a
497
+ patent against the party.
498
+
499
+ If you convey a covered work, knowingly relying on a patent license,
500
+ and the Corresponding Source of the work is not available for anyone
501
+ to copy, free of charge and under the terms of this License, through a
502
+ publicly available network server or other readily accessible means,
503
+ then you must either (1) cause the Corresponding Source to be so
504
+ available, or (2) arrange to deprive yourself of the benefit of the
505
+ patent license for this particular work, or (3) arrange, in a manner
506
+ consistent with the requirements of this License, to extend the patent
507
+ license to downstream recipients. "Knowingly relying" means you have
508
+ actual knowledge that, but for the patent license, your conveying the
509
+ covered work in a country, or your recipient's use of the covered work
510
+ in a country, would infringe one or more identifiable patents in that
511
+ country that you have reason to believe are valid.
512
+
513
+ If, pursuant to or in connection with a single transaction or
514
+ arrangement, you convey, or propagate by procuring conveyance of, a
515
+ covered work, and grant a patent license to some of the parties
516
+ receiving the covered work authorizing them to use, propagate, modify
517
+ or convey a specific copy of the covered work, then the patent license
518
+ you grant is automatically extended to all recipients of the covered
519
+ work and works based on it.
520
+
521
+ A patent license is "discriminatory" if it does not include within
522
+ the scope of its coverage, prohibits the exercise of, or is
523
+ conditioned on the non-exercise of one or more of the rights that are
524
+ specifically granted under this License. You may not convey a covered
525
+ work if you are a party to an arrangement with a third party that is
526
+ in the business of distributing software, under which you make payment
527
+ to the third party based on the extent of your activity of conveying
528
+ the work, and under which the third party grants, to any of the
529
+ parties who would receive the covered work from you, a discriminatory
530
+ patent license (a) in connection with copies of the covered work
531
+ conveyed by you (or copies made from those copies), or (b) primarily
532
+ for and in connection with specific products or compilations that
533
+ contain the covered work, unless you entered into that arrangement,
534
+ or that patent license was granted, prior to 28 March 2007.
535
+
536
+ Nothing in this License shall be construed as excluding or limiting
537
+ any implied license or other defenses to infringement that may
538
+ otherwise be available to you under applicable patent law.
539
+
540
+ 12. No Surrender of Others' Freedom.
541
+
542
+ If conditions are imposed on you (whether by court order, agreement or
543
+ otherwise) that contradict the conditions of this License, they do not
544
+ excuse you from the conditions of this License. If you cannot convey a
545
+ covered work so as to satisfy simultaneously your obligations under this
546
+ License and any other pertinent obligations, then as a consequence you may
547
+ not convey it at all. For example, if you agree to terms that obligate you
548
+ to collect a royalty for further conveying from those to whom you convey
549
+ the Program, the only way you could satisfy both those terms and this
550
+ License would be to refrain entirely from conveying the Program.
551
+
552
+ 13. Use with the GNU Affero General Public License.
553
+
554
+ Notwithstanding any other provision of this License, you have
555
+ permission to link or combine any covered work with a work licensed
556
+ under version 3 of the GNU Affero General Public License into a single
557
+ combined work, and to convey the resulting work. The terms of this
558
+ License will continue to apply to the part which is the covered work,
559
+ but the special requirements of the GNU Affero General Public License,
560
+ section 13, concerning interaction through a network will apply to the
561
+ combination as such.
562
+
563
+ 14. Revised Versions of this License.
564
+
565
+ The Free Software Foundation may publish revised and/or new versions of
566
+ the GNU General Public License from time to time. Such new versions will
567
+ be similar in spirit to the present version, but may differ in detail to
568
+ address new problems or concerns.
569
+
570
+ Each version is given a distinguishing version number. If the
571
+ Program specifies that a certain numbered version of the GNU General
572
+ Public License "or any later version" applies to it, you have the
573
+ option of following the terms and conditions either of that numbered
574
+ version or of any later version published by the Free Software
575
+ Foundation. If the Program does not specify a version number of the
576
+ GNU General Public License, you may choose any version ever published
577
+ by the Free Software Foundation.
578
+
579
+ If the Program specifies that a proxy can decide which future
580
+ versions of the GNU General Public License can be used, that proxy's
581
+ public statement of acceptance of a version permanently authorizes you
582
+ to choose that version for the Program.
583
+
584
+ Later license versions may give you additional or different
585
+ permissions. However, no additional obligations are imposed on any
586
+ author or copyright holder as a result of your choosing to follow a
587
+ later version.
588
+
589
+ 15. Disclaimer of Warranty.
590
+
591
+ THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
592
+ APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
593
+ HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
594
+ OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
595
+ THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
596
+ PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
597
+ IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
598
+ ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
599
+
600
+ 16. Limitation of Liability.
601
+
602
+ IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
603
+ WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
604
+ THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
605
+ GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
606
+ USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
607
+ DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
608
+ PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
609
+ EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
610
+ SUCH DAMAGES.
611
+
612
+ 17. Interpretation of Sections 15 and 16.
613
+
614
+ If the disclaimer of warranty and limitation of liability provided
615
+ above cannot be given local legal effect according to their terms,
616
+ reviewing courts shall apply local law that most closely approximates
617
+ an absolute waiver of all civil liability in connection with the
618
+ Program, unless a warranty or assumption of liability accompanies a
619
+ copy of the Program in return for a fee.
620
+
621
+ END OF TERMS AND CONDITIONS
622
+
623
+ How to Apply These Terms to Your New Programs
624
+
625
+ If you develop a new program, and you want it to be of the greatest
626
+ possible use to the public, the best way to achieve this is to make it
627
+ free software which everyone can redistribute and change under these terms.
628
+
629
+ To do so, attach the following notices to the program. It is safest
630
+ to attach them to the start of each source file to most effectively
631
+ state the exclusion of warranty; and each file should have at least
632
+ the "copyright" line and a pointer to where the full notice is found.
633
+
634
+ <one line to give the program's name and a brief idea of what it does.>
635
+ Copyright (C) <year> <name of author>
636
+
637
+ This program is free software: you can redistribute it and/or modify
638
+ it under the terms of the GNU General Public License as published by
639
+ the Free Software Foundation, either version 3 of the License, or
640
+ (at your option) any later version.
641
+
642
+ This program is distributed in the hope that it will be useful,
643
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
644
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
645
+ GNU General Public License for more details.
646
+
647
+ You should have received a copy of the GNU General Public License
648
+ along with this program. If not, see <https://www.gnu.org/licenses/>.
649
+
650
+ Also add information on how to contact you by electronic and paper mail.
651
+
652
+ If the program does terminal interaction, make it output a short
653
+ notice like this when it starts in an interactive mode:
654
+
655
+ <program> Copyright (C) <year> <name of author>
656
+ This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
657
+ This is free software, and you are welcome to redistribute it
658
+ under certain conditions; type `show c' for details.
659
+
660
+ The hypothetical commands `show w' and `show c' should show the appropriate
661
+ parts of the General Public License. Of course, your program's commands
662
+ might be different; for a GUI interface, you would use an "about box".
663
+
664
+ You should also get your employer (if you work as a programmer) or school,
665
+ if any, to sign a "copyright disclaimer" for the program, if necessary.
666
+ For more information on this, and how to apply and follow the GNU GPL, see
667
+ <https://www.gnu.org/licenses/>.
668
+
669
+ The GNU General Public License does not permit incorporating your program
670
+ into proprietary programs. If your program is a subroutine library, you
671
+ may consider it more useful to permit linking proprietary applications with
672
+ the library. If this is what you want to do, use the GNU Lesser General
673
+ Public License instead of this License. But first, please read
674
+ <https://www.gnu.org/licenses/why-not-lgpl.html>.
OpenRA ADDED
@@ -0,0 +1 @@
 
 
1
+ Subproject commit de92f675141c8ceff6621417ce74f82497765698
README.md ADDED
@@ -0,0 +1,479 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ title: OpenRA-RL
3
+ emoji: ๐ŸŽฎ
4
+ colorFrom: red
5
+ colorTo: blue
6
+ sdk: docker
7
+ app_port: 8000
8
+ tags:
9
+ - openenv
10
+ - reinforcement-learning
11
+ - rts
12
+ models: []
13
+ datasets: []
14
+ pinned: false
15
+ ---
16
+
17
+ # OpenRA-RL
18
+
19
+ Play [Red Alert](https://www.openra.net/) with AI agents. LLMs, scripted bots, or RL โ€” your agent commands armies in the classic RTS through a Python API.
20
+
21
+ ```
22
+ โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” HTTP / WS :8000 โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”
23
+ โ”‚ Your Agent โ”‚ โ—„โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ–บ โ”‚ OpenRA-RL Server (Docker) โ”‚
24
+ โ”‚ โ”‚ gRPC :9999 โ”‚ FastAPI + gRPC bridge โ”‚
25
+ โ”‚ LLM / Bot / RL โ”‚ โ—„โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ–บ โ”‚ OpenRA engine (headless) โ”‚
26
+ โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜
27
+ ```
28
+
29
+ ## Quick Start
30
+
31
+ ```bash
32
+ pip install openra-rl
33
+ openra-rl play
34
+ ```
35
+
36
+ On first run, an interactive wizard helps you configure your LLM provider (OpenRouter, Ollama, or LM Studio). The CLI pulls the game server Docker image and starts everything automatically.
37
+
38
+ ### Skip the wizard
39
+
40
+ ```bash
41
+ # Cloud (OpenRouter)
42
+ openra-rl play --provider openrouter --api-key sk-or-... --model anthropic/claude-sonnet-4-20250514
43
+
44
+ # Local (Ollama โ€” free, no API key)
45
+ openra-rl play --provider ollama --model qwen3:32b
46
+
47
+ # Developer mode (skip Docker, run server locally)
48
+ openra-rl play --local --provider ollama --model qwen3:32b
49
+
50
+ # Reconfigure later
51
+ openra-rl config
52
+ ```
53
+
54
+ ### Prerequisites
55
+
56
+ - **Docker** โ€” the game server runs in a container
57
+ - **Python 3.10+**
58
+ - An LLM endpoint (cloud API key or local model server)
59
+
60
+ ## CLI Reference
61
+
62
+ ```
63
+ openra-rl play Run the LLM agent (wizard on first use)
64
+ openra-rl config Re-run the setup wizard
65
+ openra-rl server start | stop | status | logs
66
+ openra-rl replay watch | list | copy | stop
67
+ openra-rl bench submit Upload results to the leaderboard
68
+ openra-rl mcp-server Start MCP stdio server (for OpenClaw / Claude Desktop)
69
+ openra-rl doctor Check system prerequisites
70
+ openra-rl version Print version
71
+ ```
72
+
73
+ ## MCP Server (OpenClaw / Claude Desktop)
74
+
75
+ OpenRA-RL exposes all 48 game tools as a standard MCP server:
76
+
77
+ ```bash
78
+ openra-rl mcp-server
79
+ ```
80
+
81
+ Add to your MCP client config (e.g. `~/.openclaw/openclaw.json`):
82
+
83
+ ```json
84
+ {
85
+ "mcpServers": {
86
+ "openra-rl": {
87
+ "command": "openra-rl",
88
+ "args": ["mcp-server"]
89
+ }
90
+ }
91
+ }
92
+ ```
93
+
94
+ Then chat: _"Start a game of Red Alert on easy difficulty, build a base, and defeat the enemy."_
95
+
96
+ ## Architecture
97
+
98
+ | Component | Language | Role |
99
+ |-----------|----------|------|
100
+ | **OpenRA-RL** | Python | Environment wrapper, agents, HTTP/WebSocket API |
101
+ | **OpenRA** (submodule) | C# | Modified game engine with embedded gRPC server |
102
+ | **OpenEnv** (pip dep) | Python | Standardized Gymnasium-style environment interface |
103
+
104
+ **Data flow:** Agent <-> FastAPI (port 8000) <-> gRPC bridge (port 9999) <-> OpenRA game engine
105
+
106
+ The game runs at ~25 ticks/sec independent of agent speed. Observations use a DropOldest channel so the agent always sees the latest game state, even if it's slower than real time.
107
+
108
+ ## Example Agents
109
+
110
+ ### Scripted Bot
111
+
112
+ A hardcoded state-machine bot that demonstrates all action types. Deploys MCV, builds a base, trains infantry, and attacks.
113
+
114
+ ```bash
115
+ python examples/scripted_bot.py --url http://localhost:8000 --verbose --max-steps 2000
116
+ ```
117
+
118
+ ### MCP Bot
119
+
120
+ A planning-aware bot that uses game knowledge tools (tech tree lookups, faction briefings, map analysis) to formulate strategy before playing.
121
+
122
+ ```bash
123
+ python examples/mcp_bot.py --url http://localhost:8000 --verbose --max-turns 3000
124
+ ```
125
+
126
+ ### LLM Agent
127
+
128
+ An AI agent powered by any OpenAI-compatible model. Supports cloud APIs (OpenRouter, OpenAI) and local model servers (Ollama, LM Studio).
129
+
130
+ ```bash
131
+ python examples/llm_agent.py \
132
+ --config examples/config-openrouter.yaml \
133
+ --api-key sk-or-... \
134
+ --verbose \
135
+ --log-file game.log
136
+ ```
137
+
138
+ CLI flags override config file values. See `python examples/llm_agent.py --help` for all options.
139
+
140
+ ## Configuration
141
+
142
+ OpenRA-RL uses a unified YAML config system. Settings are resolved with this precedence:
143
+
144
+ **CLI flags > Environment variables > Config file > Built-in defaults**
145
+
146
+ ### Config file
147
+
148
+ Copy and edit the default config:
149
+
150
+ ```bash
151
+ cp config.yaml my-config.yaml
152
+ # Edit my-config.yaml, then:
153
+ python examples/llm_agent.py --config my-config.yaml
154
+ ```
155
+
156
+ Key sections:
157
+
158
+ ```yaml
159
+ game:
160
+ openra_path: "/opt/openra" # Path to OpenRA installation
161
+ map_name: "singles.oramap" # Map to play
162
+ headless: true # No GPU rendering
163
+ record_replays: false # Save .orarep replay files
164
+
165
+ opponent:
166
+ bot_type: "normal" # AI difficulty: easy, normal, hard
167
+ ai_slot: "Multi0" # AI player slot
168
+
169
+ planning:
170
+ enabled: true # Pre-game planning phase
171
+ max_turns: 10 # Max planning turns
172
+ max_time_s: 60.0 # Planning time limit
173
+
174
+ llm:
175
+ base_url: "https://openrouter.ai/api/v1/chat/completions"
176
+ model: "qwen/qwen3-coder-next"
177
+ max_tokens: 1500
178
+ temperature: null # null = provider default
179
+
180
+ tools:
181
+ categories: # Toggle tool groups on/off
182
+ read: true
183
+ knowledge: true
184
+ movement: true
185
+ production: true
186
+ # ... see config.yaml for all categories
187
+ disabled: [] # Disable specific tools by name
188
+
189
+ alerts:
190
+ under_attack: true
191
+ low_power: true
192
+ idle_production: true
193
+ no_scouting: true
194
+ # ... see config.yaml for all alerts
195
+ ```
196
+
197
+ ### Example configs
198
+
199
+ | File | Use case |
200
+ |------|----------|
201
+ | `examples/config-openrouter.yaml` | Cloud LLM via OpenRouter (Claude, GPT, etc.) |
202
+ | `examples/config-ollama.yaml` | Local LLM via Ollama |
203
+ | `examples/config-lmstudio.yaml` | Local LLM via LM Studio |
204
+ | `examples/config-minimal.yaml` | Reduced tool set for limited-context models |
205
+
206
+ ### Environment variables
207
+
208
+ | Variable | Config path | Description |
209
+ |----------|-------------|-------------|
210
+ | `OPENROUTER_API_KEY` | `llm.api_key` | API key for OpenRouter |
211
+ | `LLM_API_KEY` | `llm.api_key` | Generic LLM API key (overrides OpenRouter key) |
212
+ | `LLM_BASE_URL` | `llm.base_url` | LLM endpoint URL |
213
+ | `LLM_MODEL` | `llm.model` | Model identifier |
214
+ | `BOT_TYPE` | `opponent.bot_type` | AI difficulty: easy, normal, hard |
215
+ | `OPENRA_PATH` | `game.openra_path` | Path to OpenRA installation |
216
+ | `RECORD_REPLAYS` | `game.record_replays` | Save replay files (true/false) |
217
+ | `PLANNING_ENABLED` | `planning.enabled` | Enable planning phase (true/false) |
218
+
219
+ ## Using Local Models
220
+
221
+ ### Ollama
222
+
223
+ ```bash
224
+ # Pull a model with tool-calling support
225
+ ollama pull qwen3:32b
226
+
227
+ # For models that need more context (default is often 2048-4096 tokens):
228
+ cat > /tmp/Modelfile <<EOF
229
+ FROM qwen3:32b
230
+ PARAMETER num_ctx 32768
231
+ EOF
232
+ ollama create qwen3-32k -f /tmp/Modelfile
233
+
234
+ # Run
235
+ openra-rl play --provider ollama --model qwen3-32k
236
+ ```
237
+
238
+ > **Note:** Not all Ollama models support tool calling. Check with `ollama show <model>` โ€” the template must include a `tools` block. Models known to work: `qwen3:32b`, `qwen3:4b`.
239
+
240
+ ### LM Studio
241
+
242
+ 1. Load a model in LM Studio and start the local server (default port 1234)
243
+ 2. Run:
244
+
245
+ ```bash
246
+ openra-rl play --provider lmstudio --model <model-name>
247
+ ```
248
+
249
+ ## Docker
250
+
251
+ ### Server management
252
+
253
+ ```bash
254
+ openra-rl server start # Start game server container
255
+ openra-rl server start --port 9000 # Custom port
256
+ openra-rl server status # Check if running
257
+ openra-rl server logs --follow # Tail logs
258
+ openra-rl server stop # Stop container
259
+ ```
260
+
261
+ ### Docker Compose (development)
262
+
263
+ | Service | Command | Description |
264
+ |---------|---------|-------------|
265
+ | `openra-rl` | `docker compose up openra-rl` | Headless game server (ports 8000, 9999) |
266
+ | `agent` | `docker compose up agent` | LLM agent (requires `OPENROUTER_API_KEY`) |
267
+ | `mcp-bot` | `docker compose run mcp-bot` | MCP bot |
268
+
269
+ ```bash
270
+ # LLM agent via Docker Compose
271
+ OPENROUTER_API_KEY=sk-or-... docker compose up agent
272
+ ```
273
+
274
+ ### Replays
275
+
276
+ After each game, replays are automatically copied to `~/.openra-rl/replays/`. Watch them in your browser:
277
+
278
+ ```bash
279
+ openra-rl replay watch # Watch the latest replay (opens browser via VNC)
280
+ openra-rl replay watch <file> # Watch a specific .orarep file
281
+ openra-rl replay list # List replays (Docker + local)
282
+ openra-rl replay copy # Copy replays from Docker to local
283
+ openra-rl replay stop # Stop the replay viewer
284
+ ```
285
+
286
+ The replay viewer runs inside Docker using the same engine that recorded the game, so replays always play back correctly. The browser connects via noVNC โ€” no local game install needed.
287
+
288
+ > **Version tracking:** Each replay records which Docker image version was used. When you upgrade, old replays are still viewable using their original engine version.
289
+
290
+ ## Local Development (without Docker)
291
+
292
+ For running the game server natively (macOS/Linux):
293
+
294
+ ### Install dependencies
295
+
296
+ ```bash
297
+ # Python
298
+ pip install -e ".[dev]"
299
+
300
+ # .NET 8.0 SDK
301
+ # macOS: brew install dotnet@8
302
+ # Ubuntu: sudo apt install dotnet-sdk-8.0
303
+
304
+ # Native libraries (macOS arm64)
305
+ brew install sdl2 openal-soft freetype luajit
306
+ cp $(brew --prefix sdl2)/lib/libSDL2.dylib OpenRA/bin/SDL2.dylib
307
+ cp $(brew --prefix openal-soft)/lib/libopenal.dylib OpenRA/bin/soft_oal.dylib
308
+ cp $(brew --prefix freetype)/lib/libfreetype.dylib OpenRA/bin/freetype6.dylib
309
+ cp $(brew --prefix luajit)/lib/libluajit-5.1.dylib OpenRA/bin/lua51.dylib
310
+ ```
311
+
312
+ ### Build OpenRA
313
+
314
+ ```bash
315
+ cd OpenRA && make && cd ..
316
+ ```
317
+
318
+ ### Start the server
319
+
320
+ ```bash
321
+ python openra_env/server/app.py
322
+ ```
323
+
324
+ ### Run tests
325
+
326
+ ```bash
327
+ pytest
328
+ ```
329
+
330
+ ## Observation Space
331
+
332
+ Each tick, the agent receives structured game state:
333
+
334
+ | Field | Description |
335
+ |-------|-------------|
336
+ | `tick` | Current game tick |
337
+ | `cash`, `ore`, `power_provided`, `power_drained` | Economy |
338
+ | `units` | Own units with position, health, type, facing, stance, speed, attack range |
339
+ | `buildings` | Own buildings with production queues, power, rally points |
340
+ | `visible_enemies`, `visible_enemy_buildings` | Fog-of-war limited enemy intel |
341
+ | `spatial_map` | 9-channel spatial tensor (terrain, height, resources, passability, fog, own buildings, own units, enemy buildings, enemy units) |
342
+ | `military` | Kill/death costs, asset value, experience, order count |
343
+ | `available_production` | What can currently be built |
344
+
345
+ ## Action Space
346
+
347
+ 18 action types available through the command API:
348
+
349
+ | Category | Actions |
350
+ |----------|---------|
351
+ | **Movement** | `move`, `attack_move`, `attack`, `stop` |
352
+ | **Production** | `produce`, `cancel_production` |
353
+ | **Building** | `place_building`, `sell`, `repair`, `power_down`, `set_rally_point`, `set_primary` |
354
+ | **Unit control** | `deploy`, `guard`, `set_stance`, `enter_transport`, `unload`, `harvest` |
355
+
356
+ ## MCP Tools
357
+
358
+ The LLM agent interacts through 48 MCP (Model Context Protocol) tools organized into categories:
359
+
360
+ | Category | Tools | Purpose |
361
+ |----------|-------|---------|
362
+ | **Read** | `get_game_state`, `get_economy`, `get_units`, `get_buildings`, `get_enemies`, `get_production`, `get_map_info`, `get_exploration_status` | Query current game state |
363
+ | **Knowledge** | `lookup_unit`, `lookup_building`, `lookup_tech_tree`, `lookup_faction` | Static game data reference |
364
+ | **Bulk Knowledge** | `get_faction_briefing`, `get_map_analysis`, `batch_lookup` | Efficient batch queries |
365
+ | **Planning** | `start_planning_phase`, `end_planning_phase`, `get_opponent_intel`, `get_planning_status` | Pre-game strategy planning |
366
+ | **Game Control** | `advance` | Advance game ticks |
367
+ | **Movement** | `move_units`, `attack_move`, `attack_target`, `stop_units` | Unit movement commands |
368
+ | **Production** | `build_unit`, `build_structure`, `build_and_place` | Build units and structures |
369
+ | **Building Actions** | `place_building`, `cancel_production`, `deploy_unit`, `sell_building`, `repair_building`, `set_rally_point`, `guard_target`, `set_stance`, `harvest`, `power_down`, `set_primary` | Building and unit management |
370
+ | **Placement** | `get_valid_placements` | Query valid building locations |
371
+ | **Unit Groups** | `assign_group`, `add_to_group`, `get_groups`, `command_group` | Group management |
372
+ | **Compound** | `batch`, `plan` | Multi-action sequences |
373
+ | **Utility** | `get_replay_path`, `surrender` | Misc |
374
+ | **Terrain** | `get_terrain_at` | Terrain queries |
375
+
376
+ Tools can be toggled per-category or individually via `config.yaml`.
377
+
378
+ ## Benchmark & Leaderboard
379
+
380
+ Game results are automatically submitted to the [OpenRA-Bench leaderboard](https://huggingface.co/spaces/openra-rl/OpenRA-Bench) after each game. Disable with `BENCH_UPLOAD=false` or `bench_upload: false` in config.
381
+
382
+ ### Agent identity
383
+
384
+ Customize how your agent appears on the leaderboard:
385
+
386
+ ```bash
387
+ # Environment variables
388
+ AGENT_NAME="DeathBot-9000" AGENT_TYPE="RL" openra-rl play
389
+
390
+ # Or in config.yaml
391
+ agent:
392
+ agent_name: "DeathBot-9000"
393
+ agent_type: "RL"
394
+ agent_url: "https://github.com/user/deathbot" # shown as link on leaderboard
395
+ ```
396
+
397
+ | Variable | Config path | Description |
398
+ |----------|-------------|-------------|
399
+ | `AGENT_NAME` | `agent.agent_name` | Display name (default: model name) |
400
+ | `AGENT_TYPE` | `agent.agent_type` | Scripted / LLM / RL (default: auto-detect) |
401
+ | `AGENT_URL` | `agent.agent_url` | GitHub/project URL shown on leaderboard |
402
+ | `BENCH_UPLOAD` | `agent.bench_upload` | Auto-upload after each game (default: true) |
403
+ | `BENCH_URL` | `agent.bench_url` | Leaderboard URL |
404
+
405
+ ### Manual submission
406
+
407
+ Upload a saved result (with optional replay file):
408
+
409
+ ```bash
410
+ openra-rl bench submit result.json
411
+ openra-rl bench submit result.json --replay game.orarep --agent-name "MyBot"
412
+ ```
413
+
414
+ ### Custom agents
415
+
416
+ If you're building your own agent (RL, CNN, multi-agent, etc.) that doesn't use the built-in LLM agent, use `build_bench_export()` to create a leaderboard submission from a final observation:
417
+
418
+ ```python
419
+ from openra_env.bench_export import build_bench_export
420
+
421
+ # obs = final observation from env.step()
422
+ export = build_bench_export(
423
+ obs,
424
+ agent_name="DeathBot-9000",
425
+ agent_type="RL",
426
+ opponent="Normal",
427
+ agent_url="https://github.com/user/deathbot",
428
+ replay_path="/path/to/replay.orarep",
429
+ )
430
+ # Saves JSON to ~/.openra-rl/bench-exports/ and returns dict with "path" key
431
+ ```
432
+
433
+ Then submit:
434
+
435
+ ```bash
436
+ openra-rl bench submit ~/.openra-rl/bench-exports/bench-DeathBot-9000-*.json --replay game.orarep
437
+ ```
438
+
439
+ ## Project Structure
440
+
441
+ ```
442
+ OpenRA-RL/
443
+ โ”œโ”€โ”€ OpenRA/ # Game engine (git submodule, C#)
444
+ โ”œโ”€โ”€ openra_env/ # Python package
445
+ โ”‚ โ”œโ”€โ”€ cli/ # CLI entry point (openra-rl command)
446
+ โ”‚ โ”œโ”€โ”€ mcp_server.py # Standard MCP server (stdio transport)
447
+ โ”‚ โ”œโ”€โ”€ client.py # WebSocket client
448
+ โ”‚ โ”œโ”€โ”€ config.py # Unified YAML configuration
449
+ โ”‚ โ”œโ”€โ”€ models.py # Pydantic data models
450
+ โ”‚ โ”œโ”€โ”€ game_data.py # Unit/building stats, tech tree
451
+ โ”‚ โ”œโ”€โ”€ reward.py # Multi-component reward function
452
+ โ”‚ โ”œโ”€โ”€ bench_export.py # Build leaderboard submissions from observations
453
+ โ”‚ โ”œโ”€โ”€ bench_submit.py # Upload results to OpenRA-Bench leaderboard
454
+ โ”‚ โ”œโ”€โ”€ opponent_intel.py # AI opponent profiles
455
+ โ”‚ โ”œโ”€โ”€ mcp_ws_client.py # MCP WebSocket client
456
+ โ”‚ โ”œโ”€โ”€ server/
457
+ โ”‚ โ”‚ โ”œโ”€โ”€ app.py # FastAPI application
458
+ โ”‚ โ”‚ โ”œโ”€โ”€ openra_environment.py # OpenEnv environment (reset/step/state)
459
+ โ”‚ โ”‚ โ”œโ”€โ”€ bridge_client.py # Async gRPC client
460
+ โ”‚ โ”‚ โ””โ”€โ”€ openra_process.py # OpenRA subprocess manager
461
+ โ”‚ โ””โ”€โ”€ generated/ # Auto-generated protobuf stubs
462
+ โ”œโ”€โ”€ examples/
463
+ โ”‚ โ”œโ”€โ”€ scripted_bot.py # Hardcoded strategy bot
464
+ โ”‚ โ”œโ”€โ”€ mcp_bot.py # MCP tool-based bot
465
+ โ”‚ โ”œโ”€โ”€ llm_agent.py # LLM-powered agent
466
+ โ”‚ โ””โ”€โ”€ config-*.yaml # Example configs (ollama, lmstudio, openrouter, minimal)
467
+ โ”œโ”€โ”€ skill/ # OpenClaw skill definition
468
+ โ”œโ”€โ”€ proto/ # Protobuf definitions (rl_bridge.proto)
469
+ โ”œโ”€โ”€ tests/ # Test suite
470
+ โ”œโ”€โ”€ .github/workflows/ # CI, Docker publish, PyPI publish
471
+ โ”œโ”€โ”€ config.yaml # Default configuration
472
+ โ”œโ”€โ”€ docker-compose.yaml # Service orchestration
473
+ โ”œโ”€โ”€ Dockerfile # Game server image
474
+ โ””โ”€โ”€ Dockerfile.agent # Lightweight agent image
475
+ ```
476
+
477
+ ## License
478
+
479
+ [GPL-3.0](LICENSE)
__init__.py ADDED
@@ -0,0 +1,4 @@
 
 
 
 
 
1
+ """OpenRA-RL: OpenEnv environment for Red Alert."""
2
+
3
+ from openra_env.client import OpenRAEnv # noqa: F401
4
+ from openra_env.models import OpenRAAction, OpenRAObservation, OpenRAState # noqa: F401
client.py ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ """OpenEnv client re-export."""
2
+
3
+ from openra_env.client import OpenRAEnv # noqa: F401
config.yaml ADDED
@@ -0,0 +1,142 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # OpenRA-RL Configuration
2
+ # ========================
3
+ # All values below show the built-in defaults (commented out).
4
+ # Uncomment and change any value to override.
5
+ # Environment variables always take highest priority (see docs for mapping).
6
+ #
7
+ # Precedence: env vars > CLI args > constructor args > this file > defaults
8
+
9
+ # โ”€โ”€ Game Engine โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
10
+ game:
11
+ openra_path: "/Users/berta/Projects/OpenRA-RL/OpenRA" # Path to OpenRA installation ($OPENRA_PATH)
12
+ # mod: "ra" # Game mod (ra, cnc, d2k)
13
+ # map_name: "singles.oramap" # Map to play
14
+ # grpc_port: 9999 # gRPC bridge port
15
+ # headless: true # Use Null renderer (no GPU)
16
+ record_replays: true # Save .orarep replay files ($RECORD_REPLAYS)
17
+ # seed: null # RNG seed for reproducibility (null = random)
18
+ # max_ticks: 0 # End game after N ticks (0 = unlimited)
19
+ # max_wall_time_s: 0 # End game after N seconds (0 = unlimited)
20
+
21
+ # โ”€โ”€ Opponent โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
22
+ # Enemy bot always spawns by default. Set ai_slot to "" to disable.
23
+ # Difficulty tiers: beginner / easy / medium / hard / brutal
24
+ # Raw play styles also accepted: rush / normal / turtle / naval
25
+ opponent:
26
+ bot_type: "beginner" # Difficulty tier ($BOT_TYPE)
27
+ ai_slot: "Multi0" # AI player slot; "" to disable enemy ($AI_SLOT)
28
+
29
+ # โ”€โ”€ Planning Phase โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
30
+ # planning:
31
+ # enabled: true # Enable pre-game planning phase ($PLANNING_ENABLED)
32
+ # max_turns: 10 # Max planning turns ($PLANNING_MAX_TURNS)
33
+ # max_time_s: 60.0 # Max planning seconds ($PLANNING_MAX_TIME)
34
+
35
+ # โ”€โ”€ Reward Function โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
36
+ # reward:
37
+ # survival: 0.001 # Per-tick survival bonus
38
+ # economic_efficiency: 0.01 # Cash delta reward
39
+ # aggression: 0.1 # Kill reward multiplier
40
+ # defense: 0.05 # Loss penalty multiplier
41
+ # victory: 1.0 # Terminal win reward
42
+ # defeat: -1.0 # Terminal loss penalty
43
+
44
+ # โ”€โ”€ Reward Vector โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
45
+ # 8-dimensional skill signal computed per step alongside the scalar reward.
46
+ # Dimensions: combat, economy, infrastructure, intelligence, composition,
47
+ # tempo, disruption, outcome
48
+ # reward_vector:
49
+ # enabled: true # Enabled by default
50
+ # weights: # Per-dimension weights (for weighted sum)
51
+ # combat: 0.30
52
+ # economy: 0.15
53
+ # infrastructure: 0.10
54
+ # intelligence: 0.10
55
+ # composition: 0.10
56
+ # tempo: 0.10
57
+ # disruption: 0.15
58
+ # outcome: 1.00
59
+
60
+ # โ”€โ”€ MCP Tools โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
61
+ # tools:
62
+ # categories: # Toggle tool groups (true/false)
63
+ # read: true # get_game_state, get_economy, get_units, etc.
64
+ # knowledge: true # lookup_unit, lookup_building, etc.
65
+ # bulk_knowledge: true # get_faction_briefing, get_map_analysis, batch_lookup
66
+ # planning: true # start/end_planning_phase, get_opponent_intel, etc.
67
+ # game_control: true # advance
68
+ # movement: true # move_units, attack_move, attack_target, stop_units
69
+ # production: true # build_unit, build_structure, build_and_place
70
+ # building_actions: true # place, cancel, deploy, sell, repair, rally, etc.
71
+ # placement: true # get_valid_placements
72
+ # unit_groups: true # assign_group, command_group, etc.
73
+ # compound: true # batch, plan
74
+ # utility: true # get_replay_path, surrender
75
+ # terrain: true # get_terrain_at
76
+ # disabled: [] # Disable specific tools by name
77
+
78
+ # โ”€โ”€ Alerts โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
79
+ # alerts:
80
+ # under_attack: true
81
+ # damaged_building: true
82
+ # low_power: true
83
+ # idle_funds: true
84
+ # ore_full: true
85
+ # idle_production: true
86
+ # production_stalled: true
87
+ # building_ready: true
88
+ # stance_warning: true
89
+ # idle_army: true
90
+ # no_defenses: true
91
+ # no_scouting: true
92
+ # loss_tracking: true
93
+ # minimap: true # Show ASCII minimap in turn briefing
94
+ # max_alerts: 0 # Max alerts per turn (0 = unlimited)
95
+
96
+ # โ”€โ”€ LLM Model โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
97
+ # llm:
98
+ # base_url: "https://openrouter.ai/api/v1/chat/completions"
99
+ # api_key: "" # Empty = not required (local models)
100
+ # model: "qwen/qwen3-coder-next"
101
+ # max_tokens: 1500
102
+ # temperature: null # null = provider default
103
+ # top_p: null # null = provider default
104
+ # keep_last_messages: 40 # Messages to keep after compression
105
+ # compression_strategy: "sliding_window" # "sliding_window" or "none"
106
+ # compression_trigger: 0 # Compress at this count (0 = keep_last * 2)
107
+ # max_retries: 4 # Retry on transient errors
108
+ # retry_backoff_s: 10 # Base backoff (multiplied by attempt)
109
+ # request_timeout_s: 120.0 # HTTP timeout per request
110
+ # extra_headers: # Custom headers (OpenRouter-specific)
111
+ # HTTP-Referer: "https://github.com/openra-rl"
112
+ # X-Title: "OpenRA-RL Agent"
113
+
114
+ # โ”€โ”€ Agent Runtime โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
115
+ # agent:
116
+ # server_url: "http://localhost:8000" # OpenRA-RL server ($OPENRA_URL)
117
+ # max_turns: 0 # 0 = unlimited
118
+ # max_time_s: 1800 # 30 minutes ($MAX_TIME)
119
+ # verbose: false
120
+ # log_file: "" # Log file path ($LLM_AGENT_LOG)
121
+ # agent_name: "" # Leaderboard display name ($AGENT_NAME); empty = model name
122
+ # agent_type: "" # Scripted/LLM/RL ($AGENT_TYPE); empty = auto-detect
123
+ # agent_url: "" # GitHub/project URL shown on leaderboard ($AGENT_URL)
124
+ # bench_upload: true # Auto-upload results after each game ($BENCH_UPLOAD)
125
+ # bench_url: "https://openra-rl-openra-bench.hf.space" # Leaderboard URL ($BENCH_URL)
126
+
127
+ # โ”€โ”€ Prompts โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
128
+ # All LLM-facing text. Override individual fields here, or point
129
+ # prompts_file to a separate YAML (copy openra_env/prompts/default_prompts.yaml).
130
+ # Templates use Python str.format() placeholders: {variable_name}
131
+ # prompts:
132
+ # system_prompt: "" # Inline system prompt (overrides built-in)
133
+ # system_prompt_file: "" # Path to .txt system prompt ($SYSTEM_PROMPT_FILE)
134
+ # prompts_file: "" # Path to prompts YAML ($PROMPTS_FILE)
135
+ # planning_nudge: "Call end_planning_phase(strategy='...') when ready to start."
136
+ # planning_complete: "Planning complete. Game is now live."
137
+ # no_tool_nudge: "No tool was called. A tool call is required each turn."
138
+ # continue_nudge: "The game is still in progress."
139
+ # alerts: # Alert message templates
140
+ # low_power: "LOW POWER: {balance} โ€” production runs at 1/3 speed"
141
+ # idle_army: "IDLE ARMY: {count} combat units idle"
142
+ # # ... see openra_env/prompts/default_prompts.yaml for all fields
docker-compose.yaml ADDED
@@ -0,0 +1,71 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Docker Compose for OpenRA-RL development
2
+ #
3
+ # Usage:
4
+ # Game server only: docker compose up openra-rl
5
+ # With LLM agent: docker compose up agent
6
+ # With MCP bot: docker compose run mcp-bot
7
+ #
8
+ # Build:
9
+ # docker compose build
10
+
11
+ services:
12
+ openra-rl:
13
+ image: ${OPENRA_RL_IMAGE:-ghcr.io/yxc20089/openra-rl:latest}
14
+ build:
15
+ context: .
16
+ dockerfile: Dockerfile
17
+ ports:
18
+ - "8000:8000" # OpenEnv HTTP API
19
+ - "9999:9999" # gRPC bridge (direct access)
20
+ environment:
21
+ - OPENRA_PATH=/opt/openra
22
+ - DISPLAY=:99
23
+ - LIBGL_ALWAYS_SOFTWARE=1
24
+ - MESA_GL_VERSION_OVERRIDE=3.3
25
+ deploy:
26
+ resources:
27
+ limits:
28
+ cpus: "4"
29
+ memory: 4G
30
+ shm_size: 256m
31
+ restart: unless-stopped
32
+ healthcheck:
33
+ test: ["CMD", "curl", "-f", "http://localhost:8000/health"]
34
+ interval: 30s
35
+ timeout: 5s
36
+ start_period: 60s
37
+ retries: 3
38
+
39
+ agent:
40
+ build:
41
+ context: .
42
+ dockerfile: Dockerfile.agent
43
+ environment:
44
+ - OPENROUTER_API_KEY=${OPENROUTER_API_KEY}
45
+ - OPENROUTER_MODEL=${OPENROUTER_MODEL:-anthropic/claude-sonnet-4-20250514}
46
+ - OPENRA_URL=http://openra-rl:8000
47
+ command: >
48
+ python examples/llm_agent.py
49
+ --url http://openra-rl:8000
50
+ --max-turns ${MAX_TURNS:-200}
51
+ --verbose
52
+ depends_on:
53
+ openra-rl:
54
+ condition: service_healthy
55
+
56
+ mcp-bot:
57
+ build:
58
+ context: .
59
+ dockerfile: Dockerfile.agent
60
+ environment:
61
+ - OPENRA_URL=http://openra-rl:8000
62
+ command: >
63
+ python examples/mcp_bot.py
64
+ --url http://openra-rl:8000
65
+ --max-turns ${MAX_TURNS:-3000}
66
+ --verbose
67
+ depends_on:
68
+ openra-rl:
69
+ condition: service_healthy
70
+ profiles:
71
+ - bot
docker/build.sh ADDED
@@ -0,0 +1,51 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/bin/bash
2
+ # Build the OpenRA-RL Docker image.
3
+ #
4
+ # This script assembles the build context by copying the OpenRA source
5
+ # into the OpenRA-RL directory (Docker can't access files outside context).
6
+ #
7
+ # Usage:
8
+ # ./docker/build.sh # Auto-detect ../OpenRA
9
+ # OPENRA_DIR=/path/to/OpenRA ./docker/build.sh # Specify OpenRA path
10
+
11
+ set -euo pipefail
12
+
13
+ SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
14
+ PROJECT_DIR="$(cd "$SCRIPT_DIR/.." && pwd)"
15
+ OPENRA_DIR="${OPENRA_DIR:-$PROJECT_DIR/OpenRA}"
16
+
17
+ if [ ! -d "$OPENRA_DIR" ]; then
18
+ echo "ERROR: OpenRA source not found at $OPENRA_DIR"
19
+ echo "Run: git submodule update --init"
20
+ exit 1
21
+ fi
22
+
23
+ if [ ! -f "$OPENRA_DIR/OpenRA.sln" ]; then
24
+ echo "ERROR: $OPENRA_DIR doesn't look like an OpenRA repo (no OpenRA.sln)"
25
+ exit 1
26
+ fi
27
+
28
+ echo "=== OpenRA-RL Docker Build ==="
29
+ echo "OpenRA source: $OPENRA_DIR"
30
+ echo "Project dir: $PROJECT_DIR"
31
+ echo ""
32
+
33
+ # If OpenRA source is external (not the submodule), copy it into build context
34
+ REAL_OPENRA="$(cd "$OPENRA_DIR" && pwd)"
35
+ REAL_SUBMODULE="$(cd "$PROJECT_DIR/OpenRA" 2>/dev/null && pwd || echo "")"
36
+ if [ "$REAL_OPENRA" != "$REAL_SUBMODULE" ]; then
37
+ echo "Copying OpenRA source into build context..."
38
+ rsync -a --delete \
39
+ --exclude='.git' \
40
+ --exclude='bin/' \
41
+ --exclude='*/obj/' \
42
+ --exclude='*.user' \
43
+ "$OPENRA_DIR/" "$PROJECT_DIR/OpenRA/"
44
+ fi
45
+
46
+ echo "Building Docker image..."
47
+ docker build -t openra-rl "$PROJECT_DIR" "$@"
48
+
49
+ echo ""
50
+ echo "=== Build complete ==="
51
+ echo "Run with: docker run -p 8000:8000 openra-rl"
docker/entrypoint.sh ADDED
@@ -0,0 +1,30 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/bin/bash
2
+ set -e
3
+
4
+ # Start Xvfb (virtual framebuffer) for headless display
5
+ echo "Starting Xvfb on display :99..."
6
+ Xvfb :99 -screen 0 1024x768x24 -ac +extension GLX +render -noreset &
7
+ XVFB_PID=$!
8
+
9
+ # Wait for Xvfb to be ready
10
+ sleep 2
11
+ if ! kill -0 $XVFB_PID 2>/dev/null; then
12
+ echo "ERROR: Xvfb failed to start"
13
+ exit 1
14
+ fi
15
+ echo "Xvfb started (PID: $XVFB_PID)"
16
+
17
+ export DISPLAY=:99
18
+
19
+ # Clean shutdown on signals
20
+ cleanup() {
21
+ echo "Shutting down..."
22
+ kill $XVFB_PID 2>/dev/null || true
23
+ wait $XVFB_PID 2>/dev/null || true
24
+ exit 0
25
+ }
26
+ trap cleanup SIGTERM SIGINT
27
+
28
+ # Execute the main command (uvicorn by default)
29
+ echo "Starting OpenRA-RL environment server..."
30
+ exec "$@"
docker/replay-viewer.sh ADDED
@@ -0,0 +1,89 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/bin/bash
2
+ set -e
3
+
4
+ # The base image sets LIBGL_ALWAYS_SOFTWARE=1 for the headless game server.
5
+ # The replay viewer needs GPU rendering, so unset it.
6
+ unset LIBGL_ALWAYS_SOFTWARE
7
+
8
+ REPLAY_FILE="$1"
9
+ if [ -z "$REPLAY_FILE" ]; then
10
+ echo "Usage: /replay-viewer.sh <replay_file_path>"
11
+ exit 1
12
+ fi
13
+
14
+ if [ ! -f "$REPLAY_FILE" ]; then
15
+ echo "ERROR: Replay file not found: $REPLAY_FILE"
16
+ exit 1
17
+ fi
18
+
19
+ # Tunable settings via environment variables (set by docker_manager.py)
20
+ REPLAY_RESOLUTION="${OPENRA_RL_REPLAY_RESOLUTION:-1280x960}"
21
+ REPLAY_WIDTH="${REPLAY_RESOLUTION%x*}"
22
+ REPLAY_HEIGHT="${REPLAY_RESOLUTION#*x}"
23
+ REPLAY_UI_SCALE="${OPENRA_RL_REPLAY_UI_SCALE:-1}"
24
+ REPLAY_VIEWPORT="${OPENRA_RL_REPLAY_VIEWPORT_DISTANCE:-Medium}"
25
+ REPLAY_MUTE="${OPENRA_RL_REPLAY_MUTE:-True}"
26
+
27
+ # Copy replay to the expected directory structure so OpenRA can read metadata
28
+ REPLAY_DIR="/root/.config/openra/Replays/ra/{DEV_VERSION}"
29
+ mkdir -p "$REPLAY_DIR"
30
+ REPLAY_BASENAME=$(basename "$REPLAY_FILE")
31
+ cp "$REPLAY_FILE" "$REPLAY_DIR/$REPLAY_BASENAME"
32
+ REPLAY_PATH="$REPLAY_DIR/$REPLAY_BASENAME"
33
+ echo "Replay copied to: $REPLAY_PATH"
34
+
35
+ # Start Xvfb at configured resolution
36
+ echo "Starting Xvfb on display :99 (${REPLAY_WIDTH}x${REPLAY_HEIGHT})..."
37
+ Xvfb :99 -screen 0 ${REPLAY_WIDTH}x${REPLAY_HEIGHT}x24 -ac +extension GLX +render -noreset &
38
+ XVFB_PID=$!
39
+ sleep 2
40
+ if ! kill -0 $XVFB_PID 2>/dev/null; then
41
+ echo "ERROR: Xvfb failed to start"
42
+ exit 1
43
+ fi
44
+ export DISPLAY=:99
45
+
46
+ # Start x11vnc with performance optimizations
47
+ echo "Starting VNC server on port 5900..."
48
+ x11vnc -display :99 -forever -nopw -shared -rfbport 5900 \
49
+ -noxdamage -wait 50 -defer 50 -quiet &
50
+ VNC_PID=$!
51
+ sleep 1
52
+
53
+ # Start noVNC (websockify proxy)
54
+ echo "Starting noVNC on port 6080..."
55
+ websockify --web /usr/share/novnc 6080 localhost:5900 &
56
+ NOVNC_PID=$!
57
+ sleep 1
58
+
59
+ echo ""
60
+ echo "=== Replay viewer ready ==="
61
+ echo "Open in browser: http://localhost:6080/vnc.html"
62
+ echo "Press Ctrl+C to stop"
63
+ echo ""
64
+
65
+ # Clean shutdown on signals
66
+ cleanup() {
67
+ echo "Shutting down replay viewer..."
68
+ kill $NOVNC_PID 2>/dev/null || true
69
+ kill $VNC_PID 2>/dev/null || true
70
+ kill $XVFB_PID 2>/dev/null || true
71
+ wait 2>/dev/null || true
72
+ exit 0
73
+ }
74
+ trap cleanup SIGTERM SIGINT
75
+
76
+ # Launch OpenRA with rendering settings tuned for VNC replay viewing.
77
+ # CPU is managed by Docker --cpus limit (set in docker_manager.py).
78
+ exec dotnet /opt/openra/bin/OpenRA.dll \
79
+ Engine.EngineDir=/opt/openra \
80
+ Game.Mod=ra \
81
+ Game.Platform=Default \
82
+ Graphics.Mode=Windowed \
83
+ Graphics.WindowedSize=${REPLAY_WIDTH},${REPLAY_HEIGHT} \
84
+ Graphics.UIScale=${REPLAY_UI_SCALE} \
85
+ Graphics.VSync=False \
86
+ Graphics.DisableGLDebugMessageCallback=True \
87
+ Graphics.ViewportDistance=${REPLAY_VIEWPORT} \
88
+ Sound.Mute=${REPLAY_MUTE} \
89
+ "Launch.Replay=$REPLAY_PATH"
examples/README.md ADDED
@@ -0,0 +1,50 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # OpenRA-RL Examples
2
+
3
+ ## Scripted Bot
4
+
5
+ A hardcoded Red Alert bot that plays a full game through the OpenEnv client API.
6
+
7
+ **Strategy:** Deploy MCV โ†’ Build Power Plant โ†’ Build Barracks โ†’ Train 5 Rifle Infantry โ†’ Attack-move toward enemy.
8
+
9
+ ### Prerequisites
10
+
11
+ ```bash
12
+ # Install the project
13
+ pip install -e .
14
+
15
+ # Start the OpenRA-RL server (Docker)
16
+ docker run -p 8000:8000 openra-rl
17
+
18
+ # Or build from source first:
19
+ OPENRA_DIR=/path/to/OpenRA ./docker/build.sh
20
+ docker run -p 8000:8000 openra-rl
21
+ ```
22
+
23
+ ### Run
24
+
25
+ ```bash
26
+ # Basic run
27
+ python examples/scripted_bot.py
28
+
29
+ # Custom server URL
30
+ python examples/scripted_bot.py --url http://localhost:8000
31
+
32
+ # Verbose mode (prints every bot decision)
33
+ python examples/scripted_bot.py --verbose
34
+
35
+ # Limit episode length
36
+ python examples/scripted_bot.py --max-steps 2000
37
+ ```
38
+
39
+ ### Output
40
+
41
+ ```
42
+ Connecting to http://localhost:8000...
43
+ Game started! Map: singles
44
+ Step 0 | Tick 0 | $ 5000 | Units: 2 (combat: 0) | Buildings: [none] | Phase: deploy_mcv
45
+ Step 100 | Tick 100 | $ 4700 | Units: 1 (combat: 0) | Buildings: [fact] | Phase: build_base
46
+ Step 200 | Tick 200 | $ 4100 | Units: 1 (combat: 0) | Buildings: [fact, powr] | Phase: build_base
47
+ ...
48
+ Game over: win after 3421 steps (tick 3421)
49
+ Total reward: 2.150
50
+ ```
examples/config-lmstudio.yaml ADDED
@@ -0,0 +1,14 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # OpenRA-RL config for LM Studio (local)
2
+ # Usage: python examples/llm_agent.py --config examples/config-lmstudio.yaml
3
+
4
+ llm:
5
+ base_url: "http://localhost:1234/v1/chat/completions"
6
+ model: "lmstudio-community/Meta-Llama-3.1-70B-Instruct-GGUF"
7
+ api_key: "" # No key needed for LM Studio
8
+ max_tokens: 2000
9
+ extra_headers: {}
10
+ request_timeout_s: 180.0
11
+
12
+ agent:
13
+ max_time_s: 3600
14
+ verbose: true
examples/config-minimal.yaml ADDED
@@ -0,0 +1,21 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # OpenRA-RL config with minimal tool set
2
+ # Reduces tool count for models with limited context or tool-calling ability.
3
+ # Usage: python examples/llm_agent.py --config examples/config-minimal.yaml
4
+
5
+ planning:
6
+ enabled: false
7
+
8
+ tools:
9
+ categories:
10
+ knowledge: false # Disable lookup_unit, lookup_building, etc.
11
+ bulk_knowledge: false # Disable get_faction_briefing, get_map_analysis, etc.
12
+ planning: false # Disabled automatically when planning.enabled=false
13
+ unit_groups: false # Disable assign_group, command_group, etc.
14
+ terrain: false # Disable get_terrain_at
15
+ compound: false # Disable batch, plan
16
+
17
+ alerts:
18
+ stance_warning: false
19
+ idle_army: false
20
+ no_scouting: false
21
+ no_defenses: false
examples/config-ollama.yaml ADDED
@@ -0,0 +1,14 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # OpenRA-RL config for Ollama (local)
2
+ # Usage: python examples/llm_agent.py --config examples/config-ollama.yaml
3
+
4
+ llm:
5
+ base_url: "http://localhost:11434/v1/chat/completions"
6
+ model: "qwen3:32b"
7
+ api_key: "" # No key needed for Ollama
8
+ max_tokens: 2000
9
+ extra_headers: {}
10
+ request_timeout_s: 300.0 # Local models need more time (auto-set if <= 120)
11
+
12
+ agent:
13
+ max_time_s: 3600 # 1 hour (local models are slower)
14
+ verbose: true
examples/config-openrouter.yaml ADDED
@@ -0,0 +1,13 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # OpenRA-RL config for OpenRouter (cloud)
2
+ # Usage: OPENROUTER_API_KEY=sk-or-... python examples/llm_agent.py --config examples/config-openrouter.yaml
3
+
4
+ llm:
5
+ base_url: "https://openrouter.ai/api/v1/chat/completions"
6
+ model: "anthropic/claude-sonnet-4-20250514"
7
+ # api_key: set via OPENROUTER_API_KEY env var
8
+ extra_headers:
9
+ HTTP-Referer: "https://github.com/openra-rl"
10
+ X-Title: "OpenRA-RL Agent"
11
+
12
+ agent:
13
+ max_time_s: 1800 # 30 minutes
examples/llm_agent.py ADDED
@@ -0,0 +1,170 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """LLM agent that plays Red Alert using any OpenAI-compatible model.
3
+
4
+ Supports OpenRouter, Ollama, LM Studio, or any local/remote endpoint
5
+ that implements the OpenAI Chat Completions API with tool calling.
6
+
7
+ Usage:
8
+ # With OpenRouter (cloud)
9
+ export OPENROUTER_API_KEY=sk-or-...
10
+ python examples/llm_agent.py --verbose
11
+
12
+ # With a YAML config file
13
+ python examples/llm_agent.py --config examples/config-ollama.yaml
14
+
15
+ # With LM Studio (local, no API key needed)
16
+ python examples/llm_agent.py --base-url http://localhost:1234/v1/chat/completions --model my-model
17
+ """
18
+
19
+ import argparse
20
+ import asyncio
21
+ import sys
22
+
23
+ from dotenv import load_dotenv
24
+ load_dotenv()
25
+
26
+ from openra_env.config import load_config
27
+ from openra_env.agent import run_agent
28
+
29
+ # Re-export for backwards compatibility
30
+ from openra_env.agent import ( # noqa: F401
31
+ SYSTEM_PROMPT,
32
+ load_system_prompt,
33
+ compose_pregame_briefing,
34
+ format_state_briefing,
35
+ mcp_tools_to_openai,
36
+ _sanitize_messages,
37
+ chat_completion,
38
+ compress_history,
39
+ )
40
+
41
+ # Line-buffered stdout so output is observable in real time
42
+ sys.stdout.reconfigure(line_buffering=True)
43
+
44
+
45
+ def main():
46
+ parser = argparse.ArgumentParser(
47
+ description="LLM agent that plays Red Alert via any OpenAI-compatible model",
48
+ formatter_class=argparse.RawDescriptionHelpFormatter,
49
+ epilog=(
50
+ "Examples:\n"
51
+ " %(prog)s --config examples/config-ollama.yaml --verbose\n"
52
+ " %(prog)s --api-key sk-or-... --verbose\n"
53
+ " %(prog)s --base-url http://localhost:1234/v1/chat/completions --model my-model\n"
54
+ ),
55
+ )
56
+ parser.add_argument(
57
+ "--config", "-c",
58
+ default=None,
59
+ help="Path to YAML config file (default: auto-discover config.yaml)",
60
+ )
61
+ parser.add_argument(
62
+ "--url",
63
+ default=None,
64
+ help="OpenRA-RL server URL (overrides config agent.server_url)",
65
+ )
66
+ parser.add_argument(
67
+ "--base-url",
68
+ default=None,
69
+ help="LLM API endpoint URL (overrides config llm.base_url)",
70
+ )
71
+ parser.add_argument(
72
+ "--model",
73
+ default=None,
74
+ help="Model ID (overrides config llm.model)",
75
+ )
76
+ parser.add_argument(
77
+ "--api-key",
78
+ default=None,
79
+ help="API key for LLM endpoint (overrides config llm.api_key)",
80
+ )
81
+ parser.add_argument(
82
+ "--max-turns",
83
+ type=int,
84
+ default=None,
85
+ help="Maximum LLM turns, 0 = unlimited (overrides config agent.max_turns)",
86
+ )
87
+ parser.add_argument(
88
+ "--max-time",
89
+ type=int,
90
+ default=None,
91
+ help="Maximum wall-clock time in seconds (overrides config agent.max_time_s)",
92
+ )
93
+ parser.add_argument(
94
+ "--verbose",
95
+ action="store_true",
96
+ help="Print detailed LLM reasoning and tool calls",
97
+ )
98
+ parser.add_argument(
99
+ "--log-file",
100
+ default=None,
101
+ help="Write all output to this log file in addition to stdout",
102
+ )
103
+ parser.add_argument(
104
+ "--system-prompt",
105
+ default=None,
106
+ help="Path to a custom system prompt .txt file (overrides built-in default)",
107
+ )
108
+ args = parser.parse_args()
109
+
110
+ # Build config: YAML file + env vars + CLI overrides (CLI wins over .env)
111
+ cli: dict = {}
112
+ if args.url is not None:
113
+ cli.setdefault("agent", {})["server_url"] = args.url
114
+ if args.base_url is not None:
115
+ cli.setdefault("llm", {})["base_url"] = args.base_url
116
+ if args.model is not None:
117
+ cli.setdefault("llm", {})["model"] = args.model
118
+ if args.api_key is not None:
119
+ cli.setdefault("llm", {})["api_key"] = args.api_key
120
+ if args.max_turns is not None:
121
+ cli.setdefault("agent", {})["max_turns"] = args.max_turns
122
+ if args.max_time is not None:
123
+ cli.setdefault("agent", {})["max_time_s"] = args.max_time
124
+ if args.verbose:
125
+ cli.setdefault("agent", {})["verbose"] = True
126
+ if args.log_file is not None:
127
+ cli.setdefault("agent", {})["log_file"] = args.log_file
128
+ if args.system_prompt is not None:
129
+ cli.setdefault("agent", {})["system_prompt_file"] = args.system_prompt
130
+
131
+ config = load_config(config_path=args.config, cli_overrides=cli)
132
+ verbose = config.agent.verbose
133
+
134
+ # Set up logging to file if requested โ€” tee all print() to both stdout and file
135
+ if config.agent.log_file:
136
+ import builtins
137
+ _builtin_print = builtins.print
138
+ _log_fh = open(config.agent.log_file, "w", encoding="utf-8")
139
+
140
+ def _tee_print(*pargs, **kwargs):
141
+ _builtin_print(*pargs, **kwargs)
142
+ kwargs.pop("file", None)
143
+ _builtin_print(*pargs, file=_log_fh, **kwargs)
144
+ _log_fh.flush()
145
+
146
+ builtins.print = _tee_print
147
+
148
+ # API key validation: only required for remote endpoints
149
+ is_local = any(h in config.llm.base_url for h in ("localhost", "127.0.0.1", "0.0.0.0"))
150
+ if not config.llm.api_key and not is_local:
151
+ print("Error: API key required for remote LLM endpoints.")
152
+ print(" Set OPENROUTER_API_KEY or LLM_API_KEY environment variable, use --api-key,")
153
+ print(" or use a config file with llm.api_key set.")
154
+ print(" For local models (Ollama, LM Studio), use --base-url http://localhost:...")
155
+ sys.exit(1)
156
+
157
+ try:
158
+ asyncio.run(run_agent(config, verbose))
159
+ except KeyboardInterrupt:
160
+ print("\nInterrupted by user")
161
+ sys.exit(0)
162
+ except ConnectionRefusedError:
163
+ print(f"\nCould not connect to {config.agent.server_url}")
164
+ print("Is the OpenRA-RL server running?")
165
+ print(" docker run -p 8000:8000 openra-rl")
166
+ sys.exit(1)
167
+
168
+
169
+ if __name__ == "__main__":
170
+ main()
examples/mcp_bot.py ADDED
@@ -0,0 +1,619 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """MCP tool-based Red Alert bot that plays entirely through MCP tools.
3
+
4
+ Validates the full MCP integration path: tool discovery, game knowledge
5
+ lookups, read tools for state, and action tools for commands. Uses
6
+ OpenRAMCPClient to interact with the OpenRA-RL server via WebSocket.
7
+
8
+ Exercises ALL 30 MCP tools:
9
+ - Read tools: get_game_state, get_economy, get_units, get_buildings,
10
+ get_enemies, get_production, get_map_info
11
+ - Knowledge tools: lookup_unit, lookup_building, lookup_tech_tree, lookup_faction,
12
+ get_faction_briefing, get_map_analysis, batch_lookup
13
+ - Action tools: advance, deploy_unit, build_structure, place_building,
14
+ build_unit, move_units, attack_move, attack_target, stop_units,
15
+ set_rally_point, guard_target, set_stance, sell_building, repair_building,
16
+ harvest, power_down, set_primary
17
+ - Replay tool: get_replay_path
18
+
19
+ Usage:
20
+ docker run -p 8000:8000 openra-rl
21
+ python examples/mcp_bot.py --verbose
22
+ """
23
+
24
+ import argparse
25
+ import asyncio
26
+ import json
27
+ import sys
28
+ from typing import Any, Optional
29
+
30
+ # Line-buffered stdout so output is observable in real time
31
+ sys.stdout.reconfigure(line_buffering=True)
32
+
33
+ from openra_env.mcp_ws_client import OpenRAMCPClient
34
+
35
+
36
+ class MCPBot:
37
+ """State-machine bot that plays Red Alert using MCP tool calls.
38
+
39
+ Phases:
40
+ startup - Look up tech tree and faction info
41
+ deploy_mcv - Find and deploy MCV
42
+ build_base - Build power/barracks/refinery/war factory
43
+ train_army - Train infantry + vehicles, set rally points
44
+ attack - Attack-move toward enemy
45
+ sustain - Repair, sell damaged, power management
46
+ """
47
+
48
+ BARRACKS_TYPES = {"tent", "barr"}
49
+ WAR_FACTORY_TYPES = {"weap"}
50
+ BUILD_ORDER = ["powr", "barracks", "proc", "weap", "powr"]
51
+ INFANTRY_TARGET = 6
52
+ GUARD_COUNT = 2
53
+ COMBAT_TYPES = {"e1", "e2", "e3", "e4", "1tnk", "2tnk", "3tnk", "arty", "jeep", "apc"}
54
+ INFANTRY_TYPES = {"e1", "e2", "e3", "e4"}
55
+
56
+ def __init__(self, env: OpenRAMCPClient, verbose: bool = False, no_planning: bool = False):
57
+ self.env = env
58
+ self.verbose = verbose
59
+ self.no_planning = no_planning
60
+ self.phase = "startup"
61
+ self.build_index = 0
62
+ self.placement_count = 0
63
+ self.deploy_issued = False
64
+ self._guards_assigned: set[int] = set()
65
+ self._stances_set: set[int] = set()
66
+ self._rally_set: set[int] = set()
67
+ self._repair_issued: set[int] = set()
68
+ self._sold: set[int] = set()
69
+ self._powered_down: set[int] = set()
70
+ self._primary_set: set[int] = set()
71
+ self._apc_trained = False
72
+ self._tools_exercised: set[str] = set()
73
+
74
+ async def call(self, tool_name: str, **kwargs: Any) -> Any:
75
+ """Call an MCP tool and track which tools have been exercised."""
76
+ self._tools_exercised.add(tool_name)
77
+ result = await self.env.call_tool(tool_name, **kwargs)
78
+ return result
79
+
80
+ def _log(self, msg: str):
81
+ if self.verbose:
82
+ print(f" [MCPBot] {msg}")
83
+
84
+ # โ”€โ”€ Main loop โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
85
+
86
+ async def run(self, max_turns: int) -> dict:
87
+ """Run the bot for up to max_turns."""
88
+ # Phase: startup โ€” exercise knowledge tools
89
+ await self._startup()
90
+
91
+ turn = 0
92
+ while turn < max_turns:
93
+ state = await self.call("get_game_state")
94
+ if state.get("done"):
95
+ self._log(f"Game over: {state.get('result', '?')}")
96
+ break
97
+
98
+ turn += 1
99
+ await self._tick(state, turn)
100
+
101
+ if turn % 100 == 0:
102
+ self._print_status(turn, state)
103
+
104
+ # End-of-game report
105
+ final_state = await self.call("get_game_state")
106
+ replay = await self.call("get_replay_path")
107
+ self._log(f"Replay: {replay}")
108
+
109
+ return {
110
+ "turns": turn,
111
+ "final_state": final_state,
112
+ "replay": replay,
113
+ "tools_exercised": sorted(self._tools_exercised),
114
+ "tools_count": len(self._tools_exercised),
115
+ "planning_strategy": getattr(self, "_planning_strategy", ""),
116
+ }
117
+
118
+ # โ”€โ”€ Startup: knowledge tools โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
119
+
120
+ async def _startup(self):
121
+ """Run planning phase and look up game knowledge at game start."""
122
+ if self.no_planning:
123
+ self._log("=== Startup: Planning DISABLED ===")
124
+ # Use bulk knowledge tool instead of individual lookups
125
+ briefing = await self.call("get_faction_briefing")
126
+ self._log(f"Faction briefing: {briefing.get('side', '?')}, "
127
+ f"{len(briefing.get('units', {}))} units, "
128
+ f"{len(briefing.get('buildings', {}))} buildings")
129
+ else:
130
+ self._log("=== Startup: Planning Phase ===")
131
+
132
+ # Try the planning phase
133
+ planning = await self.call("start_planning_phase")
134
+ if planning.get("planning_active"):
135
+ self._log(f"Planning active โ€” opponent: {planning.get('opponent_summary', '')[:120]}")
136
+
137
+ # Use bulk tools for efficient research
138
+ briefing = await self.call("get_faction_briefing")
139
+ self._log(f"Faction briefing: {briefing.get('side', '?')}, "
140
+ f"{len(briefing.get('units', {}))} units, "
141
+ f"{len(briefing.get('buildings', {}))} buildings")
142
+
143
+ map_analysis = await self.call("get_map_analysis")
144
+ self._log(f"Map analysis: {map_analysis.get('map_type', '?')}, "
145
+ f"{len(map_analysis.get('resource_patches', []))} resource patches")
146
+
147
+ intel = await self.call("get_opponent_intel")
148
+ aggressiveness = intel.get("aggressiveness", "unknown")
149
+ self._log(f"Opponent aggressiveness: {aggressiveness}")
150
+
151
+ # Formulate strategy based on opponent profile
152
+ if aggressiveness in ("high", "very_high"):
153
+ strategy = (
154
+ "Defensive opening: power plant, barracks, turrets at base entrance, "
155
+ "then ore refinery for economy. Build war factory for tanks once stable. "
156
+ "Scout early to find and deny enemy expansion."
157
+ )
158
+ else:
159
+ strategy = (
160
+ "Rush opening: power plant, barracks, infantry rush while building "
161
+ "ore refinery. Transition to tanks from war factory."
162
+ )
163
+
164
+ result = await self.call("end_planning_phase", strategy=strategy)
165
+ self._planning_strategy = strategy
166
+ self._log(f"Planning complete: {result.get('planning_duration_seconds', '?')}s, strategy: {strategy[:80]}")
167
+ else:
168
+ # Planning disabled server-side
169
+ self._log(f"Planning: {planning.get('message', 'disabled')}")
170
+ briefing = await self.call("get_faction_briefing")
171
+ self._log(f"Faction briefing: {briefing.get('side', '?')}, "
172
+ f"{len(briefing.get('units', {}))} units, "
173
+ f"{len(briefing.get('buildings', {}))} buildings")
174
+
175
+ map_info = await self.call("get_map_info")
176
+ self._log(f"Map: {map_info.get('map_name', '?')} ({map_info.get('width')}x{map_info.get('height')})")
177
+
178
+ self.phase = "deploy_mcv"
179
+ self._log("Phase โ†’ deploy_mcv")
180
+
181
+ # โ”€โ”€ Per-tick decision โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
182
+
183
+ async def _tick(self, state: dict, turn: int):
184
+ """Make decisions for one game tick."""
185
+ # Update phase based on state
186
+ await self._update_phase()
187
+
188
+ if self.phase == "deploy_mcv":
189
+ await self._do_deploy()
190
+ elif self.phase == "build_base":
191
+ await self._do_build()
192
+ elif self.phase == "train_army":
193
+ await self._do_build()
194
+ await self._do_train()
195
+ elif self.phase == "attack":
196
+ await self._do_build()
197
+ await self._do_train()
198
+ await self._do_combat()
199
+ await self._do_sustain()
200
+
201
+ # Advance game
202
+ await self.call("advance", ticks=1)
203
+
204
+ async def _update_phase(self):
205
+ """Transition phases based on game state."""
206
+ buildings = await self.call("get_buildings")
207
+ units = await self.call("get_units")
208
+
209
+ has_cy = any(b["type"] == "fact" for b in buildings)
210
+ has_barracks = any(b["type"] in self.BARRACKS_TYPES for b in buildings)
211
+ combat_units = [u for u in units if u["type"] in self.COMBAT_TYPES]
212
+ non_guard = [u for u in combat_units if u["actor_id"] not in self._guards_assigned]
213
+
214
+ if self.phase == "deploy_mcv" and has_cy:
215
+ self.phase = "build_base"
216
+ self._log("Phase โ†’ build_base")
217
+ elif self.phase == "build_base" and self.build_index >= len(self.BUILD_ORDER):
218
+ self.phase = "train_army"
219
+ self._log("Phase โ†’ train_army")
220
+ elif self.phase == "train_army" and len(non_guard) >= self.INFANTRY_TARGET:
221
+ self.phase = "attack"
222
+ self._log(f"Phase โ†’ attack ({len(non_guard)} combat units)")
223
+
224
+ # โ”€โ”€ Deploy MCV โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
225
+
226
+ async def _do_deploy(self):
227
+ """Find and deploy MCV."""
228
+ if self.deploy_issued:
229
+ return
230
+
231
+ units = await self.call("get_units")
232
+ mcv = next((u for u in units if u["type"] == "mcv"), None)
233
+ if mcv:
234
+ self._log(f"Deploying MCV (actor {mcv['actor_id']})")
235
+ await self.call("deploy_unit", unit_id=mcv["actor_id"])
236
+ self.deploy_issued = True
237
+
238
+ # โ”€โ”€ Build base โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
239
+
240
+ async def _do_build(self):
241
+ """Handle building construction and placement."""
242
+ # Check for completed buildings to place
243
+ production = await self.call("get_production")
244
+ buildings = await self.call("get_buildings")
245
+
246
+ for p in production.get("queue", []):
247
+ if p["queue_type"] == "Building" and p["progress"] >= 0.99:
248
+ cy = next((b for b in buildings if b["type"] == "fact"), None)
249
+ if cy:
250
+ x, y = self._placement_offset(cy)
251
+ self._log(f"Placing {p['item']} at ({x}, {y})")
252
+ await self.call("place_building", building_type=p["item"], cell_x=x, cell_y=y)
253
+ self.placement_count += 1
254
+
255
+ # Start new building if nothing in queue
256
+ if self.build_index >= len(self.BUILD_ORDER):
257
+ return
258
+
259
+ building_in_queue = any(p["queue_type"] == "Building" for p in production.get("queue", []))
260
+ if building_in_queue:
261
+ return
262
+
263
+ item = self.BUILD_ORDER[self.build_index]
264
+ # Resolve faction-agnostic barracks
265
+ if item == "barracks":
266
+ available = production.get("available", [])
267
+ if "tent" in available:
268
+ item = "tent"
269
+ elif "barr" in available:
270
+ item = "barr"
271
+ else:
272
+ return
273
+
274
+ # Check if already built
275
+ already = sum(1 for b in buildings if b["type"] == item)
276
+ if already > 0 and self.build_index < len(self.BUILD_ORDER) - 1:
277
+ # Skip if not a duplicate in build order
278
+ count_in_order = sum(1 for x in self.BUILD_ORDER[:self.build_index + 1]
279
+ if x == item or (x == "barracks" and item in self.BARRACKS_TYPES))
280
+ if already >= count_in_order:
281
+ self.build_index += 1
282
+ return
283
+
284
+ available = production.get("available", [])
285
+ if item in available:
286
+ economy = await self.call("get_economy")
287
+ building_stats = await self.call("lookup_building", building_type=item)
288
+ cost = building_stats.get("cost", 0)
289
+ if economy.get("cash", 0) >= cost:
290
+ self._log(f"Building {item} (#{self.build_index + 1}/{len(self.BUILD_ORDER)}, cost=${cost})")
291
+ await self.call("build_structure", building_type=item)
292
+ self.build_index += 1
293
+
294
+ # Set rally points on production buildings
295
+ await self._do_rally_points(buildings)
296
+
297
+ async def _do_rally_points(self, buildings: list[dict]):
298
+ """Set rally points on barracks and war factories."""
299
+ cy = next((b for b in buildings if b["type"] == "fact"), None)
300
+ if not cy:
301
+ return
302
+
303
+ for b in buildings:
304
+ if b["type"] in ("tent", "barr", "weap") and b["actor_id"] not in self._rally_set:
305
+ rally_x = cy["cell_x"] if cy["cell_x"] > 0 else cy.get("pos_x", 0) // 1024
306
+ rally_y = cy["cell_y"] if cy["cell_y"] > 0 else cy.get("pos_y", 0) // 1024
307
+ self._log(f"Setting rally on {b['type']} (actor {b['actor_id']}) โ†’ ({rally_x}, {rally_y})")
308
+ await self.call("set_rally_point", building_id=b["actor_id"], cell_x=rally_x, cell_y=rally_y)
309
+ self._rally_set.add(b["actor_id"])
310
+
311
+ def _placement_offset(self, cy: dict) -> tuple[int, int]:
312
+ """Calculate placement position relative to CY."""
313
+ cx = cy.get("pos_x", 0) // 1024 if cy.get("cell_x", 0) == 0 else cy["cell_x"]
314
+ cy_y = cy.get("pos_y", 0) // 1024 if cy.get("cell_y", 0) == 0 else cy["cell_y"]
315
+ offsets = [
316
+ (3, 0), (-3, 0), (0, 3), (0, -3),
317
+ (3, 3), (-3, 3), (3, -3), (-3, -3),
318
+ (6, 0), (-6, 0), (0, 6), (0, -6),
319
+ ]
320
+ idx = self.placement_count % len(offsets)
321
+ dx, dy = offsets[idx]
322
+ return cx + dx, cy_y + dy
323
+
324
+ # โ”€โ”€ Train army โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
325
+
326
+ async def _do_train(self):
327
+ """Train infantry and vehicles."""
328
+ production = await self.call("get_production")
329
+ buildings = await self.call("get_buildings")
330
+ units = await self.call("get_units")
331
+ economy = await self.call("get_economy")
332
+
333
+ has_barracks = any(b["type"] in self.BARRACKS_TYPES for b in buildings)
334
+ infantry_training = any(
335
+ p["queue_type"] == "Infantry" and p["progress"] < 0.99
336
+ for p in production.get("queue", [])
337
+ )
338
+ infantry = [u for u in units if u["type"] in self.INFANTRY_TYPES]
339
+ total_target = self.INFANTRY_TARGET + self.GUARD_COUNT
340
+
341
+ # Train infantry
342
+ if has_barracks and not infantry_training and len(infantry) < total_target:
343
+ available = production.get("available", [])
344
+ if "e1" in available and economy.get("cash", 0) >= 100:
345
+ self._log(f"Training e1 ({len(infantry)}/{total_target})")
346
+ await self.call("build_unit", unit_type="e1")
347
+
348
+ # Train APC from war factory
349
+ has_weap = any(b["type"] == "weap" for b in buildings)
350
+ vehicle_training = any(
351
+ p["queue_type"] == "Vehicle" and p["progress"] < 0.99
352
+ for p in production.get("queue", [])
353
+ )
354
+ if has_weap and not vehicle_training and not self._apc_trained:
355
+ available = production.get("available", [])
356
+ if "apc" in available and economy.get("cash", 0) >= 800:
357
+ self._log("Training APC")
358
+ await self.call("build_unit", unit_type="apc")
359
+ self._apc_trained = True
360
+
361
+ # Continuous vehicle production in attack phase
362
+ if self.phase == "attack" and has_weap and not vehicle_training:
363
+ available = production.get("available", [])
364
+ if "1tnk" in available and economy.get("cash", 0) >= 700:
365
+ self._log("Training 1tnk (continuous)")
366
+ await self.call("build_unit", unit_type="1tnk")
367
+
368
+ # Set stances on new units
369
+ for u in units:
370
+ if u["actor_id"] in self._stances_set:
371
+ continue
372
+ if u["type"] not in self.COMBAT_TYPES:
373
+ continue
374
+ stance = "defend" if u["actor_id"] in self._guards_assigned else "attack_anything"
375
+ await self.call("set_stance", unit_ids=str(u["actor_id"]), stance=stance)
376
+ self._stances_set.add(u["actor_id"])
377
+
378
+ # Assign guards to CY
379
+ if len(self._guards_assigned) < self.GUARD_COUNT:
380
+ cy = next((b for b in buildings if b["type"] == "fact"), None)
381
+ if cy:
382
+ for u in units:
383
+ if len(self._guards_assigned) >= self.GUARD_COUNT:
384
+ break
385
+ if (u["type"] in self.INFANTRY_TYPES
386
+ and u["is_idle"]
387
+ and u["actor_id"] not in self._guards_assigned):
388
+ self._log(f"Assigning {u['type']} (actor {u['actor_id']}) to guard CY")
389
+ await self.call("guard_target", unit_ids=str(u["actor_id"]), target_actor_id=cy["actor_id"])
390
+ self._guards_assigned.add(u["actor_id"])
391
+
392
+ # Set primary on multiple production buildings
393
+ for btype_set in [self.BARRACKS_TYPES, self.WAR_FACTORY_TYPES]:
394
+ bldgs_of_type = [b for b in buildings if b["type"] in btype_set]
395
+ if len(bldgs_of_type) >= 2:
396
+ newest = max(bldgs_of_type, key=lambda b: b["actor_id"])
397
+ if newest["actor_id"] not in self._primary_set:
398
+ self._log(f"Setting primary: {newest['type']} (actor {newest['actor_id']})")
399
+ await self.call("set_primary", building_id=newest["actor_id"])
400
+ self._primary_set.add(newest["actor_id"])
401
+
402
+ # โ”€โ”€ Combat โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
403
+
404
+ async def _do_combat(self):
405
+ """Attack-move idle combat units toward enemies."""
406
+ units = await self.call("get_units")
407
+ enemies = await self.call("get_enemies")
408
+
409
+ idle_fighters = [
410
+ u for u in units
411
+ if (u["type"] in self.COMBAT_TYPES
412
+ and u["is_idle"]
413
+ and u["actor_id"] not in self._guards_assigned)
414
+ ]
415
+
416
+ if len(idle_fighters) < 2:
417
+ return
418
+
419
+ # Find attack target
420
+ target_x, target_y = self._find_attack_target(enemies, units)
421
+
422
+ unit_id_list = [u["actor_id"] for u in idle_fighters]
423
+ unit_ids = ",".join(str(i) for i in unit_id_list)
424
+ self._log(f"Attacking with {len(unit_id_list)} units toward ({target_x}, {target_y})")
425
+ await self.call("attack_move", unit_ids=unit_ids, target_x=target_x, target_y=target_y)
426
+
427
+ # Attack specific visible enemy if close
428
+ if enemies.get("units"):
429
+ enemy = enemies["units"][0]
430
+ nearby = [u for u in idle_fighters[:3] if u["can_attack"]]
431
+ if nearby:
432
+ nearby_ids = ",".join(str(u["actor_id"]) for u in nearby)
433
+ await self.call(
434
+ "attack_target",
435
+ unit_ids=nearby_ids,
436
+ target_actor_id=enemy["actor_id"],
437
+ )
438
+
439
+ def _find_attack_target(self, enemies: dict, units: list[dict]) -> tuple[int, int]:
440
+ """Find best attack target: enemy buildings > units > map center."""
441
+ if enemies.get("buildings"):
442
+ b = enemies["buildings"][0]
443
+ return b["cell_x"], b["cell_y"]
444
+ if enemies.get("units"):
445
+ u = enemies["units"][0]
446
+ return u["cell_x"], u["cell_y"]
447
+ return 64, 64 # fallback: map center
448
+
449
+ # โ”€โ”€ Sustain โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
450
+
451
+ async def _do_sustain(self):
452
+ """Repair, sell, and manage power."""
453
+ buildings = await self.call("get_buildings")
454
+ economy = await self.call("get_economy")
455
+
456
+ for b in buildings:
457
+ # Repair damaged buildings
458
+ if (b["hp_percent"] < 0.7
459
+ and not b.get("is_repairing", False)
460
+ and b["actor_id"] not in self._repair_issued
461
+ and economy.get("cash", 0) >= 500):
462
+ self._log(f"Repairing {b['type']} (actor {b['actor_id']}, hp={b['hp_percent']:.0%})")
463
+ await self.call("repair_building", building_id=b["actor_id"])
464
+ self._repair_issued.add(b["actor_id"])
465
+
466
+ # Sell heavily damaged buildings
467
+ if (b["hp_percent"] < 0.2
468
+ and b["type"] != "fact"
469
+ and b["actor_id"] not in self._sold):
470
+ self._log(f"Selling {b['type']} (actor {b['actor_id']}, hp={b['hp_percent']:.0%})")
471
+ await self.call("sell_building", building_id=b["actor_id"])
472
+ self._sold.add(b["actor_id"])
473
+
474
+ # Power management
475
+ power_balance = economy.get("power_provided", 0) - economy.get("power_drained", 0)
476
+ if power_balance < 0:
477
+ power_down_priority = ["dome", "spen", "syrd", "hpad", "afld", "fix"]
478
+ for btype in power_down_priority:
479
+ for b in buildings:
480
+ if (b["type"] == btype
481
+ and b.get("is_powered", True)
482
+ and b["actor_id"] not in self._powered_down):
483
+ self._log(f"Powering down {b['type']} (actor {b['actor_id']}) โ€” power: {power_balance}")
484
+ await self.call("power_down", building_id=b["actor_id"])
485
+ self._powered_down.add(b["actor_id"])
486
+ return # one at a time
487
+
488
+ # Send idle harvesters to harvest
489
+ units = await self.call("get_units")
490
+ for u in units:
491
+ if u["type"] == "harv" and u["is_idle"]:
492
+ self._log(f"Sending harvester {u['actor_id']} to harvest")
493
+ await self.call("harvest", unit_id=u["actor_id"])
494
+ break # one at a time
495
+
496
+ # Stop fleeing units
497
+ fleeing = [u for u in units if u["type"] in self.COMBAT_TYPES
498
+ and u.get("current_activity") == "Flee"]
499
+ if fleeing:
500
+ await self.call("stop_units", unit_ids=",".join(str(u["actor_id"]) for u in fleeing[:3]))
501
+
502
+ # Move scouts
503
+ idle_scouts = [u for u in units
504
+ if u["type"] in ("jeep", "e1") and u["is_idle"]
505
+ and u["actor_id"] not in self._guards_assigned]
506
+ if idle_scouts and len(idle_scouts) > 3:
507
+ scout = idle_scouts[0]
508
+ await self.call("move_units", unit_ids=str(scout["actor_id"]), target_x=64, target_y=64)
509
+
510
+ # โ”€โ”€ Status display โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
511
+
512
+ def _print_status(self, turn: int, state: dict):
513
+ eco = state.get("economy", {})
514
+ power = eco.get("power_provided", 0) - eco.get("power_drained", 0)
515
+ print(
516
+ f"Turn {turn:4d} | Tick {state.get('tick', 0):5d} | "
517
+ f"${eco.get('cash', 0):5d} | Pwr:{power:+d} | "
518
+ f"Units:{state.get('own_units', 0)} | "
519
+ f"Enemy:{state.get('visible_enemies', 0)} | "
520
+ f"Bldgs:{state.get('own_buildings', 0)} | {self.phase}"
521
+ )
522
+
523
+
524
+ # โ”€โ”€ Main โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
525
+
526
+
527
+ async def run_mcp_bot(url: str, max_turns: int, verbose: bool, no_planning: bool = False):
528
+ """Connect to the OpenRA-RL server and play using MCP tools."""
529
+ print(f"Connecting to {url}...")
530
+
531
+ async with OpenRAMCPClient(base_url=url, message_timeout_s=300.0) as env:
532
+ print("Resetting environment (launching OpenRA)...")
533
+ await env.reset()
534
+
535
+ # Discover available tools
536
+ tools = await env.list_tools()
537
+ tool_names = sorted(t.name for t in tools)
538
+ print(f"Discovered {len(tools)} MCP tools: {tool_names}")
539
+
540
+ # Run bot
541
+ bot = MCPBot(env, verbose=verbose, no_planning=no_planning)
542
+ result = bot.run(max_turns)
543
+ if asyncio.iscoroutine(result):
544
+ result = await result
545
+
546
+ # Final report
547
+ print()
548
+ print("=" * 70)
549
+ final = result["final_state"]
550
+ print(f"Game finished after {result['turns']} turns")
551
+ if final.get("done"):
552
+ print(f"Result: {final.get('result', '?').upper()}")
553
+
554
+ # Score card
555
+ mil = final.get("military", {})
556
+ eco = final.get("economy", {})
557
+ planning = result.get("planning_strategy", "")
558
+ print()
559
+ print("--- SCORECARD ---")
560
+ print(f" Planning: {'ON โ€” ' + planning if planning else 'OFF'}")
561
+ print(f" Ticks played: {final.get('tick', '?')}")
562
+ print(f" Units killed: {mil.get('units_killed', 0)} (value: ${mil.get('kills_cost', 0)})")
563
+ print(f" Units lost: {mil.get('units_lost', 0)} (value: ${mil.get('deaths_cost', 0)})")
564
+ print(f" Buildings killed: {mil.get('buildings_killed', 0)}")
565
+ print(f" Buildings lost: {mil.get('buildings_lost', 0)}")
566
+ print(f" Army value: ${mil.get('army_value', 0)}")
567
+ print(f" Assets value: ${mil.get('assets_value', 0)}")
568
+ print(f" Experience: {mil.get('experience', 0)}")
569
+ print(f" Orders issued: {mil.get('order_count', 0)}")
570
+ print(f" Cash remaining: ${eco.get('cash', 0)}")
571
+ print(f" K/D cost ratio: {mil.get('kills_cost', 0) / max(mil.get('deaths_cost', 1), 1):.2f}")
572
+ print()
573
+
574
+ print(f"Tools exercised: {result['tools_count']}/{len(tools)}")
575
+ print(f" {result['tools_exercised']}")
576
+ if result.get("replay", {}).get("path"):
577
+ print(f"Replay: {result['replay']['path']}")
578
+ print("=" * 70)
579
+
580
+
581
+ def main():
582
+ parser = argparse.ArgumentParser(description="MCP tool-based Red Alert bot")
583
+ parser.add_argument(
584
+ "--url",
585
+ default="http://localhost:8000",
586
+ help="OpenRA-RL server URL (default: http://localhost:8000)",
587
+ )
588
+ parser.add_argument(
589
+ "--max-turns",
590
+ type=int,
591
+ default=3000,
592
+ help="Maximum turns before stopping (default: 3000)",
593
+ )
594
+ parser.add_argument(
595
+ "--verbose",
596
+ action="store_true",
597
+ help="Print detailed bot decisions",
598
+ )
599
+ parser.add_argument(
600
+ "--no-planning",
601
+ action="store_true",
602
+ help="Disable planning phase (for comparison runs)",
603
+ )
604
+ args = parser.parse_args()
605
+
606
+ try:
607
+ asyncio.run(run_mcp_bot(args.url, args.max_turns, args.verbose, no_planning=args.no_planning))
608
+ except KeyboardInterrupt:
609
+ print("\nInterrupted by user")
610
+ sys.exit(0)
611
+ except ConnectionRefusedError:
612
+ print(f"\nCould not connect to {args.url}")
613
+ print("Is the OpenRA-RL server running?")
614
+ print(" docker run -p 8000:8000 openra-rl")
615
+ sys.exit(1)
616
+
617
+
618
+ if __name__ == "__main__":
619
+ main()
examples/scripted_bot.py ADDED
@@ -0,0 +1,831 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """Scripted Red Alert bot that plays a full game via the OpenEnv client API.
3
+
4
+ Exercises ALL Sprint 4+5 observation fields and action types:
5
+ - Observations: spatial_map, visible_enemy_buildings, unit facing/stance/speed/
6
+ attack_range/experience/passengers, building cell coords/can_produce/power/
7
+ rally/repair/sell_value
8
+ - Actions: all 20 types including GUARD, SET_STANCE, ENTER_TRANSPORT, UNLOAD,
9
+ SET_RALLY_POINT, REPAIR, SELL, POWER_DOWN, SET_PRIMARY
10
+
11
+ Usage:
12
+ docker run -p 8000:8000 openra-rl
13
+ python examples/scripted_bot.py --verbose
14
+ """
15
+
16
+ import argparse
17
+ import asyncio
18
+ import base64
19
+ import sys
20
+ from typing import List, Optional, Tuple
21
+
22
+ from openra_env.client import OpenRAEnv
23
+ from openra_env.models import (
24
+ ActionType,
25
+ BuildingInfoModel,
26
+ CommandModel,
27
+ OpenRAAction,
28
+ OpenRAObservation,
29
+ UnitInfoModel,
30
+ )
31
+
32
+ # Stance constants matching C# AutoTarget.UnitStance enum
33
+ STANCE_HOLD_FIRE = 0
34
+ STANCE_RETURN_FIRE = 1
35
+ STANCE_DEFEND = 2
36
+ STANCE_ATTACK_ANYTHING = 3
37
+
38
+ STANCE_NAMES = {0: "HoldFire", 1: "ReturnFire", 2: "Defend", 3: "AttackAnything"}
39
+
40
+
41
+ class ScriptedBot:
42
+ """State-machine bot with a Red Alert build order exercising all actions.
43
+
44
+ Phases:
45
+ deploy_mcv - Deploy MCV, set stance on starting units
46
+ build_base - Build power/barracks/war factory, set rally points
47
+ train_army - Train infantry + APC, guard CY, load transport
48
+ attack - Attack-move toward enemy buildings, unload APC
49
+ sustain - Continuous production, repair, sell damaged buildings
50
+ """
51
+
52
+ # Build order uses both faction names โ€” bot picks whichever is available
53
+ BARRACKS_TYPES = {"tent", "barr"} # Allied / Soviet
54
+ WAR_FACTORY_TYPES = {"weap"}
55
+ BUILD_PRIORITY = [
56
+ "powr", # Power Plant ($300) โ€” shared
57
+ "barracks", # Placeholder: tent (Allied) or barr (Soviet)
58
+ "proc", # Ore Refinery ($2000) โ€” needed before war factory
59
+ "weap", # War Factory ($2000) โ€” shared
60
+ "powr", # Second Power Plant
61
+ ]
62
+
63
+ INFANTRY_TRAIN_TARGET = 6
64
+ GUARD_COUNT = 2 # infantry to guard CY
65
+ TRANSPORT_TYPE = "apc"
66
+ COMBAT_UNIT_TYPES = {"e1", "e2", "e3", "e4", "1tnk", "2tnk", "3tnk", "arty", "jeep", "apc"}
67
+ INFANTRY_TYPES = {"e1", "e2", "e3", "e4"}
68
+ VEHICLE_TYPES = {"1tnk", "2tnk", "3tnk", "arty", "jeep"}
69
+
70
+ def __init__(self, verbose: bool = False):
71
+ self.phase = "deploy_mcv"
72
+ self.build_index = 0
73
+ self.placement_count = 0
74
+ self.deploy_issued = False
75
+ self.verbose = verbose
76
+ self._guards_assigned: set[int] = set() # actor IDs guarding CY
77
+ self._stances_set: set[int] = set() # actor IDs with stance already set
78
+ self._rally_set: set[int] = set() # building actor IDs with rally point set
79
+ self._apc_trained = False
80
+ self._apc_loaded = False
81
+ self._repair_issued: set[int] = set() # building actor IDs being repaired
82
+ self._sold: set[int] = set() # building actor IDs sold
83
+ self._powered_down: set[int] = set() # building actor IDs powered down
84
+ self._primary_set: set[int] = set() # building actor IDs set as primary
85
+
86
+ def decide(self, obs: OpenRAObservation) -> OpenRAAction:
87
+ """Given current observation, return commands for this tick."""
88
+ commands: List[CommandModel] = []
89
+
90
+ self._update_phase(obs)
91
+
92
+ # Priority 1: Place completed buildings
93
+ commands.extend(self._handle_placement(obs))
94
+
95
+ # Priority 2: Deploy MCV
96
+ if self.phase == "deploy_mcv":
97
+ cmd = self._handle_deploy(obs)
98
+ if cmd:
99
+ commands.append(cmd)
100
+
101
+ # Priority 3: Set rally points on production buildings
102
+ commands.extend(self._handle_rally_points(obs))
103
+
104
+ # Priority 4: Power management (power down buildings if power negative)
105
+ commands.extend(self._handle_power_management(obs))
106
+
107
+ # Priority 5: Set primary production buildings
108
+ commands.extend(self._handle_set_primary(obs))
109
+
110
+ # Priority 6: Repair damaged buildings
111
+ commands.extend(self._handle_repairs(obs))
112
+
113
+ # Priority 7: Queue production (buildings + units)
114
+ commands.extend(self._handle_production(obs))
115
+
116
+ # Priority 8: Set stances on new units
117
+ commands.extend(self._handle_stances(obs))
118
+
119
+ # Priority 9: Assign guards to CY
120
+ commands.extend(self._handle_guards(obs))
121
+
122
+ # Priority 10: Load infantry into APC
123
+ commands.extend(self._handle_transport(obs))
124
+
125
+ # Priority 11: Combat โ€” attack + unload
126
+ commands.extend(self._handle_combat(obs))
127
+
128
+ # Priority 12: Sell heavily damaged buildings
129
+ commands.extend(self._handle_sell(obs))
130
+
131
+ if not commands:
132
+ commands.append(CommandModel(action=ActionType.NO_OP))
133
+
134
+ return OpenRAAction(commands=commands)
135
+
136
+ # โ”€โ”€ Phase transitions โ”€โ”€โ”€โ”€โ”€โ”€๏ฟฝ๏ฟฝโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
137
+
138
+ def _update_phase(self, obs: OpenRAObservation):
139
+ has_cy = any(b.type == "fact" for b in obs.buildings)
140
+ has_barracks = any(b.type in self.BARRACKS_TYPES for b in obs.buildings)
141
+ combat_units = [u for u in obs.units if u.type in self.COMBAT_UNIT_TYPES]
142
+ non_guard_combat = [u for u in combat_units if u.actor_id not in self._guards_assigned]
143
+
144
+ if self.phase == "deploy_mcv" and has_cy:
145
+ self.phase = "build_base"
146
+ self._log("Phase โ†’ build_base")
147
+ elif self.phase == "build_base" and self.build_index >= len(self.BUILD_PRIORITY):
148
+ self.phase = "train_army"
149
+ self._log("Phase โ†’ train_army")
150
+ elif self.phase == "train_army" and len(non_guard_combat) >= self.INFANTRY_TRAIN_TARGET:
151
+ self.phase = "attack"
152
+ self._log(f"Phase โ†’ attack ({len(non_guard_combat)} combat units ready)")
153
+ elif self.phase == "attack" and has_barracks:
154
+ # Stay in attack but also sustain production
155
+ pass
156
+
157
+ # โ”€โ”€ Deploy MCV โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
158
+
159
+ def _handle_deploy(self, obs: OpenRAObservation) -> Optional[CommandModel]:
160
+ if self.deploy_issued:
161
+ return None
162
+ mcv = next((u for u in obs.units if u.type == "mcv"), None)
163
+ if mcv:
164
+ self.deploy_issued = True
165
+ self._log(f"Deploying MCV (actor {mcv.actor_id}, facing={mcv.facing})")
166
+ return CommandModel(action=ActionType.DEPLOY, actor_id=mcv.actor_id)
167
+ return None
168
+
169
+ # โ”€โ”€ Building placement โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
170
+
171
+ def _handle_placement(self, obs: OpenRAObservation) -> List[CommandModel]:
172
+ commands = []
173
+ cy = self._find_building(obs, "fact")
174
+ if not cy:
175
+ return commands
176
+
177
+ for prod in obs.production:
178
+ if prod.queue_type == "Building" and prod.progress >= 0.99:
179
+ x, y = self._placement_offset(cy)
180
+ self._log(f"Placing {prod.item} at cell ({x}, {y}) [attempt {self.placement_count}]")
181
+ commands.append(CommandModel(
182
+ action=ActionType.PLACE_BUILDING,
183
+ item_type=prod.item,
184
+ target_x=x,
185
+ target_y=y,
186
+ ))
187
+ self.placement_count += 1
188
+ return commands
189
+
190
+ def _placement_offset(self, cy: BuildingInfoModel) -> Tuple[int, int]:
191
+ """Calculate placement position relative to CY using cell coords."""
192
+ # Use pos_x // 1024 as CenterPosition maps to cell more reliably
193
+ cx = cy.pos_x // 1024
194
+ cy_y = cy.pos_y // 1024
195
+ # Many offsets to maximize chance of finding valid terrain
196
+ offsets = [
197
+ (3, 0), (-3, 0), (0, 3), (0, -3),
198
+ (3, 3), (-3, 3), (3, -3), (-3, -3),
199
+ (6, 0), (-6, 0), (0, 6), (0, -6),
200
+ (2, 0), (-2, 0), (0, 2), (0, -2),
201
+ (4, 0), (-4, 0), (0, 4), (0, -4),
202
+ ]
203
+ idx = self.placement_count % len(offsets)
204
+ dx, dy = offsets[idx]
205
+ return cx + dx, cy_y + dy
206
+
207
+ # โ”€โ”€ Rally points (Sprint 4 action) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
208
+
209
+ def _handle_rally_points(self, obs: OpenRAObservation) -> List[CommandModel]:
210
+ commands = []
211
+ cy = self._find_building(obs, "fact")
212
+ if not cy:
213
+ return commands
214
+
215
+ # Set rally point on barracks and war factory toward CY
216
+ for b in obs.buildings:
217
+ if b.type in ("tent", "weap") and b.actor_id not in self._rally_set:
218
+ rally_x = cy.cell_x if cy.cell_x > 0 else cy.pos_x // 1024
219
+ rally_y = cy.cell_y if cy.cell_y > 0 else cy.pos_y // 1024
220
+ self._log(f"Setting rally on {b.type} (actor {b.actor_id}) โ†’ ({rally_x}, {rally_y})")
221
+ commands.append(CommandModel(
222
+ action=ActionType.SET_RALLY_POINT,
223
+ actor_id=b.actor_id,
224
+ target_x=rally_x,
225
+ target_y=rally_y,
226
+ ))
227
+ self._rally_set.add(b.actor_id)
228
+ return commands
229
+
230
+ # โ”€โ”€ Power management (Sprint 5 action) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
231
+
232
+ def _handle_power_management(self, obs: OpenRAObservation) -> List[CommandModel]:
233
+ """Power down non-essential buildings when power balance is negative."""
234
+ commands = []
235
+ power_balance = obs.economy.power_provided - obs.economy.power_drained
236
+ if power_balance >= 0:
237
+ return commands
238
+
239
+ # Power down radar/tech buildings first (keep production running)
240
+ POWER_DOWN_PRIORITY = ["dome", "spen", "syrd", "hpad", "afld", "fix"]
241
+ for btype in POWER_DOWN_PRIORITY:
242
+ for b in obs.buildings:
243
+ if b.type == btype and b.is_powered and b.actor_id not in self._powered_down:
244
+ commands.append(CommandModel(action=ActionType.POWER_DOWN, actor_id=b.actor_id))
245
+ self._powered_down.add(b.actor_id)
246
+ self._log(f"Powering down {b.type} (actor {b.actor_id}) โ€” power balance: {power_balance}")
247
+ return commands # one at a time
248
+ return commands
249
+
250
+ # โ”€โ”€ Set primary building (Sprint 5 action) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
251
+
252
+ def _handle_set_primary(self, obs: OpenRAObservation) -> List[CommandModel]:
253
+ """Set primary on newest production building of each type."""
254
+ commands = []
255
+ for btype_set in [self.BARRACKS_TYPES, self.WAR_FACTORY_TYPES]:
256
+ buildings_of_type = [b for b in obs.buildings if b.type in btype_set]
257
+ if len(buildings_of_type) >= 2:
258
+ newest = max(buildings_of_type, key=lambda b: b.actor_id)
259
+ if newest.actor_id not in self._primary_set:
260
+ commands.append(CommandModel(action=ActionType.SET_PRIMARY, actor_id=newest.actor_id))
261
+ self._primary_set.add(newest.actor_id)
262
+ self._log(f"Setting primary: {newest.type} (actor {newest.actor_id})")
263
+ return commands
264
+
265
+ # โ”€โ”€ Repair damaged buildings (Sprint 4 observation + existing action) โ”€โ”€
266
+
267
+ def _handle_repairs(self, obs: OpenRAObservation) -> List[CommandModel]:
268
+ commands = []
269
+ for b in obs.buildings:
270
+ if (b.hp_percent < 0.7
271
+ and not b.is_repairing
272
+ and b.actor_id not in self._repair_issued
273
+ and obs.economy.cash >= 500):
274
+ self._log(f"Repairing {b.type} (actor {b.actor_id}, hp={b.hp_percent:.0%})")
275
+ commands.append(CommandModel(
276
+ action=ActionType.REPAIR,
277
+ actor_id=b.actor_id,
278
+ ))
279
+ self._repair_issued.add(b.actor_id)
280
+ return commands
281
+
282
+ # โ”€โ”€ Production โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
283
+
284
+ def _handle_production(self, obs: OpenRAObservation) -> List[CommandModel]:
285
+ commands = []
286
+
287
+ # Building construction โ€” treat any Building queue item as "in progress"
288
+ # (includes completed-but-unplaced buildings that block the queue)
289
+ building_in_queue = any(
290
+ p.queue_type == "Building"
291
+ for p in obs.production
292
+ )
293
+ if not building_in_queue and self.build_index < len(self.BUILD_PRIORITY):
294
+ item_type = self._resolve_build_item(obs, self.BUILD_PRIORITY[self.build_index])
295
+ if item_type is None:
296
+ # Can't resolve this item yet, skip
297
+ pass
298
+ elif self._has_building_type(obs, item_type, self.build_index):
299
+ self.build_index += 1
300
+ elif self._can_produce_item(obs, item_type):
301
+ self._log(f"Building {item_type} (#{self.build_index + 1}/{len(self.BUILD_PRIORITY)})")
302
+ commands.append(CommandModel(action=ActionType.BUILD, item_type=item_type))
303
+ self.build_index += 1
304
+
305
+ # Infantry training
306
+ has_barracks = any(b.type in self.BARRACKS_TYPES for b in obs.buildings)
307
+ infantry_training = any(
308
+ p.queue_type == "Infantry" and p.progress < 0.99
309
+ for p in obs.production
310
+ )
311
+ infantry = [u for u in obs.units if u.type in self.INFANTRY_TYPES]
312
+ total_target = self.INFANTRY_TRAIN_TARGET + self.GUARD_COUNT
313
+
314
+ if has_barracks and not infantry_training and len(infantry) < total_target:
315
+ if self._can_produce_item(obs, "e1") and obs.economy.cash >= 100:
316
+ self._log(f"Training e1 ({len(infantry)}/{total_target})")
317
+ commands.append(CommandModel(action=ActionType.TRAIN, item_type="e1"))
318
+
319
+ # APC from war factory
320
+ has_weap = any(b.type == "weap" for b in obs.buildings)
321
+ vehicle_training = any(
322
+ p.queue_type == "Vehicle" and p.progress < 0.99
323
+ for p in obs.production
324
+ )
325
+ if (has_weap and not vehicle_training and not self._apc_trained
326
+ and self._can_produce_item(obs, self.TRANSPORT_TYPE)
327
+ and obs.economy.cash >= 800):
328
+ self._log("Training APC for transport ops")
329
+ commands.append(CommandModel(action=ActionType.TRAIN, item_type=self.TRANSPORT_TYPE))
330
+ self._apc_trained = True
331
+
332
+ # Continuous vehicle production in attack phase
333
+ if (self.phase == "attack" and has_weap and not vehicle_training
334
+ and obs.economy.cash >= 800):
335
+ # Build light tanks if available
336
+ if self._can_produce_item(obs, "1tnk"):
337
+ self._log("Training 1tnk (continuous production)")
338
+ commands.append(CommandModel(action=ActionType.TRAIN, item_type="1tnk"))
339
+
340
+ return commands
341
+
342
+ def _can_produce_item(self, obs: OpenRAObservation, item_type: str) -> bool:
343
+ """Check if item is buildable using per-building can_produce (Sprint 4)."""
344
+ # First check global available_production
345
+ if item_type in obs.available_production:
346
+ return True
347
+ # Also check per-building can_produce lists
348
+ for b in obs.buildings:
349
+ if item_type in b.can_produce:
350
+ return True
351
+ return False
352
+
353
+ # โ”€โ”€ Stances (Sprint 4 action) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
354
+
355
+ def _handle_stances(self, obs: OpenRAObservation) -> List[CommandModel]:
356
+ commands = []
357
+ for u in obs.units:
358
+ if u.actor_id in self._stances_set:
359
+ continue
360
+ if u.type not in self.COMBAT_UNIT_TYPES:
361
+ continue
362
+
363
+ # Guards get Defend stance, attackers get AttackAnything
364
+ if u.actor_id in self._guards_assigned:
365
+ desired = STANCE_DEFEND
366
+ else:
367
+ desired = STANCE_ATTACK_ANYTHING
368
+
369
+ if u.stance != desired:
370
+ self._log(
371
+ f"Setting {u.type} (actor {u.actor_id}) stance: "
372
+ f"{STANCE_NAMES.get(u.stance, '?')} โ†’ {STANCE_NAMES[desired]}"
373
+ )
374
+ commands.append(CommandModel(
375
+ action=ActionType.SET_STANCE,
376
+ actor_id=u.actor_id,
377
+ target_x=desired,
378
+ ))
379
+ self._stances_set.add(u.actor_id)
380
+ return commands
381
+
382
+ # โ”€โ”€ Guard CY (Sprint 4 action) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
383
+
384
+ def _handle_guards(self, obs: OpenRAObservation) -> List[CommandModel]:
385
+ commands = []
386
+ if len(self._guards_assigned) >= self.GUARD_COUNT:
387
+ return commands
388
+
389
+ cy = self._find_building(obs, "fact")
390
+ if not cy:
391
+ return commands
392
+
393
+ # Find idle infantry not yet guarding
394
+ for u in obs.units:
395
+ if len(self._guards_assigned) >= self.GUARD_COUNT:
396
+ break
397
+ if (u.type in self.INFANTRY_TYPES
398
+ and u.is_idle
399
+ and u.actor_id not in self._guards_assigned):
400
+ self._log(
401
+ f"Assigning {u.type} (actor {u.actor_id}, "
402
+ f"range={u.attack_range}) to guard CY"
403
+ )
404
+ commands.append(CommandModel(
405
+ action=ActionType.GUARD,
406
+ actor_id=u.actor_id,
407
+ target_actor_id=cy.actor_id,
408
+ ))
409
+ self._guards_assigned.add(u.actor_id)
410
+ return commands
411
+
412
+ # โ”€โ”€ Transport: load/unload (Sprint 4 actions) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
413
+
414
+ def _handle_transport(self, obs: OpenRAObservation) -> List[CommandModel]:
415
+ commands = []
416
+ if self._apc_loaded:
417
+ return commands
418
+
419
+ apc = next(
420
+ (u for u in obs.units
421
+ if u.type == self.TRANSPORT_TYPE and u.passenger_count == 0),
422
+ None,
423
+ )
424
+ if not apc:
425
+ return commands
426
+
427
+ # Load idle infantry (not guards) into the APC
428
+ loaded = 0
429
+ for u in obs.units:
430
+ if loaded >= 4: # APC capacity
431
+ break
432
+ if (u.type in self.INFANTRY_TYPES
433
+ and u.is_idle
434
+ and u.actor_id not in self._guards_assigned):
435
+ self._log(
436
+ f"Loading {u.type} (actor {u.actor_id}, "
437
+ f"speed={u.speed}) into APC {apc.actor_id}"
438
+ )
439
+ commands.append(CommandModel(
440
+ action=ActionType.ENTER_TRANSPORT,
441
+ actor_id=u.actor_id,
442
+ target_actor_id=apc.actor_id,
443
+ ))
444
+ loaded += 1
445
+
446
+ if loaded > 0:
447
+ self._apc_loaded = True
448
+ return commands
449
+
450
+ # โ”€โ”€ Combat โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
451
+
452
+ def _handle_combat(self, obs: OpenRAObservation) -> List[CommandModel]:
453
+ commands = []
454
+ if self.phase != "attack":
455
+ return commands
456
+
457
+ # Unload APC near enemy
458
+ commands.extend(self._handle_unload(obs))
459
+
460
+ # Attack-move idle fighters toward enemy
461
+ idle_fighters = [
462
+ u for u in obs.units
463
+ if (u.type in self.COMBAT_UNIT_TYPES
464
+ and u.is_idle
465
+ and u.actor_id not in self._guards_assigned)
466
+ ]
467
+
468
+ if len(idle_fighters) < 2:
469
+ return commands
470
+
471
+ target_x, target_y = self._find_attack_target(obs)
472
+
473
+ for unit in idle_fighters:
474
+ commands.append(CommandModel(
475
+ action=ActionType.ATTACK_MOVE,
476
+ actor_id=unit.actor_id,
477
+ target_x=target_x,
478
+ target_y=target_y,
479
+ ))
480
+
481
+ if idle_fighters:
482
+ self._log(
483
+ f"Attacking with {len(idle_fighters)} units "
484
+ f"toward ({target_x}, {target_y})"
485
+ )
486
+ return commands
487
+
488
+ def _handle_unload(self, obs: OpenRAObservation) -> List[CommandModel]:
489
+ """Unload APC when near enemies."""
490
+ commands = []
491
+ for u in obs.units:
492
+ if u.type != self.TRANSPORT_TYPE or u.passenger_count <= 0:
493
+ continue
494
+
495
+ # Check if any enemy is within ~15 cells
496
+ for enemy in obs.visible_enemies:
497
+ dx = abs(u.cell_x - enemy.cell_x)
498
+ dy = abs(u.cell_y - enemy.cell_y)
499
+ if dx + dy < 15:
500
+ self._log(
501
+ f"Unloading APC (actor {u.actor_id}, "
502
+ f"{u.passenger_count} passengers) near enemy"
503
+ )
504
+ commands.append(CommandModel(
505
+ action=ActionType.UNLOAD,
506
+ actor_id=u.actor_id,
507
+ ))
508
+ break
509
+
510
+ # Also unload near enemy buildings
511
+ for eb in obs.visible_enemy_buildings:
512
+ dx = abs(u.cell_x - eb.cell_x)
513
+ dy = abs(u.cell_y - eb.cell_y)
514
+ if dx + dy < 15:
515
+ self._log(
516
+ f"Unloading APC near enemy building {eb.type} "
517
+ f"(hp={eb.hp_percent:.0%})"
518
+ )
519
+ commands.append(CommandModel(
520
+ action=ActionType.UNLOAD,
521
+ actor_id=u.actor_id,
522
+ ))
523
+ break
524
+ return commands
525
+
526
+ def _find_attack_target(self, obs: OpenRAObservation) -> Tuple[int, int]:
527
+ """Prioritize enemy buildings > enemy units > map center."""
528
+ # Priority 1: visible enemy buildings (Sprint 4 field)
529
+ if obs.visible_enemy_buildings:
530
+ # Prefer production buildings
531
+ prod_buildings = [
532
+ b for b in obs.visible_enemy_buildings
533
+ if b.type in ("fact", "tent", "weap", "hpad", "afld")
534
+ ]
535
+ target = prod_buildings[0] if prod_buildings else obs.visible_enemy_buildings[0]
536
+ return target.cell_x, target.cell_y
537
+
538
+ # Priority 2: visible enemy units
539
+ if obs.visible_enemies:
540
+ enemy = obs.visible_enemies[0]
541
+ return enemy.cell_x, enemy.cell_y
542
+
543
+ # Fallback: map center
544
+ if obs.map_info.width > 0:
545
+ return obs.map_info.width // 2, obs.map_info.height // 2
546
+ return 64, 64
547
+
548
+ # โ”€โ”€ Sell heavily damaged buildings โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
549
+
550
+ def _handle_sell(self, obs: OpenRAObservation) -> List[CommandModel]:
551
+ commands = []
552
+ for b in obs.buildings:
553
+ if (b.hp_percent < 0.2
554
+ and b.type != "fact" # never sell CY
555
+ and b.actor_id not in self._sold):
556
+ self._log(
557
+ f"Selling {b.type} (actor {b.actor_id}, hp={b.hp_percent:.0%}, "
558
+ f"refund=${b.sell_value})"
559
+ )
560
+ commands.append(CommandModel(
561
+ action=ActionType.SELL,
562
+ actor_id=b.actor_id,
563
+ ))
564
+ self._sold.add(b.actor_id)
565
+ return commands
566
+
567
+ # โ”€โ”€ Helpers โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
568
+
569
+ def _resolve_build_item(self, obs: OpenRAObservation, placeholder: str) -> Optional[str]:
570
+ """Resolve faction-agnostic build item to actual producible type."""
571
+ if placeholder == "barracks":
572
+ # Find which barracks type is available
573
+ for btype in self.BARRACKS_TYPES:
574
+ if self._can_produce_item(obs, btype):
575
+ return btype
576
+ return None
577
+ return placeholder
578
+
579
+ def _has_building_type(self, obs: OpenRAObservation, item_type: str, build_index: int) -> bool:
580
+ """Check if we already have enough of this building type."""
581
+ already_built = sum(1 for b in obs.buildings if b.type == item_type)
582
+ # Count how many times this item appears up to current index
583
+ resolved_order = []
584
+ for i, p in enumerate(self.BUILD_PRIORITY[:build_index + 1]):
585
+ if p == "barracks":
586
+ resolved_order.append(item_type if item_type in self.BARRACKS_TYPES else p)
587
+ else:
588
+ resolved_order.append(p)
589
+ target_count = resolved_order.count(item_type)
590
+ return already_built >= target_count
591
+
592
+ def _find_building(self, obs: OpenRAObservation, btype: str) -> Optional[BuildingInfoModel]:
593
+ return next((b for b in obs.buildings if b.type == btype), None)
594
+
595
+ def _log(self, msg: str):
596
+ if self.verbose:
597
+ print(f" [Bot] {msg}")
598
+
599
+
600
+ # โ”€โ”€ Status display โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
601
+
602
+
603
+ def print_status(step: int, obs: OpenRAObservation, bot: ScriptedBot):
604
+ """Print a rich status line using Sprint 4 observation fields."""
605
+ combat = [u for u in obs.units if u.type in bot.COMBAT_UNIT_TYPES]
606
+ buildings = ", ".join(sorted(set(b.type for b in obs.buildings))) or "none"
607
+ power_balance = obs.economy.power_provided - obs.economy.power_drained
608
+
609
+ # Count enemy intel
610
+ enemy_units = len(obs.visible_enemies)
611
+ enemy_buildings = len(obs.visible_enemy_buildings)
612
+
613
+ print(
614
+ f"Step {step:4d} | Tick {obs.tick:5d} | "
615
+ f"${obs.economy.cash:5d} | Pwr:{power_balance:+d} | "
616
+ f"Units:{len(obs.units)} (combat:{len(combat)}) | "
617
+ f"Enemy:{enemy_units}u/{enemy_buildings}b | "
618
+ f"Bldgs:[{buildings}] | {bot.phase}"
619
+ )
620
+
621
+
622
+ def print_detailed_status(obs: OpenRAObservation):
623
+ """Print full observation details using all Sprint 4 fields."""
624
+ print("\nโ”€โ”€ Detailed Observation โ”€โ”€")
625
+
626
+ # Spatial map
627
+ if obs.spatial_channels > 0 and obs.spatial_map:
628
+ raw_bytes = base64.b64decode(obs.spatial_map)
629
+ w, h = obs.map_info.width, obs.map_info.height
630
+ expected_bytes = w * h * obs.spatial_channels * 4
631
+ print(
632
+ f" Spatial: {w}x{h} map, {obs.spatial_channels} channels, "
633
+ f"{len(raw_bytes)} bytes (expected {expected_bytes})"
634
+ )
635
+ else:
636
+ print(" Spatial: not populated")
637
+
638
+ # Economy
639
+ e = obs.economy
640
+ print(
641
+ f" Economy: ${e.cash} cash, {e.ore} ore, "
642
+ f"power {e.power_provided}/{e.power_drained} "
643
+ f"({e.power_provided - e.power_drained:+d}), "
644
+ f"{e.harvester_count} harvesters"
645
+ )
646
+
647
+ # Production queue
648
+ if obs.production:
649
+ print(f" Production queue ({len(obs.production)}):")
650
+ for p in obs.production:
651
+ print(f" {p.queue_type}: {p.item} @ {p.progress:.0%} (paused={p.paused})")
652
+ if obs.available_production:
653
+ print(f" Available production: {', '.join(obs.available_production[:15])}")
654
+ else:
655
+ print(" Available production: (none)")
656
+
657
+ # Own buildings with Sprint 4 fields
658
+ print(f" Buildings ({len(obs.buildings)}):")
659
+ for b in obs.buildings:
660
+ extras = []
661
+ if b.power_amount != 0:
662
+ extras.append(f"pwr={b.power_amount:+d}")
663
+ if b.is_producing:
664
+ extras.append(f"producing={b.producing_item}@{b.production_progress:.0%}")
665
+ if b.is_repairing:
666
+ extras.append("REPAIRING")
667
+ if b.rally_x >= 0:
668
+ extras.append(f"rally=({b.rally_x},{b.rally_y})")
669
+ if b.can_produce:
670
+ extras.append(f"can_produce=[{','.join(b.can_produce[:5])}{'...' if len(b.can_produce) > 5 else ''}]")
671
+ extra_str = f" ({', '.join(extras)})" if extras else ""
672
+ print(
673
+ f" {b.type:6s} #{b.actor_id:4d} "
674
+ f"cell=({b.cell_x},{b.cell_y}) "
675
+ f"hp={b.hp_percent:.0%} "
676
+ f"sell=${b.sell_value}{extra_str}"
677
+ )
678
+
679
+ # Own units with Sprint 4 fields
680
+ print(f" Units ({len(obs.units)}):")
681
+ for u in obs.units[:10]: # cap at 10 for readability
682
+ stance_name = STANCE_NAMES.get(u.stance, f"?{u.stance}")
683
+ extras = []
684
+ if u.experience_level > 0:
685
+ extras.append(f"vet={u.experience_level}")
686
+ if u.passenger_count >= 0:
687
+ extras.append(f"cargo={u.passenger_count}")
688
+ extra_str = f" ({', '.join(extras)})" if extras else ""
689
+ print(
690
+ f" {u.type:6s} #{u.actor_id:4d} "
691
+ f"cell=({u.cell_x},{u.cell_y}) "
692
+ f"hp={u.hp_percent:.0%} "
693
+ f"face={u.facing:4d} spd={u.speed:3d} "
694
+ f"rng={u.attack_range:5d} "
695
+ f"stance={stance_name} "
696
+ f"{'IDLE' if u.is_idle else u.current_activity}{extra_str}"
697
+ )
698
+ if len(obs.units) > 10:
699
+ print(f" ... and {len(obs.units) - 10} more")
700
+
701
+ # Visible enemies
702
+ if obs.visible_enemies:
703
+ print(f" Visible enemy units ({len(obs.visible_enemies)}):")
704
+ for u in obs.visible_enemies[:5]:
705
+ print(
706
+ f" {u.type:6s} #{u.actor_id:4d} "
707
+ f"cell=({u.cell_x},{u.cell_y}) hp={u.hp_percent:.0%} "
708
+ f"spd={u.speed} rng={u.attack_range}"
709
+ )
710
+
711
+ # Visible enemy buildings (Sprint 4 field)
712
+ if obs.visible_enemy_buildings:
713
+ print(f" Visible enemy buildings ({len(obs.visible_enemy_buildings)}):")
714
+ for b in obs.visible_enemy_buildings[:5]:
715
+ print(
716
+ f" {b.type:6s} #{b.actor_id:4d} "
717
+ f"cell=({b.cell_x},{b.cell_y}) hp={b.hp_percent:.0%} "
718
+ f"pwr={b.power_amount:+d}"
719
+ )
720
+
721
+
722
+ # โ”€โ”€ Main loop โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
723
+
724
+
725
+ async def run_bot(url: str, max_steps: int, verbose: bool):
726
+ """Connect to the OpenRA-RL server and play one full game."""
727
+ print(f"Connecting to {url}...")
728
+ bot = ScriptedBot(verbose=verbose)
729
+
730
+ async with OpenRAEnv(base_url=url, message_timeout_s=300.0) as env:
731
+ print("Resetting environment...")
732
+ result = await env.reset()
733
+ obs = result.observation
734
+ print(f"Game started! Map: {obs.map_info.map_name} ({obs.map_info.width}x{obs.map_info.height})")
735
+
736
+ # Print initial detailed status
737
+ if verbose:
738
+ print_detailed_status(obs)
739
+
740
+ print_status(0, obs, bot)
741
+
742
+ step = 0
743
+ total_reward = 0.0
744
+
745
+ while not result.done and step < max_steps:
746
+ action = bot.decide(result.observation)
747
+ result = await env.step(action)
748
+ step += 1
749
+ total_reward += result.reward or 0.0
750
+ obs = result.observation
751
+
752
+ if step % 100 == 0:
753
+ print_status(step, obs, bot)
754
+
755
+ # Detailed dump at key milestones
756
+ if verbose and step in (50, 200, 500, 1000):
757
+ print_detailed_status(obs)
758
+
759
+ # Final report
760
+ print()
761
+ print("=" * 70)
762
+ obs = result.observation
763
+ if obs.done:
764
+ print(f"GAME OVER: {obs.result.upper()} after {step} steps (tick {obs.tick})")
765
+ else:
766
+ print(f"Reached max steps ({max_steps}) at tick {obs.tick}")
767
+
768
+ print(f"Total reward: {total_reward:.3f}")
769
+ print(f"Final cash: ${obs.economy.cash}")
770
+ print(f"Power balance: {obs.economy.power_provided - obs.economy.power_drained:+d}")
771
+ print(f"Units killed: {obs.military.units_killed}")
772
+ print(f"Units lost: {obs.military.units_lost}")
773
+ print(f"Buildings killed: {obs.military.buildings_killed}")
774
+ print(f"Buildings lost: {obs.military.buildings_lost}")
775
+ print(f"Army value: ${obs.military.army_value}")
776
+ print(f"Own buildings: {len(obs.buildings)}")
777
+ print(f"Visible enemies: {len(obs.visible_enemies)} units, {len(obs.visible_enemy_buildings)} buildings")
778
+
779
+ # Spatial map stats
780
+ if obs.spatial_channels > 0 and obs.spatial_map:
781
+ raw_bytes = base64.b64decode(obs.spatial_map)
782
+ n_floats = len(raw_bytes) // 4
783
+ print(f"Spatial map: {n_floats} floats ({obs.spatial_channels} channels)")
784
+ else:
785
+ print("Spatial map: not populated")
786
+
787
+ # Show veteran units
788
+ vets = [u for u in obs.units if u.experience_level > 0]
789
+ if vets:
790
+ print(f"Veterans: {', '.join(f'{u.type}#{u.actor_id}(lvl{u.experience_level})' for u in vets)}")
791
+
792
+ if verbose:
793
+ print_detailed_status(obs)
794
+
795
+ print("=" * 70)
796
+
797
+
798
+ def main():
799
+ parser = argparse.ArgumentParser(description="Scripted Red Alert bot via OpenEnv")
800
+ parser.add_argument(
801
+ "--url",
802
+ default="http://localhost:8000",
803
+ help="OpenRA-RL server URL (default: http://localhost:8000)",
804
+ )
805
+ parser.add_argument(
806
+ "--max-steps",
807
+ type=int,
808
+ default=5000,
809
+ help="Maximum steps before stopping (default: 5000)",
810
+ )
811
+ parser.add_argument(
812
+ "--verbose",
813
+ action="store_true",
814
+ help="Print detailed bot decisions and observation dumps",
815
+ )
816
+ args = parser.parse_args()
817
+
818
+ try:
819
+ asyncio.run(run_bot(args.url, args.max_steps, args.verbose))
820
+ except KeyboardInterrupt:
821
+ print("\nInterrupted by user")
822
+ sys.exit(0)
823
+ except ConnectionRefusedError:
824
+ print(f"\nCould not connect to {args.url}")
825
+ print("Is the OpenRA-RL server running?")
826
+ print(" docker run -p 8000:8000 openra-rl")
827
+ sys.exit(1)
828
+
829
+
830
+ if __name__ == "__main__":
831
+ main()
models.py ADDED
@@ -0,0 +1,7 @@
 
 
 
 
 
 
 
 
1
+ """OpenEnv models re-export."""
2
+
3
+ from openra_env.models import ( # noqa: F401
4
+ OpenRAAction,
5
+ OpenRAObservation,
6
+ OpenRAState,
7
+ )
openenv.yaml ADDED
@@ -0,0 +1,6 @@
 
 
 
 
 
 
 
1
+ spec_version: 1
2
+ name: openra_env
3
+ type: space
4
+ runtime: fastapi
5
+ app: openra_env.server.app:app
6
+ port: 8000
openra_env/__init__.py ADDED
@@ -0,0 +1,6 @@
 
 
 
 
 
 
 
1
+ """OpenRA-RL: Reinforcement Learning Environment for the OpenRA RTS Engine."""
2
+
3
+ from openra_env.client import OpenRAEnv
4
+ from openra_env.models import OpenRAAction, OpenRAObservation, OpenRAState
5
+
6
+ __all__ = ["OpenRAEnv", "OpenRAAction", "OpenRAObservation", "OpenRAState"]
openra_env/agent.py ADDED
@@ -0,0 +1,1156 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """LLM agent that plays Red Alert using any OpenAI-compatible model.
2
+
3
+ Supports OpenRouter, Ollama, LM Studio, or any local/remote endpoint
4
+ that implements the OpenAI Chat Completions API with tool calling.
5
+ """
6
+
7
+ import asyncio
8
+ import json
9
+ import logging
10
+ import time
11
+
12
+ from collections import defaultdict
13
+
14
+ import httpx
15
+ from openra_env.config import LLMConfig
16
+ from openra_env.game_data import get_building_stats, get_faction_info, get_tech_tree, get_unit_stats
17
+ from openra_env.mcp_ws_client import OpenRAMCPClient
18
+
19
+ logger = logging.getLogger("llm_agent")
20
+
21
+
22
+ def _looks_like_tool_capability_error(error_text: str) -> bool:
23
+ """Best-effort detection of provider errors indicating no tool support."""
24
+ text = error_text.lower()
25
+ # Only match phrases that unambiguously refer to tool-calling capability.
26
+ # "no endpoints found" is too generic on its own โ€” guard it with "tool".
27
+ if "no endpoints found" in text and "tool" in text:
28
+ return True
29
+ markers = (
30
+ "support tool use",
31
+ "does not support tool",
32
+ "tool calling",
33
+ "tools are not supported",
34
+ )
35
+ return any(m in text for m in markers)
36
+
37
+
38
+ def _bench_export_policy(encountered_agent_error: bool) -> tuple[bool, bool, str]:
39
+ """Decide whether bench export and upload should run for this match.
40
+
41
+ Returns:
42
+ (should_export, should_upload, reason)
43
+ Local export always happens (useful for debugging).
44
+ Upload is skipped when runtime errors occurred.
45
+ """
46
+ if encountered_agent_error:
47
+ return True, False, "runtime [ERROR] occurred during the match"
48
+ return True, True, ""
49
+
50
+
51
+ def _format_llm_api_error(status_code: int, error_text: str, llm_config: LLMConfig) -> str:
52
+ """Map raw provider errors to clear, actionable runtime messages."""
53
+ error_lower = error_text.lower()
54
+
55
+ if status_code in (401, 403):
56
+ return (
57
+ f"Authentication failed ({status_code}). "
58
+ "Check your API key: openra-rl config"
59
+ )
60
+
61
+ if status_code == 400 and "model" in error_lower:
62
+ return (
63
+ f"Invalid model ID '{llm_config.model}'. "
64
+ "Update with: openra-rl config"
65
+ )
66
+
67
+ if status_code == 429:
68
+ return "Rate limited by LLM provider. Wait a minute and retry."
69
+
70
+ if status_code == 404 and _looks_like_tool_capability_error(error_text):
71
+ is_openrouter = "openrouter.ai" in llm_config.base_url.lower()
72
+ if is_openrouter:
73
+ return (
74
+ f"Model '{llm_config.model}' has no OpenRouter route that supports tool calling. "
75
+ "OpenRA-RL requires tool-calling models. "
76
+ "Use a tool-capable model/route (often not ':free'), or use Ollama "
77
+ "with qwen3:32b or qwen3:4b."
78
+ )
79
+ return (
80
+ f"Model '{llm_config.model}' does not support tool calling on this endpoint. "
81
+ "OpenRA-RL requires tool-calling models."
82
+ )
83
+
84
+ return f"LLM API error {status_code}: {error_text}"
85
+
86
+
87
+ async def _preflight_tool_calling_support(llm_config: LLMConfig) -> tuple[bool, str]:
88
+ """Check OpenRouter model route support for tool calling before game start.
89
+
90
+ Returns:
91
+ (True, "") when preflight passes or does not apply.
92
+ (False, reason) when preflight confirms tools are unsupported.
93
+ """
94
+ if "openrouter.ai" not in llm_config.base_url.lower():
95
+ return True, ""
96
+
97
+ preflight_cfg = llm_config.model_copy(
98
+ update={
99
+ "max_tokens": 1,
100
+ "request_timeout_s": min(llm_config.request_timeout_s, 30.0),
101
+ }
102
+ )
103
+ preflight_messages = [
104
+ {"role": "user", "content": "Tool-calling preflight check. Reply briefly."},
105
+ ]
106
+ preflight_tools = [
107
+ {
108
+ "type": "function",
109
+ "function": {
110
+ "name": "preflight_ping",
111
+ "description": "Preflight-only tool for capability check.",
112
+ "parameters": {"type": "object", "properties": {}},
113
+ },
114
+ }
115
+ ]
116
+ try:
117
+ await chat_completion(preflight_messages, preflight_tools, preflight_cfg, verbose=False, prompts=None)
118
+ return True, ""
119
+ except RuntimeError as e:
120
+ msg = str(e)
121
+ if _looks_like_tool_capability_error(msg):
122
+ return False, msg
123
+ raise
124
+
125
+
126
+ def _load_default_prompt() -> str:
127
+ """Load the default system prompt shipped with the package."""
128
+ from openra_env.prompts import load_default_prompt
129
+ return load_default_prompt()
130
+
131
+
132
+ # Public constant for backward compatibility (lazy-loaded on first access)
133
+ SYSTEM_PROMPT = _load_default_prompt()
134
+
135
+
136
+ def load_system_prompt(config) -> str:
137
+ """Resolve system prompt from config: inline > file > default.
138
+
139
+ Priority:
140
+ 1. config.prompts.system_prompt (inline string)
141
+ 2. config.prompts.system_prompt_file (path to .txt file)
142
+ 3. config.agent.system_prompt (deprecated, backward compat)
143
+ 4. config.agent.system_prompt_file (deprecated, backward compat)
144
+ 5. Built-in default (openra_env/prompts/default.txt)
145
+ """
146
+ from pathlib import Path
147
+
148
+ # Check prompts.* first (canonical location)
149
+ prompts_cfg = getattr(config, "prompts", None)
150
+ if prompts_cfg:
151
+ if getattr(prompts_cfg, "system_prompt", ""):
152
+ return prompts_cfg.system_prompt
153
+ prompt_file = getattr(prompts_cfg, "system_prompt_file", "")
154
+ if prompt_file:
155
+ p = Path(prompt_file).expanduser()
156
+ if p.is_file():
157
+ return p.read_text(encoding="utf-8").strip()
158
+ raise FileNotFoundError(f"system_prompt_file not found: {p}")
159
+
160
+ # Backward compat: check agent.* (deprecated)
161
+ agent_cfg = config.agent if hasattr(config, "agent") else config
162
+ if getattr(agent_cfg, "system_prompt", ""):
163
+ return agent_cfg.system_prompt
164
+ prompt_file = getattr(agent_cfg, "system_prompt_file", "")
165
+ if prompt_file:
166
+ p = Path(prompt_file).expanduser()
167
+ if p.is_file():
168
+ return p.read_text(encoding="utf-8").strip()
169
+ raise FileNotFoundError(f"system_prompt_file not found: {p}")
170
+
171
+ # Default
172
+ return SYSTEM_PROMPT
173
+
174
+
175
+ def compose_pregame_briefing(state: dict) -> str:
176
+ """Compose a strategic briefing from initial game state + static game data.
177
+
178
+ Sent once at game start so the LLM knows map, base position, faction, tech tree,
179
+ and available units/buildings without needing extra tool calls.
180
+ """
181
+ map_info = state.get("map", {})
182
+ map_w = map_info.get("width", 0)
183
+ map_h = map_info.get("height", 0)
184
+ map_name = map_info.get("map_name", "?")
185
+
186
+ # Determine base position from buildings/units
187
+ buildings = state.get("buildings_summary", [])
188
+ units = state.get("units_summary", [])
189
+ all_positions = [(b["cell_x"], b["cell_y"]) for b in buildings] + \
190
+ [(u["cell_x"], u["cell_y"]) for u in units]
191
+ if all_positions:
192
+ base_x = sum(p[0] for p in all_positions) // len(all_positions)
193
+ base_y = sum(p[1] for p in all_positions) // len(all_positions)
194
+ else:
195
+ base_x, base_y = map_w // 2, map_h // 2
196
+
197
+ # Estimate enemy spawn โ€” opposite side of map
198
+ enemy_x = max(2, min(map_w - 2, map_w - base_x))
199
+ enemy_y = max(2, min(map_h - 2, map_h - base_y))
200
+
201
+ # Determine faction and side
202
+ faction = state.get("faction", "")
203
+ allied_factions = {"england", "france", "germany"}
204
+ soviet_factions = {"russia", "ukraine"}
205
+ if faction in allied_factions:
206
+ side = "Allied"
207
+ barracks = "tent"
208
+ elif faction in soviet_factions:
209
+ side = "Soviet"
210
+ barracks = "barr"
211
+ else:
212
+ # Infer from available production or buildings
213
+ avail = state.get("available_production", [])
214
+ bldg_types = state.get("building_types", [])
215
+ if "tent" in avail or "tent" in bldg_types:
216
+ side, barracks = "Allied", "tent"
217
+ else:
218
+ side, barracks = "Soviet", "barr"
219
+
220
+ # Get tech tree โ€” returns {side: [order]} dict
221
+ tech = get_tech_tree(side.lower())
222
+ tech_order = tech.get(side.lower(), tech.get("build_order", []))
223
+
224
+ # Get faction info for available units/buildings
225
+ faction_info = get_faction_info(faction) if faction else get_faction_info(side.lower())
226
+ avail_units = faction_info.get("available_units", []) if faction_info else []
227
+ avail_buildings = faction_info.get("available_buildings", []) if faction_info else []
228
+
229
+ # Format key units with costs
230
+ unit_lines = []
231
+ for utype in avail_units[:12]: # Cap at 12 to keep concise
232
+ stats = get_unit_stats(utype)
233
+ if stats:
234
+ unit_lines.append(f" {utype}: {stats['name']} โ€” ${stats['cost']}, {stats.get('category', '?')}")
235
+
236
+ # Format key buildings with costs and power
237
+ bldg_lines = []
238
+ for btype in avail_buildings[:10]:
239
+ stats = get_building_stats(btype)
240
+ if stats:
241
+ power = stats.get("power", 0)
242
+ power_str = f", {power:+d} power" if power else ""
243
+ bldg_lines.append(f" {btype}: {stats['name']} โ€” ${stats['cost']}{power_str}")
244
+
245
+ # Calculate defense direction
246
+ dx = enemy_x - base_x
247
+ dy = enemy_y - base_y
248
+ dir_parts = []
249
+ if dy < -map_h // 6:
250
+ dir_parts.append("North")
251
+ elif dy > map_h // 6:
252
+ dir_parts.append("South")
253
+ if dx > map_w // 6:
254
+ dir_parts.append("East")
255
+ elif dx < -map_w // 6:
256
+ dir_parts.append("West")
257
+ defense_direction = "".join(dir_parts) if dir_parts else "Center"
258
+
259
+ parts = [
260
+ "## Strategic Briefing",
261
+ f"Map: {map_name} ({map_w}x{map_h})",
262
+ f"Your faction: {faction or side} ({side})",
263
+ f"Your base: ({base_x}, {base_y})",
264
+ f"Enemy likely near: ({enemy_x}, {enemy_y})",
265
+ f"Enemy approach direction: {defense_direction}",
266
+ "",
267
+ f"Tech tree: {' โ†’ '.join(tech_order[:8])}{'...' if len(tech_order) > 8 else ''}",
268
+ f"Barracks type: {barracks}",
269
+ "",
270
+ "Available units:",
271
+ *unit_lines,
272
+ "",
273
+ "Available buildings:",
274
+ *bldg_lines,
275
+ ]
276
+ return "\n".join(parts)
277
+
278
+
279
+ def format_state_briefing(state: dict) -> str:
280
+ """Format game state (from get_game_state tool) into a compact turn briefing with positions."""
281
+ if not isinstance(state, dict) or "tick" not in state:
282
+ return ""
283
+
284
+ eco = state.get("economy", {})
285
+ tick = state["tick"]
286
+ cash = eco.get("cash", 0)
287
+ ore = eco.get("ore", 0)
288
+ funds = cash + ore
289
+
290
+ parts = [
291
+ f"--- TURN BRIEFING (tick {tick}, ~{tick // 25}s game time) ---",
292
+ f"Funds: ${funds} (cash=${cash} + ore=${ore}) | Power: {state.get('power_balance', 0):+d} | Harvesters: {eco.get('harvester_count', 0)} | Explored: {state.get('explored_percent', 0)}%",
293
+ ]
294
+
295
+ # Minimap (ASCII spatial overview)
296
+ minimap = state.get("minimap", "")
297
+ if minimap:
298
+ parts.append(minimap)
299
+
300
+ # Base center from buildings
301
+ buildings = state.get("buildings_summary", [])
302
+ if buildings:
303
+ base_x = sum(b["cell_x"] for b in buildings) // len(buildings)
304
+ base_y = sum(b["cell_y"] for b in buildings) // len(buildings)
305
+ parts.append(f"Base center: ({base_x},{base_y})")
306
+
307
+ # Compact unit summary grouped by type, with IDs, positions, and activity
308
+ units = state.get("units_summary", [])
309
+ if units:
310
+ by_type = defaultdict(list)
311
+ idle_ids = []
312
+ for u in units:
313
+ by_type[u["type"]].append(u)
314
+ if u.get("idle") and u.get("can_attack"):
315
+ idle_ids.append(u["id"])
316
+ unit_parts = []
317
+ for utype, us in by_type.items():
318
+ entries = []
319
+ for u in us:
320
+ pos = f"{u['id']}@({u['cell_x']},{u['cell_y']})"
321
+ if u.get("target_x") is not None:
322
+ pos += f"โ†’({u['target_x']},{u['target_y']})"
323
+ elif not u.get("idle"):
324
+ # Show short activity tag for non-idle units without tracked target
325
+ act = u.get("activity", "")
326
+ if act and act not in ("Idle", "Unknown", "Wait"):
327
+ tag = act[:3].lower()
328
+ pos += f"โ†’{tag}"
329
+ entries.append(pos)
330
+ unit_parts.append(f"{len(us)}x{utype}[{','.join(entries)}]")
331
+ line = f"Units: {' '.join(unit_parts)}"
332
+ if idle_ids:
333
+ line += f" | Idle: [{','.join(str(i) for i in idle_ids)}]"
334
+ parts.append(line)
335
+ else:
336
+ parts.append(f"Units: {state.get('own_units', '?')}")
337
+
338
+ # Compact building summary with IDs, positions, and production category
339
+ _BLDG_CATEGORY = {"tent": "infantry", "barr": "infantry", "weap": "vehicle",
340
+ "hpad": "aircraft", "afld": "aircraft", "syrd": "ship", "spen": "ship",
341
+ "gun": "defense", "ftur": "defense", "tsla": "defense",
342
+ "sam": "defense", "agun": "defense", "pbox": "defense", "hbox": "defense"}
343
+ if buildings:
344
+ bldg_parts = []
345
+ for b in buildings:
346
+ cat = _BLDG_CATEGORY.get(b["type"], "")
347
+ cat_str = f"[{cat}]" if cat else ""
348
+ bldg_parts.append(f"{b['type']}({b['id']})@({b['cell_x']},{b['cell_y']}){cat_str}")
349
+ parts.append(f"Buildings: {' '.join(bldg_parts)}")
350
+ else:
351
+ parts.append(f"Buildings: {state.get('own_buildings', '?')} ({', '.join(state.get('building_types', []))})")
352
+
353
+ # Enemy summary with IDs and positions (units + buildings)
354
+ enemies = state.get("enemy_summary", [])
355
+ enemy_bldgs = state.get("enemy_buildings_summary", [])
356
+ if enemies or enemy_bldgs:
357
+ enemy_parts = []
358
+ if enemies:
359
+ eby_type = defaultdict(list)
360
+ for e in enemies:
361
+ eby_type[e["type"]].append(e)
362
+ for etype, es in eby_type.items():
363
+ entries = ",".join(f"{e['id']}@({e['cell_x']},{e['cell_y']})" for e in es)
364
+ enemy_parts.append(f"{len(es)}x{etype}[{entries}]")
365
+ if enemy_bldgs:
366
+ ebby_type = defaultdict(list)
367
+ for b in enemy_bldgs:
368
+ ebby_type[b["type"]].append(b)
369
+ for btype, bs in ebby_type.items():
370
+ entries = ",".join(f"{b['id']}@({b['cell_x']},{b['cell_y']})" for b in bs)
371
+ enemy_parts.append(f"{len(bs)}x{btype}[{entries}]")
372
+ # Average position of all visible enemies
373
+ all_enemy_pos = (
374
+ [(e["cell_x"], e["cell_y"]) for e in enemies]
375
+ + [(b["cell_x"], b["cell_y"]) for b in enemy_bldgs]
376
+ )
377
+ avg_x = sum(p[0] for p in all_enemy_pos) // len(all_enemy_pos)
378
+ avg_y = sum(p[1] for p in all_enemy_pos) // len(all_enemy_pos)
379
+ parts.append(f"Enemies: {' '.join(enemy_parts)} center ({avg_x},{avg_y})")
380
+ else:
381
+ n_enemy = state.get("visible_enemy_units", 0)
382
+ parts.append(f"Enemies: {'none visible' if n_enemy == 0 else f'{n_enemy} visible'}")
383
+
384
+ prod = state.get("production_items", [])
385
+ if prod:
386
+ active = [p for p in prod if "@100%" not in p]
387
+ ready = [p.split("@")[0] for p in prod if "@100%" in p]
388
+ parts_prod = []
389
+ if active:
390
+ parts_prod.append(", ".join(active))
391
+ if ready:
392
+ parts_prod.append(f"READY TO PLACE: {', '.join(ready)}")
393
+ parts.append(f"Production: {' | '.join(parts_prod)}")
394
+ else:
395
+ parts.append("Production: IDLE")
396
+
397
+ available = state.get("available_production", [])
398
+ if available:
399
+ parts.append(f"Can build: {', '.join(available)}")
400
+
401
+ alerts = state.get("alerts", [])
402
+ if alerts:
403
+ parts.append("ALERTS:")
404
+ for a in alerts:
405
+ parts.append(f" ** {a}")
406
+
407
+ parts.append("---")
408
+
409
+ if state.get("done"):
410
+ parts.append(f"GAME OVER: {state.get('result', '?')}")
411
+
412
+ return "\n".join(parts)
413
+
414
+
415
+ def mcp_tools_to_openai(tools: list) -> list[dict]:
416
+ """Convert MCP Tool schemas to OpenAI function calling format."""
417
+ result = []
418
+ for tool in tools:
419
+ schema = tool.input_schema if hasattr(tool, 'input_schema') else {}
420
+ # Clean up schema โ€” remove 'title' which confuses some models
421
+ params = dict(schema) if schema else {}
422
+ params.pop("title", None)
423
+ if "properties" not in params:
424
+ params["properties"] = {}
425
+ params["type"] = "object"
426
+
427
+ result.append({
428
+ "type": "function",
429
+ "function": {
430
+ "name": tool.name,
431
+ "description": tool.description or "",
432
+ "parameters": params,
433
+ },
434
+ })
435
+ return result
436
+
437
+
438
+ def _sanitize_messages(messages: list[dict], prompts=None) -> list[dict]:
439
+ """Merge consecutive same-role messages for strict-alternation models (e.g. Mistral).
440
+
441
+ Some models require strict user/assistant alternation and reject sequences
442
+ like ``user โ†’ user`` or ``tool โ†’ user``. This helper:
443
+ 1. Merges consecutive ``user`` messages by joining their content with newlines.
444
+ 2. Inserts a bridge ``assistant`` message when a ``tool`` result is followed
445
+ by a ``user`` message (Mistral requires tool โ†’ assistant โ†’ user).
446
+ """
447
+ if not messages:
448
+ return messages
449
+
450
+ bridge = prompts.sanitize_bridge if prompts else "Acknowledged. Continuing."
451
+ merged: list[dict] = [dict(messages[0])]
452
+ for msg in messages[1:]:
453
+ prev = merged[-1]
454
+ # Merge consecutive user messages
455
+ if msg["role"] == "user" and prev["role"] == "user":
456
+ merged[-1] = {**prev, "content": prev["content"] + "\n\n" + msg["content"]}
457
+ continue
458
+ # Bridge: tool โ†’ user needs an assistant message in between
459
+ if msg["role"] == "user" and prev["role"] == "tool":
460
+ merged.append({"role": "assistant", "content": bridge})
461
+ merged.append(msg)
462
+ return merged
463
+
464
+
465
+ async def chat_completion(
466
+ messages: list[dict],
467
+ tools: list[dict],
468
+ llm_config: LLMConfig,
469
+ verbose: bool = False,
470
+ prompts=None,
471
+ ) -> dict:
472
+ """Call an OpenAI-compatible chat completions API.
473
+
474
+ Works with OpenRouter, Ollama, LM Studio, or any endpoint
475
+ implementing the OpenAI Chat Completions spec with tool calling.
476
+ """
477
+ clean_messages = _sanitize_messages(messages, prompts=prompts)
478
+ payload = {
479
+ "model": llm_config.model,
480
+ "messages": clean_messages,
481
+ "max_tokens": llm_config.max_tokens,
482
+ }
483
+ if tools:
484
+ payload["tools"] = tools
485
+ payload["tool_choice"] = "auto"
486
+ if llm_config.temperature is not None:
487
+ payload["temperature"] = llm_config.temperature
488
+ if llm_config.top_p is not None:
489
+ payload["top_p"] = llm_config.top_p
490
+ if llm_config.reasoning_effort is not None:
491
+ payload["reasoning"] = {"effort": llm_config.reasoning_effort}
492
+
493
+ headers = dict(llm_config.extra_headers)
494
+ if llm_config.api_key:
495
+ headers["Authorization"] = f"Bearer {llm_config.api_key}"
496
+
497
+ async with httpx.AsyncClient() as client:
498
+ if verbose:
499
+ n_msgs = len(clean_messages)
500
+ roles = [m.get("role", "?") for m in clean_messages]
501
+ print(f" [LLM] Sending {n_msgs} messages to {llm_config.model}...")
502
+ print(f" [LLM] Roles: {' โ†’ '.join(roles)}")
503
+
504
+ response = await client.post(
505
+ llm_config.base_url,
506
+ headers=headers,
507
+ json=payload,
508
+ timeout=llm_config.request_timeout_s,
509
+ )
510
+
511
+ if response.status_code != 200:
512
+ error_text = response.text[:2000]
513
+ raise RuntimeError(
514
+ _format_llm_api_error(response.status_code, error_text, llm_config)
515
+ )
516
+
517
+ try:
518
+ data = response.json()
519
+ except (json.JSONDecodeError, ValueError) as e:
520
+ raise RuntimeError(f"LLM API error 502: invalid JSON response ({e})")
521
+
522
+ if "error" in data:
523
+ raise RuntimeError(f"LLM API error 500: {data['error']}")
524
+
525
+ if verbose:
526
+ usage = data.get("usage", {})
527
+ print(
528
+ f" [LLM] Response: {usage.get('prompt_tokens', '?')} prompt + "
529
+ f"{usage.get('completion_tokens', '?')} completion tokens"
530
+ )
531
+
532
+ return data
533
+
534
+
535
+ def compress_history(messages: list[dict], keep_last: int = 40,
536
+ trigger: int = 0, prompts=None, compression=None) -> list[dict]:
537
+ """Compress message history to stay within context limits.
538
+
539
+ Keeps the system prompt and the last ``keep_last`` messages, replacing
540
+ earlier messages with a state-aware summary that preserves critical
541
+ game context (buildings, economy, strategy, military, errors).
542
+
543
+ Args:
544
+ keep_last: Number of recent messages to keep after compression.
545
+ trigger: Compress when total messages exceed this threshold.
546
+ 0 (default) means ``keep_last * 2``.
547
+ prompts: PromptsConfig for customizable text.
548
+ compression: CompressionConfig controlling what to include in summary.
549
+ """
550
+ threshold = trigger if trigger > 0 else keep_last * 2
551
+ if len(messages) <= threshold:
552
+ return messages
553
+
554
+ system = messages[0]
555
+ # Find a clean cut point: recent must not start with tool role
556
+ cut = len(messages) - keep_last
557
+ while cut < len(messages) and messages[cut].get("role") == "tool":
558
+ cut += 1 # move cut forward to skip orphaned tool results
559
+ if cut >= len(messages) - 2:
560
+ return messages # can't compress safely
561
+
562
+ old_messages = messages[1:cut]
563
+ recent = messages[cut:]
564
+
565
+ # Compression config defaults
566
+ inc_strategy = compression.include_strategy if compression else True
567
+ inc_military = compression.include_military if compression else True
568
+ inc_production = compression.include_production if compression else True
569
+
570
+ # Extract game state context from old messages
571
+ last_state = {}
572
+ building_types = set()
573
+ unit_types_produced = set()
574
+ strategy_text = ""
575
+ errors = []
576
+
577
+ for msg in old_messages:
578
+ # Extract planning strategy from early user messages
579
+ if inc_strategy and msg.get("role") == "user" and not strategy_text:
580
+ content_str = msg.get("content", "")
581
+ if isinstance(content_str, str):
582
+ for line in content_str.split("\n"):
583
+ if line.strip().startswith("Strategy:"):
584
+ strategy_text = line.strip()
585
+ break
586
+
587
+ if msg.get("role") != "tool":
588
+ continue
589
+ try:
590
+ content = json.loads(msg["content"]) if isinstance(msg["content"], str) else msg["content"]
591
+ if not isinstance(content, dict):
592
+ continue
593
+
594
+ # Track latest state snapshot
595
+ if "tick" in content and "economy" in content:
596
+ last_state = content
597
+
598
+ # Track buildings built
599
+ for bt in content.get("building_types", []):
600
+ building_types.add(bt)
601
+
602
+ # Track units produced (from build_unit notes)
603
+ if inc_production and "note" in content:
604
+ note = content["note"]
605
+ if isinstance(note, str) and "queued" in note:
606
+ # Extract unit/building name from "'name' ... queued"
607
+ import re
608
+ m = re.search(r"'(\w+)'.*queued", note)
609
+ if m:
610
+ name = m.group(1)
611
+ # Distinguish units from buildings
612
+ if "per unit" in note or "each" in note:
613
+ unit_types_produced.add(name)
614
+ else:
615
+ building_types.add(name)
616
+
617
+ # Track placement failures and errors
618
+ if content.get("placement_failed"):
619
+ errors.append("placement failed")
620
+ elif "error" in content and isinstance(content["error"], str):
621
+ err = content["error"]
622
+ if len(err) < 80:
623
+ errors.append(err)
624
+ except (json.JSONDecodeError, TypeError):
625
+ pass
626
+
627
+ # Build summary
628
+ parts = [f"[History: {len(old_messages)} earlier messages removed]"]
629
+
630
+ if last_state:
631
+ eco = last_state.get("economy", {})
632
+ parts.append(
633
+ f"Last state at tick {last_state.get('tick', '?')}: "
634
+ f"${eco.get('cash', '?')} cash, "
635
+ f"{last_state.get('own_units', '?')} units, "
636
+ f"{last_state.get('own_buildings', '?')} buildings"
637
+ )
638
+
639
+ if inc_strategy and strategy_text:
640
+ parts.append(strategy_text)
641
+
642
+ if building_types:
643
+ parts.append(f"Buildings built: {', '.join(sorted(building_types))}")
644
+
645
+ if inc_production and unit_types_produced:
646
+ parts.append(f"Units produced: {', '.join(sorted(unit_types_produced))}")
647
+
648
+ if inc_military and last_state:
649
+ mil = last_state.get("military", {})
650
+ if mil:
651
+ parts.append(
652
+ f"Military: {mil.get('units_killed', 0)} kills, "
653
+ f"{mil.get('units_lost', 0)} losses"
654
+ )
655
+
656
+ if errors:
657
+ unique = list(dict.fromkeys(errors))[-3:]
658
+ parts.append(f"Recent issues: {'; '.join(unique)}")
659
+
660
+ suffix = prompts.compression_suffix if prompts else "Game continues from current state."
661
+ parts.append(suffix)
662
+
663
+ return [
664
+ system,
665
+ {"role": "user", "content": "\n".join(parts)},
666
+ *recent,
667
+ ]
668
+
669
+
670
+ async def run_agent(config, verbose: bool = False):
671
+ """Connect to OpenRA-RL and play a game using an LLM agent."""
672
+ url = config.agent.server_url
673
+ llm_config = config.llm
674
+ max_turns = config.agent.max_turns
675
+ max_time = config.agent.max_time_s
676
+
677
+ # Auto-increase timeout for local models (they're slower than cloud APIs)
678
+ is_local = any(h in llm_config.base_url for h in ("localhost", "127.0.0.1"))
679
+ if is_local and llm_config.request_timeout_s <= 120.0:
680
+ llm_config = llm_config.model_copy(update={"request_timeout_s": 300.0})
681
+
682
+ print(f"Connecting to {url}...")
683
+ print(f"Model: {llm_config.model} @ {llm_config.base_url}")
684
+ if is_local:
685
+ print(f"Timeout: {int(llm_config.request_timeout_s)}s (local model)")
686
+
687
+ if "openrouter.ai" in llm_config.base_url.lower():
688
+ print("Checking model route for tool-calling support...")
689
+ try:
690
+ preflight_ok, preflight_err = await _preflight_tool_calling_support(llm_config)
691
+ except Exception as e:
692
+ print(f" [ERROR] Preflight check failed: {e}")
693
+ print(" Aborting before game launch (no match started).")
694
+ return
695
+ if not preflight_ok:
696
+ print(f" [ERROR] Preflight check failed: {preflight_err}")
697
+ print(" Aborting before game launch (no match started).")
698
+ return
699
+
700
+ async with OpenRAMCPClient(base_url=url, message_timeout_s=300.0) as env:
701
+ print("Resetting environment (launching OpenRA)...")
702
+ await env.reset()
703
+
704
+ # Discover and convert tools
705
+ mcp_tools = await env.list_tools()
706
+ openai_tools = mcp_tools_to_openai(mcp_tools)
707
+ tool_names = {t["function"]["name"] for t in openai_tools}
708
+ print(f"Discovered {len(mcp_tools)} MCP tools")
709
+
710
+ if verbose:
711
+ for t in mcp_tools:
712
+ print(f" - {t.name}: {t.description[:60]}...")
713
+
714
+ # Initialize conversation
715
+ system_prompt = load_system_prompt(config)
716
+ messages = [{"role": "system", "content": system_prompt}]
717
+
718
+ # โ”€โ”€โ”€ Pre-Game Planning Phase โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
719
+ planning_strategy = ""
720
+ planning_status = await env.call_tool("get_planning_status")
721
+
722
+ if planning_status.get("planning_enabled", True) is not False:
723
+ print("Starting pre-game planning phase...")
724
+ planning_data = await env.call_tool("start_planning_phase")
725
+
726
+ if planning_data.get("planning_active"):
727
+ max_planning_turns = planning_data.get("max_turns", 10)
728
+ opponent_summary = planning_data.get("opponent_summary", "")
729
+
730
+ prompts = config.prompts
731
+ planning_prompt = prompts.planning_prompt.format(
732
+ max_turns=max_planning_turns,
733
+ map_name=planning_data.get("map", {}).get("map_name", "?"),
734
+ map_width=planning_data.get("map", {}).get("width", "?"),
735
+ map_height=planning_data.get("map", {}).get("height", "?"),
736
+ base_x=planning_data.get("base_position", {}).get("x", "?"),
737
+ base_y=planning_data.get("base_position", {}).get("y", "?"),
738
+ enemy_x=planning_data.get("enemy_estimated_position", {}).get("x", "?"),
739
+ enemy_y=planning_data.get("enemy_estimated_position", {}).get("y", "?"),
740
+ faction=planning_data.get("your_faction", "?"),
741
+ side=planning_data.get("your_side", "?"),
742
+ opponent_summary=opponent_summary,
743
+ planning_nudge=prompts.planning_nudge,
744
+ )
745
+ messages.append({"role": "user", "content": planning_prompt})
746
+
747
+ # Planning loop (bounded by max_planning_turns + margin)
748
+ planning_done = False
749
+ for planning_turn in range(max_planning_turns + 2):
750
+ try:
751
+ response = await chat_completion(messages, openai_tools, llm_config, verbose, prompts=config.prompts)
752
+ except (RuntimeError, httpx.ReadTimeout, httpx.ConnectTimeout) as e:
753
+ print(f" [Planning] API error: {e}")
754
+ print(" Skipping planning phase.")
755
+ break
756
+ if response is None:
757
+ break
758
+
759
+ choice = response["choices"][0]
760
+ assistant_msg = choice["message"]
761
+ messages.append(assistant_msg)
762
+
763
+ if verbose and assistant_msg.get("content"):
764
+ print(f" [Planning] {assistant_msg['content'][:200]}")
765
+
766
+ tool_calls = assistant_msg.get("tool_calls", [])
767
+ if not tool_calls:
768
+ messages.append({
769
+ "role": "user",
770
+ "content": prompts.planning_nudge,
771
+ })
772
+ continue
773
+
774
+ for tc in tool_calls:
775
+ fn_name = tc["function"]["name"]
776
+ try:
777
+ fn_args = json.loads(tc["function"].get("arguments", "{}"))
778
+ except (json.JSONDecodeError, TypeError):
779
+ fn_args = {}
780
+
781
+ if verbose:
782
+ args_str = json.dumps(fn_args)
783
+ if len(args_str) > 80:
784
+ args_str = args_str[:80] + "..."
785
+ print(f" [Planning Tool] {fn_name}({args_str})")
786
+
787
+ try:
788
+ result = await env.call_tool(fn_name, **fn_args)
789
+ except Exception as e:
790
+ result = {"error": str(e)}
791
+
792
+ messages.append({
793
+ "role": "tool",
794
+ "tool_call_id": tc["id"],
795
+ "content": json.dumps(result) if not isinstance(result, str) else result,
796
+ })
797
+
798
+ # Check if planning ended
799
+ if isinstance(result, dict):
800
+ if result.get("planning_complete"):
801
+ planning_strategy = result.get("strategy", "")
802
+ planning_done = True
803
+ if verbose:
804
+ print(f" [Planning] Strategy: {planning_strategy[:150]}...")
805
+ elif result.get("planning_expired"):
806
+ planning_strategy = result.get("strategy", "")
807
+ planning_done = True
808
+ print(f" [Planning] Expired: {result.get('reason', '?')}")
809
+
810
+ if planning_done:
811
+ break
812
+
813
+ if not planning_done:
814
+ # Force end planning
815
+ try:
816
+ result = await env.call_tool(
817
+ "end_planning_phase",
818
+ strategy="(planning timed out, no explicit strategy)"
819
+ )
820
+ planning_strategy = result.get("strategy", "")
821
+ except Exception:
822
+ pass
823
+ print(" Planning phase timed out, proceeding to gameplay.")
824
+
825
+ print(f"Planning phase complete. Strategy recorded: {bool(planning_strategy)}")
826
+ else:
827
+ if verbose:
828
+ print(f" Planning: {planning_data.get('message', 'skipped')}")
829
+
830
+ # โ”€โ”€โ”€ Game Start โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
831
+ # Reset messages to just system prompt โ€” planning context is captured
832
+ # in the strategy text below. This avoids tool/user role alternation
833
+ # issues with models that enforce strict message ordering (e.g. Mistral).
834
+ messages = [messages[0]] # keep only system prompt
835
+
836
+ state = await env.call_tool("get_game_state")
837
+ briefing = compose_pregame_briefing(state)
838
+
839
+ strategy_section = ""
840
+ if planning_strategy:
841
+ strategy_section = f"\n\n## Your Pre-Game Strategy\n{planning_strategy}\n"
842
+
843
+ # Find MCV unit ID and barracks type for context
844
+ mcv_id = None
845
+ for u in state.get("units_summary", []):
846
+ if u.get("type") == "mcv":
847
+ mcv_id = u["id"]
848
+ break
849
+ faction = state.get("faction", "")
850
+ barracks_type = "tent" if faction in {"england", "france", "germany"} else "barr"
851
+
852
+ mcv_note = f" Your MCV is unit {mcv_id}." if mcv_id else ""
853
+
854
+ game_start_prompts = config.prompts
855
+ messages.append({
856
+ "role": "user",
857
+ "content": game_start_prompts.game_start.format(
858
+ strategy_section=strategy_section,
859
+ briefing=briefing,
860
+ barracks_type=barracks_type,
861
+ mcv_note=mcv_note,
862
+ ),
863
+ })
864
+
865
+ total_tool_calls = 0
866
+ total_api_calls = 0
867
+ start_time = time.time()
868
+ game_done = False
869
+ encountered_agent_error = False
870
+ consecutive_errors = 0
871
+ MAX_CONSECUTIVE_ERRORS = 3
872
+
873
+ turn = 0
874
+ while True:
875
+ # Check limits
876
+ elapsed = time.time() - start_time
877
+ if max_time and elapsed >= max_time:
878
+ print(f"\n TIME LIMIT reached ({max_time}s). Stopping.")
879
+ break
880
+ if max_turns and turn >= max_turns:
881
+ break
882
+ turn += 1
883
+
884
+ # Compress history periodically (unless disabled)
885
+ if llm_config.compression_strategy != "none":
886
+ messages = compress_history(
887
+ messages, keep_last=llm_config.keep_last_messages,
888
+ trigger=llm_config.compression_trigger,
889
+ prompts=config.prompts,
890
+ compression=config.prompts.compression)
891
+
892
+ # Inject state briefing before LLM thinks (skip first turn โ€” initial state already provided)
893
+ if total_api_calls > 0:
894
+ try:
895
+ briefing_state = await env.call_tool("get_game_state")
896
+ briefing = format_state_briefing(briefing_state)
897
+ if briefing:
898
+ messages.append({"role": "user", "content": briefing})
899
+ if verbose:
900
+ # Print just the alerts
901
+ for a in briefing_state.get("alerts", []):
902
+ print(f" [ALERT] {a}")
903
+ # Check game over from briefing
904
+ if isinstance(briefing_state, dict) and briefing_state.get("done"):
905
+ game_done = True
906
+ print(f"\n GAME OVER: {briefing_state.get('result', '?').upper()} at tick {briefing_state.get('tick', '?')}")
907
+ break
908
+ except Exception:
909
+ pass
910
+
911
+ # Call LLM with retry for rate limits
912
+ response = None
913
+ max_retries = llm_config.max_retries
914
+ is_local = any(h in llm_config.base_url for h in ("localhost", "127.0.0.1"))
915
+ for attempt in range(max_retries):
916
+ try:
917
+ response = await chat_completion(messages, openai_tools, llm_config, verbose, prompts=config.prompts)
918
+ break
919
+ except (httpx.ReadTimeout, httpx.ConnectTimeout):
920
+ timeout_s = int(llm_config.request_timeout_s)
921
+ print(f"\n [ERROR] Request timed out after {timeout_s}s.")
922
+ encountered_agent_error = True
923
+ if is_local:
924
+ print(" [HINT] Local models can be slow. Increase timeout in config.yaml:")
925
+ print(f" llm.request_timeout_s: {timeout_s * 2}")
926
+ break
927
+ except RuntimeError as e:
928
+ err_str = str(e)
929
+ retriable = any(code in err_str for code in ("429", "500", "502", "503", "504"))
930
+ if retriable and attempt < max_retries - 1:
931
+ wait = llm_config.retry_backoff_s * (attempt + 1)
932
+ print(f"\n [RETRY] Provider error, waiting {wait}s ({attempt + 1}/{max_retries})...")
933
+ print(f" {e}")
934
+ await asyncio.sleep(wait)
935
+ else:
936
+ print(f"\n [ERROR] API call failed: {e}")
937
+ encountered_agent_error = True
938
+ break
939
+ if response is None:
940
+ print(" [ERROR] Stopping agent.")
941
+ encountered_agent_error = True
942
+ break
943
+
944
+ total_api_calls += 1
945
+ choice = response["choices"][0]
946
+ assistant_msg = choice["message"]
947
+
948
+ # Add assistant response to history
949
+ messages.append(assistant_msg)
950
+
951
+ # Print assistant's reasoning
952
+ if assistant_msg.get("content") and verbose:
953
+ print(f"\n [LLM thinks] {assistant_msg['content'][:200]}")
954
+
955
+ # Handle tool calls
956
+ tool_calls = assistant_msg.get("tool_calls", [])
957
+ if not tool_calls:
958
+ # No tool calls โ€” prompt to act
959
+ if verbose:
960
+ content = assistant_msg.get("content", "(no content)")
961
+ print(f" [LLM] No tool calls. Response: {content[:100]}")
962
+ messages.append({
963
+ "role": "user",
964
+ "content": config.prompts.no_tool_nudge,
965
+ })
966
+ continue
967
+
968
+ # Execute each tool call
969
+ for tc in tool_calls:
970
+ fn_name = tc["function"]["name"]
971
+ try:
972
+ fn_args = json.loads(tc["function"].get("arguments", "{}"))
973
+ except (json.JSONDecodeError, TypeError):
974
+ fn_args = {}
975
+
976
+ total_tool_calls += 1
977
+
978
+ if verbose:
979
+ args_str = json.dumps(fn_args)
980
+ if len(args_str) > 80:
981
+ args_str = args_str[:80] + "..."
982
+ print(f" [Tool] {fn_name}({args_str})")
983
+
984
+ try:
985
+ result = await env.call_tool(fn_name, **fn_args)
986
+ consecutive_errors = 0
987
+ except Exception as e:
988
+ result = {"error": str(e)}
989
+ # Suggest similar tools for unknown tool errors
990
+ if fn_name not in tool_names:
991
+ import difflib
992
+ close = difflib.get_close_matches(fn_name, tool_names, n=3, cutoff=0.4)
993
+ # Always include canonical build tools for build-related names
994
+ build_keywords = {"build", "place", "train", "produce", "construct"}
995
+ if any(kw in fn_name.lower() for kw in build_keywords):
996
+ for bt in ("build_unit", "build_structure", "build_and_place"):
997
+ if bt in tool_names and bt not in close:
998
+ close.append(bt)
999
+ if close:
1000
+ result["suggested_tools"] = close
1001
+
1002
+ # Detect game connection lost
1003
+ if isinstance(result, dict) and "connection lost" in str(result.get("error", "")).lower():
1004
+ consecutive_errors += 1
1005
+ if consecutive_errors >= MAX_CONSECUTIVE_ERRORS:
1006
+ print(f"\n GAME CRASHED: {consecutive_errors} consecutive connection errors. Stopping.")
1007
+ encountered_agent_error = True
1008
+ game_done = True
1009
+
1010
+ # Format result for message
1011
+ result_str = json.dumps(result) if not isinstance(result, str) else result
1012
+
1013
+ messages.append({
1014
+ "role": "tool",
1015
+ "tool_call_id": tc["id"],
1016
+ "content": result_str,
1017
+ })
1018
+
1019
+ # Check for game over
1020
+ if isinstance(result, dict) and result.get("done"):
1021
+ game_done = True
1022
+ print(f"\n GAME OVER: {result.get('result', '?').upper()} at tick {result.get('tick', '?')}")
1023
+
1024
+ if verbose and isinstance(result, dict):
1025
+ result_preview = json.dumps(result)
1026
+ if len(result_preview) > 500:
1027
+ result_preview = result_preview[:500] + "..."
1028
+ print(f" [Result] {result_preview}")
1029
+
1030
+ # Status update
1031
+ if total_api_calls % 5 == 0 or game_done:
1032
+ elapsed = time.time() - start_time
1033
+ limit_str = f"/{max_turns}" if max_turns else ""
1034
+ time_str = f"{elapsed:.0f}/{max_time}s" if max_time else f"{elapsed:.0f}s"
1035
+ print(
1036
+ f" Turn {turn}{limit_str} | "
1037
+ f"API calls: {total_api_calls} | "
1038
+ f"Tool calls: {total_tool_calls} | "
1039
+ f"Time: {time_str}"
1040
+ )
1041
+
1042
+ if game_done:
1043
+ break
1044
+
1045
+ # Check finish reason
1046
+ if choice.get("finish_reason") == "stop" and not tool_calls:
1047
+ messages.append({
1048
+ "role": "user",
1049
+ "content": config.prompts.continue_nudge,
1050
+ })
1051
+
1052
+ # Surrender so the replay has a proper ending
1053
+ if not game_done:
1054
+ try:
1055
+ await env.call_tool("surrender")
1056
+ print("\n Surrendered (replay will have proper ending)")
1057
+ except Exception:
1058
+ pass
1059
+
1060
+ # Final report
1061
+ elapsed = time.time() - start_time
1062
+ print()
1063
+ print("=" * 70)
1064
+ print(f"Agent finished after {total_api_calls} API calls, {total_tool_calls} tool calls")
1065
+ print(f"Time: {elapsed:.1f}s ({elapsed / max(total_api_calls, 1):.1f}s per API call)")
1066
+
1067
+ # Get final state and scorecard
1068
+ try:
1069
+ final = await env.call_tool("get_game_state")
1070
+ mil = final.get("military", {})
1071
+ eco = final.get("economy", {})
1072
+ print(f"Result: {final.get('result', 'ongoing').upper()}")
1073
+ print()
1074
+ print("--- SCORECARD ---")
1075
+ print(f" Planning: {'ON โ€” ' + planning_strategy[:100] if planning_strategy else 'OFF'}")
1076
+ print(f" Ticks played: {final.get('tick', '?')}")
1077
+ print(f" Units killed: {mil.get('units_killed', 0)} (value: ${mil.get('kills_cost', 0)})")
1078
+ print(f" Units lost: {mil.get('units_lost', 0)} (value: ${mil.get('deaths_cost', 0)})")
1079
+ print(f" Buildings killed: {mil.get('buildings_killed', 0)}")
1080
+ print(f" Buildings lost: {mil.get('buildings_lost', 0)}")
1081
+ print(f" Army value: ${mil.get('army_value', 0)}")
1082
+ print(f" Assets value: ${mil.get('assets_value', 0)}")
1083
+ print(f" Experience: {mil.get('experience', 0)}")
1084
+ print(f" Orders issued: {mil.get('order_count', 0)}")
1085
+ print(f" Cash remaining: ${eco.get('cash', 0)}")
1086
+ print(f" K/D cost ratio: {mil.get('kills_cost', 0) / max(mil.get('deaths_cost', 1), 1):.2f}")
1087
+ print(f" Own units: {final.get('own_units', '?')}")
1088
+ print(f" Own buildings: {final.get('own_buildings', '?')}")
1089
+ print(f" Explored: {final.get('explored_percent', 0)}%")
1090
+ rv = final.get("reward_vector", {})
1091
+ if rv:
1092
+ print(" Reward vector:")
1093
+ for dim, val in rv.items():
1094
+ print(f" {dim:15s} {val:+.3f}")
1095
+ print()
1096
+ except Exception as e:
1097
+ print(f" (could not get final state: {e})")
1098
+
1099
+ # Get replay
1100
+ replay = {}
1101
+ try:
1102
+ replay = await env.call_tool("get_replay_path")
1103
+ if replay.get("path"):
1104
+ print(f"Replay: {replay['path']}")
1105
+ except Exception:
1106
+ pass
1107
+
1108
+ # Auto-export bench submission JSON (always local, upload gated on errors)
1109
+ should_export, should_upload, skip_reason = _bench_export_policy(encountered_agent_error)
1110
+ try:
1111
+ from datetime import datetime, timezone
1112
+ from pathlib import Path
1113
+
1114
+ resolved_name = config.agent.agent_name or llm_config.model
1115
+ sub = {
1116
+ "agent_name": resolved_name,
1117
+ "agent_type": config.agent.agent_type or "LLM",
1118
+ "agent_url": config.agent.agent_url,
1119
+ "opponent": config.opponent.bot_type.capitalize(),
1120
+ "games": 1,
1121
+ "result": final.get("result", ""),
1122
+ "win": final.get("result") == "win",
1123
+ "ticks": final.get("tick", 0),
1124
+ "kills_cost": mil.get("kills_cost", 0),
1125
+ "deaths_cost": mil.get("deaths_cost", 0),
1126
+ "kd_ratio": round(mil.get("kills_cost", 0) / max(mil.get("deaths_cost", 1), 1), 2),
1127
+ "assets_value": mil.get("assets_value", 0),
1128
+ "explored_percent": final.get("explored_percent", 0),
1129
+ "reward_vector": final.get("reward_vector", {}),
1130
+ "replay_path": replay.get("path", ""),
1131
+ "timestamp": datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"),
1132
+ }
1133
+ export_dir = Path.home() / ".openra-rl" / "bench-exports"
1134
+ export_dir.mkdir(parents=True, exist_ok=True)
1135
+ ts = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ")
1136
+ slug = resolved_name.replace("/", "_")[:40]
1137
+ export_path = export_dir / f"bench-{slug}-{ts}.json"
1138
+ export_path.write_text(json.dumps(sub, indent=2))
1139
+ print(f"Bench export: {export_path}")
1140
+
1141
+ # Auto-upload to bench if enabled (skip when agent errors occurred)
1142
+ bench_url = config.agent.bench_url
1143
+ if config.agent.bench_upload and bench_url:
1144
+ if not should_upload:
1145
+ print(f"Skipping bench upload: {skip_reason}")
1146
+ else:
1147
+ try:
1148
+ from openra_env.bench_submit import gradio_submit
1149
+ msg = gradio_submit(bench_url, sub, replay_path=replay.get("path", ""))
1150
+ print(f"Uploaded to bench: {msg}")
1151
+ except Exception as e:
1152
+ print(f" (bench upload failed: {e})")
1153
+ except Exception as e:
1154
+ print(f" (bench export failed: {e})")
1155
+
1156
+ print("=" * 70)
openra_env/bench_export.py ADDED
@@ -0,0 +1,95 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Build bench export JSON from a final game observation.
2
+
3
+ Custom agents that use OpenRAEnv directly (CNN, RL, multi-agent, etc.)
4
+ can call build_bench_export() after their game loop to produce a bench
5
+ submission JSON โ€” the same format the built-in LLM agent auto-exports.
6
+
7
+ Usage:
8
+ from openra_env.bench_export import build_bench_export
9
+
10
+ obs = await env.step(action) # final observation (obs.done == True)
11
+ export = build_bench_export(
12
+ obs,
13
+ agent_name="DeathBot-9000",
14
+ agent_type="RL",
15
+ opponent="Normal",
16
+ )
17
+ print(f"Saved to {export['path']}")
18
+
19
+ # Then submit:
20
+ # openra-rl bench submit <path>
21
+ """
22
+
23
+ import json
24
+ from datetime import datetime, timezone
25
+ from pathlib import Path
26
+ from typing import Any, Dict, Optional
27
+
28
+
29
+ def build_bench_export(
30
+ obs: Any,
31
+ agent_name: str,
32
+ agent_type: str = "RL",
33
+ opponent: str = "Normal",
34
+ agent_url: str = "",
35
+ replay_path: str = "",
36
+ export_dir: Optional[Path] = None,
37
+ ) -> Dict[str, Any]:
38
+ """Build and save a bench export JSON from a final observation.
39
+
40
+ Args:
41
+ obs: Final observation โ€” either a dict or a Pydantic model with
42
+ .military, .economy, .tick, .result, .explored_percent attributes.
43
+ agent_name: Display name for the leaderboard.
44
+ agent_type: One of "Scripted", "LLM", "RL".
45
+ opponent: Difficulty tier (Beginner/Easy/Medium/Normal/Hard).
46
+ agent_url: Optional GitHub/project URL.
47
+ replay_path: Optional path to .orarep replay file.
48
+ export_dir: Where to save the JSON (default: ~/.openra-rl/bench-exports/).
49
+
50
+ Returns:
51
+ Dict with all submission fields plus "path" pointing to the saved file.
52
+ """
53
+ # Normalize obs to dict
54
+ if hasattr(obs, "model_dump"):
55
+ obs_dict = obs.model_dump()
56
+ elif hasattr(obs, "__dict__") and not isinstance(obs, dict):
57
+ obs_dict = vars(obs)
58
+ else:
59
+ obs_dict = dict(obs)
60
+
61
+ mil = obs_dict.get("military") or {}
62
+ kills = mil.get("kills_cost", 0)
63
+ deaths = mil.get("deaths_cost", 0)
64
+
65
+ sub = {
66
+ "agent_name": agent_name,
67
+ "agent_type": agent_type,
68
+ "agent_url": agent_url,
69
+ "opponent": opponent,
70
+ "games": 1,
71
+ "result": obs_dict.get("result", ""),
72
+ "win": obs_dict.get("result") == "win",
73
+ "ticks": obs_dict.get("tick", 0),
74
+ "kills_cost": kills,
75
+ "deaths_cost": deaths,
76
+ "kd_ratio": round(kills / max(deaths, 1), 2),
77
+ "assets_value": mil.get("assets_value", 0),
78
+ "explored_percent": obs_dict.get("explored_percent", 0),
79
+ "reward_vector": obs_dict.get("reward_vector", {}),
80
+ "replay_path": replay_path,
81
+ "timestamp": datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"),
82
+ }
83
+
84
+ # Save to disk
85
+ if export_dir is None:
86
+ export_dir = Path.home() / ".openra-rl" / "bench-exports"
87
+ export_dir.mkdir(parents=True, exist_ok=True)
88
+
89
+ ts = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ")
90
+ slug = agent_name.replace("/", "_").replace(" ", "_")[:40]
91
+ export_path = export_dir / f"bench-{slug}-{ts}.json"
92
+ export_path.write_text(json.dumps(sub, indent=2))
93
+
94
+ sub["path"] = str(export_path)
95
+ return sub
openra_env/bench_submit.py ADDED
@@ -0,0 +1,167 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """CLI tool to upload bench export JSON to OpenRA-Bench leaderboard.
2
+
3
+ Usage:
4
+ openra-rl bench submit result.json
5
+ openra-rl bench submit result.json --agent-name DeathBot-9000 --agent-type RL
6
+ openra-rl bench submit result.json --replay game.orarep
7
+ openra-rl bench submit result.json --bench-url http://localhost:7860
8
+ """
9
+
10
+ import argparse
11
+ import json
12
+ import sys
13
+ from pathlib import Path
14
+
15
+ import httpx
16
+
17
+ DEFAULT_BENCH_URL = "https://openra-rl-openra-bench.hf.space"
18
+
19
+
20
+ def _gradio_call(bench_url: str, api_name: str, payload: dict, timeout: float = 30) -> str:
21
+ """Call a Gradio SSE endpoint (two-step protocol).
22
+
23
+ 1. POST /gradio_api/call/<api_name> โ†’ {"event_id": "..."}
24
+ 2. GET /gradio_api/call/<api_name>/<event_id> โ†’ SSE stream
25
+ """
26
+ base = bench_url.rstrip("/")
27
+
28
+ resp = httpx.post(
29
+ f"{base}/gradio_api/call/{api_name}",
30
+ json=payload,
31
+ timeout=timeout,
32
+ )
33
+ if resp.status_code != 200:
34
+ raise RuntimeError(f"HTTP {resp.status_code}: {resp.text[:200]}")
35
+
36
+ event_id = resp.json().get("event_id")
37
+ if not event_id:
38
+ raise RuntimeError(f"No event_id in response: {resp.text[:200]}")
39
+
40
+ with httpx.stream(
41
+ "GET",
42
+ f"{base}/gradio_api/call/{api_name}/{event_id}",
43
+ timeout=timeout,
44
+ ) as stream:
45
+ for line in stream.iter_lines():
46
+ if line.startswith("data: "):
47
+ result = json.loads(line[6:])
48
+ if isinstance(result, list) and result:
49
+ return result[0]
50
+ return str(result)
51
+
52
+ raise RuntimeError("No result received from SSE stream")
53
+
54
+
55
+ def gradio_upload_file(bench_url: str, file_path: str, timeout: float = 30) -> dict:
56
+ """Upload a file to a Gradio app and return the file reference.
57
+
58
+ Returns a dict like {"path": "...", "orig_name": "...", "size": ...}
59
+ that can be passed as a file input in a Gradio API call.
60
+ """
61
+ base = bench_url.rstrip("/")
62
+ path = Path(file_path)
63
+
64
+ with open(path, "rb") as f:
65
+ resp = httpx.post(
66
+ f"{base}/gradio_api/upload",
67
+ files={"files": (path.name, f)},
68
+ timeout=timeout,
69
+ )
70
+
71
+ if resp.status_code != 200:
72
+ raise RuntimeError(f"File upload failed: HTTP {resp.status_code}: {resp.text[:200]}")
73
+
74
+ paths = resp.json()
75
+ if not paths:
76
+ raise RuntimeError("File upload returned empty response")
77
+
78
+ return {
79
+ "path": paths[0],
80
+ "orig_name": path.name,
81
+ "size": path.stat().st_size,
82
+ "meta": {"_type": "gradio.FileData"},
83
+ }
84
+
85
+
86
+ def gradio_submit(
87
+ bench_url: str,
88
+ data: dict,
89
+ replay_path: str = "",
90
+ timeout: float = 30,
91
+ ) -> str:
92
+ """Submit bench results (and optional replay) to the Gradio leaderboard.
93
+
94
+ If replay_path points to an existing file, uploads it and uses
95
+ the submit_with_replay endpoint. Otherwise uses the JSON-only submit.
96
+ """
97
+ if replay_path and Path(replay_path).is_file():
98
+ file_ref = gradio_upload_file(bench_url, replay_path, timeout=timeout)
99
+ return _gradio_call(
100
+ bench_url,
101
+ "submit_with_replay",
102
+ {"data": [json.dumps(data), file_ref]},
103
+ timeout=timeout,
104
+ )
105
+
106
+ return _gradio_call(
107
+ bench_url,
108
+ "submit",
109
+ {"data": [json.dumps(data)]},
110
+ timeout=timeout,
111
+ )
112
+
113
+
114
+ def main() -> None:
115
+ parser = argparse.ArgumentParser(
116
+ description="Upload bench export JSON to OpenRA-Bench leaderboard"
117
+ )
118
+ parser.add_argument(
119
+ "json_file",
120
+ type=Path,
121
+ help="Path to bench export JSON file",
122
+ )
123
+ parser.add_argument("--agent-name", default=None, help="Override agent name in the submission")
124
+ parser.add_argument("--agent-type", default=None, help="Override agent type (Scripted/LLM/RL)")
125
+ parser.add_argument("--agent-url", default=None, help="GitHub/project URL for the agent")
126
+ parser.add_argument("--replay", default=None, help="Path to .orarep replay file")
127
+ parser.add_argument(
128
+ "--bench-url",
129
+ default=DEFAULT_BENCH_URL,
130
+ help=f"Bench leaderboard URL (default: {DEFAULT_BENCH_URL})",
131
+ )
132
+ args = parser.parse_args()
133
+
134
+ if not args.json_file.exists():
135
+ print(f"Error: file not found: {args.json_file}")
136
+ sys.exit(1)
137
+
138
+ try:
139
+ data = json.loads(args.json_file.read_text())
140
+ except json.JSONDecodeError as e:
141
+ print(f"Error: invalid JSON: {e}")
142
+ sys.exit(1)
143
+
144
+ # Apply CLI overrides
145
+ if args.agent_name:
146
+ data["agent_name"] = args.agent_name
147
+ if args.agent_type:
148
+ data["agent_type"] = args.agent_type
149
+ if args.agent_url:
150
+ data["agent_url"] = args.agent_url
151
+
152
+ print(f"Submitting {data.get('agent_name', '?')} vs {data.get('opponent', '?')}...")
153
+ print(f" Bench: {args.bench_url}")
154
+
155
+ try:
156
+ msg = gradio_submit(args.bench_url, data, replay_path=args.replay or "")
157
+ print(f" {msg}")
158
+ except httpx.ConnectError:
159
+ print(f"Error: could not connect to {args.bench_url}")
160
+ sys.exit(1)
161
+ except Exception as e:
162
+ print(f"Error: {e}")
163
+ sys.exit(1)
164
+
165
+
166
+ if __name__ == "__main__":
167
+ main()
openra_env/cli/__init__.py ADDED
File without changes
openra_env/cli/commands.py ADDED
@@ -0,0 +1,464 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Subcommand implementations for the openra-rl CLI."""
2
+
3
+ import shutil
4
+ import subprocess
5
+ import sys
6
+ import webbrowser
7
+ from pathlib import Path
8
+ from typing import Optional
9
+
10
+ from openra_env.cli.console import dim, error, header, info, step, success, warn
11
+ from openra_env.cli import docker_manager as docker
12
+ from openra_env.cli.wizard import (
13
+ CONFIG_PATH,
14
+ has_saved_config,
15
+ load_saved_config,
16
+ merge_cli_into_config,
17
+ run_wizard,
18
+ )
19
+
20
+
21
+ def cmd_play(
22
+ provider: Optional[str] = None,
23
+ model: Optional[str] = None,
24
+ api_key: Optional[str] = None,
25
+ difficulty: str = "normal",
26
+ verbose: bool = False,
27
+ port: int = 8000,
28
+ server_url: Optional[str] = None,
29
+ local: bool = False,
30
+ image_version: Optional[str] = None,
31
+ ) -> None:
32
+ """Run the LLM agent against the game server."""
33
+ use_docker = server_url is None and not local
34
+
35
+ # 1. Check Docker (unless --local or --server-url)
36
+ if use_docker and not docker.check_docker():
37
+ sys.exit(1)
38
+
39
+ # 1b. Version selection โ€” let user pick if multiple versions exist locally
40
+ if use_docker and image_version is None:
41
+ versions = docker.list_local_versions()
42
+ # Filter out "latest" for display โ€” only show concrete version tags
43
+ concrete = [v for v in versions if v != "latest"]
44
+ if len(concrete) > 1:
45
+ info(f"Multiple engine versions available: {', '.join(concrete)}")
46
+ try:
47
+ choice = input(f" Version to use [{concrete[0]}]: ").strip()
48
+ except (EOFError, KeyboardInterrupt):
49
+ choice = ""
50
+ if choice:
51
+ image_version = choice
52
+ else:
53
+ image_version = concrete[0]
54
+
55
+ # 2. Load or create config
56
+ has_cli_overrides = any([provider, model, api_key])
57
+
58
+ if has_cli_overrides:
59
+ config = load_saved_config() or {}
60
+ config = merge_cli_into_config(config, provider=provider, model=model, api_key=api_key)
61
+ elif has_saved_config():
62
+ config = load_saved_config() or {}
63
+ else:
64
+ config = run_wizard()
65
+
66
+ # Validate we have enough config to proceed
67
+ llm_cfg = config.get("llm", {})
68
+ base_url = llm_cfg.get("base_url", "")
69
+ is_local_llm = any(h in base_url for h in ("localhost", "127.0.0.1", "0.0.0.0"))
70
+ if not llm_cfg.get("api_key") and not is_local_llm:
71
+ error("No API key configured. Run `openra-rl config` or pass --api-key.")
72
+ sys.exit(1)
73
+ if not llm_cfg.get("model"):
74
+ error("No model configured. Run `openra-rl config` or pass --model.")
75
+ sys.exit(1)
76
+
77
+ # 3. Start/reuse server
78
+ actual_url = server_url or f"http://localhost:{port}"
79
+ we_started_server = False
80
+ local_server_proc = None
81
+
82
+ if local:
83
+ # Run the server locally (for developers with local OpenRA build)
84
+ header("Starting local server...")
85
+ local_server_proc = subprocess.Popen(
86
+ [sys.executable, "-m", "openra_env.server.app"],
87
+ stdout=sys.stdout,
88
+ stderr=sys.stderr,
89
+ )
90
+ we_started_server = True
91
+ # Wait for it to be ready
92
+ import time
93
+ import urllib.request
94
+ import urllib.error
95
+ step(f"Waiting for local server on port {port}...")
96
+ start = time.time()
97
+ while time.time() - start < 60:
98
+ try:
99
+ req = urllib.request.urlopen(f"{actual_url}/health", timeout=3)
100
+ if req.status == 200:
101
+ success("Local server is ready!")
102
+ break
103
+ except (urllib.error.URLError, OSError):
104
+ pass
105
+ time.sleep(2)
106
+ else:
107
+ error("Local server did not become ready within 60s.")
108
+ local_server_proc.terminate()
109
+ sys.exit(1)
110
+ elif use_docker:
111
+ if docker.is_running():
112
+ info(f"Server already running on port {port}.")
113
+ else:
114
+ if not docker.start_server(port=port, difficulty=difficulty, version=image_version):
115
+ sys.exit(1)
116
+ we_started_server = True
117
+ if not docker.wait_for_health(port=port):
118
+ sys.exit(1)
119
+
120
+ # 4. Run the LLM agent
121
+ header("Starting LLM agent...")
122
+ provider_name = config.get("provider", "custom")
123
+ info(f"Model: {llm_cfg.get('model', '?')} via {provider_name}")
124
+ print()
125
+
126
+ try:
127
+ _run_llm_agent(config, actual_url, verbose)
128
+ except KeyboardInterrupt:
129
+ print("\nInterrupted.")
130
+ except ConnectionRefusedError:
131
+ error(f"Could not connect to {actual_url}.")
132
+ info("Try: openra-rl server start")
133
+ info("Check: openra-rl doctor")
134
+ except Exception as e:
135
+ error(f"Agent error: {e}")
136
+ info("Run with --verbose for full details, or check: openra-rl doctor")
137
+
138
+ # 5. Auto-copy replays from Docker
139
+ if use_docker and docker.is_running():
140
+ new_replays = docker.copy_replays()
141
+ if new_replays:
142
+ print()
143
+ for f in new_replays:
144
+ success(f"Replay saved: {docker.LOCAL_REPLAY_DIR / f}")
145
+ info("Watch with: openra-rl replay watch")
146
+
147
+ # 6. Cleanup
148
+ if we_started_server:
149
+ print()
150
+ if local_server_proc:
151
+ try:
152
+ answer = input(" Stop local server? [Y/n] ").strip().lower()
153
+ except (EOFError, KeyboardInterrupt):
154
+ answer = "y"
155
+ if answer in ("", "y", "yes"):
156
+ local_server_proc.terminate()
157
+ local_server_proc.wait(timeout=10)
158
+ success("Local server stopped.")
159
+ elif use_docker:
160
+ try:
161
+ answer = input(" Stop game server? [Y/n] ").strip().lower()
162
+ except (EOFError, KeyboardInterrupt):
163
+ answer = "y"
164
+ if answer in ("", "y", "yes"):
165
+ docker.stop_server()
166
+
167
+
168
+ def _run_llm_agent(config: dict, server_url: str, verbose: bool) -> None:
169
+ """Import and run the LLM agent with the given config."""
170
+ import asyncio
171
+
172
+ from openra_env.config import load_config
173
+
174
+ # Build overrides from saved config
175
+ cli_overrides: dict = {}
176
+ llm_cfg = config.get("llm", {})
177
+ if llm_cfg:
178
+ cli_overrides["llm"] = llm_cfg
179
+ cli_overrides.setdefault("agent", {})["server_url"] = server_url
180
+ if verbose:
181
+ cli_overrides.setdefault("agent", {})["verbose"] = True
182
+
183
+ app_config = load_config(cli_overrides=cli_overrides)
184
+
185
+ from openra_env.agent import run_agent
186
+ asyncio.run(run_agent(app_config, verbose))
187
+
188
+
189
+ def cmd_config() -> None:
190
+ """Re-run the setup wizard."""
191
+ run_wizard()
192
+
193
+
194
+ def cmd_server_start(port: int = 8000, difficulty: str = "normal", detach: bool = True) -> None:
195
+ """Start the game server."""
196
+ if not docker.check_docker():
197
+ sys.exit(1)
198
+ if not docker.start_server(port=port, difficulty=difficulty, detach=detach):
199
+ sys.exit(1)
200
+ if detach:
201
+ docker.wait_for_health(port=port)
202
+
203
+
204
+ def cmd_server_stop() -> None:
205
+ """Stop the game server."""
206
+ docker.stop_server()
207
+
208
+
209
+ def cmd_server_status() -> None:
210
+ """Show game server status."""
211
+ status = docker.server_status()
212
+ if status:
213
+ success(f"Server is running: {status['status']}")
214
+ if status.get("ports"):
215
+ dim(f" Ports: {status['ports']}")
216
+ else:
217
+ info("Server is not running.")
218
+
219
+
220
+ def cmd_server_logs(follow: bool = False) -> None:
221
+ """Show game server logs."""
222
+ docker.get_logs(follow=follow)
223
+
224
+
225
+ def cmd_doctor() -> None:
226
+ """Check system prerequisites."""
227
+ header("OpenRA-RL Doctor")
228
+ ok = True
229
+
230
+ # Docker
231
+ if shutil.which("docker"):
232
+ success("Docker CLI: installed")
233
+ from openra_env.cli.docker_manager import _run
234
+ result = _run(["docker", "info"])
235
+ if result.returncode == 0:
236
+ success("Docker daemon: running")
237
+ else:
238
+ warn("Docker daemon: not running")
239
+ ok = False
240
+ else:
241
+ error("Docker CLI: not found")
242
+ dim(" Install from https://docs.docker.com/get-docker/")
243
+ ok = False
244
+
245
+ # Image
246
+ if docker.image_exists():
247
+ success(f"Game image: available ({docker.IMAGE})")
248
+ else:
249
+ warn("Game image: not pulled yet (will be pulled on first `openra-rl play`)")
250
+
251
+ # Server
252
+ if docker.is_running():
253
+ success("Game server: running")
254
+ else:
255
+ dim("Game server: not running")
256
+
257
+ # Python
258
+ py_version = f"{sys.version_info.major}.{sys.version_info.minor}.{sys.version_info.micro}"
259
+ if sys.version_info >= (3, 10):
260
+ success(f"Python: {py_version}")
261
+ else:
262
+ error(f"Python: {py_version} (requires 3.10+)")
263
+ ok = False
264
+
265
+ # Saved config
266
+ if has_saved_config():
267
+ cfg = load_saved_config() or {}
268
+ provider = cfg.get("provider", "unknown")
269
+ model = cfg.get("llm", {}).get("model", "unknown")
270
+ success(f"Config: {CONFIG_PATH}")
271
+ dim(f" Provider: {provider}, Model: {model}")
272
+ else:
273
+ dim("Config: not yet configured (run `openra-rl play` or `openra-rl config`)")
274
+
275
+ print()
276
+ if ok:
277
+ success("All checks passed!")
278
+ else:
279
+ warn("Some checks failed. Fix the issues above and try again.")
280
+
281
+
282
+ def cmd_version() -> None:
283
+ """Print version."""
284
+ try:
285
+ from importlib.metadata import version
286
+ v = version("openra-rl")
287
+ except Exception:
288
+ v = "dev"
289
+ print(f"openra-rl {v}")
290
+
291
+
292
+ def cmd_mcp_server(server_url: Optional[str] = None, port: int = 8000) -> None:
293
+ """Start the MCP stdio server."""
294
+ from openra_env.mcp_server import main as mcp_main
295
+ mcp_main(server_url=server_url or f"http://localhost:{port}")
296
+
297
+
298
+ # โ”€โ”€ Replay commands โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
299
+
300
+
301
+ def cmd_replay_watch(
302
+ file: Optional[str] = None,
303
+ port: int = 6080,
304
+ resolution: Optional[str] = None,
305
+ render_mode: Optional[str] = None,
306
+ vnc_quality: Optional[int] = None,
307
+ vnc_compression: Optional[int] = None,
308
+ cpu_cores: Optional[int] = None,
309
+ ) -> None:
310
+ """Watch a replay in the browser via VNC-in-Docker."""
311
+ if not docker.check_docker():
312
+ sys.exit(1)
313
+
314
+ try:
315
+ viewer_settings = docker.load_replay_viewer_settings(
316
+ resolution=resolution,
317
+ render_mode=render_mode,
318
+ vnc_quality=vnc_quality,
319
+ vnc_compression=vnc_compression,
320
+ cpu_cores=cpu_cores,
321
+ )
322
+ except ValueError as exc:
323
+ error(f"Invalid replay viewer setting: {exc}")
324
+ sys.exit(1)
325
+
326
+ replay_path = file
327
+
328
+ if replay_path is None:
329
+ # Check local replays first (most reliable โ€” file is mounted directly)
330
+ local_replays = sorted(docker.LOCAL_REPLAY_DIR.glob("*.orarep"))
331
+ if local_replays:
332
+ replay_path = str(local_replays[-1])
333
+ info(f"Latest local replay: {local_replays[-1].name}")
334
+ elif docker.is_running():
335
+ # Fall back to container path (uses --volumes-from, less reliable)
336
+ replay_path = docker.get_latest_replay()
337
+ if replay_path:
338
+ info(f"Latest container replay: {Path(replay_path).name}")
339
+ if replay_path is None:
340
+ error("No replays found. Play a game first with: openra-rl play")
341
+ sys.exit(1)
342
+
343
+ header("Starting replay viewer...")
344
+ info(
345
+ f"Settings: {viewer_settings.width}x{viewer_settings.height}, "
346
+ f"render={viewer_settings.render_mode}, "
347
+ f"vnc q/c={viewer_settings.vnc_quality}/{viewer_settings.vnc_compression}"
348
+ )
349
+
350
+ if not docker.start_replay_viewer(replay_path, port=port, settings=viewer_settings):
351
+ sys.exit(1)
352
+
353
+ import time
354
+ import urllib.error
355
+ import urllib.request
356
+
357
+ url = (
358
+ f"http://localhost:{port}/vnc.html?autoconnect=1&resize=scale"
359
+ f"&quality={viewer_settings.vnc_quality}"
360
+ f"&compression={viewer_settings.vnc_compression}"
361
+ )
362
+ step("Waiting for viewer to be ready...")
363
+
364
+ ready = False
365
+ start_time = time.time()
366
+ timeout = 30
367
+ while time.time() - start_time < timeout:
368
+ if not docker.is_replay_viewer_running():
369
+ error("Replay viewer exited before it became ready.")
370
+ logs = docker.get_replay_viewer_logs()
371
+ if logs:
372
+ print()
373
+ info("Replay viewer logs:")
374
+ print(logs)
375
+ sys.exit(1)
376
+ try:
377
+ req = urllib.request.urlopen(f"http://localhost:{port}/vnc.html", timeout=2)
378
+ if 200 <= req.status < 500:
379
+ ready = True
380
+ break
381
+ except (urllib.error.URLError, OSError):
382
+ pass
383
+ time.sleep(1)
384
+
385
+ if not ready:
386
+ error(f"Viewer did not become ready within {timeout}s.")
387
+ logs = docker.get_replay_viewer_logs()
388
+ if logs:
389
+ print()
390
+ info("Replay viewer logs:")
391
+ print(logs)
392
+ sys.exit(1)
393
+
394
+ info(f"Opening {url}")
395
+ webbrowser.open(url)
396
+ print()
397
+ info("Tip: press F12 in the viewer for maximum replay speed.")
398
+ info("Tip: tune with --resolution, --render, --vnc-quality, --vnc-compression.")
399
+ info("Press Ctrl+C to stop the replay viewer")
400
+ print()
401
+
402
+ try:
403
+ # Wait until container exits or user presses Ctrl+C
404
+ while docker.is_replay_viewer_running():
405
+ time.sleep(2)
406
+ info("Replay viewer has stopped.")
407
+ except KeyboardInterrupt:
408
+ print()
409
+ docker.stop_replay_viewer()
410
+
411
+
412
+ def cmd_replay_list() -> None:
413
+ """List available replays from Docker and local."""
414
+ header("Game Replays")
415
+
416
+ # Docker replays
417
+ if docker.is_running():
418
+ docker_replays = docker.list_replays()
419
+ if docker_replays:
420
+ info(f"In Docker container ({len(docker_replays)}):")
421
+ for r in docker_replays:
422
+ dim(f" {Path(r).name}")
423
+ else:
424
+ dim(" No replays in Docker container.")
425
+ else:
426
+ dim(" Docker server not running โ€” cannot list container replays.")
427
+
428
+ # Local replays
429
+ print()
430
+ local_dir = docker.LOCAL_REPLAY_DIR
431
+ if local_dir.exists():
432
+ local_replays = sorted(local_dir.glob("*.orarep"))
433
+ if local_replays:
434
+ info(f"Local ({len(local_replays)}) โ€” {local_dir}:")
435
+ for r in local_replays:
436
+ dim(f" {r.name}")
437
+ else:
438
+ dim(f" No local replays in {local_dir}")
439
+ else:
440
+ dim(f" No local replay directory ({local_dir})")
441
+
442
+
443
+ def cmd_replay_copy() -> None:
444
+ """Copy replays from Docker container to local directory."""
445
+ if not docker.check_docker():
446
+ sys.exit(1)
447
+
448
+ if not docker.is_running():
449
+ error("Game server is not running. Start it first or use: openra-rl server start")
450
+ sys.exit(1)
451
+
452
+ header("Copying replays from Docker...")
453
+ new_files = docker.copy_replays()
454
+ if new_files:
455
+ for f in new_files:
456
+ success(f" Copied: {f}")
457
+ success(f"Copied {len(new_files)} new replay(s) to {docker.LOCAL_REPLAY_DIR}")
458
+ else:
459
+ info(f"No new replays to copy. Replays are in {docker.LOCAL_REPLAY_DIR}")
460
+
461
+
462
+ def cmd_replay_stop() -> None:
463
+ """Stop the replay viewer."""
464
+ docker.stop_replay_viewer()
openra_env/cli/console.py ADDED
@@ -0,0 +1,43 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """ANSI colored console output helpers (no external deps)."""
2
+
3
+ import sys
4
+
5
+ # ANSI codes โ€” disabled when not a TTY
6
+ _IS_TTY = hasattr(sys.stdout, "isatty") and sys.stdout.isatty()
7
+
8
+ _RESET = "\033[0m" if _IS_TTY else ""
9
+ _BOLD = "\033[1m" if _IS_TTY else ""
10
+ _GREEN = "\033[32m" if _IS_TTY else ""
11
+ _YELLOW = "\033[33m" if _IS_TTY else ""
12
+ _RED = "\033[31m" if _IS_TTY else ""
13
+ _CYAN = "\033[36m" if _IS_TTY else ""
14
+ _DIM = "\033[2m" if _IS_TTY else ""
15
+
16
+
17
+ def info(msg: str) -> None:
18
+ print(f" {msg}")
19
+
20
+
21
+ def success(msg: str) -> None:
22
+ print(f" {_GREEN}{msg}{_RESET}")
23
+
24
+
25
+ def error(msg: str) -> None:
26
+ print(f" {_RED}{msg}{_RESET}", file=sys.stderr)
27
+
28
+
29
+ def warn(msg: str) -> None:
30
+ print(f" {_YELLOW}{msg}{_RESET}")
31
+
32
+
33
+ def step(msg: str) -> None:
34
+ """Print a progress step (e.g. 'Pulling image...')."""
35
+ print(f" {_CYAN}{msg}{_RESET}")
36
+
37
+
38
+ def header(msg: str) -> None:
39
+ print(f"\n {_BOLD}{msg}{_RESET}")
40
+
41
+
42
+ def dim(msg: str) -> None:
43
+ print(f" {_DIM}{msg}{_RESET}")
openra_env/cli/docker_manager.py ADDED
@@ -0,0 +1,600 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Docker orchestration for the OpenRA-RL game server."""
2
+
3
+ import json
4
+ import os
5
+ import shutil
6
+ import subprocess
7
+ import sys
8
+ import time
9
+ from dataclasses import dataclass
10
+ from pathlib import Path
11
+ from typing import Optional
12
+
13
+ from openra_env.cli.console import error, info, step, success
14
+
15
+ IMAGE_REPO = "ghcr.io/yxc20089/openra-rl"
16
+ IMAGE = f"{IMAGE_REPO}:latest"
17
+ CONTAINER_NAME = "openra-rl-server"
18
+ REPLAY_CONTAINER = "openra-rl-replay"
19
+ REPLAY_DIR_IN_CONTAINER = "/root/.config/openra/Replays/ra"
20
+ LOCAL_REPLAY_DIR = Path.home() / ".openra-rl" / "replays"
21
+ MANIFEST_PATH = LOCAL_REPLAY_DIR / "manifest.json"
22
+
23
+
24
+ def _run(args: list[str], capture: bool = True, **kwargs) -> subprocess.CompletedProcess:
25
+ """Run a subprocess command, capturing output by default."""
26
+ return subprocess.run(
27
+ args,
28
+ capture_output=capture,
29
+ text=True,
30
+ encoding="utf-8",
31
+ **kwargs,
32
+ )
33
+
34
+
35
+ def check_docker() -> bool:
36
+ """Verify docker CLI is available and daemon is running."""
37
+ if not shutil.which("docker"):
38
+ error("Docker not found. Install it from https://docs.docker.com/get-docker/")
39
+ return False
40
+ result = _run(["docker", "info"])
41
+ if result.returncode != 0:
42
+ error("Docker daemon is not running. Start Docker Desktop and try again.")
43
+ return False
44
+ return True
45
+
46
+
47
+ def _image_tag(version: Optional[str] = None) -> str:
48
+ """Return the full image tag for a given version (default: latest)."""
49
+ tag = version or "latest"
50
+ return f"{IMAGE_REPO}:{tag}"
51
+
52
+
53
+ def pull_image(version: Optional[str] = None, quiet: bool = False) -> bool:
54
+ """Pull the game server image from GHCR."""
55
+ image = _image_tag(version)
56
+ if not quiet:
57
+ step(f"Pulling game server image ({image})...")
58
+ result = subprocess.run(
59
+ ["docker", "pull", image],
60
+ stdout=sys.stdout if not quiet else subprocess.DEVNULL,
61
+ stderr=sys.stderr if not quiet else subprocess.DEVNULL,
62
+ )
63
+ if result.returncode != 0:
64
+ error(f"Failed to pull {image}")
65
+ return False
66
+ if not quiet:
67
+ success("Image pulled successfully.")
68
+ return True
69
+
70
+
71
+ def image_exists(version: Optional[str] = None) -> bool:
72
+ """Check if the game server image is available locally."""
73
+ image = _image_tag(version)
74
+ result = _run(["docker", "images", "-q", image])
75
+ return bool(result.stdout.strip())
76
+
77
+
78
+ def list_local_versions() -> list[str]:
79
+ """List all locally available openra-rl image versions (tags), newest first."""
80
+ result = _run([
81
+ "docker", "images", IMAGE_REPO,
82
+ "--format", "{{.Tag}}",
83
+ ])
84
+ if result.returncode != 0:
85
+ return []
86
+ tags = [t.strip() for t in result.stdout.splitlines() if t.strip()]
87
+ # Put "latest" first, then sort the rest in reverse
88
+ versions = sorted([t for t in tags if t != "latest"], reverse=True)
89
+ if "latest" in tags:
90
+ versions.insert(0, "latest")
91
+ return versions
92
+
93
+
94
+ def get_running_image_tag() -> Optional[str]:
95
+ """Get the image tag of the currently running game server container."""
96
+ if not is_running():
97
+ return None
98
+ result = _run([
99
+ "docker", "inspect", CONTAINER_NAME,
100
+ "--format", "{{.Config.Image}}",
101
+ ])
102
+ if result.returncode != 0:
103
+ return None
104
+ image = result.stdout.strip()
105
+ # Extract tag from "ghcr.io/yxc20089/openra-rl:0.2.1"
106
+ if ":" in image:
107
+ return image.split(":")[-1]
108
+ return "latest"
109
+
110
+
111
+ # โ”€โ”€ Replay manifest โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
112
+
113
+
114
+ def _load_manifest() -> dict:
115
+ """Load the replay manifest (replay filename โ†’ image tag)."""
116
+ if MANIFEST_PATH.exists():
117
+ try:
118
+ return json.loads(MANIFEST_PATH.read_text(encoding="utf-8"))
119
+ except (json.JSONDecodeError, OSError):
120
+ pass
121
+ return {}
122
+
123
+
124
+ def _save_manifest(manifest: dict) -> None:
125
+ """Save the replay manifest."""
126
+ MANIFEST_PATH.parent.mkdir(parents=True, exist_ok=True)
127
+ MANIFEST_PATH.write_text(json.dumps(manifest, indent=2) + "\n", encoding="utf-8")
128
+
129
+
130
+ def get_replay_image_tag(replay_filename: str) -> Optional[str]:
131
+ """Look up which image tag was used to record a replay."""
132
+ manifest = _load_manifest()
133
+ return manifest.get(replay_filename)
134
+
135
+
136
+ def _record_replays_in_manifest(filenames: list[str], image_tag: str) -> None:
137
+ """Record which image tag was used for newly copied replays."""
138
+ if not filenames:
139
+ return
140
+ manifest = _load_manifest()
141
+ for f in filenames:
142
+ manifest[f] = image_tag
143
+ _save_manifest(manifest)
144
+
145
+
146
+ def is_running() -> bool:
147
+ """Check if the game server container is running."""
148
+ result = _run([
149
+ "docker", "ps", "--filter", f"name={CONTAINER_NAME}",
150
+ "--format", "{{.Names}}"
151
+ ])
152
+ return CONTAINER_NAME in result.stdout
153
+
154
+
155
+ def start_server(
156
+ port: int = 8000,
157
+ difficulty: str = "normal",
158
+ detach: bool = True,
159
+ version: Optional[str] = None,
160
+ ) -> bool:
161
+ """Start the game server container."""
162
+ if is_running():
163
+ info(f"Server already running on port {port}.")
164
+ return True
165
+
166
+ image = _image_tag(version)
167
+
168
+ # Ensure image exists
169
+ if not image_exists(version):
170
+ if not pull_image(version):
171
+ return False
172
+
173
+ step(f"Starting game server on port {port} ({image})...")
174
+ cmd = [
175
+ "docker", "run", "--rm",
176
+ "-d" if detach else "",
177
+ "-p", f"{port}:8000",
178
+ "--name", CONTAINER_NAME,
179
+ "-e", f"BOT_TYPE={difficulty}",
180
+ image,
181
+ ]
182
+ # Remove empty strings from cmd
183
+ cmd = [c for c in cmd if c]
184
+
185
+ result = _run(cmd)
186
+ if result.returncode != 0:
187
+ error(f"Failed to start server: {result.stderr.strip()}")
188
+ return False
189
+ return True
190
+
191
+
192
+ def stop_server() -> bool:
193
+ """Stop and remove the game server container."""
194
+ if not is_running():
195
+ info("Server is not running.")
196
+ return True
197
+ step("Stopping game server...")
198
+ result = _run(["docker", "stop", CONTAINER_NAME])
199
+ if result.returncode != 0:
200
+ error(f"Failed to stop server: {result.stderr.strip()}")
201
+ return False
202
+ success("Server stopped.")
203
+ return True
204
+
205
+
206
+ def wait_for_health(port: int = 8000, timeout: int = 120) -> bool:
207
+ """Poll the health endpoint until the server is ready."""
208
+ import urllib.request
209
+ import urllib.error
210
+
211
+ url = f"http://localhost:{port}/health"
212
+ step(f"Waiting for server to be ready (timeout {timeout}s)...")
213
+ start = time.time()
214
+ while time.time() - start < timeout:
215
+ try:
216
+ req = urllib.request.urlopen(url, timeout=3)
217
+ if req.status == 200:
218
+ success("Server is ready!")
219
+ return True
220
+ except (urllib.error.URLError, OSError):
221
+ pass
222
+ time.sleep(2)
223
+ error(f"Server did not become healthy within {timeout}s.")
224
+ return False
225
+
226
+
227
+ def get_logs(follow: bool = False) -> None:
228
+ """Print container logs."""
229
+ if not is_running():
230
+ # Try to get logs from stopped container too
231
+ pass
232
+ cmd = ["docker", "logs"]
233
+ if follow:
234
+ cmd.append("-f")
235
+ cmd.append(CONTAINER_NAME)
236
+ subprocess.run(cmd)
237
+
238
+
239
+ def server_status() -> Optional[dict]:
240
+ """Get server container status info."""
241
+ if not is_running():
242
+ return None
243
+ result = _run([
244
+ "docker", "ps", "--filter", f"name={CONTAINER_NAME}",
245
+ "--format", "{{.Status}}\t{{.Ports}}"
246
+ ])
247
+ if result.stdout.strip():
248
+ parts = result.stdout.strip().split("\t")
249
+ return {
250
+ "status": parts[0] if parts else "unknown",
251
+ "ports": parts[1] if len(parts) > 1 else "",
252
+ }
253
+ return None
254
+
255
+
256
+ # โ”€โ”€ Replay viewer settings โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
257
+
258
+
259
+ @dataclass(frozen=True)
260
+ class ReplayViewerSettings:
261
+ """Tunable replay viewer settings for quality/performance tradeoffs."""
262
+
263
+ width: int = 1280
264
+ height: int = 960
265
+ ui_scale: float = 1.0
266
+ viewport_distance: str = "Medium"
267
+ mute: bool = True
268
+ render_mode: str = "auto" # auto | gpu | cpu
269
+ vnc_quality: int = 8
270
+ vnc_compression: int = 4
271
+ cpu_cores: int = 4 # Docker --cpus limit for software rendering (0 = all available)
272
+
273
+
274
+ def _parse_resolution(value: str) -> tuple[int, int]:
275
+ """Parse a WxH resolution string."""
276
+ raw = value.strip().lower().replace(" ", "")
277
+ for sep in ("x", ","):
278
+ if sep in raw:
279
+ left, right = raw.split(sep, 1)
280
+ try:
281
+ w, h = int(left), int(right)
282
+ except ValueError:
283
+ break
284
+ if w < 320 or h < 240 or w > 7680 or h > 4320:
285
+ raise ValueError(f"resolution out of range (320x240..7680x4320): {value}")
286
+ return w, h
287
+ raise ValueError(f"resolution must be WxH (e.g. 960x540), got: {value!r}")
288
+
289
+
290
+ def _normalize_render_mode(value: str) -> str:
291
+ """Validate and normalize render mode."""
292
+ mode = value.strip().lower()
293
+ if mode not in ("auto", "gpu", "cpu"):
294
+ raise ValueError(f"render mode must be auto/gpu/cpu, got: {value!r}")
295
+ return mode
296
+
297
+
298
+ def _normalize_viewport(value: str) -> str:
299
+ """Validate and normalize viewport distance."""
300
+ mapping = {"close": "Close", "medium": "Medium", "far": "Far"}
301
+ key = value.strip().lower()
302
+ if key not in mapping:
303
+ raise ValueError(f"viewport must be close/medium/far, got: {value!r}")
304
+ return mapping[key]
305
+
306
+
307
+ def load_replay_viewer_settings(
308
+ resolution: Optional[str] = None,
309
+ render_mode: Optional[str] = None,
310
+ vnc_quality: Optional[int] = None,
311
+ vnc_compression: Optional[int] = None,
312
+ cpu_cores: Optional[int] = None,
313
+ ) -> ReplayViewerSettings:
314
+ """Load replay viewer settings from CLI overrides โ†’ env vars โ†’ defaults."""
315
+ env = os.environ
316
+
317
+ res = resolution or env.get("OPENRA_RL_REPLAY_RESOLUTION", "1280x960")
318
+ w, h = _parse_resolution(res)
319
+
320
+ mode = _normalize_render_mode(
321
+ render_mode if render_mode is not None else env.get("OPENRA_RL_REPLAY_RENDER", "auto")
322
+ )
323
+
324
+ vq = vnc_quality if vnc_quality is not None else int(env.get("OPENRA_RL_REPLAY_VNC_QUALITY", "8"))
325
+ vc = vnc_compression if vnc_compression is not None else int(env.get("OPENRA_RL_REPLAY_VNC_COMPRESSION", "4"))
326
+ vq = max(0, min(9, vq))
327
+ vc = max(0, min(9, vc))
328
+
329
+ cores = cpu_cores if cpu_cores is not None else int(env.get("OPENRA_RL_REPLAY_CPU_CORES", "4"))
330
+ if cores <= 0:
331
+ cores = os.cpu_count() or 4
332
+ cores = max(1, min(32, cores))
333
+
334
+ ui_scale = float(env.get("OPENRA_RL_REPLAY_UI_SCALE", "1"))
335
+ viewport = _normalize_viewport(env.get("OPENRA_RL_REPLAY_VIEWPORT_DISTANCE", "medium"))
336
+ mute_raw = env.get("OPENRA_RL_REPLAY_MUTE", "true").strip().lower()
337
+ mute = mute_raw not in ("0", "false", "no", "off")
338
+
339
+ return ReplayViewerSettings(
340
+ width=w, height=h, ui_scale=ui_scale, viewport_distance=viewport,
341
+ mute=mute, render_mode=mode, vnc_quality=vq, vnc_compression=vc,
342
+ cpu_cores=cores,
343
+ )
344
+
345
+
346
+ def _settings_env_args(settings: ReplayViewerSettings) -> list[str]:
347
+ """Convert settings to docker -e KEY=VAL args."""
348
+ return [
349
+ "-e", f"OPENRA_RL_REPLAY_RESOLUTION={settings.width}x{settings.height}",
350
+ "-e", f"OPENRA_RL_REPLAY_UI_SCALE={settings.ui_scale}",
351
+ "-e", f"OPENRA_RL_REPLAY_VIEWPORT_DISTANCE={settings.viewport_distance}",
352
+ "-e", f"OPENRA_RL_REPLAY_MUTE={'True' if settings.mute else 'False'}",
353
+ "-e", "SDL_AUDIODRIVER=dummy",
354
+ "-e", "OPENRA_DISPLAY_SCALE=1",
355
+ ]
356
+
357
+
358
+ def _gpu_docker_args(mode: str, cpu_cores: int = 4) -> list[list[str]]:
359
+ """Return docker arg variants for GPU passthrough, in preference order.
360
+
361
+ auto: try GPU variants first, fall back to CPU.
362
+ gpu: only try GPU variants (fail if none work).
363
+ cpu: only try CPU (software rendering).
364
+ cpu_cores: number of llvmpipe threads for software rendering.
365
+ """
366
+ cpu = ["-e", "LIBGL_ALWAYS_SOFTWARE=1", "-e", f"LP_NUM_THREADS={cpu_cores}"]
367
+ gpu_variants = [
368
+ ["--gpus", "all"], # NVIDIA
369
+ ["--device", "/dev/dxg:/dev/dxg", # WSL2 (AMD/NVIDIA/Intel)
370
+ "-v", "/usr/lib/wsl:/usr/lib/wsl:ro",
371
+ "-e", "LD_LIBRARY_PATH=/usr/lib/wsl/lib"],
372
+ ["--device", "/dev/kfd:/dev/kfd", # AMD ROCm (native Linux)
373
+ "--device", "/dev/dri:/dev/dri",
374
+ "--group-add", "video"],
375
+ ["--device", "/dev/dri:/dev/dri"], # Generic DRI (AMD/Intel)
376
+ ]
377
+ if mode == "cpu":
378
+ return [cpu]
379
+ if mode == "gpu":
380
+ return gpu_variants
381
+ # auto: try all GPU variants, then CPU fallback
382
+ return gpu_variants + [cpu]
383
+
384
+
385
+ # โ”€โ”€ Replay viewer โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
386
+
387
+
388
+ def list_replays() -> list[str]:
389
+ """List .orarep files inside the game server container."""
390
+ if not is_running():
391
+ return []
392
+ result = _run([
393
+ "docker", "exec", CONTAINER_NAME,
394
+ "find", REPLAY_DIR_IN_CONTAINER, "-name", "*.orarep", "-type", "f",
395
+ ])
396
+ if result.returncode != 0:
397
+ return []
398
+ files = [line.strip() for line in result.stdout.splitlines() if line.strip()]
399
+ files.sort()
400
+ return files
401
+
402
+
403
+ def get_latest_replay() -> Optional[str]:
404
+ """Return the path of the newest replay inside the game server container."""
405
+ replays = list_replays()
406
+ return replays[-1] if replays else None
407
+
408
+
409
+ def copy_replays() -> list[str]:
410
+ """Copy all replays from the game server container to ~/.openra-rl/replays/.
411
+
412
+ Returns list of newly copied filenames.
413
+ Also records the image tag in the manifest so replay watch uses the right version.
414
+ """
415
+ if not is_running():
416
+ error("Game server is not running โ€” cannot copy replays.")
417
+ return []
418
+
419
+ LOCAL_REPLAY_DIR.mkdir(parents=True, exist_ok=True)
420
+
421
+ # Get list of replays in container
422
+ replays = list_replays()
423
+ if not replays:
424
+ return []
425
+
426
+ # Get existing local files to detect new ones
427
+ existing = {f.name for f in LOCAL_REPLAY_DIR.iterdir() if f.suffix == ".orarep"}
428
+
429
+ # Copy each replay individually (docker cp doesn't glob well)
430
+ for replay_path in replays:
431
+ filename = os.path.basename(replay_path)
432
+ result = _run([
433
+ "docker", "cp",
434
+ f"{CONTAINER_NAME}:{replay_path}",
435
+ str(LOCAL_REPLAY_DIR / filename),
436
+ ])
437
+ if result.returncode != 0:
438
+ error(f"Failed to copy {filename}: {result.stderr.strip()}")
439
+
440
+ # Determine which files are new
441
+ after = {f.name for f in LOCAL_REPLAY_DIR.iterdir() if f.suffix == ".orarep"}
442
+ new_files = sorted(after - existing)
443
+
444
+ # Record the image version that produced these replays
445
+ if new_files:
446
+ tag = get_running_image_tag() or "latest"
447
+ _record_replays_in_manifest(new_files, tag)
448
+
449
+ return new_files
450
+
451
+
452
+ def is_replay_viewer_running() -> bool:
453
+ """Check if the replay viewer container is running."""
454
+ result = _run([
455
+ "docker", "ps", "--filter", f"name={REPLAY_CONTAINER}",
456
+ "--format", "{{.Names}}"
457
+ ])
458
+ return REPLAY_CONTAINER in result.stdout
459
+
460
+
461
+ def replay_viewer_exists() -> bool:
462
+ """Check if the replay viewer container exists (running or exited)."""
463
+ result = _run([
464
+ "docker", "ps", "-a", "--filter", f"name={REPLAY_CONTAINER}",
465
+ "--format", "{{.Names}}"
466
+ ])
467
+ return REPLAY_CONTAINER in result.stdout
468
+
469
+
470
+ def get_replay_viewer_logs(tail: int = 200) -> str:
471
+ """Return recent replay viewer logs, or empty string if unavailable."""
472
+ if not replay_viewer_exists():
473
+ return ""
474
+ result = _run(["docker", "logs", "--tail", str(tail), REPLAY_CONTAINER])
475
+ if result.returncode != 0:
476
+ return result.stderr.strip() or result.stdout.strip()
477
+ return result.stdout.strip()
478
+
479
+
480
+ def start_replay_viewer(
481
+ replay_path: str,
482
+ port: int = 6080,
483
+ version: Optional[str] = None,
484
+ settings: Optional[ReplayViewerSettings] = None,
485
+ ) -> bool:
486
+ """Start the replay viewer container.
487
+
488
+ Args:
489
+ replay_path: Path to .orarep file (container path or local path).
490
+ port: noVNC port to expose (default 6080).
491
+ version: Docker image version to use (default: auto-detect from manifest).
492
+ settings: Replay viewer tuning (resolution, render mode, etc.).
493
+ """
494
+ if settings is None:
495
+ settings = load_replay_viewer_settings()
496
+
497
+ if is_replay_viewer_running():
498
+ error("Replay viewer is already running. Stop it first with: openra-rl replay stop")
499
+ return False
500
+
501
+ # Clean up stale (exited) container if it exists
502
+ if replay_viewer_exists():
503
+ _run(["docker", "rm", "-f", REPLAY_CONTAINER])
504
+
505
+ # Auto-detect version from manifest if not specified
506
+ if version is None:
507
+ filename = os.path.basename(replay_path)
508
+ version = get_replay_image_tag(filename)
509
+ if version:
510
+ info(f"Using image version '{version}' (from manifest)")
511
+
512
+ image = _image_tag(version)
513
+
514
+ if not image_exists(version):
515
+ step(f"Image {image} not found locally, pulling...")
516
+ if not pull_image(version):
517
+ return False
518
+
519
+ # Determine if this is a local file or a container path.
520
+ local_file = None
521
+ container_replay_path = replay_path
522
+ local_path = Path(replay_path).resolve()
523
+
524
+ if local_path.exists():
525
+ local_file = str(local_path)
526
+ container_replay_path = f"/tmp/replay/{local_path.name}"
527
+ elif replay_path.startswith("/") and is_running():
528
+ # Container path โ€” copy locally first so we can mount it reliably
529
+ # (--volumes-from only shares Docker volumes, not the writable layer)
530
+ filename = os.path.basename(replay_path)
531
+ LOCAL_REPLAY_DIR.mkdir(parents=True, exist_ok=True)
532
+ local_dest = LOCAL_REPLAY_DIR / filename
533
+ cp_result = _run(["docker", "cp", f"{CONTAINER_NAME}:{replay_path}", str(local_dest)])
534
+ if cp_result.returncode == 0 and local_dest.exists():
535
+ local_file = str(local_dest)
536
+ container_replay_path = f"/tmp/replay/{filename}"
537
+ elif not replay_path.startswith("/"):
538
+ error(f"Replay file not found: {local_path}")
539
+ return False
540
+
541
+ step(f"Starting replay viewer on port {port} ({image})...")
542
+
543
+ # Build base docker command
544
+ base_cmd = [
545
+ "docker", "run", "-d",
546
+ "-p", f"{port}:6080",
547
+ "--name", REPLAY_CONTAINER,
548
+ "--entrypoint", "/replay-viewer.sh",
549
+ ]
550
+ base_cmd.extend(_settings_env_args(settings))
551
+
552
+ if local_file:
553
+ base_cmd.extend(["-v", f"{local_file}:{container_replay_path}:ro"])
554
+ elif is_running():
555
+ base_cmd.extend(["--volumes-from", CONTAINER_NAME])
556
+
557
+ # Try GPU variants in order, fall back to CPU
558
+ last_stderr = ""
559
+ for gpu_args in _gpu_docker_args(settings.render_mode, cpu_cores=settings.cpu_cores):
560
+ is_gpu = "--gpus" in gpu_args or "--device" in gpu_args
561
+ # Limit CPU for software rendering to prevent runaway usage.
562
+ # llvmpipe busy-loops without GPU; --cpus caps Docker scheduler.
563
+ cpu_limit = [] if is_gpu else ["--cpus", str(settings.cpu_cores)]
564
+ cmd = base_cmd + cpu_limit + gpu_args + [image, container_replay_path]
565
+ result = _run(cmd)
566
+ if result.returncode == 0:
567
+ if is_gpu:
568
+ gpu_args_str = " ".join(gpu_args)
569
+ if "--gpus" in gpu_args_str:
570
+ info("Rendering mode: GPU (NVIDIA)")
571
+ elif "/dev/dxg" in gpu_args_str:
572
+ info("Rendering mode: GPU (WSL2 DirectX)")
573
+ elif "/dev/kfd" in gpu_args_str:
574
+ info("Rendering mode: GPU (AMD ROCm)")
575
+ else:
576
+ info("Rendering mode: GPU (DRI)")
577
+ else:
578
+ info(f"Rendering mode: CPU (software, {settings.cpu_cores} cores)")
579
+ success("Replay viewer started.")
580
+ return True
581
+ last_stderr = result.stderr.strip()
582
+ # Clean up the failed container before trying next variant
583
+ _run(["docker", "rm", "-f", REPLAY_CONTAINER])
584
+
585
+ error(f"Failed to start replay viewer: {last_stderr}")
586
+ return False
587
+
588
+
589
+ def stop_replay_viewer() -> bool:
590
+ """Stop and remove the replay viewer container."""
591
+ if not replay_viewer_exists():
592
+ info("Replay viewer is not running.")
593
+ return True
594
+ step("Stopping replay viewer...")
595
+ result = _run(["docker", "rm", "-f", REPLAY_CONTAINER])
596
+ if result.returncode != 0:
597
+ error(f"Failed to stop replay viewer: {result.stderr.strip()}")
598
+ return False
599
+ success("Replay viewer stopped.")
600
+ return True
openra_env/cli/main.py ADDED
@@ -0,0 +1,212 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """CLI entry point for openra-rl."""
2
+
3
+ import argparse
4
+ import sys
5
+
6
+
7
+ def main() -> None:
8
+ parser = argparse.ArgumentParser(
9
+ prog="openra-rl",
10
+ description="Play Red Alert with AI agents",
11
+ )
12
+ parser.add_argument(
13
+ "--version", action="store_true",
14
+ help="Print version and exit",
15
+ )
16
+ subparsers = parser.add_subparsers(dest="command")
17
+
18
+ # โ”€โ”€ play โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
19
+ play_parser = subparsers.add_parser(
20
+ "play", help="Run the LLM agent against the game",
21
+ )
22
+ play_parser.add_argument(
23
+ "--provider", choices=["openrouter", "ollama", "lmstudio"],
24
+ help="LLM provider (overrides saved config)",
25
+ )
26
+ play_parser.add_argument("--model", help="Model ID")
27
+ play_parser.add_argument("--api-key", help="API key for LLM endpoint")
28
+ play_parser.add_argument(
29
+ "--difficulty", choices=["easy", "normal", "hard"], default="normal",
30
+ help="AI opponent difficulty (default: normal)",
31
+ )
32
+ play_parser.add_argument("--verbose", action="store_true", help="Verbose output")
33
+ play_parser.add_argument("--port", type=int, default=8000, help="Game server port (default: 8000)")
34
+ play_parser.add_argument("--server-url", help="Connect to existing server URL (skip Docker)")
35
+ play_parser.add_argument("--local", action="store_true", help="Run server locally instead of Docker (for developers)")
36
+ play_parser.add_argument("--version", dest="image_version", default=None, help="Docker image version to use (default: latest)")
37
+
38
+ # โ”€โ”€ config โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
39
+ subparsers.add_parser("config", help="Re-run the setup wizard")
40
+
41
+ # โ”€โ”€ server โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
42
+ server_parser = subparsers.add_parser("server", help="Manage the game server")
43
+ server_sub = server_parser.add_subparsers(dest="server_command")
44
+
45
+ start_parser = server_sub.add_parser("start", help="Start the game server")
46
+ start_parser.add_argument("--port", type=int, default=8000, help="Port (default: 8000)")
47
+ start_parser.add_argument(
48
+ "--difficulty", choices=["easy", "normal", "hard"], default="normal",
49
+ )
50
+ start_parser.add_argument("--detach", action="store_true", default=True, help="Run in background (default)")
51
+
52
+ server_sub.add_parser("stop", help="Stop the game server")
53
+ server_sub.add_parser("status", help="Show server status")
54
+
55
+ logs_parser = server_sub.add_parser("logs", help="Show server logs")
56
+ logs_parser.add_argument("--follow", "-f", action="store_true", help="Follow log output")
57
+
58
+ # โ”€โ”€ mcp-server โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
59
+ mcp_parser = subparsers.add_parser("mcp-server", help="Start MCP stdio server")
60
+ mcp_parser.add_argument("--server-url", help="Game server URL")
61
+ mcp_parser.add_argument("--port", type=int, default=8000, help="Game server port (default: 8000)")
62
+
63
+ # โ”€โ”€ replay โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
64
+ replay_parser = subparsers.add_parser("replay", help="Manage and watch game replays")
65
+ replay_sub = replay_parser.add_subparsers(dest="replay_command")
66
+
67
+ watch_parser = replay_sub.add_parser("watch", help="Watch a replay in your browser (via VNC)")
68
+ watch_parser.add_argument("file", nargs="?", default=None, help="Replay file (local path or container path; default: latest)")
69
+ watch_parser.add_argument("--port", type=int, default=6080, help="noVNC port (default: 6080)")
70
+ watch_parser.add_argument(
71
+ "--resolution", default=None,
72
+ help="Replay viewer resolution WxH (default: 1280x960)",
73
+ )
74
+ watch_parser.add_argument(
75
+ "--render", dest="render_mode", choices=["auto", "gpu", "cpu"], default=None,
76
+ help="Render backend: auto tries GPU then CPU (default: auto)",
77
+ )
78
+ watch_parser.add_argument(
79
+ "--vnc-quality", type=int, default=None,
80
+ help="VNC quality 0-9, higher = sharper (default: 8)",
81
+ )
82
+ watch_parser.add_argument(
83
+ "--vnc-compression", type=int, default=None,
84
+ help="VNC compression 0-9, higher = smaller (default: 4)",
85
+ )
86
+ watch_parser.add_argument(
87
+ "--cpus", type=int, default=None,
88
+ help="CPU cores for software rendering (default: 4, 0 = all available).",
89
+ )
90
+
91
+ replay_sub.add_parser("list", help="List available replays")
92
+ replay_sub.add_parser("copy", help="Copy replays from Docker to ~/.openra-rl/replays/")
93
+ replay_sub.add_parser("stop", help="Stop the replay viewer")
94
+
95
+ # โ”€โ”€ bench โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
96
+ bench_parser = subparsers.add_parser("bench", help="Benchmark leaderboard tools")
97
+ bench_sub = bench_parser.add_subparsers(dest="bench_command")
98
+
99
+ bench_submit_parser = bench_sub.add_parser("submit", help="Upload game result JSON to the leaderboard")
100
+ bench_submit_parser.add_argument("json_file", type=str, help="Path to bench export JSON file")
101
+ bench_submit_parser.add_argument("--agent-name", default=None, help="Override agent name")
102
+ bench_submit_parser.add_argument("--agent-type", default=None, help="Override agent type (Scripted/LLM/RL)")
103
+ bench_submit_parser.add_argument("--agent-url", default=None, help="GitHub/project URL")
104
+ bench_submit_parser.add_argument("--replay", default=None, help="Path to .orarep replay file")
105
+ bench_submit_parser.add_argument(
106
+ "--bench-url", default=None,
107
+ help="Bench leaderboard URL (default: https://openra-rl-openra-bench.hf.space)",
108
+ )
109
+
110
+ # โ”€โ”€ doctor โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
111
+ subparsers.add_parser("doctor", help="Check system prerequisites")
112
+
113
+ # โ”€โ”€ version โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
114
+ subparsers.add_parser("version", help="Print version")
115
+
116
+ args = parser.parse_args()
117
+
118
+ # Handle --version at top level
119
+ if args.version:
120
+ from openra_env.cli.commands import cmd_version
121
+ cmd_version()
122
+ return
123
+
124
+ if args.command is None:
125
+ parser.print_help()
126
+ sys.exit(0)
127
+
128
+ # Dispatch
129
+ from openra_env.cli import commands
130
+
131
+ if args.command == "play":
132
+ commands.cmd_play(
133
+ provider=args.provider,
134
+ model=args.model,
135
+ api_key=args.api_key,
136
+ difficulty=args.difficulty,
137
+ verbose=args.verbose,
138
+ port=args.port,
139
+ server_url=args.server_url,
140
+ local=args.local,
141
+ image_version=args.image_version,
142
+ )
143
+ elif args.command == "config":
144
+ commands.cmd_config()
145
+ elif args.command == "server":
146
+ if args.server_command == "start":
147
+ commands.cmd_server_start(
148
+ port=args.port,
149
+ difficulty=args.difficulty,
150
+ detach=args.detach,
151
+ )
152
+ elif args.server_command == "stop":
153
+ commands.cmd_server_stop()
154
+ elif args.server_command == "status":
155
+ commands.cmd_server_status()
156
+ elif args.server_command == "logs":
157
+ commands.cmd_server_logs(follow=args.follow)
158
+ else:
159
+ server_parser.print_help()
160
+ elif args.command == "replay":
161
+ if args.replay_command == "watch":
162
+ commands.cmd_replay_watch(
163
+ file=args.file,
164
+ port=args.port,
165
+ resolution=args.resolution,
166
+ render_mode=args.render_mode,
167
+ vnc_quality=args.vnc_quality,
168
+ vnc_compression=args.vnc_compression,
169
+ cpu_cores=args.cpus,
170
+ )
171
+ elif args.replay_command == "list":
172
+ commands.cmd_replay_list()
173
+ elif args.replay_command == "copy":
174
+ commands.cmd_replay_copy()
175
+ elif args.replay_command == "stop":
176
+ commands.cmd_replay_stop()
177
+ else:
178
+ replay_parser.print_help()
179
+ elif args.command == "mcp-server":
180
+ commands.cmd_mcp_server(
181
+ server_url=args.server_url,
182
+ port=args.port,
183
+ )
184
+ elif args.command == "bench":
185
+ if args.bench_command == "submit":
186
+ from openra_env.bench_submit import main as bench_submit_main
187
+ # Patch sys.argv so bench_submit's argparse sees the right args
188
+ submit_argv = ["openra-rl bench submit", args.json_file]
189
+ if args.agent_name:
190
+ submit_argv += ["--agent-name", args.agent_name]
191
+ if args.agent_type:
192
+ submit_argv += ["--agent-type", args.agent_type]
193
+ if args.agent_url:
194
+ submit_argv += ["--agent-url", args.agent_url]
195
+ if args.replay:
196
+ submit_argv += ["--replay", args.replay]
197
+ if args.bench_url:
198
+ submit_argv += ["--bench-url", args.bench_url]
199
+ sys.argv = submit_argv
200
+ bench_submit_main()
201
+ else:
202
+ bench_parser.print_help()
203
+ elif args.command == "doctor":
204
+ commands.cmd_doctor()
205
+ elif args.command == "version":
206
+ commands.cmd_version()
207
+ else:
208
+ parser.print_help()
209
+
210
+
211
+ if __name__ == "__main__":
212
+ main()
openra_env/cli/wizard.py ADDED
@@ -0,0 +1,166 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Interactive first-run setup wizard."""
2
+
3
+ from pathlib import Path
4
+ from typing import Optional
5
+
6
+ import yaml
7
+
8
+ from openra_env.cli.console import dim, error, header, info, success, warn
9
+
10
+ CONFIG_DIR = Path.home() / ".openra-rl"
11
+ CONFIG_PATH = CONFIG_DIR / "config.yaml"
12
+
13
+ # Provider presets
14
+ PROVIDERS = {
15
+ "openrouter": {
16
+ "name": "OpenRouter",
17
+ "base_url": "https://openrouter.ai/api/v1/chat/completions",
18
+ "needs_key": True,
19
+ "key_help": "Get one at https://openrouter.ai/keys",
20
+ "default_model": "qwen/qwen3-coder-next",
21
+ },
22
+ "ollama": {
23
+ "name": "Ollama",
24
+ "base_url": "http://localhost:11434/v1/chat/completions",
25
+ "needs_key": False,
26
+ "default_model": "qwen3:32b",
27
+ },
28
+ "lmstudio": {
29
+ "name": "LM Studio",
30
+ "base_url": "http://localhost:1234/v1/chat/completions",
31
+ "needs_key": False,
32
+ "default_model": "",
33
+ "models": [],
34
+ },
35
+ }
36
+
37
+
38
+ def _prompt(question: str, default: str = "") -> str:
39
+ """Prompt user for input with optional default."""
40
+ if default:
41
+ raw = input(f" {question} [{default}]: ").strip()
42
+ return raw or default
43
+ else:
44
+ while True:
45
+ raw = input(f" {question}: ").strip()
46
+ if raw:
47
+ return raw
48
+ error("Please enter a value.")
49
+
50
+
51
+ def _choose(question: str, options: list[tuple[str, str]], allow_custom: bool = False) -> str:
52
+ """Present numbered options and get user choice."""
53
+ print(f"\n {question}")
54
+ for i, (value, label) in enumerate(options, 1):
55
+ print(f" [{i}] {label}")
56
+ if allow_custom:
57
+ print(f" [{len(options) + 1}] Enter custom value")
58
+
59
+ max_choice = len(options) + (1 if allow_custom else 0)
60
+ while True:
61
+ raw = input(" > ").strip()
62
+ try:
63
+ idx = int(raw)
64
+ if 1 <= idx <= len(options):
65
+ return options[idx - 1][0]
66
+ if allow_custom and idx == max_choice:
67
+ return _prompt("Enter value")
68
+ except ValueError:
69
+ # Allow typing the value directly
70
+ if raw:
71
+ return raw
72
+ error(f"Please enter a number 1-{max_choice}.")
73
+
74
+
75
+ def has_saved_config() -> bool:
76
+ """Check if a saved config exists."""
77
+ return CONFIG_PATH.exists()
78
+
79
+
80
+ def load_saved_config() -> Optional[dict]:
81
+ """Load saved config if it exists."""
82
+ if not CONFIG_PATH.exists():
83
+ return None
84
+ try:
85
+ with open(CONFIG_PATH, encoding="utf-8") as f:
86
+ return yaml.safe_load(f) or {}
87
+ except Exception:
88
+ return None
89
+
90
+
91
+ def save_config(config: dict) -> None:
92
+ """Save config to ~/.openra-rl/config.yaml."""
93
+ CONFIG_DIR.mkdir(parents=True, exist_ok=True)
94
+ with open(CONFIG_PATH, "w", encoding="utf-8") as f:
95
+ yaml.dump(config, f, default_flow_style=False, sort_keys=False)
96
+ success(f"Config saved to {CONFIG_PATH}")
97
+
98
+
99
+ def run_wizard() -> dict:
100
+ """Run the interactive setup wizard. Returns a config dict."""
101
+ header("Welcome to OpenRA-RL!")
102
+ info("Let's set up your LLM provider.\n")
103
+
104
+ # Choose provider
105
+ provider_key = _choose(
106
+ "Choose provider:",
107
+ [
108
+ ("openrouter", "OpenRouter (cloud โ€” Claude, GPT, Qwen, Mistral, etc.)"),
109
+ ("ollama", "Ollama (local, free)"),
110
+ ("lmstudio", "LM Studio (local, free)"),
111
+ ],
112
+ )
113
+
114
+ provider = PROVIDERS.get(provider_key, PROVIDERS["openrouter"])
115
+ config: dict = {"provider": provider_key, "llm": {"base_url": provider["base_url"]}}
116
+
117
+ # API key (if needed)
118
+ if provider.get("needs_key"):
119
+ print()
120
+ api_key = _prompt(f"Enter your {provider['name']} API key ({provider.get('key_help', '')})")
121
+ config["llm"]["api_key"] = api_key
122
+
123
+ # Model selection
124
+ if provider.get("models"):
125
+ model = _choose(
126
+ "Choose a model:",
127
+ [(m, label) for m, label in provider["models"]],
128
+ allow_custom=True,
129
+ )
130
+ else:
131
+ model = _prompt("Enter model ID", default=provider.get("default_model", ""))
132
+
133
+ config["llm"]["model"] = model
134
+
135
+ # Ollama: warn about context window
136
+ if provider_key == "ollama":
137
+ print()
138
+ warn("Tip: If you see truncation errors, increase the context window:")
139
+ dim(f" ollama create {model}-32k --from {model} --parameter num_ctx 32768")
140
+
141
+ print()
142
+ save_config(config)
143
+ dim("Run `openra-rl config` to change these settings later.\n")
144
+
145
+ return config
146
+
147
+
148
+ def merge_cli_into_config(
149
+ config: dict,
150
+ provider: Optional[str] = None,
151
+ model: Optional[str] = None,
152
+ api_key: Optional[str] = None,
153
+ ) -> dict:
154
+ """Apply CLI flag overrides onto a config dict."""
155
+ if provider and provider in PROVIDERS:
156
+ p = PROVIDERS[provider]
157
+ config.setdefault("llm", {})["base_url"] = p["base_url"]
158
+ config["provider"] = provider
159
+
160
+ if model:
161
+ config.setdefault("llm", {})["model"] = model
162
+
163
+ if api_key:
164
+ config.setdefault("llm", {})["api_key"] = api_key
165
+
166
+ return config
openra_env/client.py ADDED
@@ -0,0 +1,113 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """OpenRA-RL environment client.
2
+
3
+ Provides the EnvClient subclass for connecting to the OpenRA-RL
4
+ environment server over WebSocket.
5
+ """
6
+
7
+ import os
8
+ from typing import Any, Dict
9
+
10
+ from openenv.core.client_types import StepResult
11
+ from openenv.core.env_client import EnvClient
12
+ from websockets.asyncio.client import connect as ws_connect
13
+
14
+ from openra_env.models import (
15
+ BuildingInfoModel,
16
+ EconomyInfo,
17
+ MapInfoModel,
18
+ MilitaryInfo,
19
+ OpenRAAction,
20
+ OpenRAObservation,
21
+ OpenRAState,
22
+ ProductionInfoModel,
23
+ UnitInfoModel,
24
+ )
25
+
26
+
27
+ class OpenRAEnv(EnvClient[OpenRAAction, OpenRAObservation, OpenRAState]):
28
+ """WebSocket client for the OpenRA-RL environment.
29
+
30
+ Usage:
31
+ async with OpenRAEnv(base_url="http://localhost:8000") as env:
32
+ result = await env.reset()
33
+ while not result.done:
34
+ action = OpenRAAction(commands=[...])
35
+ result = await env.step(action)
36
+ """
37
+
38
+ async def connect(self) -> "OpenRAEnv":
39
+ """Connect with ping keepalive disabled.
40
+
41
+ OpenRA operations (especially reset) can take 60-120+ seconds
42
+ with software rendering. The default websockets ping_interval=20s
43
+ would kill the connection before the server responds.
44
+ """
45
+ if self._ws is not None:
46
+ return self
47
+
48
+ ws_url_lower = self._ws_url.lower()
49
+ is_localhost = "localhost" in ws_url_lower or "127.0.0.1" in ws_url_lower
50
+
51
+ old_no_proxy = os.environ.get("NO_PROXY")
52
+ if is_localhost:
53
+ current_no_proxy = old_no_proxy or ""
54
+ if "localhost" not in current_no_proxy.lower():
55
+ os.environ["NO_PROXY"] = (
56
+ f"{current_no_proxy},localhost,127.0.0.1"
57
+ if current_no_proxy
58
+ else "localhost,127.0.0.1"
59
+ )
60
+
61
+ try:
62
+ self._ws = await ws_connect(
63
+ self._ws_url,
64
+ open_timeout=self._connect_timeout,
65
+ max_size=self._max_message_size,
66
+ ping_interval=None,
67
+ )
68
+ except Exception as e:
69
+ raise ConnectionError(f"Failed to connect to {self._ws_url}: {e}") from e
70
+ finally:
71
+ if is_localhost:
72
+ if old_no_proxy is None:
73
+ os.environ.pop("NO_PROXY", None)
74
+ else:
75
+ os.environ["NO_PROXY"] = old_no_proxy
76
+
77
+ return self
78
+
79
+ def _step_payload(self, action: OpenRAAction) -> Dict[str, Any]:
80
+ """Convert action to JSON for WebSocket transport."""
81
+ return action.model_dump()
82
+
83
+ def _parse_result(self, data: Dict[str, Any]) -> StepResult[OpenRAObservation]:
84
+ """Parse server response into StepResult."""
85
+ obs_data = data.get("observation", data)
86
+
87
+ observation = OpenRAObservation(
88
+ tick=obs_data.get("tick", 0),
89
+ economy=EconomyInfo(**obs_data.get("economy", {})),
90
+ military=MilitaryInfo(**obs_data.get("military", {})),
91
+ units=[UnitInfoModel(**u) for u in obs_data.get("units", [])],
92
+ buildings=[BuildingInfoModel(**b) for b in obs_data.get("buildings", [])],
93
+ production=[ProductionInfoModel(**p) for p in obs_data.get("production", [])],
94
+ visible_enemies=[UnitInfoModel(**u) for u in obs_data.get("visible_enemies", [])],
95
+ visible_enemy_buildings=[BuildingInfoModel(**b) for b in obs_data.get("visible_enemy_buildings", [])],
96
+ map_info=MapInfoModel(**obs_data.get("map_info", {})),
97
+ available_production=obs_data.get("available_production", []),
98
+ done=obs_data.get("done", False),
99
+ reward=obs_data.get("reward"),
100
+ result=obs_data.get("result", ""),
101
+ spatial_map=obs_data.get("spatial_map", ""),
102
+ spatial_channels=obs_data.get("spatial_channels", 0),
103
+ )
104
+
105
+ return StepResult(
106
+ observation=observation,
107
+ reward=data.get("reward", obs_data.get("reward")),
108
+ done=data.get("done", obs_data.get("done", False)),
109
+ )
110
+
111
+ def _parse_state(self, data: Dict[str, Any]) -> OpenRAState:
112
+ """Parse state response into OpenRAState."""
113
+ return OpenRAState(**data)
openra_env/config.py ADDED
@@ -0,0 +1,535 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Unified configuration for OpenRA-RL.
2
+
3
+ Provides a single YAML-based configuration system with Pydantic validation.
4
+ Supports multiple override layers:
5
+ env vars > constructor overrides > config file > built-in defaults
6
+
7
+ Usage:
8
+ from openra_env.config import load_config
9
+ config = load_config() # auto-find config.yaml
10
+ config = load_config("path/to/config.yaml") # explicit path
11
+ config = load_config(game={"mod": "cnc"}) # with overrides
12
+ """
13
+
14
+ import os
15
+ from pathlib import Path
16
+ from typing import Optional
17
+
18
+ import yaml
19
+ from pydantic import BaseModel, Field, model_validator
20
+
21
+
22
+ # โ”€โ”€ Pydantic Config Models โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
23
+
24
+
25
+ class GameConfig(BaseModel):
26
+ openra_path: str = "/opt/openra"
27
+ mod: str = "ra"
28
+ map_name: str = "singles.oramap"
29
+ grpc_port: int = 9999
30
+ headless: bool = True
31
+ record_replays: bool = False
32
+ seed: Optional[int] = None
33
+ max_ticks: int = 0 # 0 = unlimited
34
+ max_wall_time_s: int = 0 # 0 = unlimited
35
+
36
+
37
+ class OpponentConfig(BaseModel):
38
+ # bot_type: difficulty tiers (beginner/easy/medium/hard/brutal)
39
+ # or raw OpenRA play styles (rush/normal/turtle/naval)
40
+ # ai_slot: player slot for AI; set to "" to disable enemy spawning
41
+ bot_type: str = "easy"
42
+ ai_slot: str = "Multi0"
43
+
44
+
45
+ class PlanningConfig(BaseModel):
46
+ enabled: bool = True
47
+ max_turns: int = 10
48
+ max_time_s: float = 60.0
49
+
50
+
51
+ class RewardConfig(BaseModel):
52
+ survival: float = 0.001
53
+ economic_efficiency: float = 0.01
54
+ aggression: float = 0.1
55
+ defense: float = 0.05
56
+ victory: float = 1.0
57
+ defeat: float = -1.0
58
+
59
+
60
+ class RewardVectorConfig(BaseModel):
61
+ """Configuration for the multi-dimensional reward vector.
62
+
63
+ When enabled, each step returns an 8-dimensional reward vector
64
+ (combat, economy, infrastructure, intelligence, composition,
65
+ tempo, disruption, outcome) alongside the scalar reward.
66
+ """
67
+
68
+ enabled: bool = True # 8-dimensional skill signal (combat, economy, etc.)
69
+ weights: dict[str, float] = Field(default_factory=lambda: {
70
+ "combat": 0.30,
71
+ "economy": 0.15,
72
+ "infrastructure": 0.10,
73
+ "intelligence": 0.10,
74
+ "composition": 0.10,
75
+ "tempo": 0.10,
76
+ "disruption": 0.15,
77
+ "outcome": 1.00,
78
+ })
79
+
80
+
81
+ class ToolCategoriesConfig(BaseModel):
82
+ read: bool = True
83
+ knowledge: bool = True
84
+ bulk_knowledge: bool = True
85
+ planning: bool = True
86
+ game_control: bool = True
87
+ movement: bool = True
88
+ production: bool = True
89
+ building_actions: bool = True
90
+ placement: bool = True
91
+ unit_groups: bool = True
92
+ compound: bool = True
93
+ utility: bool = True
94
+ terrain: bool = True
95
+
96
+
97
+ class ToolsConfig(BaseModel):
98
+ categories: ToolCategoriesConfig = Field(default_factory=ToolCategoriesConfig)
99
+ disabled: list[str] = Field(default_factory=list)
100
+
101
+
102
+ class AlertsConfig(BaseModel):
103
+ under_attack: bool = True
104
+ damaged_building: bool = True
105
+ low_power: bool = True
106
+ idle_funds: bool = True
107
+ ore_full: bool = True
108
+ idle_production: bool = True
109
+ production_stalled: bool = True
110
+ building_ready: bool = True
111
+ stance_warning: bool = True
112
+ idle_army: bool = True
113
+ no_defenses: bool = True
114
+ no_scouting: bool = True
115
+ loss_tracking: bool = True
116
+ minimap: bool = True # Show ASCII minimap in turn briefing
117
+ max_alerts: int = 0 # 0 = unlimited; set >0 to cap alerts per turn
118
+
119
+
120
+ class LLMConfig(BaseModel):
121
+ base_url: str = "https://openrouter.ai/api/v1/chat/completions"
122
+ api_key: str = ""
123
+ model: str = "qwen/qwen3-coder-next"
124
+ max_tokens: int = 1500
125
+ temperature: Optional[float] = None
126
+ top_p: Optional[float] = None
127
+ keep_last_messages: int = 40
128
+ compression_strategy: str = "sliding_window" # "sliding_window" or "none"
129
+ compression_trigger: int = 0 # 0 = keep_last_messages * 2
130
+ max_retries: int = 4
131
+ retry_backoff_s: int = 10
132
+ request_timeout_s: float = 120.0
133
+ reasoning_effort: Optional[str] = None # "none", "low", "medium", "high"
134
+ extra_headers: dict[str, str] = Field(
135
+ default_factory=lambda: {
136
+ "HTTP-Referer": "https://github.com/openra-rl",
137
+ "X-Title": "OpenRA-RL Agent",
138
+ }
139
+ )
140
+
141
+
142
+ class AgentConfig(BaseModel):
143
+ server_url: str = "http://localhost:8000"
144
+ max_turns: int = 0 # 0 = unlimited
145
+ max_time_s: int = 1800
146
+ verbose: bool = False
147
+ log_file: str = ""
148
+ agent_name: str = "" # Display name on leaderboard; empty = model name
149
+ agent_type: str = "" # Scripted/LLM/RL; empty = auto-detect
150
+ agent_url: str = "" # GitHub/project URL shown on leaderboard
151
+ bench_upload: bool = True # Auto-upload results to bench after each game
152
+ bench_url: str = "https://openra-rl-openra-bench.hf.space"
153
+ system_prompt: str = "" # deprecated โ€” use prompts.system_prompt
154
+ system_prompt_file: str = "" # deprecated โ€” use prompts.system_prompt_file
155
+
156
+
157
+ class AlertPromptsConfig(BaseModel):
158
+ """Templates for in-game alert messages.
159
+
160
+ All templates use Python str.format() placeholders (e.g. {balance}).
161
+ """
162
+
163
+ under_attack: str = "UNDER ATTACK: enemy {type} id={id} near base"
164
+ under_attack_mass: str = "UNDER ATTACK: {count} enemies near base ({breakdown})"
165
+ damaged: str = "DAMAGED: {type} id={id} at {hp} HP"
166
+ low_power: str = "LOW POWER: {balance} โ€” production runs at 1/3 speed"
167
+ power_tight: str = "POWER TIGHT: {balance} surplus โ€” next building may cause low power"
168
+ idle_funds: str = "IDLE FUNDS: ${funds} available, {harvesters} harvester(s)"
169
+ ore_full: str = "ORE FULL: {ore}/{cap} storage โ€” income is being lost"
170
+ idle_production: str = "IDLE PRODUCTION: no active production queue"
171
+ stalled: str = "STALLED: {item}@{progress} โ€” $0 funds, production paused"
172
+ building_stuck: str = "BUILDING STUCK: {building} โ€” auto-placement failing"
173
+ ready_to_place: str = "READY TO PLACE: {building} โ€” completed, awaiting placement"
174
+ stance: str = "STANCE: {count} combat unit(s) on ReturnFire (only fire when fired upon)"
175
+ idle_army: str = "IDLE ARMY: {count} combat units idle"
176
+ no_defenses: str = "NO DEFENSES: no defense structures built"
177
+ no_scouting: str = (
178
+ "NO SCOUTING: enemy not found โ€” {explored} of map explored, "
179
+ "{idle} idle combat units available"
180
+ )
181
+
182
+
183
+ class CompressionConfig(BaseModel):
184
+ """Controls what context is preserved in history compression summaries."""
185
+ include_strategy: bool = True # Preserve planning strategy
186
+ include_military: bool = True # Include kill/death counts
187
+ include_production: bool = True # Track what was produced
188
+
189
+
190
+ class PromptsConfig(BaseModel):
191
+ """All LLM-facing text, configurable for customization.
192
+
193
+ Templates use Python str.format() placeholders. Override individual
194
+ fields in config.yaml, or point prompts_file to a YAML with all prompts.
195
+ """
196
+
197
+ # โ”€โ”€ System prompt โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
198
+ system_prompt: str = "" # inline override (highest priority)
199
+ system_prompt_file: str = "" # path to .txt file override
200
+ prompts_file: str = "" # path to YAML with all prompts below
201
+
202
+ # โ”€โ”€ Planning phase โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
203
+ # Variables: {max_turns}, {map_name}, {map_width}, {map_height},
204
+ # {base_x}, {base_y}, {enemy_x}, {enemy_y}, {faction}, {side},
205
+ # {opponent_summary}, {planning_nudge}
206
+ planning_prompt: str = (
207
+ "## PRE-GAME PLANNING PHASE\n"
208
+ "You have {max_turns} turns to plan.\n\n"
209
+ "### Map Intel\n"
210
+ "Map: {map_name} ({map_width}x{map_height})\n"
211
+ "Your base: ({base_x}, {base_y})\n"
212
+ "Enemy estimated: ({enemy_x}, {enemy_y})\n"
213
+ "Your faction: {faction} ({side})\n\n"
214
+ "### Opponent Intelligence\n{opponent_summary}\n\n"
215
+ "{planning_nudge}"
216
+ )
217
+ planning_nudge: str = "Call end_planning_phase(strategy='...') when ready to start."
218
+ planning_instructions: str = (
219
+ "Planning phase active. Available tools: get_faction_briefing "
220
+ "(all unit/building stats), get_map_analysis (terrain/resources), "
221
+ "get_opponent_intel (enemy profile), batch_lookup (multi-item queries). "
222
+ "Call end_planning_phase(strategy=...) to begin gameplay."
223
+ )
224
+ planning_complete: str = "Planning complete. Game is now live."
225
+
226
+ # โ”€โ”€ Game start โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
227
+ # Variables: {strategy_section}, {briefing}, {barracks_type}, {mcv_note}
228
+ game_start: str = (
229
+ "Game started!{strategy_section}\n\n{briefing}\n\n"
230
+ "Your barracks type is '{barracks_type}'.{mcv_note}"
231
+ )
232
+
233
+ # โ”€โ”€ Agent nudges โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
234
+ no_tool_nudge: str = "No tool was called. A tool call is required each turn."
235
+ continue_nudge: str = "The game is still in progress."
236
+ compression_suffix: str = "Game continues from current state."
237
+ sanitize_bridge: str = "Acknowledged. Continuing."
238
+
239
+ # โ”€โ”€ Tool warnings โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
240
+ # Variables: {building}, {drain}, {balance}
241
+ power_warning: str = (
242
+ "POWER WARNING: {building} drains {drain} power. "
243
+ "Balance will be {balance}."
244
+ )
245
+ # Variables: {available}, {item}, {cost}
246
+ insufficient_funds: str = (
247
+ "Insufficient funds: ${available} available, "
248
+ "{item} costs ${cost}."
249
+ )
250
+
251
+ # โ”€โ”€ Placement feedback โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
252
+ placement_success: str = "AUTO-PLACED: {building}"
253
+ placement_failed: str = "PLACEMENT FAILED: {building} โ€” {reason}. Auto-cancelling."
254
+ placement_water: str = "WATER BUILDING: {building} requires water tiles for placement."
255
+
256
+ # โ”€โ”€ Build confirmations โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
257
+ # Variables: {building}, {cost}, {ticks}, {seconds}
258
+ build_queued: str = (
259
+ "'{building}' (${cost}) queued, auto-places on completion. "
260
+ "~{ticks} ticks (~{seconds}s)."
261
+ )
262
+ build_structure_queued: str = (
263
+ "'{building}' (${cost}) queued. ~{ticks} ticks (~{seconds}s) to complete."
264
+ )
265
+ # Variables: {count}, {unit}, {cost}, {ticks_each}, {ticks_total}, {seconds_total}
266
+ build_unit_queued: str = (
267
+ "{count}x '{unit}' (${cost} each) queued. "
268
+ "~{ticks_each} ticks per unit, ~{ticks_total} ticks (~{seconds_total}s) total."
269
+ )
270
+
271
+ # โ”€โ”€ Build guards โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
272
+ # Variables: {building}
273
+ build_already_pending: str = "'{building}' is already queued and pending auto-placement."
274
+ place_auto_managed: str = (
275
+ "'{building}' is queued via build_and_place โ€” placement is automatic."
276
+ )
277
+
278
+ # โ”€โ”€ Movement feedback โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
279
+ # Variables: {ticks}, {seconds}
280
+ move_eta: str = "Units moving. Slowest arrives in ~{ticks} ticks (~{seconds}s)."
281
+
282
+ # โ”€โ”€ Compression โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
283
+ compression: CompressionConfig = Field(default_factory=CompressionConfig)
284
+
285
+ # โ”€โ”€ Alerts โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
286
+ alerts: AlertPromptsConfig = Field(default_factory=AlertPromptsConfig)
287
+
288
+
289
+ class OpenRARLConfig(BaseModel):
290
+ """Root configuration for the OpenRA-RL system."""
291
+
292
+ game: GameConfig = Field(default_factory=GameConfig)
293
+ opponent: OpponentConfig = Field(default_factory=OpponentConfig)
294
+ planning: PlanningConfig = Field(default_factory=PlanningConfig)
295
+ reward: RewardConfig = Field(default_factory=RewardConfig)
296
+ reward_vector: RewardVectorConfig = Field(default_factory=RewardVectorConfig)
297
+ tools: ToolsConfig = Field(default_factory=ToolsConfig)
298
+ alerts: AlertsConfig = Field(default_factory=AlertsConfig)
299
+ llm: LLMConfig = Field(default_factory=LLMConfig)
300
+ agent: AgentConfig = Field(default_factory=AgentConfig)
301
+ prompts: PromptsConfig = Field(default_factory=PromptsConfig)
302
+
303
+ @model_validator(mode="after")
304
+ def sync_planning_tools(self) -> "OpenRARLConfig":
305
+ """Auto-disable planning tools when planning is disabled."""
306
+ if not self.planning.enabled:
307
+ self.tools.categories.planning = False
308
+ return self
309
+
310
+ @model_validator(mode="after")
311
+ def migrate_system_prompt(self) -> "OpenRARLConfig":
312
+ """Backward compat: copy agent.system_prompt* to prompts.* if prompts.* empty."""
313
+ if not self.prompts.system_prompt and self.agent.system_prompt:
314
+ self.prompts.system_prompt = self.agent.system_prompt
315
+ if not self.prompts.system_prompt_file and self.agent.system_prompt_file:
316
+ self.prompts.system_prompt_file = self.agent.system_prompt_file
317
+ return self
318
+
319
+
320
+ # โ”€โ”€ Tool Category Mapping โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
321
+
322
+ TOOL_CATEGORIES: dict[str, str] = {
323
+ # Read
324
+ "get_game_state": "read",
325
+ "get_economy": "read",
326
+ "get_units": "read",
327
+ "get_buildings": "read",
328
+ "get_enemies": "read",
329
+ "get_production": "read",
330
+ "get_map_info": "read",
331
+ "get_exploration_status": "read",
332
+ # Knowledge
333
+ "lookup_unit": "knowledge",
334
+ "lookup_building": "knowledge",
335
+ "lookup_tech_tree": "knowledge",
336
+ "lookup_faction": "knowledge",
337
+ # Bulk Knowledge
338
+ "get_faction_briefing": "bulk_knowledge",
339
+ "get_map_analysis": "bulk_knowledge",
340
+ "batch_lookup": "bulk_knowledge",
341
+ # Planning
342
+ "get_opponent_intel": "planning",
343
+ "start_planning_phase": "planning",
344
+ "end_planning_phase": "planning",
345
+ "get_planning_status": "planning",
346
+ # Game Control
347
+ "advance": "game_control",
348
+ # Movement
349
+ "move_units": "movement",
350
+ "attack_move": "movement",
351
+ "attack_target": "movement",
352
+ "stop_units": "movement",
353
+ # Production
354
+ "build_unit": "production",
355
+ "build_structure": "production",
356
+ "build_and_place": "production",
357
+ # Building/Unit Actions
358
+ "place_building": "building_actions",
359
+ "cancel_production": "building_actions",
360
+ "deploy_unit": "building_actions",
361
+ "sell_building": "building_actions",
362
+ "repair_building": "building_actions",
363
+ "set_rally_point": "building_actions",
364
+ "guard_target": "building_actions",
365
+ "set_stance": "building_actions",
366
+ "harvest": "building_actions",
367
+ "power_down": "building_actions",
368
+ "set_primary": "building_actions",
369
+ # Placement
370
+ "get_valid_placements": "placement",
371
+ # Unit Groups
372
+ "assign_group": "unit_groups",
373
+ "add_to_group": "unit_groups",
374
+ "get_groups": "unit_groups",
375
+ "command_group": "unit_groups",
376
+ # Compound
377
+ "batch": "compound",
378
+ "plan": "compound",
379
+ # Utility
380
+ "get_replay_path": "utility",
381
+ "surrender": "utility",
382
+ # Terrain
383
+ "get_terrain_at": "terrain",
384
+ }
385
+
386
+
387
+ # โ”€โ”€ Env Var Mapping โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
388
+
389
+ # Ordered so that more-specific vars (LLM_*) overwrite less-specific (OPENROUTER_*)
390
+ _ENV_VAR_MAP: list[tuple[str, str]] = [
391
+ # game
392
+ ("OPENRA_PATH", "game.openra_path"),
393
+ ("RECORD_REPLAYS", "game.record_replays"),
394
+ # opponent
395
+ ("BOT_TYPE", "opponent.bot_type"),
396
+ ("AI_SLOT", "opponent.ai_slot"),
397
+ # planning
398
+ ("PLANNING_ENABLED", "planning.enabled"),
399
+ ("PLANNING_MAX_TURNS", "planning.max_turns"),
400
+ ("PLANNING_MAX_TIME", "planning.max_time_s"),
401
+ # llm โ€” legacy OpenRouter names first, then generic LLM_ names (override)
402
+ ("OPENROUTER_API_KEY", "llm.api_key"),
403
+ ("OPENROUTER_MODEL", "llm.model"),
404
+ ("LLM_BASE_URL", "llm.base_url"),
405
+ ("LLM_API_KEY", "llm.api_key"),
406
+ ("LLM_MODEL", "llm.model"),
407
+ # agent
408
+ ("OPENRA_URL", "agent.server_url"),
409
+ ("MAX_TIME", "agent.max_time_s"),
410
+ ("LLM_AGENT_LOG", "agent.log_file"),
411
+ ("AGENT_NAME", "agent.agent_name"),
412
+ ("AGENT_TYPE", "agent.agent_type"),
413
+ ("AGENT_URL", "agent.agent_url"),
414
+ ("BENCH_UPLOAD", "agent.bench_upload"),
415
+ ("BENCH_URL", "agent.bench_url"),
416
+ ("SYSTEM_PROMPT_FILE", "agent.system_prompt_file"),
417
+ # prompts
418
+ ("SYSTEM_PROMPT_FILE", "prompts.system_prompt_file"), # also maps to prompts.*
419
+ ("PROMPTS_FILE", "prompts.prompts_file"),
420
+ ]
421
+
422
+
423
+ # โ”€โ”€ Helper Functions โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
424
+
425
+
426
+ def _deep_merge(base: dict, override: dict) -> None:
427
+ """Recursively merge *override* into *base* in place."""
428
+ for key, value in override.items():
429
+ if key in base and isinstance(base[key], dict) and isinstance(value, dict):
430
+ _deep_merge(base[key], value)
431
+ else:
432
+ base[key] = value
433
+
434
+
435
+ def _set_nested(d: dict, path: str, value: object) -> None:
436
+ """Set a value in a nested dict via dotted path (e.g. ``'game.mod'``)."""
437
+ keys = path.split(".")
438
+ for key in keys[:-1]:
439
+ d = d.setdefault(key, {})
440
+ d[keys[-1]] = value
441
+
442
+
443
+ def _coerce_value(value: str) -> object:
444
+ """Coerce a string env-var value to bool / int / float / str."""
445
+ lower = value.lower()
446
+ if lower in ("true", "1", "yes"):
447
+ return True
448
+ if lower in ("false", "0", "no"):
449
+ return False
450
+ try:
451
+ return int(value)
452
+ except ValueError:
453
+ pass
454
+ try:
455
+ return float(value)
456
+ except ValueError:
457
+ pass
458
+ return value
459
+
460
+
461
+ def should_register_tool(tool_name: str, tools_config: ToolsConfig) -> bool:
462
+ """Return True if *tool_name* should be registered based on config."""
463
+ if tool_name in tools_config.disabled:
464
+ return False
465
+ category = TOOL_CATEGORIES.get(tool_name)
466
+ if category is not None:
467
+ return getattr(tools_config.categories, category, True)
468
+ return True # unknown tools default to enabled
469
+
470
+
471
+ # โ”€โ”€ Config Loading โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
472
+
473
+
474
+ def load_config(
475
+ config_path: Optional[str] = None,
476
+ cli_overrides: Optional[dict] = None,
477
+ **overrides: object,
478
+ ) -> OpenRARLConfig:
479
+ """Load configuration with precedence: CLI > env vars > overrides > file > defaults.
480
+
481
+ Parameters
482
+ ----------
483
+ config_path:
484
+ Explicit path to a YAML config file. When ``None``, searches for
485
+ ``config.yaml`` in the current working directory and the project root.
486
+ cli_overrides:
487
+ Dict of overrides from explicit CLI flags. Applied last (highest
488
+ priority), beating even environment variables. Use this for values
489
+ the user typed on the command line.
490
+ **overrides:
491
+ Keyword arguments that are deep-merged on top of the file values.
492
+ Keys should be top-level section names (e.g. ``game={...}``).
493
+ """
494
+ config_dict: dict = {}
495
+
496
+ # 1. Load YAML file
497
+ resolved_path = _resolve_config_path(config_path)
498
+ if resolved_path is not None:
499
+ with open(resolved_path, encoding="utf-8") as f:
500
+ file_dict = yaml.safe_load(f) or {}
501
+ _deep_merge(config_dict, file_dict)
502
+
503
+ # 2. Apply programmatic overrides (e.g. constructor args)
504
+ if overrides:
505
+ _deep_merge(config_dict, overrides)
506
+
507
+ # 3. Apply environment variable overrides
508
+ for env_var, dotted_path in _ENV_VAR_MAP:
509
+ value = os.environ.get(env_var)
510
+ if value is not None:
511
+ _set_nested(config_dict, dotted_path, _coerce_value(value))
512
+
513
+ # 4. Apply CLI overrides (highest priority โ€” explicit user intent)
514
+ if cli_overrides:
515
+ _deep_merge(config_dict, cli_overrides)
516
+
517
+ # 5. Validate and return
518
+ return OpenRARLConfig(**config_dict)
519
+
520
+
521
+ def _resolve_config_path(config_path: Optional[str]) -> Optional[str]:
522
+ """Find the config file to load, or None if none exists."""
523
+ if config_path is not None:
524
+ p = Path(config_path)
525
+ return str(p) if p.exists() else None
526
+
527
+ # Auto-discover: CWD first, then project root
528
+ candidates = [
529
+ Path.cwd() / "config.yaml",
530
+ Path(__file__).resolve().parent.parent / "config.yaml",
531
+ ]
532
+ for candidate in candidates:
533
+ if candidate.exists():
534
+ return str(candidate)
535
+ return None
openra_env/game_data.py ADDED
@@ -0,0 +1,984 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Static Red Alert mod data for game knowledge tools.
2
+
3
+ Provides unit stats, building stats, tech tree, and faction information
4
+ extracted from OpenRA Red Alert mod rules. This gives an LLM agent the same
5
+ reference knowledge a human player would have from experience.
6
+ """
7
+
8
+ from typing import Optional
9
+
10
+
11
+ # โ”€โ”€โ”€ Unit Data โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
12
+
13
+ RA_UNITS: dict[str, dict] = {
14
+ # Infantry
15
+ "e1": {
16
+ "name": "Rifle Infantry",
17
+ "category": "infantry",
18
+ "cost": 100,
19
+ "hp": 5000,
20
+ "speed": 56,
21
+ "armor": "none",
22
+ "side": "both",
23
+ "prerequisites": ["barr|tent"],
24
+ "description": "Basic infantry unit. Cheap and fast to produce.",
25
+ },
26
+ "e2": {
27
+ "name": "Grenadier",
28
+ "category": "infantry",
29
+ "cost": 150,
30
+ "hp": 5000,
31
+ "speed": 56,
32
+ "armor": "none",
33
+ "side": "both",
34
+ "prerequisites": ["barr|tent"],
35
+ "description": "Anti-structure infantry. Grenades deal area damage.",
36
+ },
37
+ "e3": {
38
+ "name": "Rocket Soldier",
39
+ "category": "infantry",
40
+ "cost": 300,
41
+ "hp": 4500,
42
+ "speed": 56,
43
+ "armor": "none",
44
+ "side": "both",
45
+ "prerequisites": ["barr|tent"],
46
+ "description": "Anti-armor and anti-air infantry.",
47
+ },
48
+ "e4": {
49
+ "name": "Flamethrower",
50
+ "category": "infantry",
51
+ "cost": 300,
52
+ "hp": 4000,
53
+ "speed": 56,
54
+ "armor": "none",
55
+ "side": "soviet",
56
+ "prerequisites": ["barr", "ftur"],
57
+ "description": "Short-range anti-infantry/structure. Soviet only.",
58
+ },
59
+ "e6": {
60
+ "name": "Engineer",
61
+ "category": "infantry",
62
+ "cost": 400,
63
+ "hp": 4000,
64
+ "speed": 56,
65
+ "armor": "none",
66
+ "side": "both",
67
+ "prerequisites": ["barr|tent"],
68
+ "description": "Captures enemy buildings. Cannot attack.",
69
+ },
70
+ "e7": {
71
+ "name": "Tanya",
72
+ "category": "infantry",
73
+ "cost": 1800,
74
+ "hp": 10000,
75
+ "speed": 68,
76
+ "armor": "none",
77
+ "side": "allied",
78
+ "prerequisites": ["tent", "atek"],
79
+ "build_limit": 1,
80
+ "description": "Elite commando. Destroys buildings with C4, kills infantry instantly. Allied only.",
81
+ },
82
+ "medi": {
83
+ "name": "Medic",
84
+ "category": "infantry",
85
+ "cost": 200,
86
+ "hp": 6000,
87
+ "speed": 49,
88
+ "armor": "none",
89
+ "side": "allied",
90
+ "prerequisites": ["tent"],
91
+ "description": "Heals nearby infantry. Cannot attack.",
92
+ },
93
+ "mech": {
94
+ "name": "Mechanic",
95
+ "category": "infantry",
96
+ "cost": 500,
97
+ "hp": 8000,
98
+ "speed": 49,
99
+ "armor": "none",
100
+ "side": "allied",
101
+ "prerequisites": ["tent", "fix"],
102
+ "description": "Repairs nearby vehicles. Cannot attack.",
103
+ },
104
+ "spy": {
105
+ "name": "Spy",
106
+ "category": "infantry",
107
+ "cost": 500,
108
+ "hp": 2500,
109
+ "speed": 56,
110
+ "armor": "none",
111
+ "side": "allied",
112
+ "prerequisites": ["tent", "dome"],
113
+ "description": "Disguises as enemy infantry. Infiltrates buildings for bonuses.",
114
+ },
115
+ "thf": {
116
+ "name": "Thief",
117
+ "category": "infantry",
118
+ "cost": 500,
119
+ "hp": 5000,
120
+ "speed": 68,
121
+ "armor": "none",
122
+ "side": "allied",
123
+ "prerequisites": ["tent", "dome"],
124
+ "description": "Steals credits from enemy refineries.",
125
+ },
126
+ "shok": {
127
+ "name": "Shock Trooper",
128
+ "category": "infantry",
129
+ "cost": 350,
130
+ "hp": 5000,
131
+ "speed": 49,
132
+ "armor": "none",
133
+ "side": "soviet",
134
+ "prerequisites": ["barr", "stek", "tsla"],
135
+ "description": "Tesla infantry. High damage vs all targets. Soviet only.",
136
+ },
137
+ "dog": {
138
+ "name": "Attack Dog",
139
+ "category": "infantry",
140
+ "cost": 200,
141
+ "hp": 2000,
142
+ "speed": 99,
143
+ "armor": "none",
144
+ "side": "soviet",
145
+ "prerequisites": ["kenn"],
146
+ "description": "Fast anti-infantry unit. Kills spies. Soviet only.",
147
+ },
148
+
149
+ # Vehicles
150
+ "1tnk": {
151
+ "name": "Light Tank",
152
+ "category": "vehicle",
153
+ "cost": 700,
154
+ "hp": 23000,
155
+ "speed": 113,
156
+ "armor": "heavy",
157
+ "side": "allied",
158
+ "prerequisites": ["weap"],
159
+ "description": "Fast medium tank. Good all-around. Allied only.",
160
+ },
161
+ "2tnk": {
162
+ "name": "Medium Tank",
163
+ "category": "vehicle",
164
+ "cost": 850,
165
+ "hp": 30000,
166
+ "speed": 72,
167
+ "armor": "heavy",
168
+ "side": "allied",
169
+ "prerequisites": ["weap", "fix"],
170
+ "description": "Main battle tank. Balanced stats. Allied only. Requires Repair Facility.",
171
+ },
172
+ "3tnk": {
173
+ "name": "Heavy Tank",
174
+ "category": "vehicle",
175
+ "cost": 1150,
176
+ "hp": 46000,
177
+ "speed": 64,
178
+ "armor": "heavy",
179
+ "side": "soviet",
180
+ "prerequisites": ["weap", "fix"],
181
+ "description": "Powerful main battle tank. Dual cannons. Soviet only. Requires Repair Facility.",
182
+ },
183
+ "4tnk": {
184
+ "name": "Mammoth Tank",
185
+ "category": "vehicle",
186
+ "cost": 2000,
187
+ "hp": 60000,
188
+ "speed": 43,
189
+ "armor": "heavy",
190
+ "side": "soviet",
191
+ "prerequisites": ["weap", "fix", "stek"],
192
+ "description": "Heaviest tank. Dual cannons + missiles. Self-healing. Soviet only.",
193
+ },
194
+ "v2rl": {
195
+ "name": "V2 Rocket Launcher",
196
+ "category": "vehicle",
197
+ "cost": 900,
198
+ "hp": 15000,
199
+ "speed": 72,
200
+ "armor": "light",
201
+ "side": "soviet",
202
+ "prerequisites": ["weap", "dome"],
203
+ "description": "Long-range artillery. High damage, inaccurate. Soviet only.",
204
+ },
205
+ "jeep": {
206
+ "name": "Ranger",
207
+ "category": "vehicle",
208
+ "cost": 500,
209
+ "hp": 15000,
210
+ "speed": 164,
211
+ "armor": "light",
212
+ "side": "allied",
213
+ "prerequisites": ["weap"],
214
+ "description": "Fast scout vehicle with machine gun. Allied only.",
215
+ },
216
+ "apc": {
217
+ "name": "APC",
218
+ "category": "vehicle",
219
+ "cost": 850,
220
+ "hp": 20000,
221
+ "speed": 128,
222
+ "armor": "heavy",
223
+ "side": "soviet",
224
+ "prerequisites": ["weap"],
225
+ "description": "Armored troop transport. Carries 5 infantry. Soviet only.",
226
+ },
227
+ "arty": {
228
+ "name": "Artillery",
229
+ "category": "vehicle",
230
+ "cost": 850,
231
+ "hp": 7500,
232
+ "speed": 54,
233
+ "armor": "light",
234
+ "side": "allied",
235
+ "prerequisites": ["weap", "dome"],
236
+ "description": "Long-range siege weapon. Allied only.",
237
+ },
238
+ "harv": {
239
+ "name": "Ore Truck",
240
+ "category": "vehicle",
241
+ "cost": 1100,
242
+ "hp": 60000,
243
+ "speed": 72,
244
+ "armor": "heavy",
245
+ "side": "both",
246
+ "prerequisites": ["proc"],
247
+ "description": "Harvests ore and delivers to refinery. Free with refinery.",
248
+ },
249
+ "mcv": {
250
+ "name": "MCV",
251
+ "category": "vehicle",
252
+ "cost": 2000,
253
+ "hp": 60000,
254
+ "speed": 60,
255
+ "armor": "light",
256
+ "side": "both",
257
+ "prerequisites": ["weap", "fix"],
258
+ "description": "Deploys into Construction Yard. Mobile base.",
259
+ },
260
+ "ftrk": {
261
+ "name": "Flak Truck",
262
+ "category": "vehicle",
263
+ "cost": 600,
264
+ "hp": 15000,
265
+ "speed": 113,
266
+ "armor": "light",
267
+ "side": "soviet",
268
+ "prerequisites": ["weap"],
269
+ "description": "Mobile anti-air unit. Soviet only.",
270
+ },
271
+ "mnly": {
272
+ "name": "Minelayer",
273
+ "category": "vehicle",
274
+ "cost": 800,
275
+ "hp": 30000,
276
+ "speed": 113,
277
+ "armor": "heavy",
278
+ "side": "both",
279
+ "prerequisites": ["weap", "fix"],
280
+ "description": "Lays anti-tank mines.",
281
+ },
282
+ "ttnk": {
283
+ "name": "Tesla Tank",
284
+ "category": "vehicle",
285
+ "cost": 1350,
286
+ "hp": 30000,
287
+ "speed": 92,
288
+ "armor": "light",
289
+ "side": "soviet",
290
+ "prerequisites": ["weap", "stek", "tsla"],
291
+ "description": "Tesla weapon on tracks. Effective vs all targets. Soviet only.",
292
+ },
293
+ "ctnk": {
294
+ "name": "Chrono Tank",
295
+ "category": "vehicle",
296
+ "cost": 1350,
297
+ "hp": 20000,
298
+ "speed": 86,
299
+ "armor": "light",
300
+ "side": "allied",
301
+ "prerequisites": ["weap", "atek"],
302
+ "description": "Teleporting tank. Hit and run tactics. Allied only.",
303
+ },
304
+ "stnk": {
305
+ "name": "Phase Transport",
306
+ "category": "vehicle",
307
+ "cost": 1000,
308
+ "hp": 11000,
309
+ "speed": 128,
310
+ "armor": "light",
311
+ "side": "allied",
312
+ "prerequisites": ["weap", "atek"],
313
+ "description": "Cloaked APC. Invisible when not firing. Allied only.",
314
+ },
315
+ "qtnk": {
316
+ "name": "MAD Tank",
317
+ "category": "vehicle",
318
+ "cost": 2000,
319
+ "hp": 22000,
320
+ "speed": 46,
321
+ "armor": "heavy",
322
+ "side": "soviet",
323
+ "prerequisites": ["weap", "stek"],
324
+ "description": "Deploys seismic charge, destroying self and nearby vehicles. Soviet only.",
325
+ },
326
+ "dtrk": {
327
+ "name": "Demolition Truck",
328
+ "category": "vehicle",
329
+ "cost": 2500,
330
+ "hp": 11000,
331
+ "speed": 113,
332
+ "armor": "light",
333
+ "side": "soviet",
334
+ "prerequisites": ["weap", "stek"],
335
+ "description": "Suicide vehicle. Massive area nuclear explosion on death. Soviet only.",
336
+ },
337
+ "mgg": {
338
+ "name": "Mobile Gap Generator",
339
+ "category": "vehicle",
340
+ "cost": 1000,
341
+ "hp": 11000,
342
+ "speed": 72,
343
+ "armor": "heavy",
344
+ "side": "allied",
345
+ "prerequisites": ["weap", "atek"],
346
+ "description": "Creates mobile shroud area. Allied only.",
347
+ },
348
+ "mrj": {
349
+ "name": "Mobile Radar Jammer",
350
+ "category": "vehicle",
351
+ "cost": 1000,
352
+ "hp": 11000,
353
+ "speed": 68,
354
+ "armor": "heavy",
355
+ "side": "allied",
356
+ "prerequisites": ["weap", "atek"],
357
+ "description": "Jams enemy radar in area. Allied only.",
358
+ },
359
+ "truk": {
360
+ "name": "Supply Truck",
361
+ "category": "vehicle",
362
+ "cost": 500,
363
+ "hp": 11000,
364
+ "speed": 113,
365
+ "armor": "light",
366
+ "side": "both",
367
+ "prerequisites": ["weap"],
368
+ "description": "Delivers cash when reaching allied structures.",
369
+ },
370
+
371
+ # Aircraft
372
+ "heli": {
373
+ "name": "Longbow",
374
+ "category": "aircraft",
375
+ "cost": 2000,
376
+ "hp": 12000,
377
+ "speed": 149,
378
+ "armor": "light",
379
+ "side": "allied",
380
+ "prerequisites": ["hpad"],
381
+ "description": "Anti-armor helicopter with missiles. Allied only.",
382
+ },
383
+ "hind": {
384
+ "name": "Hind",
385
+ "category": "aircraft",
386
+ "cost": 1500,
387
+ "hp": 12000,
388
+ "speed": 112,
389
+ "armor": "light",
390
+ "side": "soviet",
391
+ "prerequisites": ["afld"],
392
+ "description": "Anti-ground attack helicopter. Soviet only.",
393
+ },
394
+ "mh60": {
395
+ "name": "Black Hawk",
396
+ "category": "aircraft",
397
+ "cost": 1500,
398
+ "hp": 12000,
399
+ "speed": 112,
400
+ "armor": "light",
401
+ "side": "allied",
402
+ "prerequisites": ["hpad"],
403
+ "description": "Transport/attack helicopter. Allied only.",
404
+ },
405
+ "tran": {
406
+ "name": "Chinook",
407
+ "category": "aircraft",
408
+ "cost": 900,
409
+ "hp": 14000,
410
+ "speed": 128,
411
+ "armor": "light",
412
+ "side": "both",
413
+ "prerequisites": ["hpad|afld"],
414
+ "description": "Transport helicopter. Carries 5 infantry.",
415
+ },
416
+ "yak": {
417
+ "name": "Yak",
418
+ "category": "aircraft",
419
+ "cost": 1350,
420
+ "hp": 6000,
421
+ "speed": 178,
422
+ "armor": "light",
423
+ "side": "soviet",
424
+ "prerequisites": ["afld"],
425
+ "description": "Fast anti-infantry attack plane. Soviet only.",
426
+ },
427
+ "mig": {
428
+ "name": "MiG",
429
+ "category": "aircraft",
430
+ "cost": 2000,
431
+ "hp": 8000,
432
+ "speed": 223,
433
+ "armor": "light",
434
+ "side": "soviet",
435
+ "prerequisites": ["afld", "stek"],
436
+ "description": "Anti-structure/armor attack plane with missiles. Soviet only.",
437
+ },
438
+
439
+ # Ships
440
+ "ss": {
441
+ "name": "Submarine",
442
+ "category": "ship",
443
+ "cost": 950,
444
+ "hp": 25000,
445
+ "speed": 78,
446
+ "armor": "light",
447
+ "side": "soviet",
448
+ "prerequisites": ["spen"],
449
+ "description": "Invisible anti-ship unit. Soviet only.",
450
+ },
451
+ "dd": {
452
+ "name": "Destroyer",
453
+ "category": "ship",
454
+ "cost": 1000,
455
+ "hp": 40000,
456
+ "speed": 92,
457
+ "armor": "heavy",
458
+ "side": "allied",
459
+ "prerequisites": ["syrd", "dome"],
460
+ "description": "Multi-role warship. Anti-sub, anti-air, anti-surface. Allied only.",
461
+ },
462
+ "ca": {
463
+ "name": "Cruiser",
464
+ "category": "ship",
465
+ "cost": 2400,
466
+ "hp": 80000,
467
+ "speed": 44,
468
+ "armor": "heavy",
469
+ "side": "allied",
470
+ "prerequisites": ["syrd", "atek"],
471
+ "description": "Heavy bombardment ship. Long range. Allied only.",
472
+ },
473
+ "pt": {
474
+ "name": "Gunboat",
475
+ "category": "ship",
476
+ "cost": 500,
477
+ "hp": 20000,
478
+ "speed": 142,
479
+ "armor": "heavy",
480
+ "side": "both",
481
+ "prerequisites": ["syrd|spen"],
482
+ "description": "Fast patrol boat.",
483
+ },
484
+ "lst": {
485
+ "name": "Transport",
486
+ "category": "ship",
487
+ "cost": 500,
488
+ "hp": 40000,
489
+ "speed": 115,
490
+ "armor": "heavy",
491
+ "side": "both",
492
+ "prerequisites": ["syrd|spen"],
493
+ "description": "Naval transport. Carries vehicles and infantry.",
494
+ },
495
+ "msub": {
496
+ "name": "Missile Submarine",
497
+ "category": "ship",
498
+ "cost": 2000,
499
+ "hp": 40000,
500
+ "speed": 44,
501
+ "armor": "light",
502
+ "side": "soviet",
503
+ "prerequisites": ["spen", "stek"],
504
+ "description": "Long-range missile submarine. Soviet only.",
505
+ },
506
+ }
507
+
508
+
509
+ # โ”€โ”€โ”€ Building Data โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
510
+
511
+ RA_BUILDINGS: dict[str, dict] = {
512
+ "fact": {
513
+ "name": "Construction Yard",
514
+ "cost": 2000,
515
+ "hp": 150000,
516
+ "power": 0,
517
+ "side": "both",
518
+ "prerequisites": [],
519
+ "produces": ["Building", "Defense"],
520
+ "description": "Primary base structure. Required to build other structures.",
521
+ },
522
+ "powr": {
523
+ "name": "Power Plant",
524
+ "cost": 300,
525
+ "hp": 40000,
526
+ "power": 100,
527
+ "side": "both",
528
+ "prerequisites": [],
529
+ "produces": [],
530
+ "description": "Basic power supply. Most structures need power to function.",
531
+ },
532
+ "apwr": {
533
+ "name": "Advanced Power Plant",
534
+ "cost": 500,
535
+ "hp": 70000,
536
+ "power": 200,
537
+ "side": "both",
538
+ "prerequisites": ["dome"],
539
+ "produces": [],
540
+ "description": "Double power output. Requires radar dome tech.",
541
+ },
542
+ "barr": {
543
+ "name": "Soviet Barracks",
544
+ "cost": 500,
545
+ "hp": 60000,
546
+ "power": -20,
547
+ "side": "soviet",
548
+ "prerequisites": ["powr"],
549
+ "produces": ["Infantry"],
550
+ "description": "Soviet infantry production. Required for all Soviet infantry.",
551
+ },
552
+ "tent": {
553
+ "name": "Allied Barracks",
554
+ "cost": 500,
555
+ "hp": 60000,
556
+ "power": -20,
557
+ "side": "allied",
558
+ "prerequisites": ["powr"],
559
+ "produces": ["Infantry"],
560
+ "description": "Allied infantry production. Required for all Allied infantry.",
561
+ },
562
+ "proc": {
563
+ "name": "Ore Refinery",
564
+ "cost": 1400,
565
+ "hp": 90000,
566
+ "power": -30,
567
+ "side": "both",
568
+ "prerequisites": ["powr"],
569
+ "produces": [],
570
+ "description": "Processes ore into credits. Comes with a free Ore Truck.",
571
+ },
572
+ "weap": {
573
+ "name": "War Factory",
574
+ "cost": 2000,
575
+ "hp": 150000,
576
+ "power": -30,
577
+ "side": "both",
578
+ "prerequisites": ["proc"],
579
+ "produces": ["Vehicle"],
580
+ "description": "Vehicle production facility. Required for all vehicles.",
581
+ },
582
+ "dome": {
583
+ "name": "Radar Dome",
584
+ "cost": 1500,
585
+ "hp": 100000,
586
+ "power": -40,
587
+ "side": "both",
588
+ "prerequisites": ["proc"],
589
+ "produces": [],
590
+ "description": "Provides minimap radar. Unlocks advanced tech.",
591
+ },
592
+ "fix": {
593
+ "name": "Service Depot",
594
+ "cost": 1200,
595
+ "hp": 80000,
596
+ "power": -30,
597
+ "side": "both",
598
+ "prerequisites": ["weap"],
599
+ "produces": [],
600
+ "description": "Repairs vehicles. Unlocks MCV and Minelayer.",
601
+ },
602
+ "atek": {
603
+ "name": "Allied Tech Center",
604
+ "cost": 1500,
605
+ "hp": 60000,
606
+ "power": -200,
607
+ "side": "allied",
608
+ "prerequisites": ["dome", "weap"],
609
+ "produces": [],
610
+ "description": "Unlocks advanced Allied units. GPS satellite.",
611
+ },
612
+ "stek": {
613
+ "name": "Soviet Tech Center",
614
+ "cost": 1500,
615
+ "hp": 80000,
616
+ "power": -100,
617
+ "side": "soviet",
618
+ "prerequisites": ["dome", "weap"],
619
+ "produces": [],
620
+ "description": "Unlocks advanced Soviet units.",
621
+ },
622
+ "hpad": {
623
+ "name": "Helipad",
624
+ "cost": 500,
625
+ "hp": 80000,
626
+ "power": -10,
627
+ "side": "allied",
628
+ "prerequisites": ["dome"],
629
+ "produces": ["Aircraft"],
630
+ "description": "Allied aircraft production. Rearming pad.",
631
+ },
632
+ "afld": {
633
+ "name": "Airfield",
634
+ "cost": 500,
635
+ "hp": 100000,
636
+ "power": -20,
637
+ "side": "soviet",
638
+ "prerequisites": ["dome"],
639
+ "produces": ["Aircraft"],
640
+ "description": "Soviet aircraft production. Rearming strip.",
641
+ },
642
+ "spen": {
643
+ "name": "Sub Pen",
644
+ "cost": 800,
645
+ "hp": 100000,
646
+ "power": -20,
647
+ "side": "soviet",
648
+ "prerequisites": ["powr"],
649
+ "produces": ["Ship"],
650
+ "terrain": "water",
651
+ "description": "Soviet naval production. Repairs ships. REQUIRES WATER โ€” cannot build on land maps.",
652
+ },
653
+ "syrd": {
654
+ "name": "Naval Yard",
655
+ "cost": 1000,
656
+ "hp": 100000,
657
+ "power": -20,
658
+ "side": "allied",
659
+ "prerequisites": ["powr"],
660
+ "produces": ["Ship"],
661
+ "terrain": "water",
662
+ "description": "Allied naval production. Repairs ships. REQUIRES WATER โ€” cannot build on land maps.",
663
+ },
664
+ "silo": {
665
+ "name": "Ore Silo",
666
+ "cost": 150,
667
+ "hp": 30000,
668
+ "power": -10,
669
+ "side": "both",
670
+ "prerequisites": ["proc"],
671
+ "produces": [],
672
+ "description": "Additional ore storage capacity.",
673
+ },
674
+ "kenn": {
675
+ "name": "Kennel",
676
+ "cost": 200,
677
+ "hp": 30000,
678
+ "power": -10,
679
+ "side": "soviet",
680
+ "prerequisites": ["powr"],
681
+ "produces": ["Infantry"],
682
+ "description": "Produces attack dogs. Soviet only.",
683
+ },
684
+
685
+ # Defenses
686
+ "pbox": {
687
+ "name": "Pillbox",
688
+ "cost": 600,
689
+ "hp": 40000,
690
+ "power": 0,
691
+ "side": "allied",
692
+ "prerequisites": ["tent"],
693
+ "produces": [],
694
+ "description": "Anti-infantry defense turret. Allied only.",
695
+ },
696
+ "hbox": {
697
+ "name": "Camo Pillbox",
698
+ "cost": 750,
699
+ "hp": 40000,
700
+ "power": 0,
701
+ "side": "allied",
702
+ "prerequisites": ["tent"],
703
+ "produces": [],
704
+ "description": "Hidden anti-infantry defense. Allied only.",
705
+ },
706
+ "gun": {
707
+ "name": "Turret",
708
+ "cost": 800,
709
+ "hp": 40000,
710
+ "power": -20,
711
+ "side": "allied",
712
+ "prerequisites": ["weap"],
713
+ "produces": [],
714
+ "description": "Anti-armor defense turret. Allied only.",
715
+ },
716
+ "ftur": {
717
+ "name": "Flame Tower",
718
+ "cost": 600,
719
+ "hp": 40000,
720
+ "power": -20,
721
+ "side": "soviet",
722
+ "prerequisites": ["barr"],
723
+ "produces": [],
724
+ "description": "Short-range anti-infantry defense. Soviet only.",
725
+ },
726
+ "tsla": {
727
+ "name": "Tesla Coil",
728
+ "cost": 1200,
729
+ "hp": 40000,
730
+ "power": -75,
731
+ "side": "soviet",
732
+ "prerequisites": ["weap"],
733
+ "produces": [],
734
+ "description": "Powerful anti-ground defense. High power cost. Soviet only.",
735
+ },
736
+ "agun": {
737
+ "name": "AA Gun",
738
+ "cost": 800,
739
+ "hp": 40000,
740
+ "power": -50,
741
+ "side": "allied",
742
+ "prerequisites": ["dome"],
743
+ "produces": [],
744
+ "description": "Anti-air defense turret. Allied only.",
745
+ },
746
+ "sam": {
747
+ "name": "SAM Site",
748
+ "cost": 700,
749
+ "hp": 40000,
750
+ "power": -20,
751
+ "side": "soviet",
752
+ "prerequisites": ["dome"],
753
+ "produces": [],
754
+ "description": "Anti-air missile defense. Soviet only.",
755
+ },
756
+ "gap": {
757
+ "name": "Gap Generator",
758
+ "cost": 800,
759
+ "hp": 50000,
760
+ "power": -60,
761
+ "side": "allied",
762
+ "prerequisites": ["atek"],
763
+ "produces": [],
764
+ "description": "Creates shroud area over your base. Allied only.",
765
+ },
766
+
767
+ # Superweapons
768
+ "iron": {
769
+ "name": "Iron Curtain",
770
+ "cost": 2000,
771
+ "hp": 100000,
772
+ "power": -200,
773
+ "side": "soviet",
774
+ "prerequisites": ["stek"],
775
+ "produces": [],
776
+ "build_limit": 1,
777
+ "description": "Superweapon: Makes one unit/building invulnerable temporarily.",
778
+ },
779
+ "pdox": {
780
+ "name": "Chronosphere",
781
+ "cost": 1500,
782
+ "hp": 100000,
783
+ "power": -200,
784
+ "side": "allied",
785
+ "prerequisites": ["atek"],
786
+ "produces": [],
787
+ "build_limit": 1,
788
+ "description": "Superweapon: Teleports units across the map.",
789
+ },
790
+ "mslo": {
791
+ "name": "Missile Silo",
792
+ "cost": 2500,
793
+ "hp": 100000,
794
+ "power": -150,
795
+ "side": "soviet",
796
+ "prerequisites": ["stek"],
797
+ "produces": [],
798
+ "build_limit": 1,
799
+ "description": "Superweapon: Launches nuclear missile at target location.",
800
+ },
801
+ }
802
+
803
+
804
+ # โ”€โ”€โ”€ Tech Tree โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
805
+
806
+ RA_TECH_TREE: dict[str, list[str]] = {
807
+ "soviet": [
808
+ "powr", # Power Plant (base)
809
+ "barr", # Barracks โ†’ infantry (requires powr)
810
+ "kenn", # Kennel โ†’ dogs (requires powr)
811
+ "proc", # Ore Refinery (requires powr)
812
+ "weap", # War Factory (requires proc)
813
+ "spen", # Sub Pen (requires powr, needs water)
814
+ "dome", # Radar Dome (requires proc)
815
+ "fix", # Service Depot (requires weap)
816
+ "afld", # Airfield (requires dome)
817
+ "stek", # Tech Center (requires dome + weap)
818
+ "tsla", # Tesla Coil (requires weap)
819
+ "sam", # SAM Site (requires dome)
820
+ "ftur", # Flame Tower (requires barr)
821
+ "iron", # Iron Curtain (requires stek)
822
+ "mslo", # Missile Silo (requires stek)
823
+ ],
824
+ "allied": [
825
+ "powr", # Power Plant (base)
826
+ "tent", # Barracks โ†’ infantry (requires powr)
827
+ "proc", # Ore Refinery (requires powr)
828
+ "weap", # War Factory (requires proc)
829
+ "syrd", # Naval Yard (requires powr, needs water)
830
+ "dome", # Radar Dome (requires proc)
831
+ "fix", # Service Depot (requires weap)
832
+ "hpad", # Helipad (requires dome)
833
+ "atek", # Tech Center (requires dome + weap)
834
+ "gun", # Turret (requires weap)
835
+ "pbox", # Pillbox (requires tent)
836
+ "agun", # AA Gun (requires dome)
837
+ "gap", # Gap Generator (requires atek)
838
+ "pdox", # Chronosphere (requires atek)
839
+ ],
840
+ }
841
+
842
+
843
+ # โ”€โ”€โ”€ Faction Data โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
844
+
845
+ RA_FACTIONS: dict[str, dict] = {
846
+ "england": {
847
+ "side": "allied",
848
+ "display_name": "England",
849
+ "unique_units": [],
850
+ "description": "Standard Allied faction.",
851
+ },
852
+ "france": {
853
+ "side": "allied",
854
+ "display_name": "France",
855
+ "unique_units": ["stnk"],
856
+ "description": "Allied faction with Phase Transport (cloaked APC).",
857
+ },
858
+ "germany": {
859
+ "side": "allied",
860
+ "display_name": "Germany",
861
+ "unique_units": ["ctnk"],
862
+ "description": "Allied faction with Chrono Tank (teleporting tank).",
863
+ },
864
+ "russia": {
865
+ "side": "soviet",
866
+ "display_name": "Russia",
867
+ "unique_units": ["ttnk"],
868
+ "description": "Soviet faction with Tesla Tank.",
869
+ },
870
+ "ukraine": {
871
+ "side": "soviet",
872
+ "display_name": "Ukraine",
873
+ "unique_units": ["dtrk"],
874
+ "description": "Soviet faction with Demolition Truck (nuclear suicide vehicle).",
875
+ },
876
+ }
877
+
878
+
879
+ # โ”€โ”€โ”€ Query Functions โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
880
+
881
+
882
+ def get_unit_stats(unit_type: str) -> Optional[dict]:
883
+ """Get stats for a unit type. Returns None if not found."""
884
+ return RA_UNITS.get(unit_type.lower())
885
+
886
+
887
+ def get_building_stats(building_type: str) -> Optional[dict]:
888
+ """Get stats for a building type. Returns None if not found."""
889
+ return RA_BUILDINGS.get(building_type.lower())
890
+
891
+
892
+ def get_tech_tree(faction: Optional[str] = None) -> dict:
893
+ """Get the tech tree build order.
894
+
895
+ Args:
896
+ faction: Faction name (e.g., 'russia') or side ('allied', 'soviet').
897
+ If None, returns both sides.
898
+ """
899
+ if faction is None:
900
+ return RA_TECH_TREE
901
+
902
+ # Map faction to side
903
+ side = faction.lower()
904
+ if side in RA_FACTIONS:
905
+ side = RA_FACTIONS[side]["side"]
906
+
907
+ if side in RA_TECH_TREE:
908
+ return {side: RA_TECH_TREE[side]}
909
+
910
+ return {}
911
+
912
+
913
+ def get_faction_info(faction: str) -> Optional[dict]:
914
+ """Get faction info including available units and buildings."""
915
+ faction = faction.lower()
916
+ info = RA_FACTIONS.get(faction)
917
+ if info is None:
918
+ return None
919
+
920
+ side = info["side"]
921
+
922
+ # Collect units available to this faction
923
+ available_units = []
924
+ for unit_type, data in RA_UNITS.items():
925
+ unit_side = data.get("side", "")
926
+ if unit_side == "both" or unit_side == side:
927
+ available_units.append(unit_type)
928
+
929
+ # Add faction-unique units
930
+ for u in info.get("unique_units", []):
931
+ if u not in available_units and u in RA_UNITS:
932
+ available_units.append(u)
933
+
934
+ # Collect buildings
935
+ available_buildings = []
936
+ for bldg_type, data in RA_BUILDINGS.items():
937
+ bldg_side = data.get("side", "")
938
+ if bldg_side == "both" or bldg_side == side:
939
+ available_buildings.append(bldg_type)
940
+
941
+ return {
942
+ **info,
943
+ "faction": faction,
944
+ "available_units": sorted(available_units),
945
+ "available_buildings": sorted(available_buildings),
946
+ }
947
+
948
+
949
+ def get_all_unit_types() -> list[str]:
950
+ """Get all available unit type names."""
951
+ return sorted(RA_UNITS.keys())
952
+
953
+
954
+ def get_all_building_types() -> list[str]:
955
+ """Get all available building type names."""
956
+ return sorted(RA_BUILDINGS.keys())
957
+
958
+
959
+ def get_all_units_for_side(side: str) -> dict[str, dict]:
960
+ """Get all units available to a side ('allied' or 'soviet') with full stats.
961
+
962
+ Returns dict keyed by unit type name, each value is the full stats dict.
963
+ Includes units with side='both' plus units specific to the given side.
964
+ """
965
+ side = side.lower()
966
+ return {
967
+ utype: dict(data)
968
+ for utype, data in RA_UNITS.items()
969
+ if data.get("side") in (side, "both")
970
+ }
971
+
972
+
973
+ def get_all_buildings_for_side(side: str) -> dict[str, dict]:
974
+ """Get all buildings available to a side ('allied' or 'soviet') with full stats.
975
+
976
+ Returns dict keyed by building type name, each value is the full stats dict.
977
+ Includes buildings with side='both' plus buildings specific to the given side.
978
+ """
979
+ side = side.lower()
980
+ return {
981
+ btype: dict(data)
982
+ for btype, data in RA_BUILDINGS.items()
983
+ if data.get("side") in (side, "both")
984
+ }
openra_env/generated/__init__.py ADDED
File without changes
openra_env/generated/rl_bridge_pb2.py ADDED
@@ -0,0 +1,61 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # -*- coding: utf-8 -*-
2
+ # Generated by the protocol buffer compiler. DO NOT EDIT!
3
+ # NO CHECKED-IN PROTOBUF GENCODE
4
+ # source: rl_bridge.proto
5
+ # Protobuf Python Version: 6.31.1
6
+ """Generated protocol buffer code."""
7
+ from google.protobuf import descriptor as _descriptor
8
+ from google.protobuf import descriptor_pool as _descriptor_pool
9
+ from google.protobuf import runtime_version as _runtime_version
10
+ from google.protobuf import symbol_database as _symbol_database
11
+ from google.protobuf.internal import builder as _builder
12
+ _runtime_version.ValidateProtobufRuntimeVersion(
13
+ _runtime_version.Domain.PUBLIC,
14
+ 6,
15
+ 31,
16
+ 1,
17
+ '',
18
+ 'rl_bridge.proto'
19
+ )
20
+ # @@protoc_insertion_point(imports)
21
+
22
+ _sym_db = _symbol_database.Default()
23
+
24
+
25
+
26
+
27
+ DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n\x0frl_bridge.proto\x12\topenra.rl\"\x97\x04\n\x0fGameObservation\x12\x0c\n\x04tick\x18\x01 \x01(\x05\x12\x12\n\nepisode_id\x18\x02 \x01(\t\x12%\n\x07\x65\x63onomy\x18\x03 \x01(\x0b\x32\x14.openra.rl.RlEconomy\x12\'\n\x08military\x18\x04 \x01(\x0b\x32\x15.openra.rl.RlMilitary\x12$\n\x05units\x18\x05 \x03(\x0b\x32\x15.openra.rl.RlUnitInfo\x12,\n\tbuildings\x18\x06 \x03(\x0b\x32\x19.openra.rl.RlBuildingInfo\x12/\n\nproduction\x18\x07 \x03(\x0b\x32\x1b.openra.rl.RlProductionInfo\x12.\n\x0fvisible_enemies\x18\x08 \x03(\x0b\x32\x15.openra.rl.RlUnitInfo\x12&\n\x08map_info\x18\t \x01(\x0b\x32\x14.openra.rl.RlMapInfo\x12\x13\n\x0bspatial_map\x18\n \x01(\x0c\x12\x18\n\x10spatial_channels\x18\x0b \x01(\x05\x12\x0c\n\x04\x64one\x18\x0c \x01(\x08\x12\x0e\n\x06reward\x18\r \x01(\x02\x12\x0e\n\x06result\x18\x0e \x01(\t\x12\x1c\n\x14\x61vailable_production\x18\x0f \x03(\t\x12:\n\x17visible_enemy_buildings\x18\x10 \x03(\x0b\x32\x19.openra.rl.RlBuildingInfo\"\x89\x01\n\tRlEconomy\x12\x0c\n\x04\x63\x61sh\x18\x01 \x01(\x05\x12\x0b\n\x03ore\x18\x02 \x01(\x05\x12\x16\n\x0epower_provided\x18\x03 \x01(\x05\x12\x15\n\rpower_drained\x18\x04 \x01(\x05\x12\x19\n\x11resource_capacity\x18\x05 \x01(\x05\x12\x17\n\x0fharvester_count\x18\x06 \x01(\x05\"\xff\x01\n\nRlMilitary\x12\x14\n\x0cunits_killed\x18\x01 \x01(\x05\x12\x12\n\nunits_lost\x18\x02 \x01(\x05\x12\x18\n\x10\x62uildings_killed\x18\x03 \x01(\x05\x12\x16\n\x0e\x62uildings_lost\x18\x04 \x01(\x05\x12\x12\n\narmy_value\x18\x05 \x01(\x05\x12\x19\n\x11\x61\x63tive_unit_count\x18\x06 \x01(\x05\x12\x12\n\nkills_cost\x18\x07 \x01(\x05\x12\x13\n\x0b\x64\x65\x61ths_cost\x18\x08 \x01(\x05\x12\x14\n\x0c\x61ssets_value\x18\t \x01(\x05\x12\x12\n\nexperience\x18\n \x01(\x05\x12\x13\n\x0border_count\x18\x0b \x01(\x05\"\xe7\x02\n\nRlUnitInfo\x12\x10\n\x08\x61\x63tor_id\x18\x01 \x01(\r\x12\x0c\n\x04type\x18\x02 \x01(\t\x12\r\n\x05pos_x\x18\x03 \x01(\x05\x12\r\n\x05pos_y\x18\x04 \x01(\x05\x12\x0e\n\x06\x63\x65ll_x\x18\x05 \x01(\x05\x12\x0e\n\x06\x63\x65ll_y\x18\x06 \x01(\x05\x12\x12\n\nhp_percent\x18\x07 \x01(\x02\x12\x0f\n\x07is_idle\x18\x08 \x01(\x08\x12\x18\n\x10\x63urrent_activity\x18\t \x01(\t\x12\r\n\x05owner\x18\n \x01(\t\x12\x0c\n\x04\x61mmo\x18\x0b \x01(\x05\x12\x12\n\ncan_attack\x18\x0c \x01(\x08\x12\x0e\n\x06\x66\x61\x63ing\x18\r \x01(\x05\x12\x18\n\x10\x65xperience_level\x18\x0e \x01(\x05\x12\x0e\n\x06stance\x18\x0f \x01(\x05\x12\r\n\x05speed\x18\x10 \x01(\x05\x12\x14\n\x0c\x61ttack_range\x18\x11 \x01(\x05\x12\x17\n\x0fpassenger_count\x18\x12 \x01(\x05\x12\x13\n\x0bis_building\x18\x13 \x01(\x08\"\xe7\x02\n\x0eRlBuildingInfo\x12\x10\n\x08\x61\x63tor_id\x18\x01 \x01(\r\x12\x0c\n\x04type\x18\x02 \x01(\t\x12\r\n\x05pos_x\x18\x03 \x01(\x05\x12\r\n\x05pos_y\x18\x04 \x01(\x05\x12\x12\n\nhp_percent\x18\x05 \x01(\x02\x12\r\n\x05owner\x18\x06 \x01(\t\x12\x14\n\x0cis_producing\x18\x07 \x01(\x08\x12\x1b\n\x13production_progress\x18\x08 \x01(\x02\x12\x16\n\x0eproducing_item\x18\t \x01(\t\x12\x12\n\nis_powered\x18\n \x01(\x08\x12\x14\n\x0cis_repairing\x18\x0b \x01(\x08\x12\x12\n\nsell_value\x18\x0c \x01(\x05\x12\x0f\n\x07rally_x\x18\r \x01(\x05\x12\x0f\n\x07rally_y\x18\x0e \x01(\x05\x12\x14\n\x0cpower_amount\x18\x0f \x01(\x05\x12\x13\n\x0b\x63\x61n_produce\x18\x10 \x03(\t\x12\x0e\n\x06\x63\x65ll_x\x18\x11 \x01(\x05\x12\x0e\n\x06\x63\x65ll_y\x18\x12 \x01(\x05\"\x87\x01\n\x10RlProductionInfo\x12\x12\n\nqueue_type\x18\x01 \x01(\t\x12\x0c\n\x04item\x18\x02 \x01(\t\x12\x10\n\x08progress\x18\x03 \x01(\x02\x12\x17\n\x0fremaining_ticks\x18\x04 \x01(\x05\x12\x16\n\x0eremaining_cost\x18\x05 \x01(\x05\x12\x0e\n\x06paused\x18\x06 \x01(\x08\"<\n\tRlMapInfo\x12\r\n\x05width\x18\x01 \x01(\x05\x12\x0e\n\x06height\x18\x02 \x01(\x05\x12\x10\n\x08map_name\x18\x03 \x01(\t\"3\n\x0b\x41gentAction\x12$\n\x08\x63ommands\x18\x01 \x03(\x0b\x32\x12.openra.rl.Command\"\xa2\x01\n\x07\x43ommand\x12%\n\x06\x61\x63tion\x18\x01 \x01(\x0e\x32\x15.openra.rl.ActionType\x12\x10\n\x08\x61\x63tor_id\x18\x02 \x01(\r\x12\x17\n\x0ftarget_actor_id\x18\x03 \x01(\r\x12\x10\n\x08target_x\x18\x04 \x01(\x05\x12\x10\n\x08target_y\x18\x05 \x01(\x05\x12\x11\n\titem_type\x18\x06 \x01(\t\x12\x0e\n\x06queued\x18\x07 \x01(\x08\"\x91\x01\n\tGameState\x12\x12\n\nepisode_id\x18\x01 \x01(\t\x12\x0c\n\x04tick\x18\x02 \x01(\x05\x12\r\n\x05phase\x18\x03 \x01(\t\x12\x0e\n\x06winner\x18\x04 \x01(\t\x12\x14\n\x0cplayer_count\x18\x05 \x01(\x05\x12\x16\n\x0eplayer_faction\x18\x06 \x01(\t\x12\x15\n\renemy_faction\x18\x07 \x01(\t\"\x0e\n\x0cStateRequest*\xb9\x02\n\nActionType\x12\t\n\x05NO_OP\x10\x00\x12\x08\n\x04MOVE\x10\x01\x12\x0f\n\x0b\x41TTACK_MOVE\x10\x02\x12\n\n\x06\x41TTACK\x10\x03\x12\x08\n\x04STOP\x10\x04\x12\x0b\n\x07HARVEST\x10\x05\x12\t\n\x05\x42UILD\x10\x06\x12\t\n\x05TRAIN\x10\x07\x12\n\n\x06\x44\x45PLOY\x10\x08\x12\x08\n\x04SELL\x10\t\x12\n\n\x06REPAIR\x10\n\x12\x12\n\x0ePLACE_BUILDING\x10\x0b\x12\x15\n\x11\x43\x41NCEL_PRODUCTION\x10\x0c\x12\x13\n\x0fSET_RALLY_POINT\x10\r\x12\t\n\x05GUARD\x10\x0e\x12\x0e\n\nSET_STANCE\x10\x0f\x12\x13\n\x0f\x45NTER_TRANSPORT\x10\x10\x12\n\n\x06UNLOAD\x10\x11\x12\x0e\n\nPOWER_DOWN\x10\x12\x12\x0f\n\x0bSET_PRIMARY\x10\x13\x12\r\n\tSURRENDER\x10\x14\x32\x8c\x01\n\x08RLBridge\x12\x45\n\x0bGameSession\x12\x16.openra.rl.AgentAction\x1a\x1a.openra.rl.GameObservation(\x01\x30\x01\x12\x39\n\x08GetState\x12\x17.openra.rl.StateRequest\x1a\x14.openra.rl.GameStateB\x18\xaa\x02\x15OpenRA.Mods.Common.RLb\x06proto3')
28
+
29
+ _globals = globals()
30
+ _builder.BuildMessageAndEnumDescriptors(DESCRIPTOR, _globals)
31
+ _builder.BuildTopDescriptorsAndMessages(DESCRIPTOR, 'rl_bridge_pb2', _globals)
32
+ if not _descriptor._USE_C_DESCRIPTORS:
33
+ _globals['DESCRIPTOR']._loaded_options = None
34
+ _globals['DESCRIPTOR']._serialized_options = b'\252\002\025OpenRA.Mods.Common.RL'
35
+ _globals['_ACTIONTYPE']._serialized_start=2273
36
+ _globals['_ACTIONTYPE']._serialized_end=2586
37
+ _globals['_GAMEOBSERVATION']._serialized_start=31
38
+ _globals['_GAMEOBSERVATION']._serialized_end=566
39
+ _globals['_RLECONOMY']._serialized_start=569
40
+ _globals['_RLECONOMY']._serialized_end=706
41
+ _globals['_RLMILITARY']._serialized_start=709
42
+ _globals['_RLMILITARY']._serialized_end=964
43
+ _globals['_RLUNITINFO']._serialized_start=967
44
+ _globals['_RLUNITINFO']._serialized_end=1326
45
+ _globals['_RLBUILDINGINFO']._serialized_start=1329
46
+ _globals['_RLBUILDINGINFO']._serialized_end=1688
47
+ _globals['_RLPRODUCTIONINFO']._serialized_start=1691
48
+ _globals['_RLPRODUCTIONINFO']._serialized_end=1826
49
+ _globals['_RLMAPINFO']._serialized_start=1828
50
+ _globals['_RLMAPINFO']._serialized_end=1888
51
+ _globals['_AGENTACTION']._serialized_start=1890
52
+ _globals['_AGENTACTION']._serialized_end=1941
53
+ _globals['_COMMAND']._serialized_start=1944
54
+ _globals['_COMMAND']._serialized_end=2106
55
+ _globals['_GAMESTATE']._serialized_start=2109
56
+ _globals['_GAMESTATE']._serialized_end=2254
57
+ _globals['_STATEREQUEST']._serialized_start=2256
58
+ _globals['_STATEREQUEST']._serialized_end=2270
59
+ _globals['_RLBRIDGE']._serialized_start=2589
60
+ _globals['_RLBRIDGE']._serialized_end=2729
61
+ # @@protoc_insertion_point(module_scope)
openra_env/generated/rl_bridge_pb2_grpc.py ADDED
@@ -0,0 +1,148 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Generated by the gRPC Python protocol compiler plugin. DO NOT EDIT!
2
+ """Client and server classes corresponding to protobuf-defined services."""
3
+ import grpc
4
+
5
+ from openra_env.generated import rl_bridge_pb2 as rl__bridge__pb2
6
+
7
+ GRPC_GENERATED_VERSION = '1.75.1'
8
+ GRPC_VERSION = grpc.__version__
9
+ _version_not_supported = False
10
+
11
+ try:
12
+ from grpc._utilities import first_version_is_lower
13
+ _version_not_supported = first_version_is_lower(GRPC_VERSION, GRPC_GENERATED_VERSION)
14
+ except ImportError:
15
+ _version_not_supported = True
16
+
17
+ if _version_not_supported:
18
+ raise RuntimeError(
19
+ f'The grpc package installed is at version {GRPC_VERSION},'
20
+ + ' but the generated code in rl_bridge_pb2_grpc.py depends on'
21
+ + f' grpcio>={GRPC_GENERATED_VERSION}.'
22
+ + f' Please upgrade your grpc module to grpcio>={GRPC_GENERATED_VERSION}'
23
+ + f' or downgrade your generated code using grpcio-tools<={GRPC_VERSION}.'
24
+ )
25
+
26
+
27
+ class RLBridgeStub(object):
28
+ """The RL Bridge service allows an external agent to interact with OpenRA
29
+ via bidirectional streaming (lock-step) or unary state queries.
30
+ """
31
+
32
+ def __init__(self, channel):
33
+ """Constructor.
34
+
35
+ Args:
36
+ channel: A grpc.Channel.
37
+ """
38
+ self.GameSession = channel.stream_stream(
39
+ '/openra.rl.RLBridge/GameSession',
40
+ request_serializer=rl__bridge__pb2.AgentAction.SerializeToString,
41
+ response_deserializer=rl__bridge__pb2.GameObservation.FromString,
42
+ _registered_method=True)
43
+ self.GetState = channel.unary_unary(
44
+ '/openra.rl.RLBridge/GetState',
45
+ request_serializer=rl__bridge__pb2.StateRequest.SerializeToString,
46
+ response_deserializer=rl__bridge__pb2.GameState.FromString,
47
+ _registered_method=True)
48
+
49
+
50
+ class RLBridgeServicer(object):
51
+ """The RL Bridge service allows an external agent to interact with OpenRA
52
+ via bidirectional streaming (lock-step) or unary state queries.
53
+ """
54
+
55
+ def GameSession(self, request_iterator, context):
56
+ """Bidirectional streaming: game sends observations, agent sends actions.
57
+ Each observation waits for an action before advancing to the next tick.
58
+ """
59
+ context.set_code(grpc.StatusCode.UNIMPLEMENTED)
60
+ context.set_details('Method not implemented!')
61
+ raise NotImplementedError('Method not implemented!')
62
+
63
+ def GetState(self, request, context):
64
+ """Unary: query current game state on demand.
65
+ """
66
+ context.set_code(grpc.StatusCode.UNIMPLEMENTED)
67
+ context.set_details('Method not implemented!')
68
+ raise NotImplementedError('Method not implemented!')
69
+
70
+
71
+ def add_RLBridgeServicer_to_server(servicer, server):
72
+ rpc_method_handlers = {
73
+ 'GameSession': grpc.stream_stream_rpc_method_handler(
74
+ servicer.GameSession,
75
+ request_deserializer=rl__bridge__pb2.AgentAction.FromString,
76
+ response_serializer=rl__bridge__pb2.GameObservation.SerializeToString,
77
+ ),
78
+ 'GetState': grpc.unary_unary_rpc_method_handler(
79
+ servicer.GetState,
80
+ request_deserializer=rl__bridge__pb2.StateRequest.FromString,
81
+ response_serializer=rl__bridge__pb2.GameState.SerializeToString,
82
+ ),
83
+ }
84
+ generic_handler = grpc.method_handlers_generic_handler(
85
+ 'openra.rl.RLBridge', rpc_method_handlers)
86
+ server.add_generic_rpc_handlers((generic_handler,))
87
+ server.add_registered_method_handlers('openra.rl.RLBridge', rpc_method_handlers)
88
+
89
+
90
+ # This class is part of an EXPERIMENTAL API.
91
+ class RLBridge(object):
92
+ """The RL Bridge service allows an external agent to interact with OpenRA
93
+ via bidirectional streaming (lock-step) or unary state queries.
94
+ """
95
+
96
+ @staticmethod
97
+ def GameSession(request_iterator,
98
+ target,
99
+ options=(),
100
+ channel_credentials=None,
101
+ call_credentials=None,
102
+ insecure=False,
103
+ compression=None,
104
+ wait_for_ready=None,
105
+ timeout=None,
106
+ metadata=None):
107
+ return grpc.experimental.stream_stream(
108
+ request_iterator,
109
+ target,
110
+ '/openra.rl.RLBridge/GameSession',
111
+ rl__bridge__pb2.AgentAction.SerializeToString,
112
+ rl__bridge__pb2.GameObservation.FromString,
113
+ options,
114
+ channel_credentials,
115
+ insecure,
116
+ call_credentials,
117
+ compression,
118
+ wait_for_ready,
119
+ timeout,
120
+ metadata,
121
+ _registered_method=True)
122
+
123
+ @staticmethod
124
+ def GetState(request,
125
+ target,
126
+ options=(),
127
+ channel_credentials=None,
128
+ call_credentials=None,
129
+ insecure=False,
130
+ compression=None,
131
+ wait_for_ready=None,
132
+ timeout=None,
133
+ metadata=None):
134
+ return grpc.experimental.unary_unary(
135
+ request,
136
+ target,
137
+ '/openra.rl.RLBridge/GetState',
138
+ rl__bridge__pb2.StateRequest.SerializeToString,
139
+ rl__bridge__pb2.GameState.FromString,
140
+ options,
141
+ channel_credentials,
142
+ insecure,
143
+ call_credentials,
144
+ compression,
145
+ wait_for_ready,
146
+ timeout,
147
+ metadata,
148
+ _registered_method=True)
openra_env/mcp_server.py ADDED
@@ -0,0 +1,454 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Standard MCP server for OpenRA-RL (stdio transport).
2
+
3
+ Exposes all game tools over the MCP protocol using FastMCP.
4
+ Connects to the game server WebSocket and proxies tool calls.
5
+
6
+ Usage:
7
+ openra-rl mcp-server
8
+ openra-rl mcp-server --server-url http://localhost:8000
9
+
10
+ Works with OpenClaw, Claude Desktop, and any MCP client.
11
+ """
12
+
13
+ import json
14
+ import logging
15
+ from typing import Any, Optional
16
+
17
+ from mcp.server.fastmcp import FastMCP
18
+
19
+ logger = logging.getLogger("openra-rl-mcp")
20
+
21
+ # Lazy-initialized shared state
22
+ _client = None
23
+ _server_url = "http://localhost:8000"
24
+ _game_started = False
25
+
26
+ mcp = FastMCP(
27
+ "openra-rl",
28
+ instructions="Play Command & Conquer: Red Alert via AI tool calls",
29
+ )
30
+
31
+
32
+ async def _get_client():
33
+ """Get or create the WebSocket client connection."""
34
+ global _client
35
+ if _client is not None:
36
+ return _client
37
+ from openra_env.mcp_ws_client import OpenRAMCPClient
38
+ _client = OpenRAMCPClient(base_url=_server_url, message_timeout_s=300.0)
39
+ await _client.connect()
40
+ return _client
41
+
42
+
43
+ async def _ensure_game() -> None:
44
+ """Ensure game server is running and a game is started."""
45
+ global _game_started
46
+ if _game_started:
47
+ return
48
+
49
+ # Check if server is healthy
50
+ import urllib.request
51
+ import urllib.error
52
+
53
+ try:
54
+ req = urllib.request.urlopen(f"{_server_url}/health", timeout=3)
55
+ if req.status == 200:
56
+ client = await _get_client()
57
+ await client.reset()
58
+ _game_started = True
59
+ return
60
+ except (urllib.error.URLError, OSError):
61
+ pass
62
+
63
+ # Try starting Docker container
64
+ try:
65
+ from openra_env.cli.docker_manager import (
66
+ check_docker, is_running, start_server, wait_for_health,
67
+ )
68
+ if not is_running():
69
+ if not check_docker():
70
+ raise RuntimeError(
71
+ "Docker is not available. Start the game server manually: "
72
+ "docker run -p 8000:8000 ghcr.io/yxc20089/openra-rl:latest"
73
+ )
74
+ port = int(_server_url.split(":")[-1].split("/")[0]) if ":" in _server_url else 8000
75
+ start_server(port=port)
76
+ wait_for_health(port=port)
77
+ except ImportError:
78
+ raise RuntimeError(
79
+ f"Game server not reachable at {_server_url}. "
80
+ "Start it manually: docker run -p 8000:8000 ghcr.io/yxc20089/openra-rl:latest"
81
+ )
82
+
83
+ client = await _get_client()
84
+ await client.reset()
85
+ _game_started = True
86
+
87
+
88
+ async def _call(tool_name: str, **kwargs) -> Any:
89
+ """Call a game tool and return the result."""
90
+ await _ensure_game()
91
+ client = await _get_client()
92
+ return await client.call_tool(tool_name, **kwargs)
93
+
94
+
95
+ def _format(result: Any) -> str:
96
+ """Format a tool result as a string."""
97
+ if isinstance(result, str):
98
+ return result
99
+ return json.dumps(result, indent=2, default=str)
100
+
101
+
102
+ # โ”€โ”€ Game Lifecycle โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
103
+
104
+ @mcp.tool()
105
+ async def start_game(difficulty: str = "normal") -> str:
106
+ """Start a new Red Alert game. Returns initial game state."""
107
+ global _game_started
108
+ _game_started = False
109
+ await _ensure_game()
110
+ state = await _call("get_game_state")
111
+ return _format(state)
112
+
113
+
114
+ @mcp.tool()
115
+ async def get_game_state() -> str:
116
+ """Get current game state: economy, units, buildings, enemies, production."""
117
+ return _format(await _call("get_game_state"))
118
+
119
+
120
+ @mcp.tool()
121
+ async def advance(ticks: int = 50) -> str:
122
+ """Advance the game by N ticks (~25 ticks = 1 second).
123
+ Production, movement, combat, and auto-placement all require game time.
124
+ Also triggers auto-placement of buildings queued via build_and_place().
125
+ Typical build times: power plant ~300 ticks, barracks ~500, war factory ~750."""
126
+ return _format(await _call("advance", ticks=ticks))
127
+
128
+
129
+ # โ”€โ”€ Economy & Info โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
130
+
131
+ @mcp.tool()
132
+ async def get_economy() -> str:
133
+ """Get economy info: cash, ore, power, harvesters."""
134
+ return _format(await _call("get_economy"))
135
+
136
+
137
+ @mcp.tool()
138
+ async def get_units() -> str:
139
+ """Get list of your units with positions, health, type."""
140
+ return _format(await _call("get_units"))
141
+
142
+
143
+ @mcp.tool()
144
+ async def get_buildings() -> str:
145
+ """Get list of your buildings with positions, production, power."""
146
+ return _format(await _call("get_buildings"))
147
+
148
+
149
+ @mcp.tool()
150
+ async def get_enemies() -> str:
151
+ """Get visible enemy units and buildings."""
152
+ return _format(await _call("get_enemies"))
153
+
154
+
155
+ @mcp.tool()
156
+ async def get_production() -> str:
157
+ """Get current production queue and available builds."""
158
+ return _format(await _call("get_production"))
159
+
160
+
161
+ @mcp.tool()
162
+ async def get_map_info() -> str:
163
+ """Get map dimensions, name, and metadata."""
164
+ return _format(await _call("get_map_info"))
165
+
166
+
167
+ @mcp.tool()
168
+ async def get_exploration_status() -> str:
169
+ """Get fog-of-war data: explored %, quadrants, enemy found."""
170
+ return _format(await _call("get_exploration_status"))
171
+
172
+
173
+ # โ”€โ”€ Knowledge โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
174
+
175
+ @mcp.tool()
176
+ async def lookup_unit(unit_type: str) -> str:
177
+ """Look up stats for a unit type (e.g. 'e1', '3tnk')."""
178
+ return _format(await _call("lookup_unit", unit_type=unit_type))
179
+
180
+
181
+ @mcp.tool()
182
+ async def lookup_building(building_type: str) -> str:
183
+ """Look up stats for a building type (e.g. 'powr', 'weap')."""
184
+ return _format(await _call("lookup_building", building_type=building_type))
185
+
186
+
187
+ @mcp.tool()
188
+ async def lookup_tech_tree(faction: str = "soviet") -> str:
189
+ """Get full tech tree and build order for a faction ('allied' or 'soviet')."""
190
+ return _format(await _call("lookup_tech_tree", faction=faction))
191
+
192
+
193
+ @mcp.tool()
194
+ async def lookup_faction(faction: str) -> str:
195
+ """Get all available units and buildings for a faction."""
196
+ return _format(await _call("lookup_faction", faction=faction))
197
+
198
+
199
+ @mcp.tool()
200
+ async def get_faction_briefing() -> str:
201
+ """Get ALL units and buildings for your faction with full stats. Best for planning."""
202
+ return _format(await _call("get_faction_briefing"))
203
+
204
+
205
+ @mcp.tool()
206
+ async def get_map_analysis() -> str:
207
+ """Get strategic map analysis: resources, terrain, chokepoints, quadrants."""
208
+ return _format(await _call("get_map_analysis"))
209
+
210
+
211
+ @mcp.tool()
212
+ async def batch_lookup(queries: list[dict]) -> str:
213
+ """Batch multiple lookups. Example: [{"type":"unit","name":"3tnk"}, {"type":"building","name":"weap"}]"""
214
+ return _format(await _call("batch_lookup", queries=queries))
215
+
216
+
217
+ # โ”€โ”€ Planning โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
218
+
219
+ @mcp.tool()
220
+ async def get_opponent_intel() -> str:
221
+ """Get intelligence on the AI opponent: difficulty, tendencies, counters."""
222
+ return _format(await _call("get_opponent_intel"))
223
+
224
+
225
+ @mcp.tool()
226
+ async def start_planning_phase() -> str:
227
+ """Start pre-game planning phase with map intel and opponent report."""
228
+ return _format(await _call("start_planning_phase"))
229
+
230
+
231
+ @mcp.tool()
232
+ async def end_planning_phase(strategy: str = "") -> str:
233
+ """End planning phase with your strategy. Begins gameplay."""
234
+ return _format(await _call("end_planning_phase", strategy=strategy))
235
+
236
+
237
+ @mcp.tool()
238
+ async def get_planning_status() -> str:
239
+ """Check if planning phase is active and remaining turns."""
240
+ return _format(await _call("get_planning_status"))
241
+
242
+
243
+ # โ”€โ”€ Movement โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
244
+
245
+ @mcp.tool()
246
+ async def move_units(unit_ids: str, target_x: int, target_y: int, queued: bool = False) -> str:
247
+ """Move units to a position. unit_ids: comma-separated IDs, 'all_combat', 'type:e1', etc."""
248
+ return _format(await _call("move_units", unit_ids=unit_ids, target_x=target_x, target_y=target_y, queued=queued))
249
+
250
+
251
+ @mcp.tool()
252
+ async def attack_move(unit_ids: str, target_x: int, target_y: int, queued: bool = False) -> str:
253
+ """Move units, engaging enemies en route. Best for advancing your army."""
254
+ return _format(await _call("attack_move", unit_ids=unit_ids, target_x=target_x, target_y=target_y, queued=queued))
255
+
256
+
257
+ @mcp.tool()
258
+ async def attack_target(unit_ids: str, target_actor_id: int, queued: bool = False) -> str:
259
+ """Order units to attack a specific enemy by actor ID."""
260
+ return _format(await _call("attack_target", unit_ids=unit_ids, target_actor_id=target_actor_id, queued=queued))
261
+
262
+
263
+ @mcp.tool()
264
+ async def stop_units(unit_ids: str) -> str:
265
+ """Stop units from moving or attacking."""
266
+ return _format(await _call("stop_units", unit_ids=unit_ids))
267
+
268
+
269
+ # โ”€โ”€ Production โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
270
+
271
+ @mcp.tool()
272
+ async def build_unit(unit_type: str, count: int = 1) -> str:
273
+ """Train units. Requires the right production building (barracks, war factory)."""
274
+ return _format(await _call("build_unit", unit_type=unit_type, count=count))
275
+
276
+
277
+ @mcp.tool()
278
+ async def build_structure(building_type: str) -> str:
279
+ """Start constructing a building (manual placement workflow).
280
+ Call advance(ticks) to let construction finish, then place_building() to place it.
281
+ Prefer build_and_place() which handles placement automatically."""
282
+ return _format(await _call("build_structure", building_type=building_type))
283
+
284
+
285
+ @mcp.tool()
286
+ async def build_and_place(building_type: str, cell_x: int = 0, cell_y: int = 0) -> str:
287
+ """Build a structure and auto-place it when construction finishes.
288
+ Call advance(ticks) after this to let construction complete โ€” placement is automatic.
289
+ Do NOT call place_building() on buildings queued this way."""
290
+ return _format(await _call("build_and_place", building_type=building_type, cell_x=cell_x, cell_y=cell_y))
291
+
292
+
293
+ # โ”€โ”€ Building/Unit Actions โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
294
+
295
+ @mcp.tool()
296
+ async def place_building(building_type: str, cell_x: int = 0, cell_y: int = 0) -> str:
297
+ """Place a completed building on the map (only for build_structure workflow).
298
+ Do NOT use on buildings queued via build_and_place() โ€” those auto-place via advance().
299
+ Cell coordinates are optional โ€” engine auto-finds position if omitted."""
300
+ return _format(await _call("place_building", building_type=building_type, cell_x=cell_x, cell_y=cell_y))
301
+
302
+
303
+ @mcp.tool()
304
+ async def cancel_production(item_type: str) -> str:
305
+ """Cancel production of a unit or building type."""
306
+ return _format(await _call("cancel_production", item_type=item_type))
307
+
308
+
309
+ @mcp.tool()
310
+ async def deploy_unit(unit_id: int) -> str:
311
+ """Deploy a unit (e.g. MCV โ†’ Construction Yard)."""
312
+ return _format(await _call("deploy_unit", unit_id=unit_id))
313
+
314
+
315
+ @mcp.tool()
316
+ async def sell_building(building_id: int) -> str:
317
+ """Sell a building for partial refund."""
318
+ return _format(await _call("sell_building", building_id=building_id))
319
+
320
+
321
+ @mcp.tool()
322
+ async def repair_building(building_id: int) -> str:
323
+ """Toggle repair on a building."""
324
+ return _format(await _call("repair_building", building_id=building_id))
325
+
326
+
327
+ @mcp.tool()
328
+ async def set_rally_point(building_id: int, cell_x: int, cell_y: int) -> str:
329
+ """Set rally point for a production building. New units go here automatically."""
330
+ return _format(await _call("set_rally_point", building_id=building_id, cell_x=cell_x, cell_y=cell_y))
331
+
332
+
333
+ @mcp.tool()
334
+ async def guard_target(unit_ids: str, target_actor_id: int, queued: bool = False) -> str:
335
+ """Order units to guard a specific actor."""
336
+ return _format(await _call("guard_target", unit_ids=unit_ids, target_actor_id=target_actor_id, queued=queued))
337
+
338
+
339
+ @mcp.tool()
340
+ async def set_stance(unit_ids: str, stance: str) -> str:
341
+ """Set unit stance: 'holdfire', 'returnfire', 'defend', 'attackanything'."""
342
+ return _format(await _call("set_stance", unit_ids=unit_ids, stance=stance))
343
+
344
+
345
+ @mcp.tool()
346
+ async def harvest(unit_id: int, cell_x: int = 0, cell_y: int = 0) -> str:
347
+ """Send a harvester to harvest at a location."""
348
+ return _format(await _call("harvest", unit_id=unit_id, cell_x=cell_x, cell_y=cell_y))
349
+
350
+
351
+ @mcp.tool()
352
+ async def power_down(building_id: int) -> str:
353
+ """Toggle power on a building to save electricity."""
354
+ return _format(await _call("power_down", building_id=building_id))
355
+
356
+
357
+ @mcp.tool()
358
+ async def set_primary(building_id: int) -> str:
359
+ """Set a building as the primary production facility."""
360
+ return _format(await _call("set_primary", building_id=building_id))
361
+
362
+
363
+ # โ”€โ”€ Placement โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
364
+
365
+ @mcp.tool()
366
+ async def get_valid_placements(building_type: str, max_results: int = 8) -> str:
367
+ """Get valid placement locations for a building type."""
368
+ return _format(await _call("get_valid_placements", building_type=building_type, max_results=max_results))
369
+
370
+
371
+ # โ”€โ”€ Unit Groups โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
372
+
373
+ @mcp.tool()
374
+ async def assign_group(group_name: str, unit_ids: list[int]) -> str:
375
+ """Create a named group of units."""
376
+ return _format(await _call("assign_group", group_name=group_name, unit_ids=unit_ids))
377
+
378
+
379
+ @mcp.tool()
380
+ async def add_to_group(group_name: str, unit_ids: list[int]) -> str:
381
+ """Add units to an existing group."""
382
+ return _format(await _call("add_to_group", group_name=group_name, unit_ids=unit_ids))
383
+
384
+
385
+ @mcp.tool()
386
+ async def get_groups() -> str:
387
+ """List all unit groups and their members."""
388
+ return _format(await _call("get_groups"))
389
+
390
+
391
+ @mcp.tool()
392
+ async def command_group(
393
+ group_name: str,
394
+ command_type: str,
395
+ target_x: int = 0,
396
+ target_y: int = 0,
397
+ target_actor_id: int = 0,
398
+ queued: bool = False,
399
+ ) -> str:
400
+ """Issue a command to a unit group. command_type: move, attack_move, attack, stop, guard."""
401
+ kwargs = dict(
402
+ group_name=group_name, command_type=command_type,
403
+ target_x=target_x, target_y=target_y,
404
+ target_actor_id=target_actor_id, queued=queued,
405
+ )
406
+ return _format(await _call("command_group", **kwargs))
407
+
408
+
409
+ # โ”€โ”€ Compound โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€๏ฟฝ๏ฟฝ๏ฟฝโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
410
+
411
+ @mcp.tool()
412
+ async def batch(actions: list[dict]) -> str:
413
+ """Execute multiple actions simultaneously in one tick. Does NOT advance game time.
414
+ Cannot contain advance() or query tools. Example: [{"tool":"build_unit","unit_type":"e1"}]"""
415
+ return _format(await _call("batch", actions=actions))
416
+
417
+
418
+ @mcp.tool()
419
+ async def plan(steps: list[dict]) -> str:
420
+ """Execute steps sequentially with state refresh between each.
421
+ Does NOT advance game time between steps โ€” use advance() standalone for that."""
422
+ return _format(await _call("plan", steps=steps))
423
+
424
+
425
+ # โ”€โ”€ Utility โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
426
+
427
+ @mcp.tool()
428
+ async def get_replay_path() -> str:
429
+ """Get the path to the current game's replay file."""
430
+ return _format(await _call("get_replay_path"))
431
+
432
+
433
+ @mcp.tool()
434
+ async def surrender() -> str:
435
+ """Surrender the current game."""
436
+ return _format(await _call("surrender"))
437
+
438
+
439
+ # โ”€โ”€ Terrain โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
440
+
441
+ @mcp.tool()
442
+ async def get_terrain_at(cell_x: int, cell_y: int) -> str:
443
+ """Get terrain type at a specific cell."""
444
+ return _format(await _call("get_terrain_at", cell_x=cell_x, cell_y=cell_y))
445
+
446
+
447
+ # โ”€โ”€ Entry Point โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
448
+
449
+ def main(server_url: Optional[str] = None) -> None:
450
+ """Run the MCP stdio server."""
451
+ global _server_url
452
+ if server_url:
453
+ _server_url = server_url
454
+ mcp.run(transport="stdio")
openra_env/mcp_ws_client.py ADDED
@@ -0,0 +1,231 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """WebSocket MCP client for OpenRA-RL.
2
+
3
+ Talks to the OpenEnv server's /ws endpoint using the correct message
4
+ protocol for MCP tool calls:
5
+ - {"type": "reset"} โ†’ reset environment
6
+ - {"type": "mcp", "data": {...}} โ†’ JSON-RPC MCP call (tools/list, tools/call)
7
+ - {"type": "step", "data": {...}} โ†’ Gym-style step (OpenRAAction)
8
+
9
+ MCPToolClient from OpenEnv sends ListToolsAction via "step" which the
10
+ server tries to parse as OpenRAAction and fails. This client uses the
11
+ correct "mcp" message type instead.
12
+ """
13
+
14
+ import asyncio
15
+ import json
16
+ import os
17
+ from dataclasses import dataclass
18
+ from typing import Any, Optional
19
+
20
+ from websockets.asyncio.client import connect as ws_connect
21
+
22
+
23
+ @dataclass
24
+ class Tool:
25
+ """MCP tool descriptor."""
26
+ name: str
27
+ description: str
28
+ input_schema: dict
29
+
30
+
31
+ class OpenRAMCPClient:
32
+ """Async WebSocket client for OpenRA-RL with MCP tool support.
33
+
34
+ Usage:
35
+ async with OpenRAMCPClient("http://localhost:8000") as client:
36
+ await client.reset()
37
+ tools = await client.list_tools()
38
+ result = await client.call_tool("get_game_state")
39
+ result = await client.call_tool("build_structure", building_type="powr")
40
+ """
41
+
42
+ def __init__(
43
+ self,
44
+ base_url: str = "http://localhost:8000",
45
+ message_timeout_s: float = 300.0,
46
+ ):
47
+ # Convert HTTP URL to WebSocket URL
48
+ ws_url = base_url.replace("http://", "ws://").replace("https://", "wss://")
49
+ ws_url = ws_url.rstrip("/")
50
+ self._ws_url = f"{ws_url}/ws"
51
+ self._timeout = message_timeout_s
52
+ self._ws = None
53
+ self._rpc_id = 0
54
+ self._tools_cache: Optional[list[Tool]] = None
55
+
56
+ async def connect(self) -> "OpenRAMCPClient":
57
+ """Connect to the WebSocket endpoint."""
58
+ if self._ws is not None:
59
+ return self
60
+
61
+ # Handle proxy bypass for localhost
62
+ ws_lower = self._ws_url.lower()
63
+ is_localhost = "localhost" in ws_lower or "127.0.0.1" in ws_lower
64
+ old_no_proxy = os.environ.get("NO_PROXY")
65
+
66
+ if is_localhost:
67
+ current = old_no_proxy or ""
68
+ if "localhost" not in current.lower():
69
+ os.environ["NO_PROXY"] = (
70
+ f"{current},localhost,127.0.0.1" if current else "localhost,127.0.0.1"
71
+ )
72
+
73
+ try:
74
+ self._ws = await ws_connect(
75
+ self._ws_url,
76
+ open_timeout=30.0,
77
+ max_size=50 * 1024 * 1024, # 50 MB
78
+ ping_interval=None,
79
+ )
80
+ except (asyncio.TimeoutError, OSError, ConnectionRefusedError) as e:
81
+ raise RuntimeError(
82
+ f"Could not connect to game server at {self._ws_url}: {e}\n"
83
+ f" Is the server running? Try: openra-rl server start"
84
+ ) from e
85
+ finally:
86
+ if is_localhost:
87
+ if old_no_proxy is None:
88
+ os.environ.pop("NO_PROXY", None)
89
+ else:
90
+ os.environ["NO_PROXY"] = old_no_proxy
91
+
92
+ return self
93
+
94
+ async def close(self):
95
+ """Close the WebSocket connection."""
96
+ if self._ws:
97
+ try:
98
+ await self._ws.close()
99
+ except Exception:
100
+ pass
101
+ self._ws = None
102
+
103
+ async def __aenter__(self) -> "OpenRAMCPClient":
104
+ return await self.connect()
105
+
106
+ async def __aexit__(self, *args):
107
+ await self.close()
108
+
109
+ async def _send_recv(self, message: dict) -> dict:
110
+ """Send a message and wait for response."""
111
+ if self._ws is None:
112
+ raise RuntimeError("Not connected. Call connect() first.")
113
+
114
+ await self._ws.send(json.dumps(message))
115
+ raw = await asyncio.wait_for(self._ws.recv(), timeout=self._timeout)
116
+ return json.loads(raw)
117
+
118
+ # โ”€โ”€ Environment Control โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
119
+
120
+ async def reset(self, **kwargs) -> dict:
121
+ """Reset the environment and start a new game."""
122
+ response = await self._send_recv({"type": "reset", "data": kwargs})
123
+ if response.get("type") == "error":
124
+ raise RuntimeError(f"Reset failed: {response.get('data', {}).get('message', '?')}")
125
+ return response.get("data", {})
126
+
127
+ # โ”€โ”€ MCP Tool Operations โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
128
+
129
+ async def list_tools(self, use_cache: bool = True) -> list[Tool]:
130
+ """List available MCP tools."""
131
+ if use_cache and self._tools_cache is not None:
132
+ return self._tools_cache
133
+
134
+ self._rpc_id += 1
135
+ rpc_request = {
136
+ "jsonrpc": "2.0",
137
+ "method": "tools/list",
138
+ "params": {},
139
+ "id": self._rpc_id,
140
+ }
141
+
142
+ response = await self._send_recv({"type": "mcp", "data": rpc_request})
143
+ rpc_response = response.get("data", {})
144
+
145
+ if "error" in rpc_response:
146
+ raise RuntimeError(f"tools/list failed: {rpc_response['error']}")
147
+
148
+ tools_data = rpc_response.get("result", {}).get("tools", [])
149
+ self._tools_cache = [
150
+ Tool(
151
+ name=t.get("name", ""),
152
+ description=t.get("description", ""),
153
+ input_schema=t.get("inputSchema", t.get("input_schema", {})),
154
+ )
155
+ for t in tools_data
156
+ ]
157
+ return self._tools_cache
158
+
159
+ async def call_tool(self, name: str, **kwargs) -> Any:
160
+ """Call an MCP tool by name with keyword arguments."""
161
+ self._rpc_id += 1
162
+ rpc_request = {
163
+ "jsonrpc": "2.0",
164
+ "method": "tools/call",
165
+ "params": {"name": name, "arguments": kwargs},
166
+ "id": self._rpc_id,
167
+ }
168
+
169
+ response = await self._send_recv({"type": "mcp", "data": rpc_request})
170
+ rpc_response = response.get("data", {})
171
+
172
+ if "error" in rpc_response:
173
+ error = rpc_response["error"]
174
+ raise RuntimeError(f"Tool '{name}' failed: {error.get('message', error)}")
175
+
176
+ result = rpc_response.get("result")
177
+ return self._unwrap_mcp_result(result)
178
+
179
+ @staticmethod
180
+ def _unwrap_mcp_result(result: Any) -> Any:
181
+ """Unwrap FastMCP tool result to plain Python data.
182
+
183
+ FastMCP wraps results as:
184
+ {
185
+ "content": [{"type": "text", "text": "..."}],
186
+ "structured_content": {"result": <actual_data>},
187
+ "data": <actual_data>,
188
+ "is_error": false
189
+ }
190
+
191
+ Priority: structured_content.result > data > content text > raw result
192
+ """
193
+ if not isinstance(result, dict):
194
+ return result
195
+
196
+ # data field is correct for dicts, buggy ([{}]) for lists.
197
+ # structured_content.result is correct for lists, empty string for dicts.
198
+ # Strategy: use data if it's a non-empty dict, else structured_content.result,
199
+ # else fall back to content text parsing.
200
+ data = result.get("data")
201
+ if isinstance(data, dict) and data:
202
+ return data
203
+
204
+ sc = result.get("structured_content")
205
+ if isinstance(sc, dict):
206
+ sc_result = sc.get("result")
207
+ if sc_result is not None and sc_result != "":
208
+ return sc_result
209
+
210
+ # data for empty lists (both data=[] and sc.result=[])
211
+ if isinstance(data, list) and data != [{}]:
212
+ return data
213
+
214
+ # Fallback: parse content text items
215
+ content = result.get("content")
216
+ if isinstance(content, list) and content:
217
+ texts = []
218
+ for item in content:
219
+ if isinstance(item, dict) and item.get("type") == "text":
220
+ text = item.get("text", "")
221
+ try:
222
+ texts.append(json.loads(text))
223
+ except (json.JSONDecodeError, TypeError):
224
+ texts.append(text)
225
+ else:
226
+ texts.append(item)
227
+ if len(texts) == 1:
228
+ return texts[0]
229
+ return texts
230
+
231
+ return result
openra_env/models.py ADDED
@@ -0,0 +1,222 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Pydantic models for the OpenRA-RL environment.
2
+
3
+ Defines the Action, Observation, and State types used across
4
+ the OpenEnv client-server boundary.
5
+ """
6
+
7
+ from enum import Enum
8
+ from typing import Dict, List, Optional
9
+
10
+ from pydantic import Field
11
+
12
+ from openenv.core.env_server.types import Action, Observation, State
13
+
14
+
15
+ # โ”€โ”€โ”€ Action Types โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
16
+
17
+
18
+ class ActionType(str, Enum):
19
+ """Available command types matching the protobuf ActionType enum."""
20
+
21
+ NO_OP = "no_op"
22
+ MOVE = "move"
23
+ ATTACK_MOVE = "attack_move"
24
+ ATTACK = "attack"
25
+ STOP = "stop"
26
+ HARVEST = "harvest"
27
+ BUILD = "build"
28
+ TRAIN = "train"
29
+ DEPLOY = "deploy"
30
+ SELL = "sell"
31
+ REPAIR = "repair"
32
+ PLACE_BUILDING = "place_building"
33
+ CANCEL_PRODUCTION = "cancel_production"
34
+ SET_RALLY_POINT = "set_rally_point"
35
+ GUARD = "guard"
36
+ SET_STANCE = "set_stance"
37
+ ENTER_TRANSPORT = "enter_transport"
38
+ UNLOAD = "unload"
39
+ POWER_DOWN = "power_down"
40
+ SET_PRIMARY = "set_primary"
41
+ SURRENDER = "surrender"
42
+
43
+
44
+ class CommandModel(Action):
45
+ """A single command to issue to the game engine."""
46
+
47
+ action: ActionType = Field(..., description="Type of command to execute")
48
+ actor_id: int = Field(default=0, description="Subject actor ID (for unit commands)")
49
+ target_actor_id: int = Field(default=0, description="Target actor ID (for attack, etc.)")
50
+ target_x: int = Field(default=0, description="Target cell X coordinate")
51
+ target_y: int = Field(default=0, description="Target cell Y coordinate")
52
+ item_type: str = Field(default="", description="Actor type for build/train commands")
53
+ queued: bool = Field(default=False, description="Queue after current activity vs interrupt")
54
+
55
+
56
+ class OpenRAAction(Action):
57
+ """Action sent from the agent to the OpenRA environment.
58
+
59
+ Contains a list of commands to execute in a single game step.
60
+ Multiple commands can be issued per step (e.g., move unit A and build unit B).
61
+ """
62
+
63
+ commands: List[CommandModel] = Field(
64
+ default_factory=list, description="List of commands to execute this step"
65
+ )
66
+
67
+
68
+ # โ”€โ”€โ”€ Observation Types โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
69
+
70
+
71
+ class EconomyInfo(Action):
72
+ """Player economic state."""
73
+
74
+ cash: int = Field(default=0, description="Available cash")
75
+ ore: int = Field(default=0, description="Raw ore in silos")
76
+ power_provided: int = Field(default=0, description="Total power generation")
77
+ power_drained: int = Field(default=0, description="Total power consumption")
78
+ resource_capacity: int = Field(default=0, description="Maximum resource storage")
79
+ harvester_count: int = Field(default=0, description="Number of active harvesters")
80
+
81
+
82
+ class MilitaryInfo(Action):
83
+ """Player military statistics."""
84
+
85
+ units_killed: int = Field(default=0, description="Enemy units destroyed")
86
+ units_lost: int = Field(default=0, description="Own units lost")
87
+ buildings_killed: int = Field(default=0, description="Enemy buildings destroyed")
88
+ buildings_lost: int = Field(default=0, description="Own buildings lost")
89
+ army_value: int = Field(default=0, description="Total value of active army")
90
+ active_unit_count: int = Field(default=0, description="Number of active units")
91
+ kills_cost: int = Field(default=0, description="Total cost of enemy units/buildings killed")
92
+ deaths_cost: int = Field(default=0, description="Total cost of own units/buildings lost")
93
+ assets_value: int = Field(default=0, description="Total value of all assets (units + buildings)")
94
+ experience: int = Field(default=0, description="Player experience points")
95
+ order_count: int = Field(default=0, description="Total orders issued")
96
+
97
+
98
+ class UnitInfoModel(Action):
99
+ """Information about a single unit."""
100
+
101
+ actor_id: int = Field(..., description="Unique actor ID")
102
+ type: str = Field(..., description="Actor type (e.g., 'e1', '1tnk', 'harv')")
103
+ pos_x: int = Field(default=0, description="World position X")
104
+ pos_y: int = Field(default=0, description="World position Y")
105
+ cell_x: int = Field(default=0, description="Cell position X")
106
+ cell_y: int = Field(default=0, description="Cell position Y")
107
+ hp_percent: float = Field(default=1.0, description="Health percentage 0.0-1.0")
108
+ is_idle: bool = Field(default=True, description="Whether the unit is idle")
109
+ current_activity: str = Field(default="", description="Current activity name")
110
+ owner: str = Field(default="", description="Owner player internal name")
111
+ can_attack: bool = Field(default=False, description="Whether the unit can attack")
112
+
113
+ # Sprint 4: enriched unit data
114
+ facing: int = Field(default=0, description="WAngle 0-1023 direction unit faces")
115
+ experience_level: int = Field(default=0, description="Veterancy level (0=none)")
116
+ stance: int = Field(default=0, description="0=HoldFire, 1=ReturnFire, 2=Defend, 3=AttackAnything")
117
+ speed: int = Field(default=0, description="Base movement speed")
118
+ attack_range: int = Field(default=0, description="Max attack range in WDist units")
119
+ passenger_count: int = Field(default=-1, description="Cargo count (0 if transport empty, -1 if N/A)")
120
+ is_building: bool = Field(default=False, description="False for units, helps distinguish in visible_enemies")
121
+
122
+
123
+ class BuildingInfoModel(Action):
124
+ """Information about a single building."""
125
+
126
+ actor_id: int = Field(..., description="Unique actor ID")
127
+ type: str = Field(..., description="Actor type (e.g., 'powr', 'barr', 'weap')")
128
+ pos_x: int = Field(default=0, description="World position X")
129
+ pos_y: int = Field(default=0, description="World position Y")
130
+ hp_percent: float = Field(default=1.0, description="Health percentage 0.0-1.0")
131
+ owner: str = Field(default="", description="Owner player internal name")
132
+ is_producing: bool = Field(default=False, description="Whether actively producing")
133
+ production_progress: float = Field(default=0.0, description="Production progress 0.0-1.0")
134
+ producing_item: str = Field(default="", description="Item currently being produced")
135
+ is_powered: bool = Field(default=True, description="Whether powered")
136
+
137
+ # Sprint 4: enriched building data
138
+ is_repairing: bool = Field(default=False, description="Actively being repaired")
139
+ sell_value: int = Field(default=0, description="Refund amount if sold")
140
+ rally_x: int = Field(default=-1, description="Rally point cell X (-1 if none)")
141
+ rally_y: int = Field(default=-1, description="Rally point cell Y (-1 if none)")
142
+ power_amount: int = Field(default=0, description="Power provided (+) or consumed (-)")
143
+ can_produce: List[str] = Field(default_factory=list, description="Items this building can produce")
144
+ cell_x: int = Field(default=0, description="Cell position X")
145
+ cell_y: int = Field(default=0, description="Cell position Y")
146
+
147
+
148
+ class ProductionInfoModel(Action):
149
+ """Information about a production queue entry."""
150
+
151
+ queue_type: str = Field(..., description="Queue type: Building, Infantry, Vehicle, Aircraft")
152
+ item: str = Field(..., description="Actor type being produced")
153
+ progress: float = Field(default=0.0, description="Progress 0.0-1.0")
154
+ remaining_ticks: int = Field(default=0, description="Ticks until completion")
155
+ remaining_cost: int = Field(default=0, description="Remaining cost")
156
+ paused: bool = Field(default=False, description="Whether production is paused")
157
+
158
+
159
+ class MapInfoModel(Action):
160
+ """Basic map information."""
161
+
162
+ width: int = Field(default=0, description="Map width in cells")
163
+ height: int = Field(default=0, description="Map height in cells")
164
+ map_name: str = Field(default="", description="Map display name")
165
+
166
+
167
+ class OpenRAObservation(Observation):
168
+ """Observation returned from the OpenRA environment each step.
169
+
170
+ Contains structured game state data matching the protobuf GameObservation.
171
+ """
172
+
173
+ tick: int = Field(default=0, description="Current game tick")
174
+ economy: EconomyInfo = Field(default_factory=EconomyInfo, description="Economic state")
175
+ military: MilitaryInfo = Field(default_factory=MilitaryInfo, description="Military statistics")
176
+ units: List[UnitInfoModel] = Field(default_factory=list, description="Own units")
177
+ buildings: List[BuildingInfoModel] = Field(default_factory=list, description="Own buildings")
178
+ production: List[ProductionInfoModel] = Field(default_factory=list, description="Active production queues")
179
+ visible_enemies: List[UnitInfoModel] = Field(default_factory=list, description="Visible enemy units")
180
+ visible_enemy_buildings: List[BuildingInfoModel] = Field(
181
+ default_factory=list, description="Visible enemy buildings"
182
+ )
183
+ map_info: MapInfoModel = Field(default_factory=MapInfoModel, description="Map metadata")
184
+ available_production: List[str] = Field(
185
+ default_factory=list, description="Actor types available for production"
186
+ )
187
+ result: str = Field(default="", description="Game result: 'win', 'lose', 'draw', or ''")
188
+
189
+ # Spatial map tensor (base64-encoded float32 array for JSON transport)
190
+ spatial_map: str = Field(default="", description="Base64-encoded spatial tensor: Hร—Wร—C float32 array")
191
+ spatial_channels: int = Field(default=0, description="Number of spatial channels")
192
+
193
+ # Multi-dimensional reward vector (when reward_vector.enabled=True)
194
+ reward_vector: Optional[Dict[str, float]] = Field(
195
+ default=None,
196
+ description="8-dimensional reward: combat, economy, infrastructure, intelligence, composition, tempo, disruption, outcome",
197
+ )
198
+
199
+ # Inherited from Observation:
200
+ # done: bool = False
201
+ # reward: float | None = None
202
+ # metadata: Dict[str, Any] = {}
203
+
204
+
205
+ # โ”€โ”€โ”€ State โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
206
+
207
+
208
+ class OpenRAState(State):
209
+ """Environment state tracking episode metadata.
210
+
211
+ Extends the base State with OpenRA-specific fields.
212
+ """
213
+
214
+ game_tick: int = Field(default=0, description="Current game tick")
215
+ map_name: str = Field(default="", description="Active map name")
216
+ opponent_type: str = Field(default="bot_normal", description="Opponent type: bot_easy, bot_normal, bot_hard")
217
+ planning_strategy: str = Field(default="", description="Agent's pre-game strategy if planning was used")
218
+ planning_turns_used: int = Field(default=0, description="Number of planning turns used")
219
+
220
+ # Inherited from State:
221
+ # episode_id: Optional[str] = None
222
+ # step_count: int = 0
openra_env/opponent_intel.py ADDED
@@ -0,0 +1,263 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Hardcoded opponent intelligence profiles for OpenRA AI bots.
2
+
3
+ Provides scouting reports and behavioral profiles based on the AI difficulty
4
+ level. These are static assessments based on observed AI behavior patterns.
5
+ """
6
+
7
+ from typing import Optional
8
+
9
+
10
+ # โ”€โ”€ Opponent Profiles โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
11
+
12
+ AI_PROFILES: dict[str, dict] = {
13
+ "beginner": {
14
+ "difficulty": "Beginner",
15
+ "display_name": "Beginner AI",
16
+ "aggressiveness": "minimal",
17
+ "expansion_tendency": "none",
18
+ "unit_diversity": "very_low",
19
+ "build_order_quality": "very_poor",
20
+ "estimated_win_rate_vs_new_player": 0.10,
21
+ "typical_first_attack_tick": 150000,
22
+ "behavioral_traits": [
23
+ "Almost never attacks โ€” first attack after 100+ minutes",
24
+ "Builds only basic infantry (rifle soldiers, grenadiers)",
25
+ "No vehicles, no aircraft, no navy",
26
+ "Tiny squads of 3-5 units that pose almost no threat",
27
+ "Stays at starting base, never expands",
28
+ "Extremely slow economy โ€” one refinery, one harvester",
29
+ "Does not repair damaged buildings",
30
+ "Very slow construction speed โ€” 8x slower than normal AI",
31
+ "Does not use superweapons or advanced tech",
32
+ "Barely defends base โ€” minimal turrets placed very late",
33
+ ],
34
+ "recommended_counters": [
35
+ "Any military force will win โ€” even 3-4 infantry can overwhelm",
36
+ "Take your time building economy and army โ€” no rush needed",
37
+ "Good difficulty for learning basic game mechanics",
38
+ "Practice build orders without pressure",
39
+ ],
40
+ "typical_army_composition": {
41
+ "infantry": 1.0,
42
+ "vehicles": 0.0,
43
+ "aircraft": 0.0,
44
+ "ships": 0.0,
45
+ },
46
+ "recent_match_history": [
47
+ {"result": "loss", "duration_ticks": 8000, "score": 400},
48
+ {"result": "loss", "duration_ticks": 6000, "score": 300},
49
+ {"result": "loss", "duration_ticks": 10000, "score": 600},
50
+ ],
51
+ },
52
+ "easy": {
53
+ "difficulty": "Easy",
54
+ "display_name": "Easy AI",
55
+ "aggressiveness": "low",
56
+ "expansion_tendency": "very_low",
57
+ "unit_diversity": "low",
58
+ "build_order_quality": "poor",
59
+ "estimated_win_rate_vs_new_player": 0.25,
60
+ "typical_first_attack_tick": 80000,
61
+ "behavioral_traits": [
62
+ "Passive โ€” first attack after ~50 minutes of game time",
63
+ "Builds basic infantry and some light vehicles (light tanks, APCs)",
64
+ "No aircraft, no navy, no advanced tech",
65
+ "Small attack squads of 8-12 units",
66
+ "Rarely expands beyond starting base",
67
+ "Slow economy โ€” 1-2 refineries with 2-4 harvesters",
68
+ "Repairs buildings slowly (5x slower than normal)",
69
+ "Moderate construction speed โ€” 3x slower than normal AI",
70
+ "Limited unit caps โ€” cannot mass large armies",
71
+ "Defenses delayed but eventually builds pillboxes and turrets",
72
+ ],
73
+ "recommended_counters": [
74
+ "Build a small army of 10-15 units and attack before their defenses solidify",
75
+ "Any combined arms force (infantry + tanks) will overwhelm them",
76
+ "Economy is their weakness โ€” denying resources cripples them further",
77
+ "No need to rush โ€” focus on good build order first",
78
+ ],
79
+ "typical_army_composition": {
80
+ "infantry": 0.6,
81
+ "vehicles": 0.4,
82
+ "aircraft": 0.0,
83
+ "ships": 0.0,
84
+ },
85
+ "recent_match_history": [
86
+ {"result": "loss", "duration_ticks": 5000, "score": 800},
87
+ {"result": "loss", "duration_ticks": 7000, "score": 1200},
88
+ {"result": "win", "duration_ticks": 15000, "score": 2500},
89
+ ],
90
+ },
91
+ "medium": {
92
+ "difficulty": "Medium",
93
+ "display_name": "Medium AI",
94
+ "aggressiveness": "moderate",
95
+ "expansion_tendency": "moderate",
96
+ "unit_diversity": "moderate",
97
+ "build_order_quality": "decent",
98
+ "estimated_win_rate_vs_new_player": 0.50,
99
+ "typical_first_attack_tick": 5000,
100
+ "behavioral_traits": [
101
+ "Moderately aggressive โ€” sends first attack around tick 5000 (~3 minutes)",
102
+ "Builds a balanced ground force (infantry, tanks, artillery)",
103
+ "No aircraft or naval units โ€” ground-focused only",
104
+ "Medium-sized attack squads of 20-35 units",
105
+ "Will expand to a second base if resources allow",
106
+ "Decent economy โ€” 2-3 refineries with up to 6 harvesters",
107
+ "Repairs buildings at normal speed",
108
+ "Slightly slower construction than Hard/Brutal AI",
109
+ "Builds advanced tech eventually (tech centers delayed ~8 minutes)",
110
+ "Uses superweapons if available but slowly",
111
+ "Limited production capacity โ€” fewer factories than Hard AI",
112
+ ],
113
+ "recommended_counters": [
114
+ "Build early defenses โ€” first attack comes around tick 5000",
115
+ "Scout by tick 2000 to identify expansion attempts",
116
+ "Match their economy with 2+ refineries minimum",
117
+ "Combined arms with anti-armor focus works well",
118
+ "Their lack of air power means you can skip AA early",
119
+ "Deny expansion to keep resource advantage",
120
+ ],
121
+ "typical_army_composition": {
122
+ "infantry": 0.35,
123
+ "vehicles": 0.65,
124
+ "aircraft": 0.0,
125
+ "ships": 0.0,
126
+ },
127
+ "recent_match_history": [
128
+ {"result": "win", "duration_ticks": 7000, "score": 3200},
129
+ {"result": "loss", "duration_ticks": 9000, "score": 3800},
130
+ {"result": "win", "duration_ticks": 8000, "score": 4200},
131
+ {"result": "loss", "duration_ticks": 10000, "score": 3500},
132
+ ],
133
+ },
134
+ "normal": {
135
+ "difficulty": "Normal",
136
+ "display_name": "Normal AI",
137
+ "aggressiveness": "high",
138
+ "expansion_tendency": "high",
139
+ "unit_diversity": "high",
140
+ "build_order_quality": "good",
141
+ "estimated_win_rate_vs_new_player": 0.65,
142
+ "typical_first_attack_tick": 1500,
143
+ "behavioral_traits": [
144
+ "Very aggressive โ€” sends attack waves frequently starting around tick 1500",
145
+ "Masters all different unit types (infantry, tanks, aircraft, ships)",
146
+ "Eager to open a second base near your position or mid-way on the map",
147
+ "Strong economy โ€” builds 2-3 refineries with multiple harvesters",
148
+ "Rebuilds destroyed buildings quickly and adapts composition",
149
+ "Will target your harvesters and exposed, undefended buildings",
150
+ "Uses combined arms effectively (infantry + vehicles + air strikes)",
151
+ "Scouts your base early and adjusts strategy based on what you build",
152
+ ],
153
+ "recommended_counters": [
154
+ "Build early defenses (turrets) at base entrance โ€” first attack comes ~tick 1500",
155
+ "Scout early (by tick 500) to find and deny expansion attempts",
156
+ "Send a small raiding force to destroy their second base before it's established",
157
+ "Maintain power surplus at all times โ€” their attacks exploit brownouts",
158
+ "Build anti-air (SAM/AA Gun) by mid-game to counter their aircraft",
159
+ "Match their economy: build 2+ refineries minimum to keep up",
160
+ "Don't turtle โ€” they will out-expand and out-resource you",
161
+ ],
162
+ "typical_army_composition": {
163
+ "infantry": 0.30,
164
+ "vehicles": 0.45,
165
+ "aircraft": 0.15,
166
+ "ships": 0.10,
167
+ },
168
+ "recent_match_history": [
169
+ {"result": "win", "duration_ticks": 8000, "score": 5200},
170
+ {"result": "win", "duration_ticks": 6500, "score": 4800},
171
+ {"result": "loss", "duration_ticks": 10000, "score": 6100},
172
+ {"result": "win", "duration_ticks": 7200, "score": 5500},
173
+ {"result": "loss", "duration_ticks": 9000, "score": 4000},
174
+ ],
175
+ },
176
+ "hard": {
177
+ "difficulty": "Hard",
178
+ "display_name": "Hard AI",
179
+ "aggressiveness": "very_high",
180
+ "expansion_tendency": "very_high",
181
+ "unit_diversity": "very_high",
182
+ "build_order_quality": "optimal",
183
+ "estimated_win_rate_vs_new_player": 0.85,
184
+ "typical_first_attack_tick": 1000,
185
+ "behavioral_traits": [
186
+ "Extremely aggressive โ€” attacks within first 1000 ticks with combined forces",
187
+ "Optimal build orders โ€” wastes no time or resources, perfect macro",
188
+ "Expands aggressively with multiple bases across the map",
189
+ "Uses superweapons if tech allows (nuclear missile, iron curtain)",
190
+ "Coordinates multi-front attacks simultaneously from different angles",
191
+ "Excellent at resource denial โ€” prioritizes harvesters and refineries",
192
+ "Rapid tech progression to advanced units (Mammoth tanks, MiGs)",
193
+ "Will cheat slightly on resource gathering speed",
194
+ ],
195
+ "recommended_counters": [
196
+ "MUST build defenses immediately โ€” turrets before second refinery",
197
+ "Scout by tick 300 โ€” their expansion is very fast",
198
+ "Deny expansions aggressively or you'll be completely out-resourced",
199
+ "Build multiple production buildings for faster unit output",
200
+ "Never let power go negative โ€” they will exploit it ruthlessly",
201
+ "Mix anti-air into every attack group โ€” they will use aircraft",
202
+ "Prepare for superweapons by mid-game โ€” keep army spread out",
203
+ ],
204
+ "typical_army_composition": {
205
+ "infantry": 0.20,
206
+ "vehicles": 0.45,
207
+ "aircraft": 0.25,
208
+ "ships": 0.10,
209
+ },
210
+ "recent_match_history": [
211
+ {"result": "win", "duration_ticks": 5000, "score": 7200},
212
+ {"result": "win", "duration_ticks": 4500, "score": 6800},
213
+ {"result": "win", "duration_ticks": 6000, "score": 8100},
214
+ {"result": "loss", "duration_ticks": 12000, "score": 9500},
215
+ {"result": "win", "duration_ticks": 5500, "score": 7500},
216
+ ],
217
+ },
218
+ }
219
+
220
+
221
+ def get_opponent_profile(difficulty: str) -> Optional[dict]:
222
+ """Get the opponent intelligence profile for a given AI difficulty.
223
+
224
+ Args:
225
+ difficulty: One of "beginner", "easy", "medium", "normal", "hard".
226
+ Also accepts "bot_" prefix (strips it).
227
+
228
+ Returns:
229
+ Profile dict or None if not found.
230
+ """
231
+ clean = difficulty.lower().replace("bot_", "")
232
+ return AI_PROFILES.get(clean)
233
+
234
+
235
+ def get_opponent_summary(difficulty: str) -> str:
236
+ """Get a human-readable scouting report for LLM consumption."""
237
+ profile = get_opponent_profile(difficulty)
238
+ if profile is None:
239
+ return f"Unknown AI difficulty: {difficulty}"
240
+
241
+ traits = "\n".join(f" - {t}" for t in profile["behavioral_traits"])
242
+ counters = "\n".join(f" - {c}" for c in profile["recommended_counters"])
243
+
244
+ wins = sum(1 for m in profile["recent_match_history"] if m["result"] == "win")
245
+ total = len(profile["recent_match_history"])
246
+ avg_score = sum(m["score"] for m in profile["recent_match_history"]) // total
247
+
248
+ army = profile["typical_army_composition"]
249
+ army_str = ", ".join(f"{k}: {v:.0%}" for k, v in army.items() if v > 0)
250
+
251
+ return (
252
+ f"## Opponent Scouting Report: {profile['display_name']}\n"
253
+ f"Aggressiveness: {profile['aggressiveness']}\n"
254
+ f"Expansion tendency: {profile['expansion_tendency']}\n"
255
+ f"Unit diversity: {profile['unit_diversity']}\n"
256
+ f"Build order quality: {profile['build_order_quality']}\n"
257
+ f"Estimated first attack: ~tick {profile['typical_first_attack_tick']}\n"
258
+ f"Win rate vs new players: {profile['estimated_win_rate_vs_new_player']:.0%}\n"
259
+ f"Recent record: {wins}W-{total - wins}L (avg score: {avg_score})\n"
260
+ f"Typical army mix: {army_str}\n"
261
+ f"\nBehavioral traits:\n{traits}\n"
262
+ f"\nRecommended counters:\n{counters}"
263
+ )