yxc20098 commited on
Commit
f4f5f3d
·
1 Parent(s): 050e315

feat(scenario): scout-detect-incoming-army — early-warn intercept (SC2 / NORAD / IDS anchor)

Browse files

Wave-6 perception pack — early-warning intrusion detection paired with
targeted intercept. The classical scout-for-attack drill: an enemy hunt
squad is inbound from one of two candidate axes (NORTH y=10 or SOUTH
y=30, both ALWAYS placed on medium/hard since enemy actors don't honour
spawn_point), and the agent must (a) push jeep scouts forward to
LOCALISE the threat axis, then (b) re-position the medium-tank
defenders FORWARD on the localised lane so the tank cannon engages
the e1 stack at range BEFORE the squad's e3 rockets close.

Real-world anchors: SC2 early-warn scout / scout-for-attack, NORAD-
style early-warning systems, intrusion detection systems (IDS),
military reconnaissance-in-force / counter-recon screen.

Win predicate (load-bearing across all four axes):
* enemies_discovered_gte:4 (the SPOT bar)
* units_killed_gte:3 (the INTERCEPT bar)
* units_lost_lte:2 (the PRESERVATION bar; 5 starting units)
* units_in_region_gte at the NORTH-flank (45,10) intercept point on
easy; any_of over NORTH (45,10) AND SOUTH (45,30) on medium/hard.
The FORWARD-INTERCEPT clause is what makes scouting load-bearing:
a base-only auto-defence that meets the kill bar still FAILS this
clause because every defender stays west of x≈20.
* within_ticks:2500 + after_ticks:2501 ⇒ real reachable timeout
LOSS (max_turns 50, reachable tick 4503), never a draw.

Hard tier: 2 agent spawn_point groups (NORTH base y=14 / SOUTH base
y=26) round-robined by seed; 2 hunt bands (N+S, 5× e1 each) always
place. A memorised opening cannot generalise; the scout-and-
intercept doctrine does.

Scripted-policy bar (deterministic, no model / no network):
* stall → LOSS (every level/seed)
* no-scout blind → LOSS (every level/seed; mid-lane fails
position clause)
* scout-no-position → LOSS (every level/seed; e3 rockets bust
attrition cap)
* intended → WIN (every level/seed)

Sibling distinction:
* scout-and-survive — discover + return; no intercept step.
* scout-detect-enemy-tech — read building composition, not unit
forces or threat axis.
* def-pre-position-mobile-reserve — both flanks TOLD; tests where
to commit the reserve. This pack tests
the perception step that PRECEDES the
commitment.

Registered in tests/test_hard_tier.py::UPGRADED with the
NORTH/SOUTH spawn-variation rationale.

