yxc20098 commited on
Commit
ce2878c
·
1 Parent(s): ad1da47

feat(scenario): def-reinforce-the-breach — reactive defense, shift the reserve to the breached lane (military reserve commitment)

Browse files

ACTION pack: a two-lane defence with a mobile reserve held at the
centre. Mid-episode a heavy breach wave is injected on the south lane
via scheduled_events; the agent must read which lane was breached and
shift the reserve forward into it. Stall and brute LOSE; reinforcing
the wrong (un-breached) lane LOSES on medium/hard; only shifting the
reserve into the breached lane WINS. The win carries an after_ticks
gate so it cannot latch before the breach develops. Hard defines two
seed-driven reserve spawn_point columns.

openra_bench/scenarios/packs/def-reinforce-the-breach.yaml ADDED
@@ -0,0 +1,331 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # def-reinforce-the-breach — ACTION, reactive defense: shift the
2
+ # mobile reserve to the lane that gets breached mid-episode
3
+ # (Wave-12).
4
+ #
5
+ # The capability under test is REACTIVE RESERVE COMMITMENT. The
6
+ # agent holds a multi-lane defence: a north lane and a south lane,
7
+ # each with a light fixed garrison, plus a mobile armoured reserve
8
+ # held UNCOMMITTED at the centre. At t=0 both lanes face only a
9
+ # light probe. Mid-episode a HEAVY breach wave is injected on ONE
10
+ # lane (the south lane) via a `scheduled_events: spawn_actors` hook
11
+ # — the lane is breached. The light garrison there cannot hold the
12
+ # breach alone; the agent must REACT, read which lane was breached,
13
+ # and shift the mobile reserve FORWARD into that lane to reinforce
14
+ # it before the line collapses.
15
+ #
16
+ # Real-world anchors:
17
+ # • Military "reinforce the breach" doctrine / reserve commitment:
18
+ # the general reserve is held uncommitted until the enemy's main
19
+ # effort localises; the instant a sector is breached the reserve
20
+ # is committed FORWARD to that sector to restore the line.
21
+ # • Incident surge response: an on-call response team is staged
22
+ # centrally; when one site escalates, the surge capacity is
23
+ # committed to THAT site — not held back, not sent to a quiet
24
+ # site.
25
+ # • SC2 reactive defence: hold an army at a central position, then
26
+ # react to the opponent's attack by reinforcing the lane that is
27
+ # actually being hit.
28
+ #
29
+ # Mechanism (the load-bearing design):
30
+ # • TWO lanes (north y≈12 / south y≈28), each with a 2-tank fixed
31
+ # garrison. A 4-tank mobile reserve is held at the CENTRE
32
+ # (x≈44, y=20) — equidistant from both lanes.
33
+ # • At t=0 both lanes face only a light 3×e1 probe. The breach
34
+ # wave (a heavier e1 wave) is injected on the SOUTH lane at a
35
+ # scheduled tick via `scheduled_events: spawn_actors`.
36
+ # • The enemy uses the `hunt` bot so each enemy unit advances on
37
+ # and engages the NEAREST agent unit — the two lanes are
38
+ # genuinely INDEPENDENT fights (a `rusher` bot would pool every
39
+ # band onto the agent centroid and collapse the multi-lane
40
+ # test; CLAUDE.md / def-pre-position-mobile-reserve).
41
+ # • The win is a FORWARD-ZONE clause: `units_in_region_gte` at the
42
+ # SOUTH lane's intercept zone, requiring N tanks there. The
43
+ # 2-tank south garrison alone cannot fill the zone — the
44
+ # reserve must reinforce it. A reserve held at the centre, or
45
+ # sent to the un-breached NORTH lane, never fills the south
46
+ # zone ⇒ LOSS. Plus an `own_units_gte` survival clause: a
47
+ # breach left un-reinforced overruns the south garrison ⇒ the
48
+ # survival clause busts too.
49
+ #
50
+ # Discrimination (four-policy bar — scripted, no model needed):
51
+ # • stall (observe-only): the reserve sits at the centre; the
52
+ # forward-zone clause is never satisfied AND the un-reinforced
53
+ # south garrison is overrun by the breach wave ⇒ LOSS (the
54
+ # `after_ticks` deadline is a backstop — no draw degeneracy).
55
+ # • reinforce-the-wrong-lane (commit the reserve to the NORTH
56
+ # lane): the south forward zone is left short ⇒ the
57
+ # `units_in_region_gte` clause fails ⇒ LOSS (medium/hard, where
58
+ # the zone needs the reserve's tanks; on easy the bar is looser
59
+ # — see the level notes).
60
+ # • brute (one attack_move straight east, ignore the lanes): the
61
+ # reserve drives off the breached lane's intercept zone ⇒ the
62
+ # forward-zone clause fails ⇒ LOSS.
63
+ # • intended reinforce-the-breach (read the mid-episode breach,
64
+ # shift the reserve FORWARD into the SOUTH lane's intercept
65
+ # zone): the forward-zone clause is satisfied AND the
66
+ # reinforced line holds ⇒ WIN.
67
+ #
68
+ # Engine facts used (CLAUDE.md / verified against the installed
69
+ # openra_train wheel, 2026-05-20):
70
+ # • `scheduled_events: spawn_actors` injects fresh-id actors at a
71
+ # scheduled tick (Wave-9 feature; test_scheduled_events). The
72
+ # breach wave is the only way to make ONE lane heavier
73
+ # MID-episode — a t=0 placement cannot exercise "react to a
74
+ # breach that develops".
75
+ # • `hunt` bot: each enemy unit advances on and attacks the
76
+ # NEAREST agent unit, so the two lanes are independent fights;
77
+ # a far-off breach wave still advances (a `stance:3` actor only
78
+ # advances toward a VISIBLE enemy and would stall in fog).
79
+ # • `units_in_region_gte: {x, y, radius, n}` — the load-bearing
80
+ # forward-zone clause: it is pure geometry and does not depend
81
+ # on combat resolution, so the discrimination is deterministic.
82
+ # • Tick alignment (~90 ticks/turn): within_ticks ≤ 93+90·
83
+ # (max_turns−1); fail after_ticks = within_ticks+1 ⇒ a
84
+ # non-finisher is a real reachable LOSS, never a DRAW. The win
85
+ # also carries an `after_ticks: 720` GATE — the win cannot
86
+ # latch until the breach wave has developed (it spawns at tick
87
+ # 450 and is fully in play by ~720), so a reserve that rushes
88
+ # the south zone BEFORE the breach cannot win prematurely. The
89
+ # win is purely positional (no conquest clause) and the far
90
+ # enemy `fact` is never destroyed, so the `after_ticks` win
91
+ # gate is safe from the engine's enemy-elimination auto-done
92
+ # (CLAUDE.md after_ticks/Conquest footgun does not apply).
93
+ # • A far inert enemy `fact` marker keeps the engine from
94
+ # auto-`done`ing on enemy-elimination before the win/fail
95
+ # evaluator runs (CLAUDE.md auto-done footgun).
96
+ # • Hard tier: two agent spawn_point groups round-robin the
97
+ # mobile reserve's start COLUMN (x=40 vs x=50 — both remain
98
+ # equidistant from the two lanes) so a memorised reserve cell
99
+ # cannot generalise; the centre-then-react DOCTRINE does. The
100
+ # garrisons and the breach wave (no spawn_point) place every
101
+ # seed.
102
+ #
103
+ # Validate (no model / no network):
104
+ # python3 -m pytest tests/test_def_reinforce_the_breach.py -q
105
+
106
+ meta:
107
+ id: def-reinforce-the-breach
108
+ title: 'Reactive Defense — Reinforce the Breached Lane'
109
+ capability: action
110
+ real_world_meaning: >
111
+ A multi-lane defence with a mobile armoured reserve held
112
+ uncommitted at the centre. At first both lanes face only a light
113
+ probe, but mid-episode one lane is BREACHED by a heavier wave.
114
+ The light garrison there cannot hold the breach alone; the agent
115
+ must react — read which lane was breached — and shift the mobile
116
+ reserve FORWARD into that lane to reinforce it before the line
117
+ collapses. Holding the reserve at the centre, or committing it to
118
+ the quiet lane, lets the breach overrun the line. The pack tests
119
+ reactive reserve commitment: identify the developing breach and
120
+ commit the reserve to the sector that is actually being hit.
121
+ robotics_analogue: >
122
+ Incident surge response: an on-call response team is staged
123
+ centrally, equidistant from candidate sites. When one site
124
+ escalates into a breach, the surge capacity must be committed to
125
+ THAT site — promptly and in force. Holding the reserve back or
126
+ dispatching it to a quiet site both fail the response: the skill
127
+ is reading where the main effort has localised and committing
128
+ the reserve there.
129
+ benchmark_anchor:
130
+ - "military reserve commitment"
131
+ - "reinforce the breach doctrine"
132
+ - "incident surge response"
133
+ author: openra-bench
134
+
135
+ base_map: rush-hour-arena
136
+
137
+ base:
138
+ agent: {faction: allies, cash: 0}
139
+ # `hunt`: each enemy unit advances on and engages the NEAREST
140
+ # agent unit, so the north and south lanes are genuinely
141
+ # independent fights (a `rusher` bot pools every band onto the
142
+ # agent centroid and collapses the multi-lane test).
143
+ enemy: {faction: soviet, cash: 0, bot_type: hunt}
144
+ tools: [observe, move_units, attack_unit, attack_move, stop]
145
+ planning: true
146
+ termination: {max_ticks: 8000}
147
+ actors: []
148
+
149
+ levels:
150
+ # ── EASY ────────────────────────────────────────────────────────
151
+ # The bare reactive-reserve skill: REACT AT ALL. Two lanes, a
152
+ # 4-tank reserve at the centre. The south lane is breached at tick
153
+ # 450 by an 8×e1 wave. The forward-zone bar is n=3 — the 2-tank
154
+ # south garrison plus ONE reinforcing reserve tank fills it. The
155
+ # discrimination at this tier is stall (reserve never moves ⇒ zone
156
+ # unfilled + garrison overrun ⇒ LOSS) vs reinforce (move the
157
+ # reserve south ⇒ WIN). Picking the WRONG lane is punished at
158
+ # medium; easy only tests that the agent reacts.
159
+ easy:
160
+ description: >
161
+ You hold a two-lane defence — a NORTH lane (y≈12) and a SOUTH
162
+ lane (y≈28), each with a 2-tank garrison — plus a 4-tank
163
+ mobile reserve held at the CENTRE (x≈44, y=20). At first both
164
+ lanes face only a light probe. Mid-episode the SOUTH lane is
165
+ BREACHED by a heavier wave. React: shift the mobile reserve
166
+ FORWARD into the SOUTH lane to reinforce it — at least 3 tanks
167
+ must be within radius 12 of the south intercept point
168
+ (62, 28) at win time, you must keep at least 4 tanks alive,
169
+ and finish before tick 2400. Holding the reserve at the centre
170
+ LOSES — the breach overruns the un-reinforced south garrison.
171
+ Stalling LOSES.
172
+ overrides:
173
+ actors:
174
+ - {type: fact, owner: agent, position: [10, 20]}
175
+ # NORTH lane garrison.
176
+ - {type: 2tnk, owner: agent, position: [30, 11], stance: 2}
177
+ - {type: 2tnk, owner: agent, position: [30, 13], stance: 2}
178
+ # SOUTH lane garrison.
179
+ - {type: 2tnk, owner: agent, position: [30, 27], stance: 2}
180
+ - {type: 2tnk, owner: agent, position: [30, 29], stance: 2}
181
+ # Mobile reserve at the CENTRE — equidistant from both lanes.
182
+ - {type: 2tnk, owner: agent, position: [44, 19], stance: 2}
183
+ - {type: 2tnk, owner: agent, position: [44, 20], stance: 2}
184
+ - {type: 2tnk, owner: agent, position: [44, 21], stance: 2}
185
+ - {type: 2tnk, owner: agent, position: [45, 20], stance: 2}
186
+ # Initial light probes — one per lane.
187
+ - {type: e1, owner: enemy, position: [78, 12], stance: 3, count: 3}
188
+ - {type: e1, owner: enemy, position: [78, 28], stance: 3, count: 3}
189
+ # Far inert enemy marker — anti-DRAW.
190
+ - {type: fact, owner: enemy, position: [120, 20]}
191
+ # Breach wave — heavier wave injected on the SOUTH lane.
192
+ scheduled_events:
193
+ - tick: 450
194
+ type: spawn_actors
195
+ actors:
196
+ - {type: e1, owner: enemy, position: [78, 28], stance: 3, count: 8}
197
+ win_condition:
198
+ all_of:
199
+ # `after_ticks` gate: the win cannot latch until the breach
200
+ # wave has developed (it spawns at tick 450 and is fully in
201
+ # play by ~720) — so a reserve that rushes the zone before
202
+ # the breach cannot win prematurely.
203
+ - {after_ticks: 720}
204
+ - {units_in_region_gte: {x: 62, y: 28, radius: 12, n: 3}}
205
+ - {own_units_gte: 4}
206
+ - {within_ticks: 2400}
207
+ fail_condition:
208
+ any_of:
209
+ - {after_ticks: 2401}
210
+ - {not: {own_units_gte: 1}}
211
+ max_turns: 40
212
+
213
+ # ── MEDIUM ──────────────────────────────────────────────────────
214
+ # +1 controlled variable: REACT TO THE RIGHT LANE. The forward-
215
+ # zone bar rises to n=4 — the 2-tank south garrison can no longer
216
+ # fill it alone, and a reserve committed to the NORTH lane leaves
217
+ # the south zone two tanks short. Now reinforcing the WRONG lane
218
+ # LOSES, not just stalling. Same breach (south, tick 450, 8×e1),
219
+ # same clock.
220
+ medium:
221
+ description: >
222
+ You hold a two-lane defence — a NORTH lane and a SOUTH lane,
223
+ each with a 2-tank garrison — plus a 4-tank mobile reserve at
224
+ the CENTRE. Mid-episode one lane is BREACHED by a heavier
225
+ wave. React to the lane that is actually hit: shift the mobile
226
+ reserve FORWARD into the BREACHED lane — at least 4 tanks must
227
+ be within radius 12 of that lane's intercept point at win
228
+ time, you must keep at least 4 tanks alive, and finish before
229
+ tick 2400. Holding the reserve at the centre LOSES; committing
230
+ it to the QUIET lane LOSES (the breached lane is left two
231
+ tanks short); stalling LOSES.
232
+ overrides:
233
+ actors:
234
+ - {type: fact, owner: agent, position: [10, 20]}
235
+ - {type: 2tnk, owner: agent, position: [30, 11], stance: 2}
236
+ - {type: 2tnk, owner: agent, position: [30, 13], stance: 2}
237
+ - {type: 2tnk, owner: agent, position: [30, 27], stance: 2}
238
+ - {type: 2tnk, owner: agent, position: [30, 29], stance: 2}
239
+ - {type: 2tnk, owner: agent, position: [44, 19], stance: 2}
240
+ - {type: 2tnk, owner: agent, position: [44, 20], stance: 2}
241
+ - {type: 2tnk, owner: agent, position: [44, 21], stance: 2}
242
+ - {type: 2tnk, owner: agent, position: [45, 20], stance: 2}
243
+ - {type: e1, owner: enemy, position: [78, 12], stance: 3, count: 3}
244
+ - {type: e1, owner: enemy, position: [78, 28], stance: 3, count: 3}
245
+ - {type: fact, owner: enemy, position: [120, 20]}
246
+ # Breach wave — SOUTH lane, tick 450.
247
+ scheduled_events:
248
+ - tick: 450
249
+ type: spawn_actors
250
+ actors:
251
+ - {type: e1, owner: enemy, position: [78, 28], stance: 3, count: 8}
252
+ win_condition:
253
+ all_of:
254
+ # `after_ticks` gate: the win cannot latch until the breach
255
+ # wave has developed (it spawns at tick 450 and is fully in
256
+ # play by ~720) — so a reserve that rushes the zone before
257
+ # the breach cannot win prematurely.
258
+ - {after_ticks: 720}
259
+ - {units_in_region_gte: {x: 62, y: 28, radius: 12, n: 4}}
260
+ - {own_units_gte: 4}
261
+ - {within_ticks: 2400}
262
+ fail_condition:
263
+ any_of:
264
+ - {after_ticks: 2401}
265
+ - {not: {own_units_gte: 1}}
266
+ max_turns: 40
267
+
268
+ # ── HARD ────────────────────────────────────────────────────────
269
+ # +2 controlled variables vs medium:
270
+ # 1. Tighter deadline (~1900 ticks instead of ~2400) — the
271
+ # reserve must be committed PROMPTLY; a dawdling shift misses
272
+ # the clock.
273
+ # 2. TWO agent spawn_point groups round-robin the mobile
274
+ # reserve's start COLUMN (x=40 vs x=50 — both equidistant
275
+ # from the two lanes) so a memorised reserve cell cannot
276
+ # generalise; the centre-then-react doctrine does. The
277
+ # garrisons and the breach wave (no spawn_point) place every
278
+ # seed.
279
+ hard:
280
+ description: >
281
+ You hold a two-lane defence — a NORTH lane and a SOUTH lane,
282
+ each with a 2-tank garrison — plus a 4-tank mobile reserve at
283
+ the CENTRE (its exact start column varies by seed). Mid-
284
+ episode one lane is BREACHED by a heavier wave. React
285
+ promptly: shift the mobile reserve FORWARD into the BREACHED
286
+ lane — at least 4 tanks must be within radius 12 of that
287
+ lane's intercept point at win time, you must keep at least 4
288
+ tanks alive, and finish before tick 1900 (a tight window).
289
+ Holding the reserve at the centre LOSES; committing it to the
290
+ QUIET lane LOSES; stalling LOSES.
291
+ overrides:
292
+ actors:
293
+ - {type: fact, owner: agent, position: [10, 20]}
294
+ - {type: 2tnk, owner: agent, position: [30, 11], stance: 2}
295
+ - {type: 2tnk, owner: agent, position: [30, 13], stance: 2}
296
+ - {type: 2tnk, owner: agent, position: [30, 27], stance: 2}
297
+ - {type: 2tnk, owner: agent, position: [30, 29], stance: 2}
298
+ # spawn_point 0 — reserve start column x=40.
299
+ - {type: 2tnk, owner: agent, position: [40, 19], stance: 2, spawn_point: 0}
300
+ - {type: 2tnk, owner: agent, position: [40, 20], stance: 2, spawn_point: 0}
301
+ - {type: 2tnk, owner: agent, position: [40, 21], stance: 2, spawn_point: 0}
302
+ - {type: 2tnk, owner: agent, position: [41, 20], stance: 2, spawn_point: 0}
303
+ # spawn_point 1 — reserve start column x=50.
304
+ - {type: 2tnk, owner: agent, position: [50, 19], stance: 2, spawn_point: 1}
305
+ - {type: 2tnk, owner: agent, position: [50, 20], stance: 2, spawn_point: 1}
306
+ - {type: 2tnk, owner: agent, position: [50, 21], stance: 2, spawn_point: 1}
307
+ - {type: 2tnk, owner: agent, position: [51, 20], stance: 2, spawn_point: 1}
308
+ - {type: e1, owner: enemy, position: [78, 12], stance: 3, count: 3}
309
+ - {type: e1, owner: enemy, position: [78, 28], stance: 3, count: 3}
310
+ - {type: fact, owner: enemy, position: [120, 20]}
311
+ # Breach wave — SOUTH lane, tick 450.
312
+ scheduled_events:
313
+ - tick: 450
314
+ type: spawn_actors
315
+ actors:
316
+ - {type: e1, owner: enemy, position: [78, 28], stance: 3, count: 8}
317
+ win_condition:
318
+ all_of:
319
+ # `after_ticks` gate: the win cannot latch until the breach
320
+ # wave has developed (it spawns at tick 450 and is fully in
321
+ # play by ~720) — so a reserve that rushes the zone before
322
+ # the breach cannot win prematurely.
323
+ - {after_ticks: 720}
324
+ - {units_in_region_gte: {x: 62, y: 28, radius: 12, n: 4}}
325
+ - {own_units_gte: 4}
326
+ - {within_ticks: 1900}
327
+ fail_condition:
328
+ any_of:
329
+ - {after_ticks: 1901}
330
+ - {not: {own_units_gte: 1}}
331
+ max_turns: 30
tests/test_def_reinforce_the_breach.py ADDED
@@ -0,0 +1,327 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """def-reinforce-the-breach — ACTION capability validation.
2
+
3
+ Reactive reserve commitment: a two-lane defence with a mobile
4
+ reserve held at the centre. Mid-episode a HEAVY breach wave is
5
+ injected on the SOUTH lane via `scheduled_events: spawn_actors`.
6
+ The agent must read which lane was breached and shift the reserve
7
+ FORWARD into that lane's intercept zone.
8
+
9
+ Bar (CLAUDE.md "no defect, no cheat, no draw"):
10
+
11
+ * stall (observe-only) LOSES every tier / every hard seed — the
12
+ reserve sits at the centre; the forward-zone clause is never
13
+ satisfied and the un-reinforced south garrison is overrun.
14
+ * brute (one attack_move straight east) LOSES every tier / seed
15
+ — the reserve drives off the breached lane's intercept zone.
16
+ * reinforce-the-wrong-lane (commit the reserve to the NORTH
17
+ lane) LOSES on medium/hard — the south forward zone is left
18
+ two tanks short of the n=4 bar.
19
+ * intended reinforce-the-breach (shift the reserve into the
20
+ SOUTH lane's intercept zone) WINS every tier / every hard
21
+ seed.
22
+ * the win carries an `after_ticks: 720` gate so it cannot latch
23
+ before the breach (tick 450) has developed — no premature win.
24
+ * hard tier defines ≥2 agent spawn_point groups (reserve start
25
+ column x=40 / x=50) round-robined by seed.
26
+ """
27
+
28
+ from __future__ import annotations
29
+
30
+ from pathlib import Path
31
+
32
+ import pytest
33
+
34
+ pytest.importorskip("openra_train", reason="Rust env wheel not installed")
35
+ pytest.importorskip("openra_rl_training", reason="Rust env wheel not installed")
36
+
37
+ from openra_bench.eval_core import run_level
38
+ from openra_bench.scenarios import load_pack
39
+ from openra_bench.scenarios.loader import PACKS_DIR, compile_level
40
+ from openra_bench.scenarios.win_conditions import WinContext, evaluate
41
+
42
+ PACK = PACKS_DIR / "def-reinforce-the-breach.yaml"
43
+ LEVELS = ("easy", "medium", "hard")
44
+ SEEDS = (1, 2, 3, 4)
45
+
46
+
47
+ # ── scripted policies ───────────────────────────────────────────────
48
+
49
+
50
+ def _tanks(rs):
51
+ return [u for u in (rs.get("units_summary") or []) if u.get("type") == "2tnk"]
52
+
53
+
54
+ def _reserve(own):
55
+ """The centre reserve: tanks on the mid latitude, x ≥ 37."""
56
+ return [u for u in own if 17 <= u["cell_y"] <= 23 and u["cell_x"] >= 37]
57
+
58
+
59
+ def _stall(rs, C):
60
+ """Observe-only — the reserve never moves; the forward-zone clause
61
+ is never satisfied and the south garrison is overrun → LOSS."""
62
+ return [C.observe()]
63
+
64
+
65
+ def _brute(rs, C):
66
+ """One attack_move straight east — the reserve drives off the
67
+ breached lane's intercept zone → the forward-zone clause fails →
68
+ LOSS."""
69
+ own = _tanks(rs)
70
+ if not own:
71
+ return [C.observe()]
72
+ return [
73
+ C.attack_move([str(u["id"]) for u in own], target_x=110, target_y=own[0]["cell_y"])
74
+ ]
75
+
76
+
77
+ def _make_reinforce(lane_y):
78
+ """Shift the reserve forward into the lane at `lane_y` (28 = the
79
+ breached SOUTH lane; 12 = the un-breached NORTH lane). The
80
+ garrisons attack the nearest visible enemy."""
81
+
82
+ def p(rs, C):
83
+ own = _tanks(rs)
84
+ if not own:
85
+ return [C.observe()]
86
+ enemies = rs.get("enemy_summary") or []
87
+ reserve = _reserve(own)
88
+ cmds = []
89
+ for u in reserve:
90
+ if enemies and lane_y == 28:
91
+ t = min(
92
+ enemies,
93
+ key=lambda e: abs(e["cell_x"] - u["cell_x"])
94
+ + abs(e["cell_y"] - u["cell_y"]),
95
+ )
96
+ cmds.append(C.attack_unit([str(u["id"])], str(t["id"])))
97
+ else:
98
+ cmds.append(
99
+ C.attack_move([str(u["id"])], target_x=58, target_y=lane_y)
100
+ )
101
+ for u in own:
102
+ if u not in reserve and enemies:
103
+ t = min(
104
+ enemies,
105
+ key=lambda e: abs(e["cell_x"] - u["cell_x"])
106
+ + abs(e["cell_y"] - u["cell_y"]),
107
+ )
108
+ cmds.append(C.attack_unit([str(u["id"])], str(t["id"])))
109
+ return cmds or [C.observe()]
110
+
111
+ return p
112
+
113
+
114
+ # ── structural tests ────────────────────────────────────────────────
115
+
116
+
117
+ def test_pack_loads_and_meta_action():
118
+ pack = load_pack(PACK)
119
+ assert pack.meta.id == "def-reinforce-the-breach"
120
+ assert pack.meta.capability == "action"
121
+ assert pack.meta.real_world_meaning
122
+ assert pack.meta.robotics_analogue
123
+ anchors = " ".join(pack.meta.benchmark_anchor).lower()
124
+ assert "military reserve commitment" in anchors, anchors
125
+ assert "reinforce the breach doctrine" in anchors, anchors
126
+ assert "incident surge response" in anchors, anchors
127
+
128
+
129
+ def test_enemy_uses_hunt_bot():
130
+ pack = load_pack(PACK)
131
+ for lvl in LEVELS:
132
+ c = compile_level(pack, lvl)
133
+ enemy = c.scenario.enemy
134
+ bot = getattr(enemy, "bot_type", None) or getattr(enemy, "bot", None)
135
+ assert str(bot).lower() == "hunt", f"{lvl}: enemy bot must be 'hunt'; got {bot}"
136
+
137
+
138
+ def test_every_level_has_a_scheduled_breach_wave():
139
+ """Every level injects the breach via scheduled_events:
140
+ spawn_actors — the breach must develop MID-episode."""
141
+ pack = load_pack(PACK)
142
+ for lvl in LEVELS:
143
+ c = compile_level(pack, lvl)
144
+ ev = getattr(c, "scheduled_events", None)
145
+ assert ev, f"{lvl}: must declare scheduled_events"
146
+ spawns = [e for e in ev if e.get("type") == "spawn_actors"]
147
+ assert spawns, f"{lvl}: must have a spawn_actors breach wave"
148
+ # The breach wave fires after t=0 (mid-episode).
149
+ for e in spawns:
150
+ assert int(e["tick"]) > 0, f"{lvl}: breach tick must be > 0"
151
+
152
+
153
+ def test_win_has_after_ticks_gate_before_the_breach_resolves():
154
+ """The win carries an `after_ticks` gate so it cannot latch
155
+ before the breach wave (scheduled) has developed."""
156
+ pack = load_pack(PACK)
157
+ for lvl in LEVELS:
158
+ c = compile_level(pack, lvl)
159
+ win = c.win_condition.model_dump()["all_of"]
160
+ gate = next((int(x["after_ticks"]) for x in win if "after_ticks" in x), None)
161
+ assert gate is not None, f"{lvl}: win must carry an after_ticks gate"
162
+ breach_tick = min(
163
+ int(e["tick"])
164
+ for e in c.scheduled_events
165
+ if e.get("type") == "spawn_actors"
166
+ )
167
+ assert gate > breach_tick, (
168
+ f"{lvl}: win gate {gate} must be after the breach tick {breach_tick}"
169
+ )
170
+
171
+
172
+ def test_forward_zone_clause_is_load_bearing():
173
+ """Every level's win requires a units_in_region_gte forward-zone
174
+ clause at the south lane intercept point."""
175
+ pack = load_pack(PACK)
176
+ for lvl in LEVELS:
177
+ c = compile_level(pack, lvl)
178
+ win = c.win_condition.model_dump()["all_of"]
179
+ region = next(
180
+ (x["units_in_region_gte"] for x in win if "units_in_region_gte" in x),
181
+ None,
182
+ )
183
+ assert region is not None, f"{lvl}: win needs a units_in_region_gte clause"
184
+ assert region["y"] == 28, f"{lvl}: forward zone must be the SOUTH lane"
185
+
186
+
187
+ def test_every_level_has_reachable_timeout_fail():
188
+ pack = load_pack(PACK)
189
+ for lvl in LEVELS:
190
+ L = pack.levels[lvl]
191
+ ceiling = 93 + 90 * (L.max_turns - 1)
192
+ wt = next(
193
+ int(c["within_ticks"])
194
+ for c in L.win_condition.model_dump()["all_of"]
195
+ if "within_ticks" in c
196
+ )
197
+ ft = next(
198
+ int(c["after_ticks"])
199
+ for c in L.fail_condition.model_dump()["any_of"]
200
+ if "after_ticks" in c
201
+ )
202
+ assert wt < ceiling, f"{lvl}: within_ticks {wt} >= ceiling {ceiling}"
203
+ assert ft <= ceiling, f"{lvl}: after_ticks {ft} > ceiling {ceiling}"
204
+ assert wt + 1 == ft, f"{lvl}: within/after mismatch {wt}/{ft}"
205
+
206
+
207
+ def test_every_level_has_a_fail_condition():
208
+ pack = load_pack(PACK)
209
+ for lvl in LEVELS:
210
+ c = compile_level(pack, lvl)
211
+ assert c.fail_condition is not None, f"{lvl} needs a fail_condition"
212
+
213
+
214
+ def test_hard_has_two_seed_driven_spawn_groups():
215
+ c = compile_level(load_pack(PACK), "hard")
216
+ sp = {
217
+ (a.spawn_point if a.spawn_point is not None else 0)
218
+ for a in c.scenario.actors
219
+ if a.owner == "agent"
220
+ }
221
+ assert sp == {0, 1}, f"hard must define spawn_point groups {{0,1}}; got {sorted(sp)}"
222
+
223
+
224
+ def test_in_bounds_actors_on_every_level():
225
+ pack = load_pack(PACK)
226
+ for lvl in LEVELS:
227
+ c = compile_level(pack, lvl)
228
+ for a in c.scenario.actors:
229
+ x, y = a.position
230
+ assert 2 <= x <= 126 and 2 <= y <= 38, (
231
+ f"{lvl}: actor {a.type} at ({x},{y}) out of bounds"
232
+ )
233
+
234
+
235
+ # ── predicate-level (no engine) ─────────────────────────────────────
236
+
237
+
238
+ def _ctx(*, tick=1000, units_xy=()):
239
+ import types
240
+
241
+ sig = types.SimpleNamespace(
242
+ game_tick=tick,
243
+ units_killed=0,
244
+ units_lost=0,
245
+ own_buildings=[],
246
+ own_building_types=set(),
247
+ enemies_seen_ids=set(),
248
+ enemy_buildings_seen_ids=set(),
249
+ )
250
+ return WinContext(
251
+ signals=sig,
252
+ render_state={
253
+ "units_summary": [{"cell_x": x, "cell_y": y} for x, y in units_xy]
254
+ },
255
+ )
256
+
257
+
258
+ def test_predicates_enforce_forward_zone_and_gate():
259
+ pm = compile_level(load_pack(PACK), "medium")
260
+ # 4 tanks in the south zone, after the gate, in time → WIN
261
+ south4 = [(62, 28), (61, 27), (63, 29), (60, 28)]
262
+ assert evaluate(pm.win_condition, _ctx(tick=1000, units_xy=south4))
263
+ # Same 4 tanks but BEFORE the after_ticks gate → not win
264
+ assert not evaluate(pm.win_condition, _ctx(tick=300, units_xy=south4))
265
+ # Only 2 tanks in the south zone (wrong-lane) → not win (need 4)
266
+ assert not evaluate(pm.win_condition, _ctx(tick=1000, units_xy=south4[:2]))
267
+ # Past the deadline → fail
268
+ assert evaluate(pm.fail_condition, _ctx(tick=2402, units_xy=south4))
269
+
270
+
271
+ # ── engine-driven: every lazy/wrong policy LOSES, intended WINS ──────
272
+
273
+
274
+ @pytest.mark.parametrize("level", LEVELS)
275
+ @pytest.mark.parametrize("seed", SEEDS)
276
+ def test_stall_loses_every_tier_and_seed(level, seed):
277
+ c = compile_level(load_pack(PACK), level)
278
+ r = run_level(c, _stall, seed=seed)
279
+ assert r.outcome == "loss", (
280
+ f"{level}/seed{seed}: stall must LOSE; got {r.outcome} "
281
+ f"lost={r.signals.units_lost} tick={r.signals.game_tick}"
282
+ )
283
+
284
+
285
+ @pytest.mark.parametrize("level", LEVELS)
286
+ @pytest.mark.parametrize("seed", SEEDS)
287
+ def test_brute_loses_every_tier_and_seed(level, seed):
288
+ c = compile_level(load_pack(PACK), level)
289
+ r = run_level(c, _brute, seed=seed)
290
+ assert r.outcome == "loss", (
291
+ f"{level}/seed{seed}: brute must LOSE; got {r.outcome} "
292
+ f"lost={r.signals.units_lost} tick={r.signals.game_tick}"
293
+ )
294
+
295
+
296
+ @pytest.mark.parametrize("level", ("medium", "hard"))
297
+ @pytest.mark.parametrize("seed", SEEDS)
298
+ def test_wrong_lane_loses_on_multi_tank_zone_tiers(level, seed):
299
+ """Reinforcing the un-breached NORTH lane leaves the south
300
+ forward zone two tanks short of the n=4 bar → LOSS on
301
+ medium/hard."""
302
+ c = compile_level(load_pack(PACK), level)
303
+ r = run_level(c, _make_reinforce(12), seed=seed)
304
+ assert r.outcome == "loss", (
305
+ f"{level}/seed{seed}: reinforce-wrong-lane must LOSE; got {r.outcome} "
306
+ f"lost={r.signals.units_lost} tick={r.signals.game_tick}"
307
+ )
308
+
309
+
310
+ @pytest.mark.parametrize("level", LEVELS)
311
+ @pytest.mark.parametrize("seed", SEEDS)
312
+ def test_reinforce_the_breach_wins_every_tier_and_seed(level, seed):
313
+ """The intended capability — shift the reserve into the breached
314
+ SOUTH lane — WINS every tier and every hard seed."""
315
+ c = compile_level(load_pack(PACK), level)
316
+ r = run_level(c, _make_reinforce(28), seed=seed)
317
+ assert r.outcome == "win", (
318
+ f"{level}/seed{seed}: reinforce-the-breach must WIN; got {r.outcome} "
319
+ f"lost={r.signals.units_lost} tick={r.signals.game_tick}"
320
+ )
321
+
322
+
323
+ def test_reinforce_run_is_deterministic_per_seed():
324
+ c = compile_level(load_pack(PACK), "medium")
325
+ a = run_level(c, _make_reinforce(28), seed=2)
326
+ b = run_level(c, _make_reinforce(28), seed=2)
327
+ assert (a.outcome, a.turns) == (b.outcome, b.turns)
tests/test_hard_tier.py CHANGED
@@ -1451,6 +1451,7 @@ UPGRADED = [
1451
  "combat-kite-and-pull", # hard: 2 agent spawn_point groups (Wave-12)
1452
  "econ-tech-vs-expand-decision", # hard: 2 agent spawn_point groups (Wave-12)
1453
  "scout-deny-enemy-vision", # hard: 2 agent spawn_point groups (Wave-12)
 
1454
  ]
1455
 
1456
  # Consciously NOT spawn-varied, with the reason (keeps the curation
 
1451
  "combat-kite-and-pull", # hard: 2 agent spawn_point groups (Wave-12)
1452
  "econ-tech-vs-expand-decision", # hard: 2 agent spawn_point groups (Wave-12)
1453
  "scout-deny-enemy-vision", # hard: 2 agent spawn_point groups (Wave-12)
1454
+ "def-reinforce-the-breach", # hard: 2 agent spawn_point groups (Wave-12)
1455
  ]
1456
 
1457
  # Consciously NOT spawn-varied, with the reason (keeps the curation