yxc20098 commited on
Commit
098c6e0
·
1 Parent(s): 6f326d5

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 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