yxc20098 commited on
Commit
82ef6bf
·
1 Parent(s): 26e71da

feat(scenario): rob-unit-loss-recovery — rebuild force after attrition (PlanBench replanning / SC2 force-regen anchor)

Browse files

Wave-6 ROBUSTNESS / reasoning capability pack. Agent loses 1+ tank to
an opening-tick enemy salvo and must commission replacements while
continuing the eastward assault. Salvaged from untracked files dropped
in main worktree by W6 agent rob-unit-loss-recovery.

openra_bench/scenarios/packs/rob-unit-loss-recovery.yaml ADDED
@@ -0,0 +1,309 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # rob-unit-loss-recovery — rebuild force structure after attrition
2
+ #
3
+ # REASONING capability — react to an exogenous COMBAT-UNIT loss event by
4
+ # (1) recognising the surviving force is below establishment, (2)
5
+ # commissioning replacements from the war-factory with the reserve cash,
6
+ # and (3) executing the original offensive (kill the eastern garrison
7
+ # defending the enemy fact) — fast enough that the deadline still bites.
8
+ #
9
+ # Real-world anchor: PlanBench replanning after exogenous loss (gold
10
+ # standard); SC2 rebuild-after-trade; military force-regen / unit
11
+ # replacement under mid-mission attrition; ScienceWorld error recovery.
12
+ #
13
+ # Discrimination (the four-script policy bar):
14
+ # • stall (only `observe`): the enemy strike force walks into the
15
+ # pre-placed agent column, kills 1-4 of the agent's combat tanks,
16
+ # and the enemy garrison at x=80 is never touched → kill bar (5)
17
+ # unmet → after_ticks 5401 fires → LOSS.
18
+ # • no-rebuild (attack_move east but skip `build`): the strike force
19
+ # kills ≥1 combat tank on the way past; the surviving 3tnks reach
20
+ # the garrison and clear the kill bar, BUT the type-count clause
21
+ # (`unit_type_count_gte: {type: 3tnk, n: 4}`) is busted because the
22
+ # dead tank was never replaced → LOSS.
23
+ # • intended (rebuild + attack_move east): build('3tnk') whenever the
24
+ # 3tnk count drops below 4, attack_move the live force east. The
25
+ # replacement budget covers the per-tier expected losses; the
26
+ # fresh-built tank rejoins the column before the deadline, the
27
+ # garrison falls, and unit-type-count and kill-bar both pass → WIN.
28
+ #
29
+ # ENGINE NOTES (verified 2026-05-20 with scripted run_level + raw smoke):
30
+ # - Combat unit is `3tnk` (heavy tank, Soviet faction). Production
31
+ # requires `fact + weap + fix`. Cost 1150 cr per replacement.
32
+ # - The enemy uses the `turtle` scripted bot (botgen.py / scripted_
33
+ # bot.rs) so the eastern e1 garrison HOLDS POSITION instead of
34
+ # surging west; this isolates the loss event to the pre-placed
35
+ # `4tnk` striker(s) at x=24 and keeps the kill bar reachable from
36
+ # a single east-bound assault.
37
+ # - Strikers are stance:3 (AttackAnything) so they fire on the agent
38
+ # column from tick 0 — the loss event lands BEFORE the agent's
39
+ # first decision turn (~tick 90), so even a perfectly-timed flee
40
+ # cannot fully evade.
41
+ # - The far enemy `fact` at (120,20) is the engine-auto-done guard
42
+ # (CLAUDE.md): without a persistent MustBeDestroyed enemy, the
43
+ # engine ends the episode the moment the last garrison e1 falls,
44
+ # which can collapse a clean run to DRAW before the win/fail
45
+ # evaluator sees the terminal frame.
46
+ # - Tick budget (CLAUDE.md): max_turns=60 ⇒ ceiling tick 5403 ⇒
47
+ # within_ticks 5400 + after_ticks 5401 both reachable; a non-
48
+ # finisher LOSES (not draws).
49
+ # - `not own_units_gte: 1` mis-fires on turn 1 when the agent starts
50
+ # unit-less (CLAUDE.md footgun). This pack starts with 4 3tnks +
51
+ # fact, so `own_units_gte: 1` is safe.
52
+
53
+ meta:
54
+ id: rob-unit-loss-recovery
55
+ title: 'Force-Regen After Attrition — Rebuild Combat Force Mid-Episode'
56
+ capability: reasoning
57
+ real_world_meaning: >
58
+ Reactive replan after an exogenous combat-unit loss. An ongoing
59
+ offensive operation has its force structure unexpectedly cut by an
60
+ enemy strike (2 units of 4 die in the opening); the agent has just
61
+ enough reserve cash to commission replacements from the war
62
+ factory; the offensive deadline is real. The model must notice the
63
+ loss (units_summary shows fewer combat tanks than the establishment
64
+ of 4), commit the reserve to rebuilding the combat type (not to a
65
+ different unit or to nothing), AND continue the original mission
66
+ (clear the eastern garrison defending the enemy construction yard)
67
+ fast enough that the deadline still bites.
68
+ robotics_analogue: >
69
+ Military force-regen / SC2 rebuild-after-trade. A theater operation
70
+ loses combat assets to an enemy strike mid-mission; the supervisor
71
+ has a discrete replacement budget for new units from the rear-area
72
+ production base; the operational objective and timeline are fixed.
73
+ The discrimination is whether the model recognises the gap in the
74
+ advertised force structure AND issues the production order in time
75
+ to keep the offensive on schedule; ignoring the loss (continuing
76
+ with a reduced force) under-strengths the assault and busts the
77
+ establishment bar; spending the budget on the wrong unit type
78
+ breaks the offensive composition; doing nothing busts the deadline.
79
+ benchmark_anchor:
80
+ - "PlanBench replanning under exogenous loss"
81
+ - "ScienceWorld error recovery"
82
+ - "military force-regen / unit replacement"
83
+ - "SC2 rebuild-after-trade"
84
+ author: "openra-bench"
85
+
86
+ base_map: rush-hour-arena
87
+ starting_cash: 1500 # per-level override below
88
+
89
+ base:
90
+ agent:
91
+ faction: soviet
92
+ enemy:
93
+ faction: allies
94
+ # `turtle` (Wave-2 scripted bot) — enemy units hold position and
95
+ # return fire only. This keeps the eastern e1 garrison at x=80
96
+ # from surging west, so the kill bar is satisfied by an eastbound
97
+ # assault rather than by absorbing waves into the base. The
98
+ # pre-placed `4tnk` strikers (stance:3 AttackAnything) handle the
99
+ # loss event independently — they fire on contact at tick 0.
100
+ bot_type: turtle
101
+ tools:
102
+ - observe
103
+ - build
104
+ - place_building
105
+ - move_units
106
+ - attack_unit
107
+ - attack_move
108
+ - stop
109
+ planning: true
110
+ termination:
111
+ max_ticks: 7000
112
+
113
+ levels:
114
+ # ── EASY ────────────────────────────────────────────────────────
115
+ # The bare skill: 1 expected loss (2 strikers land ~1 tank kill
116
+ # under stall; intended attack-move + rebuild restores the
117
+ # establishment of 4 3tnks well inside the budget). Generous
118
+ # garrison (5 e1) means the kill bar is easily reachable.
119
+ easy:
120
+ description: >
121
+ You command 4 heavy tanks (3tnk) at a forward staging area
122
+ (x=22, y=18..24) with a production base (fact + powr + weap +
123
+ fix) behind you. Two enemy heavy tanks (4tnk) are staged at the
124
+ lane mouth (x=24) and will engage the moment the episode starts
125
+ — expect ONE of your tanks to die in the opening salvo. An
126
+ eastern garrison of 5 rifle infantry (e1) at x=80 defends the
127
+ enemy construction yard. Your reserve cash (1500 cr) covers ONE
128
+ replacement heavy tank (1150 cr); the intended play is to build
129
+ a replacement once your 3tnk count drops below 4 AND attack_move
130
+ east to clear the garrison. Win when you have killed ≥5 enemy
131
+ units AND you have ≥4 heavy tanks alive AND your construction
132
+ yard is intact, before tick 5400. Stalling, or assaulting
133
+ without replacing the loss, both LOSE.
134
+ starting_cash: 1500
135
+ overrides:
136
+ actors:
137
+ # Agent base: fact + powr + weap + fix at the west edge.
138
+ - {type: fact, owner: agent, position: [8, 18]}
139
+ - {type: powr, owner: agent, position: [10, 18]}
140
+ - {type: weap, owner: agent, position: [12, 18]}
141
+ - {type: fix, owner: agent, position: [14, 18]}
142
+ # 4 combat tanks (3tnk) at the forward staging line.
143
+ - {type: '3tnk', owner: agent, position: [22, 18], stance: 2}
144
+ - {type: '3tnk', owner: agent, position: [22, 20], stance: 2}
145
+ - {type: '3tnk', owner: agent, position: [22, 22], stance: 2}
146
+ - {type: '3tnk', owner: agent, position: [22, 24], stance: 2}
147
+ # Strike force: 2× 4tnk at the lane mouth (stance:3 fire on
148
+ # contact; range 5 covers the agent column at x=22).
149
+ - {type: '4tnk', owner: enemy, position: [24, 19], stance: 3}
150
+ - {type: '4tnk', owner: enemy, position: [24, 23], stance: 3}
151
+ # Eastern garrison: 5 rifle infantry defending the enemy fact.
152
+ # Stance:1 ReturnFire so they hold but engage when fired upon.
153
+ - {type: e1, owner: enemy, position: [80, 18], stance: 1}
154
+ - {type: e1, owner: enemy, position: [80, 20], stance: 1}
155
+ - {type: e1, owner: enemy, position: [80, 22], stance: 1}
156
+ - {type: e1, owner: enemy, position: [80, 24], stance: 1}
157
+ - {type: e1, owner: enemy, position: [82, 20], stance: 1}
158
+ # Persistent enemy `fact` far east (engine auto-done guard).
159
+ - {type: fact, owner: enemy, position: [120, 20]}
160
+ win_condition:
161
+ all_of:
162
+ - {unit_type_count_gte: {type: '3tnk', n: 4}}
163
+ - {units_killed_gte: 5}
164
+ - {has_building: fact}
165
+ - {within_ticks: 5400}
166
+ fail_condition:
167
+ any_of:
168
+ - {after_ticks: 5401}
169
+ - {not: {own_units_gte: 1}}
170
+ - {not: {has_building: fact}}
171
+ max_turns: 60
172
+
173
+ # ── MEDIUM ──────────────────────────────────────────────────────
174
+ # +1 controlled variable: TIGHTER strike (3× 4tnk strikers instead
175
+ # of 2) AND larger garrison (6 e1 vs 5). The replacement budget is
176
+ # held at 1500 cr — STILL one replacement, so the agent must absorb
177
+ # the same expected single-tank loss but the strike is harder to
178
+ # outrun and the kill bar requires a longer dwell at the garrison.
179
+ medium:
180
+ description: >
181
+ Tougher version: THREE enemy heavy tanks (4tnk) are staged at
182
+ the lane mouth (x=24, y=18..24) and will engage the moment the
183
+ episode starts — expect ONE of your tanks to die in the opening
184
+ salvo (the additional strikers spread fire, but still resolve to
185
+ ~1 lost agent tank against the same 4-tank establishment). The
186
+ eastern garrison is now 6 rifle infantry at x=80. Reserve cash
187
+ (1500 cr) still covers ONE replacement 3tnk. Win when you have
188
+ killed ≥5 enemy units AND you have ≥4 heavy tanks alive AND
189
+ your construction yard is intact, before tick 5400. Stalling
190
+ and assault-without-rebuild both LOSE.
191
+ starting_cash: 1500
192
+ overrides:
193
+ actors:
194
+ - {type: fact, owner: agent, position: [8, 18]}
195
+ - {type: powr, owner: agent, position: [10, 18]}
196
+ - {type: weap, owner: agent, position: [12, 18]}
197
+ - {type: fix, owner: agent, position: [14, 18]}
198
+ - {type: '3tnk', owner: agent, position: [22, 18], stance: 2}
199
+ - {type: '3tnk', owner: agent, position: [22, 20], stance: 2}
200
+ - {type: '3tnk', owner: agent, position: [22, 22], stance: 2}
201
+ - {type: '3tnk', owner: agent, position: [22, 24], stance: 2}
202
+ # 3-tank strike: spreads fire across the agent column for a
203
+ # broader engagement footprint than easy.
204
+ - {type: '4tnk', owner: enemy, position: [24, 18], stance: 3}
205
+ - {type: '4tnk', owner: enemy, position: [24, 21], stance: 3}
206
+ - {type: '4tnk', owner: enemy, position: [24, 24], stance: 3}
207
+ # Garrison: 6 e1 at x=80..82.
208
+ - {type: e1, owner: enemy, position: [80, 18], stance: 1}
209
+ - {type: e1, owner: enemy, position: [80, 20], stance: 1}
210
+ - {type: e1, owner: enemy, position: [80, 22], stance: 1}
211
+ - {type: e1, owner: enemy, position: [80, 24], stance: 1}
212
+ - {type: e1, owner: enemy, position: [82, 20], stance: 1}
213
+ - {type: e1, owner: enemy, position: [82, 22], stance: 1}
214
+ - {type: fact, owner: enemy, position: [120, 20]}
215
+ win_condition:
216
+ all_of:
217
+ - {unit_type_count_gte: {type: '3tnk', n: 4}}
218
+ - {units_killed_gte: 5}
219
+ - {has_building: fact}
220
+ - {within_ticks: 5400}
221
+ fail_condition:
222
+ any_of:
223
+ - {after_ticks: 5401}
224
+ - {not: {own_units_gte: 1}}
225
+ - {not: {has_building: fact}}
226
+ max_turns: 60
227
+
228
+ # ── HARD ────────────────────────────────────────────────────────
229
+ # +2 controlled variables vs medium:
230
+ # 1. TWO seed-driven AGENT spawn_point groups (NORTH y=14 vs
231
+ # SOUTH y=28 e1 scout). The central combat force (4× 3tnk at
232
+ # y=18..24) and base buildings are SHARED across the two
233
+ # groups so the strike geometry is symmetric, but a memorised
234
+ # opening cannot generalise: the scout's location flips per
235
+ # seed and reveals it. (CLAUDE.md: enemy actors are NOT
236
+ # spawn-filtered, so the 3 strikers + garrison + far fact
237
+ # always place; only AGENT actors honour `spawn_point`, and
238
+ # ANY agent actor with a spawn_point causes every agent
239
+ # actor WITHOUT one to be filtered out — so base buildings
240
+ # are duplicated across BOTH spawn groups at identical cells.)
241
+ # 2. Larger replacement budget (3450 cr ⇒ 3× 3tnk rebuilds) to
242
+ # absorb the staggered strike (3 strikers spread fire across
243
+ # the column for a broader engagement — empirically still
244
+ # lands ~1 tank kill on intended-attack-move, but the budget
245
+ # lets the agent rebuild aggressively if more are lost).
246
+ hard:
247
+ description: >
248
+ Hard tier: TWO seed-driven base orientations (a NORTH-flank
249
+ scout at (16,14) on spawn 0; a SOUTH-flank scout at (16,28) on
250
+ spawn 1) keep the strike geometry symmetric but defeat a
251
+ memorised opening. Three enemy heavy tanks (4tnk) strike the
252
+ central column; the eastern garrison is 6 rifle infantry at
253
+ x=80. Reserve cash (3450 cr) covers up to THREE replacement
254
+ heavy tanks. Win when you have killed ≥5 enemy units AND you
255
+ have ≥4 heavy tanks alive AND your construction yard is intact,
256
+ before tick 5400. Stalling and assault-without-rebuild both
257
+ LOSE; the intended play is to rebuild every fallen tank AND
258
+ attack_move east in parallel.
259
+ starting_cash: 3450
260
+ overrides:
261
+ actors:
262
+ # SPAWN 0 — NORTH-flank scout at (16,14).
263
+ # Base buildings + central combat force duplicated across
264
+ # both spawn groups (per CLAUDE.md: ANY agent actor with a
265
+ # spawn_point causes agent actors WITHOUT spawn_point to be
266
+ # filtered out — so EVERY agent actor must declare
267
+ # spawn_point).
268
+ - {type: fact, owner: agent, position: [8, 18], spawn_point: 0}
269
+ - {type: powr, owner: agent, position: [10, 18], spawn_point: 0}
270
+ - {type: weap, owner: agent, position: [12, 18], spawn_point: 0}
271
+ - {type: fix, owner: agent, position: [14, 18], spawn_point: 0}
272
+ - {type: '3tnk', owner: agent, position: [22, 18], stance: 2, spawn_point: 0}
273
+ - {type: '3tnk', owner: agent, position: [22, 20], stance: 2, spawn_point: 0}
274
+ - {type: '3tnk', owner: agent, position: [22, 22], stance: 2, spawn_point: 0}
275
+ - {type: '3tnk', owner: agent, position: [22, 24], stance: 2, spawn_point: 0}
276
+ - {type: e1, owner: agent, position: [16, 14], stance: 0, spawn_point: 0}
277
+ # SPAWN 1 — SOUTH-flank scout at (16,28).
278
+ - {type: fact, owner: agent, position: [8, 18], spawn_point: 1}
279
+ - {type: powr, owner: agent, position: [10, 18], spawn_point: 1}
280
+ - {type: weap, owner: agent, position: [12, 18], spawn_point: 1}
281
+ - {type: fix, owner: agent, position: [14, 18], spawn_point: 1}
282
+ - {type: '3tnk', owner: agent, position: [22, 18], stance: 2, spawn_point: 1}
283
+ - {type: '3tnk', owner: agent, position: [22, 20], stance: 2, spawn_point: 1}
284
+ - {type: '3tnk', owner: agent, position: [22, 22], stance: 2, spawn_point: 1}
285
+ - {type: '3tnk', owner: agent, position: [22, 24], stance: 2, spawn_point: 1}
286
+ - {type: e1, owner: agent, position: [16, 28], stance: 0, spawn_point: 1}
287
+ # 3-tank strike (enemy actors place on every seed).
288
+ - {type: '4tnk', owner: enemy, position: [24, 18], stance: 3}
289
+ - {type: '4tnk', owner: enemy, position: [24, 21], stance: 3}
290
+ - {type: '4tnk', owner: enemy, position: [24, 24], stance: 3}
291
+ - {type: e1, owner: enemy, position: [80, 18], stance: 1}
292
+ - {type: e1, owner: enemy, position: [80, 20], stance: 1}
293
+ - {type: e1, owner: enemy, position: [80, 22], stance: 1}
294
+ - {type: e1, owner: enemy, position: [80, 24], stance: 1}
295
+ - {type: e1, owner: enemy, position: [82, 20], stance: 1}
296
+ - {type: e1, owner: enemy, position: [82, 22], stance: 1}
297
+ - {type: fact, owner: enemy, position: [120, 20]}
298
+ win_condition:
299
+ all_of:
300
+ - {unit_type_count_gte: {type: '3tnk', n: 4}}
301
+ - {units_killed_gte: 5}
302
+ - {has_building: fact}
303
+ - {within_ticks: 5400}
304
+ fail_condition:
305
+ any_of:
306
+ - {after_ticks: 5401}
307
+ - {not: {own_units_gte: 1}}
308
+ - {not: {has_building: fact}}
309
+ max_turns: 60
tests/test_hard_tier.py CHANGED
@@ -522,6 +522,23 @@ UPGRADED = [
522
  # the surprise wave arrives FROM flips per seed and a memorised
523
  # "expect Wave 2 from NE" disposition cannot generalise.
524
  "rob-unexpected-enemy-spawn",
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
525
  ]
