yxc20098 commited on
Commit
8c3e323
·
1 Parent(s): 21e3501

feat(scenario): combat-retreat-after-engagement — disengage to preserve force (SC2 retreat micro / military withdrawal anchor) [Wave-6 salvage]

Browse files
openra_bench/scenarios/packs/combat-retreat-after-engagement.yaml ADDED
@@ -0,0 +1,309 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # combat-retreat-after-engagement — disengage when battle goes poorly
2
+ # to PRESERVE the force (Wave-6 reasoning pack).
3
+ #
4
+ # Real-world anchors:
5
+ # - SC2 retreat micro: when a fight is going poorly, pull units back
6
+ # to a safe rally point before they die. The decision is timing —
7
+ # too early and you don't score damage; too late and you lose the
8
+ # force. "Live to fight another day."
9
+ # - Military tactical-withdrawal doctrine (skirmish-and-pullback,
10
+ # covering retreat, preservation of force). A small commitment
11
+ # trades hits for intel/attrition, then breaks contact before the
12
+ # enemy can encircle and destroy.
13
+ #
14
+ # Idiom (the four-policy bar — same shape used by combat-kite and
15
+ # combat-focus-fire):
16
+ # • stall (only observe) → LOSS. The kill bar
17
+ # (units_killed_gte:3) is never met → after_ticks LOSS.
18
+ # • brute attack-until-death (commit fully) → LOSS. The 2× heavy
19
+ # 3tnk + 5× e3 rocket-infantry mass out-trades 4× 2tnk in a
20
+ # stand-up fight at the enemy line; tanks die two-by-two and
21
+ # `own_units_gte:3` flips to fail before the survivors can pull
22
+ # back.
23
+ # • never-engage (sit at safe zone) → LOSS. Three tanks are
24
+ # in the safe-zone region the whole episode, but the kill bar is
25
+ # unmet → after_ticks LOSS.
26
+ # • intended engage-then-retreat → WIN. March east into
27
+ # fire range, attack_unit the soft e3 rocket infantry (the kill
28
+ # bar is units_killed_gte:3, and 5× e3 are the cheap kills), then
29
+ # the moment the first tank is gone (or the second is hurt) issue
30
+ # a move_units order WEST back to the safe-zone region (x≈5,
31
+ # y≈20, r=6). All four tanks need not survive — the bar is
32
+ # own_units_gte:3 (lose at most ONE tank) AND ≥3 tanks back in
33
+ # the safe-zone radius. The decision under test is DISENGAGE
34
+ # TIMING: bail BEFORE attrition busts the force-preservation cap.
35
+ #
36
+ # Why "battle is unwinnable" (verified vs engine combat sheet):
37
+ # • 4× 2tnk vs 5× e3 + 2× 3tnk: head-on, the two 3tnks alone
38
+ # (Soviet heavy cannon, ~range 5, high anti-armour DPS) trade
39
+ # 2tnk-for-3tnk roughly 1:1 and the rocket infantry (e3 Dragon
40
+ # launcher, anti-armour) finishes the surviving 2tnks. A brute
41
+ # attack_move column dies before clearing the squad — the loss
42
+ # cap (lose at most 1) flips on tank #2 going down, while only
43
+ # ~2 e3 have died.
44
+ # • The "engage 3 e3 → break contact" play kills 3 cheap targets
45
+ # fast (4-vs-1 focus on each e3 ends it in 1-2 decision turns)
46
+ # then bails BEFORE the 3tnks have ground enough HP off the
47
+ # column to crater the survival cap.
48
+ #
49
+ # Engine guardrails:
50
+ # • Map: rush-hour-arena (128 × 40, playable x in [2..126],
51
+ # y in [2..38]). Tanks stage at the west edge (x=5..7), enemies
52
+ # at (60,20). Cells verified placeable per CLAUDE.md (60,28 was
53
+ # called out as silently-failing — we use (60,20) and adjacent
54
+ # y=17..23).
55
+ # • Enemy bot is `guard` (not `hunt`): guard holds post and lunges
56
+ # within GUARD_AGGRO~16, snaps back past leash 18. This lets the
57
+ # enemy COMMIT during the engagement but NOT pursue the retreating
58
+ # tanks all the way home — the retreat is genuinely safe once the
59
+ # tanks break leash range. Critically, `guard` also means the
60
+ # never-engage policy is not punished by enemy approach (the
61
+ # enemies stay east), which is exactly what makes never-engage a
62
+ # CLEAN kill-bar LOSS rather than a confounded contact loss.
63
+ # • Persistent unarmed `fact` far east (x=120, y=20) prevents engine
64
+ # auto-done on enemy-elimination collapsing the run to DRAW — if
65
+ # the agent accidentally kills everything, the run still evaluates
66
+ # the in-region predicate at the safe zone (and the agent likely
67
+ # hasn't retreated → fail clause fires on the clock or attrition).
68
+ # • after_ticks 4501 ≤ 93 + 90·(51-1) = 4593 → the stall LOSS is
69
+ # reachable inside max_turns; no draw degeneracy.
70
+ # • units_lost / units_killed are signal-level integers (combat-
71
+ # focus-fire idiom); units_in_region_gte is the geometry predicate
72
+ # established by action-multiunit-coordination / artofwar-lure-
73
+ # the-tiger.
74
+
75
+ meta:
76
+ id: combat-retreat-after-engagement
77
+ title: 'Combat Retreat — Disengage to Preserve the Force'
78
+ capability: reasoning
79
+ real_world_meaning: >
80
+ Four medium tanks face a numerically superior enemy squad (rocket
81
+ infantry + heavy tanks) at close range. The fight is unwinnable
82
+ head-on: the rocket infantry and heavy cannons together out-trade
83
+ the column inside the loss cap. The only winning play is the
84
+ SC2 retreat-micro idiom: commit briefly to score the kill bar on
85
+ the soft anti-tank infantry (e3), then break contact and pull
86
+ the surviving tanks BACK to the safe rally point in the west
87
+ before attrition busts the force-preservation cap. The decision
88
+ under test is disengage TIMING — stalling never engages (kill
89
+ bar unmet), brute attack-until-death loses the force, and only
90
+ the engage-then-retreat play wins.
91
+ robotics_analogue: >
92
+ Military tactical-withdrawal doctrine and the SC2 retreat-micro
93
+ pattern — when a battle goes poorly, the policy must IDENTIFY
94
+ the losing trade and pull back to a safe rally before the unit
95
+ is destroyed. The skill is reading the engagement state and
96
+ issuing a withdrawal order at the right moment; over- or
97
+ under-commitment both fail. Preservation of force is its own
98
+ objective.
99
+ benchmark_anchor:
100
+ - "SC2 retreat micro / disengage timing"
101
+ - "military tactical withdrawal doctrine"
102
+ - "preservation of force / live-to-fight-another-day"
103
+ - "skirmish-and-pullback tactics"
104
+ author: openra-bench
105
+
106
+ base_map: rush-hour-arena
107
+
108
+ base:
109
+ agent: {faction: allies, cash: 0}
110
+ enemy: {faction: soviet, cash: 0, bot_type: guard}
111
+ tools: [move_units, attack_unit, attack_move, stop]
112
+ planning: true
113
+ # React the instant a tank is destroyed — retreat timing is the
114
+ # capability under test; without own_unit_destroyed the policy
115
+ # only learns of the first loss on the next turn boundary and the
116
+ # second loss lands in the gap. enemy_unit_spotted lets the policy
117
+ # see the contact frame so it can pick targets before opening fire.
118
+ interrupts:
119
+ enemy_unit_spotted: true
120
+ own_unit_destroyed: true
121
+ termination: {max_ticks: 6000}
122
+ actors: []
123
+
124
+ levels:
125
+ # ── EASY ─────────────────────────────────────────────────────────
126
+ # Bare retreat skill: 4 tanks vs LESS enemy (4× e3 + 1× 3tnk).
127
+ # Loss cap forgiving (own_units_gte:2 ⇒ lose up to 2 tanks), kill
128
+ # bar only 2. The engage-then-retreat play is comfortable; the
129
+ # brute may LOSE on the heavy + rocket attrition trade; stall and
130
+ # never-engage LOSE on the kill bar.
131
+ easy:
132
+ description: >
133
+ Four medium tanks (2tnk) stage at the safe zone in the west
134
+ (x=5, y=18..21). A smaller enemy squad of FOUR rocket
135
+ infantry (e3, anti-tank Dragon launcher) escorting ONE heavy
136
+ tank (3tnk) holds the centre at (60, 20). The fight is
137
+ unwinnable head-on — the rocket infantry kill armour fast
138
+ and the 3tnk cannon out-trades the column at close range.
139
+ The intended play: march east into engagement range, kill TWO
140
+ enemy units (the cheap e3s die in 1-2 turns of focused tank
141
+ fire), then move your tanks WEST BACK to the safe zone before
142
+ attrition busts the loss cap. Win when ≥2 enemies are killed
143
+ AND ≥3 of your tanks are within radius 6 of (5,20) AND you
144
+ have ≥2 tanks alive, before tick 4500. Stall, brute attack
145
+ until-death, and never-engage all LOSE.
146
+ overrides:
147
+ actors:
148
+ # Strike force — 4 medium tanks at the safe-zone start.
149
+ - {type: 2tnk, owner: agent, position: [5, 18], stance: 1}
150
+ - {type: 2tnk, owner: agent, position: [5, 19], stance: 1}
151
+ - {type: 2tnk, owner: agent, position: [5, 20], stance: 1}
152
+ - {type: 2tnk, owner: agent, position: [5, 21], stance: 1}
153
+ # Enemy squad — centre at (60,20). 4× e3 anti-tank rockets
154
+ # form the kill bar's soft targets; 1× 3tnk provides the
155
+ # attrition pressure that makes the brute LOSE.
156
+ - {type: e3, owner: enemy, position: [60, 18], stance: 3}
157
+ - {type: e3, owner: enemy, position: [60, 19], stance: 3}
158
+ - {type: e3, owner: enemy, position: [60, 21], stance: 3}
159
+ - {type: e3, owner: enemy, position: [60, 22], stance: 3}
160
+ - {type: 3tnk, owner: enemy, position: [60, 20], stance: 3}
161
+ # Persistent far-east enemy fact — prevents engine auto-done
162
+ # on enemy-elimination so the safe-zone predicate is
163
+ # evaluated rather than the run collapsing to DRAW.
164
+ - {type: fact, owner: enemy, position: [120, 20]}
165
+ win_condition:
166
+ all_of:
167
+ - {units_killed_gte: 2}
168
+ - {units_in_region_gte: {x: 5, y: 20, radius: 6, n: 3}}
169
+ - {own_units_gte: 2}
170
+ - {within_ticks: 4500}
171
+ fail_condition:
172
+ any_of:
173
+ - {after_ticks: 4501}
174
+ - {not: {own_units_gte: 2}}
175
+ max_turns: 51
176
+
177
+ # ── MEDIUM ───────────────────────────────────────────────────────
178
+ # +1 controlled variable vs easy: FULL enemy squad (5× e3 + 2× 3tnk)
179
+ # AND tighter survival bar (own_units_gte:3 ⇒ lose AT MOST ONE
180
+ # tank). The kill bar tightens to 3. The engage window is shorter:
181
+ # the second 3tnk doubles the close-range damage and the brute now
182
+ # loses tanks ~twice as fast. The intended engage-then-retreat
183
+ # still wins — focus-fire 3 e3s (1-2 turns each) and pull back
184
+ # before the heavies grind ≥2 tanks down.
185
+ medium:
186
+ description: >
187
+ Four medium tanks (2tnk) stage at the safe zone in the west
188
+ (x=5, y=18..21). The enemy squad at (60, 20) is FIVE rocket
189
+ infantry (e3, anti-tank Dragon launcher) escorting TWO heavy
190
+ tanks (3tnk). The fight is unwinnable head-on — the rockets
191
+ and heavy cannons together collapse the column at close
192
+ range. The intended play: march east into engagement range,
193
+ focus-fire THREE enemy units (the cheap e3s die in 1-2 turns
194
+ of concentrated tank fire each), then move your tanks WEST
195
+ BACK to the safe zone before attrition takes a second tank.
196
+ Win when ≥3 enemies are killed AND ≥3 of your tanks are
197
+ within radius 6 of (5,20) AND you have ≥3 tanks alive,
198
+ before tick 4500. Stall, brute attack-until-death, and
199
+ never-engage all LOSE.
200
+ overrides:
201
+ actors:
202
+ - {type: 2tnk, owner: agent, position: [5, 18], stance: 1}
203
+ - {type: 2tnk, owner: agent, position: [5, 19], stance: 1}
204
+ - {type: 2tnk, owner: agent, position: [5, 20], stance: 1}
205
+ - {type: 2tnk, owner: agent, position: [5, 21], stance: 1}
206
+ # FULL enemy squad — 5× e3 (kill-bar fodder; anti-tank
207
+ # Dragon launcher) ON THE LINE at x=60, with the 2× 3tnk
208
+ # heavy escort placed 4 cells back at x=64. The e3 line is
209
+ # what the attacker must close on to score kills (Dragon
210
+ # range 5); from the e3 firing line (~x=55) the rear-rank
211
+ # 3tnks (range ~5) cannot yet engage, so they only weigh in
212
+ # if the agent commits PAST the e3 line — i.e. the brute
213
+ # attack_move policy that overshoots. Spread the e3 line
214
+ # across y=17..23 so all four tanks face fire.
215
+ - {type: e3, owner: enemy, position: [60, 17], stance: 3}
216
+ - {type: e3, owner: enemy, position: [60, 18], stance: 3}
217
+ - {type: e3, owner: enemy, position: [60, 19], stance: 3}
218
+ - {type: e3, owner: enemy, position: [60, 21], stance: 3}
219
+ - {type: e3, owner: enemy, position: [60, 22], stance: 3}
220
+ # Heavy tanks placed 4 cells BEHIND the rocket line —
221
+ # within engagement leash for the guard bot to pursue when
222
+ # contact starts (GUARD_AGGRO ~16), but outside their own
223
+ # ~5-cell weapon range from the e3 firing line. They are
224
+ # the attrition trigger for the brute attack_move policy
225
+ # that closes past x=60 into 3tnk cannon range.
226
+ - {type: 3tnk, owner: enemy, position: [64, 19], stance: 3}
227
+ - {type: 3tnk, owner: enemy, position: [64, 21], stance: 3}
228
+ - {type: fact, owner: enemy, position: [120, 20]}
229
+ win_condition:
230
+ all_of:
231
+ - {units_killed_gte: 3}
232
+ - {units_in_region_gte: {x: 5, y: 20, radius: 6, n: 3}}
233
+ - {own_units_gte: 3}
234
+ - {within_ticks: 4500}
235
+ fail_condition:
236
+ any_of:
237
+ - {after_ticks: 4501}
238
+ - {not: {own_units_gte: 3}}
239
+ max_turns: 51
240
+
241
+ # ── HARD ─────────────────────────────────────────────────────────
242
+ # +1 axis vs medium: TWO seed-driven agent spawn_point groups
243
+ # (NORTH safe zone at (5,10) OR SOUTH safe zone at (5,30)). The
244
+ # safe-zone position FLIPS per seed, so a memorised "always
245
+ # retreat to (5,20)" play cannot generalise — the policy must
246
+ # read the obs to find its own start cell and retreat to THAT
247
+ # safe zone. Win predicate is an any_of over the two safe-zone
248
+ # regions so either spawn satisfies the geometry. Enemy formation
249
+ # is symmetric across y=20 mid-latitude so both spawns face the
250
+ # same engagement geometry.
251
+ #
252
+ # Per the CLAUDE.md `spawn_point` contract: ALL agent actors
253
+ # carry an explicit spawn_point (the filter applies only to AGENT
254
+ # actors); the enemy actors are unchanged and always place.
255
+ hard:
256
+ description: >
257
+ Four medium tanks (2tnk) stage at ONE of two safe-zone
258
+ corridors (NORTH at x=5, y=8..11 OR SOUTH at x=5, y=28..31,
259
+ chosen by seed — anti-memorisation). The enemy squad of FIVE
260
+ rocket infantry (e3) escorting TWO heavy tanks (3tnk) holds
261
+ the centre at (60, 20). The fight is unwinnable head-on.
262
+ The intended play: march east-and-toward-centre into
263
+ engagement range, focus-fire THREE enemy units (the cheap
264
+ e3s die fast under concentrated tank fire), then move your
265
+ tanks BACK to YOUR safe zone (the one you started in — read
266
+ your start cell from obs) before attrition takes a second
267
+ tank. Win when ≥3 enemies are killed AND ≥3 of your tanks
268
+ are within radius 6 of YOUR safe zone (north (5,10) OR south
269
+ (5,30)) AND you have ≥3 tanks alive, before tick 4500.
270
+ Stall, brute attack-until-death, never-engage, and retreating
271
+ to the WRONG safe zone all LOSE.
272
+ overrides:
273
+ actors:
274
+ # spawn_point 0 — NORTH safe zone (y=8..11)
275
+ - {type: 2tnk, owner: agent, position: [5, 8], stance: 1, spawn_point: 0}
276
+ - {type: 2tnk, owner: agent, position: [5, 9], stance: 1, spawn_point: 0}
277
+ - {type: 2tnk, owner: agent, position: [5, 10], stance: 1, spawn_point: 0}
278
+ - {type: 2tnk, owner: agent, position: [5, 11], stance: 1, spawn_point: 0}
279
+ # spawn_point 1 — SOUTH safe zone (y=28..31)
280
+ - {type: 2tnk, owner: agent, position: [5, 28], stance: 1, spawn_point: 1}
281
+ - {type: 2tnk, owner: agent, position: [5, 29], stance: 1, spawn_point: 1}
282
+ - {type: 2tnk, owner: agent, position: [5, 30], stance: 1, spawn_point: 1}
283
+ - {type: 2tnk, owner: agent, position: [5, 31], stance: 1, spawn_point: 1}
284
+ # FULL enemy squad — symmetric across y=20 so both spawns
285
+ # face the same engagement geometry. e3 line forward at
286
+ # x=60 (Dragon range 5); 3tnk escort 4 cells back at x=64
287
+ # (out of weapon range from the e3 firing line so the heavy
288
+ # only weighs in on a brute overshoot past the rocket line).
289
+ - {type: e3, owner: enemy, position: [60, 17], stance: 3}
290
+ - {type: e3, owner: enemy, position: [60, 18], stance: 3}
291
+ - {type: e3, owner: enemy, position: [60, 19], stance: 3}
292
+ - {type: e3, owner: enemy, position: [60, 21], stance: 3}
293
+ - {type: e3, owner: enemy, position: [60, 22], stance: 3}
294
+ - {type: 3tnk, owner: enemy, position: [64, 19], stance: 3}
295
+ - {type: 3tnk, owner: enemy, position: [64, 21], stance: 3}
296
+ - {type: fact, owner: enemy, position: [120, 20]}
297
+ win_condition:
298
+ all_of:
299
+ - {units_killed_gte: 3}
300
+ - any_of:
301
+ - {units_in_region_gte: {x: 5, y: 10, radius: 6, n: 3}}
302
+ - {units_in_region_gte: {x: 5, y: 30, radius: 6, n: 3}}
303
+ - {own_units_gte: 3}
304
+ - {within_ticks: 4500}
305
+ fail_condition:
306
+ any_of:
307
+ - {after_ticks: 4501}
308
+ - {not: {own_units_gte: 3}}
309
+ max_turns: 51
tests/test_combat_retreat_after_engagement.py ADDED
@@ -0,0 +1,338 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """combat-retreat-after-engagement — disengage to preserve the force.
2
+
3
+ Bar (four script-policy proxies):
4
+ • stall (observe only) → LOSS (kill bar unmet)
5
+ • brute attack-until-death → LOSS (loses too many tanks)
6
+ • never-engage (sit at safe zone) → LOSS (kill bar unmet)
7
+ • intended engage-then-retreat → WIN
8
+
9
+ The "intended" policy is the spec's load-bearing decision: march east
10
+ into engagement range, focus-fire e3 rocket infantry (the cheap kill-
11
+ bar targets), and the instant the kill bar is met OR a tank is lost
12
+ pull back to the safe-zone radius. The retreat trigger is the
13
+ capability under test — too early ⇒ kill bar fails; too late ⇒
14
+ attrition busts the survival bar.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ from pathlib import Path
20
+
21
+ import pytest
22
+
23
+ from openra_bench.scenarios import load_pack
24
+ from openra_bench.scenarios.loader import compile_level
25
+ from openra_bench.scenarios.win_conditions import WinContext, evaluate
26
+
27
+ PACKS = Path(__file__).parent.parent / "openra_bench" / "scenarios" / "packs"
28
+ PACK_PATH = PACKS / "combat-retreat-after-engagement.yaml"
29
+
30
+
31
+ # ── unit-level predicate checks ──────────────────────────────────────
32
+
33
+
34
+ def _ctx(units_xy=(), tick=1000, killed=0, lost=0):
35
+ """Synthesize a WinContext for predicate-level checks."""
36
+ import types
37
+
38
+ sig = types.SimpleNamespace(
39
+ game_tick=tick,
40
+ units_killed=killed,
41
+ units_lost=lost,
42
+ own_buildings=[],
43
+ own_building_types=set(),
44
+ enemies_seen_ids=set(),
45
+ enemy_buildings_seen_ids=set(),
46
+ )
47
+ return WinContext(
48
+ signals=sig,
49
+ render_state={
50
+ "units_summary": [
51
+ {"cell_x": x, "cell_y": y} for x, y in units_xy
52
+ ]
53
+ },
54
+ )
55
+
56
+
57
+ def test_predicates_easy():
58
+ c = compile_level(load_pack(PACK_PATH), "easy")
59
+ # 3 tanks back in safe zone (5,20,r=6), killed 2 enemies, 1 lost, in time → WIN
60
+ home3 = [(5, 18), (5, 20), (5, 21)]
61
+ assert evaluate(c.win_condition, _ctx(home3, tick=3000, killed=2, lost=1))
62
+ # Kill bar unmet (only 1 killed) → not WIN
63
+ assert not evaluate(c.win_condition, _ctx(home3, tick=3000, killed=1, lost=1))
64
+ # Tanks elsewhere (not at safe zone) → not WIN
65
+ assert not evaluate(
66
+ c.win_condition, _ctx([(55, 20), (55, 21), (55, 22)], tick=3000, killed=3, lost=1)
67
+ )
68
+ # 3 tanks lost (only 1 alive) → fail clause own_units_gte:2 fires
69
+ assert evaluate(c.fail_condition, _ctx([(5, 20)], tick=3000, killed=3, lost=3))
70
+ # Past deadline → real LOSS reachable within max_turns
71
+ assert evaluate(c.fail_condition, _ctx(home3, tick=4502, killed=0, lost=0))
72
+ assert 4501 <= 93 + 90 * (c.max_turns - 1), (
73
+ "easy after_ticks 4501 must be reachable within max_turns"
74
+ )
75
+
76
+
77
+ def test_predicates_medium_force_preservation_bar():
78
+ c = compile_level(load_pack(PACK_PATH), "medium")
79
+ home3 = [(5, 18), (5, 20), (5, 21)]
80
+ home2 = home3[:2]
81
+ # Intended: 3 kills, ≥3 tanks at safe zone, ≤1 lost → WIN
82
+ assert evaluate(c.win_condition, _ctx(home3, tick=3000, killed=3, lost=1))
83
+ # Same kills but only 2 tanks left → predicate fails (need ≥3)
84
+ assert not evaluate(c.win_condition, _ctx(home2, tick=3000, killed=3, lost=2))
85
+ # 3 tanks alive but only 2 in safe zone → fails
86
+ assert not evaluate(
87
+ c.win_condition,
88
+ _ctx([(5, 20), (5, 21), (55, 20)], tick=3000, killed=3, lost=1),
89
+ )
90
+ # 2 tanks alive ⇒ fail clause fires (preservation cap)
91
+ assert evaluate(c.fail_condition, _ctx(home2, tick=3000, killed=3, lost=2))
92
+ # Past deadline ⇒ real LOSS reachable
93
+ assert evaluate(c.fail_condition, _ctx(home3, tick=4502, killed=0, lost=0))
94
+ assert 4501 <= 93 + 90 * (c.max_turns - 1)
95
+
96
+
97
+ def test_predicates_hard_two_safe_zones():
98
+ c = compile_level(load_pack(PACK_PATH), "hard")
99
+ # NORTH safe zone (5,10) satisfies the any_of geometry
100
+ home_north = [(5, 9), (5, 10), (5, 11)]
101
+ assert evaluate(c.win_condition, _ctx(home_north, tick=3000, killed=3, lost=1))
102
+ # SOUTH safe zone (5,30) also satisfies the any_of geometry
103
+ home_south = [(5, 29), (5, 30), (5, 31)]
104
+ assert evaluate(c.win_condition, _ctx(home_south, tick=3000, killed=3, lost=1))
105
+ # Tanks at the WRONG centre (5,20) — outside BOTH safe zones at r=6
106
+ # ((5,20)-(5,10)=10>6 and (5,20)-(5,30)=10>6) → fails the geometry
107
+ assert not evaluate(
108
+ c.win_condition,
109
+ _ctx([(5, 20), (5, 19), (5, 21)], tick=3000, killed=3, lost=1),
110
+ )
111
+ # Past tighter deadline → real LOSS reachable
112
+ assert evaluate(c.fail_condition, _ctx(home_north, tick=4502, killed=0, lost=0))
113
+ assert 4501 <= 93 + 90 * (c.max_turns - 1)
114
+
115
+
116
+ def test_hard_has_two_spawn_point_groups():
117
+ """Hard-tier curation contract: ≥2 agent spawn_point groups so
118
+ the seed flips the safe-zone corridor."""
119
+ c = compile_level(load_pack(PACK_PATH), "hard")
120
+ groups = {
121
+ (a.spawn_point if a.spawn_point is not None else 0)
122
+ for a in c.scenario.actors
123
+ if a.owner == "agent"
124
+ }
125
+ assert len(groups) >= 2, f"hard needs ≥2 spawn_point groups, got {groups}"
126
+
127
+
128
+ def test_pack_compiles_and_meta_fields_populated():
129
+ pack = load_pack(PACK_PATH)
130
+ assert pack.meta.capability == "reasoning"
131
+ assert pack.meta.id == "combat-retreat-after-engagement"
132
+ anchors = pack.meta.benchmark_anchor
133
+ assert isinstance(anchors, list) and anchors, "benchmark_anchor required"
134
+ joined = " ".join(anchors).lower()
135
+ # Anchored to the doctrines the brief calls out: SC2 retreat
136
+ # micro + military tactical withdrawal + preservation of force.
137
+ assert "retreat" in joined
138
+ assert "withdrawal" in joined or "preservation" in joined or "pullback" in joined
139
+ for lvl in ("easy", "medium", "hard"):
140
+ c = compile_level(pack, lvl)
141
+ assert c.map_supported
142
+ assert c.win_condition is not None and c.fail_condition is not None
143
+
144
+
145
+ def test_timeout_loss_is_reachable_on_every_level():
146
+ """No draw degeneracy: the after_ticks deadline fits inside
147
+ max_turns on every level (∼90 ticks/turn ⇒ 93 + 90·(max_turns-1))."""
148
+ pack = load_pack(PACK_PATH)
149
+ for lvl in ("easy", "medium", "hard"):
150
+ c = compile_level(pack, lvl)
151
+ assert 4501 <= 93 + 90 * (c.max_turns - 1), (
152
+ f"{lvl}: after_ticks 4501 not reachable within max_turns"
153
+ )
154
+
155
+
156
+ # ── engine-driven scripted policies ──────────────────────────────────
157
+ #
158
+ # The four-policy bar. All engine-driven tests guard on the Rust env
159
+ # wheel; predicate-level tests above run without it.
160
+
161
+
162
+ def _stall_policy(rs, Command):
163
+ """Stall: only observe. Kill bar never met → after_ticks LOSS."""
164
+ return [Command.observe()]
165
+
166
+
167
+ def _brute_attack_until_death_policy(rs, Command):
168
+ """Brute: attack_move toward the enemy centre and never retreat.
169
+ The 2× 3tnk + 5× e3 mass alpha out-trades 4× 2tnk in melee; the
170
+ column dies before clearing the squad → own_units_gte:N fails."""
171
+ units = rs.get("units_summary", []) or []
172
+ if not units:
173
+ return [Command.observe()]
174
+ return [
175
+ Command.attack_move([str(u["id"])], target_x=60, target_y=20)
176
+ for u in units
177
+ ]
178
+
179
+
180
+ def _never_engage_policy(rs, Command):
181
+ """Never-engage: each turn re-issue a move-in-place at the spawn.
182
+ The agent has ≥3 tanks in the safe zone the entire episode, but
183
+ units_killed_gte:N is never met → after_ticks LOSS. This is the
184
+ "preserve everything, score nothing" trap the kill bar punishes."""
185
+ units = rs.get("units_summary", []) or []
186
+ if not units:
187
+ return [Command.observe()]
188
+ return [
189
+ Command.move_units(
190
+ [str(u["id"])], target_x=u["cell_x"], target_y=u["cell_y"]
191
+ )
192
+ for u in units
193
+ ]
194
+
195
+
196
+ def _make_intended_engage_then_retreat():
197
+ """Intended policy (the spec's load-bearing decision): march to
198
+ the engagement axis, focus-fire e3 rocket infantry, and the
199
+ instant a tank is lost OR ≥3 enemies are observed killed pull
200
+ back to the safe-zone radius (detected from the agent's spawn
201
+ median-y latched on first observation). Stateful — uses a
202
+ closure to track the peak number of visible killables (so we
203
+ can infer kills from the shrink without reading
204
+ signals.units_killed)."""
205
+ state = {"peak_visible": 0, "retreat_latched": False, "home_y": None}
206
+
207
+ def pol(rs, Command):
208
+ units = rs.get("units_summary", []) or []
209
+ enemies = rs.get("enemy_summary", []) or []
210
+ if not units:
211
+ return [Command.observe()]
212
+ # Latch the home Y on first observation. The agent's spawn
213
+ # cell median y resolves to one of the three safe-zone
214
+ # corridors (north y=10, centre y=20, south y=30).
215
+ if state["home_y"] is None:
216
+ ys = sorted(u["cell_y"] for u in units)
217
+ hy_med = ys[len(ys) // 2]
218
+ if hy_med < 15:
219
+ state["home_y"] = 10
220
+ elif hy_med > 25:
221
+ state["home_y"] = 30
222
+ else:
223
+ state["home_y"] = 20
224
+ hy = state["home_y"]
225
+ n_alive = len(units)
226
+ killable = [
227
+ e
228
+ for e in enemies
229
+ if not e.get("is_building")
230
+ and (e.get("type") or "").lower() != "fact"
231
+ ]
232
+ visible = len(killable)
233
+ if visible > state["peak_visible"]:
234
+ state["peak_visible"] = visible
235
+ killed_observed = state["peak_visible"] - visible
236
+ # RETREAT TRIGGER: latched, or any tank lost, or ≥3 enemies
237
+ # observed killed. Once retreating, stay retreating (a re-
238
+ # engagement would re-expose the survivors to attrition).
239
+ if state["retreat_latched"] or n_alive < 4 or killed_observed >= 3:
240
+ state["retreat_latched"] = True
241
+ return [
242
+ Command.move_units([str(u["id"])], target_x=5, target_y=hy)
243
+ for u in units
244
+ ]
245
+ # ENGAGE: pick the closest e3 to home and focus-fire it with
246
+ # ALL tanks (4-vs-1 ends a Dragon-soldier in 1-2 decision turns).
247
+ e3s = [e for e in killable if (e.get("type") or "").lower() == "e3"]
248
+ if e3s:
249
+ e3s.sort(
250
+ key=lambda e: (e["cell_x"] - 5) ** 2 + (e["cell_y"] - hy) ** 2
251
+ )
252
+ t = e3s[0]
253
+ return [
254
+ Command.attack_unit([str(u["id"])], str(t["id"])) for u in units
255
+ ]
256
+ # APPROACH: advance toward the engagement axis (50, 20) so
257
+ # the spawn corridor (y=10 or y=30 on hard) closes onto the
258
+ # mid-latitude line where the e3s will come into view.
259
+ return [
260
+ Command.move_units(
261
+ [str(u["id"])],
262
+ target_x=min(50, u["cell_x"] + 12),
263
+ target_y=20,
264
+ )
265
+ for u in units
266
+ ]
267
+
268
+ return pol
269
+
270
+
271
+ @pytest.mark.parametrize("level", ["easy", "medium", "hard"])
272
+ def test_stall_policy_loses(level):
273
+ """Stall must LOSE on every level — kill bar unmet → after_ticks LOSS."""
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
+ seeds = (1, 2, 3, 4) if level == "hard" else (1,)
279
+ for s in seeds:
280
+ res = run_level(c, _stall_policy, seed=s)
281
+ assert res.outcome == "loss", (
282
+ f"{level} seed={s}: stall must LOSE; got {res.outcome} "
283
+ f"k={res.signals.units_killed} l={res.signals.units_lost}"
284
+ )
285
+
286
+
287
+ @pytest.mark.parametrize("level", ["easy", "medium", "hard"])
288
+ def test_brute_attack_until_death_loses(level):
289
+ """Brute attack-until-death must LOSE — the mass alpha at the
290
+ enemy line out-trades the column before the bar is met."""
291
+ pytest.importorskip("openra_train")
292
+ from openra_bench.eval_core import run_level
293
+
294
+ c = compile_level(load_pack(PACK_PATH), level)
295
+ seeds = (1, 2, 3, 4) if level == "hard" else (1,)
296
+ for s in seeds:
297
+ res = run_level(c, _brute_attack_until_death_policy, seed=s)
298
+ assert res.outcome == "loss", (
299
+ f"{level} seed={s}: brute must LOSE; got {res.outcome} "
300
+ f"k={res.signals.units_killed} l={res.signals.units_lost}"
301
+ )
302
+
303
+
304
+ @pytest.mark.parametrize("level", ["easy", "medium", "hard"])
305
+ def test_never_engage_policy_loses(level):
306
+ """Never-engage must LOSE — ≥3 tanks survive in the safe zone,
307
+ but units_killed_gte:N is never met → after_ticks LOSS."""
308
+ pytest.importorskip("openra_train")
309
+ from openra_bench.eval_core import run_level
310
+
311
+ c = compile_level(load_pack(PACK_PATH), level)
312
+ seeds = (1, 2, 3, 4) if level == "hard" else (1,)
313
+ for s in seeds:
314
+ res = run_level(c, _never_engage_policy, seed=s)
315
+ assert res.outcome == "loss", (
316
+ f"{level} seed={s}: never-engage must LOSE; got {res.outcome} "
317
+ f"k={res.signals.units_killed} l={res.signals.units_lost}"
318
+ )
319
+
320
+
321
+ @pytest.mark.parametrize("level", ["easy", "medium", "hard"])
322
+ def test_intended_engage_then_retreat_wins(level):
323
+ """Intended engage-then-retreat must WIN on every level and every
324
+ hard seed (1..4): focus-fire e3s, retreat the instant a tank is
325
+ lost or ≥3 kills observed, end with ≥3 tanks in the safe zone."""
326
+ pytest.importorskip("openra_train")
327
+ from openra_bench.eval_core import run_level
328
+
329
+ c = compile_level(load_pack(PACK_PATH), level)
330
+ seeds = (1, 2, 3, 4) if level == "hard" else (1,)
331
+ for s in seeds:
332
+ pol = _make_intended_engage_then_retreat()
333
+ res = run_level(c, pol, seed=s)
334
+ assert res.outcome == "win", (
335
+ f"{level} seed={s}: intended engage-then-retreat must WIN; "
336
+ f"got {res.outcome} k={res.signals.units_killed} "
337
+ f"l={res.signals.units_lost}"
338
+ )
tests/test_hard_tier.py CHANGED
@@ -400,6 +400,8 @@ UPGRADED = [
400
  # bands (y=12 / y=28) punishes the near-edge shortcut so a
401
  # memorised single-detour-band opening cannot generalise.
402
  "scout-and-report",
 
 
403
  # Wave-6 proc-checklist-no-deviation: strict ordered visit-
404
  # checklist (IFBench step-order / PlanBench strict ordering /
405
  # aviation pre-flight / SOP no-skip-no-reorder anchor). Hard
 
400
  # bands (y=12 / y=28) punishes the near-edge shortcut so a
401
  # memorised single-detour-band opening cannot generalise.
402
  "scout-and-report",
403
+ # Wave-6 salvage: agent timeout pre-commit
404
+ "combat-retreat-after-engagement",
405
  # Wave-6 proc-checklist-no-deviation: strict ordered visit-
406
  # checklist (IFBench step-order / PlanBench strict ordering /
407
  # aviation pre-flight / SOP no-skip-no-reorder anchor). Hard