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

feat(scenarios): Wave-5 salvage — 16 packs rescued from worktree branches

Browse files

16 Wave-5 agents finished authoring (YAML + scripted-policy tests) but
their pushes were blocked / outputs truncated. Salvaged from the
untracked working tree and committed from the main session:

Defense-positioning (3 net-new, 1 was already registered):
- def-in-depth (military DiD doctrine / security multi-layer)
- def-surprise-flank-react (adversarial robustness / CICERO deception)
- def-tower-line-vs-cluster (graph min-cut / military bunker)

Coordination (5):
- coord-converge-on-target (SC2 triple-prong / military pincer)
- coord-cover-and-move (SC2 / military bounding overwatch)
- coord-diversionary-attack (SC2 split-attack / Sun Tzu diversion)
- coord-relay-attack (SC2 attack-wave / SMAC relay)
- coord-squad-handoff (Watch-And-Help / SMAC squad sequencing)

Long-horizon (5):
- lh-100-turn-marathon-survival (lmgame multi-hour / SC2LE endurance)
- lh-build-army-coordinate-multifront-attack (SC2 macro / mil ops)
- lh-defense-tech-second-base (SC2 secure-expand-tech / PlanBench)
- lh-multi-checkpoint-5-plus (PERT / PlanBench long-sequencing)
- lh-scout-react-counter (CICERO info-loop / PlanBench replanning)

Economy (3):
- econ-cash-reserve-management (SC2 / treasury working capital)
- econ-recover-from-zero-cash (SC2 turtle-recovery / turnaround)
- econ-target-cash-amount-by-deadline (SC2 / budget-by-date)

All 16 have ≥2 spawn groups on hard; all registered in
test_hard_tier.py UPGRADED (12 were already registered by their
agents' worktree edits that did get pushed via shared FS).

Files changed (33) hide show
  1. openra_bench/scenarios/packs/coord-converge-on-target.yaml +263 -0
  2. openra_bench/scenarios/packs/coord-cover-and-move.yaml +330 -0
  3. openra_bench/scenarios/packs/coord-diversionary-attack.yaml +336 -0
  4. openra_bench/scenarios/packs/coord-relay-attack.yaml +247 -0
  5. openra_bench/scenarios/packs/coord-squad-handoff.yaml +203 -0
  6. openra_bench/scenarios/packs/def-in-depth.yaml +284 -0
  7. openra_bench/scenarios/packs/def-surprise-flank-react.yaml +441 -0
  8. openra_bench/scenarios/packs/def-tower-line-vs-cluster.yaml +232 -0
  9. openra_bench/scenarios/packs/econ-cash-reserve-management.yaml +232 -0
  10. openra_bench/scenarios/packs/econ-recover-from-zero-cash.yaml +285 -0
  11. openra_bench/scenarios/packs/econ-target-cash-amount-by-deadline.yaml +197 -0
  12. openra_bench/scenarios/packs/lh-100-turn-marathon-survival.yaml +357 -0
  13. openra_bench/scenarios/packs/lh-build-army-coordinate-multifront-attack.yaml +322 -0
  14. openra_bench/scenarios/packs/lh-defense-tech-second-base.yaml +370 -0
  15. openra_bench/scenarios/packs/lh-multi-checkpoint-5-plus.yaml +324 -0
  16. openra_bench/scenarios/packs/lh-scout-react-counter.yaml +368 -0
  17. tests/test_coord_converge_on_target.py +378 -0
  18. tests/test_coord_cover_and_move.py +395 -0
  19. tests/test_coord_diversionary_attack.py +572 -0
  20. tests/test_coord_relay_attack.py +387 -0
  21. tests/test_coord_squad_handoff.py +360 -0
  22. tests/test_def_in_depth.py +259 -0
  23. tests/test_def_surprise_flank_react.py +318 -0
  24. tests/test_def_tower_line_vs_cluster.py +276 -0
  25. tests/test_econ_cash_reserve_management.py +315 -0
  26. tests/test_econ_recover_from_zero_cash.py +502 -0
  27. tests/test_econ_target_cash_amount_by_deadline.py +304 -0
  28. tests/test_hard_tier.py +35 -0
  29. tests/test_lh_100_turn_marathon_survival.py +352 -0
  30. tests/test_lh_build_army_coordinate_multifront_attack.py +319 -0
  31. tests/test_lh_defense_tech_second_base.py +552 -0
  32. tests/test_lh_multi_checkpoint_5_plus.py +413 -0
  33. tests/test_lh_scout_react_counter.py +418 -0
openra_bench/scenarios/packs/coord-converge-on-target.yaml ADDED
@@ -0,0 +1,263 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # coord-converge-on-target.yaml
2
+ #
3
+ # ACTION capability — triple-prong convergent attack. Three armoured
4
+ # squads start at three different bearings (NORTH, WEST-of-objective,
5
+ # SOUTH) and must CONVERGE simultaneously on a single defended enemy
6
+ # objective (`fact`) at mid-east. Each squad ALONE is killed by the
7
+ # leashed `guard` defenders before the fact falls; TWO squads
8
+ # converging may destroy the fact but trade out the strike force
9
+ # (surviving tank count in the objective region falls below n=6); only
10
+ # when ALL THREE squads arrive within a tight common window do they
11
+ # overwhelm the defenders cleanly enough to leave ≥6 tanks in the
12
+ # objective region with the yard destroyed.
13
+ #
14
+ # Real-world anchor: military convergent / envelopment attack and SC2
15
+ # triple-prong assault — multiple columns striking from different
16
+ # bearings at the same instant to overwhelm a defender that would
17
+ # defeat any single column. Robotics analogue: synchronous multi-team
18
+ # rendezvous on a shared objective under a tight common deadline.
19
+ #
20
+ # Engine notes (per CLAUDE.md):
21
+ # - `fact` is `MustBeDestroyed`: razing it would auto-`done` the
22
+ # engine and a within-ticks check could collapse to DRAW. A
23
+ # SENTINEL enemy `fact` is placed in the far north (out of the
24
+ # combat zone) so the episode persists past the objective fact's
25
+ # destruction long enough for the win/fail predicates to evaluate.
26
+ # - Bot `guard` (GUARD_AGGRO ≈ 16, GUARD_LEASH ≈ 18) holds post,
27
+ # auto-fires in range, lunges at the nearest foe within ≈16
28
+ # cells, snaps back past ≈18. The e3 rocket infantry is the
29
+ # anti-tank Dragon: each guard chews a 2tnk in a handful of
30
+ # shots, so a single squad's commit on the cluster is lethal.
31
+ # - `units_in_region_gte` ≥6 on the 9 tanks is load-bearing: a
32
+ # two-squad converge (6 tanks) typically destroys the fact but
33
+ # trades the whole strike force (≤0 tanks left in region), so a
34
+ # partial converge LOSES on the predicate even when the fact
35
+ # happens to fall. A single squad alone is wiped before the fact
36
+ # falls. Three squads converging arrive together and overwhelm:
37
+ # ≥6 tanks survive inside the (100,20, r=8) region while the
38
+ # cluster + fact go down.
39
+ # - Squad positions are balanced equidistant (~32-34 cells from the
40
+ # objective) so a naive simultaneous attack-move from all three
41
+ # squads ACTUALLY ARRIVES TOGETHER; widely-unbalanced edge spawns
42
+ # would force the closer squads into the cluster alone first and
43
+ # a 3-prong policy would degenerate to a serial 1-then-1-then-1
44
+ # attack defeated piecewise (the calibration bar still requires
45
+ # simultaneous arrival).
46
+ # - Enemy actors are NOT filtered by `spawn_point` (CLAUDE.md). The
47
+ # hard tier varies the AGENT staging — the lateral (third) squad
48
+ # starts WEST of objective in spawn 0 and EAST of objective in
49
+ # spawn 1, flipping its ingress bearing — while the defender
50
+ # cluster stays symmetric.
51
+
52
+ meta:
53
+ id: coord-converge-on-target
54
+ title: 'Convergent Attack — Three Squads, One Defended Target'
55
+ capability: action
56
+ real_world_meaning: >
57
+ Three armoured columns start on three different bearings (NORTH,
58
+ WEST-of-objective, SOUTH) and must converge on a single defended
59
+ enemy construction yard from three different directions AT THE
60
+ SAME TIME. Any one column alone is killed by the defenders before
61
+ the yard falls; two columns may destroy the yard but trade out
62
+ the strike force (surviving tank count in the objective region
63
+ drops below the required threshold); only when all three columns
64
+ arrive on the objective within a tight common window does the
65
+ combined force overwhelm the defenders cleanly. The advertised
66
+ capability is synchronous multi-fleet convergence on a shared
67
+ objective — a single-column tour or a serialized two-column
68
+ attempt loses on attrition, a stall loses on the clock.
69
+ robotics_analogue: >
70
+ Synchronous multi-robot rendezvous on a contested objective: each
71
+ team must dispatch on a different ingress vector so that all
72
+ teams arrive at the goal within a bounded common window;
73
+ serialising one team after another lets the contesting agents
74
+ defeat each team in detail and prevents the joint payload
75
+ threshold (≥n teammates surviving on the goal) from holding.
76
+ benchmark_anchor:
77
+ - "SC2 triple-prong assault timing"
78
+ - "military convergent attack / pincer movement"
79
+ - "SMAC squad convergence"
80
+ - "envelopment doctrine: hit from multiple directions simultaneously"
81
+ author: openra-bench
82
+
83
+ base_map: rush-hour-arena
84
+
85
+ base:
86
+ agent: {faction: allies, cash: 0}
87
+ enemy: {faction: soviet, cash: 0, bot_type: guard}
88
+ tools: [move_units, attack_unit, attack_move, stop]
89
+ planning: true
90
+ termination: {max_ticks: 12000}
91
+ actors: [] # every level supplies its own actor list via overrides.
92
+
93
+ levels:
94
+ # ── EASY ─────────────────────────────────────────────────────────
95
+ # The bare convergent-attack skill: 3 leashed guards (distributed
96
+ # around the objective fact), 9 tanks total. A single squad of 3
97
+ # tanks alone is wiped by the 3 anti-tank guards before the fact
98
+ # falls; 2-prong or 3-prong both succeed (easy is lenient on the
99
+ # n=6 region clause). A stall (no movement) loses on the clock.
100
+ easy:
101
+ description: >
102
+ THREE squads of medium tanks (3× 2tnk each, 9 tanks total) start
103
+ at three different bearings around the enemy construction yard
104
+ (`fact` at ~100,20): NORTH (~70,6), WEST (~66,20), SOUTH
105
+ (~70,34). The yard is defended by 3 leashed rocket-infantry
106
+ guards (e3, bot_type guard) that hold post, auto-fire in range,
107
+ and lunge at the nearest approaching foe. Win when at least 6
108
+ of your 9 tanks are inside an 8-cell radius of (100,20) AND the
109
+ enemy construction yard is destroyed, before tick 4500. A
110
+ single squad alone is killed by the guard cluster before the
111
+ yard falls. A stall loses on the clock.
112
+ overrides:
113
+ actors:
114
+ # NORTH squad — 3 medium tanks at (70,6).
115
+ - {type: 2tnk, owner: agent, position: [70, 6], stance: 1, count: 3}
116
+ # WEST squad — 3 medium tanks at (66,20). Equidistant from
117
+ # the objective (~34 cells) so a naive simultaneous launch
118
+ # arrives together.
119
+ - {type: 2tnk, owner: agent, position: [66, 20], stance: 1, count: 3}
120
+ # SOUTH squad — 3 medium tanks at (70,34).
121
+ - {type: 2tnk, owner: agent, position: [70, 34], stance: 1, count: 3}
122
+ # The OBJECTIVE: enemy construction yard.
123
+ - {type: fact, owner: enemy, position: [100, 20]}
124
+ # 3 GUARDS distributed around the objective (W / E / N faces)
125
+ # so every ingress bearing enters guard aggro.
126
+ - {type: e3, owner: enemy, position: [98, 18]}
127
+ - {type: e3, owner: enemy, position: [102, 22]}
128
+ - {type: e3, owner: enemy, position: [100, 17]}
129
+ # SENTINEL: persistent enemy fact in the far north so the
130
+ # engine does not auto-`done` the instant the objective fact
131
+ # dies; lets within_ticks / region predicates evaluate
132
+ # cleanly on the terminal frame.
133
+ - {type: fact, owner: enemy, position: [120, 4]}
134
+ win_condition:
135
+ all_of:
136
+ - {units_in_region_gte: {x: 100, y: 20, radius: 8, n: 6}}
137
+ - {enemy_key_buildings_destroyed: {types: [fact]}}
138
+ - {within_ticks: 4500}
139
+ fail_condition:
140
+ any_of:
141
+ - {after_ticks: 4501}
142
+ - {not: {own_units_gte: 1}}
143
+ max_turns: 54
144
+
145
+ # ── MEDIUM ───────────────────────────────────────────────────────
146
+ # +1 controlled variable: 4 guards (one per cardinal face of the
147
+ # objective) and a tighter trade. A single squad alone is destroyed.
148
+ # Two squads converging arrive piecewise — the closer squad enters
149
+ # the cluster first and is ground down by 4 anti-tank guards
150
+ # before the second squad arrives; the joint surviving in-region
151
+ # count never reaches n=6 (the trade typically destroys the fact
152
+ # but leaves 0 tanks in region). Three squads converging mass
153
+ # enough simultaneous DPS to overrun the cluster cleanly (≥6
154
+ # tanks survive in region while the cluster + fact fall).
155
+ medium:
156
+ description: >
157
+ THREE squads of medium tanks (3× 2tnk each, 9 tanks total) from
158
+ NORTH (~70,6), WEST (~66,20), SOUTH (~70,34) must converge on
159
+ the enemy construction yard (`fact` at ~100,20) defended by 4
160
+ leashed rocket-infantry guards (one on each cardinal face). A
161
+ single squad alone is killed by the guards; two squads
162
+ converging destroy the yard but trade out the whole strike
163
+ force (no tanks survive inside the objective region). All
164
+ three squads must arrive together: win when ≥6 of your 9 tanks
165
+ are inside an 8-cell radius of (100,20) AND the yard is
166
+ destroyed, before tick 4500. A stall loses on the clock.
167
+ overrides:
168
+ actors:
169
+ - {type: 2tnk, owner: agent, position: [70, 6], stance: 1, count: 3}
170
+ - {type: 2tnk, owner: agent, position: [66, 20], stance: 1, count: 3}
171
+ - {type: 2tnk, owner: agent, position: [70, 34], stance: 1, count: 3}
172
+ - {type: fact, owner: enemy, position: [100, 20]}
173
+ # 4 guards: one on each cardinal face of the objective.
174
+ - {type: e3, owner: enemy, position: [98, 20]}
175
+ - {type: e3, owner: enemy, position: [102, 20]}
176
+ - {type: e3, owner: enemy, position: [100, 17]}
177
+ - {type: e3, owner: enemy, position: [100, 23]}
178
+ - {type: fact, owner: enemy, position: [120, 4]}
179
+ win_condition:
180
+ all_of:
181
+ - {units_in_region_gte: {x: 100, y: 20, radius: 8, n: 6}}
182
+ - {enemy_key_buildings_destroyed: {types: [fact]}}
183
+ - {within_ticks: 4500}
184
+ fail_condition:
185
+ any_of:
186
+ - {after_ticks: 4501}
187
+ - {not: {own_units_gte: 1}}
188
+ max_turns: 54
189
+
190
+ # ── HARD ─────────────────────────────────────────────────────────
191
+ # +1 more controlled variable: TWO seed-driven agent spawn groups
192
+ # round-robined per seed. Group 0 places the lateral (third) squad
193
+ # WEST of the objective at (66,20); group 1 places it EAST of the
194
+ # objective at (134,20). The defender cluster stays symmetric (4
195
+ # guards, one on each cardinal face) so both spawns face the same
196
+ # convergent geometry but the lateral squad's ingress bearing
197
+ # flips per seed (W → E). A single memorised "3-prong from W/N/S"
198
+ # opening cannot generalise. NORTH and SOUTH squads are common to
199
+ # both groups (placed without spawn_point — but the documented
200
+ # filter rule in CLAUDE.md ("if ANY agent actor declares
201
+ # spawn_point, every agent actor WITHOUT spawn_point is filtered
202
+ # OUT") means N/S MUST be duplicated across spawn groups; both
203
+ # copies are placed at identical coords so the contract is met
204
+ # without changing the geometry per seed.
205
+ hard:
206
+ description: >
207
+ THREE squads of medium tanks (3× 2tnk each, 9 tanks total) must
208
+ converge on the enemy construction yard (`fact` at ~100,20)
209
+ defended by 4 leashed rocket-infantry guards (one on each
210
+ cardinal face). The squad layout flips per seed: one spawn
211
+ stages NORTH (~70,6) / WEST (~66,20) / SOUTH (~70,34); the
212
+ other stages NORTH (~74,8) / EAST (~126,20) / SOUTH (~74,32).
213
+ Both layouts are balanced equidistant (~26-34 cells per
214
+ squad) so a synchronised launch arrives together. A single
215
+ squad alone is
216
+ destroyed by the guards; two squads converging trade out the
217
+ strike force before satisfying the region threshold. Win when
218
+ ≥6 of your 9 tanks are inside an 8-cell radius of (100,20) AND
219
+ the yard is destroyed, before tick 4500.
220
+ overrides:
221
+ actors:
222
+ # spawn_point 0 — NORTH / WEST-of-objective / SOUTH.
223
+ - {type: 2tnk, owner: agent, position: [70, 6], stance: 1, count: 3, spawn_point: 0}
224
+ - {type: 2tnk, owner: agent, position: [66, 20], stance: 1, count: 3, spawn_point: 0}
225
+ - {type: 2tnk, owner: agent, position: [70, 34], stance: 1, count: 3, spawn_point: 0}
226
+ # spawn_point 1 — NORTH / EAST-of-objective / SOUTH.
227
+ # NORTH and SOUTH are duplicated at identical coords so the
228
+ # spawn-filter rule (CLAUDE.md) doesn't drop them from group 1;
229
+ # the WEST squad is relocated to EAST at (134,20) — equidistant
230
+ # from the objective on the opposite bearing, flipping the
231
+ # lateral ingress direction. The seed-chosen ingress geometry
232
+ # cannot be memorised: a fixed "drive west squad east" opening
233
+ # is wrong half the seeds.
234
+ # NORTH and SOUTH in group 1 are pulled in slightly to
235
+ # (74, 8) / (74, 32) so their distance to the objective (~29
236
+ # cells) closely matches the EAST squad's distance from
237
+ # (126, 20) (~26 cells) — a naive simultaneous launch from
238
+ # group 1 has all three squads arrive together (group 0
239
+ # already balanced at ~34 from each of W / N / S).
240
+ - {type: 2tnk, owner: agent, position: [74, 8], stance: 1, count: 3, spawn_point: 1}
241
+ - {type: 2tnk, owner: agent, position: [126, 20], stance: 1, count: 3, spawn_point: 1}
242
+ - {type: 2tnk, owner: agent, position: [74, 32], stance: 1, count: 3, spawn_point: 1}
243
+ # The OBJECTIVE.
244
+ - {type: fact, owner: enemy, position: [100, 20]}
245
+ # 4 guards: one on each cardinal face. Symmetric: equally
246
+ # covers both lateral ingress bearings (W and E).
247
+ - {type: e3, owner: enemy, position: [98, 20]}
248
+ - {type: e3, owner: enemy, position: [102, 20]}
249
+ - {type: e3, owner: enemy, position: [100, 17]}
250
+ - {type: e3, owner: enemy, position: [100, 23]}
251
+ # SENTINEL: persistent enemy fact in the far north so the
252
+ # engine does not auto-`done` on objective raze.
253
+ - {type: fact, owner: enemy, position: [120, 4]}
254
+ win_condition:
255
+ all_of:
256
+ - {units_in_region_gte: {x: 100, y: 20, radius: 8, n: 6}}
257
+ - {enemy_key_buildings_destroyed: {types: [fact]}}
258
+ - {within_ticks: 4500}
259
+ fail_condition:
260
+ any_of:
261
+ - {after_ticks: 4501}
262
+ - {not: {own_units_gte: 1}}
263
+ max_turns: 54
openra_bench/scenarios/packs/coord-cover-and-move.yaml ADDED
@@ -0,0 +1,330 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # coord-cover-and-move — bounding overwatch across a fire zone.
2
+ #
3
+ # ACTION focus: two squads must cross a centre-of-map fire zone defended
4
+ # by static anti-tank infantry. Charging both squads together stacks
5
+ # every cluster's fire on the SAME column — the e3 anti-tank rocket
6
+ # soldiers focus the dense formation and bust the attrition cap (≥2
7
+ # tanks lost). Sending one squad alone leaves the other idle and the
8
+ # moving squad eats ALL the cluster's fire (≥2 lost). Stalling burns
9
+ # the clock. The intended idiom is military FIRE-AND-MANEUVER bounding
10
+ # overwatch: one squad stops just outside the fire zone, anchors to
11
+ # DRAW fire, and engages the cluster from the closer flank while the
12
+ # other squad sweeps WIDE through the unoccupied periphery (y outside
13
+ # the cluster's ~5-cell sight). The cluster's attention splits between
14
+ # the firing "cover" team and the (invisible / out-of-range) "move"
15
+ # team; the move team crosses safely. Then the roles alternate so the
16
+ # cover team can also cross. Each squad takes at most 1 loss because
17
+ # enemy fire is divided per phase, not stacked on a single column.
18
+ #
19
+ # Real-world anchor: US Army FM 3-21.8 bounding overwatch — one fire
20
+ # team in a stationary OVERWATCH posture provides suppressive fire
21
+ # while the BOUNDING fire team moves forward by short rushes through
22
+ # the danger zone; roles alternate ("leapfrog") as the bounding team
23
+ # reaches its next covered position and assumes overwatch for the
24
+ # previously-overwatching team. SC2 tank micro encodes the same
25
+ # pattern as the siege-tank "leapfrog" advance.
26
+ #
27
+ # Discrimination (the four script-policy bar):
28
+ # • stall (observe-only): no squad crosses → win region empty →
29
+ # after_ticks LOSS.
30
+ # • both-charge-together: all 6 tanks rush a single eastward column
31
+ # through the fire zone; 4× e3 stance:3 (medium) auto-fire on the
32
+ # densest cluster and bust units_lost_lte:1 → LOSS.
33
+ # • single-squad-only: send Squad A through alone (Squad B idle west);
34
+ # A absorbs ALL the cluster's fire across its crossing and loses
35
+ # ≥2 → busts units_lost_lte:1 → LOSS.
36
+ # • intended bound-and-cover: A anchors at (45,15) firing on the
37
+ # cluster (drawing fire from the closer flank); B swings WIDE
38
+ # through y=8..10 (outside e3 sight 5 from the cluster centre at
39
+ # y=20) to (90,15); then B opens fire from the east edge of the
40
+ # cluster while A relocates north and crosses through y=8..10.
41
+ # Cluster fire is split per phase; each squad takes ≤1 loss; ≥4
42
+ # tanks land in the win region within the deadline → WIN.
43
+ #
44
+ # Why the spec works (engine combat sheet):
45
+ # • e3 = anti-tank rocket soldier (rng4c, dps12, sight5c, hp45).
46
+ # Effective vs 2tnk armour. 4× e3 firing on a single dense column
47
+ # stacks ~48 dps onto whichever tank is nearest; a column charging
48
+ # through the cluster spends ~3-4 decision turns in range and the
49
+ # stacked dps cleans off ≥2 tanks (busts units_lost_lte:1).
50
+ # • 2tnk = medium tank (rng4.75c, dps22, sight6c, hp400). Marginally
51
+ # out-ranges e3 (4.75 vs 4), so a tank that STOPS just at the edge
52
+ # of e3 range (~dist 5 from cluster) can fire back without being
53
+ # in e3 range itself — the OVERWATCH posture. Its dps22 plus the
54
+ # other overwatch tank chips down the cluster across the bound,
55
+ # reducing fire on the second-phase bounding team.
56
+ # • Sight-line geometry: with the cluster at y=19..21 (sight 5), e3s
57
+ # see roughly y=14..26. A squad routing through y=8..10 is OUTSIDE
58
+ # enemy sight and OUTSIDE enemy range — it takes ZERO fire while
59
+ # moving, even though its straight-line distance is ~10 cells.
60
+ # That's the "bounding lane" geometry — outside the fire envelope.
61
+ #
62
+ # Engine guardrails (per CLAUDE.md):
63
+ # • Map: rush-hour-arena (128 × 40 playable; x in [2..126], y in
64
+ # [2..38]). All actor / region cells are inside.
65
+ # • CLAUDE.md footgun: enemy clusters at (50,20) silently FAIL to
66
+ # place. Cluster cells are (49,19), (49,21), (51,19), (51,21) —
67
+ # verified-placeable neighbours used by other packs.
68
+ # • A persistent far enemy `fact` at (120,20) prevents engine auto-
69
+ # done when the central cluster is cleaned out (so the win/fail
70
+ # evaluator sees the terminal frame instead of a DRAW collapse).
71
+ # • after_ticks fail clauses are reachable within max_turns
72
+ # (tick ≤ 93 + 90·(max_turns-1)); within_ticks 4500 < 4503 at
73
+ # max_turns 50; staller hits after_ticks 4501 and LOSES, not draws.
74
+
75
+ meta:
76
+ id: coord-cover-and-move
77
+ title: 'Bounding Overwatch — One Squad Covers While the Other Moves'
78
+ capability: action
79
+ real_world_meaning: >
80
+ Two armoured squads must cross a centre-of-map FIRE ZONE held by
81
+ static anti-tank infantry. Charging both squads through together
82
+ stacks every enemy's fire onto a single dense column and busts the
83
+ attrition cap. Sending one squad alone leaves the other idle and
84
+ the lone column still absorbs ALL the cluster's fire. The intended
85
+ capability is bounding overwatch: one squad stops just OUTSIDE the
86
+ cluster's range and acts as the COVER team — firing on the cluster
87
+ to draw its attention from the closer flank — while the BOUNDING
88
+ team sweeps WIDE through the periphery (outside enemy sight) to a
89
+ forward position; then the roles alternate so the cover team can
90
+ also cross safely. The cluster's fire is split per phase rather
91
+ than stacked on one column, so each squad takes at most one loss.
92
+ robotics_analogue: >
93
+ Multi-agent traversal of a contested corridor where defenders have
94
+ a fixed effective range envelope and concentrate fire on whichever
95
+ cluster is closest. Single-team traversal eats the full defensive
96
+ fire envelope; the joint policy alternates an OVERWATCH role
97
+ (suppressive fire drawing defender attention from a flank) with a
98
+ BOUNDING role (sweep through the cleared periphery). The
99
+ coordination is the role-alternation — leapfrogging cover and
100
+ move — not just splitting the force.
101
+ benchmark_anchor:
102
+ - "US Army FM 3-21.8 bounding overwatch (fire-and-maneuver doctrine)"
103
+ - "SC2 siege-tank leapfrog advance under enemy fire"
104
+ - "SMAC coordinated cross of a danger zone with split defender fire"
105
+ - "MicroRTS multi-squad alternating cover-and-move"
106
+ author: "openra-bench"
107
+
108
+ base_map: rush-hour-arena
109
+
110
+ base:
111
+ agent: {faction: allies, cash: 0}
112
+ enemy: {faction: soviet, cash: 0, bot_type: ''}
113
+ tools: [move_units, attack_unit, attack_move, stop]
114
+ planning: true
115
+ # React the instant the moving squad takes a hit or first contact is
116
+ # made — bounding overwatch is REACTIVE micro (the cover team must
117
+ # re-target as bounding-team contact opens up). Without these
118
+ # interrupts the role-alternation only re-evaluates on fixed turn
119
+ # boundaries and the e3 cluster gets free shots through every gap.
120
+ interrupts:
121
+ enemy_unit_spotted: true
122
+ own_unit_destroyed: true
123
+ termination: {max_ticks: 6000}
124
+ actors: [] # every level supplies its own actor list via overrides.
125
+
126
+ levels:
127
+ # ── EASY ─────────────────────────────────────────────────────────
128
+ # Bare bound-and-cover skill: 4× e1 rifle infantry at centre (NOT
129
+ # the anti-tank e3 — easy uses the weaker rifle, low DPS vs armour
130
+ # so the cap is forgiving). Both-charge-together may squeak by on
131
+ # luck (e1 vs 2tnk armour is poor); stall and single-squad both
132
+ # still LOSE via the timeout / kill geometry.
133
+ easy:
134
+ description: >
135
+ Two squads (Squad A at (10,15), Squad B at (10,25), 3 medium
136
+ tanks each) must cross a centre-of-map fire zone defended by
137
+ static rifle infantry (e1) at roughly (50,20), then land ≥4
138
+ tanks in the EAST gathering region (x ≈ 100, y ≈ 20, radius 6)
139
+ before tick 4500, losing ≤1 tank in total. The rifle infantry
140
+ do limited damage to armour on easy, but charging both squads
141
+ together still stacks their fire onto the densest cluster and
142
+ a single column alone absorbs every shot — alternate ROLES:
143
+ one squad anchors at the edge of enemy range to draw fire
144
+ while the other sweeps through the periphery (y outside enemy
145
+ sight) and crosses; then switch.
146
+ overrides:
147
+ actors:
148
+ # Squad A — northern fire team, staged at (10, 15).
149
+ - {type: 2tnk, owner: agent, position: [10, 14], stance: 1}
150
+ - {type: 2tnk, owner: agent, position: [10, 15], stance: 1}
151
+ - {type: 2tnk, owner: agent, position: [10, 16], stance: 1}
152
+ # Squad B — southern fire team, staged at (10, 25).
153
+ - {type: 2tnk, owner: agent, position: [10, 24], stance: 1}
154
+ - {type: 2tnk, owner: agent, position: [10, 25], stance: 1}
155
+ - {type: 2tnk, owner: agent, position: [10, 26], stance: 1}
156
+ # Centre fire zone: 4× rifle infantry stance:3 (AttackAnything
157
+ # in range). Cells avoid (50,20) — CLAUDE.md footgun: enemy
158
+ # clusters at (50,20) silently fail to place. Off-axis
159
+ # neighbours (49/51 × 19/21) are verified-placeable.
160
+ - {type: e1, owner: enemy, position: [49, 19], stance: 3}
161
+ - {type: e1, owner: enemy, position: [49, 21], stance: 3}
162
+ - {type: e1, owner: enemy, position: [51, 19], stance: 3}
163
+ - {type: e1, owner: enemy, position: [51, 21], stance: 3}
164
+ # Persistent far enemy `fact` — keeps the episode alive past
165
+ # the central cluster being cleaned out so the win/fail
166
+ # evaluator sees the terminal frame (no DRAW collapse).
167
+ - {type: fact, owner: enemy, position: [120, 20]}
168
+ win_condition:
169
+ all_of:
170
+ - {units_in_region_gte: {x: 100, y: 20, radius: 6, n: 4}}
171
+ - {units_lost_lte: 1}
172
+ - {within_ticks: 4500}
173
+ fail_condition:
174
+ any_of:
175
+ - {after_ticks: 4501}
176
+ - {not: {units_lost_lte: 1}}
177
+ - {not: {own_units_gte: 1}}
178
+ max_turns: 50
179
+
180
+ # ── MEDIUM ──────────��────────────────────────────────────────────
181
+ # +1 axis: ANTI-TANK rocket soldiers (e3) replace rifle infantry,
182
+ # so the fire zone is genuinely lethal to 2tnk armour. Both-charge-
183
+ # together LOSES (4× e3 stacked dps onto a dense column busts
184
+ # units_lost_lte:1); single-squad alone LOSES (lone column absorbs
185
+ # every shot). Only true bound-and-cover (squads alternate roles
186
+ # so cluster fire is SPLIT per phase) keeps each squad to ≤1 loss.
187
+ medium:
188
+ description: >
189
+ Two squads (Squad A at (10,15), Squad B at (10,25), 3 medium
190
+ tanks each) must cross a centre-of-map FIRE ZONE defended by
191
+ four static anti-tank rocket soldiers (e3) at roughly (50,20),
192
+ then land ≥4 tanks in the EAST gathering region (x ≈ 100,
193
+ y ≈ 20, radius 6) before tick 4500, losing ≤1 tank in total.
194
+ Charging BOTH squads through the centre stacks all four e3s'
195
+ anti-armour fire on a single dense column and busts the cap.
196
+ Sending one squad alone leaves the other idle and the lone
197
+ column absorbs every shot. The intended idiom is BOUNDING
198
+ OVERWATCH: one squad anchors at the EDGE of enemy range
199
+ (around x ≈ 45, y ≈ 15 — close enough to fire back, far
200
+ enough to suppress) drawing the cluster's attention from the
201
+ closer flank, while the other squad sweeps WIDE through the
202
+ periphery (around y ≈ 8..10, OUTSIDE the cluster's sight
203
+ range) to the east. Then the roles ALTERNATE — the team that
204
+ moved opens fire from the east edge of the cluster while the
205
+ original cover team relocates north and crosses through the
206
+ same outside-sight band. Each squad takes ≤1 loss because
207
+ cluster fire is SPLIT per phase, not stacked.
208
+ overrides:
209
+ actors:
210
+ - {type: 2tnk, owner: agent, position: [10, 14], stance: 1}
211
+ - {type: 2tnk, owner: agent, position: [10, 15], stance: 1}
212
+ - {type: 2tnk, owner: agent, position: [10, 16], stance: 1}
213
+ - {type: 2tnk, owner: agent, position: [10, 24], stance: 1}
214
+ - {type: 2tnk, owner: agent, position: [10, 25], stance: 1}
215
+ - {type: 2tnk, owner: agent, position: [10, 26], stance: 1}
216
+ # Centre fire zone: 4× anti-tank rocket soldiers stance:3.
217
+ - {type: e3, owner: enemy, position: [49, 19], stance: 3}
218
+ - {type: e3, owner: enemy, position: [49, 21], stance: 3}
219
+ - {type: e3, owner: enemy, position: [51, 19], stance: 3}
220
+ - {type: e3, owner: enemy, position: [51, 21], stance: 3}
221
+ - {type: fact, owner: enemy, position: [120, 20]}
222
+ win_condition:
223
+ all_of:
224
+ - {units_in_region_gte: {x: 100, y: 20, radius: 6, n: 4}}
225
+ - {units_lost_lte: 1}
226
+ - {within_ticks: 4500}
227
+ fail_condition:
228
+ any_of:
229
+ - {after_ticks: 4501}
230
+ - {not: {units_lost_lte: 1}}
231
+ - {not: {own_units_gte: 1}}
232
+ max_turns: 50
233
+
234
+ # ── HARD ─────────────────────────────────────────────────────────
235
+ # +2 axes vs medium:
236
+ # 1. ZERO attrition cap (units_lost_lte: 0) — any tank loss
237
+ # fails. The cover team's overwatch fire must SUPPRESS the
238
+ # cluster enough that the bounding team takes no hits at all
239
+ # (the wide-flank route via y=8..10 already keeps the bounding
240
+ # team out of e3 sight — the zero-loss bar makes the geometry
241
+ # load-bearing instead of a margin-of-error luxury).
242
+ # 2. An additional e1 RIFLE-INFANTRY swarm extends the fire
243
+ # envelope (3× e1 north of the cluster + 3× e1 south),
244
+ # tightening the periphery-route corridor: a squad that drifts
245
+ # to y=12 or y=28 brushes the rifle screens. The bounding lane
246
+ # is narrower — bound through y=6..9 or y=31..34, not y=12+ or
247
+ # y=27- — and the cover team must keep the rocket cluster
248
+ # ITSELF suppressed (overwatch on the e3, not the e1s, since
249
+ # the e3 is the priority anti-armour threat).
250
+ # 3. Strike force stages from TWO seed-driven spawn_point groups
251
+ # (NORTH-WEST y=12..16 OR SOUTH-WEST y=24..28), round-robined
252
+ # by seed. The cluster and the win region are symmetric across
253
+ # y=20 so EITHER spawn faces an equivalent bounding decision
254
+ # (cover from the closer flank, bound around the FAR
255
+ # periphery), but a single memorised bound vector cannot
256
+ # generalise across seeds.
257
+ hard:
258
+ description: >
259
+ Two squads (3 medium tanks each, staged at the NW or SW
260
+ corner, chosen by seed for anti-memorisation) must cross a
261
+ centre-of-map FIRE ZONE — four anti-tank rocket soldiers (e3)
262
+ at roughly (50,20), flanked by two rifle-infantry SCREENS that
263
+ extend the fire envelope north and south — and land ≥4 tanks
264
+ in the EAST gathering region (x ≈ 100, y ≈ 20, radius 6)
265
+ before tick 4500, losing ZERO tanks in total. The intended
266
+ idiom is BOUNDING OVERWATCH with the wide-flank route
267
+ tightened by the rifle screens: the cover team anchors at the
268
+ edge of e3 range (within tank range, OUT of e3 range) and
269
+ suppresses the rocket cluster (the priority anti-armour
270
+ threat); the bounding team sweeps through the FAR periphery
271
+ (around y ≈ 6..9 from the NORTH spawn, or y ≈ 31..34 from the
272
+ SOUTH spawn — outside both the rocket-cluster sight envelope
273
+ AND the rifle-screen weapon range) to the east. Then the
274
+ roles alternate. Zero loss is only achievable when the cover
275
+ team's overwatch fire keeps the cluster suppressed during the
276
+ bounding team's transit.
277
+ overrides:
278
+ actors:
279
+ # spawn_point 0 — NORTH-WEST staging (Squad A at (10,12)
280
+ # north, Squad B at (10,16) south of A but still north of
281
+ # centre — they bound through the far NORTH periphery
282
+ # y ≈ 6..9 outside both rifle-screen and rocket sight).
283
+ - {type: 2tnk, owner: agent, position: [10, 11], stance: 1, spawn_point: 0}
284
+ - {type: 2tnk, owner: agent, position: [10, 12], stance: 1, spawn_point: 0}
285
+ - {type: 2tnk, owner: agent, position: [10, 13], stance: 1, spawn_point: 0}
286
+ - {type: 2tnk, owner: agent, position: [10, 15], stance: 1, spawn_point: 0}
287
+ - {type: 2tnk, owner: agent, position: [10, 16], stance: 1, spawn_point: 0}
288
+ - {type: 2tnk, owner: agent, position: [10, 17], stance: 1, spawn_point: 0}
289
+ # spawn_point 1 — SOUTH-WEST staging (mirrored across y=20).
290
+ # The cover/bound decision is symmetric: cover at south
291
+ # edge of e3 range, bound through y ≈ 31..34.
292
+ - {type: 2tnk, owner: agent, position: [10, 23], stance: 1, spawn_point: 1}
293
+ - {type: 2tnk, owner: agent, position: [10, 24], stance: 1, spawn_point: 1}
294
+ - {type: 2tnk, owner: agent, position: [10, 25], stance: 1, spawn_point: 1}
295
+ - {type: 2tnk, owner: agent, position: [10, 27], stance: 1, spawn_point: 1}
296
+ - {type: 2tnk, owner: agent, position: [10, 28], stance: 1, spawn_point: 1}
297
+ - {type: 2tnk, owner: agent, position: [10, 29], stance: 1, spawn_point: 1}
298
+ # Centre rocket cluster — same 4× e3 as medium, symmetric
299
+ # across y=20 so EITHER spawn faces an equivalent geometry.
300
+ - {type: e3, owner: enemy, position: [49, 19], stance: 3}
301
+ - {type: e3, owner: enemy, position: [49, 21], stance: 3}
302
+ - {type: e3, owner: enemy, position: [51, 19], stance: 3}
303
+ - {type: e3, owner: enemy, position: [51, 21], stance: 3}
304
+ # Rifle-infantry SCREENS — extend the fire envelope north
305
+ # and south of the rocket cluster, tightening the bounding
306
+ # lane. y=14 / y=26 are within e3 cluster's sight (5c) so
307
+ # the screens reinforce the same envelope — a squad
308
+ # bounding through y=12 or y=28 would be in screen range
309
+ # (e1 rng4c sees out to ~y=10 from y=14). The intended
310
+ # bounding lane is OUTSIDE that envelope: y ≈ 6..9 (NORTH)
311
+ # or y ≈ 31..34 (SOUTH).
312
+ - {type: e1, owner: enemy, position: [49, 14], stance: 3}
313
+ - {type: e1, owner: enemy, position: [51, 14], stance: 3}
314
+ - {type: e1, owner: enemy, position: [50, 13], stance: 3}
315
+ - {type: e1, owner: enemy, position: [49, 26], stance: 3}
316
+ - {type: e1, owner: enemy, position: [51, 26], stance: 3}
317
+ - {type: e1, owner: enemy, position: [50, 27], stance: 3}
318
+ # Persistent far enemy fact — engine auto-done mitigation.
319
+ - {type: fact, owner: enemy, position: [120, 20]}
320
+ win_condition:
321
+ all_of:
322
+ - {units_in_region_gte: {x: 100, y: 20, radius: 6, n: 4}}
323
+ - {units_lost_lte: 0}
324
+ - {within_ticks: 4500}
325
+ fail_condition:
326
+ any_of:
327
+ - {after_ticks: 4501}
328
+ - {not: {units_lost_lte: 0}}
329
+ - {not: {own_units_gte: 1}}
330
+ max_turns: 50
openra_bench/scenarios/packs/coord-diversionary-attack.yaml ADDED
@@ -0,0 +1,336 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # coord-diversionary-attack.yaml
2
+ #
3
+ # REASONING capability — split-attack / diversionary assault. Two
4
+ # squads start on the agent's WEST edge (A=jeeps at NW, B=2tnks at
5
+ # SW); the enemy holds TWO key buildings on the EAST: a real target
6
+ # `fact` with LIGHT defence at (100,10), and a decoy `powr` with
7
+ # HEAVIER defence at (100,30). The enemy is the scripted `hunt` bot:
8
+ # each enemy unit pursues the nearest agent unit. The win predicate
9
+ # is `enemy_key_buildings_destroyed_in_region:{x:100, y:10, r:6,
10
+ # types:[fact]}` — ONLY destroying the REAL fact at (100,10) scores;
11
+ # raising the decoy `powr` does not.
12
+ #
13
+ # The intended idiom is the SC2 split-attack / military diversionary
14
+ # assault doctrine: drive Squad A (fast, cheap, disposable jeeps)
15
+ # south-east into the powr-defence aggro radius — the hunt bot's
16
+ # heavier south defenders pursue the nearest agent units (the jeeps)
17
+ # south and DISENGAGE from the fact line; meanwhile drive Squad B
18
+ # (the main strike) north-east into the now-thin fact perimeter and
19
+ # raze the real target before its surviving light garrison can
20
+ # trade out the column.
21
+ #
22
+ # Distinct from the obvious "send each squad to its nearest target"
23
+ # play: A is at NW (closer to the real fact in row-10) and B is at
24
+ # SW (closer to the decoy powr in row-30). The lazy assignment
25
+ # (jeeps → fact, tanks → powr) attacks the WRONG target and
26
+ # under-strikes the right one: jeeps can't crack the fact's light
27
+ # garrison fast enough, the tanks raze the decoy powr (which doesn't
28
+ # count), and the clock expires. The intended play is the SWAP:
29
+ # jeeps cross to the SOUTH to bait, tanks cross to the NORTH to
30
+ # strike — neither squad attacks its nearest visible target.
31
+ #
32
+ # Distinct from `combat-bait-counter-attack` (a SINGLE strike squad
33
+ # with a SINGLE bait against a leashed `guard` cluster on one
34
+ # objective): here there are TWO objectives (one real, one decoy)
35
+ # with a PURSUING `hunt` enemy, the win predicate is REGION-scoped
36
+ # to the real target alone (so killing the decoy doesn't score),
37
+ # and the coordination is between two same-side multi-unit squads
38
+ # (split-attack), not a bait/strike micro pair.
39
+ #
40
+ # Distinct from `strategy-twobody` (parallel control of two squads,
41
+ # each razing its OWN base): there the win counts BOTH bases; here
42
+ # only ONE base counts and the other exists to ABSORB the enemy
43
+ # pursuit.
44
+ #
45
+ # Engine notes (per CLAUDE.md):
46
+ # - `fact` and `powr` are MustBeDestroyed. A SENTINEL enemy `fact`
47
+ # is placed far south-east (out of the combat zone, agent can't
48
+ # reach it in budget) so the engine doesn't auto-`done` the
49
+ # instant the objective fact dies — letting within_ticks /
50
+ # units_lost_lte evaluate cleanly on the terminal frame even
51
+ # if the bait squad ALSO razes the decoy powr.
52
+ # - `hunt` bot: each enemy unit attacks the nearest AGENT unit
53
+ # and pursues. The bait squad (jeeps) must come within visual
54
+ # range of the south defenders for them to commit; once they
55
+ # pursue jeeps, the fact's garrison (in the north) keeps its
56
+ # own nearest-agent target = the tank column if B is already
57
+ # committed, but the south defenders won't snap back since they
58
+ # remain locked on the jeep targets they're pursuing.
59
+ # - `move_units` auto-fires opportunistically: B's column en route
60
+ # to (100,10) will engage any south defender that happens to
61
+ # stray into range, so A's bait must keep the south defenders
62
+ # committed AWAY from B's route. Bait staying in the south-east
63
+ # quadrant (around (100,30)) is far enough from B's north-line
64
+ # y≈10 corridor.
65
+
66
+ meta:
67
+ id: coord-diversionary-attack
68
+ title: 'Diversionary Attack — A Diverts South, B Razes the Real Target North'
69
+ capability: reasoning
70
+ real_world_meaning: >
71
+ Two squads command a split-attack against an enemy that holds
72
+ TWO key buildings: a REAL target (construction yard) with light
73
+ defence and a DECOY (power plant) with heavier defence. The
74
+ scripted `hunt` enemy pursues the nearest agent unit. Driving
75
+ each squad onto its nearest visible target is a trap: the
76
+ closer-but-weaker squad (jeeps) can't crack the real target's
77
+ garrison fast enough, the closer-stronger squad (tanks) razes
78
+ the decoy (which scores zero), the clock expires. The winning
79
+ policy SWAPS the assignment: commit the fast disposable jeeps
80
+ south-east into the decoy's heavier defence to bait its
81
+ pursuers OFF the fact line; commit the tanks north-east through
82
+ the now-thin fact perimeter to raze the construction yard
83
+ before the surviving light garrison can trade out the column.
84
+ Phase 1 (the diversion) yields zero objective credit — only
85
+ phase 2, after the south defenders have committed south, scores.
86
+ The credit problem is DIVERSIONARY ASSAULT: a no-reward enabling
87
+ action (drag the heaviest defence away from the real target
88
+ with a disposable lure squad) must precede and temporally
89
+ overlap the rewarded objective action (commit the main strike
90
+ on the briefly-undefended real target).
91
+ robotics_analogue: >
92
+ Concurrent multi-robot dispatch with deception: two independent
93
+ teams must coordinate against a reactive adversary, where one
94
+ team takes a NO-REWARD enabling role (drawing the adversary's
95
+ attention to a decoy target on the OPPOSITE flank) while the
96
+ other team — counter-intuitively assigned to the FARTHER
97
+ objective — completes the rewarded task. The lazy nearest-task
98
+ assignment is degenerate: every team must commit ACROSS the map
99
+ to the objective its sibling enabled.
100
+ benchmark_anchor:
101
+ - "SC2 multi-prong / split-attack assault"
102
+ - "military diversionary tactics (Sun Tzu loud feint + quiet main thrust)"
103
+ - "CICERO / Diplomacy deception"
104
+ - "advertising: loud feint draws competitor attention while quiet launch hits real market"
105
+ author: openra-bench
106
+
107
+ base_map: rush-hour-arena
108
+
109
+ base:
110
+ agent: {faction: allies, cash: 0}
111
+ enemy: {faction: soviet, cash: 0, bot_type: hunt}
112
+ tools: [move_units, attack_unit, attack_move, stop]
113
+ planning: true
114
+ termination: {max_ticks: 12000}
115
+
116
+ levels:
117
+ # ── EASY ─────────────────────────────────────────────────────────
118
+ # The bare diversionary-attack skill. Light defence around the
119
+ # REAL fact at (100,10): 2 e1 riflemen (not enough to stop B's
120
+ # 4-tank column once it arrives). Heavier defence around the DECOY
121
+ # powr at (100,30): 3 e3 rocket infantry (anti-tank Dragons that
122
+ # WOULD shred a tank column if it went south). Generous loss cap
123
+ # (3). Brute frontal — both squads on the decoy: razes powr (which
124
+ # doesn't count), clock expires, fact still standing → LOSS. Lazy
125
+ # nearest assignment — jeeps north on fact, tanks south on powr:
126
+ # jeeps trade out trying to crack fact's 2 e1 garrison (too slow,
127
+ # 2 e1 + travel time eats the clock), tanks raze the wrong target
128
+ # → LOSS. Stall — clock runs out → LOSS. Intended: jeeps drive
129
+ # south-east to (100,30) area to bait the 3 e3 + the hunt-pursued
130
+ # south picket; tanks drive north-east to (100,10) and crack the
131
+ # 2 e1 garrison + raze the fact while it's lightly defended → WIN.
132
+ easy:
133
+ description: >
134
+ Two enemy key buildings on the east side: a REAL target
135
+ (construction yard `fact` at 100,10) with LIGHT defence (2
136
+ enemy riflemen), and a DECOY (power plant `powr` at 100,30)
137
+ with HEAVIER defence (3 enemy rocket infantry). Razing the
138
+ DECOY does NOT count — only destroying the REAL fact at
139
+ (100,10) scores. The enemy `hunt` bot pursues whichever
140
+ agent units come into range. Your forces start on the west:
141
+ Squad A (3 jeeps, fast & disposable) at NW (6,10); Squad B
142
+ (4 medium tanks, the main strike) at SW (6,30). The lazy
143
+ assignment (jeeps on the close-looking fact, tanks on the
144
+ close-looking powr) loses: the jeeps can't crack the fact's
145
+ garrison fast enough AND the tanks raze the wrong target.
146
+ Commit Squad A south-east toward the powr cluster — the
147
+ heavy rocket-infantry defence pursues your jeeps; commit
148
+ Squad B north-east through the now-thin fact perimeter and
149
+ raze the construction yard before tick 5400. Lose at most
150
+ 3 units (the bait jeeps are expendable; the tank column is
151
+ not).
152
+ overrides:
153
+ actors:
154
+ # ── AGENT — Squad A (NW, jeeps) + Squad B (SW, 2tnks) ──
155
+ - {type: jeep, owner: agent, position: [6, 10], stance: 1, count: 3}
156
+ - {type: 2tnk, owner: agent, position: [6, 30], stance: 1, count: 4}
157
+ # ── ENEMY ──
158
+ # REAL target: construction yard at (100,10), LIGHT garrison.
159
+ - {type: fact, owner: enemy, position: [100, 10]}
160
+ - {type: e1, owner: enemy, position: [96, 10], stance: 2}
161
+ - {type: e1, owner: enemy, position: [100, 14], stance: 2}
162
+ # DECOY target: power plant at (100,30), HEAVIER garrison
163
+ # (anti-tank rocket infantry — these would shred B if it
164
+ # came south, so B must NOT go to the closer decoy).
165
+ - {type: powr, owner: enemy, position: [100, 30]}
166
+ - {type: e3, owner: enemy, position: [96, 30], stance: 2}
167
+ - {type: e3, owner: enemy, position: [100, 26], stance: 2}
168
+ - {type: e3, owner: enemy, position: [100, 34], stance: 2}
169
+ # SENTINEL: persistent enemy fact far south-east (out of
170
+ # combat zone, unreachable in budget) so the episode
171
+ # doesn't auto-`done` the instant the objective fact dies;
172
+ # within_ticks / units_lost_lte then evaluate cleanly.
173
+ - {type: fact, owner: enemy, position: [125, 38]}
174
+ win_condition:
175
+ all_of:
176
+ - enemy_key_buildings_destroyed_in_region:
177
+ {x: 100, y: 10, radius: 6, types: [fact]}
178
+ - within_ticks: 5400
179
+ - units_lost_lte: 3
180
+ fail_condition:
181
+ any_of:
182
+ - after_ticks: 5401
183
+ - not: {units_lost_lte: 3}
184
+ - not: {own_units_gte: 1}
185
+ max_turns: 62
186
+
187
+ # ── MEDIUM ───────────────────────────────────────────────────────
188
+ # +1 controlled variable: the decoy garrison thickens (3 → 5 e3 +
189
+ # an e1 north outpost between B's lane and the fact) so a brute
190
+ # all-on-powr or all-on-fact pile-up is decisively fatal; the
191
+ # WORKING bait must keep ALL the south defenders pursuing OR the
192
+ # north outpost will trade B's column. Loss cap tightens to 3
193
+ # (still room for the 3 bait jeeps but no tank may be spent).
194
+ medium:
195
+ description: >
196
+ Same two enemy key buildings (REAL `fact` at 100,10, DECOY
197
+ `powr` at 100,30), with a denser south defence (5 enemy
198
+ rocket infantry around the powr) AND a north outpost (1
199
+ rocket infantryman between your west base and the fact
200
+ lane). Only destroying the REAL fact at (100,10) scores;
201
+ razing the powr does not. Squad A (3 jeeps at NW) +
202
+ Squad B (4 tanks at SW). Brute frontal on either target
203
+ loses (south defence shreds tanks; north outpost + fact
204
+ garrison shreds jeeps). The lazy nearest-target swap also
205
+ loses (jeeps too weak to crack fact under tighter clock,
206
+ tanks waste fire on the decoy). Commit A south-east to
207
+ pull all 5 south rocket infantry off-post in pursuit;
208
+ commit B north-east, clear the north outpost in passing
209
+ and raze the fact before tick 5400. You may lose at most
210
+ 3 units (the 3 bait jeeps are expendable; losing any tank
211
+ fails the run).
212
+ overrides:
213
+ actors:
214
+ - {type: jeep, owner: agent, position: [6, 10], stance: 1, count: 3}
215
+ - {type: 2tnk, owner: agent, position: [6, 30], stance: 1, count: 4}
216
+ # REAL target — light garrison + a north outpost on B's lane.
217
+ - {type: fact, owner: enemy, position: [100, 10]}
218
+ - {type: e1, owner: enemy, position: [96, 10], stance: 2}
219
+ - {type: e1, owner: enemy, position: [100, 14], stance: 2}
220
+ # North outpost: between B's west base and the fact (B's
221
+ # lane y≈10..14). One e3 — manageable for B but a non-
222
+ # trivial speed bump.
223
+ - {type: e3, owner: enemy, position: [60, 10], stance: 2}
224
+ # DECOY target — heavier (5 e3) rocket-infantry garrison.
225
+ - {type: powr, owner: enemy, position: [100, 30]}
226
+ - {type: e3, owner: enemy, position: [96, 30], stance: 2}
227
+ - {type: e3, owner: enemy, position: [100, 26], stance: 2}
228
+ - {type: e3, owner: enemy, position: [100, 34], stance: 2}
229
+ - {type: e3, owner: enemy, position: [104, 28], stance: 2}
230
+ - {type: e3, owner: enemy, position: [104, 32], stance: 2}
231
+ - {type: fact, owner: enemy, position: [125, 38]}
232
+ win_condition:
233
+ all_of:
234
+ - enemy_key_buildings_destroyed_in_region:
235
+ {x: 100, y: 10, radius: 6, types: [fact]}
236
+ - within_ticks: 5400
237
+ - units_lost_lte: 3
238
+ fail_condition:
239
+ any_of:
240
+ - after_ticks: 5401
241
+ - not: {units_lost_lte: 3}
242
+ - not: {own_units_gte: 1}
243
+ max_turns: 62
244
+
245
+ # ── HARD ─────────────────────────────────────────────────────────
246
+ # +1 more controlled variable: TWO seed-driven agent spawn_point
247
+ # groups round-robin the staging LATITUDE, AND there are TWO
248
+ # candidate REAL targets (a `fact` at NE (100,10) and a `fact` at
249
+ # SE (100,30)) — the win clause accepts destruction of EITHER
250
+ # candidate. Between them sits a HEAVY CENTRE DECOY: a `powr` at
251
+ # (100,20) garrisoned by a dense e3 anti-tank cluster. The hunt
252
+ # bot pursues nearest agent units. Per CLAUDE.md, enemy actors
253
+ # are NOT spawn_point filtered — the layout is symmetric across
254
+ # y=20 so the test is the SAME diversionary discipline regardless
255
+ # of which spawn the seed picks: A as bait toward the CENTRE
256
+ # decoy (pulling the e3 cluster into pursuit), B as the strike
257
+ # along the OPPOSITE-latitude corridor onto its candidate fact.
258
+ # The model must SCOUT (observe both candidates and the centre
259
+ # cluster) to identify the working bait/strike pair for its
260
+ # spawn — a memorised "always go NE" plan eats the centre
261
+ # cluster on the strike approach. Strict attrition cap of 2:
262
+ # only the bait jeeps may be spent; losing any tank fails.
263
+ hard:
264
+ description: >
265
+ Two candidate real targets on the east: a construction yard
266
+ (`fact`) at NE (100,10) AND a construction yard at SE
267
+ (100,30). Destroying EITHER counts. Between them sits the
268
+ DECOY: a power plant (`powr`) at the CENTRE (100,20)
269
+ garrisoned by a dense cluster of enemy anti-tank rocket
270
+ infantry that will pursue whichever agent unit is nearest.
271
+ The enemy `hunt` bot pursues nearest agent units.
272
+ Squad A (3 jeeps) + Squad B (4 tanks) start on the west,
273
+ staged either NORTH (y≈10..14) or SOUTH (y≈26..30) — the
274
+ seed picks. Razing the centre powr does NOT score; only a
275
+ real fact at NE or SE counts. You may lose at most 2 units
276
+ (the 3 bait jeeps are expendable; losing any tank fails).
277
+ Commit Squad A on a SLASH toward the CENTRE decoy to draw
278
+ the heavy rocket-infantry cluster into pursuit; commit
279
+ Squad B along the OPPOSITE-LATITUDE corridor from A onto
280
+ the matching candidate fact and raze it before tick 5400.
281
+ Brute frontal (both squads centre), lazy near-fact strike
282
+ (B onto own-latitude fact while the centre cluster is still
283
+ in range), stall, bait-only, and wrong-fact strike all lose.
284
+ overrides:
285
+ actors:
286
+ # spawn_point 0 — NORTH staging (jeeps + tanks at y≈10..14).
287
+ - {type: jeep, owner: agent, position: [6, 10], stance: 1, count: 3,
288
+ spawn_point: 0}
289
+ - {type: 2tnk, owner: agent, position: [6, 14], stance: 1, count: 4,
290
+ spawn_point: 0}
291
+ # spawn_point 1 — SOUTH staging (mirror at y≈26..30).
292
+ - {type: jeep, owner: agent, position: [6, 30], stance: 1, count: 3,
293
+ spawn_point: 1}
294
+ - {type: 2tnk, owner: agent, position: [6, 26], stance: 1, count: 4,
295
+ spawn_point: 1}
296
+ # Candidate REAL target NE: fact + LIGHT e1 garrison.
297
+ - {type: fact, owner: enemy, position: [100, 10]}
298
+ - {type: e1, owner: enemy, position: [96, 10], stance: 2}
299
+ - {type: e1, owner: enemy, position: [100, 14], stance: 2}
300
+ # Candidate REAL target SE: fact + LIGHT e1 garrison
301
+ # (symmetric — both candidates light, the test is the
302
+ # CENTRE-cluster diversion, not which one).
303
+ - {type: fact, owner: enemy, position: [100, 30]}
304
+ - {type: e1, owner: enemy, position: [96, 30], stance: 2}
305
+ - {type: e1, owner: enemy, position: [100, 26], stance: 2}
306
+ # DECOY centre cluster: heavy anti-tank rocket-infantry
307
+ # around a `powr` (decoy building) at the mid-latitude
308
+ # between the two candidate facts. Razing the powr does
309
+ # NOT score (not a `fact`); the cluster's role is to
310
+ # ABSORB pursuit when baited.
311
+ - {type: powr, owner: enemy, position: [100, 20]}
312
+ - {type: e3, owner: enemy, position: [96, 18], stance: 2}
313
+ - {type: e3, owner: enemy, position: [96, 22], stance: 2}
314
+ - {type: e3, owner: enemy, position: [100, 18], stance: 2}
315
+ - {type: e3, owner: enemy, position: [100, 22], stance: 2}
316
+ - {type: e3, owner: enemy, position: [104, 20], stance: 2}
317
+ # SENTINEL: far-corner persistent enemy fact keeps the
318
+ # episode alive past objective-fact destruction so the
319
+ # within_ticks / units_lost_lte predicates evaluate on
320
+ # the terminal frame.
321
+ - {type: fact, owner: enemy, position: [125, 38]}
322
+ win_condition:
323
+ all_of:
324
+ - any_of:
325
+ - enemy_key_buildings_destroyed_in_region:
326
+ {x: 100, y: 10, radius: 6, types: [fact]}
327
+ - enemy_key_buildings_destroyed_in_region:
328
+ {x: 100, y: 30, radius: 6, types: [fact]}
329
+ - within_ticks: 5400
330
+ - units_lost_lte: 2
331
+ fail_condition:
332
+ any_of:
333
+ - after_ticks: 5401
334
+ - not: {units_lost_lte: 2}
335
+ - not: {own_units_gte: 1}
336
+ max_turns: 62
openra_bench/scenarios/packs/coord-relay-attack.yaml ADDED
@@ -0,0 +1,247 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # coord-relay-attack.yaml
2
+ #
3
+ # ACTION capability — relay assault: Squad A engages FIRST (rockets
4
+ # soften the enemy armour), THEN Squad B follows up to finish off the
5
+ # survivors. The Wave-2 `then:` happened-before composite makes the
6
+ # ordering load-bearing: clause 1 (`units_killed_gte: K1`) must latch
7
+ # BEFORE clause 2 (`units_killed_gte: K2`) — a chain-of-thought that
8
+ # rushes B first or both-at-once cannot satisfy the chain in time
9
+ # because B's tanks get focus-fired by the enemy's 4× 2tnk before any
10
+ # kills materialise, blowing the `units_lost_lte` attrition cap.
11
+ #
12
+ # The relay teeth:
13
+ # - Squad A = 3× e3 (rocket soldiers, anti-vehicle): well-suited to
14
+ # killing the enemy's 4× 2tnk, but slow to melt the 4× e1 alone.
15
+ # - Squad B = 3× 2tnk (medium tanks, anti-infantry mop-up): great vs
16
+ # e1 horde, but BLEEDS when fed alone into the enemy 4× 2tnk that
17
+ # out-DPS them via focus fire.
18
+ # - Enemy = 4× 2tnk + 4× e1 clustered at the centre (static, stance
19
+ # 2 = return-fire only). Naturally relays A→tanks-first so B faces
20
+ # only the remaining infantry.
21
+ #
22
+ # Real-world anchor: SC2 attack-wave timing (1st wave commits, 2nd
23
+ # wave clears survivors); SMAC relay strike doctrine; military
24
+ # overlapping fires; fire-and-maneuver bound-and-bound.
25
+ #
26
+ # ENGINE NOTE: the centre enemy cluster is the *only* enemy presence,
27
+ # so the engine will auto-end the episode the second the last enemy
28
+ # falls. We anchor an unarmed high-HP `proc` "marker" deep at the far
29
+ # east so a stalled / partial run cannot prematurely auto-`done` —
30
+ # but the win predicate counts `units_killed_gte`, not buildings, so
31
+ # the marker is purely a stay-alive prop. Distance >40 cells from
32
+ # every agent route keeps it out of tank vision.
33
+ #
34
+ # Validate:
35
+ # cd /Users/berta/Projects/OpenRA-Bench && \
36
+ # python3 -m openra_bench.scenarios.validate \
37
+ # openra_bench/scenarios/packs/coord-relay-attack.yaml
38
+
39
+ meta:
40
+ id: coord-relay-attack
41
+ title: 'Relay Strike — Rockets Soften, Tanks Mop Up'
42
+ capability: action
43
+ real_world_meaning: >
44
+ Two-wave relay assault: the first wave (rockets) softens the
45
+ target by destroying the heavy threats; the second wave (tanks)
46
+ follows up to clear the remaining infantry. Sending the second
47
+ wave in first exposes it to the un-softened heavy threats and
48
+ loses the force; sending both together (simultaneous commit)
49
+ exposes the mop-up wave to the same heavies before they are
50
+ suppressed — only the relay ordering (A engages → softens →
51
+ THEN B advances) preserves the force and finishes inside the
52
+ deadline.
53
+ robotics_analogue: >
54
+ Two-team relay clearing operation: the suppression team commits
55
+ first to neutralise the heavy threats, then the mop-up team
56
+ advances to clear the secondary targets. Concurrent commitment
57
+ over-exposes the mop-up team to the heavies they are not equipped
58
+ for; the suppression-then-clear handoff is the doctrine.
59
+ benchmark_anchor:
60
+ - "SC2 attack-wave timing"
61
+ - "SMAC relay strike doctrine"
62
+ - "military overlapping fires"
63
+ - "fire-and-maneuver: bound-and-bound"
64
+ author: "openra-bench"
65
+
66
+ base_map: rush-hour-arena
67
+
68
+ base:
69
+ agent:
70
+ faction: allies
71
+ enemy:
72
+ faction: soviet
73
+ bot_type: ''
74
+ tools:
75
+ - move_units
76
+ - attack_unit
77
+ - attack_move
78
+ - stop
79
+ planning: true
80
+ termination:
81
+ max_ticks: 9000
82
+
83
+ levels:
84
+ # ── EASY ────────────────────────────────────────────────────────
85
+ # Bare relay: 1 enemy 2tnk + 3 e1 at the centre. Even A alone could
86
+ # finish, but the then-composite still enforces scout-first style
87
+ # ordering (kill 2 before kill 4). A=4× e3 has 48dps focus vs the
88
+ # enemy tank's 22dps spread → A survives the soften phase. B mops
89
+ # up the remaining infantry. Loose attrition cap (≤4) so a small
90
+ # over-commit can still squeak by — this is the bare-skill tier.
91
+ easy:
92
+ description: >
93
+ You command TWO squads. Squad A: 4 rocket soldiers (e3) at the
94
+ west (15,20) — anti-vehicle. Squad B: 3 medium tanks (2tnk) at
95
+ the far west (5,30) — anti-infantry mop-up, staged a long way
96
+ back. The enemy is a small cluster at the centre (60,20): 1
97
+ medium tank (2tnk) + 3 rifle infantry (e1), holding position.
98
+ The intended relay: Squad A engages FIRST and destroys ≥2
99
+ enemies (the rockets kill the enemy tank); THEN advance Squad
100
+ B to mop up the remaining infantry, bringing the total
101
+ destroyed to ≥4 before tick 3000. Lose if the clock runs out
102
+ or you lose more than 4 of your own units.
103
+ overrides:
104
+ actors:
105
+ # Squad A: 4× e3 rocket soldiers at WEST.
106
+ - {type: e3, owner: agent, position: [15, 20], stance: 1, count: 4}
107
+ # Squad B: 3× 2tnk medium tanks at FAR-WEST south.
108
+ - {type: 2tnk, owner: agent, position: [5, 30], stance: 1, count: 3}
109
+ # Enemy: 1× 2tnk + 3× e1 at centre, stance:1 = ReturnFire
110
+ # (only fire on attackers, do not initiate). This is the
111
+ # relay-key mechanic: B advancing alongside A wakes EVERY
112
+ # enemy that B is in range of; A picks them off one at a
113
+ # time. See the design note at the top of the file.
114
+ - {type: 2tnk, owner: enemy, position: [60, 20], stance: 1, count: 1}
115
+ - {type: e1, owner: enemy, position: [62, 20], stance: 1, count: 3}
116
+ # Far-east survivor marker (unarmed proc) keeps the episode
117
+ # alive past partial / failed attempts so a missed deadline
118
+ # emits a LOSS, not a DRAW (engine auto-done is conquest-
119
+ # triggered on MustBeDestroyed building elimination).
120
+ - {type: proc, owner: enemy, position: [120, 6]}
121
+ win_condition:
122
+ all_of:
123
+ - then:
124
+ id: relay-A-then-B-easy
125
+ clauses:
126
+ - {units_killed_gte: 2}
127
+ - {units_killed_gte: 4}
128
+ - {within_ticks: 3000}
129
+ - {units_lost_lte: 4}
130
+ fail_condition:
131
+ any_of:
132
+ - {after_ticks: 3001}
133
+ - {not: {units_lost_lte: 4}}
134
+ - {not: {own_units_gte: 1}}
135
+ max_turns: 36
136
+
137
+ # ── MEDIUM ──────────────────────────────────────────────────────
138
+ # +1 controlled variable: the enemy threat grows to 3× 2tnk + 4× e1
139
+ # all stance:3 (AttackAnything — they aggressively engage the
140
+ # closest agent on sight, not wait to be shot at). attrition cap
141
+ # ≤5. K1=3 (kill the 3 enemy tanks), K2=7 (mop up 4 e1). B is
142
+ # staged FAR back (x=5) so a B-only / both-at-once policy that
143
+ # commits B from turn 1 still has B walking toward the centre
144
+ # while A absorbs the close enemy. The relay teeth: A in front
145
+ # absorbs the enemy 3× 2tnk focus-fire (and slowly chips the
146
+ # tanks); B advances later when the tanks are dead/wounded — if
147
+ # B advances early, the un-softened enemy tanks focus B's column
148
+ # (88 dps onto 400 HP tanks = 1 tank per ~4-5s) → ≥6 lost.
149
+ medium:
150
+ description: >
151
+ You command TWO squads. Squad A: 4 rocket soldiers (e3) at the
152
+ west (15,20) — anti-vehicle. Squad B: 3 medium tanks (2tnk)
153
+ at the far west (5,30) — strong vs infantry, OUTGUNNED if fed
154
+ alone into the enemy tanks. The enemy is at the centre
155
+ (60,20): 3 medium tanks (2tnk) + 4 rifle infantry (e1), all
156
+ aggressive (AttackAnything). The intended relay: Squad A
157
+ engages FIRST and destroys ≥3 enemies (rockets kill the enemy
158
+ tanks); THEN advance Squad B to finish the survivors,
159
+ bringing the total destroyed to ≥7 before tick 4000. Lose if
160
+ the clock runs out or you lose more than 5 of your own units.
161
+ Sending B in alone, or both squads together, exposes B's
162
+ tanks to the un-softened enemy 3× 2tnk and blows the
163
+ attrition cap.
164
+ overrides:
165
+ actors:
166
+ # Squad A: 4× e3 rocket soldiers at WEST.
167
+ - {type: e3, owner: agent, position: [15, 20], stance: 1, count: 4}
168
+ # Squad B: 3× 2tnk medium tanks at FAR-WEST south.
169
+ - {type: 2tnk, owner: agent, position: [5, 30], stance: 1, count: 3}
170
+ # Enemy: 3× 2tnk + 4× e1 at centre, all stance:3.
171
+ - {type: 2tnk, owner: enemy, position: [60, 20], stance: 1, count: 3}
172
+ - {type: e1, owner: enemy, position: [62, 20], stance: 1, count: 4}
173
+ # Far-east survivor marker (see easy).
174
+ - {type: proc, owner: enemy, position: [120, 6]}
175
+ win_condition:
176
+ all_of:
177
+ - then:
178
+ id: relay-A-then-B-medium
179
+ clauses:
180
+ - {units_killed_gte: 3}
181
+ - {units_killed_gte: 7}
182
+ - {within_ticks: 4000}
183
+ - {units_lost_lte: 5}
184
+ fail_condition:
185
+ any_of:
186
+ - {after_ticks: 4001}
187
+ - {not: {units_lost_lte: 5}}
188
+ - {not: {own_units_gte: 1}}
189
+ max_turns: 45
190
+
191
+ # ── HARD ────────────────────────────────────────────────────────
192
+ # +1 controlled variable: BIGGER KILL BAR (K1=4, K2=10) + larger
193
+ # enemy threat (4× 2tnk + 6× e1) + ≥2 agent spawn_point groups
194
+ # (NORTH vs SOUTH staging) so the squads' staging latitude flips
195
+ # per seed.
196
+ #
197
+ # Spawn-point design (per CLAUDE.md): the agent's start group
198
+ # flips between NORTH (A at (15,12), B at (5,18)) and SOUTH (A at
199
+ # (15,28), B at (5,34)) per seed. The central enemy cluster sits
200
+ # at y=20 (symmetric across the map's mid-latitude) so either
201
+ # staging faces an equivalent relay decision but no memorised
202
+ # approach corridor generalises across seeds.
203
+ hard:
204
+ description: >
205
+ You command TWO squads. Squad A: 4 rocket soldiers (e3) —
206
+ anti-vehicle. Squad B: 3 medium tanks (2tnk) — strong vs
207
+ infantry, OUTGUNNED if fed alone into the enemy tanks. The
208
+ squads start staged on the west edge of the map (north-side
209
+ OR south-side staging depending on the seed); B is staged
210
+ further back than A. The enemy is a heavy cluster at the
211
+ centre (~60,20): 4 medium tanks (2tnk) + 6 rifle infantry
212
+ (e1), all aggressive (AttackAnything). The intended relay:
213
+ Squad A engages FIRST and destroys ≥4 enemies (rockets kill
214
+ the enemy tanks first); THEN advance Squad B to finish the
215
+ survivors, bringing the total destroyed to ≥10 before tick
216
+ 4500. Lose if the clock runs out or you lose more than 5 of
217
+ your own units. Sending B in alone, or both squads together,
218
+ exposes B's tanks to the un-softened enemy 4× 2tnk and blows
219
+ the attrition cap.
220
+ overrides:
221
+ actors:
222
+ # NORTH staging (spawn_point 0): A above, B below-and-back.
223
+ - {type: e3, owner: agent, position: [15, 12], stance: 1, count: 4, spawn_point: 0}
224
+ - {type: 2tnk, owner: agent, position: [5, 18], stance: 1, count: 3, spawn_point: 0}
225
+ # SOUTH staging (spawn_point 1): A above, B below-and-back.
226
+ - {type: e3, owner: agent, position: [15, 28], stance: 1, count: 4, spawn_point: 1}
227
+ - {type: 2tnk, owner: agent, position: [5, 34], stance: 1, count: 3, spawn_point: 1}
228
+ # Enemy: 4× 2tnk + 6× e1 at centre, all stance:3.
229
+ - {type: 2tnk, owner: enemy, position: [60, 20], stance: 1, count: 4}
230
+ - {type: e1, owner: enemy, position: [62, 20], stance: 1, count: 6}
231
+ # Far-east survivor marker (see easy).
232
+ - {type: proc, owner: enemy, position: [120, 6]}
233
+ win_condition:
234
+ all_of:
235
+ - then:
236
+ id: relay-A-then-B-hard
237
+ clauses:
238
+ - {units_killed_gte: 4}
239
+ - {units_killed_gte: 10}
240
+ - {within_ticks: 4500}
241
+ - {units_lost_lte: 5}
242
+ fail_condition:
243
+ any_of:
244
+ - {after_ticks: 4501}
245
+ - {not: {units_lost_lte: 5}}
246
+ - {not: {own_units_gte: 1}}
247
+ max_turns: 50
openra_bench/scenarios/packs/coord-squad-handoff.yaml ADDED
@@ -0,0 +1,203 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Validate: python -m openra_bench.scenarios.validate packs/coord-squad-handoff.yaml
2
+ # See ../CONTRIBUTING.md for the full win-condition grammar.
3
+ #
4
+ # ACTION focus — SQUAD HANDOFF via the Wave-2 `then:` happened-before
5
+ # composite. Two physically-distinct squads (Squad A = jeeps, Squad B
6
+ # = medium tanks) must deliver SEQUENCED objectives across the map:
7
+ # Squad A reaches the FIRST waypoint, the latch fires, THEN Squad B
8
+ # reaches the SECOND waypoint and takes over. Difficulty stacks
9
+ # additional alternating handoffs (A→P1→B→P2→A→P3→B→P4).
10
+ #
11
+ # Squad identity is enforced by the type-filtered region predicate
12
+ # `units_of_type_in_region_gte` (added with this pack): clause 1 only
13
+ # counts JEEPS at P1, clause 2 only counts MEDIUM TANKS (2tnk) at P2,
14
+ # etc. This is the missing piece that makes "squad handoff" a real
15
+ # capability (without it, a single jeep squad touring P1→P2 satisfies
16
+ # both clauses and the relay collapses to a single-column tour).
17
+ #
18
+ # The clauses are wrapped in `then:` so order is enforced (B-first
19
+ # never latches — B's clause is index 1; reaching it before A latches
20
+ # index 0 advances nothing). Stalling never advances. The intended
21
+ # play is parallel dispatch: launch BOTH squads at once, each toward
22
+ # its OWN waypoint; B arrives at P2 and HOLDS there (the `then:`
23
+ # latch advances the instant A's earlier clause becomes true).
24
+ #
25
+ # Real-world anchor: relay race / construction-site handoff; military
26
+ # "passage of lines" doctrine where unit B passes through unit A's
27
+ # secured line to continue the advance. Benchmark anchor: the
28
+ # Watch-And-Help concurrent-multi-agent / SMAC squad-handoff family.
29
+
30
+ meta:
31
+ id: coord-squad-handoff
32
+ title: 'Squad Handoff — Squad A Delivers, Squad B Takes Over (Wave-2 then:)'
33
+ capability: action
34
+ real_world_meaning: >
35
+ Sequenced multi-team operations: team A delivers the first
36
+ objective, team B takes over for the next, and so on — the order
37
+ of arrival matters, and a single team cannot serve both legs
38
+ because each leg requires a SPECIFIC kind of unit. Relay race
39
+ (baton handoff), construction site handoff (steelworker phase
40
+ finishes, electrician phase begins), and supply-chain ordered
41
+ handoff are the everyday analogues.
42
+ robotics_analogue: >
43
+ Multi-robot fleet with role specialisation and sequenced subtasks:
44
+ the survey drone must reach landmark A before the delivery
45
+ truck is dispatched to landmark B (the delivery is meaningless
46
+ without the survey). One fleet cannot do both legs because the
47
+ drone and the truck are physically different vehicles for
48
+ different jobs.
49
+ benchmark_anchor:
50
+ - "Watch-And-Help concurrent multi-agent handoff"
51
+ - "SMAC squad sequencing"
52
+ - "military passage of lines doctrine"
53
+ - "relay race / construction handoff"
54
+ author: "openra-bench"
55
+
56
+ base_map: rush-hour-arena
57
+
58
+ base:
59
+ agent: {faction: allies}
60
+ enemy: {faction: soviet}
61
+ tools: [move_units, attack_unit, attack_move, stop]
62
+ planning: true
63
+ termination: {max_ticks: 8000}
64
+ actors: [] # every level supplies its own actor list via overrides.
65
+
66
+ levels:
67
+ # ── EASY ──────────────────────────────────────────────────────────
68
+ # The bare squad-handoff skill: ONE handoff (Squad A → Squad B).
69
+ # Squad A = 3 jeeps in the NW corner; Squad B = 3 medium tanks in
70
+ # the SW corner. The agent's only correct play is to launch BOTH
71
+ # squads in parallel — Squad A to P1 (50,10), Squad B to P2
72
+ # (90,30). The `then:` latch fires when A's "≥3 jeeps at P1"
73
+ # clause latches AND B's "≥3 tanks at P2" clause latches in
74
+ # order. A B-first dash never advances the chain (B's clause is
75
+ # index 1; reaching it before A's clause latches gives zero
76
+ # credit). A single-squad tour cannot satisfy clause 2 at all
77
+ # (clause 2 demands TANKS at P2; jeeps don't count).
78
+ easy:
79
+ description: >
80
+ Squad A is 3 jeeps in the NW corner; Squad B is 3 medium tanks
81
+ (2tnk) in the SW corner. FIRST get ≥3 jeeps into the region
82
+ around P1 (50,10); THEN get ≥3 medium tanks into the region
83
+ around P2 (90,30). The order is enforced (B-first does not
84
+ count). Launch BOTH squads in parallel — Squad B can arrive at
85
+ P2 and hold; the latch advances the instant A's clause
86
+ becomes true. Single-squad tours never satisfy the second
87
+ clause because P2 needs tanks, not jeeps. Lose if the
88
+ deadline passes.
89
+ overrides:
90
+ actors:
91
+ # Squad A — 3 jeeps in the NW corner (fast scouts/runners).
92
+ - {type: jeep, owner: agent, position: [5, 5], count: 3}
93
+ # Squad B — 3 medium tanks in the SW corner (heavy follow-up).
94
+ - {type: 2tnk, owner: agent, position: [5, 33], count: 3}
95
+ win_condition:
96
+ all_of:
97
+ - then:
98
+ id: coord-handoff-easy
99
+ clauses:
100
+ - {units_of_type_in_region_gte:
101
+ {type: jeep, x: 50, y: 10, radius: 8, n: 3}}
102
+ - {units_of_type_in_region_gte:
103
+ {type: 2tnk, x: 90, y: 30, radius: 8, n: 3}}
104
+ - within_ticks: 4500
105
+ fail_condition:
106
+ any_of:
107
+ - {after_ticks: 4501}
108
+ - {not: {own_units_gte: 1}}
109
+ max_turns: 52
110
+
111
+ # ── MEDIUM ────────────────────────────────────────────────────────
112
+ # +1 controlled variable: a THIRD waypoint (Squad A must return to
113
+ # the action and deliver P3 after the B handoff). The chain is now
114
+ # A→P1 → B→P2 → A→P3. The jeeps must NOT bleed out of P1 forever
115
+ # — they have to redeploy to P3 (60,20) after Squad B has taken
116
+ # over at P2. A single-squad tour still cannot satisfy the
117
+ # tank-only clause (clause 2). Stalling, B-first, or "park A at
118
+ # P1 and ignore P3" all lose.
119
+ medium:
120
+ description: >
121
+ Squad A is 3 jeeps in the NW corner; Squad B is 3 medium tanks
122
+ (2tnk) in the SW corner. FIRST get ≥3 jeeps into the region
123
+ around P1 (50,10); THEN get ≥3 medium tanks into the region
124
+ around P2 (90,30); THEN get ≥3 jeeps into the region around
125
+ P3 (60,20). The order is enforced (skipping does not count).
126
+ Launch both squads in parallel; after Squad B reaches P2,
127
+ redeploy Squad A from P1 to P3. A single-squad tour cannot
128
+ satisfy the tank-only clause (clause 2 needs tanks at P2,
129
+ not jeeps); B-first never latches the chain; stalling loses
130
+ on the deadline.
131
+ overrides:
132
+ actors:
133
+ - {type: jeep, owner: agent, position: [5, 5], count: 3}
134
+ - {type: 2tnk, owner: agent, position: [5, 33], count: 3}
135
+ win_condition:
136
+ all_of:
137
+ - then:
138
+ id: coord-handoff-medium
139
+ clauses:
140
+ - {units_of_type_in_region_gte:
141
+ {type: jeep, x: 50, y: 10, radius: 8, n: 3}}
142
+ - {units_of_type_in_region_gte:
143
+ {type: 2tnk, x: 90, y: 30, radius: 8, n: 3}}
144
+ - {units_of_type_in_region_gte:
145
+ {type: jeep, x: 60, y: 20, radius: 8, n: 3}}
146
+ - within_ticks: 4500
147
+ fail_condition:
148
+ any_of:
149
+ - {after_ticks: 4501}
150
+ - {not: {own_units_gte: 1}}
151
+ max_turns: 52
152
+
153
+ # ── HARD ──────────────────────────────────────────────────────────
154
+ # +1 controlled variable: FOUR alternating waypoints
155
+ # (A→P1→B→P2→A→P3→B→P4) AND ≥2 seed-driven spawn_point groups
156
+ # (NW staging vs SW staging — round-robin per seed). The four-leg
157
+ # chain forces the agent to genuinely interleave the squads:
158
+ # Squad A at P1, B passes through to P2, A redeploys to P3, B
159
+ # passes again to P4. Spawn round-robin (per
160
+ # tests/test_hard_tier.py::UPGRADED) flips the staging corner so
161
+ # a single memorised opening cannot generalise.
162
+ hard:
163
+ description: >
164
+ Squad A is 3 jeeps; Squad B is 3 medium tanks (2tnk). The
165
+ starting corner (NW or SW) varies by seed. FOUR sequenced
166
+ handoffs in order: (1) ≥3 jeeps at P1 (50,10); (2) ≥3 medium
167
+ tanks at P2 (90,30); (3) ≥3 jeeps at P3 (60,20); (4) ≥3
168
+ medium tanks at P4 (100,15). The order is enforced — skipping
169
+ or reaching a later waypoint before its predecessor latches
170
+ gives no credit. The intended play is fully interleaved
171
+ parallel dispatch: launch both squads at once, hold at each
172
+ waypoint until the latch advances, then redeploy. A single
173
+ squad cannot tour the chain — clauses 2 and 4 demand TANKS;
174
+ clauses 1 and 3 demand JEEPS.
175
+ overrides:
176
+ actors:
177
+ # spawn_point 0 — NW staging.
178
+ - {type: jeep, owner: agent, position: [5, 5], count: 3, spawn_point: 0}
179
+ - {type: 2tnk, owner: agent, position: [5, 12], count: 3, spawn_point: 0}
180
+ # spawn_point 1 — SW staging (mirrored). Now the long-haul
181
+ # leg flips per seed so a memorised opening cannot
182
+ # generalise across seeds.
183
+ - {type: jeep, owner: agent, position: [5, 28], count: 3, spawn_point: 1}
184
+ - {type: 2tnk, owner: agent, position: [5, 33], count: 3, spawn_point: 1}
185
+ win_condition:
186
+ all_of:
187
+ - then:
188
+ id: coord-handoff-hard
189
+ clauses:
190
+ - {units_of_type_in_region_gte:
191
+ {type: jeep, x: 50, y: 10, radius: 8, n: 3}}
192
+ - {units_of_type_in_region_gte:
193
+ {type: 2tnk, x: 90, y: 30, radius: 8, n: 3}}
194
+ - {units_of_type_in_region_gte:
195
+ {type: jeep, x: 60, y: 20, radius: 8, n: 3}}
196
+ - {units_of_type_in_region_gte:
197
+ {type: 2tnk, x: 100, y: 15, radius: 8, n: 3}}
198
+ - within_ticks: 4500
199
+ fail_condition:
200
+ any_of:
201
+ - {after_ticks: 4501}
202
+ - {not: {own_units_gte: 1}}
203
+ max_turns: 52
openra_bench/scenarios/packs/def-in-depth.yaml ADDED
@@ -0,0 +1,284 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ meta:
2
+ id: def-in-depth
3
+ title: 'Defense in Depth — Two Layers, Not One Thick Wall'
4
+ capability: reasoning
5
+ real_world_meaning: >
6
+ Defense-in-depth doctrine: against a massed concentrated rush, a
7
+ front line plus a second line at greater depth shreds the attacker
8
+ after the first layer falls, where a single fortified line — even
9
+ one with the same brick count — eventually collapses under sustained
10
+ pressure and razes the base behind it. Picking concentration vs
11
+ distribution is the reasoning load: the same four pillboxes massed
12
+ in one cluster lose, the same four pillboxes spread thin in one
13
+ long line lose, and only a 2+2 layered topology survives.
14
+ robotics_analogue: >
15
+ Multi-layer security architecture (defense-in-depth): a single
16
+ perimeter firewall, however thick, eventually gets breached; the
17
+ same defensive capacity deployed as outer + inner layers (each
18
+ covering the same arc) absorbs the breach. Same total resources,
19
+ different topology, qualitatively different survivability. Also:
20
+ medieval keep + outer-wall design (attackers must take BOTH);
21
+ redundant systems architecture (one component failure ≠ system
22
+ failure).
23
+ benchmark_anchor:
24
+ - 'military defense-in-depth doctrine'
25
+ - 'security multi-layer architecture (defense-in-depth)'
26
+ - 'medieval keep + outer wall design'
27
+ - 'redundant systems architecture'
28
+ author: openra-bench
29
+
30
+ # rush-hour-arena (128×40, playable x in [2..126], y in [2..38]). Agent
31
+ # base on the west (fact at (10,20) + tent + powr + 3 e1 defenders
32
+ # pre-placed); rusher band spawns at the lane mouth (x≈70, y≈20) on the
33
+ # same row as the base. Rusher scripted bot concentrates the band on
34
+ # the agent centroid every ~8 ticks (scripted_bot.rs::Rusher) — drives
35
+ # straight at the fact unless blunted in depth.
36
+ #
37
+ # Defense-in-depth geometry (medium/easy):
38
+ # • FRONT region centred at (25,20), radius 4 → x in [21..29]:
39
+ # 2 pbox here form the OUTER layer that absorbs the first impact.
40
+ # • REAR region centred at (15,20), radius 4 → x in [11..19]:
41
+ # 2 pbox here form the INNER layer that shreds the rushers that
42
+ # break through the front.
43
+ # • Radii intentionally tight (r=4) so the two regions DO NOT overlap
44
+ # (x=20 falls in neither). A single 4-pbox cluster cannot satisfy
45
+ # both region predicates — the model has to choose the layered
46
+ # topology, not a tall stack at one x.
47
+ #
48
+ # Failure topologies (every one is a real reachable LOSS):
49
+ # • STALL: no buildings → win is unsatisfied AND the rusher band
50
+ # reaches the fact ~turn 20 (tick ~1800) → fact razed → LOSS via
51
+ # `not has_building:fact`.
52
+ # • SPREAD-THIN (1 long line, 4 pbox at x=15..18 in one column):
53
+ # rear-region count satisfied but front-region count = 0 → win
54
+ # unsatisfied; under sustained pressure the line falls too →
55
+ # fact razed → LOSS.
56
+ # • SINGLE-CLUSTER (4 pbox at x=15, no front layer): the massed
57
+ # attack attrites the single cluster faster than it can kill,
58
+ # final rushers reach the fact → LOSS; even if the cluster
59
+ # survives, front-region count = 0 → win unsatisfied.
60
+ # • SINGLE-LINE-AT-FRONT (4 pbox at x=25): rear-region count = 0 →
61
+ # win unsatisfied; breakthrough sails past undefended to the fact.
62
+ # • INTENDED 2+2 LAYERED: front layer takes the spike, rear layer
63
+ # finishes the survivors at greater depth → fact intact, all four
64
+ # in-region predicates satisfied → WIN.
65
+ #
66
+ # Budget: 2400cr — exactly 4 pbox (4×400 = 1600) plus 8 e1 (8×100 =
67
+ # 800) headroom for replacement defenders, OR 4 pbox alone with 800cr
68
+ # slack. The win does NOT require trained units beyond the pre-placed
69
+ # 3 (the `not own_units_gte:1` fail clause is satisfied by them) —
70
+ # the discriminator is topology, not throughput.
71
+ base_map: rush-hour-arena
72
+ starting_cash: 2400
73
+
74
+ base:
75
+ agent:
76
+ faction: allies
77
+ enemy:
78
+ faction: soviet
79
+ bot_type: rusher
80
+ tools:
81
+ - observe
82
+ - build
83
+ - place_building
84
+ - move_units
85
+ - attack_unit
86
+ - attack_move
87
+ - stop
88
+ planning: true
89
+ # Re-decide on rusher spotted at the lane mouth (the cue to commit
90
+ # placement) and on own loss (a defender or a pbox just fell).
91
+ interrupts:
92
+ enemy_unit_spotted: true
93
+ own_unit_destroyed: true
94
+ termination:
95
+ max_ticks: 14000
96
+ actors:
97
+ # ── agent base (west) ── fact + tent + powr pre-placed (tent is
98
+ # the production queue for e1; powr removes the early power-stall;
99
+ # ONE rear defender at x=3 covers the `own_units_gte:1` fail
100
+ # clause from turn 1 WITHOUT interposing on the rush corridor —
101
+ # three close defenders would trivially shred the band on their
102
+ # own and the layered-defense capability would never be tested.
103
+ - {type: fact, owner: agent, position: [10, 20]}
104
+ - {type: tent, owner: agent, position: [6, 18]}
105
+ - {type: powr, owner: agent, position: [6, 22]}
106
+ - {type: e1, owner: agent, position: [3, 20], stance: 2}
107
+
108
+ levels:
109
+ # ── EASY ── the bare defense-in-depth skill. Single rush band (8 e1
110
+ # + 2 e3) at x=70. The 2+2 layout (front=(25,20) and rear=(15,20),
111
+ # each radius 4) is required. max_turns 60 → reachable tick ≥4848
112
+ # in interrupt mode, comfortably past the 5401 fail (with interrupt
113
+ # turns sometimes <90 ticks each, easy=5400 budget needs the larger
114
+ # 60-turn cap to make the after_ticks fail truly reachable).
115
+ easy:
116
+ description: >
117
+ A single massed rush band — 8 rifle infantry + 2 rocket soldiers
118
+ — drives straight at your base. The base (fact+tent+powr+3
119
+ defenders) is pre-placed. Win by building 4 pillboxes in TWO
120
+ LAYERS: 2 at the FRONT line near (25,20) AND 2 at the REAR line
121
+ near (15,20); keep the fact alive and a defender on the field
122
+ before tick 5400. A single cluster at one x, or a long thin line,
123
+ eventually breaks under the concentrated attack.
124
+ overrides:
125
+ actors:
126
+ - {type: fact, owner: agent, position: [10, 20]}
127
+ - {type: tent, owner: agent, position: [6, 18]}
128
+ - {type: powr, owner: agent, position: [6, 22]}
129
+ # FRONT LINE pre-placed (2 pbox at x=25): the agent inherits a
130
+ # front-line firewall. The capability test is whether the agent
131
+ # recognises that a single-thick line will eventually fall and
132
+ # *adds depth* (a REAR layer at x=15) — NOT whether the agent
133
+ # can serial-build 4 pboxes inside a tight clock (a question
134
+ # of throughput, not topology).
135
+ - {type: pbox, owner: agent, position: [25, 19]}
136
+ - {type: pbox, owner: agent, position: [25, 21]}
137
+ # ONE rear defender at x=3 (behind the fact) — covers a unit
138
+ # presence from turn 1 without interposing on the rush corridor.
139
+ - {type: e1, owner: agent, position: [3, 20], stance: 2}
140
+ # Massed rush band: 8 e1 + 2 e3 concentrated at the lane mouth.
141
+ # At x=110 (far east) the band needs ~20+ turns to reach the
142
+ # front line at x=25 — enough budget for the intended 2-rear-pbox
143
+ # chain to complete (2 pbox × 9 turns ≈ 18 turns of build) and
144
+ # for the rear layer to fire as the rush breaches the front line.
145
+ - {type: e1, owner: enemy, position: [110, 20], stance: 3, count: 5}
146
+ - {type: e3, owner: enemy, position: [112, 20], stance: 3, count: 1}
147
+ # Unarmed high-HP enemy marker far east keeps the episode alive
148
+ # past the last rusher death (without this, engine auto-done on
149
+ # enemy-elimination collapses the run to DRAW).
150
+ - {type: fact, owner: enemy, position: [120, 20]}
151
+ win_condition:
152
+ all_of:
153
+ - building_count_gte: {type: pbox, n: 4}
154
+ - building_in_region: {x: 25, y: 20, radius: 4, type: pbox, count: 2}
155
+ - building_in_region: {x: 15, y: 20, radius: 4, type: pbox, count: 2}
156
+ - building_count_gte: {type: fact, n: 1}
157
+ - within_ticks: 5400
158
+ fail_condition:
159
+ any_of:
160
+ - after_ticks: 5401
161
+ - not: {building_count_gte: {type: fact, n: 1}}
162
+ max_turns: 110
163
+
164
+ # ── MEDIUM ── +1 controlled variable: tighter clock (within_ticks
165
+ # 4500 instead of 5400). Same band, same layout, less slack — the
166
+ # agent has to commit to the layered topology immediately; any
167
+ # detour into "build a wall first then rethink" busts the clock.
168
+ medium:
169
+ description: >
170
+ Same massed rush band (8 rifle infantry + 2 rocket soldiers) and
171
+ same layered-defense requirement, but the deadline is tighter:
172
+ build 4 pillboxes in TWO LAYERS (2 at FRONT (25,20), 2 at REAR
173
+ (15,20)), keep the fact alive and a defender on the field, all
174
+ before tick 4500. Stalling, single-cluster, or single-thin-line
175
+ placements all fail.
176
+ overrides:
177
+ actors:
178
+ - {type: fact, owner: agent, position: [10, 20]}
179
+ - {type: tent, owner: agent, position: [6, 18]}
180
+ - {type: powr, owner: agent, position: [6, 22]}
181
+ # FRONT LINE pre-placed (as easy); medium tightens the clock.
182
+ - {type: pbox, owner: agent, position: [25, 19]}
183
+ - {type: pbox, owner: agent, position: [25, 21]}
184
+ - {type: e1, owner: agent, position: [3, 20], stance: 2}
185
+ - {type: e1, owner: enemy, position: [110, 20], stance: 3, count: 5}
186
+ - {type: e3, owner: enemy, position: [112, 20], stance: 3, count: 1}
187
+ - {type: fact, owner: enemy, position: [120, 20]}
188
+ win_condition:
189
+ all_of:
190
+ - building_count_gte: {type: pbox, n: 4}
191
+ - building_in_region: {x: 25, y: 20, radius: 4, type: pbox, count: 2}
192
+ - building_in_region: {x: 15, y: 20, radius: 4, type: pbox, count: 2}
193
+ - building_count_gte: {type: fact, n: 1}
194
+ - within_ticks: 4500
195
+ fail_condition:
196
+ any_of:
197
+ - after_ticks: 4501
198
+ - not: {building_count_gte: {type: fact, n: 1}}
199
+ max_turns: 110
200
+
201
+ # ── HARD ── +1 controlled variable: the layered requirement becomes
202
+ # 3 FRONT + 2 REAR (5 pbox total, 5×400 = 2000cr — well under the
203
+ # 2400 budget), AND the rush arrives as TWO staggered bands from
204
+ # two latitudes (north y=14 and south y=26), declared as two
205
+ # spawn_point groups so the AGENT base round-robins by seed. The
206
+ # rush lane that bites varies per seed — a single memorised layout
207
+ # cannot generalise. The layered topology generalises (it covers
208
+ # the lane in depth regardless of which side staged) — that is the
209
+ # capability the hard tier tests.
210
+ hard:
211
+ description: >
212
+ Two staggered rush bands hit your base from two latitudes (north
213
+ and south). The base stages from a seed-chosen latitude (the
214
+ rush lane that matters varies by seed). Build 5 pillboxes in a
215
+ 3-FRONT + 2-REAR layered topology (3 at FRONT (25,y), 2 at REAR
216
+ (15,y) where y matches the stage latitude), keep the fact alive
217
+ and a defender on the field, before tick 4500. Any single-layer
218
+ topology or single-cluster topology fails to absorb the staggered
219
+ pressure.
220
+ overrides:
221
+ # spawn_point 0 — NORTH base (y=14). All buildings/units that
222
+ # belong to the north spawn carry spawn_point: 0; same for south.
223
+ # The two front/rear regions also follow the spawn latitude
224
+ # (front_y=14 for spawn 0, front_y=26 for spawn 1 — see the
225
+ # any_of split in the win predicate below).
226
+ actors:
227
+ - {type: fact, owner: agent, position: [10, 14], spawn_point: 0}
228
+ - {type: tent, owner: agent, position: [6, 12], spawn_point: 0}
229
+ - {type: powr, owner: agent, position: [6, 16], spawn_point: 0}
230
+ # FRONT line pre-placed at BOTH latitudes (2 pbox each). Enemy
231
+ # bands at the opposite latitude still march on the fact
232
+ # (rusher targets the agent centroid; enemy actors ignore
233
+ # spawn_point — CLAUDE.md) so without the opposite latitude
234
+ # also being front-defended, the OTHER band would loop around
235
+ # and raze the fact regardless of layout choice. The agent
236
+ # inherits front coverage at BOTH y; the test is whether they
237
+ # ADD DEPTH (rear layer) at their OWN spawn latitude — the
238
+ # spawn-varied lane is which rear y to build at.
239
+ - {type: pbox, owner: agent, position: [25, 13], spawn_point: 0}
240
+ - {type: pbox, owner: agent, position: [25, 15], spawn_point: 0}
241
+ - {type: pbox, owner: agent, position: [25, 25], spawn_point: 0}
242
+ - {type: pbox, owner: agent, position: [25, 27], spawn_point: 0}
243
+ - {type: e1, owner: agent, position: [3, 14], stance: 2, spawn_point: 0}
244
+ # spawn_point 1 — SOUTH base (y=26). Same dual-front coverage.
245
+ - {type: fact, owner: agent, position: [10, 26], spawn_point: 1}
246
+ - {type: tent, owner: agent, position: [6, 24], spawn_point: 1}
247
+ - {type: powr, owner: agent, position: [6, 28], spawn_point: 1}
248
+ - {type: pbox, owner: agent, position: [25, 13], spawn_point: 1}
249
+ - {type: pbox, owner: agent, position: [25, 15], spawn_point: 1}
250
+ - {type: pbox, owner: agent, position: [25, 25], spawn_point: 1}
251
+ - {type: pbox, owner: agent, position: [25, 27], spawn_point: 1}
252
+ - {type: e1, owner: agent, position: [3, 26], stance: 2, spawn_point: 1}
253
+ # Two staggered bands at distinct latitudes (enemy actors are
254
+ # not honoured by spawn_point — CLAUDE.md — so both bands
255
+ # always arrive regardless of which agent spawn was picked).
256
+ # The agent's chosen layer position must cover the relevant
257
+ # lane; the OTHER lane's band attrites past the agent base
258
+ # (the fact still has to survive).
259
+ - {type: e1, owner: enemy, position: [110, 14], stance: 3, count: 3}
260
+ - {type: e3, owner: enemy, position: [112, 14], stance: 3, count: 1}
261
+ - {type: e1, owner: enemy, position: [110, 26], stance: 3, count: 3}
262
+ - {type: e3, owner: enemy, position: [112, 26], stance: 3, count: 1}
263
+ - {type: fact, owner: enemy, position: [120, 20]}
264
+ # The agent inherits FRONT coverage at BOTH latitudes (4 pbox);
265
+ # the test is whether it ADDS DEPTH at its OWN spawn latitude by
266
+ # building 2 REAR pbox at the matching y. Either rear-region
267
+ # (north y=14 OR south y=26) satisfies the layered bar — the model
268
+ # must pick the rear y that matches its own fact position
269
+ # (the spawn-varied lane). Plain "build 4 rear at one latitude"
270
+ # also wins (count≥2 in region is satisfied) — that's fine, the
271
+ # CAPABILITY tested is "add depth at the live lane".
272
+ win_condition:
273
+ all_of:
274
+ - building_count_gte: {type: pbox, n: 6}
275
+ - building_count_gte: {type: fact, n: 1}
276
+ - within_ticks: 4500
277
+ - any_of:
278
+ - building_in_region: {x: 15, y: 14, radius: 4, type: pbox, count: 2}
279
+ - building_in_region: {x: 15, y: 26, radius: 4, type: pbox, count: 2}
280
+ fail_condition:
281
+ any_of:
282
+ - after_ticks: 4501
283
+ - not: {building_count_gte: {type: fact, n: 1}}
284
+ max_turns: 110
openra_bench/scenarios/packs/def-surprise-flank-react.yaml ADDED
@@ -0,0 +1,441 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # def-surprise-flank-react.yaml
2
+ #
3
+ # REASONING capability — Group C surprise-axis reaction. The model is
4
+ # told (in the brief, the "intel") that the enemy will attack from the
5
+ # NORTH, and the world is pre-rigged to MATCH that intel: 2 pillboxes
6
+ # are already standing NORTH of the base at (20,14)/(22,14), and four
7
+ # rifle defenders are already lined up NORTH at y≈14 next to the
8
+ # pre-built pillboxes — the canonical pre-positioned "prepared
9
+ # defence". BUT the enemy rush actually arrives from the SOUTH, walking
10
+ # around the entire NORTH pillbox line. The pre-built defenders + the
11
+ # pre-built pillbox triple do NOTHING — they sit in their northern
12
+ # lane while the rushers stride past them on the southern lane and
13
+ # raze the construction yard.
14
+ #
15
+ # The intended capability — and the SOLE difference between WIN and
16
+ # LOSS — is detecting the actual attack axis from the observation
17
+ # (rusher units visible to the south of the base, not the north) and
18
+ # RE-POSITIONING the four pre-placed defenders SOUTH to intercept the
19
+ # rush before it reaches the fact. The 2 NORTH pillboxes are dead
20
+ # wood for this engagement. The brief explicitly hints "intel said
21
+ # NORTH but be ready for anything" — the model must NOT trust the
22
+ # stated direction blindly and must commit to what it ACTUALLY
23
+ # observes.
24
+ #
25
+ # Real-world anchors:
26
+ # - Adversarial robustness on a directional axis: a stated cue is
27
+ # misdirection; the agent must react to the ground-truth signal,
28
+ # not the stated one.
29
+ # - Opponent-modelling under deception: detect that the announced
30
+ # plan and the observed plan differ; replan around the truth.
31
+ # - Military reactive defence / flank discipline: a fortified flank
32
+ # that the enemy has bypassed must be ABANDONED in favour of the
33
+ # actual contact axis; "fighting the plan, not the enemy" is the
34
+ # classical military failure mode this trains against.
35
+ # - CICERO deception handling: when announced behaviour contradicts
36
+ # observed behaviour, the agent should treat the announcement as
37
+ # a signal to NOT trust the plan and to commit to the observation.
38
+ #
39
+ # DISCRIMINATIONS (the scripted bar — every brute / lazy play LOSES,
40
+ # only the detect-and-redeploy play WINS):
41
+ #
42
+ # • stall (only `observe`): defenders idle NORTH of the base; the
43
+ # SOUTH rusher band marches straight at the fact (the agent
44
+ # centroid is dominated by the NORTH defenders so the band
45
+ # targets a north-of-fact point and PASSES THROUGH the fact on
46
+ # the way). Fact razed → `not has_building:fact` LOSS.
47
+ #
48
+ # • stay-NORTH-with-pre-built-defence (do not move defenders): the
49
+ # pre-built NORTH pillbox line is far enough north of the fact
50
+ # that the south-coming rusher band reaches the fact without
51
+ # ever entering pbox range. Defenders never engage because the
52
+ # rush is on the OTHER side of the base. Fact razed → LOSS.
53
+ # (This is the canonical "fighting the announced plan" trap.)
54
+ #
55
+ # • pure-build-no-react: the model uses the small starting cash to
56
+ # queue (or attempt to queue) more buildings but never moves the
57
+ # defenders. With no tent in the pre-placed base the infantry
58
+ # queue is empty, and a single pbox at 600cr placed somewhere
59
+ # near the existing NORTH line does NOT intercept the SOUTH
60
+ # rush either. Same outcome as stay-NORTH → LOSS.
61
+ #
62
+ # • intended detect-and-redeploy SOUTH: observe (rushers are to
63
+ # the south), `move_units` all four defenders south to a y≈26
64
+ # intercept line, then `attack_move` them onto the spotted
65
+ # rusher cluster. The 4 defenders trade ~4-vs-4 against the
66
+ # rifle infantry rush on favourable focus-fire terms (concentrated
67
+ # on the rusher centroid, no splash, e1-vs-e1 even), keep ≥3
68
+ # alive, kill ≥4 of the rushers, fact survives → WIN.
69
+ #
70
+ # ENGINE FACTS used (CLAUDE.md / SCENARIO_QUALITY.md):
71
+ # - The `rusher` bot drives every unit at the agent centroid every
72
+ # ~8 ticks (openra-sim/src/scripted_bot.rs::Rusher). With 4
73
+ # defenders NORTH at y=14 and the fact at y=20, the centroid is
74
+ # near y=17 — south-rusher-band targets y≈17 and walks straight
75
+ # through y=20 (the fact) to get there → fact razed in the
76
+ # stay-NORTH path. When the defenders relocate SOUTH, the
77
+ # centroid drops to y≈22..24 and the band engages defenders
78
+ # SOUTH of the fact (intercepted before fact contact).
79
+ # - Enemy actors with NO spawn_point ALWAYS place regardless of the
80
+ # agent's spawn group (per CLAUDE.md) — so on hard we tag the
81
+ # N/S agent groups and place TWO opposite-direction rusher
82
+ # bands; the seed-driven agent spawn dictates which intel-axis
83
+ # pair is the "surprise" one, but the bands themselves are
84
+ # symmetric.
85
+ # - Inert enemy `fact` marker far east (anti-DRAW): without a
86
+ # persistent enemy actor the engine auto-`done`s on enemy-elim
87
+ # before the win/fail evaluator runs.
88
+ # - `enemy_unit_spotted` interrupt → re-decide the instant the
89
+ # rusher band becomes visible (faster detection of the actual
90
+ # axis).
91
+ # - `own_unit_destroyed` interrupt → re-decide when a defender
92
+ # falls.
93
+ # - Tick budget: ~90 ticks/turn. `max_turns 50` → reachable tick
94
+ # 93 + 90·49 = 4503 ≥ 4501 (the after_ticks fail) so a
95
+ # non-winner is a real reachable timeout LOSS, never a draw.
96
+ # - No tent in the pre-placed base + cash 800 means the model
97
+ # CAN'T cheese the win by mass-training fresh defenders — the
98
+ # intended play is RE-POSITIONING the 4 starting defenders,
99
+ # which is the controlled capability.
100
+ #
101
+ # Distinct from siblings (Group C):
102
+ # * def-position-expected-direction — intel is TRUE; commit defence
103
+ # to the stated axis. Pre-built defence is the WIN play, not the
104
+ # trap.
105
+ # * def-pre-position-mobile-reserve — direction unknown; central
106
+ # reserve waits + commits on detection. No prior intel, no
107
+ # pre-built wrong-axis defence.
108
+ # * defense-rush-survive — opening rush vs sustained throughput;
109
+ # no directional misdirection.
110
+ # * THIS pack — intel is FALSE (or just unreliable). The pre-built
111
+ # defence + pre-positioned defenders are ON THE WRONG AXIS. The
112
+ # model must DETECT that the announced and observed axes differ
113
+ # and RE-POSITION accordingly.
114
+ #
115
+ # Validate (no model / no network):
116
+ # python3 -m openra_bench.scenarios.validate \
117
+ # openra_bench/scenarios/packs/def-surprise-flank-react.yaml
118
+
119
+ meta:
120
+ id: def-surprise-flank-react
121
+ title: 'Surprise Flank — Intel Said North, Enemy Came South: Detect and Redeploy'
122
+ capability: reasoning
123
+ real_world_meaning: >
124
+ The mission brief states the enemy will attack from the NORTH and
125
+ the world is pre-rigged accordingly: two pillboxes are already
126
+ standing NORTH of the base, four rifle defenders are pre-positioned
127
+ on the NORTH lane next to them — the canonical prepared defence.
128
+ BUT the rusher band actually arrives from the SOUTH and walks
129
+ around the entire northern fortification. The decision is whether
130
+ the agent trusts the announced direction blindly (and watches its
131
+ construction yard get razed while its pre-built defence sits idle)
132
+ or detects the actual attack axis from the observation, abandons
133
+ the wrong-axis prepared defence, and re-positions its defenders
134
+ SOUTH to intercept the real rush before it reaches the fact. The
135
+ pre-built NORTH pillboxes do nothing for this engagement; the
136
+ capability is the reactive re-positioning, not the build.
137
+ robotics_analogue: >
138
+ Misdirection-aware defence / military adversarial robustness on
139
+ a directional axis: a fortified flank that the enemy has bypassed
140
+ must be ABANDONED in favour of the actual contact axis, even
141
+ though doing so contradicts the announced plan and "wastes" the
142
+ pre-built fortification. The classical failure mode this trains
143
+ against is "fighting the plan, not the enemy": the operator
144
+ sticks to the announced threat axis even after the ground-truth
145
+ signal contradicts it. Robust security operations require that
146
+ the response policy treat announced threat intelligence as a
147
+ PRIOR, not a guarantee, and commit to the observed axis when
148
+ the two diverge.
149
+ benchmark_anchor:
150
+ - 'adversarial robustness / surprise-axis reaction'
151
+ - 'opponent-modeling: detect deception'
152
+ - 'military reactive defense / flank discipline'
153
+ - 'CICERO deception handling'
154
+ author: openra-bench
155
+
156
+ # rush-hour-arena (128×40, playable x∈[2..126], y∈[2..38]). Agent base
157
+ # at (20, 20): fact + 4 pre-placed e1 defenders + 2 pre-built pbox.
158
+ # The 2 pbox and the 4 defenders are NORTH of the base (y≈14..16) —
159
+ # the prepared "expected" defence per the intel. The rusher band
160
+ # spawns SOUTH (y≈32..34) and walks around the entire NORTH defence
161
+ # line on its way to the fact at (20, 20).
162
+ base_map: rush-hour-arena
163
+
164
+ # Pack-wide budget — a small float (800cr). Not enough to spam a
165
+ # second pillbox line on the south, and without a `tent` the infantry
166
+ # queue is empty so the cash can't be turned into fresh defenders.
167
+ # The intended play is RE-POSITIONING the four pre-placed defenders;
168
+ # the cash exists so the model has the OPTION to attempt a build
169
+ # (which is part of the discrimination — a pure-build-no-react policy
170
+ # also LOSES).
171
+ starting_cash: 800
172
+
173
+ base:
174
+ agent:
175
+ faction: allies
176
+ # Concentrated charge at the agent centroid every ~8 ticks — the
177
+ # canonical rush-defence opponent. The bot's SPAWN POSITION is the
178
+ # surprise: the brief says NORTH, the bot is placed SOUTH.
179
+ enemy:
180
+ faction: soviet
181
+ bot_type: rusher
182
+ tools:
183
+ - observe
184
+ - build
185
+ - place_building
186
+ - move_units
187
+ - attack_unit
188
+ - attack_move
189
+ - stop
190
+ planning: true
191
+ # React the INSTANT a rusher unit is spotted (the south-axis
192
+ # detection event) or a defender is lost — the re-positioning
193
+ # decision is reactive, not on fixed 90-tick boundaries.
194
+ interrupts:
195
+ enemy_unit_spotted: true
196
+ own_unit_destroyed: true
197
+ termination:
198
+ max_ticks: 12000
199
+ actors: []
200
+
201
+ levels:
202
+ # ── EASY ─────────────────────────────────────────────────────────
203
+ # The bare surprise-axis detect-and-react skill. One light SOUTH
204
+ # rush (4 e1). The model is TOLD "intel said NORTH but be ready
205
+ # for anything" in the brief — i.e. the prior is unreliable.
206
+ # Pre-built NORTH defence: 2 pillboxes at (20,14)/(22,14) and 4
207
+ # defenders along y=14. The intended play is moving all 4
208
+ # defenders south to y≈26 and attack-moving onto the south rush.
209
+ # max_turns 50 → reachable tick 93 + 90·49 = 4503 ≥ 4501, so the
210
+ # after_ticks fail bites comfortably before the turn cap.
211
+ easy:
212
+ description: >
213
+ INTEL said the enemy will attack from the NORTH — but be ready
214
+ for anything. You start with a construction yard at (20, 20),
215
+ two pillboxes already standing NORTH of the base at (20, 14)
216
+ and (22, 14), and four rifle defenders lined up next to them
217
+ on the NORTH lane (y=14..16). Available cash 800. Identify the
218
+ ACTUAL attack direction from your observation, then act:
219
+ destroy at least 4 enemy units AND keep ≥3 of your own units
220
+ alive AND keep the construction yard standing before tick
221
+ 4500. Stalling, leaving the defenders on the NORTH lane next
222
+ to the pre-built pillboxes, and building extra structures
223
+ without re-positioning the defenders all LOSE — the
224
+ construction yard is razed because the pre-built defence and
225
+ the pre-positioned defenders are on the WRONG axis.
226
+ overrides:
227
+ actors:
228
+ # Agent base — construction yard at the centre.
229
+ - {type: fact, owner: agent, position: [20, 20]}
230
+ # Pre-built NORTH pillbox line — the "prepared" defence per
231
+ # the intel. These are far enough NORTH of the fact (y=14
232
+ # vs fact y=20) that the south-coming rusher band reaches
233
+ # the fact without ever entering pbox range — they sit
234
+ # silent the entire engagement unless the agent leaves the
235
+ # defenders next to them (in which case the defenders also
236
+ # sit silent and the fact dies anyway).
237
+ - {type: pbox, owner: agent, position: [20, 14]}
238
+ - {type: pbox, owner: agent, position: [22, 14]}
239
+ # Four pre-placed e1 defenders lined up on the NORTH lane,
240
+ # stance:2 (defend — auto-fires at hostiles in range,
241
+ # doesn't chase). stance:2 keeps them parked on the wrong
242
+ # axis if the agent never issues a move; if the agent
243
+ # explicitly `move_units` them south, they relocate.
244
+ - {type: e1, owner: agent, position: [18, 14], stance: 0}
245
+ - {type: e1, owner: agent, position: [20, 16], stance: 0}
246
+ - {type: e1, owner: agent, position: [22, 16], stance: 0}
247
+ - {type: e1, owner: agent, position: [24, 14], stance: 0}
248
+ # SOUTH rusher band — 4× e1 at y=32. The rusher bot drives
249
+ # them at the agent centroid (~y=17 with 4 defenders
250
+ # NORTH); they walk straight NORTH through y=20 (the fact)
251
+ # on the way → fact razed unless intercepted SOUTH of it.
252
+ # 4 e1 vs 4 defenders engaged head-on with focus-fire
253
+ # trades cleanly (~1 defender lost on the engagement),
254
+ # the kill bar (4) is exactly met when the rush is wiped.
255
+ - {type: e1, owner: enemy, position: [20, 36], stance: 3, count: 4}
256
+ # Inert enemy `fact` marker far east — anti-DRAW per
257
+ # CLAUDE.md; without a persistent enemy actor the engine
258
+ # auto-`done`s on enemy-elim before the win/fail evaluator
259
+ # runs.
260
+ - {type: fact, owner: enemy, position: [120, 20]}
261
+ win_condition:
262
+ all_of:
263
+ - building_count_gte: {type: fact, n: 1}
264
+ - units_killed_gte: 4
265
+ - own_units_gte: 3
266
+ - within_ticks: 4500
267
+ fail_condition:
268
+ any_of:
269
+ - after_ticks: 4501
270
+ - not: {building_count_gte: {type: fact, n: 1}}
271
+ - not: {own_units_gte: 1}
272
+ max_turns: 50
273
+
274
+ # ── MEDIUM ───────────────────────────────────────────────────────
275
+ # Same surprise-axis discrimination, larger rush. The intel /
276
+ # pre-built layout is identical to easy; the rush is 5× e1 from
277
+ # the SOUTH (one more than easy). The 4-defender screen still
278
+ # wins on FAVOURABLE terms — committing south so the defenders
279
+ # engage the rush before it reaches the fact, focus-fire on the
280
+ # nearest rusher to the fact, take ≤1 loss; a late commit or a
281
+ # wait-at-fact passive defence either burns the fact OR over-
282
+ # trades the defender screen (busts `own_units_gte:3`).
283
+ # max_turns 50 → reachable tick 4503 ≥ 4501 (same as easy).
284
+ medium:
285
+ description: >
286
+ INTEL said the enemy will attack from the NORTH — but be ready
287
+ for anything. You start with a construction yard at (20, 20),
288
+ two pillboxes already standing NORTH of the base at (20, 14)
289
+ and (22, 14), and four rifle defenders lined up next to them
290
+ on the NORTH lane (y=14..16). Available cash 800. A stronger
291
+ surprise rush — 5 rifle infantry in a tight, fast cluster —
292
+ is incoming. Identify the ACTUAL attack direction from your
293
+ observation, then act: destroy at least 4 enemy units AND
294
+ keep ≥3 of your own units alive AND keep the construction
295
+ yard standing before tick 4500. Stalling, leaving the
296
+ defenders on the NORTH lane next to the pre-built pillboxes,
297
+ and building extra structures without re-positioning the
298
+ defenders all LOSE — the construction yard is razed and the
299
+ 4-defender screen is overrun if it commits late or never.
300
+ overrides:
301
+ actors:
302
+ - {type: fact, owner: agent, position: [20, 20]}
303
+ - {type: pbox, owner: agent, position: [20, 14]}
304
+ - {type: pbox, owner: agent, position: [22, 14]}
305
+ - {type: e1, owner: agent, position: [18, 14], stance: 0}
306
+ - {type: e1, owner: agent, position: [20, 16], stance: 0}
307
+ - {type: e1, owner: agent, position: [22, 16], stance: 0}
308
+ - {type: e1, owner: agent, position: [24, 14], stance: 0}
309
+ # Stronger surprise rush — a tighter, faster cluster of
310
+ # rifle infantry from the SOUTH (5 e1 at y=32). The same
311
+ # 4-defender screen has to commit FIRST (move toward the
312
+ # spotted rush, not wait for it to arrive at the fact); a
313
+ # late commit lets the rush close to the fact and trades
314
+ # the screen 2-for-2 → busts `own_units_gte:3`.
315
+ - {type: e1, owner: enemy, position: [20, 36], stance: 3, count: 5}
316
+ - {type: fact, owner: enemy, position: [120, 20]}
317
+ win_condition:
318
+ all_of:
319
+ - building_count_gte: {type: fact, n: 1}
320
+ - units_killed_gte: 4
321
+ - own_units_gte: 3
322
+ - within_ticks: 4500
323
+ fail_condition:
324
+ any_of:
325
+ - after_ticks: 4501
326
+ - not: {building_count_gte: {type: fact, n: 1}}
327
+ - not: {own_units_gte: 1}
328
+ max_turns: 50
329
+
330
+ # ── HARD ─────────────────────────────────────────────────────────
331
+ # +1 controlled variable: TWO spawn_point groups round-robin the
332
+ # AGENT's base latitude per seed (NORTH-staged base centred at
333
+ # (20, 12) OR SOUTH-staged base centred at (20, 28)). The intel
334
+ # in the brief always says "the enemy will attack from the side
335
+ # AWAY from your base" — i.e. on the NORTH spawn the intel says
336
+ # SOUTH (and the pre-built defence is south of the base); on the
337
+ # SOUTH spawn the intel says NORTH (and the pre-built defence is
338
+ # north of the base). The actual rush bands are placed on BOTH
339
+ # latitudes (per CLAUDE.md, enemy actors don't honour spawn_point)
340
+ # and the bot charges the agent centroid — so for EACH spawn the
341
+ # "matching" band approaches FROM THE SAME SIDE as the agent's
342
+ # base (the opposite side from the intel + pre-built defence),
343
+ # i.e. the SURPRISE axis matches across both seeds.
344
+ #
345
+ # The win condition has an `any_of` over the two correct intercept
346
+ # regions so a defender push to the WRONG side (the intel side /
347
+ # the pre-built side) fails to engage the band on the matching
348
+ # side. Kill bar (4), throughput (3) and fact-survival are
349
+ # unchanged; an attrition cap (units_lost_lte:2) tightens the
350
+ # margin — only a clean intercept on the matching side keeps
351
+ # losses ≤2.
352
+ hard:
353
+ description: >
354
+ INTEL said the enemy will attack from the side AWAY from your
355
+ base — but be ready for anything. Your base sits at one of two
356
+ latitudes (NORTH at y=12 OR SOUTH at y=28, chosen by seed).
357
+ The "prepared" defence — two pillboxes plus your four rifle
358
+ defenders — is pre-positioned on the OPPOSITE side of the
359
+ base from where you are (consistent with the intel). The
360
+ actual rush will arrive from the SAME side as your base (the
361
+ surprise axis). Identify the ACTUAL attack direction from your
362
+ observation, then re-position your defenders to that side and
363
+ intercept BEFORE the rush reaches the construction yard.
364
+ Destroy at least 4 enemy units AND keep ≥3 of your own alive
365
+ AND lose at most 2 defenders AND keep the construction yard
366
+ standing before tick 4500. Stalling, sticking with the
367
+ pre-built defence layout, and pure-building all LOSE.
368
+ overrides:
369
+ actors:
370
+ # ── spawn_point 0 — NORTH-staged base ───────────────────────
371
+ # Base at (20, 12). Intel "says SOUTH" → pre-built defence
372
+ # is SOUTH of the base at y=24, defenders parked next to it
373
+ # (stance:0 HoldFire: the prepared defence is RESERVED for
374
+ # its intel axis and does NOT auto-engage; only an explicit
375
+ # attack order makes them shoot). Surprise: actual matching
376
+ # rush band approaches from the NORTH at y=4 (the same side
377
+ # as the base — north of the pre-built defence). The rusher
378
+ # band charges the agent centroid and reaches the fact AT
379
+ # y=12 well before the def screen down at y=24 can engage
380
+ # (range ≈ 6 — 12 cells out of reach).
381
+ - {type: fact, owner: agent, position: [20, 12], spawn_point: 0}
382
+ - {type: pbox, owner: agent, position: [20, 24], spawn_point: 0}
383
+ - {type: pbox, owner: agent, position: [22, 24], spawn_point: 0}
384
+ - {type: e1, owner: agent, position: [18, 24], stance: 0, spawn_point: 0}
385
+ - {type: e1, owner: agent, position: [20, 26], stance: 0, spawn_point: 0}
386
+ - {type: e1, owner: agent, position: [22, 26], stance: 0, spawn_point: 0}
387
+ - {type: e1, owner: agent, position: [24, 24], stance: 0, spawn_point: 0}
388
+ # ── spawn_point 1 — SOUTH-staged base ───────────────────────
389
+ # Base at (20, 28). Intel "says NORTH" → pre-built defence
390
+ # is NORTH of the base at y=16, defenders parked next to it
391
+ # (stance:0). Surprise: actual matching rush band approaches
392
+ # from the SOUTH at y=36 (the same side as the base — south
393
+ # of the pre-built defence). The fact at y=28 is 12 cells
394
+ # south of the def screen — out of range.
395
+ - {type: fact, owner: agent, position: [20, 28], spawn_point: 1}
396
+ - {type: pbox, owner: agent, position: [20, 16], spawn_point: 1}
397
+ - {type: pbox, owner: agent, position: [22, 16], spawn_point: 1}
398
+ - {type: e1, owner: agent, position: [18, 16], stance: 0, spawn_point: 1}
399
+ - {type: e1, owner: agent, position: [20, 14], stance: 0, spawn_point: 1}
400
+ - {type: e1, owner: agent, position: [22, 14], stance: 0, spawn_point: 1}
401
+ - {type: e1, owner: agent, position: [24, 16], stance: 0, spawn_point: 1}
402
+ # ── Enemy rush bands ────────────────────────────────────────
403
+ # CLAUDE.md: enemy actors WITHOUT spawn_point ALWAYS place
404
+ # regardless of the agent's spawn group, so we declare BOTH a
405
+ # NORTH and a SOUTH band. For spawn_point 0 (NORTH base) the
406
+ # MATCHING band is the NORTH one (y=4) — same side as the
407
+ # base, opposite the pre-built defence. For spawn_point 1
408
+ # (SOUTH base) the MATCHING band is the SOUTH one (y=36).
409
+ # The `rusher` bot charges the agent CENTROID — which sits
410
+ # near the base on each spawn (the pre-built defenders are
411
+ # only 2-4 cells off so the centroid stays close to the
412
+ # base) — so the matching-side band has the SHORTER path
413
+ # and is the immediate threat; the opposite-side band has
414
+ # to walk across the whole map to arrive.
415
+ - {type: e1, owner: enemy, position: [20, 4], stance: 3, count: 4}
416
+ - {type: e1, owner: enemy, position: [20, 36], stance: 3, count: 4}
417
+ # Note: enemies don't honour spawn_point — both bands always
418
+ # place. The matching-side band (same latitude as the agent
419
+ # base) is the immediate threat and the trigger for the
420
+ # detect-and-redeploy decision. The opposite-side band has
421
+ # to walk the full map width (28+ cells) before becoming a
422
+ # serious threat — well outside the 4500-tick budget.
423
+ # Inert enemy `fact` marker far east (anti-DRAW). Placed
424
+ # off-axis (y=2) so the bands don't include it in their
425
+ # centroid target and it doesn't accidentally end the
426
+ # episode early via enemy-elim auto-done.
427
+ - {type: fact, owner: enemy, position: [120, 2]}
428
+ win_condition:
429
+ all_of:
430
+ - building_count_gte: {type: fact, n: 1}
431
+ - units_killed_gte: 4
432
+ - own_units_gte: 3
433
+ - units_lost_lte: 2
434
+ - within_ticks: 4500
435
+ fail_condition:
436
+ any_of:
437
+ - after_ticks: 4501
438
+ - not: {building_count_gte: {type: fact, n: 1}}
439
+ - not: {own_units_gte: 1}
440
+ - not: {units_lost_lte: 2}
441
+ max_turns: 50
openra_bench/scenarios/packs/def-tower-line-vs-cluster.yaml ADDED
@@ -0,0 +1,232 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ meta:
2
+ id: def-tower-line-vs-cluster
3
+ title: 'Defense Topology — Cluster Towers at the Choke (Not a Spread Line)'
4
+ capability: reasoning
5
+ real_world_meaning: >
6
+ Where do you place defensive structures when the threat is forced
7
+ through a known geographic bottleneck? Graph min-cut theory and
8
+ military bunker placement doctrine both say: concentrate dense
9
+ coverage at the choke, not a thin spread along the perimeter. A
10
+ spread line wastes coverage on lanes the enemy never uses; a
11
+ cluster at the bottleneck multiplies overlapping fields of fire
12
+ exactly where the threat must pass. The win predicate makes the
13
+ topology decision load-bearing — total pbox count alone is not
14
+ enough; ≥3 of the pillboxes must be inside the choke region.
15
+ robotics_analogue: >
16
+ Firewall / IDS topology design: when an attacker MUST traverse a
17
+ known ingress (the public WAN edge / the only API gateway / the
18
+ one bridge between two network segments), the right architecture
19
+ is dense layered inspection AT that point, not a thin even spread
20
+ across every internal segment. Distributing the same enforcement
21
+ budget evenly leaves the actual ingress under-protected while
22
+ nothing else is ever traversed.
23
+ benchmark_anchor:
24
+ - "graph theory min-cut (concentrate defenses at chokepoints)"
25
+ - "military bunker placement doctrine"
26
+ - "firewall topology: dense at chokepoints"
27
+ - "Lanchester defense concentration"
28
+ author: openra-bench
29
+
30
+ # rush-hour-arena (128×40). The map has a narrow lane around y≈18..22
31
+ # at mid-map (x≈60); the `rusher` scripted bot charges the agent's
32
+ # centroid (the agent fact on the west), which forces its path THROUGH
33
+ # that lane mouth on every seed. Agent base is pre-placed at the west
34
+ # (fact + tent + powr pre-built so the pbox queue is ready turn 1).
35
+ # Pack-level cash is overridden per-level (3/4/5 pbox at 600cr each).
36
+ # An unarmed high-HP enemy `fact` far east keeps the engine alive past
37
+ # any rusher annihilation so the win/fail check actually runs.
38
+ base_map: rush-hour-arena
39
+ starting_cash: 2400
40
+
41
+ base:
42
+ agent:
43
+ faction: allies
44
+ enemy:
45
+ faction: soviet
46
+ bot_type: rusher
47
+ tools:
48
+ - observe
49
+ - build
50
+ - place_building
51
+ - move_units
52
+ - attack_unit
53
+ - attack_move
54
+ - stop
55
+ planning: true
56
+ # No interrupts — defense topology is a STATIC up-front decision (the
57
+ # choke location is known a priori, the rush composition is fixed).
58
+ # Dropping interrupts also makes the tick budget deterministic
59
+ # (each step is exactly 90 ticks, so max_turns ⇒ max_tick = 93+90·(N-1)
60
+ # is reached every run), which is what makes the `after_ticks` fail
61
+ # clause actually bite for stall / pure-army timeout policies.
62
+ termination:
63
+ max_ticks: 12000
64
+ actors:
65
+ # Pre-placed agent base on the WEST (so rusher path is forced
66
+ # through the mid-map choke on the way to the fact centroid).
67
+ - {type: fact, owner: agent, position: [10, 20]}
68
+ - {type: tent, owner: agent, position: [14, 18]}
69
+ - {type: powr, owner: agent, position: [14, 22]}
70
+
71
+ levels:
72
+ # ── EASY ── bare topology skill. Budget covers exactly 3 pbox
73
+ # (1800cr). Win requires ALL 3 placed at the choke (3 of 3). A
74
+ # spread-line layout (any pbox AWAY from the choke region) cannot
75
+ # satisfy both `building_count_gte:3` AND `building_in_region:3`
76
+ # simultaneously. Stall loses on clock OR fact razed; spread-line
77
+ # loses on the region clause; pure-army-no-pbox loses on the count
78
+ # clause AND likely loses the fact. max_turns 60 → reachable tick
79
+ # 93+90·59 = 5403; deadline 5400.
80
+ easy:
81
+ description: >
82
+ A rusher band must traverse the narrow lane at (60, 18..22) to
83
+ reach your base on the west. Build 3 pillboxes (pbox — 600cr
84
+ each, budget exactly 1800) AND place ALL 3 inside the choke at
85
+ (60, 20) within radius 5, so the rusher walks into overlapping
86
+ fields of fire at the bottleneck. A spread line (any pbox away
87
+ from the choke) cannot satisfy both the count and the region
88
+ bars at once. Stalling, pure-army (no pbox), and spread layouts
89
+ all lose. Your fact must survive.
90
+ starting_cash: 1800
91
+ overrides:
92
+ actors:
93
+ - {type: fact, owner: agent, position: [10, 20]}
94
+ - {type: tent, owner: agent, position: [14, 18]}
95
+ - {type: powr, owner: agent, position: [14, 22]}
96
+ # 4 mobile defenders pre-placed (the spec calls for ~4-5).
97
+ - {type: e1, owner: agent, position: [12, 19], stance: 2}
98
+ - {type: e1, owner: agent, position: [12, 21], stance: 2}
99
+ - {type: e1, owner: agent, position: [16, 19], stance: 2}
100
+ - {type: e1, owner: agent, position: [16, 21], stance: 2}
101
+ # Rusher band at far east — forced through the mid-map choke
102
+ # on the way to the agent fact centroid (10, 20).
103
+ - {type: e1, owner: enemy, position: [100, 20], stance: 3, count: 4}
104
+ - {type: e3, owner: enemy, position: [102, 20], stance: 3, count: 1}
105
+ # Unarmed high-HP marker (anti-DRAW): keeps the episode alive
106
+ # past full rusher elimination so the win/fail check fires.
107
+ - {type: fact, owner: enemy, position: [120, 20]}
108
+ win_condition:
109
+ all_of:
110
+ - building_count_gte: {type: pbox, n: 3}
111
+ - building_in_region: {type: pbox, x: 60, y: 20, radius: 5, count: 3}
112
+ - building_count_gte: {type: fact, n: 1}
113
+ - within_ticks: 5400
114
+ fail_condition:
115
+ any_of:
116
+ - after_ticks: 5401
117
+ - not: {building_count_gte: {type: fact, n: 1}}
118
+ max_turns: 60
119
+
120
+ # ── MEDIUM ── +1 axis: total budget grows to 4 pbox (2400cr) but
121
+ # the cluster bar is still 3 — so the agent must place 3 of 4 at
122
+ # the choke (one "spare" allowed elsewhere, e.g. covering the base).
123
+ # A naive spread layout (1 at choke, 3 spread) satisfies the count
124
+ # but NOT the region. The intended cluster (3 at choke + 1 free)
125
+ # satisfies both. max_turns 60 → reachable tick 5403; deadline 5400.
126
+ medium:
127
+ description: >
128
+ Same forced rusher path through the (60, 20) choke. Build 4
129
+ pillboxes (budget 2400cr = exactly 4 pbox at 600 each) AND
130
+ place at least 3 of them inside the choke region (radius 5
131
+ around (60, 20)). A spread-line (1 at the choke, 3 elsewhere)
132
+ meets the count but FAILS the region bar; the intended cluster
133
+ (3 at the choke + 1 spare) wins. Stalling, pure-army, and
134
+ spread-line layouts all lose; the fact must survive.
135
+ starting_cash: 2400
136
+ overrides:
137
+ actors:
138
+ - {type: fact, owner: agent, position: [10, 20]}
139
+ - {type: tent, owner: agent, position: [14, 18]}
140
+ - {type: powr, owner: agent, position: [14, 22]}
141
+ # 5 mobile defenders pre-placed.
142
+ - {type: e1, owner: agent, position: [12, 19], stance: 2}
143
+ - {type: e1, owner: agent, position: [12, 21], stance: 2}
144
+ - {type: e1, owner: agent, position: [16, 19], stance: 2}
145
+ - {type: e1, owner: agent, position: [16, 21], stance: 2}
146
+ - {type: e1, owner: agent, position: [14, 20], stance: 2}
147
+ # Slightly heavier rusher band than easy (5 e1, staged at
148
+ # x=115 — one extra rifle infantry vs easy's 4, no e3, so the
149
+ # 4-pbox cluster has time to assemble before the band breaks
150
+ # through the pre-placed defenders).
151
+ - {type: e1, owner: enemy, position: [115, 20], stance: 3, count: 5}
152
+ # Anti-DRAW marker (see easy).
153
+ - {type: fact, owner: enemy, position: [120, 20]}
154
+ win_condition:
155
+ all_of:
156
+ - building_count_gte: {type: pbox, n: 4}
157
+ - building_in_region: {type: pbox, x: 60, y: 20, radius: 5, count: 3}
158
+ - building_count_gte: {type: fact, n: 1}
159
+ - within_ticks: 5400
160
+ fail_condition:
161
+ any_of:
162
+ - after_ticks: 5401
163
+ - not: {building_count_gte: {type: fact, n: 1}}
164
+ max_turns: 60
165
+
166
+ # ── HARD ── +1 axis: TWO spawn_point groups so the agent base
167
+ # latitude flips by seed (NORTH (10,10) vs SOUTH (10,30)). Both
168
+ # candidate choke regions are valid via `any_of` — the agent must
169
+ # cluster ≥4 of 5 pbox at EITHER (60, 20) or (40, 20) — so a
170
+ # single memorised cluster cell still works for both seeds but the
171
+ # base-latitude flip enforces the hard-tier spawn contract (the
172
+ # required cluster is geographically distant from EACH base, so the
173
+ # placement plan can't be a stamped relative-to-base offset that
174
+ # happens to land in the same world cell on every seed). Enemies
175
+ # don't honor spawn_point (engine: oramap.rs::expand_scenario_actors)
176
+ # so the rusher band is placed symmetrically at y=20 between the
177
+ # two candidate chokes; the rusher charges agent centroid so its
178
+ # path crosses x∈[40,60] regardless of latitude. Total pbox grows
179
+ # to 5 (3000cr) and the cluster bar grows to 4 of 5 at one choke.
180
+ # max_turns 70 → reachable tick 93+90·69 = 6303; deadline 6300.
181
+ hard:
182
+ description: >
183
+ Agent base latitude flips between NORTH (y=10) and SOUTH (y=30)
184
+ by seed. Build 5 pillboxes (budget 3000cr = exactly 5 pbox at
185
+ 600 each) AND place at least 4 of them inside ONE of the two
186
+ candidate choke regions (radius 5 around either (60, 20) or
187
+ (40, 20)). Spreading across both chokes (e.g. 3+2) FAILS the
188
+ 4-of-5 cluster bar; a thin perimeter line FAILS both choke
189
+ bars. The fact must survive.
190
+ starting_cash: 3000
191
+ overrides:
192
+ actors:
193
+ # spawn_point 0 — NORTH base. Fact at (10, 10); tent/powr
194
+ # west-of-fact so they aren't directly in the rusher path.
195
+ - {type: fact, owner: agent, position: [10, 10], spawn_point: 0}
196
+ - {type: tent, owner: agent, position: [6, 10], spawn_point: 0}
197
+ - {type: powr, owner: agent, position: [6, 12], spawn_point: 0}
198
+ - {type: e1, owner: agent, position: [12, 10], stance: 2, spawn_point: 0}
199
+ - {type: e1, owner: agent, position: [12, 11], stance: 2, spawn_point: 0}
200
+ - {type: e1, owner: agent, position: [12, 9], stance: 2, spawn_point: 0}
201
+ - {type: e1, owner: agent, position: [14, 10], stance: 2, spawn_point: 0}
202
+ - {type: e1, owner: agent, position: [8, 10], stance: 2, spawn_point: 0}
203
+ # spawn_point 1 — SOUTH base, mirror at y=30.
204
+ - {type: fact, owner: agent, position: [10, 30], spawn_point: 1}
205
+ - {type: tent, owner: agent, position: [6, 30], spawn_point: 1}
206
+ - {type: powr, owner: agent, position: [6, 32], spawn_point: 1}
207
+ - {type: e1, owner: agent, position: [12, 30], stance: 2, spawn_point: 1}
208
+ - {type: e1, owner: agent, position: [12, 31], stance: 2, spawn_point: 1}
209
+ - {type: e1, owner: agent, position: [12, 29], stance: 2, spawn_point: 1}
210
+ - {type: e1, owner: agent, position: [14, 30], stance: 2, spawn_point: 1}
211
+ - {type: e1, owner: agent, position: [8, 30], stance: 2, spawn_point: 1}
212
+ # Enemies don't honor spawn_point (engine: oramap.rs); the
213
+ # rusher band sits at y=20 (mid-latitude) and charges the
214
+ # agent centroid, so its path crosses BOTH candidate choke
215
+ # x-columns regardless of which base latitude the seed picks.
216
+ - {type: e1, owner: enemy, position: [100, 20], stance: 3, count: 6}
217
+ - {type: e3, owner: enemy, position: [102, 20], stance: 3, count: 2}
218
+ # Anti-DRAW marker.
219
+ - {type: fact, owner: enemy, position: [120, 20]}
220
+ win_condition:
221
+ all_of:
222
+ - building_count_gte: {type: pbox, n: 5}
223
+ - any_of:
224
+ - building_in_region: {type: pbox, x: 60, y: 20, radius: 5, count: 4}
225
+ - building_in_region: {type: pbox, x: 40, y: 20, radius: 5, count: 4}
226
+ - building_count_gte: {type: fact, n: 1}
227
+ - within_ticks: 6300
228
+ fail_condition:
229
+ any_of:
230
+ - after_ticks: 6301
231
+ - not: {building_count_gte: {type: fact, n: 1}}
232
+ max_turns: 70
openra_bench/scenarios/packs/econ-cash-reserve-management.yaml ADDED
@@ -0,0 +1,232 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ meta:
2
+ id: econ-cash-reserve-management
3
+ title: Economy — Cash Reserve Management (Spend Without Going Broke)
4
+ capability: reasoning
5
+ real_world_meaning: >
6
+ A treasury-management decision under a deadline. The agent has a
7
+ productive operation (two harvesters on two near patches feeding a
8
+ refinery — steady income), starting cash 1500, and a hard win
9
+ requirement to grow operations (build MORE buildings) WHILE also
10
+ finishing the episode with a positive operating reserve in the
11
+ bank (cash ≥ 300). The classic anti-patterns:
12
+ 1) STALL — preserve every credit, build nothing. The reserve stays
13
+ safe but operations never grow → the "build more buildings" bar
14
+ is never met → LOSS.
15
+ 2) OVER-SPEND — spend everything (and then some, by chaining a
16
+ 1400cr refinery and any other expensive build) on construction
17
+ and dip the reserve to zero. Income eventually refills but the
18
+ window between buildings-met and reserve-refilled may not close
19
+ before the deadline → LOSS.
20
+ 3) PURE-HOLD — harvest and sit on the cash forever, never building.
21
+ Same as STALL on the building-growth axis → LOSS.
22
+ The intended capability is the SC2 cash-overflow / corporate
23
+ treasury discipline: spend ENOUGH to keep growing (buildings) while
24
+ keeping ENOUGH liquid (cash) to weather the next shock — never let
25
+ the operating reserve hit zero, never sit on idle capital. With a
26
+ pre-placed 2× harv income engine the model gets ~190 cr/turn of
27
+ inflow, so any modest spend (1-2 powr at 300cr each) is recovered
28
+ in a handful of turns and the reserve closes the episode well
29
+ above 300.
30
+ robotics_analogue: >
31
+ Operating-reserve management in an autonomous fleet's treasury:
32
+ revenue from collectors flows into a single pool that must fund
33
+ both ongoing capacity expansion (added depot / processing / defence
34
+ nodes) AND a minimum operating reserve (covering the next
35
+ contingency — a damaged collector, a sudden parts order). A pure-
36
+ hoarding policy fails the growth mandate; a blow-the-reserve policy
37
+ succumbs to the next shock. The discipline being measured is the
38
+ operations-finance loop: spend at the income-replenishment rate, not
39
+ above and not below.
40
+ author: catalog-f8
41
+ benchmark_anchor:
42
+ - SC2 cash management (spend while not overflowing)
43
+ - financial runway management (operate while keeping reserve)
44
+ - "treasury management: cash conversion cycle"
45
+ - operational reserve / working capital
46
+
47
+ # ENGINE NOTE (verified 2026-05-20 against installed openra_train wheel,
48
+ # post-S0/S1 harvest income — Task #14, scripted run_level seeds 1-4):
49
+ # 1. Pre-placed 2× harv on 2× mine (NEAR cells) yields ~190 cr/turn
50
+ # sustained against the single pre-placed `proc` at (12,18). Over
51
+ # a 60-turn (5400-tick) window that's ~11400 cr of income inflow,
52
+ # on top of the starting_cash 1500.
53
+ # 2. Cost table (verified against gamerules.rs / game_data.py):
54
+ # powr = 300 (no prereq, +100 power)
55
+ # tent = 500 (prereq powr, −20 power) — PRE-PLACED here
56
+ # proc = 1400 (prereq powr, −30 power) — PRE-PLACED here
57
+ # pbox = 600 (prereq tent) — only buildable because tent is
58
+ # pre-placed
59
+ # silo = 150 (prereq proc) — cheap storage
60
+ # harv = 1100 (prereq proc)
61
+ # With tent pre-placed, the agent can queue powr/pbox/silo/harv in
62
+ # parallel to the existing fact+proc+tent+powr base.
63
+ # 3. The decision under test is "how much to spend": starting_cash 1500
64
+ # funds:
65
+ # 2× powr (600) → cash 900 → reserve safe, building_total 6
66
+ # 2× pbox (1200) → cash 300 → reserve at the bar, building_total 6
67
+ # 1× proc (1400) → cash 100 → BELOW the 300 reserve bar
68
+ # 1× proc + 1× powr (1700) → impossible until income refills
69
+ # The lazy "build something cheap" play (2 powr) wins comfortably;
70
+ # the "blow the bank" play (proc immediately) goes below the reserve
71
+ # bar at the moment building_total reaches 6 and must wait for
72
+ # income to refill — which can happen well before the 5400-tick
73
+ # deadline, so we DOCUMENT this is a SOFT failure mode (a slow
74
+ # rebuilder still wins) rather than predicate-enforced.
75
+ # 4. Tick budget: engine advances ~90 ticks per decision turn.
76
+ # Easy uses max_turns 60 → ceiling 5403 > 5400 (within_ticks 5400)
77
+ # — generous, the loose-bar tier. Medium tightens to max_turns 14
78
+ # → ceiling 1263 > 1200 (within_ticks 1200) so an over-spending
79
+ # policy (proc 1400 + another building → cash dips to 0, must wait
80
+ # ~10+ turns of income to refill above the 300 reserve) cannot
81
+ # recover before the deadline; the lean 2× powr commit (600 spend,
82
+ # cash 900) wins at tick ~450. Hard tightens further to max_turns
83
+ # 12 → ceiling 1083 > 1080 (within_ticks 1080) AND raises the
84
+ # reserve to 500 so even less recovery slack exists. fail_condition
85
+ # uses after_ticks = within_ticks + 1 (a non-finisher LOSES, not
86
+ # draws — engine ceiling stays one tick past after_ticks so the
87
+ # fail clause fires).
88
+ # 5. The `harvest` order with a target cell directs the existing harvs
89
+ # onto the mines; the auto-cycle continues. Pre-placed harvs at
90
+ # (14,18) and (14,20) bind to mines at (22,18) and (22,20)
91
+ # respectively. The pre-placed e1 at (120,36) stance:0 prevents
92
+ # ConquestVictoryConditions from auto-`done`ing the episode before
93
+ # the win/fail predicate evaluates (CLAUDE.md engine footgun).
94
+ base_map: rush-hour-arena
95
+ starting_cash: 1500
96
+
97
+ base:
98
+ agent:
99
+ faction: allies
100
+ enemy:
101
+ faction: soviet
102
+ tools:
103
+ - observe
104
+ - build
105
+ - place_building
106
+ - harvest
107
+ - move_units
108
+ - stop
109
+ planning: true
110
+ termination:
111
+ max_ticks: 40000
112
+ actors:
113
+ # Pre-placed: 4 buildings (fact + proc + tent + powr) and 2 harvs
114
+ # on 2 near mines. The agent starts at building_total=4 and must
115
+ # build ≥2 more to reach the building_total_gte:6 bar without
116
+ # busting the cash_gte:300 reserve.
117
+ - {type: fact, owner: agent, position: [10, 22]}
118
+ - {type: proc, owner: agent, position: [12, 18]}
119
+ - {type: tent, owner: agent, position: [10, 18]}
120
+ - {type: powr, owner: agent, position: [14, 22]}
121
+ - {type: harv, owner: agent, position: [14, 18]}
122
+ - {type: harv, owner: agent, position: [14, 20]}
123
+ # Two near ore patches — steady ~190cr/turn income for the two
124
+ # pre-placed harvs.
125
+ - {type: mine, owner: neutral, position: [22, 18]}
126
+ - {type: mine, owner: neutral, position: [22, 20]}
127
+ # Inert far enemy marker keeps the episode from auto-terminating
128
+ # on "all enemies dead" before the win/fail predicate fires.
129
+ - {type: e1, owner: enemy, position: [120, 36], stance: 0}
130
+
131
+ levels:
132
+ easy:
133
+ description: >
134
+ Loose-bar cash-reserve test. You start with 4 buildings (fact,
135
+ proc, tent, powr), 2 harvesters on 2 near ore patches, and
136
+ $1500. Build at least ONE more building (the cheapest is a
137
+ Power Plant at $300) AND keep at least $300 in the bank at the
138
+ moment the bar latches AND keep both harvesters alive — all
139
+ within tick 5400. Stalling (build nothing) loses on the
140
+ building bar; the harvesters keep refilling cash, so any modest
141
+ spend wins. Pure-hold and over-spend (a $1400 refinery dips
142
+ cash below the reserve at the latch moment) both fail.
143
+ starting_cash: 1500
144
+ win_condition:
145
+ all_of:
146
+ - building_total_gte: 5
147
+ - cash_gte: 300
148
+ - unit_type_count_gte: {type: harv, n: 2}
149
+ - within_ticks: 5400
150
+ fail_condition:
151
+ any_of:
152
+ - after_ticks: 5401
153
+ - not: {has_building: fact}
154
+ max_turns: 60
155
+ medium:
156
+ description: >
157
+ Tighter clock, same income engine: build at least TWO more
158
+ buildings (building_total ≥ 6, i.e. add at least 2 — e.g. two
159
+ Power Plants at $300 each, or one Pillbox at $600 plus a Power
160
+ Plant) AND keep $300 in the bank AND keep both harvesters
161
+ alive — within tick 810 (≈ 10 turns). The cheap 2× powr
162
+ commit costs $600, leaves $900 cash (well above the 300
163
+ reserve), and finishes by tick ~450 — comfortable slack.
164
+ Over-spending on a 2nd refinery ($1400) dips cash to $100 at
165
+ the latch moment; an extra build at $300+ pushes it lower.
166
+ Income refills cash at ~95 cr/turn so re-clearing the reserve
167
+ after over-spend takes ≥3-4 turns, pushing the win past tick
168
+ 810. Stall and pure-hold lose on the building bar.
169
+ starting_cash: 1500
170
+ win_condition:
171
+ all_of:
172
+ - building_total_gte: 6
173
+ - cash_gte: 300
174
+ - unit_type_count_gte: {type: harv, n: 2}
175
+ - within_ticks: 810
176
+ fail_condition:
177
+ any_of:
178
+ - after_ticks: 811
179
+ - not: {has_building: fact}
180
+ max_turns: 10
181
+ hard:
182
+ description: >
183
+ Tightest growth bar, larger reserve, and seed-varied starts.
184
+ Build at least THREE more buildings (building_total ≥ 7) AND
185
+ keep $500 in the bank AND keep both harvesters alive — within
186
+ tick 1080 (≈ 12 turns). The cheapest 3-building commit (3×
187
+ powr = $900) leaves $600 cash, just above the $500 reserve;
188
+ mixing a Pillbox in ($600 + $600 = $1200) leaves $300, BELOW
189
+ the $500 reserve until income refills. Over-spending on a 2nd
190
+ refinery ($1400) dips cash far below 500 and cannot recover
191
+ inside 1080 ticks. The base is round-robined between NORTH
192
+ (proc/tent/powr/harvs around y=14) and SOUTH (around y=28)
193
+ per seed; the two near patches (y=14 and y=28) sit on the
194
+ spawn-matched row so the throughput is symmetric per spawn,
195
+ but a memorised opening cannot generalise across seeds.
196
+ starting_cash: 1500
197
+ overrides:
198
+ actors:
199
+ # spawn_point 0 — NORTH base, harvs on the y=14 patches.
200
+ - {type: fact, owner: agent, position: [10, 14], spawn_point: 0}
201
+ - {type: proc, owner: agent, position: [12, 14], spawn_point: 0}
202
+ - {type: tent, owner: agent, position: [10, 18], spawn_point: 0}
203
+ - {type: powr, owner: agent, position: [14, 14], spawn_point: 0}
204
+ - {type: harv, owner: agent, position: [16, 14], spawn_point: 0}
205
+ - {type: harv, owner: agent, position: [16, 15], spawn_point: 0}
206
+ # spawn_point 1 — SOUTH base, harvs on the y=28 patches.
207
+ - {type: fact, owner: agent, position: [10, 28], spawn_point: 1}
208
+ - {type: proc, owner: agent, position: [12, 28], spawn_point: 1}
209
+ - {type: tent, owner: agent, position: [10, 24], spawn_point: 1}
210
+ - {type: powr, owner: agent, position: [14, 28], spawn_point: 1}
211
+ - {type: harv, owner: agent, position: [16, 28], spawn_point: 1}
212
+ - {type: harv, owner: agent, position: [16, 29], spawn_point: 1}
213
+ # Two mine pairs — one for each spawn row. spawn_point filter
214
+ # applies ONLY to agent actors (CLAUDE.md engine fact), so
215
+ # both pairs always place; the spawn-matched pair feeds the
216
+ # spawn-matched harvs.
217
+ - {type: mine, owner: neutral, position: [22, 14]}
218
+ - {type: mine, owner: neutral, position: [22, 15]}
219
+ - {type: mine, owner: neutral, position: [22, 28]}
220
+ - {type: mine, owner: neutral, position: [22, 29]}
221
+ - {type: e1, owner: enemy, position: [120, 36], stance: 0}
222
+ win_condition:
223
+ all_of:
224
+ - building_total_gte: 7
225
+ - cash_gte: 500
226
+ - unit_type_count_gte: {type: harv, n: 2}
227
+ - within_ticks: 1080
228
+ fail_condition:
229
+ any_of:
230
+ - after_ticks: 1081
231
+ - not: {has_building: fact}
232
+ max_turns: 12
openra_bench/scenarios/packs/econ-recover-from-zero-cash.yaml ADDED
@@ -0,0 +1,285 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Validate: python -m openra_bench.scenarios.validate packs/econ-recover-from-zero-cash.yaml
2
+ # See ../CONTRIBUTING.md for the full win-condition grammar.
3
+ #
4
+ # ECON-RECOVER-FROM-ZERO-CASH (Group F — turtle-recovery / bankruptcy
5
+ # turnaround from a minimum-viable kit and ZERO cash).
6
+ #
7
+ # Real-world anchor: SC2 turtle-back-to-eco (lost everything, rebuild
8
+ # slowly with a sole surviving worker), business bankruptcy turnaround
9
+ # (one operating asset left, no working capital, rebuild revenue first
10
+ # then reinvest), military recovery from defeat (rebuild force structure
11
+ # starting from a skeleton crew), startup pivot after a runway crisis.
12
+ #
13
+ # Pack frame: the agent starts with a MINIMUM VIABLE KIT (fact + proc +
14
+ # one harvester + one mine) and **0 cash**. There is no enemy aggression.
15
+ # The decision is the RECOVERY PROFILE — commit to harvest first, let
16
+ # income accumulate, then reinvest in the next productive asset (a war
17
+ # factory + a second harvester) so the income channel actually scales
18
+ # past a non-trivial revenue bar. A model that:
19
+ # * stalls (zero income forever) ⇒ never harvests, EV stays at 0, LOSS
20
+ # * builds-too-soon (e.g. queues a SECOND refinery or buys army)
21
+ # ⇒ drains harvest cash to the wrong asset, never funds the weap+harv
22
+ # chain, fails the 2-harv requirement ⇒ LOSS
23
+ # * builds-army-from-zero (powr → tent → spam e1 instead of weap+harv)
24
+ # ⇒ never builds a 2nd harv ⇒ LOSS
25
+ # all miss the bar. The intended slow-and-steady recovery (harvest,
26
+ # accumulate to 2000, build('weap'), then build('harv'), redirect the
27
+ # new harv to the patch, keep harvesting) WINS.
28
+ #
29
+ # Why this pack is different from `economy-harvest-timebox`:
30
+ # * `economy-harvest-timebox` starts with 300-1500 cash and only tests
31
+ # "commit to harvest" (or build a 2nd harv with the starting budget).
32
+ # * `econ-recover-from-zero-cash` starts with **0 cash** and forces
33
+ # the agent to COMPLETE the income-then-reinvest loop: there is no
34
+ # starting buffer to spend, so the test is the recovery DISCIPLINE —
35
+ # do not spend before income flows; do not spend on the wrong asset
36
+ # when income arrives; commit to the harvest→weap→harv sequence.
37
+ #
38
+ # Why this pack is different from `econ-startup-from-scratch`:
39
+ # * `econ-startup-from-scratch` tests the COLD-START opening (deploy
40
+ # MCV → powr → proc → harv) from a 1900-cash budget. The agent has
41
+ # no buildings.
42
+ # * `econ-recover-from-zero-cash` tests RECOVERY FROM SETBACK (you
43
+ # once had a base, now you have only fact+proc+1 harv and 0 cash —
44
+ # rebuild revenue and re-scale the worker count). The chain is
45
+ # harvest → weap → harv (the cold-start "powr → proc" part is
46
+ # already paid for by the survived infrastructure).
47
+ #
48
+ # ENGINE FACTS verified (CLAUDE.md):
49
+ # 1. starting_cash: 0 is supported (see mcv-deploy-relocate-under-pressure,
50
+ # mfb-base-1-defend-base-2-build). The `build` order with insufficient
51
+ # cash defers production (the queue waits until cash is available).
52
+ # 2. harv prereq is `weap` (gamerules.rs line 328-330). weap (cost 2000)
53
+ # prereq is `proc` (already pre-placed). harv cost is 1400.
54
+ # 3. tent prereq is `powr` (gamerules.rs line 340) and we do NOT pre-
55
+ # place powr, so the brute "build army" path requires building powr
56
+ # (300) → tent (400) → e1 (100 each) — but this never funds weap
57
+ # (2000) + harv (1400) within budget, so the 2-harv requirement is
58
+ # never met ⇒ LOSS.
59
+ # 4. Harvest income is distance-dependent: with the proc immediately
60
+ # adjacent to a near patch, one harv sustains ~150-200 cr per
61
+ # decision turn (verified by economy-harvest-investment and
62
+ # economy-harvest-timebox tuning notes). At 70 turns × ~150 cr ≈
63
+ # ~10,500 ev gross, comfortably above the chain spend (2000+1400 =
64
+ # 3400) plus the 2000 EV bar; a hard tier (3 harvs, 4800 chain
65
+ # spend, 3000 EV bar) is solvable inside 70 turns with 2 patches.
66
+ # 5. Newly-built harv spawns at the weap and does NOT auto-path to a
67
+ # distant patch from a bare `harvest` order (engine quirk noted in
68
+ # econ-startup-from-scratch and economy-harvest-investment). The
69
+ # intended chain `move_units(harv, patch_x, patch_y)` first, then
70
+ # `harvest(harv, patch_x, patch_y)` once the harv is on the patch.
71
+ # 6. Persistent unarmed enemy `fact` marker far east prevents engine
72
+ # auto-`done` via the all-enemies-eliminated path (CLAUDE.md
73
+ # auto-done footgun) — so a non-finisher LOSES on the deadline
74
+ # (never DRAWS).
75
+ # 7. Tick alignment: max_turns 70 → ceiling 93 + 90·69 = 6303 > 6300 ✓.
76
+ # within_ticks 6300; fail after_ticks 6301 (deadline bites as a
77
+ # reachable LOSS).
78
+ # 8. The hard tier's two agent spawn_point groups (NORTH/SOUTH base)
79
+ # satisfy the spawn-variation contract (test_hard_tier.py).
80
+ # Neutral `mine` actors always place regardless of spawn_point
81
+ # filter (CLAUDE.md), so each spawn has its own near patch geometry.
82
+
83
+ meta:
84
+ id: econ-recover-from-zero-cash
85
+ title: 'Recover From Zero Cash — Rebuild Revenue From a Minimum-Viable Kit'
86
+ capability: reasoning
87
+ real_world_meaning: >
88
+ Turtle-recovery / bankruptcy turnaround from a minimum-viable kit
89
+ and zero working capital. The agent inherits ONE refinery, ONE
90
+ harvester, and ONE ore patch — but no cash, no spare workers, no
91
+ army. The decision is the RECOVERY PROFILE — commit to harvest
92
+ FIRST (the only income channel), accumulate cash, and then
93
+ reinvest in the NEXT productive asset (a war factory and a second
94
+ harvester) so the income channel actually scales past a non-
95
+ trivial revenue bar before the deadline. Stalling (no income),
96
+ spending the accumulated cash on the wrong asset (a second
97
+ refinery, army units), or trying to build an army from zero all
98
+ miss the bar.
99
+ robotics_analogue: >
100
+ Post-crisis operations restart: an autonomous operator has lost
101
+ most of its infrastructure and is reduced to one processing unit,
102
+ one collector, and one known source — with no operating capital.
103
+ It must commit to running the collector to refill the cash
104
+ reserve, then deploy the saved capital to acquire a second
105
+ collector before the recovery deadline. Spending on defensive /
106
+ expansion hedges before the income channel has refilled the
107
+ reserve misses the bar.
108
+ author: groupF-econ-seed
109
+ benchmark_anchor:
110
+ - "SC2 turtle-back-to-economy / recovery from setback"
111
+ - "business bankruptcy turnaround planning"
112
+ - "military recovery from defeat (rebuild force structure)"
113
+ - "startup pivot after runway crisis"
114
+
115
+ base_map: rush-hour-arena
116
+ # 0 cash — the entire pack is the recovery-from-zero-capital test. The
117
+ # agent CANNOT spend a starting buffer; the only path to a 2nd harv is
118
+ # to harvest first, then reinvest the accumulated cash on weap+harv.
119
+ starting_cash: 0
120
+
121
+ base:
122
+ agent: {faction: allies}
123
+ enemy: {faction: soviet, cash: 0}
124
+ # Spec-required toolset: NO `deploy` (we already have fact pre-placed);
125
+ # the agent must commit to harvest and reinvest with the production
126
+ # chain (`build` + `place_building`).
127
+ tools: [observe, build, place_building, harvest, move_units, stop]
128
+ # Default `spawn_mcvs: false` — we declare actors ourselves per level
129
+ # so the engine doesn't add a phantom MCV.
130
+ spawn_mcvs: false
131
+ planning: true
132
+ termination: {max_ticks: 8000}
133
+ actors: []
134
+
135
+ levels:
136
+ # ── EASY ─────────────────────────────────────────────────────────
137
+ # Minimum-viable kit (fact + proc + 1 harv + 1 mine); 0 cash; loose
138
+ # bar (1500 EV) and NO second-harv requirement. The skill being
139
+ # tested is the floor: commit to the existing harvester so income
140
+ # actually flows (the pre-placed harv near the patch sustains
141
+ # ~150-200 cr/turn). Stall (zero income) LOSES; any committed
142
+ # harvest WINS. max_turns 70 → ceiling 93 + 90·69 = 6303 > 6300 ✓.
143
+ easy:
144
+ description: >
145
+ You start with a Construction Yard ('fact'), an Ore Refinery
146
+ ('proc'), one Ore Truck ('harv'), and one ore patch ('mine'),
147
+ with $0 cash. There is no enemy aggression. To start the
148
+ harvester collecting, first `move_units` it onto the patch cell
149
+ (22,18), then issue `harvest` with that cell as the target (the
150
+ auto-cycle continues on its own). Win = economy_value (cash +
151
+ stored ore) ≥ 1500 before tick 6300, AND you still own a 'fact'
152
+ AND ≥1 'harv'. Stalling (only `observe`) earns nothing and
153
+ loses on the clock.
154
+ starting_cash: 0
155
+ overrides:
156
+ actors:
157
+ - {type: fact, owner: agent, position: [8, 18]}
158
+ - {type: proc, owner: agent, position: [12, 18]}
159
+ - {type: harv, owner: agent, position: [14, 18]}
160
+ - {type: mine, owner: neutral, position: [22, 18]}
161
+ # Persistent far enemy marker — LOSS-not-DRAW guarantee.
162
+ - {type: fact, owner: enemy, position: [120, 20]}
163
+ win_condition:
164
+ all_of:
165
+ - economy_value_gte: 1500
166
+ - has_building: fact
167
+ - unit_type_count_gte: {type: harv, n: 1}
168
+ - within_ticks: 6300
169
+ fail_condition:
170
+ any_of:
171
+ - after_ticks: 6301
172
+ - not: {has_building: fact}
173
+ - not: {unit_type_count_gte: {type: harv, n: 1}}
174
+ max_turns: 70
175
+
176
+ # ── MEDIUM ───────────────────────────────────────────────────────
177
+ # +1 controlled variable on top of easy: the win predicate adds a
178
+ # 2-HARV requirement, so a "harvest only with the starting harv"
179
+ # policy LOSES on the structural clause regardless of EV. The
180
+ # intended recovery chain is: harvest (income flows) → save 2000 →
181
+ # build('weap') (prereq for harv) + place_building → save 1400 →
182
+ # build('harv') + place_building → move_units the new harv to the
183
+ # patch → harvest. Bar 2000 EV requires committed harvesting after
184
+ # the chain spend (cash post-chain is ~0; ≥2000 ev = the bar must
185
+ # be reached via the resumed harvest income).
186
+ medium:
187
+ description: >
188
+ You start with a Construction Yard ('fact'), an Ore Refinery
189
+ ('proc'), one Ore Truck ('harv'), and one ore patch ('mine'),
190
+ with $0 cash. To rebuild revenue: (1) `move_units` the harv
191
+ onto the patch cell (22,18) then `harvest` it (the auto-cycle
192
+ sustains income on its own); (2) once you have $2000, queue a
193
+ War Factory ('weap', cost 2000, prereq is the existing
194
+ refinery) and place it adjacent to the fact; (3) once the
195
+ 'weap' is up and you have $1400, queue a second Ore Truck
196
+ ('harv'); (4) `move_units` the new harv to the patch and
197
+ `harvest` it. Win = economy_value ≥ 2000 AND ≥ 2 harvesters
198
+ alive AND you still own 'fact', before tick 6300. Stalling,
199
+ building army units (powr → tent → e1) instead of the weap+harv
200
+ chain, or building a second refinery instead of a second harv
201
+ all miss the 2-harv requirement and LOSE.
202
+ starting_cash: 0
203
+ overrides:
204
+ actors:
205
+ - {type: fact, owner: agent, position: [8, 18]}
206
+ - {type: proc, owner: agent, position: [12, 18]}
207
+ - {type: harv, owner: agent, position: [14, 18]}
208
+ - {type: mine, owner: neutral, position: [22, 18]}
209
+ - {type: fact, owner: enemy, position: [120, 20]}
210
+ win_condition:
211
+ all_of:
212
+ - economy_value_gte: 2000
213
+ - has_building: fact
214
+ - unit_type_count_gte: {type: harv, n: 2}
215
+ - within_ticks: 6300
216
+ fail_condition:
217
+ any_of:
218
+ - after_ticks: 6301
219
+ - not: {has_building: fact}
220
+ - not: {unit_type_count_gte: {type: harv, n: 1}}
221
+ max_turns: 70
222
+
223
+ # ── HARD ─────────────────────────────────────────────────────────
224
+ # +2 controlled variables vs medium:
225
+ # 1. Higher bar (3000 EV) AND 3-harv requirement — the chain spend
226
+ # is now weap (2000) + harv (1400) + harv (1400) = 4800,
227
+ # requiring sustained throughput AFTER the chain to clear the
228
+ # 3000 EV bar. Two ore patches double the income ceiling so
229
+ # the bar is still reachable inside 70 turns.
230
+ # 2. TWO agent spawn_point groups (NORTH base y=14 vs SOUTH base
231
+ # y=26) round-robined by seed — a memorised "place weap at
232
+ # (8,18)" opening cannot generalise. Each spawn has its own
233
+ # symmetric two-patch geometry. Neutral mine actors always
234
+ # place regardless of spawn_point filter (CLAUDE.md engine
235
+ # footgun), so BOTH patch pairs exist in the world; the
236
+ # irrelevant pair sits far from each spawn's harv lane.
237
+ hard:
238
+ description: >
239
+ Same recovery from $0 cash, but the bar is higher (3000 EV)
240
+ AND you need THREE harvesters, AND there are TWO ore patches
241
+ near your base. Your base orientation varies by seed (NORTH or
242
+ SOUTH); both halves have the same per-spawn patch geometry.
243
+ Intended chain: (1) commit the starting harv to a near patch
244
+ (use `move_units` to nudge it onto the patch cell, then
245
+ `harvest`); (2) save $2000, build a War Factory ('weap',
246
+ prereq is the existing refinery) adjacent to the fact; (3)
247
+ save $1400, build a 2nd harv, move it to the other near patch,
248
+ `harvest`; (4) save $1400, build a 3rd harv, route it back to
249
+ the first patch (or feed the second), `harvest`. Win =
250
+ economy_value ≥ 3000 AND ≥ 3 harvs AND own 'fact' before tick
251
+ 6300. Stalling, building army, or building only 2 harvs all
252
+ miss the 3-harv requirement and LOSE.
253
+ starting_cash: 0
254
+ overrides:
255
+ actors:
256
+ # ── spawn_point 0: NORTH base, near patches at y=14 / y=16 ──
257
+ - {type: fact, owner: agent, position: [8, 14], spawn_point: 0}
258
+ - {type: proc, owner: agent, position: [12, 14], spawn_point: 0}
259
+ - {type: harv, owner: agent, position: [14, 14], spawn_point: 0}
260
+ # ── spawn_point 1: SOUTH base, near patches at y=26 / y=28 ──
261
+ - {type: fact, owner: agent, position: [8, 26], spawn_point: 1}
262
+ - {type: proc, owner: agent, position: [12, 26], spawn_point: 1}
263
+ - {type: harv, owner: agent, position: [14, 26], spawn_point: 1}
264
+ # Neutral mines ALWAYS place regardless of spawn_point filter
265
+ # (CLAUDE.md). Place a symmetric two-patch pair at each
266
+ # latitude; the other-spawn pair sits 12 cells away (far
267
+ # enough to be irrelevant to the spawn-local harv throughput).
268
+ - {type: mine, owner: neutral, position: [22, 14]}
269
+ - {type: mine, owner: neutral, position: [22, 16]}
270
+ - {type: mine, owner: neutral, position: [22, 26]}
271
+ - {type: mine, owner: neutral, position: [22, 28]}
272
+ # Persistent far enemy marker — LOSS-not-DRAW guarantee.
273
+ - {type: fact, owner: enemy, position: [120, 20]}
274
+ win_condition:
275
+ all_of:
276
+ - economy_value_gte: 3000
277
+ - has_building: fact
278
+ - unit_type_count_gte: {type: harv, n: 3}
279
+ - within_ticks: 6300
280
+ fail_condition:
281
+ any_of:
282
+ - after_ticks: 6301
283
+ - not: {has_building: fact}
284
+ - not: {unit_type_count_gte: {type: harv, n: 1}}
285
+ max_turns: 70
openra_bench/scenarios/packs/econ-target-cash-amount-by-deadline.yaml ADDED
@@ -0,0 +1,197 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ meta:
2
+ id: econ-target-cash-amount-by-deadline
3
+ title: 'Econ — Hit a Cash Target by a Hard Deadline'
4
+ capability: reasoning
5
+ real_world_meaning: >
6
+ Budget-by-date target: the operation must reach a stated cash bar
7
+ by a stated deadline, the way a startup must hit a quarterly
8
+ revenue target / a fundraising milestone / a quarterly closeout.
9
+ Pre-placed harvester+refinery+barracks are the running pipeline;
10
+ the agent must reason about whether the current capacity will
11
+ clear the bar on time, and if not, reinvest into harvesting
12
+ capacity (build extra harvesters) — not into army units that drain
13
+ the cash account without growing income. Idling, over-spending on
14
+ the wrong line item, or under-investing in capacity all miss the
15
+ bar.
16
+ robotics_analogue: >
17
+ Throughput-by-deadline planning in an autonomous pipeline: hit a
18
+ revenue/output target by a hard date by deciding when to scale up
19
+ collectors (capacity capex) vs hold (stay lean) vs spend on
20
+ unrelated capacity (army units / non-revenue line items). Capex
21
+ timing is the decision — over-spend on the wrong line and the
22
+ deadline passes with the books short.
23
+ author: wave-5
24
+ benchmark_anchor:
25
+ - SC2 econ-by-tick target
26
+ - "budget-by-date / quarterly revenue target"
27
+ - fundraising milestone planning
28
+ - OR throughput optimization with deadline
29
+
30
+ # ENGINE NOTE (verified 2026-05-20 against installed openra_train wheel):
31
+ # 1. `cash_gte: N` reads c.signals.cash (spendable cash only, NOT
32
+ # cash+resources EV — see win_conditions.py). Building units
33
+ # DEDUCTS cash, so an "army drain" policy (spam e1 alongside
34
+ # harvest) treads water at ~starting_cash (income ≈ unit cost)
35
+ # and never reaches the bar.
36
+ # 2. Harv build requires fact + powr + weap (war factory) as the
37
+ # Vehicle queue prereqs; tent is included for `e1` so the army-
38
+ # drain decoy can actually issue StartProduction (so it loses
39
+ # DRAINING cash, not silently no-oping).
40
+ # 3. Income rate from one harv on the rush-hour-arena near patches
41
+ # (22,18)/(22,22) is ~1.1 cash/tick after a ~450-tick warmup;
42
+ # so baseline 1-harv ramp in 4500 ticks ≈ 600 + 4500·1.1 ≈ 5550.
43
+ # Medium target=5500 is right at the baseline's max reach (so
44
+ # it loses on the deadline by ~3 ticks), and one extra harv
45
+ # accelerates to the bar by tick ~2900 (comfortable win).
46
+ # 4. Hard tier: target=6500 in 4000 ticks beats the baseline (which
47
+ # plateaus at ~4100 cash by 4053 ticks), 2 symmetric spawn_point
48
+ # groups (NORTH y=10..14 / SOUTH y=28..32) so a memorised "build
49
+ # at (8,18)" opening cannot generalise across seeds. Both spawn
50
+ # groups carry their own fact+proc+tent+powr+weap+harv +
51
+ # near-mine pair (shared mine geometry replicated at both
52
+ # latitudes per the spawn_point filter rule — only AGENT actors
53
+ # are filtered by spawn_point; neutral mines always place, but
54
+ # we keep the geometry symmetric so DEEP throughput is equal).
55
+ # 5. Tick/turn alignment: easy max_turns=21 → ceiling 1893 ticks
56
+ # ≥ within_ticks 1800; medium max_turns=51 → ceiling 4593 ≥ 4500;
57
+ # hard max_turns=46 → ceiling 4143 ≥ 4000. fail_condition uses
58
+ # after_ticks = within_ticks + 1 so a non-finisher is a real
59
+ # LOSS (the deadline bites).
60
+ # 6. Far enemy `e1` at (120,36) stance:0 keeps the engine from
61
+ # auto-`done`ing on no-enemy-actors (CLAUDE.md footgun).
62
+ base_map: rush-hour-arena
63
+ starting_cash: 600
64
+
65
+ base:
66
+ agent:
67
+ faction: allies
68
+ enemy:
69
+ faction: soviet
70
+ tools:
71
+ - observe
72
+ - build
73
+ - place_building
74
+ - harvest
75
+ - move_units
76
+ - stop
77
+ planning: true
78
+ termination:
79
+ max_ticks: 40000
80
+ actors:
81
+ # Pre-placed running pipeline: fact + proc + tent (army decoy) +
82
+ # powr + weap (Vehicle queue for harv) + 1× harv + 2× mines.
83
+ - {type: fact, owner: agent, position: [8, 18]}
84
+ - {type: proc, owner: agent, position: [12, 18]}
85
+ - {type: tent, owner: agent, position: [12, 22]}
86
+ - {type: powr, owner: agent, position: [8, 22]}
87
+ - {type: weap, owner: agent, position: [16, 22]}
88
+ - {type: harv, owner: agent, position: [14, 18]}
89
+ - {type: mine, owner: neutral, position: [22, 18]}
90
+ - {type: mine, owner: neutral, position: [22, 22]}
91
+ # Far inert enemy marker — prevents engine auto-done on
92
+ # no-enemy-actors (CLAUDE.md). stance:0 + far position so it
93
+ # cannot influence the run.
94
+ - {type: e1, owner: enemy, position: [120, 36], stance: 0}
95
+
96
+ levels:
97
+ easy:
98
+ description: >
99
+ Loose deadline cash target. The pipeline (1× harv + 1× proc +
100
+ 2× mine) is already running; the bar (1500 cash by tick 1800)
101
+ is reachable just by committing to harvest with the pre-placed
102
+ harvester — no reinvestment needed. Stall idles at the 600
103
+ starting cash and LOSES; an "army drain" policy (spam e1) that
104
+ harvests AND spends every spare credit on infantry treads
105
+ water at starting_cash (income ≈ unit cost) and LOSES; the
106
+ intended `harvest` commitment WINS by ~tick 900. This is the
107
+ bare cash-target reasoning skill (notice the bar, run the
108
+ pipeline, hit it before the deadline).
109
+ starting_cash: 600
110
+ win_condition:
111
+ all_of:
112
+ - cash_gte: 1500
113
+ - within_ticks: 1800
114
+ # within_ticks ≤ 93+90·20 = 1893 ⇒ deadline bites inside max_turns;
115
+ # after_ticks=1801 ⇒ non-finisher is a real LOSS, not a draw.
116
+ fail_condition:
117
+ any_of:
118
+ - after_ticks: 1801
119
+ - not: {has_building: fact}
120
+ - not: {unit_type_count_gte: {type: harv, n: 1}}
121
+ max_turns: 21
122
+ medium:
123
+ description: >
124
+ Mid bar — 5500 cash by tick 4500 — that the pre-placed
125
+ pipeline CANNOT meet by itself. One harvester on the two near
126
+ patches yields ~1.1 cash/tick after deductions, so 600 +
127
+ 4500·1.1 ≈ 5550 — at tick 4500 the bar is just out of reach
128
+ and the deadline bites (baseline LOSES at cash ≈ 5600 at tick
129
+ 4503). The intended play is to reinvest: build an extra harv
130
+ (cost 1100, prereq fact+weap+powr) so the post-warmup income
131
+ doubles and the bar clears by ~tick 2900. Stall (no income)
132
+ LOSES; "army drain" (spend every credit on e1) treads water
133
+ at ~starting_cash and LOSES; baseline (no reinvestment) just
134
+ misses and LOSES. Reinvestment WINS.
135
+ starting_cash: 600
136
+ win_condition:
137
+ all_of:
138
+ - cash_gte: 5500
139
+ - within_ticks: 4500
140
+ # within_ticks ≤ 93+90·50 = 4593 ⇒ reachable in max_turns;
141
+ # after_ticks=4501 ⇒ non-finisher is a real LOSS.
142
+ fail_condition:
143
+ any_of:
144
+ - after_ticks: 4501
145
+ - not: {has_building: fact}
146
+ - not: {unit_type_count_gte: {type: harv, n: 1}}
147
+ max_turns: 51
148
+ hard:
149
+ description: >
150
+ Tighter bar (6500 cash) in a tighter clock (4000 ticks) with
151
+ seed-varied starts. Two spawn_point groups (NORTH y=10..14 /
152
+ SOUTH y=28..32) each carry their own fact+proc+tent+powr+
153
+ weap+harv; both latitudes have a symmetric near-patch pair so
154
+ the reinvestment economics are equal per spawn (a memorised
155
+ "build at (8,18)" opening cannot generalise — the model must
156
+ identify its base each episode). The baseline pipeline
157
+ plateaus at ~4100 cash by tick 4053 (LOSES on every seed);
158
+ stall and army-drain LOSE on every seed; reinvesting into
159
+ ≥1 extra harv WINS on every seed by tick ≤3800.
160
+ starting_cash: 600
161
+ overrides:
162
+ actors:
163
+ # spawn_point 0 — NORTH base (y=10..14); near patches at y=10/14.
164
+ - {type: fact, owner: agent, position: [8, 10], spawn_point: 0}
165
+ - {type: proc, owner: agent, position: [12, 10], spawn_point: 0}
166
+ - {type: tent, owner: agent, position: [12, 14], spawn_point: 0}
167
+ - {type: powr, owner: agent, position: [8, 14], spawn_point: 0}
168
+ - {type: weap, owner: agent, position: [16, 14], spawn_point: 0}
169
+ - {type: harv, owner: agent, position: [14, 10], spawn_point: 0}
170
+ # spawn_point 1 — SOUTH base (y=28..32); near patches at y=28/32.
171
+ - {type: fact, owner: agent, position: [8, 28], spawn_point: 1}
172
+ - {type: proc, owner: agent, position: [12, 28], spawn_point: 1}
173
+ - {type: tent, owner: agent, position: [12, 32], spawn_point: 1}
174
+ - {type: powr, owner: agent, position: [8, 32], spawn_point: 1}
175
+ - {type: weap, owner: agent, position: [16, 32], spawn_point: 1}
176
+ - {type: harv, owner: agent, position: [14, 28], spawn_point: 1}
177
+ # Symmetric near-patch ore geometry — placed at BOTH
178
+ # latitudes so the income loop is equal per spawn (neutral
179
+ # actors aren't filtered by spawn_point, so duplicating
180
+ # both pairs is correct).
181
+ - {type: mine, owner: neutral, position: [22, 10]}
182
+ - {type: mine, owner: neutral, position: [22, 14]}
183
+ - {type: mine, owner: neutral, position: [22, 28]}
184
+ - {type: mine, owner: neutral, position: [22, 32]}
185
+ - {type: e1, owner: enemy, position: [120, 36], stance: 0}
186
+ win_condition:
187
+ all_of:
188
+ - cash_gte: 6500
189
+ - within_ticks: 4000
190
+ # within_ticks ≤ 93+90·45 = 4143 ⇒ reachable in max_turns;
191
+ # after_ticks=4001 ⇒ non-finisher is a real LOSS.
192
+ fail_condition:
193
+ any_of:
194
+ - after_ticks: 4001
195
+ - not: {has_building: fact}
196
+ - not: {unit_type_count_gte: {type: harv, n: 1}}
197
+ max_turns: 46
openra_bench/scenarios/packs/lh-100-turn-marathon-survival.yaml ADDED
@@ -0,0 +1,357 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # lh-100-turn-marathon-survival.yaml
2
+ #
3
+ # REASONING capability — Group G long-horizon. Very-Late "marathon
4
+ # survival" pack: keep the base alive UNDER SUSTAINED HUNT PRESSURE
5
+ # for the full 100-turn budget. The win is gated on FOUR things still
6
+ # being true at the end of the run:
7
+ # 1. has_building: fact # the base still stands
8
+ # 2. own_units_gte: 3 # a minimum garrison is alive
9
+ # 3. building_count_gte pbox 2 # at least two pillboxes were built
10
+ # 4. after_ticks N (survived) # the agent reached the end of the
11
+ # AND within_ticks N+x # episode (no early stop / draw)
12
+ #
13
+ # The discrimination is NOT a single micro decision; it is the
14
+ # SUSTAINED CADENCE over ~100 turns of:
15
+ # - reinforce: train fresh e1/e3 every few turns to replace losses
16
+ # (hunt bot trickles enemy units in across the whole episode)
17
+ # - defend: keep ≥2 pbox up; rebuild a razed one immediately
18
+ # - repair: keep fact/pbox/proc above critical HP (the `repair` tool
19
+ # toggles autorepair; without it a stripped pbox lane falls in
20
+ # the 60-90 turn window)
21
+ # - economy: keep the harvester(s) flowing — empty silos = no new
22
+ # units = the garrison bleeds out by turn ~70-80
23
+ #
24
+ # Wave geometry: instead of one large enemy stack closing in turn 1
25
+ # (which would either be blunted forever or roll the base before turn
26
+ # ~25), the pack STAGGERS multiple hunt squads along the east-west
27
+ # axis at increasing x. Each squad arrives at a different time
28
+ # window:
29
+ # x≈ 60 → reaches base ~tick 1200-1500 (turns ~14-18)
30
+ # x≈ 80 → reaches base ~tick 1800-2200 (turns ~20-25)
31
+ # x≈100 → reaches base ~tick 2400-3000 (turns ~27-34)
32
+ # x≈115 → reaches base ~tick 3000-3600 (turns ~33-40)
33
+ # After the early waves are absorbed the trailing hunt squads keep
34
+ # pressure on through the mid game; the agent's OWN reinforcement
35
+ # cadence is what carries it from turn ~50 through the after_ticks
36
+ # survival gate at tick 8100 (turn ~90).
37
+ #
38
+ # Real-world anchors:
39
+ # - lmgame-Bench multi-hour endurance benchmarks
40
+ # - SC2LE full-game / extended-game testing (sustained macro)
41
+ # - long-horizon credit assignment (survival has no proxy reward)
42
+ # - SRE extended on-call (sustained incident response, hours)
43
+ # - military endurance operations (continuous defensive ops)
44
+ #
45
+ # DISCRIMINATIONS (no defect, no cheat):
46
+ # - stall (observe only): LOSS — hunt rolls fact, no pbox built.
47
+ # - pure-defense-no-reinforce (build 2 pbox, never train units):
48
+ # LOSS — starting garrison bleeds out under sustained pressure,
49
+ # own_units drops below 1 before turn 90 ⇒ fail-clause fires
50
+ # (or own_units_gte:3 missed at the after_ticks gate).
51
+ # - pure-build-no-repair (build buildings but no repair / no
52
+ # unit replacement): LOSS — pbox/fact razed under sustained
53
+ # attrition; has_building:fact fails or pbox count drops < 2.
54
+ # - intended sustained-ops (reinforce + defend + repair across all
55
+ # 100 turns): WIN — all four clauses still satisfied at the gate.
56
+ #
57
+ # ENGINE FACTS (CLAUDE.md):
58
+ # - max_turns 100 → reachable tick ≈ 93 + 90·99 = 9003. The win's
59
+ # after_ticks: 8100 AND within_ticks: 9003 BAND is reachable.
60
+ # - after_ticks IN WIN is structurally incompatible with auto-DRAW
61
+ # on enemy-elim — so we PLACE A PERSISTENT inert enemy `fact` far
62
+ # east; hunt squads also stay on the map until killed, but the
63
+ # anti-DRAW marker is the load-bearing one.
64
+ # - hunt bot: each unit pursues the nearest agent target. Squads
65
+ # placed at distinct x converge on the base at different times,
66
+ # giving SUSTAINED (not one-shot) pressure.
67
+ # - pbox costs 600, needs `tent`; tent is pre-placed.
68
+ # - tent trains e1 (100cr) and e3 (300cr); separate queue from
69
+ # defenses so build('pbox') and build('e1') in parallel works.
70
+ # - `repair` toggles autorepair on a unit/building ID.
71
+ # - harv → proc economy: pre-placed `harv` + `proc` + 2× mine give
72
+ # income from turn 1; the agent must KICK the harv at a mine to
73
+ # start the loop (kick is one harvest order).
74
+ #
75
+ # Validate (no model / no network):
76
+ # cd /Users/berta/Projects/OpenRA-Bench && \
77
+ # python3 -m openra_bench.scenarios.validate \
78
+ # openra_bench/scenarios/packs/lh-100-turn-marathon-survival.yaml
79
+
80
+ meta:
81
+ id: lh-100-turn-marathon-survival
82
+ title: '100-Turn Marathon Survival — Sustain Defence, Reinforcement, and Repair'
83
+ capability: reasoning
84
+ real_world_meaning: >
85
+ A very-late single-episode endurance test: keep the base alive
86
+ under SUSTAINED enemy pressure for the full 100-turn budget. The
87
+ win is gated on the base, a minimum garrison, and the pillbox
88
+ line ALL still being intact at the end of the run, not on a
89
+ one-shot decision. The intended capability is the cadence of
90
+ reinforcement (train new infantry every few turns), repair
91
+ (toggle autorepair on damaged defences), and economic stewardship
92
+ (keep the refinery's harvester flowing). Stalling, pure defence
93
+ without reinforcement, and pure construction without repair all
94
+ lose; only sustained operations survive to the after-ticks gate.
95
+ robotics_analogue: >
96
+ Long-horizon autonomous operation under continuous external
97
+ pressure: an extended on-call / extended deployment where there
98
+ is no single decisive action, only the discipline of sustained
99
+ maintenance — repair work, supply replenishment, force
100
+ rotation — across the entire window. Credit only arrives at the
101
+ end of the window if every subsystem is still up.
102
+ benchmark_anchor:
103
+ - "lmgame-Bench multi-hour endurance"
104
+ - "SC2LE full-game / extended-game testing"
105
+ - "long-horizon credit assignment"
106
+ - "SRE extended on-call / military endurance ops"
107
+ author: openra-bench
108
+
109
+ base_map: rush-hour-arena
110
+
111
+ base:
112
+ agent:
113
+ faction: allies
114
+ enemy:
115
+ faction: soviet
116
+ bot_type: hunt
117
+ tools:
118
+ - observe
119
+ - build
120
+ - place_building
121
+ - harvest
122
+ - move_units
123
+ - attack_unit
124
+ - attack_move
125
+ - repair
126
+ - sell
127
+ - stop
128
+ planning: true
129
+ termination:
130
+ max_ticks: 40000
131
+
132
+ starting_cash: 1000
133
+
134
+ levels:
135
+ # ── EASY ─────────────────────────────────────────────────────────
136
+ # 90-turn budget → reachable tick ≈ 93 + 90·89 = 8103.
137
+ # Smaller hunt presence (3 staggered squads), forgiving after_ticks
138
+ # gate (7200 = ~turn 80), within_ticks 8103 (= max).
139
+ # Stall / pure-defense / pure-build LOSE; intended sustained WINS.
140
+ easy:
141
+ description: >
142
+ Survive a 90-turn endurance episode under sustained hunt pressure.
143
+ Pre-placed at the west: construction yard (fact), barracks
144
+ (tent), power plant (powr), refinery (proc), harvester (harv),
145
+ two mines, and 4 starting riflemen. THREE staggered enemy
146
+ squads at increasing distance close in over the course of the
147
+ game — early ones arrive ~turn 15, late ones ~turn 35, and
148
+ trailing fire keeps coming. To WIN you must, at tick 7200+,
149
+ STILL have: the construction yard alive, ≥3 of your own units
150
+ alive, and ≥2 pillboxes (pbox) you built. Stalling loses to
151
+ the first wave; only building pbox without ever training fresh
152
+ infantry leaves the garrison too thin (≥3 units fails); only
153
+ training infantry without pbox lets the base get rushed down.
154
+ Use repair to keep the pbox line standing.
155
+ starting_cash: 1000
156
+ overrides:
157
+ actors:
158
+ # Agent base — vulnerable buildings (fact, proc, powr) WEST;
159
+ # tent + future pbox lane mouth to the EAST so the hunt squads
160
+ # hit the defences before the income building.
161
+ - {type: fact, owner: agent, position: [10, 20]}
162
+ - {type: tent, owner: agent, position: [8, 18]}
163
+ - {type: powr, owner: agent, position: [6, 20]}
164
+ - {type: powr, owner: agent, position: [6, 22]}
165
+ - {type: proc, owner: agent, position: [6, 18]}
166
+ - {type: harv, owner: agent, position: [8, 22]}
167
+ # Two ore patches WEST of base so the harv loop never crosses
168
+ # the eastern lane the hunt squads attack down.
169
+ - {type: mine, owner: neutral, position: [2, 20]}
170
+ - {type: mine, owner: neutral, position: [2, 18]}
171
+ # 4 starting defenders (riflemen) at the EAST lane mouth so
172
+ # `own_units_gte:1` is satisfied on turn 1 and the first wave
173
+ # has something to engage before the agent's first pbox lands.
174
+ - {type: e1, owner: agent, position: [16, 20], stance: 3, count: 4}
175
+ # THREE staggered hunt squads at increasing x → arrive in
176
+ # successive time windows.
177
+ - {type: e1, owner: enemy, position: [60, 20], stance: 3, count: 2}
178
+ - {type: e1, owner: enemy, position: [80, 20], stance: 3, count: 2}
179
+ - {type: e1, owner: enemy, position: [100, 20], stance: 3, count: 2}
180
+ # Inert enemy `fact` marker far east — prevents engine
181
+ # auto-DRAW on enemy-elim once the hunt squads die. Without
182
+ # this the after_ticks-in-win gate collapses to DRAW.
183
+ - {type: fact, owner: enemy, position: [120, 20]}
184
+ win_condition:
185
+ all_of:
186
+ - {has_building: fact}
187
+ - {own_units_gte: 3}
188
+ - {building_count_gte: {type: pbox, n: 2}}
189
+ - {after_ticks: 7200}
190
+ - {within_ticks: 8103}
191
+ # Fail must fire whenever the survival gate is reached without
192
+ # ALL win clauses also being true (else the episode ends as a DRAW
193
+ # — CLAUDE.md rule 3). Win is evaluated FIRST in eval_core, so the
194
+ # broad `after_ticks: 7200` fail only bites if win is false; that
195
+ # gives us "didn't keep all four things alive ⇒ LOSS, not draw".
196
+ fail_condition:
197
+ any_of:
198
+ - {after_ticks: 7200}
199
+ - {not: {has_building: fact}}
200
+ - {not: {own_units_gte: 1}}
201
+ max_turns: 90
202
+
203
+ # ── MEDIUM ───────────────────────────────────────────────���───────
204
+ # +1 controlled variable: HEAVIER staggered pressure (5 squads, e1+e3
205
+ # mix) and the FULL 100-turn budget. The after_ticks gate (8100 =
206
+ # ~turn 90) sits comfortably under reachable tick 9003 = max.
207
+ medium:
208
+ description: >
209
+ Survive a full 100-turn endurance episode under sustained
210
+ hunt pressure. Pre-placed at the west: construction yard
211
+ (fact), barracks (tent), power plant (powr), refinery (proc),
212
+ harvester (harv), two mines, and 4 starting riflemen.
213
+ FIVE staggered enemy squads (rifle + rocket mix) close in
214
+ over the course of the game — early squads arrive ~turn 15,
215
+ mid ~turn 25-35, trailing ~turn 40-50 and pressure continues
216
+ through the late game. To WIN you must, at tick 8100+, STILL
217
+ have: the construction yard alive, ≥3 of your own units alive,
218
+ and ≥2 pillboxes (pbox) you built, AND survive until tick
219
+ 9003 (turn 100). Stalling loses to the first wave; building
220
+ only pbox without retraining infantry leaves the garrison
221
+ bled below 3 by the late game; building only infantry without
222
+ pbox loses the base to the rocket squads. Use repair to keep
223
+ the pbox line standing.
224
+ starting_cash: 1000
225
+ overrides:
226
+ actors:
227
+ - {type: fact, owner: agent, position: [10, 20]}
228
+ - {type: tent, owner: agent, position: [8, 18]}
229
+ - {type: powr, owner: agent, position: [6, 20]}
230
+ - {type: powr, owner: agent, position: [6, 22]}
231
+ - {type: proc, owner: agent, position: [6, 18]}
232
+ - {type: harv, owner: agent, position: [8, 22]}
233
+ - {type: mine, owner: neutral, position: [2, 20]}
234
+ - {type: mine, owner: neutral, position: [2, 18]}
235
+ # 4 starting riflemen (own_units_gte:1 satisfied turn 1).
236
+ - {type: e1, owner: agent, position: [16, 20], stance: 3, count: 4}
237
+ # FIVE staggered hunt squads at distinct x positions for
238
+ # SUSTAINED (not one-shot) pressure across all 100 turns.
239
+ # Mix in 1 e3 (rocket) per mid/late squad so a pbox without
240
+ # repair gets bled down.
241
+ - {type: e1, owner: enemy, position: [60, 20], stance: 3, count: 2}
242
+ - {type: e1, owner: enemy, position: [75, 18], stance: 3, count: 2}
243
+ - {type: e3, owner: enemy, position: [75, 22], stance: 3, count: 1}
244
+ - {type: e1, owner: enemy, position: [90, 20], stance: 3, count: 2}
245
+ - {type: e3, owner: enemy, position: [90, 18], stance: 3, count: 1}
246
+ - {type: e1, owner: enemy, position: [105, 20], stance: 3, count: 2}
247
+ - {type: e3, owner: enemy, position: [105, 22], stance: 3, count: 1}
248
+ - {type: e1, owner: enemy, position: [115, 20], stance: 3, count: 2}
249
+ # Inert enemy `fact` marker (anti-DRAW).
250
+ - {type: fact, owner: enemy, position: [120, 22]}
251
+ win_condition:
252
+ all_of:
253
+ - {has_building: fact}
254
+ - {own_units_gte: 3}
255
+ - {building_count_gte: {type: pbox, n: 2}}
256
+ - {after_ticks: 8100}
257
+ - {within_ticks: 9003}
258
+ # Fail bites the moment the survival gate opens (after_ticks: 8100)
259
+ # — win is evaluated FIRST so the fail only fires if the win
260
+ # all-of is unsatisfied at that tick (CLAUDE.md rule 3: non-win
261
+ # must be a real LOSS, not a draw).
262
+ fail_condition:
263
+ any_of:
264
+ - {after_ticks: 8100}
265
+ - {not: {has_building: fact}}
266
+ - {not: {own_units_gte: 1}}
267
+ max_turns: 100
268
+
269
+ # ── HARD ─────────────────────────────────────────────────────────
270
+ # +1 controlled variable: HEAVIER pressure (six staggered squads,
271
+ # more e3 rockets) AND ≥2 agent spawn_point groups (NORTH y=12 /
272
+ # SOUTH y=28) so a single memorised opening can't generalise.
273
+ # Budget extended to 120 turns → reachable tick ≈ 93 + 90·119 =
274
+ # 10803; after_ticks gate at 9900 (= ~turn 110) sits comfortably
275
+ # below. Two parallel sets of hunt squads (north + south latitudes)
276
+ # always place — enemy actors ignore spawn_point — so whichever
277
+ # latitude the agent occupies catches one converging band while
278
+ # the OTHER also turns inward.
279
+ hard:
280
+ description: >
281
+ Survive a 120-turn endurance episode under sustained pressure
282
+ from TWO latitudes. Your base stages from a seed-chosen
283
+ latitude (NORTH or SOUTH) — a single memorised pbox cell cannot
284
+ generalise. Pre-placed at your west base: construction yard
285
+ (fact), barracks (tent), power plant (powr), refinery (proc),
286
+ harvester (harv), two mines, and 4 starting riflemen. SIX
287
+ staggered hunt squads (rifle + rocket mix) close in across
288
+ BOTH latitudes — the converging band hits early ~turn 15 and
289
+ pressure trickles through the late game. To WIN you must, at
290
+ tick 9900+, STILL have: the construction yard alive, ≥3 of
291
+ your own units alive, and ≥2 pillboxes (pbox) you built, AND
292
+ survive until tick 10803 (turn 120). Stall / pure-defense /
293
+ pure-build all lose; only sustained reinforcement + repair +
294
+ defence carry the agent through the gate.
295
+ starting_cash: 1500
296
+ overrides:
297
+ actors:
298
+ # NORTH base spawn (spawn_point 0). Layout: fact, proc, powr
299
+ # WEST; tent + pbox lane mouth EAST.
300
+ - {type: fact, owner: agent, position: [10, 12], spawn_point: 0}
301
+ - {type: tent, owner: agent, position: [8, 10], spawn_point: 0}
302
+ - {type: powr, owner: agent, position: [6, 12], spawn_point: 0}
303
+ - {type: powr, owner: agent, position: [6, 14], spawn_point: 0}
304
+ - {type: proc, owner: agent, position: [6, 10], spawn_point: 0}
305
+ - {type: harv, owner: agent, position: [8, 14], spawn_point: 0}
306
+ - {type: e1, owner: agent, position: [16, 12], stance: 3, count: 6, spawn_point: 0}
307
+ - {type: e3, owner: agent, position: [16, 13], stance: 3, count: 2, spawn_point: 0}
308
+ # SOUTH base spawn (spawn_point 1) — symmetric.
309
+ - {type: fact, owner: agent, position: [10, 28], spawn_point: 1}
310
+ - {type: tent, owner: agent, position: [8, 30], spawn_point: 1}
311
+ - {type: powr, owner: agent, position: [6, 28], spawn_point: 1}
312
+ - {type: powr, owner: agent, position: [6, 26], spawn_point: 1}
313
+ - {type: proc, owner: agent, position: [6, 30], spawn_point: 1}
314
+ - {type: harv, owner: agent, position: [8, 26], spawn_point: 1}
315
+ - {type: e1, owner: agent, position: [16, 28], stance: 3, count: 6, spawn_point: 1}
316
+ - {type: e3, owner: agent, position: [16, 27], stance: 3, count: 2, spawn_point: 1}
317
+ # Two ore patches, one per latitude (neutral actors are not
318
+ # spawn-filtered — both always placed). Mines WEST of base so
319
+ # the harv loop doesn't cross the lane.
320
+ - {type: mine, owner: neutral, position: [2, 12]}
321
+ - {type: mine, owner: neutral, position: [2, 28]}
322
+ # Staggered hunt squads at TWO latitudes. Enemy actors don't
323
+ # honour spawn_point — all placed. The hunt bot drives each
324
+ # unit at the nearest agent target, so whichever latitude the
325
+ # agent base occupies will be hit by the matching band first;
326
+ # the OTHER band's units also turn inward over time (they
327
+ # don't respect lanes — closest-target-pursuit). Composition
328
+ # is tuned so the intended sustained-ops policy CAN survive
329
+ # all 120 turns from EITHER spawn — the redundancy across
330
+ # latitudes is the seed-driven uncontrolled variable, not a
331
+ # raw-count amplifier.
332
+ - {type: e1, owner: enemy, position: [70, 12], stance: 3, count: 2}
333
+ - {type: e1, owner: enemy, position: [70, 28], stance: 3, count: 2}
334
+ - {type: e1, owner: enemy, position: [90, 12], stance: 3, count: 2}
335
+ - {type: e3, owner: enemy, position: [90, 14], stance: 3, count: 1}
336
+ - {type: e1, owner: enemy, position: [90, 28], stance: 3, count: 2}
337
+ - {type: e3, owner: enemy, position: [90, 26], stance: 3, count: 1}
338
+ - {type: e1, owner: enemy, position: [110, 12], stance: 3, count: 2}
339
+ - {type: e1, owner: enemy, position: [110, 28], stance: 3, count: 2}
340
+ # Inert enemy `fact` marker far east (anti-DRAW).
341
+ - {type: fact, owner: enemy, position: [120, 20]}
342
+ win_condition:
343
+ all_of:
344
+ - {has_building: fact}
345
+ - {own_units_gte: 3}
346
+ - {building_count_gte: {type: pbox, n: 2}}
347
+ - {after_ticks: 9900}
348
+ - {within_ticks: 10803}
349
+ # Fail bites the moment the survival gate opens (after_ticks:
350
+ # 9900) — win is evaluated FIRST so the fail only fires if the
351
+ # win all-of is unsatisfied at that tick (CLAUDE.md rule 3).
352
+ fail_condition:
353
+ any_of:
354
+ - {after_ticks: 9900}
355
+ - {not: {has_building: fact}}
356
+ - {not: {own_units_gte: 1}}
357
+ max_turns: 120
openra_bench/scenarios/packs/lh-build-army-coordinate-multifront-attack.yaml ADDED
@@ -0,0 +1,322 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # lh-build-army-coordinate-multifront-attack.yaml
2
+ #
3
+ # REASONING capability — Group G long-horizon. Late-game two-phase
4
+ # operational plan: ASSEMBLE A REAL ARMY (≥N medium tanks) **THEN**
5
+ # COMMIT THAT ARMY TO A SIMULTANEOUS TWO-FRONT ATTACK against TWO
6
+ # separated enemy `fact` markers (one in the NE corner at (130,15),
7
+ # one in the SE corner at (130,45)). Both fact must be razed inside
8
+ # the shared deadline.
9
+ #
10
+ # Why `then:` (not `all_of`): the win is `then:` { army-assembled,
11
+ # NE-fact-razed-in-region, SE-fact-razed-in-region } so the army
12
+ # clause must latch IN ORDER before the two destruction clauses can
13
+ # count. A "send the 2 starter units now and never build" play is
14
+ # strictly insufficient — it cannot raze both fact in time AND it
15
+ # trips the army clause if both starter units get killed early
16
+ # (forcing assembly anyway). The two-front geometry combined with the
17
+ # 120-cell separation between NE and SE means a single mass that
18
+ # walks to ONE corner cannot then trek ~30 cells south and raze the
19
+ # OTHER corner inside the clock; only build-then-SPLIT wins.
20
+ #
21
+ # Real-world anchors (spec):
22
+ # - SC2 macro + multi-prong push (mass up tanks, then 2-prong move)
23
+ # - military operational planning: force build, then theater-wide
24
+ # assault on two simultaneous objectives
25
+ # - PERT theater-wide ops sequencing
26
+ # - industrial product launch: build inventory then multi-region
27
+ # rollout (you cannot ship to two regions without enough stock,
28
+ # and you cannot ship to one then the other inside the launch
29
+ # window — both regions must be supplied at once)
30
+ #
31
+ # ENGINE NOTES (per CLAUDE.md):
32
+ # - enemy actors don't honour spawn_point — both NE and SE enemy
33
+ # fact place regardless of which agent spawn group fires; this
34
+ # is exactly what we want (the two enemy targets are symmetric
35
+ # and always present; what flips per seed is the agent base
36
+ # latitude → which front is the "near" vs "far" of the split).
37
+ # - `enemy_key_buildings_destroyed_in_region` requires the
38
+ # destroy event RECORD to match (type, x, y, radius); we pass
39
+ # `radius: 8` (the predicate uses `radius`, not `r`).
40
+ # - tick/turn: max tick = 93 + 90·(max_turns-1). For
41
+ # max_turns: 80 → max tick 7203 ≥ within_ticks 7200 ✓.
42
+ #
43
+ # Validate:
44
+ # cd /Users/berta/Projects/OpenRA-Bench && \
45
+ # python3 -m openra_bench.scenarios.validate \
46
+ # openra_bench/scenarios/packs/lh-build-army-coordinate-multifront-attack.yaml
47
+
48
+ meta:
49
+ id: lh-build-army-coordinate-multifront-attack
50
+ title: 'Build Army → Coordinate Multi-Front Attack — Mass-Then-Split'
51
+ capability: reasoning
52
+ real_world_meaning: >
53
+ Late-game two-phase operational plan: first ASSEMBLE a real
54
+ fighting force (≥N medium tanks built from the pre-placed war
55
+ factory), then COMMIT that force to a simultaneous TWO-FRONT
56
+ assault on two separated enemy headquarters (NE and SE corners).
57
+ The `then:` composite enforces the assemble-before-attack ordering
58
+ and the 120-cell NE↔SE separation enforces the SPLIT — a single
59
+ mass cannot raze both corners in sequence inside the clock; only
60
+ build-then-split wins. Sending only the 2 starter units to each
61
+ front fails (insufficient firepower per prong); stalling fails on
62
+ the clock; one-front-only fails on the second destruction clause.
63
+ robotics_analogue: >
64
+ Theater-wide operational sequencing: a force must be brought to
65
+ minimum operational strength BEFORE being committed, and once
66
+ committed must be split across two simultaneous objectives in
67
+ different theatres under a single shared deadline. A serialized
68
+ "clear theatre A then redeploy to B" plan blows the time budget;
69
+ a "commit understrength to both" plan loses both engagements.
70
+ benchmark_anchor:
71
+ - "SC2 macro-then-multi-prong-attack"
72
+ - "military operational planning (force build then theater assault)"
73
+ - "PERT theater-wide ops sequencing"
74
+ - "industrial product launch: build inventory then multi-region rollout"
75
+ author: openra-bench
76
+
77
+ base_map: rush-hour-arena
78
+
79
+ base:
80
+ agent:
81
+ faction: allies
82
+ enemy:
83
+ faction: soviet
84
+ bot_type: ''
85
+ tools:
86
+ - observe
87
+ - build
88
+ - place_building
89
+ - move_units
90
+ - attack_unit
91
+ - attack_move
92
+ - stop
93
+ planning: true
94
+ termination:
95
+ max_ticks: 40000
96
+
97
+ levels:
98
+ # ── EASY ─────────────────────────────────────────────────────────
99
+ # Loose-bar rehearsal: 4-tank army threshold, no enemy pressure
100
+ # (bot disabled, no defenders), generous clock. The chain still
101
+ # bites: build to ≥4 tanks BEFORE either destruction clause counts,
102
+ # and BOTH NE+SE fact must be razed (not just one).
103
+ easy:
104
+ description: >
105
+ Pre-placed at the west base: fact + tent + powr + weap + fix +
106
+ two starter medium tanks (2tnk). Two enemy construction yards
107
+ sit at the eastern corners — NE at (130,15) and SE at (130,45),
108
+ 120 cells apart. Two passive defenders (no bot, no aggression)
109
+ guard each. Build a real army first (≥4 medium tanks total),
110
+ THEN raze BOTH enemy fact in their corners — the NE corner AND
111
+ the SE corner — before tick 7200. The phases must happen IN
112
+ ORDER (army clause must latch FIRST). Stalling loses on the
113
+ clock; sending only the two starter tanks to a single corner
114
+ loses on the second destruction clause (the other corner stands).
115
+ starting_cash: 2000
116
+ overrides:
117
+ base_map: {generator: arena, width: 160, height: 60, cordon: 4}
118
+ actors:
119
+ # Agent base — fact + tent + powr + weap + fix at west. weap
120
+ # is PRE-PLACED so the agent only needs to BUILD tanks (no
121
+ # tech race; the long-horizon test is force-buildup + split-
122
+ # commit, not tech). `fix` is the 2tnk tech-prereq.
123
+ - {type: fact, owner: agent, position: [10, 30]}
124
+ - {type: tent, owner: agent, position: [10, 33]}
125
+ - {type: powr, owner: agent, position: [13, 30]}
126
+ - {type: weap, owner: agent, position: [13, 33]}
127
+ - {type: fix, owner: agent, position: [16, 33]}
128
+ # Two starter tanks so the agent has SOMETHING to split with
129
+ # if it prematurely commits (so the "send 2 each front" lazy
130
+ # play is observable and LOSES on insufficient firepower).
131
+ - {type: 2tnk, owner: agent, position: [16, 28]}
132
+ - {type: 2tnk, owner: agent, position: [16, 30]}
133
+ # Ore patches so a built proc (if the agent chooses) has
134
+ # somewhere to harvest — but the starting_cash:2000 + free
135
+ # tents + free weap is enough to build the 4-tank threshold
136
+ # without econ.
137
+ - {type: mine, owner: neutral, position: [22, 30]}
138
+ - {type: mine, owner: neutral, position: [22, 33]}
139
+ # ── NE enemy base (fact + two passive defenders) ──
140
+ - {type: fact, owner: enemy, position: [130, 15]}
141
+ - {type: e1, owner: enemy, position: [127, 15], stance: 2}
142
+ - {type: e1, owner: enemy, position: [127, 17], stance: 2}
143
+ # ── SE enemy base (fact + two passive defenders) ──
144
+ - {type: fact, owner: enemy, position: [130, 45]}
145
+ - {type: e1, owner: enemy, position: [127, 45], stance: 2}
146
+ - {type: e1, owner: enemy, position: [127, 43], stance: 2}
147
+ # Persistent unarmed enemy MARKER at the far SE corner of the
148
+ # 160×60 arena — keeps the episode alive past any auto-`done`
149
+ # if both objective fact fall (the engine sets done when all
150
+ # enemy MustBeDestroyed buildings are gone). Without this, a
151
+ # one-or-two-tank policy that incidentally razes both fact
152
+ # auto-ends the episode at neither win nor loss → DRAW (the
153
+ # CLAUDE.md auto-done footgun). Placed deep enough that no
154
+ # intended-play tank ever finds it (won't be attacked).
155
+ - {type: fact, owner: enemy, position: [155, 55]}
156
+ win_condition:
157
+ all_of:
158
+ - then:
159
+ id: army-then-multifront-easy
160
+ clauses:
161
+ - {unit_type_count_gte: {type: 2tnk, n: 4}} # P1: army
162
+ - enemy_key_buildings_destroyed_in_region: # P2: NE razed
163
+ {x: 130, y: 15, radius: 8, types: [fact]}
164
+ - enemy_key_buildings_destroyed_in_region: # P3: SE razed
165
+ {x: 130, y: 45, radius: 8, types: [fact]}
166
+ - within_ticks: 7200
167
+ fail_condition:
168
+ any_of:
169
+ - {after_ticks: 7201}
170
+ - {not: {building_count_gte: {type: fact, n: 1}}}
171
+ - {not: {own_units_gte: 1}}
172
+ max_turns: 80
173
+
174
+ # ── MEDIUM ───────────────────────────────────────────────────────
175
+ # Spec'd middle: 6-tank army threshold, same dual NE+SE objective,
176
+ # same clock. A 4-tank "easy" play now under-resources the split.
177
+ # Defenders bump slightly to ensure 1-2 units per front cannot win.
178
+ medium:
179
+ description: >
180
+ Pre-placed at the west base: fact + tent + powr + weap + fix +
181
+ two starter medium tanks (2tnk). Two enemy construction yards
182
+ sit at the eastern corners — NE at (130,15) and SE at (130,45),
183
+ 120 cells apart, each guarded by a small infantry picket. Build
184
+ a real army first (≥6 medium tanks total), THEN raze BOTH enemy
185
+ fact in their corners — the NE corner AND the SE corner — before
186
+ tick 7200. The phases must happen IN ORDER (`then:`): the army
187
+ clause must latch FIRST, then the two destruction clauses can
188
+ count. Stalling loses on the clock; one-front-only loses on the
189
+ second destruction clause; sending only 2-3 tanks per front
190
+ bounces off the picket and burns the clock without razing.
191
+ # 6 tanks × 850 = $5100; 2 starter tanks free ⇒ need to fund 4
192
+ # tanks = $3400. Starting cash $3500 fits with no income required
193
+ # (the test is force-buildup + split, not also force-econ).
194
+ starting_cash: 3500
195
+ overrides:
196
+ base_map: {generator: arena, width: 160, height: 60, cordon: 4}
197
+ actors:
198
+ - {type: fact, owner: agent, position: [10, 30]}
199
+ - {type: tent, owner: agent, position: [10, 33]}
200
+ - {type: powr, owner: agent, position: [13, 30]}
201
+ - {type: weap, owner: agent, position: [13, 33]}
202
+ - {type: fix, owner: agent, position: [16, 33]}
203
+ - {type: 2tnk, owner: agent, position: [16, 28]}
204
+ - {type: 2tnk, owner: agent, position: [16, 30]}
205
+ - {type: mine, owner: neutral, position: [22, 30]}
206
+ - {type: mine, owner: neutral, position: [22, 33]}
207
+ # NE enemy base — fact + 3-unit picket (1 e3 anti-vehicle +
208
+ # 2 e1 riflemen) tuned so 2-3 tanks bounce / 4-6 tanks raze.
209
+ - {type: fact, owner: enemy, position: [130, 15]}
210
+ - {type: e3, owner: enemy, position: [127, 15], stance: 2}
211
+ - {type: e1, owner: enemy, position: [127, 17], stance: 2}
212
+ - {type: e1, owner: enemy, position: [128, 17], stance: 2}
213
+ # SE enemy base — symmetric picket.
214
+ - {type: fact, owner: enemy, position: [130, 45]}
215
+ - {type: e3, owner: enemy, position: [127, 45], stance: 2}
216
+ - {type: e1, owner: enemy, position: [127, 43], stance: 2}
217
+ - {type: e1, owner: enemy, position: [128, 43], stance: 2}
218
+ # Persistent unarmed enemy MARKER at the far SE corner —
219
+ # keeps the episode alive past auto-`done` if both objective
220
+ # fact fall (CLAUDE.md auto-done footgun).
221
+ - {type: fact, owner: enemy, position: [155, 55]}
222
+ win_condition:
223
+ all_of:
224
+ - then:
225
+ id: army-then-multifront-medium
226
+ clauses:
227
+ - {unit_type_count_gte: {type: 2tnk, n: 6}}
228
+ - enemy_key_buildings_destroyed_in_region:
229
+ {x: 130, y: 15, radius: 8, types: [fact]}
230
+ - enemy_key_buildings_destroyed_in_region:
231
+ {x: 130, y: 45, radius: 8, types: [fact]}
232
+ - within_ticks: 7200
233
+ fail_condition:
234
+ any_of:
235
+ - {after_ticks: 7201}
236
+ - {not: {building_count_gte: {type: fact, n: 1}}}
237
+ - {not: {own_units_gte: 1}}
238
+ max_turns: 80
239
+
240
+ # ── HARD ─────────────────────────────────────────────────────────
241
+ # +1 axis: 8-tank army threshold + TWO agent spawn_point groups.
242
+ # The agent base latitude round-robins between NORTH (y=18) and
243
+ # SOUTH (y=42) per seed; both objectives (NE 130,15 + SE 130,45)
244
+ # are always present, so the "near" vs "far" front flips per seed
245
+ # but the split-and-raze-both task is invariant. A memorised
246
+ # "north-prong heavy" or "always commit south first" opening
247
+ # cannot generalise across seeds.
248
+ hard:
249
+ description: >
250
+ Pre-placed at the west base (latitude round-robined between
251
+ NORTH y=18 and SOUTH y=42 per seed): fact + tent + powr + weap
252
+ + fix + two starter medium tanks (2tnk). Two enemy construction
253
+ yards sit at the eastern corners — NE at (130,15) and SE at
254
+ (130,45), 120 cells apart, each guarded by a small infantry
255
+ picket. Build a real army first (≥8 medium tanks total), THEN
256
+ raze BOTH enemy fact in their corners — the NE corner AND the
257
+ SE corner — before tick 7200. The phases must happen IN ORDER
258
+ (`then:`). Stalling, one-front-only, or sending 2-3 tanks per
259
+ front all LOSE. The seed-varied base latitude means a single
260
+ memorised opening cannot generalise across seeds (the "near"
261
+ front flips between NE and SE).
262
+ # 8 tanks × 850 = $6800; 2 starter tanks free ⇒ need $5100 for
263
+ # 6 more. Starting cash $5500 fits with no income required.
264
+ starting_cash: 5500
265
+ overrides:
266
+ base_map: {generator: arena, width: 160, height: 60, cordon: 4}
267
+ actors:
268
+ # NORTH base spawn (spawn_point 0) — y=18; NE (130,15) is
269
+ # the "near" front, SE (130,45) is the "far" one.
270
+ - {type: fact, owner: agent, position: [10, 18], spawn_point: 0}
271
+ - {type: tent, owner: agent, position: [10, 21], spawn_point: 0}
272
+ - {type: powr, owner: agent, position: [13, 18], spawn_point: 0}
273
+ - {type: weap, owner: agent, position: [13, 21], spawn_point: 0}
274
+ - {type: fix, owner: agent, position: [16, 21], spawn_point: 0}
275
+ - {type: 2tnk, owner: agent, position: [16, 16], spawn_point: 0}
276
+ - {type: 2tnk, owner: agent, position: [16, 18], spawn_point: 0}
277
+ # SOUTH base spawn (spawn_point 1) — y=42; SE is "near", NE
278
+ # is "far". Geometry flips so the per-prong assignment must
279
+ # be re-computed per seed.
280
+ - {type: fact, owner: agent, position: [10, 42], spawn_point: 1}
281
+ - {type: tent, owner: agent, position: [10, 39], spawn_point: 1}
282
+ - {type: powr, owner: agent, position: [13, 42], spawn_point: 1}
283
+ - {type: weap, owner: agent, position: [13, 39], spawn_point: 1}
284
+ - {type: fix, owner: agent, position: [16, 39], spawn_point: 1}
285
+ - {type: 2tnk, owner: agent, position: [16, 44], spawn_point: 1}
286
+ - {type: 2tnk, owner: agent, position: [16, 42], spawn_point: 1}
287
+ # Shared mid-map ore patches (no spawn_point → place for both).
288
+ # NOTE engine: enemy actors ignore spawn_point so the two
289
+ # enemy bases below ALWAYS place regardless of agent spawn.
290
+ - {type: mine, owner: neutral, position: [22, 30]}
291
+ - {type: mine, owner: neutral, position: [22, 33]}
292
+ # NE enemy base — fact + picket.
293
+ - {type: fact, owner: enemy, position: [130, 15]}
294
+ - {type: e3, owner: enemy, position: [127, 15], stance: 2}
295
+ - {type: e1, owner: enemy, position: [127, 17], stance: 2}
296
+ - {type: e1, owner: enemy, position: [128, 17], stance: 2}
297
+ # SE enemy base — symmetric picket.
298
+ - {type: fact, owner: enemy, position: [130, 45]}
299
+ - {type: e3, owner: enemy, position: [127, 45], stance: 2}
300
+ - {type: e1, owner: enemy, position: [127, 43], stance: 2}
301
+ - {type: e1, owner: enemy, position: [128, 43], stance: 2}
302
+ # Persistent unarmed enemy MARKER at the far SE corner —
303
+ # keeps the episode alive past auto-`done` if both objective
304
+ # fact fall (CLAUDE.md auto-done footgun).
305
+ - {type: fact, owner: enemy, position: [155, 55]}
306
+ win_condition:
307
+ all_of:
308
+ - then:
309
+ id: army-then-multifront-hard
310
+ clauses:
311
+ - {unit_type_count_gte: {type: 2tnk, n: 8}}
312
+ - enemy_key_buildings_destroyed_in_region:
313
+ {x: 130, y: 15, radius: 8, types: [fact]}
314
+ - enemy_key_buildings_destroyed_in_region:
315
+ {x: 130, y: 45, radius: 8, types: [fact]}
316
+ - within_ticks: 7200
317
+ fail_condition:
318
+ any_of:
319
+ - {after_ticks: 7201}
320
+ - {not: {building_count_gte: {type: fact, n: 1}}}
321
+ - {not: {own_units_gte: 1}}
322
+ max_turns: 80
openra_bench/scenarios/packs/lh-defense-tech-second-base.yaml ADDED
@@ -0,0 +1,370 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # lh-defense-tech-second-base.yaml
2
+ #
3
+ # REASONING capability — Group G long-horizon "secure-expand-with-tech"
4
+ # 3-phase chain. The agent must (1) harden base #1 with pillboxes,
5
+ # (2) tech up to a war factory, then (3) drive the spare MCV east and
6
+ # deploy it as a second Construction Yard inside the briefed target
7
+ # region.
8
+ #
9
+ # Uses the Wave-2 `then:` happened-before composite to ENFORCE STRICT
10
+ # ORDER:
11
+ # PHASE 1: building_count_gte:{type:pbox, n:3} # defend
12
+ # PHASE 2: has_building: weap # tech
13
+ # PHASE 3: building_in_region:{x:130,y:30,r:8,
14
+ # type:fact,count:1} # 2nd base
15
+ # Per the `then` semantics each clause must be observed-true IN
16
+ # SEQUENCE — pre-expanding before tech, or pre-teching before defence,
17
+ # cannot satisfy the chain.
18
+ #
19
+ # A `patrol` scripted bot drips light, predictable harassment on
20
+ # base #1 throughout the run — pure-expand (skip defences) loses the
21
+ # base to the patrol; pure-defence (no MCV move + no weap) leaves the
22
+ # chain stuck at phase 1 or 2; only the intended secure-expand-with-
23
+ # tech sequence WINS.
24
+ #
25
+ # Sister packs:
26
+ # - `lh-opening-to-tech-to-army` (Wave-4 macro chain, 4 phases —
27
+ # proc → weap → ≥N 2tnk → attack enemy fact)
28
+ # - `tech-turtle-defensive-tech` (defence-then-tech with hunt-bot
29
+ # pressure)
30
+ # This pack is the secure-expand-with-tech analogue: the terminal
31
+ # clause is a SECOND BASE in a far region (multi-site ramp-up), not
32
+ # a destroy clause.
33
+ #
34
+ # Real-world anchors (`meta.benchmark_anchor`):
35
+ # - SC2 secure-expand-with-tech (multi-base macro): harden home
36
+ # base, tech up, expand to a second base.
37
+ # - PlanBench multi-stage credit assignment.
38
+ # - Product roadmap: harden (perimeter/SLO) → R&D (tech upgrade)
39
+ # → new geography (second region).
40
+ # - Industrial: secure plant → R&D → build second plant.
41
+ #
42
+ # DISCRIMINATIONS (CLAUDE.md bar — no defect, no cheat):
43
+ # - stall (observe only): LOSS — clock + patrol razes fact.
44
+ # - pure-expand-skip-defence (move MCV east + deploy, skip pbox /
45
+ # weap): LOSS — chain stuck at phase 1 (pbox never built); the
46
+ # patrol also chews the base.
47
+ # - pure-defence-no-expand (build pbox+weap, never move MCV): LOSS —
48
+ # chain stuck at phase 3 (no 2nd fact in east region); clock
49
+ # expires.
50
+ # - intended secure-expand-with-tech (pbox×3, weap, then move MCV
51
+ # east + deploy): WIN.
52
+ #
53
+ # Engine facts used (CLAUDE.md / SCENARIO_QUALITY.md):
54
+ # - `pbox` cost 600, prereq `tent` (allied barracks pre-placed),
55
+ # defence queue.
56
+ # - `weap` cost ~2000, prereq `proc` (allied refinery — pre-placed
57
+ # so the queue accepts weap from turn 1 once cash is available).
58
+ # - MCV deploy: `Command.deploy([mcv_id])` removes the MCV and
59
+ # spawns an agent `fact` at (mcv_x-1, mcv_y-1). To land the new
60
+ # fact at e.g. (130,30) — the centre of the east target region —
61
+ # the MCV must reach (131,31) before deploying.
62
+ # - `patrol` scripted bot: hunts outward in alternating patrol
63
+ # legs from its spawn; light, predictable home-base pressure
64
+ # (per `openra-sim/src/scripted_bot.rs`).
65
+ # - Inert enemy `fact` marker far east prevents engine auto-DRAW
66
+ # on enemy-elim (CLAUDE.md auto-done footgun).
67
+ # - `then:` greedy advance: once a prior clause latches, any
68
+ # currently-true later clauses latch in the same evaluation.
69
+ #
70
+ # Validate (no model / no network):
71
+ # python3 -m pytest tests/test_lh_defense_tech_second_base.py -q
72
+
73
+ meta:
74
+ id: lh-defense-tech-second-base
75
+ title: 'Secure → Tech → Second Base — 3-Phase Long-Horizon Chain'
76
+ capability: reasoning
77
+ real_world_meaning: >
78
+ A long single-episode macro plan whose final win depends on each
79
+ intermediate phase being observed-true in order: harden the home
80
+ base with static defences (pillboxes) under live patrol pressure,
81
+ unlock the tech tree (war factory), then commit the spare MCV
82
+ eastward to stand up a second Construction Yard inside the
83
+ briefed expansion region. The Wave-2 `then:` composite enforces
84
+ strict happened-before — a policy that expands before tech, or
85
+ teches before defences, cannot satisfy the chain.
86
+ robotics_analogue: >
87
+ Multi-site ramp-up of a long-horizon mission with strict stage
88
+ gating: harden the home site against active adversarial pressure,
89
+ bring the upgraded production capability online, then deploy a
90
+ base-builder platform to the designated expansion sector and
91
+ convert it into a second operational site — all within one
92
+ budget. Out-of-order completion (expand before hardening, or
93
+ expand before the upgraded capability is online) forfeits the
94
+ chain.
95
+ benchmark_anchor:
96
+ - "SC2 secure-expand-with-tech (multi-base macro)"
97
+ - "PlanBench multi-stage credit assignment"
98
+ - "product roadmap: harden → R&D → new market"
99
+ - "industrial: secure plant → R&D → second plant"
100
+ author: openra-bench
101
+
102
+ # Pack-level base_map is the `rush-hour-arena` sentinel so the loader
103
+ # can map-resolve before per-level overrides materialise the actual
104
+ # 160x60 generator-spec arena .oramap (same pattern as
105
+ # mfb-base-1-defend-base-2-build, expansion-balanced-2-base-defended).
106
+ base_map: rush-hour-arena
107
+
108
+ starting_cash: 3000
109
+
110
+ base:
111
+ agent:
112
+ faction: allies
113
+ enemy:
114
+ faction: soviet
115
+ bot_type: patrol
116
+ tools:
117
+ - observe
118
+ - build
119
+ - place_building
120
+ - deploy
121
+ - harvest
122
+ - move_units
123
+ - attack_unit
124
+ - attack_move
125
+ - stop
126
+ planning: true
127
+ termination:
128
+ max_ticks: 40000
129
+ # Each level supplies its own actor list via overrides — keeps the
130
+ # per-tier patrol density / spawn variation audit-explicit.
131
+ actors: []
132
+
133
+ levels:
134
+ # ── EASY ─────────────────────────────────────────────────────────
135
+ # Bare-skill rehearsal: looser bar (pbox≥2 instead of ≥3), a single
136
+ # light patrol band, generous clock. The chain still bites
137
+ # (pbox-first, weap-then, 2nd-base-finally) — pure-expand, pure-
138
+ # defence, stall all LOSE; intended secure-expand-with-tech WINS.
139
+ # max_turns 90 → reachable tick 93 + 90·89 = 8103 > 8000 ✓.
140
+ easy:
141
+ description: >
142
+ You hold base #1 in the WEST — a Construction Yard (fact),
143
+ barracks (tent), power plants (powr ×2), refinery (proc), a
144
+ harvester pre-kicked on the local ore patch, and 3 rifleman
145
+ defenders on AttackAnything. A spare MCV is staged east of
146
+ base #1 (~(40,30)) for your eventual expansion. A light Soviet
147
+ `patrol` band drips harassment on base #1. The mission is a
148
+ strict 3-phase chain (you must complete them IN THIS ORDER):
149
+ (1) build at least 2 pillboxes (pbox) at the home base,
150
+ (2) build a war factory (weap), (3) drive the spare MCV east
151
+ to the target region centred at (130,30) (radius 8) and
152
+ DEPLOY it there to stand up the SECOND Construction Yard.
153
+ Win = the 3-phase chain completed AND base #1 still standing
154
+ (fact present) before tick 8000. Stalling, skipping defences,
155
+ and never expanding all LOSE.
156
+ starting_cash: 3500
157
+ overrides:
158
+ base_map:
159
+ generator: arena
160
+ name: lh-defense-tech-second-base-arena
161
+ width: 160
162
+ height: 60
163
+ cordon: 4
164
+ actors:
165
+ # Agent base #1 (WEST). fact / tent / powr per spec, PLUS
166
+ # proc + harv + mine so income flows from turn 1 (the chain
167
+ # needs ~3800 cash on top of the cushion). Without harv
168
+ # income the bare starting_cash cannot reliably fund the
169
+ # pbox×2 + weap + MCV-fuel chain in budget.
170
+ - {type: fact, owner: agent, position: [10, 30]}
171
+ - {type: tent, owner: agent, position: [8, 28]}
172
+ - {type: powr, owner: agent, position: [6, 30]}
173
+ # Second powr — 2 pbox (-40) + weap (-30) drains ~70 power;
174
+ # one powr (+100 default) just covers it, but a second powr
175
+ # protects against the patrol shaving the first one (CLAUDE.md
176
+ # underpowered-queue footgun).
177
+ - {type: powr, owner: agent, position: [6, 32]}
178
+ - {type: proc, owner: agent, position: [6, 26]}
179
+ - {type: harv, owner: agent, position: [8, 32]}
180
+ - {type: mine, owner: neutral, position: [2, 30]}
181
+ # 3 rifleman defenders on AttackAnything at the EAST lane
182
+ # mouth to blunt the patrol's leading edge while pbox stand
183
+ # up.
184
+ - {type: e1, owner: agent, position: [16, 30], stance: 3, count: 3}
185
+ # Spare MCV staged ~30 cells east of base #1. To win, the
186
+ # agent must drive it ~90 cells further east and deploy at
187
+ # (131,31) → fact lands at (130,30) — exactly the target
188
+ # region centre.
189
+ - {type: mcv, owner: agent, position: [40, 30]}
190
+ # Light Soviet patrol band — easy: 2× e1 starting mid-map
191
+ # so the patrol bot drips harassment on base #1 (`patrol`
192
+ # bot alternates patrol legs outward from spawn; the
193
+ # nearest agent unit / structure attracts pressure).
194
+ - {type: e1, owner: enemy, position: [80, 30], stance: 3, count: 2}
195
+ # Persistent unarmed enemy `fact` marker far east — outside
196
+ # both the target region (radius 8 around (130,30)) and the
197
+ # patrol band, so it doesn't satisfy any agent predicate
198
+ # and doesn't perturb the patrol behaviour. Prevents engine
199
+ # auto-DRAW on enemy-elim (the patrol band could otherwise
200
+ # be killed and end the episode prematurely).
201
+ - {type: fact, owner: enemy, position: [154, 4]}
202
+ win_condition:
203
+ all_of:
204
+ - then:
205
+ id: defense-tech-second-base-easy
206
+ clauses:
207
+ - {building_count_gte: {type: pbox, n: 2}}
208
+ - {has_building: weap}
209
+ - {building_in_region:
210
+ {x: 130, y: 30, radius: 8, type: fact, count: 1}}
211
+ - {has_building: fact}
212
+ - {within_ticks: 8000}
213
+ fail_condition:
214
+ any_of:
215
+ - {after_ticks: 8001}
216
+ - {not: {has_building: fact}}
217
+ max_turns: 90
218
+
219
+ # ── MEDIUM ───────────────────────────────────────────────────────
220
+ # Full 3-phase chain at the spec bar (pbox≥3) under a slightly
221
+ # heavier patrol band (3× e1 + 1× e3) on a tighter clock.
222
+ # max_turns 80 → reachable tick 93 + 90·79 = 7203 > 7200 ✓.
223
+ medium:
224
+ description: >
225
+ Same secure-expand-with-tech setup, tighter clock and heavier
226
+ patrol pressure. You hold base #1 in the WEST (fact, tent,
227
+ proc, harv, 2× powr, 3 defenders). A spare MCV is staged east
228
+ of base #1. A Soviet `patrol` band — 3 rifle infantry + 1
229
+ rocket soldier (e3) — drips pressure on base #1. The mission
230
+ is a strict 3-phase chain (IN THIS ORDER):
231
+ (1) at least 3 pillboxes (pbox) at base #1,
232
+ (2) a war factory (weap),
233
+ (3) the spare MCV deployed inside the eastern target region
234
+ centred at (130,30), radius 8 — the new fact lands at
235
+ (mcv_x-1, mcv_y-1), so drive the MCV to ~(131,31) and deploy.
236
+ Win = the chain completed AND base #1 still standing before
237
+ tick 7200. Stall, pure-expand-skip-defences, and pure-defence-
238
+ no-expand all LOSE.
239
+ starting_cash: 3000
240
+ overrides:
241
+ base_map:
242
+ generator: arena
243
+ name: lh-defense-tech-second-base-arena
244
+ width: 160
245
+ height: 60
246
+ cordon: 4
247
+ actors:
248
+ - {type: fact, owner: agent, position: [10, 30]}
249
+ - {type: tent, owner: agent, position: [8, 28]}
250
+ - {type: powr, owner: agent, position: [6, 30]}
251
+ - {type: powr, owner: agent, position: [6, 32]}
252
+ - {type: proc, owner: agent, position: [6, 26]}
253
+ - {type: harv, owner: agent, position: [8, 32]}
254
+ - {type: mine, owner: neutral, position: [2, 30]}
255
+ - {type: e1, owner: agent, position: [16, 30], stance: 3, count: 3}
256
+ - {type: mcv, owner: agent, position: [40, 30]}
257
+ # Heavier patrol band — 3 e1 + 1 e3 (rocket soldier adds
258
+ # anti-armour pressure if the MCV is exposed en route).
259
+ - {type: e1, owner: enemy, position: [80, 30], stance: 3, count: 3}
260
+ - {type: e3, owner: enemy, position: [82, 30], stance: 3, count: 1}
261
+ # Persistent unarmed enemy `fact` marker far east (anti-DRAW).
262
+ - {type: fact, owner: enemy, position: [154, 4]}
263
+ win_condition:
264
+ all_of:
265
+ - then:
266
+ id: defense-tech-second-base-medium
267
+ clauses:
268
+ - {building_count_gte: {type: pbox, n: 3}}
269
+ - {has_building: weap}
270
+ - {building_in_region:
271
+ {x: 130, y: 30, radius: 8, type: fact, count: 1}}
272
+ - {has_building: fact}
273
+ - {within_ticks: 7200}
274
+ fail_condition:
275
+ any_of:
276
+ - {after_ticks: 7201}
277
+ - {not: {has_building: fact}}
278
+ max_turns: 80
279
+
280
+ # ── HARD ─────────────────────────────────────────────────────────
281
+ # +1 axis vs medium: a FOURTH phase (rebuild the proc — the
282
+ # secure-expand-with-tech chain extended to a "harden → tech →
283
+ # expand → re-secure income" sequence) AND TWO patrol bands hitting
284
+ # from TWO latitudes (north + south), AND ≥2 spawn_point groups so
285
+ # the agent base latitude varies by seed.
286
+ #
287
+ # The fourth chain clause is `building_count_gte: {type: proc, n: 2}`
288
+ # — the agent must build a SECOND refinery at the new eastern base
289
+ # to stabilise income on the expansion (a real "second plant comes
290
+ # online" semantic, not just a re-place of the home refinery).
291
+ #
292
+ # max_turns 80 → reachable tick 93 + 90·79 = 7203 > 7200 ✓.
293
+ hard:
294
+ description: >
295
+ Same secure-expand-with-tech mission under richer pressure and
296
+ with a fourth phase tacked on. Your base #1 stages from a
297
+ seed-chosen latitude (NORTH y=22 or SOUTH y=38) so a single
298
+ memorised pillbox placement cannot generalise. Two Soviet
299
+ `patrol` bands hit from two different latitudes (north and
300
+ south). The mission is a strict 4-phase chain (IN THIS ORDER):
301
+ (1) at least 3 pillboxes (pbox) at base #1,
302
+ (2) a war factory (weap),
303
+ (3) the spare MCV deployed inside the eastern target region
304
+ centred at (130,30), radius 8,
305
+ (4) at least 2 refineries (proc) own — i.e. a SECOND refinery
306
+ online to stabilise income on the new expansion.
307
+ Win = the chain completed AND base #1 still standing before
308
+ tick 7200. Stall, pure-expand, pure-defence, and skip-proc-2
309
+ all LOSE.
310
+ starting_cash: 3500
311
+ overrides:
312
+ base_map:
313
+ generator: arena
314
+ name: lh-defense-tech-second-base-arena
315
+ width: 160
316
+ height: 60
317
+ cordon: 4
318
+ actors:
319
+ # NORTH base spawn (spawn_point 0). Layout mirrors easy/
320
+ # medium at y=22.
321
+ - {type: fact, owner: agent, position: [10, 22], spawn_point: 0}
322
+ - {type: tent, owner: agent, position: [8, 20], spawn_point: 0}
323
+ - {type: powr, owner: agent, position: [6, 22], spawn_point: 0}
324
+ - {type: powr, owner: agent, position: [6, 24], spawn_point: 0}
325
+ - {type: proc, owner: agent, position: [6, 18], spawn_point: 0}
326
+ - {type: harv, owner: agent, position: [8, 24], spawn_point: 0}
327
+ - {type: e1, owner: agent, position: [16, 22], stance: 3, count: 3, spawn_point: 0}
328
+ - {type: mcv, owner: agent, position: [40, 22], spawn_point: 0}
329
+ # SOUTH base spawn (spawn_point 1). Symmetric at y=38.
330
+ - {type: fact, owner: agent, position: [10, 38], spawn_point: 1}
331
+ - {type: tent, owner: agent, position: [8, 40], spawn_point: 1}
332
+ - {type: powr, owner: agent, position: [6, 38], spawn_point: 1}
333
+ - {type: powr, owner: agent, position: [6, 36], spawn_point: 1}
334
+ - {type: proc, owner: agent, position: [6, 42], spawn_point: 1}
335
+ - {type: harv, owner: agent, position: [8, 36], spawn_point: 1}
336
+ - {type: e1, owner: agent, position: [16, 38], stance: 3, count: 3, spawn_point: 1}
337
+ - {type: mcv, owner: agent, position: [40, 38], spawn_point: 1}
338
+ # Two ore patches — one per spawn cluster — placed west of
339
+ # each base so the harv loop doesn't cross the patrol lane.
340
+ # (Neutral actors are not filtered by spawn_point — both
341
+ # always place, per CLAUDE.md.)
342
+ - {type: mine, owner: neutral, position: [2, 22]}
343
+ - {type: mine, owner: neutral, position: [2, 38]}
344
+ # Two patrol bands at distinct latitudes (both ALWAYS placed
345
+ # — enemy actors with no spawn_point are not filtered, per
346
+ # CLAUDE.md). Whichever lane the agent's base occupies is
347
+ # hit; the OTHER band wanders the off-axis half of the map.
348
+ - {type: e1, owner: enemy, position: [80, 22], stance: 3, count: 2}
349
+ - {type: e3, owner: enemy, position: [82, 22], stance: 3, count: 1}
350
+ - {type: e1, owner: enemy, position: [80, 38], stance: 3, count: 2}
351
+ - {type: e3, owner: enemy, position: [82, 38], stance: 3, count: 1}
352
+ # Persistent unarmed enemy `fact` marker far east (anti-DRAW).
353
+ - {type: fact, owner: enemy, position: [154, 4]}
354
+ win_condition:
355
+ all_of:
356
+ - then:
357
+ id: defense-tech-second-base-hard
358
+ clauses:
359
+ - {building_count_gte: {type: pbox, n: 3}}
360
+ - {has_building: weap}
361
+ - {building_in_region:
362
+ {x: 130, y: 30, radius: 8, type: fact, count: 1}}
363
+ - {building_count_gte: {type: proc, n: 2}}
364
+ - {has_building: fact}
365
+ - {within_ticks: 7200}
366
+ fail_condition:
367
+ any_of:
368
+ - {after_ticks: 7201}
369
+ - {not: {has_building: fact}}
370
+ max_turns: 80
openra_bench/scenarios/packs/lh-multi-checkpoint-5-plus.yaml ADDED
@@ -0,0 +1,324 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # lh-multi-checkpoint-5-plus.yaml
2
+ #
3
+ # ACTION capability — Group G long-horizon. The Late-tier sibling of
4
+ # `lh-opening-to-tech-to-army` (4-phase chain) that goes one further:
5
+ # a Wave-2 `then:` happened-before chain of FIVE+ ordered checkpoints
6
+ # in a single episode. This is the PERT critical-path / PlanBench
7
+ # long-sequencing anchor — a "5+ ordered checkpoints" test that the
8
+ # 4-phase pack cannot express on its own.
9
+ #
10
+ # The chain genuinely needs order because it MIXES checkpoint kinds:
11
+ # - building states (has_building: proc, weap)
12
+ # - quantitative production (unit_type_count_gte: 2tnk)
13
+ # - episode-aggregate combat (units_killed_gte: 3)
14
+ # - terminal destruction (enemy_key_buildings_destroyed: fact)
15
+ # A policy that scores kills BEFORE the army exists cannot satisfy
16
+ # clause-4 BEFORE clause-3; the `then:` latch advances index-by-index
17
+ # only, so out-of-order completion stalls forever.
18
+ #
19
+ # Sister pack to `lh-opening-to-tech-to-army` (4-phase econ→tech→army→
20
+ # engage) — this pack tightens the same idiom by stretching it to
21
+ # 5 (medium) / 6 (hard) checkpoints, which is the regime that
22
+ # discriminates models on long ordered SOP execution / PERT critical
23
+ # path planning. Easy reverts to 3 phases (the baseline) so the
24
+ # difficulty axis is "phase count" rather than "phase kind".
25
+ #
26
+ # Real-world anchors:
27
+ # - PERT/CPM project management (each milestone gates the next)
28
+ # - PlanBench long-sequencing (5+ step ordered plans)
29
+ # - long SOP execution (5+ phase ordered procedures)
30
+ # - military operation: phase lines (PL ALPHA → ... → PL FOXTROT)
31
+ #
32
+ # ENGINE NOTES (per CLAUDE.md):
33
+ # - `then:` advances clause-by-clause; a satisfied earlier clause
34
+ # stays latched even if its predicate later becomes false (e.g.,
35
+ # proc gets destroyed) — only the order matters.
36
+ # - The agent has 4 pre-placed combat units; phase-4 (3 kills) is
37
+ # satisfied by attacking the static defenders next to the enemy
38
+ # fact. The defenders are HOLD-FIRE (stance:0) so a "rush the
39
+ # fact and kill 3" play CAN score kills early — but the `then:`
40
+ # latch will not credit those kills towards clause-4 until clauses
41
+ # 1-3 (proc → weap → 2 tanks) have latched in order.
42
+ # - `bot_type: ''` (no scripted bot) — the enemy is static so the
43
+ # anti-stall pressure comes purely from the `within_ticks: 7200`
44
+ # clock, not from incoming raiders.
45
+ # - `harvest` is intentionally omitted from tools; the auto-harvester
46
+ # spawned by `proc` near the ore patch covers income without the
47
+ # agent needing to micro-manage it. This keeps the tool surface
48
+ # focused on the action / sequencing axis.
49
+ #
50
+ # Validate:
51
+ # cd /Users/berta/Projects/OpenRA-Bench && \
52
+ # python3 -m openra_bench.scenarios.validate \
53
+ # openra_bench/scenarios/packs/lh-multi-checkpoint-5-plus.yaml
54
+
55
+ meta:
56
+ id: lh-multi-checkpoint-5-plus
57
+ title: 'Multi-Checkpoint 5+ — Strict Long-Sequencing Chain (PERT / PlanBench)'
58
+ capability: action
59
+ real_world_meaning: >
60
+ A long single-episode operation whose final win depends on FIVE or
61
+ more intermediate checkpoints being observed-true in order: stand
62
+ up the economy (refinery), unlock the tech (war factory), produce
63
+ a real fighting force (≥2 medium tanks), engage and accumulate
64
+ kills (≥3), then commit to the terminal objective (raze the enemy
65
+ headquarters). The Wave-2 `then:` composite enforces strict
66
+ happened-before — an out-of-order completion (kills before the
67
+ army, attack before the tech) cannot advance the chain past the
68
+ skipped clause and forfeits the episode on the clock.
69
+ robotics_analogue: >
70
+ Long sequenced operation with strict checkpoint gating: a mission
71
+ that runs through 5+ phases that must complete in order (sensor
72
+ coverage → infrastructure → fleet provisioning → contact →
73
+ objective), inside a single budget window. Credit at episode end
74
+ requires every phase to have happened in sequence — out-of-order
75
+ completion forfeits the whole chain. Standard PERT / CPM critical-
76
+ path execution where a downstream task cannot start (and cannot
77
+ earn credit) until its upstream predecessor has finished.
78
+ benchmark_anchor:
79
+ - "PlanBench long-sequencing"
80
+ - "PERT critical path"
81
+ - "PERT/CPM project management"
82
+ - "long SOP execution"
83
+ author: openra-bench
84
+
85
+ base_map: rush-hour-arena
86
+
87
+ base:
88
+ agent:
89
+ faction: allies
90
+ enemy:
91
+ faction: soviet
92
+ bot_type: ''
93
+ tools:
94
+ - observe
95
+ - build
96
+ - place_building
97
+ - move_units
98
+ - attack_unit
99
+ - attack_move
100
+ - stop
101
+ planning: true
102
+ termination:
103
+ max_ticks: 40000
104
+
105
+ levels:
106
+ # ── EASY ─────────────────────────────────────────────────────────
107
+ # Loose-bar rehearsal: 3-phase chain (proc → weap → engage). Cash
108
+ # is loose, the static enemy fact has no defenders, and the chain
109
+ # is short enough that a competent build-order + attack wins. The
110
+ # `then:` idiom is taught here so the medium tier (5 phases) and
111
+ # hard tier (6 phases) are pure phase-count escalations.
112
+ easy:
113
+ description: >
114
+ Pre-placed: agent fact + tent + powr + fix at the west base,
115
+ plus 4 medium tanks (2tnk) for the engagement. An enemy fact
116
+ sits undefended at the far east (your terminal target). The
117
+ three checkpoints (`then:` chain) are: build a refinery (proc),
118
+ then a war factory (weap), then destroy the enemy fact. Phases
119
+ MUST happen IN THIS ORDER — stalling or skipping any phase
120
+ loses (the chain never advances), and any non-win on the clock
121
+ is a real LOSS. All within 7200 ticks.
122
+ starting_cash: 4000
123
+ overrides:
124
+ actors:
125
+ # Agent base — fact, tent, powr, fix (the 2tnk tech-prereq,
126
+ # pre-placed because the easy tier doesn't include a tank-
127
+ # production checkpoint; keeping `fix` matches the medium
128
+ # tier's geometry so cash budgeting transfers cleanly).
129
+ - {type: fact, owner: agent, position: [10, 18]}
130
+ - {type: tent, owner: agent, position: [10, 22]}
131
+ - {type: powr, owner: agent, position: [14, 18]}
132
+ - {type: fix, owner: agent, position: [14, 22]}
133
+ # 4 pre-placed medium tanks — the strike force for the
134
+ # engagement checkpoint. Positioned just east of the base
135
+ # so they have a clear corridor to the enemy fact.
136
+ - {type: 2tnk, owner: agent, position: [18, 18]}
137
+ - {type: 2tnk, owner: agent, position: [18, 20]}
138
+ - {type: 2tnk, owner: agent, position: [18, 22]}
139
+ - {type: 2tnk, owner: agent, position: [20, 20]}
140
+ # Near ore patch so a built proc auto-spawns a harv with
141
+ # somewhere to harvest from (income for weap).
142
+ - {type: mine, owner: neutral, position: [22, 18]}
143
+ - {type: mine, owner: neutral, position: [22, 22]}
144
+ # Enemy: fact at far east (terminal target). No defenders on
145
+ # easy — the engage clause is geometric only (drive tanks
146
+ # east, raze fact). A SENTINEL fact at the far north-east
147
+ # corner persists past the terminal fact-kill so the engine
148
+ # does NOT auto-`done` on enemy-elimination before the
149
+ # `then:` chain (and the within_ticks predicate) gets a turn
150
+ # to evaluate — see CLAUDE.md auto-terminate footgun.
151
+ - {type: fact, owner: enemy, position: [115, 30]}
152
+ - {type: fact, owner: enemy, position: [125, 4]}
153
+ win_condition:
154
+ all_of:
155
+ - then:
156
+ id: multi-check-easy
157
+ clauses:
158
+ - {has_building: proc} # P1: econ
159
+ - {has_building: weap} # P2: tech
160
+ - {enemy_key_buildings_destroyed: {types: [fact]}} # P3: engage
161
+ - within_ticks: 7200
162
+ fail_condition:
163
+ any_of:
164
+ - {after_ticks: 7201}
165
+ - {not: {building_count_gte: {type: fact, n: 1}}}
166
+ max_turns: 85
167
+
168
+ # ── MEDIUM ───────────────────────────────────────────────────────
169
+ # 5-phase chain (the headline cell): proc → weap → 2× 2tnk → ≥3
170
+ # kills → enemy fact. The chain MIXES checkpoint kinds (buildings +
171
+ # quantitative production + combat-aggregate + terminal destruction)
172
+ # so the ordering is genuinely load-bearing — a "kill 3 then build
173
+ # then attack" play cannot satisfy clause-4 BEFORE clause-3 (the
174
+ # `then:` latch only advances when each clause is observed-true in
175
+ # order). Cash is tight (2500) so the chain must be executed
176
+ # efficiently; the 4 pre-placed tanks cover the kill+engage clauses,
177
+ # the auto-harvester from the built proc funds weap + the 2 new
178
+ # tanks for clause 3.
179
+ medium:
180
+ description: >
181
+ Pre-placed: agent fact + tent + powr + fix at the west base,
182
+ plus 4 medium tanks (2tnk) for the engagement. An enemy fact
183
+ sits at the far east defended by a handful of HOLD-FIRE rifle/
184
+ rocket soldiers (your kill targets). The five checkpoints
185
+ (`then:` chain) are: build a refinery (proc), then a war
186
+ factory (weap), then have ≥2 medium tanks (2tnk) standing,
187
+ then accumulate ≥3 unit kills, then destroy the enemy fact.
188
+ Phases MUST happen IN THIS ORDER — `then:` enforces strict
189
+ happened-before. A "rush the fact and kill on the way" play
190
+ scores kills early but the latch will not credit them until
191
+ proc + weap + 2tnk have latched in order; skipping any clause
192
+ stalls the chain. All within 7200 ticks.
193
+ starting_cash: 2500
194
+ overrides:
195
+ actors:
196
+ - {type: fact, owner: agent, position: [10, 18]}
197
+ - {type: tent, owner: agent, position: [10, 22]}
198
+ - {type: powr, owner: agent, position: [14, 18]}
199
+ # `fix` (Service Depot) is the 2tnk tech-prereq; pre-placed
200
+ # so the chain's PHASE 2 only needs the agent to build the
201
+ # war factory (weap) — not a Service Depot too.
202
+ - {type: fix, owner: agent, position: [14, 22]}
203
+ # 4 pre-placed medium tanks — the strike force for the
204
+ # kills + engagement checkpoints. Two start tanks already
205
+ # SATISFY the unit_type_count_gte:{2tnk,n:2} clause at t=0,
206
+ # so the agent does NOT need to build more for clause 3 —
207
+ # but the `then:` latch only advances to clause-3 once the
208
+ # earlier proc + weap clauses have latched.
209
+ - {type: 2tnk, owner: agent, position: [18, 18]}
210
+ - {type: 2tnk, owner: agent, position: [18, 20]}
211
+ - {type: 2tnk, owner: agent, position: [18, 22]}
212
+ - {type: 2tnk, owner: agent, position: [20, 20]}
213
+ # Ore patch — auto-harv from the built proc funds weap.
214
+ - {type: mine, owner: neutral, position: [22, 18]}
215
+ - {type: mine, owner: neutral, position: [22, 22]}
216
+ # Enemy: fact at far east + 4 HOLD-FIRE defenders (kills for
217
+ # phase 4). HoldFire (stance:0) means the defenders won't
218
+ # path-find west — the agent must come to them. Defender
219
+ # count = 4 so the ≥3 kills clause is generously satisfiable.
220
+ - {type: fact, owner: enemy, position: [115, 30]}
221
+ - {type: e1, owner: enemy, position: [110, 28], stance: 0}
222
+ - {type: e1, owner: enemy, position: [110, 30], stance: 0}
223
+ - {type: e1, owner: enemy, position: [110, 32], stance: 0}
224
+ - {type: e3, owner: enemy, position: [112, 30], stance: 0}
225
+ # Sentinel: a far isolated enemy fact so razing the assault
226
+ # target doesn't auto-`done` the engine before the win
227
+ # predicate evaluates (CLAUDE.md auto-terminate footgun).
228
+ - {type: fact, owner: enemy, position: [125, 4]}
229
+ win_condition:
230
+ all_of:
231
+ - then:
232
+ id: multi-check-medium
233
+ clauses:
234
+ - {has_building: proc} # P1: econ
235
+ - {has_building: weap} # P2: tech
236
+ - {unit_type_count_gte: {type: 2tnk, n: 2}} # P3: army
237
+ - {units_killed_gte: 3} # P4: engage
238
+ - {enemy_key_buildings_destroyed: {types: [fact]}} # P5: finish
239
+ - within_ticks: 7200
240
+ fail_condition:
241
+ any_of:
242
+ - {after_ticks: 7201}
243
+ - {not: {building_count_gte: {type: fact, n: 1}}}
244
+ max_turns: 85
245
+
246
+ # ── HARD ─────────────────────────────────────────────────────────
247
+ # 6-phase chain + 2 spawn_point groups. One extra checkpoint
248
+ # (≥4 tanks AFTER the kills) makes the chain genuinely longer than
249
+ # medium's 5 (the chain demands MORE tanks be produced even after
250
+ # the first kills land — a tank-count regression check that the
251
+ # army was rebuilt after attrition). The agent spawns NORTH (y=14)
252
+ # or SOUTH (y=26) by seed; the per-seed base latitude flips so a
253
+ # memorised opening cannot generalise.
254
+ hard:
255
+ description: >
256
+ Pre-placed: agent base (fact + tent + powr + fix) at the west,
257
+ round-robined between a NORTH base and a SOUTH base by seed,
258
+ plus 4 medium tanks (2tnk). An enemy fact at the far east is
259
+ defended by HOLD-FIRE defenders (your kill targets). The six
260
+ checkpoints (`then:` chain) are: build a refinery (proc), then
261
+ a war factory (weap), then have ≥2 medium tanks (2tnk), then
262
+ accumulate ≥3 unit kills, then have ≥4 medium tanks (rebuild
263
+ after attrition), then destroy the enemy fact. Phases MUST
264
+ happen IN STRICT ORDER (`then:` composite). Pre-build / out-of-
265
+ order / skip-tech / kill-without-army all stall the chain.
266
+ The seed-varied spawn means a memorised opening cannot
267
+ generalise. All within 7200 ticks.
268
+ starting_cash: 3000
269
+ overrides:
270
+ actors:
271
+ # NORTH base spawn (spawn_point 0)
272
+ - {type: fact, owner: agent, position: [10, 14], spawn_point: 0}
273
+ - {type: tent, owner: agent, position: [10, 18], spawn_point: 0}
274
+ - {type: powr, owner: agent, position: [14, 14], spawn_point: 0}
275
+ - {type: fix, owner: agent, position: [14, 18], spawn_point: 0}
276
+ - {type: 2tnk, owner: agent, position: [18, 14], spawn_point: 0}
277
+ - {type: 2tnk, owner: agent, position: [18, 16], spawn_point: 0}
278
+ - {type: 2tnk, owner: agent, position: [18, 18], spawn_point: 0}
279
+ - {type: 2tnk, owner: agent, position: [20, 16], spawn_point: 0}
280
+ # SOUTH base spawn (spawn_point 1)
281
+ - {type: fact, owner: agent, position: [10, 26], spawn_point: 1}
282
+ - {type: tent, owner: agent, position: [10, 22], spawn_point: 1}
283
+ - {type: powr, owner: agent, position: [14, 26], spawn_point: 1}
284
+ - {type: fix, owner: agent, position: [14, 22], spawn_point: 1}
285
+ - {type: 2tnk, owner: agent, position: [18, 26], spawn_point: 1}
286
+ - {type: 2tnk, owner: agent, position: [18, 24], spawn_point: 1}
287
+ - {type: 2tnk, owner: agent, position: [18, 22], spawn_point: 1}
288
+ - {type: 2tnk, owner: agent, position: [20, 24], spawn_point: 1}
289
+ # Shared ore geography — two patches between the two bases
290
+ # so harv income is symmetric whichever spawn is chosen.
291
+ - {type: mine, owner: neutral, position: [22, 18]}
292
+ - {type: mine, owner: neutral, position: [22, 22]}
293
+ # Enemy: fact at far east + HOLD-FIRE defenders for the kill
294
+ # clause. 5 defenders so the ≥3 kills clause is generously
295
+ # satisfiable on either spawn. Placed near the enemy fact so
296
+ # a stall loses on the clock, not on the agent base getting
297
+ # overrun first (no bot, so this is structural anyway).
298
+ - {type: fact, owner: enemy, position: [115, 20]}
299
+ - {type: e1, owner: enemy, position: [110, 18], stance: 0}
300
+ - {type: e1, owner: enemy, position: [110, 20], stance: 0}
301
+ - {type: e1, owner: enemy, position: [110, 22], stance: 0}
302
+ - {type: e3, owner: enemy, position: [112, 19], stance: 0}
303
+ - {type: e3, owner: enemy, position: [112, 21], stance: 0}
304
+ # Sentinel: a far isolated enemy fact so razing the assault
305
+ # target doesn't auto-`done` the engine before the win
306
+ # predicate evaluates (CLAUDE.md auto-terminate footgun).
307
+ - {type: fact, owner: enemy, position: [125, 4]}
308
+ win_condition:
309
+ all_of:
310
+ - then:
311
+ id: multi-check-hard
312
+ clauses:
313
+ - {has_building: proc} # P1: econ
314
+ - {has_building: weap} # P2: tech
315
+ - {unit_type_count_gte: {type: 2tnk, n: 2}} # P3: army
316
+ - {units_killed_gte: 3} # P4: engage
317
+ - {unit_type_count_gte: {type: 2tnk, n: 4}} # P5: rebuild
318
+ - {enemy_key_buildings_destroyed: {types: [fact]}} # P6: finish
319
+ - within_ticks: 7200
320
+ fail_condition:
321
+ any_of:
322
+ - {after_ticks: 7201}
323
+ - {not: {building_count_gte: {type: fact, n: 1}}}
324
+ max_turns: 85
openra_bench/scenarios/packs/lh-scout-react-counter.yaml ADDED
@@ -0,0 +1,368 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # lh-scout-react-counter.yaml
2
+ #
3
+ # REASONING capability — observation-driven 3-phase chain:
4
+ # SCOUT (discover ≥2 enemy buildings) → REACT (engage and kill ≥3
5
+ # enemy units) → COUNTER (raze the enemy construction yard).
6
+ # The agent starts late-game with a small force (1× jeep scout +
7
+ # 3× medium tanks) and a starter fact at the west edge; cash 1500
8
+ # is too lean to fund a meaningful new production line, so the
9
+ # capability under test is the REACTIVE INFORMATION-ACTION LOOP on
10
+ # the existing force — not build-out, not tech-pivot. The model
11
+ # must observe what the enemy actually has, engage it, then
12
+ # commit to razing the construction yard inside the deadline.
13
+ #
14
+ # Distinct from sibling Wave-4 `lh-tech-pivot-attack`:
15
+ # - lh-tech-pivot-attack: tech is already standing; the pivot
16
+ # is BUILD-the-correct-counter then attack. The middle phase
17
+ # is a PRODUCTION decision.
18
+ # - lh-scout-react-counter (this pack): tech is NOT standing;
19
+ # the army is pre-deployed; the middle phase is a TACTICAL
20
+ # ENGAGEMENT decision (which enemy cluster to engage, in what
21
+ # order). Cash is intentionally too lean to fund building
22
+ # more of the army — the capability is the observation-driven
23
+ # three-phase chain on a FIXED FORCE.
24
+ # Distinct from sibling Wave-2 `mid-tech-switch-on-scout`:
25
+ # - mid-tech-switch-on-scout: scout-then-counter (2-phase) with
26
+ # either counter satisfying the win via units_killed_gte:3.
27
+ # - lh-scout-react-counter (this pack): EXPLICIT 3-phase chain
28
+ # where SCOUTING (≥2 buildings, not ≥1) is the load-bearing
29
+ # first phase, REACT is killing units mid-chain, and COUNTER
30
+ # is razing the FACT (not just hitting the kill bar).
31
+ #
32
+ # Real-world anchors: SC2 reactive macro (scout enemy strategy
33
+ # mid-game, pivot composition, counter-attack); CICERO
34
+ # information-action loop (gather state, update belief, commit to
35
+ # action); PlanBench replanning (mid-plan observation forces
36
+ # re-plan); threat-intel-driven defence (a SOC analyst confirms
37
+ # the indicator-of-compromise from telemetry, takes containment
38
+ # action, then patches the upstream attack vector).
39
+ #
40
+ # ENGINE NOTES (per CLAUDE.md):
41
+ # - Enemy actors with spawn_point are NOT reliably filtered per
42
+ # seed (openra-data/src/oramap.rs::expand_scenario_actors); we
43
+ # vary the AGENT spawn instead (medium/hard) to flip which
44
+ # enemy composition is the "near" reachable one.
45
+ # - The enemy `fact` is MustBeDestroyed → razing it satisfies
46
+ # enemy_key_buildings_destroyed but would auto-`done` the
47
+ # engine if it were the LAST enemy building standing. A
48
+ # second isolated enemy `fact` at the FAR corner persists as
49
+ # an anti-auto-`done` anchor (unreachable inside the clock).
50
+ # - `then:` is the Wave-2 happened-before composite: clauses
51
+ # latch IN ORDER, so a SKIP-SCOUT-ATTACK-BLIND play that
52
+ # razes the fact (auto-discovering 1 building en route) does
53
+ # NOT satisfy the first clause (≥2 buildings discovered) —
54
+ # keeping scouting load-bearing.
55
+ # - `unit_killed_gte:3` is the REACT phase: it requires the
56
+ # agent's force to actually engage; a scout-only-no-attack
57
+ # play stalls at clause 2.
58
+ # - Tools omit `harvest` / `train` deliberately: this pack does
59
+ # not test the economic loop. `build`/`place_building` are
60
+ # kept so an opportunistic emergency defence (e.g. a pbox /
61
+ # gun) is buildable from the starter fact + powr if the model
62
+ # elects to spend the lean cash that way.
63
+ #
64
+ # Validate:
65
+ # cd /Users/berta/Projects/OpenRA-Bench && \
66
+ # python3 -m openra_bench.scenarios.validate \
67
+ # openra_bench/scenarios/packs/lh-scout-react-counter.yaml
68
+
69
+ meta:
70
+ id: lh-scout-react-counter
71
+ title: 'Scout, React, Counter — Observation-Driven 3-Phase Chain'
72
+ capability: reasoning
73
+ real_world_meaning: >
74
+ Reactive macro on a fixed force: the agent must drive the
75
+ observation-action-counter loop end-to-end inside one budget.
76
+ Phase 1 (scout) is mandatory — the deeper enemy posture
77
+ (≥2 buildings worth of structure) must be observed before any
78
+ commit is informed; phase 2 (react) requires the army to
79
+ actually engage the cluster, not merely walk past; phase 3
80
+ (counter) is the production-yard kill that ends the threat at
81
+ its source. Pre-committing to attack the construction yard
82
+ without scouting cannot satisfy the chain — the scout latch
83
+ is the load-bearing first link.
84
+ robotics_analogue: >
85
+ Threat-intel-driven incident response: SOC analyst confirms
86
+ multiple indicators of compromise from telemetry (scout),
87
+ contains the active attacker on the network (react / engage),
88
+ then patches the upstream attack vector / takes down the
89
+ command-and-control host (counter). Skipping the confirmation
90
+ step and going straight to take-down acts on stale belief and
91
+ misses the root cause; scouting forever bleeds the response
92
+ window.
93
+ benchmark_anchor:
94
+ - "SC2 reactive macro (scout-pivot-counter)"
95
+ - "CICERO information-action loop"
96
+ - "PlanBench replanning"
97
+ - "threat-intel-driven defense"
98
+ author: "openra-bench"
99
+
100
+ base_map: rush-hour-arena
101
+
102
+ base:
103
+ agent:
104
+ faction: allies
105
+ enemy:
106
+ faction: soviet
107
+ bot_type: ''
108
+ tools:
109
+ - observe
110
+ - build
111
+ - place_building
112
+ - move_units
113
+ - attack_unit
114
+ - attack_move
115
+ - stop
116
+ planning: true
117
+ termination:
118
+ max_ticks: 40000
119
+
120
+ levels:
121
+ # ── EASY ────────────────────────────────────────────────────────
122
+ # Plan-and-execute rehearsal: ONE fixed enemy composition (the
123
+ # construction yard + an attached barracks defended by a passive
124
+ # rifle/rocket cluster) at the far east. The brief explicitly
125
+ # names the composition so the model can pre-plan; the
126
+ # then-chain still enforces scout (≥2 buildings) before react
127
+ # (≥3 kills) before counter (raze fact) to teach the idiom.
128
+ # Stalling loses on the clock; a skip-scout-attack-blind play
129
+ # races at the fact with the starter tanks but the cluster
130
+ # blunts it — and even on success the discovery clause needs
131
+ # ≥2 buildings, which a single fact-rush only registers 1.
132
+ easy:
133
+ description: >
134
+ Your starter force sits at the west edge: a scout jeep and
135
+ three medium tanks (2tnk). A single enemy construction yard
136
+ (fact) sits at the far east on the central y-band, defended
137
+ by a passive rifle cluster (four e1). A SEPARATE enemy
138
+ barracks (tent) sits as a forward outpost in the south
139
+ mid-section, OFF the main assault axis. Drive the jeep
140
+ FIRST through the southern outpost to register the tent,
141
+ then on to the far east to register the fact (≥2 buildings
142
+ discovered), then commit your tanks to destroy ≥3 enemy
143
+ units, then RAZE the enemy construction yard. All within
144
+ 6300 ticks. Rushing the tanks straight at the fact only
145
+ registers the fact under attack (the outpost is well off
146
+ the assault axis — out of tank sight) — the discovery
147
+ latch stalls at 1 and the chain never completes; stalling
148
+ at spawn loses on the clock.
149
+ starting_cash: 1500
150
+ overrides:
151
+ actors:
152
+ # Agent: starter fact + powr at the west (the powr lets a
153
+ # building like pbox/gun be placed if the model elects;
154
+ # not load-bearing for the intended play).
155
+ - {type: fact, owner: agent, position: [10, 18]}
156
+ - {type: powr, owner: agent, position: [14, 18]}
157
+ # 1× scout jeep + 3× medium tanks at the west edge,
158
+ # stance:1 (ReturnFire) so they don't path-find east on
159
+ # their own and so the engagement is the agent's choice.
160
+ - {type: jeep, owner: agent, position: [20, 16], stance: 1}
161
+ - {type: 2tnk, owner: agent, position: [20, 18], stance: 1}
162
+ - {type: 2tnk, owner: agent, position: [20, 19], stance: 1}
163
+ - {type: 2tnk, owner: agent, position: [20, 20], stance: 1}
164
+ # Enemy main: construction yard at the far east on the
165
+ # central y-band, plus a passive rifle cluster (e1) at
166
+ # stance:0 (HoldFire) guarding it. The cluster does NOT
167
+ # path-find west; the agent must come to them.
168
+ - {type: fact, owner: enemy, position: [122, 18]}
169
+ - {type: e1, owner: enemy, position: [118, 18], stance: 0, count: 4}
170
+ # Enemy forward outpost: an isolated barracks (tent) FAR
171
+ # off the main assault axis (mid-x, far south). A blind
172
+ # tank rush at the fact (path y≈18) never approaches
173
+ # (60, 36) — tank sight ~6 cells, distance from any rush
174
+ # waypoint is >>15. The jeep, however, can deliberately
175
+ # detour through (60, 36) before continuing east to see
176
+ # the main fact. This makes the ≥2-buildings discovery
177
+ # latch genuinely require a scout phase: the second
178
+ # building is geographically unreachable on the assault
179
+ # path.
180
+ - {type: tent, owner: enemy, position: [60, 36]}
181
+ # Sentinel: a second isolated enemy fact at the far north,
182
+ # unreachable inside the clock budget, so razing the near
183
+ # fact does NOT auto-`done` the engine before the win
184
+ # predicate evaluates (defence-in-depth per CLAUDE.md
185
+ # auto-terminate footgun).
186
+ - {type: fact, owner: enemy, position: [124, 4]}
187
+ win_condition:
188
+ all_of:
189
+ - then:
190
+ id: scout-react-counter-easy
191
+ clauses:
192
+ - {buildings_discovered_gte: 2}
193
+ - {units_killed_gte: 3}
194
+ - {enemy_key_buildings_destroyed: {types: [fact]}}
195
+ - {within_ticks: 6300}
196
+ fail_condition:
197
+ any_of:
198
+ - {after_ticks: 6301}
199
+ - {not: {own_units_gte: 1}}
200
+ - {not: {has_building: fact}}
201
+ max_turns: 75
202
+
203
+ # ── MEDIUM ──────────────────────────────────────────────────────
204
+ # +1 controlled variable: HIDDEN COMPOSITION (the near enemy
205
+ # posture varies by seed). Two enemy fact+tent+defender clusters
206
+ # sit at the NE and SE corners; the agent's start latitude
207
+ # round-robins between a NORTH spawn (y=10) and a SOUTH spawn
208
+ # (y=30) so the natural "near" composition flips per seed. The
209
+ # scout phase now requires actually driving the jeep into the
210
+ # near corner (not just guessing) to register ≥2 buildings; a
211
+ # blind rush at the FAR corner overruns the clock; a
212
+ # scout-then-no-attack stalls at the react clause.
213
+ medium:
214
+ description: >
215
+ Your starter force sits at the west edge: a scout jeep and
216
+ three medium tanks (2tnk). Two enemy construction yards
217
+ (fact) sit hidden at the far NE and SE corners, each
218
+ defended by a passive infantry cluster. A SEPARATE enemy
219
+ barracks (tent) sits as a forward outpost at mid-x mid-y,
220
+ off the assault axis from either spawn. Drive the jeep
221
+ FIRST through the mid outpost to register the tent, then
222
+ east to the near corner to register the fact (≥2 buildings
223
+ discovered), then commit your tanks to destroy ≥3 enemy
224
+ units, then RAZE the near construction yard. All within
225
+ 6300 ticks. Pre-rushing tanks at the fact only registers
226
+ the fact (1 building); scouting forever bleeds the clock;
227
+ stalling at spawn never advances the chain.
228
+ starting_cash: 1500
229
+ overrides:
230
+ actors:
231
+ # North-base spawn (spawn_point 0).
232
+ - {type: fact, owner: agent, position: [10, 10], spawn_point: 0}
233
+ - {type: powr, owner: agent, position: [14, 10], spawn_point: 0}
234
+ - {type: jeep, owner: agent, position: [20, 8], stance: 1, spawn_point: 0}
235
+ - {type: 2tnk, owner: agent, position: [20, 10], stance: 1, spawn_point: 0}
236
+ - {type: 2tnk, owner: agent, position: [20, 11], stance: 1, spawn_point: 0}
237
+ - {type: 2tnk, owner: agent, position: [20, 12], stance: 1, spawn_point: 0}
238
+ # South-base spawn (spawn_point 1).
239
+ - {type: fact, owner: agent, position: [10, 30], spawn_point: 1}
240
+ - {type: powr, owner: agent, position: [14, 30], spawn_point: 1}
241
+ - {type: jeep, owner: agent, position: [20, 28], stance: 1, spawn_point: 1}
242
+ - {type: 2tnk, owner: agent, position: [20, 30], stance: 1, spawn_point: 1}
243
+ - {type: 2tnk, owner: agent, position: [20, 31], stance: 1, spawn_point: 1}
244
+ - {type: 2tnk, owner: agent, position: [20, 32], stance: 1, spawn_point: 1}
245
+ # NE enemy base: fact + defending e1 cluster.
246
+ - {type: fact, owner: enemy, position: [122, 5]}
247
+ - {type: e1, owner: enemy, position: [118, 5], stance: 0, count: 4}
248
+ # SE enemy base: fact + defending e3 cluster (anti-
249
+ # vehicle, stance:0 HoldFire — equivalent posture to NE
250
+ # so the seed-rotated near base presents an equivalent
251
+ # scout/react/counter task).
252
+ - {type: fact, owner: enemy, position: [122, 38]}
253
+ - {type: e3, owner: enemy, position: [118, 38], stance: 0, count: 4}
254
+ # Shared FORWARD OUTPOST: a lone enemy barracks (tent) at
255
+ # mid-x, mid-y. From EITHER spawn, the tank rush path
256
+ # stays inside its own y-band (N: y≈5..12, S: y≈30..38)
257
+ # and never approaches (60, 20) within tank sight ~6
258
+ # (closest the rush gets is ~8 cells, still out of
259
+ # sight). The jeep must deliberately detour through
260
+ # (60, 20) to register the tent, then continue east to
261
+ # register the near fact — making ≥2 buildings discovered
262
+ # require an EXPLICIT scout phase.
263
+ - {type: tent, owner: enemy, position: [60, 20]}
264
+ win_condition:
265
+ all_of:
266
+ - then:
267
+ id: scout-react-counter-medium
268
+ clauses:
269
+ - {buildings_discovered_gte: 2}
270
+ - {units_killed_gte: 3}
271
+ - {enemy_key_buildings_destroyed: {types: [fact]}}
272
+ - {within_ticks: 6300}
273
+ fail_condition:
274
+ any_of:
275
+ - {after_ticks: 6301}
276
+ - {not: {own_units_gte: 1}}
277
+ - {not: {has_building: fact}}
278
+ max_turns: 75
279
+
280
+ # ── HARD ────────────────────────────────────────────────────────
281
+ # +1 controlled variable: an ATTRITION CAP (units_lost_lte:3,
282
+ # so the agent cannot trade tanks freely). The map adds a
283
+ # MIDDLE pocket (a third fact+tent cluster at mid-latitude) so
284
+ # the scout phase has THREE candidate posture readings, not two
285
+ # — even on a fixed spawn the jeep must actually identify the
286
+ # nearest reachable cluster before committing. 2 spawn_point
287
+ # groups satisfy `tests/test_hard_tier.py::UPGRADED`. Blind
288
+ # rush busts the discovery latch AND the attrition cap; a
289
+ # scout-then-trade-everything play busts the attrition cap; an
290
+ # over-scout that runs the jeep to all three pockets bleeds
291
+ # the clock.
292
+ hard:
293
+ description: >
294
+ Your starter force at the west edge — a scout jeep and
295
+ three medium tanks (2tnk) — round-robined between a NORTH
296
+ base and a SOUTH base by seed. Three enemy construction
297
+ yards (fact) sit hidden at the far east (NE, MID, SE),
298
+ each with a passive defending cluster. A SEPARATE enemy
299
+ barracks (tent) sits as a forward outpost at mid-x mid-y,
300
+ off the assault axis from either spawn. Drive the jeep
301
+ through the mid outpost to register the tent and then to
302
+ the NEAREST corner (compass-only briefing, no coordinates)
303
+ to register the fact (≥2 buildings discovered), then
304
+ engage with the tanks to destroy ≥3 enemy units, and RAZE
305
+ the near construction yard — all while losing no more
306
+ than three of your own. All within 6300 ticks. Blind
307
+ rushing fails the discovery latch; reckless engagements
308
+ bust the attrition cap; an over-scout that touches all
309
+ three corners AND the outpost bleeds the clock.
310
+ starting_cash: 1500
311
+ objective_coords: relative
312
+ overrides:
313
+ actors:
314
+ # North-base spawn (spawn_point 0).
315
+ - {type: fact, owner: agent, position: [10, 10], spawn_point: 0}
316
+ - {type: powr, owner: agent, position: [14, 10], spawn_point: 0}
317
+ - {type: jeep, owner: agent, position: [20, 8], stance: 1, spawn_point: 0}
318
+ - {type: 2tnk, owner: agent, position: [20, 10], stance: 1, spawn_point: 0}
319
+ - {type: 2tnk, owner: agent, position: [20, 11], stance: 1, spawn_point: 0}
320
+ - {type: 2tnk, owner: agent, position: [20, 12], stance: 1, spawn_point: 0}
321
+ # South-base spawn (spawn_point 1).
322
+ - {type: fact, owner: agent, position: [10, 30], spawn_point: 1}
323
+ - {type: powr, owner: agent, position: [14, 30], spawn_point: 1}
324
+ - {type: jeep, owner: agent, position: [20, 28], stance: 1, spawn_point: 1}
325
+ - {type: 2tnk, owner: agent, position: [20, 30], stance: 1, spawn_point: 1}
326
+ - {type: 2tnk, owner: agent, position: [20, 31], stance: 1, spawn_point: 1}
327
+ - {type: 2tnk, owner: agent, position: [20, 32], stance: 1, spawn_point: 1}
328
+ # NE enemy base: fact + e1 cluster.
329
+ - {type: fact, owner: enemy, position: [122, 5]}
330
+ - {type: e1, owner: enemy, position: [118, 5], stance: 0, count: 4}
331
+ # SE enemy base: fact + e3 cluster.
332
+ - {type: fact, owner: enemy, position: [122, 38]}
333
+ - {type: e3, owner: enemy, position: [118, 38], stance: 0, count: 4}
334
+ # MID enemy base — the third compositional reading on the
335
+ # tighter attrition tier. fact + cluster at mid-latitude;
336
+ # unreachable as the "near" base from either NORTH (y=10)
337
+ # or SOUTH (y=30) spawn inside the tighter attrition
338
+ # budget (units_lost_lte:3) — the over-scout penalty is
339
+ # precisely the diversion to the mid pocket.
340
+ - {type: fact, owner: enemy, position: [122, 22]}
341
+ - {type: e1, owner: enemy, position: [118, 22], stance: 0, count: 4}
342
+ # Shared FORWARD OUTPOST: lone enemy tent at mid-x AND
343
+ # mid-y. The rush axis from either spawn stays inside
344
+ # its own y-band and never approaches (60, 20) within
345
+ # tank sight ~6; the jeep must detour through (60, 20)
346
+ # for the second building. Sits 5 cells north of the
347
+ # MID base fact (122, 22) so it cannot be confused with
348
+ # the MID base — the jeep at (60, 20) sees the outpost
349
+ # but the MID fact at distance √(62²+2²)≈62 is far out
350
+ # of jeep sight.
351
+ - {type: tent, owner: enemy, position: [60, 20]}
352
+ win_condition:
353
+ all_of:
354
+ - then:
355
+ id: scout-react-counter-hard
356
+ clauses:
357
+ - {buildings_discovered_gte: 2}
358
+ - {units_killed_gte: 3}
359
+ - {enemy_key_buildings_destroyed: {types: [fact]}}
360
+ - {within_ticks: 6300}
361
+ - {units_lost_lte: 3}
362
+ fail_condition:
363
+ any_of:
364
+ - {after_ticks: 6301}
365
+ - {not: {own_units_gte: 1}}
366
+ - {not: {has_building: fact}}
367
+ - {not: {units_lost_lte: 3}}
368
+ max_turns: 75
tests/test_coord_converge_on_target.py ADDED
@@ -0,0 +1,378 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """coord-converge-on-target — triple-prong convergent attack.
2
+
3
+ The bar: intended THREE-PRONG converge (all three squads driven onto
4
+ the objective fact at ~100,20 simultaneously) WINS on every level and
5
+ every hard seed (1..4). STALL (only observe), SINGLE-SQUAD-N (only the
6
+ north squad attacks while the other two stand still), and TWO-SQUADS
7
+ (north + south only — the west / east third squad stands still) all
8
+ LOSE on every level and every hard seed. Non-win is a real reachable
9
+ timeout LOSS via the `after_ticks` fail clause (force-wipe also fails).
10
+
11
+ Validation is scripted (no model / network).
12
+ """
13
+ from __future__ import annotations
14
+
15
+ from pathlib import Path
16
+
17
+ import pytest
18
+
19
+ pytest.importorskip("openra_rl_training", reason="Rust env wheel not installed")
20
+ from openra_bench.scenarios import load_pack
21
+ from openra_bench.scenarios.loader import compile_level
22
+ from openra_bench.scenarios.win_conditions import WinContext, evaluate
23
+
24
+ PACKS = Path(__file__).parent.parent / "openra_bench" / "scenarios" / "packs"
25
+ PACK_PATH = PACKS / "coord-converge-on-target.yaml"
26
+
27
+
28
+ # ── unit-level predicate / metadata checks (no engine) ──────────────
29
+
30
+
31
+ def test_pack_compiles_and_meta_fields_populated():
32
+ pack = load_pack(PACK_PATH)
33
+ assert pack.meta.id == "coord-converge-on-target"
34
+ assert pack.meta.capability == "action"
35
+ assert pack.meta.real_world_meaning, "real_world_meaning required"
36
+ assert pack.meta.robotics_analogue, "robotics_analogue required"
37
+ anchors = pack.meta.benchmark_anchor
38
+ assert isinstance(anchors, list) and len(anchors) >= 3, (
39
+ f"benchmark_anchor must list ≥3 anchors, got {anchors!r}"
40
+ )
41
+ joined = " ".join(anchors).lower()
42
+ for needle in ("sc2", "convergent", "envelopment"):
43
+ assert needle in joined, f"missing anchor keyword: {needle}"
44
+ for lvl in ("easy", "medium", "hard"):
45
+ c = compile_level(pack, lvl)
46
+ assert c.map_supported
47
+ assert c.win_condition is not None
48
+ assert c.fail_condition is not None
49
+
50
+
51
+ def _ctx(*, units=(), tick=1000, lost=0, fact_destroyed=False):
52
+ """Synthesize a WinContext for predicate-level checks."""
53
+ import types
54
+
55
+ types_count = {"fact": 1} if fact_destroyed else {}
56
+ sig = types.SimpleNamespace(
57
+ game_tick=tick,
58
+ units_killed=0,
59
+ units_lost=lost,
60
+ cash=0,
61
+ resources=0,
62
+ own_buildings=[],
63
+ own_building_types=set(),
64
+ enemies_seen_ids=set(),
65
+ enemy_buildings_seen_ids=set(),
66
+ enemy_buildings_destroyed=1 if fact_destroyed else 0,
67
+ enemy_buildings_destroyed_types=types_count,
68
+ enemy_buildings_destroyed_records=(
69
+ [("fact", 100, 20)] if fact_destroyed else []
70
+ ),
71
+ )
72
+ return WinContext(
73
+ signals=sig,
74
+ render_state={"units_summary": list(units)},
75
+ )
76
+
77
+
78
+ def _tanks_at(cx, cy, n, base_id=1000):
79
+ return [
80
+ {"cell_x": cx, "cell_y": cy, "type": "2tnk", "id": str(base_id + i)}
81
+ for i in range(n)
82
+ ]
83
+
84
+
85
+ def test_predicates_easy_medium_hard():
86
+ pack = load_pack(PACK_PATH)
87
+ for lvl in ("easy", "medium", "hard"):
88
+ c = compile_level(pack, lvl)
89
+ # WIN: ≥6 tanks inside (100,20,r=8) AND fact destroyed AND in budget.
90
+ win_units = _tanks_at(100, 20, 6) + _tanks_at(10, 20, 3, base_id=2000)
91
+ assert evaluate(
92
+ c.win_condition,
93
+ _ctx(units=win_units, tick=4000, fact_destroyed=True),
94
+ ), f"{lvl}: 6-in-region + fact destroyed must WIN"
95
+
96
+ # Fact NOT destroyed → not a win even with tanks in region.
97
+ assert not evaluate(
98
+ c.win_condition,
99
+ _ctx(units=win_units, tick=4000, fact_destroyed=False),
100
+ )
101
+
102
+ # Only 5 tanks in region (one short of n=6) → not a win.
103
+ five_in = _tanks_at(100, 20, 5) + _tanks_at(10, 20, 4, base_id=2000)
104
+ assert not evaluate(
105
+ c.win_condition,
106
+ _ctx(units=five_in, tick=4000, fact_destroyed=True),
107
+ ), f"{lvl}: 5-in-region must not WIN (n=6 threshold)"
108
+
109
+ # Past deadline → not a win.
110
+ assert not evaluate(
111
+ c.win_condition,
112
+ _ctx(units=win_units, tick=4501, fact_destroyed=True),
113
+ )
114
+
115
+ # Timeout with bar unmet → fail (after_ticks 4501).
116
+ assert evaluate(
117
+ c.fail_condition,
118
+ _ctx(units=win_units, tick=4600, fact_destroyed=False),
119
+ )
120
+
121
+ # Force-wipe → fail (not own_units_gte:1).
122
+ assert evaluate(
123
+ c.fail_condition,
124
+ _ctx(units=[], tick=3000, fact_destroyed=False),
125
+ )
126
+
127
+
128
+ def test_timeout_reachable_inside_max_turns():
129
+ """No draw degeneracy: after_ticks 4501 ≤ 93 + 90·(max_turns-1)."""
130
+ pack = load_pack(PACK_PATH)
131
+ for lvl in ("easy", "medium", "hard"):
132
+ c = compile_level(pack, lvl)
133
+ max_tick = 93 + 90 * (c.max_turns - 1)
134
+ assert 4501 <= max_tick, (
135
+ f"{lvl}: after_ticks 4501 > max reachable tick {max_tick} "
136
+ f"(max_turns={c.max_turns}); deadline never bites"
137
+ )
138
+ assert 4500 <= max_tick, (
139
+ f"{lvl}: within_ticks 4500 > max tick {max_tick}"
140
+ )
141
+
142
+
143
+ def test_hard_has_two_spawn_point_groups():
144
+ """Hard-tier curation: ≥2 distinct agent spawn_point groups so the
145
+ seed round-robins the staging geometry (N/W/S vs N/E/S). Engine
146
+ round-trip is asserted by tests/test_hard_tier.py."""
147
+ c = compile_level(load_pack(PACK_PATH), "hard")
148
+ groups = {
149
+ (a.spawn_point if a.spawn_point is not None else 0)
150
+ for a in c.scenario.actors
151
+ if a.owner == "agent"
152
+ }
153
+ assert len(groups) >= 2, f"hard needs ≥2 spawn_point groups, got {groups}"
154
+
155
+
156
+ def test_guard_bot_and_e3_cluster():
157
+ """The enemy must be the `guard` scripted bot (leashed defender)
158
+ and the cluster must be e3 rocket-infantry (anti-tank Dragon) —
159
+ these together make any single squad's frontal commit costly,
160
+ so a 3-prong convergence is required to win the trade."""
161
+ pack = load_pack(PACK_PATH)
162
+ enemy = pack.base.get("enemy") if isinstance(pack.base, dict) else None
163
+ assert enemy is not None
164
+ bot = enemy.get("bot_type") or enemy.get("bot") or ""
165
+ assert bot == "guard", f"expected guard bot, got {bot!r}"
166
+ for lvl in ("easy", "medium", "hard"):
167
+ c = compile_level(pack, lvl)
168
+ types = [a.type for a in c.scenario.actors if a.owner == "enemy"]
169
+ assert "e3" in types, f"{lvl}: cluster must be e3 rocket infantry"
170
+ assert "fact" in types, f"{lvl}: objective must include enemy fact"
171
+
172
+
173
+ def test_objective_fact_and_sentinel_present():
174
+ """Two enemy `fact` per level: one objective fact (~100,20) and one
175
+ far sentinel fact keeping the episode alive past objective-fact
176
+ destruction so within_ticks evaluates on the terminal frame (the
177
+ CLAUDE.md MustBeDestroyed auto-terminate footgun)."""
178
+ pack = load_pack(PACK_PATH)
179
+ for lvl in ("easy", "medium", "hard"):
180
+ c = compile_level(pack, lvl)
181
+ facts = [
182
+ (a.position[0], a.position[1])
183
+ for a in c.scenario.actors
184
+ if a.owner == "enemy" and a.type == "fact"
185
+ ]
186
+ assert len(facts) == 2, (
187
+ f"{lvl}: must have 2 enemy facts (objective + sentinel), "
188
+ f"got {len(facts)} at {facts}"
189
+ )
190
+ obj = [p for p in facts if 92 <= p[0] <= 108 and 14 <= p[1] <= 26]
191
+ far = [p for p in facts if not (92 <= p[0] <= 108 and 14 <= p[1] <= 26)]
192
+ assert len(obj) == 1 and len(far) == 1, (
193
+ f"{lvl}: expected 1 objective fact near (100,20) + 1 sentinel "
194
+ f"far, got obj={obj} far={far}"
195
+ )
196
+
197
+
198
+ def test_guard_count_per_level():
199
+ """Difficulty axis: easy 3 guards (distributed), medium 4 (one
200
+ per cardinal face), hard 4 + seed-driven agent spawn variation.
201
+ The discrimination on medium / hard is tighter than on easy: a
202
+ single squad alone is destroyed in every tier; a two-squad
203
+ converge destroys the fact but trades the strike force out on
204
+ medium / hard (cardinal cover); only the three-prong meets the
205
+ n=6 region threshold."""
206
+ pack = load_pack(PACK_PATH)
207
+ expected = {"easy": 3, "medium": 4, "hard": 4}
208
+ for lvl, want in expected.items():
209
+ c = compile_level(pack, lvl)
210
+ n_e3 = sum(
211
+ 1 for a in c.scenario.actors if a.owner == "enemy" and a.type == "e3"
212
+ )
213
+ assert n_e3 == want, f"{lvl}: expected {want} guards, got {n_e3}"
214
+
215
+
216
+ def test_three_agent_squads_per_spawn():
217
+ """Every spawn group must place 3 squads of 3× 2tnk each (9 tanks
218
+ total) so the n=6 region clause requires a true 3-prong converge
219
+ (a 2-prong with full attrition cannot reach 6 inside the region)."""
220
+ pack = load_pack(PACK_PATH)
221
+ for lvl in ("easy", "medium", "hard"):
222
+ c = compile_level(pack, lvl)
223
+ # group spawn -> total agent tank count
224
+ by_grp: dict = {}
225
+ for a in c.scenario.actors:
226
+ if a.owner == "agent" and a.type == "2tnk":
227
+ key = a.spawn_point if a.spawn_point is not None else 0
228
+ by_grp[key] = by_grp.get(key, 0) + (a.count or 1)
229
+ for k, n in by_grp.items():
230
+ assert n == 9, (
231
+ f"{lvl}: spawn group {k} must have 9× 2tnk (3 squads × 3), "
232
+ f"got {n}"
233
+ )
234
+
235
+
236
+ # ── engine-driven scripted policies ─────────────────────────────────
237
+
238
+
239
+ def _of_type(rs, want_types):
240
+ return [
241
+ u for u in (rs.get("units_summary", []) or [])
242
+ if u.get("type") in want_types
243
+ ]
244
+
245
+
246
+ def _stall(rs, Command):
247
+ """Pure observe — no tank ever moves; objective fact untouched and
248
+ the deadline expires → LOSS."""
249
+ return [Command.observe()]
250
+
251
+
252
+ def _by_squad(rs):
253
+ """Bucket agent tanks by their starting hemisphere so a partial
254
+ policy can drive only some squads. Squads start at:
255
+ NORTH y≈6 (any tank with cell_y ≤ 12)
256
+ SOUTH y≈34 (any tank with cell_y ≥ 28)
257
+ WEST x≈10 (any tank with cell_x ≤ 20 and 12 < cell_y < 28)
258
+ EAST x≈120 (any tank with cell_x ≥ 110 and 12 < cell_y < 28)
259
+ Per-seed the hard tier flips W↔E for the third squad; the bucket
260
+ label is "flank" for whichever lateral squad is present.
261
+ """
262
+ north, south, flank = [], [], []
263
+ for t in _of_type(rs, {"2tnk"}):
264
+ x, y = t["cell_x"], t["cell_y"]
265
+ if y <= 12:
266
+ north.append(t)
267
+ elif y >= 28:
268
+ south.append(t)
269
+ else:
270
+ flank.append(t)
271
+ return north, south, flank
272
+
273
+
274
+ def _single_squad_n(rs, Command):
275
+ """Only the NORTH squad attack-moves onto the objective; the other
276
+ two squads stand still. Three tanks vs the guard cluster (3 / 6 /
277
+ 8 by tier) lose the trade — fact never falls and/or the attacking
278
+ squad is wiped (≤3 of 9 in region < n=6). LOSS."""
279
+ north, _south, _flank = _by_squad(rs)
280
+ cmds = []
281
+ for t in north:
282
+ cmds.append(Command.attack_move([str(t["id"])], 100, 20))
283
+ return cmds or [Command.observe()]
284
+
285
+
286
+ def _two_squads(rs, Command):
287
+ """NORTH + SOUTH squads attack-move onto the objective; the
288
+ lateral (WEST or EAST) squad stands still. 6 tanks vs the cluster
289
+ are still defeated under heavier defender density (medium 6
290
+ guards, hard 8) and the surviving in-region count drops below
291
+ n=6. LOSS."""
292
+ north, south, _flank = _by_squad(rs)
293
+ cmds = []
294
+ for t in north + south:
295
+ cmds.append(Command.attack_move([str(t["id"])], 100, 20))
296
+ return cmds or [Command.observe()]
297
+
298
+
299
+ def _intended_three_prong(rs, Command):
300
+ """All three squads attack-move directly onto the objective fact at
301
+ (100,20). 9 tanks converging on the guard cluster (3 / 6 / 8 e3
302
+ defenders) overwhelm by mass: at least 6 of the 9 tanks survive
303
+ inside the (100,20,r=8) region while the cluster + fact fall.
304
+ WIN on every tier and every seed."""
305
+ cmds = []
306
+ for t in _of_type(rs, {"2tnk"}):
307
+ cmds.append(Command.attack_move([str(t["id"])], 100, 20))
308
+ return cmds or [Command.observe()]
309
+
310
+
311
+ @pytest.mark.parametrize("level", ["easy", "medium", "hard"])
312
+ @pytest.mark.parametrize("seed", [1, 2, 3, 4])
313
+ def test_intended_three_prong_wins(level, seed):
314
+ pytest.importorskip("openra_train")
315
+ from openra_bench.eval_core import run_level
316
+
317
+ c = compile_level(load_pack(PACK_PATH), level)
318
+ r = run_level(c, _intended_three_prong, seed=seed)
319
+ assert r.outcome == "win", (
320
+ f"{level} seed={seed}: intended 3-prong converge should WIN, "
321
+ f"got {r.outcome} after {r.turns} turns "
322
+ f"(lost={r.signals.units_lost}, "
323
+ f"bldgs={r.signals.enemy_buildings_destroyed_types})"
324
+ )
325
+
326
+
327
+ @pytest.mark.parametrize("level", ["easy", "medium", "hard"])
328
+ @pytest.mark.parametrize("seed", [1, 2, 3, 4])
329
+ def test_stall_loses(level, seed):
330
+ pytest.importorskip("openra_train")
331
+ from openra_bench.eval_core import run_level
332
+
333
+ c = compile_level(load_pack(PACK_PATH), level)
334
+ r = run_level(c, _stall, seed=seed)
335
+ assert r.outcome == "loss", (
336
+ f"{level} seed={seed}: stall must be a real timeout LOSS "
337
+ f"(fact never takes damage), got {r.outcome}"
338
+ )
339
+
340
+
341
+ @pytest.mark.parametrize("level", ["medium", "hard"])
342
+ @pytest.mark.parametrize("seed", [1, 2, 3, 4])
343
+ def test_single_squad_north_loses(level, seed):
344
+ """Single north squad alone vs the guard cluster must LOSE on
345
+ medium (6 guards) and hard (8 guards). Easy with only 3 guards
346
+ may occasionally let a single squad squeak through; the
347
+ discrimination tier is medium+."""
348
+ pytest.importorskip("openra_train")
349
+ from openra_bench.eval_core import run_level
350
+
351
+ c = compile_level(load_pack(PACK_PATH), level)
352
+ r = run_level(c, _single_squad_n, seed=seed)
353
+ assert r.outcome == "loss", (
354
+ f"{level} seed={seed}: single-squad north must LOSE "
355
+ f"(3 tanks vs guard cluster), got {r.outcome} "
356
+ f"(lost={r.signals.units_lost}, "
357
+ f"bldgs={r.signals.enemy_buildings_destroyed_types})"
358
+ )
359
+
360
+
361
+ @pytest.mark.parametrize("level", ["medium", "hard"])
362
+ @pytest.mark.parametrize("seed", [1, 2, 3, 4])
363
+ def test_two_squads_loses(level, seed):
364
+ """Two squads (north + south) without the third must LOSE on
365
+ medium (6 guards) and hard (8 guards) — 6 tanks vs the cluster
366
+ trade out and the surviving in-region count drops below n=6 (or
367
+ the fact never falls)."""
368
+ pytest.importorskip("openra_train")
369
+ from openra_bench.eval_core import run_level
370
+
371
+ c = compile_level(load_pack(PACK_PATH), level)
372
+ r = run_level(c, _two_squads, seed=seed)
373
+ assert r.outcome == "loss", (
374
+ f"{level} seed={seed}: two-squad converge must LOSE on "
375
+ f"medium/hard (defender density overwhelms 6 tanks), "
376
+ f"got {r.outcome} (lost={r.signals.units_lost}, "
377
+ f"bldgs={r.signals.enemy_buildings_destroyed_types})"
378
+ )
tests/test_coord_cover_and_move.py ADDED
@@ -0,0 +1,395 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """coord-cover-and-move — bounding-overwatch across a centre fire zone.
2
+
3
+ The bar (binding):
4
+ - intended bound-and-cover policy WINS on every level + every hard
5
+ seed (1..4);
6
+ - stall / both-charge-together / single-squad-only LOSE on every level
7
+ + every hard seed (with one documented exception: easy uses e1 rifle
8
+ infantry which does limited damage to 2tnk armour, so both-charge
9
+ and single-squad may squeak through — bare-skill tier; matches the
10
+ pack design comment and SCENARIO_REVIEW_CHECKLIST.md note that
11
+ "inert anti-cheat teeth are acceptable on easy");
12
+ - non-win is a real reachable timeout LOSS via the `after_ticks`
13
+ fail clause (no DRAW degeneracy: 4501 ≤ 93 + 90·(max_turns − 1));
14
+ - hard ships ≥2 `spawn_point` groups (seed-driven start variation).
15
+
16
+ Validation is scripted (no model / network).
17
+ """
18
+ from __future__ import annotations
19
+
20
+ from pathlib import Path
21
+
22
+ import pytest
23
+
24
+ pytest.importorskip("openra_rl_training", reason="Rust env wheel not installed")
25
+ from openra_bench.scenarios import load_pack
26
+ from openra_bench.scenarios.loader import compile_level
27
+ from openra_bench.scenarios.win_conditions import WinContext, evaluate
28
+
29
+ PACKS = Path(__file__).parent.parent / "openra_bench" / "scenarios" / "packs"
30
+ PACK_PATH = PACKS / "coord-cover-and-move.yaml"
31
+
32
+
33
+ # ── declarative / schema invariants (no engine needed) ─────────────
34
+
35
+
36
+ def test_pack_compiles_and_meta_fields_populated():
37
+ pack = load_pack(PACK_PATH)
38
+ assert pack.meta.id == "coord-cover-and-move"
39
+ assert pack.meta.capability == "action"
40
+ assert pack.meta.real_world_meaning, "real_world_meaning required"
41
+ assert pack.meta.robotics_analogue, "robotics_analogue required"
42
+ anchors = pack.meta.benchmark_anchor
43
+ assert isinstance(anchors, list) and len(anchors) == 4, (
44
+ f"benchmark_anchor must list all 4 anchors, got {anchors!r}"
45
+ )
46
+ joined = " ".join(anchors).lower()
47
+ # Spec-mandated anchors: military bounding overwatch + fire-and-
48
+ # maneuver + SC2 tank movement + SMAC danger-zone cross.
49
+ for needle in ("bounding overwatch", "fire-and-maneuver",
50
+ "sc2", "smac"):
51
+ assert needle in joined, f"missing anchor keyword: {needle}"
52
+ for lvl in ("easy", "medium", "hard"):
53
+ c = compile_level(pack, lvl)
54
+ assert c.map_supported
55
+ assert c.win_condition is not None
56
+ assert c.fail_condition is not None
57
+
58
+
59
+ def _ctx(*, units=(), tick=1000, kills=0, lost=0):
60
+ """Synthesize a WinContext for predicate-level checks."""
61
+ import types
62
+
63
+ sig = types.SimpleNamespace(
64
+ game_tick=tick,
65
+ units_killed=kills,
66
+ units_lost=lost,
67
+ cash=0,
68
+ resources=0,
69
+ own_buildings=[],
70
+ own_building_types=set(),
71
+ enemies_seen_ids=set(),
72
+ enemy_buildings_seen_ids=set(),
73
+ )
74
+ return WinContext(
75
+ signals=sig,
76
+ render_state={"units_summary": list(units)},
77
+ )
78
+
79
+
80
+ def _tanks_at(n, x=100, y=20):
81
+ return [
82
+ {"cell_x": x, "cell_y": y, "type": "2tnk", "id": str(2000 + i)}
83
+ for i in range(n)
84
+ ]
85
+
86
+
87
+ @pytest.mark.parametrize("lvl", ["easy", "medium", "hard"])
88
+ def test_win_requires_four_units_in_east_region(lvl):
89
+ c = compile_level(load_pack(PACK_PATH), lvl)
90
+ # 4 tanks in win region, 0 lost, in time → WIN
91
+ cap = 0 if lvl == "hard" else 1
92
+ assert evaluate(c.win_condition,
93
+ _ctx(units=_tanks_at(4), tick=2000, lost=0))
94
+ # Only 3 in region → not a win
95
+ assert not evaluate(c.win_condition,
96
+ _ctx(units=_tanks_at(3), tick=2000, lost=0))
97
+ # 4 in region but attrition cap busted → not a win
98
+ assert not evaluate(
99
+ c.win_condition,
100
+ _ctx(units=_tanks_at(4), tick=2000, lost=cap + 1),
101
+ )
102
+ # 4 in region but past the deadline → not a win
103
+ assert not evaluate(c.win_condition,
104
+ _ctx(units=_tanks_at(4), tick=4600, lost=0))
105
+
106
+
107
+ @pytest.mark.parametrize("lvl", ["easy", "medium", "hard"])
108
+ def test_fail_clauses_are_reachable_losses(lvl):
109
+ c = compile_level(load_pack(PACK_PATH), lvl)
110
+ cap = 0 if lvl == "hard" else 1
111
+ # Past deadline → fail
112
+ assert evaluate(c.fail_condition,
113
+ _ctx(units=_tanks_at(6, x=10), tick=4600, lost=0))
114
+ # Attrition cap busted → fail
115
+ assert evaluate(c.fail_condition,
116
+ _ctx(units=_tanks_at(5), tick=2000, lost=cap + 1))
117
+ # Force wipe → fail
118
+ assert evaluate(c.fail_condition,
119
+ _ctx(units=[], tick=2000, lost=6))
120
+
121
+
122
+ def test_timeout_reachable_inside_max_turns():
123
+ """No draw degeneracy: after_ticks 4501 ≤ 93 + 90·(max_turns-1)."""
124
+ pack = load_pack(PACK_PATH)
125
+ for lvl in ("easy", "medium", "hard"):
126
+ c = compile_level(pack, lvl)
127
+ max_tick = 93 + 90 * (c.max_turns - 1)
128
+ assert 4501 <= max_tick, (
129
+ f"{lvl}: after_ticks 4501 > max reachable tick {max_tick} "
130
+ f"(max_turns={c.max_turns}); deadline never bites"
131
+ )
132
+ assert 4500 <= max_tick, (
133
+ f"{lvl}: within_ticks 4500 > max reachable tick {max_tick}"
134
+ )
135
+
136
+
137
+ def test_hard_has_two_spawn_point_groups():
138
+ """Hard curation: ≥2 distinct agent spawn_point groups so the
139
+ seed round-robins the staging corner (NW / SW). Engine-roundtrip
140
+ asserted by tests/test_hard_tier.py."""
141
+ c = compile_level(load_pack(PACK_PATH), "hard")
142
+ groups = {
143
+ (a.spawn_point if a.spawn_point is not None else 0)
144
+ for a in c.scenario.actors
145
+ if a.owner == "agent"
146
+ }
147
+ assert len(groups) >= 2, f"hard needs ≥2 spawn_point groups, got {groups}"
148
+
149
+
150
+ def test_two_squads_six_tanks_each_level():
151
+ """Two squads (3+3 = 6 tanks total per spawn) is the load-bearing
152
+ geometry — fewer tanks and the cover/bound roles cannot afford
153
+ losses; more and the cap is meaningless. Hard places the same 6
154
+ per spawn_point group across two groups."""
155
+ pack = load_pack(PACK_PATH)
156
+ for lvl in ("easy", "medium"):
157
+ c = compile_level(pack, lvl)
158
+ tanks = [a for a in c.scenario.actors
159
+ if a.owner == "agent" and a.type == "2tnk"]
160
+ assert len(tanks) == 6, f"{lvl}: expected 6 tanks, got {len(tanks)}"
161
+ c = compile_level(pack, "hard")
162
+ per_group = {}
163
+ for a in c.scenario.actors:
164
+ if a.owner != "agent" or a.type != "2tnk":
165
+ continue
166
+ sp = a.spawn_point if a.spawn_point is not None else 0
167
+ per_group[sp] = per_group.get(sp, 0) + 1
168
+ assert all(n == 6 for n in per_group.values()), (
169
+ f"hard: each spawn_point group should have 6 tanks, got {per_group}"
170
+ )
171
+
172
+
173
+ def test_fire_zone_uses_anti_tank_rocket_on_medium_and_hard():
174
+ """The fire zone's lethality is the load-bearing property —
175
+ medium / hard must use e3 (anti-tank rocket soldier, dps12 vs
176
+ armour) at the centre cluster. Easy may use e1 (forgiving)."""
177
+ pack = load_pack(PACK_PATH)
178
+ for lvl in ("medium", "hard"):
179
+ c = compile_level(pack, lvl)
180
+ types = [a.type for a in c.scenario.actors if a.owner == "enemy"]
181
+ assert types.count("e3") >= 4, (
182
+ f"{lvl}: need ≥4 e3 (anti-tank rocket) for fire zone; got {types}"
183
+ )
184
+ # Persistent far enemy marker (engine auto-done mitigation).
185
+ assert "fact" in types, f"{lvl}: needs persistent enemy fact"
186
+
187
+
188
+ # ── engine-driven scripted policies ────────────────────────────────
189
+
190
+
191
+ def _own_units(rs):
192
+ return rs.get("units_summary", []) or []
193
+
194
+
195
+ def _own_ids(rs):
196
+ return [str(u["id"]) for u in _own_units(rs)]
197
+
198
+
199
+ def _stall(rs, Command):
200
+ """Pure observe — no squad crosses, win region stays empty,
201
+ after_ticks LOSS."""
202
+ return [Command.observe()]
203
+
204
+
205
+ def _both_charge_together(rs, Command):
206
+ """Both squads sprint east through the centre on the same y-band.
207
+ Engine spreads fire across the dense column → on medium/hard the
208
+ 4× e3 anti-tank dps stacks enough to bust units_lost_lte:1 → LOSS."""
209
+ units = _own_units(rs)
210
+ if not units:
211
+ return [Command.observe()]
212
+ cmds = []
213
+ for u in units:
214
+ # Everyone barrels east toward the win region (100,20).
215
+ cmds.append(Command.move_units([str(u["id"])], 100, 20))
216
+ return cmds
217
+
218
+
219
+ def _single_squad_only(rs, Command):
220
+ """Only the NORTHERN squad (y < 20 at spawn) crosses; the southern
221
+ squad is idle. The lone column absorbs ALL the centre cluster's
222
+ fire and busts the attrition cap → LOSS on medium/hard."""
223
+ units = _own_units(rs)
224
+ if not units:
225
+ return [Command.observe()]
226
+ cmds = []
227
+ for u in units:
228
+ if u["cell_y"] < 20:
229
+ cmds.append(Command.move_units([str(u["id"])], 100, 20))
230
+ else:
231
+ cmds.append(Command.stop([str(u["id"])]))
232
+ return cmds
233
+
234
+
235
+ def _intended_bound_and_cover(rs, Command):
236
+ """Bounding overwatch:
237
+
238
+ Phase 1 — cover squad (northern at spawn, by y) advances to its
239
+ overwatch post at (~45, ~spawn_y) at the EDGE of the e3 range
240
+ (tank rng 4.75 vs e3 rng 4 — tanks fire from dist ~5);
241
+ bounding squad (southern) takes the FAR-NORTH wide-flank route
242
+ outside the fire envelope: detour to (~50, ~6 or ~34) then
243
+ east toward (~100, ~20).
244
+ Phase 2 — bounding squad pushed past the cluster onto the east
245
+ side; switch — original cover squad now relocates to the far
246
+ wide-flank route, then east.
247
+
248
+ The policy doesn't need ticks-aware role-flipping: it routes the
249
+ SOUTH squad through y≈6 (or y≈34 if south spawn) — the wide flank
250
+ — and the NORTH squad through y≈6 as well after a stagger. The
251
+ cover team's fire SUPPRESSES the cluster while the bounding team
252
+ is in transit. A simple implementation:
253
+ - sort own units by y; northern half = COVER, southern half = MOVE
254
+ - for the NORTH spawn (median y < 20): MOVE goes via y=8 then
255
+ east; COVER drives to (45, 15) and attack_moves on cluster.
256
+ - for the SOUTH spawn (median y > 20): mirror — MOVE goes via
257
+ y=32 then east; COVER drives to (45, 25) and attack_moves.
258
+ - once MOVE has cleared the fire zone (cell_x > 70), COVER also
259
+ starts the wide-flank route through the same outside-sight
260
+ band and follows east.
261
+ """
262
+ units = _own_units(rs)
263
+ if not units:
264
+ return [Command.observe()]
265
+
266
+ # Identify spawn geometry by current median y. Squads start with
267
+ # y ∈ {11..17} (NORTH spawn) or {23..29} (SOUTH spawn) in hard.
268
+ # Easy/medium always use y ∈ {14..16, 24..26}.
269
+ ys = sorted(u["cell_y"] for u in units)
270
+ median_y = ys[len(ys) // 2]
271
+
272
+ if median_y < 20:
273
+ # NORTH spawn: COVER on the south-of-spawn flank near the
274
+ # cluster's NORTH edge; MOVE detours through y ≈ 6..8.
275
+ flank_y = 8
276
+ cover_y = 15
277
+ else:
278
+ # SOUTH spawn (hard only): mirror across y=20.
279
+ flank_y = 32
280
+ cover_y = 25
281
+
282
+ # Split units: the half closer to the cluster's y axis (i.e.
283
+ # closer to y=20) becomes COVER; the half farther from y=20
284
+ # becomes MOVE (these are the ones we'll route via the wide flank).
285
+ units_sorted = sorted(units, key=lambda u: abs(u["cell_y"] - 20))
286
+ half = len(units_sorted) // 2
287
+ cover_team = units_sorted[:half]
288
+ move_team = units_sorted[half:]
289
+
290
+ cmds = []
291
+ # Check whether MOVE team has cleared the fire zone (any move-team
292
+ # tank with x ≥ 75). When cleared, COVER also begins its bound.
293
+ move_cleared = any(u["cell_x"] >= 75 for u in move_team)
294
+
295
+ # COVER team: drive to overwatch post and attack_move onto the
296
+ # cluster centre (the engine auto-targets nearest hostile in
297
+ # range; with cover at (45, cover_y) and cluster at (50, 20), the
298
+ # e3s are the visible targets and tanks fire dps22 each).
299
+ for u in cover_team:
300
+ if not move_cleared:
301
+ if u["cell_x"] < 45:
302
+ # Still approaching the overwatch post — drive into
303
+ # firing range of the cluster.
304
+ cmds.append(Command.attack_move([str(u["id"])], 45, cover_y))
305
+ else:
306
+ # Posted: keep firing. attack_move onto cluster cell.
307
+ cmds.append(Command.attack_move([str(u["id"])], 50, 20))
308
+ else:
309
+ # MOVE team has crossed; COVER bounds through the wide
310
+ # flank too (same y-band the MOVE team used).
311
+ if u["cell_x"] < 60:
312
+ # Detour north/south away from the cluster first.
313
+ cmds.append(Command.move_units([str(u["id"])], 50, flank_y))
314
+ else:
315
+ cmds.append(Command.move_units([str(u["id"])], 100, 20))
316
+
317
+ # MOVE team: wide-flank route. Stage 1 detour to (50, flank_y) so
318
+ # the column stays outside the cluster's sight envelope; stage 2
319
+ # east toward the win region.
320
+ for u in move_team:
321
+ if u["cell_x"] < 60:
322
+ cmds.append(Command.move_units([str(u["id"])], 50, flank_y))
323
+ elif u["cell_x"] < 90:
324
+ cmds.append(Command.move_units([str(u["id"])], 90, 20))
325
+ else:
326
+ cmds.append(Command.move_units([str(u["id"])], 100, 20))
327
+ return cmds
328
+
329
+
330
+ @pytest.mark.parametrize("level", ["easy", "medium", "hard"])
331
+ @pytest.mark.parametrize("seed", [1, 2, 3, 4])
332
+ def test_intended_bound_and_cover_wins(level, seed):
333
+ pytest.importorskip("openra_train")
334
+ from openra_bench.eval_core import run_level
335
+
336
+ c = compile_level(load_pack(PACK_PATH), level)
337
+ r = run_level(c, _intended_bound_and_cover, seed=seed)
338
+ assert r.outcome == "win", (
339
+ f"{level} seed={seed}: bound-and-cover should WIN, got "
340
+ f"{r.outcome} after {r.turns} turns "
341
+ f"(losses={r.signals.units_lost})"
342
+ )
343
+
344
+
345
+ @pytest.mark.parametrize("level", ["easy", "medium", "hard"])
346
+ @pytest.mark.parametrize("seed", [1, 2, 3, 4])
347
+ def test_stall_loses(level, seed):
348
+ pytest.importorskip("openra_train")
349
+ from openra_bench.eval_core import run_level
350
+
351
+ c = compile_level(load_pack(PACK_PATH), level)
352
+ r = run_level(c, _stall, seed=seed)
353
+ assert r.outcome == "loss", (
354
+ f"{level} seed={seed}: stall must be a real timeout LOSS "
355
+ f"(no squad crosses → win region empty → after_ticks fires), "
356
+ f"got {r.outcome}"
357
+ )
358
+
359
+
360
+ @pytest.mark.parametrize("level", ["medium", "hard"])
361
+ @pytest.mark.parametrize("seed", [1, 2, 3, 4])
362
+ def test_both_charge_together_loses(level, seed):
363
+ """Both squads sprint together → fire stacks on dense column →
364
+ busts units_lost_lte cap on medium and hard. Easy excluded
365
+ (e1 rifle does limited damage to armour; forgiving bare-skill
366
+ tier; matches SCENARIO_REVIEW_CHECKLIST.md note that "inert
367
+ anti-cheat teeth are acceptable on easy")."""
368
+ pytest.importorskip("openra_train")
369
+ from openra_bench.eval_core import run_level
370
+
371
+ c = compile_level(load_pack(PACK_PATH), level)
372
+ r = run_level(c, _both_charge_together, seed=seed)
373
+ assert r.outcome == "loss", (
374
+ f"{level} seed={seed}: both-charge must LOSE (stacked e3 fire "
375
+ f"busts attrition cap), got {r.outcome} "
376
+ f"(losses={r.signals.units_lost})"
377
+ )
378
+
379
+
380
+ @pytest.mark.parametrize("level", ["medium", "hard"])
381
+ @pytest.mark.parametrize("seed", [1, 2, 3, 4])
382
+ def test_single_squad_only_loses(level, seed):
383
+ """Only one squad crosses → lone column absorbs ALL cluster fire
384
+ → busts attrition cap on medium/hard. Easy excluded (same
385
+ forgiving-bare-skill reasoning as both-charge above)."""
386
+ pytest.importorskip("openra_train")
387
+ from openra_bench.eval_core import run_level
388
+
389
+ c = compile_level(load_pack(PACK_PATH), level)
390
+ r = run_level(c, _single_squad_only, seed=seed)
391
+ assert r.outcome == "loss", (
392
+ f"{level} seed={seed}: single-squad must LOSE (lone column "
393
+ f"absorbs full cluster fire), got {r.outcome} "
394
+ f"(losses={r.signals.units_lost})"
395
+ )
tests/test_coord_diversionary_attack.py ADDED
@@ -0,0 +1,572 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """coord-diversionary-attack — diversionary / split-attack assault.
2
+
3
+ The bar: intended diversionary-attack (Squad A jeeps drag the
4
+ heavier enemy garrison off the real target by attacking the decoy /
5
+ centre cluster; Squad B tanks raze the REAL fact while the south
6
+ defenders are committed in pursuit) WINS on every level and every
7
+ hard seed (1..4). STALL (only observe), BRUTE-FRONTAL (every unit
8
+ attack-moves onto the centre / decoy), LAZY NEAREST (jeeps onto the
9
+ near-looking fact, tanks onto the near-looking powr), and BAIT-ONLY
10
+ (jeeps slash south but tanks stand still) all LOSE on every level
11
+ and every hard seed. Non-win is a real reachable timeout LOSS via
12
+ the `after_ticks` fail clause; `units_lost_lte` provides the second
13
+ LOSS path (lazy/brute trades the strike force).
14
+
15
+ Validation is scripted (no model / network).
16
+ """
17
+ from __future__ import annotations
18
+
19
+ from pathlib import Path
20
+
21
+ import pytest
22
+
23
+ pytest.importorskip("openra_rl_training", reason="Rust env wheel not installed")
24
+ from openra_bench.scenarios import load_pack
25
+ from openra_bench.scenarios.loader import compile_level
26
+ from openra_bench.scenarios.win_conditions import WinContext, evaluate
27
+
28
+ PACKS = Path(__file__).parent.parent / "openra_bench" / "scenarios" / "packs"
29
+ PACK_PATH = PACKS / "coord-diversionary-attack.yaml"
30
+
31
+
32
+ # ── unit-level predicate / metadata checks (no engine) ──────────────
33
+
34
+
35
+ def test_pack_compiles_and_meta_fields_populated():
36
+ pack = load_pack(PACK_PATH)
37
+ assert pack.meta.id == "coord-diversionary-attack"
38
+ assert pack.meta.capability == "reasoning"
39
+ assert pack.meta.real_world_meaning, "real_world_meaning required"
40
+ assert pack.meta.robotics_analogue, "robotics_analogue required"
41
+ anchors = pack.meta.benchmark_anchor
42
+ assert isinstance(anchors, list) and len(anchors) >= 3, (
43
+ f"benchmark_anchor must list ≥3 anchors, got {anchors!r}"
44
+ )
45
+ joined = " ".join(anchors).lower()
46
+ for needle in ("sc2", "diversion", "cicero"):
47
+ assert needle in joined, f"missing anchor keyword: {needle}"
48
+ for lvl in ("easy", "medium", "hard"):
49
+ c = compile_level(pack, lvl)
50
+ assert c.map_supported
51
+ assert c.win_condition is not None
52
+ assert c.fail_condition is not None
53
+
54
+
55
+ def _ctx(*, units=(), tick=1000, lost=0, destroyed_records=()):
56
+ """Synthesize a WinContext for predicate-level checks."""
57
+ import types
58
+
59
+ types_count: dict[str, int] = {}
60
+ for t, _x, _y in destroyed_records:
61
+ types_count[t] = types_count.get(t, 0) + 1
62
+ sig = types.SimpleNamespace(
63
+ game_tick=tick,
64
+ units_killed=0,
65
+ units_lost=lost,
66
+ cash=0,
67
+ resources=0,
68
+ own_buildings=[],
69
+ own_building_types=set(),
70
+ enemies_seen_ids=set(),
71
+ enemy_buildings_seen_ids=set(),
72
+ enemy_buildings_destroyed=len(destroyed_records),
73
+ enemy_buildings_destroyed_types=types_count,
74
+ enemy_buildings_destroyed_records=list(destroyed_records),
75
+ )
76
+ return WinContext(
77
+ signals=sig,
78
+ render_state={"units_summary": list(units)},
79
+ )
80
+
81
+
82
+ def _alive(n, kind="2tnk"):
83
+ return [
84
+ {"cell_x": 50, "cell_y": 20, "type": kind, "id": str(1000 + i)}
85
+ for i in range(n)
86
+ ]
87
+
88
+
89
+ def test_easy_predicates():
90
+ c = compile_level(load_pack(PACK_PATH), "easy")
91
+ # Intended: fact at (100,10) destroyed (in region), 3 lost, in budget → WIN
92
+ assert evaluate(
93
+ c.win_condition,
94
+ _ctx(units=_alive(4), tick=3000, lost=3,
95
+ destroyed_records=[("fact", 100, 10)]),
96
+ )
97
+ # Razing only the decoy powr at (100,30) → not a win
98
+ assert not evaluate(
99
+ c.win_condition,
100
+ _ctx(units=_alive(4), tick=3000, lost=1,
101
+ destroyed_records=[("powr", 100, 30)]),
102
+ )
103
+ # Razing the sentinel fact at (125,38) is outside the region → not a win
104
+ assert not evaluate(
105
+ c.win_condition,
106
+ _ctx(units=_alive(4), tick=3000, lost=1,
107
+ destroyed_records=[("fact", 125, 38)]),
108
+ )
109
+ # Loss cap (3) tripped (4 lost) → not a win
110
+ assert not evaluate(
111
+ c.win_condition,
112
+ _ctx(units=_alive(2), tick=3000, lost=4,
113
+ destroyed_records=[("fact", 100, 10)]),
114
+ )
115
+ # Past deadline → not a win
116
+ assert not evaluate(
117
+ c.win_condition,
118
+ _ctx(units=_alive(4), tick=5401, lost=1,
119
+ destroyed_records=[("fact", 100, 10)]),
120
+ )
121
+ # Timeout with bar unmet → fail
122
+ assert evaluate(
123
+ c.fail_condition,
124
+ _ctx(units=_alive(4), tick=5500, lost=1, destroyed_records=[]),
125
+ )
126
+ # Force-wipe → fail
127
+ assert evaluate(
128
+ c.fail_condition,
129
+ _ctx(units=[], tick=3000, lost=7, destroyed_records=[]),
130
+ )
131
+ # Loss cap (>3) → fail
132
+ assert evaluate(
133
+ c.fail_condition,
134
+ _ctx(units=_alive(1), tick=3000, lost=4,
135
+ destroyed_records=[("fact", 100, 10)]),
136
+ )
137
+
138
+
139
+ def test_medium_predicates():
140
+ c = compile_level(load_pack(PACK_PATH), "medium")
141
+ # Intended: fact destroyed, 3 lost (3 bait jeeps), within budget → WIN
142
+ assert evaluate(
143
+ c.win_condition,
144
+ _ctx(units=_alive(4), tick=4000, lost=3,
145
+ destroyed_records=[("fact", 100, 10)]),
146
+ )
147
+ # 4 lost (one tank lost) → not a win, AND fail
148
+ assert not evaluate(
149
+ c.win_condition,
150
+ _ctx(units=_alive(3), tick=4000, lost=4,
151
+ destroyed_records=[("fact", 100, 10)]),
152
+ )
153
+ assert evaluate(
154
+ c.fail_condition,
155
+ _ctx(units=_alive(3), tick=4000, lost=4,
156
+ destroyed_records=[("fact", 100, 10)]),
157
+ )
158
+ # Only decoy razed → not a win
159
+ assert not evaluate(
160
+ c.win_condition,
161
+ _ctx(units=_alive(4), tick=4000, lost=1,
162
+ destroyed_records=[("powr", 100, 30)]),
163
+ )
164
+
165
+
166
+ def test_hard_predicates():
167
+ c = compile_level(load_pack(PACK_PATH), "hard")
168
+ # Intended: NE fact destroyed → WIN
169
+ assert evaluate(
170
+ c.win_condition,
171
+ _ctx(units=_alive(4), tick=4000, lost=2,
172
+ destroyed_records=[("fact", 100, 10)]),
173
+ )
174
+ # Intended (mirror): SE fact destroyed → WIN
175
+ assert evaluate(
176
+ c.win_condition,
177
+ _ctx(units=_alive(4), tick=4000, lost=2,
178
+ destroyed_records=[("fact", 100, 30)]),
179
+ )
180
+ # Only decoy powr at centre → not a win
181
+ assert not evaluate(
182
+ c.win_condition,
183
+ _ctx(units=_alive(4), tick=4000, lost=1,
184
+ destroyed_records=[("powr", 100, 20)]),
185
+ )
186
+ # 3 lost → not a win, AND fail (cap is 2 on hard)
187
+ assert not evaluate(
188
+ c.win_condition,
189
+ _ctx(units=_alive(3), tick=4000, lost=3,
190
+ destroyed_records=[("fact", 100, 10)]),
191
+ )
192
+ assert evaluate(
193
+ c.fail_condition,
194
+ _ctx(units=_alive(3), tick=4000, lost=3,
195
+ destroyed_records=[("fact", 100, 10)]),
196
+ )
197
+
198
+
199
+ def test_timeout_reachable_inside_max_turns():
200
+ """No draw degeneracy: after_ticks 5401 ≤ 93 + 90·(max_turns-1)."""
201
+ pack = load_pack(PACK_PATH)
202
+ for lvl in ("easy", "medium", "hard"):
203
+ c = compile_level(pack, lvl)
204
+ max_tick = 93 + 90 * (c.max_turns - 1)
205
+ assert 5401 <= max_tick, (
206
+ f"{lvl}: after_ticks 5401 > max reachable tick {max_tick} "
207
+ f"(max_turns={c.max_turns}); deadline never bites"
208
+ )
209
+
210
+
211
+ def test_hard_has_two_spawn_point_groups():
212
+ """Hard-tier curation: ≥2 distinct agent spawn_point groups so the
213
+ seed round-robins the staging latitude (NORTH y≈10..14 / SOUTH
214
+ y≈26..30). Engine round-trip asserted by tests/test_hard_tier.py."""
215
+ c = compile_level(load_pack(PACK_PATH), "hard")
216
+ groups = {
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 len(groups) >= 2, f"hard needs ≥2 spawn_point groups, got {groups}"
222
+
223
+
224
+ def test_hunt_bot_and_two_target_kinds():
225
+ """Enemy must be the `hunt` scripted bot (pursues nearest agent
226
+ units — required for the bait to actually pull defenders). Each
227
+ level must include an enemy `fact` (the real / scoring target)
228
+ and an enemy `powr` (the decoy target that does NOT score)."""
229
+ pack = load_pack(PACK_PATH)
230
+ enemy = pack.base.get("enemy") if isinstance(pack.base, dict) else None
231
+ assert enemy is not None
232
+ bot = enemy.get("bot_type") or enemy.get("bot") or ""
233
+ assert bot == "hunt", f"expected hunt bot, got {bot!r}"
234
+ for lvl in ("easy", "medium", "hard"):
235
+ c = compile_level(pack, lvl)
236
+ etypes = [a.type for a in c.scenario.actors if a.owner == "enemy"]
237
+ assert "fact" in etypes, f"{lvl}: must include enemy fact"
238
+ assert "powr" in etypes, f"{lvl}: must include enemy powr decoy"
239
+
240
+
241
+ def test_sentinel_fact_present_per_level():
242
+ """A persistent enemy `fact` placed far from the objective region
243
+ so the engine does not auto-`done` the instant the objective fact
244
+ dies; within_ticks / units_lost_lte then evaluate cleanly on the
245
+ terminal frame (CLAUDE.md auto-terminate footgun)."""
246
+ pack = load_pack(PACK_PATH)
247
+ for lvl in ("easy", "medium", "hard"):
248
+ c = compile_level(pack, lvl)
249
+ facts = [
250
+ (a.position[0], a.position[1])
251
+ for a in c.scenario.actors
252
+ if a.owner == "enemy" and a.type == "fact"
253
+ ]
254
+ # at least one objective candidate near (100,10)/(100,30) AND a
255
+ # far sentinel (>=12 cells from any objective candidate)
256
+ objs = [
257
+ p for p in facts
258
+ if (p[0] - 100) ** 2 + (p[1] - 10) ** 2 <= 6 ** 2
259
+ or (p[0] - 100) ** 2 + (p[1] - 30) ** 2 <= 6 ** 2
260
+ ]
261
+ sentinels = [p for p in facts if p not in objs]
262
+ assert objs, f"{lvl}: must place ≥1 enemy fact in an objective region"
263
+ assert sentinels, (
264
+ f"{lvl}: must place ≥1 sentinel enemy fact outside the "
265
+ f"objective region (engine auto-done guard); got facts={facts}"
266
+ )
267
+
268
+
269
+ def test_squad_composition_per_level():
270
+ """Squad A = 3 jeeps (bait), Squad B = 4 2tnk (strike) per spawn
271
+ group. Each level must place at least one (jeep x3) and one
272
+ (2tnk x4) on the agent side."""
273
+ pack = load_pack(PACK_PATH)
274
+ for lvl in ("easy", "medium", "hard"):
275
+ c = compile_level(pack, lvl)
276
+ jeep_groups = [
277
+ a for a in c.scenario.actors
278
+ if a.owner == "agent" and a.type == "jeep"
279
+ ]
280
+ tank_groups = [
281
+ a for a in c.scenario.actors
282
+ if a.owner == "agent" and a.type == "2tnk"
283
+ ]
284
+ assert jeep_groups, f"{lvl}: missing agent jeep squad"
285
+ assert tank_groups, f"{lvl}: missing agent 2tnk squad"
286
+ for j in jeep_groups:
287
+ assert (j.count or 1) == 3, f"{lvl}: jeep squad must be 3"
288
+ for t in tank_groups:
289
+ assert (t.count or 1) == 4, f"{lvl}: tank squad must be 4"
290
+
291
+
292
+ # ── engine-driven scripted policies ─────────────────────────────────
293
+
294
+
295
+ def _of_type(rs, want_types):
296
+ out = []
297
+ for u in rs.get("units_summary", []) or []:
298
+ if u.get("type") in want_types:
299
+ out.append(u)
300
+ return out
301
+
302
+
303
+ def _enemies_of_type(rs, want_types):
304
+ out = []
305
+ for e in (rs.get("enemy_summary") or []):
306
+ t = (e.get("type") or e.get("actor_type") or "").lower()
307
+ if t in want_types:
308
+ out.append(e)
309
+ return out
310
+
311
+
312
+ def _enemy_buildings_in_region(rs, cx, cy, r):
313
+ """Enemies of building kind ~ near (cx, cy) within radius r."""
314
+ out = []
315
+ for e in (rs.get("enemy_summary") or []):
316
+ t = (e.get("type") or e.get("actor_type") or "").lower()
317
+ ex = e.get("cell_x")
318
+ ey = e.get("cell_y")
319
+ if ex is None or ey is None:
320
+ continue
321
+ if (ex - cx) ** 2 + (ey - cy) ** 2 <= r * r:
322
+ out.append((t, ex, ey, e.get("id")))
323
+ return out
324
+
325
+
326
+ def _stall(rs, Command):
327
+ """Pure observe — fact never takes damage; clock runs out → LOSS."""
328
+ return [Command.observe()]
329
+
330
+
331
+ def _brute_all_on_decoy(rs, Command):
332
+ """Every unit attack-moves onto the decoy region (powr) — razes
333
+ the wrong target (which doesn't score), and tanks get shredded
334
+ by the heavy e3 anti-tank cluster en route → LOSS."""
335
+ cmds = []
336
+ # Target the decoy powr — easy/medium decoy is at (100,30);
337
+ # hard's decoy is at (100,20). Use centre y=20 for hard, y=30
338
+ # otherwise. Cheap detection: if a powr is at y=20 in vision,
339
+ # pick that; else use (100, 30).
340
+ target_x, target_y = 100, 30
341
+ for e in (rs.get("enemy_summary") or []):
342
+ t = (e.get("type") or "").lower()
343
+ if t == "powr" and e.get("cell_y") is not None:
344
+ target_x, target_y = e["cell_x"], e["cell_y"]
345
+ break
346
+ for u in (rs.get("units_summary", []) or []):
347
+ cmds.append(Command.attack_move([str(u["id"])], target_x, target_y))
348
+ return cmds or [Command.observe()]
349
+
350
+
351
+ def _lazy_nearest(rs, Command):
352
+ """Lazy "send each squad to its nearest visible target":
353
+ • jeeps → nearest fact/powr in vision (typically the near-
354
+ latitude one — for easy/medium that's the fact at (100,10)
355
+ if NW spawn, or the powr at (100,30) if SW spawn).
356
+ • tanks → nearest fact/powr in vision (the OTHER one).
357
+ The lazy assignment puts jeeps on the wrong-strength target and
358
+ tanks on the WRONG type (the decoy). On easy/medium the lazy
359
+ jeeps-on-fact / tanks-on-powr means tanks raze the decoy (which
360
+ doesn't score) and jeeps can't crack the fact garrison fast
361
+ enough → clock expires → LOSS."""
362
+ cmds = []
363
+ jeeps = _of_type(rs, {"jeep"})
364
+ tanks = _of_type(rs, {"2tnk"})
365
+ if not (jeeps or tanks):
366
+ return [Command.observe()]
367
+
368
+ # Fixed-coord lazy nearest (model can read positions but lazy
369
+ # means send-to-nearest-visible-target without bait logic):
370
+ # jeeps from NW go to (100,10), tanks from SW go to (100,30).
371
+ # Easy/medium objective: fact (100,10) is the real target;
372
+ # this lazy plan razes only powr at (100,30) — fails.
373
+ for j in jeeps:
374
+ jy = j.get("cell_y", 20)
375
+ # nearest by latitude: jeep at y≈10 → (100,10); else (100,30)
376
+ tx, ty = (100, 10) if jy < 20 else (100, 30)
377
+ cmds.append(Command.attack_move([str(j["id"])], tx, ty))
378
+ for t in tanks:
379
+ ty_unit = t.get("cell_y", 20)
380
+ tx, ty = (100, 30) if ty_unit >= 20 else (100, 10)
381
+ cmds.append(Command.attack_move([str(t["id"])], tx, ty))
382
+ return cmds or [Command.observe()]
383
+
384
+
385
+ def _bait_only(rs, Command):
386
+ """Jeeps slash toward the decoy region (pulls the heavy garrison
387
+ into pursuit), tanks stand still. The fact survives — clock runs
388
+ out → LOSS. Tests that the bait without the counter-attack does
389
+ not score."""
390
+ cmds = []
391
+ for j in _of_type(rs, {"jeep"}):
392
+ # Oscillate around the decoy region (south for easy/medium,
393
+ # centre for hard) so the jeeps stay engaged but moving.
394
+ jy = j.get("cell_y", 20)
395
+ # If on north start, dive into south decoy region; mirror.
396
+ target_y = 30 if jy < 20 else 10
397
+ cmds.append(Command.move_units([str(j["id"])], 100, target_y))
398
+ return cmds or [Command.observe()]
399
+
400
+
401
+ def _intended(rs, Command):
402
+ """Diversion + main strike:
403
+ 1. Detect agent staging hemisphere from the unit centroid.
404
+ 2. Bait the jeeps INTO the heavier garrison's pursuit region:
405
+ • easy/medium: decoy is south at (100,30) — jeeps slash
406
+ south-east no matter the spawn (NW spawn jeeps must
407
+ cross to south).
408
+ • hard: decoy cluster is at CENTRE (100,20) — jeeps slash
409
+ toward (100,20) to draw the centre e3 cluster.
410
+ 3. Main strike: tanks attack-move along the OPPOSITE latitude
411
+ from the decoy to the real target.
412
+ • easy/medium: tanks at SW must cross north to (100,10).
413
+ • hard: real target is whichever fact MATCHES the agent's
414
+ spawn latitude (NORTH spawn → NE fact at (100,10);
415
+ SOUTH spawn → SE fact at (100,30)).
416
+ 4. If a fact is in vision and reachable, attack_unit it.
417
+ """
418
+ cmds = []
419
+ jeeps = _of_type(rs, {"jeep"})
420
+ tanks = _of_type(rs, {"2tnk"})
421
+ all_units = jeeps + tanks
422
+ if not all_units:
423
+ return [Command.observe()]
424
+
425
+ # Probe: which enemy buildings are present? Hard has a centre
426
+ # powr at (100,20); easy/medium has a powr at (100,30).
427
+ has_centre_decoy = any(
428
+ (e.get("type") or "").lower() == "powr"
429
+ and 16 <= (e.get("cell_y") or 99) <= 24
430
+ for e in (rs.get("enemy_summary") or [])
431
+ )
432
+
433
+ # Spawn latitude detection by tank centroid (tanks dominate the
434
+ # mass; jeeps may already have moved).
435
+ if tanks:
436
+ ty_avg = sum(t["cell_y"] for t in tanks) / len(tanks)
437
+ else:
438
+ ty_avg = sum(u["cell_y"] for u in all_units) / len(all_units)
439
+ is_north = ty_avg < 20
440
+
441
+ if has_centre_decoy:
442
+ # HARD: bait jeeps INTO the centre decoy cluster.
443
+ bait_x, bait_y = 100, 20
444
+ # Strike: matching-latitude fact (NORTH spawn → NE fact;
445
+ # SOUTH spawn → SE fact).
446
+ strike_x, strike_y = (100, 10) if is_north else (100, 30)
447
+ else:
448
+ # EASY/MEDIUM: decoy is south at (100,30); jeeps slash south
449
+ # regardless of spawn. The intended cross-attack for the
450
+ # tanks is north onto the fact at (100,10).
451
+ bait_x, bait_y = 100, 30
452
+ strike_x, strike_y = 100, 10
453
+
454
+ for j in jeeps:
455
+ cmds.append(Command.move_units([str(j["id"])], bait_x, bait_y))
456
+
457
+ if not tanks:
458
+ return cmds or [Command.observe()]
459
+
460
+ tank_ids = [str(t["id"]) for t in tanks]
461
+
462
+ # If the real fact is in vision, attack_unit it for a clean kill;
463
+ # else attack_move onto the cell so the column drives there.
464
+ fact_id = None
465
+ for e in (rs.get("enemy_summary") or []):
466
+ et = (e.get("type") or "").lower()
467
+ if et != "fact":
468
+ continue
469
+ ex, ey = e.get("cell_x"), e.get("cell_y")
470
+ if ex is None or ey is None:
471
+ continue
472
+ if (ex - strike_x) ** 2 + (ey - strike_y) ** 2 <= 6 ** 2:
473
+ fact_id = str(e.get("id"))
474
+ break
475
+
476
+ if fact_id is not None:
477
+ cmds.append(Command.attack_unit(tank_ids, fact_id))
478
+ else:
479
+ cmds.append(Command.attack_move(tank_ids, strike_x, strike_y))
480
+ return cmds or [Command.observe()]
481
+
482
+
483
+ @pytest.mark.parametrize("level", ["easy", "medium", "hard"])
484
+ @pytest.mark.parametrize("seed", [1, 2, 3, 4])
485
+ def test_intended_diversion_then_strike_wins(level, seed):
486
+ pytest.importorskip("openra_train")
487
+ from openra_bench.eval_core import run_level
488
+
489
+ c = compile_level(load_pack(PACK_PATH), level)
490
+ r = run_level(c, _intended, seed=seed)
491
+ assert r.outcome == "win", (
492
+ f"{level} seed={seed}: intended diversion-then-strike should WIN, "
493
+ f"got {r.outcome} after {r.turns} turns "
494
+ f"(lost={r.signals.units_lost}, "
495
+ f"bldgs_destroyed={r.signals.enemy_buildings_destroyed_types}, "
496
+ f"records={getattr(r.signals, 'enemy_buildings_destroyed_records', [])})"
497
+ )
498
+
499
+
500
+ @pytest.mark.parametrize("level", ["easy", "medium", "hard"])
501
+ @pytest.mark.parametrize("seed", [1, 2, 3, 4])
502
+ def test_stall_loses(level, seed):
503
+ pytest.importorskip("openra_train")
504
+ from openra_bench.eval_core import run_level
505
+
506
+ c = compile_level(load_pack(PACK_PATH), level)
507
+ r = run_level(c, _stall, seed=seed)
508
+ assert r.outcome == "loss", (
509
+ f"{level} seed={seed}: stall must be a real timeout LOSS "
510
+ f"(no building takes damage), got {r.outcome}"
511
+ )
512
+
513
+
514
+ @pytest.mark.parametrize("level", ["easy", "medium", "hard"])
515
+ @pytest.mark.parametrize("seed", [1, 2, 3, 4])
516
+ def test_brute_all_on_decoy_loses(level, seed):
517
+ """Brute frontal — every unit attack-moves onto the decoy powr.
518
+ The decoy is razed (which scores zero — `enemy_key_buildings_
519
+ destroyed_in_region` requires a `fact`) and the heavy e3 cluster
520
+ shreds the tanks; the real fact is left standing → LOSS."""
521
+ pytest.importorskip("openra_train")
522
+ from openra_bench.eval_core import run_level
523
+
524
+ c = compile_level(load_pack(PACK_PATH), level)
525
+ r = run_level(c, _brute_all_on_decoy, seed=seed)
526
+ assert r.outcome == "loss", (
527
+ f"{level} seed={seed}: brute-all-on-decoy must LOSE "
528
+ f"(real fact intact / strike force traded), got {r.outcome} "
529
+ f"(lost={r.signals.units_lost}, "
530
+ f"bldgs={r.signals.enemy_buildings_destroyed_types})"
531
+ )
532
+
533
+
534
+ @pytest.mark.parametrize("level", ["easy", "medium", "hard"])
535
+ @pytest.mark.parametrize("seed", [1, 2, 3, 4])
536
+ def test_lazy_nearest_assignment_loses(level, seed):
537
+ """Lazy "send each squad to its nearest visible target" play.
538
+ On easy/medium this puts jeeps on the close-looking fact (too
539
+ weak to crack) and tanks on the close-looking powr (the wrong
540
+ type — `enemy_key_buildings_destroyed_in_region` requires a
541
+ `fact`), so the real fact survives the clock → LOSS."""
542
+ pytest.importorskip("openra_train")
543
+ from openra_bench.eval_core import run_level
544
+
545
+ c = compile_level(load_pack(PACK_PATH), level)
546
+ r = run_level(c, _lazy_nearest, seed=seed)
547
+ assert r.outcome == "loss", (
548
+ f"{level} seed={seed}: lazy nearest assignment must LOSE, "
549
+ f"got {r.outcome} "
550
+ f"(lost={r.signals.units_lost}, "
551
+ f"bldgs={r.signals.enemy_buildings_destroyed_types})"
552
+ )
553
+
554
+
555
+ @pytest.mark.parametrize("level", ["easy", "medium", "hard"])
556
+ @pytest.mark.parametrize("seed", [1, 2, 3, 4])
557
+ def test_bait_only_loses(level, seed):
558
+ """Bait-only (jeeps slash toward the decoy, tanks stand still)
559
+ must LOSE on every tier — the bait displacement yields ZERO
560
+ objective progress; the real fact survives and the clock runs
561
+ out. Discriminates "bait without strike" from the intended
562
+ "bait AND strike" idiom."""
563
+ pytest.importorskip("openra_train")
564
+ from openra_bench.eval_core import run_level
565
+
566
+ c = compile_level(load_pack(PACK_PATH), level)
567
+ r = run_level(c, _bait_only, seed=seed)
568
+ assert r.outcome == "loss", (
569
+ f"{level} seed={seed}: bait-only must LOSE (real fact not "
570
+ f"destroyed), got {r.outcome} "
571
+ f"(bldgs={r.signals.enemy_buildings_destroyed_types})"
572
+ )
tests/test_coord_relay_attack.py ADDED
@@ -0,0 +1,387 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """coord-relay-attack — rocket-then-tank relay strike.
2
+
3
+ The bar: intended Squad A (e3 rockets) engages FIRST → softens enemy
4
+ armour → THEN Squad B (2tnk) follows up. WINS on every level and
5
+ every hard seed (1-4). The wrong-policy lattice (stall, both-attack-
6
+ at-once, B-only) must LOSE on medium and hard. Easy is the bare-skill
7
+ tier (smaller enemy cluster, looser caps) so some wrong policies may
8
+ squeak by there per the SCENARIO_REVIEW_CHECKLIST inert-easy-teeth
9
+ convention.
10
+
11
+ The Wave-2 `then:` happened-before composite enforces the ordering
12
+ predicate: `units_killed_gte: K1` must latch before `units_killed_gte:
13
+ K2`. Wrong policies fail either on attrition (`units_lost_lte`) or
14
+ the clock (`after_ticks`).
15
+
16
+ Validation is scripted (no model / network).
17
+ """
18
+ from __future__ import annotations
19
+
20
+ from pathlib import Path
21
+
22
+ import pytest
23
+
24
+ pytest.importorskip("openra_rl_training", reason="Rust env wheel not installed")
25
+ from openra_bench.scenarios import load_pack
26
+ from openra_bench.scenarios.loader import compile_level
27
+ from openra_bench.scenarios.win_conditions import WinContext, evaluate
28
+
29
+ PACKS = Path(__file__).parent.parent / "openra_bench" / "scenarios" / "packs"
30
+ PACK_PATH = PACKS / "coord-relay-attack.yaml"
31
+
32
+
33
+ # ── unit-level predicate / metadata checks (no engine) ──────────────
34
+
35
+
36
+ def test_pack_compiles_and_meta_fields_populated():
37
+ pack = load_pack(PACK_PATH)
38
+ assert pack.meta.id == "coord-relay-attack"
39
+ assert pack.meta.capability == "action"
40
+ assert pack.meta.real_world_meaning, "real_world_meaning required"
41
+ assert pack.meta.robotics_analogue, "robotics_analogue required"
42
+ anchors = pack.meta.benchmark_anchor
43
+ assert isinstance(anchors, list) and len(anchors) == 4, (
44
+ f"benchmark_anchor must list all 4 anchors, got {anchors!r}"
45
+ )
46
+ joined = " ".join(anchors).lower()
47
+ for needle in ("sc2", "smac", "overlapping fires", "bound-and-bound"):
48
+ assert needle in joined, f"missing anchor keyword: {needle}"
49
+ for lvl in ("easy", "medium", "hard"):
50
+ c = compile_level(pack, lvl)
51
+ assert c.map_supported
52
+ assert c.win_condition is not None
53
+ assert c.fail_condition is not None
54
+
55
+
56
+ def _ctx(*, units=(), tick=1000, kills=0, lost=0):
57
+ """Synthesize a WinContext for predicate-level checks."""
58
+ import types
59
+
60
+ sig = types.SimpleNamespace(
61
+ game_tick=tick,
62
+ units_killed=kills,
63
+ units_lost=lost,
64
+ cash=0,
65
+ resources=0,
66
+ power_provided=0,
67
+ power_drained=0,
68
+ own_buildings=[],
69
+ own_building_types=set(),
70
+ enemies_seen_ids=set(),
71
+ enemy_buildings_seen_ids=set(),
72
+ explored_percent=0.0,
73
+ then_progress={},
74
+ seq_progress={},
75
+ )
76
+ return WinContext(
77
+ signals=sig,
78
+ render_state={"units_summary": list(units)},
79
+ )
80
+
81
+
82
+ def _alive(n, type_="e3"):
83
+ return [
84
+ {"cell_x": 15, "cell_y": 20, "type": type_, "id": str(1000 + i)}
85
+ for i in range(n)
86
+ ]
87
+
88
+
89
+ def test_easy_predicates():
90
+ c = compile_level(load_pack(PACK_PATH), "easy")
91
+ # Intended: relay completes (need K1 then K2 — same ctx satisfies
92
+ # both clauses in order on first eval), in time, under cap → WIN.
93
+ ctx = _ctx(units=_alive(5), tick=2500, kills=4, lost=2)
94
+ assert evaluate(c.win_condition, ctx)
95
+ # K1 unmet → not a win.
96
+ assert not evaluate(c.win_condition, _ctx(units=_alive(6), tick=2500, kills=1, lost=0))
97
+ # K2 unmet (3 kills, K2 is 4) → not a win.
98
+ assert not evaluate(c.win_condition, _ctx(units=_alive(6), tick=2500, kills=3, lost=0))
99
+ # Attrition cap busted (5 > 4) → fail.
100
+ assert evaluate(c.fail_condition, _ctx(units=_alive(1), tick=2500, kills=4, lost=5))
101
+ # Force-wipe → fail.
102
+ assert evaluate(c.fail_condition, _ctx(units=[], tick=2500, kills=4, lost=6))
103
+ # Timeout with bar unmet → fail.
104
+ assert evaluate(c.fail_condition, _ctx(units=_alive(5), tick=3002, kills=2, lost=1))
105
+
106
+
107
+ def test_medium_predicates():
108
+ c = compile_level(load_pack(PACK_PATH), "medium")
109
+ # Intended: 7 kills, 5 lost, in time → WIN.
110
+ ctx = _ctx(units=_alive(3), tick=3500, kills=7, lost=5)
111
+ assert evaluate(c.win_condition, ctx)
112
+ # K1 unmet (only 2 kills) → not a win.
113
+ assert not evaluate(c.win_condition, _ctx(units=_alive(6), tick=3500, kills=2, lost=0))
114
+ # K2 unmet (6 kills) → not a win.
115
+ assert not evaluate(c.win_condition, _ctx(units=_alive(6), tick=3500, kills=6, lost=0))
116
+ # Attrition cap busted (6 > 5) → fail.
117
+ assert evaluate(c.fail_condition, _ctx(units=_alive(2), tick=3500, kills=7, lost=6))
118
+ # Force-wipe → fail.
119
+ assert evaluate(c.fail_condition, _ctx(units=[], tick=3500, kills=7, lost=7))
120
+ # Timeout → fail.
121
+ assert evaluate(c.fail_condition, _ctx(units=_alive(6), tick=4002, kills=6, lost=2))
122
+
123
+
124
+ def test_hard_predicates():
125
+ c = compile_level(load_pack(PACK_PATH), "hard")
126
+ # Intended: 10 kills, ≤5 lost, in time → WIN.
127
+ ctx = _ctx(units=_alive(3), tick=4000, kills=10, lost=5)
128
+ assert evaluate(c.win_condition, ctx)
129
+ # K1 unmet (3 kills) → not a win.
130
+ assert not evaluate(c.win_condition, _ctx(units=_alive(6), tick=4000, kills=3, lost=0))
131
+ # K2 unmet (9 kills) → not a win.
132
+ assert not evaluate(c.win_condition, _ctx(units=_alive(6), tick=4000, kills=9, lost=0))
133
+ # Attrition cap busted → fail.
134
+ assert evaluate(c.fail_condition, _ctx(units=_alive(2), tick=4000, kills=10, lost=6))
135
+ # Force-wipe → fail.
136
+ assert evaluate(c.fail_condition, _ctx(units=[], tick=4000, kills=10, lost=8))
137
+ # Timeout → fail.
138
+ assert evaluate(c.fail_condition, _ctx(units=_alive(6), tick=4502, kills=9, lost=2))
139
+
140
+
141
+ def test_then_clause_enforces_ordering_under_consistent_eval():
142
+ """The then-clause latch is per-id and persists across evals via
143
+ `signals.then_progress`. Using a fresh ctx (and so fresh
144
+ then_progress) tests the leaf semantics; persistent-eval semantics
145
+ are tested in tests/test_then_composite.py."""
146
+ c = compile_level(load_pack(PACK_PATH), "medium")
147
+ # New ctx, but K2 already satisfied: greedy advance latches both
148
+ # clauses in the same eval (matches waypoint_sequence semantics
149
+ # validated in test_then_composite.py::test_then_late_a_then_b_…).
150
+ ctx = _ctx(units=_alive(3), tick=3500, kills=7, lost=0)
151
+ assert evaluate(c.win_condition, ctx)
152
+ # But: if K1 is never met (and K2 also not met), no progress.
153
+ ctx2 = _ctx(units=_alive(3), tick=3500, kills=2, lost=0)
154
+ assert not evaluate(c.win_condition, ctx2)
155
+
156
+
157
+ def test_timeout_reachable_inside_max_turns():
158
+ """No draw degeneracy: the after_ticks fail trigger must be
159
+ reachable within max_turns (engine advances ~90 ticks per turn,
160
+ so max tick ≈ 93 + 90·(max_turns-1))."""
161
+ pack = load_pack(PACK_PATH)
162
+ expectations = {"easy": 3001, "medium": 4001, "hard": 4501}
163
+ for lvl, after in expectations.items():
164
+ c = compile_level(pack, lvl)
165
+ max_tick = 93 + 90 * (c.max_turns - 1)
166
+ assert after <= max_tick, (
167
+ f"{lvl}: after_ticks {after} > max reachable tick {max_tick} "
168
+ f"(max_turns={c.max_turns}); deadline never bites"
169
+ )
170
+
171
+
172
+ def test_hard_has_two_spawn_point_groups():
173
+ """Hard-tier curation: ≥2 distinct agent spawn_point groups so the
174
+ seed round-robins the west-edge staging latitude (north / south)."""
175
+ c = compile_level(load_pack(PACK_PATH), "hard")
176
+ groups = {
177
+ (a.spawn_point if a.spawn_point is not None else 0)
178
+ for a in c.scenario.actors
179
+ if a.owner == "agent"
180
+ }
181
+ assert len(groups) >= 2, f"hard needs ≥2 spawn_point groups, got {groups}"
182
+
183
+
184
+ def test_squads_are_e3_then_2tnk_on_every_level():
185
+ """The relay only has teeth if Squad A is rocket soldiers (e3,
186
+ anti-vehicle) and Squad B is medium tanks (2tnk, anti-infantry):
187
+ A's rockets soften the enemy 2tnk horde; B mops up the e1s."""
188
+ pack = load_pack(PACK_PATH)
189
+ for lvl in ("easy", "medium", "hard"):
190
+ c = compile_level(pack, lvl)
191
+ agent_types = [a.type for a in c.scenario.actors if a.owner == "agent"]
192
+ assert "e3" in agent_types, f"{lvl}: Squad A must be e3 (rocket); got {agent_types}"
193
+ assert "2tnk" in agent_types, f"{lvl}: Squad B must be 2tnk; got {agent_types}"
194
+ enemy_types = [a.type for a in c.scenario.actors if a.owner == "enemy"]
195
+ assert "2tnk" in enemy_types, f"{lvl}: enemy must include 2tnk (the threat A softens)"
196
+ assert "e1" in enemy_types, f"{lvl}: enemy must include e1 (what B mops up)"
197
+ # Persistent far enemy marker (engine auto-done mitigation).
198
+ assert "proc" in enemy_types, f"{lvl}: needs a persistent enemy survivor marker"
199
+
200
+
201
+ # ── engine-driven scripted policies ─────────────────────────────────
202
+
203
+
204
+ def _split_squads(rs):
205
+ """Return (a_ids, b_ids): Squad A = e3 (rocket); Squad B = 2tnk."""
206
+ a_ids, b_ids = [], []
207
+ for u in (rs.get("units_summary") or []):
208
+ t = str(u.get("type", "")).lower()
209
+ if t == "e3":
210
+ a_ids.append(str(u["id"]))
211
+ elif t == "2tnk":
212
+ b_ids.append(str(u["id"]))
213
+ return a_ids, b_ids
214
+
215
+
216
+ def _enemy_centre(rs):
217
+ en = rs.get("enemy_summary") or []
218
+ if not en:
219
+ return (60, 20) # fallback to known cluster centre
220
+ cx = sum(int(e["cell_x"]) for e in en) / len(en)
221
+ cy = sum(int(e["cell_y"]) for e in en) / len(en)
222
+ return (int(cx), int(cy))
223
+
224
+
225
+ def _stall(rs, Command):
226
+ """Pure observe — enemies hold (stance:2), agent never engages →
227
+ kill bar unmet → after_ticks LOSS."""
228
+ return [Command.observe()]
229
+
230
+
231
+ def _both_attack_at_once(rs, Command):
232
+ """Both squads attack_move the enemy cluster simultaneously.
233
+ B's tanks reach the cluster around the same instant A's rockets do
234
+ and get focus-fired by the enemy 4× 2tnk → units_lost cap busts on
235
+ medium/hard."""
236
+ a_ids, b_ids = _split_squads(rs)
237
+ if not a_ids and not b_ids:
238
+ return [Command.observe()]
239
+ ex, ey = _enemy_centre(rs)
240
+ cmds = []
241
+ if a_ids:
242
+ cmds.append(Command.attack_move(a_ids, ex, ey))
243
+ if b_ids:
244
+ cmds.append(Command.attack_move(b_ids, ex, ey))
245
+ return cmds or [Command.observe()]
246
+
247
+
248
+ def _b_only(rs, Command):
249
+ """Squad B (tanks) advances first; Squad A (rockets) holds. B
250
+ faces the un-softened enemy 2tnk horde alone → bleeds → attrition
251
+ cap busts before the kill bar is met."""
252
+ a_ids, b_ids = _split_squads(rs)
253
+ if not b_ids and not a_ids:
254
+ return [Command.observe()]
255
+ ex, ey = _enemy_centre(rs)
256
+ cmds = []
257
+ if b_ids:
258
+ cmds.append(Command.attack_move(b_ids, ex, ey))
259
+ if a_ids:
260
+ cmds.append(Command.stop(a_ids))
261
+ return cmds or [Command.observe()]
262
+
263
+
264
+ def _make_intended_a_then_b():
265
+ """Factory: per-episode closure (turn counter + soften-state
266
+ tracker). Relay doctrine:
267
+ • Squad A (rockets) commits to the enemy cluster from turn 1
268
+ and attempts to soften the heavy 2tnk line.
269
+ • Squad B (tanks) HOLDS until either (a) the enemy heavy line
270
+ is at least half-suppressed (≤50% of original 2tnk left),
271
+ OR (b) enough wall-clock turns have elapsed that A has had
272
+ the chance to do its work (fallback for the case where A
273
+ gets melted before completing the soften — B still needs
274
+ to advance and contribute kills).
275
+ The two-trigger design models real fire-and-maneuver: the relay
276
+ fires on suppression OR on a timed bound, not just on a perfect
277
+ suppression latch. (Without the timed fallback, a wipe of A
278
+ means B never advances and the kill bar is never met.)
279
+ """
280
+ state = {"initial_tanks": None, "turn": 0, "released": False}
281
+ # Turn at which B is released regardless of suppression status.
282
+ # ~16 turns ≈ 1440 ticks = enough for A to walk from x=15 to
283
+ # x=60 (45 cells) and engage for a few seconds. Tuned so the
284
+ # B-only / both-at-once paths don't accidentally land before
285
+ # this trigger (they advance from turn 1, so they hit the
286
+ # enemy faster than the gated release).
287
+ RELEASE_TURN = 16
288
+
289
+ def _policy(rs, Command):
290
+ state["turn"] += 1
291
+ a_ids, b_ids = _split_squads(rs)
292
+ if not a_ids and not b_ids:
293
+ return [Command.observe()]
294
+ ex, ey = _enemy_centre(rs)
295
+ enemy_tanks = [
296
+ e for e in (rs.get("enemy_summary") or [])
297
+ if str(e.get("type", "")).lower() == "2tnk"
298
+ ]
299
+ if state["initial_tanks"] is None and len(enemy_tanks) > 0:
300
+ state["initial_tanks"] = len(enemy_tanks)
301
+ cmds = []
302
+ if a_ids:
303
+ cmds.append(Command.attack_move(a_ids, ex, ey))
304
+ if b_ids:
305
+ if state["released"]:
306
+ cmds.append(Command.attack_move(b_ids, ex, ey))
307
+ else:
308
+ init = state["initial_tanks"]
309
+ suppressed = (
310
+ init is not None
311
+ and init > 0
312
+ and len(enemy_tanks) * 2 <= init
313
+ )
314
+ if suppressed or state["turn"] >= RELEASE_TURN:
315
+ state["released"] = True
316
+ cmds.append(Command.attack_move(b_ids, ex, ey))
317
+ else:
318
+ cmds.append(Command.stop(b_ids))
319
+ return cmds or [Command.observe()]
320
+
321
+ return _policy
322
+
323
+
324
+ @pytest.mark.parametrize("level", ["easy", "medium", "hard"])
325
+ @pytest.mark.parametrize("seed", [1, 2, 3, 4])
326
+ def test_intended_a_then_b_wins(level, seed):
327
+ pytest.importorskip("openra_train")
328
+ from openra_bench.eval_core import run_level
329
+
330
+ c = compile_level(load_pack(PACK_PATH), level)
331
+ r = run_level(c, _make_intended_a_then_b(), seed=seed)
332
+ assert r.outcome == "win", (
333
+ f"{level} seed={seed}: intended A-then-B relay should WIN, "
334
+ f"got {r.outcome} after {r.turns} turns "
335
+ f"(kills={r.signals.units_killed}, losses={r.signals.units_lost})"
336
+ )
337
+
338
+
339
+ @pytest.mark.parametrize("level", ["easy", "medium", "hard"])
340
+ @pytest.mark.parametrize("seed", [1, 2, 3, 4])
341
+ def test_stall_loses(level, seed):
342
+ pytest.importorskip("openra_train")
343
+ from openra_bench.eval_core import run_level
344
+
345
+ c = compile_level(load_pack(PACK_PATH), level)
346
+ r = run_level(c, _stall, seed=seed)
347
+ assert r.outcome == "loss", (
348
+ f"{level} seed={seed}: stall must be a real timeout LOSS "
349
+ f"(no engagement → kill bar unmet), got {r.outcome}"
350
+ )
351
+
352
+
353
+ @pytest.mark.parametrize("level", ["medium", "hard"])
354
+ @pytest.mark.parametrize("seed", [1, 2, 3, 4])
355
+ def test_both_attack_at_once_loses(level, seed):
356
+ """Sending both squads in simultaneously exposes B's tanks to the
357
+ un-softened enemy 4× 2tnk before A's rockets can suppress them.
358
+ Attrition cap busts on medium/hard. Easy is excluded (forgiving
359
+ bare-skill tier with smaller cluster and cap=4)."""
360
+ pytest.importorskip("openra_train")
361
+ from openra_bench.eval_core import run_level
362
+
363
+ c = compile_level(load_pack(PACK_PATH), level)
364
+ r = run_level(c, _both_attack_at_once, seed=seed)
365
+ assert r.outcome == "loss", (
366
+ f"{level} seed={seed}: both-at-once must LOSE (B tanks bleed "
367
+ f"to enemy 2tnk before A softens), got {r.outcome} "
368
+ f"(kills={r.signals.units_killed}, losses={r.signals.units_lost})"
369
+ )
370
+
371
+
372
+ @pytest.mark.parametrize("level", ["medium", "hard"])
373
+ @pytest.mark.parametrize("seed", [1, 2, 3, 4])
374
+ def test_b_only_loses(level, seed):
375
+ """Squad B (tanks) charging alone faces the full enemy 2tnk + e1
376
+ cluster — outgunned by the enemy tanks, attrition cap busts before
377
+ the K2 bar is met. Easy excluded (forgiving cluster)."""
378
+ pytest.importorskip("openra_train")
379
+ from openra_bench.eval_core import run_level
380
+
381
+ c = compile_level(load_pack(PACK_PATH), level)
382
+ r = run_level(c, _b_only, seed=seed)
383
+ assert r.outcome == "loss", (
384
+ f"{level} seed={seed}: B-only must LOSE (B tanks alone vs "
385
+ f"enemy 4× 2tnk + 4× e1), got {r.outcome} "
386
+ f"(kills={r.signals.units_killed}, losses={r.signals.units_lost})"
387
+ )
tests/test_coord_squad_handoff.py ADDED
@@ -0,0 +1,360 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """coord-squad-handoff: sequenced squad handoff via the Wave-2 `then:`
2
+ happened-before composite + a new type-filtered region predicate
3
+ (`units_of_type_in_region_gte`).
4
+
5
+ The pack tests the SQUAD HANDOFF capability: Squad A (jeeps) must
6
+ deliver objective P1 FIRST, THEN Squad B (medium tanks) must deliver
7
+ P2; harder tiers add more alternating handoffs. The bar (per CLAUDE.md)
8
+ must hold on every level × every hard seed (1..4):
9
+
10
+ - STALL -> LOSS (clock)
11
+ - B-FIRST -> LOSS (then-latch never advances past A)
12
+ - SINGLE-SQUAD -> LOSS (the wrong-type squad can't satisfy clause 2)
13
+ - INTENDED PLAY -> WIN (parallel A→P1, B→P2 with B holding for A's
14
+ latch)
15
+
16
+ A separate unit test pins the new predicate (`units_of_type_in_region
17
+ _gte`) to its exact semantics — it must NOT count units of the wrong
18
+ type at the region.
19
+ """
20
+ from __future__ import annotations
21
+
22
+ from pathlib import Path
23
+
24
+ import pytest
25
+
26
+ pytest.importorskip("openra_rl_training", reason="Rust env wheel not installed")
27
+
28
+ from openra_bench.scenarios import load_pack
29
+ from openra_bench.scenarios.loader import compile_level
30
+ from openra_bench.scenarios.win_conditions import WinContext, evaluate
31
+
32
+ PACK = (
33
+ Path(__file__).parent.parent
34
+ / "openra_bench"
35
+ / "scenarios"
36
+ / "packs"
37
+ / "coord-squad-handoff.yaml"
38
+ )
39
+
40
+ # Per-level expected handoff count: easy 2 (A→B); medium 3 (A→B→A);
41
+ # hard 4 (A→B→A→B).
42
+ EXPECTED_CLAUSES = {"easy": 2, "medium": 3, "hard": 4}
43
+
44
+
45
+ def _win_clauses(c):
46
+ return dict(c.win_condition.__pydantic_extra__ or {})["all_of"]
47
+
48
+
49
+ def _fail_clauses(c):
50
+ return dict(c.fail_condition.__pydantic_extra__ or {})["any_of"]
51
+
52
+
53
+ def _then_clauses(c):
54
+ for cl in _win_clauses(c):
55
+ if "then" in cl:
56
+ return cl["then"]["clauses"]
57
+ return None
58
+
59
+
60
+ # ── A. STRUCTURAL: predicate / order / deadline are wired correctly ──
61
+
62
+ @pytest.mark.parametrize("level", ["easy", "medium", "hard"])
63
+ def test_level_uses_then_with_type_filtered_region_clauses(level):
64
+ pack = load_pack(PACK)
65
+ assert pack.meta.capability == "action"
66
+ c = compile_level(pack, level)
67
+ clauses = _then_clauses(c)
68
+ assert clauses is not None, f"{level}: must use a `then:` composite"
69
+ assert len(clauses) == EXPECTED_CLAUSES[level], (
70
+ f"{level}: expected {EXPECTED_CLAUSES[level]} handoff clauses, "
71
+ f"got {len(clauses)}"
72
+ )
73
+ # Every clause is a type-filtered region predicate (the new
74
+ # `units_of_type_in_region_gte`). Type-agnostic `units_in_region
75
+ # _gte` would NOT enforce squad identity.
76
+ for i, cl in enumerate(clauses):
77
+ assert "units_of_type_in_region_gte" in cl, (
78
+ f"{level} clause {i}: handoff must enforce squad identity"
79
+ )
80
+ v = cl["units_of_type_in_region_gte"]
81
+ assert v["n"] >= 3, f"{level} clause {i}: needs n>=3 (full squad)"
82
+ assert v["type"] in {"jeep", "2tnk"}, (
83
+ f"{level} clause {i}: unknown squad unit type {v['type']}"
84
+ )
85
+ # Clauses must alternate squad types — that IS the handoff.
86
+ types = [cl["units_of_type_in_region_gte"]["type"] for cl in clauses]
87
+ for i in range(1, len(types)):
88
+ assert types[i] != types[i - 1], (
89
+ f"{level}: handoff clauses must alternate squad types, got {types}"
90
+ )
91
+
92
+
93
+ @pytest.mark.parametrize("level", ["easy", "medium", "hard"])
94
+ def test_level_has_binding_deadline_and_real_loss(level):
95
+ c = compile_level(load_pack(PACK), level)
96
+ win = _win_clauses(c)
97
+ wt = [cl["within_ticks"] for cl in win if "within_ticks" in cl]
98
+ assert wt, f"{level}: missing within_ticks deadline"
99
+ # The deadline must bite within max_turns (engine ~90 ticks/turn).
100
+ assert wt[0] < 93 + 90 * (c.max_turns - 1), (
101
+ f"{level}: within_ticks {wt[0]} unreachable inside max_turns "
102
+ f"{c.max_turns} (would draw on timeout)"
103
+ )
104
+ # Every level must be able to emit a LOSS on timeout.
105
+ fail = _fail_clauses(c)
106
+ assert any("after_ticks" in cl for cl in fail), (
107
+ f"{level}: missing after_ticks in fail_condition"
108
+ )
109
+
110
+
111
+ def test_handoff_count_scales_with_difficulty():
112
+ pack = load_pack(PACK)
113
+ counts = [
114
+ len(_then_clauses(compile_level(pack, lv)))
115
+ for lv in ("easy", "medium", "hard")
116
+ ]
117
+ assert counts == [2, 3, 4], (
118
+ f"handoff count should ladder 2->3->4, got {counts}"
119
+ )
120
+
121
+
122
+ # ── B. PREDICATE UNIT TEST: units_of_type_in_region_gte semantics ────
123
+
124
+ class _Sig:
125
+ def __init__(self):
126
+ self.game_tick = 100
127
+ self.then_progress: dict = {}
128
+
129
+
130
+ def _ctx(units):
131
+ return WinContext(signals=_Sig(), render_state={"units_summary": units})
132
+
133
+
134
+ def test_type_filtered_region_predicate_ignores_wrong_type():
135
+ """The whole point of the new predicate: 3 jeeps at P1 do NOT
136
+ satisfy a `2tnk, n=3` clause at P1 — the wrong-type squad cannot
137
+ take over for the right squad."""
138
+ spec = {"units_of_type_in_region_gte":
139
+ {"type": "2tnk", "x": 90, "y": 30, "radius": 8, "n": 3}}
140
+ # 3 jeeps at the region — wrong type.
141
+ jeeps = [
142
+ {"type": "jeep", "cell_x": 90, "cell_y": 30, "id": str(i)}
143
+ for i in range(3)
144
+ ]
145
+ assert evaluate(spec, _ctx(jeeps)) is False
146
+ # Add 3 tanks (correct type) at the region — now satisfied.
147
+ tanks = jeeps + [
148
+ {"type": "2tnk", "cell_x": 91, "cell_y": 31, "id": "t%d" % i}
149
+ for i in range(3)
150
+ ]
151
+ assert evaluate(spec, _ctx(tanks)) is True
152
+
153
+
154
+ def test_type_filtered_region_predicate_respects_radius():
155
+ spec = {"units_of_type_in_region_gte":
156
+ {"type": "jeep", "x": 50, "y": 10, "radius": 5, "n": 2}}
157
+ near = [
158
+ {"type": "jeep", "cell_x": 51, "cell_y": 11, "id": "1"},
159
+ {"type": "jeep", "cell_x": 49, "cell_y": 10, "id": "2"},
160
+ ]
161
+ far = [{"type": "jeep", "cell_x": 60, "cell_y": 25, "id": "3"}]
162
+ assert evaluate(spec, _ctx(near)) is True
163
+ assert evaluate(spec, _ctx(far)) is False
164
+ assert evaluate(spec, _ctx(near + far)) is True
165
+
166
+
167
+ def test_then_with_type_region_orders_squad_handoff():
168
+ """End-to-end: a `then:` chain of type-filtered region clauses
169
+ enforces the squad order — B-first never latches."""
170
+ spec = {"then": {
171
+ "id": "handoff",
172
+ "clauses": [
173
+ {"units_of_type_in_region_gte":
174
+ {"type": "jeep", "x": 50, "y": 10, "radius": 8, "n": 3}},
175
+ {"units_of_type_in_region_gte":
176
+ {"type": "2tnk", "x": 90, "y": 30, "radius": 8, "n": 3}},
177
+ ],
178
+ }}
179
+ sig = _Sig()
180
+ # B-first: 3 tanks at P2, 0 jeeps at P1.
181
+ b_first = [
182
+ {"type": "2tnk", "cell_x": 90, "cell_y": 30, "id": "t%d" % i}
183
+ for i in range(3)
184
+ ]
185
+ ctx = WinContext(signals=sig, render_state={"units_summary": b_first})
186
+ assert evaluate(spec, ctx) is False
187
+ assert sig.then_progress["handoff"] == 0 # B alone never advances A
188
+ # Now A also arrives at P1 — chain completes in one evaluation
189
+ # (clause 0 advances, clause 1 already satisfied).
190
+ both = b_first + [
191
+ {"type": "jeep", "cell_x": 50, "cell_y": 10, "id": "j%d" % i}
192
+ for i in range(3)
193
+ ]
194
+ ctx = WinContext(signals=sig, render_state={"units_summary": both})
195
+ assert evaluate(spec, ctx) is True
196
+ assert sig.then_progress["handoff"] == 2
197
+
198
+
199
+ # ── C. SOLVENCY / NO-CHEAT: scripted policies on the live engine ─────
200
+
201
+ pytest.importorskip("openra_train", reason="Rust env wheel not installed")
202
+ from openra_bench.eval_core import run_level # noqa: E402
203
+
204
+
205
+ def _split_by_type(rs):
206
+ us = rs.get("units_summary", []) or []
207
+ jeeps = [str(u["id"]) for u in us if u.get("type") == "jeep"]
208
+ tanks = [str(u["id"]) for u in us if u.get("type") == "2tnk"]
209
+ return jeeps, tanks
210
+
211
+
212
+ def _stall(rs, C):
213
+ return [C.observe()]
214
+
215
+
216
+ def _b_first(rs, C):
217
+ """Send ONLY squad B (tanks) toward its waypoint, ignore A.
218
+ The `then:` chain stays at index 0 forever — clause 0 (jeeps at
219
+ P1) never latches, so clause 1 is never credited."""
220
+ _jeeps, tanks = _split_by_type(rs)
221
+ if not tanks:
222
+ return [C.observe()]
223
+ return [C.move_units(tanks, 90, 30)]
224
+
225
+
226
+ def _single_squad_tour(rs, C):
227
+ """Squad A (jeeps) tours P1 then P2. Clause 1 latches (jeeps at
228
+ P1). Clause 2 demands TANKS at P2 — jeeps don't count — so the
229
+ chain never completes regardless of where the jeeps drive."""
230
+ jeeps, _tanks = _split_by_type(rs)
231
+ if not jeeps:
232
+ return [C.observe()]
233
+ # Just keep sending jeeps to BOTH waypoints — engine moves them.
234
+ return [C.move_units(jeeps, 90, 30)]
235
+
236
+
237
+ def _intended_handoff(rs, C):
238
+ """Parallel dispatch: jeeps to P1, tanks to P2 — B holds at P2
239
+ while A latches at P1, then `then:` advances both clauses."""
240
+ jeeps, tanks = _split_by_type(rs)
241
+ cmds = []
242
+ if jeeps:
243
+ cmds.append(C.move_units(jeeps, 50, 10))
244
+ if tanks:
245
+ cmds.append(C.move_units(tanks, 90, 30))
246
+ return cmds or [C.observe()]
247
+
248
+
249
+ def _make_handoff_policy(legs):
250
+ """Build a stateful policy that drives each squad through its
251
+ own list of waypoints in order — once a leg is reached (>=3
252
+ units of that type in radius 8), it sticks (no oscillation back
253
+ to a satisfied earlier leg if a single unit drifts out). `legs`
254
+ is {unit_type: [(x,y), (x,y), ...]}, executed in order."""
255
+ state = {t: 0 for t in legs}
256
+
257
+ def policy(rs, C):
258
+ us = rs.get("units_summary", []) or []
259
+ cmds = []
260
+ for utype, waypoints in legs.items():
261
+ ids = [str(u["id"]) for u in us if u.get("type") == utype]
262
+ if not ids:
263
+ continue
264
+ idx = state[utype]
265
+ # Advance through every consecutive waypoint already
266
+ # satisfied (sticky — never regress).
267
+ while idx < len(waypoints):
268
+ wx, wy = waypoints[idx]
269
+ here = sum(
270
+ 1 for u in us
271
+ if u.get("type") == utype
272
+ and (u["cell_x"] - wx) ** 2 + (u["cell_y"] - wy) ** 2
273
+ <= 8 * 8
274
+ )
275
+ if here >= 3 and idx < len(waypoints) - 1:
276
+ idx += 1
277
+ else:
278
+ break
279
+ state[utype] = idx
280
+ tx, ty = waypoints[idx]
281
+ cmds.append(C.move_units(ids, tx, ty))
282
+ return cmds or [C.observe()]
283
+
284
+ return policy
285
+
286
+
287
+ def _intended_handoff_medium_factory():
288
+ return _make_handoff_policy({
289
+ "jeep": [(50, 10), (60, 20)],
290
+ "2tnk": [(90, 30)],
291
+ })
292
+
293
+
294
+ def _intended_handoff_hard_factory():
295
+ return _make_handoff_policy({
296
+ "jeep": [(50, 10), (60, 20)],
297
+ "2tnk": [(90, 30), (100, 15)],
298
+ })
299
+
300
+
301
+ # Seeds 1..4 = the held-out seed contract from CLAUDE.md.
302
+
303
+ @pytest.mark.parametrize("level", ["easy", "medium", "hard"])
304
+ @pytest.mark.parametrize("seed", [1, 2, 3, 4])
305
+ def test_stall_loses(level, seed):
306
+ c = compile_level(load_pack(PACK), level)
307
+ res = run_level(c, _stall, seed=seed)
308
+ assert res.outcome == "loss", (
309
+ f"{level} seed{seed}: stall must LOSE on timeout, got {res.outcome}"
310
+ )
311
+
312
+
313
+ @pytest.mark.parametrize("level", ["easy", "medium", "hard"])
314
+ @pytest.mark.parametrize("seed", [1, 2, 3, 4])
315
+ def test_b_first_loses(level, seed):
316
+ c = compile_level(load_pack(PACK), level)
317
+ res = run_level(c, _b_first, seed=seed)
318
+ assert res.outcome == "loss", (
319
+ f"{level} seed{seed}: B-first dash must LOSE (then-latch never "
320
+ f"advances past A's clause), got {res.outcome}"
321
+ )
322
+
323
+
324
+ @pytest.mark.parametrize("level", ["easy", "medium", "hard"])
325
+ @pytest.mark.parametrize("seed", [1, 2, 3, 4])
326
+ def test_single_squad_tour_loses(level, seed):
327
+ c = compile_level(load_pack(PACK), level)
328
+ res = run_level(c, _single_squad_tour, seed=seed)
329
+ assert res.outcome == "loss", (
330
+ f"{level} seed{seed}: single-squad tour (jeeps only) must LOSE "
331
+ f"— the tank-only clause cannot be satisfied — got {res.outcome}"
332
+ )
333
+
334
+
335
+ @pytest.mark.parametrize("seed", [1, 2, 3, 4])
336
+ def test_intended_handoff_wins_easy(seed):
337
+ c = compile_level(load_pack(PACK), "easy")
338
+ res = run_level(c, _intended_handoff, seed=seed)
339
+ assert res.outcome == "win", (
340
+ f"easy seed{seed}: parallel A→P1/B→P2 must WIN, got {res.outcome}"
341
+ )
342
+
343
+
344
+ @pytest.mark.parametrize("seed", [1, 2, 3, 4])
345
+ def test_intended_handoff_wins_medium(seed):
346
+ c = compile_level(load_pack(PACK), "medium")
347
+ res = run_level(c, _intended_handoff_medium_factory(), seed=seed)
348
+ assert res.outcome == "win", (
349
+ f"medium seed{seed}: 3-leg handoff must WIN, got {res.outcome}"
350
+ )
351
+
352
+
353
+ @pytest.mark.parametrize("seed", [1, 2, 3, 4])
354
+ def test_intended_handoff_wins_hard(seed):
355
+ c = compile_level(load_pack(PACK), "hard")
356
+ res = run_level(c, _intended_handoff_hard_factory(), seed=seed)
357
+ assert res.outcome == "win", (
358
+ f"hard seed{seed}: 4-leg interleaved handoff must WIN, got "
359
+ f"{res.outcome}"
360
+ )
tests/test_def_in_depth.py ADDED
@@ -0,0 +1,259 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """def-in-depth scenario family, full loop on Rust.
2
+
3
+ The pack tests defense-in-depth doctrine: against a massed rush band,
4
+ the same total pbox capacity laid out as TWO concentric layers wins,
5
+ while the same pbox count in a single line or a single cluster loses.
6
+ The discriminator is layout topology, not raw count.
7
+
8
+ * `building_count_gte:{pbox,4}` (easy/medium) / 5 (hard) — minimum
9
+ defensive capacity floor; less than this can't survive even with
10
+ perfect placement.
11
+ * `building_in_region:{(25,20),r=4,pbox,2}` AND
12
+ `building_in_region:{(15,20),r=4,pbox,2}` — REGION predicates with
13
+ non-overlapping radii (FRONT x in [21..29], REAR x in [11..19]) so
14
+ the model MUST physically split the 4 pbox across two depths;
15
+ a single cluster of 4 pbox at one x can satisfy at most ONE region.
16
+ * `has_building:fact` + `own_units_gte:1` — the base survives and a
17
+ defender is on the field at the deadline.
18
+ * `within_ticks:5400` (easy) / 4500 (medium/hard) paired with
19
+ `after_ticks:5401/4501` — a non-finisher is a real reachable
20
+ timeout LOSS (60 turns × ≤90 ticks/step in interrupt mode reaches
21
+ ≥4848), never a draw.
22
+
23
+ These tests prove with deterministic scripted policies (no model, no
24
+ network) that:
25
+
26
+ * the intended layered 2+2 policy WINS every easy/medium seed (1..4);
27
+ * stall / single-line / single-cluster all LOSE every level + seed —
28
+ a real LOSS, not a draw;
29
+ * the `after_ticks` deadline is reachable inside `max_turns`;
30
+ * the hard tier defines ≥2 spawn_point groups.
31
+ """
32
+
33
+ from __future__ import annotations
34
+
35
+ import pytest
36
+
37
+ pytest.importorskip("openra_train", reason="Rust env wheel not installed")
38
+ pytest.importorskip("openra_rl_training", reason="Rust env wheel not installed")
39
+ from openra_bench.eval_core import run_level
40
+ from openra_bench.scenarios import load_pack
41
+ from openra_bench.scenarios.loader import PACKS_DIR, compile_level
42
+
43
+ PACK = PACKS_DIR / "def-in-depth.yaml"
44
+ LEVELS = ("easy", "medium", "hard")
45
+ SEEDS = (1, 2, 3, 4)
46
+
47
+
48
+ # ── scripted policies ─────────────────────────────────────────────────
49
+
50
+
51
+ def stall(rs, C):
52
+ """Observe-only — never spends. Fact razed by the rush."""
53
+ return [C.observe()]
54
+
55
+
56
+ def _make_cluster_builder(cells):
57
+ """Queue many pbox up front (Defense queue accepts backlog), then
58
+ place each at the next cell from `cells` as production completes.
59
+ Stops once every cell is built."""
60
+
61
+ def policy(rs, C):
62
+ own_b = rs.get("own_buildings") or []
63
+ pbox_count = sum(1 for b in own_b if b.get("type") == "pbox")
64
+ if pbox_count >= len(cells):
65
+ return [C.observe()]
66
+ prod = rs.get("production") or []
67
+ prod_items = [p.get("item") for p in prod if isinstance(p, dict)]
68
+ n_in_q = sum(1 for it in prod_items if it == "pbox")
69
+ cmds = []
70
+ # Refill queue so we always have ≥1 pbox in flight while waiting
71
+ # (the Defense queue tolerates a backlog; this halves wall-clock
72
+ # from serial build-place pairs).
73
+ need_to_queue = len(cells) - pbox_count - n_in_q
74
+ for _ in range(max(0, need_to_queue)):
75
+ cmds.append(C.build("pbox"))
76
+ # Try to place the next cell each turn — if no pbox is ready
77
+ # the engine logs PLACE BLOCKED and moves on (harmless).
78
+ cell = cells[pbox_count]
79
+ cmds.append(C.place_building("pbox", cell[0], cell[1]))
80
+ return cmds
81
+
82
+ return policy
83
+
84
+
85
+ def make_layered_2_2():
86
+ """Intended: 2 front (x=25) + 2 rear (x=15), y centred on 20."""
87
+ return _make_cluster_builder(
88
+ [(25, 19), (25, 21), (15, 19), (15, 21)]
89
+ )
90
+
91
+
92
+ def make_layered_3_2(front_y, rear_y):
93
+ """Hard-tier intended: 4 front pbox are pre-placed (2 at each
94
+ latitude); agent adds 2 REAR pbox at its OWN spawn latitude →
95
+ total 6 pbox: 4 front + 2 rear-at-spawn. `front_y` is unused;
96
+ it's kept in the signature for symmetry with the test caller."""
97
+ return _make_cluster_builder([
98
+ (15, rear_y - 1), (15, rear_y + 1),
99
+ ])
100
+
101
+
102
+ def make_stack_front():
103
+ """Stack MORE pbox at FRONT (x=25): 2 inherited + 4 more → all 6
104
+ at front, 0 at rear → rear-region predicate fails. The thick
105
+ single wall eventually attrites under sustained pressure even
106
+ if it survives the first wave → fact razed OR clock LOSS."""
107
+ return _make_cluster_builder(
108
+ [(25, 17), (25, 18), (25, 22), (25, 23)]
109
+ )
110
+
111
+
112
+ def make_single_line():
113
+ """4 pbox in a thin column at x=20 — BETWEEN the two regions
114
+ (front=[21..29], rear=[11..19] both exclude x=20). Neither region
115
+ gains a NEW pbox here (rear-region count from inherited front
116
+ pboxes = 0) → win unsatisfied; under pressure the thin line
117
+ falls and the fact dies → LOSS."""
118
+ return _make_cluster_builder(
119
+ [(20, 16), (20, 18), (20, 20), (20, 22)]
120
+ )
121
+
122
+
123
+ def make_no_build():
124
+ """Issue no build orders — the 2 inherited pbox satisfy front
125
+ region but rear region count = 0, total count = 2 < 4. LOSS."""
126
+ def policy(rs, C):
127
+ return [C.observe()]
128
+ return policy
129
+
130
+
131
+ # ── scenario-shape invariants ─────────────────────────────────────────
132
+
133
+
134
+ def test_pack_compiles_with_three_levels_and_rusher_bot():
135
+ pack = load_pack(PACK)
136
+ assert pack.meta.id == "def-in-depth"
137
+ assert pack.meta.capability == "reasoning"
138
+ assert set(pack.levels) == {"easy", "medium", "hard"}
139
+ anchors = pack.meta.benchmark_anchor
140
+ assert any("defense-in-depth" in a.lower() for a in anchors), anchors
141
+ assert any("multi-layer" in a.lower() for a in anchors), anchors
142
+ for lvl in LEVELS:
143
+ c = compile_level(pack, lvl)
144
+ assert c.map_supported
145
+ enemy = c.scenario.enemy
146
+ bot = getattr(enemy, "bot_type", None) or getattr(enemy, "bot", None)
147
+ assert (str(bot).lower() == "rusher"), (lvl, bot)
148
+
149
+
150
+ @pytest.mark.parametrize("level", LEVELS)
151
+ def test_every_level_has_a_reachable_timeout_fail(level):
152
+ c = compile_level(load_pack(PACK), level)
153
+ assert c.fail_condition is not None
154
+ fc = c.fail_condition.model_dump(exclude_none=True)
155
+ deadline = None
156
+ for clause in fc.get("any_of", []) or []:
157
+ if "after_ticks" in clause:
158
+ deadline = int(clause["after_ticks"])
159
+ assert deadline is not None, f"{level}: no after_ticks fail clause"
160
+ reachable = 93 + 90 * (c.max_turns - 1)
161
+ assert deadline < reachable, (
162
+ f"{level}: deadline {deadline} unreachable within "
163
+ f"{c.max_turns} turns (max tick {reachable}) → draw degeneracy"
164
+ )
165
+
166
+
167
+ def test_hard_has_two_spawn_point_groups():
168
+ c = compile_level(load_pack(PACK), "hard")
169
+ groups = {
170
+ a.spawn_point for a in c.scenario.actors
171
+ if a.owner == "agent" and a.spawn_point is not None
172
+ }
173
+ assert groups == {0, 1}, groups
174
+ for a in c.scenario.actors:
175
+ x, y = a.position
176
+ assert 2 <= x <= 126 and 2 <= y <= 38, (a.type, a.position)
177
+
178
+
179
+ def test_region_radii_are_non_overlapping():
180
+ """The two layer regions (front=(25,20,r=4), rear=(15,20,r=4)) MUST
181
+ not overlap, or a single tall cluster satisfies both predicates and
182
+ the capability isn't enforced."""
183
+ c = compile_level(load_pack(PACK), "easy")
184
+ win = c.win_condition.model_dump(exclude_none=True)
185
+ regions = [
186
+ clause["building_in_region"]
187
+ for clause in win.get("all_of", [])
188
+ if "building_in_region" in clause
189
+ ]
190
+ assert len(regions) == 2, regions
191
+ (a, b) = regions
192
+ dx = abs(int(a["x"]) - int(b["x"]))
193
+ assert dx > int(a["radius"]) + int(b["radius"]), (
194
+ f"regions overlap: dx={dx}, rA+rB={a['radius']+b['radius']}"
195
+ )
196
+
197
+
198
+ # ── solvency: intended WINS every easy/medium seed ────────────────────
199
+
200
+
201
+ @pytest.mark.parametrize("level", ["easy", "medium"])
202
+ def test_intended_layered_policy_wins_every_seed(level):
203
+ c = compile_level(load_pack(PACK), level)
204
+ for seed in SEEDS:
205
+ r = run_level(c, make_layered_2_2(), seed=seed)
206
+ assert r.outcome == "win", (
207
+ f"{level} seed{seed}: intended 2+2 layered play must WIN; "
208
+ f"got {r.outcome} (tick={r.signals.game_tick}, "
209
+ f"kills={r.signals.units_killed}, "
210
+ f"lost={r.signals.units_lost}, "
211
+ f"buildings={r.signals.own_buildings})"
212
+ )
213
+
214
+
215
+ def test_intended_layered_3_2_wins_hard_every_seed():
216
+ """Hard tier — the agent commits 3+2 at whichever latitude its
217
+ base spawned (try BOTH and take whichever wins; the any_of win
218
+ accepts either)."""
219
+ c = compile_level(load_pack(PACK), "hard")
220
+ for seed in SEEDS:
221
+ # spawn_point round-robins 0/1 by seed; without knowing which
222
+ # one this seed picked, try both latitudes and require at
223
+ # least one to win — the model in the field will read its own
224
+ # fact position from the observation.
225
+ wins = []
226
+ for (fy, ry) in [(14, 14), (26, 26)]:
227
+ r = run_level(c, make_layered_3_2(fy, ry), seed=seed)
228
+ wins.append(r.outcome == "win")
229
+ assert any(wins), (
230
+ f"hard seed{seed}: at least one layered 3+2 latitude "
231
+ f"must WIN (model picks latitude from its own fact pos)"
232
+ )
233
+
234
+
235
+ # ── no-cheat: every wrong-topology policy LOSES (not draws) ───────────
236
+
237
+
238
+ @pytest.mark.parametrize("level", ["easy", "medium"])
239
+ @pytest.mark.parametrize(
240
+ "policy_name,policy_factory",
241
+ [
242
+ ("stall", lambda: stall),
243
+ ("stack_front", make_stack_front),
244
+ ("single_line_between", make_single_line),
245
+ ("no_build", make_no_build),
246
+ ],
247
+ )
248
+ def test_lazy_and_wrong_topology_policies_lose_every_seed(
249
+ level, policy_name, policy_factory
250
+ ):
251
+ c = compile_level(load_pack(PACK), level)
252
+ for seed in SEEDS:
253
+ r = run_level(c, policy_factory(), seed=seed)
254
+ assert r.outcome == "loss", (
255
+ f"{level} seed{seed} {policy_name}: must LOSE (real fail, "
256
+ f"not a draw); got {r.outcome} (tick={r.signals.game_tick}, "
257
+ f"kills={r.signals.units_killed}, "
258
+ f"buildings={r.signals.own_buildings})"
259
+ )
tests/test_def_surprise_flank_react.py ADDED
@@ -0,0 +1,318 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """def-surprise-flank-react scenario family, full loop on Rust.
2
+
3
+ The pack tests SURPRISE-AXIS REACTION: the brief states the enemy will
4
+ attack from the NORTH and the world is pre-rigged accordingly (2
5
+ pillboxes + 4 defenders pre-positioned on the NORTH lane). The actual
6
+ rusher band arrives from the SOUTH and walks around the entire NORTH
7
+ defence line. The intended capability is detecting the actual attack
8
+ axis from the observation and RE-POSITIONING the four pre-placed
9
+ defenders to intercept the real rush. The pre-built NORTH pillboxes
10
+ are dead wood for this engagement; the capability is the reactive
11
+ re-positioning, not the build.
12
+
13
+ The win predicate makes all three axes load-bearing:
14
+
15
+ * `has_building:fact` ⇒ the fact must survive (stall / stay-NORTH /
16
+ pure-build all let it get razed by the south rush);
17
+ * `units_killed_gte:4` ⇒ the south rush must actually be engaged
18
+ (stay-NORTH never engages, the 4 defenders sit at y≈14 while the
19
+ rusher band walks past them at y≈20..32);
20
+ * `own_units_gte:3` ⇒ ≥3 defenders must survive (the south rush
21
+ reaching the fact splashes the close defenders even if some
22
+ trade);
23
+ * `within_ticks:4500` paired with `after_ticks:4501` ⇒ a non-finisher
24
+ is a real reachable timeout LOSS (50 turns × ≤90 ticks/step reaches
25
+ ≥4503 in interrupt mode), never a draw.
26
+
27
+ These tests prove with deterministic scripted policies (no model, no
28
+ network) that:
29
+
30
+ * the intended detect-and-redeploy policy WINS every level + every
31
+ hard seed (1..4);
32
+ * stall / stay-NORTH / pure-build all LOSE every level + every hard
33
+ seed (a real LOSS, not a draw);
34
+ * the `after_ticks` deadline is reachable inside `max_turns`;
35
+ * the hard tier defines ≥2 spawn_point groups (so the base latitude
36
+ and matching surprise-axis vary by seed — anti-memorisation).
37
+ """
38
+
39
+ from __future__ import annotations
40
+
41
+ import pytest
42
+
43
+ pytest.importorskip("openra_train", reason="Rust env wheel not installed")
44
+
45
+ pytest.importorskip("openra_rl_training", reason="Rust env wheel not installed")
46
+ from openra_bench.eval_core import run_level
47
+ from openra_bench.scenarios import load_pack
48
+ from openra_bench.scenarios.loader import PACKS_DIR, compile_level
49
+
50
+ PACK = PACKS_DIR / "def-surprise-flank-react.yaml"
51
+ LEVELS = ("easy", "medium", "hard")
52
+ SEEDS = (1, 2, 3, 4)
53
+
54
+
55
+ # ── scripted policies ─────────────────────────────────────────────────
56
+
57
+
58
+ def _fact_xy(rs):
59
+ fact = next(
60
+ (b for b in (rs.get("own_buildings") or [])
61
+ if b.get("type") == "fact"),
62
+ None,
63
+ )
64
+ if fact is None:
65
+ return None
66
+ return int(fact["cell_x"]), int(fact["cell_y"])
67
+
68
+
69
+ def _enemies(rs):
70
+ return [
71
+ e for e in (rs.get("enemy_positions") or [])
72
+ ]
73
+
74
+
75
+ def stall(rs, C):
76
+ """Observe-only — defenders never move; the south rush walks
77
+ past the pre-built NORTH defence and razes the fact."""
78
+ return [C.observe()]
79
+
80
+
81
+ def stay_north(rs, C):
82
+ """Stay-NORTH: explicitly issue an attack_move toward the NORTH
83
+ pillbox line (where the intel said the rush would arrive). The
84
+ defenders re-park on the WRONG axis and never engage the actual
85
+ south rusher band; fact razed → LOSS.
86
+ """
87
+ units = rs.get("units_summary") or []
88
+ own = [u for u in units if str(u.get("type", "")).lower() in ("e1", "e3")]
89
+ if not own:
90
+ return [C.observe()]
91
+ xy = _fact_xy(rs)
92
+ if xy is None:
93
+ return [C.observe()]
94
+ fx, fy = xy
95
+ # Park defenders NORTH of the fact (y_fact - 6). This is where
96
+ # the pre-built pillbox line sits (the intel direction).
97
+ target_y = max(2, fy - 6)
98
+ ids = [str(u["id"]) for u in own]
99
+ return [C.attack_move(ids, target_x=fx, target_y=target_y)]
100
+
101
+
102
+ def pure_build(rs, C):
103
+ """Pure-build: try to spend the starting cash on an extra building
104
+ near the existing NORTH defence (or anywhere) but NEVER re-position
105
+ the defenders. With cash 800 and no tent the infantry queue is
106
+ empty; the model can only queue a pbox (600cr) or a powr. Either
107
+ way the south rush reaches the fact unopposed → LOSS.
108
+ """
109
+ own_b = rs.get("own_buildings") or []
110
+ prod = rs.get("production") or []
111
+ prod_items = [
112
+ p.get("item") for p in prod if isinstance(p, dict)
113
+ ]
114
+ xy = _fact_xy(rs)
115
+ if xy is None:
116
+ return [C.observe()]
117
+ fx, fy = xy
118
+ n_pbox = sum(1 for b in own_b if b.get("type") == "pbox")
119
+ cmds = []
120
+ # Try to queue + place a third NORTH pbox (the "intel" lane).
121
+ if n_pbox < 3:
122
+ if "pbox" not in prod_items:
123
+ cmds.append(C.build("pbox"))
124
+ cmds.append(C.place_building("pbox", fx + 4, fy - 6))
125
+ if not cmds:
126
+ cmds.append(C.observe())
127
+ return cmds
128
+
129
+
130
+ def make_intended():
131
+ """Detect-and-redeploy: observe the spotted rusher units (which
132
+ arrive on the SURPRISE axis, not the intel axis), then commit all
133
+ four defenders to engage them. The 4 defenders intercept the rush
134
+ before/at the fact, focus-fire on the visible rusher cluster,
135
+ kill ≥4 and keep ≥3 alive; fact survives → WIN.
136
+ """
137
+
138
+ def policy(rs, C):
139
+ units = rs.get("units_summary") or []
140
+ own = [u for u in units if str(u.get("type", "")).lower() in ("e1", "e3")]
141
+ if not own:
142
+ return [C.observe()]
143
+ xy = _fact_xy(rs)
144
+ if xy is None:
145
+ return [C.observe()]
146
+ fx, fy = xy
147
+ ids = [str(u["id"]) for u in own]
148
+ # Detect actual rush axis from any visible enemy (rs lists
149
+ # them in `enemy_summary`; the fogged `enemy_positions` is
150
+ # empty for this pack's start). If any rusher is visible,
151
+ # commit ALL defenders onto it (attack_unit on the nearest
152
+ # rusher → focus-fire). Otherwise march toward the OPPOSITE
153
+ # side of the base from the pre-built pbox line — the
154
+ # surprise axis the brief warns about.
155
+ es = rs.get("enemy_summary") or []
156
+ # Filter out the inert far-east fact marker (and any other
157
+ # buildings); only mobile combat units.
158
+ rusher_units = [
159
+ e for e in es
160
+ if str(e.get("type", "")).lower() in ("e1", "e3")
161
+ ]
162
+ if rusher_units:
163
+ # Nearest rusher to the fact (the immediate threat).
164
+ tgt = min(
165
+ rusher_units,
166
+ key=lambda e: (
167
+ (int(e.get("cell_x", fx)) - fx) ** 2
168
+ + (int(e.get("cell_y", fy)) - fy) ** 2
169
+ ),
170
+ )
171
+ eid = str(tgt.get("id"))
172
+ return [C.attack_unit(ids, eid)]
173
+ # No rusher visible yet — march toward the surprise axis
174
+ # (opposite the pre-built pbox line).
175
+ own_b = rs.get("own_buildings") or []
176
+ pboxes = [b for b in own_b if b.get("type") == "pbox"]
177
+ if pboxes:
178
+ pby = sum(int(b["cell_y"]) for b in pboxes) // len(pboxes)
179
+ ty = fy + 6 if pby < fy else fy - 6
180
+ tx = fx
181
+ else:
182
+ return [C.observe()]
183
+ return [C.attack_move(ids, target_x=tx, target_y=ty)]
184
+
185
+ return policy
186
+
187
+
188
+ # ── scenario-shape invariants ─────────────────────────────────────────
189
+
190
+
191
+ def test_pack_compiles_with_three_levels_and_rusher_bot():
192
+ pack = load_pack(PACK)
193
+ assert pack.meta.id == "def-surprise-flank-react"
194
+ assert pack.meta.capability == "reasoning"
195
+ assert set(pack.levels) == {"easy", "medium", "hard"}
196
+ anchors = pack.meta.benchmark_anchor
197
+ assert any("adversarial robustness" in a for a in anchors), anchors
198
+ assert any("CICERO" in a for a in anchors), anchors
199
+ for lvl in LEVELS:
200
+ c = compile_level(pack, lvl)
201
+ assert c.map_supported
202
+ enemy = c.scenario.enemy
203
+ bot = getattr(enemy, "bot_type", None) or getattr(enemy, "bot", None)
204
+ assert (str(bot).lower() == "rusher"), (lvl, bot)
205
+
206
+
207
+ @pytest.mark.parametrize("level", LEVELS)
208
+ def test_every_level_has_a_reachable_timeout_fail(level):
209
+ """Non-win must be a real LOSS: the `after_ticks` fail must be
210
+ strictly below the tick reachable at max_turns (≤90 ticks/step in
211
+ interrupt mode)."""
212
+ c = compile_level(load_pack(PACK), level)
213
+ assert c.fail_condition is not None
214
+ fc = c.fail_condition.model_dump(exclude_none=True)
215
+ deadline = None
216
+ for clause in fc.get("any_of", []) or []:
217
+ if "after_ticks" in clause:
218
+ deadline = int(clause["after_ticks"])
219
+ assert deadline is not None, f"{level}: no after_ticks fail clause"
220
+ reachable = 93 + 90 * (c.max_turns - 1)
221
+ assert deadline < reachable, (
222
+ f"{level}: deadline {deadline} unreachable within "
223
+ f"{c.max_turns} turns (max tick {reachable}) → draw degeneracy"
224
+ )
225
+
226
+
227
+ def test_hard_has_two_spawn_point_groups():
228
+ """Hard-tier contract: ≥2 distinct seed-driven spawn_point groups
229
+ so the base latitude (and matching surprise axis) varies by seed."""
230
+ c = compile_level(load_pack(PACK), "hard")
231
+ groups = {
232
+ a.spawn_point for a in c.scenario.actors
233
+ if a.owner == "agent" and a.spawn_point is not None
234
+ }
235
+ assert groups == {0, 1}, groups
236
+ # In-bounds check (rush-hour-arena playable y ≈ 2..38, x ≈ 2..126):
237
+ for a in c.scenario.actors:
238
+ x, y = a.position
239
+ assert 2 <= x <= 126 and 2 <= y <= 38, (a.type, a.position)
240
+
241
+
242
+ def test_pre_built_north_defense_present_easy_and_medium():
243
+ """The PRE-BUILT NORTH pillbox line + four pre-placed NORTH
244
+ defenders must be present on easy and medium (the "trap" the
245
+ intended play must walk away from)."""
246
+ for level in ("easy", "medium"):
247
+ c = compile_level(load_pack(PACK), level)
248
+ pboxes = [
249
+ a for a in c.scenario.actors
250
+ if a.owner == "agent" and a.type == "pbox"
251
+ ]
252
+ assert len(pboxes) == 2, (level, pboxes)
253
+ defenders = [
254
+ a for a in c.scenario.actors
255
+ if a.owner == "agent" and a.type == "e1"
256
+ ]
257
+ assert len(defenders) == 4, (level, defenders)
258
+
259
+
260
+ # ── solvency: intended WINS every level + every hard seed ────────────
261
+
262
+
263
+ @pytest.mark.parametrize("level", LEVELS)
264
+ def test_intended_detect_and_redeploy_wins_every_level_and_seed(level):
265
+ c = compile_level(load_pack(PACK), level)
266
+ for seed in SEEDS:
267
+ r = run_level(c, make_intended(), seed=seed)
268
+ assert r.outcome == "win", (
269
+ f"{level} seed{seed}: intended detect-and-redeploy play "
270
+ f"must WIN; got {r.outcome} (tick={r.signals.game_tick}, "
271
+ f"kills={r.signals.units_killed}, "
272
+ f"lost={r.signals.units_lost}, "
273
+ f"buildings={r.signals.own_buildings})"
274
+ )
275
+
276
+
277
+ # ── no-cheat: every lazy / wrong-axis policy LOSES (not draws) ───────
278
+
279
+
280
+ @pytest.mark.parametrize("level", LEVELS)
281
+ @pytest.mark.parametrize(
282
+ "policy_name,policy_factory",
283
+ [
284
+ ("stall", lambda: stall),
285
+ ("stay_north", lambda: stay_north),
286
+ ("pure_build", lambda: pure_build),
287
+ ],
288
+ )
289
+ def test_lazy_and_wrong_axis_policies_lose_every_level_and_seed(
290
+ level, policy_name, policy_factory
291
+ ):
292
+ """Stall (fact razed), stay-NORTH (defenders park on the intel
293
+ lane while the south rush walks past), and pure-build (more
294
+ buildings on the intel lane, defenders never re-positioned) must
295
+ ALL LOSE on every level + every seed — no draw."""
296
+ c = compile_level(load_pack(PACK), level)
297
+ for seed in SEEDS:
298
+ r = run_level(c, policy_factory(), seed=seed)
299
+ assert r.outcome == "loss", (
300
+ f"{level} seed{seed} {policy_name}: must LOSE (real fail, "
301
+ f"not a draw); got {r.outcome} (tick={r.signals.game_tick}, "
302
+ f"kills={r.signals.units_killed}, "
303
+ f"buildings={r.signals.own_buildings})"
304
+ )
305
+
306
+
307
+ # ── determinism ───────────────────────────────────────────────────────
308
+
309
+
310
+ def test_intended_run_is_deterministic_on_easy():
311
+ c = compile_level(load_pack(PACK), "easy")
312
+ a = run_level(c, make_intended(), seed=3)
313
+ b = run_level(c, make_intended(), seed=3)
314
+ assert (a.outcome, a.turns, a.signals.units_killed) == (
315
+ b.outcome,
316
+ b.turns,
317
+ b.signals.units_killed,
318
+ ), "same seed must be deterministic"
tests/test_def_tower_line_vs_cluster.py ADDED
@@ -0,0 +1,276 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """def-tower-line-vs-cluster scenario family, full loop on Rust.
2
+
3
+ The pack tests DEFENSE TOPOLOGY: when the threat is forced through a
4
+ known chokepoint, the right architecture is dense cluster AT the choke
5
+ (graph min-cut / military bunker placement doctrine), NOT a thin spread
6
+ along the perimeter. The win predicate makes the topology decision
7
+ load-bearing — total pbox count alone is not sufficient:
8
+
9
+ * `building_count_gte:{pbox, n}` ⇒ the agent actually built the budget
10
+ worth of defences (3 on easy, 4 on medium, 5 on hard);
11
+ * `building_in_region:{pbox, x:60, y:20, radius:5, count}` ⇒ ≥3 (easy),
12
+ ≥3 (medium), or ≥4 (hard) of those pbox sit INSIDE the choke region —
13
+ a spread-line layout (one pbox at the choke, the rest along the
14
+ perimeter) satisfies the count but NOT the region;
15
+ * `building_count_gte:{fact,1}` ⇒ the fact must still stand (rather than
16
+ `has_building:fact` which is a one-shot "ever seen" set — see CLAUDE.md
17
+ footgun);
18
+ * `within_ticks` paired with `after_ticks` ⇒ a non-finisher is a real
19
+ reachable timeout LOSS (no interrupts on this pack ⇒ each step is
20
+ exactly 90 ticks, so max_turns is a hard tick budget that the
21
+ `after_ticks` deadline reliably bites in).
22
+
23
+ The scripted-policy validations prove deterministically that:
24
+
25
+ * the intended CLUSTER policy (all pbox built INSIDE the choke region)
26
+ WINS every level + every hard seed (1..4);
27
+ * stall / spread-line (1 at choke + rest perimeter) / pure-army
28
+ (no pbox) all LOSE every level + every hard seed — a real LOSS, not
29
+ a draw;
30
+ * the hard tier defines ≥2 spawn_point groups (north y=10 / south y=30)
31
+ so a memorised relative-to-base placement that lands in the same
32
+ world cell on every seed cannot solve the pack.
33
+ """
34
+
35
+ from __future__ import annotations
36
+
37
+ import pytest
38
+
39
+ pytest.importorskip("openra_train", reason="Rust env wheel not installed")
40
+ pytest.importorskip("openra_rl_training", reason="Rust env wheel not installed")
41
+
42
+ from openra_bench.eval_core import run_level
43
+ from openra_bench.scenarios import load_pack
44
+ from openra_bench.scenarios.loader import PACKS_DIR, compile_level
45
+
46
+ PACK = PACKS_DIR / "def-tower-line-vs-cluster.yaml"
47
+ LEVELS = ("easy", "medium", "hard")
48
+ SEEDS = (1, 2, 3, 4)
49
+
50
+ # Per-level total pbox budget (exactly `starting_cash / 600`).
51
+ _N_PBOX = {"easy": 3, "medium": 4, "hard": 5}
52
+
53
+
54
+ # ── scripted policies ────────────────────────────────────────────────
55
+
56
+
57
+ def stall(rs, C):
58
+ """Observe-only — the agent never spends. Fact gets razed and/or
59
+ the clock runs out."""
60
+ return [C.observe()]
61
+
62
+
63
+ def make_cluster(choke=(60, 20)):
64
+ """Intended CLUSTER topology: every pbox is placed INSIDE the choke
65
+ region (within radius 5 of the choke cell)."""
66
+ cx0, cy0 = choke
67
+ # Eight pre-chosen cells around the choke; the policy uses as many
68
+ # as the budget needs (easy:3, medium:4, hard:5).
69
+ cells = [
70
+ (cx0 - 2, cy0 - 1), (cx0, cy0 - 1), (cx0 + 2, cy0 - 1),
71
+ (cx0 - 2, cy0 + 1), (cx0, cy0 + 1), (cx0 + 2, cy0 + 1),
72
+ (cx0 - 1, cy0), (cx0 + 1, cy0),
73
+ ]
74
+
75
+ def policy(rs, C):
76
+ own_b = rs.get("own_buildings") or []
77
+ n = sum(1 for b in own_b if b.get("type") == "pbox")
78
+ prod = rs.get("production") or []
79
+ prod_items = [p.get("item") for p in prod if isinstance(p, dict)]
80
+ cmds = []
81
+ # Once enough pboxes are up, idle (the win clause counts the
82
+ # current building list each turn).
83
+ if n >= len(cells):
84
+ return [C.observe()]
85
+ if "pbox" not in prod_items:
86
+ cmds.append(C.build("pbox"))
87
+ cmds.append(C.place_building("pbox", cells[n][0], cells[n][1]))
88
+ if not cmds:
89
+ cmds.append(C.observe())
90
+ return cmds
91
+
92
+ return policy
93
+
94
+
95
+ def make_spread_line(n_pbox):
96
+ """SPREAD-LINE topology: one pbox at the choke + the rest along the
97
+ perimeter near the base. Satisfies `building_count_gte` but FAILS
98
+ `building_in_region` (only 1 of N at the choke, not the required
99
+ 3-of-3 / 3-of-4 / 4-of-5)."""
100
+ cells = [(60, 20), (20, 18), (24, 18), (28, 18), (32, 18)][:n_pbox]
101
+
102
+ def policy(rs, C):
103
+ own_b = rs.get("own_buildings") or []
104
+ n = sum(1 for b in own_b if b.get("type") == "pbox")
105
+ prod = rs.get("production") or []
106
+ prod_items = [p.get("item") for p in prod if isinstance(p, dict)]
107
+ cmds = []
108
+ if n >= len(cells):
109
+ return [C.observe()]
110
+ if "pbox" not in prod_items:
111
+ cmds.append(C.build("pbox"))
112
+ cmds.append(C.place_building("pbox", cells[n][0], cells[n][1]))
113
+ if not cmds:
114
+ cmds.append(C.observe())
115
+ return cmds
116
+
117
+ return policy
118
+
119
+
120
+ def pure_army(rs, C):
121
+ """PURE-ARMY: only ever train e1 — never builds a pbox. FAILS the
122
+ `building_count_gte:pbox` clause AND lets the rush eventually reach
123
+ the fact (or runs out the clock with no pbox)."""
124
+ prod = rs.get("production") or []
125
+ prod_items = [p.get("item") for p in prod if isinstance(p, dict)]
126
+ if "e1" not in prod_items:
127
+ return [C.build("e1")]
128
+ return [C.observe()]
129
+
130
+
131
+ # ── scenario-shape invariants ────────────────────────────────────────
132
+
133
+
134
+ def test_pack_compiles_with_three_levels_and_rusher_bot():
135
+ pack = load_pack(PACK)
136
+ assert pack.meta.id == "def-tower-line-vs-cluster"
137
+ assert pack.meta.capability == "reasoning"
138
+ assert set(pack.levels) == {"easy", "medium", "hard"}
139
+ # Required-by-spec benchmark anchors.
140
+ anchors = pack.meta.benchmark_anchor
141
+ assert any("min-cut" in a.lower() or "chokepoint" in a.lower() for a in anchors), anchors
142
+ assert any("bunker" in a.lower() for a in anchors), anchors
143
+ # Rusher bot wired through (charges agent centroid → forces the
144
+ # rush path through the mid-map choke on every seed).
145
+ for lvl in LEVELS:
146
+ c = compile_level(pack, lvl)
147
+ assert c.map_supported
148
+ bot = getattr(c.scenario.enemy, "bot_type", None) or getattr(
149
+ c.scenario.enemy, "bot", None
150
+ )
151
+ assert str(bot).lower() == "rusher", (lvl, bot)
152
+
153
+
154
+ def test_starting_cash_is_exact_pbox_budget():
155
+ """The cash is intentionally tight (3/4/5 pbox at 600 each, zero
156
+ slack). A model that spends on units OR extra power runs out before
157
+ the count clause is satisfied — the topology decision is the spend."""
158
+ pack = load_pack(PACK)
159
+ for lvl, expected in (("easy", 1800), ("medium", 2400), ("hard", 3000)):
160
+ c = compile_level(pack, lvl)
161
+ assert c.starting_cash == expected, (lvl, c.starting_cash)
162
+
163
+
164
+ @pytest.mark.parametrize("level", LEVELS)
165
+ def test_every_level_has_a_reachable_timeout_fail(level):
166
+ """Non-win must be a real LOSS: the `after_ticks` fail clause must
167
+ be strictly below the tick reachable at max_turns. No interrupts on
168
+ this pack ⇒ each step is exactly 90 ticks (max tick = 93+90·(N-1))."""
169
+ c = compile_level(load_pack(PACK), level)
170
+ assert c.fail_condition is not None
171
+ fc = c.fail_condition.model_dump(exclude_none=True)
172
+ deadline = None
173
+ for clause in fc.get("any_of", []) or []:
174
+ if "after_ticks" in clause:
175
+ deadline = int(clause["after_ticks"])
176
+ assert deadline is not None, f"{level}: no after_ticks fail clause"
177
+ reachable = 93 + 90 * (c.max_turns - 1)
178
+ assert deadline < reachable, (
179
+ f"{level}: deadline {deadline} unreachable within "
180
+ f"{c.max_turns} turns (max tick {reachable}) → draw degeneracy"
181
+ )
182
+
183
+
184
+ def test_fact_alive_clause_uses_present_tense_predicate():
185
+ """The fact-survival clause must use the PRESENT-TENSE predicate
186
+ (`building_count_gte:{type:fact,n:1}`) rather than `has_building`,
187
+ which is a one-shot "ever seen" set that stays true after the fact
188
+ is destroyed (a documented CLAUDE.md footgun). Otherwise the rush
189
+ razing the fact would not trigger a LOSS."""
190
+ for lvl in LEVELS:
191
+ c = compile_level(load_pack(PACK), lvl)
192
+ fc = c.fail_condition.model_dump(exclude_none=True)
193
+ fact_clauses = [
194
+ clause for clause in fc.get("any_of", []) or []
195
+ if isinstance(clause, dict)
196
+ and isinstance(clause.get("not"), dict)
197
+ and "building_count_gte" in (clause["not"] or {})
198
+ and (clause["not"]["building_count_gte"] or {}).get("type") == "fact"
199
+ ]
200
+ assert fact_clauses, f"{lvl}: missing present-tense fact-alive fail clause"
201
+
202
+
203
+ def test_hard_has_two_spawn_point_groups():
204
+ """Hard-tier contract: ≥2 distinct agent spawn_point groups so a
205
+ memorised relative-to-base placement that lands in the same world
206
+ cell on every seed cannot generalise."""
207
+ c = compile_level(load_pack(PACK), "hard")
208
+ groups = {
209
+ a.spawn_point for a in c.scenario.actors
210
+ if a.owner == "agent" and a.spawn_point is not None
211
+ }
212
+ assert groups == {0, 1}, groups
213
+ # In-bounds check (rush-hour-arena playable y ≈ 2..38, x ≈ 2..126):
214
+ for a in c.scenario.actors:
215
+ x, y = a.position
216
+ assert 2 <= x <= 126 and 2 <= y <= 38, (a.type, a.position)
217
+
218
+
219
+ # ── solvency: intended CLUSTER wins every level + every hard seed ────
220
+
221
+
222
+ @pytest.mark.parametrize("level", LEVELS)
223
+ def test_intended_cluster_wins_every_level_and_seed(level):
224
+ c = compile_level(load_pack(PACK), level)
225
+ for seed in SEEDS:
226
+ r = run_level(c, make_cluster(), seed=seed)
227
+ assert r.outcome == "win", (
228
+ f"{level} seed{seed}: intended cluster topology must WIN; "
229
+ f"got {r.outcome} (tick={r.signals.game_tick}, "
230
+ f"kills={r.signals.units_killed}, "
231
+ f"lost={r.signals.units_lost}, "
232
+ f"buildings={r.signals.own_buildings})"
233
+ )
234
+
235
+
236
+ # ── no-cheat: every lazy / wrong-topology policy LOSES (not draws) ───
237
+
238
+
239
+ @pytest.mark.parametrize("level", LEVELS)
240
+ @pytest.mark.parametrize(
241
+ "policy_name,policy_factory",
242
+ [
243
+ ("stall", lambda lvl: stall),
244
+ ("spread_line", lambda lvl: make_spread_line(_N_PBOX[lvl])),
245
+ ("pure_army", lambda lvl: pure_army),
246
+ ],
247
+ )
248
+ def test_lazy_and_wrong_topology_policies_lose_every_level_and_seed(
249
+ level, policy_name, policy_factory
250
+ ):
251
+ """Stall (rush razes fact OR clock), spread-line (region clause
252
+ unmet), and pure-army (count clause unmet) must ALL LOSE on every
253
+ level + every seed — no draw."""
254
+ c = compile_level(load_pack(PACK), level)
255
+ fn = policy_factory(level)
256
+ for seed in SEEDS:
257
+ r = run_level(c, fn, seed=seed)
258
+ assert r.outcome == "loss", (
259
+ f"{level} seed{seed} {policy_name}: must LOSE (real fail, "
260
+ f"not a draw); got {r.outcome} (tick={r.signals.game_tick}, "
261
+ f"buildings={r.signals.own_buildings})"
262
+ )
263
+
264
+
265
+ # ── determinism ──────────────────────────────────────────────────────
266
+
267
+
268
+ def test_intended_run_is_deterministic_on_easy():
269
+ c = compile_level(load_pack(PACK), "easy")
270
+ a = run_level(c, make_cluster(), seed=3)
271
+ b = run_level(c, make_cluster(), seed=3)
272
+ assert (a.outcome, a.turns, a.signals.units_killed) == (
273
+ b.outcome,
274
+ b.turns,
275
+ b.signals.units_killed,
276
+ ), "same seed must be deterministic"
tests/test_econ_cash_reserve_management.py ADDED
@@ -0,0 +1,315 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """econ-cash-reserve-management — REASONING capability validation.
2
+
3
+ Real-world anchor: SC2 cash overflow management (spend down while
4
+ keeping reserve); corporate treasury / operational-reserve doctrine;
5
+ financial-runway management. The agent starts with a productive base
6
+ (4 buildings: fact + proc + tent + powr; 2 harvs on 2 near mines)
7
+ and $1500. The win predicate requires SPEND (build more buildings)
8
+ AND maintaining a CASH RESERVE simultaneously — the "cash management
9
+ loop" (operate while preserving working capital).
10
+
11
+ Bar (CLAUDE.md "no defect, no cheat"):
12
+ - stall LOSES every tier / every hard seed (no build → bldg_total
13
+ stuck at 4 → bar unmet → timeout LOSS).
14
+ - pure-hold (harvest only, never build) LOSES every tier / seed
15
+ (same: bldg_total stuck at 4).
16
+ - over-spend (chain proc 1400 + pbox 600 → cash dips to 0) LOSES
17
+ on medium and hard (income cannot refill above the reserve bar
18
+ before the tight tick deadline). Easy permits over-spend because
19
+ the deadline is generous — that's the "loose-bar" tier by
20
+ construction.
21
+ - intended steady-build-with-reserve WINS every tier / seed.
22
+ - hard tier defines ≥2 agent spawn_point groups (NORTH / SOUTH
23
+ base) so a memorised opening cannot generalise.
24
+ """
25
+
26
+ from __future__ import annotations
27
+
28
+ from pathlib import Path
29
+
30
+ import pytest
31
+
32
+ pytest.importorskip("openra_train", reason="Rust env wheel not installed")
33
+ pytest.importorskip("openra_rl_training", reason="Rust env wheel not installed")
34
+
35
+ from openra_bench.eval_core import run_level
36
+ from openra_bench.scenarios import load_pack
37
+ from openra_bench.scenarios.loader import PACKS_DIR, compile_level
38
+ from openra_bench.scenarios.win_conditions import WinContext, evaluate
39
+
40
+ PACK = PACKS_DIR / "econ-cash-reserve-management.yaml"
41
+
42
+
43
+ # ── policies ────────────────────────────────────────────────────────
44
+
45
+
46
+ def _stall(rs, Command):
47
+ """No-op every turn. Bldg_total never grows past 4 → bar unmet → LOSS."""
48
+ return [Command.observe()]
49
+
50
+
51
+ def _pure_hold(rs, Command):
52
+ """Harvest only, never build. Income accumulates but bldg_total
53
+ stays at 4 → bar unmet → LOSS on building bar."""
54
+ units = rs.get("units_summary", []) or []
55
+ harvs = [u for u in units if u.get("type") == "harv"]
56
+ if not harvs:
57
+ return [Command.observe()]
58
+ return [Command.harvest([str(h["id"])], 22, int(h["cell_y"])) for h in harvs]
59
+
60
+
61
+ def _over_spend(rs, Command):
62
+ """Chain a proc (1400) + 2× pbox (600 each) immediately. Cash dips
63
+ to 0 the moment bldg_total reaches 6/7 — must wait for income to
64
+ refill above the 300/500 reserve bar. On medium/hard the tick
65
+ budget runs out before recovery → LOSS."""
66
+ units = rs.get("units_summary", []) or []
67
+ harvs = [u for u in units if u.get("type") == "harv"]
68
+ cmds = []
69
+ for h in harvs:
70
+ cmds.append(Command.harvest([str(h["id"])], 22, int(h["cell_y"])))
71
+ # Queue + place orders spammed each turn until accepted.
72
+ cmds.append(Command.build("proc"))
73
+ cmds.append(Command.place_building("proc", 14, 24))
74
+ cmds.append(Command.build("pbox"))
75
+ cmds.append(Command.place_building("pbox", 16, 24))
76
+ cmds.append(Command.build("pbox"))
77
+ cmds.append(Command.place_building("pbox", 18, 24))
78
+ return cmds
79
+
80
+
81
+ def _intended(rs, Command):
82
+ """Build 3× powr ($900 total) while keeping both harvs harvesting.
83
+ Reaches bldg_total 7 at cash ~600 (well above the medium 300 and
84
+ hard 500 reserve). Wins easy/medium (bldg_total only needs 5/6)
85
+ and hard (bldg_total 7). The cash-management capability: spend
86
+ cheap, keep reserve."""
87
+ units = rs.get("units_summary", []) or []
88
+ harvs = [u for u in units if u.get("type") == "harv"]
89
+ cmds = []
90
+ for h in harvs:
91
+ cmds.append(Command.harvest([str(h["id"])], 22, int(h["cell_y"])))
92
+ cmds.append(Command.build("powr"))
93
+ cmds.append(Command.place_building("powr", 18, 20))
94
+ cmds.append(Command.build("powr"))
95
+ cmds.append(Command.place_building("powr", 18, 22))
96
+ cmds.append(Command.build("powr"))
97
+ cmds.append(Command.place_building("powr", 18, 24))
98
+ return cmds
99
+
100
+
101
+ # ── helpers ─────────────────────────────────────────────────────────
102
+
103
+
104
+ def _run(level, policy, seed=1):
105
+ c = compile_level(load_pack(PACK), level)
106
+ assert c.map_supported, "rush-hour-arena terrain must be present"
107
+ return c, run_level(c, policy, seed=seed)
108
+
109
+
110
+ # ── structural ──────────────────────────────────────────────────────
111
+
112
+
113
+ def test_pack_loads_and_meta_active():
114
+ pack = load_pack(PACK)
115
+ assert pack.meta.id == "econ-cash-reserve-management"
116
+ assert pack.meta.capability == "reasoning"
117
+ assert pack.meta.real_world_meaning
118
+ assert pack.meta.robotics_analogue
119
+ anchors = " ".join(pack.meta.benchmark_anchor).lower()
120
+ assert "sc2" in anchors
121
+ assert "treasury" in anchors or "reserve" in anchors or "runway" in anchors
122
+
123
+
124
+ def test_tools_include_required_set():
125
+ """Pack must declare the [observe, build, place_building, harvest,
126
+ move_units, stop] toolset (the cash-management interaction surface)."""
127
+ pack = load_pack(PACK)
128
+ tools = set(pack.base.get("tools", []) if isinstance(pack.base, dict) else [])
129
+ for required in ("observe", "build", "place_building", "harvest",
130
+ "move_units", "stop"):
131
+ assert required in tools, f"missing tool: {required!r}"
132
+
133
+
134
+ def test_all_tiers_have_reachable_deadlines():
135
+ """tick-alignment idiom: within_ticks ≤ ceiling AND
136
+ after_ticks ≤ ceiling AND within_ticks + 1 == after_ticks (so a
137
+ non-finisher LOSES, not draws)."""
138
+ pack = load_pack(PACK)
139
+ for lvl in ("easy", "medium", "hard"):
140
+ L = pack.levels[lvl]
141
+ ceiling = 93 + 90 * (L.max_turns - 1)
142
+ wt = next(
143
+ int(c["within_ticks"])
144
+ for c in L.win_condition.model_dump()["all_of"]
145
+ if "within_ticks" in c
146
+ )
147
+ ft = next(
148
+ int(c["after_ticks"])
149
+ for c in L.fail_condition.model_dump()["any_of"]
150
+ if "after_ticks" in c
151
+ )
152
+ assert wt <= ceiling, f"{lvl}: within_ticks {wt} > ceiling {ceiling}"
153
+ assert ft <= ceiling, f"{lvl}: after_ticks {ft} > ceiling {ceiling}"
154
+ assert wt + 1 == ft, (
155
+ f"{lvl}: within_ticks {wt} / after_ticks {ft} mismatch "
156
+ "(non-finisher must LOSE, not draw — fail clause one tick"
157
+ " past win clause)"
158
+ )
159
+
160
+
161
+ def test_hard_has_two_seed_driven_spawn_groups():
162
+ """Hard tier: ≥2 distinct agent spawn_point groups so engine
163
+ round-robins start by seed."""
164
+ c = compile_level(load_pack(PACK), "hard")
165
+ sp = {
166
+ (a.spawn_point if a.spawn_point is not None else 0)
167
+ for a in c.scenario.actors
168
+ if a.owner == "agent"
169
+ }
170
+ assert len(sp) >= 2, (
171
+ f"hard must define ≥2 agent spawn_point groups; got {sorted(sp)}"
172
+ )
173
+
174
+
175
+ def test_fail_condition_present_on_every_tier():
176
+ pack = load_pack(PACK)
177
+ for lvl in ("easy", "medium", "hard"):
178
+ c = compile_level(pack, lvl)
179
+ assert c.fail_condition is not None, f"{lvl} needs a fail_condition"
180
+
181
+
182
+ # ── predicate-level (no engine) ─────────────────────────────────────
183
+
184
+
185
+ def _ctx(*, units=(), tick=1000, cash=0, resources=0, own_buildings=()):
186
+ import types
187
+
188
+ sig = types.SimpleNamespace(
189
+ game_tick=tick,
190
+ units_killed=0,
191
+ units_lost=0,
192
+ cash=cash,
193
+ resources=resources,
194
+ own_buildings=list(own_buildings),
195
+ own_building_types={str(t).lower() for (t, _, _) in own_buildings},
196
+ enemies_seen_ids=set(),
197
+ enemy_buildings_seen_ids=set(),
198
+ )
199
+ return WinContext(
200
+ signals=sig,
201
+ render_state={"units_summary": list(units)},
202
+ )
203
+
204
+
205
+ def test_predicates_enforce_capability():
206
+ """Win requires (bldg_total bar AND cash reserve AND ≥2 harvs)
207
+ AND in-time; fail fires on timeout OR fact destroyed."""
208
+ c = compile_level(load_pack(PACK), "medium")
209
+ two_harvs = [
210
+ {"cell_x": 14, "cell_y": 18, "type": "harv"},
211
+ {"cell_x": 14, "cell_y": 20, "type": "harv"},
212
+ ]
213
+ six_bldgs = [
214
+ ("fact", 10, 22),
215
+ ("proc", 12, 18),
216
+ ("tent", 10, 18),
217
+ ("powr", 14, 22),
218
+ ("powr", 18, 22),
219
+ ("powr", 18, 24),
220
+ ]
221
+
222
+ # Intended: bldg_total≥6, cash≥300, 2 harvs, in time → WIN
223
+ assert evaluate(
224
+ c.win_condition,
225
+ _ctx(units=two_harvs, tick=600, cash=900, own_buildings=six_bldgs),
226
+ )
227
+ # Cash one short of 300 → not a win (reserve discipline)
228
+ assert not evaluate(
229
+ c.win_condition,
230
+ _ctx(units=two_harvs, tick=600, cash=299, own_buildings=six_bldgs),
231
+ )
232
+ # Only 5 buildings → not a win (growth bar)
233
+ assert not evaluate(
234
+ c.win_condition,
235
+ _ctx(units=two_harvs, tick=600, cash=2000, own_buildings=six_bldgs[:5]),
236
+ )
237
+ # Only 1 harv (the other died) → not a win
238
+ assert not evaluate(
239
+ c.win_condition,
240
+ _ctx(units=two_harvs[:1], tick=600, cash=900, own_buildings=six_bldgs),
241
+ )
242
+ # Timeout (tick past after_ticks): bar unmet → fail
243
+ assert evaluate(
244
+ c.fail_condition,
245
+ _ctx(units=two_harvs, tick=812, cash=0, own_buildings=six_bldgs),
246
+ )
247
+ # Fact destroyed → fail
248
+ assert evaluate(
249
+ c.fail_condition,
250
+ _ctx(units=two_harvs, tick=600, cash=900, own_buildings=six_bldgs[1:]),
251
+ )
252
+
253
+
254
+ # ── engine-driven policies (the no-cheat bar) ───────────────────────
255
+
256
+
257
+ @pytest.mark.parametrize("level", ["easy", "medium", "hard"])
258
+ @pytest.mark.parametrize("seed", [1, 2, 3, 4])
259
+ def test_stall_loses_every_tier_and_seed(level, seed):
260
+ """No build → bldg_total stays at 4 → bar unmet → timeout LOSS."""
261
+ _, r = _run(level, _stall, seed=seed)
262
+ assert r.outcome == "loss", (
263
+ f"{level}/seed{seed}: stall must LOSE (no growth, bar unmet); "
264
+ f"got {r.outcome} cash={r.signals.cash} turns={r.turns}"
265
+ )
266
+
267
+
268
+ @pytest.mark.parametrize("level", ["easy", "medium", "hard"])
269
+ @pytest.mark.parametrize("seed", [1, 2, 3, 4])
270
+ def test_pure_hold_loses_every_tier_and_seed(level, seed):
271
+ """Pure harvest, never build → bldg_total stays at 4 → bar unmet
272
+ → timeout LOSS. The cash hoards but the growth axis is the bar."""
273
+ _, r = _run(level, _pure_hold, seed=seed)
274
+ assert r.outcome == "loss", (
275
+ f"{level}/seed{seed}: pure-hold must LOSE (no buildings built); "
276
+ f"got {r.outcome} cash={r.signals.cash} turns={r.turns}"
277
+ )
278
+
279
+
280
+ @pytest.mark.parametrize("level", ["medium", "hard"])
281
+ @pytest.mark.parametrize("seed", [1, 2, 3, 4])
282
+ def test_over_spend_loses_on_medium_and_hard(level, seed):
283
+ """Chain proc + 2× pbox → cash drains to 0; income cannot refill
284
+ above the reserve bar inside the tight medium/hard tick budget →
285
+ timeout LOSS. (Easy is intentionally loose — over-spend permitted
286
+ there; the discrimination tier is medium/hard.)"""
287
+ _, r = _run(level, _over_spend, seed=seed)
288
+ assert r.outcome == "loss", (
289
+ f"{level}/seed{seed}: over-spend must LOSE on tight tier; "
290
+ f"got {r.outcome} cash={r.signals.cash} turns={r.turns}"
291
+ )
292
+
293
+
294
+ @pytest.mark.parametrize("level", ["easy", "medium", "hard"])
295
+ @pytest.mark.parametrize("seed", [1, 2, 3, 4])
296
+ def test_intended_wins_every_tier_and_seed(level, seed):
297
+ """The intended capability — build cheap (3× powr = $900) while
298
+ harvesting — WINS every tier and every hard seed. Reaches
299
+ bldg_total 7 at cash ~600 (above the medium 300 and hard 500
300
+ reserve) well inside the deadline."""
301
+ _, r = _run(level, _intended, seed=seed)
302
+ assert r.outcome == "win", (
303
+ f"{level}/seed{seed}: intended cash-discipline should WIN; "
304
+ f"got {r.outcome} cash={r.signals.cash} turns={r.turns}"
305
+ )
306
+
307
+
308
+ def test_outcomes_are_deterministic_per_seed():
309
+ """Same seed, same policy → identical outcome / cash / turn count."""
310
+ c = compile_level(load_pack(PACK), "medium")
311
+ a = run_level(c, _intended, seed=2)
312
+ b = run_level(c, _intended, seed=2)
313
+ assert (a.outcome, a.turns, a.signals.cash) == (
314
+ b.outcome, b.turns, b.signals.cash
315
+ )
tests/test_econ_recover_from_zero_cash.py ADDED
@@ -0,0 +1,502 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """No-cheat + solvency proof for `econ-recover-from-zero-cash` (Group F
2
+ turtle-recovery / bankruptcy turnaround from a minimum-viable kit and
3
+ zero working capital).
4
+
5
+ The pack frames a RECOVERY-FROM-SETBACK decision: the agent inherits a
6
+ minimum viable kit (fact + proc + one harv + one mine) and **$0 cash**.
7
+ It must commit to harvesting first (the only income channel), then
8
+ reinvest the accumulated cash on the next productive asset (a war
9
+ factory + a second/third harvester) so the income channel actually
10
+ scales past a non-trivial revenue bar.
11
+
12
+ For every level + every hard seed (1-4):
13
+ * the INTENDED chain (harvest → save 2000 → build weap → build harv →
14
+ move+harvest new harv [→ build 3rd harv on hard]) WINS;
15
+ * STALL (only `observe`) LOSES every tier — no harvest, EV stays at 0,
16
+ clock bites as a reachable timeout LOSS;
17
+ * HARVEST-ONLY (run the starting harv but never build anything) is
18
+ the EASY floor (wins easy) but LOSES medium/hard on the 2-harv /
19
+ 3-harv structural clause regardless of EV;
20
+ * BUILD-ARMY-FROM-ZERO (harvest → powr → tent → spam e1 instead of
21
+ weap → harv) LOSES every tier — no weap means no harv can be built,
22
+ and `tent`+`e1` spend drains the cash that should have gone to the
23
+ income-scaling chain;
24
+ * BUILD-TOO-SOON (harvest → spend cash on a SECOND refinery instead of
25
+ weap+harv) LOSES medium/hard — cash drained on the wrong asset
26
+ starves the 2-harv requirement.
27
+
28
+ The 4 lazy plays + 1 intended × 3 levels × 4 seeds gives the full
29
+ no-defect / no-cheat coverage demanded by CLAUDE.md.
30
+ """
31
+
32
+ from __future__ import annotations
33
+
34
+ import pytest
35
+
36
+ pytest.importorskip("openra_train", reason="Rust env wheel not installed")
37
+ pytest.importorskip("openra_rl_training", reason="Rust env wheel not installed")
38
+ from openra_bench.eval_core import run_level
39
+ from openra_bench.scenarios import load_pack
40
+ from openra_bench.scenarios.loader import PACKS_DIR, compile_level
41
+
42
+ PACK = PACKS_DIR / "econ-recover-from-zero-cash.yaml"
43
+ LEVELS = ("easy", "medium", "hard")
44
+ SEEDS = (1, 2, 3, 4)
45
+
46
+
47
+ # ───────────────────────── helpers ────────────────────────────────────────
48
+
49
+
50
+ def _patches_for_fact(fy: int) -> list[tuple[int, int]]:
51
+ """easy/medium have one patch at (22,18); hard has spawn-local
52
+ near patches: NORTH spawn → (22,14)+(22,16); SOUTH → (22,26)+(22,28)."""
53
+ if abs(fy - 18) <= 2:
54
+ return [(22, 18)]
55
+ if fy < 18:
56
+ return [(22, 14), (22, 16)]
57
+ return [(22, 26), (22, 28)]
58
+
59
+
60
+ def _own_fact(bldgs):
61
+ """Return the AGENT-owned fact (skip the far enemy marker)."""
62
+ for b in bldgs:
63
+ if b.get("type") == "fact" and b.get("owner", "agent") != "enemy":
64
+ cx = b.get("cell_x", 0)
65
+ # The enemy marker is at (120,20) — filter it by x.
66
+ if cx < 100:
67
+ return b
68
+ return None
69
+
70
+
71
+ def _ev(res):
72
+ return res.signals.cash + res.signals.resources
73
+
74
+
75
+ # ───────────────────────── scripted policies ──────────────────────────────
76
+
77
+
78
+ def _stall(rs, Command):
79
+ """Idle: never harvests, never builds. EV stays at 0 → bar unmet →
80
+ reachable timeout LOSS."""
81
+ return [Command.observe()]
82
+
83
+
84
+ def _harvest_only_factory():
85
+ """Run the starting harv but never build anything. WINS easy (the
86
+ floor — bar 1500 is reachable with one harv) but LOSES medium/hard
87
+ (the 2-harv / 3-harv structural clause fails regardless of EV)."""
88
+
89
+ state = {"moved": set()}
90
+
91
+ def policy(rs, Command):
92
+ units = rs.get("units_summary", []) or []
93
+ bldgs = rs.get("own_buildings", []) or []
94
+ fact_b = _own_fact(bldgs)
95
+ if fact_b is None:
96
+ return [Command.observe()]
97
+ patches = _patches_for_fact(fact_b["cell_y"])
98
+ harvs = [
99
+ (u["id"], u.get("cell_x"), u.get("cell_y"))
100
+ for u in units
101
+ if str(u.get("type", "")).lower() == "harv"
102
+ ]
103
+ cmds = []
104
+ for i, (uid, cx, cy) in enumerate(harvs):
105
+ uid_s = str(uid)
106
+ px, py = patches[i % len(patches)]
107
+ if uid_s not in state["moved"]:
108
+ cmds.append(Command.move_units([uid_s], target_x=px, target_y=py))
109
+ if abs(cx - px) <= 3 and abs(cy - py) <= 3:
110
+ state["moved"].add(uid_s)
111
+ else:
112
+ cmds.append(Command.harvest([uid_s], px, py))
113
+ return cmds if cmds else [Command.observe()]
114
+
115
+ return policy
116
+
117
+
118
+ def _build_army_factory():
119
+ """Harvest a bit, then powr → tent → spam e1 instead of weap+harv.
120
+ Without weap, no harv can be built → 2-harv clause fails → LOSS on
121
+ every tier (even easy, where the army spend drains the EV bar)."""
122
+
123
+ state = {"moved": set()}
124
+
125
+ def policy(rs, Command):
126
+ units = rs.get("units_summary", []) or []
127
+ bldgs = rs.get("own_buildings", []) or []
128
+ own_types = {b["type"] for b in bldgs}
129
+ prod = rs.get("production", []) or []
130
+ cash = rs.get("cash", 0)
131
+ fact_b = _own_fact(bldgs)
132
+ if fact_b is None:
133
+ return [Command.observe()]
134
+ fx, fy = fact_b["cell_x"], fact_b["cell_y"]
135
+ patches = _patches_for_fact(fy)
136
+ harvs = [
137
+ (u["id"], u.get("cell_x"), u.get("cell_y"))
138
+ for u in units
139
+ if str(u.get("type", "")).lower() == "harv"
140
+ ]
141
+ cmds = []
142
+ for i, (uid, cx, cy) in enumerate(harvs):
143
+ uid_s = str(uid)
144
+ px, py = patches[i % len(patches)]
145
+ if uid_s not in state["moved"]:
146
+ cmds.append(Command.move_units([uid_s], target_x=px, target_y=py))
147
+ if abs(cx - px) <= 3 and abs(cy - py) <= 3:
148
+ state["moved"].add(uid_s)
149
+ else:
150
+ cmds.append(Command.harvest([uid_s], px, py))
151
+ if "powr" not in own_types:
152
+ if cash >= 300 and "powr" not in prod:
153
+ cmds.append(Command.build("powr"))
154
+ cmds.append(Command.place_building("powr", fx + 2, fy - 3))
155
+ elif "tent" not in own_types:
156
+ if cash >= 400 and "tent" not in prod:
157
+ cmds.append(Command.build("tent"))
158
+ cmds.append(Command.place_building("tent", fx + 2, fy + 3))
159
+ elif cash >= 100 and "e1" not in prod:
160
+ cmds.append(Command.build("e1"))
161
+ return cmds if cmds else [Command.observe()]
162
+
163
+ return policy
164
+
165
+
166
+ def _build_too_soon_factory():
167
+ """Harvest, then spend the FIRST accumulated cash on a SECOND
168
+ refinery (the wrong scaling asset) instead of weap+harv. The
169
+ income channel does NOT scale (single harv still) → the 2-harv
170
+ clause fails on medium/hard → LOSS."""
171
+
172
+ state = {"moved": set()}
173
+
174
+ def policy(rs, Command):
175
+ units = rs.get("units_summary", []) or []
176
+ bldgs = rs.get("own_buildings", []) or []
177
+ prod = rs.get("production", []) or []
178
+ cash = rs.get("cash", 0)
179
+ fact_b = _own_fact(bldgs)
180
+ if fact_b is None:
181
+ return [Command.observe()]
182
+ fx, fy = fact_b["cell_x"], fact_b["cell_y"]
183
+ patches = _patches_for_fact(fy)
184
+ harvs = [
185
+ (u["id"], u.get("cell_x"), u.get("cell_y"))
186
+ for u in units
187
+ if str(u.get("type", "")).lower() == "harv"
188
+ ]
189
+ cmds = []
190
+ for i, (uid, cx, cy) in enumerate(harvs):
191
+ uid_s = str(uid)
192
+ px, py = patches[i % len(patches)]
193
+ if uid_s not in state["moved"]:
194
+ cmds.append(Command.move_units([uid_s], target_x=px, target_y=py))
195
+ if abs(cx - px) <= 3 and abs(cy - py) <= 3:
196
+ state["moved"].add(uid_s)
197
+ else:
198
+ cmds.append(Command.harvest([uid_s], px, py))
199
+ # Always try to queue a second proc and place it — the engine
200
+ # may block on prereqs but that's the point (spending intent on
201
+ # the wrong asset, not weap/harv).
202
+ n_proc = sum(1 for b in bldgs if b.get("type") == "proc")
203
+ if n_proc < 2:
204
+ if cash >= 1400 and "proc" not in prod:
205
+ cmds.append(Command.build("proc"))
206
+ cmds.append(Command.place_building("proc", fx + 5, fy + 1))
207
+ return cmds if cmds else [Command.observe()]
208
+
209
+ return policy
210
+
211
+
212
+ def _intended_factory():
213
+ """Intended recovery chain: harvest with starting harv → save 2000 →
214
+ build('weap') + place → save 1400 → build('harv') → move new harv to
215
+ patch → harvest. On hard, build a 3rd harv after the 2nd. WINS
216
+ every tier × every seed."""
217
+
218
+ state = {"moved": set()}
219
+
220
+ def policy(rs, Command):
221
+ units = rs.get("units_summary", []) or []
222
+ bldgs = rs.get("own_buildings", []) or []
223
+ own_types = {b["type"] for b in bldgs}
224
+ prod = rs.get("production", []) or []
225
+ cash = rs.get("cash", 0)
226
+ fact_b = _own_fact(bldgs)
227
+ proc_b = next((b for b in bldgs if b.get("type") == "proc"), None)
228
+ if fact_b is None or proc_b is None:
229
+ return [Command.observe()]
230
+ fx, fy = fact_b["cell_x"], fact_b["cell_y"]
231
+ patches = _patches_for_fact(fy)
232
+ harvs = [
233
+ (u["id"], u.get("cell_x"), u.get("cell_y"))
234
+ for u in units
235
+ if str(u.get("type", "")).lower() == "harv"
236
+ ]
237
+ cmds = []
238
+ # Move each harv to a patch (round-robin) then harvest.
239
+ for i, (uid, cx, cy) in enumerate(harvs):
240
+ uid_s = str(uid)
241
+ px, py = patches[i % len(patches)]
242
+ if uid_s not in state["moved"]:
243
+ cmds.append(Command.move_units([uid_s], target_x=px, target_y=py))
244
+ if abs(cx - px) <= 3 and abs(cy - py) <= 3:
245
+ state["moved"].add(uid_s)
246
+ else:
247
+ cmds.append(Command.harvest([uid_s], px, py))
248
+ # Build weap as soon as cash allows; spam place_building each
249
+ # turn (engine ignores PLACE until production completes — see
250
+ # econ-startup-from-scratch intended policy).
251
+ if "weap" not in own_types:
252
+ if cash >= 2000 and "weap" not in prod:
253
+ cmds.append(Command.build("weap"))
254
+ cmds.append(Command.place_building("weap", fx + 2, fy + 3))
255
+ else:
256
+ # Determine how many harvs we want: 2 for single-patch
257
+ # tiers (easy/medium), 3 for the two-patch hard tier.
258
+ want_harv = 2 if len(patches) == 1 else 3
259
+ if len(harvs) < want_harv and cash >= 1400 and "harv" not in prod:
260
+ cmds.append(Command.build("harv"))
261
+ return cmds if cmds else [Command.observe()]
262
+
263
+ return policy
264
+
265
+
266
+ def _run(level, policy_or_factory, seed=1):
267
+ c = compile_level(load_pack(PACK), level)
268
+ assert c.map_supported, "rush-hour-arena must compile"
269
+ pol = (
270
+ policy_or_factory()
271
+ if callable(policy_or_factory)
272
+ and policy_or_factory.__name__.endswith("factory")
273
+ else policy_or_factory
274
+ )
275
+ return c, run_level(c, pol, seed=seed)
276
+
277
+
278
+ # ───────────────────────── structural ─────────────────────────────────────
279
+
280
+
281
+ def test_pack_loads_with_three_levels_and_required_tools():
282
+ pack = load_pack(PACK)
283
+ assert pack.meta.id == "econ-recover-from-zero-cash"
284
+ assert pack.meta.capability == "reasoning"
285
+ assert pack.meta.status == "active"
286
+ assert set(pack.levels) == set(LEVELS)
287
+ c = compile_level(pack, "easy")
288
+ tools = set(c.scenario.tools or [])
289
+ # Spec-required tools (Wave-5 spec).
290
+ for t in ("observe", "build", "place_building", "harvest", "move_units", "stop"):
291
+ assert t in tools, f"missing tool {t} in {tools}"
292
+
293
+
294
+ def test_starting_cash_is_zero_on_every_level():
295
+ """The pack headline is recovery FROM zero cash — every tier must
296
+ start with 0 cash so the income-first → reinvest decision is real."""
297
+ pack = load_pack(PACK)
298
+ for L in LEVELS:
299
+ c = compile_level(pack, L)
300
+ assert c.starting_cash == 0, (
301
+ f"{L}: starting_cash must be 0 (got {c.starting_cash})"
302
+ )
303
+
304
+
305
+ def test_benchmark_anchor_lists_turtle_and_turnaround():
306
+ pack = load_pack(PACK)
307
+ anchors = pack.meta.benchmark_anchor
308
+ assert anchors, "benchmark_anchor must be non-empty"
309
+ blob = " | ".join(anchors).lower()
310
+ assert "sc2" in blob, anchors
311
+ assert "turtle" in blob or "recovery" in blob, anchors
312
+ assert "bankruptcy" in blob or "turnaround" in blob, anchors
313
+
314
+
315
+ @pytest.mark.parametrize("level", LEVELS)
316
+ def test_every_level_has_a_reachable_timeout_fail(level):
317
+ """Non-win must be a real LOSS, never a DRAW: after_ticks in
318
+ fail_condition must be reachable within max_turns (tick ≈ 93 +
319
+ 90·(max_turns − 1)). within_ticks + 1 == after_ticks idiom."""
320
+ c = compile_level(load_pack(PACK), level)
321
+ assert c.fail_condition is not None
322
+ after_ticks = int(c.fail_condition.model_dump()["any_of"][0]["after_ticks"])
323
+ reachable = 93 + 90 * (c.max_turns - 1)
324
+ assert after_ticks <= reachable, (
325
+ f"{level}: fail after_ticks {after_ticks} unreachable within "
326
+ f"{c.max_turns} turns (max tick {reachable}) → draw degeneracy"
327
+ )
328
+ within_clauses = c.win_condition.model_dump().get("all_of", [])
329
+ wt = next(int(x["within_ticks"]) for x in within_clauses if "within_ticks" in x)
330
+ assert after_ticks == wt + 1, (
331
+ f"{level}: after_ticks {after_ticks} must equal within_ticks+1 ({wt + 1})"
332
+ )
333
+
334
+
335
+ def test_hard_has_two_spawn_groups_for_base():
336
+ """Hard tier contract: ≥2 distinct agent spawn_point groups
337
+ (NORTH / SOUTH base) so seed round-robin varies the start cell and
338
+ a memorised opening can't generalise."""
339
+ c = compile_level(load_pack(PACK), "hard")
340
+ sps = {
341
+ (a.spawn_point if a.spawn_point is not None else 0)
342
+ for a in c.scenario.actors
343
+ if a.owner == "agent"
344
+ }
345
+ assert len(sps) >= 2, f"hard needs ≥2 spawn groups, got {sps}"
346
+
347
+
348
+ # ───────────────────────── intended WIN bar ───────────────────────────────
349
+
350
+
351
+ @pytest.mark.parametrize("level", LEVELS)
352
+ @pytest.mark.parametrize("seed", SEEDS)
353
+ def test_intended_chain_wins(level, seed):
354
+ """Intended recovery (harvest → weap → harv [+ harv on hard]) WINS
355
+ every tier × every seed."""
356
+ c, r = _run(level, _intended_factory, seed=seed)
357
+ assert r.outcome == "win", (
358
+ f"{level} seed{seed}: intended chain should WIN, got {r.outcome}; "
359
+ f"ev={_ev(r)}, cash={r.signals.cash}, harvs={r.signals.harvesters}, "
360
+ f"types={r.signals.own_building_types}, tick={r.signals.game_tick}"
361
+ )
362
+
363
+
364
+ # ─────────────────────���─── no-cheat: lazy plays LOSE ──────────────────────
365
+
366
+
367
+ @pytest.mark.parametrize("level", LEVELS)
368
+ @pytest.mark.parametrize("seed", SEEDS)
369
+ def test_stall_loses(level, seed):
370
+ """Stall (only `observe`) must LOSE every tier × every seed — no
371
+ harvest, EV stays at 0, bar unmet, clock bites as a real LOSS."""
372
+ c, r = _run(level, _stall, seed=seed)
373
+ assert r.outcome == "loss", (
374
+ f"{level} seed{seed} stall must LOSE; got {r.outcome} "
375
+ f"(ev={_ev(r)}, tick={r.signals.game_tick})"
376
+ )
377
+ assert _ev(r) == 0, f"stall earns nothing; got ev={_ev(r)}"
378
+
379
+
380
+ @pytest.mark.parametrize("seed", SEEDS)
381
+ def test_harvest_only_is_easy_floor(seed):
382
+ """Harvest-only with the starting harv is the EASY FLOOR — it
383
+ clears the loose 1500 bar without needing a 2nd harv."""
384
+ c, r = _run("easy", _harvest_only_factory, seed=seed)
385
+ assert r.outcome == "win", (
386
+ f"easy seed{seed} harvest-only should be the FLOOR and WIN; "
387
+ f"got {r.outcome} (ev={_ev(r)})"
388
+ )
389
+
390
+
391
+ @pytest.mark.parametrize("level", ("medium", "hard"))
392
+ @pytest.mark.parametrize("seed", SEEDS)
393
+ def test_harvest_only_loses_medium_and_hard(level, seed):
394
+ """Harvest-only LOSES medium/hard — the 2-harv / 3-harv structural
395
+ clause fails regardless of EV. This is the discriminator that the
396
+ recovery chain (build weap → build harv) is actually exercised."""
397
+ c, r = _run(level, _harvest_only_factory, seed=seed)
398
+ assert r.outcome == "loss", (
399
+ f"{level} seed{seed} harvest-only must LOSE on the 2/3-harv "
400
+ f"clause; got {r.outcome} (ev={_ev(r)}, "
401
+ f"harvs={r.signals.harvesters}, types={r.signals.own_building_types})"
402
+ )
403
+ # Harvester count never grew past the pre-placed one.
404
+ assert r.signals.harvesters <= 1, (
405
+ f"{level}: harvest-only should not produce more harvs; "
406
+ f"harvs={r.signals.harvesters}"
407
+ )
408
+
409
+
410
+ @pytest.mark.parametrize("level", LEVELS)
411
+ @pytest.mark.parametrize("seed", SEEDS)
412
+ def test_build_army_loses(level, seed):
413
+ """Harvest → powr → tent → spam e1 (NEVER builds weap → no harv)
414
+ must LOSE every tier — easy on the EV bar (army drain), medium/hard
415
+ on the 2/3-harv clause AND the EV bar."""
416
+ c, r = _run(level, _build_army_factory, seed=seed)
417
+ assert r.outcome == "loss", (
418
+ f"{level} seed{seed} build-army must LOSE; got {r.outcome} "
419
+ f"(ev={_ev(r)}, types={r.signals.own_building_types})"
420
+ )
421
+ # Wrong-path commitment confirmed: tent landed but weap never did.
422
+ assert "tent" in r.signals.own_building_types, (
423
+ f"{level}: build-army should commit to tent; "
424
+ f"types={r.signals.own_building_types}"
425
+ )
426
+ assert "weap" not in r.signals.own_building_types, (
427
+ f"{level}: build-army should NEVER build weap; "
428
+ f"types={r.signals.own_building_types}"
429
+ )
430
+
431
+
432
+ @pytest.mark.parametrize("level", ("medium", "hard"))
433
+ @pytest.mark.parametrize("seed", SEEDS)
434
+ def test_build_too_soon_loses_medium_and_hard(level, seed):
435
+ """Build-too-soon = spend cash on a 2nd refinery (the wrong scaling
436
+ asset) instead of weap+harv. The income channel does NOT scale →
437
+ the 2/3-harv clause fails on medium/hard → LOSS."""
438
+ c, r = _run(level, _build_too_soon_factory, seed=seed)
439
+ assert r.outcome == "loss", (
440
+ f"{level} seed{seed} build-too-soon must LOSE; got {r.outcome} "
441
+ f"(ev={_ev(r)}, harvs={r.signals.harvesters}, "
442
+ f"types={r.signals.own_building_types})"
443
+ )
444
+ # No weap (so no harv could be built) — the discriminator.
445
+ assert "weap" not in r.signals.own_building_types, (
446
+ f"{level}: build-too-soon must not happen to also build weap; "
447
+ f"types={r.signals.own_building_types}"
448
+ )
449
+ assert r.signals.harvesters <= 1, (
450
+ f"{level}: build-too-soon should not produce more harvs; "
451
+ f"harvs={r.signals.harvesters}"
452
+ )
453
+
454
+
455
+ # ───────────────────────── hard spawn round-robin ─────────────────────────
456
+
457
+
458
+ def test_hard_seed_round_robin_produces_distinct_starts():
459
+ """Seeds 1-4 must round-robin between the two declared
460
+ spawn_point groups (NORTH y=14 / SOUTH y=26) so a memorised opening
461
+ can't generalise."""
462
+ from pathlib import Path
463
+
464
+ from openra_bench.eval_core import RustEnvPool, _scenario_to_tmp_yaml
465
+ from openra_bench.rust_adapter import RustObsAdapter
466
+
467
+ c = compile_level(load_pack(PACK), "hard")
468
+ tmp = _scenario_to_tmp_yaml(c)
469
+ pool = RustEnvPool(size=1, scenario_path=tmp)
470
+ env = pool.acquire()
471
+ starts = set()
472
+ try:
473
+ for seed in (1, 2, 3, 4):
474
+ ad = RustObsAdapter()
475
+ ad.observe(env.reset(seed=seed))
476
+ u = ad.render_state().get("units_summary", []) or []
477
+ if u:
478
+ starts.add(
479
+ tuple(sorted((x["cell_x"], x["cell_y"]) for x in u))
480
+ )
481
+ finally:
482
+ pool.release(env)
483
+ pool.shutdown()
484
+ Path(tmp).unlink(missing_ok=True)
485
+ assert len(starts) >= 2, (
486
+ f"hard seeds 1-4 produced identical starts {starts}; "
487
+ "spawn_point round-robin not taking effect"
488
+ )
489
+
490
+
491
+ # ───────────────────────── determinism ────────────────────────────────────
492
+
493
+
494
+ def test_outcomes_are_deterministic_per_seed():
495
+ """Same seed, same pack, same policy → identical outcome."""
496
+ c = compile_level(load_pack(PACK), "medium")
497
+ a = run_level(c, _intended_factory(), seed=2)
498
+ b = run_level(c, _intended_factory(), seed=2)
499
+ assert (a.outcome, a.turns, _ev(a)) == (b.outcome, b.turns, _ev(b)), (
500
+ f"determinism: {(a.outcome, a.turns, _ev(a))} vs "
501
+ f"{(b.outcome, b.turns, _ev(b))}"
502
+ )
tests/test_econ_target_cash_amount_by_deadline.py ADDED
@@ -0,0 +1,304 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """No-cheat + solvency proof for `econ-target-cash-amount-by-deadline`.
2
+
3
+ The capability: reach an exact SPENDABLE CASH bar (`cash_gte: N`, not
4
+ EV) by a hard tick deadline. The model must reason whether the
5
+ pre-placed pipeline will clear the bar on time and reinvest into
6
+ harvesting capacity if not — without draining cash into army units
7
+ that don't grow income.
8
+
9
+ For every level (and every hard seed 1-4) the no-cheat bar holds:
10
+
11
+ - STALL (only `observe`) → LOSS (idles at starting_cash)
12
+ - ARMY-DRAIN (harvest + spam `e1`) → LOSS (income ≈ unit cost)
13
+ - BASELINE (1 harv, no reinvest) → LOSS on medium+hard
14
+ (income too slow; deadline
15
+ bites) — WIN on easy
16
+ (loose bar; bare commitment
17
+ suffices, by design)
18
+ - INTENDED (1 harv + build harv) → WIN on every level / seed
19
+
20
+ Plus tick/turn alignment, fail-condition reachability, hard spawn
21
+ contract.
22
+ """
23
+
24
+ from __future__ import annotations
25
+
26
+ import pytest
27
+
28
+ pytest.importorskip("openra_train", reason="Rust env wheel not installed")
29
+ pytest.importorskip(
30
+ "openra_rl_training", reason="Rust env wheel not installed"
31
+ )
32
+
33
+ from openra_bench.eval_core import run_level
34
+ from openra_bench.scenarios import load_pack
35
+ from openra_bench.scenarios.loader import PACKS_DIR, compile_level
36
+
37
+ PACK_PATH = PACKS_DIR / "econ-target-cash-amount-by-deadline.yaml"
38
+
39
+ # All near-patch mine cells across levels — the scripted harvest
40
+ # policy tries each (the engine picks the reachable one per spawn).
41
+ ALL_NEAR_MINES = [
42
+ (22, 18),
43
+ (22, 22), # easy + medium pre-placed
44
+ (22, 10),
45
+ (22, 14), # hard NORTH spawn
46
+ (22, 28),
47
+ (22, 32), # hard SOUTH spawn
48
+ ]
49
+
50
+
51
+ # ───────────────────────── scripted policies ──────────────────────────────
52
+
53
+
54
+ def stall(rs, Command):
55
+ """Do-nothing — must lose: starting_cash (600) < bar everywhere."""
56
+ return [Command.observe()]
57
+
58
+
59
+ def _harv_only(rs, Command):
60
+ """Pre-placed harv only, no reinvestment (the BASELINE)."""
61
+ ids = [
62
+ str(u["id"])
63
+ for u in rs.get("units_summary", []) or []
64
+ if u.get("type") == "harv"
65
+ ]
66
+ if not ids:
67
+ return [Command.observe()]
68
+ cmds = []
69
+ for i, uid in enumerate(ids):
70
+ mx, my = ALL_NEAR_MINES[i % len(ALL_NEAR_MINES)]
71
+ cmds.append(Command.harvest([uid], mx, my))
72
+ return cmds
73
+
74
+
75
+ def _army_drain(rs, Command):
76
+ """Harvest with the pre-placed harv AND spam `e1` every turn that
77
+ cash >= 100. e1 is ~100cr and income from one harv after warmup is
78
+ ~100cr/turn, so cash treads water near starting_cash and never
79
+ clears the bar — the "spend on the wrong line item" decoy."""
80
+ cmds = []
81
+ ids = [
82
+ str(u["id"])
83
+ for u in rs.get("units_summary", []) or []
84
+ if u.get("type") == "harv"
85
+ ]
86
+ for i, uid in enumerate(ids):
87
+ mx, my = ALL_NEAR_MINES[i % len(ALL_NEAR_MINES)]
88
+ cmds.append(Command.harvest([uid], mx, my))
89
+ if rs.get("cash", 0) >= 100:
90
+ cmds.append(Command.build("e1"))
91
+ return cmds if cmds else [Command.observe()]
92
+
93
+
94
+ def _intended_factory(extra_harv: int):
95
+ """Reinvestment factory: harvest with all harvs AND queue `extra_harv`
96
+ additional harvesters (each costs 1100, prereq fact+powr+weap).
97
+ Closure-local state so each test invocation starts fresh."""
98
+
99
+ def make():
100
+ queued = [0]
101
+
102
+ def p(rs, Command):
103
+ cmds = []
104
+ ids = [
105
+ str(u["id"])
106
+ for u in rs.get("units_summary", []) or []
107
+ if u.get("type") == "harv"
108
+ ]
109
+ for i, uid in enumerate(ids):
110
+ mx, my = ALL_NEAR_MINES[i % len(ALL_NEAR_MINES)]
111
+ cmds.append(Command.harvest([uid], mx, my))
112
+ if queued[0] < extra_harv and rs.get("cash", 0) >= 1100:
113
+ cmds.append(Command.build("harv"))
114
+ queued[0] += 1
115
+ return cmds if cmds else [Command.observe()]
116
+
117
+ return p
118
+
119
+ return make
120
+
121
+
122
+ # ───────────────────────── solvency: intended WINS ────────────────────────
123
+
124
+
125
+ @pytest.mark.parametrize("seed", [1, 2, 3, 4])
126
+ def test_easy_intended_wins(seed):
127
+ """Easy bar (1500 by tick 1800) is reachable just by harvesting —
128
+ no reinvestment needed. Run the BASELINE; it must WIN."""
129
+ c = compile_level(load_pack(PACK_PATH), "easy")
130
+ r = run_level(c, _harv_only, seed=seed)
131
+ assert r.outcome == "win", (
132
+ f"easy baseline seed={seed} should WIN, got {r.outcome} "
133
+ f"(cash={r.signals.cash}, tick={r.signals.game_tick})"
134
+ )
135
+
136
+
137
+ @pytest.mark.parametrize("seed", [1, 2, 3, 4])
138
+ def test_medium_intended_wins(seed):
139
+ """Medium bar (5500 by tick 4500) needs reinvestment. Run the
140
+ INTENDED policy (build 1 extra harv); it must WIN."""
141
+ c = compile_level(load_pack(PACK_PATH), "medium")
142
+ r = run_level(c, _intended_factory(1)(), seed=seed)
143
+ assert r.outcome == "win", (
144
+ f"medium intended (+1 harv) seed={seed} should WIN, got "
145
+ f"{r.outcome} (cash={r.signals.cash}, tick={r.signals.game_tick})"
146
+ )
147
+
148
+
149
+ @pytest.mark.parametrize("seed", [1, 2, 3, 4])
150
+ def test_hard_intended_wins_every_seed(seed):
151
+ """Hard bar (6500 by tick 4000) on each of two spawn groups needs
152
+ reinvestment. Run the INTENDED policy (build 2 extra harvs for
153
+ safety margin against the tighter clock); it must WIN on every
154
+ seed."""
155
+ c = compile_level(load_pack(PACK_PATH), "hard")
156
+ r = run_level(c, _intended_factory(2)(), seed=seed)
157
+ assert r.outcome == "win", (
158
+ f"hard intended (+2 harv) seed={seed} should WIN, got "
159
+ f"{r.outcome} (cash={r.signals.cash}, tick={r.signals.game_tick})"
160
+ )
161
+
162
+
163
+ # ───────────────────────── no-cheat: lazy plays LOSE ──────────────────────
164
+
165
+
166
+ @pytest.mark.parametrize("level", ["easy", "medium", "hard"])
167
+ def test_stall_loses_on_every_level(level):
168
+ """Stall idles at starting_cash (600) < bar (1500/5500/6500). The
169
+ `after_ticks` deadline must bite as a real LOSS (not a draw)."""
170
+ c = compile_level(load_pack(PACK_PATH), level)
171
+ r = run_level(c, stall, seed=1)
172
+ assert r.outcome == "loss", (
173
+ f"{level} stall must be a real timeout LOSS, got {r.outcome} "
174
+ f"(cash={r.signals.cash})"
175
+ )
176
+
177
+
178
+ @pytest.mark.parametrize("level", ["easy", "medium", "hard"])
179
+ def test_army_drain_loses_on_every_level(level):
180
+ """Spamming `e1` alongside harvest exactly absorbs the harv
181
+ income (~100cr/turn each). Cash hovers near starting_cash and
182
+ never clears the bar — LOSS on every level."""
183
+ c = compile_level(load_pack(PACK_PATH), level)
184
+ r = run_level(c, _army_drain, seed=1)
185
+ assert r.outcome == "loss", (
186
+ f"{level} army-drain must LOSE (cash drained by units), got "
187
+ f"{r.outcome} (cash={r.signals.cash})"
188
+ )
189
+
190
+
191
+ @pytest.mark.parametrize("seed", [1, 2, 3, 4])
192
+ def test_medium_baseline_no_reinvest_loses(seed):
193
+ """The pre-placed pipeline alone reaches ~5600 cash but only at
194
+ tick ≥4500 — the deadline bites first. Baseline (no reinvest)
195
+ LOSES on every seed: this is what makes the medium tier a
196
+ capex-decision test."""
197
+ c = compile_level(load_pack(PACK_PATH), "medium")
198
+ r = run_level(c, _harv_only, seed=seed)
199
+ assert r.outcome == "loss", (
200
+ f"medium baseline seed={seed} must LOSE (deadline bites "
201
+ f"before bar reached), got {r.outcome} "
202
+ f"(cash={r.signals.cash}, tick={r.signals.game_tick})"
203
+ )
204
+
205
+
206
+ @pytest.mark.parametrize("seed", [1, 2, 3, 4])
207
+ def test_hard_baseline_no_reinvest_loses(seed):
208
+ """Hard target (6500 in 4000 ticks) is well above what 1 harv
209
+ can produce in time (~4100 cash by tick 4053). Baseline LOSES
210
+ on every seed."""
211
+ c = compile_level(load_pack(PACK_PATH), "hard")
212
+ r = run_level(c, _harv_only, seed=seed)
213
+ assert r.outcome == "loss", (
214
+ f"hard baseline seed={seed} must LOSE, got {r.outcome} "
215
+ f"(cash={r.signals.cash}, tick={r.signals.game_tick})"
216
+ )
217
+
218
+
219
+ # ───────────────────────── tick/turn alignment ────────────────────────────
220
+
221
+
222
+ @pytest.mark.parametrize("level", ["easy", "medium", "hard"])
223
+ def test_deadline_reachable_inside_max_turns(level):
224
+ """`within_ticks` (and the matching `after_ticks` fail) must be
225
+ reachable inside max_turns — otherwise stall draws instead of
226
+ losing. Engine advances ~90 ticks per turn (tick ≈ 93+90·(t-1))."""
227
+ c = compile_level(load_pack(PACK_PATH), level)
228
+ max_tick = 93 + 90 * (c.max_turns - 1)
229
+ win_extra = c.win_condition.__pydantic_extra__ or {}
230
+ fail_extra = (
231
+ c.fail_condition.__pydantic_extra__ if c.fail_condition else {}
232
+ ) or {}
233
+
234
+ def _find(node, key):
235
+ if not isinstance(node, dict):
236
+ return None
237
+ for k, v in node.items():
238
+ if k == key:
239
+ return int(v)
240
+ if k in ("all_of", "any_of"):
241
+ for c2 in v:
242
+ r = _find(c2, key)
243
+ if r is not None:
244
+ return r
245
+ return None
246
+
247
+ within = _find(win_extra, "within_ticks")
248
+ assert within is not None, f"{level} should have within_ticks"
249
+ assert within <= max_tick, (
250
+ f"{level} within_ticks={within} > max reachable tick {max_tick} "
251
+ f"(max_turns={c.max_turns}); deadline never bites"
252
+ )
253
+ after = _find(fail_extra, "after_ticks")
254
+ assert after is not None, f"{level} should have an after_ticks fail"
255
+ assert after <= max_tick, (
256
+ f"{level} after_ticks={after} > max reachable tick {max_tick}"
257
+ )
258
+
259
+
260
+ # ───────────────────────── hard spawn contract ────────────────────────────
261
+
262
+
263
+ def test_hard_has_two_spawn_point_groups():
264
+ c = compile_level(load_pack(PACK_PATH), "hard")
265
+ sp = {
266
+ (a.spawn_point if a.spawn_point is not None else 0)
267
+ for a in c.scenario.actors
268
+ if a.owner == "agent"
269
+ }
270
+ assert len(sp) >= 2, (
271
+ f"hard must define ≥2 agent spawn_point groups, got {sorted(sp)}"
272
+ )
273
+
274
+
275
+ def test_hard_seed_round_robin_produces_distinct_starts():
276
+ """Smoke that the engine actually round-robins the spawn groups by
277
+ seed (the whole point of declaring multiple spawn_point groups)."""
278
+ from pathlib import Path
279
+
280
+ from openra_bench.eval_core import RustEnvPool, _scenario_to_tmp_yaml
281
+ from openra_bench.rust_adapter import RustObsAdapter
282
+
283
+ c = compile_level(load_pack(PACK_PATH), "hard")
284
+ tmp = _scenario_to_tmp_yaml(c)
285
+ pool = RustEnvPool(size=1, scenario_path=tmp)
286
+ env = pool.acquire()
287
+ starts = set()
288
+ try:
289
+ for seed in (1, 2, 3, 4):
290
+ ad = RustObsAdapter()
291
+ ad.observe(env.reset(seed=seed))
292
+ u = ad.render_state().get("units_summary", []) or []
293
+ if u:
294
+ starts.add(
295
+ tuple(sorted((x["cell_x"], x["cell_y"]) for x in u))
296
+ )
297
+ finally:
298
+ pool.release(env)
299
+ pool.shutdown()
300
+ Path(tmp).unlink(missing_ok=True)
301
+ assert len(starts) >= 2, (
302
+ f"hard seeds 1-4 produced identical starts {starts}; "
303
+ "spawn_point round-robin not taking effect"
304
+ )
tests/test_hard_tier.py CHANGED
@@ -321,6 +321,41 @@ UPGRADED = [
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
 
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
+ # Wave-5 Group F cash-reserve / treasury management pack (SC2 cash
325
+ # overflow / financial runway / operational reserve anchor). Hard
326
+ # tier defines two agent spawn_point groups (NORTH base y=14 /
327
+ # SOUTH base y=28) round-robined by seed; the two near patches
328
+ # sit on the spawn-matched row so throughput is symmetric per
329
+ # spawn, but a memorised opening cannot generalise across seeds.
330
+ "econ-cash-reserve-management",
331
+ # Wave-5 Group F cash-bar-by-deadline pack (SC2 econ-by-tick /
332
+ # budget-by-date / fundraising milestone / OR throughput-with-
333
+ # deadline anchor). Hard tier defines two agent spawn_point groups
334
+ # (NORTH y=10..14 / SOUTH y=28..32) round-robined by seed with
335
+ # symmetric near-patch ore geometry at both latitudes — so the
336
+ # reinvestment-vs-baseline decision is the same per spawn but a
337
+ # memorised "build at (8,18)" opening cannot generalise across
338
+ # seeds.
339
+ "econ-target-cash-amount-by-deadline",
340
+ # Wave-5 Group G observation-driven 3-phase chain pack (SC2 reactive
341
+ # macro / CICERO information-action loop / PlanBench replanning /
342
+ # threat-intel-driven defense anchor). Hard tier defines two agent
343
+ # spawn_point groups (NORTH base y=10 / SOUTH base y=30) round-
344
+ # robined by seed; three enemy bases (NE/MID/SE) plus a SHARED
345
+ # forward outpost at (60,20) always place (enemy actors don't
346
+ # honour spawn_point — CLAUDE.md), so the nearest-corner assault
347
+ # target flips per seed and a memorised "scout-then-attack-NE"
348
+ # opening cannot generalise across spawn variants.
349
+ "lh-scout-react-counter",
350
+ # Wave-5 salvaged-from-worktree packs: agents finished authoring
351
+ # YAML+tests but didn't push (multiple agents ran into push-blocked
352
+ # / output-truncation states). Salvaged from the untracked working
353
+ # tree and committed from the main session. All 4 have ≥2 spawn
354
+ # groups on hard.
355
+ "coord-cover-and-move",
356
+ "coord-diversionary-attack",
357
+ "def-in-depth",
358
+ "def-surprise-flank-react",
359
  ]
360
 
361
  # Consciously NOT spawn-varied, with the reason (keeps the curation
tests/test_lh_100_turn_marathon_survival.py ADDED
@@ -0,0 +1,352 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """lh-100-turn-marathon-survival pack — full no-cheat validation on Rust.
2
+
3
+ Wave-5 Group G long-horizon endurance pack. The win is gated on FOUR
4
+ things still being true at the after_ticks survival gate:
5
+ 1. has_building: fact
6
+ 2. own_units_gte: 3
7
+ 3. building_count_gte pbox 2
8
+ 4. after_ticks N (survived to the gate) AND within_ticks N+x
9
+
10
+ Sustained pressure comes from STAGGERED hunt squads placed at
11
+ increasing x along the east-west axis, so squads arrive in successive
12
+ time windows rather than all at once. The fail clause uses the
13
+ ``any_of: [after_ticks(GATE), not has_building:fact, not own_units_gte:1]``
14
+ idiom — eval_core evaluates win FIRST, so the broad after_ticks
15
+ fail only bites if the win all-of is unsatisfied at the gate (CLAUDE.md
16
+ rule 3: non-win must be a real LOSS, not a draw).
17
+
18
+ Bar (per CLAUDE.md): the intended SUSTAINED-OPS policy (kick harv +
19
+ build ≥2 pbox + continuously train e1) must WIN on every (level,
20
+ seed); stall / pure-defense-no-reinforce / pure-build-no-repair must
21
+ LOSE on every (level, seed). No draws.
22
+
23
+ NOTE: episodes are LONG (90/100/120 turns ≈ ~8/9/11 thousand ticks).
24
+ The engine-bound tests parametrise over 4 seeds × 3 levels × 4
25
+ policies = 48 runs; expect this file to take several minutes.
26
+ """
27
+
28
+ from __future__ import annotations
29
+
30
+ import pytest
31
+
32
+ pytest.importorskip("openra_train", reason="Rust env wheel not installed")
33
+ pytest.importorskip("openra_rl_training", reason="Rust env wheel not installed")
34
+ from openra_bench.eval_core import run_level
35
+ from openra_bench.scenarios import load_pack
36
+ from openra_bench.scenarios.loader import PACKS_DIR, compile_level
37
+
38
+ PACK = PACKS_DIR / "lh-100-turn-marathon-survival.yaml"
39
+ LEVELS = ("easy", "medium", "hard")
40
+ SEEDS = (1, 2, 3, 4)
41
+
42
+
43
+ # ── Policies ──────────────────────────────────────────────────────
44
+
45
+
46
+ def _stall_policy():
47
+ """Idles every turn — must LOSE (hunt squads raze fact)."""
48
+ def pol(obs, Cmd):
49
+ return [Cmd.observe()]
50
+ return pol
51
+
52
+
53
+ def _pure_defense_no_reinforce_policy():
54
+ """Build pbox to 4 standing but NEVER train fresh infantry. The
55
+ starting garrison bleeds under sustained pressure; either fact
56
+ falls (no infantry to back the pbox line) or own_units drops
57
+ below 3 (1) at the gate ⇒ LOSS."""
58
+ state = {"placed_pbox": 0, "harv_kicked": False}
59
+
60
+ def pol(obs, Cmd):
61
+ ob = obs.get("own_buildings", []) or []
62
+ pbox_have = sum(1 for b in ob if b["type"] == "pbox")
63
+ prod = obs.get("production", []) or []
64
+ cash = int(obs.get("cash", 0) or 0)
65
+ units = obs.get("units_summary", []) or []
66
+ base = next((b for b in ob if b["type"] == "fact"), None)
67
+ if not base:
68
+ return [Cmd.observe()]
69
+ bx, by = base["cell_x"], base["cell_y"]
70
+ cmds = []
71
+ if not state["harv_kicked"]:
72
+ harv = next((u for u in units if u.get("type") == "harv"), None)
73
+ if harv:
74
+ cmds.append(Cmd.harvest([harv["id"]], 2, by))
75
+ state["harv_kicked"] = True
76
+ if pbox_have < 4 and "pbox" not in prod and cash >= 600:
77
+ cmds.append(Cmd.build("pbox"))
78
+ if pbox_have < 4:
79
+ i = state["placed_pbox"]
80
+ cmds.append(Cmd.place_building(
81
+ "pbox", bx + 4 + (i % 4), by + (-3 + (i // 4))
82
+ ))
83
+ state["placed_pbox"] += 1
84
+ if not cmds:
85
+ cmds.append(Cmd.observe())
86
+ return cmds
87
+ return pol
88
+
89
+
90
+ def _pure_build_no_repair_policy():
91
+ """Build just 2 pbox up front, then idle — no infantry production
92
+ and no further building. The lone pbox pair gets stripped by
93
+ sustained hunt fire (rocket squads contribute on medium/hard),
94
+ fact eventually falls or building_count_gte pbox 2 drops below 2
95
+ at the gate ⇒ LOSS."""
96
+ state = {"placed_pbox": 0, "harv_kicked": False}
97
+
98
+ def pol(obs, Cmd):
99
+ ob = obs.get("own_buildings", []) or []
100
+ prod = obs.get("production", []) or []
101
+ cash = int(obs.get("cash", 0) or 0)
102
+ units = obs.get("units_summary", []) or []
103
+ base = next((b for b in ob if b["type"] == "fact"), None)
104
+ if not base:
105
+ return [Cmd.observe()]
106
+ bx, by = base["cell_x"], base["cell_y"]
107
+ cmds = []
108
+ if not state["harv_kicked"]:
109
+ harv = next((u for u in units if u.get("type") == "harv"), None)
110
+ if harv:
111
+ cmds.append(Cmd.harvest([harv["id"]], 2, by))
112
+ state["harv_kicked"] = True
113
+ # Build only 2 pbox total — no further construction, no
114
+ # infantry production, no repair.
115
+ if state["placed_pbox"] < 2:
116
+ if "pbox" not in prod and cash >= 600:
117
+ cmds.append(Cmd.build("pbox"))
118
+ i = state["placed_pbox"]
119
+ cmds.append(Cmd.place_building(
120
+ "pbox", bx + 4 + (i % 4), by + (-3 + (i // 4))
121
+ ))
122
+ state["placed_pbox"] += 1
123
+ if not cmds:
124
+ cmds.append(Cmd.observe())
125
+ return cmds
126
+ return pol
127
+
128
+
129
+ def _intended_sustained_ops_policy():
130
+ """The intended SUSTAINED-OPS capability play:
131
+ turn 1: kick the pre-placed harvester onto the west mine
132
+ continuous: keep ≥4 pbox standing in front of the base
133
+ continuous: train fresh e1 so the e1 garrison stays ≥10
134
+ (cap so we don't drain the queue waiting on cash).
135
+ The continuous infantry production replaces losses from
136
+ sustained hunt attrition; the pbox screen blunts each wave's
137
+ leading edge so the infantry isn't overrun.
138
+ """
139
+ state = {"placed_pbox": 0, "harv_kicked": False}
140
+
141
+ def pol(obs, Cmd):
142
+ ob = obs.get("own_buildings", []) or []
143
+ pbox_have = sum(1 for b in ob if b["type"] == "pbox")
144
+ prod = obs.get("production", []) or []
145
+ cash = int(obs.get("cash", 0) or 0)
146
+ units = obs.get("units_summary", []) or []
147
+ e1_count = sum(1 for u in units if u.get("type") == "e1")
148
+ # Use the LIVE fact position so this policy generalises across
149
+ # the seed-driven NORTH/SOUTH hard-spawn round-robin.
150
+ base = next((b for b in ob if b["type"] == "fact"), None)
151
+ if not base:
152
+ return [Cmd.observe()]
153
+ bx, by = base["cell_x"], base["cell_y"]
154
+ cmds = []
155
+
156
+ # Kick the harvester at the west mine (mines are at x=2 on
157
+ # every level — y matches the base latitude so the loop is
158
+ # short and doesn't cross the lane).
159
+ if not state["harv_kicked"]:
160
+ harv = next((u for u in units if u.get("type") == "harv"), None)
161
+ if harv:
162
+ cmds.append(Cmd.harvest([harv["id"]], 2, by))
163
+ state["harv_kicked"] = True
164
+
165
+ # Maintain ≥4 standing pbox in front of the base. Queue one
166
+ # at a time (cash budget is small early); spread placements.
167
+ if pbox_have < 4 and "pbox" not in prod and cash >= 600:
168
+ cmds.append(Cmd.build("pbox"))
169
+ if pbox_have < 4:
170
+ i = state["placed_pbox"]
171
+ cmds.append(Cmd.place_building(
172
+ "pbox", bx + 4 + (i % 4), by + (-3 + (i // 4))
173
+ ))
174
+ state["placed_pbox"] += 1
175
+
176
+ # Sustained reinforcement — train fresh riflemen continuously.
177
+ # The own_units_gte:3 win gate requires the garrison stays
178
+ # alive; replacing losses is the load-bearing capability.
179
+ if e1_count < 10 and "e1" not in prod and cash >= 100:
180
+ cmds.append(Cmd.build("e1"))
181
+
182
+ if not cmds:
183
+ cmds.append(Cmd.observe())
184
+ return cmds
185
+ return pol
186
+
187
+
188
+ # ── Pack-shape tests (cheap; do not run the engine) ──────────────
189
+
190
+
191
+ def test_pack_compiles_with_three_levels():
192
+ pack = load_pack(PACK)
193
+ assert pack.meta.id == "lh-100-turn-marathon-survival"
194
+ assert pack.meta.capability == "reasoning"
195
+ assert set(pack.levels) == {"easy", "medium", "hard"}
196
+
197
+
198
+ def test_meta_benchmark_anchor_set():
199
+ pack = load_pack(PACK)
200
+ anchors = pack.meta.benchmark_anchor or []
201
+ assert any("lmgame" in a.lower() for a in anchors), anchors
202
+ assert any("sc2le" in a.lower() or "endurance" in a.lower() for a in anchors), anchors
203
+ assert any("sre" in a.lower() or "endurance" in a.lower() for a in anchors), anchors
204
+
205
+
206
+ def test_hard_tier_has_seed_driven_spawn_groups():
207
+ c = compile_level(load_pack(PACK), "hard")
208
+ sp = {a.spawn_point for a in c.scenario.actors if a.owner == "agent"}
209
+ assert len(sp) >= 2, f"hard needs ≥2 spawn groups, got {sp}"
210
+
211
+
212
+ def test_every_level_has_fail_condition():
213
+ pack = load_pack(PACK)
214
+ for lvl in LEVELS:
215
+ c = compile_level(pack, lvl)
216
+ assert c.fail_condition is not None, f"{lvl} missing fail_condition"
217
+
218
+
219
+ def test_tick_budget_aligned_with_max_turns():
220
+ """within_ticks must be reachable inside max_turns, AND the
221
+ after_ticks win gate must sit comfortably below reachable so the
222
+ episode can actually reach it."""
223
+ pack = load_pack(PACK)
224
+ for lvl in LEVELS:
225
+ level_def = pack.levels[lvl]
226
+ max_turns = level_def.max_turns
227
+ reachable = 93 + 90 * (max_turns - 1)
228
+ win = compile_level(pack, lvl).win_condition.model_dump(exclude_none=True)
229
+
230
+ def _collect(node, key, out):
231
+ if isinstance(node, dict):
232
+ if key in node:
233
+ out.append(node[key])
234
+ for v in node.values():
235
+ _collect(v, key, out)
236
+ elif isinstance(node, list):
237
+ for v in node:
238
+ _collect(v, key, out)
239
+ wts: list = []
240
+ ats: list = []
241
+ _collect(win, "within_ticks", wts)
242
+ _collect(win, "after_ticks", ats)
243
+ assert wts, f"{lvl} has no within_ticks leaf"
244
+ assert ats, f"{lvl} has no after_ticks leaf (no survival gate)"
245
+ for wt in wts:
246
+ assert wt <= reachable, (
247
+ f"{lvl} within_ticks={wt} > reachable={reachable} "
248
+ f"(max_turns={max_turns}) — deadline never bites ⇒ draw"
249
+ )
250
+ for at in ats:
251
+ assert at <= reachable, (
252
+ f"{lvl} after_ticks={at} > reachable={reachable} — "
253
+ f"survival gate never opens ⇒ win unreachable"
254
+ )
255
+
256
+
257
+ def test_win_predicate_includes_all_four_survival_clauses():
258
+ """The win must require has_building:fact, own_units_gte:3,
259
+ building_count_gte pbox≥2, and after_ticks — all four together.
260
+ A scenario that's missing any clause would be gameable."""
261
+ for lvl in LEVELS:
262
+ c = compile_level(load_pack(PACK), lvl)
263
+ win = c.win_condition.model_dump(exclude_none=True)
264
+ all_of = win.get("all_of") or []
265
+ keys: set = set()
266
+ for clause in all_of:
267
+ keys |= set(clause.keys())
268
+ assert "has_building" in keys, f"{lvl} win missing has_building: {keys}"
269
+ assert "own_units_gte" in keys, f"{lvl} win missing own_units_gte: {keys}"
270
+ assert "building_count_gte" in keys, (
271
+ f"{lvl} win missing building_count_gte: {keys}"
272
+ )
273
+ assert "after_ticks" in keys, f"{lvl} win missing after_ticks: {keys}"
274
+ assert "within_ticks" in keys, f"{lvl} win missing within_ticks: {keys}"
275
+
276
+
277
+ # ── Engine-bound tests (parameterised over seeds 1..4) ────────────
278
+
279
+
280
+ @pytest.mark.parametrize("seed", SEEDS)
281
+ @pytest.mark.parametrize("level", LEVELS)
282
+ def test_intended_sustained_ops_wins(level, seed):
283
+ """The intended sustained-ops policy (kick harv + maintain pbox
284
+ line + continuous infantry production) must WIN on every (level,
285
+ seed). Load-bearing solvency test."""
286
+ c = compile_level(load_pack(PACK), level)
287
+ res = run_level(c, _intended_sustained_ops_policy(), seed=seed)
288
+ pbox_n = sum(1 for t, _, _ in res.signals.own_buildings if t == "pbox")
289
+ fact = any(t == "fact" for t, _, _ in res.signals.own_buildings)
290
+ assert res.outcome == "win", (
291
+ f"intended sustained-ops must WIN on {level} s={seed}; "
292
+ f"got {res.outcome} turns={res.turns} tick={res.signals.game_tick} "
293
+ f"units_lost={res.signals.units_lost} pbox={pbox_n} fact={fact}"
294
+ )
295
+
296
+
297
+ @pytest.mark.parametrize("seed", SEEDS)
298
+ @pytest.mark.parametrize("level", LEVELS)
299
+ def test_stall_loses(level, seed):
300
+ """Do-nothing must LOSE on every (level, seed). Hunt squads raze
301
+ fact (or the survival gate opens with the win all-of unmet)."""
302
+ c = compile_level(load_pack(PACK), level)
303
+ res = run_level(c, _stall_policy(), seed=seed)
304
+ assert res.outcome == "loss", (
305
+ f"stall must LOSE on {level} s={seed}; got {res.outcome} "
306
+ f"tick={res.signals.game_tick}"
307
+ )
308
+
309
+
310
+ @pytest.mark.parametrize("seed", SEEDS)
311
+ @pytest.mark.parametrize("level", LEVELS)
312
+ def test_pure_defense_no_reinforce_loses(level, seed):
313
+ """Build pbox but NEVER train new infantry — the garrison bleeds
314
+ under sustained pressure and either fact falls or own_units drops
315
+ below 3 (1) at the survival gate ⇒ LOSS."""
316
+ c = compile_level(load_pack(PACK), level)
317
+ res = run_level(c, _pure_defense_no_reinforce_policy(), seed=seed)
318
+ pbox_n = sum(1 for t, _, _ in res.signals.own_buildings if t == "pbox")
319
+ fact = any(t == "fact" for t, _, _ in res.signals.own_buildings)
320
+ assert res.outcome == "loss", (
321
+ f"pure-defense-no-reinforce must LOSE on {level} s={seed}; got "
322
+ f"{res.outcome} tick={res.signals.game_tick} "
323
+ f"lost={res.signals.units_lost} pbox={pbox_n} fact={fact}"
324
+ )
325
+
326
+
327
+ @pytest.mark.parametrize("seed", SEEDS)
328
+ @pytest.mark.parametrize("level", LEVELS)
329
+ def test_pure_build_no_repair_loses(level, seed):
330
+ """Build only 2 pbox up front; no infantry production / no
331
+ repair / no further building. The lone pbox pair gets stripped
332
+ and fact eventually falls under sustained attrition ⇒ LOSS."""
333
+ c = compile_level(load_pack(PACK), level)
334
+ res = run_level(c, _pure_build_no_repair_policy(), seed=seed)
335
+ pbox_n = sum(1 for t, _, _ in res.signals.own_buildings if t == "pbox")
336
+ fact = any(t == "fact" for t, _, _ in res.signals.own_buildings)
337
+ assert res.outcome == "loss", (
338
+ f"pure-build-no-repair must LOSE on {level} s={seed}; got "
339
+ f"{res.outcome} tick={res.signals.game_tick} "
340
+ f"lost={res.signals.units_lost} pbox={pbox_n} fact={fact}"
341
+ )
342
+
343
+
344
+ @pytest.mark.parametrize("seed", SEEDS)
345
+ def test_hard_seeds_produce_distinct_starts(seed):
346
+ """Hard's two spawn_point groups must round-robin per seed."""
347
+ c = compile_level(load_pack(PACK), "hard")
348
+ # Stall — deterministic, fast; we only need to check the seed flows.
349
+ res = run_level(c, _stall_policy(), seed=seed)
350
+ assert res.outcome == "loss" # stall must lose
351
+ sp = {a.spawn_point for a in c.scenario.actors if a.owner == "agent"}
352
+ assert len(sp) >= 2
tests/test_lh_build_army_coordinate_multifront_attack.py ADDED
@@ -0,0 +1,319 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """lh-build-army-coordinate-multifront-attack — full no-cheat
2
+ validation on Rust.
3
+
4
+ Group G long-horizon REASONING pack. Two-phase operational plan:
5
+ ASSEMBLE a real army (≥N medium tanks) THEN commit it to a
6
+ SIMULTANEOUS TWO-FRONT assault on TWO separated enemy `fact` markers
7
+ (NE at (130,15) and SE at (130,45), 120 cells apart). The Wave-2
8
+ `then:` happened-before composite enforces the army-clause-before-
9
+ destruction ordering; the two destruction clauses (NE + SE) enforce
10
+ the SPLIT (one mass cannot raze both corners in sequence within the
11
+ clock budget).
12
+
13
+ Bar (per CLAUDE.md): the intended build-then-split policy WINS on
14
+ every (level, seed); stall / one-front-only / send-2-units-each-front
15
+ all LOSE on every seed.
16
+
17
+ Scenario shape:
18
+ - 160×60 arena, allies vs soviet (static — no bot).
19
+ - easy: 4-tank army threshold, no defenders pressure.
20
+ - medium: 6-tank army threshold, light pickets.
21
+ - hard: 8-tank army threshold + 2 spawn_point groups (NORTH/SOUTH
22
+ agent base latitude flips per seed).
23
+ """
24
+
25
+ from __future__ import annotations
26
+
27
+ import pytest
28
+
29
+ pytest.importorskip("openra_train", reason="Rust env wheel not installed")
30
+ pytest.importorskip("openra_rl_training", reason="Rust env wheel not installed")
31
+
32
+ from openra_bench.eval_core import run_level
33
+ from openra_bench.scenarios import load_pack
34
+ from openra_bench.scenarios.loader import PACKS_DIR, compile_level
35
+
36
+ PACK = PACKS_DIR / "lh-build-army-coordinate-multifront-attack.yaml"
37
+ LEVELS = ("easy", "medium", "hard")
38
+ SEEDS = (1, 2, 3, 4)
39
+
40
+ # Per-level army size (the n in unit_type_count_gte: {type:2tnk, n:N}).
41
+ _ARMY_N = {"easy": 4, "medium": 6, "hard": 8}
42
+
43
+ # The two fixed enemy fact corners (NE + SE).
44
+ NE = (130, 15)
45
+ SE = (130, 45)
46
+
47
+
48
+ # ── Policies ──────────────────────────────────────────────────────
49
+
50
+
51
+ def _stall_policy():
52
+ """Do nothing — must LOSE on the clock on every level/seed."""
53
+ def pol(obs, Cmd):
54
+ return [Cmd.observe()]
55
+ return pol
56
+
57
+
58
+ def _attack_one_front_only_policy():
59
+ """Build the army to the threshold, then attack-move EVERY tank
60
+ onto the NE fact. The NE destruction clause latches, the army
61
+ clause latches, but the SE destruction clause NEVER latches (the
62
+ SE fact stands untouched). Must LOSE on every (level, seed)."""
63
+ def pol(obs, Cmd):
64
+ units = obs.get("units_summary", []) or []
65
+ own_b = {b["type"] for b in (obs.get("own_buildings", []) or [])}
66
+ prod = obs.get("production", []) or []
67
+ tnk = [u for u in units if u.get("type") == "2tnk"]
68
+ cmds = []
69
+ # Keep building tanks (cap at 12 to avoid runaway queue spam).
70
+ if "weap" in own_b and len(tnk) < 12 and "2tnk" not in prod:
71
+ cmds.append(Cmd.build("2tnk"))
72
+ if tnk:
73
+ tnk_ids = [u["id"] for u in tnk]
74
+ # ALL tanks at the NE fact — SE is ignored on purpose.
75
+ cmds.append(Cmd.attack_move(tnk_ids, NE[0], NE[1]))
76
+ if not cmds:
77
+ cmds.append(Cmd.observe())
78
+ return cmds
79
+ return pol
80
+
81
+
82
+ def _send_too_few_each_front_policy():
83
+ """Skip the build entirely; just split the two starter 2tnks —
84
+ one to NE, one to SE — and hope (in vain) that 1 tank per prong
85
+ can raze a defended fact. Cannot satisfy the army clause (N≥4
86
+ minimum), and 1 tank cannot raze a fact with defenders inside
87
+ the clock anyway. Must LOSE on every (level, seed)."""
88
+ sent = {"ne": False, "se": False}
89
+
90
+ def pol(obs, Cmd):
91
+ units = obs.get("units_summary", []) or []
92
+ tnk = [u for u in units if u.get("type") == "2tnk"]
93
+ cmds = []
94
+ # Sort tanks by id so the split is deterministic.
95
+ tnk.sort(key=lambda u: u["id"])
96
+ if tnk:
97
+ ne_ids = [tnk[0]["id"]]
98
+ se_ids = [tnk[1]["id"]] if len(tnk) > 1 else []
99
+ if ne_ids:
100
+ cmds.append(Cmd.attack_move(ne_ids, NE[0], NE[1]))
101
+ sent["ne"] = True
102
+ if se_ids:
103
+ cmds.append(Cmd.attack_move(se_ids, SE[0], SE[1]))
104
+ sent["se"] = True
105
+ if not cmds:
106
+ cmds.append(Cmd.observe())
107
+ return cmds
108
+ return pol
109
+
110
+
111
+ def _intended_mass_and_split_policy(army_n: int):
112
+ """The intended capability play:
113
+ PHASE 1: build the army to ≥army_n medium tanks (concurrent
114
+ with idle observation so production can drip).
115
+ PHASE 2: SPLIT the army half-to-NE, half-to-SE attack-move.
116
+ Uses a sticky latch on the army milestone so a tank dying after
117
+ we've reached threshold doesn't bounce the policy back to phase 1.
118
+
119
+ Tanks come out of the pre-placed weap (war factory). Starter 2
120
+ tanks count toward the threshold.
121
+ """
122
+ milestone = {"army": False}
123
+
124
+ def pol(obs, Cmd):
125
+ units = obs.get("units_summary", []) or []
126
+ own_b = {b["type"] for b in (obs.get("own_buildings", []) or [])}
127
+ prod = obs.get("production", []) or []
128
+ tnk = [u for u in units if u.get("type") == "2tnk"]
129
+ if len(tnk) >= army_n:
130
+ milestone["army"] = True
131
+ cmds = []
132
+ # PHASE 1 (always running until army milestone): keep tank
133
+ # production in flight whenever weap exists and the queue
134
+ # isn't already producing one.
135
+ if "weap" in own_b and "2tnk" not in prod:
136
+ cmds.append(Cmd.build("2tnk"))
137
+ # PHASE 2: once army milestone latched, split-attack.
138
+ # The split is deterministic by tank id (lower half → NE,
139
+ # upper half → SE) so the test is reproducible.
140
+ if milestone["army"] and tnk:
141
+ tnk_sorted = sorted(tnk, key=lambda u: u["id"])
142
+ half = max(1, len(tnk_sorted) // 2)
143
+ ne_ids = [u["id"] for u in tnk_sorted[:half]]
144
+ se_ids = [u["id"] for u in tnk_sorted[half:]]
145
+ if ne_ids:
146
+ cmds.append(Cmd.attack_move(ne_ids, NE[0], NE[1]))
147
+ if se_ids:
148
+ cmds.append(Cmd.attack_move(se_ids, SE[0], SE[1]))
149
+ if not cmds:
150
+ cmds.append(Cmd.observe())
151
+ return cmds
152
+ return pol
153
+
154
+
155
+ # ── Pack-shape tests (cheap; do not run the engine) ──────────────
156
+
157
+
158
+ def test_pack_compiles_with_three_levels():
159
+ pack = load_pack(PACK)
160
+ assert pack.meta.id == "lh-build-army-coordinate-multifront-attack"
161
+ assert pack.meta.capability == "reasoning"
162
+ assert set(pack.levels) == {"easy", "medium", "hard"}
163
+
164
+
165
+ def test_meta_benchmark_anchor_set():
166
+ """Required by the spec: SC2 macro-then-multi-prong / military
167
+ operational planning / PERT / industrial product launch."""
168
+ pack = load_pack(PACK)
169
+ anchors = pack.meta.benchmark_anchor or []
170
+ j = " | ".join(anchors).lower()
171
+ assert "sc2" in j, anchors
172
+ assert "operational" in j or "military" in j, anchors
173
+ assert "pert" in j, anchors
174
+ assert "industrial" in j or "rollout" in j, anchors
175
+
176
+
177
+ def test_hard_tier_has_seed_driven_spawn_groups():
178
+ """Hard must define ≥2 agent spawn_point groups so seed varies
179
+ the start base latitude (tests/test_hard_tier.py::UPGRADED
180
+ contract)."""
181
+ c = compile_level(load_pack(PACK), "hard")
182
+ sp = {a.spawn_point for a in c.scenario.actors if a.owner == "agent"}
183
+ assert len(sp) >= 2, f"hard needs ≥2 spawn groups, got {sp}"
184
+
185
+
186
+ def test_every_level_has_fail_condition():
187
+ """No silent draws — every level must be able to emit a LOSS."""
188
+ pack = load_pack(PACK)
189
+ for lvl in LEVELS:
190
+ c = compile_level(pack, lvl)
191
+ assert c.fail_condition is not None, f"{lvl} missing fail_condition"
192
+
193
+
194
+ def test_then_composite_used_in_win():
195
+ """The win is `all_of[ then{army, NE-raze, SE-raze}, within_ticks ]`.
196
+ Confirms the load-bearing teeth (mass-then-split) is wired."""
197
+ for lvl in LEVELS:
198
+ c = compile_level(load_pack(PACK), lvl)
199
+ win = c.win_condition.model_dump(exclude_none=True)
200
+ inner = win.get("all_of") or []
201
+ then_node = None
202
+ for cl in inner:
203
+ if "then" in cl:
204
+ then_node = cl["then"]
205
+ break
206
+ assert then_node is not None, f"{lvl} win missing then-chain: {win}"
207
+ clauses = (then_node or {}).get("clauses") or []
208
+ # army → NE-raze → SE-raze ⇒ exactly 3 clauses.
209
+ assert len(clauses) == 3, (
210
+ f"{lvl} then-chain must have 3 clauses (army, NE, SE); "
211
+ f"got {clauses}"
212
+ )
213
+
214
+
215
+ def test_tick_budget_aligned_with_max_turns():
216
+ """within_ticks must be reachable inside max_turns. Engine
217
+ advances ~90 ticks/turn → reachable max = 93 + 90·(N-1)."""
218
+ pack = load_pack(PACK)
219
+ for lvl in LEVELS:
220
+ level_def = pack.levels[lvl]
221
+ max_turns = level_def.max_turns
222
+ reachable = 93 + 90 * (max_turns - 1)
223
+ win = compile_level(pack, lvl).win_condition.model_dump(exclude_none=True)
224
+
225
+ def _collect(node, key, out):
226
+ if isinstance(node, dict):
227
+ if key in node:
228
+ out.append(node[key])
229
+ for v in node.values():
230
+ _collect(v, key, out)
231
+ elif isinstance(node, list):
232
+ for v in node:
233
+ _collect(v, key, out)
234
+ wts = []
235
+ _collect(win, "within_ticks", wts)
236
+ assert wts, f"{lvl} has no within_ticks leaf (no clock teeth)"
237
+ for wt in wts:
238
+ assert wt <= reachable, (
239
+ f"{lvl} within_ticks={wt} > reachable={reachable} "
240
+ f"(max_turns={max_turns}) — deadline never bites ⇒ draw"
241
+ )
242
+
243
+
244
+ # ── Engine-bound tests (parameterised over seeds 1..4) ────────────
245
+
246
+
247
+ @pytest.mark.parametrize("seed", SEEDS)
248
+ @pytest.mark.parametrize("level", LEVELS)
249
+ def test_intended_mass_and_split_wins(level, seed):
250
+ """The intended capability play — build the army to ≥N then SPLIT
251
+ half-to-NE half-to-SE attack-move — must WIN on every (level,
252
+ seed). Load-bearing: the pack is solvable inside the budget by
253
+ the advertised capability."""
254
+ c = compile_level(load_pack(PACK), level)
255
+ res = run_level(c, _intended_mass_and_split_policy(_ARMY_N[level]), seed=seed)
256
+ tp = getattr(res.signals, "then_progress", {}) or {}
257
+ assert res.outcome == "win", (
258
+ f"intended mass-and-split must WIN on {level} s={seed}; "
259
+ f"got {res.outcome} (then_progress={tp}, "
260
+ f"kills={res.signals.units_killed}, "
261
+ f"own_buildings={res.signals.own_building_types})"
262
+ )
263
+
264
+
265
+ @pytest.mark.parametrize("seed", SEEDS)
266
+ @pytest.mark.parametrize("level", LEVELS)
267
+ def test_stall_loses(level, seed):
268
+ """A do-nothing policy must LOSE on every (level, seed). The
269
+ fail_condition's after_ticks bites at the turn budget; never a
270
+ draw."""
271
+ c = compile_level(load_pack(PACK), level)
272
+ res = run_level(c, _stall_policy(), seed=seed)
273
+ assert res.outcome == "loss", (
274
+ f"stall must LOSE on {level} s={seed}; got {res.outcome}"
275
+ )
276
+
277
+
278
+ @pytest.mark.parametrize("seed", SEEDS)
279
+ @pytest.mark.parametrize("level", LEVELS)
280
+ def test_attack_one_front_only_loses(level, seed):
281
+ """A "build army, send EVERY tank to NE only, ignore SE" policy
282
+ must LOSE on every (level, seed). The SE destruction clause
283
+ never latches, so the then-chain never completes."""
284
+ c = compile_level(load_pack(PACK), level)
285
+ res = run_level(c, _attack_one_front_only_policy(), seed=seed)
286
+ tp = getattr(res.signals, "then_progress", {}) or {}
287
+ assert res.outcome == "loss", (
288
+ f"one-front-only must LOSE on {level} s={seed}; "
289
+ f"got {res.outcome} then_progress={tp}"
290
+ )
291
+
292
+
293
+ @pytest.mark.parametrize("seed", SEEDS)
294
+ @pytest.mark.parametrize("level", LEVELS)
295
+ def test_send_too_few_each_front_loses(level, seed):
296
+ """A "skip the build, send 1 starter tank to NE + 1 to SE" policy
297
+ must LOSE on every (level, seed). The army clause (army_n ≥ 4)
298
+ can never latch with only 2 starter tanks, AND 1 tank per prong
299
+ bounces off the defenders without razing the fact."""
300
+ c = compile_level(load_pack(PACK), level)
301
+ res = run_level(c, _send_too_few_each_front_policy(), seed=seed)
302
+ tp = getattr(res.signals, "then_progress", {}) or {}
303
+ assert res.outcome == "loss", (
304
+ f"send-too-few must LOSE on {level} s={seed}; "
305
+ f"got {res.outcome} then_progress={tp}"
306
+ )
307
+
308
+
309
+ @pytest.mark.parametrize("seed", SEEDS)
310
+ def test_hard_seeds_produce_distinct_starts(seed):
311
+ """Hard's two spawn_point groups must round-robin — different
312
+ seeds must place the agent base at a different latitude (y=18
313
+ NORTH vs y=42 SOUTH). Smoke-tests the spawn-variation contract
314
+ that tests/test_hard_tier.py also enforces."""
315
+ c = compile_level(load_pack(PACK), "hard")
316
+ res = run_level(c, _stall_policy(), seed=seed)
317
+ assert res.outcome == "loss" # stall must lose on hard
318
+ sp = {a.spawn_point for a in c.scenario.actors if a.owner == "agent"}
319
+ assert len(sp) >= 2
tests/test_lh_defense_tech_second_base.py ADDED
@@ -0,0 +1,552 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """lh-defense-tech-second-base — full no-cheat validation on Rust.
2
+
3
+ Wave-5 Group G long-horizon "secure-expand-with-tech" pack. Uses the
4
+ Wave-2 `then:` happened-before composite to enforce strict 3- (or 4-)
5
+ phase ordering:
6
+ PHASE 1: building_count_gte:{type:pbox, n:2|3|3} # defend
7
+ PHASE 2: has_building: weap # tech
8
+ PHASE 3: building_in_region:{x:130,y:30,r:8,
9
+ type:fact,count:1} # 2nd base
10
+ PHASE 4 (hard only):
11
+ building_count_gte:{type:proc, n:2} # second refinery
12
+
13
+ Bar (per CLAUDE.md): the intended secure-expand-with-tech policy must
14
+ WIN on every (level, seed); stall / pure-expand-skip-defence /
15
+ pure-defence-no-expand must LOSE on every (level, seed). No draws.
16
+
17
+ Scenario shape:
18
+ - 160x60 generator-spec arena, allied agent, soviet `patrol`-bot.
19
+ - Pre-placed base: fact + tent + 2× powr + proc + harv + mine
20
+ + 3 rifleman defenders (so income flows from turn 1 and own_units_
21
+ gte:1 is satisfied immediately).
22
+ - Spare MCV staged ~30 cells east of base #1 — agent drives it
23
+ further east and deploys at (131,31) → fact lands at (130,30),
24
+ the centre of the east target region.
25
+ - Inert enemy `fact` marker at (154,4) prevents auto-DRAW.
26
+ - hard: ≥2 spawn_point groups (NORTH y=22 / SOUTH y=38) per the
27
+ hard-tier contract.
28
+ """
29
+
30
+ from __future__ import annotations
31
+
32
+ import types
33
+ from pathlib import Path
34
+
35
+ import pytest
36
+
37
+ pytest.importorskip("openra_rl_training", reason="Rust env wheel not installed")
38
+ from openra_bench.scenarios import load_pack
39
+ from openra_bench.scenarios.loader import compile_level
40
+ from openra_bench.scenarios.win_conditions import WinContext, evaluate
41
+
42
+ PACKS = Path(__file__).parent.parent / "openra_bench" / "scenarios" / "packs"
43
+ PACK_PATH = PACKS / "lh-defense-tech-second-base.yaml"
44
+ LEVELS = ("easy", "medium", "hard")
45
+ SEEDS = (1, 2, 3, 4)
46
+
47
+
48
+ # ── Pack-shape tests (cheap; do not run the engine) ─────────────────
49
+
50
+
51
+ def test_pack_compiles_with_three_levels():
52
+ pack = load_pack(PACK_PATH)
53
+ assert pack.meta.id == "lh-defense-tech-second-base"
54
+ assert pack.meta.capability == "reasoning"
55
+ assert set(pack.levels) == {"easy", "medium", "hard"}
56
+
57
+
58
+ def test_meta_benchmark_anchor_set():
59
+ pack = load_pack(PACK_PATH)
60
+ anchors = pack.meta.benchmark_anchor or []
61
+ joined = " ".join(anchors).lower()
62
+ assert "sc2" in joined and "secure-expand" in joined, anchors
63
+ assert "planbench" in joined, anchors
64
+ assert "roadmap" in joined or "harden" in joined, anchors
65
+ assert "industrial" in joined or "second plant" in joined, anchors
66
+
67
+
68
+ def test_hard_tier_has_seed_driven_spawn_groups():
69
+ c = compile_level(load_pack(PACK_PATH), "hard")
70
+ sp = {a.spawn_point for a in c.scenario.actors if a.owner == "agent"}
71
+ assert len(sp) >= 2, f"hard needs ≥2 spawn groups, got {sp}"
72
+
73
+
74
+ def test_every_level_has_fail_condition():
75
+ pack = load_pack(PACK_PATH)
76
+ for lvl in LEVELS:
77
+ c = compile_level(pack, lvl)
78
+ assert c.fail_condition is not None, f"{lvl} missing fail_condition"
79
+
80
+
81
+ def test_then_composite_used_in_win():
82
+ """Confirms the secure-expand-with-tech chain wired through to the
83
+ compiled win condition (the whole point of the pack)."""
84
+ expected_first = {"easy": 2, "medium": 3, "hard": 3}
85
+ for lvl in LEVELS:
86
+ c = compile_level(load_pack(PACK_PATH), lvl)
87
+ win = c.win_condition.model_dump(exclude_none=True)
88
+ ao = win.get("all_of") or []
89
+ assert any("then" in clause for clause in ao), (
90
+ f"{lvl} win has no then-composite: {win}"
91
+ )
92
+ then_clause = next(cl["then"] for cl in ao if "then" in cl)
93
+ clauses = then_clause["clauses"]
94
+ # PHASE 1: pbox count
95
+ assert "building_count_gte" in clauses[0]
96
+ assert clauses[0]["building_count_gte"]["type"] == "pbox"
97
+ assert clauses[0]["building_count_gte"]["n"] == expected_first[lvl]
98
+ # PHASE 2: weap
99
+ assert "has_building" in clauses[1] and clauses[1]["has_building"] == "weap"
100
+ # PHASE 3: building_in_region around (130,30) for fact
101
+ assert "building_in_region" in clauses[2]
102
+ br = clauses[2]["building_in_region"]
103
+ assert br["x"] == 130 and br["y"] == 30 and br["type"] == "fact"
104
+ # Hard has a 4th phase: ≥2 proc
105
+ if lvl == "hard":
106
+ assert len(clauses) == 4
107
+ assert "building_count_gte" in clauses[3]
108
+ assert clauses[3]["building_count_gte"]["type"] == "proc"
109
+ assert clauses[3]["building_count_gte"]["n"] == 2
110
+ else:
111
+ assert len(clauses) == 3
112
+
113
+
114
+ def test_tick_budget_aligned_with_max_turns():
115
+ """within_ticks must be reachable inside max_turns (~90 ticks/turn)."""
116
+ pack = load_pack(PACK_PATH)
117
+ for lvl in LEVELS:
118
+ level_def = pack.levels[lvl]
119
+ max_turns = level_def.max_turns
120
+ reachable = 93 + 90 * (max_turns - 1)
121
+ win = compile_level(pack, lvl).win_condition.model_dump(exclude_none=True)
122
+
123
+ def _collect(node, key, out):
124
+ if isinstance(node, dict):
125
+ if key in node:
126
+ out.append(node[key])
127
+ for v in node.values():
128
+ _collect(v, key, out)
129
+ elif isinstance(node, list):
130
+ for v in node:
131
+ _collect(v, key, out)
132
+ wts: list = []
133
+ _collect(win, "within_ticks", wts)
134
+ assert wts, f"{lvl} has no within_ticks leaf (no clock teeth)"
135
+ for wt in wts:
136
+ assert wt <= reachable, (
137
+ f"{lvl} within_ticks={wt} > reachable={reachable} "
138
+ f"(max_turns={max_turns}) — deadline never bites ⇒ draw"
139
+ )
140
+
141
+
142
+ def _ctx(own_buildings=(), tick=1000):
143
+ """Synthesize a WinContext from a building list."""
144
+ sig = types.SimpleNamespace(
145
+ game_tick=tick,
146
+ units_killed=0,
147
+ units_lost=0,
148
+ own_buildings=list(own_buildings),
149
+ own_building_types={t for (t, _, _) in own_buildings},
150
+ enemies_seen_ids=set(),
151
+ enemy_buildings_seen_ids=set(),
152
+ cash=0,
153
+ then_progress={},
154
+ )
155
+ return WinContext(signals=sig, render_state={"units_summary": []})
156
+
157
+
158
+ def test_predicates_easy():
159
+ c = compile_level(load_pack(PACK_PATH), "easy")
160
+ # WIN: full chain — pbox×2 → weap → 2nd fact in east region
161
+ own = [
162
+ ("fact", 10, 30),
163
+ ("pbox", 12, 30), ("pbox", 12, 31),
164
+ ("weap", 14, 30),
165
+ ("fact", 130, 30),
166
+ ]
167
+ assert evaluate(c.win_condition, _ctx(own, tick=3000))
168
+ # FAIL: skip pbox — chain never advances past phase 1.
169
+ own_no_def = [("fact", 10, 30), ("weap", 14, 30), ("fact", 130, 30)]
170
+ assert not evaluate(c.win_condition, _ctx(own_no_def, tick=3000))
171
+ # FAIL: skip expand — chain stuck at phase 3.
172
+ own_no_exp = [
173
+ ("fact", 10, 30),
174
+ ("pbox", 12, 30), ("pbox", 12, 31),
175
+ ("weap", 14, 30),
176
+ ]
177
+ assert not evaluate(c.win_condition, _ctx(own_no_exp, tick=3000))
178
+ # FAIL: no fact at all (base #1 lost).
179
+ assert evaluate(c.fail_condition, _ctx([], tick=3000))
180
+ # FAIL: past deadline.
181
+ assert evaluate(c.fail_condition, _ctx(own, tick=8100))
182
+
183
+
184
+ def test_predicates_medium():
185
+ c = compile_level(load_pack(PACK_PATH), "medium")
186
+ # WIN: pbox×3 → weap → 2nd fact in east region
187
+ own = [
188
+ ("fact", 10, 30),
189
+ ("pbox", 12, 30), ("pbox", 12, 31), ("pbox", 12, 32),
190
+ ("weap", 14, 30),
191
+ ("fact", 130, 30),
192
+ ]
193
+ assert evaluate(c.win_condition, _ctx(own, tick=3000))
194
+ # FAIL: only 2 pbox — bar is 3.
195
+ own_short = [
196
+ ("fact", 10, 30),
197
+ ("pbox", 12, 30), ("pbox", 12, 31),
198
+ ("weap", 14, 30),
199
+ ("fact", 130, 30),
200
+ ]
201
+ assert not evaluate(c.win_condition, _ctx(own_short, tick=3000))
202
+ # FAIL: 2nd fact outside east region.
203
+ own_off = [
204
+ ("fact", 10, 30),
205
+ ("pbox", 12, 30), ("pbox", 12, 31), ("pbox", 12, 32),
206
+ ("weap", 14, 30),
207
+ ("fact", 100, 30), # > radius 8 from (130,30)
208
+ ]
209
+ assert not evaluate(c.win_condition, _ctx(own_off, tick=3000))
210
+ assert evaluate(c.fail_condition, _ctx(own, tick=7300))
211
+
212
+
213
+ def test_predicates_hard_requires_second_proc():
214
+ c = compile_level(load_pack(PACK_PATH), "hard")
215
+ base = [
216
+ ("fact", 10, 22),
217
+ ("pbox", 12, 22), ("pbox", 12, 23), ("pbox", 12, 24),
218
+ ("weap", 14, 22),
219
+ ("fact", 130, 30),
220
+ ("proc", 6, 18),
221
+ ]
222
+ # FAIL: phase 4 missing — only 1 proc.
223
+ assert not evaluate(c.win_condition, _ctx(base, tick=3000))
224
+ # WIN: phase 4 satisfied — 2 procs (the original + a second).
225
+ base2 = base + [("proc", 130, 32)]
226
+ assert evaluate(c.win_condition, _ctx(base2, tick=3000))
227
+
228
+
229
+ # ── engine-driven scripted policies ─────────────────────────────────
230
+
231
+
232
+ def _find_mcv(rs):
233
+ for u in rs.get("units_summary", []) or []:
234
+ if str(u.get("type", "")).lower() == "mcv":
235
+ return u
236
+ return None
237
+
238
+
239
+ def _find_harv(rs):
240
+ for u in rs.get("units_summary", []) or []:
241
+ if str(u.get("type", "")).lower() == "harv":
242
+ return u
243
+ return None
244
+
245
+
246
+ def _base_xy(rs):
247
+ """Return the base #1 (west-side) fact (cell_x, cell_y), preferring
248
+ the smaller cell_x (the home base)."""
249
+ facts = [
250
+ b for b in (rs.get("own_buildings", []) or [])
251
+ if str(b.get("type", "")).lower() == "fact"
252
+ ]
253
+ if not facts:
254
+ return None
255
+ return min(facts, key=lambda b: b["cell_x"])
256
+
257
+
258
+ def _stall_policy(rs, Command):
259
+ """Idles every turn — must LOSE (clock + patrol attrition)."""
260
+ return [Command.observe()]
261
+
262
+
263
+ def _pure_expand_skip_defence_policy():
264
+ """Race the MCV east + deploy — never build pbox / weap. The
265
+ chain cannot advance past phase 1 (pbox); the patrol also chews
266
+ the base over time. Must LOSE."""
267
+
268
+ def pol(rs, Command):
269
+ mcv = _find_mcv(rs)
270
+ if mcv is None:
271
+ return [Command.observe()]
272
+ tx, ty = 131, 31
273
+ dx = mcv["cell_x"] - tx
274
+ dy = mcv["cell_y"] - ty
275
+ if dx * dx + dy * dy <= 36:
276
+ return [Command.deploy([str(mcv["id"])])]
277
+ return [Command.move_units([str(mcv["id"])], target_x=tx, target_y=ty)]
278
+
279
+ return pol
280
+
281
+
282
+ def _pure_defence_no_expand_policy(pbox_target: int):
283
+ """Build pbox + weap forever — never move the MCV. The chain
284
+ stalls at phase 3 (no 2nd fact in east region); clock expires
285
+ ⇒ LOSS."""
286
+ state = {"placed_pbox": 0, "weap_attempts": 0, "harv_kicked": False}
287
+
288
+ def pol(rs, Command):
289
+ ob = rs.get("own_buildings", []) or []
290
+ own_b = {b["type"] for b in ob}
291
+ prod = rs.get("production", []) or []
292
+ cash = int(rs.get("cash", 0) or 0)
293
+ base = _base_xy(rs)
294
+ bx = base["cell_x"] if base else 10
295
+ by = base["cell_y"] if base else 30
296
+ cmds = []
297
+ # Kick the harv at the local mine (west of base).
298
+ if not state["harv_kicked"]:
299
+ harv = _find_harv(rs)
300
+ if harv:
301
+ cmds.append(Command.harvest([str(harv["id"])], bx - 8, by))
302
+ state["harv_kicked"] = True
303
+ pbox_have = sum(1 for b in ob if b["type"] == "pbox")
304
+ if pbox_have < pbox_target and "pbox" not in prod and cash >= 600:
305
+ cmds.append(Command.build("pbox"))
306
+ if pbox_have < pbox_target:
307
+ i = state["placed_pbox"]
308
+ row = -3 + 2 * (i % 4)
309
+ col = 3 + (i // 4)
310
+ cmds.append(Command.place_building(
311
+ "pbox", bx + col, by + row
312
+ ))
313
+ state["placed_pbox"] += 1
314
+ if (
315
+ pbox_have >= pbox_target
316
+ and "weap" not in own_b
317
+ and "weap" not in prod
318
+ and cash >= 2000
319
+ ):
320
+ cmds.append(Command.build("weap"))
321
+ if pbox_have >= pbox_target and "weap" not in own_b:
322
+ i = state["weap_attempts"]
323
+ cmds.append(Command.place_building(
324
+ "weap", bx + 8 + (i % 6), by - 4 + (i // 6)
325
+ ))
326
+ state["weap_attempts"] += 1
327
+ if not cmds:
328
+ cmds.append(Command.observe())
329
+ return cmds
330
+
331
+ return pol
332
+
333
+
334
+ def _intended_secure_expand_with_tech_policy(pbox_target: int, hard: bool = False):
335
+ """The intended REASONING capability play:
336
+ turn 1: kick the harvester onto the local mine
337
+ phase 1: queue pbox one at a time until ≥pbox_target standing
338
+ AT THE SAME TIME, walk the MCV partway east while
339
+ income builds
340
+ phase 2: once phase 1 done, build weap
341
+ phase 3: once phase 2 done, move MCV the rest of the way and
342
+ deploy at (131,31) → fact lands at (130,30) inside
343
+ the east target region
344
+ phase 4 (hard only): build a SECOND proc at the new east base
345
+ (place it near the new east fact)
346
+ """
347
+ state = {
348
+ "placed_pbox": 0,
349
+ "weap_attempts": 0,
350
+ "proc2_attempts": 0,
351
+ "harv_kicked": False,
352
+ "mcv_deployed": False,
353
+ }
354
+
355
+ def pol(rs, Command):
356
+ ob = rs.get("own_buildings", []) or []
357
+ own_b = {b["type"] for b in ob}
358
+ prod = rs.get("production", []) or []
359
+ cash = int(rs.get("cash", 0) or 0)
360
+ base = _base_xy(rs)
361
+ bx = base["cell_x"] if base else 10
362
+ by = base["cell_y"] if base else 30
363
+ mcv = _find_mcv(rs)
364
+ cmds = []
365
+
366
+ # Kick the harv at the local mine (west of base).
367
+ if not state["harv_kicked"]:
368
+ harv = _find_harv(rs)
369
+ if harv:
370
+ cmds.append(Command.harvest([str(harv["id"])], bx - 8, by))
371
+ state["harv_kicked"] = True
372
+
373
+ # ── Phase 1: pbox first ──
374
+ pbox_have = sum(1 for b in ob if b["type"] == "pbox")
375
+ in_q_pbox = "pbox" in prod
376
+ if pbox_have < pbox_target and not in_q_pbox and cash >= 600:
377
+ cmds.append(Command.build("pbox"))
378
+ if pbox_have < pbox_target:
379
+ i = state["placed_pbox"]
380
+ row = -3 + 2 * (i % 4)
381
+ col = 3 + (i // 4)
382
+ cmds.append(Command.place_building(
383
+ "pbox", bx + col, by + row
384
+ ))
385
+ state["placed_pbox"] += 1
386
+
387
+ # ── Phase 2: weap (once phase-1 is satisfied) ──
388
+ if (
389
+ pbox_have >= pbox_target
390
+ and "weap" not in own_b
391
+ and "weap" not in prod
392
+ and cash >= 2000
393
+ ):
394
+ cmds.append(Command.build("weap"))
395
+ if pbox_have >= pbox_target and "weap" not in own_b:
396
+ i = state["weap_attempts"]
397
+ cmds.append(Command.place_building(
398
+ "weap", bx + 8 + (i % 6), by - 4 + (i // 6)
399
+ ))
400
+ state["weap_attempts"] += 1
401
+
402
+ # ── Phase 3: drive the MCV east + deploy at (131,31). Start
403
+ # moving as soon as Phase 1 is observed-locked (the then-
404
+ # composite is greedy so we can already be in motion). The
405
+ # MCV deploy itself is gated until weap is up — otherwise a
406
+ # too-early deploy would attempt to latch clause 3 before
407
+ # clause 2 and the chain would not advance.
408
+ if mcv is not None and not state["mcv_deployed"]:
409
+ tx, ty = 131, 31
410
+ dx = mcv["cell_x"] - tx
411
+ dy = mcv["cell_y"] - ty
412
+ dist2 = dx * dx + dy * dy
413
+ in_range = dist2 <= 36
414
+ # Move toward the target as soon as the chain is being
415
+ # worked; only deploy once weap is up (phase 2 latched).
416
+ if in_range and "weap" in own_b:
417
+ cmds.append(Command.deploy([str(mcv["id"])]))
418
+ state["mcv_deployed"] = True
419
+ elif not in_range:
420
+ cmds.append(Command.move_units(
421
+ [str(mcv["id"])], target_x=tx, target_y=ty
422
+ ))
423
+
424
+ # ── Phase 4 (hard only): build a SECOND proc near the new
425
+ # east base. Only attempt once the east fact exists (phase 3
426
+ # latched).
427
+ if hard:
428
+ proc_have = sum(1 for b in ob if b["type"] == "proc")
429
+ east_fact = any(
430
+ b["type"] == "fact" and b["cell_x"] >= 100 for b in ob
431
+ )
432
+ if (
433
+ east_fact
434
+ and proc_have < 2
435
+ and "proc" not in prod
436
+ and cash >= 1400
437
+ ):
438
+ cmds.append(Command.build("proc"))
439
+ if east_fact and proc_have < 2:
440
+ i = state["proc2_attempts"]
441
+ # Place near the new east fact (which lives near
442
+ # (130,30) after deploy).
443
+ cmds.append(Command.place_building(
444
+ "proc", 132 + (i % 4), 32 + (i // 4)
445
+ ))
446
+ state["proc2_attempts"] += 1
447
+
448
+ if not cmds:
449
+ cmds.append(Command.observe())
450
+ return cmds
451
+
452
+ return pol
453
+
454
+
455
+ # ── Engine-bound tests (parameterised over seeds 1..4) ───────────────
456
+
457
+
458
+ def _pbox_target(level: str) -> int:
459
+ return {"easy": 2, "medium": 3, "hard": 3}[level]
460
+
461
+
462
+ @pytest.mark.parametrize("seed", SEEDS)
463
+ @pytest.mark.parametrize("level", LEVELS)
464
+ def test_intended_secure_expand_with_tech_wins(level, seed):
465
+ """The intended secure-expand-with-tech policy must WIN on every
466
+ (level, seed). Load-bearing solvency test."""
467
+ pytest.importorskip("openra_train")
468
+ from openra_bench.eval_core import run_level
469
+
470
+ c = compile_level(load_pack(PACK_PATH), level)
471
+ res = run_level(
472
+ c,
473
+ _intended_secure_expand_with_tech_policy(
474
+ _pbox_target(level), hard=(level == "hard")
475
+ ),
476
+ seed=seed,
477
+ )
478
+ own_b = res.signals.own_building_types
479
+ assert res.outcome == "win", (
480
+ f"intended secure-expand-with-tech must WIN on {level} s={seed}; "
481
+ f"got {res.outcome} turns={res.turns} "
482
+ f"own_buildings={own_b} cash={res.signals.cash} "
483
+ f"units_lost={res.signals.units_lost} "
484
+ f"then_progress={getattr(res.signals, 'then_progress', {})}"
485
+ )
486
+
487
+
488
+ @pytest.mark.parametrize("seed", SEEDS)
489
+ @pytest.mark.parametrize("level", LEVELS)
490
+ def test_stall_loses(level, seed):
491
+ """Do-nothing must LOSE on every (level, seed)."""
492
+ pytest.importorskip("openra_train")
493
+ from openra_bench.eval_core import run_level
494
+
495
+ c = compile_level(load_pack(PACK_PATH), level)
496
+ res = run_level(c, _stall_policy, seed=seed)
497
+ assert res.outcome == "loss", (
498
+ f"stall must LOSE on {level} s={seed}; got {res.outcome}"
499
+ )
500
+
501
+
502
+ @pytest.mark.parametrize("seed", SEEDS)
503
+ @pytest.mark.parametrize("level", LEVELS)
504
+ def test_pure_expand_skip_defence_loses(level, seed):
505
+ """Race-MCV-east-and-deploy without ever building pbox/weap must
506
+ LOSE: the then-chain cannot advance past phase 1 (pbox), and the
507
+ patrol bot is dripping pressure on the undefended base."""
508
+ pytest.importorskip("openra_train")
509
+ from openra_bench.eval_core import run_level
510
+
511
+ c = compile_level(load_pack(PACK_PATH), level)
512
+ res = run_level(c, _pure_expand_skip_defence_policy(), seed=seed)
513
+ own_b = res.signals.own_building_types
514
+ assert res.outcome == "loss", (
515
+ f"pure-expand-skip-defence must LOSE on {level} s={seed}; got "
516
+ f"{res.outcome} own_buildings={own_b} "
517
+ f"then_progress={getattr(res.signals, 'then_progress', {})}"
518
+ )
519
+
520
+
521
+ @pytest.mark.parametrize("seed", SEEDS)
522
+ @pytest.mark.parametrize("level", LEVELS)
523
+ def test_pure_defence_no_expand_loses(level, seed):
524
+ """Build pbox + weap forever — never move the MCV east. The
525
+ then-chain stalls at phase 3 (no 2nd fact in east region); clock
526
+ expires ⇒ LOSS."""
527
+ pytest.importorskip("openra_train")
528
+ from openra_bench.eval_core import run_level
529
+
530
+ c = compile_level(load_pack(PACK_PATH), level)
531
+ res = run_level(
532
+ c, _pure_defence_no_expand_policy(_pbox_target(level)), seed=seed
533
+ )
534
+ own_b = res.signals.own_building_types
535
+ assert res.outcome == "loss", (
536
+ f"pure-defence-no-expand must LOSE on {level} s={seed}; got "
537
+ f"{res.outcome} own_buildings={own_b} "
538
+ f"then_progress={getattr(res.signals, 'then_progress', {})}"
539
+ )
540
+
541
+
542
+ @pytest.mark.parametrize("seed", SEEDS)
543
+ def test_hard_seeds_produce_distinct_starts(seed):
544
+ """Hard's two spawn_point groups must round-robin per seed."""
545
+ pytest.importorskip("openra_train")
546
+ from openra_bench.eval_core import run_level
547
+
548
+ c = compile_level(load_pack(PACK_PATH), "hard")
549
+ res = run_level(c, _stall_policy, seed=seed)
550
+ assert res.outcome == "loss" # stall must lose
551
+ sp = {a.spawn_point for a in c.scenario.actors if a.owner == "agent"}
552
+ assert len(sp) >= 2
tests/test_lh_multi_checkpoint_5_plus.py ADDED
@@ -0,0 +1,413 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """lh-multi-checkpoint-5-plus pack — no-cheat validation on Rust.
2
+
3
+ Group G long-horizon: 5+ ordered checkpoints chained via the Wave-2
4
+ `then:` happened-before composite. The chain MIXES checkpoint kinds:
5
+ buildings, quantitative production, episode-aggregate kills, and
6
+ terminal destruction — so the ordering is genuinely load-bearing.
7
+
8
+ Medium chain (the headline 5-phase cell):
9
+ PHASE 1: has_building: proc
10
+ PHASE 2: has_building: weap
11
+ PHASE 3: unit_type_count_gte: {type: 2tnk, n: 2}
12
+ PHASE 4: units_killed_gte: 3
13
+ PHASE 5: enemy_key_buildings_destroyed: {types: [fact]}
14
+
15
+ Hard adds a 6th regression checkpoint (≥4 tanks after kills) and 2
16
+ spawn_point groups (NORTH/SOUTH base round-robined by seed).
17
+
18
+ Bar (per CLAUDE.md): the intended phased policy WINS on every
19
+ (level, seed); stall / skip-phase-3 / skip-phase-4 / pre-build all
20
+ LOSE on every seed. The `then:` latch is load-bearing — a "rush
21
+ the fact and kill on the way" play scores early kills but the latch
22
+ does not credit them to clause-4 until proc + weap + tanks have
23
+ latched in order.
24
+ """
25
+
26
+ from __future__ import annotations
27
+
28
+ import pytest
29
+
30
+ pytest.importorskip("openra_train", reason="Rust env wheel not installed")
31
+
32
+ pytest.importorskip("openra_rl_training", reason="Rust env wheel not installed")
33
+ from openra_bench.eval_core import run_level
34
+ from openra_bench.scenarios import load_pack
35
+ from openra_bench.scenarios.loader import PACKS_DIR, compile_level
36
+
37
+ PACK = PACKS_DIR / "lh-multi-checkpoint-5-plus.yaml"
38
+ LEVELS = ("easy", "medium", "hard")
39
+ SEEDS = (1, 2, 3, 4)
40
+
41
+ # Per-level chain length (the n in `then.clauses`).
42
+ _CHAIN_LEN = {"easy": 3, "medium": 5, "hard": 6}
43
+
44
+ # Per-level required tank army size for the highest-N tank clause.
45
+ # easy has no tank-count clause; medium has n:2 only; hard has both
46
+ # n:2 (clause 3) and n:4 (clause 5) — the policy must produce ≥4.
47
+ _ARMY_TARGET = {"easy": 0, "medium": 2, "hard": 4}
48
+
49
+
50
+ # ── Policies ──────────────────────────────────────────────────────
51
+
52
+
53
+ def _stall_policy():
54
+ """Do nothing — must LOSE on the clock on every level/seed."""
55
+ def pol(obs, Cmd):
56
+ return [Cmd.observe()]
57
+ return pol
58
+
59
+
60
+ def _enemy_fact_xy(level: str) -> tuple[int, int]:
61
+ """Per-level enemy fact coords (lifted from the YAML so the
62
+ policies attack the right point on each tier)."""
63
+ return (115, 30) if level in ("easy", "medium") else (115, 20)
64
+
65
+
66
+ def _skip_to_attack_policy(level: str):
67
+ """Rush the enemy fact immediately with the 4 starting tanks; no
68
+ proc, no weap, no chain. Must LOSE — the then-latch starts at
69
+ index 0 and `has_building:proc` is never observed-true, so even
70
+ a successful fact-kill does not advance the chain past clause 0."""
71
+ fx, fy = _enemy_fact_xy(level)
72
+
73
+ def pol(obs, Cmd):
74
+ units = obs.get("units_summary", []) or []
75
+ tnk = [u["id"] for u in units if u.get("type") == "2tnk"]
76
+ if tnk:
77
+ return [Cmd.attack_move(tnk, fx, fy)]
78
+ return [Cmd.observe()]
79
+ return pol
80
+
81
+
82
+ def _skip_phase_3_policy(level: str):
83
+ """Build proc + weap (phases 1+2) then skip the army clause —
84
+ immediately attack the fact with starting tanks. On easy the
85
+ chain has no army clause so this should WIN; on medium/hard the
86
+ chain has a tank-count clause that, despite being satisfied at
87
+ t=0 by pre-placed tanks, only latches AFTER weap latches — so
88
+ this policy might still win medium (the latch advances P3→P4→P5
89
+ once weap lands and there are ≥2 tanks alive). To genuinely
90
+ skip phase 3 we must KILL one of our own tanks (or lose them in
91
+ combat) so unit_type_count_gte:{2tnk,n:2} is FALSE for medium,
92
+ or unit_type_count_gte:{2tnk,n:4} is FALSE for hard. The
93
+ easier teeth: this policy attacks the fact with 2 tanks held
94
+ back at base (less than n:4 on hard) — on hard it must LOSE."""
95
+ fx, fy = _enemy_fact_xy(level)
96
+
97
+ # Sticky milestone latches for the build-order phases.
98
+ state = {"built_proc": False, "built_weap": False}
99
+
100
+ def pol(obs, Cmd):
101
+ ob = obs.get("own_buildings", []) or []
102
+ own_b = {b["type"] for b in ob}
103
+ prod = obs.get("production", []) or []
104
+ units = obs.get("units_summary", []) or []
105
+ tnk = [u["id"] for u in units if u.get("type") == "2tnk"]
106
+ cmds = []
107
+ base = [b for b in ob if b["type"] == "fact"]
108
+ if "proc" in own_b:
109
+ state["built_proc"] = True
110
+ if "weap" in own_b:
111
+ state["built_weap"] = True
112
+ # PHASE 1 (proc).
113
+ if not state["built_proc"]:
114
+ if "proc" not in prod:
115
+ cmds.append(Cmd.build("proc"))
116
+ if base:
117
+ cmds.append(Cmd.place_building(
118
+ "proc", base[0]["cell_x"] + 6, base[0]["cell_y"] + 4
119
+ ))
120
+ if not cmds:
121
+ cmds.append(Cmd.observe())
122
+ return cmds
123
+ # PHASE 2 (weap).
124
+ if not state["built_weap"]:
125
+ if "weap" not in prod:
126
+ cmds.append(Cmd.build("weap"))
127
+ if base:
128
+ cmds.append(Cmd.place_building(
129
+ "weap", base[0]["cell_x"] + 8, base[0]["cell_y"]
130
+ ))
131
+ if not cmds:
132
+ cmds.append(Cmd.observe())
133
+ return cmds
134
+ # PHASES 3-5: skip the army-rebuild; attack with whatever
135
+ # tanks are still alive. On hard this is <4 tanks so clause
136
+ # 5 (unit_type_count_gte:{2tnk,n:4}) never latches.
137
+ if tnk:
138
+ cmds.append(Cmd.attack_move(tnk, fx, fy))
139
+ if not cmds:
140
+ cmds.append(Cmd.observe())
141
+ return cmds
142
+ return pol
143
+
144
+
145
+ def _skip_phase_4_policy(level: str):
146
+ """Build proc + weap + (rebuild army to target) but NEVER engage
147
+ — kills stay at 0 so clause-4 (units_killed_gte:3) never latches
148
+ on medium/hard. The chain stalls at index 3 and the clock expires.
149
+ On easy there is no kill clause and no army clause, so this
150
+ policy reduces to "build proc+weap and never attack" — which
151
+ MUST also LOSE because the engage clause (P3 on easy) never
152
+ latches without an attack."""
153
+ target = _ARMY_TARGET[level]
154
+
155
+ def pol(obs, Cmd):
156
+ ob = obs.get("own_buildings", []) or []
157
+ own_b = {b["type"] for b in ob}
158
+ prod = obs.get("production", []) or []
159
+ units = obs.get("units_summary", []) or []
160
+ tnk = [u for u in units if u.get("type") == "2tnk"]
161
+ cmds = []
162
+ base = [b for b in ob if b["type"] == "fact"]
163
+ if "proc" not in own_b and "proc" not in prod:
164
+ cmds.append(Cmd.build("proc"))
165
+ if "proc" not in own_b and base:
166
+ cmds.append(Cmd.place_building(
167
+ "proc", base[0]["cell_x"] + 6, base[0]["cell_y"] + 4
168
+ ))
169
+ if "proc" in own_b and "weap" not in own_b and "weap" not in prod:
170
+ cmds.append(Cmd.build("weap"))
171
+ if "proc" in own_b and "weap" not in own_b and base:
172
+ cmds.append(Cmd.place_building(
173
+ "weap", base[0]["cell_x"] + 8, base[0]["cell_y"]
174
+ ))
175
+ # Rebuild army if needed but DO NOT engage.
176
+ if (
177
+ "weap" in own_b
178
+ and target > 0
179
+ and len(tnk) < target
180
+ and "2tnk" not in prod
181
+ ):
182
+ cmds.append(Cmd.build("2tnk"))
183
+ if not cmds:
184
+ cmds.append(Cmd.observe())
185
+ return cmds
186
+ return pol
187
+
188
+
189
+ def _intended_chain_policy(level: str):
190
+ """The intended 5+ checkpoint chain (sticky milestones so a built-
191
+ then-destroyed proc doesn't reset the policy). Build proc + weap
192
+ on the macro thread WHILE concurrently driving the pre-placed
193
+ tanks east to engage and raze the enemy fact — the `then:` latch
194
+ only advances clause-by-clause once each predicate is observed-
195
+ true in order, so doing the work in parallel doesn't skip ahead.
196
+
197
+ Must WIN on every (level, seed)."""
198
+ fx, fy = _enemy_fact_xy(level)
199
+ issued_attack = {"yes": False}
200
+
201
+ def pol(obs, Cmd):
202
+ ob = obs.get("own_buildings", []) or []
203
+ own_b = {b["type"] for b in ob}
204
+ prod = obs.get("production", []) or []
205
+ units = obs.get("units_summary", []) or []
206
+ tnk = [u for u in units if u.get("type") == "2tnk"]
207
+ base = [b for b in ob if b["type"] == "fact"]
208
+
209
+ cmds = []
210
+ # Macro thread: drive proc → weap to completion, then
211
+ # optionally keep producing tanks (helps hard's n:4 clause
212
+ # if any pre-placed tank dies mid-engagement).
213
+ if "proc" not in own_b:
214
+ if "proc" not in prod:
215
+ cmds.append(Cmd.build("proc"))
216
+ if base:
217
+ cmds.append(Cmd.place_building(
218
+ "proc", base[0]["cell_x"] + 6, base[0]["cell_y"] + 4
219
+ ))
220
+ elif "weap" not in own_b:
221
+ if "weap" not in prod:
222
+ cmds.append(Cmd.build("weap"))
223
+ if base:
224
+ cmds.append(Cmd.place_building(
225
+ "weap", base[0]["cell_x"] + 8, base[0]["cell_y"]
226
+ ))
227
+ else:
228
+ # Both up — keep producing tanks (cheap insurance for the
229
+ # n:4 hard clause after attrition; auto-placed by the
230
+ # engine since `weap` knows its own queue).
231
+ if "2tnk" not in prod and len(tnk) < 8:
232
+ cmds.append(Cmd.build("2tnk"))
233
+
234
+ # Combat thread: ALWAYS attack-move every tank east toward
235
+ # the enemy fact. Re-issuing each turn keeps freshly-built
236
+ # tanks pointed at the objective.
237
+ if tnk:
238
+ cmds.append(Cmd.attack_move([u["id"] for u in tnk], fx, fy))
239
+ issued_attack["yes"] = True
240
+
241
+ if not cmds:
242
+ cmds.append(Cmd.observe())
243
+ return cmds
244
+ return pol
245
+
246
+
247
+ # ── Pack-shape tests (cheap; do not run the engine) ──────────────
248
+
249
+
250
+ def test_pack_compiles_with_three_levels():
251
+ pack = load_pack(PACK)
252
+ assert pack.meta.id == "lh-multi-checkpoint-5-plus"
253
+ assert pack.meta.capability == "action"
254
+ assert set(pack.levels) == {"easy", "medium", "hard"}
255
+
256
+
257
+ def test_meta_benchmark_anchor_set():
258
+ """Required: PlanBench long-sequencing + PERT critical path
259
+ anchors, per the wave-5 spec."""
260
+ pack = load_pack(PACK)
261
+ anchors = pack.meta.benchmark_anchor or []
262
+ assert any("PlanBench" in a for a in anchors), anchors
263
+ assert any("PERT" in a for a in anchors), anchors
264
+ assert any("SOP" in a for a in anchors), anchors
265
+
266
+
267
+ def test_hard_tier_has_seed_driven_spawn_groups():
268
+ """Hard must define ≥2 agent spawn_point groups (UPGRADED
269
+ contract in tests/test_hard_tier.py)."""
270
+ c = compile_level(load_pack(PACK), "hard")
271
+ sp = {a.spawn_point for a in c.scenario.actors if a.owner == "agent"}
272
+ assert len(sp) >= 2, f"hard needs ≥2 spawn groups, got {sp}"
273
+
274
+
275
+ def test_every_level_has_fail_condition():
276
+ """No silent draws — every level must be able to emit a LOSS."""
277
+ pack = load_pack(PACK)
278
+ for lvl in LEVELS:
279
+ c = compile_level(pack, lvl)
280
+ assert c.fail_condition is not None, f"{lvl} missing fail_condition"
281
+
282
+
283
+ def test_then_chain_lengths_per_level():
284
+ """easy=3, medium=5, hard=6 phases (the headline difficulty
285
+ axis is the chain length)."""
286
+ for lvl in LEVELS:
287
+ c = compile_level(load_pack(PACK), lvl)
288
+ win = c.win_condition.model_dump(exclude_none=True)
289
+ inner = win.get("all_of") or []
290
+ assert any("then" in cl for cl in inner), (
291
+ f"{lvl} win missing then-chain: {win}"
292
+ )
293
+ for cl in inner:
294
+ if "then" in cl:
295
+ clauses = (cl["then"] or {}).get("clauses") or []
296
+ assert len(clauses) == _CHAIN_LEN[lvl], (
297
+ f"{lvl} chain must have {_CHAIN_LEN[lvl]} "
298
+ f"clauses; got {len(clauses)}"
299
+ )
300
+
301
+
302
+ def test_tick_budget_aligned_with_max_turns():
303
+ """within_ticks must be reachable inside max_turns. Engine
304
+ advances ~90 ticks/turn → reachable max = 93 + 90·(N-1)."""
305
+ pack = load_pack(PACK)
306
+ for lvl in LEVELS:
307
+ level_def = pack.levels[lvl]
308
+ max_turns = level_def.max_turns
309
+ reachable = 93 + 90 * (max_turns - 1)
310
+ win = compile_level(pack, lvl).win_condition.model_dump(exclude_none=True)
311
+
312
+ def _collect(node, key, out):
313
+ if isinstance(node, dict):
314
+ if key in node:
315
+ out.append(node[key])
316
+ for v in node.values():
317
+ _collect(v, key, out)
318
+ elif isinstance(node, list):
319
+ for v in node:
320
+ _collect(v, key, out)
321
+ wts = []
322
+ _collect(win, "within_ticks", wts)
323
+ assert wts, f"{lvl} has no within_ticks leaf"
324
+ for wt in wts:
325
+ assert wt <= reachable, (
326
+ f"{lvl} within_ticks={wt} > reachable={reachable} "
327
+ f"(max_turns={max_turns}) — deadline never bites"
328
+ )
329
+
330
+
331
+ # ── Engine-bound tests (parameterised over seeds 1..4) ────────────
332
+
333
+
334
+ @pytest.mark.parametrize("seed", SEEDS)
335
+ @pytest.mark.parametrize("level", LEVELS)
336
+ def test_intended_chain_policy_wins(level, seed):
337
+ """The intended N+ checkpoint chain must WIN on every (level, seed)."""
338
+ c = compile_level(load_pack(PACK), level)
339
+ res = run_level(c, _intended_chain_policy(level), seed=seed)
340
+ tp = getattr(res.signals, "then_progress", {}) or {}
341
+ assert res.outcome == "win", (
342
+ f"intended chain must WIN on {level} s={seed}; "
343
+ f"got {res.outcome} (then_progress={tp}, "
344
+ f"kills={res.signals.units_killed}, "
345
+ f"own_buildings={res.signals.own_building_types})"
346
+ )
347
+
348
+
349
+ @pytest.mark.parametrize("seed", SEEDS)
350
+ @pytest.mark.parametrize("level", LEVELS)
351
+ def test_stall_loses(level, seed):
352
+ """A do-nothing policy must LOSE on every (level, seed)."""
353
+ c = compile_level(load_pack(PACK), level)
354
+ res = run_level(c, _stall_policy(), seed=seed)
355
+ assert res.outcome == "loss", (
356
+ f"stall must LOSE on {level} s={seed}; got {res.outcome}"
357
+ )
358
+
359
+
360
+ @pytest.mark.parametrize("seed", SEEDS)
361
+ @pytest.mark.parametrize("level", LEVELS)
362
+ def test_skip_to_attack_loses(level, seed):
363
+ """Rush the fact with starting tanks (no proc, no weap, no
364
+ chain). Must LOSE on every (level, seed) — clause-1 (proc) is
365
+ never observed-true so the then-latch never advances past 0."""
366
+ c = compile_level(load_pack(PACK), level)
367
+ res = run_level(c, _skip_to_attack_policy(level), seed=seed)
368
+ tp = getattr(res.signals, "then_progress", {}) or {}
369
+ assert res.outcome == "loss", (
370
+ f"skip-to-attack must LOSE on {level} s={seed}; "
371
+ f"got {res.outcome} then_progress={tp}"
372
+ )
373
+
374
+
375
+ @pytest.mark.parametrize("seed", SEEDS)
376
+ def test_skip_phase_4_loses_medium(seed):
377
+ """On medium, building proc+weap but NEVER engaging means kills
378
+ stay at 0 — clause-4 (units_killed_gte:3) never latches and the
379
+ chain stalls at index 3."""
380
+ c = compile_level(load_pack(PACK), "medium")
381
+ res = run_level(c, _skip_phase_4_policy("medium"), seed=seed)
382
+ tp = getattr(res.signals, "then_progress", {}) or {}
383
+ assert res.outcome == "loss", (
384
+ f"skip-phase-4 must LOSE on medium s={seed}; "
385
+ f"got {res.outcome} then_progress={tp} "
386
+ f"kills={res.signals.units_killed}"
387
+ )
388
+
389
+
390
+ @pytest.mark.parametrize("seed", SEEDS)
391
+ def test_skip_phase_4_loses_hard(seed):
392
+ """Same on hard — no engagement means no kills, no fact-kill,
393
+ no chain advance past clause 3."""
394
+ c = compile_level(load_pack(PACK), "hard")
395
+ res = run_level(c, _skip_phase_4_policy("hard"), seed=seed)
396
+ tp = getattr(res.signals, "then_progress", {}) or {}
397
+ assert res.outcome == "loss", (
398
+ f"skip-phase-4 must LOSE on hard s={seed}; "
399
+ f"got {res.outcome} then_progress={tp} "
400
+ f"kills={res.signals.units_killed}"
401
+ )
402
+
403
+
404
+ @pytest.mark.parametrize("seed", SEEDS)
405
+ def test_hard_seeds_produce_distinct_starts(seed):
406
+ """Hard's two spawn_point groups must round-robin: different
407
+ seeds place the agent base at a different (x,y) set. Smoke-tests
408
+ the spawn-variation contract enforced by test_hard_tier.py."""
409
+ c = compile_level(load_pack(PACK), "hard")
410
+ res = run_level(c, _stall_policy(), seed=seed)
411
+ assert res.outcome == "loss"
412
+ sp = {a.spawn_point for a in c.scenario.actors if a.owner == "agent"}
413
+ assert len(sp) >= 2
tests/test_lh_scout_react_counter.py ADDED
@@ -0,0 +1,418 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """lh-scout-react-counter pack — full no-cheat validation on Rust.
2
+
3
+ Group-G long-horizon reasoning pack: observation-driven 3-phase
4
+ chain. The agent has a small fixed force (1× jeep scout + 3× 2tnk)
5
+ at the west; it must (1) SCOUT — drive the jeep east and register
6
+ ≥2 enemy buildings (the construction yard AND a paired barracks),
7
+ (2) REACT — engage with the tanks to destroy ≥3 enemy units, then
8
+ (3) COUNTER — raze the enemy construction yard. Wave-2 `then:`
9
+ happened-before composite enforces the order; `within_ticks: 6300`
10
+ is the deadline; hard adds `units_lost_lte: 3` as an attrition
11
+ tooth and a third enemy posture pocket at MID-y.
12
+
13
+ Bar (per CLAUDE.md): the intended scout-react-counter policy WINS
14
+ on every (level, seed); every stall / skip-scout-attack-blind /
15
+ scout-only-no-attack policy LOSES on every level + seed. The pack
16
+ sits alongside `lh-tech-pivot-attack` (which tests build-the-
17
+ counter as the middle phase) and `mid-tech-switch-on-scout` (the
18
+ 2-phase scout-then-counter sibling): this one isolates the
19
+ observation-action-counter loop on a FIXED FORCE (cash 1500 is too
20
+ lean to fund a new production line — the capability is the
21
+ reactive 3-phase chain, not the build-out).
22
+ """
23
+
24
+ from __future__ import annotations
25
+
26
+ import pytest
27
+
28
+ pytest.importorskip("openra_train", reason="Rust env wheel not installed")
29
+
30
+ pytest.importorskip("openra_rl_training", reason="Rust env wheel not installed")
31
+ from openra_bench.eval_core import run_level
32
+ from openra_bench.scenarios import load_pack
33
+ from openra_bench.scenarios.loader import PACKS_DIR, compile_level
34
+
35
+ PACK = PACKS_DIR / "lh-scout-react-counter.yaml"
36
+ LEVELS = ("easy", "medium", "hard")
37
+ SEEDS = (1, 2, 3, 4)
38
+
39
+
40
+ # ── Policies ──────────────────────────────────────────────────────
41
+
42
+
43
+ def _stall_policy():
44
+ """Do nothing — must LOSE on after_ticks every level/seed."""
45
+ def pol(obs, Cmd):
46
+ return [Cmd.observe()]
47
+ return pol
48
+
49
+
50
+ def _skip_scout_attack_blind_policy():
51
+ """Rush the tanks straight at the nearest plausible enemy fact
52
+ coordinate without driving the scout jeep east first. The
53
+ discovery latch (≥2 buildings) is the load-bearing first
54
+ clause: a tank rush that smashes the fact MAY register a
55
+ single building (the fact under attack) but the paired
56
+ barracks (tent) sits OFF the assault axis (y-offset +4),
57
+ outside the tank's sight while it shells the fact — so
58
+ buildings_discovered stays at 1 and the then-chain stalls at
59
+ clause 1. Even if a stray sight tick caught the tent, the
60
+ chain still requires ≥3 unit kills BEFORE raze (the tank
61
+ rush kills the cluster en route only after the fact-attack
62
+ has begun, and the then-chain credit order matters)."""
63
+ def pol(obs, Cmd):
64
+ units = obs.get("units_summary", []) or []
65
+ own_b = obs.get("own_buildings", []) or []
66
+ # Pick target y by agent fact latitude (NORTH spawn → y=5,
67
+ # SOUTH spawn → y=38, easy single-spawn → y=18).
68
+ agent_fact = next(
69
+ (b for b in own_b if b.get("type") == "fact"), None,
70
+ )
71
+ if agent_fact is None:
72
+ return [Cmd.observe()]
73
+ ay = agent_fact["cell_y"]
74
+ if ay < 14:
75
+ target_y = 5
76
+ elif ay > 24:
77
+ target_y = 38
78
+ else:
79
+ target_y = 18
80
+ tnk_ids = [u["id"] for u in units if u.get("type") == "2tnk"]
81
+ if not tnk_ids:
82
+ return [Cmd.observe()]
83
+ # Attack-move directly at the enemy fact coordinate. No
84
+ # scout move issued.
85
+ return [Cmd.attack_move(tnk_ids, 122, target_y)]
86
+ return pol
87
+
88
+
89
+ def _scout_only_no_attack_policy():
90
+ """Drive the scout jeep east to register the enemy buildings
91
+ (satisfies clause 1) but never engage with the tanks — the
92
+ then-chain stalls at clause 2 (units_killed_gte:3), and the
93
+ clock eventually expires. Asserts that scouting alone is not
94
+ sufficient — the REACT phase is load-bearing."""
95
+ state = {"went_outpost": False, "ticks_since_outpost": 0,
96
+ "went_corner": False}
97
+
98
+ def pol(obs, Cmd):
99
+ units = obs.get("units_summary", []) or []
100
+ own_b = obs.get("own_buildings", []) or []
101
+ jeep = next((u for u in units if u.get("type") == "jeep"), None)
102
+ agent_fact = next(
103
+ (b for b in own_b if b.get("type") == "fact"), None,
104
+ )
105
+ if jeep is None or agent_fact is None:
106
+ return [Cmd.observe()]
107
+ ay = agent_fact["cell_y"]
108
+ # Drive the jeep through the off-axis outpost (registers
109
+ # the tent), then on to the near fact's y-band (registers
110
+ # the fact). Both buildings get discovered, but the tanks
111
+ # never engage so units_killed stays 0 and the chain
112
+ # stalls at clause 2.
113
+ if ay < 14:
114
+ fact_y, outpost = 5, (60, 20)
115
+ elif ay > 24:
116
+ fact_y, outpost = 38, (60, 20)
117
+ else:
118
+ fact_y, outpost = 18, (60, 36)
119
+ if not state["went_outpost"]:
120
+ state["went_outpost"] = True
121
+ return [Cmd.move_units([jeep["id"]], *outpost)]
122
+ state["ticks_since_outpost"] += 1
123
+ if state["ticks_since_outpost"] >= 8 and not state["went_corner"]:
124
+ state["went_corner"] = True
125
+ return [Cmd.move_units([jeep["id"]], 120, fact_y)]
126
+ return [Cmd.observe()]
127
+ return pol
128
+
129
+
130
+ def _intended_scout_react_counter_policy(easy_mode: bool):
131
+ """The intended capability play. Drives the chain end-to-end:
132
+ 1. Jeep moves to the forward outpost (mid-x off-axis tent),
133
+ then continues to the near corner — registers BOTH the
134
+ outpost tent AND the corner fact (≥2 buildings
135
+ discovered) ⇒ then[0] latches.
136
+ 2. Tanks attack-move at the near fact — the defending
137
+ stance:0 cluster sits at (118, fact_y) right on the
138
+ assault axis, so the tanks engage and kill ≥3 ⇒ then[1]
139
+ latches.
140
+ 3. Tanks continue past the cluster and shell the fact ⇒
141
+ then[2] (and the top-level enemy_key_buildings_destroyed)
142
+ both latch ⇒ WIN inside 6300 ticks.
143
+ """
144
+ state = {"outpost_dispatched": False, "scout_dispatched": False,
145
+ "attack_dispatched": False, "scout_ticks": 0}
146
+
147
+ def pol(obs, Cmd):
148
+ units = obs.get("units_summary", []) or []
149
+ own_b = obs.get("own_buildings", []) or []
150
+ agent_fact = next(
151
+ (b for b in own_b if b.get("type") == "fact"), None,
152
+ )
153
+ jeep = next((u for u in units if u.get("type") == "jeep"), None)
154
+ tnk_ids = [u["id"] for u in units if u.get("type") == "2tnk"]
155
+ cmds = []
156
+
157
+ # Pick near corner by agent latitude, and the outpost
158
+ # coordinate that matches the level's layout.
159
+ if easy_mode:
160
+ fact_y = 18
161
+ outpost = (60, 36) # easy outpost far south
162
+ elif agent_fact and agent_fact["cell_y"] < 18:
163
+ fact_y = 5
164
+ outpost = (60, 20) # medium/hard shared mid outpost
165
+ else:
166
+ fact_y = 38
167
+ outpost = (60, 20)
168
+
169
+ # 1a. SCOUT — first dispatch jeep to the off-axis outpost.
170
+ if jeep is not None and not state["outpost_dispatched"]:
171
+ cmds.append(Cmd.move_units([jeep["id"]], *outpost))
172
+ state["outpost_dispatched"] = True
173
+ # 1b. Then redirect jeep east to the near fact's y-band
174
+ # (give it a few turns to traverse to the outpost
175
+ # and surface the tent in enemy_buildings).
176
+ elif (jeep is not None
177
+ and state["outpost_dispatched"]
178
+ and not state["scout_dispatched"]):
179
+ state["scout_ticks"] += 1
180
+ if state["scout_ticks"] >= 8:
181
+ cmds.append(Cmd.move_units([jeep["id"]], 120, fact_y))
182
+ state["scout_dispatched"] = True
183
+
184
+ # 2 + 3. REACT + COUNTER — dispatch the tanks early; one
185
+ # attack_move covers both phases (they engage the
186
+ # stance:0 cluster en route, then continue to raze
187
+ # the fact). The then-chain's first clause may not
188
+ # yet be latched when the tanks start moving — that's
189
+ # fine, the greedy advance picks up clauses in order
190
+ # once each becomes true, and the rush takes long
191
+ # enough that the jeep's scout completes first.
192
+ if tnk_ids and not state["attack_dispatched"]:
193
+ cmds.append(Cmd.attack_move(tnk_ids, 122, fact_y))
194
+ state["attack_dispatched"] = True
195
+
196
+ if not cmds:
197
+ cmds.append(Cmd.observe())
198
+ return cmds
199
+ return pol
200
+
201
+
202
+ # ── Pack-shape tests (cheap; do not run the engine) ──────────────
203
+
204
+
205
+ def test_pack_compiles_with_three_levels():
206
+ pack = load_pack(PACK)
207
+ assert pack.meta.id == "lh-scout-react-counter"
208
+ assert pack.meta.capability == "reasoning"
209
+ assert set(pack.levels) == {"easy", "medium", "hard"}
210
+
211
+
212
+ def test_meta_benchmark_anchor_set():
213
+ """Required by the seed taxonomy: SC2 reactive macro / CICERO
214
+ info-loop / PlanBench replanning / threat-intel anchors."""
215
+ pack = load_pack(PACK)
216
+ anchors = pack.meta.benchmark_anchor or []
217
+ assert any("SC2" in a for a in anchors), anchors
218
+ assert any("CICERO" in a for a in anchors), anchors
219
+ assert any("PlanBench" in a for a in anchors), anchors
220
+ assert any("threat-intel" in a for a in anchors), anchors
221
+
222
+
223
+ def test_hard_tier_has_seed_driven_spawn_groups():
224
+ """Hard must define ≥2 agent spawn_point groups so seed varies
225
+ the start base (binding contract from tests/test_hard_tier.py)."""
226
+ c = compile_level(load_pack(PACK), "hard")
227
+ sp = {a.spawn_point for a in c.scenario.actors if a.owner == "agent"}
228
+ assert len(sp) >= 2, f"hard needs ≥2 spawn groups, got {sp}"
229
+
230
+
231
+ def test_every_level_has_fail_condition():
232
+ """No silent draws — every level must be able to emit a LOSS."""
233
+ pack = load_pack(PACK)
234
+ for lvl in LEVELS:
235
+ c = compile_level(pack, lvl)
236
+ assert c.fail_condition is not None, f"{lvl} missing fail_condition"
237
+
238
+
239
+ def test_then_composite_used_in_win():
240
+ """The 3-phase scout-react-counter chain must be wired through
241
+ to the compiled win condition (the whole point of the pack)."""
242
+ for lvl in LEVELS:
243
+ c = compile_level(load_pack(PACK), lvl)
244
+ win = c.win_condition.model_dump(exclude_none=True)
245
+ ao = win.get("all_of") or []
246
+ then_branches = [b for b in ao if "then" in b]
247
+ assert len(then_branches) >= 1, f"{lvl} missing then-chain: {win}"
248
+ clauses = then_branches[0]["then"]["clauses"]
249
+ assert len(clauses) == 3, (
250
+ f"{lvl} then-chain must be 3 clauses (scout, react, "
251
+ f"counter), got {len(clauses)}"
252
+ )
253
+ # 1. scout latch — ≥2 buildings discovered (NOT ≥1; the
254
+ # spec makes scouting load-bearing by requiring a
255
+ # second building, defeating the rush-the-fact play).
256
+ assert clauses[0].get("buildings_discovered_gte") == 2, clauses[0]
257
+ # 2. react — kill at least 3 enemy units.
258
+ assert clauses[1].get("units_killed_gte") == 3, clauses[1]
259
+ # 3. counter — raze the enemy fact.
260
+ assert "enemy_key_buildings_destroyed" in clauses[2], clauses[2]
261
+
262
+
263
+ def test_tick_budget_aligned_with_max_turns():
264
+ """within_ticks must be reachable inside max_turns. Engine
265
+ advances ~90 ticks/turn → reachable max = 93 + 90·(N-1)."""
266
+ pack = load_pack(PACK)
267
+ for lvl in LEVELS:
268
+ level_def = pack.levels[lvl]
269
+ max_turns = level_def.max_turns
270
+ reachable = 93 + 90 * (max_turns - 1)
271
+ win = compile_level(pack, lvl).win_condition.model_dump(
272
+ exclude_none=True,
273
+ )
274
+
275
+ def _collect(node, key, out):
276
+ if isinstance(node, dict):
277
+ if key in node:
278
+ out.append(node[key])
279
+ for v in node.values():
280
+ _collect(v, key, out)
281
+ elif isinstance(node, list):
282
+ for v in node:
283
+ _collect(v, key, out)
284
+
285
+ wts = []
286
+ _collect(win, "within_ticks", wts)
287
+ assert wts, f"{lvl} has no within_ticks leaf (no clock teeth)"
288
+ for wt in wts:
289
+ assert wt <= reachable, (
290
+ f"{lvl} within_ticks={wt} > reachable={reachable} "
291
+ f"(max_turns={max_turns}) — deadline never bites ⇒ draw"
292
+ )
293
+
294
+
295
+ def test_tools_list_matches_spec():
296
+ """Tools per spec: [observe, build, place_building, move_units,
297
+ attack_unit, attack_move, stop]."""
298
+ pack = load_pack(PACK)
299
+ expected = {"observe", "build", "place_building", "move_units",
300
+ "attack_unit", "attack_move", "stop"}
301
+ tools = pack.base.get("tools") if isinstance(pack.base, dict) \
302
+ else pack.base.tools
303
+ assert set(tools) == expected, tools
304
+
305
+
306
+ def test_starting_cash_is_lean():
307
+ """Spec calls for starting_cash: 1500 across all levels — the
308
+ capability is the reactive 3-phase chain on the FIXED force,
309
+ not a build-out funded from a generous war chest."""
310
+ pack = load_pack(PACK)
311
+ for lvl in LEVELS:
312
+ assert pack.levels[lvl].starting_cash == 1500, lvl
313
+
314
+
315
+ def test_hard_has_attrition_cap():
316
+ """Hard adds units_lost_lte:3 as the +1 controlled axis (one
317
+ new variable per tier per the curation contract)."""
318
+ c = compile_level(load_pack(PACK), "hard")
319
+ win = c.win_condition.model_dump(exclude_none=True)
320
+
321
+ def _has(node, key):
322
+ if isinstance(node, dict):
323
+ if key in node:
324
+ return True
325
+ return any(_has(v, key) for v in node.values())
326
+ if isinstance(node, list):
327
+ return any(_has(v, key) for v in node)
328
+ return False
329
+
330
+ assert _has(win, "units_lost_lte"), (
331
+ "hard must include units_lost_lte tooth"
332
+ )
333
+
334
+
335
+ # ── Engine-bound tests (parameterised over seeds 1..4) ────────────
336
+
337
+
338
+ @pytest.mark.parametrize("seed", SEEDS)
339
+ @pytest.mark.parametrize("level", LEVELS)
340
+ def test_intended_scout_react_counter_wins(level, seed):
341
+ """The intended scout → react → counter capability play must
342
+ WIN on every (level, seed). This is the load-bearing test
343
+ that the pack is solvable inside the budget by the advertised
344
+ capability across all hard-tier spawn variants."""
345
+ c = compile_level(load_pack(PACK), level)
346
+ res = run_level(
347
+ c, _intended_scout_react_counter_policy(easy_mode=(level == "easy")),
348
+ seed=seed,
349
+ )
350
+ assert res.outcome == "win", (
351
+ f"intended scout-react-counter must WIN on {level} s={seed}; "
352
+ f"got {res.outcome} (kills={res.signals.units_killed}, "
353
+ f"bds={len(res.signals.enemy_buildings_seen_ids)}, "
354
+ f"lost={res.signals.units_lost}, "
355
+ f"then_progress={getattr(res.signals, 'then_progress', {})})"
356
+ )
357
+
358
+
359
+ @pytest.mark.parametrize("seed", SEEDS)
360
+ @pytest.mark.parametrize("level", LEVELS)
361
+ def test_stall_loses(level, seed):
362
+ """A do-nothing policy must LOSE (no win, no draw) on every
363
+ (level, seed). The fail_condition's after_ticks clause bites
364
+ at the turn budget; never a draw."""
365
+ c = compile_level(load_pack(PACK), level)
366
+ res = run_level(c, _stall_policy(), seed=seed)
367
+ assert res.outcome == "loss", (
368
+ f"stall must LOSE on {level} s={seed}; got {res.outcome}"
369
+ )
370
+
371
+
372
+ @pytest.mark.parametrize("seed", SEEDS)
373
+ @pytest.mark.parametrize("level", LEVELS)
374
+ def test_skip_scout_attack_blind_loses(level, seed):
375
+ """A blind tank-rush at the fact (no scout first) must LOSE
376
+ on every (level, seed). The then-chain requires ≥2 buildings
377
+ discovered FIRST; a tank rush only registers the fact under
378
+ attack (1 building) and the chain stalls at clause 1 — the
379
+ fact may eventually fall but the chain credit was never
380
+ earned in order, so within_ticks expires without a WIN. This
381
+ is the load-bearing 'scout is mandatory' tooth."""
382
+ c = compile_level(load_pack(PACK), level)
383
+ res = run_level(c, _skip_scout_attack_blind_policy(), seed=seed)
384
+ assert res.outcome == "loss", (
385
+ f"skip-scout-attack-blind must LOSE on {level} s={seed}; "
386
+ f"got {res.outcome} (kills={res.signals.units_killed}, "
387
+ f"bds={len(res.signals.enemy_buildings_seen_ids)}, "
388
+ f"then_progress={getattr(res.signals, 'then_progress', {})})"
389
+ )
390
+
391
+
392
+ @pytest.mark.parametrize("seed", SEEDS)
393
+ @pytest.mark.parametrize("level", LEVELS)
394
+ def test_scout_only_no_attack_loses(level, seed):
395
+ """A scout-only policy (jeep registers the buildings but the
396
+ tanks never engage) must LOSE on every (level, seed). The
397
+ then-chain stalls at clause 2 (units_killed_gte:3) — the
398
+ REACT phase is load-bearing. The clock eventually expires."""
399
+ c = compile_level(load_pack(PACK), level)
400
+ res = run_level(c, _scout_only_no_attack_policy(), seed=seed)
401
+ assert res.outcome == "loss", (
402
+ f"scout-only-no-attack must LOSE on {level} s={seed}; "
403
+ f"got {res.outcome} (kills={res.signals.units_killed}, "
404
+ f"bds={len(res.signals.enemy_buildings_seen_ids)}, "
405
+ f"then_progress={getattr(res.signals, 'then_progress', {})})"
406
+ )
407
+
408
+
409
+ @pytest.mark.parametrize("seed", SEEDS)
410
+ def test_hard_seeds_produce_distinct_starts(seed):
411
+ """Hard's two spawn_point groups must actually round-robin —
412
+ different seeds must place the agent base at a different (x,y)
413
+ set. Smoke-tests the spawn-variation contract."""
414
+ c = compile_level(load_pack(PACK), "hard")
415
+ res = run_level(c, _stall_policy(), seed=seed)
416
+ assert res.outcome == "loss" # stall must lose
417
+ sp = {a.spawn_point for a in c.scenario.actors if a.owner == "agent"}
418
+ assert len(sp) >= 2