526
 
527
  # Consciously NOT spawn-varied, with the reason (keeps the curation
 
522
  # the surprise wave arrives FROM flips per seed and a memorised
523
  # "expect Wave 2 from NE" disposition cannot generalise.
524
  "rob-unexpected-enemy-spawn",
525
+ # Wave-6 ROBUSTNESS / reasoning seed — PlanBench replanning under
526
+ # exogenous loss / SC2 rebuild-after-trade / military force-regen /
527
+ # ScienceWorld error recovery anchor. Agent commands 4 heavy tanks
528
+ # (3tnk) + a production base (fact + powr + weap + fix); a pre-
529
+ # placed enemy 4tnk strike force at the lane mouth lands its
530
+ # opening salvo on tick 0 (stance:3) and kills 1+ agent tanks;
531
+ # the agent must commission replacements from the war factory with
532
+ # the reserve cash AND continue the eastward assault to clear the
533
+ # e1 garrison. Hard tier defines two agent spawn_point groups
534
+ # (NORTH-flank scout (16,14) vs SOUTH-flank scout (16,28)) round-
535
+ # robined by seed; the central combat force + base buildings are
536
+ # SHARED across both groups (per CLAUDE.md, base buildings are
537
+ # duplicated under both spawn_points because ANY agent actor with
538
+ # a spawn_point causes agent actors WITHOUT a spawn_point to be
539
+ # filtered out), so the strike geometry is symmetric but a
540
+ # memorised opening cannot generalise.
541
+ "rob-unit-loss-recovery",
542
  ]
