yxc20098 commited on
Commit
a6bd17b
·
1 Parent(s): 6aa3e8e

feat(scenario): def-position-expected-direction — fortify intel'd threat axis (ERQA / MicroRTS anchor)

Browse files

Group C reasoning pack on rush-hour-arena: the mission brief states
WHERE the enemy will attack from (NORTH on easy/medium; matched-side
NORTH/SOUTH per spawn on hard) and the agent must commit its pillbox
triple to that lane.

Win idiom: building_count_gte:{pbox,3} AND building_in_region:{pbox,
NORTH_centre, r=6, 3} AND has_building:fact AND own_units_gte:3
AND within_ticks:5400 (paired with after_ticks:5401 for a real
reachable timeout LOSS).

Hard tier defines two agent spawn_point groups (NORTH base y=12 /
SOUTH base y=28) round-robined by seed; the win clause is any_of
over the two correct region orientations so 'NORTH of YOUR base'
flips per seed and a single memorised pbox cell cannot generalise.

Scripted-bar (all seeds 1..4): intended NORTH-pbox triple WINS
every level + every hard seed; stall / defenses-SOUTH / no-defenses
ALL LOSE (real reachable timeout LOSS) every level + every hard
seed — never a draw.

Model smoke: Qwen/Qwen3.6-Plus on medium seed 1 = LOSS (composite
0.3305) — valid discrimination, the model failed to commit defence
to the declared threat axis.

Anchors: ERQA spatial commit, MicroRTS terrain-aware defence,
military pre-positioned defence, intel-driven facility hardening.

