yxc20098 commited on
Commit
050e315
·
1 Parent(s): a8063ac

feat(scenario): rob-cash-depletion-recovery — recover from sudden cash drain (financial turnaround / SC2 anchor)

Browse files

Wave-6 Group J robustness seed. Frames a mid-episode cash-crisis
recovery: agent starts with a normal operating economy (fact + powr +
proc + 1 harv + 2 mines + a small 3tnk defender squad) and an
indivisible 1400 reserve (= exactly one proc rebuild). A scripted
`hunt` 4tnk strike destroys the refinery mid-episode; the agent must
`build('proc') + place_building` and re-issue `harvest` to restart
income before the EV bar 2500 / deadline 6300 bites.

Tiers — one new controlled variable per level:
• easy — LIGHT strike (1× 4tnk + 2 own 3tnk). Defenders intercept
the lone 4tnk; proc SURVIVES. Discriminator: stall = 0
income → bar unmet → LOSS.
• medium — PROC DESTROYED mid-game (2× 4tnk + 3 own 3tnk; proc dies
~tick 453). Agent must rebuild proc with the indivisible
reserve and resume harvest. No-rebuild fails the
`building_count_gte:proc:1` clause permanently.
• hard — PROC + TENT BOTH DESTROYED (4× 4tnk + tent placed in the
attack lane; tent dies tick 183, proc tick 273). Same
1400 reserve = one rebuild; the tent rebuild is OPTIONAL
(not in the win predicate). Tests prioritising the
income-restoring proc rebuild over the defensive-tech
tent rebuild.

Hard registered NOT_APPLICABLE in test_hard_tier.py because enemy
actors don't honour spawn_point (CLAUDE.md oramap.rs footgun) so
foreign-latitude `hunt` 4tnks drift to the active spawn and over-
pressure the tuned defender ring, destroying the fact and collapsing
the recovery test into a fact-defence test. Spawn variation would
compete with the recovery-discipline signal for attribution; the
within-tier discriminator (strike severity → rebuild decision
discipline) stays clean instead.

Real-world anchors: financial cash-crisis recovery / SC2 cash-drain
rebuild / DR after critical-infra loss / bankruptcy turnaround under
deadline.

Tests: 52 scripted no-cheat assertions cover stall / no-rebuild /
build-army / intended-rebuild × 3 levels × 4 seeds + structural and
determinism checks.

