OpenRA-Bench / tests /test_combat_formation_tank_wedge.py
yxc20098's picture
fix(scenario): combat-formation-tank-wedge โ€” recalibrate after engine movement fixes
f46590b
Raw
History Blame Contribute Delete
13.9 kB
"""combat-formation-tank-wedge โ€” drive 5ร— 2tnk in a wedge formation
through a bracketing fire corridor to reach the eastern objective
region with most of the force intact.
Bar: the intended wedge (lead absorbs on-axis fire; flankers engage
the brackets end-on from off-axis) is the load-bearing decision.
The strict engine-driven LOSS bar holds for the lazy / brute
policies:
โ€ข stall (only observe) โ†’ LOSS (region bar unmet on the
clock; defenders are stance:2 and never approach the strike
force, so the within_ticks bar elapses โ†’ after_ticks LOSS).
โ€ข brute attack_move east on y=20 โ†’ LOSS (column on the engagement
axis takes simultaneous cross-fire from BOTH brackets at
Manhattan 4; โ‰ฅ3 tanks die before the column clears the gap โ†’
own_units_gte:3 fails / region bar unmet โ†’ LOSS).
Engine note (recalibrated 2026-05-20, after the engine movement
fixes โ€” moving units take fire en route and attack_unit on an
out-of-sight target closes at real speed): the OpenRA-Rust combat
numbers for 2tnk-vs-e3 trade favour tank cannon DPS by a wide
margin, so the PREDICATE-level discrimination is strict (a play
that loses โ‰ฅ3 of 5 tanks LOSES under own_units_gte:3 regardless of
mechanism), and the column-vs-wedge geometry is the load-bearing
decision encoded in the win predicate. The easy north bracket was
widened from 3 to FIVE e3 because the post-fix column slipped past
the old single 3-rocket bracket losing only one tank โ€” the denser
bracket makes a single-file column bleed โ‰ฅ3 tanks while the off-
axis wedge still loses zero. The engine-driven scripted wedge
policy WINS on every level and seed (lead-on-axis + flankers
off-axis sequences the engagement so only 1-2 e3 fire on a given
tank at once); the stall and brute LOSS bars hold on every
level/seed.
"""
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-formation-tank-wedge.yaml"
# โ”€โ”€ unit-level predicate checks โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
def _ctx(units_xy=(), tick=1000, killed=0, lost=0):
"""Synthesize a WinContext for predicate-level checks."""
import types
sig = types.SimpleNamespace(
game_tick=tick,
units_killed=killed,
units_lost=lost,
own_buildings=[],
own_building_types=set(),
enemies_seen_ids=set(),
enemy_buildings_seen_ids=set(),
)
return WinContext(
signals=sig,
render_state={
"units_summary": [
{"cell_x": x, "cell_y": y} for x, y in units_xy
]
},
)
def test_predicates_easy():
c = compile_level(load_pack(PACK_PATH), "easy")
# 4 tanks AT objective (within radius 6 of 80,20) โ€” WIN
at_obj4 = [(78, 19), (80, 20), (82, 21), (80, 18)]
# 3 tanks at objective + 2 elsewhere โ€” own โ‰ฅ4 ok but region n<4 fails
at_obj3 = [(78, 19), (80, 20), (82, 21), (6, 18), (6, 19)]
# 4 tanks at objective + 1 elsewhere โ€” 5 alive total, region n=4 ok
at_obj4_plus = [(78, 19), (80, 20), (82, 21), (80, 18), (6, 18)]
# 2 tanks at objective โ€” region predicate fails
at_obj2 = [(78, 19), (80, 20)]
# Intended: โ‰ฅ4 at objective, โ‰ฅ4 alive, in time โ†’ WIN
assert evaluate(c.win_condition, _ctx(at_obj4_plus, tick=3000))
assert evaluate(c.win_condition, _ctx(at_obj4, tick=3000))
# 3 at objective + 2 elsewhere: own=5 ok but region n<4 โ†’ predicate fails
assert not evaluate(c.win_condition, _ctx(at_obj3, tick=3000))
# Only 2 tanks at objective and only 2 alive: own_units_gte:4 fails
assert not evaluate(c.win_condition, _ctx(at_obj2, tick=3000))
# 2 tanks remaining โ†’ fail clause fires (not own_units_gte:3)
assert evaluate(c.fail_condition, _ctx(at_obj2, tick=3000))
# Past deadline โ†’ real loss, reachable within max_turns
assert evaluate(c.fail_condition, _ctx(at_obj4_plus, tick=4502))
assert 4501 <= 93 + 90 * (c.max_turns - 1), (
"after_ticks 4501 must be reachable within max_turns"
)
def test_predicates_medium_relaxed_three_survive_bar():
"""Per the header relaxation: medium uses own_units_gte:3 + โ‰ฅ3 of
5 tanks at objective, not the strict 5-of-5. The discriminator is
column-vs-wedge survival differential, not absolute counts."""
c = compile_level(load_pack(PACK_PATH), "medium")
at_obj3 = [(78, 19), (80, 20), (82, 21)]
at_obj3_plus = [(78, 19), (80, 20), (82, 21), (6, 18)]
at_obj2 = [(78, 19), (80, 20)]
# Intended: โ‰ฅ3 at objective, โ‰ฅ3 alive, โ‰ฅ3 kills, in time โ†’ WIN
assert evaluate(c.win_condition, _ctx(at_obj3, tick=3000, killed=3))
assert evaluate(c.win_condition, _ctx(at_obj3_plus, tick=3000, killed=3))
# 3 at objective but only 2 kills โ†’ predicate fails (kill bar)
assert not evaluate(c.win_condition, _ctx(at_obj3, tick=3000, killed=2))
# 2 at objective (and 2 alive) โ†’ predicate fails (region + survival)
assert not evaluate(c.win_condition, _ctx(at_obj2, tick=3000, killed=4))
# 2 tanks remaining โ†’ fail clause fires
assert evaluate(c.fail_condition, _ctx(at_obj2, tick=3000, killed=4))
def test_predicates_hard_two_blockers():
c = compile_level(load_pack(PACK_PATH), "hard")
at_obj3 = [(78, 19), (80, 20), (82, 21)]
at_obj2 = [(78, 19), (80, 20)]
# Intended: โ‰ฅ3 at objective, โ‰ฅ3 alive, โ‰ฅ4 kills, in time โ†’ WIN
assert evaluate(c.win_condition, _ctx(at_obj3, tick=3000, killed=4))
# Kill bar tighter on hard โ€” 3 kills not enough
assert not evaluate(c.win_condition, _ctx(at_obj3, tick=3000, killed=3))
# Two tanks alive โ†’ fail
assert evaluate(c.fail_condition, _ctx(at_obj2, tick=3000, killed=4))
# Past deadline โ†’ fail reachable
assert evaluate(c.fail_condition, _ctx(at_obj3, tick=4502, killed=4))
assert 4501 <= 93 + 90 * (c.max_turns - 1)
def test_hard_has_two_spawn_groups():
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_pack_compiles_and_meta_fields_populated():
pack = load_pack(PACK_PATH)
assert pack.meta.capability == "action"
assert pack.meta.id == "combat-formation-tank-wedge"
anchors = pack.meta.benchmark_anchor
assert isinstance(anchors, list) and anchors, "benchmark_anchor required"
joined = " ".join(anchors).lower()
# Anchored to the doctrines the brief calls out.
assert "wedge" in joined or "formation" in joined
assert "sc2" in joined or "military" in joined or "combined-arms" in joined
for lvl in ("easy", "medium", "hard"):
c = compile_level(pack, lvl)
assert c.map_supported
assert c.win_condition is not None and c.fail_condition is not None
def test_timeout_loss_is_reachable_on_every_level():
"""No draw degeneracy: the after_ticks deadline fits inside
max_turns on every level (~90 ticks/turn โ‡’ 93 + 90ยท(max_turns-1))."""
pack = load_pack(PACK_PATH)
for lvl in ("easy", "medium", "hard"):
c = compile_level(pack, lvl)
assert 4501 <= 93 + 90 * (c.max_turns - 1), (
f"{lvl}: after_ticks 4501 not reachable within max_turns"
)
# โ”€โ”€ engine-driven scripted policies โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
def _targets(enemies):
return [
e for e in enemies
if (e.get("type") or "").lower() in ("e3", "1tnk", "3tnk")
and not e.get("is_building")
]
def _stall_policy(rs, Command):
"""Stall: only observe. Region bar never met (the agent never
moves toward (80,20)) โ†’ after_ticks LOSS."""
return [Command.observe()]
def _brute_column_policy(rs, Command):
"""Brute column attack_move east along y=20. The column squeezes
through the corridor on the engagement axis; rocket-soldier fire
from the bracket(s) focuses on the lead, then inherits down the
line โ€” the column busts the survival bar before reaching
(80,20)."""
units = rs.get("units_summary", []) or []
if not units:
return [Command.observe()]
cmds = []
for u in units:
cmds.append(
Command.attack_move([str(u["id"])], target_x=110, target_y=20)
)
return cmds
def _intended_wedge_policy(rs, Command):
"""Intended wedge cycle: advance the formation to the mouth of the
fire corridor, then โ€” instead of running the gauntlet single-file
โ€” turn the whole wedge ONTO the off-axis brackets and dismantle the
rocket-soldier clusters end-on before pushing through to the
objective. The column policy that just attack_moves straight east
along y=20 sits inside Dragon range of BOTH brackets at once and
bleeds itself dry; the wedge eliminates the off-axis threat first,
so the residual drive to (80,20) is uncontested.
Phases:
1. brackets alive & lead still west of the corridor โ†’ advance the
formation to xโ‰ˆ38 (corridor mouth), holding y-spread.
2. brackets alive & lead at the corridor โ†’ turn EVERY tank onto
its nearest rocket soldier (focus-fire the brackets end-on).
3. brackets cleared โ†’ attack_move the surviving wedge to the
objective region centred on (80, 20).
"""
units = rs.get("units_summary", []) or []
enemies = rs.get("enemy_summary", []) or []
targs = _targets(enemies)
if not units:
return [Command.observe()]
e3s = [e for e in targs if (e.get("type") or "").lower() == "e3"]
lead_x = max(u["cell_x"] for u in units)
cmds = []
if e3s and lead_x >= 30:
# Phase 2 โ€” turn the wedge onto the brackets, end-on.
for u in units:
ux, uy = u["cell_x"], u["cell_y"]
t0 = min(
e3s,
key=lambda e: abs(e["cell_x"] - ux) + abs(e["cell_y"] - uy),
)
cmds.append(Command.attack_unit([str(u["id"])], str(t0["id"])))
return cmds
if e3s:
# Phase 1 โ€” advance the formation to the corridor mouth.
for u in units:
cmds.append(
Command.move_units(
[str(u["id"])], target_x=38, target_y=u["cell_y"]
)
)
return cmds
# Phase 3 โ€” brackets cleared; drive the survivors to the objective.
for u in units:
cmds.append(
Command.attack_move([str(u["id"])], target_x=80, target_y=20)
)
return cmds
@pytest.mark.parametrize("level", ["easy", "medium", "hard"])
@pytest.mark.parametrize("seed", [1, 2, 3, 4])
def test_stall_policy_loses(level, seed):
"""Stall must LOSE on every level and seed โ€” the region bar is
never met because the agent never moves; defenders are stance:2
and never come to the strike force, so the within_ticks deadline
elapses โ†’ after_ticks LOSS."""
pytest.importorskip("openra_train")
from openra_bench.eval_core import run_level
c = compile_level(load_pack(PACK_PATH), level)
res = run_level(c, _stall_policy, seed=seed)
assert res.outcome == "loss", (
f"{level} seed={seed}: stall must LOSE; got {res.outcome} "
f"killed={res.signals.units_killed} lost={res.signals.units_lost}"
)
@pytest.mark.parametrize("level", ["easy", "medium", "hard"])
@pytest.mark.parametrize("seed", [1, 2, 3, 4])
def test_brute_column_attack_move_loses(level, seed):
"""Brute attack_move east on y=20 must LOSE on every level and
seed โ€” the column on the engagement axis takes concentrated
rocket fire from the bracket(s) (both brackets on medium/hard);
โ‰ฅ3 tanks die before the column clears the gap โ†’ own_units_gte:3
fails OR the region-at-objective bar is unmet in time โ†’ LOSS
(never a draw).
"""
pytest.importorskip("openra_train")
from openra_bench.eval_core import run_level
c = compile_level(load_pack(PACK_PATH), level)
res = run_level(c, _brute_column_policy, seed=seed)
assert res.outcome == "loss", (
f"{level} seed={seed}: brute column attack_move must LOSE; "
f"got {res.outcome} killed={res.signals.units_killed} "
f"lost={res.signals.units_lost}"
)
@pytest.mark.parametrize("level", ["easy", "medium", "hard"])
@pytest.mark.parametrize("seed", [1, 2, 3, 4])
def test_intended_wedge_wins(level, seed):
"""Intended wedge cycle WINS on every level and every hard seed:
the formation advances to the corridor mouth, turns onto the
off-axis rocket-soldier brackets and dismantles them end-on, then
drives the survivors uncontested to (80,20). Recalibrated
2026-05-20 after the engine movement fixes (moving units take
fire en route; attack_unit closes at real speed): engaging the
brackets end-on keeps 5-of-5 (easy, medium) / โ‰ฅ4-of-5 (hard)
tanks alive while a single-file column busts the survival bar."""
pytest.importorskip("openra_train")
from openra_bench.eval_core import run_level
c = compile_level(load_pack(PACK_PATH), level)
res = run_level(c, _intended_wedge_policy, seed=seed)
assert res.outcome == "win", (
f"{level} seed={seed}: intended wedge should WIN, got "
f"{res.outcome} killed={res.signals.units_killed} "
f"lost={res.signals.units_lost}"
)