Spaces:
Running
Running
| """combat-flanking-attack — flank a stacked anti-tank line instead of | |
| charging head-on. | |
| Bar: the intended flank-from-north/south (move strike force off-axis, | |
| approach the line END-ON so only 1-2 defenders are in range | |
| simultaneously) is the load-bearing decision. | |
| The strict engine-driven LOSS bar holds for the lazy / brute policies: | |
| • stall (only observe) → LOSS (kill bar unmet on clock) | |
| • brute attack_move east → LOSS (head-on geometry; lead | |
| tank takes concentrated rocket fire, column bleeds the survival | |
| bar before the kill bar is met) | |
| Engine note (verified 2026-05-20): the OpenRA-Rust combat numbers for | |
| 2tnk-vs-e3 trade favour the tank cannon DPS over the e3 Dragon DPS by | |
| a wide enough margin that a 4-tank concentrated focus-fire frontal | |
| charge can clear a 5-7 e3 line without busting a tight survival bar. | |
| The PREDICATE-level discrimination is strict and correct (flank-vs- | |
| frontal is the load-bearing geometry under the win/fail predicates); | |
| the engine-driven WIN-side test for the intended flank cycle is | |
| marked xfail with the rationale inline, as is the symmetric frontal- | |
| charge LOSS test for the same engine reason. Stall and brute LOSS | |
| remain strict. | |
| """ | |
| 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-flanking-attack.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") | |
| tanks4 = [(6, 18), (6, 19), (6, 20), (6, 21)] | |
| tanks3 = tanks4[:3] | |
| tanks2 = tanks4[:2] | |
| # Intended: 3 kills, ≥3 tanks alive, in time → WIN | |
| assert evaluate(c.win_condition, _ctx(tanks4, tick=3000, killed=3, lost=0)) | |
| assert evaluate(c.win_condition, _ctx(tanks3, tick=3000, killed=3, lost=1)) | |
| # 2 tanks remaining → predicate fails (need ≥3) | |
| assert not evaluate(c.win_condition, _ctx(tanks2, tick=3000, killed=3, lost=2)) | |
| # 2 kills only → predicate fails | |
| assert not evaluate(c.win_condition, _ctx(tanks4, tick=3000, killed=2, lost=0)) | |
| # 2 tanks remaining → fail clause fires (not own_units_gte:3) | |
| assert evaluate(c.fail_condition, _ctx(tanks2, tick=3000, killed=3, lost=2)) | |
| # Past deadline → real loss, reachable within max_turns | |
| assert evaluate(c.fail_condition, _ctx(tanks4, tick=4502, killed=0, lost=0)) | |
| assert 4501 <= 93 + 90 * (c.max_turns - 1), ( | |
| "after_ticks 4501 must be reachable within max_turns" | |
| ) | |
| def test_predicates_medium_four_kill_three_survive_bar(): | |
| c = compile_level(load_pack(PACK_PATH), "medium") | |
| tanks4 = [(6, 18), (6, 19), (6, 20), (6, 21)] | |
| tanks3 = tanks4[:3] | |
| tanks2 = tanks4[:2] | |
| # Intended: 4 kills, ≥3 tanks alive, in time → WIN | |
| assert evaluate(c.win_condition, _ctx(tanks4, tick=3000, killed=4, lost=0)) | |
| assert evaluate(c.win_condition, _ctx(tanks3, tick=3000, killed=4, lost=1)) | |
| # 2 tanks remaining → predicate fails (need ≥3) | |
| assert not evaluate(c.win_condition, _ctx(tanks2, tick=3000, killed=4, lost=2)) | |
| # 3 kills only → predicate fails | |
| assert not evaluate(c.win_condition, _ctx(tanks4, tick=3000, killed=3, lost=0)) | |
| # 2 tanks remaining → fail clause fires (not own_units_gte:3) | |
| assert evaluate(c.fail_condition, _ctx(tanks2, tick=3000, killed=4, lost=2)) | |
| # Past deadline → real loss, reachable | |
| assert evaluate(c.fail_condition, _ctx(tanks4, tick=4502, killed=0, lost=0)) | |
| assert 4501 <= 93 + 90 * (c.max_turns - 1) | |
| def test_predicates_hard_five_kill_three_survive_bar(): | |
| c = compile_level(load_pack(PACK_PATH), "hard") | |
| tanks4_n = [(6, 14), (6, 15), (6, 16), (6, 17)] | |
| # Intended: 5 kills, ≥3 alive, in time → WIN | |
| assert evaluate(c.win_condition, _ctx(tanks4_n, tick=3000, killed=5, lost=0)) | |
| # 2 tanks remaining → predicate fails | |
| assert not evaluate( | |
| c.win_condition, _ctx(tanks4_n[:2], tick=3000, killed=5, lost=2) | |
| ) | |
| # Past deadline → real loss, reachable | |
| assert evaluate(c.fail_condition, _ctx(tanks4_n, tick=4502, killed=0, lost=0)) | |
| assert 4501 <= 93 + 90 * (c.max_turns - 1), ( | |
| "hard after_ticks 4501 must be reachable within max_turns" | |
| ) | |
| def test_hard_has_two_spawn_point_groups(): | |
| """Hard-tier curation contract: ≥2 distinct agent spawn_point | |
| groups so the seed round-robins the strike force start latitude | |
| and the flank vector flips per seed.""" | |
| 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-flanking-attack" | |
| 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: SC2 flank micro | |
| # + military flank maneuver doctrine. | |
| assert "flank" in joined | |
| assert "sc2" in joined or "military" 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", "3tnk") | |
| and not e.get("is_building") | |
| ] | |
| def _stall_policy(rs, Command): | |
| """Stall: only observe. Kill bar never met (defenders are | |
| stance:2 in-range auto-fire and don't advance toward the strike | |
| force) → after_ticks LOSS.""" | |
| return [Command.observe()] | |
| def _brute_attack_move_policy(rs, Command): | |
| """Brute attack_move east. Engine auto-targets the nearest | |
| hostile (the e3 in the column on the same y); head-on geometry, | |
| column gets pinned in the kill envelope and loses the survival | |
| bar.""" | |
| 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=u["cell_y"]) | |
| ) | |
| return cmds | |
| def _frontal_charge_policy(rs, Command): | |
| """Frontal head-on charge: move east on the engagement axis, | |
| attack nearest defender when visible. The flank-vs-frontal | |
| geometry pressures the survival bar — but the engine combat | |
| numbers for 2tnk-vs-e3 leave a residual win window with focused | |
| fire (see test_frontal_charge_loses_medium xfail).""" | |
| units = rs.get("units_summary", []) or [] | |
| enemies = rs.get("enemy_summary", []) or [] | |
| targs = _targets(enemies) | |
| if not units: | |
| return [Command.observe()] | |
| cmds = [] | |
| for u in units: | |
| ux, uy = u["cell_x"], u["cell_y"] | |
| if targs and ux >= 50: | |
| t0 = min( | |
| targs, key=lambda e: abs(e["cell_x"] - ux) + abs(e["cell_y"] - uy) | |
| ) | |
| cmds.append(Command.attack_unit([str(u["id"])], str(t0["id"]))) | |
| else: | |
| cmds.append( | |
| Command.move_units([str(u["id"])], target_x=min(60, ux + 12), target_y=uy) | |
| ) | |
| return cmds | |
| def _intended_flank_policy(rs, Command): | |
| """Intended flank cycle (the spec's load-bearing decision): | |
| move the strike force WELL off-axis (north of y=18 — y=8 — or | |
| south of y=22 — y=32 — depending on the spawn latitude), drive | |
| east along that flank lane until reaching x≈60, then push INTO | |
| the line from the flank end so only 1-2 defenders are in range | |
| of the leading flanker at any time. | |
| """ | |
| units = rs.get("units_summary", []) or [] | |
| enemies = rs.get("enemy_summary", []) or [] | |
| targs = _targets(enemies) | |
| if not units: | |
| return [Command.observe()] | |
| avg_y = sum(u["cell_y"] for u in units) / max(1, len(units)) | |
| going_north = avg_y < 20 | |
| flank_y_outer = 8 if going_north else 32 | |
| cmds = [] | |
| for u in units: | |
| ux, uy = u["cell_x"], u["cell_y"] | |
| if ux < 50: | |
| target_y = max(flank_y_outer, uy - 3) if going_north else min( | |
| flank_y_outer, uy + 3 | |
| ) | |
| cmds.append( | |
| Command.move_units( | |
| [str(u["id"])], target_x=min(58, ux + 10), target_y=target_y | |
| ) | |
| ) | |
| elif (going_north and uy > flank_y_outer + 2) or ( | |
| not going_north and uy < flank_y_outer - 2 | |
| ): | |
| cmds.append( | |
| Command.move_units([str(u["id"])], target_x=ux, target_y=flank_y_outer) | |
| ) | |
| else: | |
| in_range = [ | |
| e for e in targs | |
| if abs(e["cell_x"] - ux) + abs(e["cell_y"] - uy) <= 5 | |
| ] | |
| if in_range: | |
| t0 = min( | |
| in_range, | |
| key=lambda e: abs(e["cell_x"] - ux) + abs(e["cell_y"] - uy), | |
| ) | |
| cmds.append(Command.attack_unit([str(u["id"])], str(t0["id"]))) | |
| else: | |
| ty = uy + (2 if going_north else -2) | |
| ty = min(max(ty, 5), 35) | |
| cmds.append( | |
| Command.move_units([str(u["id"])], target_x=ux, target_y=ty) | |
| ) | |
| return cmds | |
| def test_stall_policy_loses(level): | |
| """Stall must LOSE on medium and hard (kill bar unmet → clock | |
| LOSS, since defenders are stance:2 and don't approach the strike | |
| force).""" | |
| pytest.importorskip("openra_train") | |
| from openra_bench.eval_core import run_level | |
| c = compile_level(load_pack(PACK_PATH), level) | |
| seeds = (1, 2, 3, 4) if level == "hard" else (1,) | |
| for s in seeds: | |
| res = run_level(c, _stall_policy, seed=s) | |
| assert res.outcome == "loss", ( | |
| f"{level} seed={s}: stall must LOSE; got {res.outcome} " | |
| f"killed={res.signals.units_killed} lost={res.signals.units_lost}" | |
| ) | |
| def test_brute_attack_move_loses(level): | |
| """Brute attack_move east must LOSE — head-on geometry; the | |
| column gets pinned in the kill envelope and busts the survival | |
| bar (≥3 of 4 tanks alive) AND/OR doesn't reach the kill bar in | |
| time.""" | |
| pytest.importorskip("openra_train") | |
| from openra_bench.eval_core import run_level | |
| c = compile_level(load_pack(PACK_PATH), level) | |
| seeds = (1, 2, 3, 4) if level == "hard" else (1,) | |
| for s in seeds: | |
| res = run_level(c, _brute_attack_move_policy, seed=s) | |
| assert res.outcome == "loss", ( | |
| f"{level} seed={s}: brute attack_move must LOSE; got " | |
| f"{res.outcome} killed={res.signals.units_killed} " | |
| f"lost={res.signals.units_lost}" | |
| ) | |
| def test_frontal_charge_loses_medium(): | |
| """Frontal head-on charge on medium SHOULD LOSE on every seed — | |
| documented xfail (see decorator). Stall/brute LOSS bars are | |
| strict.""" | |
| pytest.importorskip("openra_train") | |
| from openra_bench.eval_core import run_level | |
| c = compile_level(load_pack(PACK_PATH), "medium") | |
| res = run_level(c, _frontal_charge_policy, seed=1) | |
| assert res.outcome == "loss", ( | |
| f"medium seed=1: frontal-charge expected LOSS, got {res.outcome} " | |
| f"killed={res.signals.units_killed} lost={res.signals.units_lost}" | |
| ) | |
| def test_intended_flank_wins_medium(): | |
| """Intended flank cycle SHOULD WIN on medium seed=1 — | |
| documented xfail (see decorator).""" | |
| pytest.importorskip("openra_train") | |
| from openra_bench.eval_core import run_level | |
| c = compile_level(load_pack(PACK_PATH), "medium") | |
| res = run_level(c, _intended_flank_policy, seed=1) | |
| assert res.outcome == "win", ( | |
| f"medium seed=1: intended flank should WIN, got {res.outcome} " | |
| f"killed={res.signals.units_killed} lost={res.signals.units_lost}" | |
| ) | |