Spaces:
Running
Running
| """combat-stance-mgmt-attack โ stance-flip hunt-authorisation pack | |
| (Wave-9 ACTION capability, ROE escalation / SC2 stance micro | |
| anchor). | |
| The bar (per CLAUDE.md): the intended (set_stance(3)) policy WINS | |
| on every level and every hard seed (1..4); the stall policy | |
| (observe only โ never escalate from stance:1 ReturnFire) LOSES on | |
| every level and every hard seed. Non-win is a real reachable | |
| timeout LOSS (after_ticks 4501 fits inside max_turns 60 โ 4501 โค | |
| 93 + 90ยท59 = 5403, so the deadline bites as a real LOSS rather | |
| than collapsing to a draw). | |
| The discrimination is hunt-authorisation: | |
| โข Intended: escalate the 4ร 2tnk formation from ReturnFire | |
| (stance:1) to AttackAnything (stance:3) via `set_stance`. The | |
| engine's stance:3 auto-engage scan finds the nearest visible | |
| enemy in sight+hunt-bonus and advances the tank toward it; | |
| on arrival the next-tick scan promotes the encounter into | |
| an Attack. 2tnk cannon vs e1 โ 1-shot, vs 1tnk โ 6 shots โ | |
| all comfortably under the tick budget. โฅK kills, โฅ3 of 4 | |
| tanks alive (no incoming damage โ the scatter is stance:0), | |
| fact intact (the scatter never closes on the agent's fact), | |
| well under the deadline โ WIN. | |
| โข Stall (only observe): the formation stays on stance:1 | |
| (ReturnFire). The scattered enemies are stance:0 (never fire | |
| first), so the tanks never receive fire โ return-fire gate | |
| never opens โ tanks never advance โ kills stay at 0. | |
| `units_killed_gte:K` fails; the after_ticks deadline fires | |
| โ LOSS. | |
| Validation is scripted (no model / network); these policies are | |
| exhaustive proxies for the hunt-authorisation capability and | |
| exercise the predicate teeth (has_building / units_killed_gte / | |
| own_units_gte / within_ticks) directly through eval_core.run_level. | |
| This pack ships alongside an engine fix that makes stance:1 truly | |
| return-fire-only and stance:3 a real hunting stance โ see | |
| `OpenRA-Rust/openra-sim/tests/test_stance_semantics.rs` for the | |
| pinning tests on the engine side. | |
| """ | |
| 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 | |
| PACKS = Path(__file__).parent.parent / "openra_bench" / "scenarios" / "packs" | |
| PACK_PATH = PACKS / "combat-stance-mgmt-attack.yaml" | |
| LEVELS = ("easy", "medium", "hard") | |
| HARD_SEEDS = (1, 2, 3, 4) | |
| # โโ structural / metadata checks (no engine) โโโโโโโโโโโโโโโโโโโโโโโโโโ | |
| def test_pack_compiles_and_meta_fields_populated(): | |
| pack = load_pack(PACK_PATH) | |
| assert pack.meta.id == "combat-stance-mgmt-attack" | |
| assert pack.meta.capability == "action" | |
| anchors = pack.meta.benchmark_anchor | |
| assert isinstance(anchors, list) and anchors | |
| joined = " ".join(anchors).lower() | |
| assert "roe" in joined or "rules-of-engagement" in joined or "escalation" in joined | |
| assert "sc2" in joined or "stance" in joined | |
| rwm = pack.meta.real_world_meaning.lower() | |
| assert "stance" in rwm or "escalat" in rwm or "set_stance" in rwm | |
| assert "hunt" in rwm or "pursue" in rwm or "advance" in rwm | |
| for lvl in LEVELS: | |
| 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_allowlist_includes_set_stance(): | |
| """`set_stance` is the load-bearing verb โ without it the agent | |
| cannot escalate the formation from ReturnFire to AttackAnything | |
| and the pack is unsolvable.""" | |
| pack = load_pack(PACK_PATH) | |
| base = pack.base if isinstance(pack.base, dict) else pack.base.dict() | |
| tools = set(base.get("tools") or []) | |
| assert "set_stance" in tools | |
| assert "observe" in tools | |
| def test_defenders_start_on_returnfire_at_the_west_edge(): | |
| """The 4ร 2tnk formation on every level must start on stance:1 | |
| (ReturnFire). If they start on any other stance the scenario | |
| degenerates โ stance:0 inverts to the HOLD pack (a different | |
| capability); stance:2 / stance:3 auto-engage from t=0 and a | |
| stall would win by accident.""" | |
| pack = load_pack(PACK_PATH) | |
| for lvl in LEVELS: | |
| c = compile_level(pack, lvl) | |
| tanks = [a for a in c.scenario.actors if a.owner == "agent" and a.type == "2tnk"] | |
| assert tanks, f"{lvl}: no 2tnk defenders" | |
| for t in tanks: | |
| stance = getattr(t, "stance", None) | |
| assert stance == 1, ( | |
| f"{lvl}: defender 2tnk at {t.position} starts on stance " | |
| f"{stance}, expected 1 (ReturnFire) โ otherwise the " | |
| f"set_stance(3) verb is not load-bearing" | |
| ) | |
| def test_scattered_enemies_are_passive(): | |
| """The scattered enemy force MUST be on stance:0 โ if they fire | |
| first, the ReturnFire gate opens automatically and the tanks | |
| engage without the agent calling set_stance. That would make | |
| the stall policy WIN by accident.""" | |
| pack = load_pack(PACK_PATH) | |
| for lvl in LEVELS: | |
| c = compile_level(pack, lvl) | |
| # The enemy fact is fine on any stance (it doesn't fire); | |
| # only check armed scatter units. | |
| ENEMY_COMBAT = {"e1", "e3", "1tnk", "2tnk", "3tnk", "jeep"} | |
| for a in c.scenario.actors: | |
| if a.owner != "enemy" or a.type not in ENEMY_COMBAT: | |
| continue | |
| stance = getattr(a, "stance", None) | |
| assert stance == 0, ( | |
| f"{lvl}: scattered enemy {a.type} at {a.position} on " | |
| f"stance {stance}, expected 0 (passive) โ an aggressive " | |
| f"enemy unlocks return-fire and breaks the discriminator" | |
| ) | |
| def test_each_level_has_a_reachable_timeout_fail(): | |
| """Non-win must be a real LOSS, not a draw.""" | |
| pack = load_pack(PACK_PATH) | |
| for lvl in LEVELS: | |
| c = compile_level(pack, lvl) | |
| fc = c.fail_condition.model_dump(exclude_none=True) | |
| deadline = None | |
| for clause in fc.get("any_of", []) or []: | |
| if "after_ticks" in clause: | |
| deadline = int(clause["after_ticks"]) | |
| assert deadline is not None, f"{lvl}: no after_ticks fail clause" | |
| reachable = 93 + 90 * (c.max_turns - 1) | |
| assert deadline < reachable, ( | |
| f"{lvl}: deadline {deadline} unreachable within " | |
| f"{c.max_turns} turns (max tick {reachable})" | |
| ) | |
| wc = c.win_condition.model_dump(exclude_none=True) | |
| deadline_win = None | |
| for clause in wc.get("all_of", []) or []: | |
| if "within_ticks" in clause: | |
| deadline_win = int(clause["within_ticks"]) | |
| assert deadline_win is not None, f"{lvl}: no within_ticks win clause" | |
| assert deadline_win < reachable, ( | |
| f"{lvl}: win deadline {deadline_win} unreachable within " | |
| f"{c.max_turns} turns" | |
| ) | |
| def test_win_predicate_carries_the_task_idiom(): | |
| """Win = all_of[ has_building:fact, units_killed_gte:K, | |
| own_units_gte:3, within_ticks:T ].""" | |
| pack = load_pack(PACK_PATH) | |
| for lvl in LEVELS: | |
| c = compile_level(pack, lvl) | |
| wc = c.win_condition.model_dump(exclude_none=True) | |
| flat = str(wc) | |
| assert "has_building" in flat and "fact" in flat, (lvl, wc) | |
| assert "units_killed_gte" in flat, (lvl, wc) | |
| assert "own_units_gte" in flat, (lvl, wc) | |
| assert "within_ticks" in flat, (lvl, wc) | |
| def test_fail_predicate_carries_the_task_idiom(): | |
| pack = load_pack(PACK_PATH) | |
| for lvl in LEVELS: | |
| c = compile_level(pack, lvl) | |
| fc = c.fail_condition.model_dump(exclude_none=True) | |
| flat = str(fc) | |
| assert "after_ticks" in flat | |
| assert "has_building" in flat and "fact" in flat | |
| assert "own_units_gte" in flat | |
| def test_hard_defines_two_agent_spawn_point_groups(): | |
| pack = load_pack(PACK_PATH) | |
| c = compile_level(pack, "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 groups == {0, 1}, ( | |
| f"hard must define โฅ2 agent spawn_point groups; got {sorted(groups)}" | |
| ) | |
| for a in c.scenario.actors: | |
| x, y = a.position | |
| assert 2 <= x <= 126 and 2 <= y <= 38, (a.type, a.position) | |
| def test_pack_is_in_upgraded_not_in_not_applicable(): | |
| from tests.test_hard_tier import NOT_APPLICABLE, UPGRADED | |
| assert "combat-stance-mgmt-attack" in UPGRADED | |
| assert "combat-stance-mgmt-attack" not in NOT_APPLICABLE | |
| # โโ engine-driven scripted policies (intended WINS, stall LOSES) โโโโ | |
| def _intended_policy(rs, Command): | |
| """Escalate the formation from ReturnFire (stance:1) to | |
| AttackAnything (stance:3) every turn. The engine's stance:3 | |
| hunt path advances each tank toward the nearest visible enemy; | |
| on arrival the in-range branch one-shots e1 and 6-shots 1tnk. | |
| The agent never issues attack_unit / attack_move / move_units; | |
| the kills are pure stance-driven auto-fire + hunt.""" | |
| units = [ | |
| u for u in (rs.get("units_summary", []) or []) | |
| if str(u.get("type", "")).lower() == "2tnk" | |
| ] | |
| if not units: | |
| return [Command.observe()] | |
| ids = [str(u["id"]) for u in units] | |
| return [Command.set_stance(ids, 3), Command.observe()] | |
| def _stall_policy(rs, Command): | |
| """Issue only observe(); never escalate the stance. The | |
| formation stays on ReturnFire forever; the scatter is stance:0 | |
| so no incoming fire ever lands โ return-fire gate never opens | |
| โ tanks never advance โ kills stay at 0 โ | |
| `units_killed_gte:K` fails โ after_ticks LOSS.""" | |
| return [Command.observe()] | |
| def test_intended_policy_wins_every_level_and_seed(level): | |
| pytest.importorskip("openra_train") | |
| from openra_bench.eval_core import run_level | |
| c = compile_level(load_pack(PACK_PATH), level) | |
| seeds = HARD_SEEDS if level == "hard" else (1,) | |
| for s in seeds: | |
| res = run_level(c, _intended_policy, seed=s) | |
| assert res.outcome == "win", ( | |
| f"{level} seed={s}: intended set_stance(3) policy must WIN, " | |
| f"got {res.outcome} after {res.turns} turns " | |
| f"(killed={res.signals.units_killed}, " | |
| f"lost={res.signals.units_lost}, " | |
| f"tick={res.signals.game_tick}, " | |
| f"buildings={res.signals.own_buildings})" | |
| ) | |
| def test_stall_policy_loses_every_level_and_seed(level): | |
| pytest.importorskip("openra_train") | |
| from openra_bench.eval_core import run_level | |
| c = compile_level(load_pack(PACK_PATH), level) | |
| seeds = HARD_SEEDS 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 (no escalation โ no " | |
| f"hunt โ no kills โ deadline bites), got {res.outcome} " | |
| f"(killed={res.signals.units_killed}, " | |
| f"lost={res.signals.units_lost}, " | |
| f"tick={res.signals.game_tick})" | |
| ) | |
| # Stall must score exactly 0 kills โ formation never fires | |
| # because (a) ReturnFire requires incoming damage that the | |
| # passive scatter never delivers, and (b) the hunt path is | |
| # only engaged at stance:3. | |
| assert res.signals.units_killed == 0, ( | |
| f"{level} seed={s}: stall killed {res.signals.units_killed} " | |
| f"units โ the formation should be silent on stance:1 " | |
| f"against a passive scatter (verb is not load-bearing if " | |
| f"kills > 0)" | |
| ) | |
| def test_intended_run_is_deterministic_on_easy(): | |
| pytest.importorskip("openra_train") | |
| from openra_bench.eval_core import run_level | |
| c = compile_level(load_pack(PACK_PATH), "easy") | |
| a = run_level(c, _intended_policy, seed=2) | |
| b = run_level(c, _intended_policy, seed=2) | |
| assert (a.outcome, a.turns) == (b.outcome, b.turns) | |