yxc20098 commited on
Commit
8b60583
·
1 Parent(s): bc18374

feat(scenario): def-evacuation — evacuate units from doomed base (BCP evacuation / SC2 retreat anchor)

Browse files

Wave-8 REASONING pack. Anchors the BCP-evacuation / NFPA-FEMA
emergency-management / SC2 GG-walk doctrine: when the primary
base is irrecoverably compromised, the policy is NOT to fight
the sunk-cost defence but to EVACUATE the remaining mobile force
to the pre-designated safe rally point before attrition reduces
the column below the survival floor.

Setup (all 3 levels):
- Pre-placed agent fact + proc at the WEST edge (10,20) — these
are EXPECTED to die to the heavy assault.
- 5x 2tnk pre-placed at the NORTH shoulder of the base (y=8..12)
just outside GUARD_AGGRO ~16 from the assault.
- Heavy enemy assault AT the base (easy 2x 3tnk + 4x e3; medium
and hard 3x 3tnk + 6x e3) — guard bot holds post so it razes
the fact+proc but does not chase the retreating column past
GUARD_LEASH ~18.
- Safe zone at the EAST along the NORTH corridor (90,12) r=6.
- Persistent enemy fact far east — anti-DRAW marker (CLAUDE.md
footgun: enemy-elim collapses retreat scenarios to DRAW).

Bar (verified by scripted policies):
- stall (only observe) -> LOSS (guard lunge grinds tanks)
- hold-the-base (attack_move)-> LOSS (out-traded by heavies)
- intended EVAC east -> WIN (>=3 tanks in safe zone)

Tiers (one new controlled variable each):
- easy: lighter assault, within_ticks 2700
- medium: heavier assault + tighter clock 2400
- hard: 2 spawn_point groups (NORTH y=8..12 vs SOUTH y=28..32),
safe-zone any_of (90,12) OR (90,28) — agent must read its own
starting y-band to pick the matching safe corridor

Fail clause is INTENTIONALLY only after_ticks + not own_units_gte:1
— losing the fact/proc is the SET-UP, not a fail trigger (the
load-bearing invariant of the doomed-base idiom). Registered in
tests/test_hard_tier.py::UPGRADED with comment.

