github-actions[bot] commited on
Commit ยท
02f4a63
0
Parent(s):
Sync from GitHub ac82c3e
Browse filesThis view is limited to 50 files because it contains too many changes. ย See raw diff
- .dockerignore +21 -0
- .github/workflows/ci.yml +32 -0
- .github/workflows/docker-publish.yml +52 -0
- .github/workflows/pypi-publish.yml +26 -0
- .github/workflows/sync-to-hf.yml +25 -0
- .gitignore +19 -0
- .gitmodules +3 -0
- .openenvignore +28 -0
- Dockerfile +149 -0
- Dockerfile.agent +32 -0
- LICENSE +674 -0
- OpenRA +1 -0
- README.md +479 -0
- __init__.py +4 -0
- client.py +3 -0
- config.yaml +142 -0
- docker-compose.yaml +71 -0
- docker/build.sh +51 -0
- docker/entrypoint.sh +30 -0
- docker/replay-viewer.sh +89 -0
- examples/README.md +50 -0
- examples/config-lmstudio.yaml +14 -0
- examples/config-minimal.yaml +21 -0
- examples/config-ollama.yaml +14 -0
- examples/config-openrouter.yaml +13 -0
- examples/llm_agent.py +170 -0
- examples/mcp_bot.py +619 -0
- examples/scripted_bot.py +831 -0
- models.py +7 -0
- openenv.yaml +6 -0
- openra_env/__init__.py +6 -0
- openra_env/agent.py +1156 -0
- openra_env/bench_export.py +95 -0
- openra_env/bench_submit.py +167 -0
- openra_env/cli/__init__.py +0 -0
- openra_env/cli/commands.py +464 -0
- openra_env/cli/console.py +43 -0
- openra_env/cli/docker_manager.py +600 -0
- openra_env/cli/main.py +212 -0
- openra_env/cli/wizard.py +166 -0
- openra_env/client.py +113 -0
- openra_env/config.py +535 -0
- openra_env/game_data.py +984 -0
- openra_env/generated/__init__.py +0 -0
- openra_env/generated/rl_bridge_pb2.py +61 -0
- openra_env/generated/rl_bridge_pb2_grpc.py +148 -0
- openra_env/mcp_server.py +454 -0
- openra_env/mcp_ws_client.py +231 -0
- openra_env/models.py +222 -0
- 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 |
+
)
|