Spaces:
Running
Running
Add Rust-backed eval stack: scenario packs, adapter, spine, integration tests
Browse files- EVAL_STACK_PLAN.md: architecture/phasing; Rust-only backend, Bench
refactored in place to reuse OpenRA-RL-Training components.
- openra_bench/rust_adapter.py: Rust obs -> render/score schema with
delta-derived signals (Rust reward is hardcoded 0.0).
- openra_bench/eval_core.py: episode spine (run_episode/run_level) with
per-turn declarative win/fail evaluation.
- openra_bench/scenarios/: contributor scenario-pack layer (schema,
composable win-condition grammar, loader w/ Rust map gating, validate
CLI, CONTRIBUTING + TEMPLATE) + 6 authored P/R/A packs (3 levels each).
- tests/test_rust_integration.py: 17 rule-based tests booting the real
Rust engine (tool correctness, corner cases, determinism, win/fail
wiring, end-to-end pack runs).
- EVAL_STACK_PLAN.md +179 -0
- openra_bench/__init__.py +7 -0
- openra_bench/eval_core.py +181 -0
- openra_bench/rust_adapter.py +238 -0
- openra_bench/scenarios/CONTRIBUTING.md +95 -0
- openra_bench/scenarios/__init__.py +31 -0
- openra_bench/scenarios/loader.py +66 -0
- openra_bench/scenarios/packs/TEMPLATE.yaml +52 -0
- openra_bench/scenarios/packs/action-multiunit-coordination.yaml +110 -0
- openra_bench/scenarios/packs/action-sequenced-execution.yaml +107 -0
- openra_bench/scenarios/packs/perception-frontier-reading.yaml +101 -0
- openra_bench/scenarios/packs/perception-target-vs-fog.yaml +111 -0
- openra_bench/scenarios/packs/reasoning-frontier-commit.yaml +111 -0
- openra_bench/scenarios/packs/reasoning-risk-route.yaml +132 -0
- openra_bench/scenarios/schema.py +130 -0
- openra_bench/scenarios/validate.py +61 -0
- openra_bench/scenarios/win_conditions.py +114 -0
- tests/test_rust_integration.py +344 -0
EVAL_STACK_PLAN.md
ADDED
|
@@ -0,0 +1,179 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# OpenRA-Bench → Unified Eval Stack Plan
|
| 2 |
+
|
| 3 |
+
## Goal
|
| 4 |
+
|
| 5 |
+
Turn **OpenRA-Bench** into an efficient, customizable harness to evaluate model
|
| 6 |
+
performance on **spatial, multi-modal, and complex multi-target multi-step
|
| 7 |
+
reasoning / planning**, by reusing the mature eval stack from
|
| 8 |
+
**OpenRA-RL-Training** on top of the **Rust** environment (OpenRA-Rust).
|
| 9 |
+
|
| 10 |
+
Two evaluation modes:
|
| 11 |
+
|
| 12 |
+
1. **Fixed-scenario eval** — N episodes of one model on a controlled scenario,
|
| 13 |
+
scored with verifiable rubrics + composite score + 95% CIs.
|
| 14 |
+
2. **Pairwise adversarial 1v1** — two models in one 1v1 map, win-rate / Elo.
|
| 15 |
+
|
| 16 |
+
Scenarios must be *controlled* and isolate specific capabilities (e.g. the
|
| 17 |
+
observed failure: model cannot connect an unexplored region to a target area to
|
| 18 |
+
explore — a vision/perception + spatial-planning deficiency).
|
| 19 |
+
|
| 20 |
+
## Core framing: the Perception → Reasoning → Action chain
|
| 21 |
+
|
| 22 |
+
Every scenario evaluates one chain: **read the (visual + symbolic) state →
|
| 23 |
+
form a multi-step plan → emit valid commands that execute it**. The eval must
|
| 24 |
+
*attribute* failure to a specific link, not just report a score:
|
| 25 |
+
|
| 26 |
+
- **Perception** — did the model correctly read the minimap/state? (e.g.
|
| 27 |
+
locate the unexplored region and the target). Probe via state-readback /
|
| 28 |
+
forced-choice checks derived from ground-truth obs.
|
| 29 |
+
- **Reasoning** — given correct perception, did it form a valid plan? (e.g.
|
| 30 |
+
connect unexplored→target, sequence multi-target order). Probe via plan
|
| 31 |
+
quality vs optimal (path length, target ordering, sub-goal coverage).
|
| 32 |
+
- **Action** — did it emit syntactically/semantically valid commands that
|
| 33 |
+
realize the plan? Probe via action-validity rate and plan↔execution drift.
|
| 34 |
+
|
| 35 |
+
Per-scenario rubrics carry one diagnostic per link so a low score points at the
|
| 36 |
+
broken link. This is the primary product differentiator vs a raw win-rate bench.
|
| 37 |
+
|
| 38 |
+
## Decisions (locked)
|
| 39 |
+
|
| 40 |
+
- **Repo**: refactor OpenRA-Bench in place; it imports Training's eval stack.
|
| 41 |
+
OpenRA-RL-Training stays source of truth for the engine code.
|
| 42 |
+
- **Backend**: Rust only (`openra_train` PyO3). C# (openra-rl) is slow/fragile
|
| 43 |
+
and is dropped from the eval path. Rust must be made faithful to the C#
|
| 44 |
+
reference where scenarios require it.
|
| 45 |
+
- **Multi-modal**: reuse Training's `minimap_renderer.render_minimap()` PNG,
|
| 46 |
+
injected as `image_url` in the agent prompt.
|
| 47 |
+
|
| 48 |
+
## Source components reused from OpenRA-RL-Training
|
| 49 |
+
|
| 50 |
+
| Component | Path | Role in Bench |
|
| 51 |
+
|---|---|---|
|
| 52 |
+
| Episode engine | `openra_rl_training/training/agent_rollout.py` (`play_episodes_async`) | Drives the real model loop (currently Bench's agent fn is a no-op) |
|
| 53 |
+
| Reward dims | `training/reward_funcs.py` | Per-scenario weighted scoring |
|
| 54 |
+
| Rust pool | `training/rust_env_pool.py` | The only backend |
|
| 55 |
+
| Minimap | `training/minimap_renderer.py` | Multi-modal observation |
|
| 56 |
+
| Scenarios/rubrics | `scenarios/*.yaml`, `curricula/*.yaml`, verifiable metrics | Controlled tasks + pass/fail |
|
| 57 |
+
| CI comparison | `scripts/build_eval_comparison.py` | Stat-sound model comparison |
|
| 58 |
+
|
| 59 |
+
## Rust faithfulness gap (drives sequencing)
|
| 60 |
+
|
| 61 |
+
- Commands: 3/22 (Move, Attack, Observe). Missing: Build/Train/Harvest/Deploy/
|
| 62 |
+
Sell/Repair/Stance/Transport/Power/RallyPoint/Guard/Patrol…
|
| 63 |
+
- Observations: ~30% of C# proto. Missing: economy, production, military stats,
|
| 64 |
+
spatial tensor, kill_events, result/reward fields.
|
| 65 |
+
- Scenarios: 2 hand-built (rush-hour, scout-maginot). No generic `.oramap` load.
|
| 66 |
+
- Engine: movement (A*), combat, projectiles, fog, static defenses = done.
|
| 67 |
+
Economy, production/tech, transport, multi-armament = not done.
|
| 68 |
+
|
| 69 |
+
→ Movement/combat/fog scenarios + combat-only 1v1 work **today**. Economy /
|
| 70 |
+
production / tech scenarios require Rust engine work first.
|
| 71 |
+
|
| 72 |
+
## CRITICAL FINDING (verified end-to-end, local)
|
| 73 |
+
|
| 74 |
+
`play_episodes_async` (agent_rollout.py:4815) is **hardwired to the C# gRPC
|
| 75 |
+
server** via `openra_env.mcp_ws_client.OpenRAMCPClient` and is entangled with
|
| 76 |
+
TRL (tokenizer, prompt_ids/completion_ids, worker pool, partial cache). It is
|
| 77 |
+
**not reusable as-is on Rust**. `rust_env_pool` is used only by the lighter
|
| 78 |
+
`rollout.py` path.
|
| 79 |
+
|
| 80 |
+
Also: `minimap_renderer.render_minimap()` expects `state["minimap"]` (ASCII),
|
| 81 |
+
`units_summary`, `enemy_summary` — **none of which the Rust env emits.**
|
| 82 |
+
|
| 83 |
+
Verified live Rust obs schema (`openra_train` rush-hour, local wheel,
|
| 84 |
+
Python 3.12, anaconda):
|
| 85 |
+
```
|
| 86 |
+
keys = enemy_buildings_summary, enemy_hp, enemy_positions, explored_cells,
|
| 87 |
+
explored_percent, game_tick, unit_hp, unit_positions, units_killed
|
| 88 |
+
unit_positions = {actor_id: {cell_x, cell_y[, target, activity, ...]}}
|
| 89 |
+
step() -> (obs, reward=0.0 hardcoded, done:bool, info={game_tick, warnings})
|
| 90 |
+
```
|
| 91 |
+
No `minimap` ASCII, no economy/military/result/reward, no terrain.
|
| 92 |
+
|
| 93 |
+
**Consequence:** Phase 0 builds a Bench-side episode loop that reuses
|
| 94 |
+
*components* (reward_funcs, minimap_renderer, scenario loader, action parser)
|
| 95 |
+
behind a **Rust→schema adapter** (`openra_bench/rust_adapter.py`). The adapter
|
| 96 |
+
is the crux and overlaps directly with the "make Rust faithful" workstream:
|
| 97 |
+
|
| 98 |
+
- `unit_positions` → `units_summary` (renderer/prompt schema)
|
| 99 |
+
- `enemy_positions` + `enemy_buildings_summary` → `enemy_summary`
|
| 100 |
+
- synthesize ASCII `minimap` from `explored_cells` + scenario map dims
|
| 101 |
+
- load `terrain_png` from the scenario's base `.oramap` (as Training does)
|
| 102 |
+
- derive scoring signals (kills, discovery, exploration, outcome) from obs
|
| 103 |
+
deltas since Rust `reward` is hardcoded 0.0 — feeds reward_funcs + the
|
| 104 |
+
P/R/A diagnostics directly.
|
| 105 |
+
|
| 106 |
+
## Target Bench layout
|
| 107 |
+
|
| 108 |
+
```
|
| 109 |
+
OpenRA-Bench/
|
| 110 |
+
openra_bench/
|
| 111 |
+
eval_core.py # thin wrapper over play_episodes_async, Rust backend forced
|
| 112 |
+
agent.py # REAL model agent (OpenAI-compatible), minimap multimodal
|
| 113 |
+
scenarios/ # controlled eval scenarios (symlink/copy + Bench-authored)
|
| 114 |
+
rubrics.py # verifiable + composite scoring (reuse Training)
|
| 115 |
+
pairwise.py # 1v1 adversarial orchestration + Elo
|
| 116 |
+
evaluate.py # fixed-scenario CLI (rewritten to use eval_core)
|
| 117 |
+
compare.py # CI comparison front-end (wraps build_eval_comparison)
|
| 118 |
+
app.py # leaderboard (fed by both modes)
|
| 119 |
+
```
|
| 120 |
+
|
| 121 |
+
## Phases
|
| 122 |
+
|
| 123 |
+
### Phase 0 — Integration spine (no Rust changes)
|
| 124 |
+
- Bench depends on `openra_rl_training` + `openra_train`.
|
| 125 |
+
- `eval_core.py`: wrap `play_episodes_async`, force Rust pool.
|
| 126 |
+
- `agent.py`: real OpenAI-compatible model agent w/ minimap PNG.
|
| 127 |
+
- Rewrite `evaluate.py` → fixed-scenario eval producing `eval_stats.json`.
|
| 128 |
+
- `compare.py` → 95% CI tables. Wire results into `app.py` leaderboard.
|
| 129 |
+
- Validate on rush-hour + scout-maginot (these *are* the perception tasks).
|
| 130 |
+
|
| 131 |
+
### Phase 1 — Adversarial 1v1 (combat-only, current mechanics)
|
| 132 |
+
- Rust: add a second RL-controlled player slot in a 1v1 map (both sides accept
|
| 133 |
+
Commands; remove scripted-enemy assumption).
|
| 134 |
+
- Bench `pairwise.py`: two-model orchestration, win-rate + Elo, leaderboard.
|
| 135 |
+
|
| 136 |
+
### Phase 2 — Controlled scenario library (current mechanics)
|
| 137 |
+
- Author perception/spatial scenarios that isolate the unexplored→target
|
| 138 |
+
connection failure; maze/chokepoint pathfinding; multi-target prioritization.
|
| 139 |
+
- Verifiable rubrics per scenario (intelligence_pct, path-optimality, etc.).
|
| 140 |
+
|
| 141 |
+
### Phase 3 — Rust mechanics expansion (unlocks scenario families)
|
| 142 |
+
- 3a Economy: ore/cash/harvester obs + HARVEST cmd + economy reward.
|
| 143 |
+
- 3b Production/tech: production queue, BUILD/TRAIN, available_production, power.
|
| 144 |
+
- Each sub-phase ships its scenario family + rubrics.
|
| 145 |
+
|
| 146 |
+
## Test coverage (live engine, no mocks)
|
| 147 |
+
|
| 148 |
+
`tests/test_rust_integration.py` — 17 tests, ~1.9s, boots real
|
| 149 |
+
`openra_train` with rule-based bots (idle / charge / hunter):
|
| 150 |
+
- tool correctness: move_units reaches target; idle units hold; attack
|
| 151 |
+
path; reset schema; same-seed determinism (bit-for-bit).
|
| 152 |
+
- corner cases: empty command list, invalid unit id (warns, no raise),
|
| 153 |
+
invalid attack target, out-of-bounds move — all safe.
|
| 154 |
+
- invariants: explored% and units_killed monotonic non-decreasing;
|
| 155 |
+
discovery set cumulative.
|
| 156 |
+
- stack: adapter signal tracking; win-condition predicates + composites
|
| 157 |
+
+ unknown-key rejection; **deterministic win/fail plumbing** (trivially
|
| 158 |
+
true win/fail conditions ⇒ exact outcome, not bot-skill dependent);
|
| 159 |
+
all authored packs run end-to-end.
|
| 160 |
+
|
| 161 |
+
## Sequencing (locked)
|
| 162 |
+
|
| 163 |
+
`Phase 0 → Phase 2 (+ P/R/A diagnostics) → Phase 1 → Phase 3`
|
| 164 |
+
|
| 165 |
+
Scenario breadth + per-link diagnostics first: fastest path to exposing real
|
| 166 |
+
model strengths/weaknesses on current Rust mechanics. Adversarial 1v1 and the
|
| 167 |
+
economy/production engine work follow.
|
| 168 |
+
|
| 169 |
+
## Model provider abstraction (Phase 0)
|
| 170 |
+
|
| 171 |
+
`openra_bench/agent.py` exposes a provider-agnostic agent. Adapters:
|
| 172 |
+
|
| 173 |
+
- **openai-compatible** (default): covers local vLLM (matches Training's rollout
|
| 174 |
+
path) and **OpenRouter** (test target). Same Chat Completions + multimodal
|
| 175 |
+
`image_url` for the minimap PNG. Base URL + key from config/env.
|
| 176 |
+
- **bedrock**: separate adapter (AWS SDK / Converse API), added when needed.
|
| 177 |
+
|
| 178 |
+
Selected via Bench config (`provider`, `base_url`, `model`, `api_key_env`).
|
| 179 |
+
Phase 0 validates with OpenRouter.
|
openra_bench/__init__.py
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""OpenRA-Bench — model evaluation on spatial / multi-modal / multi-step
|
| 2 |
+
planning, on the Rust OpenRA environment, reusing OpenRA-RL-Training components.
|
| 3 |
+
|
| 4 |
+
See EVAL_STACK_PLAN.md for architecture and phasing.
|
| 5 |
+
"""
|
| 6 |
+
|
| 7 |
+
__all__ = ["rust_adapter", "eval_core"]
|
openra_bench/eval_core.py
ADDED
|
@@ -0,0 +1,181 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Episode spine: Rust env + adapter + pluggable agent.
|
| 2 |
+
|
| 3 |
+
This is the Bench-side replacement for Training's `play_episodes_async`
|
| 4 |
+
(which is hardwired to the C# gRPC server). It reuses Training *components*
|
| 5 |
+
via the adapter; provider-agnostic agents plug in here (Phase 0 follow-up:
|
| 6 |
+
openra_bench/agent.py with vLLM/OpenRouter/Bedrock).
|
| 7 |
+
|
| 8 |
+
An `agent_fn` has signature:
|
| 9 |
+
agent_fn(render_state: dict, Command) -> list[Command]
|
| 10 |
+
where `Command` is `openra_train.Command` (move_units/attack_unit/observe).
|
| 11 |
+
"""
|
| 12 |
+
|
| 13 |
+
from __future__ import annotations
|
| 14 |
+
|
| 15 |
+
import tempfile
|
| 16 |
+
from dataclasses import dataclass, field
|
| 17 |
+
from pathlib import Path
|
| 18 |
+
from typing import Any, Callable
|
| 19 |
+
|
| 20 |
+
import yaml
|
| 21 |
+
from openra_rl_training.training.rust_env_pool import RustEnvPool
|
| 22 |
+
|
| 23 |
+
from .rust_adapter import EpisodeSignals, RustObsAdapter
|
| 24 |
+
from .scenarios.schema import CompiledLevel
|
| 25 |
+
from .scenarios.win_conditions import WinContext, evaluate
|
| 26 |
+
|
| 27 |
+
AgentFn = Callable[[dict, Any], list]
|
| 28 |
+
|
| 29 |
+
|
| 30 |
+
def _scenario_to_tmp_yaml(compiled: CompiledLevel) -> str:
|
| 31 |
+
"""Serialize a compiled level's ScenarioDefinition to a temp YAML the
|
| 32 |
+
Rust env can load (it reads actors from the given scenario path; the
|
| 33 |
+
map geometry is the Rust-supported base map)."""
|
| 34 |
+
data = compiled.scenario.model_dump(mode="json", exclude_none=True)
|
| 35 |
+
fd = tempfile.NamedTemporaryFile(
|
| 36 |
+
"w", suffix=f"_{compiled.pack_id}_{compiled.level}.yaml", delete=False
|
| 37 |
+
)
|
| 38 |
+
yaml.safe_dump(data, fd, sort_keys=False)
|
| 39 |
+
fd.close()
|
| 40 |
+
return fd.name
|
| 41 |
+
|
| 42 |
+
|
| 43 |
+
@dataclass
|
| 44 |
+
class EpisodeResult:
|
| 45 |
+
scenario: str
|
| 46 |
+
seed: int
|
| 47 |
+
turns: int
|
| 48 |
+
signals: EpisodeSignals
|
| 49 |
+
outcome: str = "draw" # "win" | "loss" | "draw"
|
| 50 |
+
trace: list[dict] = field(default_factory=list)
|
| 51 |
+
|
| 52 |
+
|
| 53 |
+
def scripted_explore_agent(render_state: dict, Command: Any) -> list:
|
| 54 |
+
"""Baseline reference agent: walk every unit toward the nearest
|
| 55 |
+
unexplored frontier cell. Exercises the move path; a useful
|
| 56 |
+
lower-bound control for the perception/exploration scenarios.
|
| 57 |
+
"""
|
| 58 |
+
grid = render_state["minimap"].splitlines()
|
| 59 |
+
h = len(grid)
|
| 60 |
+
w = len(grid[0]) if grid else 0
|
| 61 |
+
frontier = [
|
| 62 |
+
(x, y)
|
| 63 |
+
for y in range(h)
|
| 64 |
+
for x in range(min(w, len(grid[y])))
|
| 65 |
+
if grid[y][x] == "#"
|
| 66 |
+
]
|
| 67 |
+
units = render_state.get("units_summary", [])
|
| 68 |
+
if not units or not frontier:
|
| 69 |
+
return [Command.observe()]
|
| 70 |
+
cmds = []
|
| 71 |
+
for u in units:
|
| 72 |
+
ux, uy = u["cell_x"], u["cell_y"]
|
| 73 |
+
tx, ty = min(frontier, key=lambda c: (c[0] - ux) ** 2 + (c[1] - uy) ** 2)
|
| 74 |
+
cmds.append(Command.move_units([str(u["id"])], target_x=tx, target_y=ty))
|
| 75 |
+
return cmds
|
| 76 |
+
|
| 77 |
+
|
| 78 |
+
def run_episode(
|
| 79 |
+
scenario_path: str,
|
| 80 |
+
agent_fn: AgentFn = scripted_explore_agent,
|
| 81 |
+
max_turns: int = 40,
|
| 82 |
+
seed: int = 0,
|
| 83 |
+
pool: RustEnvPool | None = None,
|
| 84 |
+
) -> EpisodeResult:
|
| 85 |
+
owns_pool = pool is None
|
| 86 |
+
if pool is None:
|
| 87 |
+
pool = RustEnvPool(size=1, scenario_path=scenario_path)
|
| 88 |
+
env = pool.acquire()
|
| 89 |
+
try:
|
| 90 |
+
adapter = RustObsAdapter()
|
| 91 |
+
obs = env.reset(seed=seed)
|
| 92 |
+
adapter.observe(obs)
|
| 93 |
+
trace: list[dict] = []
|
| 94 |
+
turns = 0
|
| 95 |
+
for turns in range(1, max_turns + 1):
|
| 96 |
+
rs = adapter.render_state()
|
| 97 |
+
cmds = agent_fn(rs, env.Command) or [env.Command.observe()]
|
| 98 |
+
obs, _reward, done, info = env.step(cmds)
|
| 99 |
+
adapter.observe(obs, done=done)
|
| 100 |
+
trace.append(
|
| 101 |
+
{
|
| 102 |
+
"turn": turns,
|
| 103 |
+
"tick": adapter.signals.game_tick,
|
| 104 |
+
"explored": round(adapter.signals.explored_percent, 2),
|
| 105 |
+
"kills": adapter.signals.units_killed,
|
| 106 |
+
"enemies_seen": len(adapter.signals.enemies_seen_ids),
|
| 107 |
+
"n_cmds": len(cmds),
|
| 108 |
+
}
|
| 109 |
+
)
|
| 110 |
+
if done:
|
| 111 |
+
break
|
| 112 |
+
return EpisodeResult(
|
| 113 |
+
scenario=scenario_path,
|
| 114 |
+
seed=seed,
|
| 115 |
+
turns=turns,
|
| 116 |
+
signals=adapter.signals,
|
| 117 |
+
trace=trace,
|
| 118 |
+
)
|
| 119 |
+
finally:
|
| 120 |
+
pool.release(env)
|
| 121 |
+
if owns_pool:
|
| 122 |
+
pool.shutdown()
|
| 123 |
+
|
| 124 |
+
|
| 125 |
+
def run_level(
|
| 126 |
+
compiled: CompiledLevel,
|
| 127 |
+
agent_fn: AgentFn = scripted_explore_agent,
|
| 128 |
+
seed: int = 0,
|
| 129 |
+
) -> EpisodeResult:
|
| 130 |
+
"""Run one scenario-pack level, scoring against its declarative
|
| 131 |
+
win/fail conditions (checked every turn). Outcome maps to the
|
| 132 |
+
`reward_outcome` convention: win=1.0, draw=0.5, loss=0.0.
|
| 133 |
+
"""
|
| 134 |
+
if not compiled.map_supported:
|
| 135 |
+
raise RuntimeError(
|
| 136 |
+
f"{compiled.pack_id}: base map not Rust-loadable yet (Phase 3). "
|
| 137 |
+
f"Validate-only; cannot execute."
|
| 138 |
+
)
|
| 139 |
+
tmp_path = _scenario_to_tmp_yaml(compiled)
|
| 140 |
+
pool = RustEnvPool(size=1, scenario_path=tmp_path)
|
| 141 |
+
env = pool.acquire()
|
| 142 |
+
try:
|
| 143 |
+
adapter = RustObsAdapter()
|
| 144 |
+
adapter.observe(env.reset(seed=seed))
|
| 145 |
+
trace: list[dict] = []
|
| 146 |
+
outcome = "draw"
|
| 147 |
+
turns = 0
|
| 148 |
+
for turns in range(1, compiled.max_turns + 1):
|
| 149 |
+
rs = adapter.render_state()
|
| 150 |
+
cmds = agent_fn(rs, env.Command) or [env.Command.observe()]
|
| 151 |
+
obs, _r, done, _info = env.step(cmds)
|
| 152 |
+
adapter.observe(obs, done=done)
|
| 153 |
+
ctx = WinContext(signals=adapter.signals, render_state=adapter.render_state())
|
| 154 |
+
if evaluate(compiled.win_condition, ctx):
|
| 155 |
+
outcome = "win"
|
| 156 |
+
elif evaluate(compiled.fail_condition, ctx):
|
| 157 |
+
outcome = "loss"
|
| 158 |
+
trace.append(
|
| 159 |
+
{
|
| 160 |
+
"turn": turns,
|
| 161 |
+
"tick": adapter.signals.game_tick,
|
| 162 |
+
"explored": round(adapter.signals.explored_percent, 2),
|
| 163 |
+
"kills": adapter.signals.units_killed,
|
| 164 |
+
"enemies_seen": len(adapter.signals.enemies_seen_ids),
|
| 165 |
+
}
|
| 166 |
+
)
|
| 167 |
+
if outcome != "draw" or done:
|
| 168 |
+
break
|
| 169 |
+
adapter.signals.outcome = {"win": 1.0, "draw": 0.5, "loss": 0.0}[outcome]
|
| 170 |
+
return EpisodeResult(
|
| 171 |
+
scenario=f"{compiled.pack_id}:{compiled.level}",
|
| 172 |
+
seed=seed,
|
| 173 |
+
turns=turns,
|
| 174 |
+
signals=adapter.signals,
|
| 175 |
+
outcome=outcome,
|
| 176 |
+
trace=trace,
|
| 177 |
+
)
|
| 178 |
+
finally:
|
| 179 |
+
pool.release(env)
|
| 180 |
+
pool.shutdown()
|
| 181 |
+
Path(tmp_path).unlink(missing_ok=True)
|
openra_bench/rust_adapter.py
ADDED
|
@@ -0,0 +1,238 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Rust env -> Training-component schema adapter.
|
| 2 |
+
|
| 3 |
+
The Rust env (`openra_train.OpenRAEnv`) emits a lean observation:
|
| 4 |
+
|
| 5 |
+
keys = unit_positions, unit_hp, enemy_positions, enemy_hp,
|
| 6 |
+
enemy_buildings_summary, explored_cells, explored_percent,
|
| 7 |
+
game_tick, units_killed
|
| 8 |
+
step() -> (obs, reward=0.0 (hardcoded), done: bool,
|
| 9 |
+
info={game_tick, warnings})
|
| 10 |
+
|
| 11 |
+
`minimap_renderer.render_minimap()` and the prompt builders in
|
| 12 |
+
OpenRA-RL-Training expect a different shape (`units_summary`,
|
| 13 |
+
`enemy_summary`, an ASCII `minimap`, `terrain_png`). And because the
|
| 14 |
+
Rust env hardcodes reward to 0.0, all scoring/diagnostic signals must be
|
| 15 |
+
derived here from observation deltas.
|
| 16 |
+
|
| 17 |
+
This module is the single translation point. It is intentionally pure
|
| 18 |
+
(no model / network / file I/O beyond optional terrain load) so it can
|
| 19 |
+
be unit-tested against captured Rust observations.
|
| 20 |
+
"""
|
| 21 |
+
|
| 22 |
+
from __future__ import annotations
|
| 23 |
+
|
| 24 |
+
from dataclasses import dataclass, field
|
| 25 |
+
from typing import Any
|
| 26 |
+
|
| 27 |
+
|
| 28 |
+
def _cells(obj: Any) -> list[tuple[int, int]]:
|
| 29 |
+
"""Normalize explored_cells / position lists to [(x, y), ...]."""
|
| 30 |
+
out: list[tuple[int, int]] = []
|
| 31 |
+
if not obj:
|
| 32 |
+
return out
|
| 33 |
+
for c in obj:
|
| 34 |
+
if isinstance(c, dict):
|
| 35 |
+
out.append((int(c.get("cell_x", 0)), int(c.get("cell_y", 0))))
|
| 36 |
+
elif isinstance(c, (list, tuple)) and len(c) >= 2:
|
| 37 |
+
out.append((int(c[0]), int(c[1])))
|
| 38 |
+
return out
|
| 39 |
+
|
| 40 |
+
|
| 41 |
+
def _units_to_render_list(
|
| 42 |
+
positions: dict[str, Any],
|
| 43 |
+
hp: dict[str, Any] | None,
|
| 44 |
+
type_by_id: dict[str, str] | None = None,
|
| 45 |
+
) -> list[dict]:
|
| 46 |
+
"""unit_positions {id: {cell_x, cell_y, ...}} -> [{cell_x, cell_y, type, id, hp}]."""
|
| 47 |
+
hp = hp or {}
|
| 48 |
+
type_by_id = type_by_id or {}
|
| 49 |
+
out: list[dict] = []
|
| 50 |
+
for uid, p in (positions or {}).items():
|
| 51 |
+
if isinstance(p, dict):
|
| 52 |
+
cx, cy = int(p.get("cell_x", 0)), int(p.get("cell_y", 0))
|
| 53 |
+
activity = p.get("activity")
|
| 54 |
+
elif isinstance(p, (list, tuple)) and len(p) >= 2:
|
| 55 |
+
cx, cy, activity = int(p[0]), int(p[1]), None
|
| 56 |
+
else:
|
| 57 |
+
continue
|
| 58 |
+
out.append(
|
| 59 |
+
{
|
| 60 |
+
"id": str(uid),
|
| 61 |
+
"cell_x": cx,
|
| 62 |
+
"cell_y": cy,
|
| 63 |
+
"type": type_by_id.get(str(uid)),
|
| 64 |
+
"hp": float(hp.get(uid, hp.get(str(uid), 1.0)) or 0.0),
|
| 65 |
+
"activity": activity,
|
| 66 |
+
}
|
| 67 |
+
)
|
| 68 |
+
return out
|
| 69 |
+
|
| 70 |
+
|
| 71 |
+
@dataclass
|
| 72 |
+
class EpisodeSignals:
|
| 73 |
+
"""Cumulative + per-step signals derived from Rust obs deltas.
|
| 74 |
+
|
| 75 |
+
Drives both `reward_funcs` inputs and the P/R/A diagnostic rubrics
|
| 76 |
+
(task #2). Rust gives no reward/result, so every signal lives here.
|
| 77 |
+
"""
|
| 78 |
+
|
| 79 |
+
units_killed: int = 0
|
| 80 |
+
units_killed_delta: int = 0
|
| 81 |
+
units_lost: int = 0
|
| 82 |
+
explored_percent: float = 0.0
|
| 83 |
+
explored_delta: float = 0.0
|
| 84 |
+
enemies_seen_ids: set[str] = field(default_factory=set)
|
| 85 |
+
enemy_buildings_seen_ids: set[str] = field(default_factory=set)
|
| 86 |
+
new_enemies_this_step: int = 0
|
| 87 |
+
new_buildings_this_step: int = 0
|
| 88 |
+
game_tick: int = 0
|
| 89 |
+
done: bool = False
|
| 90 |
+
# Outcome is synthesized (Rust has no result field): a scenario is
|
| 91 |
+
# "won" when all enemy buildings have been discovered AND/OR all
|
| 92 |
+
# enemy units neutralized — refined per-scenario in Phase 2 rubrics.
|
| 93 |
+
outcome: float = 0.0
|
| 94 |
+
|
| 95 |
+
def as_reward_kwargs(self) -> dict[str, Any]:
|
| 96 |
+
"""Shape expected by OpenRA-RL-Training reward_funcs (game signals)."""
|
| 97 |
+
return {
|
| 98 |
+
"units_killed": self.units_killed,
|
| 99 |
+
"units_lost": self.units_lost,
|
| 100 |
+
"explored_percent": self.explored_percent,
|
| 101 |
+
"enemies_discovered": len(self.enemies_seen_ids),
|
| 102 |
+
"buildings_discovered": len(self.enemy_buildings_seen_ids),
|
| 103 |
+
"outcome": self.outcome,
|
| 104 |
+
"game_tick": self.game_tick,
|
| 105 |
+
"done": self.done,
|
| 106 |
+
}
|
| 107 |
+
|
| 108 |
+
|
| 109 |
+
class RustObsAdapter:
|
| 110 |
+
"""Stateful per-episode adapter. One instance per episode.
|
| 111 |
+
|
| 112 |
+
Usage:
|
| 113 |
+
ad = RustObsAdapter(scenario_def)
|
| 114 |
+
ad.observe(reset_obs)
|
| 115 |
+
...loop: ad.observe(step_obs, done=done)
|
| 116 |
+
render_state = ad.render_state() # for minimap_renderer
|
| 117 |
+
sig = ad.signals # for scoring / diagnostics
|
| 118 |
+
"""
|
| 119 |
+
|
| 120 |
+
def __init__(self, scenario: Any = None, type_by_id: dict[str, str] | None = None):
|
| 121 |
+
self.scenario = scenario
|
| 122 |
+
self.type_by_id = type_by_id or {}
|
| 123 |
+
self.signals = EpisodeSignals()
|
| 124 |
+
self._explored: set[tuple[int, int]] = set()
|
| 125 |
+
self._prev_own_ids: set[str] = set()
|
| 126 |
+
self._raw: dict[str, Any] = {}
|
| 127 |
+
self._first_own_count: int | None = None
|
| 128 |
+
|
| 129 |
+
# -- ingestion --------------------------------------------------------
|
| 130 |
+
def observe(self, obs: dict[str, Any], done: bool = False) -> None:
|
| 131 |
+
self._raw = obs or {}
|
| 132 |
+
s = self.signals
|
| 133 |
+
|
| 134 |
+
own = self._raw.get("unit_positions", {}) or {}
|
| 135 |
+
own_ids = {str(k) for k in own}
|
| 136 |
+
if self._first_own_count is None:
|
| 137 |
+
self._first_own_count = len(own_ids)
|
| 138 |
+
# Lost = units that disappeared from our roster.
|
| 139 |
+
s.units_lost = max(0, (self._first_own_count or 0) - len(own_ids))
|
| 140 |
+
self._prev_own_ids = own_ids
|
| 141 |
+
|
| 142 |
+
prev_kills = s.units_killed
|
| 143 |
+
s.units_killed = int(self._raw.get("units_killed", s.units_killed) or 0)
|
| 144 |
+
s.units_killed_delta = max(0, s.units_killed - prev_kills)
|
| 145 |
+
|
| 146 |
+
prev_expl = s.explored_percent
|
| 147 |
+
s.explored_percent = float(self._raw.get("explored_percent", prev_expl) or 0.0)
|
| 148 |
+
s.explored_delta = max(0.0, s.explored_percent - prev_expl)
|
| 149 |
+
self._explored.update(_cells(self._raw.get("explored_cells")))
|
| 150 |
+
|
| 151 |
+
before_e = len(s.enemies_seen_ids)
|
| 152 |
+
for e in self._raw.get("enemy_positions", []) or []:
|
| 153 |
+
if isinstance(e, dict) and e.get("id") is not None:
|
| 154 |
+
s.enemies_seen_ids.add(str(e["id"]))
|
| 155 |
+
s.new_enemies_this_step = len(s.enemies_seen_ids) - before_e
|
| 156 |
+
|
| 157 |
+
before_b = len(s.enemy_buildings_seen_ids)
|
| 158 |
+
for b in self._raw.get("enemy_buildings_summary", []) or []:
|
| 159 |
+
if isinstance(b, dict) and b.get("id") is not None:
|
| 160 |
+
s.enemy_buildings_seen_ids.add(str(b["id"]))
|
| 161 |
+
s.new_buildings_this_step = len(s.enemy_buildings_seen_ids) - before_b
|
| 162 |
+
|
| 163 |
+
s.game_tick = int(self._raw.get("game_tick", s.game_tick) or 0)
|
| 164 |
+
s.done = bool(done)
|
| 165 |
+
|
| 166 |
+
# -- render schema ----------------------------------------------------
|
| 167 |
+
def grid_dims(self, margin: int = 4) -> tuple[int, int]:
|
| 168 |
+
"""Derive a working (width, height) from observed extents.
|
| 169 |
+
|
| 170 |
+
Phase 0: the Rust env exposes no map_info; bound from observed
|
| 171 |
+
cells. Phase 3 will plumb true map dims through the adapter.
|
| 172 |
+
"""
|
| 173 |
+
xs, ys = [0], [0]
|
| 174 |
+
for src in (self._explored, _cells(self._raw.get("explored_cells"))):
|
| 175 |
+
for x, y in src:
|
| 176 |
+
xs.append(x)
|
| 177 |
+
ys.append(y)
|
| 178 |
+
for coll in (
|
| 179 |
+
self._raw.get("unit_positions", {}) or {},
|
| 180 |
+
self._raw.get("enemy_positions", []) or [],
|
| 181 |
+
self._raw.get("enemy_buildings_summary", []) or [],
|
| 182 |
+
):
|
| 183 |
+
items = coll.values() if isinstance(coll, dict) else coll
|
| 184 |
+
for p in items:
|
| 185 |
+
if isinstance(p, dict):
|
| 186 |
+
xs.append(int(p.get("cell_x", 0)))
|
| 187 |
+
ys.append(int(p.get("cell_y", 0)))
|
| 188 |
+
return max(xs) + margin, max(ys) + margin
|
| 189 |
+
|
| 190 |
+
def ascii_minimap(self) -> str:
|
| 191 |
+
"""Synthesize the ASCII grid the renderer parses for the explored
|
| 192 |
+
mask: '#' = unexplored, '.' = explored. Faithful to
|
| 193 |
+
minimap_renderer._parse_ascii_minimap (anything != '#' = explored).
|
| 194 |
+
"""
|
| 195 |
+
w, h = self.grid_dims()
|
| 196 |
+
explored = set(self._explored) | set(_cells(self._raw.get("explored_cells")))
|
| 197 |
+
rows = []
|
| 198 |
+
for y in range(h):
|
| 199 |
+
rows.append("".join("." if (x, y) in explored else "#" for x in range(w)))
|
| 200 |
+
return "\n".join(rows)
|
| 201 |
+
|
| 202 |
+
def render_state(self) -> dict[str, Any]:
|
| 203 |
+
"""State dict shaped for minimap_renderer.render_minimap()/prompts."""
|
| 204 |
+
w, h = self.grid_dims()
|
| 205 |
+
own = _units_to_render_list(
|
| 206 |
+
self._raw.get("unit_positions", {}),
|
| 207 |
+
self._raw.get("unit_hp"),
|
| 208 |
+
self.type_by_id,
|
| 209 |
+
)
|
| 210 |
+
enemy = _units_to_render_list(
|
| 211 |
+
{
|
| 212 |
+
str(e.get("id", i)): e
|
| 213 |
+
for i, e in enumerate(self._raw.get("enemy_positions", []) or [])
|
| 214 |
+
},
|
| 215 |
+
self._raw.get("enemy_hp"),
|
| 216 |
+
)
|
| 217 |
+
enemy += [
|
| 218 |
+
{
|
| 219 |
+
"id": str(b.get("id", f"bldg{i}")),
|
| 220 |
+
"cell_x": int(b.get("cell_x", 0)),
|
| 221 |
+
"cell_y": int(b.get("cell_y", 0)),
|
| 222 |
+
"type": b.get("kind") or b.get("type"),
|
| 223 |
+
"hp": float(b.get("hp_pct", 1.0) or 0.0),
|
| 224 |
+
"is_building": True,
|
| 225 |
+
}
|
| 226 |
+
for i, b in enumerate(self._raw.get("enemy_buildings_summary", []) or [])
|
| 227 |
+
]
|
| 228 |
+
return {
|
| 229 |
+
"units_summary": own,
|
| 230 |
+
"enemy_summary": enemy,
|
| 231 |
+
"minimap": self.ascii_minimap(),
|
| 232 |
+
"map_width": w,
|
| 233 |
+
"map_height": h,
|
| 234 |
+
"bounds_x": 0,
|
| 235 |
+
"bounds_y": 0,
|
| 236 |
+
"game_tick": self.signals.game_tick,
|
| 237 |
+
"explored_percent": self.signals.explored_percent,
|
| 238 |
+
}
|
openra_bench/scenarios/CONTRIBUTING.md
ADDED
|
@@ -0,0 +1,95 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Contributing a Scenario Pack
|
| 2 |
+
|
| 3 |
+
A **pack** is one YAML file in `packs/` describing **one decision problem
|
| 4 |
+
at three difficulty levels** (`easy` / `medium` / `hard`). You write only
|
| 5 |
+
YAML — no Python.
|
| 6 |
+
|
| 7 |
+
## Rules
|
| 8 |
+
|
| 9 |
+
1. **It must mean something.** `meta.real_world_meaning` and
|
| 10 |
+
`meta.robotics_analogue` are required and reviewed. Example: *path
|
| 11 |
+
planning algorithms are solved; deciding **which** unexplored region
|
| 12 |
+
to commit to under partial information is not — that is the search-
|
| 13 |
+
and-rescue problem.*
|
| 14 |
+
2. **One capability focus.** `meta.capability` ∈ `perception` |
|
| 15 |
+
`reasoning` | `action` — the link of the Perception→Reasoning→Action
|
| 16 |
+
chain this scenario primarily stresses.
|
| 17 |
+
3. **Three real levels.** Difficulty must come from the *decision*
|
| 18 |
+
getting harder (less information, more decoys, tighter deadline,
|
| 19 |
+
stronger defenses) — not just bigger numbers.
|
| 20 |
+
4. **Custom win condition, declaratively.** Use the grammar below; the
|
| 21 |
+
"bot" / objective is whatever the win condition says.
|
| 22 |
+
5. **Map.** `base_map: rush-hour-arena` works today. Other maps are
|
| 23 |
+
schema-valid but skipped until the Rust generic-map loader (Phase 3).
|
| 24 |
+
|
| 25 |
+
## File shape
|
| 26 |
+
|
| 27 |
+
```yaml
|
| 28 |
+
meta:
|
| 29 |
+
id: partial-info-rescue # lowercase-kebab, unique
|
| 30 |
+
title: "Rescue Under Partial Information"
|
| 31 |
+
capability: reasoning
|
| 32 |
+
real_world_meaning: >
|
| 33 |
+
Pathfinding is solved; choosing which unexplored area to search
|
| 34 |
+
first with limited fuel/time is the actual rescue problem.
|
| 35 |
+
robotics_analogue: "UAV search-and-rescue frontier selection"
|
| 36 |
+
author: "your-name"
|
| 37 |
+
|
| 38 |
+
base_map: rush-hour-arena
|
| 39 |
+
base: # shared ScenarioDefinition fields
|
| 40 |
+
agent: {faction: allies}
|
| 41 |
+
enemy: {faction: soviet}
|
| 42 |
+
tools: [move_units, attack_unit, stop_units]
|
| 43 |
+
planning: true
|
| 44 |
+
actors:
|
| 45 |
+
- {type: jeep, owner: agent, position: [5, 5], count: 3}
|
| 46 |
+
- {type: e1, owner: enemy, position: [60, 20], stance: 2}
|
| 47 |
+
termination: {max_ticks: 8000}
|
| 48 |
+
|
| 49 |
+
levels:
|
| 50 |
+
easy:
|
| 51 |
+
description: "Target in the nearest unexplored quadrant."
|
| 52 |
+
overrides: {} # deep-merge patch onto base
|
| 53 |
+
win_condition: {all_of: [{buildings_discovered_gte: 1}, {within_ticks: 6000}]}
|
| 54 |
+
fail_condition: {units_lost_lte: -1} # optional
|
| 55 |
+
max_turns: 30
|
| 56 |
+
medium:
|
| 57 |
+
description: "Two plausible regions; one is a decoy."
|
| 58 |
+
overrides:
|
| 59 |
+
actors: # full list replaces base.actors
|
| 60 |
+
- {type: jeep, owner: agent, position: [5, 5], count: 2}
|
| 61 |
+
- {type: e1, owner: enemy, position: [90, 30], stance: 2}
|
| 62 |
+
win_condition: {all_of: [{buildings_discovered_gte: 1}, {within_ticks: 5000}]}
|
| 63 |
+
max_turns: 35
|
| 64 |
+
hard:
|
| 65 |
+
description: "Three regions, decoys, tight deadline, attrition."
|
| 66 |
+
overrides: { ... }
|
| 67 |
+
win_condition: { ... }
|
| 68 |
+
max_turns: 40
|
| 69 |
+
```
|
| 70 |
+
|
| 71 |
+
## Win-condition grammar
|
| 72 |
+
|
| 73 |
+
Composites: `all_of: [..]`, `any_of: [..]`, `not: {..}`. Leaves (a node
|
| 74 |
+
with multiple leaves is an implicit AND):
|
| 75 |
+
|
| 76 |
+
| Leaf | Meaning |
|
| 77 |
+
|---|---|
|
| 78 |
+
| `explored_pct_gte: <float>` | map % revealed ≥ value |
|
| 79 |
+
| `enemies_discovered_gte: <int>` | distinct enemy units seen ≥ value |
|
| 80 |
+
| `buildings_discovered_gte: <int>` | distinct enemy buildings seen ≥ value |
|
| 81 |
+
| `units_killed_gte: <int>` | agent kills ≥ value |
|
| 82 |
+
| `units_lost_lte: <int>` | agent losses ≤ value (constraint) |
|
| 83 |
+
| `within_ticks: <int>` | reached by game tick ≤ value (deadline) |
|
| 84 |
+
| `after_ticks: <int>` | only after game tick ≥ value |
|
| 85 |
+
| `reach_region: {x,y,radius}` | ≥1 agent unit within radius of (x,y) |
|
| 86 |
+
| `all_units_in_region: {x,y,radius}` | every agent unit within radius |
|
| 87 |
+
|
| 88 |
+
`win_condition` is checked every turn; first turn it holds → **win**.
|
| 89 |
+
`fail_condition` likewise → **loss**. Neither by `max_turns` → **draw**.
|
| 90 |
+
|
| 91 |
+
## Validate before opening a PR
|
| 92 |
+
|
| 93 |
+
```bash
|
| 94 |
+
python -m openra_bench.scenarios.validate packs/your-pack.yaml
|
| 95 |
+
```
|
openra_bench/scenarios/__init__.py
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Contributor-facing scenario layer.
|
| 2 |
+
|
| 3 |
+
A *scenario pack* is one YAML file describing one decision problem at
|
| 4 |
+
three difficulty levels (`easy` / `medium` / `hard`). Each level compiles
|
| 5 |
+
to an OpenRA-RL-Training `ScenarioDefinition` plus a declarative
|
| 6 |
+
`WinCondition` — so lab mates contribute scenarios with custom bot win
|
| 7 |
+
conditions and (schema-wise) custom maps without writing Python.
|
| 8 |
+
|
| 9 |
+
Public API:
|
| 10 |
+
load_pack(path) -> ScenarioPack
|
| 11 |
+
discover_packs(dir) -> list[ScenarioPack]
|
| 12 |
+
pack.compile(level) -> CompiledLevel (engine def + win cond)
|
| 13 |
+
|
| 14 |
+
See CONTRIBUTING.md and packs/TEMPLATE.yaml.
|
| 15 |
+
"""
|
| 16 |
+
|
| 17 |
+
from .loader import discover_packs, load_pack
|
| 18 |
+
from .schema import CompiledLevel, Level, ScenarioMeta, ScenarioPack
|
| 19 |
+
from .win_conditions import WinCondition, WinContext, evaluate
|
| 20 |
+
|
| 21 |
+
__all__ = [
|
| 22 |
+
"load_pack",
|
| 23 |
+
"discover_packs",
|
| 24 |
+
"ScenarioPack",
|
| 25 |
+
"ScenarioMeta",
|
| 26 |
+
"Level",
|
| 27 |
+
"CompiledLevel",
|
| 28 |
+
"WinCondition",
|
| 29 |
+
"WinContext",
|
| 30 |
+
"evaluate",
|
| 31 |
+
]
|
openra_bench/scenarios/loader.py
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Pack discovery + loading + map-support gating.
|
| 2 |
+
|
| 3 |
+
The Rust env currently loads only two hardcoded map geometries
|
| 4 |
+
(`rush-hour`, `scout-maginot` — see OpenRA-Rust env.rs). Contributors
|
| 5 |
+
may still author meaningful scenarios *today* by varying actors, spawns,
|
| 6 |
+
and win conditions on a supported geometry. A pack that names an
|
| 7 |
+
unsupported `base_map` still loads and validates, but its compiled
|
| 8 |
+
levels carry `map_supported=False` so the runner can skip/flag them
|
| 9 |
+
rather than crash. Generic `.oramap` loading lands in Phase 3.
|
| 10 |
+
"""
|
| 11 |
+
|
| 12 |
+
from __future__ import annotations
|
| 13 |
+
|
| 14 |
+
from pathlib import Path
|
| 15 |
+
|
| 16 |
+
import yaml
|
| 17 |
+
|
| 18 |
+
from .schema import LevelName, ScenarioPack
|
| 19 |
+
|
| 20 |
+
# Logical base_map id -> the Rust scenario alias the env actually loads.
|
| 21 |
+
# Extend this as Phase 3 adds real map loading.
|
| 22 |
+
SUPPORTED_MAPS: dict[str, str] = {
|
| 23 |
+
"rush-hour-arena": "scenarios/discovery/rush-hour.yaml",
|
| 24 |
+
"scout-maginot": "scenarios/strategy/scout-maginot.yaml",
|
| 25 |
+
}
|
| 26 |
+
|
| 27 |
+
PACKS_DIR = Path(__file__).parent / "packs"
|
| 28 |
+
|
| 29 |
+
|
| 30 |
+
def load_pack(path: str | Path) -> ScenarioPack:
|
| 31 |
+
"""Parse and validate a single pack YAML."""
|
| 32 |
+
path = Path(path)
|
| 33 |
+
with open(path) as f:
|
| 34 |
+
data = yaml.safe_load(f)
|
| 35 |
+
try:
|
| 36 |
+
return ScenarioPack(**data)
|
| 37 |
+
except Exception as e: # noqa: BLE001 — re-raise with file context
|
| 38 |
+
raise ValueError(f"invalid scenario pack {path}: {e}") from e
|
| 39 |
+
|
| 40 |
+
|
| 41 |
+
def discover_packs(directory: str | Path | None = None) -> list[ScenarioPack]:
|
| 42 |
+
"""Load every *.yaml pack in `directory` (default: bundled packs/).
|
| 43 |
+
|
| 44 |
+
Templates (filenames starting with '_' or 'TEMPLATE') are skipped.
|
| 45 |
+
"""
|
| 46 |
+
directory = Path(directory) if directory else PACKS_DIR
|
| 47 |
+
packs: list[ScenarioPack] = []
|
| 48 |
+
for p in sorted(directory.glob("*.yaml")):
|
| 49 |
+
if p.name.startswith(("_", "TEMPLATE")):
|
| 50 |
+
continue
|
| 51 |
+
packs.append(load_pack(p))
|
| 52 |
+
return packs
|
| 53 |
+
|
| 54 |
+
|
| 55 |
+
def is_map_supported(base_map: str) -> bool:
|
| 56 |
+
return base_map in SUPPORTED_MAPS
|
| 57 |
+
|
| 58 |
+
|
| 59 |
+
def rust_scenario_alias(base_map: str) -> str:
|
| 60 |
+
"""The path/alias to hand the Rust env for this logical map."""
|
| 61 |
+
return SUPPORTED_MAPS[base_map]
|
| 62 |
+
|
| 63 |
+
|
| 64 |
+
def compile_level(pack: ScenarioPack, level: LevelName):
|
| 65 |
+
"""Compile one level, wiring in the map-support flag."""
|
| 66 |
+
return pack.compile(level, map_supported=is_map_supported(pack.base_map))
|
openra_bench/scenarios/packs/TEMPLATE.yaml
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Copy this file to packs/<your-id>.yaml and fill it in.
|
| 2 |
+
# Validate: python -m openra_bench.scenarios.validate packs/<your-id>.yaml
|
| 3 |
+
# See ../CONTRIBUTING.md for the full win-condition grammar.
|
| 4 |
+
|
| 5 |
+
meta:
|
| 6 |
+
id: my-scenario-id # lowercase-kebab, unique
|
| 7 |
+
title: "Human Readable Title"
|
| 8 |
+
capability: perception # perception | reasoning | action
|
| 9 |
+
real_world_meaning: >
|
| 10 |
+
What real decision does this abstract? (>= 20 chars, reviewed)
|
| 11 |
+
robotics_analogue: "Concrete robotics/agentic parallel"
|
| 12 |
+
author: "your-name"
|
| 13 |
+
|
| 14 |
+
base_map: rush-hour-arena # only Rust-loadable map today
|
| 15 |
+
|
| 16 |
+
base: # shared ScenarioDefinition fields
|
| 17 |
+
agent: {faction: allies}
|
| 18 |
+
enemy: {faction: soviet}
|
| 19 |
+
tools: [move_units, attack_unit, stop_units]
|
| 20 |
+
planning: true
|
| 21 |
+
termination: {max_ticks: 8000}
|
| 22 |
+
actors:
|
| 23 |
+
- {type: jeep, owner: agent, position: [5, 5], count: 3}
|
| 24 |
+
- {type: e1, owner: enemy, position: [60, 20], stance: 2}
|
| 25 |
+
|
| 26 |
+
levels:
|
| 27 |
+
easy:
|
| 28 |
+
description: "Decision is obvious / fully observable."
|
| 29 |
+
overrides: {}
|
| 30 |
+
win_condition: {all_of: [{enemies_discovered_gte: 1}, {within_ticks: 6000}]}
|
| 31 |
+
max_turns: 30
|
| 32 |
+
medium:
|
| 33 |
+
description: "Partial info / one decoy."
|
| 34 |
+
overrides:
|
| 35 |
+
actors:
|
| 36 |
+
- {type: jeep, owner: agent, position: [5, 5], count: 2}
|
| 37 |
+
- {type: e1, owner: enemy, position: [90, 30], stance: 2}
|
| 38 |
+
win_condition: {all_of: [{enemies_discovered_gte: 1}, {within_ticks: 5000}]}
|
| 39 |
+
max_turns: 35
|
| 40 |
+
hard:
|
| 41 |
+
description: "Decoys + deadline + attrition pressure."
|
| 42 |
+
overrides:
|
| 43 |
+
actors:
|
| 44 |
+
- {type: jeep, owner: agent, position: [5, 5], count: 2}
|
| 45 |
+
- {type: e1, owner: enemy, position: [110, 35], stance: 2}
|
| 46 |
+
- {type: e3, owner: enemy, position: [40, 10], stance: 2}
|
| 47 |
+
win_condition:
|
| 48 |
+
all_of:
|
| 49 |
+
- {enemies_discovered_gte: 2}
|
| 50 |
+
- {within_ticks: 4500}
|
| 51 |
+
- {units_lost_lte: 1}
|
| 52 |
+
max_turns: 40
|
openra_bench/scenarios/packs/action-multiunit-coordination.yaml
ADDED
|
@@ -0,0 +1,110 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Validate: python -m openra_bench.scenarios.validate packs/action-multiunit-coordination.yaml
|
| 2 |
+
# See ../CONTRIBUTING.md for the full win-condition grammar.
|
| 3 |
+
#
|
| 4 |
+
# ACTION focus: the plan is trivial and stated outright — "split the force,
|
| 5 |
+
# send one group to region A and another to region B, arrive at both before
|
| 6 |
+
# the deadline". The hard part is EXECUTION: a model that controls only one
|
| 7 |
+
# group at a time, or marches everyone to a single objective, fails. Each
|
| 8 |
+
# level adds another simultaneous objective / a tighter window so a single
|
| 9 |
+
# serialized column can no longer satisfy the win condition in time.
|
| 10 |
+
|
| 11 |
+
meta:
|
| 12 |
+
id: action-multiunit-coordination
|
| 13 |
+
title: "Split-Force Simultaneous Objectives"
|
| 14 |
+
capability: action
|
| 15 |
+
real_world_meaning: >
|
| 16 |
+
Multi-region task allocation under a shared deadline is not a planning
|
| 17 |
+
problem here — the split is obvious. It tests whether the controller can
|
| 18 |
+
actually drive several effector groups in parallel instead of completing
|
| 19 |
+
one objective then the next, which serialized control fails to do in
|
| 20 |
+
time.
|
| 21 |
+
robotics_analogue: >
|
| 22 |
+
Coordinated multi-robot fleet dispatch: a logistics swarm must place
|
| 23 |
+
distinct sub-teams at separate depots within one delivery window;
|
| 24 |
+
one-at-a-time control blows the schedule.
|
| 25 |
+
author: "openra-bench"
|
| 26 |
+
|
| 27 |
+
base_map: rush-hour-arena
|
| 28 |
+
|
| 29 |
+
base:
|
| 30 |
+
agent: {faction: allies}
|
| 31 |
+
enemy: {faction: soviet}
|
| 32 |
+
tools: [move_units, attack_unit, stop_units]
|
| 33 |
+
planning: true
|
| 34 |
+
termination: {max_ticks: 8000}
|
| 35 |
+
actors:
|
| 36 |
+
# Two distinct agent groups in the spawn corner (x < 25) that must be
|
| 37 |
+
# split and driven to two separate regions at the same time.
|
| 38 |
+
- {type: 2tnk, owner: agent, position: [5, 6], count: 3}
|
| 39 |
+
- {type: 1tnk, owner: agent, position: [7, 10], count: 3}
|
| 40 |
+
# Static enemy buildings act as the destination markers / targets.
|
| 41 |
+
- {type: fact, owner: enemy, position: [110, 6]}
|
| 42 |
+
- {type: proc, owner: enemy, position: [110, 33]}
|
| 43 |
+
|
| 44 |
+
levels:
|
| 45 |
+
easy:
|
| 46 |
+
description: >
|
| 47 |
+
Two objectives, generous window. Split the 2tnk group to the
|
| 48 |
+
north-east region and the 1tnk group to the south-east region;
|
| 49 |
+
both groups must be on station before the deadline.
|
| 50 |
+
overrides: {}
|
| 51 |
+
win_condition:
|
| 52 |
+
all_of:
|
| 53 |
+
- {reach_region: {x: 110, y: 6, radius: 8}}
|
| 54 |
+
- {reach_region: {x: 110, y: 33, radius: 8}}
|
| 55 |
+
- {within_ticks: 6000}
|
| 56 |
+
max_turns: 30
|
| 57 |
+
|
| 58 |
+
medium:
|
| 59 |
+
description: >
|
| 60 |
+
Three simultaneous regions and an attrition cap. The force splits
|
| 61 |
+
three ways (NE, SE, mid-east); enemy infantry contest the lanes so
|
| 62 |
+
sloppy serialized movement bleeds units and misses the tighter
|
| 63 |
+
deadline.
|
| 64 |
+
overrides:
|
| 65 |
+
actors:
|
| 66 |
+
- {type: 2tnk, owner: agent, position: [5, 6], count: 3}
|
| 67 |
+
- {type: 1tnk, owner: agent, position: [7, 10], count: 3}
|
| 68 |
+
- {type: jeep, owner: agent, position: [5, 14], count: 2}
|
| 69 |
+
- {type: fact, owner: enemy, position: [110, 5]}
|
| 70 |
+
- {type: proc, owner: enemy, position: [110, 34]}
|
| 71 |
+
- {type: powr, owner: enemy, position: [112, 20]}
|
| 72 |
+
- {type: e1, owner: enemy, position: [60, 8], stance: 2, count: 2}
|
| 73 |
+
- {type: e1, owner: enemy, position: [60, 31], stance: 2, count: 2}
|
| 74 |
+
win_condition:
|
| 75 |
+
all_of:
|
| 76 |
+
- {reach_region: {x: 110, y: 5, radius: 7}}
|
| 77 |
+
- {reach_region: {x: 110, y: 34, radius: 7}}
|
| 78 |
+
- {reach_region: {x: 112, y: 20, radius: 7}}
|
| 79 |
+
- {within_ticks: 5000}
|
| 80 |
+
- {units_lost_lte: 2}
|
| 81 |
+
max_turns: 36
|
| 82 |
+
|
| 83 |
+
hard:
|
| 84 |
+
description: >
|
| 85 |
+
Three contested regions, the full force must arrive (every unit in
|
| 86 |
+
one of the three zones — checked as occupied corners), defenders
|
| 87 |
+
have turrets, the deadline is tight and attrition is strict. Only
|
| 88 |
+
genuine parallel multi-group control can satisfy all clauses.
|
| 89 |
+
overrides:
|
| 90 |
+
actors:
|
| 91 |
+
- {type: 2tnk, owner: agent, position: [5, 6], count: 3}
|
| 92 |
+
- {type: 1tnk, owner: agent, position: [7, 10], count: 3}
|
| 93 |
+
- {type: jeep, owner: agent, position: [5, 14], count: 2}
|
| 94 |
+
- {type: apc, owner: agent, position: [9, 18], count: 2}
|
| 95 |
+
- {type: fact, owner: enemy, position: [112, 5]}
|
| 96 |
+
- {type: proc, owner: enemy, position: [112, 35]}
|
| 97 |
+
- {type: powr, owner: enemy, position: [115, 20]}
|
| 98 |
+
- {type: gun, owner: enemy, position: [100, 6]}
|
| 99 |
+
- {type: gun, owner: enemy, position: [100, 34]}
|
| 100 |
+
- {type: e3, owner: enemy, position: [55, 9], stance: 2, count: 2}
|
| 101 |
+
- {type: e3, owner: enemy, position: [55, 31], stance: 2, count: 2}
|
| 102 |
+
- {type: e1, owner: enemy, position: [58, 20], stance: 2, count: 2}
|
| 103 |
+
win_condition:
|
| 104 |
+
all_of:
|
| 105 |
+
- {reach_region: {x: 112, y: 5, radius: 7}}
|
| 106 |
+
- {reach_region: {x: 112, y: 35, radius: 7}}
|
| 107 |
+
- {reach_region: {x: 115, y: 20, radius: 7}}
|
| 108 |
+
- {within_ticks: 4200}
|
| 109 |
+
- {units_lost_lte: 2}
|
| 110 |
+
max_turns: 44
|
openra_bench/scenarios/packs/action-sequenced-execution.yaml
ADDED
|
@@ -0,0 +1,107 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Validate: python -m openra_bench.scenarios.validate packs/action-sequenced-execution.yaml
|
| 2 |
+
# See ../CONTRIBUTING.md for the full win-condition grammar.
|
| 3 |
+
#
|
| 4 |
+
# ACTION focus: the route is given as an ordered plan — waypoint W1, then
|
| 5 |
+
# W2, then the final objective. The model is not asked to *find* the order;
|
| 6 |
+
# it is asked to *execute* it without stalling. The win condition encodes
|
| 7 |
+
# the sequence via after_ticks gates: the final region must be reached only
|
| 8 |
+
# after enough ticks that an idle / dithering agent runs out the clock,
|
| 9 |
+
# while a decisive agent that keeps the column moving clears each leg in
|
| 10 |
+
# time. Each level lengthens the route and tightens the budget so stalling
|
| 11 |
+
# between legs becomes the dominant failure mode.
|
| 12 |
+
|
| 13 |
+
meta:
|
| 14 |
+
id: action-sequenced-execution
|
| 15 |
+
title: "Ordered Route Execution Without Stalling"
|
| 16 |
+
capability: action
|
| 17 |
+
real_world_meaning: >
|
| 18 |
+
The route is already planned; the open problem is faithful, non-stalling
|
| 19 |
+
execution of an ordered objective sequence — issuing the next move the
|
| 20 |
+
instant a leg completes rather than idling, re-deliberating, or skipping
|
| 21 |
+
a waypoint, which is what makes long autonomous missions fail.
|
| 22 |
+
robotics_analogue: >
|
| 23 |
+
Manipulator / mobile-base task sequencing: a robot must hit a fixed
|
| 24 |
+
ordered set of stations (pick, transit, place) within a cycle-time
|
| 25 |
+
budget; pausing or reordering between steps misses the takt time.
|
| 26 |
+
author: "openra-bench"
|
| 27 |
+
|
| 28 |
+
base_map: rush-hour-arena
|
| 29 |
+
|
| 30 |
+
base:
|
| 31 |
+
agent: {faction: allies}
|
| 32 |
+
enemy: {faction: soviet}
|
| 33 |
+
tools: [move_units, attack_unit, stop_units]
|
| 34 |
+
planning: true
|
| 35 |
+
termination: {max_ticks: 8000}
|
| 36 |
+
actors:
|
| 37 |
+
# One cohesive column that must traverse an ordered route.
|
| 38 |
+
- {type: 2tnk, owner: agent, position: [5, 8], count: 2}
|
| 39 |
+
- {type: 1tnk, owner: agent, position: [7, 12], count: 2}
|
| 40 |
+
# Waypoint / terminal markers as static enemy buildings.
|
| 41 |
+
- {type: powr, owner: enemy, position: [45, 30]} # W1
|
| 42 |
+
- {type: proc, owner: enemy, position: [90, 10]} # W2
|
| 43 |
+
- {type: fact, owner: enemy, position: [118, 33]} # final
|
| 44 |
+
|
| 45 |
+
levels:
|
| 46 |
+
easy:
|
| 47 |
+
description: >
|
| 48 |
+
Two-leg route: go to waypoint W1 (SW-mid), then the final objective
|
| 49 |
+
(far SE). The after_ticks gate means the agent must already be
|
| 50 |
+
moving; arriving at the end only counts once W1's leg time has
|
| 51 |
+
elapsed, and the overall deadline punishes any stall.
|
| 52 |
+
overrides: {}
|
| 53 |
+
win_condition:
|
| 54 |
+
all_of:
|
| 55 |
+
- {reach_region: {x: 118, y: 33, radius: 8}}
|
| 56 |
+
- {after_ticks: 1200}
|
| 57 |
+
- {within_ticks: 6000}
|
| 58 |
+
max_turns: 30
|
| 59 |
+
|
| 60 |
+
medium:
|
| 61 |
+
description: >
|
| 62 |
+
Three-leg route W1 -> W2 -> final with a contested first leg and an
|
| 63 |
+
attrition cap. A model that stalls to re-plan between legs cannot
|
| 64 |
+
clear all three before the tighter deadline.
|
| 65 |
+
overrides:
|
| 66 |
+
actors:
|
| 67 |
+
- {type: 2tnk, owner: agent, position: [5, 8], count: 2}
|
| 68 |
+
- {type: 1tnk, owner: agent, position: [7, 12], count: 2}
|
| 69 |
+
- {type: jeep, owner: agent, position: [5, 16]}
|
| 70 |
+
- {type: powr, owner: enemy, position: [45, 31]}
|
| 71 |
+
- {type: proc, owner: enemy, position: [92, 9]}
|
| 72 |
+
- {type: fact, owner: enemy, position: [120, 34]}
|
| 73 |
+
- {type: e1, owner: enemy, position: [40, 25], stance: 2, count: 2}
|
| 74 |
+
win_condition:
|
| 75 |
+
all_of:
|
| 76 |
+
- {reach_region: {x: 120, y: 34, radius: 7}}
|
| 77 |
+
- {after_ticks: 2200}
|
| 78 |
+
- {within_ticks: 5000}
|
| 79 |
+
- {units_lost_lte: 1}
|
| 80 |
+
max_turns: 36
|
| 81 |
+
|
| 82 |
+
hard:
|
| 83 |
+
description: >
|
| 84 |
+
Four-leg serpentine route across the full arena with defended
|
| 85 |
+
waypoints, a strict attrition cap and a tight budget. Every leg must
|
| 86 |
+
be executed back-to-back; any idling, re-deliberation, or skipped
|
| 87 |
+
leg overruns the clock or loses too many units.
|
| 88 |
+
overrides:
|
| 89 |
+
actors:
|
| 90 |
+
- {type: 2tnk, owner: agent, position: [5, 8], count: 2}
|
| 91 |
+
- {type: 1tnk, owner: agent, position: [7, 12], count: 2}
|
| 92 |
+
- {type: jeep, owner: agent, position: [5, 16]}
|
| 93 |
+
- {type: apc, owner: agent, position: [9, 20]}
|
| 94 |
+
- {type: powr, owner: enemy, position: [45, 33]} # W1
|
| 95 |
+
- {type: proc, owner: enemy, position: [80, 6]} # W2
|
| 96 |
+
- {type: tsla, owner: enemy, position: [110, 30]} # W3
|
| 97 |
+
- {type: fact, owner: enemy, position: [122, 6]} # final
|
| 98 |
+
- {type: e3, owner: enemy, position: [42, 27], stance: 2, count: 2}
|
| 99 |
+
- {type: e1, owner: enemy, position: [78, 12], stance: 2, count: 2}
|
| 100 |
+
- {type: gun, owner: enemy, position: [108, 25]}
|
| 101 |
+
win_condition:
|
| 102 |
+
all_of:
|
| 103 |
+
- {reach_region: {x: 122, y: 6, radius: 7}}
|
| 104 |
+
- {after_ticks: 3400}
|
| 105 |
+
- {within_ticks: 4400}
|
| 106 |
+
- {units_lost_lte: 1}
|
| 107 |
+
max_turns: 46
|
openra_bench/scenarios/packs/perception-frontier-reading.yaml
ADDED
|
@@ -0,0 +1,101 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Validate: python -m openra_bench.scenarios.validate \
|
| 2 |
+
# openra_bench/scenarios/packs/perception-frontier-reading.yaml
|
| 3 |
+
# See ../CONTRIBUTING.md for the full win-condition grammar.
|
| 4 |
+
#
|
| 5 |
+
# PERCEPTION focus: can the model correctly READ the fog frontier — i.e.
|
| 6 |
+
# tell which cells are still unrevealed and steer scouts INTO them — as
|
| 7 |
+
# opposed to re-treading already-seen ground or stalling against decoys?
|
| 8 |
+
# The win is a raw map-coverage threshold within a deadline, so the only
|
| 9 |
+
# way to pass is to perceive where the unexplored mass is and commit
|
| 10 |
+
# movement toward it. Difficulty rises by hiding the frontier farther
|
| 11 |
+
# from spawn, splitting it across the map, adding decoy enemies that bait
|
| 12 |
+
# attention to already-explored zones, and tightening the clock.
|
| 13 |
+
|
| 14 |
+
meta:
|
| 15 |
+
id: perception-frontier-reading
|
| 16 |
+
title: "Reading the Fog Frontier"
|
| 17 |
+
capability: perception
|
| 18 |
+
real_world_meaning: >
|
| 19 |
+
Path planning is solved; the hard part is reading an occupancy grid
|
| 20 |
+
correctly to tell explored cells from the unknown frontier and
|
| 21 |
+
pushing sensors into the unknown instead of re-scanning known space.
|
| 22 |
+
robotics_analogue: "SLAM frontier detection — picking the next unexplored cell to drive a scout robot toward"
|
| 23 |
+
author: "openra-bench"
|
| 24 |
+
|
| 25 |
+
base_map: rush-hour-arena
|
| 26 |
+
|
| 27 |
+
base:
|
| 28 |
+
agent: {faction: allies}
|
| 29 |
+
enemy: {faction: soviet}
|
| 30 |
+
tools: [move_units, attack_unit, stop_units]
|
| 31 |
+
planning: true
|
| 32 |
+
termination: {max_ticks: 10000}
|
| 33 |
+
actors:
|
| 34 |
+
# Fast, fragile scouts spawned in the bottom-left pocket (x<25).
|
| 35 |
+
- {type: jeep, owner: agent, position: [5, 33], count: 3}
|
| 36 |
+
- {type: jeep, owner: agent, position: [7, 30], count: 2}
|
| 37 |
+
|
| 38 |
+
levels:
|
| 39 |
+
easy:
|
| 40 |
+
description: >
|
| 41 |
+
One contiguous unexplored mass directly east of spawn along the
|
| 42 |
+
open lane. The frontier is obvious and reachable; generous clock.
|
| 43 |
+
overrides:
|
| 44 |
+
actors:
|
| 45 |
+
- {type: jeep, owner: agent, position: [5, 33], count: 3}
|
| 46 |
+
- {type: jeep, owner: agent, position: [7, 30], count: 2}
|
| 47 |
+
# A single static building parked deep east — incidentally seen
|
| 48 |
+
# by any honest eastward sweep.
|
| 49 |
+
- {type: proc, owner: enemy, position: [100, 20]}
|
| 50 |
+
win_condition:
|
| 51 |
+
all_of:
|
| 52 |
+
- {explored_pct_gte: 45}
|
| 53 |
+
- {within_ticks: 8000}
|
| 54 |
+
max_turns: 30
|
| 55 |
+
|
| 56 |
+
medium:
|
| 57 |
+
description: >
|
| 58 |
+
Unexplored area is split: a near pocket bottom-left already partly
|
| 59 |
+
visible, and the real bulk of the frontier lies far NE. A decoy
|
| 60 |
+
enemy squad sits inside the ALREADY-explored band to bait the
|
| 61 |
+
scouts into re-treading seen ground. Tighter clock, fewer scouts.
|
| 62 |
+
overrides:
|
| 63 |
+
actors:
|
| 64 |
+
- {type: jeep, owner: agent, position: [5, 33], count: 3}
|
| 65 |
+
# Decoy: enemy infantry near spawn, inside soon-to-be-seen cells.
|
| 66 |
+
- {type: e1, owner: enemy, position: [20, 34], stance: 2, count: 2}
|
| 67 |
+
- {type: dog, owner: enemy, position: [22, 30], stance: 2}
|
| 68 |
+
# Real frontier mass: far NE, only reachable by a deliberate push.
|
| 69 |
+
- {type: powr, owner: enemy, position: [115, 6]}
|
| 70 |
+
- {type: proc, owner: enemy, position: [108, 12]}
|
| 71 |
+
win_condition:
|
| 72 |
+
all_of:
|
| 73 |
+
- {explored_pct_gte: 55}
|
| 74 |
+
- {within_ticks: 6000}
|
| 75 |
+
max_turns: 35
|
| 76 |
+
|
| 77 |
+
hard:
|
| 78 |
+
description: >
|
| 79 |
+
Frontier is fragmented across three corners (far NE, far SE, and a
|
| 80 |
+
thin NW strip). Two decoy squads sit in the explored center to pull
|
| 81 |
+
scouts off the true frontier, attrition is real (must keep >=4 of 5
|
| 82 |
+
scouts), and the deadline is short — only correct, simultaneous
|
| 83 |
+
reading of all three fog pockets clears the coverage bar in time.
|
| 84 |
+
overrides:
|
| 85 |
+
actors:
|
| 86 |
+
- {type: jeep, owner: agent, position: [5, 33], count: 3}
|
| 87 |
+
- {type: jeep, owner: agent, position: [7, 30], count: 2}
|
| 88 |
+
# Center decoys (already-explored zone) — combat bait.
|
| 89 |
+
- {type: e1, owner: enemy, position: [55, 20], stance: 2, count: 3}
|
| 90 |
+
- {type: e3, owner: enemy, position: [58, 22], stance: 2}
|
| 91 |
+
- {type: dog, owner: enemy, position: [50, 18], stance: 2, count: 2}
|
| 92 |
+
# Three separated frontier markers forcing a full-map read.
|
| 93 |
+
- {type: powr, owner: enemy, position: [122, 4]}
|
| 94 |
+
- {type: proc, owner: enemy, position: [120, 36]}
|
| 95 |
+
- {type: gun, owner: enemy, position: [10, 4]}
|
| 96 |
+
win_condition:
|
| 97 |
+
all_of:
|
| 98 |
+
- {explored_pct_gte: 62}
|
| 99 |
+
- {within_ticks: 4800}
|
| 100 |
+
- {units_lost_lte: 1}
|
| 101 |
+
max_turns: 40
|
openra_bench/scenarios/packs/perception-target-vs-fog.yaml
ADDED
|
@@ -0,0 +1,111 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Validate: python -m openra_bench.scenarios.validate \
|
| 2 |
+
# openra_bench/scenarios/packs/perception-target-vs-fog.yaml
|
| 3 |
+
# See ../CONTRIBUTING.md for the full win-condition grammar.
|
| 4 |
+
#
|
| 5 |
+
# PERCEPTION focus, targeting a KNOWN failure mode: the model treats
|
| 6 |
+
# "unexplored region" and "where the target is" as unrelated facts. It
|
| 7 |
+
# explores the nearest fog patch, sees nothing, and never connects that
|
| 8 |
+
# the target building must therefore be in the OTHER unexplored region.
|
| 9 |
+
# Here the win requires discovering the actual target building (not just
|
| 10 |
+
# map coverage), and there is always a nearer-but-empty fog pocket plus a
|
| 11 |
+
# farther fog pocket that genuinely contains the target. Passing means
|
| 12 |
+
# the model spatially reasoned about WHERE the unexplored mass is
|
| 13 |
+
# relative to WHERE the target must be — not just that fog exists.
|
| 14 |
+
|
| 15 |
+
meta:
|
| 16 |
+
id: perception-target-vs-fog
|
| 17 |
+
title: "Where the Target Hides in the Fog"
|
| 18 |
+
capability: perception
|
| 19 |
+
real_world_meaning: >
|
| 20 |
+
The real search problem is not "go to fog" but inferring which of
|
| 21 |
+
several unexplored regions could actually contain the target given
|
| 22 |
+
what the empty regions rule out, then committing sensors there.
|
| 23 |
+
robotics_analogue: "Search-and-rescue: choosing which unexplored room can still hold the victim after clearing the near ones"
|
| 24 |
+
|
| 25 |
+
base_map: rush-hour-arena
|
| 26 |
+
|
| 27 |
+
base:
|
| 28 |
+
agent: {faction: allies}
|
| 29 |
+
enemy: {faction: soviet}
|
| 30 |
+
tools: [move_units, attack_unit, stop_units]
|
| 31 |
+
planning: true
|
| 32 |
+
termination: {max_ticks: 10000}
|
| 33 |
+
actors:
|
| 34 |
+
# Scouts spawn top-left (x<25, y<10).
|
| 35 |
+
- {type: jeep, owner: agent, position: [6, 5], count: 3}
|
| 36 |
+
- {type: 1tnk, owner: agent, position: [8, 8], count: 2}
|
| 37 |
+
|
| 38 |
+
levels:
|
| 39 |
+
easy:
|
| 40 |
+
description: >
|
| 41 |
+
One target building, one unexplored region — both far east. The
|
| 42 |
+
near area is already open (no decoy fog). Reading "fog is east,
|
| 43 |
+
therefore the target is east" is direct. Generous clock.
|
| 44 |
+
overrides:
|
| 45 |
+
actors:
|
| 46 |
+
- {type: jeep, owner: agent, position: [6, 5], count: 3}
|
| 47 |
+
- {type: 1tnk, owner: agent, position: [8, 8], count: 2}
|
| 48 |
+
# Target building in the single eastern fog mass.
|
| 49 |
+
- {type: fact, owner: enemy, position: [105, 20]}
|
| 50 |
+
# Light guard so it reads as a real objective, not a stray actor.
|
| 51 |
+
- {type: e1, owner: enemy, position: [102, 22], stance: 2}
|
| 52 |
+
win_condition:
|
| 53 |
+
all_of:
|
| 54 |
+
- {buildings_discovered_gte: 1}
|
| 55 |
+
- {within_ticks: 8000}
|
| 56 |
+
max_turns: 30
|
| 57 |
+
|
| 58 |
+
medium:
|
| 59 |
+
description: >
|
| 60 |
+
Two unexplored regions: a NEAR one (NE, closer to spawn) that is
|
| 61 |
+
EMPTY, and a FAR one (SE, opposite corner) that holds the real
|
| 62 |
+
target. The model must not stop after clearing the near fog and
|
| 63 |
+
finding nothing — it must infer the target is in the remaining
|
| 64 |
+
unexplored region. Decoy enemy units sit in the empty near region.
|
| 65 |
+
overrides:
|
| 66 |
+
actors:
|
| 67 |
+
- {type: jeep, owner: agent, position: [6, 5], count: 3}
|
| 68 |
+
- {type: 1tnk, owner: agent, position: [8, 8], count: 2}
|
| 69 |
+
# Near (NE) decoy region: enemy units but NO building (a trap
|
| 70 |
+
# that satisfies curiosity but not the win condition).
|
| 71 |
+
- {type: e1, owner: enemy, position: [70, 6], stance: 2, count: 2}
|
| 72 |
+
- {type: jeep, owner: enemy, position: [74, 9], stance: 2}
|
| 73 |
+
# Far (SE) region: the actual target building.
|
| 74 |
+
- {type: fact, owner: enemy, position: [118, 35]}
|
| 75 |
+
- {type: e2, owner: enemy, position: [114, 33], stance: 2}
|
| 76 |
+
win_condition:
|
| 77 |
+
all_of:
|
| 78 |
+
- {buildings_discovered_gte: 1}
|
| 79 |
+
- {within_ticks: 6000}
|
| 80 |
+
max_turns: 35
|
| 81 |
+
|
| 82 |
+
hard:
|
| 83 |
+
description: >
|
| 84 |
+
Three unexplored regions. Two are decoys: a NEAR-NE pocket with a
|
| 85 |
+
noisy enemy squad (pure bait) and a MID-SOUTH pocket with a single
|
| 86 |
+
enemy *building of the wrong kind already implicitly elsewhere* —
|
| 87 |
+
both contain no usable target. The real target is a lone building
|
| 88 |
+
tucked in the far-NW strip, the LEAST intuitive direction from a
|
| 89 |
+
spawn that naturally pushes east. Short deadline and attrition:
|
| 90 |
+
only a correct read of which fog pocket can still hold the target
|
| 91 |
+
— after the near ones are ruled out — discovers it in time.
|
| 92 |
+
overrides:
|
| 93 |
+
actors:
|
| 94 |
+
- {type: jeep, owner: agent, position: [6, 5], count: 3}
|
| 95 |
+
- {type: 1tnk, owner: agent, position: [8, 8], count: 2}
|
| 96 |
+
# Decoy A — near NE, loud enemy squad, no target building.
|
| 97 |
+
- {type: e1, owner: enemy, position: [60, 6], stance: 2, count: 3}
|
| 98 |
+
- {type: dog, owner: enemy, position: [64, 9], stance: 2, count: 2}
|
| 99 |
+
# Decoy B — mid-south, enemy units only, no target building.
|
| 100 |
+
- {type: e3, owner: enemy, position: [70, 34], stance: 2, count: 2}
|
| 101 |
+
- {type: apc, owner: enemy, position: [66, 31], stance: 2}
|
| 102 |
+
# The real target: a single building in the far-NW strip, the
|
| 103 |
+
# counter-intuitive corner relative to an eastward spawn bias.
|
| 104 |
+
- {type: tsla, owner: enemy, position: [118, 4]}
|
| 105 |
+
- {type: e1, owner: enemy, position: [115, 6], stance: 2}
|
| 106 |
+
win_condition:
|
| 107 |
+
all_of:
|
| 108 |
+
- {buildings_discovered_gte: 1}
|
| 109 |
+
- {within_ticks: 4800}
|
| 110 |
+
- {units_lost_lte: 1}
|
| 111 |
+
max_turns: 40
|
openra_bench/scenarios/packs/reasoning-frontier-commit.yaml
ADDED
|
@@ -0,0 +1,111 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# reasoning-frontier-commit.yaml
|
| 2 |
+
#
|
| 3 |
+
# REASONING-link scenario. Perception is trivially correct (the map is
|
| 4 |
+
# fog-of-war and a single scout discovers whatever it drives into); the
|
| 5 |
+
# hard part is the *decision*: given partial information and a finite
|
| 6 |
+
# tick budget, WHICH unexplored region do you commit your scout to?
|
| 7 |
+
# Multiple regions look equally plausible from the corner; only one
|
| 8 |
+
# holds the survivor (the objective marker). Wrong commitment burns the
|
| 9 |
+
# clock and you draw.
|
| 10 |
+
#
|
| 11 |
+
# Validate:
|
| 12 |
+
# cd /Users/berta/Projects/OpenRA-Bench && \
|
| 13 |
+
# python3 -m openra_bench.scenarios.validate \
|
| 14 |
+
# openra_bench/scenarios/packs/reasoning-frontier-commit.yaml
|
| 15 |
+
|
| 16 |
+
meta:
|
| 17 |
+
id: reasoning-frontier-commit
|
| 18 |
+
title: "Frontier Commitment Under Partial Information"
|
| 19 |
+
capability: reasoning
|
| 20 |
+
real_world_meaning: >
|
| 21 |
+
Path planning to any point is solved. The unsolved problem is
|
| 22 |
+
deciding which of several unexplored regions to commit a
|
| 23 |
+
time/fuel-limited searcher to when only one hides the target and
|
| 24 |
+
going to the wrong one first means you never reach the right one in
|
| 25 |
+
time. That commitment-under-uncertainty step is the real
|
| 26 |
+
search-and-rescue problem, not the navigation.
|
| 27 |
+
robotics_analogue: >
|
| 28 |
+
UAV/UGV search-and-rescue frontier selection: with limited
|
| 29 |
+
endurance and several candidate search cells, choose the cell most
|
| 30 |
+
likely to contain the survivor before the battery dies.
|
| 31 |
+
author: "berta"
|
| 32 |
+
|
| 33 |
+
base_map: rush-hour-arena
|
| 34 |
+
|
| 35 |
+
base:
|
| 36 |
+
agent: {faction: allies}
|
| 37 |
+
enemy: {faction: soviet}
|
| 38 |
+
tools: [move_units, attack_unit, stop_units]
|
| 39 |
+
planning: true
|
| 40 |
+
termination: {max_ticks: 8000}
|
| 41 |
+
actors:
|
| 42 |
+
# Single fast scout in the NW corner (x<25). One body, one
|
| 43 |
+
# commitment — the decision cannot be hedged by splitting forces.
|
| 44 |
+
- {type: jeep, owner: agent, position: [6, 6], count: 1}
|
| 45 |
+
# The survivor: a static building marker, the only thing that
|
| 46 |
+
# satisfies the win condition. NE region.
|
| 47 |
+
- {type: gun, owner: enemy, position: [112, 6], stance: 0}
|
| 48 |
+
|
| 49 |
+
levels:
|
| 50 |
+
easy:
|
| 51 |
+
description: >
|
| 52 |
+
One survivor, one plausible region. The objective marker sits in
|
| 53 |
+
the far-NE region and nothing else does — perception is honest,
|
| 54 |
+
the only task is to plan a direct commitment within the deadline.
|
| 55 |
+
overrides: {}
|
| 56 |
+
win_condition:
|
| 57 |
+
all_of:
|
| 58 |
+
- {buildings_discovered_gte: 1}
|
| 59 |
+
- {within_ticks: 6500}
|
| 60 |
+
max_turns: 30
|
| 61 |
+
|
| 62 |
+
medium:
|
| 63 |
+
description: >
|
| 64 |
+
Two candidate regions, one decoy. A decoy marker sits in the
|
| 65 |
+
far-SE region; the real survivor is in the far-NE. From the
|
| 66 |
+
corner both are equidistant-looking, but only the NE building
|
| 67 |
+
counts toward 'buildings_discovered'. Committing to the SE decoy
|
| 68 |
+
first wastes enough ticks to miss the deadline — the model must
|
| 69 |
+
reason about which to commit to, not just navigate.
|
| 70 |
+
overrides:
|
| 71 |
+
actors:
|
| 72 |
+
- {type: jeep, owner: agent, position: [6, 6], count: 1}
|
| 73 |
+
# Decoy: an enemy *unit* in SE — discovering it does NOT satisfy
|
| 74 |
+
# the buildings-based win condition. Pure distractor.
|
| 75 |
+
- {type: e1, owner: enemy, position: [112, 34], stance: 0, count: 2}
|
| 76 |
+
# Real survivor marker: NE building.
|
| 77 |
+
- {type: gun, owner: enemy, position: [112, 6], stance: 0}
|
| 78 |
+
termination: {max_ticks: 7000}
|
| 79 |
+
win_condition:
|
| 80 |
+
all_of:
|
| 81 |
+
- {buildings_discovered_gte: 1}
|
| 82 |
+
- {within_ticks: 5200}
|
| 83 |
+
max_turns: 34
|
| 84 |
+
|
| 85 |
+
hard:
|
| 86 |
+
description: >
|
| 87 |
+
Three candidate regions, two decoys, a tighter deadline, and an
|
| 88 |
+
attrition constraint. Decoy unit clusters sit in the SE and
|
| 89 |
+
mid-S regions; the real survivor marker is in the far-NE. One
|
| 90 |
+
decoy cluster is hostile (stance 2) so a careless route into it
|
| 91 |
+
can get the lone scout killed — the model must pick the
|
| 92 |
+
commitment that finds the building, fits the clock, AND avoids
|
| 93 |
+
the lethal frontier. Reasoning must trade route safety against
|
| 94 |
+
time without splitting (only one unit).
|
| 95 |
+
overrides:
|
| 96 |
+
actors:
|
| 97 |
+
- {type: jeep, owner: agent, position: [6, 6], count: 1}
|
| 98 |
+
# Decoy A (SE) — hostile cluster; a route through it risks the
|
| 99 |
+
# lone scout (units_lost_lte: 0 constraint below).
|
| 100 |
+
- {type: e3, owner: enemy, position: [110, 34], stance: 2, count: 3}
|
| 101 |
+
# Decoy B (mid-S) — passive distractor unit.
|
| 102 |
+
- {type: e1, owner: enemy, position: [62, 36], stance: 0, count: 2}
|
| 103 |
+
# Real survivor marker: far-NE building.
|
| 104 |
+
- {type: gun, owner: enemy, position: [118, 5], stance: 0}
|
| 105 |
+
termination: {max_ticks: 6000}
|
| 106 |
+
win_condition:
|
| 107 |
+
all_of:
|
| 108 |
+
- {buildings_discovered_gte: 1}
|
| 109 |
+
- {within_ticks: 4200}
|
| 110 |
+
- {units_lost_lte: 0}
|
| 111 |
+
max_turns: 38
|
openra_bench/scenarios/packs/reasoning-risk-route.yaml
ADDED
|
@@ -0,0 +1,132 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# reasoning-risk-route.yaml
|
| 2 |
+
#
|
| 3 |
+
# REASONING-link scenario. Perception is correct (defenses are visible
|
| 4 |
+
# once approached and the layout is fixed); the hard part is the
|
| 5 |
+
# *plan*: there are two ways to the objective region — a short corridor
|
| 6 |
+
# straight through lethal defenses, and a longer detour along a map
|
| 7 |
+
# edge that avoids them. The short route is faster but kills a
|
| 8 |
+
# light-unit scout; the safe route is slower but survivable. The model
|
| 9 |
+
# must reason about the risk/route tradeoff and choose the longer,
|
| 10 |
+
# safer plan under an attrition constraint and a deadline that still
|
| 11 |
+
# permits the detour.
|
| 12 |
+
#
|
| 13 |
+
# Validate:
|
| 14 |
+
# cd /Users/berta/Projects/OpenRA-Bench && \
|
| 15 |
+
# python3 -m openra_bench.scenarios.validate \
|
| 16 |
+
# openra_bench/scenarios/packs/reasoning-risk-route.yaml
|
| 17 |
+
|
| 18 |
+
meta:
|
| 19 |
+
id: reasoning-risk-route
|
| 20 |
+
title: "Risk vs Route: Safe-but-Long over Short-but-Deadly"
|
| 21 |
+
capability: reasoning
|
| 22 |
+
real_world_meaning: >
|
| 23 |
+
Shortest-path is solved. The unsolved decision is whether the
|
| 24 |
+
shortest path is the right path: a direct corridor may pass through
|
| 25 |
+
a hazard that destroys the vehicle, while a longer perimeter route
|
| 26 |
+
completes the mission intact. Choosing the survivable plan over the
|
| 27 |
+
fastest one, given a deadline that the detour still satisfies, is
|
| 28 |
+
the actual mission-planning problem.
|
| 29 |
+
robotics_analogue: >
|
| 30 |
+
Field-robot route selection under hazard: a delivery/inspection
|
| 31 |
+
robot must reject the shortest path when it crosses a no-go hazard
|
| 32 |
+
zone and instead commit to a longer but survivable route that still
|
| 33 |
+
meets the time window.
|
| 34 |
+
author: "berta"
|
| 35 |
+
|
| 36 |
+
base_map: rush-hour-arena
|
| 37 |
+
|
| 38 |
+
base:
|
| 39 |
+
agent: {faction: allies}
|
| 40 |
+
enemy: {faction: soviet}
|
| 41 |
+
tools: [move_units, attack_unit, stop_units]
|
| 42 |
+
planning: true
|
| 43 |
+
termination: {max_ticks: 9000}
|
| 44 |
+
actors:
|
| 45 |
+
# Lone light scout, NW corner (x<25). Fragile — cannot trade fire
|
| 46 |
+
# with turrets, so the route choice is the whole game.
|
| 47 |
+
- {type: jeep, owner: agent, position: [6, 20], count: 1}
|
| 48 |
+
# Objective marker on the far (east) side of the map.
|
| 49 |
+
- {type: gun, owner: enemy, position: [120, 20], stance: 0}
|
| 50 |
+
# Mid-map central choke "short route" defenses (lethal). The detour
|
| 51 |
+
# is along the top edge (low y) or bottom edge (high y), which is
|
| 52 |
+
# longer but clear in easy/medium.
|
| 53 |
+
- {type: tsla, owner: enemy, position: [62, 20], stance: 0}
|
| 54 |
+
- {type: e3, owner: enemy, position: [60, 19], stance: 2, count: 3}
|
| 55 |
+
- {type: e3, owner: enemy, position: [64, 21], stance: 2, count: 3}
|
| 56 |
+
|
| 57 |
+
levels:
|
| 58 |
+
easy:
|
| 59 |
+
description: >
|
| 60 |
+
One central hazard, one obvious safe detour. The direct line
|
| 61 |
+
x-axis route passes through a tesla coil + rocket infantry that
|
| 62 |
+
shred the jeep; the top and bottom edges are completely open.
|
| 63 |
+
Generous deadline: any safe detour wins. The model only has to
|
| 64 |
+
recognise the corridor is lethal and not drive into it.
|
| 65 |
+
overrides: {}
|
| 66 |
+
win_condition:
|
| 67 |
+
all_of:
|
| 68 |
+
- {reach_region: {x: 120, y: 20, radius: 7}}
|
| 69 |
+
- {units_lost_lte: 0}
|
| 70 |
+
- {within_ticks: 7500}
|
| 71 |
+
max_turns: 32
|
| 72 |
+
|
| 73 |
+
medium:
|
| 74 |
+
description: >
|
| 75 |
+
The bottom detour is now also guarded, so only the top-edge route
|
| 76 |
+
is safe — a longer commitment that the deadline still allows but
|
| 77 |
+
with less slack. The model must reason that the shortest route is
|
| 78 |
+
lethal, the bottom route is also lethal, and the top route,
|
| 79 |
+
though longest, is the only plan that satisfies both the
|
| 80 |
+
no-loss constraint and the tighter clock.
|
| 81 |
+
overrides:
|
| 82 |
+
actors:
|
| 83 |
+
- {type: jeep, owner: agent, position: [6, 20], count: 1}
|
| 84 |
+
- {type: gun, owner: enemy, position: [120, 20], stance: 0}
|
| 85 |
+
# Central short-route hazard.
|
| 86 |
+
- {type: tsla, owner: enemy, position: [62, 20], stance: 0}
|
| 87 |
+
- {type: e3, owner: enemy, position: [60, 19], stance: 2, count: 3}
|
| 88 |
+
- {type: e3, owner: enemy, position: [64, 21], stance: 2, count: 3}
|
| 89 |
+
# Bottom-edge route now blocked too (lethal cluster, high y).
|
| 90 |
+
- {type: e3, owner: enemy, position: [62, 36], stance: 2, count: 4}
|
| 91 |
+
- {type: gun, owner: enemy, position: [62, 38], stance: 0}
|
| 92 |
+
termination: {max_ticks: 8000}
|
| 93 |
+
win_condition:
|
| 94 |
+
all_of:
|
| 95 |
+
- {reach_region: {x: 120, y: 20, radius: 7}}
|
| 96 |
+
- {units_lost_lte: 0}
|
| 97 |
+
- {within_ticks: 6000}
|
| 98 |
+
max_turns: 36
|
| 99 |
+
|
| 100 |
+
hard:
|
| 101 |
+
description: >
|
| 102 |
+
Both edges are partly contested and the deadline is tight enough
|
| 103 |
+
that the longest fully-safe path is too slow — the model must
|
| 104 |
+
reason about a graduated tradeoff: the top edge is fastest but
|
| 105 |
+
grazes a defended pocket, the bottom edge is fully safe but too
|
| 106 |
+
long for the clock, so the only winning plan threads the
|
| 107 |
+
narrow safe seam near the top while staying out of weapon range.
|
| 108 |
+
Risk and route must be traded against the deadline, not avoided
|
| 109 |
+
outright.
|
| 110 |
+
overrides:
|
| 111 |
+
actors:
|
| 112 |
+
- {type: jeep, owner: agent, position: [6, 20], count: 1}
|
| 113 |
+
- {type: gun, owner: enemy, position: [122, 20], stance: 0}
|
| 114 |
+
# Central short-route hazard (still lethal).
|
| 115 |
+
- {type: tsla, owner: enemy, position: [62, 20], stance: 0}
|
| 116 |
+
- {type: e3, owner: enemy, position: [60, 19], stance: 2, count: 3}
|
| 117 |
+
- {type: e3, owner: enemy, position: [64, 21], stance: 2, count: 3}
|
| 118 |
+
# Top-edge defended pocket — must skirt it precisely, not
|
| 119 |
+
# plough through it.
|
| 120 |
+
- {type: e3, owner: enemy, position: [70, 6], stance: 2, count: 2}
|
| 121 |
+
- {type: gun, owner: enemy, position: [72, 4], stance: 0}
|
| 122 |
+
# Bottom-edge fully blocked (forces the top seam, but bottom
|
| 123 |
+
# detour would also be too slow for the clock anyway).
|
| 124 |
+
- {type: e3, owner: enemy, position: [62, 36], stance: 2, count: 4}
|
| 125 |
+
- {type: tsla, owner: enemy, position: [62, 38], stance: 0}
|
| 126 |
+
termination: {max_ticks: 7000}
|
| 127 |
+
win_condition:
|
| 128 |
+
all_of:
|
| 129 |
+
- {reach_region: {x: 122, y: 20, radius: 6}}
|
| 130 |
+
- {units_lost_lte: 0}
|
| 131 |
+
- {within_ticks: 4800}
|
| 132 |
+
max_turns: 40
|
openra_bench/scenarios/schema.py
ADDED
|
@@ -0,0 +1,130 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Scenario-pack schema: one YAML -> three difficulty levels.
|
| 2 |
+
|
| 3 |
+
A pack composes (not forks) the OpenRA-RL-Training `ScenarioDefinition`.
|
| 4 |
+
`base` holds the shared engine fields; each level supplies a deep-merge
|
| 5 |
+
`overrides` patch plus its own `win_condition` / `fail_condition`. This
|
| 6 |
+
keeps a three-level scenario in a single readable file and guarantees
|
| 7 |
+
every level stays a valid engine scenario.
|
| 8 |
+
"""
|
| 9 |
+
|
| 10 |
+
from __future__ import annotations
|
| 11 |
+
|
| 12 |
+
import copy
|
| 13 |
+
from typing import Any, Literal
|
| 14 |
+
|
| 15 |
+
from openra_rl_training.scenario import ScenarioDefinition
|
| 16 |
+
from pydantic import BaseModel, Field, field_validator
|
| 17 |
+
|
| 18 |
+
from .win_conditions import WinCondition
|
| 19 |
+
|
| 20 |
+
LevelName = Literal["easy", "medium", "hard"]
|
| 21 |
+
Capability = Literal["perception", "reasoning", "action"]
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
def deep_merge(base: dict, patch: dict) -> dict:
|
| 25 |
+
"""Recursive dict merge; lists/scalars in `patch` replace wholesale.
|
| 26 |
+
|
| 27 |
+
Replacing (not concatenating) lists is deliberate: a level that
|
| 28 |
+
customises `actors` states the full actor list, so diffs stay
|
| 29 |
+
auditable in review.
|
| 30 |
+
"""
|
| 31 |
+
out = copy.deepcopy(base)
|
| 32 |
+
for k, v in (patch or {}).items():
|
| 33 |
+
if isinstance(v, dict) and isinstance(out.get(k), dict):
|
| 34 |
+
out[k] = deep_merge(out[k], v)
|
| 35 |
+
else:
|
| 36 |
+
out[k] = copy.deepcopy(v)
|
| 37 |
+
return out
|
| 38 |
+
|
| 39 |
+
|
| 40 |
+
class ScenarioMeta(BaseModel):
|
| 41 |
+
"""Why this scenario exists — required so the library stays meaningful."""
|
| 42 |
+
|
| 43 |
+
id: str = Field(..., description="Unique slug, e.g. partial-info-rescue")
|
| 44 |
+
title: str
|
| 45 |
+
capability: Capability = Field(
|
| 46 |
+
..., description="Primary P/R/A chain link this scenario stresses"
|
| 47 |
+
)
|
| 48 |
+
real_world_meaning: str = Field(
|
| 49 |
+
..., min_length=20, description="The real decision this abstracts"
|
| 50 |
+
)
|
| 51 |
+
robotics_analogue: str = Field(
|
| 52 |
+
..., min_length=10, description="Concrete robotics/agentic parallel"
|
| 53 |
+
)
|
| 54 |
+
author: str = "unknown"
|
| 55 |
+
|
| 56 |
+
@field_validator("id")
|
| 57 |
+
@classmethod
|
| 58 |
+
def _slug(cls, v: str) -> str:
|
| 59 |
+
if not v.replace("-", "").isalnum() or v != v.lower():
|
| 60 |
+
raise ValueError(f"id must be lowercase kebab-case slug, got {v!r}")
|
| 61 |
+
return v
|
| 62 |
+
|
| 63 |
+
|
| 64 |
+
class Level(BaseModel):
|
| 65 |
+
description: str = Field(..., min_length=10)
|
| 66 |
+
overrides: dict[str, Any] = Field(
|
| 67 |
+
default_factory=dict, description="Deep-merge patch onto pack.base"
|
| 68 |
+
)
|
| 69 |
+
win_condition: WinCondition
|
| 70 |
+
fail_condition: WinCondition | None = None
|
| 71 |
+
max_turns: int = Field(default=40, ge=1, le=400)
|
| 72 |
+
|
| 73 |
+
|
| 74 |
+
class CompiledLevel(BaseModel):
|
| 75 |
+
"""A single runnable level: validated engine scenario + conditions."""
|
| 76 |
+
|
| 77 |
+
model_config = {"arbitrary_types_allowed": True}
|
| 78 |
+
|
| 79 |
+
pack_id: str
|
| 80 |
+
level: LevelName
|
| 81 |
+
scenario: ScenarioDefinition
|
| 82 |
+
win_condition: WinCondition
|
| 83 |
+
fail_condition: WinCondition | None
|
| 84 |
+
max_turns: int
|
| 85 |
+
meta: ScenarioMeta
|
| 86 |
+
map_supported: bool = Field(
|
| 87 |
+
..., description="False => Rust lacks this map (Phase 3 gate)"
|
| 88 |
+
)
|
| 89 |
+
|
| 90 |
+
|
| 91 |
+
class ScenarioPack(BaseModel):
|
| 92 |
+
"""The contributor-authored unit. One file = one decision problem."""
|
| 93 |
+
|
| 94 |
+
meta: ScenarioMeta
|
| 95 |
+
base_map: str = Field(
|
| 96 |
+
default="rush-hour-arena",
|
| 97 |
+
description="Logical map id; loader maps to a Rust-supported map",
|
| 98 |
+
)
|
| 99 |
+
base: dict[str, Any] = Field(
|
| 100 |
+
..., description="Shared ScenarioDefinition fields (actors, factions, tools…)"
|
| 101 |
+
)
|
| 102 |
+
levels: dict[LevelName, Level]
|
| 103 |
+
|
| 104 |
+
@field_validator("levels")
|
| 105 |
+
@classmethod
|
| 106 |
+
def _all_three(cls, v: dict) -> dict:
|
| 107 |
+
missing = {"easy", "medium", "hard"} - set(v)
|
| 108 |
+
if missing:
|
| 109 |
+
raise ValueError(f"pack must define all levels; missing {sorted(missing)}")
|
| 110 |
+
return v
|
| 111 |
+
|
| 112 |
+
def compile(self, level: LevelName, *, map_supported: bool = True) -> CompiledLevel:
|
| 113 |
+
lvl = self.levels[level]
|
| 114 |
+
merged = deep_merge(self.base, lvl.overrides)
|
| 115 |
+
merged.setdefault("name", f"{self.meta.title} [{level}]")
|
| 116 |
+
merged.setdefault("description", lvl.description)
|
| 117 |
+
merged.setdefault("base_map", self.base_map)
|
| 118 |
+
# Validate against the real engine model so a broken level fails
|
| 119 |
+
# at load time, not mid-eval.
|
| 120 |
+
scenario = ScenarioDefinition(**merged)
|
| 121 |
+
return CompiledLevel(
|
| 122 |
+
pack_id=self.meta.id,
|
| 123 |
+
level=level,
|
| 124 |
+
scenario=scenario,
|
| 125 |
+
win_condition=lvl.win_condition,
|
| 126 |
+
fail_condition=lvl.fail_condition,
|
| 127 |
+
max_turns=lvl.max_turns,
|
| 128 |
+
meta=self.meta,
|
| 129 |
+
map_supported=map_supported,
|
| 130 |
+
)
|
openra_bench/scenarios/validate.py
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""`python -m openra_bench.scenarios.validate [pack.yaml | dir]`
|
| 2 |
+
|
| 3 |
+
Validates pack schema, all three levels' engine compilation, and
|
| 4 |
+
win/fail-condition grammar. Exits non-zero on the first failure so it
|
| 5 |
+
can gate CI / PRs.
|
| 6 |
+
"""
|
| 7 |
+
|
| 8 |
+
from __future__ import annotations
|
| 9 |
+
|
| 10 |
+
import sys
|
| 11 |
+
from pathlib import Path
|
| 12 |
+
|
| 13 |
+
from .loader import PACKS_DIR, compile_level, is_map_supported, load_pack
|
| 14 |
+
|
| 15 |
+
|
| 16 |
+
def _validate_one(path: Path) -> list[str]:
|
| 17 |
+
errs: list[str] = []
|
| 18 |
+
try:
|
| 19 |
+
pack = load_pack(path)
|
| 20 |
+
except Exception as e: # noqa: BLE001
|
| 21 |
+
return [str(e)]
|
| 22 |
+
for level in ("easy", "medium", "hard"):
|
| 23 |
+
try:
|
| 24 |
+
compile_level(pack, level) # constructs ScenarioDefinition + WinCondition
|
| 25 |
+
except Exception as e: # noqa: BLE001
|
| 26 |
+
errs.append(f"[{pack.meta.id}:{level}] {e}")
|
| 27 |
+
if not is_map_supported(pack.base_map):
|
| 28 |
+
errs.append(
|
| 29 |
+
f"[{pack.meta.id}] base_map {pack.base_map!r} not Rust-loadable yet "
|
| 30 |
+
f"(schema-valid; runner will skip until Phase 3)"
|
| 31 |
+
)
|
| 32 |
+
return errs
|
| 33 |
+
|
| 34 |
+
|
| 35 |
+
def main(argv: list[str]) -> int:
|
| 36 |
+
target = Path(argv[1]) if len(argv) > 1 else PACKS_DIR
|
| 37 |
+
files = (
|
| 38 |
+
[target]
|
| 39 |
+
if target.is_file()
|
| 40 |
+
else [p for p in sorted(target.glob("*.yaml")) if not p.name.startswith(("_", "TEMPLATE"))]
|
| 41 |
+
)
|
| 42 |
+
if not files:
|
| 43 |
+
print(f"no pack files found at {target}")
|
| 44 |
+
return 1
|
| 45 |
+
failed = False
|
| 46 |
+
for f in files:
|
| 47 |
+
errs = _validate_one(f)
|
| 48 |
+
warns = [e for e in errs if "not Rust-loadable" in e]
|
| 49 |
+
hard = [e for e in errs if e not in warns]
|
| 50 |
+
status = "FAIL" if hard else ("WARN" if warns else "OK")
|
| 51 |
+
print(f"{status:4} {f.name}")
|
| 52 |
+
for e in hard:
|
| 53 |
+
print(f" ✗ {e}")
|
| 54 |
+
for w in warns:
|
| 55 |
+
print(f" ! {w}")
|
| 56 |
+
failed |= bool(hard)
|
| 57 |
+
return 1 if failed else 0
|
| 58 |
+
|
| 59 |
+
|
| 60 |
+
if __name__ == "__main__":
|
| 61 |
+
sys.exit(main(sys.argv))
|
openra_bench/scenarios/win_conditions.py
ADDED
|
@@ -0,0 +1,114 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Declarative, composable win conditions.
|
| 2 |
+
|
| 3 |
+
Contributors express the "custom bot win con" entirely in YAML — no
|
| 4 |
+
Python. A condition is a tree of composites (`all_of` / `any_of` / `not`)
|
| 5 |
+
over leaf predicates evaluated against a `WinContext` (the per-turn
|
| 6 |
+
adapter signals plus the rendered state). The same tree also expresses
|
| 7 |
+
*failure* conditions, so a scenario can be lost as well as won.
|
| 8 |
+
|
| 9 |
+
Leaf predicates (key: value):
|
| 10 |
+
explored_pct_gte: float map % revealed >= value
|
| 11 |
+
enemies_discovered_gte: int distinct enemy units seen >= value
|
| 12 |
+
buildings_discovered_gte: int distinct enemy buildings seen >= value
|
| 13 |
+
units_killed_gte: int agent kill count >= value
|
| 14 |
+
units_lost_lte: int agent units lost <= value (constraint)
|
| 15 |
+
within_ticks: int current game tick <= value (deadline)
|
| 16 |
+
after_ticks: int current game tick >= value
|
| 17 |
+
reach_region: {x,y,radius} any agent unit within radius of (x,y)
|
| 18 |
+
all_units_in_region: {x,y,radius} every agent unit within radius
|
| 19 |
+
|
| 20 |
+
Adding a predicate = one entry in `_PREDICATES`. Keep them pure.
|
| 21 |
+
"""
|
| 22 |
+
|
| 23 |
+
from __future__ import annotations
|
| 24 |
+
|
| 25 |
+
from dataclasses import dataclass
|
| 26 |
+
from typing import Any, Callable
|
| 27 |
+
|
| 28 |
+
from pydantic import BaseModel, model_validator
|
| 29 |
+
|
| 30 |
+
|
| 31 |
+
@dataclass
|
| 32 |
+
class WinContext:
|
| 33 |
+
"""Everything a predicate may read. Pure data, no engine handles."""
|
| 34 |
+
|
| 35 |
+
signals: Any # rust_adapter.EpisodeSignals
|
| 36 |
+
render_state: dict # rust_adapter.RustObsAdapter.render_state()
|
| 37 |
+
|
| 38 |
+
|
| 39 |
+
def _agent_units(ctx: WinContext) -> list[dict]:
|
| 40 |
+
return ctx.render_state.get("units_summary", []) or []
|
| 41 |
+
|
| 42 |
+
|
| 43 |
+
def _in_radius(units: list[dict], x: int, y: int, r: float) -> int:
|
| 44 |
+
return sum(1 for u in units if (u["cell_x"] - x) ** 2 + (u["cell_y"] - y) ** 2 <= r * r)
|
| 45 |
+
|
| 46 |
+
|
| 47 |
+
# Each predicate: (ctx, value) -> bool. Pure and side-effect free.
|
| 48 |
+
_PREDICATES: dict[str, Callable[[WinContext, Any], bool]] = {
|
| 49 |
+
"explored_pct_gte": lambda c, v: c.signals.explored_percent >= float(v),
|
| 50 |
+
"enemies_discovered_gte": lambda c, v: len(c.signals.enemies_seen_ids) >= int(v),
|
| 51 |
+
"buildings_discovered_gte": lambda c, v: len(c.signals.enemy_buildings_seen_ids)
|
| 52 |
+
>= int(v),
|
| 53 |
+
"units_killed_gte": lambda c, v: c.signals.units_killed >= int(v),
|
| 54 |
+
"units_lost_lte": lambda c, v: c.signals.units_lost <= int(v),
|
| 55 |
+
"within_ticks": lambda c, v: c.signals.game_tick <= int(v),
|
| 56 |
+
"after_ticks": lambda c, v: c.signals.game_tick >= int(v),
|
| 57 |
+
"reach_region": lambda c, v: _in_radius(
|
| 58 |
+
_agent_units(c), int(v["x"]), int(v["y"]), float(v.get("radius", 3))
|
| 59 |
+
)
|
| 60 |
+
>= 1,
|
| 61 |
+
"all_units_in_region": lambda c, v: len(_agent_units(c)) > 0
|
| 62 |
+
and _in_radius(_agent_units(c), int(v["x"]), int(v["y"]), float(v.get("radius", 3)))
|
| 63 |
+
== len(_agent_units(c)),
|
| 64 |
+
}
|
| 65 |
+
|
| 66 |
+
LEAF_KEYS = frozenset(_PREDICATES)
|
| 67 |
+
COMPOSITE_KEYS = frozenset({"all_of", "any_of", "not"})
|
| 68 |
+
|
| 69 |
+
|
| 70 |
+
class WinCondition(BaseModel):
|
| 71 |
+
"""One node: exactly one composite OR one-or-more leaf predicates.
|
| 72 |
+
|
| 73 |
+
Leaf form (implicit AND over keys):
|
| 74 |
+
{explored_pct_gte: 60, within_ticks: 6000}
|
| 75 |
+
Composite form:
|
| 76 |
+
{any_of: [{...}, {...}]} {not: {...}}
|
| 77 |
+
"""
|
| 78 |
+
|
| 79 |
+
model_config = {"extra": "allow"}
|
| 80 |
+
|
| 81 |
+
@model_validator(mode="after")
|
| 82 |
+
def _check_keys(self) -> "WinCondition":
|
| 83 |
+
keys = set(self.__pydantic_extra__ or {})
|
| 84 |
+
if not keys:
|
| 85 |
+
raise ValueError("win_condition node is empty")
|
| 86 |
+
unknown = keys - LEAF_KEYS - COMPOSITE_KEYS
|
| 87 |
+
if unknown:
|
| 88 |
+
raise ValueError(
|
| 89 |
+
f"unknown win-condition keys {sorted(unknown)}; "
|
| 90 |
+
f"valid leaves={sorted(LEAF_KEYS)} composites={sorted(COMPOSITE_KEYS)}"
|
| 91 |
+
)
|
| 92 |
+
if keys & COMPOSITE_KEYS and keys & LEAF_KEYS:
|
| 93 |
+
raise ValueError("cannot mix composite and leaf keys in one node")
|
| 94 |
+
if len(keys & COMPOSITE_KEYS) > 1:
|
| 95 |
+
raise ValueError("at most one composite key per node")
|
| 96 |
+
return self
|
| 97 |
+
|
| 98 |
+
def evaluate(self, ctx: WinContext) -> bool:
|
| 99 |
+
node = dict(self.__pydantic_extra__ or {})
|
| 100 |
+
if "all_of" in node:
|
| 101 |
+
return all(WinCondition(**c).evaluate(ctx) for c in node["all_of"])
|
| 102 |
+
if "any_of" in node:
|
| 103 |
+
return any(WinCondition(**c).evaluate(ctx) for c in node["any_of"])
|
| 104 |
+
if "not" in node:
|
| 105 |
+
return not WinCondition(**node["not"]).evaluate(ctx)
|
| 106 |
+
return all(_PREDICATES[k](ctx, v) for k, v in node.items())
|
| 107 |
+
|
| 108 |
+
|
| 109 |
+
def evaluate(cond: WinCondition | dict | None, ctx: WinContext) -> bool:
|
| 110 |
+
if cond is None:
|
| 111 |
+
return False
|
| 112 |
+
if isinstance(cond, dict):
|
| 113 |
+
cond = WinCondition(**cond)
|
| 114 |
+
return cond.evaluate(ctx)
|
tests/test_rust_integration.py
ADDED
|
@@ -0,0 +1,344 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Integration tests that boot the real Rust OpenRA engine.
|
| 2 |
+
|
| 3 |
+
These are *not* mocked: every test in `TestRustEngineTools` instantiates
|
| 4 |
+
`openra_train.OpenRAEnv` and drives it with rule-based bot players, then
|
| 5 |
+
asserts on observed engine behaviour (tool correctness + corner cases).
|
| 6 |
+
`TestStackIntegration` exercises the Bench stack (adapter, win
|
| 7 |
+
conditions, scenario packs, eval_core) on top of the live engine.
|
| 8 |
+
|
| 9 |
+
Behaviour pinned here was first probed against the engine, not assumed:
|
| 10 |
+
* move_units drives a unit to (and reaching) the target cell
|
| 11 |
+
* same (scenario, seed) is bit-for-bit deterministic
|
| 12 |
+
* bad unit ids warn ("not owned"), don't raise
|
| 13 |
+
* empty / invalid commands are safe no-ops
|
| 14 |
+
* explored% and units_killed are monotonic non-decreasing
|
| 15 |
+
|
| 16 |
+
Run: pytest tests/test_rust_integration.py -v
|
| 17 |
+
"""
|
| 18 |
+
|
| 19 |
+
from __future__ import annotations
|
| 20 |
+
|
| 21 |
+
from pathlib import Path
|
| 22 |
+
|
| 23 |
+
import pytest
|
| 24 |
+
|
| 25 |
+
ot = pytest.importorskip("openra_train", reason="Rust env wheel not installed")
|
| 26 |
+
|
| 27 |
+
TRAIN = Path("/Users/berta/Projects/OpenRA-RL-Training")
|
| 28 |
+
RUSH_HOUR = str(TRAIN / "scenarios" / "discovery" / "rush-hour.yaml")
|
| 29 |
+
|
| 30 |
+
pytestmark = pytest.mark.skipif(
|
| 31 |
+
not Path(RUSH_HOUR).exists(), reason="OpenRA-RL-Training scenarios not present"
|
| 32 |
+
)
|
| 33 |
+
|
| 34 |
+
|
| 35 |
+
# --------------------------------------------------------------------------- #
|
| 36 |
+
# Rule-based bot players #
|
| 37 |
+
# --------------------------------------------------------------------------- #
|
| 38 |
+
def idle_bot(_obs):
|
| 39 |
+
"""No-op: only `observe()`. Used to test determinism / no side effects."""
|
| 40 |
+
return [ot.Command.observe()]
|
| 41 |
+
|
| 42 |
+
|
| 43 |
+
def charge_bot(target):
|
| 44 |
+
"""Move every owned unit toward a fixed cell. Tests move_units."""
|
| 45 |
+
|
| 46 |
+
def _bot(obs):
|
| 47 |
+
ids = list(obs.get("unit_positions", {}))
|
| 48 |
+
if not ids:
|
| 49 |
+
return [ot.Command.observe()]
|
| 50 |
+
return [ot.Command.move_units(ids, target[0], target[1])]
|
| 51 |
+
|
| 52 |
+
return _bot
|
| 53 |
+
|
| 54 |
+
|
| 55 |
+
def hunter_bot(obs):
|
| 56 |
+
"""Attack the first visible enemy; otherwise push east. Tests attack_unit
|
| 57 |
+
and the discover→engage path."""
|
| 58 |
+
ids = list(obs.get("unit_positions", {}))
|
| 59 |
+
if not ids:
|
| 60 |
+
return [ot.Command.observe()]
|
| 61 |
+
enemies = obs.get("enemy_positions", []) or []
|
| 62 |
+
if enemies:
|
| 63 |
+
tid = str(enemies[0].get("id"))
|
| 64 |
+
return [ot.Command.attack_unit(ids, tid)]
|
| 65 |
+
return [ot.Command.move_units(ids, 120, 20)]
|
| 66 |
+
|
| 67 |
+
|
| 68 |
+
def _first_unit(obs):
|
| 69 |
+
up = obs["unit_positions"]
|
| 70 |
+
k = sorted(up)[0]
|
| 71 |
+
return k, (up[k]["cell_x"], up[k]["cell_y"])
|
| 72 |
+
|
| 73 |
+
|
| 74 |
+
def _man(a, b):
|
| 75 |
+
return abs(a[0] - b[0]) + abs(a[1] - b[1])
|
| 76 |
+
|
| 77 |
+
|
| 78 |
+
def _run(env, bot, steps):
|
| 79 |
+
"""Drive `env` with `bot` for `steps`, yielding (obs, reward, done, info)."""
|
| 80 |
+
obs = env.reset()
|
| 81 |
+
out = []
|
| 82 |
+
for _ in range(steps):
|
| 83 |
+
o, r, d, i = env.step(bot(obs))
|
| 84 |
+
out.append((o, r, d, i))
|
| 85 |
+
obs = o
|
| 86 |
+
if d:
|
| 87 |
+
break
|
| 88 |
+
return out
|
| 89 |
+
|
| 90 |
+
|
| 91 |
+
# --------------------------------------------------------------------------- #
|
| 92 |
+
# Engine + tool correctness (live Rust env) #
|
| 93 |
+
# --------------------------------------------------------------------------- #
|
| 94 |
+
class TestRustEngineTools:
|
| 95 |
+
def test_reset_schema_and_initial_state(self):
|
| 96 |
+
obs = ot.OpenRAEnv(RUSH_HOUR, 7).reset()
|
| 97 |
+
for key in (
|
| 98 |
+
"unit_positions",
|
| 99 |
+
"unit_hp",
|
| 100 |
+
"enemy_positions",
|
| 101 |
+
"explored_percent",
|
| 102 |
+
"game_tick",
|
| 103 |
+
"units_killed",
|
| 104 |
+
):
|
| 105 |
+
assert key in obs, f"missing obs key {key!r}"
|
| 106 |
+
assert obs["unit_positions"], "agent should own units at reset"
|
| 107 |
+
assert obs["explored_percent"] == pytest.approx(0.0, abs=1e-6)
|
| 108 |
+
assert obs["units_killed"] == 0
|
| 109 |
+
assert obs["game_tick"] < 50
|
| 110 |
+
|
| 111 |
+
def test_same_seed_is_deterministic(self):
|
| 112 |
+
a, b = ot.OpenRAEnv(RUSH_HOUR, 11), ot.OpenRAEnv(RUSH_HOUR, 11)
|
| 113 |
+
oa, ob = a.reset(), b.reset()
|
| 114 |
+
assert oa["unit_positions"] == ob["unit_positions"]
|
| 115 |
+
for _ in range(6):
|
| 116 |
+
oa, *_ = a.step([ot.Command.observe()])
|
| 117 |
+
ob, *_ = b.step([ot.Command.observe()])
|
| 118 |
+
assert oa["unit_positions"] == ob["unit_positions"]
|
| 119 |
+
assert oa["game_tick"] == ob["game_tick"]
|
| 120 |
+
|
| 121 |
+
def test_move_units_drives_unit_to_target(self):
|
| 122 |
+
env = ot.OpenRAEnv(RUSH_HOUR, 7)
|
| 123 |
+
obs = env.reset()
|
| 124 |
+
uid, start = _first_unit(obs)
|
| 125 |
+
target = (start[0] + 25, start[1])
|
| 126 |
+
last = start
|
| 127 |
+
for _ in range(15):
|
| 128 |
+
obs, *_ = env.step([ot.Command.move_units([uid], target[0], target[1])])
|
| 129 |
+
last = (obs["unit_positions"][uid]["cell_x"], obs["unit_positions"][uid]["cell_y"])
|
| 130 |
+
assert _man(last, target) < _man(start, target), "unit did not move toward target"
|
| 131 |
+
assert _man(last, target) <= 1, f"unit {uid} did not reach {target}, at {last}"
|
| 132 |
+
|
| 133 |
+
def test_idle_units_do_not_move(self):
|
| 134 |
+
"""A corner agent unit with no order must hold position (no
|
| 135 |
+
spontaneous teleport / drift)."""
|
| 136 |
+
env = ot.OpenRAEnv(RUSH_HOUR, 7)
|
| 137 |
+
obs = env.reset()
|
| 138 |
+
uid, start = _first_unit(obs) # spawn-corner unit, far from enemies
|
| 139 |
+
for _ in range(4):
|
| 140 |
+
obs, *_ = env.step([ot.Command.observe()])
|
| 141 |
+
pos = (obs["unit_positions"][uid]["cell_x"], obs["unit_positions"][uid]["cell_y"])
|
| 142 |
+
assert pos == start, f"idle unit drifted {start} -> {pos}"
|
| 143 |
+
|
| 144 |
+
def test_empty_command_list_is_safe(self):
|
| 145 |
+
env = ot.OpenRAEnv(RUSH_HOUR, 7)
|
| 146 |
+
env.reset()
|
| 147 |
+
obs, reward, done, info = env.step([])
|
| 148 |
+
assert isinstance(done, bool) and done is False
|
| 149 |
+
assert isinstance(reward, float)
|
| 150 |
+
assert obs["game_tick"] > 0
|
| 151 |
+
|
| 152 |
+
def test_invalid_unit_id_warns_not_raises(self):
|
| 153 |
+
env = ot.OpenRAEnv(RUSH_HOUR, 7)
|
| 154 |
+
env.reset()
|
| 155 |
+
_o, _r, _d, info = env.step([ot.Command.move_units(["999999"], 10, 10)])
|
| 156 |
+
warns = info.get("warnings", [])
|
| 157 |
+
assert any("999999" in w and "not owned" in w for w in warns), warns
|
| 158 |
+
|
| 159 |
+
def test_invalid_attack_target_is_safe(self):
|
| 160 |
+
env = ot.OpenRAEnv(RUSH_HOUR, 7)
|
| 161 |
+
obs = env.reset()
|
| 162 |
+
uid, _ = _first_unit(obs)
|
| 163 |
+
obs, _r, done, _i = env.step([ot.Command.attack_unit([uid], "888888")])
|
| 164 |
+
assert done is False
|
| 165 |
+
assert obs["unit_positions"], "engine state intact after bad attack target"
|
| 166 |
+
|
| 167 |
+
def test_out_of_bounds_move_is_safe(self):
|
| 168 |
+
env = ot.OpenRAEnv(RUSH_HOUR, 7)
|
| 169 |
+
obs = env.reset()
|
| 170 |
+
uid, _ = _first_unit(obs)
|
| 171 |
+
for _ in range(5):
|
| 172 |
+
obs, _r, done, _i = env.step([ot.Command.move_units([uid], 99999, 99999)])
|
| 173 |
+
assert done is False
|
| 174 |
+
p = obs["unit_positions"][uid]
|
| 175 |
+
assert 0 <= p["cell_x"] < 1000 and 0 <= p["cell_y"] < 1000
|
| 176 |
+
|
| 177 |
+
def test_explored_percent_monotonic_and_grows(self):
|
| 178 |
+
env = ot.OpenRAEnv(RUSH_HOUR, 7)
|
| 179 |
+
prev = -1.0
|
| 180 |
+
seen = []
|
| 181 |
+
for obs, *_ in _run(env, charge_bot((120, 20)), 20):
|
| 182 |
+
assert obs["explored_percent"] >= prev - 1e-6, "explored% decreased"
|
| 183 |
+
prev = obs["explored_percent"]
|
| 184 |
+
seen.append(prev)
|
| 185 |
+
assert seen[-1] > seen[0], "moving across the map revealed no new area"
|
| 186 |
+
|
| 187 |
+
def test_units_killed_monotonic_nondecreasing(self):
|
| 188 |
+
env = ot.OpenRAEnv(RUSH_HOUR, 7)
|
| 189 |
+
prev = 0
|
| 190 |
+
for obs, *_ in _run(env, hunter_bot, 30):
|
| 191 |
+
assert obs["units_killed"] >= prev, "units_killed went backwards"
|
| 192 |
+
prev = obs["units_killed"]
|
| 193 |
+
assert prev >= 0
|
| 194 |
+
|
| 195 |
+
|
| 196 |
+
# --------------------------------------------------------------------------- #
|
| 197 |
+
# Bench stack on the live engine #
|
| 198 |
+
# --------------------------------------------------------------------------- #
|
| 199 |
+
class TestStackIntegration:
|
| 200 |
+
def test_adapter_signals_track_engine(self):
|
| 201 |
+
from openra_bench.rust_adapter import RustObsAdapter
|
| 202 |
+
|
| 203 |
+
env = ot.OpenRAEnv(RUSH_HOUR, 7)
|
| 204 |
+
ad = RustObsAdapter()
|
| 205 |
+
ad.observe(env.reset())
|
| 206 |
+
bot = charge_bot((120, 20))
|
| 207 |
+
prev_seen = 0
|
| 208 |
+
for _ in range(20):
|
| 209 |
+
o, _r, d, _i = env.step(bot({"unit_positions": ad._raw.get("unit_positions", {})}))
|
| 210 |
+
ad.observe(o, done=d)
|
| 211 |
+
# discovery set is cumulative — never shrinks
|
| 212 |
+
assert len(ad.signals.enemies_seen_ids) >= prev_seen
|
| 213 |
+
prev_seen = len(ad.signals.enemies_seen_ids)
|
| 214 |
+
kw = ad.signals.as_reward_kwargs()
|
| 215 |
+
assert set(kw) >= {
|
| 216 |
+
"units_killed",
|
| 217 |
+
"units_lost",
|
| 218 |
+
"explored_percent",
|
| 219 |
+
"enemies_discovered",
|
| 220 |
+
"outcome",
|
| 221 |
+
"game_tick",
|
| 222 |
+
"done",
|
| 223 |
+
}
|
| 224 |
+
assert kw["explored_percent"] > 0.0
|
| 225 |
+
assert kw["units_lost"] >= 0
|
| 226 |
+
|
| 227 |
+
def test_win_condition_predicates_pure(self):
|
| 228 |
+
from openra_bench.rust_adapter import EpisodeSignals
|
| 229 |
+
from openra_bench.scenarios.win_conditions import WinContext, evaluate
|
| 230 |
+
|
| 231 |
+
sig = EpisodeSignals(explored_percent=60.0, units_killed=3, game_tick=4000)
|
| 232 |
+
sig.enemies_seen_ids = {"a", "b"}
|
| 233 |
+
rs = {"units_summary": [{"id": "1", "cell_x": 10, "cell_y": 10, "type": None}]}
|
| 234 |
+
ctx = WinContext(signals=sig, render_state=rs)
|
| 235 |
+
|
| 236 |
+
assert evaluate({"explored_pct_gte": 50}, ctx) is True
|
| 237 |
+
assert evaluate({"explored_pct_gte": 75}, ctx) is False
|
| 238 |
+
assert evaluate({"enemies_discovered_gte": 2}, ctx) is True
|
| 239 |
+
assert evaluate({"within_ticks": 5000}, ctx) is True
|
| 240 |
+
assert evaluate({"within_ticks": 3000}, ctx) is False
|
| 241 |
+
assert evaluate({"reach_region": {"x": 11, "y": 11, "radius": 3}}, ctx) is True
|
| 242 |
+
assert evaluate({"reach_region": {"x": 99, "y": 99, "radius": 2}}, ctx) is False
|
| 243 |
+
# composites
|
| 244 |
+
assert evaluate({"all_of": [{"explored_pct_gte": 50}, {"within_ticks": 5000}]}, ctx)
|
| 245 |
+
assert evaluate({"any_of": [{"explored_pct_gte": 99}, {"units_killed_gte": 3}]}, ctx)
|
| 246 |
+
assert evaluate({"not": {"explored_pct_gte": 99}}, ctx) is True
|
| 247 |
+
|
| 248 |
+
def test_win_condition_rejects_unknown_keys(self):
|
| 249 |
+
from openra_bench.scenarios.win_conditions import WinCondition
|
| 250 |
+
|
| 251 |
+
with pytest.raises(ValueError):
|
| 252 |
+
WinCondition(definitely_not_a_predicate=1)
|
| 253 |
+
with pytest.raises(ValueError):
|
| 254 |
+
WinCondition(all_of=[{"explored_pct_gte": 1}], explored_pct_gte=2)
|
| 255 |
+
|
| 256 |
+
def test_eval_core_win_and_loss_wiring_deterministic(self):
|
| 257 |
+
"""Pin the win/fail plumbing without depending on bot skill:
|
| 258 |
+
a trivially-true win condition => 'win' on turn 1; a trivially-
|
| 259 |
+
true fail condition => 'loss'."""
|
| 260 |
+
from openra_bench.eval_core import run_level
|
| 261 |
+
from openra_bench.scenarios.schema import ScenarioPack
|
| 262 |
+
|
| 263 |
+
base = {
|
| 264 |
+
"agent": {"faction": "allies"},
|
| 265 |
+
"enemy": {"faction": "soviet"},
|
| 266 |
+
"tools": ["move_units", "attack_unit"],
|
| 267 |
+
"actors": [
|
| 268 |
+
{"type": "jeep", "owner": "agent", "position": [5, 5], "count": 2},
|
| 269 |
+
{"type": "e1", "owner": "enemy", "position": [60, 20], "stance": 2},
|
| 270 |
+
],
|
| 271 |
+
"termination": {"max_ticks": 8000},
|
| 272 |
+
}
|
| 273 |
+
meta = {
|
| 274 |
+
"id": "selftest-wiring",
|
| 275 |
+
"title": "Self Test",
|
| 276 |
+
"capability": "action",
|
| 277 |
+
"real_world_meaning": "deterministic plumbing check for the runner",
|
| 278 |
+
"robotics_analogue": "unit-test harness",
|
| 279 |
+
"author": "ci",
|
| 280 |
+
}
|
| 281 |
+
|
| 282 |
+
def pack(win, fail=None):
|
| 283 |
+
lvl = {
|
| 284 |
+
"description": "wiring check level",
|
| 285 |
+
"overrides": {},
|
| 286 |
+
"win_condition": win,
|
| 287 |
+
"max_turns": 3,
|
| 288 |
+
}
|
| 289 |
+
if fail:
|
| 290 |
+
lvl["fail_condition"] = fail
|
| 291 |
+
return ScenarioPack(
|
| 292 |
+
meta=meta, base_map="rush-hour-arena", base=base,
|
| 293 |
+
levels={"easy": lvl, "medium": lvl, "hard": lvl},
|
| 294 |
+
)
|
| 295 |
+
|
| 296 |
+
win_pack = pack({"after_ticks": 0}) # always true once ticks >= 0
|
| 297 |
+
res = run_level(win_pack.compile("easy"), bot_to_agent(idle_bot), seed=3)
|
| 298 |
+
assert res.outcome == "win"
|
| 299 |
+
assert res.signals.outcome == 1.0
|
| 300 |
+
assert res.turns == 1
|
| 301 |
+
assert len(res.trace) == res.turns
|
| 302 |
+
|
| 303 |
+
loss_pack = pack({"explored_pct_gte": 999}, fail={"after_ticks": 0})
|
| 304 |
+
res2 = run_level(loss_pack.compile("easy"), bot_to_agent(idle_bot), seed=3)
|
| 305 |
+
assert res2.outcome == "loss"
|
| 306 |
+
assert res2.signals.outcome == 0.0
|
| 307 |
+
|
| 308 |
+
@pytest.mark.parametrize(
|
| 309 |
+
"pack_file,level",
|
| 310 |
+
[
|
| 311 |
+
("perception-frontier-reading.yaml", "easy"),
|
| 312 |
+
("reasoning-frontier-commit.yaml", "easy"),
|
| 313 |
+
("action-multiunit-coordination.yaml", "easy"),
|
| 314 |
+
],
|
| 315 |
+
)
|
| 316 |
+
def test_authored_packs_run_end_to_end(self, pack_file, level):
|
| 317 |
+
from openra_bench.eval_core import run_level, scripted_explore_agent
|
| 318 |
+
from openra_bench.scenarios import load_pack
|
| 319 |
+
from openra_bench.scenarios.loader import PACKS_DIR, compile_level
|
| 320 |
+
|
| 321 |
+
pack = load_pack(PACKS_DIR / pack_file)
|
| 322 |
+
compiled = compile_level(pack, level)
|
| 323 |
+
assert compiled.map_supported, "authored packs must target a Rust-loadable map"
|
| 324 |
+
res = run_level(compiled, scripted_explore_agent, seed=1)
|
| 325 |
+
assert res.outcome in {"win", "draw", "loss"}
|
| 326 |
+
assert 1 <= res.turns <= compiled.max_turns
|
| 327 |
+
assert len(res.trace) == res.turns
|
| 328 |
+
assert res.signals.game_tick > 0
|
| 329 |
+
assert res.signals.as_reward_kwargs()["outcome"] in (0.0, 0.5, 1.0)
|
| 330 |
+
|
| 331 |
+
|
| 332 |
+
def bot_to_agent(bot):
|
| 333 |
+
"""Adapt an obs-only rule bot to the eval_core agent_fn signature
|
| 334 |
+
`(render_state, Command) -> [Command]`. The render_state carries
|
| 335 |
+
unit ids the bot needs under units_summary."""
|
| 336 |
+
|
| 337 |
+
def _agent(render_state, Command):
|
| 338 |
+
up = {
|
| 339 |
+
str(u["id"]): {"cell_x": u["cell_x"], "cell_y": u["cell_y"]}
|
| 340 |
+
for u in render_state.get("units_summary", [])
|
| 341 |
+
}
|
| 342 |
+
return bot({"unit_positions": up, "enemy_positions": []})
|
| 343 |
+
|
| 344 |
+
return _agent
|