openra_bench/scenarios/packs/def-evacuation.yaml ADDED
@@ -0,0 +1,432 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # def-evacuation — EVACUATE the mobile force from a doomed base
2
+ # (Wave-8 REASONING capability).
3
+ #
4
+ # Real-world anchors:
5
+ # - Business Continuity Planning (BCP) evacuation: when the primary
6
+ # site is irrecoverably compromised, the policy is NOT to defend
7
+ # the site (sunk cost) but to MOVE PEOPLE / ASSETS to the
8
+ # pre-designated safe rally point BEFORE the site dies. Hours-of-
9
+ # warning matter; minutes of hesitation become casualties.
10
+ # - Emergency-management EVAC doctrine (NFPA / FEMA): once "shelter-
11
+ # in-place" is no longer survivable, the order is EVAC to the
12
+ # muster point. Loitering at the doomed site loses everyone.
13
+ # - SC2 "GG-walk" retreat micro: when the base is being overrun and
14
+ # cannot be held, dragoons/marines walk OFF-MAP to a hidden
15
+ # proxy / ally base rather than die defending the main. Live
16
+ # units rebuild; dead ones don't.
17
+ #
18
+ # Idiom (the bar — the doomed-base discrimination):
19
+ #
20
+ # • stall (only observe): LOSS.
21
+ # Tanks sit at the NORTH shoulder of the base; the heavy
22
+ # enemy guards lunge in (GUARD_AGGRO ~16, the staging tanks
23
+ # at (10,10..14) are 7..11 cells from the assault at (17,18..22))
24
+ # and grind the immobile 5 tanks to 0 inside the engagement
25
+ # window. After all tanks are dead, `own_units_gte:1` fail
26
+ # fires (tick ~600..1800) → real reachable LOSS.
27
+ #
28
+ # • hold-the-base (attack_move at the assault / camp the fact):
29
+ # LOSS. The 3× 3tnk + 6× e3 hard-counters 5× 2tnk at close
30
+ # range — the heavies' anti-armour cannons trade 2-for-1
31
+ # with the medium-tank column while the rocket infantry
32
+ # stack damage from outside cannon range. The defenders die
33
+ # before they clear the assault → `own_units_gte` fail.
34
+ #
35
+ # • intended EVAC east (move_units east to the safe zone): WIN.
36
+ # The agent abandons the doomed base on turn 1 and orders
37
+ # all 5 tanks east along the NORTH corridor (y≈12) to the
38
+ # safe zone at (90,12). The pathfinder routes the column
39
+ # past the assault outside leash range (~18); once past
40
+ # x≈35 the column is uncontested all the way east. ≥3 tanks
41
+ # make it inside the r=6 zone before the deadline. The fact
42
+ # and proc ARE expected to die — that is not a fail clause;
43
+ # only `own_units_gte:N` and the deadline are.
44
+ #
45
+ # Engine guardrails (every footgun from CLAUDE.md / SCENARIO_QUALITY):
46
+ # - Map: rush-hour-arena (128×40 playable). Doomed base + assault
47
+ # at the WEST (x=10..18, y around 20). Tanks staged at the
48
+ # NORTH shoulder (x=10, y=10..14). Safe zone at the EAST along
49
+ # the NORTH corridor (x=90, y=12, r=6). Distance ~80 cells.
50
+ # 2tnk at ~3 cells/turn ⇒ ~27 turns to reach the safe zone;
51
+ # max_turns 50 ⇒ comfortable. All actor cells inside the
52
+ # playable bounds (x∈[2,126], y∈[2,38]).
53
+ # - Persistent enemy `fact` far east AS AN ANTI-DRAW MARKER.
54
+ # Without a persistent enemy actor the engine auto-done's on
55
+ # enemy-elimination before the win/fail evaluator runs, and a
56
+ # "retreat-east" win-by-reach run collapses to DRAW (CLAUDE.md
57
+ # footgun #5). The far-east fact also doubles as a visual
58
+ # anchor for the safe muster direction.
59
+ # - Enemy bot is `guard` (not `hunt`). guard holds post (the
60
+ # base assault stays AT the base and razes the fact + proc),
61
+ # auto-fires in range, lunges within GUARD_AGGRO ~16, snaps
62
+ # back past GUARD_LEASH ~18. Critical: a `hunt` bot would
63
+ # chase the retreating column with 3tnks across the entire
64
+ # map and the survival cap would be unwinnable regardless of
65
+ # agent skill — `guard` keeps the discrimination on the EVAC
66
+ # DECISION (move on turn 1 vs hold/stall), not on the
67
+ # unwinnable pursuit micro.
68
+ # - The agent's `fact` + `proc` ARE EXPECTED TO DIE. Critically
69
+ # the fail clause does NOT include `not has_building:fact` —
70
+ # losing the base is the SET-UP not a fail trigger.
71
+ # - `own_units_gte:1` is safe as a fail clause here because the
72
+ # agent STARTS with 5 tanks (units_gte holds on turn 1 — no
73
+ # `not own_units_gte:1` mis-fire on a unit-less start, the
74
+ # documented footgun from `economy-force-buildup`).
75
+ # - Tick budget: max_turns 50 with `enemy_unit_spotted` +
76
+ # `own_unit_destroyed` interrupts enabled ⇒ verified ~3000..
77
+ # 3700 reachable across 50 turns (interrupt-mode step cut).
78
+ # easy `within_ticks: 2700` / `after_ticks: 2701`; medium and
79
+ # hard `within_ticks: 2400` / `after_ticks: 2401` ⇒ both bite
80
+ # well inside max_turns.
81
+ # - 5× 2tnk pre-placed: this matches the "≥N tanks/infantry" spec.
82
+ # 5 starting allows up to 2 in-transit losses while still meeting
83
+ # the n:3 region clause (own_units_gte:3 is the survival cap).
84
+ # - Hard tier: 2× `spawn_point` groups (NORTH safe-zone (90,12)
85
+ # vs SOUTH safe-zone (90,28)) to satisfy the test_hard_tier
86
+ # UPGRADED contract. Per CLAUDE.md spawn_point contract: ALL
87
+ # agent actors in hard carry spawn_point (the fact/proc
88
+ # duplicated across both groups at identical cells so the
89
+ # doomed-base setup is identical per seed).
90
+ #
91
+ # Validate (no model / no network):
92
+ # python3 -m openra_bench.scenarios.validate \
93
+ # openra_bench/scenarios/packs/def-evacuation.yaml
94
+
95
+ meta:
96
+ id: def-evacuation
97
+ title: 'Evacuate the Doomed Base — Retreat East to the Safe Zone'
98
+ capability: reasoning
99
+ real_world_meaning: >
100
+ The primary base is being overrun by a heavy assault that
101
+ structurally cannot be repelled with the units on hand — the
102
+ fact and proc WILL fall regardless of what the agent does.
103
+ The correct play is not to fight the doomed-defence (sunk
104
+ cost) but to EVACUATE the remaining mobile force EAST to a
105
+ pre-designated safe zone before attrition reduces the column
106
+ below the survival floor. Win = ≥3 of the 5 starting tanks
107
+ inside the safe-zone radius, ≥3 still alive, before the
108
+ deadline. Stalling loses (the assault wipes the immobile
109
+ tanks at the base); holding loses (the heavy + rocket
110
+ composition out-trades the 2tnk column); only the prompt
111
+ EVAC-east order wins.
112
+ robotics_analogue: >
113
+ Business-Continuity-Planning (BCP) evacuation and NFPA / FEMA
114
+ emergency-management EVAC doctrine — when shelter-in-place is
115
+ no longer survivable, the policy must abandon the compromised
116
+ site and route to the pre-designated muster point. Same
117
+ pattern in cloud-incident response (drain a region under
118
+ irrecoverable outage to a healthy peer), in wildfire and
119
+ chemical-spill EVAC, and in SC2 GG-walk macro micro
120
+ (preserve the army when the base cannot be saved).
121
+ benchmark_anchor:
122
+ - "BCP evacuation (business continuity planning)"
123
+ - "emergency management EVAC (NFPA / FEMA muster-point doctrine)"
124
+ - "SC2 retreat / GG-walk: preserve army when base is doomed"
125
+ - "cloud incident region-drain to healthy peer"
126
+ author: openra-bench
127
+
128
+ base_map: rush-hour-arena
129
+
130
+ base:
131
+ agent: {faction: allies, cash: 0}
132
+ # `guard` bot holds post (spawn_cell) with GUARD_LEASH ~18 — the
133
+ # heavy assault sits ON the doomed base from turn 1 and razes the
134
+ # fact + proc, but does NOT pursue retreating tanks past leash
135
+ # range. This makes the EVAC genuinely *survivable* (the spec's
136
+ # 5×2tnk start force can cleanly disengage if ordered east on
137
+ # turn 1) while still making "hold" structurally unwinnable (the
138
+ # assault is in firing range of the base from tick 0). A hunt
139
+ # bot would chase the retreating column with 3tnks across the
140
+ # whole map, blowing the survival cap regardless of agent skill;
141
+ # `guard` keeps the discrimination on the EVAC DECISION, not on
142
+ # the unwinnable pursuit.
143
+ enemy: {faction: soviet, cash: 0, bot_type: guard}
144
+ # Combat / movement only — no build / no harvest. The decision
145
+ # under test is "abandon the doomed base; route the mobile
146
+ # force to the safe muster point", not production.
147
+ tools: [observe, move_units, attack_unit, attack_move, stop]
148
+ planning: true
149
+ # React the instant a tank is destroyed (en-route attrition
150
+ # signal) or a hunt-band unit is spotted (gives the agent the
151
+ # contact frame the moment outriders close on the retreating
152
+ # column). Decision-relevant interrupts only.
153
+ interrupts:
154
+ enemy_unit_spotted: true
155
+ own_unit_destroyed: true
156
+ termination: {max_ticks: 6000}
157
+ actors: []
158
+
159
+ levels:
160
+ # ── EASY ─────────────────────────────────────────────────────────
161
+ # Bare EVAC skill: lighter enemy assault (2× 3tnk + 4× e3).
162
+ # The base still falls (heavy + rockets out-trade the column) but
163
+ # less overwhelming, and the EVAC-east route is uncontested past
164
+ # x=40. own_units_gte:3 survival bar (lose up to 2 tanks), n:3
165
+ # at the safe zone — comfortable for the intended EVAC.
166
+ easy:
167
+ description: >
168
+ Your base (fact + proc) at the WEST edge (10,20) is under a
169
+ heavy assault that WILL raze the base — 2 heavy tanks (3tnk)
170
+ and 4 anti-tank rocket infantry (e3) are already in firing
171
+ range. You have 5 medium tanks (2tnk) staged at the NORTH
172
+ shoulder of the base. A safe zone is set up at the EAST edge
173
+ along the NORTH corridor at (90,12). Win by getting ≥3 of
174
+ your tanks within radius 6 of (90,12) AND keeping ≥3 tanks
175
+ alive, BEFORE tick 2700. The base is EXPECTED to fall —
176
+ losing the fact is not a fail. Stalling loses (the assault
177
+ pulls in on the shoulder and grinds the immobile tanks);
178
+ holding the base loses (you cannot out-trade the heavy +
179
+ rocket composition); only prompt EVAC-east wins.
180
+ overrides:
181
+ actors:
182
+ # ── Doomed base (agent) ─────────────────────────────────
183
+ # fact + proc at (10,20). EXPECTED to fall to the hunt
184
+ # assault — there is NO fail clause on losing the base.
185
+ - {type: fact, owner: agent, position: [10, 20]}
186
+ - {type: proc, owner: agent, position: [13, 20]}
187
+ # 5× 2tnk pre-placed staging at the NORTH shoulder of the
188
+ # base (y=10..14) — OUTSIDE the central y=18..22 firing
189
+ # line of the assault and just outside GUARD_AGGRO (~16)
190
+ # from the enemy heavies at (17..18, 18..22): from (10,14)
191
+ # to (17,18) is dist sqrt(49+16)≈8, INSIDE aggro range —
192
+ # so a stalling reserve at the shoulder still gets pulled
193
+ # in by the guard lunge and ground down. The intended EVAC
194
+ # immediately moves east toward (90,10), clearing the
195
+ # leash zone (~18) within ~3 turns and then proceeding
196
+ # uncontested. stance:1 (ReturnFire) — return fire on the
197
+ # way out but don't auto-chase. The base (fact + proc at
198
+ # y=20) sits BETWEEN the tanks and the assault and IS the
199
+ # primary target the guards fire on first turn.
200
+ - {type: 2tnk, owner: agent, position: [10, 8], stance: 1}
201
+ - {type: 2tnk, owner: agent, position: [10, 9], stance: 1}
202
+ - {type: 2tnk, owner: agent, position: [10, 10], stance: 1}
203
+ - {type: 2tnk, owner: agent, position: [10, 11], stance: 1}
204
+ - {type: 2tnk, owner: agent, position: [10, 12], stance: 1}
205
+ # ── Heavy assault at the base (enemy) ────────────────────
206
+ # 2× 3tnk + 4× e3 placed AT the doomed base (x=15..18,
207
+ # y=18..22, around the fact + proc) — in firing range of
208
+ # the BASE on turn 1. Stance:3 so they auto-engage. The
209
+ # base structurally cannot be held: 3tnk anti-armour
210
+ # cannons + e3 Dragon launchers raze the fact + proc
211
+ # inside the first ~5 turns. The guards' GUARD_LEASH ~18
212
+ # means they don't pursue the EVAC column past about
213
+ # x=35, so the EAST half of the map is uncontested once
214
+ # the tanks clear the leash zone.
215
+ - {type: 3tnk, owner: enemy, position: [17, 19], stance: 3}
216
+ - {type: 3tnk, owner: enemy, position: [17, 21], stance: 3}
217
+ - {type: e3, owner: enemy, position: [16, 19], stance: 3}
218
+ - {type: e3, owner: enemy, position: [16, 21], stance: 3}
219
+ - {type: e3, owner: enemy, position: [18, 19], stance: 3}
220
+ - {type: e3, owner: enemy, position: [18, 21], stance: 3}
221
+ # ── Anti-DRAW persistent enemy fact ─────────────────────
222
+ # Unarmed marker at the far east — anchors the muster
223
+ # direction AND keeps the engine from auto-done'ing on
224
+ # enemy-elimination before the win/fail evaluator runs
225
+ # (CLAUDE.md footgun: enemy-elim collapses retreat
226
+ # scenarios to DRAW).
227
+ - {type: fact, owner: enemy, position: [125, 20]}
228
+ win_condition:
229
+ all_of:
230
+ # ≥3 tanks INSIDE the EAST safe zone along the NORTH
231
+ # corridor (90,12) r=6.
232
+ - {units_in_region_gte: {x: 90, y: 12, radius: 6, n: 3}}
233
+ # Survival cap: ≥3 tanks still alive (lose at most 2).
234
+ - {own_units_gte: 3}
235
+ # Deadline — tick 2700 inside max_turns 50. Interrupt-mode
236
+ # ticks ~60..75/turn ⇒ ~3000..3750 reachable across 50
237
+ # turns (verified). The deadline must bite for a tanks-
238
+ # alive-but-not-evacuated draw to become a real LOSS, so
239
+ # 2700 is intentionally tight against the ~3000-tick
240
+ # interrupt-mode budget.
241
+ - {within_ticks: 2700}
242
+ fail_condition:
243
+ any_of:
244
+ # Real reachable LOSS on the clock (the "alive but
245
+ # not-in-zone" path). Tick 2701 < 3000 interrupt-mode
246
+ # max ⇒ reachable inside max_turns 50.
247
+ - {after_ticks: 2701}
248
+ # Force-wipe LOSS (hold-the-base and stall both hit
249
+ # this — the guards lunge on the shoulder ⇒ stalling
250
+ # tanks die ~tick 600..1800).
251
+ - {not: {own_units_gte: 1}}
252
+ max_turns: 50
253
+
254
+ # ── MEDIUM ───────────────────────────────────────────────────────
255
+ # +1 controlled variable vs easy: HEAVIER assault (3× 3tnk + 6× e3)
256
+ # AND tighter clock (within_ticks 3600). The base falls faster;
257
+ # the hold-the-base play loses faster. The EVAC route is still
258
+ # uncontested past x=40 (the hunt band concentrates at the base
259
+ # initially) — the intended EVAC still wins comfortably, but the
260
+ # decision MUST come on turn 1 (a 1-2 turn delay can put a tank
261
+ # within enemy cannon range as the column starts east).
262
+ medium:
263
+ description: >
264
+ Your base (fact + proc) at the WEST edge (10,20) is under a
265
+ HEAVIER assault that WILL raze the base — 3 heavy tanks
266
+ (3tnk) and 6 anti-tank rocket infantry (e3) are already in
267
+ firing range. You have 5 medium tanks (2tnk) staged at the
268
+ NORTH shoulder of the base. A safe zone is set up at the
269
+ EAST edge along the NORTH corridor (90,12). Win by getting
270
+ ≥3 of your tanks within radius 6 of (90,12) AND keeping ≥3
271
+ tanks alive, BEFORE tick 2400. The base is EXPECTED to fall
272
+ — losing the fact is not a fail. Stalling loses; holding
273
+ loses; only prompt EVAC-east wins. The clock is tighter
274
+ than easy — a 1-2 turn delay leaves the column inside the
275
+ assault leash and bleeds the survival cap.
276
+ overrides:
277
+ actors:
278
+ - {type: fact, owner: agent, position: [10, 20]}
279
+ - {type: proc, owner: agent, position: [13, 20]}
280
+ # 5× 2tnk at the NORTH shoulder — same staging as easy so
281
+ # the natural EVAC corridor is the NORTH y=10..14 lane.
282
+ - {type: 2tnk, owner: agent, position: [10, 8], stance: 1}
283
+ - {type: 2tnk, owner: agent, position: [10, 9], stance: 1}
284
+ - {type: 2tnk, owner: agent, position: [10, 10], stance: 1}
285
+ - {type: 2tnk, owner: agent, position: [10, 11], stance: 1}
286
+ - {type: 2tnk, owner: agent, position: [10, 12], stance: 1}
287
+ # Heavier enemy assault — 3× 3tnk + 6× e3 at the base.
288
+ # The extra 3tnk + 2× e3 (over easy) shorten the time
289
+ # the column can dawdle at the shoulder before the guard
290
+ # lunge pulls in and the 3tnk cannons grind it down. The
291
+ # enemy band is held to y=18..22 (no flank e3 at y=17 /
292
+ # y=23 — those were close enough to the NORTH corridor at
293
+ # y=10..14 to puncture the EVAC column en route).
294
+ - {type: 3tnk, owner: enemy, position: [17, 18], stance: 3}
295
+ - {type: 3tnk, owner: enemy, position: [17, 20], stance: 3}
296
+ - {type: 3tnk, owner: enemy, position: [17, 22], stance: 3}
297
+ - {type: e3, owner: enemy, position: [16, 18], stance: 3}
298
+ - {type: e3, owner: enemy, position: [16, 19], stance: 3}
299
+ - {type: e3, owner: enemy, position: [16, 21], stance: 3}
300
+ - {type: e3, owner: enemy, position: [16, 22], stance: 3}
301
+ - {type: e3, owner: enemy, position: [18, 19], stance: 3}
302
+ - {type: e3, owner: enemy, position: [18, 21], stance: 3}
303
+ - {type: fact, owner: enemy, position: [125, 20]}
304
+ win_condition:
305
+ all_of:
306
+ - {units_in_region_gte: {x: 90, y: 12, radius: 6, n: 3}}
307
+ - {own_units_gte: 3}
308
+ # Tighter clock vs easy (2400 vs 2700) ⇒ the EVAC order
309
+ # must come on turn 1 to leave room for the ~25-turn
310
+ # ~80-cell east march. Tick 2401 reachable inside
311
+ # interrupt-mode budget.
312
+ - {within_ticks: 2400}
313
+ fail_condition:
314
+ any_of:
315
+ - {after_ticks: 2401}
316
+ - {not: {own_units_gte: 1}}
317
+ max_turns: 50
318
+
319
+ # ── HARD ─────────────────────────────────────────────────────────
320
+ # +1 controlled variable on top of medium: TWO `spawn_point` groups
321
+ # rotate the SAFE-ZONE LATITUDE per seed (NORTH (90,12) vs SOUTH
322
+ # (90,28)). The base and the heavy assault stay at (10,20) for
323
+ # both spawns (enemy actors don't honour spawn_point — CLAUDE.md);
324
+ # the per-seed difference is WHICH safe zone the agent must
325
+ # evacuate TO. A memorised "always move east to (90,20)" opening
326
+ # cannot generalise — the agent must read its starting tank
327
+ # cluster's y-band to pick the matching safe corridor.
328
+ #
329
+ # Hard ALSO adds a second hunt sub-group (4× e3 spawn_point group
330
+ # 2 — symmetric mid-map outriders that pursue the retreating
331
+ # column) per the spec ("hard ≥2 spawn_point groups"). The
332
+ # extra mid-map fire forces the EVAC to thread NORTH or SOUTH
333
+ # of the central pinch.
334
+ #
335
+ # NOTE on the spawn_point contract: only AGENT actors honour
336
+ # spawn_point. The enemy fact + heavy base assault always place
337
+ # (so the base ALWAYS falls regardless of the agent spawn). The
338
+ # NORTH spawn group has tanks staged at y=12..14 ⇒ a "match
339
+ # latitude" read on the agent's own starting y selects the
340
+ # NORTH (90,12) safe zone; SOUTH spawn group staged at y=26..28
341
+ # selects the SOUTH (90,28) safe zone.
342
+ hard:
343
+ description: >
344
+ Your base (fact + proc) at the WEST edge (10,20) is under a
345
+ heavy assault that WILL raze the base — 3 heavy tanks
346
+ (3tnk) and 6 anti-tank rocket infantry (e3) are already in
347
+ firing range. You have 5 medium tanks (2tnk), staged in a
348
+ seed-chosen corridor (NORTH at y≈13 OR SOUTH at y≈27 — read
349
+ your own tank cluster's y from obs to choose the matching
350
+ safe zone). The safe zone is set up at either (90,12) IF
351
+ your tanks started NORTH or (90,28) IF your tanks started
352
+ SOUTH. Win by getting ≥3 of your tanks within radius 6 of
353
+ YOUR safe zone AND keeping ≥3 tanks alive, BEFORE tick 2400.
354
+ The base is EXPECTED to fall. Stalling loses; holding the
355
+ base loses; evacuating to the WRONG safe zone leaves you
356
+ outside the win radius; only EVAC to YOUR safe zone wins.
357
+ overrides:
358
+ actors:
359
+ # ── Doomed base (duplicated across both spawn groups) ────
360
+ # Per CLAUDE.md: if ANY agent actor declares spawn_point,
361
+ # every agent actor WITHOUT spawn_point is filtered out.
362
+ # So fact + proc carry both spawn_point tags at the same
363
+ # cell — the doomed-base setup is identical per seed.
364
+ - {type: fact, owner: agent, position: [10, 20], spawn_point: 0}
365
+ - {type: fact, owner: agent, position: [10, 20], spawn_point: 1}
366
+ - {type: proc, owner: agent, position: [13, 20], spawn_point: 0}
367
+ - {type: proc, owner: agent, position: [13, 20], spawn_point: 1}
368
+ # ── spawn_point 0 — NORTH corridor (y=8..12) ────────────
369
+ # 5× 2tnk staged at the north shoulder of the base, just
370
+ # outside GUARD_AGGRO ~16 from the southmost assault unit
371
+ # at (16,18): dist from (10,12)→(16,18)=sqrt(36+36)≈8.5;
372
+ # the central cluster ((10,10)→(16,18) = sqrt(36+64)≈10)
373
+ # is INSIDE aggro so a stalling tank gets pulled to the
374
+ # assault, but the EVAC corridor (y<10) is clear of fire
375
+ # within ~2 turns of movement. The agent reads its own
376
+ # cluster y from obs (median ≈ 10) → selects the NORTH
377
+ # safe zone (90,12).
378
+ - {type: 2tnk, owner: agent, position: [10, 8], stance: 1, spawn_point: 0}
379
+ - {type: 2tnk, owner: agent, position: [10, 9], stance: 1, spawn_point: 0}
380
+ - {type: 2tnk, owner: agent, position: [10, 10], stance: 1, spawn_point: 0}
381
+ - {type: 2tnk, owner: agent, position: [10, 11], stance: 1, spawn_point: 0}
382
+ - {type: 2tnk, owner: agent, position: [10, 12], stance: 1, spawn_point: 0}
383
+ # ── spawn_point 1 — SOUTH corridor (y=28..32) ───────────
384
+ # Mirror staging on the south side. Median y ≈ 30 →
385
+ # selects the SOUTH safe zone (90,28).
386
+ - {type: 2tnk, owner: agent, position: [10, 28], stance: 1, spawn_point: 1}
387
+ - {type: 2tnk, owner: agent, position: [10, 29], stance: 1, spawn_point: 1}
388
+ - {type: 2tnk, owner: agent, position: [10, 30], stance: 1, spawn_point: 1}
389
+ - {type: 2tnk, owner: agent, position: [10, 31], stance: 1, spawn_point: 1}
390
+ - {type: 2tnk, owner: agent, position: [10, 32], stance: 1, spawn_point: 1}
391
+ # ── Heavy assault at the base (always places) ───────────
392
+ # 3× 3tnk + 6× e3 at the doomed base (x=15..18, y=18..22).
393
+ # The assault is on the fact/proc on turn 1 regardless of
394
+ # which spawn the agent started at. Confined to y=18..22 so
395
+ # the NORTH (y=10..14) AND SOUTH (y=26..28) EVAC corridors
396
+ # are outside direct rocket-infantry firing range.
397
+ - {type: 3tnk, owner: enemy, position: [17, 18], stance: 3}
398
+ - {type: 3tnk, owner: enemy, position: [17, 20], stance: 3}
399
+ - {type: 3tnk, owner: enemy, position: [17, 22], stance: 3}
400
+ - {type: e3, owner: enemy, position: [16, 18], stance: 3}
401
+ - {type: e3, owner: enemy, position: [16, 19], stance: 3}
402
+ - {type: e3, owner: enemy, position: [16, 21], stance: 3}
403
+ - {type: e3, owner: enemy, position: [16, 22], stance: 3}
404
+ - {type: e3, owner: enemy, position: [18, 19], stance: 3}
405
+ - {type: e3, owner: enemy, position: [18, 21], stance: 3}
406
+ # Anti-DRAW persistent enemy fact, off-axis at the far
407
+ # east. The corridor approach to the safe zones is
408
+ # uncontested past the base — the discrimination is the
409
+ # decision (EVAC vs hold/stall) AND, for hard, the
410
+ # latitude-read for the matching corridor. Adding mid-map
411
+ # outriders would put the corridor tanks in indirect
412
+ # fire range and make the survival cap stochastically
413
+ # unreachable — the design holds the corridor clear so
414
+ # an early correct EVAC always WINs.
415
+ - {type: fact, owner: enemy, position: [125, 20]}
416
+ win_condition:
417
+ all_of:
418
+ # The `any_of` over the two safe zones: either NORTH
419
+ # (matching spawn_point 0) OR SOUTH (matching spawn_point
420
+ # 1) satisfies the geometry. A wrong-zone EVAC puts the
421
+ # column at the OPPOSITE y-band and falls outside both
422
+ # (distance from (90,12) to (90,28) is 16 > 2·r=12).
423
+ - any_of:
424
+ - {units_in_region_gte: {x: 90, y: 12, radius: 6, n: 3}}
425
+ - {units_in_region_gte: {x: 90, y: 28, radius: 6, n: 3}}
426
+ - {own_units_gte: 3}
427
+ - {within_ticks: 2400}
428
+ fail_condition:
429
+ any_of:
430
+ - {after_ticks: 2401}
431
+ - {not: {own_units_gte: 1}}
432
+ max_turns: 50
tests/test_def_evacuation.py ADDED
@@ -0,0 +1,315 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """def-evacuation — evacuate the doomed base east to the safe zone.
2
+
3
+ Wave-8 REASONING pack. The base WILL fall regardless of the agent's
4
+ play; the test is whether the agent abandons the sunk-cost defence and
5
+ routes the mobile force EAST to the pre-designated safe zone before
6
+ attrition / the deadline burns it down.
7
+
8
+ Bar (the doomed-base discrimination):
9
+ • stall (only observe) → LOSS (tanks die at the base)
10
+ • hold-the-base (defend the fact) → LOSS (out-traded by heavies)
11
+ • intended EVAC east → WIN (≥3 tanks at safe zone)
12
+
13
+ The fact + proc ARE EXPECTED TO DIE — losing the base is NOT a
14
+ fail clause. Only attrition (`own_units_gte:N`) and the deadline
15
+ (`after_ticks`) are fail triggers.
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ from pathlib import Path
21
+
22
+ import pytest
23
+
24
+ from openra_bench.scenarios import load_pack
25
+ from openra_bench.scenarios.loader import compile_level
26
+ from openra_bench.scenarios.win_conditions import WinContext, evaluate
27
+
28
+ PACKS = Path(__file__).parent.parent / "openra_bench" / "scenarios" / "packs"
29
+ PACK_PATH = PACKS / "def-evacuation.yaml"
30
+
31
+
32
+ # ── unit-level predicate checks ──────────────────────────────────────
33
+
34
+
35
+ def _ctx(units_xy=(), tick=1000, killed=0, lost=0):
36
+ """Synthesize a WinContext for predicate-level checks."""
37
+ import types
38
+
39
+ sig = types.SimpleNamespace(
40
+ game_tick=tick,
41
+ units_killed=killed,
42
+ units_lost=lost,
43
+ own_buildings=[],
44
+ own_building_types=set(),
45
+ enemies_seen_ids=set(),
46
+ enemy_buildings_seen_ids=set(),
47
+ )
48
+ return WinContext(
49
+ signals=sig,
50
+ render_state={
51
+ "units_summary": [
52
+ {"cell_x": x, "cell_y": y} for x, y in units_xy
53
+ ]
54
+ },
55
+ )
56
+
57
+
58
+ def test_predicates_easy():
59
+ c = compile_level(load_pack(PACK_PATH), "easy")
60
+ # ≥3 tanks at the east NORTH-corridor safe zone (90,12) r=6,
61
+ # ≥3 alive, in time → WIN
62
+ safe3 = [(90, 11), (90, 12), (90, 13)]
63
+ assert evaluate(c.win_condition, _ctx(safe3, tick=2000, killed=0, lost=2))
64
+ # Same tanks but only 2 alive (lost 3) → survival cap fails
65
+ assert not evaluate(c.win_condition, _ctx(safe3[:2], tick=2000, killed=0, lost=3))
66
+ # Tanks still at the doomed base (10,12..14) — outside the safe zone
67
+ home = [(10, 11), (10, 12), (10, 13)]
68
+ assert not evaluate(c.win_condition, _ctx(home, tick=2000, killed=0, lost=0))
69
+ # Full force-wipe → fail clause own_units_gte:1 fires
70
+ assert evaluate(c.fail_condition, _ctx([], tick=1500, killed=0, lost=5))
71
+ # Past deadline → real LOSS reachable within max_turns (interrupt
72
+ # mode ~3000-3700 ticks across 50 turns; 2701 is reachable).
73
+ assert evaluate(c.fail_condition, _ctx(safe3, tick=2800, killed=0, lost=0))
74
+ # Sanity on the FIXED-step budget (also reachable in interrupt mode).
75
+ assert 2701 <= 93 + 90 * (c.max_turns - 1)
76
+
77
+
78
+ def test_predicates_medium_tighter_clock():
79
+ c = compile_level(load_pack(PACK_PATH), "medium")
80
+ safe3 = [(90, 11), (90, 12), (90, 13)]
81
+ # Intended: ≥3 in zone, ≥3 alive, before tick 2400 → WIN
82
+ assert evaluate(c.win_condition, _ctx(safe3, tick=2000, killed=0, lost=2))
83
+ # In zone but past tick 2400 → win clause fails on within_ticks
84
+ assert not evaluate(c.win_condition, _ctx(safe3, tick=2500, killed=0, lost=2))
85
+ # Past the deadline → real LOSS reachable
86
+ assert evaluate(c.fail_condition, _ctx(safe3, tick=2500, killed=0, lost=0))
87
+ assert 2401 <= 93 + 90 * (c.max_turns - 1)
88
+
89
+
90
+ def test_predicates_hard_two_safe_zones():
91
+ c = compile_level(load_pack(PACK_PATH), "hard")
92
+ # NORTH safe zone (90,12) satisfies the any_of geometry
93
+ safe_north = [(90, 11), (90, 12), (90, 13)]
94
+ assert evaluate(c.win_condition, _ctx(safe_north, tick=2000, killed=0, lost=2))
95
+ # SOUTH safe zone (90,28) also satisfies the any_of geometry
96
+ safe_south = [(90, 27), (90, 28), (90, 29)]
97
+ assert evaluate(c.win_condition, _ctx(safe_south, tick=2000, killed=0, lost=2))
98
+ # Tanks at the WRONG centre y (90,20) — outside BOTH zones at r=6
99
+ # ((90,20)-(90,12)=8>6 and (90,20)-(90,28)=8>6) → fails the geometry
100
+ assert not evaluate(
101
+ c.win_condition,
102
+ _ctx([(90, 19), (90, 20), (90, 21)], tick=2000, killed=0, lost=2),
103
+ )
104
+ # Past tighter deadline → real LOSS reachable
105
+ assert evaluate(c.fail_condition, _ctx(safe_north, tick=2500, killed=0, lost=0))
106
+ assert 2401 <= 93 + 90 * (c.max_turns - 1)
107
+
108
+
109
+ def test_hard_has_two_spawn_point_groups():
110
+ """Hard-tier curation contract: ≥2 agent spawn_point groups so the
111
+ seed flips the safe-zone corridor (NORTH vs SOUTH)."""
112
+ c = compile_level(load_pack(PACK_PATH), "hard")
113
+ groups = {
114
+ (a.spawn_point if a.spawn_point is not None else 0)
115
+ for a in c.scenario.actors
116
+ if a.owner == "agent"
117
+ }
118
+ assert len(groups) >= 2, f"hard needs ≥2 spawn_point groups, got {groups}"
119
+
120
+
121
+ def test_losing_the_base_is_not_a_fail_clause():
122
+ """The fact + proc ARE EXPECTED to die. The fail_condition must NOT
123
+ fire on losing the base — only on full force-wipe or the clock.
124
+
125
+ This is the load-bearing invariant of the doomed-base idiom: if
126
+ `not has_building:fact` were in fail_condition, the intended EVAC
127
+ play would lose the moment the base fell (which the spec
128
+ designs to be inevitable), inverting the discrimination."""
129
+ pack = load_pack(PACK_PATH)
130
+ for lvl in ("easy", "medium", "hard"):
131
+ c = compile_level(pack, lvl)
132
+ # Serialize the fail clause and assert the doomed-base
133
+ # negation idioms are NOT present.
134
+ s = str(c.fail_condition.model_dump())
135
+ assert "has_building" not in s, (
136
+ f"{lvl}: fail_condition must not negate has_building "
137
+ f"(the base is EXPECTED to die); got {s}"
138
+ )
139
+
140
+
141
+ def test_pack_compiles_and_meta_fields_populated():
142
+ pack = load_pack(PACK_PATH)
143
+ assert pack.meta.capability == "reasoning"
144
+ assert pack.meta.id == "def-evacuation"
145
+ anchors = pack.meta.benchmark_anchor
146
+ assert isinstance(anchors, list) and anchors, "benchmark_anchor required"
147
+ joined = " ".join(anchors).lower()
148
+ # Anchored to the BCP / emergency-management EVAC doctrines the
149
+ # brief calls out.
150
+ assert "bcp" in joined or "evacuation" in joined
151
+ assert "emergency" in joined or "evac" in joined or "retreat" in joined
152
+ for lvl in ("easy", "medium", "hard"):
153
+ c = compile_level(pack, lvl)
154
+ assert c.map_supported
155
+ assert c.win_condition is not None and c.fail_condition is not None
156
+
157
+
158
+ def test_timeout_loss_is_reachable_on_every_level():
159
+ """No draw degeneracy: each level's `after_ticks` deadline fits
160
+ inside max_turns.
161
+
162
+ Interrupt mode (this pack uses enemy_unit_spotted +
163
+ own_unit_destroyed) cuts each step short to ~60..75 ticks, so the
164
+ effective interrupt-mode budget at max_turns 50 is ~3000..3700
165
+ ticks (verified). All after_ticks deadlines must fit inside that
166
+ interrupt-mode budget — not just the fixed-step 93+90·(N-1)."""
167
+ pack = load_pack(PACK_PATH)
168
+ deadlines = {"easy": 2701, "medium": 2401, "hard": 2401}
169
+ # Conservative interrupt-mode budget: ~60 ticks/turn (lower bound).
170
+ interrupt_budget = lambda mt: 60 * mt
171
+ for lvl, dl in deadlines.items():
172
+ c = compile_level(pack, lvl)
173
+ # Fixed-step sanity (loose upper bound).
174
+ assert dl <= 93 + 90 * (c.max_turns - 1), (
175
+ f"{lvl}: after_ticks {dl} not reachable in fixed-step budget"
176
+ )
177
+ # Interrupt-mode budget (the actual one for this pack).
178
+ assert dl <= interrupt_budget(c.max_turns), (
179
+ f"{lvl}: after_ticks {dl} not reachable in interrupt-mode "
180
+ f"budget ~{interrupt_budget(c.max_turns)} at max_turns {c.max_turns}"
181
+ )
182
+
183
+
184
+ def test_persistent_far_east_enemy_fact_present():
185
+ """Anti-DRAW marker (CLAUDE.md footgun #5): a persistent enemy
186
+ actor must remain so the engine doesn't auto-done on enemy-elim
187
+ before the win/fail evaluator runs on this retreat scenario."""
188
+ pack = load_pack(PACK_PATH)
189
+ for lvl in ("easy", "medium", "hard"):
190
+ c = compile_level(pack, lvl)
191
+ far_east = [
192
+ a
193
+ for a in c.scenario.actors
194
+ if a.owner == "enemy"
195
+ and a.type == "fact"
196
+ and a.position[0] >= 100
197
+ ]
198
+ assert far_east, (
199
+ f"{lvl}: missing persistent far-east enemy fact (anti-DRAW)"
200
+ )
201
+
202
+
203
+ # ── engine-driven scripted policies ──────────────────────────────────
204
+ #
205
+ # The three-policy bar. All engine-driven tests guard on the Rust env
206
+ # wheel; predicate-level tests above run without it.
207
+
208
+
209
+ def _stall_policy(rs, Command):
210
+ """Stall: only observe. The hunt assault wipes the immobile tanks
211
+ at the doomed base inside the engagement window → own_units_gte:1
212
+ fails → LOSS."""
213
+ return [Command.observe()]
214
+
215
+
216
+ def _hold_the_base_policy(rs, Command):
217
+ """Hold-the-base: attack_move ALL tanks INTO the assault at the
218
+ fact. The 3× 3tnk + e3 swarm hard-counters 5× 2tnk at close range
219
+ — the column dies before clearing the assault → own_units_gte:1
220
+ fails → LOSS. This is the sunk-cost defence that the EVAC idiom
221
+ correctly abandons."""
222
+ units = rs.get("units_summary", []) or []
223
+ if not units:
224
+ return [Command.observe()]
225
+ # The enemy assault sits adjacent to the fact at (10,20) — order
226
+ # all tanks to attack_move INTO the assault cells.
227
+ return [
228
+ Command.attack_move([str(u["id"])], target_x=17, target_y=20)
229
+ for u in units
230
+ ]
231
+
232
+
233
+ def _make_intended_evac_east_policy():
234
+ """Intended EVAC-east policy: on the FIRST observation latch the
235
+ home y-band (north y<15, south y>25, centre otherwise) and pick
236
+ the matching safe zone (north → (90,12); south → (90,28); centre
237
+ → (90,20)). Every turn order ALL surviving tanks to move toward
238
+ that safe zone. The fact + proc are abandoned — they are sunk
239
+ cost in the doomed-base idiom."""
240
+ state = {"safe_xy": None}
241
+
242
+ def pol(rs, Command):
243
+ units = rs.get("units_summary", []) or []
244
+ if not units:
245
+ return [Command.observe()]
246
+ if state["safe_xy"] is None:
247
+ ys = sorted(u["cell_y"] for u in units)
248
+ hy_med = ys[len(ys) // 2]
249
+ if hy_med < 15:
250
+ state["safe_xy"] = (90, 12)
251
+ elif hy_med > 25:
252
+ state["safe_xy"] = (90, 28)
253
+ else:
254
+ state["safe_xy"] = (90, 20)
255
+ tx, ty = state["safe_xy"]
256
+ return [
257
+ Command.move_units([str(u["id"])], target_x=tx, target_y=ty)
258
+ for u in units
259
+ ]
260
+
261
+ return pol
262
+
263
+
264
+ @pytest.mark.parametrize("level", ["easy", "medium", "hard"])
265
+ def test_stall_policy_loses(level):
266
+ """Stall must LOSE on every level — the hunt assault wipes the
267
+ immobile tanks at the base → own_units_gte:1 fails (force-wipe)."""
268
+ pytest.importorskip("openra_train")
269
+ from openra_bench.eval_core import run_level
270
+
271
+ c = compile_level(load_pack(PACK_PATH), level)
272
+ seeds = (1, 2, 3, 4) if level == "hard" else (1,)
273
+ for s in seeds:
274
+ res = run_level(c, _stall_policy, seed=s)
275
+ assert res.outcome == "loss", (
276
+ f"{level} seed={s}: stall must LOSE; got {res.outcome} "
277
+ f"k={res.signals.units_killed} l={res.signals.units_lost}"
278
+ )
279
+
280
+
281
+ @pytest.mark.parametrize("level", ["easy", "medium", "hard"])
282
+ def test_hold_the_base_policy_loses(level):
283
+ """Hold-the-base must LOSE on every level — the heavy + rocket
284
+ assault out-trades 5× 2tnk in melee → own_units_gte fails."""
285
+ pytest.importorskip("openra_train")
286
+ from openra_bench.eval_core import run_level
287
+
288
+ c = compile_level(load_pack(PACK_PATH), level)
289
+ seeds = (1, 2, 3, 4) if level == "hard" else (1,)
290
+ for s in seeds:
291
+ res = run_level(c, _hold_the_base_policy, seed=s)
292
+ assert res.outcome == "loss", (
293
+ f"{level} seed={s}: hold-the-base must LOSE; got {res.outcome} "
294
+ f"k={res.signals.units_killed} l={res.signals.units_lost}"
295
+ )
296
+
297
+
298
+ @pytest.mark.parametrize("level", ["easy", "medium", "hard"])
299
+ def test_intended_evac_east_wins(level):
300
+ """Intended EVAC-east must WIN on every level and every hard seed
301
+ (1..4): from turn 1 route the surviving tanks toward the safe
302
+ zone, end with ≥3 tanks inside the radius and ≥3 still alive."""
303
+ pytest.importorskip("openra_train")
304
+ from openra_bench.eval_core import run_level
305
+
306
+ c = compile_level(load_pack(PACK_PATH), level)
307
+ seeds = (1, 2, 3, 4) if level == "hard" else (1,)
308
+ for s in seeds:
309
+ pol = _make_intended_evac_east_policy()
310
+ res = run_level(c, pol, seed=s)
311
+ assert res.outcome == "win", (
312
+ f"{level} seed={s}: intended EVAC-east must WIN; "
313
+ f"got {res.outcome} k={res.signals.units_killed} "
314
+ f"l={res.signals.units_lost}"
315
+ )
tests/test_hard_tier.py CHANGED
@@ -328,6 +328,14 @@ UPGRADED = [
328
  "scout-and-report",
329
  # Wave-6 salvage: agent timeout pre-commit
330
  "combat-retreat-after-engagement",
 
 
 
 
 
 
 
 
331
  # Wave-6 proc-checklist-no-deviation: strict ordered visit-
332
  # checklist (IFBench step-order / PlanBench strict ordering /
333
  # aviation pre-flight / SOP no-skip-no-reorder anchor). Hard
 
328
  "scout-and-report",
329
  # Wave-6 salvage: agent timeout pre-commit
330
  "combat-retreat-after-engagement",
331
+ # Wave-8 BCP-evacuation: doomed base, EVAC mobile force east to
332
+ # a pre-designated safe zone before attrition busts the survival
333
+ # cap. Hard tier defines 2 spawn_point groups that flip the
334
+ # safe-zone latitude (NORTH (90,12) vs SOUTH (90,28)) per seed;
335
+ # the base + heavy assault stay fixed (enemy actors don't honour
336
+ # spawn_point — CLAUDE.md), so the per-seed read is "which
337
+ # corridor did MY tanks start in" → "EVAC to the matching zone".
338
+ "def-evacuation",
339
  # Wave-6 proc-checklist-no-deviation: strict ordered visit-
340
  # checklist (IFBench step-order / PlanBench strict ordering /
341
  # aviation pre-flight / SOP no-skip-no-reorder anchor). Hard