Spaces:
Running
Running
| """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 | |
| 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}" | |
| ) | |
| 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}" | |
| ) | |
| 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}" | |
| ) | |