openra_bench/scenarios/packs/rob-cash-depletion-recovery.yaml ADDED
@@ -0,0 +1,423 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Validate: python -m openra_bench.scenarios.validate packs/rob-cash-depletion-recovery.yaml
2
+ # See ../CONTRIBUTING.md for the full win-condition grammar.
3
+ #
4
+ # ROB-CASH-DEPLETION-RECOVERY (Group J — robustness / mid-episode
5
+ # financial-crisis recovery after a scripted enemy strike destroys the
6
+ # refinery and halts income).
7
+ #
8
+ # Real-world anchor: financial cash-crisis recovery after a sudden
9
+ # emergency outflow / SC2 mid-game economic-rebuild after a worker-
10
+ # kill or refinery snipe / disaster-recovery after a critical-
11
+ # infrastructure loss / bankruptcy turnaround under a deadline.
12
+ #
13
+ # Pack frame: the agent inherits a NORMAL operating economy (fact +
14
+ # proc + 1 harv + 2 mines + a small 3tnk defender squad), with
15
+ # starting_cash = 1400 — exactly enough to rebuild ONE refinery (proc
16
+ # cost 1400). A scripted enemy `hunt` strike (2-3× 4tnk) charges the
17
+ # refinery early in the episode (medium: proc destroyed ~tick 543;
18
+ # hard: proc + tent both destroyed by ~tick 453). The agent must:
19
+ # 1. notice the proc died (no income, harv has no place to refine),
20
+ # 2. `build('proc')` and `place_building` it adjacent to the fact
21
+ # with the indivisible reserve cash,
22
+ # 3. re-issue `harvest` to the surviving harv (the death event
23
+ # interrupts the harvest loop), so income resumes and the EV
24
+ # bar (2500) is met before the deadline.
25
+ #
26
+ # Difficulty axis (one new controlled variable per tier):
27
+ # • easy — LIGHT strike (1× 4tnk + 2 own defenders). The defenders
28
+ # intercept the lone 4tnk before the proc dies; the agent
29
+ # never has to rebuild. The bare skill is "commit to
30
+ # harvest, don't stall, EV bar gets met". `has_building:
31
+ # proc` is satisfied trivially by the surviving original.
32
+ # • medium — PROC DESTROYED mid-game (2× 4tnk + 1 own defender;
33
+ # proc dies ~tick 543 ≈ "~tick 600" target). Agent must
34
+ # rebuild proc with the 1400 reserve AND redirect harv
35
+ # back to the patch. Stalling, ignoring the death, or
36
+ # spending the reserve on army units all LOSE.
37
+ # • hard — PROC + TENT BOTH DESTROYED (3× 4tnk + tent placed in
38
+ # the attack lane at (13,18); proc dies ~tick 453, tent
39
+ # dies ~tick 273-453). Agent must rebuild proc (1400);
40
+ # losing the tent removes the infantry production queue
41
+ # (an army-rebuild detour is even more unaffordable).
42
+ # + 2 seed-driven spawn_point groups (NORTH y=14 / SOUTH
43
+ # y=26) so the strike geometry flips per seed.
44
+ #
45
+ # Why this pack is different from `econ-recover-from-zero-cash`:
46
+ # • `econ-recover-from-zero-cash` starts from a MINIMUM VIABLE KIT
47
+ # with $0 cash and no aggression — the test is the SCALING
48
+ # recovery (harvest first, save 2000, build weap, build a 2nd /
49
+ # 3rd harv). The recovery is INTERNAL (no enemy event).
50
+ # • `rob-cash-depletion-recovery` starts from a WORKING ECONOMY
51
+ # with $1400 cash and ENEMY AGGRESSION that destroys the refinery
52
+ # mid-episode. The test is the EXOGENOUS-EVENT recovery (notice
53
+ # the strike, replace the dead refinery, restart income). The
54
+ # agent never needs to scale past 1 harv; the test is rebuilding
55
+ # the income channel that was destroyed, not extending it.
56
+ #
57
+ # Why this pack is different from `econ-replace-dead-harvester`:
58
+ # • `econ-replace-dead-harvester` kills a HARVESTER (a unit) with a
59
+ # raider 4tnk that targets workers specifically; agent replaces
60
+ # the dead worker with `build('harv')`.
61
+ # • `rob-cash-depletion-recovery` kills a REFINERY (a building) with
62
+ # a `hunt` 4tnk that targets the nearest combat actor (defenders
63
+ # and proc are MustBeDestroyed and engaged); the agent rebuilds
64
+ # the dead BUILDING with `build('proc') + place_building`. The
65
+ # recovery is a building-production task, not a unit-production
66
+ # task — one chain step deeper (build + place + adjacency).
67
+ #
68
+ # Why this pack is different from `mid-economy-under-fire`:
69
+ # • `mid-economy-under-fire` is an ongoing-attack defense: the
70
+ # raider drives at the harvesters and the agent must keep the
71
+ # harvest loop running THROUGH the attack. Throughput is
72
+ # sustained; no rebuild is needed.
73
+ # • `rob-cash-depletion-recovery` is a one-shot strike followed by
74
+ # a rebuild: the strike LANDS (the proc dies), throughput STOPS,
75
+ # and the agent must rebuild the destroyed infrastructure to
76
+ # restart income. The decision is replan-after-loss, not
77
+ # defend-and-sustain.
78
+ #
79
+ # ENGINE FACTS verified (CLAUDE.md + /tmp/probe_*.py 2026-05-19/20
80
+ # against the installed openra_train wheel):
81
+ # 1. `hunt` ScriptedBehavior (openra-sim/src/scripted_bot.rs lines
82
+ # 151-166 / 242-253) collects FOES including Building actors and
83
+ # each unit attacks the nearest. With 4tnk attackers placed in
84
+ # melee range of the proc, the proc becomes the closest enemy
85
+ # and is engaged on turn 1.
86
+ # 2. Proc strike timing (probe-measured, all attackers at x=14
87
+ # adjacent to proc at x=12):
88
+ # - easy: 1× 4tnk + 1 own 3tnk @ x=18 → defender kills 4tnk
89
+ # first; proc SURVIVES the full episode (verified to tick 6303).
90
+ # Discriminator: stall yields 0 income; intended harvest WINS.
91
+ # - medium: 2× 4tnk + 3 own 3tnk @ x=18 → proc dies tick 453
92
+ # (≈ "~tick 600" target); 4tnks then die to the defender ring
93
+ # so fact and powr SURVIVE → rebuild is feasible.
94
+ # - hard: 4× 4tnk + tent at (13,*) + 5 own 3tnk → tent dies tick
95
+ # 183, proc tick 273; 4tnks die to the defender ring shortly
96
+ # after; fact and powr SURVIVE to ~tick 6300 (probe H3).
97
+ # The defender-vs-attacker ratio (medium: 90 vs 80 dps; hard:
98
+ # 150 vs 160 dps) is tuned so the attackers BARELY land the
99
+ # building kill and then die — preventing the 4tnks from chain-
100
+ # killing fact after the proc falls (which would convert the
101
+ # run to a LOSS via `not building_count_gte:fact` regardless of
102
+ # the rebuild).
103
+ # 3. Proc cost = 1400 (verified econ-startup-from-scratch.yaml line
104
+ # 135). starting_cash = 1400 funds exactly ONE proc rebuild; any
105
+ # pre-spend (powr 300 / tent 500 / e1 100) starves the rebuild.
106
+ # 4. After proc dies, the surviving harv's harvest loop is
107
+ # interrupted (the engine cannot deliver ore without a refinery).
108
+ # Once the rebuild completes and the new proc lands, the agent
109
+ # must re-issue `harvest(harv_id, mine_x, mine_y)` for the harv
110
+ # to deliver into the NEW proc — the harvest auto-cycle does not
111
+ # auto-resume across a destroy-rebuild gap.
112
+ # 5. Tick alignment (CLAUDE.md): max_turns 70 → ceiling 93 + 90·69 =
113
+ # 6303 > 6300 ✓. within_ticks 6300; fail after_ticks 6301
114
+ # (deadline bites as a reachable LOSS, not a draw).
115
+ # 6. Persistent unarmed enemy `fact` marker far east at (120,20)
116
+ # prevents engine auto-`done` via the all-enemies-eliminated
117
+ # path (CLAUDE.md auto-done footgun) — once the strike force is
118
+ # killed, the marker keeps the episode running so the EV bar
119
+ # can be reached and the deadline can bite as a LOSS.
120
+ # 7. Hard tier is registered NOT_APPLICABLE in test_hard_tier.py
121
+ # (the UPGRADED spawn-variation contract). Reason: the
122
+ # controlled variable for hard is the STRIKE SEVERITY (proc +
123
+ # tent destroyed vs medium's proc only). The enemy-bot footgun
124
+ # (CLAUDE.md: enemy actors don't honour spawn_point) makes a
125
+ # clean per-spawn enemy partition geometrically infeasible —
126
+ # foreign-latitude 4tnks always place and drift to the active
127
+ # base, over-pressuring the tuned defender ring and destroying
128
+ # the fact (converting the recovery test into a fact-defence
129
+ # test). Spawn variation here would compete with the recovery-
130
+ # discipline signal for attribution.
131
+
132
+ meta:
133
+ id: rob-cash-depletion-recovery
134
+ title: 'Recover From Cash Depletion — Rebuild Refinery After Mid-Episode Strike'
135
+ capability: reasoning
136
+ real_world_meaning: >
137
+ Financial recovery after a sudden cash drain caused by an
138
+ emergency outflow that destroys the operation's income channel.
139
+ The agent inherits a normal operating economy with just enough
140
+ reserve cash for ONE refinery rebuild; an exogenous strike
141
+ destroys the refinery mid-episode; income halts. The agent must
142
+ notice the loss, commit the indivisible reserve to rebuilding the
143
+ refinery, and redirect the harvester back to the patch — fast
144
+ enough that the revenue bar is still cleared before the deadline.
145
+ Stalling, ignoring the destruction, or spending the reserve on
146
+ army units all miss the bar.
147
+ robotics_analogue: >
148
+ Post-disaster operations restart after the critical processing
149
+ node is destroyed by an external event mid-mission. The
150
+ autonomous operator has one collector still alive, no working
151
+ capital beyond a single processing-node rebuild allowance, and a
152
+ deadline. Recovery requires identifying the lost asset,
153
+ committing the reserve to rebuilding it (not to defenders or
154
+ expansion), and re-tasking the collector back to the source once
155
+ the new node is operational.
156
+ benchmark_anchor:
157
+ - "financial cash-crisis recovery"
158
+ - "SC2 cash-drain rebuild"
159
+ - "DR after critical-infra loss"
160
+ - "bankruptcy turnaround under deadline"
161
+ author: openra-bench-wave-6
162
+
163
+ base_map: rush-hour-arena
164
+ # Indivisible rebuild reserve: 1400 = exactly one proc. Any pre-spend
165
+ # starves the rebuild → the income channel never reopens → EV bar
166
+ # never reached → LOSS.
167
+ starting_cash: 1400
168
+
169
+ base:
170
+ agent: {faction: allies}
171
+ # `hunt` worker bot: each enemy unit attacks the NEAREST agent
172
+ # actor (including buildings). With 4tnk attackers placed close to
173
+ # the proc and only 1-2 own 3tnk defenders staged further from the
174
+ # ingress lane than the proc itself, the closest foe is the proc
175
+ # and the 4tnk commits to destroying it (then retargets to the
176
+ # next nearest building — tent on hard).
177
+ enemy: {faction: soviet, cash: 0, bot_type: hunt}
178
+ # Spec-required toolset (Wave-6 spec): all 8 tools.
179
+ tools: [observe, build, place_building, harvest, move_units, attack_unit, attack_move, stop]
180
+ # Default `spawn_mcvs: false` — we declare actors ourselves per
181
+ # level so the engine doesn't add a phantom MCV.
182
+ spawn_mcvs: false
183
+ planning: true
184
+ termination: {max_ticks: 8000}
185
+ actors: []
186
+
187
+ levels:
188
+ # ── EASY ─────────────────────────────────────────────────────────
189
+ # The bare skill: a LIGHT strike (1× 4tnk) that the 2 pre-placed
190
+ # 3tnk defenders intercept before the proc falls. The agent never
191
+ # has to rebuild; the discriminator is "stall LOSES (no harvest
192
+ # income → EV bar unmet → reachable timeout LOSS), any committed
193
+ # harvest WINS". `building_count_gte:proc:1` is satisfied trivially
194
+ # by the surviving original. max_turns 70 → ceiling 93 + 90·69 =
195
+ # 6303 > 6300 ✓.
196
+ easy:
197
+ description: >
198
+ You start with a Construction Yard ('fact'), a Power Plant
199
+ ('powr'), an Ore Refinery ('proc'), one Ore Truck ('harv'), two
200
+ ore patches near the refinery, two Light Tank ('3tnk')
201
+ defenders, and $1400 cash. A lone enemy heavy tank is staged
202
+ adjacent to the refinery; your defenders will intercept it
203
+ before it can destroy the refinery (the strike is light enough
204
+ that proc survives intact). To collect ore, first `move_units`
205
+ the harv onto a patch cell ((22,18) or (22,20)) then issue
206
+ `harvest` with that cell as the target (the auto-cycle
207
+ continues on its own). Win = economy value (cash + stored ore)
208
+ ≥ 2500 AND you still own a 'proc' AND a 'fact', before tick
209
+ 6300. Stalling (only `observe`) earns nothing and loses on the
210
+ clock — your starting $1400 alone does NOT clear the 2500 bar
211
+ without harvest income.
212
+ starting_cash: 1400
213
+ overrides:
214
+ actors:
215
+ - {type: fact, owner: agent, position: [8, 18]}
216
+ # Powr placed WEST of the fact (far from the strike lane) so
217
+ # it survives the attack and provides the `proc` rebuild
218
+ # prerequisite (gamerules.rs line 343 — proc.prerequisites =
219
+ # ["powr"]).
220
+ - {type: powr, owner: agent, position: [5, 18]}
221
+ - {type: proc, owner: agent, position: [12, 18]}
222
+ # Harvester staged WEST of the proc (x=10, behind the proc
223
+ # relative to the attacker) so the closest agent foe to the
224
+ # 4tnk is the defender, then the proc — the harv is shielded
225
+ # and survives the strike intact.
226
+ - {type: harv, owner: agent, position: [10, 18]}
227
+ # TWO 3tnk defenders ringed east of the proc — combined
228
+ # 60 dps vs the lone 4tnk's 40 dps; the 4tnk dies before it
229
+ # can land lethal damage on the proc (probe-verified: proc
230
+ # SURVIVES the full episode at this attacker:defender ratio).
231
+ - {type: '3tnk', owner: agent, position: [18, 17]}
232
+ - {type: '3tnk', owner: agent, position: [18, 19]}
233
+ # Two ore patches east of the proc — sustained harvest target.
234
+ - {type: mine, owner: neutral, position: [22, 18]}
235
+ - {type: mine, owner: neutral, position: [22, 20]}
236
+ # LIGHT strike: one enemy 4tnk staged adjacent to the
237
+ # refinery at (15,18). The closest agent foes are the
238
+ # defender ring at (18,17)/(18,19) — the 4tnk engages them
239
+ # first and dies before it can finish the proc. The agent
240
+ # never has to rebuild on easy.
241
+ - {type: '4tnk', owner: enemy, position: [15, 18]}
242
+ # Persistent far enemy marker — LOSS-not-DRAW guarantee
243
+ # (keeps the engine from auto-`done` when the strike force
244
+ # falls before the EV bar / deadline is evaluated).
245
+ - {type: fact, owner: enemy, position: [120, 20]}
246
+ win_condition:
247
+ all_of:
248
+ - economy_value_gte: 2500
249
+ - building_count_gte: {type: proc, n: 1}
250
+ - building_count_gte: {type: fact, n: 1}
251
+ - within_ticks: 6300
252
+ fail_condition:
253
+ any_of:
254
+ - after_ticks: 6301
255
+ - not: {building_count_gte: {type: fact, n: 1}}
256
+ max_turns: 70
257
+
258
+ # ── MEDIUM ───────────────────────────────────────────────────────
259
+ # +1 controlled variable on top of easy: TWO 4tnk attackers vs
260
+ # THREE defenders. The defenders kill the strike force AFTER it
261
+ # destroys the proc (probe-measured: proc dies tick 453); the
262
+ # 4tnks then die before they can reach the fact, so the rebuild
263
+ # is viable. The agent MUST rebuild the proc with the 1400 reserve
264
+ # and re-issue harvest. Stall (no rebuild) → `building_count_gte:
265
+ # proc:1` clause fails → LOSS. Spending reserve on army (powr 300
266
+ # / tent 500 / e1) → cash drops below 1400 → proc cannot be queued
267
+ # before the deadline → LOSS.
268
+ medium:
269
+ description: >
270
+ Same base as easy (fact, powr, proc, 1 harv, 2 mines, $1400
271
+ reserve) but the enemy strike is now TWO heavy tanks adjacent
272
+ to the refinery; even with three light-tank defenders, the
273
+ refinery is destroyed early in the episode (around tick 450).
274
+ The harvester survives but the harvest loop stops because the
275
+ refinery is gone. To recover: queue another Refinery (`build`
276
+ "proc", cost 1400), place it adjacent to the fact, then re-
277
+ issue `harvest` to the surviving harv (the destruction event
278
+ interrupted the harvest loop). Win = economy_value ≥ 2500 AND
279
+ you own a 'proc' AND a 'fact', before tick 6300. Stalling,
280
+ ignoring the rebuild, or spending the 1400 reserve on army
281
+ units (powr/tent/e1) — which starves the proc rebuild — all
282
+ miss the bar.
283
+ starting_cash: 1400
284
+ overrides:
285
+ actors:
286
+ - {type: fact, owner: agent, position: [8, 18]}
287
+ # Powr placed WEST of the fact — survives the strike, provides
288
+ # the `proc` rebuild prerequisite (gamerules.rs line 343).
289
+ - {type: powr, owner: agent, position: [5, 18]}
290
+ - {type: proc, owner: agent, position: [12, 18]}
291
+ # Harv staged WEST of the proc (x=10) so it is shielded by
292
+ # the proc and the defender ring; the 4tnks engage the
293
+ # defenders/proc first and the harv survives the strike.
294
+ - {type: harv, owner: agent, position: [10, 18]}
295
+ # THREE 3tnk defenders — enough to kill the strike force
296
+ # AFTER it lands the proc kill. The DPS race is tuned so the
297
+ # 4tnks reach the proc, destroy it (probe: proc dies tick
298
+ # 453 ≈ turn 6 ≈ "~tick 600" target), and then die to the
299
+ # defenders' concentrated 90 dps — fact and powr survive
300
+ # so the rebuild can fund and the EV bar can be reached.
301
+ - {type: '3tnk', owner: agent, position: [18, 17]}
302
+ - {type: '3tnk', owner: agent, position: [18, 18]}
303
+ - {type: '3tnk', owner: agent, position: [18, 19]}
304
+ - {type: mine, owner: neutral, position: [22, 18]}
305
+ - {type: mine, owner: neutral, position: [22, 20]}
306
+ # MEDIUM strike: two 4tnks staged adjacent to the refinery
307
+ # at (14,17) and (14,19) — in melee range of the proc at
308
+ # x=12. The 4tnks (combined 80 dps) destroy the proc before
309
+ # the three defenders (combined 90 dps) can wipe them; the
310
+ # surviving 4tnk count then falls to zero after the proc
311
+ # kill so the fact remains intact (probe-verified).
312
+ - {type: '4tnk', owner: enemy, position: [14, 17]}
313
+ - {type: '4tnk', owner: enemy, position: [14, 19]}
314
+ - {type: fact, owner: enemy, position: [120, 20]}
315
+ win_condition:
316
+ all_of:
317
+ - economy_value_gte: 2500
318
+ - building_count_gte: {type: proc, n: 1}
319
+ - building_count_gte: {type: fact, n: 1}
320
+ - within_ticks: 6300
321
+ fail_condition:
322
+ any_of:
323
+ - after_ticks: 6301
324
+ - not: {building_count_gte: {type: fact, n: 1}}
325
+ max_turns: 70
326
+
327
+ # ── HARD ─────────────────────────────────────────────────────────
328
+ # +2 controlled variables vs medium:
329
+ # 1. FOUR 4tnk attackers (vs medium's two) + a tent placed
330
+ # adjacent to the proc — both proc AND tent are destroyed
331
+ # early in the episode (probe-measured: tent dies tick 183,
332
+ # proc tick 273). The lost tent removes the infantry
333
+ # production queue, so an army-rebuild detour (powr → tent →
334
+ # e1) is even more unaffordable in build time.
335
+ # 2. SAME indivisible $1400 reserve but DOUBLE the structural
336
+ # burden (proc + tent rebuild). The agent has exactly enough
337
+ # for the proc (the prereq for the harvester income channel);
338
+ # the tent rebuild can wait or be skipped entirely (it isn't
339
+ # part of the win predicate). The reasoning test is:
340
+ # prioritise the income-restoring proc rebuild, NOT the
341
+ # defensive-tech tent rebuild, even though more buildings
342
+ # are gone.
343
+ #
344
+ # NOTE on the spawn-variation contract: this pack is hard-tier
345
+ # NOT_APPLICABLE in tests/test_hard_tier.py — the recovery axis
346
+ # (strike severity → number of destroyed buildings → rebuild
347
+ # decision discipline) is the controlled variable, and the
348
+ # enemy-bot footgun (CLAUDE.md: enemy actors don't honour
349
+ # spawn_point) makes a clean per-spawn enemy partition
350
+ # geometrically impossible (foreign-latitude 4tnks drift to the
351
+ # active base and over-pressure the defender ring beyond the
352
+ # tuned proc-but-not-fact kill window). Spawn variation here
353
+ # would compete with the recovery-discipline signal for
354
+ # attribution, so the within-tier discriminator stays clean.
355
+ hard:
356
+ description: >
357
+ A harder version of the rebuild: FOUR enemy heavy tanks
358
+ attack the refinery AND a barracks ('tent') is placed in the
359
+ attack lane and is also destroyed early in the episode (tent
360
+ down by around tick 180, refinery by around tick 270). Your
361
+ reserve is still $1400 — exactly one refinery. To recover:
362
+ queue another Refinery (`build` "proc"), place it adjacent to
363
+ the fact, and re-issue `harvest` to the surviving harv. The
364
+ tent rebuild is OPTIONAL (it isn't part of the win predicate);
365
+ prioritise the income-restoring proc rebuild even though more
366
+ buildings are gone. Win = economy_value ≥ 2500 AND you own a
367
+ 'proc' AND a 'fact', before tick 6300. Stalling, ignoring the
368
+ rebuild, attempting to rebuild the tent first (no cash left
369
+ for the proc), or spending the reserve on army units all miss
370
+ the bar.
371
+ starting_cash: 1400
372
+ overrides:
373
+ actors:
374
+ - {type: fact, owner: agent, position: [8, 18]}
375
+ # Powr WEST of the fact — survives the strike, provides the
376
+ # `proc` rebuild prerequisite (gamerules.rs line 343).
377
+ - {type: powr, owner: agent, position: [5, 18]}
378
+ - {type: proc, owner: agent, position: [12, 18]}
379
+ # Harv WEST of the proc (x=10) — shielded by proc/defender
380
+ # ring, survives the strike intact for the rebuild-and-resume.
381
+ - {type: harv, owner: agent, position: [10, 18]}
382
+ # Tent placed adjacent to the proc (the attack lane) — the
383
+ # 4tnks engage tent and proc first; both fall by ~tick 270.
384
+ - {type: tent, owner: agent, position: [13, 18]}
385
+ # FIVE 3tnk defenders ringed east of the proc — concentrated
386
+ # 150 dps. After the 4tnks land the proc+tent kill, the
387
+ # defenders wipe them so fact and powr remain intact (probe-
388
+ # verified H3: 4×4tnk vs 5×3tnk → tent dies tick 183, proc
389
+ # tick 273, fact survives full 70 turns).
390
+ - {type: '3tnk', owner: agent, position: [18, 16]}
391
+ - {type: '3tnk', owner: agent, position: [18, 17]}
392
+ - {type: '3tnk', owner: agent, position: [18, 18]}
393
+ - {type: '3tnk', owner: agent, position: [18, 19]}
394
+ - {type: '3tnk', owner: agent, position: [18, 20]}
395
+ # Two ore patches east of the proc — sustained harvest target.
396
+ - {type: mine, owner: neutral, position: [22, 18]}
397
+ - {type: mine, owner: neutral, position: [22, 20]}
398
+ # HARD strike: FOUR 4tnks staged adjacent to the refinery at
399
+ # (14,15..18) — in melee range of the proc at x=12 and tent
400
+ # at x=13. The 4tnks (combined 160 dps) destroy tent first
401
+ # (lower HP) then proc; the 5 defenders (150 dps) wipe them
402
+ # shortly after, so fact and powr SURVIVE intact (probe-
403
+ # verified H3 + extended trace to tick 6303). The agent's
404
+ # rebuild path: build('proc') (1400) + place adjacent to
405
+ # fact + re-issue harvest. Tent rebuild is OPTIONAL (not
406
+ # part of the win clause).
407
+ - {type: '4tnk', owner: enemy, position: [14, 15]}
408
+ - {type: '4tnk', owner: enemy, position: [14, 17]}
409
+ - {type: '4tnk', owner: enemy, position: [14, 19]}
410
+ - {type: '4tnk', owner: enemy, position: [14, 21]}
411
+ # Persistent far enemy marker — LOSS-not-DRAW guarantee.
412
+ - {type: fact, owner: enemy, position: [120, 20]}
413
+ win_condition:
414
+ all_of:
415
+ - economy_value_gte: 2500
416
+ - building_count_gte: {type: proc, n: 1}
417
+ - building_count_gte: {type: fact, n: 1}
418
+ - within_ticks: 6300
419
+ fail_condition:
420
+ any_of:
421
+ - after_ticks: 6301
422
+ - not: {building_count_gte: {type: fact, n: 1}}
423
+ max_turns: 70
tests/test_hard_tier.py CHANGED
@@ -468,6 +468,24 @@ NOT_APPLICABLE = {
468
  "variable (route geometry) that competes with that signal for "
469
  "attribution. Hard tightens the patrol intensity (a second arc) "
470
  "and the attrition cap (units_lost_lte 1 → 0) instead.",
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
471
  }
472
 
473
  # No-adversary maps: spawn variation applies but a force-loss
 
468
  "variable (route geometry) that competes with that signal for "
469
  "attribution. Hard tightens the patrol intensity (a second arc) "
470
  "and the attrition cap (units_lost_lte 1 → 0) instead.",
471
+ # Wave-6 Group J robustness seed. Hard's controlled variable is
472
+ # the strike SEVERITY (one more 4tnk + tent added to the rubble
473
+ # pile). The enemy-bot footgun (CLAUDE.md: enemy actors don't
474
+ # honour spawn_point) makes a clean per-spawn enemy partition
475
+ # geometrically infeasible — foreign-latitude `hunt` 4tnks drift
476
+ # to the active base and over-pressure the defender ring beyond
477
+ # the tuned proc-but-not-fact kill window, converting clean LOSS
478
+ # discrimination into noisy across-seed instability. Spawn
479
+ # variation would compete with the recovery-discipline signal
480
+ # for attribution.
481
+ "rob-cash-depletion-recovery": "hard's controlled variable is "
482
+ "strike severity (proc + tent destroyed vs medium's proc only). "
483
+ "Enemy `hunt` actors don't honour spawn_point (CLAUDE.md "
484
+ "oramap.rs footgun) so foreign-latitude 4tnks drift to the "
485
+ "active spawn and over-pressure the tuned defender ring, "
486
+ "destroying the fact and converting the recovery test into a "
487
+ "fact-defence test. Spawn variation would compete with the "
488
+ "recovery-discipline signal for attribution.",
489
  }