openra_bench/scenarios/packs/def-position-expected-direction.yaml ADDED
@@ -0,0 +1,320 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # def-position-expected-direction.yaml
2
+ #
3
+ # REASONING capability — DEFENSE POSITIONING BASED ON DECLARED THREAT
4
+ # DIRECTION. The model is TOLD where the enemy will attack from (the
5
+ # objective brief states the intel: "rushers will come from the
6
+ # NORTH"). The decision is purely one of spatial commitment: invest
7
+ # the defence budget on the CORRECT lane — the NORTH lane — and
8
+ # the rush is blunted; commit the SAME budget on the WRONG lane
9
+ # (SOUTH) and the rushers walk past, the deadline expires (the
10
+ # wrong-direction pbox cannot satisfy the NORTH region clause), and
11
+ # the run loses. Building no defences at all also loses (no pbox).
12
+ #
13
+ # Win idiom:
14
+ # * `building_count_gte:{pbox, 3}` — three pillboxes total
15
+ # (forces a real defence commitment, not a token pbox);
16
+ # * `building_in_region:{pbox, NORTH_centre, r, 3}` — ALL THREE
17
+ # must be in the NORTH-of-base region (a SOUTH-built triple
18
+ # does not satisfy this clause);
19
+ # * `has_building: fact` — the construction yard still stands
20
+ # (sanity / anti-degeneracy);
21
+ # * `own_units_gte: 3` — the four pre-placed defenders must
22
+ # mostly survive (a chaotic micro that bleeds them out fails
23
+ # this even with the NORTH pbox triple in place);
24
+ # * `within_ticks: 5400` paired with a `after_ticks: 5401` fail
25
+ # ⇒ reachable timeout LOSS, never a draw.
26
+ #
27
+ # Real-world / benchmark anchors:
28
+ # - Pre-positioned defence based on known threat direction;
29
+ # - ERQA spatial commit (commit to one of N candidate spatial
30
+ # regions given a textual cue);
31
+ # - MicroRTS terrain-aware defence;
32
+ # - military prepared-defence doctrine (intel-driven facility
33
+ # hardening on the threat axis).
34
+ #
35
+ # Distinct from siblings (Group C):
36
+ # * defense-rush-survive — REACTIVE rush-defence under tempo (the
37
+ # threat axis is one fixed lane; the decision is balancing
38
+ # defence + throughput under clock). This pack is purely about
39
+ # COMMITTING DEFENCE TO THE CORRECT AXIS given declared intel.
40
+ # * tech-turtle-defensive-tech — sequenced fortify-then-tech
41
+ # (the axis is fixed; the decision is the order, not the side).
42
+ # * expansion-turtle-1-base-fortified — single-base mass-defence
43
+ # (no directional axis; the whole perimeter must be covered).
44
+ #
45
+ # Validate:
46
+ # cd /Users/berta/Projects/OpenRA-Bench && \
47
+ # python3 -m openra_bench.scenarios.validate \
48
+ # openra_bench/scenarios/packs/def-position-expected-direction.yaml
49
+
50
+ meta:
51
+ id: def-position-expected-direction
52
+ title: 'Fortify the Threat Axis — Pre-Position Defence on the Intel Lane'
53
+ capability: reasoning
54
+ real_world_meaning: >
55
+ Spatial commitment of a finite defence budget to the CORRECT
56
+ direction given declared intel. The mission brief states where the
57
+ enemy will attack from; the agent must invest its pillboxes on
58
+ that lane (and not the opposite lane). A correct-axis commitment
59
+ blunts the rush; a wrong-axis commitment lets the rushers walk
60
+ past and the run loses on the spatial clause. This is the
61
+ decision drill of pre-positioned defence: not "should I defend?"
62
+ but "WHERE should I defend, given what intel says?".
63
+ robotics_analogue: >
64
+ Intel-driven facility hardening: a security operator is told the
65
+ threat axis (a perimeter breach is expected on the NORTH gate)
66
+ and must commit the available barriers / sensors / responders to
67
+ that axis. Spending the same budget on the SOUTH gate satisfies
68
+ no objective. This pack tests whether the policy converts a
69
+ textual directional cue into a CORRECT spatial commitment.
70
+ benchmark_anchor:
71
+ - 'ERQA spatial commit'
72
+ - 'MicroRTS terrain-aware defense'
73
+ - 'military pre-positioned defense'
74
+ - 'intel-driven facility hardening'
75
+ author: openra-bench
76
+
77
+ # rush-hour-arena (128×40, playable x∈[2..126], y∈[2..38]). Agent
78
+ # base at (20, 20) with the fact + tent + powr + 4 starter e1s
79
+ # pre-placed (so 3 pbox can build immediately — pbox needs tent which
80
+ # needs powr — and the throughput SLA `own_units_gte:3` is trivially
81
+ # satisfied at start; the rush will erode it unless the NORTH lane is
82
+ # covered). The scripted `rusher` bot concentrates its whole band on
83
+ # the agent centroid (~8-tick interval, see scripted_bot.rs::Rusher)
84
+ # — declared NORTH of the base, so the band charges SOUTH straight at
85
+ # the fact and is intercepted by a NORTH-placed pbox triple.
86
+ base_map: rush-hour-arena
87
+
88
+ # Pack-wide budget — funds exactly the intended NORTH-pbox triple
89
+ # (3 × 600cr = 1800cr) with a small buffer (~600) for any
90
+ # replacement e1 the agent decides to queue. Spamming pbox in the
91
+ # wrong direction burns the same budget without satisfying the
92
+ # NORTH region clause.
93
+ starting_cash: 2400
94
+
95
+ base:
96
+ agent:
97
+ faction: allies
98
+ # Relentless concentrated charge at the agent centroid every
99
+ # ~8 ticks — the canonical rush-defence opponent. The bot's
100
+ # spawn position (NORTH for easy/medium, NORTH+SOUTH on hard) is
101
+ # the declared intel the model gets in the brief.
102
+ enemy:
103
+ faction: soviet
104
+ bot_type: rusher
105
+ tools:
106
+ - observe
107
+ - build
108
+ - place_building
109
+ - move_units
110
+ - attack_unit
111
+ - attack_move
112
+ - stop
113
+ planning: true
114
+ # Re-decide the instant a rusher unit is spotted at the lane mouth
115
+ # or a defender is lost — defence commitment is reactive.
116
+ interrupts:
117
+ enemy_unit_spotted: true
118
+ own_unit_destroyed: true
119
+ termination:
120
+ max_ticks: 12000
121
+ actors:
122
+ # Base-pack defaults; each level overrides with the spawn-specific
123
+ # layout. (Anti-empty-actors sanity for the loader.)
124
+ - {type: fact, owner: agent, position: [20, 20]}
125
+
126
+ levels:
127
+ # ── EASY ── the bare directional-commit skill. A LIGHT rusher push
128
+ # (3 e1) comes from the NORTH (spawn at (20, 6)). The model is told
129
+ # in the brief "rushers will come from the NORTH" and must place
130
+ # three pillboxes in the NORTH region of its base (around (20, 14),
131
+ # radius 6). The starting 4 defenders survive the light push so
132
+ # own_units_gte:3 holds; the deadline is forgiving but real.
133
+ # max_turns 66 → reachable tick 93+90·65 = 5943 ≥ 5402 even with
134
+ # interrupts firing (per-turn step shrinks below 90 ticks when an
135
+ # interrupt fires; the empirical floor on this pack is ~86 t/turn,
136
+ # so 66 turns × 86 ≈ 5676 still clears the 5401 deadline).
137
+ easy:
138
+ description: >
139
+ INTEL: the enemy will attack from the NORTH. You start with a
140
+ construction yard, a barracks, a power plant, and four rifle
141
+ defenders at (20, 20). Build at least THREE pillboxes (pbox)
142
+ and place them in the NORTH region of your base (within radius
143
+ 6 of (20, 14)) — that is, BETWEEN your base and the enemy's
144
+ approach. Keep your construction yard standing AND ≥3 of your
145
+ own units alive. Win before tick 5400. Placing pillboxes south
146
+ of your base does not satisfy the NORTH-region clause; building
147
+ no pillboxes never reaches the count; stalling loses on the
148
+ clock.
149
+ overrides:
150
+ actors:
151
+ # Agent base — fact at the centre, tent + powr supporting
152
+ # so the pbox tech chain is satisfied from turn 1 (no
153
+ # waiting on powr→tent before pbox). Tent placed WEST of the
154
+ # fact, powr SOUTH; both well clear of the NORTH defence
155
+ # region so a NORTH-placed pbox triple is unobstructed.
156
+ - {type: fact, owner: agent, position: [20, 20]}
157
+ - {type: tent, owner: agent, position: [16, 20]}
158
+ - {type: powr, owner: agent, position: [20, 24]}
159
+ # Four starter defenders south of the base (away from the
160
+ # NORTH lane mouth) — they survive a light push and satisfy
161
+ # `own_units_gte:3` even if one dies; positioning them away
162
+ # from the lane avoids the auto-target-fire complication.
163
+ - {type: e1, owner: agent, position: [22, 24], stance: 2}
164
+ - {type: e1, owner: agent, position: [24, 24], stance: 2}
165
+ - {type: e1, owner: agent, position: [22, 26], stance: 2}
166
+ - {type: e1, owner: agent, position: [24, 26], stance: 2}
167
+ # Light rusher push from the NORTH — 3 e1 at (20, 6).
168
+ # The rusher bot drives them straight at the agent
169
+ # centroid (the fact at 20,20); a NORTH pbox triple at
170
+ # y≈12..16 intercepts them on the way.
171
+ - {type: e1, owner: enemy, position: [20, 6], stance: 3, count: 3}
172
+ # Unarmed high-HP enemy marker far east — keeps the episode
173
+ # alive past the rush, so the win/fail predicate has time
174
+ # to evaluate (anti-DRAW per CLAUDE.md footgun).
175
+ - {type: fact, owner: enemy, position: [120, 20]}
176
+ win_condition:
177
+ all_of:
178
+ - building_count_gte: {type: pbox, n: 3}
179
+ - building_in_region: {type: pbox, x: 20, y: 14, radius: 6, count: 3}
180
+ - has_building: fact
181
+ - own_units_gte: 3
182
+ - within_ticks: 5400
183
+ fail_condition:
184
+ any_of:
185
+ - after_ticks: 5401
186
+ - not: {has_building: fact}
187
+ - not: {own_units_gte: 1}
188
+ max_turns: 66
189
+
190
+ # ── MEDIUM ── +1 controlled variable: a STRONGER northern push
191
+ # (5 e1 + 1 e3). The starter 4 defenders can no longer absorb the
192
+ # rush on their own — a NORTH pbox triple is REQUIRED to blunt the
193
+ # rocket soldier (e3 splash) before it ploughs through the
194
+ # defenders. Wrong-direction pbox (SOUTH) leaves the NORTH lane
195
+ # naked, the e3 kills the defenders, `own_units_gte:3` fails AND
196
+ # `building_in_region` fails ⇒ double LOSS path.
197
+ # max_turns 62 → reachable tick 5583 ≥ 5402.
198
+ medium:
199
+ description: >
200
+ INTEL: the enemy will attack from the NORTH. You start with a
201
+ construction yard, a barracks, a power plant, and four rifle
202
+ defenders at (20, 20). A stronger northern push — 5 rifle
203
+ infantry and 1 rocket soldier — will charge your base. Build
204
+ at least THREE pillboxes (pbox) and place all three in the
205
+ NORTH region of your base (within radius 6 of (20, 14)). Keep
206
+ the construction yard standing AND ≥3 of your own units alive
207
+ before tick 5400. Placing pillboxes south of your base does
208
+ not satisfy the NORTH-region clause AND leaves the northern
209
+ lane naked (the rocket soldier ploughs through the defenders);
210
+ building no pillboxes loses on both counts; stalling loses on
211
+ the clock.
212
+ overrides:
213
+ actors:
214
+ - {type: fact, owner: agent, position: [20, 20]}
215
+ - {type: tent, owner: agent, position: [16, 20]}
216
+ - {type: powr, owner: agent, position: [20, 24]}
217
+ - {type: e1, owner: agent, position: [22, 24], stance: 2}
218
+ - {type: e1, owner: agent, position: [24, 24], stance: 2}
219
+ - {type: e1, owner: agent, position: [22, 26], stance: 2}
220
+ - {type: e1, owner: agent, position: [24, 26], stance: 2}
221
+ # Stronger northern push: 5 e1 + 1 e3. The e3 is the
222
+ # discriminator — it has splash + AT damage; a defender-only
223
+ # screen cannot kill it before it bursts the screen.
224
+ - {type: e1, owner: enemy, position: [20, 6], stance: 3, count: 5}
225
+ - {type: e3, owner: enemy, position: [22, 6], stance: 3, count: 1}
226
+ - {type: fact, owner: enemy, position: [120, 20]}
227
+ win_condition:
228
+ all_of:
229
+ - building_count_gte: {type: pbox, n: 3}
230
+ - building_in_region: {type: pbox, x: 20, y: 14, radius: 6, count: 3}
231
+ - has_building: fact
232
+ - own_units_gte: 3
233
+ - within_ticks: 5400
234
+ fail_condition:
235
+ any_of:
236
+ - after_ticks: 5401
237
+ - not: {has_building: fact}
238
+ - not: {own_units_gte: 1}
239
+ max_turns: 66
240
+
241
+ # ── HARD ── +1 controlled variable: TWO spawn_point groups — the
242
+ # AGENT base round-robins between a NORTH-staged base (centre
243
+ # (20, 12), enemy comes from y≈4) and a SOUTH-staged base (centre
244
+ # (20, 28), enemy comes from y≈36). The intel rotates accordingly:
245
+ # for the NORTH base, "fortify NORTH" means defend at (20, 6)
246
+ # (between the base and the enemy north of it); for the SOUTH
247
+ # base, "fortify SOUTH" means defend at (20, 34) (between the
248
+ # base and the enemy south of it). The win predicate uses `any_of`
249
+ # over the two correct region orientations — the agent must place
250
+ # its pbox triple on the lane that matches its OWN spawn (a
251
+ # NORTH-base pbox triple does NOT satisfy the SOUTH-base region).
252
+ # The rusher hits from BOTH latitudes (each spawn faces the side
253
+ # matching its base — see comment on enemy actors below).
254
+ # max_turns 62 → reachable tick 5583 ≥ 5402.
255
+ hard:
256
+ description: >
257
+ INTEL: the enemy will attack from the side FACING your base —
258
+ from the NORTH if your base is in the NORTH half, from the
259
+ SOUTH if your base is in the SOUTH half. Identify your base's
260
+ latitude from the observation, then build at least THREE
261
+ pillboxes (pbox) and place all three BETWEEN your base and the
262
+ threat — within radius 6 of either (20, 6) (if your base is at
263
+ y=12) or (20, 34) (if your base is at y=28). Keep the
264
+ construction yard standing AND ≥3 of your own units alive
265
+ before tick 5400. Defending on the WRONG side (the side away
266
+ from your base's threat) does not satisfy the region clause
267
+ AND leaves the threat axis naked; building no pillboxes loses
268
+ both clauses; stalling loses on the clock.
269
+ overrides:
270
+ actors:
271
+ # ── NORTH spawn (spawn_point 0) — base centred at (20, 12).
272
+ # Defence region for THIS spawn: (20, 6) r 5 (between the
273
+ # base and the rusher band north of it at y=4).
274
+ - {type: fact, owner: agent, position: [20, 12], spawn_point: 0}
275
+ - {type: tent, owner: agent, position: [16, 12], spawn_point: 0}
276
+ - {type: powr, owner: agent, position: [20, 16], spawn_point: 0}
277
+ - {type: e1, owner: agent, position: [22, 16], stance: 2, spawn_point: 0}
278
+ - {type: e1, owner: agent, position: [24, 16], stance: 2, spawn_point: 0}
279
+ - {type: e1, owner: agent, position: [22, 18], stance: 2, spawn_point: 0}
280
+ - {type: e1, owner: agent, position: [24, 18], stance: 2, spawn_point: 0}
281
+ # ── SOUTH spawn (spawn_point 1) — base centred at (20, 28).
282
+ # Defence region for THIS spawn: (20, 34) r 5 (between the
283
+ # base and the rusher band south of it at y=36).
284
+ - {type: fact, owner: agent, position: [20, 28], spawn_point: 1}
285
+ - {type: tent, owner: agent, position: [16, 28], spawn_point: 1}
286
+ - {type: powr, owner: agent, position: [20, 24], spawn_point: 1}
287
+ - {type: e1, owner: agent, position: [22, 22], stance: 2, spawn_point: 1}
288
+ - {type: e1, owner: agent, position: [24, 22], stance: 2, spawn_point: 1}
289
+ - {type: e1, owner: agent, position: [22, 24], stance: 2, spawn_point: 1}
290
+ - {type: e1, owner: agent, position: [24, 24], stance: 2, spawn_point: 1}
291
+ # Enemy rushers — CLAUDE.md: enemy actors WITHOUT a
292
+ # spawn_point are placed regardless of the chosen agent
293
+ # group, so we declare BOTH a north and a south rush band.
294
+ # The `rusher` bot charges the agent centroid; whichever
295
+ # spawn is active, the matching-side band is the immediate
296
+ # threat (the opposite band has further to walk and the
297
+ # matching-side correct pbox triple will already be
298
+ # absorbing the lead band by the time the far band closes).
299
+ - {type: e1, owner: enemy, position: [20, 4], stance: 3, count: 4}
300
+ - {type: e3, owner: enemy, position: [22, 4], stance: 3, count: 1}
301
+ - {type: e1, owner: enemy, position: [20, 36], stance: 3, count: 4}
302
+ - {type: e3, owner: enemy, position: [22, 36], stance: 3, count: 1}
303
+ - {type: fact, owner: enemy, position: [120, 20]}
304
+ # Two CORRECT region orientations — the pbox triple must sit
305
+ # between THIS spawn's base and THIS spawn's matching rush band.
306
+ win_condition:
307
+ all_of:
308
+ - building_count_gte: {type: pbox, n: 3}
309
+ - any_of:
310
+ - building_in_region: {type: pbox, x: 20, y: 6, radius: 5, count: 3}
311
+ - building_in_region: {type: pbox, x: 20, y: 34, radius: 5, count: 3}
312
+ - has_building: fact
313
+ - own_units_gte: 3
314
+ - within_ticks: 5400
315
+ fail_condition:
316
+ any_of:
317
+ - after_ticks: 5401
318
+ - not: {has_building: fact}
319
+ - not: {own_units_gte: 1}
320
+ max_turns: 66
tests/test_def_position_expected_direction.py ADDED
@@ -0,0 +1,339 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """def-position-expected-direction scenario pack, full loop on Rust.
2
+
3
+ REASONING capability — DEFENSE POSITIONING BASED ON DECLARED THREAT
4
+ DIRECTION. The model is TOLD where the enemy will attack from
5
+ (the objective brief states the intel: "rushers from the NORTH").
6
+ The decision is purely one of SPATIAL COMMITMENT: invest the
7
+ defence budget on the CORRECT lane and the rush is blunted; commit
8
+ the same budget on the WRONG lane (SOUTH) and the rushers walk past,
9
+ the deadline expires (the wrong-direction pbox cannot satisfy the
10
+ NORTH region clause), and the run loses. Building no defences at
11
+ all also loses (no pbox).
12
+
13
+ The win predicate makes the directional axis load-bearing:
14
+
15
+ * `building_count_gte:{pbox, 3}` ⇒ three pillboxes total (forces a
16
+ real defence commitment, not a token pbox);
17
+ * `building_in_region:{pbox, NORTH_centre, r=6, 3}` ⇒ ALL THREE must
18
+ sit in the NORTH region — a SOUTH-built triple cannot satisfy it;
19
+ * `has_building: fact` ⇒ the construction yard still stands;
20
+ * `own_units_gte: 3` ⇒ the throughput SLA (the four pre-placed
21
+ defenders must mostly survive);
22
+ * `within_ticks: 5400` paired with `after_ticks: 5401` ⇒ a non-
23
+ finisher is a real reachable timeout LOSS (max_turns 62 reaches
24
+ tick 5583 in interrupt mode), never a draw.
25
+
26
+ These tests prove deterministically (no model / no network) that:
27
+
28
+ * the intended NORTH-pbox triple WINS every level + every hard seed;
29
+ * stall, build-defences-SOUTH (wrong direction), and build-no-
30
+ defences ALL LOSE every level + every hard seed (a real LOSS,
31
+ never a draw);
32
+ * `after_ticks` is reachable inside `max_turns`;
33
+ * the hard tier defines ≥2 spawn_point groups so the threat axis
34
+ rotates by seed (single-cell memorisation cannot generalise).
35
+ """
36
+
37
+ from __future__ import annotations
38
+
39
+ import pytest
40
+
41
+ pytest.importorskip("openra_train", reason="Rust env wheel not installed")
42
+ pytest.importorskip("openra_rl_training", reason="Rust env wheel not installed")
43
+
44
+ from openra_bench.eval_core import run_level
45
+ from openra_bench.scenarios import load_pack
46
+ from openra_bench.scenarios.loader import PACKS_DIR, compile_level
47
+
48
+ PACK = PACKS_DIR / "def-position-expected-direction.yaml"
49
+ LEVELS = ("easy", "medium", "hard")
50
+ SEEDS = (1, 2, 3, 4)
51
+
52
+
53
+ # ── helpers ──────────────────────────────────────────────────────────
54
+
55
+
56
+ def _fact_xy(rs):
57
+ fact = next(
58
+ (b for b in (rs.get("own_buildings") or [])
59
+ if b.get("type") == "fact"),
60
+ None,
61
+ )
62
+ if fact is None:
63
+ return None
64
+ return int(fact["cell_x"]), int(fact["cell_y"])
65
+
66
+
67
+ # ── scripted policies ────────────────────────────────────────────────
68
+
69
+
70
+ def stall(rs, C):
71
+ """Observe-only. Never builds anything; never satisfies the pbox
72
+ count or the region clauses → reachable timeout LOSS."""
73
+ return [C.observe()]
74
+
75
+
76
+ def make_intended():
77
+ """Build THREE pbox in the NORTH region of the agent base. The
78
+ NORTH side is `fy - 6` from the base (for easy/medium the base
79
+ is at y=20 so pbox at y≈14; for the hard NORTH spawn at y=12
80
+ pbox at y≈6; for the hard SOUTH spawn at y=28 pbox at y≈34).
81
+ Detects spawn by reading fact y and picks the side AWAY from
82
+ the centre (rushers come from the OUTSIDE on hard; on easy/
83
+ medium they come from the NORTH so the centre IS the rush
84
+ direction)."""
85
+
86
+ def policy(rs, C):
87
+ own_b = rs.get("own_buildings") or []
88
+ types = [b.get("type") for b in own_b]
89
+ pbox_count = sum(1 for t in types if t == "pbox")
90
+ prod = rs.get("production") or []
91
+ prod_items = [
92
+ p.get("item") for p in prod if isinstance(p, dict)
93
+ ]
94
+ xy = _fact_xy(rs)
95
+ if xy is None:
96
+ return [C.observe()]
97
+ fx, fy = xy
98
+ # Pick the defence side: for easy/medium fact at y=20 →
99
+ # NORTH lane (fy-6 = 14). For hard NORTH spawn (fy=12) →
100
+ # NORTH (fy-6 = 6). For hard SOUTH spawn (fy=28) → SOUTH
101
+ # (fy+6 = 34). Heuristic: if fact y is in the SOUTH half
102
+ # (y>20), defend SOUTH; otherwise defend NORTH.
103
+ if fy > 20:
104
+ target_y = fy + 6 # SOUTH of base
105
+ else:
106
+ target_y = fy - 6 if fy >= 12 else fy - 6 # NORTH of base
107
+ cmds = []
108
+ if pbox_count < 3:
109
+ if prod_items.count("pbox") + pbox_count < 3:
110
+ cmds.append(C.build("pbox"))
111
+ # Stagger placement so 3 pbox sit in a tight cluster on
112
+ # the threat lane (all within radius 6 of (fx, target_y)).
113
+ dx = -2 + 2 * pbox_count
114
+ cmds.append(
115
+ C.place_building("pbox", fx + dx, target_y)
116
+ )
117
+ if not cmds:
118
+ cmds.append(C.observe())
119
+ return cmds
120
+
121
+ return policy
122
+
123
+
124
+ def make_defenses_south():
125
+ """Build THREE pbox in the WRONG region (the SOUTH side of the
126
+ base when the intel says NORTH, and vice versa for the hard SOUTH
127
+ spawn). Satisfies `building_count_gte:pbox,3` but NEVER the
128
+ `building_in_region` NORTH clause ⇒ deadline LOSS — AND the
129
+ NORTH lane is left naked so the rusher band tears through the
130
+ starter defenders (additional LOSS path on `own_units_gte`)."""
131
+
132
+ def policy(rs, C):
133
+ own_b = rs.get("own_buildings") or []
134
+ types = [b.get("type") for b in own_b]
135
+ pbox_count = sum(1 for t in types if t == "pbox")
136
+ prod = rs.get("production") or []
137
+ prod_items = [
138
+ p.get("item") for p in prod if isinstance(p, dict)
139
+ ]
140
+ xy = _fact_xy(rs)
141
+ if xy is None:
142
+ return [C.observe()]
143
+ fx, fy = xy
144
+ # WRONG side: invert the intended heuristic.
145
+ if fy > 20:
146
+ wrong_y = fy - 6 # NORTH of a SOUTH base ⇒ wrong
147
+ else:
148
+ wrong_y = fy + 6 # SOUTH of a NORTH base ⇒ wrong
149
+ cmds = []
150
+ if pbox_count < 3:
151
+ if prod_items.count("pbox") + pbox_count < 3:
152
+ cmds.append(C.build("pbox"))
153
+ dx = -2 + 2 * pbox_count
154
+ cmds.append(
155
+ C.place_building("pbox", fx + dx, wrong_y)
156
+ )
157
+ if not cmds:
158
+ cmds.append(C.observe())
159
+ return cmds
160
+
161
+ return policy
162
+
163
+
164
+ def no_defenses(rs, C):
165
+ """Build no pillboxes ever. Never satisfies `building_count_gte:
166
+ pbox,3` ⇒ reachable timeout LOSS. (Also typically dies on
167
+ `own_units_gte` as the rush eats the defenders.)"""
168
+ # Burn the budget on extra power plants — proves the agent had
169
+ # cash to spend, just spent it on the wrong category.
170
+ own_b = rs.get("own_buildings") or []
171
+ prod = rs.get("production") or []
172
+ prod_items = [p.get("item") for p in prod if isinstance(p, dict)]
173
+ n_powr = sum(1 for b in own_b if b.get("type") == "powr")
174
+ xy = _fact_xy(rs)
175
+ if xy is None:
176
+ return [C.observe()]
177
+ fx, fy = xy
178
+ cmds = []
179
+ if n_powr < 5 and "powr" not in prod_items:
180
+ cmds.append(C.build("powr"))
181
+ cmds.append(
182
+ C.place_building("powr", fx - 4, fy + 2 + n_powr)
183
+ )
184
+ if not cmds:
185
+ cmds.append(C.observe())
186
+ return cmds
187
+
188
+
189
+ # ── scenario-shape invariants ────────────────────────────────────────
190
+
191
+
192
+ def test_pack_compiles_with_three_levels_and_rusher_bot():
193
+ pack = load_pack(PACK)
194
+ assert pack.meta.id == "def-position-expected-direction"
195
+ assert pack.meta.capability == "reasoning"
196
+ assert set(pack.levels) == {"easy", "medium", "hard"}
197
+ # Required-by-spec benchmark anchors.
198
+ anchors = pack.meta.benchmark_anchor
199
+ assert any("ERQA" in a for a in anchors), anchors
200
+ assert any("MicroRTS" in a for a in anchors), anchors
201
+ assert any("military" in a.lower() for a in anchors), anchors
202
+ assert any("intel" in a.lower() for a in anchors), anchors
203
+ # Rusher bot wired through to the engine for every level.
204
+ for lvl in LEVELS:
205
+ c = compile_level(pack, lvl)
206
+ assert c.map_supported
207
+ enemy = c.scenario.enemy
208
+ bot = getattr(enemy, "bot_type", None) or getattr(enemy, "bot", None)
209
+ assert str(bot).lower() == "rusher", (lvl, bot)
210
+
211
+
212
+ @pytest.mark.parametrize("level", LEVELS)
213
+ def test_every_level_has_a_reachable_timeout_fail(level):
214
+ """Non-win must be a real LOSS: the `after_ticks` fail must be
215
+ strictly below the tick reachable at max_turns (≤90 ticks/step
216
+ in interrupt mode)."""
217
+ c = compile_level(load_pack(PACK), level)
218
+ assert c.fail_condition is not None
219
+ fc = c.fail_condition.model_dump(exclude_none=True)
220
+ deadline = None
221
+ for clause in fc.get("any_of", []) or []:
222
+ if "after_ticks" in clause:
223
+ deadline = int(clause["after_ticks"])
224
+ assert deadline is not None, f"{level}: no after_ticks fail clause"
225
+ reachable = 93 + 90 * (c.max_turns - 1)
226
+ assert deadline < reachable, (
227
+ f"{level}: deadline {deadline} unreachable within "
228
+ f"{c.max_turns} turns (max tick {reachable}) → draw degeneracy"
229
+ )
230
+
231
+
232
+ @pytest.mark.parametrize("level", LEVELS)
233
+ def test_every_level_enforces_pbox_count_and_region(level):
234
+ """The directional axis must be load-bearing: the win condition
235
+ must require BOTH ≥3 pbox AND those pbox in the correct region."""
236
+ c = compile_level(load_pack(PACK), level)
237
+ win = c.win_condition.model_dump(exclude_none=True)
238
+ flat = str(win)
239
+ assert "building_count_gte" in flat and "pbox" in flat, win
240
+ assert "building_in_region" in flat, win
241
+ # The placement region must be NORTH-of-base (low y) on
242
+ # easy/medium, and `any_of` over NORTH+SOUTH on hard.
243
+ region_ys = []
244
+
245
+ def walk(node):
246
+ if isinstance(node, dict):
247
+ if "building_in_region" in node:
248
+ v = node["building_in_region"]
249
+ if (v or {}).get("type") == "pbox":
250
+ region_ys.append(int(v["y"]))
251
+ for v in node.values():
252
+ walk(v)
253
+ elif isinstance(node, list):
254
+ for v in node:
255
+ walk(v)
256
+
257
+ walk(win)
258
+ assert region_ys, win
259
+ if level == "hard":
260
+ # hard must use any_of over the two correct region orientations.
261
+ assert "any_of" in flat, win
262
+ assert set(region_ys) == {6, 34}, region_ys
263
+ else:
264
+ # easy/medium fortify NORTH of base (y=20) ⇒ region centre y=14.
265
+ assert region_ys == [14], region_ys
266
+
267
+
268
+ def test_hard_has_two_spawn_point_groups():
269
+ """Hard-tier contract: ≥2 distinct seed-driven spawn_point groups
270
+ so the threat axis rotates by seed (anti-memorisation)."""
271
+ c = compile_level(load_pack(PACK), "hard")
272
+ groups = {
273
+ a.spawn_point for a in c.scenario.actors
274
+ if a.owner == "agent" and a.spawn_point is not None
275
+ }
276
+ assert groups == {0, 1}, groups
277
+ # In-bounds check (rush-hour-arena playable y ≈ 2..38).
278
+ for a in c.scenario.actors:
279
+ x, y = a.position
280
+ assert 2 <= x <= 126 and 2 <= y <= 38, (a.type, a.position)
281
+
282
+
283
+ # ── solvency: intended WINS every level + every hard seed ────────────
284
+
285
+
286
+ @pytest.mark.parametrize("level", LEVELS)
287
+ def test_intended_north_pbox_triple_wins_every_level_and_seed(level):
288
+ c = compile_level(load_pack(PACK), level)
289
+ for seed in SEEDS:
290
+ r = run_level(c, make_intended(), seed=seed)
291
+ assert r.outcome == "win", (
292
+ f"{level} seed{seed}: intended NORTH-pbox-triple play "
293
+ f"must WIN; got {r.outcome} "
294
+ f"(tick={r.signals.game_tick}, "
295
+ f"lost={r.signals.units_lost}, "
296
+ f"buildings={r.signals.own_buildings})"
297
+ )
298
+
299
+
300
+ # ── no-cheat: every wrong / lazy policy LOSES (real LOSS, not draw) ──
301
+
302
+
303
+ @pytest.mark.parametrize("level", LEVELS)
304
+ @pytest.mark.parametrize(
305
+ "policy_name,policy_factory",
306
+ [
307
+ ("stall", lambda: stall),
308
+ ("defenses_south", make_defenses_south),
309
+ ("no_defenses", lambda: no_defenses),
310
+ ],
311
+ )
312
+ def test_lazy_and_wrong_direction_policies_lose_every_level_and_seed(
313
+ level, policy_name, policy_factory
314
+ ):
315
+ """Stall (no pbox), defenses-SOUTH (3 pbox in WRONG region), and
316
+ no-defenses (no pbox, just power) must ALL LOSE on every level +
317
+ every seed — a real reachable timeout LOSS, never a draw."""
318
+ c = compile_level(load_pack(PACK), level)
319
+ for seed in SEEDS:
320
+ r = run_level(c, policy_factory(), seed=seed)
321
+ assert r.outcome == "loss", (
322
+ f"{level} seed{seed} {policy_name}: must LOSE (real fail, "
323
+ f"not a draw); got {r.outcome} "
324
+ f"(tick={r.signals.game_tick}, "
325
+ f"lost={r.signals.units_lost}, "
326
+ f"buildings={r.signals.own_buildings})"
327
+ )
328
+
329
+
330
+ # ── determinism ──────────────────────────────────────────────────────
331
+
332
+
333
+ def test_intended_run_is_deterministic_on_easy():
334
+ c = compile_level(load_pack(PACK), "easy")
335
+ a = run_level(c, make_intended(), seed=3)
336
+ b = run_level(c, make_intended(), seed=3)
337
+ assert (a.outcome, a.turns) == (b.outcome, b.turns), (
338
+ "same seed must be deterministic"
339
+ )
tests/test_hard_tier.py CHANGED
@@ -152,6 +152,13 @@ UPGRADED = [
152
  # main session.
153
  "lh-opening-to-tech-to-army",
154
  "lh-tech-pivot-attack",
 
 
 
 
 
 
 
155
  "mfb-tech-base-vs-economy-base",
156
  "combat-harass-balanced-hit-and-run",
157
  "tech-aggro-all-in",
@@ -201,6 +208,119 @@ UPGRADED = [
201
  # regardless (enemy actors don't honour spawn_point — CLAUDE.md),
202
  # so a memorised pbox layout from one spawn does not generalise.
203
  "expansion-turtle-1-base-fortified",
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
204
  ]
205
 
206
  # Consciously NOT spawn-varied, with the reason (keeps the curation
 
152
  # main session.
153
  "lh-opening-to-tech-to-army",
154
  "lh-tech-pivot-attack",
155
+ # Wave-5 Group G long-horizon: 5+ ordered checkpoints chained via
156
+ # the Wave-2 `then:` composite (PERT critical path / PlanBench
157
+ # long-sequencing anchor). Hard defines 2 agent spawn_point groups
158
+ # (NORTH base y=14..18 / SOUTH base y=22..26) round-robined by
159
+ # seed; the per-seed base latitude flips so a memorised 6-phase
160
+ # opening cannot generalise.
161
+ "lh-multi-checkpoint-5-plus",
162
  "mfb-tech-base-vs-economy-base",
163
  "combat-harass-balanced-hit-and-run",
164
  "tech-aggro-all-in",
 
208
  # regardless (enemy actors don't honour spawn_point — CLAUDE.md),
209
  # so a memorised pbox layout from one spawn does not generalise.
210
  "expansion-turtle-1-base-fortified",
211
+ # Wave-5 Group G long-horizon "secure-expand-with-tech" pack (SC2
212
+ # secure-expand multi-base macro / PlanBench multi-stage credit
213
+ # assignment anchor). Strict 3-phase chain (pbox×3 → weap → 2nd
214
+ # fact in east region) on medium; 4-phase on hard (+ second proc).
215
+ # Hard defines two agent spawn_point groups (NORTH y=22 / SOUTH
216
+ # y=38) round-robined by seed; two patrol bands hit BOTH latitudes
217
+ # (enemy actors don't honour spawn_point — CLAUDE.md) so a memorised
218
+ # base layout cannot generalise.
219
+ "lh-defense-tech-second-base",
220
+ # Wave-5 Group C reasoning seed — central reserve doctrine /
221
+ # chess-piece centralization / military QRF. Hard tier defines two
222
+ # agent spawn_point groups (centre column x=40 / x=50) round-
223
+ # robined by seed so the reserve's exact staging cell varies but
224
+ # both remain equidistant from N/S flanks; THREE simultaneous
225
+ # rush bands (N/S/E) always place (enemy actors don't honour
226
+ # spawn_point — CLAUDE.md), so a single committed-flank policy
227
+ # cannot generalise and the centre-then-react doctrine is the
228
+ # only winning play.
229
+ "def-pre-position-mobile-reserve",
230
+ # Wave-5 Group C reasoning pack — ERQA spatial commit / MicroRTS
231
+ # terrain-aware defence / military pre-positioned defence /
232
+ # intel-driven facility hardening anchor. The model is TOLD where
233
+ # the enemy will attack from; the decision is purely spatial
234
+ # commitment (build the pbox triple on the CORRECT lane). Hard
235
+ # tier defines two agent spawn_point groups (NORTH base y=12 /
236
+ # SOUTH base y=28) round-robined by seed and the win clause is
237
+ # `any_of` over the two correct region orientations — the
238
+ # threat-axis intel rotates with the spawn so "NORTH of YOUR
239
+ # base" flips per seed; a memorised single-cell pbox layout
240
+ # cannot generalise.
241
+ "def-position-expected-direction",
242
+ # Wave-5 Group N coordination pack — SC2 triple-prong assault /
243
+ # military convergent (pincer) attack / envelopment doctrine. Hard
244
+ # tier defines two agent spawn_point groups (squads staged
245
+ # NORTH/WEST/SOUTH in group 0, NORTH/EAST/SOUTH in group 1)
246
+ # round-robined by seed; the third squad's ingress bearing flips
247
+ # per seed and a single memorised opening cannot generalise. The
248
+ # defender cluster is symmetric so both spawns face the same
249
+ # convergent geometry from different start corners.
250
+ "coord-converge-on-target",
251
+ # Wave-5 Group N coordination pack — SC2 attack-wave timing /
252
+ # SMAC relay strike / military overlapping fires (bound-and-bound)
253
+ # anchor. Squad A (3× e3 rockets) softens the enemy 2tnk cluster
254
+ # FIRST; Squad B (3× 2tnk) follows up to mop up the e1 survivors.
255
+ # Hard tier defines two agent spawn_point groups (NORTH staging
256
+ # y=12..18 / SOUTH staging y=28..34) round-robined by seed; the
257
+ # central enemy cluster (6× 2tnk + 6× e1 at y=20) is symmetric
258
+ # across the map's mid-latitude so either staging faces an
259
+ # equivalent relay decision but no memorised approach corridor
260
+ # generalises across seeds.
261
+ "coord-relay-attack",
262
+ # Wave-5 Group G long-horizon "marathon survival" pack — lmgame-Bench
263
+ # multi-hour endurance / SC2LE full-game extended testing / SRE
264
+ # extended on-call anchor. Hard tier defines two agent spawn_point
265
+ # groups (NORTH y=12 / SOUTH y=28) round-robined by seed; six
266
+ # staggered hunt squads always place at BOTH latitudes (enemy
267
+ # actors don't honour spawn_point — CLAUDE.md), so a memorised
268
+ # pbox-lane / starting cell cannot generalise across seeds.
269
+ "lh-100-turn-marathon-survival",
270
+ # Wave-5 Group F reasoning pack — SC2 mass-vs-elite army composition
271
+ # / industrial mass-production vs craftsmanship / military
272
+ # quantity-vs-quality doctrine / OR unit-composition optimisation
273
+ # anchor. Hard tier defines two agent spawn_point groups (NORTH
274
+ # base y=10 / SOUTH base y=30) round-robined by seed; TWO enemy
275
+ # garrisons of OPPOSING composition (anti-INFANTRY at the north
276
+ # latitude, anti-VEHICLE at the south latitude) always place
277
+ # (enemy actors don't honour spawn_point — CLAUDE.md), so the
278
+ # right counter (mass-cheap rifles vs elite heavy tanks) flips
279
+ # per seed and a single memorised opening cannot generalise.
280
+ "econ-quantitative-vs-qualitative-spend",
281
+ # Wave-5 Group N coord seed: SQUAD HANDOFF via Wave-2 `then:`
282
+ # composite + new `units_of_type_in_region_gte` predicate (type-
283
+ # filtered region count). Hard defines two agent spawn_point groups
284
+ # (NW staging y=5..12 / SW staging y=33..38) round-robined by seed;
285
+ # the four-leg alternating handoff (A→P1→B→P2→A→P3→B→P4) is
286
+ # symmetric across starts but the per-squad long-haul leg flips
287
+ # per seed so a memorised opening cannot generalise.
288
+ "coord-squad-handoff",
289
+ # Wave-5 Group G long-horizon reasoning seed — SC2 macro-then-
290
+ # multi-prong-attack / military operational planning (force build
291
+ # then theater assault) / PERT theater-wide ops sequencing.
292
+ # Two-phase plan: assemble ≥N medium tanks THEN raze TWO separated
293
+ # enemy fact (NE 130,15 + SE 130,45) in parallel. Hard tier defines
294
+ # two agent spawn_point groups (NORTH base y=18 / SOUTH base y=42)
295
+ # round-robined by seed; both enemy bases always place (enemy
296
+ # actors don't honour spawn_point — CLAUDE.md), so the "near" vs
297
+ # "far" front flips per seed and the per-prong assignment must be
298
+ # re-computed instead of memorised.
299
+ "lh-build-army-coordinate-multifront-attack",
300
+ # Wave-5 Group F turtle-recovery seed — SC2 turtle-back-to-economy
301
+ # / business bankruptcy turnaround / military rebuild-from-defeat /
302
+ # startup pivot-after-runway-crisis anchor. Agent starts with a
303
+ # minimum viable kit (fact + proc + 1 harv + 1 mine) and $0 cash;
304
+ # must commit to harvest first then reinvest into a war factory +
305
+ # 2nd/3rd harvester. Hard tier defines 2 agent spawn_point groups
306
+ # (NORTH base y=14 / SOUTH base y=26) round-robined by seed; each
307
+ # spawn has its own symmetric two-patch geometry so the harv
308
+ # routing flips per seed and a memorised "place weap at (8,18)"
309
+ # opening cannot generalise.
310
+ "econ-recover-from-zero-cash",
311
+ # Wave-5 Group C defense-topology pack — graph theory min-cut /
312
+ # military bunker placement / firewall topology at chokepoints /
313
+ # Lanchester defense concentration anchor. The win predicate makes
314
+ # the cluster-vs-spread topology decision load-bearing
315
+ # (`building_in_region` ≥ N out of `building_count_gte` ≥ M total).
316
+ # Hard tier defines two agent spawn_point groups (NORTH base y=10 /
317
+ # SOUTH base y=30) round-robined by seed; the rusher band is staged
318
+ # symmetrically at y=20 (enemies don't honour spawn_point — CLAUDE.md)
319
+ # and charges the agent centroid, so the cluster's required world
320
+ # cell is geographically distant from each base — a memorised
321
+ # relative-to-base placement that lands in the same world cell on
322
+ # every seed cannot generalise.
323
+ "def-tower-line-vs-cluster",
324
  ]
325
 
326
  # Consciously NOT spawn-varied, with the reason (keeps the curation