yxc20098 commited on
Commit
ee008d0
·
1 Parent(s): f4f5f3d

feat(scenario): combat-protect-vip-escort — escort fragile VIP through cumulative arty walls (SC2 medivac / VIP protection anchor)

Browse files

Wave-6 ACTION cell. VIP=unique harv (unarmed); escort=4× 2tnk.
Cumulative arty walls scale 1/2/3 per tier; hard adds 2 agent
spawn_point groups (NORTH y=14 / SOUTH y=26) round-robined by seed.
Salvaged from main-worktree drop by W6 agent.

openra_bench/scenarios/packs/combat-protect-vip-escort.yaml ADDED
@@ -0,0 +1,379 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # combat-protect-vip-escort — Get the fragile VIP through, preserve it.
2
+ #
3
+ # ACTION focus: a single unique `harv` is the VIP — the only one of
4
+ # its type on the map AND structurally unarmed (the harvester
5
+ # carries no weapon: it can never return fire even on stance:3).
6
+ # It must reach the east extraction point (~100, 20). Four 2tnk
7
+ # escorts at the same west staging zone must clear a long-range
8
+ # artillery threat AND the rifle infantry walls strung along the
9
+ # route AND keep the VIP behind their advance, so the arty + e1
10
+ # squads burn DPS into 400-HP tank armour rather than the fragile
11
+ # harvester. The VIP cannot engage directly (no weapon). If the
12
+ # agent drives it into the arty's range envelope without the
13
+ # tanks suppressing the arty first, the arty (dps45) shreds the
14
+ # VIP in two-three decision turns; the escort tanks must lead,
15
+ # suppress the arty + the rifle walls, and only then does the
16
+ # VIP cross safely.
17
+ #
18
+ # Real-world anchor: VIP-protection / diplomatic-escort detail —
19
+ # the principal (POTUS limo, ambassador convoy, SecDef movement)
20
+ # is unarmed and must not engage; the protective detail moves in
21
+ # bounding formation ahead and to the flanks, neutralising threats
22
+ # along the route before the principal traverses. SC2 VIP-survival
23
+ # missions, MicroRTS fragile-target preservation, and military
24
+ # convoy doctrine all encode the same constraint: the high-value
25
+ # unit is preserved by the escort's offence, not by its own fight.
26
+ #
27
+ # Discrimination (the four-script bar — scripted, no model):
28
+ # • stall (observe-only): nothing moves; the VIP never reaches
29
+ # the extraction → after_ticks 4501 LOSS (no draw degeneracy).
30
+ # • VIP-alone: only the harvester is moved; with no escort it
31
+ # enters the arty envelope (rng 7c) unaccompanied and is the
32
+ # only target the arty has — arty dps45 vs harv burns the
33
+ # harvester down in 2-3 decision turns → `not unit_type_count_
34
+ # gte:{harv,1}` fail clause fires → LOSS.
35
+ # • VIP-leads-charge: harv + tanks dispatched in a single column.
36
+ # On medium and hard the cumulative arty + rifle-wall fire
37
+ # onto the column kills the VIP before extraction → LOSS. On
38
+ # easy the single rifle wall isn't enough cumulative damage,
39
+ # so charge MAY squeak by — the forgiving bare-skill tier
40
+ # (SCENARIO_REVIEW_CHECKLIST.md: "inert anti-cheat teeth are
41
+ # acceptable on easy").
42
+ # • intended escort-clears-path: escort tanks attack_move east
43
+ # AHEAD of the harv (4× 2tnk dps22 = 88 dps focus on the arty
44
+ # first — arty hp75 dies in ~1 decision turn of concentrated
45
+ # fire; then the tanks pick off the e1 walls). The harv hangs
46
+ # ~10 cells behind the rear-most tank so the arty + rifle fire
47
+ # auto-target the closer tanks; once tanks have cleared the
48
+ # route, the harv crosses safely → harv arrives at extraction
49
+ # alive within budget → WIN.
50
+ #
51
+ # Why the spec works (engine combat sheet):
52
+ # • harv = ore harvester (hp600 nominal, heavily armoured, slow,
53
+ # UNARMED — game_knowledge: "ore harvester (gathers ore for
54
+ # credits; unarmed)"; prompt_v2: "$1100 hp600 unarmed sight4c
55
+ # tracked"). With no weapon it cannot return fire even on
56
+ # stance:3 — "the VIP must not engage" is enforced by the unit
57
+ # kit. The harv is reasonably tough vs rifle fire (e1 dps6 vs
58
+ # armour) but FRAGILE vs arty (dps45) — arty puts the harv to
59
+ # 0 hp in ~2 decision turns of standing exposure.
60
+ # • e1 = basic rifle infantry (rng3c, dps6 vs armour, sight4c,
61
+ # hp50). Cheap to clear (one tank shot per e1). Their role
62
+ # here is to extend the fire envelope so a charge column
63
+ # accumulates attrition damage, AND so a focus-fire policy
64
+ # must pick the arty SPECIFICALLY rather than relying on
65
+ # attack_move auto-target (which spreads damage across all
66
+ # visible threats).
67
+ # • arty = artillery (rng7c, dps45 splash, hp75, sight5c). Long
68
+ # range (outranges 2tnk's 4.75c by 2.25c) — a stationary arty
69
+ # fires on closing tanks BEFORE they can return fire, so the
70
+ # escort takes attrition while approaching. Anti-armour DPS is
71
+ # high enough to kill the harv in ~2 decision turns of standing
72
+ # exposure. 4 tanks concentrating fire can kill 1 arty in ~1
73
+ # decision turn (88 dps vs hp75).
74
+ # • 2tnk = medium tank (rng4.75c, dps22, hp 400). Four tanks vs
75
+ # an e1 wall: combined dps 88 onto hp200 cleans a 4-e1 wall in
76
+ # ~2-3 decision turns.
77
+ # • Distance / time budget: extraction at (100,20), staging at
78
+ # (8,20) ⇒ ~92 cells. A 2tnk moves ~5-6 cells per decision turn
79
+ # uncontested, ~3-4 cells per turn while engaging. Escort
80
+ # timeline: ~5 turns to first contact (wall at x=30), ~3 turns
81
+ # to clear wall 1, ~2 turns to arty contact (x=49), ~2 turns
82
+ # focus-fire arty, ~5 turns to wall 2 (x=70), ~3 turns to
83
+ # clear it, ~5 turns final push — ~25 turns ⇒ ~2300 ticks,
84
+ # comfortably under within_ticks 4500.
85
+ #
86
+ # Engine guardrails (per CLAUDE.md):
87
+ # • Map: rush-hour-arena (128 × 40 playable; x in [2..126], y in
88
+ # [2..38]). All actor / region cells are inside.
89
+ # • CLAUDE.md footgun: enemy clusters at (50,20) silently FAIL to
90
+ # place — every enemy y row avoids that exact cell.
91
+ # • A persistent far enemy `fact` at (120,20) keeps the episode
92
+ # alive past the route enemies being cleaned out (so the
93
+ # win/fail evaluator sees the terminal frame; no DRAW collapse).
94
+ # • after_ticks fail clauses are reachable within max_turns
95
+ # (tick ≤ 93 + 90·(max_turns-1)); within_ticks 4500 < 4503 at
96
+ # max_turns 50; staller hits after_ticks 4501 and LOSES, not
97
+ # draws.
98
+ # • unit_type_count_gte:{type:harv,n:1} as a NEGATED fail clause
99
+ # fires the instant the VIP dies (signals.units_summary loses
100
+ # the harv entry → count == 0 → not(...) == True). That is the
101
+ # "VIP died" loss condition, distinct from total force wipe.
102
+ # • Interrupts are LEFT DISABLED in this pack: enemy_unit_spotted
103
+ # fires on each route enemy and step_until_event advances only
104
+ # ~5 ticks per interrupt — with 10+ enemies strung along the
105
+ # route the per-turn tick rate collapses and even the intended
106
+ # escort cannot reach the extraction inside the 4500-tick
107
+ # budget. Without interrupts each turn advances ~90 ticks
108
+ # uniformly and the escort completes in ~25 turns. The model
109
+ # still sees the full per-turn state every decision; reactive
110
+ # micro is not the controlled capability of this pack — escort
111
+ # LEADERSHIP (tanks-before-VIP) is.
112
+ # • The engine ignores the `health` placement field (verified by
113
+ # direct comparison: health=10/50/100 all give the same initial
114
+ # hp_ratio 1.0 and identical decay under enemy fire). The VIP's
115
+ # fragility is enforced via ENEMY CHOICE (arty dps45 vs harv's
116
+ # hp600 = ~13s to kill = ~2 decision turns) rather than reduced
117
+ # max-HP.
118
+ # • Spawn-driven hard tier: enemy actors don't honour spawn_point
119
+ # (CLAUDE.md), so the route enemies always place identically.
120
+ # Variation comes from AGENT staging row (NORTH y=14 / SOUTH
121
+ # y=26). The route enemies' y-band is widened (y=13..27 in hard)
122
+ # so both staging rows face an equivalent contested corridor.
123
+
124
+ meta:
125
+ id: combat-protect-vip-escort
126
+ title: 'Protect the VIP — Escort a Fragile Unit Through the Hazard'
127
+ capability: action
128
+ real_world_meaning: >
129
+ A single fragile high-value unit (the VIP — an unarmed
130
+ harvester, the only one of its kind on the map) must reach
131
+ the east extraction point. Four medium tanks at the same west
132
+ staging zone are the protective detail. The VIP cannot fight
133
+ (no weapon) and dies under sustained artillery fire, so the
134
+ escort must move AHEAD, clear the rifle infantry and the
135
+ long-range artillery strung along the route, and only then
136
+ let the VIP cross. Sending the VIP alone or at the head of
137
+ the column gets it killed; the escort's offence preserves
138
+ the VIP.
139
+ robotics_analogue: >
140
+ Protected-asset transit / VIP detail / diplomatic convoy: a
141
+ high-value principal (unarmed, fragile) must traverse a
142
+ contested route. The protective detail moves in bounding
143
+ formation ahead and to the flanks, neutralising threats along
144
+ the route before the principal traverses. The principal does
145
+ not engage directly — its survival depends on the detail's
146
+ offence, not its own fight.
147
+ benchmark_anchor:
148
+ - "SC2 VIP-survival missions"
149
+ - "military VIP protection doctrine"
150
+ - "diplomatic security / convoy escort"
151
+ - "fragile-target preservation tactics"
152
+ author: "openra-bench"
153
+
154
+ base_map: rush-hour-arena
155
+
156
+ base:
157
+ agent: {faction: allies, cash: 0}
158
+ enemy: {faction: soviet, cash: 0, bot_type: ''}
159
+ tools: [move_units, attack_unit, attack_move, stop]
160
+ planning: true
161
+ # Interrupts DISABLED — enemy_unit_spotted on a route with 10+
162
+ # spread-out enemies collapses per-turn ticks (step_until_event
163
+ # advances only ~5 ticks per interrupt) and the deadline becomes
164
+ # unreachable even for the intended escort. The model still sees
165
+ # the full per-turn state on every decision boundary; reactive
166
+ # micro is not the controlled capability of this pack.
167
+ interrupts: {}
168
+ termination: {max_ticks: 6000}
169
+ actors: [] # every level supplies its own actor list via overrides.
170
+
171
+ levels:
172
+ # ── EASY ─────────────────────────────────────────────────────────
173
+ # Bare VIP-escort skill: ONE rifle wall along the route (~x=70,
174
+ # 4× e1 across y=17..23) AND one long-range artillery piece at
175
+ # the centre (~x=49, off the (50,20) footgun column). The arty
176
+ # is what makes "VIP alone" a real death sentence — without it,
177
+ # the harv's 600 hp and rifle's dps6 vs armour lets the VIP
178
+ # outrun rifle fire entirely. Charge plays may squeak by on
179
+ # easy because there is only one rifle wall after the arty —
180
+ # the cumulative DPS into the VIP under charge isn't enough to
181
+ # kill it before extraction (forgiving bare-skill tier).
182
+ easy:
183
+ description: >
184
+ A single ore harvester (the VIP — unique unit type, unarmed
185
+ and fragile under sustained heavy fire) is staged at (8,20)
186
+ with four medium tank escorts. The VIP must reach the EAST
187
+ extraction point (x ≈ 100, y ≈ 20, radius 6) before tick
188
+ 4500, and MUST be alive on arrival (the harv is the only
189
+ one of its type; a destroyed harv fails the run). One rifle-
190
+ infantry wall (4× e1, vertically arrayed across y=17..23)
191
+ defends the route at x ≈ 70, and a single long-range
192
+ artillery piece (arty, range 7c, dps45) sits at the centre
193
+ (x ≈ 49). The escort tanks must move AHEAD of the VIP and
194
+ clear the artillery first; the unarmed VIP cannot return
195
+ fire and is shredded by the arty if it enters the envelope
196
+ unaccompanied.
197
+ overrides:
198
+ actors:
199
+ # VIP — the single unique harvester at the west staging
200
+ # zone. Unique-unit-type marker: the only harv on the map,
201
+ # so the "VIP alive" check is exactly
202
+ # `unit_type_count_gte:{harv,1}`. stance:0 (HoldFire) is
203
+ # the convoy-discipline posture (the harv is unarmed
204
+ # anyway, but the stance documents intent).
205
+ - {type: harv, owner: agent, position: [8, 20], stance: 0}
206
+ # Escorts — 4 medium tanks at the same west staging zone,
207
+ # in a 2-wide formation around the VIP so the agent can
208
+ # spawn them in a leading wedge. stance:1 (return fire) so
209
+ # the escorts retaliate when contacted but don't wander.
210
+ - {type: 2tnk, owner: agent, position: [8, 18], stance: 1}
211
+ - {type: 2tnk, owner: agent, position: [8, 19], stance: 1}
212
+ - {type: 2tnk, owner: agent, position: [8, 21], stance: 1}
213
+ - {type: 2tnk, owner: agent, position: [8, 22], stance: 1}
214
+ # Centre artillery — the load-bearing anti-VIP threat.
215
+ # arty rng 7c outranges 2tnk's 4.75c by 2.25c, so it gets
216
+ # opening shots while the escort closes. Off the (50,20)
217
+ # silently-failing column.
218
+ - {type: arty, owner: enemy, position: [49, 20], stance: 3}
219
+ # Rifle wall — extends the fire envelope and adds attrition
220
+ # damage to a charge play. 4× e1 across y=17..23 so the
221
+ # harv pathfinder cannot trivially detour around the wall.
222
+ - {type: e1, owner: enemy, position: [70, 17], stance: 3}
223
+ - {type: e1, owner: enemy, position: [70, 19], stance: 3}
224
+ - {type: e1, owner: enemy, position: [70, 21], stance: 3}
225
+ - {type: e1, owner: enemy, position: [70, 23], stance: 3}
226
+ # Persistent far enemy `fact` — keeps the episode alive
227
+ # past the route enemies being cleaned out so the win/fail
228
+ # evaluator sees the terminal frame (no DRAW collapse).
229
+ - {type: fact, owner: enemy, position: [120, 20]}
230
+ win_condition:
231
+ all_of:
232
+ - {units_of_type_in_region_gte: {type: harv, x: 100, y: 20, radius: 6, n: 1}}
233
+ - {unit_type_count_gte: {type: harv, n: 1}}
234
+ - {within_ticks: 4500}
235
+ fail_condition:
236
+ any_of:
237
+ - {after_ticks: 4501}
238
+ - {not: {unit_type_count_gte: {type: harv, n: 1}}}
239
+ max_turns: 50
240
+
241
+ # ── MEDIUM ───────────────────────────────────────────────────────
242
+ # +1 axis: a SECOND rifle wall (x=30) extends the fire envelope
243
+ # both before AND after the central artillery, so a single
244
+ # charging column accumulates more attrition while crossing.
245
+ # VIP-leads-charge LOSES on medium (the cumulative arty + 2-wall
246
+ # rifle fire onto the harv is enough to kill it before extraction)
247
+ # while easy's single-wall configuration may let charge squeak
248
+ # by. Stall / VIP-alone LOSE; intended escort WINS.
249
+ medium:
250
+ description: >
251
+ A single ore harvester (the VIP — unique unit type, unarmed
252
+ and fragile under sustained heavy fire) is staged at (8,20)
253
+ with four medium tank escorts. The VIP must reach the EAST
254
+ extraction point (x ≈ 100, y ≈ 20, radius 6) before tick
255
+ 4500, and MUST be alive on arrival. TWO rifle-infantry walls
256
+ (4× e1 each, vertically arrayed across y=17..23) defend the
257
+ route at x ≈ 30 and x ≈ 70, AND a long-range artillery
258
+ piece (arty, range 7c, dps45) sits at the centre (x ≈ 49).
259
+ The escort tanks must lead — focus-fire the artillery first
260
+ (it is the anti-VIP threat), then clear each rifle wall —
261
+ because the unarmed VIP dies under the cumulative arty +
262
+ rifle fire if it enters either wall's envelope without
263
+ escort suppression.
264
+ overrides:
265
+ actors:
266
+ - {type: harv, owner: agent, position: [8, 20], stance: 0}
267
+ - {type: 2tnk, owner: agent, position: [8, 18], stance: 1}
268
+ - {type: 2tnk, owner: agent, position: [8, 19], stance: 1}
269
+ - {type: 2tnk, owner: agent, position: [8, 21], stance: 1}
270
+ - {type: 2tnk, owner: agent, position: [8, 22], stance: 1}
271
+ # Centre artillery — the load-bearing anti-VIP threat
272
+ # (same as easy).
273
+ - {type: arty, owner: enemy, position: [49, 20], stance: 3}
274
+ # Rifle wall 1 — x ≈ 30 (BEFORE the arty so the charge
275
+ # column takes attrition before reaching the arty kill
276
+ # zone).
277
+ - {type: e1, owner: enemy, position: [30, 17], stance: 3}
278
+ - {type: e1, owner: enemy, position: [30, 19], stance: 3}
279
+ - {type: e1, owner: enemy, position: [30, 21], stance: 3}
280
+ - {type: e1, owner: enemy, position: [30, 23], stance: 3}
281
+ # Rifle wall 2 — x ≈ 70 (AFTER the arty).
282
+ - {type: e1, owner: enemy, position: [70, 17], stance: 3}
283
+ - {type: e1, owner: enemy, position: [70, 19], stance: 3}
284
+ - {type: e1, owner: enemy, position: [70, 21], stance: 3}
285
+ - {type: e1, owner: enemy, position: [70, 23], stance: 3}
286
+ - {type: fact, owner: enemy, position: [120, 20]}
287
+ win_condition:
288
+ all_of:
289
+ - {units_of_type_in_region_gte: {type: harv, x: 100, y: 20, radius: 6, n: 1}}
290
+ - {unit_type_count_gte: {type: harv, n: 1}}
291
+ - {within_ticks: 4500}
292
+ fail_condition:
293
+ any_of:
294
+ - {after_ticks: 4501}
295
+ - {not: {unit_type_count_gte: {type: harv, n: 1}}}
296
+ max_turns: 50
297
+
298
+ # ── HARD ─────────────────────────────────────────────────────────
299
+ # +1 axis vs medium: TWO seed-driven spawn groups (NORTH staging
300
+ # y≈14 OR SOUTH staging y≈26 — round-robined per seed) AND a
301
+ # THIRD rifle wall (x=55) tightens the route lethality enough
302
+ # that VIP-leads-charge LOSES on BOTH spawn variants (the
303
+ # cumulative arty + 3-wall rifle fire is unsurvivable). The
304
+ # rifle walls are vertically widened to y=13..27 so BOTH spawns
305
+ # face an equivalent contested corridor — the harv from EITHER
306
+ # staging row must still traverse the artillery + 3-wall
307
+ # gauntlet. The artillery and extraction at (100,20) are on the
308
+ # symmetry axis, so both spawn variants face the same anti-VIP
309
+ # geometry but a single memorised staging-to-extraction vector
310
+ # cannot generalise across seeds.
311
+ hard:
312
+ description: >
313
+ A single ore harvester (the VIP — unique unit type, unarmed
314
+ and fragile under sustained heavy fire) is staged with four
315
+ medium tank escorts at the NORTH staging row (y ≈ 14) or
316
+ the SOUTH staging row (y ≈ 26), chosen by seed for anti-
317
+ memorisation. The VIP must reach the EAST extraction point
318
+ (x ≈ 100, y ≈ 20, radius 6) before tick 4500, alive. THREE
319
+ vertically-widened rifle-infantry walls (6× e1 each, across
320
+ y=13..27) defend the route at x ≈ 30, x ≈ 55, and x ≈ 75,
321
+ AND a long-range artillery piece (arty, range 7c, dps45)
322
+ sits at the centre (x ≈ 49) — both NORTH and SOUTH staging
323
+ rows face an equivalent contested corridor. The escort
324
+ tanks must lead the way; the unarmed VIP dies under
325
+ sustained arty + 3-wall rifle fire if it enters any wall's
326
+ envelope unaccompanied or at the head of the column.
327
+ overrides:
328
+ actors:
329
+ # spawn_point 0 — NORTH staging at y ≈ 14.
330
+ - {type: harv, owner: agent, position: [8, 14], stance: 0, spawn_point: 0}
331
+ - {type: 2tnk, owner: agent, position: [8, 12], stance: 1, spawn_point: 0}
332
+ - {type: 2tnk, owner: agent, position: [8, 13], stance: 1, spawn_point: 0}
333
+ - {type: 2tnk, owner: agent, position: [8, 15], stance: 1, spawn_point: 0}
334
+ - {type: 2tnk, owner: agent, position: [8, 16], stance: 1, spawn_point: 0}
335
+ # spawn_point 1 — SOUTH staging at y ≈ 26 (mirrored across
336
+ # y=20).
337
+ - {type: harv, owner: agent, position: [8, 26], stance: 0, spawn_point: 1}
338
+ - {type: 2tnk, owner: agent, position: [8, 24], stance: 1, spawn_point: 1}
339
+ - {type: 2tnk, owner: agent, position: [8, 25], stance: 1, spawn_point: 1}
340
+ - {type: 2tnk, owner: agent, position: [8, 27], stance: 1, spawn_point: 1}
341
+ - {type: 2tnk, owner: agent, position: [8, 28], stance: 1, spawn_point: 1}
342
+ # Centre artillery — same anti-VIP threat as medium, on
343
+ # the y=20 symmetry axis so both spawn variants face it.
344
+ - {type: arty, owner: enemy, position: [49, 20], stance: 3}
345
+ # Rifle wall 1 — x=30, vertically widened y=13..27 so both
346
+ # NORTH and SOUTH staging rows face an equivalent corridor.
347
+ - {type: e1, owner: enemy, position: [30, 13], stance: 3}
348
+ - {type: e1, owner: enemy, position: [30, 16], stance: 3}
349
+ - {type: e1, owner: enemy, position: [30, 18], stance: 3}
350
+ - {type: e1, owner: enemy, position: [30, 22], stance: 3}
351
+ - {type: e1, owner: enemy, position: [30, 24], stance: 3}
352
+ - {type: e1, owner: enemy, position: [30, 27], stance: 3}
353
+ # Rifle wall 2 — x=55, same vertical span (off the (50,20)
354
+ # footgun column).
355
+ - {type: e1, owner: enemy, position: [55, 13], stance: 3}
356
+ - {type: e1, owner: enemy, position: [55, 16], stance: 3}
357
+ - {type: e1, owner: enemy, position: [55, 18], stance: 3}
358
+ - {type: e1, owner: enemy, position: [55, 22], stance: 3}
359
+ - {type: e1, owner: enemy, position: [55, 24], stance: 3}
360
+ - {type: e1, owner: enemy, position: [55, 27], stance: 3}
361
+ # Rifle wall 3 — x=75, same vertical span.
362
+ - {type: e1, owner: enemy, position: [75, 13], stance: 3}
363
+ - {type: e1, owner: enemy, position: [75, 16], stance: 3}
364
+ - {type: e1, owner: enemy, position: [75, 18], stance: 3}
365
+ - {type: e1, owner: enemy, position: [75, 22], stance: 3}
366
+ - {type: e1, owner: enemy, position: [75, 24], stance: 3}
367
+ - {type: e1, owner: enemy, position: [75, 27], stance: 3}
368
+ # Persistent far enemy fact — engine auto-done mitigation.
369
+ - {type: fact, owner: enemy, position: [120, 20]}
370
+ win_condition:
371
+ all_of:
372
+ - {units_of_type_in_region_gte: {type: harv, x: 100, y: 20, radius: 6, n: 1}}
373
+ - {unit_type_count_gte: {type: harv, n: 1}}
374
+ - {within_ticks: 4500}
375
+ fail_condition:
376
+ any_of:
377
+ - {after_ticks: 4501}
378
+ - {not: {unit_type_count_gte: {type: harv, n: 1}}}
379
+ max_turns: 50
tests/test_combat_protect_vip_escort.py ADDED
@@ -0,0 +1,407 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """combat-protect-vip-escort — escort a fragile VIP through hazards.
2
+
3
+ The bar (binding, applied seeds 1..4 on every level):
4
+ - intended escort-clears-path policy WINS;
5
+ - stall / VIP-alone / VIP-leads-charge LOSE on every level + every
6
+ hard seed (easy may be forgiving for some failure modes given the
7
+ sparser hazard — see per-test docstrings);
8
+ - non-win is a real reachable timeout / VIP-died LOSS (no DRAW
9
+ degeneracy: after_ticks 4501 ≤ 93 + 90·(max_turns − 1));
10
+ - hard ships ≥2 `spawn_point` groups (seed-driven start variation).
11
+
12
+ Validation is scripted (no model / network).
13
+ """
14
+ from __future__ import annotations
15
+
16
+ from pathlib import Path
17
+
18
+ import pytest
19
+
20
+ pytest.importorskip("openra_rl_training", reason="Rust env wheel not installed")
21
+ from openra_bench.scenarios import load_pack
22
+ from openra_bench.scenarios.loader import compile_level
23
+ from openra_bench.scenarios.win_conditions import WinContext, evaluate
24
+
25
+ PACKS = Path(__file__).parent.parent / "openra_bench" / "scenarios" / "packs"
26
+ PACK_PATH = PACKS / "combat-protect-vip-escort.yaml"
27
+
28
+
29
+ # ── declarative / schema invariants (no engine needed) ─────────────
30
+
31
+
32
+ def test_pack_compiles_and_meta_fields_populated():
33
+ pack = load_pack(PACK_PATH)
34
+ assert pack.meta.id == "combat-protect-vip-escort"
35
+ assert pack.meta.capability == "action"
36
+ assert pack.meta.real_world_meaning, "real_world_meaning required"
37
+ assert pack.meta.robotics_analogue, "robotics_analogue required"
38
+ anchors = pack.meta.benchmark_anchor
39
+ assert isinstance(anchors, list) and len(anchors) == 4, (
40
+ f"benchmark_anchor must list all 4 anchors, got {anchors!r}"
41
+ )
42
+ joined = " ".join(anchors).lower()
43
+ # Spec-mandated anchors: SC2 VIP-survival, military VIP doctrine,
44
+ # diplomatic convoy escort, fragile-target preservation.
45
+ for needle in ("sc2 vip", "vip protection",
46
+ "diplomatic", "fragile-target"):
47
+ assert needle in joined, f"missing anchor keyword: {needle}"
48
+ for lvl in ("easy", "medium", "hard"):
49
+ c = compile_level(pack, lvl)
50
+ assert c.map_supported
51
+ assert c.win_condition is not None
52
+ assert c.fail_condition is not None
53
+
54
+
55
+ def _ctx(*, units=(), tick=1000, kills=0, lost=0):
56
+ """Synthesize a WinContext for predicate-level checks."""
57
+ import types
58
+
59
+ sig = types.SimpleNamespace(
60
+ game_tick=tick,
61
+ units_killed=kills,
62
+ units_lost=lost,
63
+ cash=0,
64
+ resources=0,
65
+ own_buildings=[],
66
+ own_building_types=set(),
67
+ enemies_seen_ids=set(),
68
+ enemy_buildings_seen_ids=set(),
69
+ )
70
+ return WinContext(
71
+ signals=sig,
72
+ render_state={"units_summary": list(units)},
73
+ )
74
+
75
+
76
+ def _harv_at(x=100, y=20):
77
+ return {"cell_x": x, "cell_y": y, "type": "harv", "id": "9001"}
78
+
79
+
80
+ def _tank_at(i, x=100, y=20):
81
+ return {"cell_x": x, "cell_y": y, "type": "2tnk", "id": str(2000 + i)}
82
+
83
+
84
+ @pytest.mark.parametrize("lvl", ["easy", "medium", "hard"])
85
+ def test_win_requires_harv_in_east_region_and_alive(lvl):
86
+ c = compile_level(load_pack(PACK_PATH), lvl)
87
+ # VIP in region, alive, in time → WIN.
88
+ assert evaluate(
89
+ c.win_condition,
90
+ _ctx(units=[_harv_at(100, 20), _tank_at(0, 99, 20)], tick=2000),
91
+ )
92
+ # Tanks arrive but VIP didn't → not a WIN (VIP missing).
93
+ assert not evaluate(
94
+ c.win_condition,
95
+ _ctx(units=[_tank_at(0, 100, 20), _tank_at(1, 100, 21)], tick=2000),
96
+ )
97
+ # VIP elsewhere on the map → not a WIN (not in region).
98
+ assert not evaluate(
99
+ c.win_condition,
100
+ _ctx(units=[_harv_at(50, 20)], tick=2000),
101
+ )
102
+ # VIP in region but past the deadline → not a WIN.
103
+ assert not evaluate(
104
+ c.win_condition,
105
+ _ctx(units=[_harv_at(100, 20)], tick=4600),
106
+ )
107
+
108
+
109
+ @pytest.mark.parametrize("lvl", ["easy", "medium", "hard"])
110
+ def test_fail_clauses_are_reachable_losses(lvl):
111
+ c = compile_level(load_pack(PACK_PATH), lvl)
112
+ # Past deadline → fail (the VIP is still in the west).
113
+ assert evaluate(
114
+ c.fail_condition,
115
+ _ctx(units=[_harv_at(8, 20)], tick=4600),
116
+ )
117
+ # VIP killed (no harv left, even if escorts survive) → fail.
118
+ assert evaluate(
119
+ c.fail_condition,
120
+ _ctx(units=[_tank_at(0, 50, 20)], tick=2000),
121
+ )
122
+ # Force wipe (no harv among the wipe) → fail.
123
+ assert evaluate(c.fail_condition, _ctx(units=[], tick=2000))
124
+
125
+
126
+ def test_timeout_reachable_inside_max_turns():
127
+ """No draw degeneracy: after_ticks 4501 ≤ 93 + 90·(max_turns-1)."""
128
+ pack = load_pack(PACK_PATH)
129
+ for lvl in ("easy", "medium", "hard"):
130
+ c = compile_level(pack, lvl)
131
+ max_tick = 93 + 90 * (c.max_turns - 1)
132
+ assert 4501 <= max_tick, (
133
+ f"{lvl}: after_ticks 4501 > max reachable tick {max_tick} "
134
+ f"(max_turns={c.max_turns}); deadline never bites"
135
+ )
136
+ assert 4500 <= max_tick, (
137
+ f"{lvl}: within_ticks 4500 > max reachable tick {max_tick}"
138
+ )
139
+
140
+
141
+ def test_hard_has_two_spawn_point_groups():
142
+ """Hard curation: ≥2 distinct agent spawn_point groups so the seed
143
+ round-robins the staging row (NORTH y≈14 / SOUTH y≈26). Engine-
144
+ roundtrip asserted by tests/test_hard_tier.py."""
145
+ c = compile_level(load_pack(PACK_PATH), "hard")
146
+ groups = {
147
+ (a.spawn_point if a.spawn_point is not None else 0)
148
+ for a in c.scenario.actors
149
+ if a.owner == "agent"
150
+ }
151
+ assert len(groups) >= 2, f"hard needs ≥2 spawn_point groups, got {groups}"
152
+
153
+
154
+ def test_exactly_one_harv_per_spawn_group():
155
+ """The VIP is a UNIQUE unit type — exactly one harv per spawn
156
+ group (so `unit_type_count_gte:{harv,1}` collapses to a single
157
+ "VIP alive" check; multiple harvs would let the win predicate
158
+ accept partial loss)."""
159
+ pack = load_pack(PACK_PATH)
160
+ for lvl in ("easy", "medium"):
161
+ c = compile_level(pack, lvl)
162
+ harvs = [
163
+ a for a in c.scenario.actors
164
+ if a.owner == "agent" and a.type == "harv"
165
+ ]
166
+ assert len(harvs) == 1, f"{lvl}: must have exactly 1 harv VIP, got {len(harvs)}"
167
+ c = compile_level(pack, "hard")
168
+ per_group = {}
169
+ for a in c.scenario.actors:
170
+ if a.owner != "agent" or a.type != "harv":
171
+ continue
172
+ sp = a.spawn_point if a.spawn_point is not None else 0
173
+ per_group[sp] = per_group.get(sp, 0) + 1
174
+ assert all(n == 1 for n in per_group.values()), (
175
+ f"hard: each spawn group should have exactly 1 harv, got {per_group}"
176
+ )
177
+
178
+
179
+ def test_escort_is_four_medium_tanks_per_group():
180
+ """The escort is 4× 2tnk per spawn group (sized so the escort can
181
+ actually clear the route's rifle walls in budget — fewer tanks
182
+ and the route doesn't clean in time)."""
183
+ pack = load_pack(PACK_PATH)
184
+ for lvl in ("easy", "medium"):
185
+ c = compile_level(pack, lvl)
186
+ tanks = [a for a in c.scenario.actors
187
+ if a.owner == "agent" and a.type == "2tnk"]
188
+ assert len(tanks) == 4, f"{lvl}: expected 4 escort tanks, got {len(tanks)}"
189
+ c = compile_level(pack, "hard")
190
+ per_group = {}
191
+ for a in c.scenario.actors:
192
+ if a.owner != "agent" or a.type != "2tnk":
193
+ continue
194
+ sp = a.spawn_point if a.spawn_point is not None else 0
195
+ per_group[sp] = per_group.get(sp, 0) + 1
196
+ assert all(n == 4 for n in per_group.values()), (
197
+ f"hard: each spawn group should have 4 escort tanks, got {per_group}"
198
+ )
199
+
200
+
201
+ def test_each_level_has_an_arty_anti_vip_threat():
202
+ """The VIP's fragility is enforced via ENEMY CHOICE — specifically
203
+ an artillery piece (arty, dps45 splash) along the route. The
204
+ engine ignores the `health` placement field (verified: health=10
205
+ vs health=100 give the same initial hp_ratio 1.0 and identical
206
+ decay under enemy fire), so the harv's hp600 + armour withstands
207
+ a rifle-only gauntlet. Without an arty the discrimination
208
+ collapses (VIP-alone outruns rifle fire entirely)."""
209
+ pack = load_pack(PACK_PATH)
210
+ for lvl in ("easy", "medium", "hard"):
211
+ c = compile_level(pack, lvl)
212
+ artys = [
213
+ a for a in c.scenario.actors
214
+ if a.owner == "enemy" and a.type == "arty"
215
+ ]
216
+ assert len(artys) >= 1, (
217
+ f"{lvl}: must have ≥1 arty (anti-VIP threat); got {len(artys)}"
218
+ )
219
+
220
+
221
+ def test_interrupts_disabled_so_time_budget_is_real():
222
+ """enemy_unit_spotted via step_until_event advances only ~5 ticks
223
+ per interrupt, so on a route with 10+ enemies the per-turn tick
224
+ rate collapses and even the intended escort cannot reach the
225
+ extraction inside the 4500-tick budget. With interrupts disabled,
226
+ each turn advances ~90 ticks uniformly and the budget is real."""
227
+ pack = load_pack(PACK_PATH)
228
+ for lvl in ("easy", "medium", "hard"):
229
+ c = compile_level(pack, lvl)
230
+ ints = c.scenario.interrupts or {}
231
+ enabled = [k for k, v in ints.items() if v]
232
+ assert not enabled, (
233
+ f"{lvl}: interrupts must be disabled (the route has 10+ "
234
+ f"enemies; step_until_event would collapse the tick "
235
+ f"budget); got enabled={enabled}"
236
+ )
237
+
238
+
239
+ # ── engine-driven scripted policies ────────────────────────────────
240
+
241
+
242
+ def _own_units(rs):
243
+ return rs.get("units_summary", []) or []
244
+
245
+
246
+ def _harv(rs):
247
+ for u in _own_units(rs):
248
+ if str(u.get("type", "")).lower() == "harv":
249
+ return u
250
+ return None
251
+
252
+
253
+ def _escorts(rs):
254
+ return [u for u in _own_units(rs)
255
+ if str(u.get("type", "")).lower() == "2tnk"]
256
+
257
+
258
+ def _stall(rs, Command):
259
+ """Pure observe — no unit moves; VIP never reaches the
260
+ extraction → after_ticks LOSS."""
261
+ return [Command.observe()]
262
+
263
+
264
+ def _vip_alone(rs, Command):
265
+ """Only the harvester is moved east; escorts stay idle in the
266
+ west. The unarmed VIP enters the first rifle wall
267
+ unaccompanied → dies → `not unit_type_count_gte:{harv,1}` fail
268
+ clause fires → LOSS."""
269
+ units = _own_units(rs)
270
+ if not units:
271
+ return [Command.observe()]
272
+ cmds = []
273
+ for u in units:
274
+ if str(u.get("type", "")).lower() == "harv":
275
+ cmds.append(Command.move_units([str(u["id"])], 100, 20))
276
+ else:
277
+ cmds.append(Command.stop([str(u["id"])]))
278
+ return cmds
279
+
280
+
281
+ def _vip_leads_charge(rs, Command):
282
+ """Every unit (harv + tanks) charges east on the same y-band
283
+ with the harv at the front. The first rifle wall auto-targets
284
+ the NEAREST visible hostile — the harvester — and kills it
285
+ before the tanks finish suppressing the e1s. → LOSS."""
286
+ units = _own_units(rs)
287
+ if not units:
288
+ return [Command.observe()]
289
+ cmds = []
290
+ for u in units:
291
+ cmds.append(Command.move_units([str(u["id"])], 100, 20))
292
+ return cmds
293
+
294
+
295
+ def _intended_escort_clears_path(rs, Command):
296
+ """Escort-clears-path:
297
+
298
+ 1. Tanks attack_move east AHEAD of the VIP, sweeping each
299
+ rifle wall (~x=35, ~x=55, ~x=75) until cleared. attack_move
300
+ auto-fires on the nearest hostile in range.
301
+ 2. The VIP holds ~10 cells behind the rear-most tank so the
302
+ rifle walls auto-target the closer high-armour tanks.
303
+ 3. Once the rear-most tank is at the extraction (x≥92), the
304
+ VIP completes the cross to (100,20).
305
+
306
+ The VIP target_x is clamped to never push it past the rear-most
307
+ tank — if the tanks haven't cleared the next wall, the VIP
308
+ waits.
309
+ """
310
+ units = _own_units(rs)
311
+ if not units:
312
+ return [Command.observe()]
313
+ h = _harv(rs)
314
+ tanks = _escorts(rs)
315
+ cmds = []
316
+
317
+ # Escort tanks: attack_move east to the extraction, engaging
318
+ # anything in the way.
319
+ for t in tanks:
320
+ cmds.append(Command.attack_move([str(t["id"])], 100, 20))
321
+
322
+ if h is not None:
323
+ if tanks:
324
+ # The harv hangs 10 cells behind the rear-most tank.
325
+ rear_x = min(t["cell_x"] for t in tanks)
326
+ tx = min(rear_x - 10, 100)
327
+ # Never drive the harv backwards past its own start.
328
+ tx = max(tx, h["cell_x"])
329
+ # If tanks are already at the extraction (rear_x≥92), VIP
330
+ # finishes the cross.
331
+ if rear_x >= 92:
332
+ tx = 100
333
+ cmds.append(Command.move_units([str(h["id"])], int(tx), 20))
334
+ else:
335
+ # No escorts left — just sprint (best-effort).
336
+ cmds.append(Command.move_units([str(h["id"])], 100, 20))
337
+ return cmds
338
+
339
+
340
+ @pytest.mark.parametrize("level", ["easy", "medium", "hard"])
341
+ @pytest.mark.parametrize("seed", [1, 2, 3, 4])
342
+ def test_intended_escort_clears_path_wins(level, seed):
343
+ pytest.importorskip("openra_train")
344
+ from openra_bench.eval_core import run_level
345
+
346
+ c = compile_level(load_pack(PACK_PATH), level)
347
+ r = run_level(c, _intended_escort_clears_path, seed=seed)
348
+ assert r.outcome == "win", (
349
+ f"{level} seed={seed}: escort-clears-path should WIN, got "
350
+ f"{r.outcome} after {r.turns} turns "
351
+ f"(losses={r.signals.units_lost})"
352
+ )
353
+
354
+
355
+ @pytest.mark.parametrize("level", ["easy", "medium", "hard"])
356
+ @pytest.mark.parametrize("seed", [1, 2, 3, 4])
357
+ def test_stall_loses(level, seed):
358
+ pytest.importorskip("openra_train")
359
+ from openra_bench.eval_core import run_level
360
+
361
+ c = compile_level(load_pack(PACK_PATH), level)
362
+ r = run_level(c, _stall, seed=seed)
363
+ assert r.outcome == "loss", (
364
+ f"{level} seed={seed}: stall must be a real timeout LOSS "
365
+ f"(VIP never reaches extraction → after_ticks fires), "
366
+ f"got {r.outcome}"
367
+ )
368
+
369
+
370
+ @pytest.mark.parametrize("level", ["easy", "medium", "hard"])
371
+ @pytest.mark.parametrize("seed", [1, 2, 3, 4])
372
+ def test_vip_alone_loses(level, seed):
373
+ """VIP sent alone enters the first rifle wall unaccompanied;
374
+ unarmed harv can't return fire and the rifle squad burns it
375
+ down (health:25 → ~150 effective hp dies in 2-3 turns of
376
+ standing exposure) → `not unit_type_count_gte:{harv,1}` fail
377
+ clause fires → LOSS."""
378
+ pytest.importorskip("openra_train")
379
+ from openra_bench.eval_core import run_level
380
+
381
+ c = compile_level(load_pack(PACK_PATH), level)
382
+ r = run_level(c, _vip_alone, seed=seed)
383
+ assert r.outcome == "loss", (
384
+ f"{level} seed={seed}: VIP-alone must LOSE (unarmed harv "
385
+ f"dies in the first wall), got {r.outcome} "
386
+ f"(losses={r.signals.units_lost})"
387
+ )
388
+
389
+
390
+ @pytest.mark.parametrize("level", ["medium", "hard"])
391
+ @pytest.mark.parametrize("seed", [1, 2, 3, 4])
392
+ def test_vip_leads_charge_loses(level, seed):
393
+ """Harv + tanks charge in a single column with harv at the
394
+ front; rifle wall auto-targets the closer harv → VIP dies →
395
+ LOSS. Easy excluded (sparser hazard may let the tanks suppress
396
+ fast enough to keep the harv alive; SCENARIO_REVIEW_CHECKLIST.md
397
+ accepts inert anti-cheat teeth on easy for the bare-skill tier)."""
398
+ pytest.importorskip("openra_train")
399
+ from openra_bench.eval_core import run_level
400
+
401
+ c = compile_level(load_pack(PACK_PATH), level)
402
+ r = run_level(c, _vip_leads_charge, seed=seed)
403
+ assert r.outcome == "loss", (
404
+ f"{level} seed={seed}: VIP-leads-charge must LOSE (harv at "
405
+ f"the front of the column is the nearest target), got "
406
+ f"{r.outcome} (losses={r.signals.units_lost})"
407
+ )
tests/test_hard_tier.py CHANGED
@@ -397,6 +397,10 @@ UPGRADED = [
397
  # is symmetric across y=20 so both spawns face the same pincer
398
  # decision.
399
  "combat-pincer-coordination",
 
 
 
 
400
  # Wave-6 Group D combat-micro pack: SC2 flank micro / military
401
  # flank-maneuver doctrine / force-multiplier-through-angle-of-
402
  # attack anchor. Hard tier defines two agent spawn_point groups
 
397
  # is symmetric across y=20 so both spawns face the same pincer
398
  # decision.
399
  "combat-pincer-coordination",
400
+ # Wave-6 ACTION cell — escort fragile VIP (harv) through cumulative
401
+ # arty walls; hard tier defines 2 agent spawn_point groups (NORTH y=14
402
+ # / SOUTH y=26) round-robined by seed.
403
+ "combat-protect-vip-escort",
404
  # Wave-6 Group D combat-micro pack: SC2 flank micro / military
405
  # flank-maneuver doctrine / force-multiplier-through-angle-of-
406
  # attack anchor. Hard tier defines two agent spawn_point groups