Spaces:
Running
Running
| """combat-bait-counter-attack — bait-and-flank counter-attack micro. | |
| The bar: intended bait-then-strike (bait jeep pulls a SIDE of a leashed | |
| GUARD cluster off the enemy fact; strike tanks engage the now-isolated | |
| guards and then destroy the briefly-undefended fact) WINS on every | |
| level and every hard seed (1..4). STALL (only observe), BRUTE FRONTAL | |
| (attack-move all units east at the fact), and BAIT-ONLY (jeep flanks | |
| south but no strike) all LOSE on every level and every hard seed. | |
| Non-win is a real reachable timeout LOSS via the `after_ticks` fail | |
| clause; `units_lost_lte` provides the second LOSS path (brute frontal | |
| trades the strike force). | |
| 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-bait-counter-attack.yaml" | |
| # ── unit-level predicate / metadata checks (no engine) ────────────── | |
| def test_pack_compiles_and_meta_fields_populated(): | |
| pack = load_pack(PACK_PATH) | |
| assert pack.meta.id == "combat-bait-counter-attack" | |
| assert pack.meta.capability == "reasoning" | |
| 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) == 3, ( | |
| f"benchmark_anchor must list all 3 anchors, got {anchors!r}" | |
| ) | |
| joined = " ".join(anchors).lower() | |
| for needle in ("sc2", "feint", "cicero"): | |
| 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, lost=0, fact_destroyed=False): | |
| """Synthesize a WinContext for predicate-level checks.""" | |
| import types | |
| types_count = {"fact": 1} if fact_destroyed else {} | |
| sig = types.SimpleNamespace( | |
| game_tick=tick, | |
| units_killed=0, | |
| units_lost=lost, | |
| cash=0, | |
| resources=0, | |
| own_buildings=[], | |
| own_building_types=set(), | |
| enemies_seen_ids=set(), | |
| enemy_buildings_seen_ids=set(), | |
| enemy_buildings_destroyed=1 if fact_destroyed else 0, | |
| enemy_buildings_destroyed_types=types_count, | |
| enemy_buildings_destroyed_records=( | |
| [("fact", 80, 20)] if fact_destroyed else [] | |
| ), | |
| ) | |
| return WinContext( | |
| signals=sig, | |
| render_state={"units_summary": list(units)}, | |
| ) | |
| def _alive(n): | |
| return [ | |
| {"cell_x": 10, "cell_y": 20, "type": "2tnk", "id": str(1000 + i)} | |
| for i in range(n) | |
| ] | |
| def test_easy_predicates(): | |
| c = compile_level(load_pack(PACK_PATH), "easy") | |
| # Intended: fact destroyed, 1 unit lost (bait), in budget → WIN | |
| assert evaluate( | |
| c.win_condition, | |
| _ctx(units=_alive(3), tick=3000, lost=1, fact_destroyed=True), | |
| ) | |
| # Fact NOT destroyed → not a win even if in budget and few losses | |
| assert not evaluate( | |
| c.win_condition, | |
| _ctx(units=_alive(3), tick=3000, lost=1, fact_destroyed=False), | |
| ) | |
| # Loss cap (3) tripped → not a win | |
| assert not evaluate( | |
| c.win_condition, | |
| _ctx(units=_alive(0), tick=3000, lost=4, fact_destroyed=True), | |
| ) | |
| # Past deadline → not a win | |
| assert not evaluate( | |
| c.win_condition, | |
| _ctx(units=_alive(3), tick=5401, lost=1, fact_destroyed=True), | |
| ) | |
| # Timeout with bar unmet → fail (after_ticks 5401) | |
| assert evaluate( | |
| c.fail_condition, | |
| _ctx(units=_alive(3), tick=5500, lost=1, fact_destroyed=False), | |
| ) | |
| # Force-wipe → fail (not own_units_gte:1) | |
| assert evaluate( | |
| c.fail_condition, | |
| _ctx(units=[], tick=3000, lost=4, fact_destroyed=False), | |
| ) | |
| # Loss cap tripped (>3) → fail | |
| assert evaluate( | |
| c.fail_condition, | |
| _ctx(units=_alive(0), tick=3000, lost=4, fact_destroyed=True), | |
| ) | |
| def test_medium_predicates(): | |
| c = compile_level(load_pack(PACK_PATH), "medium") | |
| # Intended: fact destroyed, ≤2 lost → WIN | |
| assert evaluate( | |
| c.win_condition, | |
| _ctx(units=_alive(3), tick=3000, lost=2, fact_destroyed=True), | |
| ) | |
| # 3 lost (one too many) → not a win, AND fail | |
| assert not evaluate( | |
| c.win_condition, | |
| _ctx(units=_alive(2), tick=3000, lost=3, fact_destroyed=True), | |
| ) | |
| assert evaluate( | |
| c.fail_condition, | |
| _ctx(units=_alive(2), tick=3000, lost=3, fact_destroyed=True), | |
| ) | |
| # Timeout with fact intact → fail | |
| assert evaluate( | |
| c.fail_condition, | |
| _ctx(units=_alive(4), tick=5500, lost=1, fact_destroyed=False), | |
| ) | |
| def test_hard_predicates(): | |
| c = compile_level(load_pack(PACK_PATH), "hard") | |
| # Intended: fact destroyed, ≤2 lost → WIN (cap is 2: bait + ≤1 tank) | |
| assert evaluate( | |
| c.win_condition, | |
| _ctx(units=_alive(3), tick=3500, lost=2, fact_destroyed=True), | |
| ) | |
| # 3 lost → not a win, AND fail | |
| assert not evaluate( | |
| c.win_condition, | |
| _ctx(units=_alive(1), tick=3500, lost=3, fact_destroyed=True), | |
| ) | |
| assert evaluate( | |
| c.fail_condition, | |
| _ctx(units=_alive(1), tick=3500, lost=3, fact_destroyed=True), | |
| ) | |
| def test_timeout_reachable_inside_max_turns(): | |
| """No draw degeneracy: after_ticks 5401 ≤ 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 5401 <= max_tick, ( | |
| f"{lvl}: after_ticks 5401 > max reachable tick {max_tick} " | |
| f"(max_turns={c.max_turns}); deadline never bites" | |
| ) | |
| assert 5400 <= max_tick, ( | |
| f"{lvl}: within_ticks 5400 > max tick {max_tick}" | |
| ) | |
| def test_hard_has_two_spawn_point_groups(): | |
| """Hard-tier curation: ≥2 distinct agent spawn_point groups so the | |
| seed round-robins the staging DISTANCE from the yard (far west | |
| x=6 / forward x=18, both on the north flank y=12). | |
| Engine-roundtrip is 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_guard_bot_and_e3_cluster(): | |
| """The enemy must be the `guard` scripted bot (leashed defender) and | |
| the cluster must be e3 rocket-infantry (anti-tank Dragon, range 5, | |
| damage 5000 vs Heavy) — these together make the bait-and-flank idiom | |
| load-bearing (a hunter or patrol bot would not snap back, and an e1 | |
| cluster would not threaten the strike tanks).""" | |
| pack = load_pack(PACK_PATH) | |
| enemy = pack.base.get("enemy") if isinstance(pack.base, dict) else None | |
| assert enemy is not None | |
| bot = enemy.get("bot_type") or enemy.get("bot") or "" | |
| assert bot == "guard", f"expected guard bot, got {bot!r}" | |
| for lvl in ("easy", "medium", "hard"): | |
| c = compile_level(pack, lvl) | |
| types = [a.type for a in c.scenario.actors if a.owner == "enemy"] | |
| assert "e3" in types, f"{lvl}: cluster must be e3 rocket infantry" | |
| assert "fact" in types, f"{lvl}: objective must include enemy fact" | |
| def test_objective_fact_and_sentinel_present(): | |
| """Two enemy `fact` per level: one objective fact (mid-map, ~80,20) | |
| and one sentinel fact (far north ~120,4) keeping the episode alive | |
| past objective-fact destruction so within_ticks / units_lost_lte | |
| can evaluate on the terminal frame (the CLAUDE.md auto-terminate | |
| footgun on MustBeDestroyed).""" | |
| pack = load_pack(PACK_PATH) | |
| for lvl in ("easy", "medium", "hard"): | |
| c = compile_level(pack, lvl) | |
| facts = [ | |
| (a.position[0], a.position[1]) | |
| for a in c.scenario.actors | |
| if a.owner == "enemy" and a.type == "fact" | |
| ] | |
| assert len(facts) == 2, ( | |
| f"{lvl}: must have 2 enemy facts (objective + sentinel), " | |
| f"got {len(facts)} at {facts}" | |
| ) | |
| # One ~mid-map (objective), one far from that one (sentinel). | |
| # Use the rough centre of the playable arena. | |
| mid = [p for p in facts if 60 <= p[0] <= 100 and 14 <= p[1] <= 26] | |
| far = [p for p in facts if not (60 <= p[0] <= 100 and 14 <= p[1] <= 26)] | |
| assert len(mid) == 1 and len(far) == 1, ( | |
| f"{lvl}: expected 1 objective fact mid-map + 1 sentinel far, " | |
| f"got mid={mid} far={far}" | |
| ) | |
| def test_guard_count_per_level(): | |
| """Difficulty axis: easy 3 guards, medium 5, hard 5 — easy→medium | |
| adds cluster density + tightens the loss cap (3 → 2); medium→hard | |
| keeps the same 5-guard cover and adds the seed-driven staging- | |
| distance spawn variation. The strike force scales with the cover | |
| (easy 4 tanks, medium/hard 5) after the engine move-fire fixes | |
| made e3 anti-tank fire bite the 2tnk armour harder.""" | |
| pack = load_pack(PACK_PATH) | |
| expected_guards = {"easy": 3, "medium": 5, "hard": 5} | |
| expected_tanks = {"easy": 4, "medium": 5, "hard": 5} | |
| for lvl, want in expected_guards.items(): | |
| c = compile_level(pack, lvl) | |
| n_e3 = sum( | |
| 1 for a in c.scenario.actors if a.owner == "enemy" and a.type == "e3" | |
| ) | |
| assert n_e3 == want, f"{lvl}: expected {want} guards, got {n_e3}" | |
| n_tnk = sum( | |
| a.count or 1 | |
| for a in c.scenario.actors | |
| if a.owner == "agent" and a.type == "2tnk" | |
| ) | |
| # hard has 5 tanks per spawn group (2 groups) → count sums to 10 | |
| per_group = expected_tanks[lvl] | |
| groups = 2 if lvl == "hard" else 1 | |
| assert n_tnk == per_group * groups, ( | |
| f"{lvl}: expected {per_group * groups} tank-slots, got {n_tnk}" | |
| ) | |
| # ── engine-driven scripted policies ───────────────────────────────── | |
| def _own_ids(rs): | |
| return [str(u["id"]) for u in (rs.get("units_summary", []) or [])] | |
| def _of_type(rs, want_types): | |
| out = [] | |
| for u in rs.get("units_summary", []) or []: | |
| if u.get("type") in want_types: | |
| out.append(u) | |
| return out | |
| def _enemies_of_type(rs, want_types): | |
| out = [] | |
| for e in (rs.get("enemy_summary") or []): | |
| t = (e.get("type") or e.get("actor_type") or "").lower() | |
| if t in want_types: | |
| out.append(e) | |
| return out | |
| def _stall(rs, Command): | |
| """Pure observe — fact never takes damage; clock runs out → LOSS.""" | |
| return [Command.observe()] | |
| def _brute(rs, Command): | |
| """Attack-move every unit straight east at the fact. The strike | |
| column eats anti-tank fire from the whole guard cluster on | |
| approach, trades 4 units (1 jeep + 3 tanks) before the fact | |
| falls, trips the units_lost_lte cap → LOSS.""" | |
| cmds = [] | |
| for u in (rs.get("units_summary", []) or []): | |
| cmds.append(Command.attack_move([str(u["id"])], 80, 20)) | |
| return cmds or [Command.observe()] | |
| def _bait_only(rs, Command): | |
| """Jeep flanks south (pulls a side of the guards off), strike | |
| tanks stand still. The fact survives — clock runs out → LOSS. | |
| Tests that the bait without the counter-attack does not score.""" | |
| cmds = [] | |
| for u in (rs.get("units_summary", []) or []): | |
| if u.get("type") == "jeep": | |
| jx, jy = u["cell_x"], u["cell_y"] | |
| if jy >= 31: | |
| cmds.append(Command.move_units([str(u["id"])], 80, 28)) | |
| else: | |
| cmds.append(Command.move_units([str(u["id"])], 80, 33)) | |
| return cmds or [Command.observe()] | |
| def _intended(rs, Command): | |
| """Bait + counter-attack: | |
| 1. Commit the cheap fast jeep on a deep south flank vector | |
| (toward 80,36) — this comes within GUARD_AGGRO (~16) of the | |
| south arc of the guard cover, so those guards lunge off post | |
| after it, displacing the cover for a transient window. | |
| 2. Strike: focus-fire the WHOLE tank column on the single guard | |
| nearest the tank centroid. Concentration of fire kills the | |
| displaced / front guards one at a time — a favourable | |
| many-vs-one trade per kill — and the strike stays bunched. | |
| 3. Once no guard is in vision, drive the column directly onto | |
| the briefly-undefended construction yard (`attack_unit` on | |
| the objective `fact` if it is in vision, else `attack_move` | |
| onto its coordinate). | |
| The bait is genuinely load-bearing: bait-only never razes the | |
| yard, and a brute frontal push without the bait pull trades the | |
| whole strike force against the full undisplaced cover. | |
| """ | |
| cmds = [] | |
| tanks = _of_type(rs, {"2tnk"}) | |
| jeeps = _of_type(rs, {"jeep"}) | |
| tank_ids = [str(t["id"]) for t in tanks] | |
| if not (tanks or jeeps): | |
| return [Command.observe()] | |
| # Bait: drive the jeep deep south past the guard cover so the | |
| # south arc lunges off post after it. | |
| for j in jeeps: | |
| cmds.append(Command.move_units([str(j["id"])], 80, 36)) | |
| if not tanks: | |
| return cmds or [Command.observe()] | |
| tx = sum(t["cell_x"] for t in tanks) / len(tanks) | |
| ty = sum(t["cell_y"] for t in tanks) / len(tanks) | |
| guards = _enemies_of_type(rs, {"e3"}) | |
| fact_id = None | |
| for e in (rs.get("enemy_summary", []) or []): | |
| et = (e.get("type") or "").lower() | |
| if et == "fact" and e.get("cell_x", 200) < 100: | |
| fact_id = str(e.get("id")) | |
| break | |
| if guards: | |
| # Concentration of fire: the whole column on the nearest guard. | |
| guards.sort( | |
| key=lambda g: (g["cell_x"] - tx) ** 2 + (g["cell_y"] - ty) ** 2 | |
| ) | |
| cmds.append(Command.attack_unit(tank_ids, str(guards[0]["id"]))) | |
| elif fact_id is not None: | |
| cmds.append(Command.attack_unit(tank_ids, fact_id)) | |
| else: | |
| # Cluster broken but fact not yet in vision — push onto its | |
| # known coordinate (it stays put as a building). | |
| cmds.append(Command.attack_move(tank_ids, 80, 20)) | |
| return cmds or [Command.observe()] | |
| def test_intended_bait_then_strike_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, seed=seed) | |
| assert r.outcome == "win", ( | |
| f"{level} seed={seed}: intended bait-then-strike should WIN, " | |
| f"got {r.outcome} after {r.turns} turns " | |
| f"(lost={r.signals.units_lost}, " | |
| f"bldgs_destroyed={r.signals.enemy_buildings_destroyed_types})" | |
| ) | |
| 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"(fact never takes damage), got {r.outcome}" | |
| ) | |
| def test_brute_frontal_loses(level, seed): | |
| """Attack-move all units straight east at the fact must LOSE on | |
| every tier — the e3 anti-tank Dragons (range 5, damage 5000 vs | |
| Heavy armour) shred the column on approach, trading the whole | |
| strike force (4 lost) before the fact can be destroyed; the | |
| units_lost_lte cap fails.""" | |
| pytest.importorskip("openra_train") | |
| from openra_bench.eval_core import run_level | |
| c = compile_level(load_pack(PACK_PATH), level) | |
| r = run_level(c, _brute, seed=seed) | |
| assert r.outcome == "loss", ( | |
| f"{level} seed={seed}: brute frontal must LOSE " | |
| f"(strike force traded), got {r.outcome} " | |
| f"(lost={r.signals.units_lost}, " | |
| f"bldgs={r.signals.enemy_buildings_destroyed_types})" | |
| ) | |
| def test_bait_only_loses(level, seed): | |
| """Bait-only (jeep kites south, strike tanks stand still) must LOSE | |
| on every tier — the bait displacement yields ZERO objective | |
| progress; the fact survives and the clock runs out. Discriminates | |
| "bait without counter-attack" from the intended "bait AND counter- | |
| attack" idiom.""" | |
| pytest.importorskip("openra_train") | |
| from openra_bench.eval_core import run_level | |
| c = compile_level(load_pack(PACK_PATH), level) | |
| r = run_level(c, _bait_only, seed=seed) | |
| assert r.outcome == "loss", ( | |
| f"{level} seed={seed}: bait-only must LOSE (fact not destroyed), " | |
| f"got {r.outcome} (bldgs={r.signals.enemy_buildings_destroyed_types})" | |
| ) | |