543
 
544
  # Consciously NOT spawn-varied, with the reason (keeps the curation
tests/test_rob_unit_loss_recovery.py ADDED
@@ -0,0 +1,310 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """rob-unit-loss-recovery — REASONING capability validation.
2
+
3
+ Real-world anchor: PlanBench replanning under exogenous loss (gold
4
+ standard); SC2 rebuild-after-trade; military force-regen / unit
5
+ replacement; ScienceWorld error recovery. The agent starts with 4
6
+ combat heavy tanks (3tnk) + a production base (fact + powr + weap +
7
+ fix) and a discrete reserve cash budget; an enemy 4tnk strike force
8
+ staged at the lane mouth (stance:3 AttackAnything) lands its opening
9
+ salvo on tick 0 and kills 1-4 agent tanks. The agent must
10
+ 1. notice the establishment is below 4 (units_summary count drops),
11
+ 2. build('3tnk') to commission replacements from the war factory
12
+ with the indivisible reserve cash, AND
13
+ 3. continue the eastward offensive to clear the e1 garrison
14
+ defending the enemy fact at x=120,
15
+ so the kill bar (5 enemy units) + the establishment bar (≥4 3tnks) +
16
+ the construction-yard intact bar all pass before the tick deadline.
17
+
18
+ Bar (CLAUDE.md "no defect, no cheat"):
19
+ - stall LOSES every tier / every hard seed (no attack, no kills →
20
+ kill bar unmet → after_ticks 5401 fires → timeout LOSS).
21
+ - no_rebuild LOSES every tier (the strike kills ≥1 agent 3tnk; the
22
+ surviving 3 tanks clear the kill bar but the type-count clause
23
+ `unit_type_count_gte: {type: 3tnk, n: 4}` is busted because the
24
+ dead tank was never replaced → LOSS even though kills ≥ 5).
25
+ - intended-rebuild-and-attack WINS every tier / seed.
26
+ - hard tier defines ≥2 agent spawn_point groups (NORTH-flank scout
27
+ vs SOUTH-flank scout) so a memorised opening cannot generalise.
28
+ """
29
+
30
+ from __future__ import annotations
31
+
32
+ from pathlib import Path
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
+
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
+ from openra_bench.scenarios.win_conditions import WinContext, evaluate
43
+
44
+ PACK = PACKS_DIR / "rob-unit-loss-recovery.yaml"
45
+
46
+
47
+ # ── policies ────────────────────────────────────────────────────────
48
+
49
+
50
+ def _stall(rs, Command):
51
+ """Pure observe — no orders → no kills → kill bar unmet →
52
+ after_ticks 5401 fires → timeout LOSS."""
53
+ return [Command.observe()]
54
+
55
+
56
+ def _no_rebuild(rs, Command):
57
+ """Attack-move east WITHOUT ever issuing build. The strike kills
58
+ ≥1 3tnk on the way past (the agent now has <4 of the combat type);
59
+ the surviving force reaches the garrison and clears the kill bar
60
+ BUT the `unit_type_count_gte: {type: 3tnk, n: 4}` clause is busted
61
+ → LOSS even though kills ≥ 5."""
62
+ units = rs.get("units_summary", []) or []
63
+ tanks = [u for u in units if u.get("type") == "3tnk"]
64
+ if not tanks:
65
+ return [Command.observe()]
66
+ ids = [str(u["id"]) for u in tanks]
67
+ return [Command.attack_move(ids, 80, 20)]
68
+
69
+
70
+ def _intended(rs, Command):
71
+ """React to the loss: if 3tnk count drops below 4, commission a
72
+ replacement via build('3tnk'); always attack_move the live 3tnk
73
+ force toward the eastern garrison at (80,20). The replacement
74
+ budget covers the per-tier expected losses; the fresh-built tank
75
+ rejoins the column before the deadline; the type-count + kill-bar
76
+ + construction-yard bars all pass → WIN."""
77
+ units = rs.get("units_summary", []) or []
78
+ tanks = [u for u in units if u.get("type") == "3tnk"]
79
+ cmds = []
80
+ if len(tanks) < 4:
81
+ cmds.append(Command.build("3tnk"))
82
+ ids = [str(u["id"]) for u in tanks]
83
+ if ids:
84
+ cmds.append(Command.attack_move(ids, 80, 20))
85
+ return cmds or [Command.observe()]
86
+
87
+
88
+ # ── helpers ─────────────────────────────────────────────────────────
89
+
90
+
91
+ def _run(level, policy, seed=1):
92
+ c = compile_level(load_pack(PACK), level)
93
+ assert c.map_supported, "rush-hour-arena terrain must be present"
94
+ return c, run_level(c, policy, seed=seed)
95
+
96
+
97
+ # ── structural ──────────────────────────────────────────────────────
98
+
99
+
100
+ def test_pack_loads_and_meta_active():
101
+ pack = load_pack(PACK)
102
+ assert pack.meta.id == "rob-unit-loss-recovery"
103
+ assert pack.meta.capability == "reasoning"
104
+ assert pack.meta.real_world_meaning
105
+ assert pack.meta.robotics_analogue
106
+ anchors = pack.meta.benchmark_anchor
107
+ joined = " ".join(anchors).lower()
108
+ assert "planbench" in joined
109
+ assert "scienceworld" in joined
110
+ assert "sc2" in joined
111
+ assert "force-regen" in joined or "force regen" in joined or "rebuild" in joined
112
+
113
+
114
+ def test_uses_turtle_bot():
115
+ """The pack must declare the Wave-2 `turtle` bot — the holding-
116
+ in-place idiom is what isolates the loss event to the pre-placed
117
+ strikers and keeps the kill bar reachable from a single eastbound
118
+ assault (a surging garrison would either overrun the base or be
119
+ killed without the agent moving, breaking both bars)."""
120
+ pack = load_pack(PACK)
121
+ enemy = pack.base.get("enemy") if isinstance(pack.base, dict) else None
122
+ assert enemy is not None
123
+ bot = enemy.get("bot_type") or enemy.get("bot")
124
+ assert bot == "turtle", f"expected turtle bot, got {bot!r}"
125
+
126
+
127
+ def test_all_tiers_have_reachable_deadlines():
128
+ """tick-alignment idiom: within_ticks ≤ ceiling AND
129
+ after_ticks ≤ ceiling AND within_ticks + 1 == after_ticks (so a
130
+ non-finisher LOSES, not draws)."""
131
+ pack = load_pack(PACK)
132
+ for lvl in ("easy", "medium", "hard"):
133
+ L = pack.levels[lvl]
134
+ ceiling = 93 + 90 * (L.max_turns - 1)
135
+ wt = next(
136
+ int(c["within_ticks"])
137
+ for c in L.win_condition.model_dump()["all_of"]
138
+ if "within_ticks" in c
139
+ )
140
+ ft = next(
141
+ int(c["after_ticks"])
142
+ for c in L.fail_condition.model_dump()["any_of"]
143
+ if "after_ticks" in c
144
+ )
145
+ assert wt <= ceiling, f"{lvl}: within_ticks {wt} > ceiling {ceiling}"
146
+ assert ft <= ceiling, f"{lvl}: after_ticks {ft} > ceiling {ceiling}"
147
+ assert wt + 1 == ft, (
148
+ f"{lvl}: within_ticks {wt} / after_ticks {ft} mismatch "
149
+ "(non-finisher must LOSE, not draw — fail clause one tick"
150
+ " past win clause)"
151
+ )
152
+
153
+
154
+ def test_hard_has_two_seed_driven_spawn_groups():
155
+ """Hard tier: ≥2 distinct agent spawn_point groups so engine
156
+ round-robins start by seed. The central combat force + base
157
+ buildings are SHARED across both groups so the strike geometry is
158
+ symmetric, but the spawn-distinguishing scout (NORTH (16,14) vs
159
+ SOUTH (16,28)) reveals which seed the engine picked — a memorised
160
+ opening cannot generalise."""
161
+ c = compile_level(load_pack(PACK), "hard")
162
+ sp = {
163
+ (a.spawn_point if a.spawn_point is not None else 0)
164
+ for a in c.scenario.actors
165
+ if a.owner == "agent"
166
+ }
167
+ assert len(sp) >= 2, (
168
+ f"hard must define ≥2 agent spawn_point groups; got {sorted(sp)}"
169
+ )
170
+
171
+
172
+ def test_fail_condition_present_on_every_tier():
173
+ pack = load_pack(PACK)
174
+ for lvl in ("easy", "medium", "hard"):
175
+ c = compile_level(pack, lvl)
176
+ assert c.fail_condition is not None, f"{lvl} needs a fail_condition"
177
+
178
+
179
+ def test_tools_match_spec():
180
+ """The advertised toolset is exactly the rebuild-and-assault kit:
181
+ observe + build + place_building + move_units + attack_unit +
182
+ attack_move + stop. No `harvest` (this is a no-economy scenario —
183
+ the rebuild budget is the starting_cash reserve)."""
184
+ pack = load_pack(PACK)
185
+ tools = set(pack.base.get("tools", []))
186
+ expected = {
187
+ "observe", "build", "place_building", "move_units",
188
+ "attack_unit", "attack_move", "stop",
189
+ }
190
+ assert tools == expected, f"tools mismatch: got {sorted(tools)}"
191
+
192
+
193
+ # ── predicate-level (no engine) ─────────────────────────────────────
194
+
195
+
196
+ def _ctx(*, units=(), tick=1000, kills=0, lost=0, own_buildings=()):
197
+ import types
198
+
199
+ sig = types.SimpleNamespace(
200
+ game_tick=tick,
201
+ units_killed=kills,
202
+ units_lost=lost,
203
+ cash=0,
204
+ resources=0,
205
+ own_buildings=list(own_buildings),
206
+ own_building_types={str(t).lower() for (t, _, _) in own_buildings},
207
+ enemies_seen_ids=set(),
208
+ enemy_buildings_seen_ids=set(),
209
+ )
210
+ return WinContext(
211
+ signals=sig,
212
+ render_state={"units_summary": list(units)},
213
+ )
214
+
215
+
216
+ def test_predicates_enforce_capability():
217
+ """Win requires (≥4 3tnks AND ≥5 kills AND fact alive) AND in-time;
218
+ fail fires on timeout OR all-units-dead OR fact destroyed."""
219
+ c = compile_level(load_pack(PACK), "medium")
220
+ four_tanks = [{"cell_x": 22, "cell_y": 18 + 2 * i, "type": "3tnk"} for i in range(4)]
221
+ fact = [("fact", 8, 18)]
222
+
223
+ # Intended: 4 3tnks, 5 kills, fact alive, in time → WIN
224
+ assert evaluate(
225
+ c.win_condition,
226
+ _ctx(units=four_tanks, tick=2000, kills=5, own_buildings=fact),
227
+ )
228
+ # Only 3 3tnks (didn't rebuild after a loss) → not a win
229
+ assert not evaluate(
230
+ c.win_condition,
231
+ _ctx(units=four_tanks[:3], tick=2000, kills=5, own_buildings=fact),
232
+ )
233
+ # Only 4 kills (didn't clear the garrison) → not a win
234
+ assert not evaluate(
235
+ c.win_condition,
236
+ _ctx(units=four_tanks, tick=2000, kills=4, own_buildings=fact),
237
+ )
238
+ # All units dead → real fail (capability collapses)
239
+ assert evaluate(
240
+ c.fail_condition,
241
+ _ctx(units=[], tick=2000, kills=5, own_buildings=fact),
242
+ )
243
+ # Timeout (tick past after_ticks): bar unmet → fail
244
+ assert evaluate(
245
+ c.fail_condition,
246
+ _ctx(units=four_tanks, tick=5402, kills=0, own_buildings=fact),
247
+ )
248
+ # Construction yard destroyed → fail
249
+ assert evaluate(
250
+ c.fail_condition,
251
+ _ctx(units=four_tanks, tick=2000, kills=5, own_buildings=[]),
252
+ )
253
+
254
+
255
+ # ── engine-driven policies (the no-cheat bar) ───────────────────────
256
+
257
+
258
+ @pytest.mark.parametrize("level", ["easy", "medium", "hard"])
259
+ @pytest.mark.parametrize("seed", [1, 2, 3, 4])
260
+ def test_stall_loses_every_tier_and_seed(level, seed):
261
+ """No orders → no kills → kill bar unmet → timeout LOSS via
262
+ after_ticks. The strike force kills 1-4 agent tanks during the
263
+ grind, but the agent neither rebuilds nor advances on the
264
+ garrison."""
265
+ _, r = _run(level, _stall, seed=seed)
266
+ assert r.outcome == "loss", (
267
+ f"{level}/seed{seed}: stall must LOSE (no kills, no rebuild); "
268
+ f"got {r.outcome} kills={r.signals.units_killed} "
269
+ f"losses={r.signals.units_lost} turns={r.turns}"
270
+ )
271
+
272
+
273
+ @pytest.mark.parametrize("level", ["easy", "medium", "hard"])
274
+ @pytest.mark.parametrize("seed", [1, 2, 3, 4])
275
+ def test_no_rebuild_loses(level, seed):
276
+ """Assault-without-rebuild: the strike kills ≥1 3tnk; the surviving
277
+ force clears the garrison so kills ≥ 5, but the type-count clause
278
+ busts because the dead tank was never replaced → LOSS even with a
279
+ cleared kill bar. This is the canonical 'forgot to replace the
280
+ casualty' failure mode the scenario is designed to catch."""
281
+ _, r = _run(level, _no_rebuild, seed=seed)
282
+ assert r.outcome == "loss", (
283
+ f"{level}/seed{seed}: no-rebuild must LOSE (type-count busted "
284
+ f"by unreplaced casualty); got {r.outcome} "
285
+ f"kills={r.signals.units_killed} losses={r.signals.units_lost}"
286
+ )
287
+
288
+
289
+ @pytest.mark.parametrize("level", ["easy", "medium", "hard"])
290
+ @pytest.mark.parametrize("seed", [1, 2, 3, 4])
291
+ def test_intended_rebuild_and_attack_wins(level, seed):
292
+ """The intended capability — build replacements whenever the
293
+ establishment drops below 4 AND attack_move the live force east —
294
+ WINS every tier and every hard seed well inside the tick budget."""
295
+ _, r = _run(level, _intended, seed=seed)
296
+ assert r.outcome == "win", (
297
+ f"{level}/seed{seed}: intended rebuild-and-attack should WIN; "
298
+ f"got {r.outcome} kills={r.signals.units_killed} "
299
+ f"losses={r.signals.units_lost} turns={r.turns}"
300
+ )
301
+
302
+
303
+ def test_outcomes_are_deterministic_per_seed():
304
+ """Same seed, same policy → identical outcome."""
305
+ c = compile_level(load_pack(PACK), "medium")
306
+ a = run_level(c, _intended, seed=2)
307
+ b = run_level(c, _intended, seed=2)
308
+ assert (a.outcome, a.turns, a.signals.units_killed, a.signals.units_lost) == (
309
+ b.outcome, b.turns, b.signals.units_killed, b.signals.units_lost
310
+ )