yxc20098 commited on
Commit
c5e9821
·
1 Parent(s): 6370f31

feat(scenario): def-position-revealed-direction — scout-then-fortify adaptive defense (PlanBench replanning anchor)

Browse files
openra_bench/scenarios/packs/def-position-revealed-direction.yaml ADDED
@@ -0,0 +1,457 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # def-position-revealed-direction.yaml
2
+ #
3
+ # REASONING capability — INTEL-DRIVEN ADAPTIVE DEFENSE. Unlike its
4
+ # sibling `def-position-expected-direction` (where the threat axis is
5
+ # STATED in the brief and the agent merely commits a budget to the
6
+ # named lane), this pack hides the axis: the agent must FIRST SCOUT to
7
+ # discover where the enemy's forward outpost actually is, THEN fortify
8
+ # THAT lane with pillboxes, THEN engage and kill the incoming rush.
9
+ # Pre-fortifying any one direction on faith LOSES on seeds where the
10
+ # enemy comes from the opposite side; only the scout-then-fortify
11
+ # discipline generalises.
12
+ #
13
+ # Win idiom (the load-bearing chain):
14
+ # then:
15
+ # - {buildings_discovered_gte: 1} # FIRST — scout finds the
16
+ # # enemy outpost on the
17
+ # # approach axis (outposts are
18
+ # # OUTSIDE pbox/base vision; a
19
+ # # scout MUST move on medium/
20
+ # # hard).
21
+ # - {building_count_gte: {type: pbox, n: 3}} # THEN — commit 3
22
+ # # pillboxes (defensive
23
+ # # posture).
24
+ # - {units_killed_gte: K} # THEN — actually engage and
25
+ # # break the incoming rush.
26
+ # AND building_count_gte:{type: fact, n: 1} AND within_ticks: T.
27
+ # Fail: after_ticks: T+1 OR not building_count_gte:{type: fact, n: 1}
28
+ # OR not own_units_gte: 1.
29
+ # (We use `building_count_gte` rather than `has_building` because the
30
+ # latter checks `own_building_types`, which ACCUMULATES across the
31
+ # episode — once `fact` has been owned, `has_building:fact` stays true
32
+ # even after the fact is razed, so it is structurally useless as a
33
+ # "still standing" fail clause. `building_count_gte` reads the live
34
+ # `own_buildings` list each frame and bites correctly when the fact
35
+ # is destroyed.)
36
+ #
37
+ # Pre-fortify-N-without-scout fails: clause 1 of the then-chain never
38
+ # latches because the pbox vision radius (~6) cannot reach the enemy
39
+ # outpost (placed 10+ cells away from the base on the approach axis).
40
+ # Pre-fortify-N-with-scout (memorised NORTH on hard) fails on the
41
+ # SOUTH-spawn seeds: the defenses sit silent on the wrong axis, the
42
+ # south-band rushers walk straight to the south-spawn fact,
43
+ # units_killed never reaches K, and the fact is razed → double LOSS
44
+ # path (chain clause 3 + the fact-loss fail clause). Stall LOSES on
45
+ # the deadline (or earlier on the fact-loss fail clause when the rush
46
+ # closes the home fact).
47
+ #
48
+ # Real-world / benchmark anchors:
49
+ # - PlanBench replanning: model must update its plan based on
50
+ # observation (which axis is the actual threat) before committing.
51
+ # - Military reactive defense / intel-driven facility hardening:
52
+ # reconnaissance precedes fortification; pre-committing without
53
+ # intel is the classical "Maginot Line" failure mode (defenses on
54
+ # the wrong axis are dead weight).
55
+ # - Intel-driven defense: convert reconnaissance into committed
56
+ # defensive posture; a generalist policy MUST drive the scout
57
+ # loop, not memorise a fixed axis.
58
+ #
59
+ # Distinct from siblings:
60
+ # * def-position-expected-direction — intel is GIVEN; the decision
61
+ # is purely WHICH axis given the textual cue. THIS pack hides the
62
+ # axis; the decision is whether to SCOUT before committing.
63
+ # * def-pre-position-mobile-reserve — direction unknown; mobile
64
+ # reserve commits on detection (no static defenses). THIS pack
65
+ # requires STATIC fortification AFTER scouting.
66
+ # * def-surprise-flank-react — intel is FALSE (announced N, actual
67
+ # S); the trap is trusting the brief. THIS pack has NO false
68
+ # intel; the brief states "you don't know — scout to find out".
69
+ # * lh-scout-react-counter — scout→react→counter on a FIXED enemy
70
+ # posture; the chain is the same idiom but the capability is
71
+ # offensive (raze the construction yard) rather than defensive
72
+ # (fortify the lane).
73
+ #
74
+ # ENGINE FACTS (per CLAUDE.md / SCENARIO_QUALITY.md):
75
+ # - `pbox` costs 600cr; needs tent (which needs powr). Pre-place
76
+ # fact + proc + powr + tent so the pbox queue is hot from turn 1.
77
+ # Starting cash 2400 = 3 × 600 + ~600 buffer.
78
+ # - Enemy actors with NO spawn_point ALWAYS place regardless of the
79
+ # agent's spawn group (CLAUDE.md). So on hard we declare BOTH a
80
+ # NORTH band and a SOUTH band; the seed-driven agent spawn picks
81
+ # which band is the "near" (immediate) one. Each band has its
82
+ # own outpost building so scouting either side reveals exactly
83
+ # one outpost.
84
+ # - `then:` is the happened-before composite: clauses latch IN
85
+ # ORDER, so a pre-fortify-no-scout play that places 3 pbox first
86
+ # and then idles cannot satisfy clause 1 (scout) after the fact
87
+ # UNLESS a scout actually drives close enough to see the
88
+ # outpost — which is the load-bearing capability.
89
+ # - `building_count_gte:{pbox, 3}` (NOT building_in_region) — the
90
+ # scout latch already enforces axis awareness via clause 1; we
91
+ # don't double-encode the region. Placement is purely about the
92
+ # agent committing to defense after intel; lane choice is enforced
93
+ # by the units_killed clause (3 pbox on the wrong axis sit silent,
94
+ # the rushers walk past, kills don't accrue, fact dies).
95
+ # - Inert enemy `fact` marker far east — anti-DRAW per CLAUDE.md;
96
+ # without a persistent enemy actor the engine auto-`done`s on
97
+ # enemy-elim before the win/fail evaluator runs.
98
+ # - `enemy_unit_spotted` interrupt — re-decide the instant a rusher
99
+ # unit is spotted (the actual axis becomes confirmed by the rush
100
+ # itself, in case the scout missed the outpost).
101
+ # - `enemy_building_spotted` interrupt — re-decide the instant the
102
+ # outpost is discovered (the FIRST clause latches).
103
+ # - Tick budget: max_turns 66 → reachable tick 93 + 90·65 = 5943 ≥
104
+ # 5402 (deadline 5400 + 1 = 5401), so a non-finisher is a real
105
+ # reachable timeout LOSS, never a draw.
106
+ #
107
+ # Validate (no model / no network):
108
+ # python3 -m openra_bench.scenarios.validate \
109
+ # openra_bench/scenarios/packs/def-position-revealed-direction.yaml
110
+
111
+ meta:
112
+ id: def-position-revealed-direction
113
+ title: 'Scout the Axis, Then Fortify — Intel-Driven Adaptive Defense'
114
+ capability: reasoning
115
+ real_world_meaning: >
116
+ Adaptive defense under uncertainty: the threat axis is NOT known
117
+ a priori; the agent must drive its scouts to discover the
118
+ enemy's forward outpost (where the build-up actually is), then
119
+ commit pillboxes on THAT lane, then engage. Pre-committing to a
120
+ single direction without scouting is the classical Maginot-Line
121
+ failure mode — defenses on the wrong axis are dead weight while
122
+ the unguarded lane is overrun. The capability under test is the
123
+ intel-action loop: observation FIRST, commitment SECOND.
124
+ robotics_analogue: >
125
+ Intel-driven facility hardening / military reactive defense: a
126
+ perimeter operator does not know which gate will be breached;
127
+ they must dispatch reconnaissance, confirm the threat axis from
128
+ sensor returns, and then deploy barriers / responders on the
129
+ confirmed lane. Skipping the reconnaissance step and pre-staging
130
+ on a guessed lane is correct half the time and catastrophically
131
+ wrong the other half. The robust doctrine is scout-then-fortify,
132
+ not commit-on-faith.
133
+ benchmark_anchor:
134
+ - 'PlanBench replanning'
135
+ - 'military reactive defense'
136
+ - 'intel-driven defense'
137
+ author: openra-bench
138
+
139
+ # rush-hour-arena (128×40, playable x∈[2..126], y∈[2..38]). Agent
140
+ # base at (20, 20) on easy/medium. The fact + proc + powr + tent let
141
+ # the pbox queue fire from turn 1. Two jeep scouts at the base — the
142
+ # agent must drive them to confirm the enemy outpost's lane before
143
+ # committing pillboxes (medium/hard; on easy the jeeps are pre-staged
144
+ # forward and passively see the outpost from turn 1).
145
+ base_map: rush-hour-arena
146
+
147
+ # Pack-wide budget — funds the intended 3-pbox triple (3 × 600 =
148
+ # 1800cr) with a small buffer (~600cr) for replacement defenders or
149
+ # an extra pbox if the agent wants. Spamming pbox on the wrong axis
150
+ # burns the same budget without satisfying the kill clause (wrong-axis
151
+ # pbox sit silent, the rushers walk past).
152
+ starting_cash: 2400
153
+
154
+ base:
155
+ agent:
156
+ faction: allies
157
+ # Concentrated charge at the agent centroid every ~8 ticks — the
158
+ # canonical rush-defense opponent. The bot's SPAWN POSITION is the
159
+ # hidden information the model must discover by scouting.
160
+ enemy:
161
+ faction: soviet
162
+ bot_type: rusher
163
+ tools:
164
+ - observe
165
+ - build
166
+ - place_building
167
+ - move_units
168
+ - attack_unit
169
+ - attack_move
170
+ - stop
171
+ planning: true
172
+ # Re-decide the instant the scout discovers an enemy outpost
173
+ # (the first latch in the then-chain) or a rusher unit is spotted
174
+ # (confirms the actual axis) or a defender is lost (defense
175
+ # commitment is reactive).
176
+ interrupts:
177
+ enemy_building_spotted: true
178
+ enemy_unit_spotted: true
179
+ own_unit_destroyed: true
180
+ termination:
181
+ max_ticks: 12000
182
+ actors:
183
+ # Base-pack defaults; each level overrides with the spawn-specific
184
+ # layout. (Anti-empty-actors sanity for the loader.)
185
+ - {type: fact, owner: agent, position: [20, 20]}
186
+
187
+ levels:
188
+ # ── EASY ─────────────────────────────────────────────────────────
189
+ # The bare scout-then-fortify chain. Axis is NORTH; the brief
190
+ # explicitly tells the model the chain (scout → fortify → engage)
191
+ # and the threat axis. The two jeeps are pre-positioned FORWARD
192
+ # (y=8) so a single passive observe on turn 1 already has line-of-
193
+ # sight to the enemy outpost (a `tent` at (20, 2), within jeep
194
+ # vision range of ~7). The first then-clause latches immediately;
195
+ # the agent only needs to build 3 pbox and engage the incoming
196
+ # light rush (3 e1) — a 3-kill bar matched by the screen + pbox.
197
+ # max_turns 66 → reachable tick 5943 ≥ 5401.
198
+ easy:
199
+ description: >
200
+ A light enemy rush is incoming from the NORTH (your scouts can
201
+ already see the enemy outpost up the lane). The discipline
202
+ under test is the scout-then-fortify chain: confirm the threat
203
+ with intel, then commit defenses, then engage. You start with
204
+ a construction yard, refinery, power plant, and barracks at
205
+ (20, 20), four rifle defenders, and TWO scout jeeps pre-
206
+ positioned NORTH at (20, 8)/(22, 8) — already in line of
207
+ sight of the enemy outpost. Build at least THREE pillboxes
208
+ (pbox), then destroy ≥3 of the incoming rifle infantry. Keep
209
+ the construction yard standing and ≥1 of your own units
210
+ alive. Win before tick 5400. Stalling loses on the clock;
211
+ pure-build-no-fight loses on the kill bar; pure-fight-no-
212
+ build loses when the rush overruns the defenders.
213
+ overrides:
214
+ actors:
215
+ # Agent base — fact + proc + powr + tent so the pbox queue
216
+ # (needs powr + tent) is hot from turn 1. The tent is also
217
+ # the source of the four pre-placed rifle defenders.
218
+ - {type: fact, owner: agent, position: [20, 20]}
219
+ - {type: proc, owner: agent, position: [16, 20]}
220
+ - {type: powr, owner: agent, position: [20, 24]}
221
+ - {type: tent, owner: agent, position: [16, 24]}
222
+ # Two scout jeeps pre-positioned FORWARD-NORTH at y=8 —
223
+ # already within vision range of the enemy outpost at (20, 2)
224
+ # (distance 6, inside jeep sight ~7). A single observe on
225
+ # turn 1 latches `buildings_discovered_gte:1` — easy teaches
226
+ # the chain without requiring scout MOVEMENT.
227
+ - {type: jeep, owner: agent, position: [20, 8], stance: 1}
228
+ - {type: jeep, owner: agent, position: [22, 8], stance: 1}
229
+ # Four starter defenders SOUTH of the base (away from the
230
+ # NORTH lane mouth) — they survive a light push so
231
+ # own_units_gte:1 holds; positioning them away from the lane
232
+ # avoids auto-fire chasing the rush past the pbox line.
233
+ - {type: e1, owner: agent, position: [22, 24], stance: 2}
234
+ - {type: e1, owner: agent, position: [24, 24], stance: 2}
235
+ - {type: e1, owner: agent, position: [22, 26], stance: 2}
236
+ - {type: e1, owner: agent, position: [24, 26], stance: 2}
237
+ # Enemy forward outpost (NORTH): a `tent` at (20, 2) —
238
+ # placed at the playable edge so it is OUT of canonical pbox
239
+ # vision (a pbox at y=14 is 12 cells from (20,2), well past
240
+ # any pbox sight range). The forward jeeps at y=8 sit 6
241
+ # cells away and see it from turn 1 (jeep sight ~7).
242
+ - {type: tent, owner: enemy, position: [20, 2]}
243
+ # Light NORTH rusher push — 3 e1 at (20, 4). The rusher bot
244
+ # charges the agent centroid (~y=22 with the southern
245
+ # defenders), walking south through (20, 14) where the pbox
246
+ # line intercepts.
247
+ - {type: e1, owner: enemy, position: [20, 4], stance: 3, count: 3}
248
+ # Inert enemy `fact` marker far east — anti-DRAW per
249
+ # CLAUDE.md; without a persistent enemy actor the engine
250
+ # auto-`done`s on enemy-elim before the win/fail evaluator
251
+ # runs.
252
+ - {type: fact, owner: enemy, position: [120, 20]}
253
+ win_condition:
254
+ all_of:
255
+ - then:
256
+ id: scout-fortify-engage-easy
257
+ clauses:
258
+ - {buildings_discovered_gte: 1}
259
+ - {building_count_gte: {type: pbox, n: 3}}
260
+ - {units_killed_gte: 3}
261
+ - building_count_gte: {type: fact, n: 1}
262
+ - own_units_gte: 1
263
+ - within_ticks: 5400
264
+ fail_condition:
265
+ any_of:
266
+ - after_ticks: 5401
267
+ - not: {building_count_gte: {type: fact, n: 1}}
268
+ - not: {own_units_gte: 1}
269
+ max_turns: 66
270
+
271
+ # ── MEDIUM ───────────────────────────────────────────────────────
272
+ # +1 controlled variable: the AXIS IS FOGGED. Jeeps now start AT
273
+ # the base (20, 20), NOT forward — the outpost at (20, 2) is 18
274
+ # cells away, well outside passive vision (~7). A no-scout play
275
+ # cannot latch the first then-clause. The intended play is
276
+ # `move_units([jeep1, jeep2], 20, 8)` to enter outpost sight, THEN
277
+ # build 3 pbox NORTH, THEN engage.
278
+ # A pair of forward delayer e1 at y=10 absorbs the first wave so
279
+ # the pbox triple has time to come online (without them, even an
280
+ # intended-scout play loses because the rush razes the fact before
281
+ # 3 pbox finish building).
282
+ # max_turns 66 → reachable tick 5943 ≥ 5401.
283
+ medium:
284
+ description: >
285
+ A rush is incoming, but the threat axis is NOT given — you
286
+ must SCOUT to find it. You start with a construction yard,
287
+ refinery, power plant, and barracks at (20, 20), four rifle
288
+ defenders, and TWO scout jeeps at the base. The enemy has a
289
+ forward outpost on the approach axis; drive a jeep north (or
290
+ whichever lane your observation suggests) to register at least
291
+ ONE enemy building, THEN build at least THREE pillboxes (pbox),
292
+ THEN destroy ≥3 of the incoming rifle infantry. Keep the
293
+ construction yard standing and ≥1 of your own units alive.
294
+ Win before tick 5400. Skipping the scout phase fails the
295
+ then-chain at clause 1; pre-fortifying without scouting wastes
296
+ the budget on the wrong-axis intuition; stalling loses on the
297
+ clock.
298
+ overrides:
299
+ actors:
300
+ - {type: fact, owner: agent, position: [20, 20]}
301
+ - {type: proc, owner: agent, position: [16, 20]}
302
+ - {type: powr, owner: agent, position: [20, 24]}
303
+ - {type: tent, owner: agent, position: [16, 24]}
304
+ # Jeeps AT the base — must drive to scout. Distance from
305
+ # (24, 20) to outpost (20, 2) is √(16+324)≈18, far out of
306
+ # jeep vision (~7).
307
+ - {type: jeep, owner: agent, position: [24, 20], stance: 1}
308
+ - {type: jeep, owner: agent, position: [26, 20], stance: 1}
309
+ - {type: e1, owner: agent, position: [22, 24], stance: 2}
310
+ - {type: e1, owner: agent, position: [24, 24], stance: 2}
311
+ - {type: e1, owner: agent, position: [22, 26], stance: 2}
312
+ - {type: e1, owner: agent, position: [24, 26], stance: 2}
313
+ # Forward delayer pair NORTH of the base — stationed ahead of
314
+ # the pbox line so they soak the first wave while the pbox
315
+ # triple comes online (the rush from y=4 reaches y=14 before
316
+ # the 1st pbox is up; without absorbers, the wave punches
317
+ # through and razes the fact before clause 3 can latch).
318
+ - {type: e1, owner: agent, position: [20, 10], stance: 2}
319
+ - {type: e1, owner: agent, position: [22, 10], stance: 2}
320
+ # Enemy outpost (NORTH) at the playable edge (y=2) — well
321
+ # outside pbox vision (pbox at y=14 is 12 cells away). Scout
322
+ # MUST drive a jeep north to enter outpost sight range.
323
+ # Placing pbox WITHOUT scouting cannot retroactively satisfy
324
+ # clause 1 of the then-chain.
325
+ - {type: tent, owner: enemy, position: [20, 2]}
326
+ # NORTH rush — 3 e1 (matched to easy because the medium +1
327
+ # controlled variable is the SCOUT requirement (outpost no
328
+ # longer visible from starting jeep position), NOT raw
329
+ # defensive load — a heavier push would force the medium to
330
+ # discriminate on a SECOND axis (absorption) and break the
331
+ # clean tier attribution).
332
+ - {type: e1, owner: enemy, position: [20, 4], stance: 3, count: 3}
333
+ - {type: fact, owner: enemy, position: [120, 20]}
334
+ win_condition:
335
+ all_of:
336
+ - then:
337
+ id: scout-fortify-engage-medium
338
+ clauses:
339
+ - {buildings_discovered_gte: 1}
340
+ - {building_count_gte: {type: pbox, n: 3}}
341
+ - {units_killed_gte: 3}
342
+ - building_count_gte: {type: fact, n: 1}
343
+ - own_units_gte: 1
344
+ - within_ticks: 5400
345
+ fail_condition:
346
+ any_of:
347
+ - after_ticks: 5401
348
+ - not: {building_count_gte: {type: fact, n: 1}}
349
+ - not: {own_units_gte: 1}
350
+ max_turns: 66
351
+
352
+ # ── HARD ─────────────────────────────────────────────────────────
353
+ # +1 controlled variable: TWO agent spawn_point groups round-robin
354
+ # the AGENT BASE LATITUDE per seed (NORTH-staged base centred at
355
+ # (20, 16) vs SOUTH-staged base centred at (20, 24)). Enemy actors
356
+ # don't honour spawn_point (CLAUDE.md) so BOTH a north outpost
357
+ # (20, 4) and a south outpost (20, 36) always place; the MATCHING-
358
+ # side band is the immediate threat on each spawn (the rusher bot
359
+ # charges the agent centroid, which sits near each base's
360
+ # latitude, so the closer band has the shorter walk). The capability
361
+ # test is the same scout-then-fortify chain — but the agent must
362
+ # FIRST identify its OWN base latitude from observation (the
363
+ # threat is on the SIDE FACING the base's own latitude) and THEN
364
+ # commit defenses on that side. A memorised "always defend NORTH"
365
+ # opening cannot generalise: on the SOUTH spawn the north scout
366
+ # never reaches the south outpost (clause 1 never latches), the
367
+ # south-band rushers walk straight past the wrong-axis pbox line,
368
+ # and the south-spawn fact dies → double LOSS path.
369
+ # The rush is 4 e1 + 1 e3 per band (the e3 is the discriminator —
370
+ # rocket splash + AT damage means the base defenders alone cannot
371
+ # hold the matching lane, so the on-axis pbox triple is load-
372
+ # bearing; the agent base is staged farther from each rush band
373
+ # (14 cells instead of 10) to give the intended play time to land
374
+ # the 3 pbox before the rush arrives).
375
+ # max_turns 66 → reachable tick 5943 ≥ 5401.
376
+ hard:
377
+ description: >
378
+ A rush is incoming, but the threat axis is NOT given AND your
379
+ base could be in the NORTH half (fact at y=16) OR the SOUTH
380
+ half (fact at y=24) — chosen by seed. Identify your base's
381
+ latitude from observation; scout the lane FACING your base
382
+ (if your base is north of centre, the threat approaches from
383
+ the north edge; if south of centre, from the south edge), then
384
+ build at least THREE pillboxes (pbox) on that lane (between
385
+ your base and the threat — roughly y≈10 if your base is at
386
+ y=16, y≈30 if your base is at y=24), THEN destroy ≥3 of the
387
+ incoming rifle infantry. Keep the construction yard standing
388
+ and ≥1 of your own units alive. Win before tick 5400. A
389
+ memorised "always defend NORTH" opening loses on the SOUTH-
390
+ base seeds (rushers walk past your wrong-axis pbox line and
391
+ raze the fact); a no-scout commit fails the chain at clause 1.
392
+ overrides:
393
+ actors:
394
+ # ── NORTH spawn (spawn_point 0) — base centred at (20, 16).
395
+ # Threat: from the north band at y=2 (north outpost at y=4).
396
+ # Base-to-rush distance = 14 cells, gives the intended scout-
397
+ # then-fortify play time to land 3 pbox at y≈10 (between base
398
+ # at y=16 and band at y=2) before the rush arrives. Correct
399
+ # defense lane: pbox at y≈10. The defender ring + the 4
400
+ # starter e1 sit SOUTH of fact (y=18..20, away from the lane
401
+ # mouth) so they survive the first wave and satisfy
402
+ # own_units_gte:1.
403
+ - {type: fact, owner: agent, position: [20, 16], spawn_point: 0}
404
+ - {type: proc, owner: agent, position: [16, 16], spawn_point: 0}
405
+ - {type: powr, owner: agent, position: [20, 20], spawn_point: 0}
406
+ - {type: tent, owner: agent, position: [16, 20], spawn_point: 0}
407
+ - {type: jeep, owner: agent, position: [24, 16], stance: 1, spawn_point: 0}
408
+ - {type: jeep, owner: agent, position: [26, 16], stance: 1, spawn_point: 0}
409
+ - {type: e1, owner: agent, position: [22, 20], stance: 2, spawn_point: 0}
410
+ - {type: e1, owner: agent, position: [24, 20], stance: 2, spawn_point: 0}
411
+ - {type: e1, owner: agent, position: [22, 18], stance: 2, spawn_point: 0}
412
+ - {type: e1, owner: agent, position: [24, 18], stance: 2, spawn_point: 0}
413
+ # ── SOUTH spawn (spawn_point 1) — base centred at (20, 24).
414
+ # Mirror of the NORTH spawn (fact 8 cells south of map mid),
415
+ # threat from the south band at y=38 (south outpost at y=36).
416
+ # Correct defense lane: pbox at y≈30.
417
+ - {type: fact, owner: agent, position: [20, 24], spawn_point: 1}
418
+ - {type: proc, owner: agent, position: [16, 24], spawn_point: 1}
419
+ - {type: powr, owner: agent, position: [20, 20], spawn_point: 1}
420
+ - {type: tent, owner: agent, position: [16, 20], spawn_point: 1}
421
+ - {type: jeep, owner: agent, position: [24, 24], stance: 1, spawn_point: 1}
422
+ - {type: jeep, owner: agent, position: [26, 24], stance: 1, spawn_point: 1}
423
+ - {type: e1, owner: agent, position: [22, 20], stance: 2, spawn_point: 1}
424
+ - {type: e1, owner: agent, position: [24, 20], stance: 2, spawn_point: 1}
425
+ - {type: e1, owner: agent, position: [22, 22], stance: 2, spawn_point: 1}
426
+ - {type: e1, owner: agent, position: [24, 22], stance: 2, spawn_point: 1}
427
+ # Enemy outposts and rush bands — enemy actors don't honour
428
+ # spawn_point (CLAUDE.md), so BOTH north and south bands
429
+ # always place. Each spawn faces the MATCHING side as the
430
+ # near (immediate) threat — the opposite-side rush has the
431
+ # full map width to walk and is well outside the 5400-tick
432
+ # budget for arrival.
433
+ - {type: tent, owner: enemy, position: [20, 4]}
434
+ - {type: e1, owner: enemy, position: [20, 2], stance: 3, count: 4}
435
+ - {type: e3, owner: enemy, position: [22, 2], stance: 3, count: 1}
436
+ - {type: tent, owner: enemy, position: [20, 36]}
437
+ - {type: e1, owner: enemy, position: [20, 38], stance: 3, count: 4}
438
+ - {type: e3, owner: enemy, position: [22, 38], stance: 3, count: 1}
439
+ # Inert enemy `fact` marker far east — anti-DRAW.
440
+ - {type: fact, owner: enemy, position: [120, 20]}
441
+ win_condition:
442
+ all_of:
443
+ - then:
444
+ id: scout-fortify-engage-hard
445
+ clauses:
446
+ - {buildings_discovered_gte: 1}
447
+ - {building_count_gte: {type: pbox, n: 3}}
448
+ - {units_killed_gte: 3}
449
+ - building_count_gte: {type: fact, n: 1}
450
+ - own_units_gte: 1
451
+ - within_ticks: 5400
452
+ fail_condition:
453
+ any_of:
454
+ - after_ticks: 5401
455
+ - not: {building_count_gte: {type: fact, n: 1}}
456
+ - not: {own_units_gte: 1}
457
+ max_turns: 66
tests/test_def_position_revealed_direction.py ADDED
@@ -0,0 +1,475 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """def-position-revealed-direction scenario pack — full no-cheat
2
+ validation on Rust.
3
+
4
+ REASONING capability — INTEL-DRIVEN ADAPTIVE DEFENSE. The threat axis
5
+ is HIDDEN from the brief (sibling `def-position-expected-direction`
6
+ DECLARES the axis; this pack does not). The agent must FIRST drive its
7
+ scout jeeps to register the enemy's forward outpost on the actual
8
+ approach lane, THEN commit a 3-pbox triple between its base and that
9
+ outpost, THEN engage and break the incoming rush. The win predicate is
10
+ the Wave-2 `then:` happened-before composite:
11
+
12
+ then:
13
+ - {buildings_discovered_gte: 1} # SCOUT first (axis intel)
14
+ - {building_count_gte: {type: pbox, n: 3}} # FORTIFY second
15
+ - {units_killed_gte: 3} # ENGAGE third
16
+
17
+ paired with `building_count_gte:{fact, 1}` (the home fact still stands)
18
+ and `within_ticks: 5400` (a reachable deadline given max_turns=66, which
19
+ caps ticks at 93+90·65=5943). Fail is `after_ticks: 5401` OR
20
+ `not building_count_gte:{fact,1}` OR `not own_units_gte:1` — a non-win
21
+ is always a real reachable LOSS, never a draw.
22
+
23
+ `building_count_gte:{fact,1}` is used in place of the more common
24
+ `has_building: fact` because the latter checks
25
+ `own_building_types` which ACCUMULATES across the episode (once
26
+ the agent ever owned a fact the set still contains "fact" after
27
+ the fact is razed). The `building_count_gte` predicate reads the
28
+ live `own_buildings` list each frame and correctly bites when
29
+ the fact is destroyed — without this swap the stall policy on
30
+ the hard NORTH spawn ended in DRAW (engine auto-`done` on player
31
+ elimination before the deadline) rather than LOSS.
32
+
33
+ These tests prove deterministically (no model / no network) that:
34
+
35
+ * the intended scout-then-fortify-then-engage policy WINS every level
36
+ and every hard seed (1..4) — including BOTH the NORTH-base and the
37
+ SOUTH-base spawn groups on hard;
38
+ * stall (observe-only) LOSES every level and every seed (real LOSS,
39
+ never draw);
40
+ * no-scout-pre-fortify-NORTH (build 3 pbox at y=14 immediately and
41
+ never move the jeeps) WINS only on EASY (where the jeeps start at
42
+ y=8 already in sight of the outpost) and LOSES on every MEDIUM and
43
+ HARD seed (clause 1 of the then-chain never latches);
44
+ * scout-then-fortify-WRONG-AXIS (memorised "always defend NORTH") LOSES
45
+ on every hard SOUTH-spawn seed — the south rush walks past the
46
+ wrong-axis pbox line and razes the SOUTH-spawn fact, AND the
47
+ buildings-discovered clause cannot latch on the SOUTH-spawn jeep
48
+ because the north outpost is out of sight;
49
+ * the hard tier defines ≥2 spawn_point groups (NORTH base y=16 /
50
+ SOUTH base y=24) so the base latitude rotates by seed (no single-
51
+ cell memorised opening can generalise);
52
+ * every level's `after_ticks` is reachable inside `max_turns` so a
53
+ non-finisher is a real timeout LOSS, never a draw.
54
+ """
55
+
56
+ from __future__ import annotations
57
+
58
+ import pytest
59
+
60
+ pytest.importorskip("openra_train", reason="Rust env wheel not installed")
61
+ pytest.importorskip("openra_rl_training", reason="Rust env wheel not installed")
62
+
63
+ from openra_bench.eval_core import run_level
64
+ from openra_bench.scenarios import load_pack
65
+ from openra_bench.scenarios.loader import PACKS_DIR, compile_level
66
+
67
+ PACK = PACKS_DIR / "def-position-revealed-direction.yaml"
68
+ LEVELS = ("easy", "medium", "hard")
69
+ SEEDS = (1, 2, 3, 4)
70
+
71
+
72
+ # ── scripted policies ────────────────────────────────────────────────
73
+
74
+
75
+ def stall(rs, C):
76
+ """Observe-only. Never scouts, never builds, never engages.
77
+ Reachable timeout LOSS (or fact-loss LOSS, which is the same
78
+ fail-clause family)."""
79
+ return [C.observe()]
80
+
81
+
82
+ def make_intended():
83
+ """The intended capability play: identify own base latitude
84
+ (NORTH-of-mid → threat-from-NORTH; SOUTH-of-mid → threat-from-
85
+ SOUTH; mid → easy preset NORTH lane), DRIVE the jeeps to the
86
+ matching outpost band, BUILD 3 pbox between base and that band,
87
+ and let the on-axis pbox + base defenders rack up ≥3 kills on
88
+ the incoming rush."""
89
+
90
+ state = {"scout_dispatched": False, "queued": 0}
91
+
92
+ def policy(rs, C):
93
+ units = rs.get("units_summary") or []
94
+ own_b = rs.get("own_buildings") or []
95
+ fact = next(
96
+ (b for b in own_b if b.get("type") == "fact"), None,
97
+ )
98
+ if fact is None:
99
+ return [C.observe()]
100
+ fx, fy = int(fact["cell_x"]), int(fact["cell_y"])
101
+ # Pick the threat side from base latitude. Hard: y=16 ⇒
102
+ # north lane (scout to y=8, pbox at y=10); y=24 ⇒ south
103
+ # lane (scout to y=32, pbox at y=30). Easy/medium: y=20 ⇒
104
+ # north preset.
105
+ if fy < 20:
106
+ scout_y = max(2, fy - 8)
107
+ pbox_y = max(4, fy - 6)
108
+ elif fy > 20:
109
+ scout_y = min(38, fy + 8)
110
+ pbox_y = min(34, fy + 6)
111
+ else:
112
+ scout_y = 8
113
+ pbox_y = 14
114
+
115
+ types = [b.get("type") for b in own_b]
116
+ pbox_count = sum(1 for t in types if t == "pbox")
117
+
118
+ cmds = []
119
+ jeeps = [u for u in units if u.get("type") == "jeep"]
120
+ if jeeps and not state["scout_dispatched"]:
121
+ cmds.append(
122
+ C.move_units([j["id"] for j in jeeps], fx, scout_y),
123
+ )
124
+ state["scout_dispatched"] = True
125
+ # Queue the 3 pbox up front; one StartProduction per turn
126
+ # (the Defense queue is one-at-a-time, so queueing more
127
+ # than 3 wastes only the StartProduction order).
128
+ if state["queued"] < 3:
129
+ cmds.append(C.build("pbox"))
130
+ state["queued"] += 1
131
+ # Always emit the NEXT slot's place_building — the engine
132
+ # buffers blocked PLACEs until production finishes, then
133
+ # places at the requested cell.
134
+ if pbox_count < 3:
135
+ dx = -2 + 2 * pbox_count
136
+ cmds.append(C.place_building("pbox", fx + dx, pbox_y))
137
+ if not cmds:
138
+ cmds.append(C.observe())
139
+ return cmds
140
+
141
+ return policy
142
+
143
+
144
+ def make_no_scout_north_pbox():
145
+ """Pre-fortify NORTH-lane WITHOUT ever moving the jeeps. On EASY
146
+ this WINS (the jeeps are pre-staged at y=8, passively in sight of
147
+ the outpost at y=2 — clause 1 latches at turn 1 even without a
148
+ move command). On MEDIUM and HARD it LOSES every seed — the
149
+ jeeps sit at the base, outside outpost vision, so the then-chain
150
+ NEVER reaches clause 1, and the deadline expires."""
151
+
152
+ state = {"queued": 0}
153
+
154
+ def policy(rs, C):
155
+ own_b = rs.get("own_buildings") or []
156
+ fact = next(
157
+ (b for b in own_b if b.get("type") == "fact"), None,
158
+ )
159
+ if fact is None:
160
+ return [C.observe()]
161
+ fx = int(fact["cell_x"])
162
+ # Memorised: always build pbox at y=14 (the easy/medium
163
+ # canonical NORTH lane). NEVER moves the jeeps.
164
+ types = [b.get("type") for b in own_b]
165
+ pbox_count = sum(1 for t in types if t == "pbox")
166
+ cmds = []
167
+ if state["queued"] < 3:
168
+ cmds.append(C.build("pbox"))
169
+ state["queued"] += 1
170
+ if pbox_count < 3:
171
+ dx = -2 + 2 * pbox_count
172
+ cmds.append(C.place_building("pbox", fx + dx, 14))
173
+ if not cmds:
174
+ cmds.append(C.observe())
175
+ return cmds
176
+
177
+ return policy
178
+
179
+
180
+ def make_scout_then_fortify_always_north():
181
+ """Drives the scout NORTH and builds pbox on the NORTH lane —
182
+ the 'memorised: always defend NORTH' policy. On HARD SOUTH-spawn
183
+ seeds the north scout never reaches the south outpost (the
184
+ matching threat), so clause 1 stays unlatched AND the south rush
185
+ razes the SOUTH-spawn fact (the y=18 pbox line on the SOUTH
186
+ spawn is on the wrong axis). LOSS on every hard SOUTH-spawn
187
+ seed; WIN on every hard NORTH-spawn seed (where it accidentally
188
+ happens to be the correct lane)."""
189
+
190
+ state = {"scout_dispatched": False, "queued": 0}
191
+
192
+ def policy(rs, C):
193
+ units = rs.get("units_summary") or []
194
+ own_b = rs.get("own_buildings") or []
195
+ fact = next(
196
+ (b for b in own_b if b.get("type") == "fact"), None,
197
+ )
198
+ if fact is None:
199
+ return [C.observe()]
200
+ fx, fy = int(fact["cell_x"]), int(fact["cell_y"])
201
+ # ALWAYS scout NORTH (whatever the base latitude); ALWAYS
202
+ # build pbox on the NORTH side of base.
203
+ scout_y = max(2, fy - 8)
204
+ pbox_y = max(4, fy - 6)
205
+
206
+ types = [b.get("type") for b in own_b]
207
+ pbox_count = sum(1 for t in types if t == "pbox")
208
+ cmds = []
209
+ jeeps = [u for u in units if u.get("type") == "jeep"]
210
+ if jeeps and not state["scout_dispatched"]:
211
+ cmds.append(
212
+ C.move_units([j["id"] for j in jeeps], fx, scout_y),
213
+ )
214
+ state["scout_dispatched"] = True
215
+ if state["queued"] < 3:
216
+ cmds.append(C.build("pbox"))
217
+ state["queued"] += 1
218
+ if pbox_count < 3:
219
+ dx = -2 + 2 * pbox_count
220
+ cmds.append(C.place_building("pbox", fx + dx, pbox_y))
221
+ if not cmds:
222
+ cmds.append(C.observe())
223
+ return cmds
224
+
225
+ return policy
226
+
227
+
228
+ # ── scenario-shape invariants ────────────────────────────────────────
229
+
230
+
231
+ def test_pack_compiles_with_three_levels_and_rusher_bot():
232
+ pack = load_pack(PACK)
233
+ assert pack.meta.id == "def-position-revealed-direction"
234
+ assert pack.meta.capability == "reasoning"
235
+ assert set(pack.levels) == {"easy", "medium", "hard"}
236
+ # Required-by-spec benchmark anchors (per the seed taxonomy).
237
+ anchors = pack.meta.benchmark_anchor or []
238
+ assert any("PlanBench" in a for a in anchors), anchors
239
+ assert any("military" in a.lower() for a in anchors), anchors
240
+ assert any("intel" in a.lower() for a in anchors), anchors
241
+ # Rusher bot wired through to the engine for every level.
242
+ for lvl in LEVELS:
243
+ c = compile_level(pack, lvl)
244
+ assert c.map_supported
245
+ enemy = c.scenario.enemy
246
+ bot = (
247
+ getattr(enemy, "bot_type", None)
248
+ or getattr(enemy, "bot", None)
249
+ )
250
+ assert str(bot).lower() == "rusher", (lvl, bot)
251
+
252
+
253
+ @pytest.mark.parametrize("level", LEVELS)
254
+ def test_every_level_has_a_reachable_timeout_fail(level):
255
+ """Non-win must be a real LOSS: the `after_ticks` fail clause must
256
+ be strictly below the tick reachable at max_turns (≤90 ticks per
257
+ step in interrupt mode)."""
258
+ c = compile_level(load_pack(PACK), level)
259
+ assert c.fail_condition is not None
260
+ fc = c.fail_condition.model_dump(exclude_none=True)
261
+ deadline = None
262
+ for clause in fc.get("any_of", []) or []:
263
+ if "after_ticks" in clause:
264
+ deadline = int(clause["after_ticks"])
265
+ assert deadline is not None, f"{level}: no after_ticks fail clause"
266
+ reachable = 93 + 90 * (c.max_turns - 1)
267
+ assert deadline < reachable, (
268
+ f"{level}: deadline {deadline} unreachable within "
269
+ f"{c.max_turns} turns (max tick {reachable}) → draw degeneracy"
270
+ )
271
+
272
+
273
+ @pytest.mark.parametrize("level", LEVELS)
274
+ def test_every_level_uses_then_chain_with_scout_fortify_engage(level):
275
+ """The 3-phase scout→fortify→engage chain MUST be wired through
276
+ as a Wave-2 `then:` happened-before composite (the whole point of
277
+ the pack — ordered intel-driven defense, not a degenerate
278
+ permutation-tolerant `all_of`)."""
279
+ c = compile_level(load_pack(PACK), level)
280
+ win = c.win_condition.model_dump(exclude_none=True)
281
+ ao = win.get("all_of") or []
282
+ then_branches = [b for b in ao if "then" in b]
283
+ assert len(then_branches) == 1, (
284
+ f"{level}: expected exactly one then-chain in win; got {win}"
285
+ )
286
+ clauses = then_branches[0]["then"]["clauses"]
287
+ assert len(clauses) == 3, (
288
+ f"{level}: then-chain must be 3 clauses (scout, fortify, "
289
+ f"engage); got {len(clauses)}: {clauses}"
290
+ )
291
+ # Clause 1: scout latch — ≥1 enemy building discovered (the
292
+ # forward outpost the agent's jeep MUST reach by movement on
293
+ # medium/hard; passively visible on easy).
294
+ assert clauses[0].get("buildings_discovered_gte") == 1, clauses[0]
295
+ # Clause 2: fortify — exactly 3 pbox (not a token pbox).
296
+ bcg = clauses[1].get("building_count_gte") or {}
297
+ assert bcg.get("type") == "pbox" and int(bcg.get("n", 0)) == 3, (
298
+ clauses[1]
299
+ )
300
+ # Clause 3: engage — at least 3 kills.
301
+ assert clauses[2].get("units_killed_gte") == 3, clauses[2]
302
+
303
+
304
+ @pytest.mark.parametrize("level", LEVELS)
305
+ def test_win_predicate_uses_building_count_gte_for_fact_persistence(
306
+ level,
307
+ ):
308
+ """The fact-persistence clause MUST be `building_count_gte:
309
+ {type:fact, n:1}` (live count) and NOT `has_building:fact`
310
+ (accumulating set). See module docstring for the footgun."""
311
+ c = compile_level(load_pack(PACK), level)
312
+ win = c.win_condition.model_dump(exclude_none=True)
313
+ flat = str(win)
314
+ assert "has_building" not in flat, (
315
+ f"{level}: win must avoid the accumulating `has_building` "
316
+ f"footgun; use `building_count_gte:{{type:fact,n:1}}` "
317
+ f"instead. win={win}"
318
+ )
319
+ # And the live count clause IS present:
320
+ ao = win.get("all_of") or []
321
+ found = False
322
+ for clause in ao:
323
+ bcg = (
324
+ clause.get("building_count_gte")
325
+ if isinstance(clause, dict) else None
326
+ )
327
+ if isinstance(bcg, dict) and bcg.get("type") == "fact":
328
+ assert int(bcg.get("n", 0)) >= 1
329
+ found = True
330
+ assert found, f"{level}: missing fact-alive clause; got {win}"
331
+
332
+
333
+ def test_hard_has_two_spawn_point_groups():
334
+ """Hard-tier contract: ≥2 distinct seed-driven spawn_point groups
335
+ so the threat axis rotates by seed (anti-memorisation). Both
336
+ bands of enemy outposts always place (CLAUDE.md: enemy actors
337
+ don't honour spawn_point) so the SAME scout-then-fortify
338
+ discipline is tested from a flipped base latitude per seed."""
339
+ c = compile_level(load_pack(PACK), "hard")
340
+ groups = {
341
+ a.spawn_point for a in c.scenario.actors
342
+ if a.owner == "agent" and a.spawn_point is not None
343
+ }
344
+ assert groups == {0, 1}, groups
345
+ # In-bounds check (rush-hour-arena playable y ≈ 2..38).
346
+ for a in c.scenario.actors:
347
+ x, y = a.position
348
+ assert 2 <= x <= 126 and 2 <= y <= 38, (a.type, a.position)
349
+
350
+
351
+ def test_tools_list_matches_spec():
352
+ pack = load_pack(PACK)
353
+ expected = {
354
+ "observe", "build", "place_building", "move_units",
355
+ "attack_unit", "attack_move", "stop",
356
+ }
357
+ tools = (
358
+ pack.base.get("tools")
359
+ if isinstance(pack.base, dict)
360
+ else pack.base.tools
361
+ )
362
+ assert set(tools) == expected, tools
363
+
364
+
365
+ # ── solvency: intended WINS every level + every hard seed ────────────
366
+
367
+
368
+ @pytest.mark.parametrize("seed", SEEDS)
369
+ @pytest.mark.parametrize("level", LEVELS)
370
+ def test_intended_scout_fortify_engage_wins_every_level_and_seed(
371
+ level, seed,
372
+ ):
373
+ """The intended capability play (identify base latitude → scout
374
+ matching outpost → fortify matching lane → engage) MUST WIN on
375
+ every (level, seed). This is the load-bearing solvency test."""
376
+ c = compile_level(load_pack(PACK), level)
377
+ r = run_level(c, make_intended(), seed=seed)
378
+ assert r.outcome == "win", (
379
+ f"{level} seed{seed}: intended scout-fortify-engage must WIN; "
380
+ f"got {r.outcome} (tick={r.signals.game_tick}, "
381
+ f"kills={r.signals.units_killed}, "
382
+ f"then_progress={getattr(r.signals, 'then_progress', {})}, "
383
+ f"buildings_seen="
384
+ f"{len(r.signals.enemy_buildings_seen_ids)})"
385
+ )
386
+
387
+
388
+ # ── no-cheat: every wrong / lazy policy LOSES (real LOSS, not draw) ──
389
+
390
+
391
+ @pytest.mark.parametrize("seed", SEEDS)
392
+ @pytest.mark.parametrize("level", LEVELS)
393
+ def test_stall_loses_every_level_and_seed(level, seed):
394
+ """Stall (observe-only) MUST LOSE on every (level, seed). The
395
+ fail clause family (after_ticks: 5401 OR
396
+ not building_count_gte:{fact,1} OR not own_units_gte:1) bites
397
+ deterministically — never a draw."""
398
+ c = compile_level(load_pack(PACK), level)
399
+ r = run_level(c, stall, seed=seed)
400
+ assert r.outcome == "loss", (
401
+ f"{level} seed{seed}: stall must LOSE (real fail, not a "
402
+ f"draw); got {r.outcome} (tick={r.signals.game_tick})"
403
+ )
404
+
405
+
406
+ @pytest.mark.parametrize("seed", SEEDS)
407
+ def test_no_scout_pre_fortify_loses_on_medium(seed):
408
+ """Build 3 pbox at the canonical NORTH lane without ever moving
409
+ the jeeps. The jeeps sit at the base, outside outpost vision —
410
+ so clause 1 (`buildings_discovered_gte:1`) NEVER latches and the
411
+ deadline expires. (On EASY the jeeps START forward at y=8 and
412
+ passively see the outpost from turn 1, so this play wins easy —
413
+ that's by design, easy explicitly teaches the chain without
414
+ requiring scout movement.)"""
415
+ c = compile_level(load_pack(PACK), "medium")
416
+ r = run_level(c, make_no_scout_north_pbox(), seed=seed)
417
+ assert r.outcome == "loss", (
418
+ f"medium s={seed}: no-scout pre-fortify must LOSE; got "
419
+ f"{r.outcome} (tick={r.signals.game_tick}, "
420
+ f"then_progress={getattr(r.signals, 'then_progress', {})})"
421
+ )
422
+
423
+
424
+ @pytest.mark.parametrize("seed", SEEDS)
425
+ def test_no_scout_pre_fortify_loses_on_hard(seed):
426
+ """Same no-scout pre-fortify play on hard. Loses for two
427
+ independent reasons depending on spawn: (i) on NORTH spawn the
428
+ jeeps at y=16 can't see the y=4 outpost without moving — clause
429
+ 1 never latches; (ii) on SOUTH spawn the pbox at y=14 is on the
430
+ WRONG axis (south rush comes from y=38), the south rush razes
431
+ the south-spawn fact AND clause 1 never latches."""
432
+ c = compile_level(load_pack(PACK), "hard")
433
+ r = run_level(c, make_no_scout_north_pbox(), seed=seed)
434
+ assert r.outcome == "loss", (
435
+ f"hard s={seed}: no-scout pre-fortify must LOSE; got "
436
+ f"{r.outcome} (tick={r.signals.game_tick}, "
437
+ f"then_progress={getattr(r.signals, 'then_progress', {})})"
438
+ )
439
+
440
+
441
+ def test_scout_then_always_north_fortify_loses_on_hard_south_spawn():
442
+ """The memorised 'always scout/fortify NORTH' policy LOSES on
443
+ every hard SOUTH-spawn seed (s=1, s=3 in the engine's spawn_idx
444
+ round-robin): the north scout doesn't reach the south outpost
445
+ (clause 1 never latches) AND the south-spawn fact at y=24 is
446
+ razed by the y=38 south rush which the NORTH-side pbox line at
447
+ y=18 can't intercept. Demonstrates the spawn-variation
448
+ discrimination — a fixed-axis opening cannot generalise across
449
+ seeds."""
450
+ c = compile_level(load_pack(PACK), "hard")
451
+ # Verified empirically: SOUTH spawn corresponds to seeds 1 & 3
452
+ # in the engine's spawn_idx round-robin; NORTH to 2 & 4.
453
+ south_spawn_seeds = (1, 3)
454
+ for seed in south_spawn_seeds:
455
+ r = run_level(
456
+ c, make_scout_then_fortify_always_north(), seed=seed,
457
+ )
458
+ assert r.outcome == "loss", (
459
+ f"hard SOUTH-spawn s={seed}: scout-and-fortify-always-"
460
+ f"NORTH must LOSE; got {r.outcome} "
461
+ f"(tick={r.signals.game_tick}, "
462
+ f"then_progress={getattr(r.signals, 'then_progress', {})})"
463
+ )
464
+
465
+
466
+ # ── determinism ──────────────────────────────────────────────────────
467
+
468
+
469
+ def test_intended_run_is_deterministic_on_easy():
470
+ c = compile_level(load_pack(PACK), "easy")
471
+ a = run_level(c, make_intended(), seed=3)
472
+ b = run_level(c, make_intended(), seed=3)
473
+ assert (a.outcome, a.turns) == (b.outcome, b.turns), (
474
+ "same seed must be deterministic"
475
+ )