OpenRA-Bench / tests /test_combat_protect_vip_escort.py
yxc20098's picture
fix(scenario): combat-protect-vip-escort — recalibrate after engine movement fixes
f356f22
Raw
History Blame Contribute Delete
18.6 kB
"""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)}
@pytest.mark.parametrize("lvl", ["easy", "medium", "hard"])
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),
)
@pytest.mark.parametrize("lvl", ["easy", "medium", "hard"])
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
@pytest.mark.parametrize("level", ["easy", "medium", "hard"])
@pytest.mark.parametrize("seed", [1, 2, 3, 4])
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})"
)
@pytest.mark.parametrize("level", ["easy", "medium", "hard"])
@pytest.mark.parametrize("seed", [1, 2, 3, 4])
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}"
)
@pytest.mark.parametrize("level", ["easy", "medium", "hard"])
@pytest.mark.parametrize("seed", [1, 2, 3, 4])
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})"
)
@pytest.mark.parametrize("level", ["easy", "medium", "hard"])
@pytest.mark.parametrize("seed", [1, 2, 3, 4])
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})"
)