openra_bench/scenarios/packs/scout-detect-incoming-army.yaml ADDED
@@ -0,0 +1,420 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # scout-detect-incoming-army.yaml
2
+ #
3
+ # PERCEPTION capability — Wave-6 early-warning intrusion-detection
4
+ # pack. The classical scout-for-attack drill: an enemy army is
5
+ # inbound from ONE OF TWO DIRECTIONS (NORTH or SOUTH on medium,
6
+ # expanded to two simultaneous bands on hard), and the agent must
7
+ # (a) push scouts out into the unknown to DETECT the approach
8
+ # direction, then (b) re-position the defenders to INTERCEPT the
9
+ # army before it reaches the base. Pure-defence-without-scouting
10
+ # loses on the discovery bar; pure-scouting-without-positioning
11
+ # loses on the kill bar (the army arrives at the base and overruns
12
+ # the un-positioned defenders); stalling loses on the clock.
13
+ #
14
+ # This is the perception-cell sibling of the reasoning pack
15
+ # def-pre-position-mobile-reserve (which TELLS the agent both
16
+ # flanks are active and tests where to commit the reserve). The
17
+ # distinct skill here is the PERCEPTION step: actively READING
18
+ # the unknown by pushing sensors out to LOCALISE the threat axis
19
+ # before committing the defenders.
20
+ #
21
+ # Real-world / benchmark anchors:
22
+ # - SC2 early-warn scout / scout-for-attack micro: the SC2 idiom
23
+ # of sacrificing a worker / overlord at a likely approach lane
24
+ # to detect an incoming army before it hits the base.
25
+ # - NORAD-style early-warning systems: a sparse network of
26
+ # sensors at the perimeter detects an inbound threat in time
27
+ # for the defending force to be vectored to the intercept.
28
+ # - Intrusion-detection systems (IDS): the perimeter sensor
29
+ # LOCALISES the breach lane; the response is targeted to that
30
+ # lane, not the whole perimeter.
31
+ # - Military reconnaissance-in-force / counter-recon screen:
32
+ # scouts pushed forward of the main body to detect the enemy
33
+ # axis of advance so the main body can be deployed on the
34
+ # correct lane.
35
+ #
36
+ # DISCRIMINATIONS (the bar — every lazy / blind / partial play
37
+ # LOSES, only the intended scout-then-intercept play WINS):
38
+ #
39
+ # • stall (observe only): tanks and scouts sit at the base. The
40
+ # hunt squad closes and the e3 rocket soldiers in the squad
41
+ # focus-fire on tanks at close range, killing ≥3 of the 5
42
+ # agent units (busts units_lost_lte:2) by the time the kill
43
+ # bar is met. LOSS.
44
+ #
45
+ # • no-scout intercept-blind: tanks attack-move forward (eg
46
+ # east at y=20), no scouts deployed. With 2 candidate
47
+ # directions on medium and bands always at BOTH lanes, the
48
+ # mid-y attack-move puts the tanks between the two bands —
49
+ # they get fired on by both squads' e3s and overrun before
50
+ # the kill bar is met. LOSS.
51
+ #
52
+ # • scout-but-no-position: scouts push out and detect (≥4
53
+ # discovered), but tanks stay at the base. The hunt squad
54
+ # arrives at the base and the e3s rocket the tanks at close
55
+ # range; units_lost > 2 ⇒ LOSS even though detection
56
+ # succeeded.
57
+ #
58
+ # • intended scout-and-intercept: scouts push out (one N, one
59
+ # S on medium) to localise the threat axis; when the army is
60
+ # spotted, tanks are re-positioned (attack_move) FORWARD on
61
+ # the localised lane so the tank cannon's range (≈5 cells)
62
+ # engages the e1 stack before the e3s close to rocket range
63
+ # (≈3 cells). Detection bar met; tanks pick off the e1s at
64
+ # range; kill bar met with ≤2 lost (typically only the
65
+ # forward scout).
66
+ #
67
+ # ENGINE FACTS used (CLAUDE.md / SCENARIO_QUALITY.md):
68
+ # - `hunt` bot: each enemy unit attacks the NEAREST agent unit
69
+ # (openra-sim/src/scripted_bot.rs::Hunt). With scouts pushed
70
+ # out toward the hunt squad's spawn lane, the hunt squad
71
+ # engages the scouts first (giving the tanks time to
72
+ # re-position); with no scouts the hunt squad walks straight
73
+ # at the tank cluster.
74
+ # - Enemy actors with NO spawn_point ALWAYS place regardless
75
+ # of the agent's spawn group (CLAUDE.md). Medium / hard
76
+ # ALWAYS place BOTH a NORTH and a SOUTH band; the agent's
77
+ # spawn_point varies BASE LATITUDE (hard) so a memorised
78
+ # intercept cell cannot generalise across seeds. The
79
+ # "2 seeded directions" is encoded as "both flanks active"
80
+ # — strictly the harder test of the same scout-for-axis
81
+ # capability (def-pre-position-mobile-reserve uses the same
82
+ # idiom for the same reason).
83
+ # - Tanks (2tnk) are stance:1 (ReturnFire) — they don't pursue
84
+ # out of range but auto-engage hostiles in range. Scouts
85
+ # (jeep) are stance:0 (HoldFire) so they don't pull aggro
86
+ # before the model issues an explicit `move_units` forward;
87
+ # pure-perception role (detect, not engage).
88
+ # - 2tnk vs e1: CLAUDE.md "2tnk cannon ~1-shots e1". 2tnk
89
+ # cannon range > e1 rifle range, so a tank pushed forward
90
+ # to engagement range kills e1s before they fire back. e3
91
+ # rockets vs 2tnk are damaging but the e3 range is short
92
+ # (~3 cells); a tank that engages the squad's e1s at cannon
93
+ # range whittles the squad before the e3s close — that's
94
+ # the load-bearing geometry the intended policy exploits.
95
+ # - Inert enemy `fact` marker far east, OFF-AXIS (y=2): anti-
96
+ # DRAW per CLAUDE.md. Without a persistent enemy actor the
97
+ # engine auto-`done`s on enemy-elim before the win/fail
98
+ # evaluator runs. Placed off-axis so it doesn't bias the
99
+ # hunt path or contaminate enemies_discovered (only enemy
100
+ # UNITS count toward enemies_discovered, not buildings).
101
+ # - `enemy_unit_spotted` interrupt: re-decide the instant a
102
+ # hunt-squad unit becomes visible — the localisation event
103
+ # fires immediately, not on the next 90-tick decision
104
+ # boundary.
105
+ # - `own_unit_destroyed` interrupt: re-decide when a scout or
106
+ # tank falls so the policy can re-commit the remaining
107
+ # force.
108
+ # - Tick budget: ~90 ticks/turn fixed-step. max_turns 50 →
109
+ # reachable tick 93 + 90·49 = 4503 ≥ 4501, so the
110
+ # `after_ticks: 4501` fail bites comfortably inside the
111
+ # turn cap (real reachable LOSS, never a draw).
112
+ #
113
+ # Distinct from siblings:
114
+ # * scout-and-survive — discover + return alive (no
115
+ # interception). This pack adds the INTERCEPT step.
116
+ # * scout-detect-enemy-tech — read building composition (which
117
+ # buildings = which tech path). This pack reads UNIT FORCES
118
+ # (where the army is, not what tech tree it's on).
119
+ # * def-pre-position-mobile-reserve — both flanks ALWAYS
120
+ # spotted (the agent is TOLD), tests where to commit the
121
+ # reserve. This pack tests the perception step that
122
+ # PRECEDES the commitment.
123
+ # * lh-scout-react-counter — scout-then-attack on the OFFENCE
124
+ # side (3-phase chain). This pack is purely defensive (3
125
+ # tank defenders + 2 jeep scouts on the WEST side, all
126
+ # combat starting units, no build tools).
127
+ #
128
+ # Validate (no model / no network):
129
+ # python3 -m openra_bench.scenarios.validate \
130
+ # openra_bench/scenarios/packs/scout-detect-incoming-army.yaml
131
+
132
+ meta:
133
+ id: scout-detect-incoming-army
134
+ title: 'Early Warning — Scout to Detect the Incoming Army Axis, Then Intercept'
135
+ capability: perception
136
+ real_world_meaning: >
137
+ Early-warning intrusion detection paired with a targeted
138
+ response. An enemy force is inbound and the agent's defenders
139
+ are at the base, but the agent does NOT know which axis the
140
+ army will arrive on. Pure defence-without-scouting cannot
141
+ pre-position the defenders correctly; pure offence-without-
142
+ scouting commits the defenders to the wrong lane; only a
143
+ deliberate scout step — push sensors out, READ the approach
144
+ axis, then re-position the defenders FORWARD on the correct
145
+ lane — wins. This is the perception decision that precedes
146
+ a targeted defensive commitment.
147
+ robotics_analogue: >
148
+ NORAD-style early-warning network paired with QRF dispatch:
149
+ sparse perimeter sensors LOCALISE an incoming threat; the
150
+ response force is then vectored to the localised lane, not
151
+ spread across the whole perimeter. An IDS that detects the
152
+ breach lane lets the SecOps response target the right
153
+ subnet instead of fortifying every host. A reconnaissance-
154
+ in-force screen detects the enemy axis of advance so the
155
+ main body deploys on the correct lane.
156
+ benchmark_anchor:
157
+ - "SC2 early-warn scout / scout-for-attack"
158
+ - "NORAD-style early-warning systems"
159
+ - "intrusion detection systems (IDS)"
160
+ - "military reconnaissance-in-force detection"
161
+ author: "openra-bench"
162
+
163
+ # rush-hour-arena: 128×40 playable. Agent base at the WEST
164
+ # (x≈15, y=20). Two candidate enemy approach lanes: NORTH (y=10)
165
+ # and SOUTH (y=30), spawn at the EAST edge (x≈100). Scouts pushed
166
+ # from the base to a NORTH-forward (50,10) or SOUTH-forward
167
+ # (50,30) cell each cover one of the two candidate axes — see
168
+ # either axis when the army band closes within ~4 cells.
169
+ base_map: rush-hour-arena
170
+ starting_cash: 0
171
+
172
+ base:
173
+ agent:
174
+ faction: allies
175
+ cash: 0
176
+ # Hunt bot: each enemy unit attacks the NEAREST agent unit
177
+ # (scripted_bot.rs::Hunt). With scouts pushed forward the hunt
178
+ # squad engages the forward scout FIRST (giving the tanks
179
+ # time to re-position); with no scouts the squad walks straight
180
+ # at the tank cluster.
181
+ enemy:
182
+ faction: soviet
183
+ cash: 0
184
+ bot_type: hunt
185
+ # Pure-combat toolset (NO build / NO harvest): the test is
186
+ # SCOUT + INTERCEPT positioning, not production.
187
+ tools:
188
+ - observe
189
+ - move_units
190
+ - attack_unit
191
+ - attack_move
192
+ - stop
193
+ planning: true
194
+ # React the INSTANT an enemy unit becomes visible (the
195
+ # localisation event that the SCOUT exists to trigger) or
196
+ # a scout/tank is lost — the detect-and-redeploy loop is
197
+ # reactive, not on fixed 90-tick boundaries.
198
+ interrupts:
199
+ enemy_unit_spotted: true
200
+ own_unit_destroyed: true
201
+ termination:
202
+ max_ticks: 6000
203
+ actors: []
204
+
205
+ levels:
206
+ # ── EASY ─────────────────────────────────────────────────────────
207
+ # Bare scout-then-intercept skill: ONE hunt band, FIXED direction
208
+ # (NORTH y=10 every seed — no surprise). The capability is the
209
+ # PERCEPTION step (push a scout out and detect ≥4 enemy units
210
+ # early) paired with a targeted INTERCEPT (tanks re-positioned
211
+ # to the NORTH lane to engage at tank-cannon range BEFORE the
212
+ # squad's e3 rockets close). Stall loses because the e3s rocket
213
+ # the 3 tanks at close range and bust units_lost_lte:2 before
214
+ # the kill bar is met. max_turns 50 → reachable tick 4503 ≥
215
+ # 4501 (after_ticks fail bites inside the turn cap).
216
+ easy:
217
+ description: >
218
+ An enemy army (6 rifle infantry + 2 rocket soldiers) is
219
+ inbound from the NORTH (y=10, spawn at the east edge). You
220
+ have 2 jeep scouts and 3 medium tanks pre-positioned at
221
+ your WEST base (x≈15, y=20). Push the scouts FORWARD to
222
+ localise the threat axis (your defenders' vision alone is
223
+ not enough), then re-position the tanks to intercept the
224
+ approaching army on its lane BEFORE the rocket soldiers
225
+ close to short range. Win requires: SPOT ≥4 enemy units
226
+ AND destroy ≥3 of them AND lose AT MOST 2 of your own
227
+ units AND finish before tick 4500. Stalling at the base
228
+ lets the rocket soldiers close on the tanks; attacking
229
+ blind into the wrong lane misses the army; scouting
230
+ without re-positioning the tanks leaves them at the base
231
+ where the rockets bust the attrition cap.
232
+ overrides:
233
+ actors:
234
+ # 2 jeep scouts at the west base — stance:0 (HoldFire)
235
+ # so they don't pull aggro before the model issues an
236
+ # explicit forward move; pure-perception role.
237
+ - {type: jeep, owner: agent, position: [15, 18], stance: 0}
238
+ - {type: jeep, owner: agent, position: [15, 22], stance: 0}
239
+ # 3 medium tanks (2tnk) at the west base — stance:1
240
+ # (ReturnFire) so they auto-defend if the hunt squad
241
+ # walks into them, but don't pursue out of range. Tanks
242
+ # dominate e1 at cannon range; e3 rockets close-range
243
+ # damage is the discriminator that punishes auto-defence
244
+ # (stall) without forward intercept.
245
+ - {type: 2tnk, owner: agent, position: [14, 19], stance: 3}
246
+ - {type: 2tnk, owner: agent, position: [14, 21], stance: 3}
247
+ - {type: 2tnk, owner: agent, position: [16, 20], stance: 3}
248
+ # NORTH hunt squad — 5× e1 at the east edge, y=10. The
249
+ # army size is tuned so the intended forward-intercept
250
+ # play wipes ≥3 e1s cleanly (tanks dominate per-shot,
251
+ # cannon outranges e1 rifle so kiting at (40,10) trades
252
+ # favourably); a stalling defence at the base lets the
253
+ # army arrive at close range where focus-fire from the
254
+ # e1 stack on a single tank kills it while the other
255
+ # tanks pick off only 1-2 enemies.
256
+ - {type: e1, owner: enemy, position: [100, 10], stance: 3, count: 5}
257
+ # Inert enemy `fact` marker far east, off-axis (y=2) so
258
+ # it doesn't bias the hunt path. Anti-DRAW per CLAUDE.md.
259
+ # Buildings don't count toward enemies_discovered (which
260
+ # only counts enemy UNITS), so this doesn't inflate the
261
+ # discovery bar.
262
+ - {type: fact, owner: enemy, position: [120, 2]}
263
+ # Win: spec bar (discover ≥4, killed ≥3, lost ≤2, within
264
+ # tick budget) PLUS a load-bearing FORWARD-INTERCEPT position
265
+ # clause — ≥1 agent unit within radius 10 of the NORTH-flank
266
+ # intercept point (45, 10) at win time. Stall keeps every
267
+ # unit at the base spawn (x≈15) → distance from (45,10) is
268
+ # >20 → clause fails. The forward clause is what enforces
269
+ # the SCOUT-AND-INTERCEPT capability: a base-only auto-
270
+ # defence cannot satisfy the position clause even if it
271
+ # happens to meet the kill bar.
272
+ win_condition:
273
+ all_of:
274
+ - {enemies_discovered_gte: 4}
275
+ - {units_killed_gte: 3}
276
+ - {units_lost_lte: 2}
277
+ - {units_in_region_gte: {x: 45, y: 10, radius: 10, n: 1}}
278
+ - {within_ticks: 2500}
279
+ fail_condition:
280
+ any_of:
281
+ - {after_ticks: 2501}
282
+ - {not: {units_lost_lte: 2}}
283
+ - {not: {own_units_gte: 1}}
284
+ max_turns: 50
285
+
286
+ # ── MEDIUM ───────────────────────────────────────────────────────
287
+ # +1 controlled variable: the threat AXIS is UNCERTAIN — two
288
+ # candidate directions (NORTH y=10 and SOUTH y=30) with BOTH
289
+ # bands ALWAYS placed (engine constraint: enemy actors don't
290
+ # honour spawn_point — CLAUDE.md). The discrimination is the
291
+ # SCOUT step: a defender-only blind commit to one flank
292
+ # leaves the other band a clear path; the intended policy
293
+ # sends ONE scout NORTH and ONE scout SOUTH, localises which
294
+ # band is closer (the immediate threat), and commits the
295
+ # tanks FORWARD on that lane — engaging the e1s at cannon
296
+ # range while the OTHER band is still far away.
297
+ medium:
298
+ description: >
299
+ Two enemy army bands are inbound — one from the NORTH
300
+ (y=10, 4 rifle infantry + 1 rocket soldier) and one from
301
+ the SOUTH (y=30, 4 rifle infantry + 1 rocket soldier) —
302
+ both spawning at the east edge. You have 2 jeep scouts and
303
+ 3 medium tanks pre-positioned at your WEST base. Push the
304
+ scouts FORWARD on BOTH candidate axes (one NORTH, one
305
+ SOUTH) to localise the threats, then re-position the tanks
306
+ to intercept on whichever lane is closing first. Win
307
+ requires: SPOT ≥4 enemy units AND destroy ≥3 of them AND
308
+ lose AT MOST 2 of your own units AND finish before tick
309
+ 4500. Stalling at the base lets the rockets reach the
310
+ tanks; blind-committing all tanks mid-lane (y=20) puts
311
+ them between both squads' fields of fire; scouting without
312
+ re-positioning the tanks leaves them passively at the
313
+ base.
314
+ overrides:
315
+ actors:
316
+ - {type: jeep, owner: agent, position: [15, 18], stance: 0}
317
+ - {type: jeep, owner: agent, position: [15, 22], stance: 0}
318
+ - {type: 2tnk, owner: agent, position: [14, 19], stance: 3}
319
+ - {type: 2tnk, owner: agent, position: [14, 21], stance: 3}
320
+ - {type: 2tnk, owner: agent, position: [16, 20], stance: 3}
321
+ # NORTH hunt band — 4× e1 at the east edge, y=10.
322
+ - {type: e1, owner: enemy, position: [100, 10], stance: 3, count: 4}
323
+ # SOUTH hunt band — 4× e1 at the east edge, y=30.
324
+ - {type: e1, owner: enemy, position: [100, 30], stance: 3, count: 4}
325
+ - {type: fact, owner: enemy, position: [120, 2]}
326
+ # Win: discover + kill + survive + at least 1 unit on EITHER
327
+ # the NORTH-forward or the SOUTH-forward intercept point.
328
+ # The `any_of` over the two lanes is what makes this a
329
+ # PERCEPTION test: the agent commits forward on the lane
330
+ # the SCOUT has localised; blind-committing all units to
331
+ # one flank loses the OTHER band's enemies' kills, but
332
+ # crucially every non-forward position FAILS the clause.
333
+ win_condition:
334
+ all_of:
335
+ - {enemies_discovered_gte: 4}
336
+ - {units_killed_gte: 3}
337
+ - {units_lost_lte: 2}
338
+ - any_of:
339
+ - {units_in_region_gte: {x: 45, y: 10, radius: 10, n: 1}}
340
+ - {units_in_region_gte: {x: 45, y: 30, radius: 10, n: 1}}
341
+ - {within_ticks: 2500}
342
+ fail_condition:
343
+ any_of:
344
+ - {after_ticks: 2501}
345
+ - {not: {units_lost_lte: 2}}
346
+ - {not: {own_units_gte: 1}}
347
+ max_turns: 50
348
+
349
+ # ── HARD ─────────────────────────────────────────────────────────
350
+ # +1 controlled variable on top of medium: TWO seed-driven agent
351
+ # spawn_point groups (base at y=14 vs y=26) round-robined by
352
+ # seed AND a larger enemy band (5+1 + 5+1 = 12 enemies across
353
+ # 2 lanes). The threat axes are the same as medium (N+S both
354
+ # ALWAYS placed), but the agent's BASE LATITUDE varies per
355
+ # seed so a memorised "send scout to (50,10)" opening cannot
356
+ # generalise — the doctrine (scout BOTH flanks from THIS base
357
+ # latitude, commit tanks forward on the localised lane) does.
358
+ # max_turns 50, same clock as medium — tighter under the
359
+ # heavier band but still comfortably reachable.
360
+ hard:
361
+ description: >
362
+ Two enemy army bands are inbound — one from the NORTH
363
+ (y=10, 5 rifle infantry + 1 rocket soldier) and one from
364
+ the SOUTH (y=30, 5 rifle infantry + 1 rocket soldier) —
365
+ both spawning at the east edge. You have 2 jeep scouts and
366
+ 3 medium tanks pre-positioned at your WEST base — the
367
+ base's exact LATITUDE varies per seed (a memorised opening
368
+ cannot generalise; the scout-and-intercept DOCTRINE still
369
+ applies from either start). Push the scouts FORWARD on
370
+ BOTH candidate axes, localise the threats, and re-position
371
+ the tanks to intercept on the closer lane. Win requires:
372
+ SPOT ≥4 enemy units AND destroy ≥3 of them AND lose AT
373
+ MOST 2 of your own units AND finish before tick 4500.
374
+ Stalling, blind defence, and one-flank-only pre-commits
375
+ all LOSE.
376
+ overrides:
377
+ actors:
378
+ # spawn_point 0 — base shifted NORTH (y=14). All agent
379
+ # actors duplicated across BOTH spawn groups (CLAUDE.md:
380
+ # if ANY agent actor declares spawn_point, every agent
381
+ # actor WITHOUT spawn_point is filtered OUT — so we tag
382
+ # every agent actor).
383
+ - {type: jeep, owner: agent, position: [15, 12], stance: 0, spawn_point: 0}
384
+ - {type: jeep, owner: agent, position: [15, 16], stance: 0, spawn_point: 0}
385
+ - {type: 2tnk, owner: agent, position: [14, 13], stance: 3, spawn_point: 0}
386
+ - {type: 2tnk, owner: agent, position: [14, 15], stance: 3, spawn_point: 0}
387
+ - {type: 2tnk, owner: agent, position: [16, 14], stance: 3, spawn_point: 0}
388
+ # spawn_point 1 — base shifted SOUTH (y=26). Symmetric.
389
+ - {type: jeep, owner: agent, position: [15, 24], stance: 0, spawn_point: 1}
390
+ - {type: jeep, owner: agent, position: [15, 28], stance: 0, spawn_point: 1}
391
+ - {type: 2tnk, owner: agent, position: [14, 25], stance: 3, spawn_point: 1}
392
+ - {type: 2tnk, owner: agent, position: [14, 27], stance: 3, spawn_point: 1}
393
+ - {type: 2tnk, owner: agent, position: [16, 26], stance: 3, spawn_point: 1}
394
+ # TWO hunt bands — N (y=10) and S (y=30), 5× e1 each
395
+ # (10 enemies total; kill bar 3 is achievable with one
396
+ # successful intercept on whichever lane the agent
397
+ # commits to). Enemy actors don't honour spawn_point
398
+ # (CLAUDE.md) so both bands ALWAYS place every seed.
399
+ - {type: e1, owner: enemy, position: [100, 10], stance: 3, count: 5}
400
+ - {type: e1, owner: enemy, position: [100, 30], stance: 3, count: 5}
401
+ - {type: fact, owner: enemy, position: [120, 2]}
402
+ # Win: discover + kill + survive + at least 1 unit on EITHER
403
+ # the NORTH-forward or the SOUTH-forward intercept point.
404
+ # Same load-bearing position clause as medium; spawn-point
405
+ # variation is the new axis vs medium.
406
+ win_condition:
407
+ all_of:
408
+ - {enemies_discovered_gte: 4}
409
+ - {units_killed_gte: 3}
410
+ - {units_lost_lte: 2}
411
+ - any_of:
412
+ - {units_in_region_gte: {x: 45, y: 10, radius: 10, n: 1}}
413
+ - {units_in_region_gte: {x: 45, y: 30, radius: 10, n: 1}}
414
+ - {within_ticks: 2500}
415
+ fail_condition:
416
+ any_of:
417
+ - {after_ticks: 2501}
418
+ - {not: {units_lost_lte: 2}}
419
+ - {not: {own_units_gte: 1}}
420
+ max_turns: 50
tests/test_hard_tier.py CHANGED
@@ -405,6 +405,17 @@ UPGRADED = [
405
  # y=20 so either spawn faces the same flank-vs-frontal decision
406
  # from a flipped bearing, and no memorised opening generalises.
407
  "combat-flanking-attack",
 
 
 
 
 
 
 
 
 
 
 
408
  ]
