Spaces:
Running
Running
| """Armor-class damage multipliers โ Python harness mirror of | |
| `OpenRA-Rust/openra-sim/tests/test_armor_class_damage.rs`. | |
| Pins the armor-class combat model at the wheel / RustEnvPool | |
| boundary: each weapon scales its hit by a per-armor-class `Versus` | |
| multiplier, AND a multi-armament actor picks the weapon best suited | |
| to the target. | |
| Background: the engine had the `Versus` multiplier wired but always | |
| fired `weapons[0]`. e3 (rocket soldier) lists RedEye (anti-air) as | |
| its PRIMARY armament and Dragon (anti-ground, anti-armor) as | |
| SECONDARY, so it shot its anti-air missile at tanks and never used | |
| its anti-armor Dragon. The fix adds target-aware weapon selection in | |
| `world.rs` (`GameRules::best_weapon_against`) so e3 fires Dragon at | |
| heavy armor. | |
| Consequences pinned here: | |
| โข e3 vs 2tnk โ the rocket squad genuinely destroys heavy tanks | |
| (anti-armor counter works). | |
| โข e1 vs 2tnk โ the rifle's 10%-vs-heavy multiplier means a | |
| cost-equal rifle squad cannot break a tank (small-arms-vs-armor | |
| is feeble). | |
| See `OpenRA-Bench/CLAUDE.md` "engine blockers" section and | |
| `OpenRA-Rust/openra-sim/tests/test_armor_class_damage.rs`. | |
| """ | |
| from __future__ import annotations | |
| import tempfile | |
| from pathlib import Path | |
| import pytest | |
| import yaml | |
| pytest.importorskip("openra_train", reason="Rust env wheel not installed") | |
| def _scenario(actors: list[dict], max_ticks: int = 6000) -> dict: | |
| """Minimal arena scenario with explicit actor placement.""" | |
| return { | |
| "name": "armor-class-test", | |
| "description": "armor-class damage fixture", | |
| "base_map": "rush-hour-arena", | |
| "starting_cash": 0, | |
| "spawn_mcvs": False, | |
| "agent": {"faction": "allies", "cash": 0}, | |
| "enemy": {"faction": "soviet", "cash": 0}, | |
| "tools": ["observe", "move_units", "attack_unit", "attack_move", "stop"], | |
| "planning": True, | |
| "termination": {"max_ticks": max_ticks}, | |
| "actors": actors, | |
| } | |
| def _scenario_path(scenario: dict) -> str: | |
| fd = tempfile.NamedTemporaryFile("w", suffix="_armor.yaml", delete=False) | |
| # sort_keys=False is load-bearing โ the scenario YAML loader is | |
| # key-order sensitive. | |
| yaml.safe_dump(scenario, fd, sort_keys=False) | |
| fd.close() | |
| return fd.name | |
| def _adapter_run(scenario: dict, n_steps: int, *, seed: int = 1): | |
| """Run for n_steps observe() calls; return (adapter, render_state).""" | |
| from openra_rl_training.training.rust_env_pool import RustEnvPool | |
| from openra_bench.rust_adapter import RustObsAdapter | |
| path = _scenario_path(scenario) | |
| pool = RustEnvPool(size=1, scenario_path=path) | |
| env = pool.acquire() | |
| ad = RustObsAdapter() | |
| try: | |
| obs = env.reset(seed=seed) | |
| ad.observe(obs) | |
| rs = ad.render_state() | |
| for _ in range(n_steps): | |
| obs, _r, done, _i = env.step([env.Command.observe()]) | |
| ad.observe(obs, done=done) | |
| rs = ad.render_state() | |
| if done: | |
| break | |
| return ad, rs | |
| finally: | |
| pool.release(env) | |
| pool.shutdown() | |
| Path(path).unlink(missing_ok=True) | |
| def test_e3_rockets_destroy_heavy_tanks_python(): | |
| """A cost-fair rocket-soldier squad (anti-armor) must destroy a | |
| heavy-tank line. e3 fires its Dragon (anti-ground) โ armor-class | |
| weapon selection routes the anti-armor warhead at the tanks. | |
| 9ร e3 (cost 2700) vs 3ร 2tnk (cost 2550) โ cost parity. Both | |
| sides are stance:3 (auto-hunt), so the squad engages without | |
| agent micro. Proof: `units_killed >= 3` โ the rocket squad | |
| destroyed every tank.""" | |
| actors: list[dict] = [] | |
| for i in range(9): | |
| actors.append( | |
| { | |
| "type": "e3", | |
| "owner": "agent", | |
| "position": [40 + i % 3, 16 + i], | |
| "stance": 3, | |
| } | |
| ) | |
| for i in range(3): | |
| actors.append( | |
| { | |
| "type": "2tnk", | |
| "owner": "enemy", | |
| "position": [44, 18 + i], | |
| "stance": 3, | |
| } | |
| ) | |
| scen = _scenario(actors) | |
| # ~70 observe steps ร ~90 ticks/step โ 6000 ticks โ a generous | |
| # but finite engagement window. | |
| ad, _rs = _adapter_run(scen, n_steps=70) | |
| assert ad.signals.units_killed >= 3, ( | |
| f"anti-armor e3 squad failed to destroy all 3 heavy tanks: " | |
| f"only {ad.signals.units_killed} kills credited" | |
| ) | |
| def test_rifles_cannot_break_heavy_armor_python(): | |
| """A cost-equal rifle squad must NOT destroy a heavy tank inside | |
| a real engagement window โ the M1Carbine's 10%-vs-heavy `Versus` | |
| multiplier makes small-arms feeble against armor. | |
| 8ร e1 (cost 800) vs 1ร 2tnk (cost 800) โ cost parity. The agent | |
| rifles are stance:3 and pile onto the tank; the tank (stance:0, | |
| HoldFire) does not fight back, isolating pure rifle-vs-armor | |
| damage. Proof: `units_killed == 0` โ no rifle scores a kill, the | |
| tank survives.""" | |
| actors: list[dict] = [] | |
| for i in range(8): | |
| actors.append( | |
| { | |
| "type": "e1", | |
| "owner": "agent", | |
| "position": [40 + i % 4, 16 + i // 4], | |
| "stance": 3, | |
| } | |
| ) | |
| # Tank on HoldFire so the measurement is pure rifle-vs-armor: no | |
| # tank kills muddy the `units_killed == 0` proof. | |
| actors.append( | |
| { | |
| "type": "2tnk", | |
| "owner": "enemy", | |
| "position": [44, 19], | |
| "stance": 0, | |
| } | |
| ) | |
| # A realistic single-engagement window (~8 decision turns at | |
| # ~90 ticks/turn). The rifle's 10%-vs-heavy multiplier means a | |
| # cost-equal rifle squad cannot break a heavy tank inside a real | |
| # fight โ by contrast `test_e3_rockets_destroy_heavy_tanks_python` | |
| # shows anti-armor rockets shred tanks. That asymmetry IS the | |
| # armor-class combat model. | |
| scen = _scenario(actors, max_ticks=2000) | |
| ad, _rs = _adapter_run(scen, n_steps=8) | |
| assert ad.signals.units_killed == 0, ( | |
| f"a cost-equal rifle squad broke a heavy tank in a real " | |
| f"engagement window โ small-arms must be feeble vs armor " | |
| f"(units_killed={ad.signals.units_killed})" | |
| ) | |