OpenRA-Bench / tests /test_combat_hold_chokepoint.py
yxc20098's picture
combat-hold-chokepoint: rewrite as canonical chokepoint-arena generator use case
73e261f
Raw
History Blame Contribute Delete
13.5 kB
"""combat-hold-chokepoint โ€” hold a narrow corridor so a larger enemy
force can only feed a few-abreast trickle through the choke.
Capability (action): positioning the squad IN a chokepoint lets a
small force defeat a larger one โ€” the terrain caps how many attackers
can bring weapons to bear at once. Anchored at the mouth of a
water-walled corridor (the chokepoint-arena generator's `pinch_x=48`,
`pinch_width=6`, per-tier `corridor_width`), the defenders grind the
larger force down piecemeal; the same squad fighting in the open is
surrounded and focus-fired down.
Per-tier (rewritten 2026-05-23 for the canonical chokepoint-arena
use case โ€” terrain water carries the wall, no silo-as-wall actors):
easy โ€” corridor width 4, 3x 2tnk defenders, 6x 1tnk attackers,
quota 4, region n>=2 within radius 6 of (44,20).
medium โ€” corridor width 3, 4x 2tnk defenders, 10x 1tnk attackers,
quota 7, region n>=3.
hard โ€” corridor width 2, 4x 2tnk defenders, 14x 1tnk attackers
(seeded NORTH or SOUTH cluster), quota 9, region n>=3.
Bar (every level, every seed):
โ€ข stall (only observe) -> LOSS. The pre-placed squad
auto-fires (stance:2) but SPREADS its fire across the funnel
front instead of concentrating it; kills too slowly to clear the
quota before the survival cap busts.
โ€ข brute attack_move east -> LOSS. The squad charges through
the corridor into the open enemy lobe; the whole force converges
and focus-fires the tanks down before the quota.
โ€ข wrong-path retreat WEST -> LOSS. Pulling out of the corridor
mouth into the west open: even when the retreating squad scrapes
the kill quota by kiting, the units_in_region geometry clause
(mouth at x=44, y=20) fails โ€” the squad sits at x~20 โ€” and/or
the survival cap busts.
โ€ข intended hold-the-choke -> WIN. The squad stays anchored at
the mouth (x=43..44, y in the corridor rows) and focus-fires the
frontmost attacker; the funneled queue is ground down piecemeal
and the squad keeps enough tanks at the mouth.
Engine note: WATER terrain blocks PATHING (not weapons fire), so the
chokepoint works by CONFINING the attacker to the corridor-width row
band (capping how many enemies are within weapon range at once),
not by blocking line of sight โ€” the lane is the only crossing for
the full map height.
"""
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-hold-chokepoint.yaml"
# โ”€โ”€ unit-level predicate checks โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
def _ctx(n_units, tick=1000, killed=0, fact=1, pos=(44, 20)):
"""Synthesize a WinContext for predicate-level checks. `pos` is the
cell every own unit sits at โ€” defaults to the corridor mouth
(44,20) so the units_in_region win clause is satisfied; pass an
out-of-corridor cell to exercise the geometry teeth."""
import types
sig = types.SimpleNamespace(
game_tick=tick,
units_killed=killed,
units_lost=0,
# own_buildings is a list of (type, cell_x, cell_y) tuples.
own_buildings=[("fact", 10, 20)] * fact,
own_building_types={"fact"} if fact else set(),
enemies_seen_ids=set(),
enemy_buildings_seen_ids=set(),
)
return WinContext(
signals=sig,
render_state={
"units_summary": [
{"cell_x": pos[0], "cell_y": pos[1]}
] * n_units,
},
)
def test_predicates_easy():
c = compile_level(load_pack(PACK_PATH), "easy")
# 2 tanks at the mouth, 9 kills, fact alive, in time -> WIN
assert evaluate(c.win_condition, _ctx(2, tick=3000, killed=9))
assert evaluate(c.win_condition, _ctx(3, tick=3000, killed=12))
# kill quota unmet (8 < 9) -> win fails
assert not evaluate(c.win_condition, _ctx(3, tick=3000, killed=8))
# fact razed -> win fails
assert not evaluate(c.win_condition, _ctx(3, tick=3000, killed=12, fact=0))
# 1 tank left -> fail clause fires (not own_units_gte:2)
assert evaluate(c.fail_condition, _ctx(1, tick=3000, killed=12))
# past deadline -> real loss, reachable within max_turns
assert evaluate(c.fail_condition, _ctx(3, tick=5402, killed=12))
assert 5401 <= 93 + 90 * (c.max_turns - 1), (
"after_ticks 5401 must be reachable within max_turns"
)
def test_predicates_medium_and_hard():
for lvl, quota in (("medium", 11), ("hard", 9)):
c = compile_level(load_pack(PACK_PATH), lvl)
# meeting the quota with >=3 tanks at the mouth and fact alive
# -> WIN
assert evaluate(c.win_condition, _ctx(3, tick=3000, killed=quota))
# one short of the quota -> win fails
assert not evaluate(
c.win_condition, _ctx(4, tick=3000, killed=quota - 1)
)
# 1 tank left -> fail fires
assert evaluate(c.fail_condition, _ctx(1, tick=3000, killed=quota))
# deadline reachable
assert evaluate(c.fail_condition, _ctx(4, tick=5402, killed=quota))
def test_corridor_geometry_clause_bites():
"""The units_in_region win clause makes the hold load-bearing: a
squad that met the kill quota and survival cap but abandoned the
corridor (charged east or pulled west into the open) still fails
the win โ€” only a squad anchored at the mouth (x=44, y=20)
satisfies it."""
for lvl, quota in (("easy", 9), ("medium", 11), ("hard", 9)):
c = compile_level(load_pack(PACK_PATH), lvl)
# Anchored at the mouth (44,20) -> WIN
assert evaluate(
c.win_condition, _ctx(3, tick=3000, killed=quota, pos=(44, 20))
)
# Pulled WEST into the open (x=20) -> geometry clause fails
assert not evaluate(
c.win_condition, _ctx(3, tick=3000, killed=quota, pos=(20, 20))
)
# Charged EAST through the corridor (x=80) -> geometry fails
assert not evaluate(
c.win_condition, _ctx(3, tick=3000, killed=quota, pos=(80, 20))
)
def test_timeout_loss_is_reachable_on_every_level():
"""No draw degeneracy: after_ticks 5401 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 5401 <= 93 + 90 * (c.max_turns - 1), (
f"{lvl}: after_ticks 5401 not reachable within max_turns"
)
def test_hard_has_two_spawn_groups():
"""Hard defines two ENEMY-side spawn_point groups (NORTH / SOUTH
cluster) round-robined by seed โ€” enemy-side activation per Wave-9
per-owner spawn_point semantics."""
c = compile_level(load_pack(PACK_PATH), "hard")
enemy_sps = {
a.spawn_point
for a in c.scenario.actors
if a.owner == "enemy" and a.spawn_point is not None
}
assert len(enemy_sps) >= 2, (
f"hard needs โ‰ฅ2 enemy spawn_point groups, got {enemy_sps}"
)
def test_pack_compiles_and_meta_fields_populated():
pack = load_pack(PACK_PATH)
assert pack.meta.capability == "action"
assert pack.meta.id == "combat-hold-chokepoint"
anchors = pack.meta.benchmark_anchor
assert isinstance(anchors, list) and anchors, "benchmark_anchor required"
joined = " ".join(anchors).lower()
assert "chokepoint" in joined
assert "thermopylae" in joined
assert "ramp" in joined or "sc2" 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
# โ”€โ”€ engine-driven scripted policies โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
def _stall_policy(rs, Command):
"""Stall: only observe. The pre-placed squad auto-fires (stance:2)
but spreads its fire โ€” kills too slowly, is worn down, and the
own_units_gte:2 fail clause busts."""
return [Command.observe()]
def _brute_east_policy(rs, Command):
"""Brute: attack_move every tank east out of the corridor into the
open. The whole enemy force converges and focus-fires the squad
down before the kill quota is met."""
units = rs.get("units_summary", []) or []
if not units:
return [Command.observe()]
return [
Command.attack_move([str(u["id"])], target_x=88, target_y=20)
for u in units
]
def _wrong_path_west_policy(rs, Command):
"""Wrong-path: pull the squad WEST out of the corridor into the
open ground behind the choke. Even when the retreating squad
auto-fires down enough pursuers to scrape the kill quota, it ends
outside the choke region (geometry clause fails) and the survival
cap busts -> LOSS on every level/seed."""
units = rs.get("units_summary", []) or []
if not units:
return [Command.observe()]
return [
Command.move_units([str(u["id"])], target_x=20, target_y=u["cell_y"])
for u in units
]
def _intended_hold_policy(rs, Command):
"""Intended hold-the-choke: keep the squad anchored in the corridor
and FOCUS-FIRE the frontmost (lowest cell_x) enemy with every tank.
The bracketing walls confine the attackers to a 3-abreast trickle;
concentrated fire grinds them down piecemeal."""
units = rs.get("units_summary", []) or []
enemies = [
e
for e in (rs.get("enemy_summary", []) or [])
if not e.get("is_building")
and (e.get("type") or "").lower() == "1tnk"
]
if not units or not enemies:
return [Command.observe()]
front = min(enemies, key=lambda e: e["cell_x"])
return [
Command.attack_unit([str(u["id"])], str(front["id"]))
for u in units
]
@pytest.mark.parametrize("level", ["easy", "medium", "hard"])
def test_stall_policy_loses(level):
"""Stall must LOSE on every level/seed โ€” the pre-placed squad
auto-fires but spreads fire instead of concentrating it, so it
kills too slowly (below the quota of 9) and is worn down before
the quota is met -> own_units_gte:2 busts AND the kill quota is
unmet (recalibrated 2026-05-20: 14-tank easy / quota 9)."""
pytest.importorskip("openra_train")
from openra_bench.eval_core import run_level
c = compile_level(load_pack(PACK_PATH), level)
for s in (1, 2, 3, 4):
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}"
)
@pytest.mark.parametrize("level", ["easy", "medium", "hard"])
def test_wrong_path_west_policy_loses(level):
"""Wrong-path retreat WEST out of the corridor must LOSE on every
level/seed โ€” the squad abandons the choke; the survival cap busts
and/or the units_in_region geometry win clause fails (the squad
sits in the open at x~20, not in the lane)."""
pytest.importorskip("openra_train")
from openra_bench.eval_core import run_level
c = compile_level(load_pack(PACK_PATH), level)
for s in (1, 2, 3, 4):
res = run_level(c, _wrong_path_west_policy, seed=s)
assert res.outcome == "loss", (
f"{level} seed={s}: wrong-path west must LOSE; got "
f"{res.outcome} killed={res.signals.units_killed} "
f"lost={res.signals.units_lost}"
)
@pytest.mark.parametrize("level", ["easy", "medium", "hard"])
def test_brute_east_policy_loses(level):
"""Brute attack_move east out of the corridor must LOSE โ€” the squad
abandons the choke and meets the full force in the open; it is
surrounded and focus-fired down before any kills register."""
pytest.importorskip("openra_train")
from openra_bench.eval_core import run_level
c = compile_level(load_pack(PACK_PATH), level)
for s in (1, 2, 3, 4):
res = run_level(c, _brute_east_policy, seed=s)
assert res.outcome == "loss", (
f"{level} seed={s}: brute east must LOSE; got {res.outcome} "
f"killed={res.signals.units_killed} lost={res.signals.units_lost}"
)
@pytest.mark.parametrize("level", ["easy", "medium", "hard"])
def test_intended_hold_wins(level):
"""The intended hold-the-choke policy (anchor in the corridor +
focus-fire the frontmost enemy) WINS on every level/seed โ€” the
3-cell corridor caps the larger force to a 3-abreast trickle the
squad grinds down, keeping >=3 tanks anchored in the choke
region (recalibrated 2026-05-20: killed 9-10, lost 0-1)."""
pytest.importorskip("openra_train")
from openra_bench.eval_core import run_level
c = compile_level(load_pack(PACK_PATH), level)
for s in (1, 2, 3, 4):
res = run_level(c, _intended_hold_policy, seed=s)
assert res.outcome == "win", (
f"{level} seed={s}: intended hold should WIN; got {res.outcome} "
f"killed={res.signals.units_killed} lost={res.signals.units_lost}"
)