409
 
410
  # Consciously NOT spawn-varied, with the reason (keeps the curation
 
405
  # y=20 so either spawn faces the same flank-vs-frontal decision
406
  # from a flipped bearing, and no memorised opening generalises.
407
  "combat-flanking-attack",
408
+ # Wave-6 perception pack — early-warning intrusion detection
409
+ # paired with targeted intercept (SC2 early-warn scout /
410
+ # NORAD early-warning / IDS / military reconnaissance-in-force
411
+ # anchor). Hard tier defines two agent spawn_point groups
412
+ # (NORTH base y=14 / SOUTH base y=26) round-robined by seed;
413
+ # two hunt bands (N y=10 + S y=30) always place (enemy actors
414
+ # don't honour spawn_point — CLAUDE.md), so the per-seed base
415
+ # latitude varies but the scout-then-intercept doctrine
416
+ # generalises. A memorised "send scout to (40,10) + tanks to
417
+ # (45,10)" opening cannot generalise across seeds.
418
+ "scout-detect-incoming-army",
419
  ]
420
 
421
  # Consciously NOT spawn-varied, with the reason (keeps the curation
tests/test_scout_detect_incoming_army.py ADDED
@@ -0,0 +1,411 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """scout-detect-incoming-army — perception pack: early-warning scout
2
+ to localise an incoming army's approach axis + targeted intercept.
3
+
4
+ The pack tests TWO chained skills:
5
+
6
+ 1) PERCEPTION (the scout step): push jeeps forward to localise
7
+ which lane the hunt squad is arriving on (the agent's
8
+ base-only vision cannot read the eastern frontier in time).
9
+ 2) ACTION (the intercept step): re-position the medium-tank
10
+ defenders forward on the localised lane so the tank cannon
11
+ engages the e1 stack at range BEFORE the squad closes to
12
+ short range and overruns the base.
13
+
14
+ Win predicate (load-bearing across all four axes):
15
+ * `enemies_discovered_gte:4` — the SPOT bar (the SC2 early-warn
16
+ scout / NORAD / IDS anchor — the agent must SEE ≥4 of the
17
+ incoming force).
18
+ * `units_killed_gte:3` — the INTERCEPT bar (the targeted
19
+ response must actually kill at least part of the army).
20
+ * `units_lost_lte:2` — the PRESERVATION bar (out of 5
21
+ starting units; a sloppy intercept that bleeds 3+ units busts
22
+ the attrition cap even if the kill bar is met).
23
+ * `units_in_region_gte` at the
24
+ NORTH-flank (45,10) intercept point (easy) OR `any_of` over
25
+ the NORTH (45,10) AND SOUTH (45,30) intercept points (medium
26
+ / hard) — the FORWARD-INTERCEPT clause. A base-only auto-
27
+ defence that satisfies the kill bar still FAILS this clause
28
+ because every defender stays west of x≈20, well outside the
29
+ forward intercept radius.
30
+ * `within_ticks:2500` paired with `after_ticks:2501` ⇒ a non-
31
+ finisher is a real reachable timeout LOSS (max_turns 50 →
32
+ reachable tick 93+90·49=4503 ≥ 2501 even in interrupt mode),
33
+ never a draw degeneracy.
34
+
35
+ Scripted policies cover the four bar-defining outcomes:
36
+ * stall → LOSS (tanks overrun at base, units_lost>2
37
+ OR after_ticks fires before the kill bar
38
+ and position clause are met)
39
+ * no-scout blind → LOSS (units march east at y=20 mid-lane,
40
+ position clause unmet on either flank)
41
+ * scout-no-position → LOSS (scouts forward but tanks stay at
42
+ base, e3 rockets bust the attrition cap)
43
+ * intended → WIN (scouts localise the lane, tanks
44
+ attack_unit on the squad's e1s at cannon
45
+ range, kill ≥3 and keep ≥3 alive)
46
+ """
47
+
48
+ from __future__ import annotations
49
+
50
+ import pytest
51
+
52
+ pytest.importorskip("openra_train", reason="Rust env wheel not installed")
53
+ pytest.importorskip("openra_rl_training", reason="Rust env wheel not installed")
54
+
55
+ from openra_bench.eval_core import run_level
56
+ from openra_bench.scenarios import load_pack
57
+ from openra_bench.scenarios.loader import PACKS_DIR, compile_level
58
+
59
+ PACK = PACKS_DIR / "scout-detect-incoming-army.yaml"
60
+ LEVELS = ("easy", "medium", "hard")
61
+ SEEDS = (1, 2, 3, 4)
62
+
63
+
64
+ # ── scenario-shape invariants ─────────────────────────────────────────
65
+
66
+
67
+ def test_pack_compiles_with_three_levels_and_hunt_bot():
68
+ pack = load_pack(PACK)
69
+ assert pack.meta.id == "scout-detect-incoming-army"
70
+ assert pack.meta.capability == "perception"
71
+ assert set(pack.levels) == {"easy", "medium", "hard"}
72
+ for lvl in LEVELS:
73
+ c = compile_level(pack, lvl)
74
+ assert c.map_supported
75
+ bot = getattr(c.scenario.enemy, "bot_type", None) or getattr(
76
+ c.scenario.enemy, "bot", None
77
+ )
78
+ assert str(bot).lower() == "hunt", (lvl, bot)
79
+
80
+
81
+ def test_benchmark_anchor_set():
82
+ """Wave-6 seed-taxonomy contract: anchors must call out the
83
+ SC2 early-warn scout / NORAD / IDS / military recon framings."""
84
+ pack = load_pack(PACK)
85
+ anchors = pack.meta.benchmark_anchor or []
86
+ assert any("SC2 early-warn scout" in a for a in anchors), anchors
87
+ assert any("NORAD" in a for a in anchors), anchors
88
+ assert any("intrusion detection" in a.lower() for a in anchors), anchors
89
+ assert any(
90
+ "reconnaissance" in a.lower() or "recon" in a.lower()
91
+ for a in anchors
92
+ ), anchors
93
+
94
+
95
+ def test_every_level_has_fail_condition():
96
+ """No silent draws — every level emits a real LOSS on timeout
97
+ or attrition or force-wipe."""
98
+ pack = load_pack(PACK)
99
+ for lvl in LEVELS:
100
+ c = compile_level(pack, lvl)
101
+ assert c.fail_condition is not None, f"{lvl} missing fail_condition"
102
+
103
+
104
+ def test_tick_budget_aligned_with_max_turns():
105
+ """within_ticks / after_ticks must be reachable inside max_turns
106
+ (engine ≤90 ticks/turn → reachable max = 93 + 90·(N-1)).
107
+ Otherwise the deadline never bites ⇒ DRAW degeneracy."""
108
+ pack = load_pack(PACK)
109
+ for lvl in LEVELS:
110
+ c = compile_level(pack, lvl)
111
+ max_turns = pack.levels[lvl].max_turns
112
+ reachable = 93 + 90 * (max_turns - 1)
113
+ # Collect every within_ticks/after_ticks leaf.
114
+ def _collect(node, key, out):
115
+ if isinstance(node, dict):
116
+ if key in node:
117
+ out.append(node[key])
118
+ for v in node.values():
119
+ _collect(v, key, out)
120
+ elif isinstance(node, list):
121
+ for v in node:
122
+ _collect(v, key, out)
123
+
124
+ wts: list[int] = []
125
+ win = c.win_condition.model_dump(exclude_none=True)
126
+ _collect(win, "within_ticks", wts)
127
+ assert wts, f"{lvl} has no within_ticks leaf"
128
+ for wt in wts:
129
+ assert wt <= reachable, (
130
+ f"{lvl} within_ticks={wt} > reachable={reachable} "
131
+ f"(max_turns={max_turns}) — deadline never bites ⇒ draw"
132
+ )
133
+ # The after_ticks fail clause must ALSO be reachable.
134
+ fts: list[int] = []
135
+ fc = c.fail_condition.model_dump(exclude_none=True)
136
+ _collect(fc, "after_ticks", fts)
137
+ assert fts, f"{lvl} has no after_ticks fail leaf"
138
+ for ft in fts:
139
+ assert ft <= reachable, (
140
+ f"{lvl} after_ticks={ft} > reachable={reachable} "
141
+ f"(max_turns={max_turns}) — fail never bites ⇒ draw"
142
+ )
143
+
144
+
145
+ def test_forward_position_clause_present_in_win():
146
+ """The load-bearing perception teeth: every level's win must
147
+ require ≥1 agent unit in a FORWARD intercept region (not a
148
+ base-only zone). Without this, stall / no-scout policies that
149
+ happen to meet the kill bar via base auto-defence could win."""
150
+ pack = load_pack(PACK)
151
+
152
+ def _walk(node, out):
153
+ if isinstance(node, dict):
154
+ if "units_in_region_gte" in node:
155
+ out.append(node["units_in_region_gte"])
156
+ for v in node.values():
157
+ _walk(v, out)
158
+ elif isinstance(node, list):
159
+ for v in node:
160
+ _walk(v, out)
161
+
162
+ for lvl in LEVELS:
163
+ c = compile_level(pack, lvl)
164
+ win = c.win_condition.model_dump(exclude_none=True)
165
+ found: list = []
166
+ _walk(win, found)
167
+ assert found, f"{lvl}: missing units_in_region_gte forward clause"
168
+ # Every region must be FORWARD of the base (x≥30): a clause
169
+ # at the spawn cells would be satisfied by stall.
170
+ for region in found:
171
+ assert region["x"] >= 30, (
172
+ f"{lvl}: forward region must have x>=30, got {region}"
173
+ )
174
+
175
+
176
+ def test_hard_has_two_spawn_point_groups():
177
+ """Hard-tier contract: ≥2 distinct seed-driven spawn_point groups
178
+ so the base latitude varies by seed."""
179
+ c = compile_level(load_pack(PACK), "hard")
180
+ groups = {
181
+ a.spawn_point for a in c.scenario.actors
182
+ if a.owner == "agent" and a.spawn_point is not None
183
+ }
184
+ assert groups == {0, 1}, groups
185
+ # In-bounds check (rush-hour-arena playable y ≈ 2..38, x ≈ 2..126):
186
+ for a in c.scenario.actors:
187
+ x, y = a.position
188
+ assert 2 <= x <= 126 and 2 <= y <= 38, (a.type, a.position)
189
+
190
+
191
+ def test_actor_composition_2_jeeps_3_tanks():
192
+ """Per-spec actor manifest: 2 jeep scouts + 3 medium tanks PER
193
+ spawn group at the WEST base. Easy/medium have one implicit
194
+ group (=2 jeeps + 3 tanks total); hard has TWO declared spawn
195
+ groups (=4 jeeps + 6 tanks total — the spawn_point filter
196
+ selects ONE group at reset, leaving exactly 2 jeeps + 3 tanks
197
+ in play)."""
198
+ for lvl in LEVELS:
199
+ c = compile_level(load_pack(PACK), lvl)
200
+ jeeps = [
201
+ a for a in c.scenario.actors
202
+ if a.owner == "agent" and a.type == "jeep"
203
+ ]
204
+ tanks = [
205
+ a for a in c.scenario.actors
206
+ if a.owner == "agent" and a.type == "2tnk"
207
+ ]
208
+ groups = {
209
+ a.spawn_point for a in c.scenario.actors
210
+ if a.owner == "agent" and a.spawn_point is not None
211
+ }
212
+ n_groups = max(1, len(groups))
213
+ assert len(jeeps) == 2 * n_groups, (lvl, jeeps)
214
+ assert len(tanks) == 3 * n_groups, (lvl, tanks)
215
+
216
+
217
+ # ── scripted policies ─────────────────────────────────────────────────
218
+
219
+
220
+ def _own_units(rs):
221
+ return rs.get("units_summary") or []
222
+
223
+
224
+ def stall(rs, C):
225
+ """Observe-only — every unit sits at the base spawn. The hunt
226
+ squad closes and the e3 rockets in the squad shred the tanks
227
+ at close range; units_lost > 2 → LOSS even if the squad is
228
+ eventually wiped, AND the forward-position clause is never
229
+ satisfied."""
230
+ return [C.observe()]
231
+
232
+
233
+ def no_scout_blind_east(rs, C):
234
+ """No scouting: attack_move all units east at mid-latitude
235
+ (y=20). The mid-lane line satisfies NEITHER the NORTH-forward
236
+ (y=10) nor the SOUTH-forward (y=30) position clause, so even
237
+ when the units accidentally kill enough enemies the win clause
238
+ is unmet → after_ticks fires → LOSS."""
239
+ units = _own_units(rs)
240
+ if not units:
241
+ return [C.observe()]
242
+ ids = [str(u["id"]) for u in units]
243
+ return [C.attack_move(ids, target_x=80, target_y=20)]
244
+
245
+
246
+ def scout_but_no_position(rs, C):
247
+ """Scouts push out (one NORTH, one SOUTH) — detection bar met
248
+ — but tanks STAY at the base. The hunt squad closes on the
249
+ tanks; e3 rockets bust the attrition cap → LOSS even though
250
+ detection succeeded."""
251
+ units = _own_units(rs)
252
+ if not units:
253
+ return [C.observe()]
254
+ jeeps = [u for u in units if str(u.get("type", "")).lower() == "jeep"]
255
+ cmds = []
256
+ if len(jeeps) >= 1:
257
+ cmds.append(C.move_units([str(jeeps[0]["id"])], target_x=70, target_y=10))
258
+ if len(jeeps) >= 2:
259
+ cmds.append(C.move_units([str(jeeps[1]["id"])], target_x=70, target_y=30))
260
+ if not cmds:
261
+ cmds.append(C.observe())
262
+ return cmds
263
+
264
+
265
+ def make_intended():
266
+ """Scout-and-react: jeeps push forward on BOTH candidate lanes
267
+ on turn 1; the moment any enemy unit is spotted, lock in the
268
+ commit lane (the one with the closer enemy) and have tanks
269
+ attack_unit on the nearest visible hostile on that lane (or
270
+ any hostile if the commit lane has none visible yet). The
271
+ attack_unit verb keeps tanks engaged rather than parking at
272
+ a static cell."""
273
+ state = {"turn": 0, "commit_y": None}
274
+
275
+ def policy(rs, C):
276
+ state["turn"] += 1
277
+ units = _own_units(rs)
278
+ if not units:
279
+ return [C.observe()]
280
+ defs = [u for u in units if str(u.get("type", "")).lower() == "2tnk"]
281
+ jeeps = [u for u in units if str(u.get("type", "")).lower() == "jeep"]
282
+ es = rs.get("enemy_summary") or []
283
+ rusher_units = [
284
+ e for e in es
285
+ if str(e.get("type", "")).lower() in ("e1", "e3")
286
+ ]
287
+ # Localise threat axis from the first visible enemy(s).
288
+ if rusher_units and state["commit_y"] is None:
289
+ n = [e for e in rusher_units if int(e.get("cell_y", 20)) < 20]
290
+ s = [e for e in rusher_units if int(e.get("cell_y", 20)) >= 20]
291
+ if n and s:
292
+ nx = min(int(e.get("cell_x", 100)) for e in n)
293
+ sx = min(int(e.get("cell_x", 100)) for e in s)
294
+ state["commit_y"] = 10 if nx <= sx else 30
295
+ elif n:
296
+ state["commit_y"] = 10
297
+ elif s:
298
+ state["commit_y"] = 30
299
+ cmds = []
300
+ # Turn 1: scout push (one NORTH, one SOUTH).
301
+ if jeeps and state["turn"] == 1:
302
+ cmds.append(C.move_units([str(jeeps[0]["id"])], target_x=40, target_y=10))
303
+ if len(jeeps) > 1:
304
+ cmds.append(C.move_units([str(jeeps[1]["id"])], target_x=40, target_y=30))
305
+ # Tanks: attack_unit on nearest visible hostile if any (the
306
+ # attack_unit verb keeps tanks engaged), else attack_move
307
+ # forward toward the commit lane (or NORTH by default).
308
+ if defs:
309
+ def_ids = [str(d["id"]) for d in defs]
310
+ if rusher_units:
311
+ # Filter to the committed lane if known.
312
+ if state["commit_y"] is not None:
313
+ lane = [
314
+ e for e in rusher_units
315
+ if abs(int(e.get("cell_y", 20)) - state["commit_y"]) < 10
316
+ ]
317
+ if not lane:
318
+ lane = rusher_units
319
+ else:
320
+ lane = rusher_units
321
+ tx = sum(int(d["cell_x"]) for d in defs) // len(defs)
322
+ ty = sum(int(d["cell_y"]) for d in defs) // len(defs)
323
+ tgt = min(
324
+ lane,
325
+ key=lambda e: (int(e.get("cell_x", 100)) - tx) ** 2
326
+ + (int(e.get("cell_y", 20)) - ty) ** 2,
327
+ )
328
+ cmds.append(C.attack_unit(def_ids, str(tgt.get("id"))))
329
+ else:
330
+ ty = state["commit_y"] if state["commit_y"] is not None else 10
331
+ cmds.append(C.attack_move(def_ids, target_x=40, target_y=ty))
332
+ if not cmds:
333
+ cmds.append(C.observe())
334
+ return cmds
335
+
336
+ return policy
337
+
338
+
339
+ # ── solvency: intended WINS every level + every hard seed ─────────────
340
+
341
+
342
+ @pytest.mark.parametrize("seed", SEEDS)
343
+ @pytest.mark.parametrize("level", LEVELS)
344
+ def test_intended_scout_and_intercept_wins(level, seed):
345
+ c = compile_level(load_pack(PACK), level)
346
+ r = run_level(c, make_intended(), seed=seed)
347
+ assert r.outcome == "win", (
348
+ f"intended scout-and-intercept must WIN on {level} s={seed}; "
349
+ f"got {r.outcome} (tick={r.signals.game_tick}, "
350
+ f"kills={r.signals.units_killed}, "
351
+ f"lost={r.signals.units_lost}, "
352
+ f"seen={len(r.signals.enemies_seen_ids)})"
353
+ )
354
+
355
+
356
+ # ── no-cheat: every lazy / blind / partial policy LOSES (not draws) ──
357
+
358
+
359
+ @pytest.mark.parametrize("seed", SEEDS)
360
+ @pytest.mark.parametrize("level", LEVELS)
361
+ def test_stall_loses(level, seed):
362
+ c = compile_level(load_pack(PACK), level)
363
+ r = run_level(c, stall, seed=seed)
364
+ assert r.outcome == "loss", (
365
+ f"stall must LOSE on {level} s={seed}; got {r.outcome} "
366
+ f"(tick={r.signals.game_tick}, kills={r.signals.units_killed}, "
367
+ f"lost={r.signals.units_lost})"
368
+ )
369
+
370
+
371
+ @pytest.mark.parametrize("seed", SEEDS)
372
+ @pytest.mark.parametrize("level", LEVELS)
373
+ def test_no_scout_blind_east_loses(level, seed):
374
+ """Blind mid-lane attack-move never satisfies the forward
375
+ position clause on N or S — even when it accidentally wipes
376
+ the army it still LOSES."""
377
+ c = compile_level(load_pack(PACK), level)
378
+ r = run_level(c, no_scout_blind_east, seed=seed)
379
+ assert r.outcome == "loss", (
380
+ f"no-scout blind must LOSE on {level} s={seed}; got {r.outcome} "
381
+ f"(tick={r.signals.game_tick}, kills={r.signals.units_killed}, "
382
+ f"lost={r.signals.units_lost})"
383
+ )
384
+
385
+
386
+ @pytest.mark.parametrize("seed", SEEDS)
387
+ @pytest.mark.parametrize("level", LEVELS)
388
+ def test_scout_but_no_position_loses(level, seed):
389
+ """Scouts forward but tanks STAY at the base — the squad
390
+ closes and the e3 rockets bust the attrition cap → LOSS."""
391
+ c = compile_level(load_pack(PACK), level)
392
+ r = run_level(c, scout_but_no_position, seed=seed)
393
+ assert r.outcome == "loss", (
394
+ f"scout-no-position must LOSE on {level} s={seed}; got {r.outcome} "
395
+ f"(tick={r.signals.game_tick}, kills={r.signals.units_killed}, "
396
+ f"lost={r.signals.units_lost})"
397
+ )
398
+
399
+
400
+ # ── Spawn-variation contract (hard) ───────────────────────────────────
401
+
402
+
403
+ @pytest.mark.parametrize("seed", SEEDS)
404
+ def test_hard_seeds_run_to_terminal(seed):
405
+ """Hard's two spawn_point groups must actually round-robin
406
+ cleanly — stall on each seed completes deterministically (the
407
+ cross-seed start-cell distinctness contract is enforced by
408
+ tests/test_hard_tier.py::test_curated_hard_still_compiles_and_runs)."""
409
+ c = compile_level(load_pack(PACK), "hard")
410
+ r = run_level(c, stall, seed=seed)
411
+ assert r.outcome == "loss"