Spaces:
Sleeping
fix(scenario): combat-hold-chokepoint — recalibrate after engine movement fixes
Browse filesThe OpenRA-Rust movement fixes (moving units fire and take fire en
route; attack_unit on out-of-sight targets paths normally) made a
static auto-firing squad strong enough that the easy tier was
beatable by a pure stall: the pre-placed stance:2 squad ground a
12-tank trickle to the kill quota of 8 with no agent input.
Recalibration:
- easy: enemy force 12 -> 14 light tanks, kill quota 8 -> 9 so only
active focus-fire of the frontmost tank clears the bar; idle spread
auto-fire is worn down first (stall now killed 7-8 < 9, lost 3-4).
- hard: kill quota 8 -> 9 (matches the comment header and medium).
- all tiers: added a units_in_region win clause requiring >=3 tanks
anchored in the choke region (53,20,r12). This makes the hold
load-bearing — a wrong-path retreat WEST into the open could still
scrape the quota on some hard seeds, but now fails the geometry.
Bar verified every level x seeds 1-4: stall / brute-east /
wrong-path-west all LOSE; intended hold-the-choke WINS.
|
@@ -157,29 +157,31 @@ base:
|
|
| 157 |
|
| 158 |
levels:
|
| 159 |
# ── EASY ────────────────────────────────────────────────────────────
|
| 160 |
-
# Bare chokepoint-hold skill. 4x 2tnk vs a force of
|
| 161 |
# (1tnk) hunting from the east. Holding the 3-cell corridor caps the
|
| 162 |
-
# enemy to a 3-abreast trickle —
|
| 163 |
-
# into the open east, or letting the enemy fan into the open
|
| 164 |
-
# gets the squad surrounded. Win: kill >=
|
| 165 |
-
# alive, before tick 5400.
|
| 166 |
easy:
|
| 167 |
description: >
|
| 168 |
Four medium tanks (2tnk) hold a narrow corridor. Two silo walls
|
| 169 |
split the map; the ONLY path between the west and east halves is
|
| 170 |
a 3-cell-tall lane (y=19,20,21) running x=44..96. Your squad is
|
| 171 |
pre-placed inside the corridor at the choke (around x=52..53). A
|
| 172 |
-
force of
|
| 173 |
-
and hunts your squad — every enemy drives at the nearest
|
| 174 |
-
and pursues. Win by destroying at least
|
| 175 |
-
keeping at least THREE of your tanks alive
|
| 176 |
-
|
| 177 |
-
|
| 178 |
-
|
| 179 |
-
|
| 180 |
-
|
| 181 |
-
|
| 182 |
-
|
|
|
|
|
|
|
| 183 |
overrides:
|
| 184 |
actors:
|
| 185 |
# SQUAD — 4 medium tanks PRE-PLACED at the choke, inside the
|
|
@@ -195,9 +197,15 @@ levels:
|
|
| 195 |
# fight. building_count_gte:fact:1 in the win clause; razed
|
| 196 |
# only if the enemy breaks the hold and reaches the west base.
|
| 197 |
- {type: fact, owner: agent, position: [10, 20]}
|
| 198 |
-
# ENEMY FORCE —
|
| 199 |
# They converge on the squad and pursue; the corridor funnels
|
| 200 |
-
# them to a 3-abreast trickle through the lane.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 201 |
- {type: 1tnk, owner: enemy, position: [104, 19], stance: 3, count: 1}
|
| 202 |
- {type: 1tnk, owner: enemy, position: [104, 20], stance: 3, count: 1}
|
| 203 |
- {type: 1tnk, owner: enemy, position: [104, 21], stance: 3, count: 1}
|
|
@@ -210,6 +218,8 @@ levels:
|
|
| 210 |
- {type: 1tnk, owner: enemy, position: [110, 19], stance: 3, count: 1}
|
| 211 |
- {type: 1tnk, owner: enemy, position: [110, 20], stance: 3, count: 1}
|
| 212 |
- {type: 1tnk, owner: enemy, position: [110, 21], stance: 3, count: 1}
|
|
|
|
|
|
|
| 213 |
# Persistent far-east enemy fact — absorbs engine auto-done on
|
| 214 |
# enemy-wipe so the terminal frame is evaluated.
|
| 215 |
- {type: fact, owner: enemy, position: [122, 20]}
|
|
@@ -2020,9 +2030,15 @@ levels:
|
|
| 2020 |
- {type: silo, owner: enemy, position: [ 96, 38], stance: 0}
|
| 2021 |
win_condition:
|
| 2022 |
all_of:
|
| 2023 |
-
- {units_killed_gte:
|
| 2024 |
- {own_units_gte: 3}
|
| 2025 |
- {building_count_gte: {type: fact, n: 1}}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2026 |
- {within_ticks: 5400}
|
| 2027 |
fail_condition:
|
| 2028 |
any_of:
|
|
@@ -3892,6 +3908,8 @@ levels:
|
|
| 3892 |
- {units_killed_gte: 9}
|
| 3893 |
- {own_units_gte: 3}
|
| 3894 |
- {building_count_gte: {type: fact, n: 1}}
|
|
|
|
|
|
|
| 3895 |
- {within_ticks: 5400}
|
| 3896 |
fail_condition:
|
| 3897 |
any_of:
|
|
@@ -7594,9 +7612,15 @@ levels:
|
|
| 7594 |
- {type: silo, owner: enemy, position: [ 96, 38], stance: 0, spawn_point: 1}
|
| 7595 |
win_condition:
|
| 7596 |
all_of:
|
| 7597 |
-
- {units_killed_gte:
|
| 7598 |
- {own_units_gte: 3}
|
| 7599 |
- {building_count_gte: {type: fact, n: 1}}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 7600 |
- {within_ticks: 5400}
|
| 7601 |
fail_condition:
|
| 7602 |
any_of:
|
|
|
|
| 157 |
|
| 158 |
levels:
|
| 159 |
# ── EASY ────────────────────────────────────────────────────────────
|
| 160 |
+
# Bare chokepoint-hold skill. 4x 2tnk vs a force of 14 light tanks
|
| 161 |
# (1tnk) hunting from the east. Holding the 3-cell corridor caps the
|
| 162 |
+
# enemy to a 3-abreast trickle — concentrated fire out-trades it.
|
| 163 |
+
# Charging into the open east, or letting the enemy fan into the open
|
| 164 |
+
# west, gets the squad surrounded. Win: kill >=9, keep >=3 tanks in
|
| 165 |
+
# the choke region, fact alive, before tick 5400.
|
| 166 |
easy:
|
| 167 |
description: >
|
| 168 |
Four medium tanks (2tnk) hold a narrow corridor. Two silo walls
|
| 169 |
split the map; the ONLY path between the west and east halves is
|
| 170 |
a 3-cell-tall lane (y=19,20,21) running x=44..96. Your squad is
|
| 171 |
pre-placed inside the corridor at the choke (around x=52..53). A
|
| 172 |
+
force of FOURTEEN enemy light tanks (1tnk) stages in the EAST
|
| 173 |
+
OPEN and hunts your squad — every enemy drives at the nearest
|
| 174 |
+
tank and pursues. Win by destroying at least NINE enemy units
|
| 175 |
+
while keeping at least THREE of your tanks alive AND anchored in
|
| 176 |
+
the corridor, with your construction yard intact, before tick
|
| 177 |
+
5400. HOLD THE CORRIDOR: keep your four tanks anchored in the
|
| 178 |
+
lane and concentrate their fire on the frontmost enemy — the
|
| 179 |
+
walls confine the attackers to a 3-abreast trickle you can grind
|
| 180 |
+
down. Sitting idle and letting the squad spread its fire across
|
| 181 |
+
the front is too slow — the force wears you down first. Charging
|
| 182 |
+
EAST out of the corridor into the open, or pulling WEST and
|
| 183 |
+
letting the enemy fan out, lets the whole force surround you at
|
| 184 |
+
once and focus-fire your squad down.
|
| 185 |
overrides:
|
| 186 |
actors:
|
| 187 |
# SQUAD — 4 medium tanks PRE-PLACED at the choke, inside the
|
|
|
|
| 197 |
# fight. building_count_gte:fact:1 in the win clause; razed
|
| 198 |
# only if the enemy breaks the hold and reaches the west base.
|
| 199 |
- {type: fact, owner: agent, position: [10, 20]}
|
| 200 |
+
# ENEMY FORCE — 14 light tanks hunting from the east open.
|
| 201 |
# They converge on the squad and pursue; the corridor funnels
|
| 202 |
+
# them to a 3-abreast trickle through the lane. (Recalibrated
|
| 203 |
+
# 2026-05-20: bumped 12→14 after the engine movement fixes —
|
| 204 |
+
# a pre-placed stance:2 squad on pure auto-fire could grind a
|
| 205 |
+
# 12-tank trickle to the old kill quota unaided, so a stall
|
| 206 |
+
# policy won for free. 14 attackers + a quota of 9 means only
|
| 207 |
+
# active focus-fire of the frontmost tank clears the bar; the
|
| 208 |
+
# spread auto-fire of an idle squad is worn down first.)
|
| 209 |
- {type: 1tnk, owner: enemy, position: [104, 19], stance: 3, count: 1}
|
| 210 |
- {type: 1tnk, owner: enemy, position: [104, 20], stance: 3, count: 1}
|
| 211 |
- {type: 1tnk, owner: enemy, position: [104, 21], stance: 3, count: 1}
|
|
|
|
| 218 |
- {type: 1tnk, owner: enemy, position: [110, 19], stance: 3, count: 1}
|
| 219 |
- {type: 1tnk, owner: enemy, position: [110, 20], stance: 3, count: 1}
|
| 220 |
- {type: 1tnk, owner: enemy, position: [110, 21], stance: 3, count: 1}
|
| 221 |
+
- {type: 1tnk, owner: enemy, position: [106, 18], stance: 3, count: 1}
|
| 222 |
+
- {type: 1tnk, owner: enemy, position: [108, 18], stance: 3, count: 1}
|
| 223 |
# Persistent far-east enemy fact — absorbs engine auto-done on
|
| 224 |
# enemy-wipe so the terminal frame is evaluated.
|
| 225 |
- {type: fact, owner: enemy, position: [122, 20]}
|
|
|
|
| 2030 |
- {type: silo, owner: enemy, position: [ 96, 38], stance: 0}
|
| 2031 |
win_condition:
|
| 2032 |
all_of:
|
| 2033 |
+
- {units_killed_gte: 9}
|
| 2034 |
- {own_units_gte: 3}
|
| 2035 |
- {building_count_gte: {type: fact, n: 1}}
|
| 2036 |
+
# HOLD THE CORRIDOR — ≥3 tanks must still be inside the choke
|
| 2037 |
+
# region at the win evaluation. A squad that charged east
|
| 2038 |
+
# (brute) or pulled west into the open (wrong-path) fails the
|
| 2039 |
+
# geometry even if it scraped the kill quota; only a squad
|
| 2040 |
+
# anchored in the lane satisfies it.
|
| 2041 |
+
- {units_in_region_gte: {x: 53, y: 20, radius: 12, n: 3}}
|
| 2042 |
- {within_ticks: 5400}
|
| 2043 |
fail_condition:
|
| 2044 |
any_of:
|
|
|
|
| 3908 |
- {units_killed_gte: 9}
|
| 3909 |
- {own_units_gte: 3}
|
| 3910 |
- {building_count_gte: {type: fact, n: 1}}
|
| 3911 |
+
# HOLD THE CORRIDOR — ≥3 tanks anchored in the choke region.
|
| 3912 |
+
- {units_in_region_gte: {x: 53, y: 20, radius: 12, n: 3}}
|
| 3913 |
- {within_ticks: 5400}
|
| 3914 |
fail_condition:
|
| 3915 |
any_of:
|
|
|
|
| 7612 |
- {type: silo, owner: enemy, position: [ 96, 38], stance: 0, spawn_point: 1}
|
| 7613 |
win_condition:
|
| 7614 |
all_of:
|
| 7615 |
+
- {units_killed_gte: 9}
|
| 7616 |
- {own_units_gte: 3}
|
| 7617 |
- {building_count_gte: {type: fact, n: 1}}
|
| 7618 |
+
# HOLD THE CORRIDOR — ≥3 tanks anchored in the choke region.
|
| 7619 |
+
# Critical on hard: with the seed-flipped enemy cluster a
|
| 7620 |
+
# wrong-path retreat WEST into the open could still scrape the
|
| 7621 |
+
# kill quota on some seeds; the geometry clause makes that a
|
| 7622 |
+
# LOSS — the squad must be in the lane, not just alive.
|
| 7623 |
+
- {units_in_region_gte: {x: 53, y: 20, radius: 12, n: 3}}
|
| 7624 |
- {within_ticks: 5400}
|
| 7625 |
fail_condition:
|
| 7626 |
any_of:
|
|
@@ -8,18 +8,30 @@ corridor, 4 medium tanks grind a 12-16 strong light-tank force down
|
|
| 8 |
piecemeal; the same squad fighting in the open is surrounded and
|
| 9 |
focus-fired down.
|
| 10 |
|
| 11 |
-
Bar (
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 12 |
• stall (only observe) -> LOSS. The pre-placed squad
|
| 13 |
auto-fires (stance:2) but SPREADS its fire across the 3-abreast
|
| 14 |
-
front instead of concentrating it, kills too slowly
|
| 15 |
-
down -> own_units_gte:2 busts
|
|
|
|
| 16 |
• brute attack_move east -> LOSS. The squad charges out of the
|
| 17 |
corridor into the open; the whole force converges and focus-fires
|
| 18 |
-
all 4 tanks -> loss cap busted before
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 19 |
• intended hold-the-choke -> WIN. The squad stays anchored in
|
| 20 |
the corridor and focus-fires the frontmost enemy; the 3-abreast
|
| 21 |
-
funnel is ground down; the squad keeps >=3 tanks
|
| 22 |
-
kill quota (killed
|
| 23 |
|
| 24 |
Engine note: `silo` walls block PATHING but not weapons fire, so the
|
| 25 |
chokepoint works by CONFINING the attacker to the 3-row lane (capping
|
|
@@ -46,8 +58,11 @@ PACK_PATH = PACKS / "combat-hold-chokepoint.yaml"
|
|
| 46 |
# ── unit-level predicate checks ──────────────────────────────────────
|
| 47 |
|
| 48 |
|
| 49 |
-
def _ctx(n_units, tick=1000, killed=0, fact=1):
|
| 50 |
-
"""Synthesize a WinContext for predicate-level checks.
|
|
|
|
|
|
|
|
|
|
| 51 |
import types
|
| 52 |
|
| 53 |
sig = types.SimpleNamespace(
|
|
@@ -63,20 +78,23 @@ def _ctx(n_units, tick=1000, killed=0, fact=1):
|
|
| 63 |
return WinContext(
|
| 64 |
signals=sig,
|
| 65 |
render_state={
|
| 66 |
-
"units_summary": [
|
|
|
|
|
|
|
| 67 |
},
|
| 68 |
)
|
| 69 |
|
| 70 |
|
| 71 |
def test_predicates_easy():
|
| 72 |
c = compile_level(load_pack(PACK_PATH), "easy")
|
| 73 |
-
# 3 tanks alive,
|
| 74 |
-
|
|
|
|
| 75 |
assert evaluate(c.win_condition, _ctx(4, tick=3000, killed=12))
|
| 76 |
# only 2 tanks alive -> own_units_gte:3 fails
|
| 77 |
assert not evaluate(c.win_condition, _ctx(2, tick=3000, killed=12))
|
| 78 |
-
# kill quota unmet -> win fails
|
| 79 |
-
assert not evaluate(c.win_condition, _ctx(4, tick=3000, killed=
|
| 80 |
# fact razed -> win fails
|
| 81 |
assert not evaluate(c.win_condition, _ctx(4, tick=3000, killed=12, fact=0))
|
| 82 |
# 1 tank left -> fail clause fires (not own_units_gte:2)
|
|
@@ -91,9 +109,10 @@ def test_predicates_easy():
|
|
| 91 |
|
| 92 |
|
| 93 |
def test_predicates_medium_and_hard():
|
| 94 |
-
for lvl, quota in (("medium", 9), ("hard",
|
| 95 |
c = compile_level(load_pack(PACK_PATH), lvl)
|
| 96 |
-
# meeting the quota with >=3 tanks and fact alive
|
|
|
|
| 97 |
assert evaluate(c.win_condition, _ctx(3, tick=3000, killed=quota))
|
| 98 |
# one short of the quota -> win fails
|
| 99 |
assert not evaluate(
|
|
@@ -105,6 +124,27 @@ def test_predicates_medium_and_hard():
|
|
| 105 |
assert evaluate(c.fail_condition, _ctx(4, tick=5402, killed=quota))
|
| 106 |
|
| 107 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 108 |
def test_timeout_loss_is_reachable_on_every_level():
|
| 109 |
"""No draw degeneracy: after_ticks 5401 fits inside max_turns on
|
| 110 |
every level (~90 ticks/turn ⇒ 93 + 90·(max_turns-1))."""
|
|
@@ -170,6 +210,21 @@ def _brute_east_policy(rs, Command):
|
|
| 170 |
]
|
| 171 |
|
| 172 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 173 |
def _intended_hold_policy(rs, Command):
|
| 174 |
"""Intended hold-the-choke: keep the squad anchored in the corridor
|
| 175 |
and FOCUS-FIRE the frontmost (lowest cell_x) enemy with every tank.
|
|
@@ -194,8 +249,10 @@ def _intended_hold_policy(rs, Command):
|
|
| 194 |
@pytest.mark.parametrize("level", ["easy", "medium", "hard"])
|
| 195 |
def test_stall_policy_loses(level):
|
| 196 |
"""Stall must LOSE on every level/seed — the pre-placed squad
|
| 197 |
-
auto-fires but spreads fire instead of concentrating it, so it
|
| 198 |
-
|
|
|
|
|
|
|
| 199 |
pytest.importorskip("openra_train")
|
| 200 |
from openra_bench.eval_core import run_level
|
| 201 |
|
|
@@ -208,6 +265,25 @@ def test_stall_policy_loses(level):
|
|
| 208 |
)
|
| 209 |
|
| 210 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 211 |
@pytest.mark.parametrize("level", ["easy", "medium", "hard"])
|
| 212 |
def test_brute_east_policy_loses(level):
|
| 213 |
"""Brute attack_move east out of the corridor must LOSE — the squad
|
|
@@ -230,8 +306,8 @@ def test_intended_hold_wins(level):
|
|
| 230 |
"""The intended hold-the-choke policy (anchor in the corridor +
|
| 231 |
focus-fire the frontmost enemy) WINS on every level/seed — the
|
| 232 |
3-cell corridor caps the larger force to a 3-abreast trickle the
|
| 233 |
-
squad grinds down, keeping >=3 tanks
|
| 234 |
-
killed
|
| 235 |
pytest.importorskip("openra_train")
|
| 236 |
from openra_bench.eval_core import run_level
|
| 237 |
|
|
|
|
| 8 |
piecemeal; the same squad fighting in the open is surrounded and
|
| 9 |
focus-fired down.
|
| 10 |
|
| 11 |
+
Bar (recalibrated 2026-05-20 after the OpenRA-Rust engine movement
|
| 12 |
+
fixes — moving units now fire and take fire en route, and attack_unit
|
| 13 |
+
on an out-of-sight target paths normally. Those fixes made a static
|
| 14 |
+
auto-firing squad strong enough that the old 12-tank / quota-8 easy
|
| 15 |
+
tier was beatable by a pure stall; the recalibration bumps easy to 14
|
| 16 |
+
attackers + quota 9 and adds a corridor-geometry win clause):
|
| 17 |
+
|
| 18 |
• stall (only observe) -> LOSS. The pre-placed squad
|
| 19 |
auto-fires (stance:2) but SPREADS its fire across the 3-abreast
|
| 20 |
+
front instead of concentrating it, kills too slowly (~7-8 < the
|
| 21 |
+
quota of 9), and is worn down -> own_units_gte:2 busts AND the
|
| 22 |
+
kill quota is unmet (killed 7-8, lost 3-4).
|
| 23 |
• brute attack_move east -> LOSS. The squad charges out of the
|
| 24 |
corridor into the open; the whole force converges and focus-fires
|
| 25 |
+
all 4 tanks -> loss cap busted before the kill quota (killed 0-1,
|
| 26 |
+
lost 3-4), and the squad is no longer in the choke region.
|
| 27 |
+
• wrong-path retreat WEST -> LOSS. Pulling the squad west out of
|
| 28 |
+
the corridor into the open: even when it scrapes the kill quota
|
| 29 |
+
the survival cap busts and the units_in_region geometry clause
|
| 30 |
+
fails (the squad is at x~20, not in the lane).
|
| 31 |
• intended hold-the-choke -> WIN. The squad stays anchored in
|
| 32 |
the corridor and focus-fires the frontmost enemy; the 3-abreast
|
| 33 |
+
funnel is ground down; the squad keeps >=3 tanks in the choke
|
| 34 |
+
region and hits the kill quota (killed 9-10, lost 0-1).
|
| 35 |
|
| 36 |
Engine note: `silo` walls block PATHING but not weapons fire, so the
|
| 37 |
chokepoint works by CONFINING the attacker to the 3-row lane (capping
|
|
|
|
| 58 |
# ── unit-level predicate checks ──────────────────────────────────────
|
| 59 |
|
| 60 |
|
| 61 |
+
def _ctx(n_units, tick=1000, killed=0, fact=1, pos=(52, 20)):
|
| 62 |
+
"""Synthesize a WinContext for predicate-level checks. `pos` is the
|
| 63 |
+
cell every own unit sits at — defaults to the choke (52,20) so the
|
| 64 |
+
units_in_region win clause is satisfied; pass an out-of-corridor
|
| 65 |
+
cell to exercise the geometry teeth."""
|
| 66 |
import types
|
| 67 |
|
| 68 |
sig = types.SimpleNamespace(
|
|
|
|
| 78 |
return WinContext(
|
| 79 |
signals=sig,
|
| 80 |
render_state={
|
| 81 |
+
"units_summary": [
|
| 82 |
+
{"cell_x": pos[0], "cell_y": pos[1]}
|
| 83 |
+
] * n_units,
|
| 84 |
},
|
| 85 |
)
|
| 86 |
|
| 87 |
|
| 88 |
def test_predicates_easy():
|
| 89 |
c = compile_level(load_pack(PACK_PATH), "easy")
|
| 90 |
+
# 3 tanks alive (in the choke region), 9 kills, fact alive, in
|
| 91 |
+
# time -> WIN
|
| 92 |
+
assert evaluate(c.win_condition, _ctx(3, tick=3000, killed=9))
|
| 93 |
assert evaluate(c.win_condition, _ctx(4, tick=3000, killed=12))
|
| 94 |
# only 2 tanks alive -> own_units_gte:3 fails
|
| 95 |
assert not evaluate(c.win_condition, _ctx(2, tick=3000, killed=12))
|
| 96 |
+
# kill quota unmet (8 < 9) -> win fails
|
| 97 |
+
assert not evaluate(c.win_condition, _ctx(4, tick=3000, killed=8))
|
| 98 |
# fact razed -> win fails
|
| 99 |
assert not evaluate(c.win_condition, _ctx(4, tick=3000, killed=12, fact=0))
|
| 100 |
# 1 tank left -> fail clause fires (not own_units_gte:2)
|
|
|
|
| 109 |
|
| 110 |
|
| 111 |
def test_predicates_medium_and_hard():
|
| 112 |
+
for lvl, quota in (("medium", 9), ("hard", 9)):
|
| 113 |
c = compile_level(load_pack(PACK_PATH), lvl)
|
| 114 |
+
# meeting the quota with >=3 tanks in the choke and fact alive
|
| 115 |
+
# -> WIN
|
| 116 |
assert evaluate(c.win_condition, _ctx(3, tick=3000, killed=quota))
|
| 117 |
# one short of the quota -> win fails
|
| 118 |
assert not evaluate(
|
|
|
|
| 124 |
assert evaluate(c.fail_condition, _ctx(4, tick=5402, killed=quota))
|
| 125 |
|
| 126 |
|
| 127 |
+
def test_corridor_geometry_clause_bites():
|
| 128 |
+
"""The units_in_region win clause makes the hold load-bearing: a
|
| 129 |
+
squad that met the kill quota and survival cap but abandoned the
|
| 130 |
+
corridor (charged east or pulled west into the open) still fails
|
| 131 |
+
the win — only a squad anchored in the choke satisfies it."""
|
| 132 |
+
for lvl, quota in (("easy", 9), ("medium", 9), ("hard", 9)):
|
| 133 |
+
c = compile_level(load_pack(PACK_PATH), lvl)
|
| 134 |
+
# Anchored in the choke (52,20) -> WIN
|
| 135 |
+
assert evaluate(
|
| 136 |
+
c.win_condition, _ctx(4, tick=3000, killed=quota, pos=(52, 20))
|
| 137 |
+
)
|
| 138 |
+
# Pulled WEST into the open (x=20) -> geometry clause fails
|
| 139 |
+
assert not evaluate(
|
| 140 |
+
c.win_condition, _ctx(4, tick=3000, killed=quota, pos=(20, 20))
|
| 141 |
+
)
|
| 142 |
+
# Charged EAST out of the corridor (x=100) -> geometry fails
|
| 143 |
+
assert not evaluate(
|
| 144 |
+
c.win_condition, _ctx(4, tick=3000, killed=quota, pos=(100, 20))
|
| 145 |
+
)
|
| 146 |
+
|
| 147 |
+
|
| 148 |
def test_timeout_loss_is_reachable_on_every_level():
|
| 149 |
"""No draw degeneracy: after_ticks 5401 fits inside max_turns on
|
| 150 |
every level (~90 ticks/turn ⇒ 93 + 90·(max_turns-1))."""
|
|
|
|
| 210 |
]
|
| 211 |
|
| 212 |
|
| 213 |
+
def _wrong_path_west_policy(rs, Command):
|
| 214 |
+
"""Wrong-path: pull the squad WEST out of the corridor into the
|
| 215 |
+
open ground behind the choke. Even when the retreating squad
|
| 216 |
+
auto-fires down enough pursuers to scrape the kill quota, it ends
|
| 217 |
+
outside the choke region (geometry clause fails) and the survival
|
| 218 |
+
cap busts -> LOSS on every level/seed."""
|
| 219 |
+
units = rs.get("units_summary", []) or []
|
| 220 |
+
if not units:
|
| 221 |
+
return [Command.observe()]
|
| 222 |
+
return [
|
| 223 |
+
Command.move_units([str(u["id"])], target_x=20, target_y=u["cell_y"])
|
| 224 |
+
for u in units
|
| 225 |
+
]
|
| 226 |
+
|
| 227 |
+
|
| 228 |
def _intended_hold_policy(rs, Command):
|
| 229 |
"""Intended hold-the-choke: keep the squad anchored in the corridor
|
| 230 |
and FOCUS-FIRE the frontmost (lowest cell_x) enemy with every tank.
|
|
|
|
| 249 |
@pytest.mark.parametrize("level", ["easy", "medium", "hard"])
|
| 250 |
def test_stall_policy_loses(level):
|
| 251 |
"""Stall must LOSE on every level/seed — the pre-placed squad
|
| 252 |
+
auto-fires but spreads fire instead of concentrating it, so it
|
| 253 |
+
kills too slowly (below the quota of 9) and is worn down before
|
| 254 |
+
the quota is met -> own_units_gte:2 busts AND the kill quota is
|
| 255 |
+
unmet (recalibrated 2026-05-20: 14-tank easy / quota 9)."""
|
| 256 |
pytest.importorskip("openra_train")
|
| 257 |
from openra_bench.eval_core import run_level
|
| 258 |
|
|
|
|
| 265 |
)
|
| 266 |
|
| 267 |
|
| 268 |
+
@pytest.mark.parametrize("level", ["easy", "medium", "hard"])
|
| 269 |
+
def test_wrong_path_west_policy_loses(level):
|
| 270 |
+
"""Wrong-path retreat WEST out of the corridor must LOSE on every
|
| 271 |
+
level/seed — the squad abandons the choke; the survival cap busts
|
| 272 |
+
and/or the units_in_region geometry win clause fails (the squad
|
| 273 |
+
sits in the open at x~20, not in the lane)."""
|
| 274 |
+
pytest.importorskip("openra_train")
|
| 275 |
+
from openra_bench.eval_core import run_level
|
| 276 |
+
|
| 277 |
+
c = compile_level(load_pack(PACK_PATH), level)
|
| 278 |
+
for s in (1, 2, 3, 4):
|
| 279 |
+
res = run_level(c, _wrong_path_west_policy, seed=s)
|
| 280 |
+
assert res.outcome == "loss", (
|
| 281 |
+
f"{level} seed={s}: wrong-path west must LOSE; got "
|
| 282 |
+
f"{res.outcome} killed={res.signals.units_killed} "
|
| 283 |
+
f"lost={res.signals.units_lost}"
|
| 284 |
+
)
|
| 285 |
+
|
| 286 |
+
|
| 287 |
@pytest.mark.parametrize("level", ["easy", "medium", "hard"])
|
| 288 |
def test_brute_east_policy_loses(level):
|
| 289 |
"""Brute attack_move east out of the corridor must LOSE — the squad
|
|
|
|
| 306 |
"""The intended hold-the-choke policy (anchor in the corridor +
|
| 307 |
focus-fire the frontmost enemy) WINS on every level/seed — the
|
| 308 |
3-cell corridor caps the larger force to a 3-abreast trickle the
|
| 309 |
+
squad grinds down, keeping >=3 tanks anchored in the choke
|
| 310 |
+
region (recalibrated 2026-05-20: killed 9-10, lost 0-1)."""
|
| 311 |
pytest.importorskip("openra_train")
|
| 312 |
from openra_bench.eval_core import run_level
|
| 313 |
|