Spaces:
Running
Running
| """combat-protect-vip-escort — escort a fragile VIP through hazards. | |
| The bar (binding, applied seeds 1..4 on every level): | |
| - intended escort-clears-path policy WINS; | |
| - stall / VIP-alone / VIP-leads-charge LOSE on every level + every | |
| hard seed; | |
| - non-win is a real reachable timeout / VIP-died LOSS (no DRAW | |
| degeneracy: after_ticks 4501 ≤ 93 + 90·(max_turns − 1)); | |
| - hard ships ≥2 `spawn_point` groups (seed-driven start variation). | |
| The route threat is a `guard` bot interception force built around | |
| the Soviet heavy tank (3tnk): easy/medium carry ONE full-HP 3tnk | |
| on the route axis, hard carries TWO at health:55 — one aligned to | |
| each seed-chosen staging lane — plus e1 rifle filler. The VIP | |
| itself starts fragile at health:70: a harv crossing the hot route | |
| in the column eats live heavy fire (engine move-fire fix) and | |
| dies. The escort must destroy the interceptors AHEAD of the VIP | |
| and hold the VIP back until the route is clear (cold), then sprint | |
| it across. | |
| Validation is scripted (no model / network). | |
| """ | |
| from __future__ import annotations | |
| from pathlib import Path | |
| import pytest | |
| pytest.importorskip("openra_rl_training", reason="Rust env wheel not installed") | |
| from openra_bench.scenarios import load_pack | |
| from openra_bench.scenarios.loader import compile_level | |
| from openra_bench.scenarios.win_conditions import WinContext, evaluate | |
| PACKS = Path(__file__).parent.parent / "openra_bench" / "scenarios" / "packs" | |
| PACK_PATH = PACKS / "combat-protect-vip-escort.yaml" | |
| # ── declarative / schema invariants (no engine needed) ───────────── | |
| def test_pack_compiles_and_meta_fields_populated(): | |
| pack = load_pack(PACK_PATH) | |
| assert pack.meta.id == "combat-protect-vip-escort" | |
| assert pack.meta.capability == "action" | |
| assert pack.meta.real_world_meaning, "real_world_meaning required" | |
| assert pack.meta.robotics_analogue, "robotics_analogue required" | |
| anchors = pack.meta.benchmark_anchor | |
| assert isinstance(anchors, list) and len(anchors) == 4, ( | |
| f"benchmark_anchor must list all 4 anchors, got {anchors!r}" | |
| ) | |
| joined = " ".join(anchors).lower() | |
| # Spec-mandated anchors: SC2 VIP-survival, military VIP doctrine, | |
| # diplomatic convoy escort, fragile-target preservation. | |
| for needle in ("sc2 vip", "vip protection", | |
| "diplomatic", "fragile-target"): | |
| assert needle in joined, f"missing anchor keyword: {needle}" | |
| for lvl in ("easy", "medium", "hard"): | |
| c = compile_level(pack, lvl) | |
| assert c.map_supported | |
| assert c.win_condition is not None | |
| assert c.fail_condition is not None | |
| def _ctx(*, units=(), tick=1000, kills=0, lost=0): | |
| """Synthesize a WinContext for predicate-level checks.""" | |
| import types | |
| sig = types.SimpleNamespace( | |
| game_tick=tick, | |
| units_killed=kills, | |
| units_lost=lost, | |
| cash=0, | |
| resources=0, | |
| own_buildings=[], | |
| own_building_types=set(), | |
| enemies_seen_ids=set(), | |
| enemy_buildings_seen_ids=set(), | |
| ) | |
| return WinContext( | |
| signals=sig, | |
| render_state={"units_summary": list(units)}, | |
| ) | |
| def _harv_at(x=100, y=20): | |
| return {"cell_x": x, "cell_y": y, "type": "harv", "id": "9001"} | |
| def _tank_at(i, x=100, y=20): | |
| return {"cell_x": x, "cell_y": y, "type": "2tnk", "id": str(2000 + i)} | |
| def test_win_requires_harv_in_east_region_and_alive(lvl): | |
| c = compile_level(load_pack(PACK_PATH), lvl) | |
| # VIP in region, alive, in time → WIN. | |
| assert evaluate( | |
| c.win_condition, | |
| _ctx(units=[_harv_at(100, 20), _tank_at(0, 99, 20)], tick=2000), | |
| ) | |
| # Tanks arrive but VIP didn't → not a WIN (VIP missing). | |
| assert not evaluate( | |
| c.win_condition, | |
| _ctx(units=[_tank_at(0, 100, 20), _tank_at(1, 100, 21)], tick=2000), | |
| ) | |
| # VIP elsewhere on the map → not a WIN (not in region). | |
| assert not evaluate( | |
| c.win_condition, | |
| _ctx(units=[_harv_at(50, 20)], tick=2000), | |
| ) | |
| # VIP in region but past the deadline → not a WIN. | |
| assert not evaluate( | |
| c.win_condition, | |
| _ctx(units=[_harv_at(100, 20)], tick=4600), | |
| ) | |
| def test_fail_clauses_are_reachable_losses(lvl): | |
| c = compile_level(load_pack(PACK_PATH), lvl) | |
| # Past deadline → fail (the VIP is still in the west). | |
| assert evaluate( | |
| c.fail_condition, | |
| _ctx(units=[_harv_at(8, 20)], tick=4600), | |
| ) | |
| # VIP killed (no harv left, even if escorts survive) → fail. | |
| assert evaluate( | |
| c.fail_condition, | |
| _ctx(units=[_tank_at(0, 50, 20)], tick=2000), | |
| ) | |
| # Force wipe (no harv among the wipe) → fail. | |
| assert evaluate(c.fail_condition, _ctx(units=[], tick=2000)) | |
| def test_timeout_reachable_inside_max_turns(): | |
| """No draw degeneracy: after_ticks 4501 ≤ 93 + 90·(max_turns-1).""" | |
| pack = load_pack(PACK_PATH) | |
| for lvl in ("easy", "medium", "hard"): | |
| c = compile_level(pack, lvl) | |
| max_tick = 93 + 90 * (c.max_turns - 1) | |
| assert 4501 <= max_tick, ( | |
| f"{lvl}: after_ticks 4501 > max reachable tick {max_tick} " | |
| f"(max_turns={c.max_turns}); deadline never bites" | |
| ) | |
| assert 4500 <= max_tick, ( | |
| f"{lvl}: within_ticks 4500 > max reachable tick {max_tick}" | |
| ) | |
| def test_hard_has_two_spawn_point_groups(): | |
| """Hard curation: ≥2 distinct agent spawn_point groups so the seed | |
| round-robins the staging row (NORTH y≈14 / SOUTH y≈26). Engine- | |
| roundtrip asserted by tests/test_hard_tier.py.""" | |
| c = compile_level(load_pack(PACK_PATH), "hard") | |
| groups = { | |
| (a.spawn_point if a.spawn_point is not None else 0) | |
| for a in c.scenario.actors | |
| if a.owner == "agent" | |
| } | |
| assert len(groups) >= 2, f"hard needs ≥2 spawn_point groups, got {groups}" | |
| def test_exactly_one_harv_per_spawn_group(): | |
| """The VIP is a UNIQUE unit type — exactly one harv per spawn | |
| group (so `unit_type_count_gte:{harv,1}` collapses to a single | |
| "VIP alive" check; multiple harvs would let the win predicate | |
| accept partial loss).""" | |
| pack = load_pack(PACK_PATH) | |
| for lvl in ("easy", "medium"): | |
| c = compile_level(pack, lvl) | |
| harvs = [ | |
| a for a in c.scenario.actors | |
| if a.owner == "agent" and a.type == "harv" | |
| ] | |
| assert len(harvs) == 1, f"{lvl}: must have exactly 1 harv VIP, got {len(harvs)}" | |
| c = compile_level(pack, "hard") | |
| per_group = {} | |
| for a in c.scenario.actors: | |
| if a.owner != "agent" or a.type != "harv": | |
| continue | |
| sp = a.spawn_point if a.spawn_point is not None else 0 | |
| per_group[sp] = per_group.get(sp, 0) + 1 | |
| assert all(n == 1 for n in per_group.values()), ( | |
| f"hard: each spawn group should have exactly 1 harv, got {per_group}" | |
| ) | |
| def test_escort_is_four_medium_tanks_per_group(): | |
| """The escort is 4× 2tnk per spawn group (sized so the escort can | |
| actually clear the route's rifle walls in budget — fewer tanks | |
| and the route doesn't clean in time).""" | |
| pack = load_pack(PACK_PATH) | |
| for lvl in ("easy", "medium"): | |
| c = compile_level(pack, lvl) | |
| tanks = [a for a in c.scenario.actors | |
| if a.owner == "agent" and a.type == "2tnk"] | |
| assert len(tanks) == 4, f"{lvl}: expected 4 escort tanks, got {len(tanks)}" | |
| c = compile_level(pack, "hard") | |
| per_group = {} | |
| for a in c.scenario.actors: | |
| if a.owner != "agent" or a.type != "2tnk": | |
| continue | |
| sp = a.spawn_point if a.spawn_point is not None else 0 | |
| per_group[sp] = per_group.get(sp, 0) + 1 | |
| assert all(n == 4 for n in per_group.values()), ( | |
| f"hard: each spawn group should have 4 escort tanks, got {per_group}" | |
| ) | |
| def test_route_threat_is_a_guard_bot(): | |
| """The route interception force is a `guard` bot — it holds post | |
| but lunges at the nearest foe within GUARD_AGGRO. A static | |
| defender (omitted/Defend stance) would let the heavily-armoured | |
| harv simply sprint past untouched; the mobile guard interceptor | |
| is what runs down an unescorted VIP.""" | |
| pack = load_pack(PACK_PATH) | |
| enemy = ( | |
| pack.base.get("enemy") | |
| if isinstance(pack.base, dict) | |
| else getattr(pack.base, "enemy", {}) | |
| ) or {} | |
| assert enemy.get("bot_type") == "guard", ( | |
| f"enemy must be a `guard` bot interception force; " | |
| f"got bot_type={enemy.get('bot_type')!r}" | |
| ) | |
| def test_guard_heavy_interceptor_count_and_health(): | |
| """The load-bearing anti-VIP threat is the guard Soviet heavy | |
| tank (3tnk). | |
| easy / medium carry exactly ONE full-HP 3tnk on the y=20 route | |
| axis — the harv's lane — so a harv-led column crossing eats its | |
| focus and the 70%-HP VIP dies, while a 4× 2tnk escort can still | |
| focus that one heavy down. | |
| hard carries TWO 3tnk — one aligned to each seed-chosen staging | |
| lane — because post the engine no-teleport attack-chase fix a | |
| single y=20-axis heavy can no longer catch a harv-led column | |
| that funnels off-axis. Both hard heavies are at health:55 so the | |
| 4-tank escort can still clear BOTH within budget; two FULL-HP | |
| heavies would out-trade the escort and break intended-WINS.""" | |
| pack = load_pack(PACK_PATH) | |
| for lvl in ("easy", "medium"): | |
| c = compile_level(pack, lvl) | |
| heavies = [ | |
| a for a in c.scenario.actors | |
| if a.owner == "enemy" and a.type == "3tnk" | |
| ] | |
| assert len(heavies) == 1, ( | |
| f"{lvl}: must have exactly ONE guard 3tnk interceptor; " | |
| f"got {len(heavies)}" | |
| ) | |
| assert heavies[0].health == 100, ( | |
| f"{lvl}: the single route 3tnk is a full-HP heavy; " | |
| f"got health={heavies[0].health}" | |
| ) | |
| c = compile_level(pack, "hard") | |
| heavies = [ | |
| a for a in c.scenario.actors | |
| if a.owner == "enemy" and a.type == "3tnk" | |
| ] | |
| assert len(heavies) == 2, ( | |
| f"hard: must have TWO lane-aligned guard 3tnk interceptors; " | |
| f"got {len(heavies)}" | |
| ) | |
| for h in heavies: | |
| assert h.health == 55, ( | |
| f"hard: each lane 3tnk must be at health:55 so the escort " | |
| f"can clear both in budget; got health={h.health}" | |
| ) | |
| def test_vip_starts_fragile(): | |
| """The VIP starts at health:70 — a genuinely fragile principal. | |
| Post the engine move-fire fix this is the load-bearing lever: a | |
| harv crossing the hot route WITH the column eats live heavy fire | |
| in motion and a 70%-HP harv does not survive it. The intended | |
| escort holds the VIP back so it only ever crosses a cleared cold | |
| route — arriving untouched at 70%, alive.""" | |
| pack = load_pack(PACK_PATH) | |
| for lvl in ("easy", "medium", "hard"): | |
| c = compile_level(pack, lvl) | |
| harvs = [ | |
| a for a in c.scenario.actors | |
| if a.owner == "agent" and a.type == "harv" | |
| ] | |
| assert harvs, f"{lvl}: no VIP harv found" | |
| for h in harvs: | |
| assert h.health == 70, ( | |
| f"{lvl}: the VIP must start fragile at health:70; " | |
| f"got health={h.health}" | |
| ) | |
| def test_interrupts_disabled_so_time_budget_is_real(): | |
| """enemy_unit_spotted via step_until_event advances only ~5 ticks | |
| per interrupt, so on a route with 10+ enemies the per-turn tick | |
| rate collapses and even the intended escort cannot reach the | |
| extraction inside the 4500-tick budget. With interrupts disabled, | |
| each turn advances ~90 ticks uniformly and the budget is real.""" | |
| pack = load_pack(PACK_PATH) | |
| for lvl in ("easy", "medium", "hard"): | |
| c = compile_level(pack, lvl) | |
| ints = c.scenario.interrupts or {} | |
| enabled = [k for k, v in ints.items() if v] | |
| assert not enabled, ( | |
| f"{lvl}: interrupts must be disabled (the route has 10+ " | |
| f"enemies; step_until_event would collapse the tick " | |
| f"budget); got enabled={enabled}" | |
| ) | |
| # ── engine-driven scripted policies ──────────────────────────────── | |
| def _own_units(rs): | |
| return rs.get("units_summary", []) or [] | |
| def _harv(rs): | |
| for u in _own_units(rs): | |
| if str(u.get("type", "")).lower() == "harv": | |
| return u | |
| return None | |
| def _escorts(rs): | |
| return [u for u in _own_units(rs) | |
| if str(u.get("type", "")).lower() == "2tnk"] | |
| def _stall(rs, Command): | |
| """Pure observe — no unit moves; VIP never reaches the | |
| extraction → after_ticks LOSS.""" | |
| return [Command.observe()] | |
| def _vip_alone(rs, Command): | |
| """Only the harvester is moved east; escorts stay idle in the | |
| west. The unarmed VIP enters the route unaccompanied → the | |
| guard heavy tank lunges at it and runs it down → `not | |
| unit_type_count_gte:{harv,1}` fail clause fires → LOSS.""" | |
| units = _own_units(rs) | |
| if not units: | |
| return [Command.observe()] | |
| cmds = [] | |
| for u in units: | |
| if str(u.get("type", "")).lower() == "harv": | |
| cmds.append(Command.move_units([str(u["id"])], 100, 20)) | |
| else: | |
| cmds.append(Command.stop([str(u["id"])])) | |
| return cmds | |
| def _vip_leads_charge(rs, Command): | |
| """Every unit (harv + tanks) charges east on the same y-band | |
| with the harv at the front. The guard interceptor + the e1 | |
| corridor focus the leading harvester and kill it before the | |
| tanks finish clearing the route → LOSS.""" | |
| units = _own_units(rs) | |
| if not units: | |
| return [Command.observe()] | |
| cmds = [] | |
| for u in units: | |
| cmds.append(Command.move_units([str(u["id"])], 100, 20)) | |
| return cmds | |
| def _killable_enemies(rs): | |
| """Live, non-building, non-marker enemies (the interception | |
| force the escort must clear).""" | |
| return [ | |
| e for e in (rs.get("enemy_summary") or []) | |
| if not e.get("is_building") | |
| and str(e.get("type", "")).lower() != "fact" | |
| ] | |
| def _intended_escort_clears_path(rs, Command): | |
| """Escort-clears-path: | |
| 1. The escort tanks attack the interception force AHEAD of | |
| the VIP — each tank focus-fires (attack_unit) its nearest | |
| live interceptor; once the route is clear they attack_move | |
| on to the extraction. | |
| 2. The VIP HOLDS at the staging zone — it does NOT move while | |
| any interceptor is alive OR the escort has not yet reached | |
| the extraction latitude (rear-most tank past x≥82). | |
| 3. Once the route is confirmed clear (no live interceptors) | |
| and the escort has pushed through, the VIP sprints across | |
| to (100,20) uncontested. | |
| """ | |
| units = _own_units(rs) | |
| if not units: | |
| return [Command.observe()] | |
| h = _harv(rs) | |
| tanks = _escorts(rs) | |
| enemies = _killable_enemies(rs) | |
| cmds = [] | |
| # Escort tanks: focus-fire the nearest interceptor; sweep to | |
| # the extraction once nothing is left. | |
| for t in tanks: | |
| if enemies: | |
| enemies.sort( | |
| key=lambda e: (e["cell_x"] - t["cell_x"]) ** 2 | |
| + (e["cell_y"] - t["cell_y"]) ** 2 | |
| ) | |
| cmds.append( | |
| Command.attack_unit([str(t["id"])], str(enemies[0]["id"])) | |
| ) | |
| else: | |
| cmds.append(Command.attack_move([str(t["id"])], 100, 20)) | |
| if h is not None: | |
| rear_x = min((t["cell_x"] for t in tanks), default=0) | |
| if tanks and rear_x >= 82 and not enemies: | |
| # Route confirmed clear and the escort is through — | |
| # sprint the VIP across. | |
| cmds.append(Command.move_units([str(h["id"])], 100, 20)) | |
| else: | |
| # Hold the VIP at the staging zone. | |
| cmds.append(Command.stop([str(h["id"])])) | |
| return cmds | |
| def test_intended_escort_clears_path_wins(level, seed): | |
| pytest.importorskip("openra_train") | |
| from openra_bench.eval_core import run_level | |
| c = compile_level(load_pack(PACK_PATH), level) | |
| r = run_level(c, _intended_escort_clears_path, seed=seed) | |
| assert r.outcome == "win", ( | |
| f"{level} seed={seed}: escort-clears-path should WIN, got " | |
| f"{r.outcome} after {r.turns} turns " | |
| f"(losses={r.signals.units_lost})" | |
| ) | |
| def test_stall_loses(level, seed): | |
| pytest.importorskip("openra_train") | |
| from openra_bench.eval_core import run_level | |
| c = compile_level(load_pack(PACK_PATH), level) | |
| r = run_level(c, _stall, seed=seed) | |
| assert r.outcome == "loss", ( | |
| f"{level} seed={seed}: stall must be a real timeout LOSS " | |
| f"(VIP never reaches extraction → after_ticks fires), " | |
| f"got {r.outcome}" | |
| ) | |
| def test_vip_alone_loses(level, seed): | |
| """VIP sent alone enters the route unaccompanied; the guard | |
| heavy tank lunges at the lone harvester and destroys it → | |
| `not unit_type_count_gte:{harv,1}` fail clause fires → LOSS | |
| on every level and every seed.""" | |
| pytest.importorskip("openra_train") | |
| from openra_bench.eval_core import run_level | |
| c = compile_level(load_pack(PACK_PATH), level) | |
| r = run_level(c, _vip_alone, seed=seed) | |
| assert r.outcome == "loss", ( | |
| f"{level} seed={seed}: VIP-alone must LOSE (unarmed harv " | |
| f"dies in the first wall), got {r.outcome} " | |
| f"(losses={r.signals.units_lost})" | |
| ) | |
| def test_vip_leads_charge_loses(level, seed): | |
| """Harv + tanks charge in a single column with the harv at the | |
| front; the guard interceptor + the e1 corridor focus the | |
| leading harvester → VIP dies → LOSS on every level and every | |
| seed.""" | |
| pytest.importorskip("openra_train") | |
| from openra_bench.eval_core import run_level | |
| c = compile_level(load_pack(PACK_PATH), level) | |
| r = run_level(c, _vip_leads_charge, seed=seed) | |
| assert r.outcome == "loss", ( | |
| f"{level} seed={seed}: VIP-leads-charge must LOSE (harv at " | |
| f"the front of the column is run down by the interceptors), " | |
| f"got {r.outcome} (losses={r.signals.units_lost})" | |
| ) | |