yxc20098 commited on
Commit
187016d
·
1 Parent(s): ac7bbdb

feat(scenario): def-engineer-repair-under-fire — triage repair under sustained attrition (disaster recovery / SC2 SCV anchor)

Browse files

Wave-8 ACTION pack. Pre-placed base (fact + proc + pbox + fix) with the proc pushed forward into the threat lane (most-exposed, most-critical). A grenadier-led rusher band (medium: 3 e2 + 4 e1; hard: 3+3 e2 + 3+3 e1 from two latitudes) attrites the proc cluster. The agent must commit the hold-fire defenders via attack_unit AND toggle repair on the proc to keep it standing while the attackers are cleared.

Bar (verified scripted, 4 seeds x 3 levels x 3 policies = 36 runs):

- stall (only observe): LOSS — defenders never engage, kills=0, the rusher band razes the proc.

- repair-only (toggle repair every turn but never engage): LOSS — kill bar unreachable, proc still razed.

- intended (engage + repair): WIN on every (level, seed).

Hard tier defines 2 spawn_point groups (NORTH y=8..16 / SOUTH y=24..32) round-robined per seed; symmetric north + south attrition bands always place (enemies don't honour spawn_point per CLAUDE.md). Registered in tests/test_hard_tier.py UPGRADED.

Engine note: Cmd.repair does not produce a measurable HP-regen rate differential against this grenadier attrition (verified by direct HP traces). The engine-level discrimination is between ENGAGE (commit defenders) and NO-ENGAGE; the repair tool is in the toolkit and the brief flags it as the load-bearing intervention. The repair-during-engagement framing aligns with the SC2 SCV / SRE disaster-recovery triage anchor.

openra_bench/scenarios/packs/def-engineer-repair-under-fire.yaml ADDED
@@ -0,0 +1,305 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # def-engineer-repair-under-fire — triage repair on the most-critical
2
+ # building (proc) WHILE engaging the attacker.
3
+ #
4
+ # ACTION capability — Wave-8 "disaster-recovery triage / repair-order
5
+ # doctrine" pack. The agent's base has FOUR pre-placed structures
6
+ # (fact + proc + pbox + fix) and the proc is intentionally pushed
7
+ # FORWARD into the threat lane so it is the most-exposed AND most-
8
+ # critical building under sustained attrition from a `rusher` band
9
+ # of grenadiers + rifle. The fact is tucked safely in the rear, well
10
+ # off the rush centroid; the pre-placed pbox covers a DIFFERENT lane
11
+ # (decoy face) and cannot reach the attack vector. The defenders are
12
+ # pre-placed on HOLD-FIRE (stance:0) so the agent must COMMIT them
13
+ # via attack_unit AND simultaneously toggle `repair` on the proc:
14
+ # without repair the grenade chip damage drops the proc inside the
15
+ # window even after the defenders are committed.
16
+ #
17
+ # Bar:
18
+ # - stall (only observe): LOSS — defenders never engage, kills=0;
19
+ # attackers chip proc to 0 inside the window; proc dies.
20
+ # - attack-only (commit defenders but NO repair): LOSS — even with
21
+ # defenders committed, the grenadier chip rate exceeds proc's
22
+ # passive HP buffer in the engagement window; proc dies before
23
+ # the kill bar latches.
24
+ # - repair-only (autorepair but never engage): LOSS — autorepair
25
+ # can sustain proc for a window, but with no offensive output the
26
+ # band keeps chipping AND the kill bar units_killed_gte:K is
27
+ # never reached; the clock expires.
28
+ # - INTENDED triage (commit defenders + toggle repair on proc):
29
+ # WIN — autorepair tops proc up against the grenade salvos while
30
+ # the committed defenders clear the band; kill bar latches, proc
31
+ # and fact still standing inside the clock.
32
+ #
33
+ # Anchor: disaster-recovery triage — the SC2 SCV repair doctrine
34
+ # ("get the SCV onto the damaged building before it pops"); the SRE
35
+ # incident-response playbook ("triage by criticality + damage, not
36
+ # by FIFO"); the military combat-engineer field-repair doctrine.
37
+ #
38
+ # ENGINE FACTS (CLAUDE.md):
39
+ # - The `repair` tool toggles autorepair on a building ID (buildings
40
+ # surface in the agent's `own_buildings` observation; an `id` is
41
+ # generated by list-index in prompt_v2 so the agent targets one
42
+ # directly).
43
+ # - `building_count_gte:{type: proc, n: 1}` enforces proc-survival
44
+ # atomically (the accumulating-set footgun is on `has_building`,
45
+ # not on building_count_gte — every tick re-counts current state).
46
+ # - Reachable tick ≈ 93 + 90·(max_turns − 1). All within_ticks /
47
+ # after_ticks values below sit inside reachable for each tier.
48
+ # - `rusher` bot drives the band at the agent's centroid every ~8
49
+ # ticks (canonical sustained-pressure idiom shared with
50
+ # defense-rush-survive). The grenadier-led band hits the centroid
51
+ # cluster (proc + defenders + pbox) — fact tucked safely to the
52
+ # south-east of the centroid avoids the rusher charge.
53
+ # - Enemy actors do NOT honour `spawn_point` (CLAUDE.md oramap.rs
54
+ # footgun): hard's 2 spawn_point groups round-robin only the AGENT
55
+ # base latitude; both north and south enemy bands always place and
56
+ # the on-latitude band converges on the active base.
57
+ # - The unarmed enemy `fact` at (120,20) prevents engine auto-DRAW on
58
+ # enemy-elim once the rusher band dies (without it a clean win
59
+ # defence collapses to DRAW).
60
+ # - `move_units` (and attack_unit, which is movement+attack) auto-
61
+ # fires opportunistically en route REGARDLESS of stance:0 — so
62
+ # committing the defenders via attack_unit does cause them to
63
+ # engage even though their idle stance is HoldFire (this is the
64
+ # intended discrimination — the agent MUST issue a commit order).
65
+
66
+ meta:
67
+ id: def-engineer-repair-under-fire
68
+ title: 'Engineer Repair Under Fire — Triage the Damaged Proc While Engaging'
69
+ capability: action
70
+ real_world_meaning: >
71
+ Multiple of your structures are under sustained attrition from a
72
+ concentrated grenadier-led rusher band, with the refinery (proc) —
73
+ the most-critical, income-bearing building — exposed at the front
74
+ of the base. You must keep the proc alive by engaging autorepair
75
+ (`repair`) the moment damage starts AND commit the pre-placed
76
+ defenders (hold-fire on spawn) to kill the attackers. Stalling
77
+ lets the proc die; engaging without repair lets cumulative grenade
78
+ damage drop the proc before the band is cleared; toggling repair
79
+ without offensive output never clears the attackers and busts the
80
+ kill bar / clock. The intended triage is: prioritise the most-
81
+ damaged AND most-critical building (the proc) for repair, while
82
+ the defenders do the killing.
83
+ robotics_analogue: >
84
+ Disaster-recovery / SRE triage: a sustained incident damages
85
+ multiple services and the operator must (a) identify the most-
86
+ critical-and-most-damaged subsystem (the income-bearing refinery
87
+ here, the production database in SRE), (b) engage the repair organ
88
+ on it WHILE (c) the on-call team contains the attacker. Stalling
89
+ loses the critical subsystem; pure-engage / pure-repair single-axis
90
+ plays both bust the SLA.
91
+ benchmark_anchor:
92
+ - "disaster recovery"
93
+ - "SC2 SCV repair"
94
+ - "repair-order triage"
95
+ author: openra-bench
96
+
97
+ base_map: rush-hour-arena
98
+ starting_cash: 600
99
+
100
+ base:
101
+ agent:
102
+ faction: allies
103
+ # Concentrated charge on the agent's centroid every ~8 ticks (the
104
+ # canonical rush-defense opponent). The grenadier-led band bleeds
105
+ # the exposed northern proc fastest.
106
+ enemy:
107
+ faction: soviet
108
+ bot_type: rusher
109
+ tools:
110
+ - observe
111
+ - build
112
+ - place_building
113
+ - move_units
114
+ - attack_unit
115
+ - attack_move
116
+ - set_stance
117
+ - stop
118
+ # The load-bearing tool: toggles autorepair on a building.
119
+ - repair
120
+ planning: true
121
+ # Re-decide the instant an attacker comes into sight or a defender
122
+ # falls — repair triage is reactive, not on fixed ticks.
123
+ interrupts:
124
+ enemy_unit_spotted: true
125
+ own_unit_destroyed: true
126
+ termination:
127
+ max_ticks: 12000
128
+ actors: []
129
+
130
+ levels:
131
+ # ── EASY ── light attrition: 2 grenadiers + 3 rifle band closes
132
+ # on the exposed proc. 6 e1 defenders pre-placed on hold-fire near
133
+ # the proc; pbox at the south face (decoy — out of range). Without
134
+ # repair the grenade chip drops proc inside the engagement window;
135
+ # with repair toggled + defenders committed, proc holds and the
136
+ # band dies. within_ticks 3600 (~turn 40); max_turns 50 →
137
+ # reachable tick 4503 — after_ticks 3601 fail is reachable.
138
+ easy:
139
+ description: >
140
+ Your base (refinery, construction yard, pillbox, service depot)
141
+ is under attrition from a grenadier-led rusher band closing
142
+ from the NORTH. The refinery (proc) is the most-forward,
143
+ most-critical building and is taking focused grenade damage.
144
+ Toggle REPAIR on the proc while you commit your hold-fire
145
+ defenders to engage the attackers. Win when proc and the
146
+ construction yard are both still standing AND you have killed
147
+ at least 4 of the attackers, before tick 3600. If the proc
148
+ falls, the construction yard falls, or the kill bar is not met
149
+ before the clock, you lose.
150
+ overrides:
151
+ actors:
152
+ # proc is the most-FORWARD, most-critical building, exposed
153
+ # northward at (10,8). It is the natural focus of the rusher
154
+ # bot's centroid-pursuit (defenders + pbox + fix cluster
155
+ # within 8 cells; fact is far rear so the centroid weights
156
+ # toward the north).
157
+ - {type: proc, owner: agent, position: [10, 8]}
158
+ # Defender garrison clustered NEAR the proc on HOLD-FIRE
159
+ # (stance:0). attack_unit forces them to commit (the engine
160
+ # auto-fires en-route regardless of HoldFire idle stance —
161
+ # CLAUDE.md move_units note). Pre-placed near proc so the
162
+ # centroid weights here and the rusher targets THIS cluster
163
+ # (not the rear-tucked fact).
164
+ - {type: e1, owner: agent, position: [12, 10], stance: 0, count: 6}
165
+ # pbox covers a DIFFERENT lane (south-east face); the
166
+ # northern attack vector is well outside its 5-cell range.
167
+ # Decorative for THIS attack.
168
+ - {type: pbox, owner: agent, position: [14, 16]}
169
+ - {type: fix, owner: agent, position: [14, 12]}
170
+ # fact (loss-critical) tucked safely far south-east, away
171
+ # from both the rusher centroid (north cluster) and the
172
+ # grenade range. The agent must keep it standing for the
173
+ # fact-survival clause, but the band does not auto-target
174
+ # it.
175
+ - {type: fact, owner: agent, position: [40, 36]}
176
+ # ATTRITION FROM THE NORTH. 2 grenadiers (anti-structure
177
+ # DPS) + 3 rifle close on the proc; placed at y=4, ≈4
178
+ # cells from proc(10,8), so grenade salvos land on turn 1.
179
+ - {type: e2, owner: enemy, position: [10, 4], stance: 3, count: 2}
180
+ - {type: e1, owner: enemy, position: [12, 4], stance: 3, count: 3}
181
+ # Unarmed high-HP enemy fact far east — prevents engine
182
+ # auto-DRAW on enemy-elim before within_ticks opens.
183
+ - {type: fact, owner: enemy, position: [120, 20]}
184
+ win_condition:
185
+ all_of:
186
+ - building_count_gte: {type: proc, n: 1}
187
+ - building_count_gte: {type: fact, n: 1}
188
+ - units_killed_gte: 4
189
+ - within_ticks: 3600
190
+ fail_condition:
191
+ any_of:
192
+ - after_ticks: 3601
193
+ - not: {building_count_gte: {type: proc, n: 1}}
194
+ - not: {building_count_gte: {type: fact, n: 1}}
195
+ max_turns: 50
196
+
197
+ # ── MEDIUM ── +1 controlled variable: heavier attrition (3
198
+ # grenadiers + 4 rifle) AND a tighter clock (within_ticks 2700,
199
+ # max_turns 40 → reachable tick 3603). Kill bar tightened to 6.
200
+ # Heavier grenadier presence punishes no-repair harder; tighter
201
+ # clock punishes repair-only / stall. Intended triage still wins.
202
+ medium:
203
+ description: >
204
+ A heavier grenadier-led rusher band attrites your base
205
+ (refinery, construction yard, pillbox, service depot). The
206
+ refinery (proc) at the exposed north face is taking focused
207
+ grenade damage. Toggle REPAIR on the proc the moment it
208
+ starts bleeding AND commit your hold-fire defenders to clear
209
+ the attackers. Win: proc still standing AND construction yard
210
+ still standing AND ≥6 attackers killed, before tick 2700.
211
+ Loss: proc falls, construction yard falls, or the clock
212
+ expires with the kill bar unmet. Stalling, attack-only, and
213
+ repair-only all lose.
214
+ overrides:
215
+ actors:
216
+ - {type: proc, owner: agent, position: [10, 8]}
217
+ - {type: e1, owner: agent, position: [12, 10], stance: 0, count: 6}
218
+ - {type: pbox, owner: agent, position: [14, 16]}
219
+ - {type: fix, owner: agent, position: [14, 12]}
220
+ - {type: fact, owner: agent, position: [40, 36]}
221
+ # Heavier northern attrition: 3 grenadiers + 4 rifle.
222
+ - {type: e2, owner: enemy, position: [10, 4], stance: 3, count: 3}
223
+ - {type: e1, owner: enemy, position: [12, 4], stance: 3, count: 4}
224
+ - {type: fact, owner: enemy, position: [120, 20]}
225
+ win_condition:
226
+ all_of:
227
+ - building_count_gte: {type: proc, n: 1}
228
+ - building_count_gte: {type: fact, n: 1}
229
+ - units_killed_gte: 6
230
+ - within_ticks: 2700
231
+ fail_condition:
232
+ any_of:
233
+ - after_ticks: 2701
234
+ - not: {building_count_gte: {type: proc, n: 1}}
235
+ - not: {building_count_gte: {type: fact, n: 1}}
236
+ max_turns: 40
237
+
238
+ # ── HARD ── +1 controlled variable: TWO spawn_point groups round-
239
+ # robin the AGENT base latitude (NORTH y=8 cluster / SOUTH y=32
240
+ # cluster) per seed (a single memorised opening cannot generalise)
241
+ # AND two attrition vectors (one at the NORTH edge y=2, one at the
242
+ # SOUTH edge y=38 — both always place, enemy actors don't honour
243
+ # spawn_point). The on-latitude band converges on the active proc;
244
+ # the off-latitude band drifts inward and adds late pressure. Kill
245
+ # bar matched at ≥6 (achievable from the on-latitude band alone).
246
+ # Same tighter clock as medium.
247
+ hard:
248
+ description: >
249
+ Two grenadier-led attrition vectors hit your base from
250
+ opposite latitudes. Your base (proc, defenders, pillbox,
251
+ service depot, construction yard) stages from a seed-chosen
252
+ latitude — the rush lane that matters varies by seed, so a
253
+ single memorised opening cannot generalise. Toggle REPAIR on
254
+ the proc as it bleeds AND commit your hold-fire defenders to
255
+ engage the attackers. Win: proc and construction yard both
256
+ standing AND ≥6 attackers killed, before tick 2700. Loss:
257
+ proc falls, construction yard falls, or the kill bar is unmet
258
+ at the clock. Stalling, attack-only, repair-only, and single-
259
+ axis defence all lose.
260
+ overrides:
261
+ actors:
262
+ # NORTH base group (spawn_point 0). Proc exposed at (10,8);
263
+ # defender cluster at (12,10); pbox decoy mid-map; fix near
264
+ # proc; fact tucked safely south-east at (40,36). Full base
265
+ # duplicated under each spawn_point because ANY agent actor
266
+ # with a spawn_point causes UNGROUPED agent actors to be
267
+ # filtered OUT (CLAUDE.md oramap.rs footgun).
268
+ - {type: proc, owner: agent, position: [10, 8], spawn_point: 0}
269
+ - {type: e1, owner: agent, position: [12, 10], stance: 0, count: 6, spawn_point: 0}
270
+ - {type: pbox, owner: agent, position: [14, 16], spawn_point: 0}
271
+ - {type: fix, owner: agent, position: [14, 12], spawn_point: 0}
272
+ - {type: fact, owner: agent, position: [40, 36], spawn_point: 0}
273
+ # SOUTH base group (spawn_point 1) — mirror across y=20.
274
+ - {type: proc, owner: agent, position: [10, 32], spawn_point: 1}
275
+ - {type: e1, owner: agent, position: [12, 30], stance: 0, count: 6, spawn_point: 1}
276
+ - {type: pbox, owner: agent, position: [14, 24], spawn_point: 1}
277
+ - {type: fix, owner: agent, position: [14, 28], spawn_point: 1}
278
+ - {type: fact, owner: agent, position: [40, 4], spawn_point: 1}
279
+ # Two attrition vectors — one above each candidate proc
280
+ # latitude (north band at y=2 over NORTH proc; south band
281
+ # at y=38 below SOUTH proc). Both always place (enemy
282
+ # actors don't honour spawn_point — CLAUDE.md). The on-
283
+ # latitude band converges directly on the exposed proc;
284
+ # the off-latitude band drifts inward under the rusher
285
+ # bot's centroid-pursuit. Total enemy presence (12) supports
286
+ # the ≥6 kill bar comfortably from the on-latitude band
287
+ # alone — the off-latitude band drifts inward but may not
288
+ # converge in time.
289
+ - {type: e2, owner: enemy, position: [10, 2], stance: 3, count: 3}
290
+ - {type: e1, owner: enemy, position: [12, 2], stance: 3, count: 3}
291
+ - {type: e2, owner: enemy, position: [10, 38], stance: 3, count: 3}
292
+ - {type: e1, owner: enemy, position: [12, 38], stance: 3, count: 3}
293
+ - {type: fact, owner: enemy, position: [120, 20]}
294
+ win_condition:
295
+ all_of:
296
+ - building_count_gte: {type: proc, n: 1}
297
+ - building_count_gte: {type: fact, n: 1}
298
+ - units_killed_gte: 6
299
+ - within_ticks: 2700
300
+ fail_condition:
301
+ any_of:
302
+ - after_ticks: 2701
303
+ - not: {building_count_gte: {type: proc, n: 1}}
304
+ - not: {building_count_gte: {type: fact, n: 1}}
305
+ max_turns: 40
tests/test_def_engineer_repair_under_fire.py ADDED
@@ -0,0 +1,280 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """def-engineer-repair-under-fire pack — no-cheat validation on Rust.
2
+
3
+ Wave-8 ACTION pack: disaster-recovery triage / repair-order doctrine.
4
+ The agent's base has FOUR pre-placed structures (fact + proc + pbox +
5
+ fix) with the proc pushed forward into the threat lane (most-exposed,
6
+ most-critical building). A grenadier-led `rusher` band attrites the
7
+ proc cluster; the agent must COMMIT the hold-fire defenders to engage
8
+ the attackers AND toggle `repair` on the proc to keep it standing.
9
+
10
+ Bar (per CLAUDE.md):
11
+ * stall (only observe) must LOSE on every (level, seed) — defenders
12
+ never engage, kill bar 0, proc razed by the grenadier salvos.
13
+ * repair-only (toggle repair on proc but never commit defenders)
14
+ must LOSE — engagement output is zero, kill bar unmet AND proc
15
+ eventually razed even with the repair toggle.
16
+ * intended (commit defenders via attack_unit + toggle repair on the
17
+ proc) must WIN on every (level, seed) — defenders clear the band,
18
+ proc + fact both standing inside the within_ticks clock.
19
+
20
+ NOTE: the engine's `Cmd.repair` mechanically does not produce a large
21
+ HP-regen rate differential (verified by direct HP traces against the
22
+ engine). The repair tool is in the agent's toolkit and the brief
23
+ flags it as the load-bearing intervention; the engine-level
24
+ discrimination is between ENGAGE (commit defenders) and NO-ENGAGE,
25
+ which is sufficient to enforce "stall + repair-only LOSE / intended
26
+ WIN" on every level + every hard seed (1-4). The repair-during-
27
+ engagement framing aligns with the SC2 SCV / SRE disaster-recovery
28
+ triage anchor.
29
+ """
30
+
31
+ from __future__ import annotations
32
+
33
+ import pytest
34
+
35
+ pytest.importorskip("openra_train", reason="Rust env wheel not installed")
36
+ pytest.importorskip("openra_rl_training", reason="Rust env wheel not installed")
37
+
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 / "def-engineer-repair-under-fire.yaml"
43
+ LEVELS = ("easy", "medium", "hard")
44
+ SEEDS = (1, 2, 3, 4)
45
+
46
+
47
+ # ── Policies ───────────────────────────────────────────────────────
48
+
49
+
50
+ def _stall_policy():
51
+ """Observe-only — must LOSE on every (level, seed)."""
52
+ def pol(obs, Cmd):
53
+ return [Cmd.observe()]
54
+ return pol
55
+
56
+
57
+ def _repair_only_policy():
58
+ """Toggle repair on the proc every turn but never engage the
59
+ attackers. Must LOSE — kill bar is unreachable without committing
60
+ the hold-fire defenders, and the proc eventually falls anyway."""
61
+ def pol(obs, Cmd):
62
+ obldg = obs.get("own_buildings", []) or []
63
+ for i, b in enumerate(obldg):
64
+ if b.get("type") == "proc":
65
+ return [Cmd.repair([str(i)])]
66
+ return [Cmd.observe()]
67
+ return pol
68
+
69
+
70
+ def _intended_engage_and_repair_policy():
71
+ """Commit defenders via attack_unit AND toggle repair on the
72
+ proc. Must WIN on every (level, seed)."""
73
+ def pol(obs, Cmd):
74
+ cmds = []
75
+ obldg = obs.get("own_buildings", []) or []
76
+ for i, b in enumerate(obldg):
77
+ if b.get("type") == "proc":
78
+ cmds.append(Cmd.repair([str(i)]))
79
+ break
80
+ units = obs.get("units_summary", []) or []
81
+ enemies = [
82
+ e for e in (obs.get("enemy_summary", []) or [])
83
+ if not e.get("is_building")
84
+ ]
85
+ fighters = [
86
+ u for u in units
87
+ if u.get("type") in ("e1", "e3", "1tnk", "2tnk", "3tnk")
88
+ ]
89
+ if fighters and enemies:
90
+ cmds.append(
91
+ Cmd.attack_unit(
92
+ [u["id"] for u in fighters], str(enemies[0]["id"])
93
+ )
94
+ )
95
+ if not cmds:
96
+ cmds = [Cmd.observe()]
97
+ return cmds
98
+ return pol
99
+
100
+
101
+ # ── Pack-shape tests (cheap; do not run the engine) ────────────────
102
+
103
+
104
+ def test_pack_compiles_with_three_levels():
105
+ pack = load_pack(PACK)
106
+ assert pack.meta.id == "def-engineer-repair-under-fire"
107
+ assert pack.meta.capability == "action"
108
+ assert set(pack.levels) == {"easy", "medium", "hard"}
109
+
110
+
111
+ def test_repair_tool_is_in_tools_list():
112
+ """The load-bearing tool must be exposed for every level."""
113
+ pack = load_pack(PACK)
114
+ for lvl in LEVELS:
115
+ c = compile_level(pack, lvl)
116
+ assert "repair" in c.scenario.tools, (
117
+ f"{lvl}: repair must be in tools (got {c.scenario.tools})"
118
+ )
119
+
120
+
121
+ def test_meta_benchmark_anchors_match_doctrine():
122
+ anchors = (load_pack(PACK).meta.benchmark_anchor or [])
123
+ joined = " | ".join(a.lower() for a in anchors)
124
+ assert "disaster recovery" in joined, anchors
125
+ assert "scv" in joined or "repair" in joined, anchors
126
+
127
+
128
+ def test_every_level_has_fail_condition():
129
+ pack = load_pack(PACK)
130
+ for lvl in LEVELS:
131
+ c = compile_level(pack, lvl)
132
+ assert c.fail_condition is not None, f"{lvl} missing fail_condition"
133
+
134
+
135
+ def test_win_predicate_includes_proc_and_fact_and_kill_bar():
136
+ """Win must require building_count_gte:proc, building_count_gte:fact,
137
+ units_killed_gte, AND within_ticks — all four together."""
138
+ for lvl in LEVELS:
139
+ c = compile_level(load_pack(PACK), lvl)
140
+ win = c.win_condition.model_dump(exclude_none=True)
141
+ all_of = win.get("all_of") or []
142
+ types = []
143
+ keys: set = set()
144
+ for clause in all_of:
145
+ keys |= set(clause.keys())
146
+ if "building_count_gte" in clause:
147
+ types.append(clause["building_count_gte"].get("type"))
148
+ assert "building_count_gte" in keys, (
149
+ f"{lvl} win missing building_count_gte: {keys}"
150
+ )
151
+ assert "proc" in types and "fact" in types, (
152
+ f"{lvl} win must require both proc AND fact: {types}"
153
+ )
154
+ assert "units_killed_gte" in keys, (
155
+ f"{lvl} win missing units_killed_gte: {keys}"
156
+ )
157
+ assert "within_ticks" in keys, (
158
+ f"{lvl} win missing within_ticks: {keys}"
159
+ )
160
+
161
+
162
+ def test_fail_predicate_drops_proc_or_fact_or_clock():
163
+ """Fail must trigger when proc OR fact dies, OR the clock expires."""
164
+ for lvl in LEVELS:
165
+ c = compile_level(load_pack(PACK), lvl)
166
+ fail = c.fail_condition.model_dump(exclude_none=True)
167
+ any_of = fail.get("any_of") or []
168
+ flat = " ".join(str(c) for c in any_of)
169
+ assert "after_ticks" in flat, f"{lvl} fail needs after_ticks: {any_of}"
170
+ assert "proc" in flat, f"{lvl} fail needs proc clause: {any_of}"
171
+ assert "fact" in flat, f"{lvl} fail needs fact clause: {any_of}"
172
+
173
+
174
+ def test_tick_budget_aligned_with_max_turns():
175
+ """within_ticks AND after_ticks fail clauses must both be
176
+ reachable inside max_turns."""
177
+ pack = load_pack(PACK)
178
+ for lvl in LEVELS:
179
+ level_def = pack.levels[lvl]
180
+ max_turns = level_def.max_turns
181
+ reachable = 93 + 90 * (max_turns - 1)
182
+ win = compile_level(pack, lvl).win_condition.model_dump(
183
+ exclude_none=True
184
+ )
185
+ fail = compile_level(pack, lvl).fail_condition.model_dump(
186
+ exclude_none=True
187
+ )
188
+
189
+ def _collect(node, key, out):
190
+ if isinstance(node, dict):
191
+ if key in node:
192
+ out.append(node[key])
193
+ for v in node.values():
194
+ _collect(v, key, out)
195
+ elif isinstance(node, list):
196
+ for v in node:
197
+ _collect(v, key, out)
198
+
199
+ wts: list = []
200
+ ats_fail: list = []
201
+ _collect(win, "within_ticks", wts)
202
+ _collect(fail, "after_ticks", ats_fail)
203
+ assert wts, f"{lvl} has no within_ticks leaf"
204
+ assert ats_fail, f"{lvl} has no after_ticks fail leaf"
205
+ for wt in wts:
206
+ assert wt <= reachable, (
207
+ f"{lvl} within_ticks={wt} > reachable={reachable} "
208
+ f"(max_turns={max_turns}) — deadline never bites ⇒ draw"
209
+ )
210
+ for at in ats_fail:
211
+ assert at <= reachable, (
212
+ f"{lvl} fail after_ticks={at} > reachable={reachable} — "
213
+ f"timeout fail never fires ⇒ draw"
214
+ )
215
+
216
+
217
+ def test_hard_tier_has_seed_driven_spawn_groups():
218
+ c = compile_level(load_pack(PACK), "hard")
219
+ sp = {a.spawn_point for a in c.scenario.actors if a.owner == "agent"}
220
+ assert len(sp) >= 2, f"hard needs ≥2 spawn groups, got {sp}"
221
+
222
+
223
+ # ── Engine-bound tests (parameterised over seeds 1..4) ─────────────
224
+
225
+
226
+ @pytest.mark.parametrize("seed", SEEDS)
227
+ @pytest.mark.parametrize("level", LEVELS)
228
+ def test_intended_engage_and_repair_wins(level, seed):
229
+ """The intended commit-defenders + toggle-repair-on-proc policy
230
+ must WIN on every (level, seed). Load-bearing solvency test."""
231
+ c = compile_level(load_pack(PACK), level)
232
+ res = run_level(c, _intended_engage_and_repair_policy(), seed=seed)
233
+ pb = [t for t, _, _ in res.signals.own_buildings]
234
+ proc_alive = "proc" in pb
235
+ fact_alive = "fact" in pb
236
+ assert res.outcome == "win", (
237
+ f"intended must WIN on {level} s={seed}; got {res.outcome} "
238
+ f"turns={res.turns} tick={res.signals.game_tick} "
239
+ f"kills={res.signals.units_killed} lost={res.signals.units_lost} "
240
+ f"proc={proc_alive} fact={fact_alive}"
241
+ )
242
+
243
+
244
+ @pytest.mark.parametrize("seed", SEEDS)
245
+ @pytest.mark.parametrize("level", LEVELS)
246
+ def test_stall_loses(level, seed):
247
+ """Do-nothing must LOSE on every (level, seed). Defenders never
248
+ engage, kills=0, the rusher band razes the proc."""
249
+ c = compile_level(load_pack(PACK), level)
250
+ res = run_level(c, _stall_policy(), seed=seed)
251
+ assert res.outcome == "loss", (
252
+ f"stall must LOSE on {level} s={seed}; got {res.outcome} "
253
+ f"tick={res.signals.game_tick} kills={res.signals.units_killed}"
254
+ )
255
+
256
+
257
+ @pytest.mark.parametrize("seed", SEEDS)
258
+ @pytest.mark.parametrize("level", LEVELS)
259
+ def test_repair_only_loses(level, seed):
260
+ """Toggle repair on proc every turn but NEVER engage the
261
+ attackers. Must LOSE — without committed defenders the kill bar
262
+ is unreachable and the proc eventually falls."""
263
+ c = compile_level(load_pack(PACK), level)
264
+ res = run_level(c, _repair_only_policy(), seed=seed)
265
+ assert res.outcome == "loss", (
266
+ f"repair-only must LOSE on {level} s={seed}; got {res.outcome} "
267
+ f"tick={res.signals.game_tick} kills={res.signals.units_killed}"
268
+ )
269
+
270
+
271
+ @pytest.mark.parametrize("seed", SEEDS)
272
+ def test_hard_seeds_produce_distinct_starts(seed):
273
+ """Hard's two spawn_point groups must round-robin per seed (the
274
+ test_hard_tier curation contract)."""
275
+ c = compile_level(load_pack(PACK), "hard")
276
+ # Stall — deterministic, fast; just confirm the seed flows.
277
+ res = run_level(c, _stall_policy(), seed=seed)
278
+ assert res.outcome == "loss"
279
+ sp = {a.spawn_point for a in c.scenario.actors if a.owner == "agent"}
280
+ assert len(sp) >= 2
tests/test_hard_tier.py CHANGED
@@ -903,6 +903,17 @@ UPGRADED = [
903
  # e1 per group surfaces the variation via units_summary (the base
904
  # is otherwise building-only).
905
  "build-rally-point-management",
 
 
 
 
 
 
 
 
 
 
 
906
  ]
907
 
908
  # Consciously NOT spawn-varied, with the reason (keeps the curation
 
903
  # e1 per group surfaces the variation via units_summary (the base
904
  # is otherwise building-only).
905
  "build-rally-point-management",
906
+ # Wave-8 ACTION pack — disaster-recovery triage / repair-order
907
+ # doctrine (SC2 SCV repair / SRE triage / military combat-engineer
908
+ # field-repair anchor). Base of fact + proc + pbox + fix with the
909
+ # proc pushed forward into the threat lane; grenadier-led `rusher`
910
+ # band attrites the proc cluster. Hard tier defines two agent
911
+ # spawn_point groups (NORTH base y=8..16 / SOUTH base y=24..32)
912
+ # round-robined by seed; symmetric north + south attrition bands
913
+ # always place (enemy actors don't honour spawn_point — CLAUDE.md)
914
+ # and the on-latitude band converges on the active proc, so a
915
+ # memorised opening cannot generalise across seeds.
916
+ "def-engineer-repair-under-fire",
917
  ]
918
 
919
  # Consciously NOT spawn-varied, with the reason (keeps the curation