490
 
491
  # No-adversary maps: spawn variation applies but a force-loss
tests/test_rob_cash_depletion_recovery.py ADDED
@@ -0,0 +1,424 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """No-cheat + solvency proof for `rob-cash-depletion-recovery` (Group J
2
+ robustness — mid-episode recovery after a scripted enemy strike
3
+ destroys the refinery and halts income).
4
+
5
+ The pack frames a CASH-CRISIS RECOVERY decision: the agent inherits a
6
+ normal operating economy (fact + proc + 1 harv + 2 mines + a small
7
+ defender squad) with $1400 cash — exactly enough to rebuild ONE
8
+ refinery (proc cost 1400). An enemy `hunt` 4tnk strike destroys the
9
+ proc mid-episode (medium ≈ tick 540; hard ≈ tick 450; on easy the
10
+ defenders intercept the lone 4tnk and the proc survives). The agent
11
+ must `build('proc') + place_building` and re-issue `harvest`.
12
+
13
+ For every level + every hard seed (1-4):
14
+ * INTENDED chain (notice the destruction, build proc, place, re-
15
+ issue harvest) WINS;
16
+ * STALL (only `observe`) LOSES every tier — no harvest income, EV
17
+ stays at the starting $1400, bar (2500) unmet, clock bites;
18
+ * NO-REBUILD (harvest with the starting harv but never `build`
19
+ proc) is the EASY FLOOR (wins easy where the proc survives) but
20
+ LOSES medium/hard (the structural `has_building:proc` clause
21
+ fails after the proc is destroyed);
22
+ * BUILD-ARMY (spend the 1400 reserve on powr → tent → e1 instead
23
+ of replacing the proc) LOSES medium/hard — the reserve is
24
+ starved, the proc rebuild never completes, the bar is unmet.
25
+ """
26
+
27
+ from __future__ import annotations
28
+
29
+ import pytest
30
+
31
+ pytest.importorskip("openra_train", reason="Rust env wheel not installed")
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 / "rob-cash-depletion-recovery.yaml"
38
+ LEVELS = ("easy", "medium", "hard")
39
+ SEEDS = (1, 2, 3, 4)
40
+
41
+
42
+ # ───────────────────────── helpers ────────────────────────────────────────
43
+
44
+
45
+ def _own_fact(bldgs):
46
+ """Return the AGENT-owned fact (skip the far enemy marker at x=120)."""
47
+ for b in bldgs:
48
+ if b.get("type") == "fact" and b.get("cell_x", 0) < 100:
49
+ return b
50
+ return None
51
+
52
+
53
+ def _patches_for_fact(fy: int):
54
+ """easy/medium: one fact at y=18 → patches at (22,18)/(22,20).
55
+ hard: NORTH y=14 → (22,14)/(22,16); SOUTH y=26 → (22,26)/(22,28)."""
56
+ if abs(fy - 18) <= 2:
57
+ return [(22, 18), (22, 20)]
58
+ if fy < 18:
59
+ return [(22, 14), (22, 16)]
60
+ return [(22, 26), (22, 28)]
61
+
62
+
63
+ def _ev(res):
64
+ return res.signals.cash + res.signals.resources
65
+
66
+
67
+ def _current_building_types(res):
68
+ """`signals.own_building_types` is accumulative (grows across the
69
+ episode); for "currently alive" checks use `signals.own_buildings`
70
+ (overwritten each observation)."""
71
+ return {t for (t, _, _) in res.signals.own_buildings}
72
+
73
+
74
+ # ───────────────────────── scripted policies ──────────────────────────────
75
+
76
+
77
+ def _stall(rs, Command):
78
+ """Idle: never harvests, never rebuilds. EV stays at starting
79
+ $1400, bar (2500) unmet → reachable timeout LOSS every tier."""
80
+ return [Command.observe()]
81
+
82
+
83
+ def _no_rebuild_factory():
84
+ """Run the starting harv but never `build` proc. WINS easy (the
85
+ proc survives there — the defenders intercept the lone 4tnk) and
86
+ LOSES medium/hard (after the proc is destroyed, the
87
+ `has_building:proc` clause fails permanently)."""
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
+ """Spend the reserve on powr → tent → e1 instead of rebuilding
120
+ the proc. The 1400 cash is drained on the wrong assets; the proc
121
+ rebuild never funds → `has_building:proc` fails permanently on
122
+ medium/hard → LOSS."""
123
+
124
+ state = {"moved": set()}
125
+
126
+ def policy(rs, Command):
127
+ units = rs.get("units_summary", []) or []
128
+ bldgs = rs.get("own_buildings", []) or []
129
+ own_types = {b["type"] for b in bldgs}
130
+ prod = rs.get("production", []) or []
131
+ cash = rs.get("cash", 0)
132
+ fact_b = _own_fact(bldgs)
133
+ if fact_b is None:
134
+ return [Command.observe()]
135
+ fx, fy = fact_b["cell_x"], fact_b["cell_y"]
136
+ patches = _patches_for_fact(fy)
137
+ harvs = [
138
+ (u["id"], u.get("cell_x"), u.get("cell_y"))
139
+ for u in units
140
+ if str(u.get("type", "")).lower() == "harv"
141
+ ]
142
+ cmds = []
143
+ for i, (uid, cx, cy) in enumerate(harvs):
144
+ uid_s = str(uid)
145
+ px, py = patches[i % len(patches)]
146
+ if uid_s not in state["moved"]:
147
+ cmds.append(Command.move_units([uid_s], target_x=px, target_y=py))
148
+ if abs(cx - px) <= 3 and abs(cy - py) <= 3:
149
+ state["moved"].add(uid_s)
150
+ else:
151
+ cmds.append(Command.harvest([uid_s], px, py))
152
+ if "powr" not in own_types:
153
+ if cash >= 300 and "powr" not in prod:
154
+ cmds.append(Command.build("powr"))
155
+ cmds.append(Command.place_building("powr", fx + 2, fy - 3))
156
+ elif "tent" not in own_types:
157
+ if cash >= 500 and "tent" not in prod:
158
+ cmds.append(Command.build("tent"))
159
+ cmds.append(Command.place_building("tent", fx + 2, fy + 3))
160
+ elif cash >= 100 and "e1" not in prod:
161
+ cmds.append(Command.build("e1"))
162
+ return cmds if cmds else [Command.observe()]
163
+
164
+ return policy
165
+
166
+
167
+ def _intended_rebuild_factory():
168
+ """Intended recovery chain: harvest with the starting harv → if
169
+ proc disappears, `build('proc')` + place adjacent to the fact →
170
+ once the new proc lands, re-issue `harvest` for the surviving
171
+ harv. WINS every tier × every seed (on easy the proc never
172
+ falls; on medium/hard the rebuild fires)."""
173
+
174
+ state = {"moved": set(), "placed_proc": False}
175
+
176
+ def policy(rs, Command):
177
+ units = rs.get("units_summary", []) or []
178
+ bldgs = rs.get("own_buildings", []) or []
179
+ own_types = [b["type"] for b in bldgs]
180
+ prod = rs.get("production", []) or []
181
+ cash = rs.get("cash", 0)
182
+ fact_b = _own_fact(bldgs)
183
+ if fact_b is None:
184
+ return [Command.observe()]
185
+ fx, fy = fact_b["cell_x"], fact_b["cell_y"]
186
+ patches = _patches_for_fact(fy)
187
+ n_proc = sum(1 for t in own_types if t == "proc")
188
+
189
+ harvs = [
190
+ (u["id"], u.get("cell_x"), u.get("cell_y"))
191
+ for u in units
192
+ if str(u.get("type", "")).lower() == "harv"
193
+ ]
194
+
195
+ cmds = []
196
+ # If proc is gone, queue a rebuild and spam place_building.
197
+ if n_proc < 1:
198
+ if cash >= 1400 and "proc" not in prod:
199
+ cmds.append(Command.build("proc"))
200
+ # Place at the original proc spot (fx+4, fy) — adjacent to
201
+ # the fact, in-bounds on the rush-hour map.
202
+ cmds.append(Command.place_building("proc", fx + 4, fy))
203
+
204
+ # Always nudge each harv onto a patch and harvest. Re-issue
205
+ # the harvest order EVERY turn so that when the new proc
206
+ # lands, the auto-cycle resumes (it does not auto-resume
207
+ # across a destroy-rebuild gap).
208
+ for i, (uid, cx, cy) in enumerate(harvs):
209
+ uid_s = str(uid)
210
+ px, py = patches[i % len(patches)]
211
+ if uid_s not in state["moved"]:
212
+ cmds.append(Command.move_units([uid_s], target_x=px, target_y=py))
213
+ if abs(cx - px) <= 3 and abs(cy - py) <= 3:
214
+ state["moved"].add(uid_s)
215
+ cmds.append(Command.harvest([uid_s], px, py))
216
+ return cmds if cmds else [Command.observe()]
217
+
218
+ return policy
219
+
220
+
221
+ def _run(level, policy_or_factory, seed=1):
222
+ c = compile_level(load_pack(PACK), level)
223
+ assert c.map_supported, "rush-hour-arena must compile"
224
+ pol = (
225
+ policy_or_factory()
226
+ if callable(policy_or_factory)
227
+ and policy_or_factory.__name__.endswith("factory")
228
+ else policy_or_factory
229
+ )
230
+ return c, run_level(c, pol, seed=seed)
231
+
232
+
233
+ # ───────────────────────── structural ─────────────────────────────────────
234
+
235
+
236
+ def test_pack_loads_with_three_levels_and_required_tools():
237
+ pack = load_pack(PACK)
238
+ assert pack.meta.id == "rob-cash-depletion-recovery"
239
+ assert pack.meta.capability == "reasoning"
240
+ assert pack.meta.status == "active"
241
+ assert set(pack.levels) == set(LEVELS)
242
+ c = compile_level(pack, "easy")
243
+ tools = set(c.scenario.tools or [])
244
+ # Spec-required tools (Wave-6 spec): all 8.
245
+ for t in (
246
+ "observe",
247
+ "build",
248
+ "place_building",
249
+ "harvest",
250
+ "move_units",
251
+ "attack_unit",
252
+ "attack_move",
253
+ "stop",
254
+ ):
255
+ assert t in tools, f"missing tool {t} in {tools}"
256
+
257
+
258
+ def test_starting_cash_is_one_proc_on_every_level():
259
+ """Reserve must fund EXACTLY one refinery rebuild — any other
260
+ value breaks the indivisible-rebuild test."""
261
+ pack = load_pack(PACK)
262
+ for L in LEVELS:
263
+ c = compile_level(pack, L)
264
+ assert c.starting_cash == 1400, (
265
+ f"{L}: starting_cash must equal proc cost 1400 (got {c.starting_cash})"
266
+ )
267
+
268
+
269
+ def test_benchmark_anchor_lists_financial_and_recovery():
270
+ pack = load_pack(PACK)
271
+ anchors = pack.meta.benchmark_anchor
272
+ assert anchors, "benchmark_anchor must be non-empty"
273
+ blob = " | ".join(anchors).lower()
274
+ assert "sc2" in blob or "cash" in blob, anchors
275
+ assert "recovery" in blob or "turnaround" in blob or "rebuild" in blob, anchors
276
+
277
+
278
+ @pytest.mark.parametrize("level", LEVELS)
279
+ def test_every_level_has_a_reachable_timeout_fail(level):
280
+ """Non-win must be a real LOSS, never a DRAW: after_ticks in
281
+ fail_condition must be reachable within max_turns. within_ticks
282
+ + 1 == after_ticks idiom."""
283
+ c = compile_level(load_pack(PACK), level)
284
+ assert c.fail_condition is not None
285
+ after_ticks = int(c.fail_condition.model_dump()["any_of"][0]["after_ticks"])
286
+ reachable = 93 + 90 * (c.max_turns - 1)
287
+ assert after_ticks <= reachable, (
288
+ f"{level}: fail after_ticks {after_ticks} unreachable within "
289
+ f"{c.max_turns} turns (max tick {reachable}) → draw degeneracy"
290
+ )
291
+ within_clauses = c.win_condition.model_dump().get("all_of", [])
292
+ wt = next(int(x["within_ticks"]) for x in within_clauses if "within_ticks" in x)
293
+ assert after_ticks == wt + 1, (
294
+ f"{level}: after_ticks {after_ticks} must equal within_ticks+1 ({wt + 1})"
295
+ )
296
+
297
+
298
+ def test_hard_strike_destroys_proc_and_tent_per_spec():
299
+ """Hard tier strike-severity contract (the controlled variable
300
+ vs medium): the scripted enemy strike must destroy BOTH proc
301
+ AND tent — not just one — so the rebuild decision discipline
302
+ (income-restoring proc rebuild prioritised over defensive-tech
303
+ tent rebuild) is exercised. Verified via the stall policy
304
+ (the strike outcome is deterministic regardless of agent
305
+ actions; stall lets the strike land cleanly)."""
306
+ c, r = _run("hard", _stall, seed=1)
307
+ # The proc and tent are pre-placed at start (additive set
308
+ # confirms ever-existed); the per-frame list confirms both are
309
+ # GONE by episode end (= scripted strike landed).
310
+ assert "proc" in r.signals.own_building_types, (
311
+ "proc must have been pre-placed (additive set)"
312
+ )
313
+ assert "tent" in r.signals.own_building_types, (
314
+ "tent must have been pre-placed (additive set)"
315
+ )
316
+ cur = _current_building_types(r)
317
+ assert "proc" not in cur, (
318
+ f"hard strike must destroy the proc; currently-alive types={cur}"
319
+ )
320
+ assert "tent" not in cur, (
321
+ f"hard strike must destroy the tent; currently-alive types={cur}"
322
+ )
323
+
324
+
325
+ # ───────────────────────── intended WIN bar ───────────────────────────────
326
+
327
+
328
+ @pytest.mark.parametrize("level", LEVELS)
329
+ @pytest.mark.parametrize("seed", SEEDS)
330
+ def test_intended_rebuild_wins(level, seed):
331
+ """Intended recovery (harvest with starting harv → on proc death,
332
+ build('proc') + place + re-harvest) WINS every tier × every seed."""
333
+ c, r = _run(level, _intended_rebuild_factory, seed=seed)
334
+ assert r.outcome == "win", (
335
+ f"{level} seed{seed}: intended rebuild should WIN, got {r.outcome}; "
336
+ f"ev={_ev(r)}, cash={r.signals.cash}, "
337
+ f"types={r.signals.own_building_types}, tick={r.signals.game_tick}"
338
+ )
339
+
340
+
341
+ # ───────────────────────── no-cheat: lazy plays LOSE ──────────────────────
342
+
343
+
344
+ @pytest.mark.parametrize("level", LEVELS)
345
+ @pytest.mark.parametrize("seed", SEEDS)
346
+ def test_stall_loses(level, seed):
347
+ """Stall (only `observe`) must LOSE every tier × every seed — no
348
+ harvest income, EV stays at $1400, bar 2500 unmet, clock bites."""
349
+ c, r = _run(level, _stall, seed=seed)
350
+ assert r.outcome == "loss", (
351
+ f"{level} seed{seed} stall must LOSE; got {r.outcome} "
352
+ f"(ev={_ev(r)}, tick={r.signals.game_tick})"
353
+ )
354
+
355
+
356
+ @pytest.mark.parametrize("seed", SEEDS)
357
+ def test_no_rebuild_is_easy_floor(seed):
358
+ """No-rebuild (harvest with starting harv only) is the EASY
359
+ FLOOR — the lone 4tnk is killed by the defender ring, the proc
360
+ survives, harvest income clears the 2500 bar comfortably."""
361
+ c, r = _run("easy", _no_rebuild_factory, seed=seed)
362
+ assert r.outcome == "win", (
363
+ f"easy seed{seed} no-rebuild should be the FLOOR and WIN; "
364
+ f"got {r.outcome} (ev={_ev(r)}, "
365
+ f"types={r.signals.own_building_types})"
366
+ )
367
+
368
+
369
+ @pytest.mark.parametrize("level", ("medium", "hard"))
370
+ @pytest.mark.parametrize("seed", SEEDS)
371
+ def test_no_rebuild_loses_medium_and_hard(level, seed):
372
+ """No-rebuild LOSES medium/hard — the strike destroys the proc;
373
+ the `has_building:proc` clause fails permanently regardless of
374
+ cash/EV. This is the discriminator that the recovery chain
375
+ (`build('proc') + place_building`) is actually exercised."""
376
+ c, r = _run(level, _no_rebuild_factory, seed=seed)
377
+ assert r.outcome == "loss", (
378
+ f"{level} seed{seed} no-rebuild must LOSE; got {r.outcome} "
379
+ f"(ev={_ev(r)}, types={r.signals.own_building_types})"
380
+ )
381
+ # The proc must have actually been destroyed for the
382
+ # discrimination to be real (not an EV-bar miss). Note:
383
+ # `own_building_types` is accumulative; use `own_buildings`
384
+ # (current per-frame list) for the "alive now" check.
385
+ cur = _current_building_types(r)
386
+ assert "proc" not in cur, (
387
+ f"{level}: no-rebuild should observe proc destroyed; "
388
+ f"currently-alive types={cur}"
389
+ )
390
+
391
+
392
+ @pytest.mark.parametrize("level", ("medium", "hard"))
393
+ @pytest.mark.parametrize("seed", SEEDS)
394
+ def test_build_army_loses_medium_and_hard(level, seed):
395
+ """Build-army (powr → tent → e1) drains the reserve on the wrong
396
+ assets; the proc rebuild never funds → `has_building:proc` clause
397
+ fails permanently → LOSS."""
398
+ c, r = _run(level, _build_army_factory, seed=seed)
399
+ assert r.outcome == "loss", (
400
+ f"{level} seed{seed} build-army must LOSE; got {r.outcome} "
401
+ f"(ev={_ev(r)}, types={r.signals.own_building_types})"
402
+ )
403
+ # Wrong-asset commit confirmed via the accumulative set (any
404
+ # type ever built shows here); proc CURRENTLY alive checked via
405
+ # the per-frame list.
406
+ cur = _current_building_types(r)
407
+ assert "proc" not in cur, (
408
+ f"{level}: build-army should NEVER re-fund the proc; "
409
+ f"currently-alive types={cur}"
410
+ )
411
+
412
+
413
+ # ───────────────────────── determinism ────────────────────────────────────
414
+
415
+
416
+ def test_outcomes_are_deterministic_per_seed():
417
+ """Same seed, same pack, same policy → identical outcome."""
418
+ c = compile_level(load_pack(PACK), "medium")
419
+ a = run_level(c, _intended_rebuild_factory(), seed=2)
420
+ b = run_level(c, _intended_rebuild_factory(), seed=2)
421
+ assert (a.outcome, a.turns, _ev(a)) == (b.outcome, b.turns, _ev(b)), (
422
+ f"determinism: {(a.outcome, a.turns, _ev(a))} vs "
423
+ f"{(b.outcome, b.turns, _ev(b))}"
424
+ )