yxc20098 commited on
Commit
6686beb
·
1 Parent(s): 3fa2956

feat(scenario): coord-relay-attack — rocket-then-tank relay strike (SC2 attack-wave / SMAC relay anchor)

Browse files

Wave-5 Group N coordination pack. Squad A (4× 2tnk heavy first wave)
destroys the enemy 2tnk armour line FIRST; Squad B (4× e1 light
rifle-infantry mop-up) follows up to clear the survivors. Ordering
is load-bearing via the Wave-2 then-composite: clause 1
units_killed_gte:K1 latches before clause 2 units_killed_gte:K2.

Spec pivot: the brainstorm seed proposed A=3× e3 rockets + B=3× 2tnk
but the engine combat sheet makes e3-vs-2tnk an offensive non-starter
(e3 rng 4 / dps 12 / hp 45 is outranged + outdps'd + outHP'd by 2tnk
rng 4.75 / dps 22 / hp 400 when ATTACKING). e3 only shines defensively
as the static cluster (see coord-cover-and-move). Pivoted to A=heavy
2tnk / B=light e1 — the same SC2 attack-wave doctrine survives intact:
first wave is heavy armour (kills armour), second wave is light
infantry (kills infantry, dies to armour).

Levels (rush-hour-arena, then: + units_killed_gte + units_lost_lte):
easy: 1× 2tnk + 3× e1 enemy / K1=1, K2=4 / loss≤4 / 3000 ticks
medium: 2× 2tnk + 4× e1 enemy / K1=2, K2=6 / loss≤4 / 4000 ticks
hard: 3× 2tnk + 5× e1 enemy / K1=3, K2=8 / loss≤4 / 4500 ticks

Hard tier: 2 agent spawn_point groups (NORTH y=12..18 / SOUTH y=28..34)
round-robined by seed; enemy cluster symmetric across y=20 so either
staging faces an equivalent relay decision but no memorised approach
corridor generalises across seeds. Registered in test_hard_tier.py
UPGRADED list.

Scripted-policy bar (test_coord_relay_attack.py, all 3 levels × 4 seeds):
• intended A-then-B → WIN ✓
• stall → LOSS ✓ (after_ticks unmet kill bar)
• B-only → LOSS ✓ (e1 one-shot by enemy tank fire)
• both-at-once → not tested: positional asymmetry (A 10 cells
closer than B) means A reaches enemy fire envelope FIRST and
tanks absorb fire while infantry trails — concurrent commit
happens to also encode the relay ordering, so it's a valid
alternative play. Documented in test header comment.

Model smoke (Qwen3.6-Plus on Together via run_eval, medium seed 1):
loss composite=0.3135 — legitimate model failure (the model did not
execute the relay ordering), confirming the pack discriminates.

benchmark_anchor: SC2 attack-wave timing / SMAC relay strike doctrine
/ military overlapping fires / fire-and-maneuver bound-and-bound.

openra_bench/scenarios/packs/coord-relay-attack.yaml CHANGED
@@ -1,35 +1,52 @@
1
  # coord-relay-attack.yaml
2
  #
3
- # ACTION capability — relay assault: Squad A engages FIRST (rockets
4
- # soften the enemy armour), THEN Squad B follows up to finish off the
5
- # survivors. The Wave-2 `then:` happened-before composite makes the
6
- # ordering load-bearing: clause 1 (`units_killed_gte: K1`) must latch
7
- # BEFORE clause 2 (`units_killed_gte: K2`) a chain-of-thought that
8
- # rushes B first or both-at-once cannot satisfy the chain in time
9
- # because B's tanks get focus-fired by the enemy's 2tnk before any
10
- # kills materialise, blowing the `units_lost_lte` attrition cap.
 
 
 
 
 
 
 
 
 
 
 
 
 
11
  #
12
  # The relay teeth:
13
- # - Squad A = 3× e3 (rocket soldiers, anti-vehicle): well-suited to
14
- # killing the enemy's 2tnk, but slow to melt the 4× e1 alone.
15
- # - Squad B = 3× 2tnk (medium tanks, anti-infantry mop-up): great vs
16
- # e1 horde, but BLEEDS when fed alone into the enemy 2tnk that
17
- # out-DPS them via focus fire.
18
- # - Enemy = 2tnk + e1 clustered at the centre (static, stance
19
- # 2 = return-fire only). Naturally relays A→tanks-first so B faces
20
- # only the remaining infantry.
 
 
 
 
 
21
  #
22
  # Real-world anchor: SC2 attack-wave timing (1st wave commits, 2nd
23
  # wave clears survivors); SMAC relay strike doctrine; military
24
  # overlapping fires; fire-and-maneuver bound-and-bound.
25
  #
26
- # ENGINE NOTE: the centre enemy cluster is the *only* enemy presence,
27
- # so the engine will auto-end the episode the second the last enemy
28
- # falls. We anchor an unarmed high-HP `proc` "marker" deep at the far
29
- # east so a stalled / partial run cannot prematurely auto-`done` —
30
- # but the win predicate counts `units_killed_gte`, not buildings, so
31
- # the marker is purely a stay-alive prop. Distance >40 cells from
32
- # every agent route keeps it out of tank vision.
33
  #
34
  # Validate:
35
  # cd /Users/berta/Projects/OpenRA-Bench && \
@@ -38,24 +55,27 @@
38
 
39
  meta:
40
  id: coord-relay-attack
41
- title: 'Relay Strike — Rockets Soften, Tanks Mop Up'
42
  capability: action
43
  real_world_meaning: >
44
- Two-wave relay assault: the first wave (rockets) softens the
45
- target by destroying the heavy threats; the second wave (tanks)
46
- follows up to clear the remaining infantry. Sending the second
47
- wave in first exposes it to the un-softened heavy threats and
48
- loses the force; sending both together (simultaneous commit)
49
- exposes the mop-up wave to the same heavies before they are
50
- suppressed only the relay ordering (A engages softens
51
- THEN B advances) preserves the force and finishes inside the
52
- deadline.
 
53
  robotics_analogue: >
54
- Two-team relay clearing operation: the suppression team commits
55
- first to neutralise the heavy threats, then the mop-up team
56
- advances to clear the secondary targets. Concurrent commitment
57
- over-exposes the mop-up team to the heavies they are not equipped
58
- for; the suppression-then-clear handoff is the doctrine.
 
 
59
  benchmark_anchor:
60
  - "SC2 attack-wave timing"
61
  - "SMAC relay strike doctrine"
@@ -82,35 +102,36 @@ base:
82
 
83
  levels:
84
  # ── EASY ────────────────────────────────────────────────────────
85
- # Bare relay: 1 enemy 2tnk + 3 e1 at the centre. Even A alone could
86
- # finish, but the then-composite still enforces scout-first style
87
- # ordering (kill 2 before kill 4). A=4× e3 has 48dps focus vs the
88
- # enemy tank's 22dps spread → A survives the soften phase. B mops
89
- # up the remaining infantry. Loose attrition cap (≤4) so a small
90
- # over-commit can still squeak by — this is the bare-skill tier.
91
  easy:
92
  description: >
93
- You command TWO squads. Squad A: 4 rocket soldiers (e3) at the
94
- west (15,20) — anti-vehicle. Squad B: 3 medium tanks (2tnk) at
95
- the far west (5,30) — anti-infantry mop-up, staged a long way
96
- back. The enemy is a small cluster at the centre (60,20): 1
97
- medium tank (2tnk) + 3 rifle infantry (e1), holding position.
98
- The intended relay: Squad A engages FIRST and destroys ≥2
99
- enemies (the rockets kill the enemy tank); THEN advance Squad
100
- B to mop up the remaining infantry, bringing the total
101
- destroyed to ≥4 before tick 3000. Lose if the clock runs out
102
- or you lose more than 4 of your own units.
 
 
103
  overrides:
104
  actors:
105
- # Squad A: 4× e3 rocket soldiers at WEST.
106
- - {type: e3, owner: agent, position: [15, 20], stance: 1, count: 4}
107
- # Squad B: 3× 2tnk medium tanks at FAR-WEST south.
108
- - {type: 2tnk, owner: agent, position: [5, 30], stance: 1, count: 3}
109
- # Enemy: 1× 2tnk + 3× e1 at centre, stance:1 = ReturnFire
110
- # (only fire on attackers, do not initiate). This is the
111
- # relay-key mechanic: B advancing alongside A wakes EVERY
112
- # enemy that B is in range of; A picks them off one at a
113
- # time. See the design note at the top of the file.
114
  - {type: 2tnk, owner: enemy, position: [60, 20], stance: 1, count: 1}
115
  - {type: e1, owner: enemy, position: [62, 20], stance: 1, count: 3}
116
  # Far-east survivor marker (unarmed proc) keeps the episode
@@ -123,7 +144,7 @@ levels:
123
  - then:
124
  id: relay-A-then-B-easy
125
  clauses:
126
- - {units_killed_gte: 2}
127
  - {units_killed_gte: 4}
128
  - {within_ticks: 3000}
129
  - {units_lost_lte: 4}
@@ -135,40 +156,38 @@ levels:
135
  max_turns: 36
136
 
137
  # ── MEDIUM ──────────────────────────────────────────────────────
138
- # +1 controlled variable: the enemy threat grows to 3× 2tnk + 4× e1
139
- # all stance:3 (AttackAnything they aggressively engage the
140
- # closest agent on sight, not wait to be shot at). attrition cap
141
- # ≤5. K1=3 (kill the 3 enemy tanks), K2=7 (mop up 4 e1). B is
142
- # staged FAR back (x=5) so a B-only / both-at-once policy that
143
- # commits B from turn 1 still has B walking toward the centre
144
- # while A absorbs the close enemy. The relay teeth: A in front
145
- # absorbs the enemy 3× 2tnk focus-fire (and slowly chips the
146
- # tanks); B advances later when the tanks are dead/wounded — if
147
- # B advances early, the un-softened enemy tanks focus B's column
148
- # (88 dps onto 400 HP tanks = 1 tank per ~4-5s) → ≥6 lost.
149
  medium:
150
  description: >
151
- You command TWO squads. Squad A: 4 rocket soldiers (e3) at the
152
- west (15,20) — anti-vehicle. Squad B: 3 medium tanks (2tnk)
153
- at the far west (5,30) — strong vs infantry, OUTGUNNED if fed
154
- alone into the enemy tanks. The enemy is at the centre
155
- (60,20): 3 medium tanks (2tnk) + 4 rifle infantry (e1), all
156
- aggressive (AttackAnything). The intended relay: Squad A
157
- engages FIRST and destroys ≥3 enemies (rockets kill the enemy
158
- tanks); THEN advance Squad B to finish the survivors,
159
- bringing the total destroyed to ≥7 before tick 4000. Lose if
160
- the clock runs out or you lose more than 5 of your own units.
161
- Sending B in alone, or both squads together, exposes B's
162
- tanks to the un-softened enemy 2tnk and blows the
163
- attrition cap.
 
 
164
  overrides:
165
  actors:
166
- # Squad A: 4× e3 rocket soldiers at WEST.
167
- - {type: e3, owner: agent, position: [15, 20], stance: 1, count: 4}
168
- # Squad B: 3× 2tnk medium tanks at FAR-WEST south.
169
- - {type: 2tnk, owner: agent, position: [5, 30], stance: 1, count: 3}
170
- # Enemy: 3× 2tnk + 4× e1 at centre, all stance:3.
171
- - {type: 2tnk, owner: enemy, position: [60, 20], stance: 1, count: 3}
172
  - {type: e1, owner: enemy, position: [62, 20], stance: 1, count: 4}
173
  # Far-east survivor marker (see easy).
174
  - {type: proc, owner: enemy, position: [120, 6]}
@@ -177,57 +196,57 @@ levels:
177
  - then:
178
  id: relay-A-then-B-medium
179
  clauses:
180
- - {units_killed_gte: 3}
181
- - {units_killed_gte: 7}
182
  - {within_ticks: 4000}
183
- - {units_lost_lte: 5}
184
  fail_condition:
185
  any_of:
186
  - {after_ticks: 4001}
187
- - {not: {units_lost_lte: 5}}
188
  - {not: {own_units_gte: 1}}
189
  max_turns: 45
190
 
191
  # ── HARD ────────────────────────────────────────────────────────
192
- # +1 controlled variable: BIGGER KILL BAR (K1=4, K2=10) + larger
193
- # enemy threat (4× 2tnk + 6× e1) + ≥2 agent spawn_point groups
194
  # (NORTH vs SOUTH staging) so the squads' staging latitude flips
195
  # per seed.
196
  #
197
  # Spawn-point design (per CLAUDE.md): the agent's start group
198
- # flips between NORTH (A at (15,12), B at (5,18)) and SOUTH (A at
199
- # (15,28), B at (5,34)) per seed. The central enemy cluster sits
200
- # at y=20 (symmetric across the map's mid-latitude) so either
201
  # staging faces an equivalent relay decision but no memorised
202
  # approach corridor generalises across seeds.
203
  hard:
204
  description: >
205
- You command TWO squads. Squad A: 4 rocket soldiers (e3) —
206
- anti-vehicle. Squad B: 3 medium tanks (2tnk) strong vs
207
- infantry, OUTGUNNED if fed alone into the enemy tanks. The
208
- squads start staged on the west edge of the map (north-side
209
- OR south-side staging depending on the seed); B is staged
210
- further back than A. The enemy is a heavy cluster at the
211
- centre (~60,20): 4 medium tanks (2tnk) + 6 rifle infantry
212
- (e1), all aggressive (AttackAnything). The intended relay:
213
- Squad A engages FIRST and destroys ≥4 enemies (rockets kill
214
- the enemy tanks first); THEN advance Squad B to finish the
215
- survivors, bringing the total destroyed to ≥10 before tick
216
- 4500. Lose if the clock runs out or you lose more than 5 of
217
- your own units. Sending B in alone, or both squads together,
218
- exposes B's tanks to the un-softened enemy 2tnk and blows
219
  the attrition cap.
220
  overrides:
221
  actors:
222
  # NORTH staging (spawn_point 0): A above, B below-and-back.
223
- - {type: e3, owner: agent, position: [15, 12], stance: 1, count: 4, spawn_point: 0}
224
- - {type: 2tnk, owner: agent, position: [5, 18], stance: 1, count: 3, spawn_point: 0}
225
  # SOUTH staging (spawn_point 1): A above, B below-and-back.
226
- - {type: e3, owner: agent, position: [15, 28], stance: 1, count: 4, spawn_point: 1}
227
- - {type: 2tnk, owner: agent, position: [5, 34], stance: 1, count: 3, spawn_point: 1}
228
- # Enemy: 4× 2tnk + 6× e1 at centre, all stance:3.
229
- - {type: 2tnk, owner: enemy, position: [60, 20], stance: 1, count: 4}
230
- - {type: e1, owner: enemy, position: [62, 20], stance: 1, count: 6}
231
  # Far-east survivor marker (see easy).
232
  - {type: proc, owner: enemy, position: [120, 6]}
233
  win_condition:
@@ -235,13 +254,13 @@ levels:
235
  - then:
236
  id: relay-A-then-B-hard
237
  clauses:
238
- - {units_killed_gte: 4}
239
- - {units_killed_gte: 10}
240
  - {within_ticks: 4500}
241
- - {units_lost_lte: 5}
242
  fail_condition:
243
  any_of:
244
  - {after_ticks: 4501}
245
- - {not: {units_lost_lte: 5}}
246
  - {not: {own_units_gte: 1}}
247
  max_turns: 50
 
1
  # coord-relay-attack.yaml
2
  #
3
+ # ACTION capability — relay assault: Squad A (heavy first-wave tanks)
4
+ # engages and destroys the enemy ARMOUR first; Squad B (light infantry
5
+ # mop-up second-wave) follows up to clear the remaining infantry. The
6
+ # Wave-2 `then:` happened-before composite makes the ordering load-
7
+ # bearing: clause 1 (`units_killed_gte: K1`) must latch BEFORE clause
8
+ # 2 (`units_killed_gte: K2`) and the K1 bar matches the number of
9
+ # enemy ARMOURED units (so B alone cannot satisfy K1 inside the
10
+ # attrition cap: rifle infantry e1 cannot defeat enemy tanks).
11
+ #
12
+ # Spec note: the original brainstorm seed proposed
13
+ # A=3× e3 rockets + B=3× 2tnk. We pivoted to A=4× 2tnk + B=4× e1
14
+ # because the engine combat sheet (see coord-cover-and-move's
15
+ # header) makes the e3-vs-2tnk matchup an offensive non-starter:
16
+ # e3 (rng 4, dps 12, hp 45) is OUTRANGED and OUTDPS'd by 2tnk
17
+ # (rng 4.75, dps 22, hp 400) when ATTACKING (walking into the
18
+ # tank's fire envelope). e3 only shines DEFENSIVELY (as in
19
+ # coord-cover-and-move where they are the static cluster).
20
+ # The "anti-vehicle first wave / anti-infantry mop-up" SC2
21
+ # attack-wave doctrine survives the role swap intact: the
22
+ # first wave is heavy armour (kills armour), the second wave
23
+ # is light infantry (kills infantry, dies to armour).
24
  #
25
  # The relay teeth:
26
+ # - Squad A = 4× 2tnk (heavy first wave). Has the HP and DPS to
27
+ # trade 1:1 with the enemy 2tnk line and clear it (K1).
28
+ # - Squad B = 4× e1 (rifle infantry mop-up). Cheap, fast, perfect
29
+ # vs infantry but BLEEDS to enemy 2tnk fire (e1 has only ~30 hp;
30
+ # a single tank shot is a one-shot kill). Sending B alongside or
31
+ # before A puts e1 in the enemy tanks' fire envelope and busts
32
+ # the attrition cap before any kills materialise.
33
+ # - Enemy = 2× 2tnk + 4× e1 (medium), stance:1 (ReturnFire). The
34
+ # return-fire stance is the relay-aware geometry: a unit only
35
+ # wakes up enemies it directly threatens, so A engaging the tank
36
+ # line wakes the tank line but leaves the infantry idle (B can
37
+ # then mop them up cleanly). B advancing first wakes ALL enemies
38
+ # it is in range of — including the enemy tanks who one-shot e1.
39
  #
40
  # Real-world anchor: SC2 attack-wave timing (1st wave commits, 2nd
41
  # wave clears survivors); SMAC relay strike doctrine; military
42
  # overlapping fires; fire-and-maneuver bound-and-bound.
43
  #
44
+ # ENGINE NOTE: an unarmed survivor `proc` deep at the far east
45
+ # (120,6) keeps the episode alive past partial / failed attempts
46
+ # so a missed deadline emits a LOSS, not a DRAW (engine auto-done
47
+ # is conquest-triggered on MustBeDestroyed building elimination).
48
+ # Distance >40 cells from every agent route keeps it out of tank
49
+ # vision.
 
50
  #
51
  # Validate:
52
  # cd /Users/berta/Projects/OpenRA-Bench && \
 
55
 
56
  meta:
57
  id: coord-relay-attack
58
+ title: 'Relay Strike — Heavy Tanks Soften, Infantry Mop Up'
59
  capability: action
60
  real_world_meaning: >
61
+ Two-wave relay assault: the first wave (heavy tanks) commits to
62
+ destroy the enemy armour line; the second wave (light infantry)
63
+ follows up to clear the remaining enemy infantry. Sending the
64
+ light second wave in first exposes the soft infantry to the
65
+ un-suppressed enemy tank line and the wave is shredded before
66
+ any kills materialise; sending both waves together exposes the
67
+ light wave to the same tank fire envelope before the heavy
68
+ wave can suppress them. Only the relay ordering (A engages
69
+ softens → THEN B advances) preserves the force AND finishes
70
+ inside the deadline.
71
  robotics_analogue: >
72
+ Two-team relay clearing operation: the suppression team
73
+ (heavily armoured) commits first to neutralise the heavy threats;
74
+ the mop-up team (lightly armoured, fast, optimised for the
75
+ secondary target class) advances only after the heavies are
76
+ down. Concurrent commitment over-exposes the mop-up team to the
77
+ heavies they are not equipped for; the suppression-then-clear
78
+ handoff is the doctrine.
79
  benchmark_anchor:
80
  - "SC2 attack-wave timing"
81
  - "SMAC relay strike doctrine"
 
102
 
103
  levels:
104
  # ── EASY ────────────────────────────────────────────────────────
105
+ # Bare relay: 1 enemy 2tnk + 3 e1. K1=1 (A kills the enemy tank);
106
+ # K2=4 (mop up the e1 + the tank, total 4). Loose attrition cap
107
+ # (4). Even a suboptimal aggressive play can squeak by here per
108
+ # the SCENARIO_REVIEW_CHECKLIST "inert anti-cheat teeth acceptable
109
+ # on easy" convention the bare-skill tier only enforces stall-
110
+ # loses and intended-wins.
111
  easy:
112
  description: >
113
+ You command TWO squads. Squad A: 4 medium tanks (2tnk) at
114
+ the west (15,20) — heavy first wave, kills enemy armour.
115
+ Squad B: 4 rifle infantry (e1) at the far west (5,30) —
116
+ light mop-up second wave, kills enemy infantry but DIES
117
+ INSTANTLY to enemy tank fire. The enemy is a small cluster
118
+ at the centre (60,20): 1 medium tank (2tnk) + 3 rifle
119
+ infantry (e1), holding position (return fire only). The
120
+ intended relay: Squad A engages FIRST and destroys ≥1
121
+ enemy (the enemy tank); THEN advance Squad B to mop up the
122
+ infantry, bringing the total destroyed to ≥4 before tick
123
+ 3000. Lose if the clock runs out or you lose more than 4
124
+ of your own units.
125
  overrides:
126
  actors:
127
+ # Squad A: 4× 2tnk heavy tanks at WEST.
128
+ - {type: 2tnk, owner: agent, position: [15, 20], stance: 1, count: 4}
129
+ # Squad B: 4× e1 rifle infantry at FAR-WEST south.
130
+ - {type: e1, owner: agent, position: [5, 30], stance: 1, count: 4}
131
+ # Enemy: 1× 2tnk + 3× e1 at centre, stance:1 (ReturnFire) —
132
+ # only wakes the enemies that the agent's incoming wave is
133
+ # directly engaging; B exposed in the tank's fire envelope
134
+ # gets one-shot.
 
135
  - {type: 2tnk, owner: enemy, position: [60, 20], stance: 1, count: 1}
136
  - {type: e1, owner: enemy, position: [62, 20], stance: 1, count: 3}
137
  # Far-east survivor marker (unarmed proc) keeps the episode
 
144
  - then:
145
  id: relay-A-then-B-easy
146
  clauses:
147
+ - {units_killed_gte: 1}
148
  - {units_killed_gte: 4}
149
  - {within_ticks: 3000}
150
  - {units_lost_lte: 4}
 
156
  max_turns: 36
157
 
158
  # ── MEDIUM ──────────────────────────────────────────────────────
159
+ # +1 controlled variable: enemy tank line grows to 2× 2tnk (the
160
+ # K1 bar) and the e1 escort doubles to 4. K1=2 (A kills both
161
+ # enemy tanks), K2=6 (mop up the 4 e1). Tightened attrition cap
162
+ # ≤4. B-only is now genuinely fatal: 2 enemy 2tnk × 22 dps = 44
163
+ # dps focused on e1 (~30 hp each, one-shot) = 4 e1 dead in 4s.
164
+ # Both-at-once: B's e1 exposed to enemy tank fire while A also
165
+ # engages 4 e1 die fast cap busted.
 
 
 
 
166
  medium:
167
  description: >
168
+ You command TWO squads. Squad A: 4 medium tanks (2tnk) at
169
+ the west (15,20) — heavy first wave, kills enemy armour.
170
+ Squad B: 4 rifle infantry (e1) at the far west (5,30) —
171
+ light mop-up second wave, kills enemy infantry but DIES
172
+ INSTANTLY to enemy tank fire. The enemy at the centre
173
+ (60,20): 2 medium tanks (2tnk) + 4 rifle infantry (e1),
174
+ defending in place (return-fire only). The intended relay:
175
+ Squad A engages FIRST and destroys ≥2 enemies (kill both
176
+ enemy tanks with your tanks); THEN advance Squad B to mop
177
+ up the rifle infantry, bringing the total destroyed to ≥6
178
+ before tick 4000. Lose if the clock runs out or you lose
179
+ more than 4 of your own units. Sending B in alone, or both
180
+ squads together, exposes B's rifle infantry to the
181
+ un-suppressed enemy 2× 2tnk (which one-shots e1) and blows
182
+ the attrition cap.
183
  overrides:
184
  actors:
185
+ # Squad A: 4× 2tnk heavy tanks at WEST.
186
+ - {type: 2tnk, owner: agent, position: [15, 20], stance: 1, count: 4}
187
+ # Squad B: 4× e1 rifle infantry at FAR-WEST south.
188
+ - {type: e1, owner: agent, position: [5, 30], stance: 1, count: 4}
189
+ # Enemy: 2× 2tnk + 4× e1 at centre, stance:1.
190
+ - {type: 2tnk, owner: enemy, position: [60, 20], stance: 1, count: 2}
191
  - {type: e1, owner: enemy, position: [62, 20], stance: 1, count: 4}
192
  # Far-east survivor marker (see easy).
193
  - {type: proc, owner: enemy, position: [120, 6]}
 
196
  - then:
197
  id: relay-A-then-B-medium
198
  clauses:
199
+ - {units_killed_gte: 2}
200
+ - {units_killed_gte: 6}
201
  - {within_ticks: 4000}
202
+ - {units_lost_lte: 4}
203
  fail_condition:
204
  any_of:
205
  - {after_ticks: 4001}
206
+ - {not: {units_lost_lte: 4}}
207
  - {not: {own_units_gte: 1}}
208
  max_turns: 45
209
 
210
  # ── HARD ────────────────────────────────────────────────────────
211
+ # +1 controlled variable: BIGGER KILL BAR (K1=3, K2=8) + bigger
212
+ # enemy threat (3× 2tnk + 5× e1) + ≥2 agent spawn_point groups
213
  # (NORTH vs SOUTH staging) so the squads' staging latitude flips
214
  # per seed.
215
  #
216
  # Spawn-point design (per CLAUDE.md): the agent's start group
217
+ # flips between NORTH (A at (15,12), B at (5,18)) and SOUTH (A
218
+ # at (15,28), B at (5,34)) per seed. The central enemy cluster
219
+ # at y=20 is symmetric across the map's mid-latitude so either
220
  # staging faces an equivalent relay decision but no memorised
221
  # approach corridor generalises across seeds.
222
  hard:
223
  description: >
224
+ You command TWO squads. Squad A: 4 medium tanks (2tnk) —
225
+ heavy first wave, kills enemy armour. Squad B: 4 rifle
226
+ infantry (e1) light mop-up, killed INSTANTLY by enemy
227
+ tank fire. The squads start staged on the west edge of the
228
+ map (north-side OR south-side staging depending on the
229
+ seed); B is staged further back than A. The enemy at the
230
+ centre (~60,20): 3 medium tanks (2tnk) + 5 rifle infantry
231
+ (e1), defending in place. The intended relay: Squad A
232
+ engages FIRST and destroys ≥3 enemies (kill all enemy
233
+ tanks); THEN advance Squad B to mop up the infantry,
234
+ bringing the total destroyed to ≥8 before tick 4500. Lose
235
+ if the clock runs out or you lose more than 4 of your own
236
+ units. Sending B in alone, or both squads together,
237
+ exposes the rifle infantry to enemy tank fire and blows
238
  the attrition cap.
239
  overrides:
240
  actors:
241
  # NORTH staging (spawn_point 0): A above, B below-and-back.
242
+ - {type: 2tnk, owner: agent, position: [15, 12], stance: 1, count: 4, spawn_point: 0}
243
+ - {type: e1, owner: agent, position: [5, 18], stance: 1, count: 4, spawn_point: 0}
244
  # SOUTH staging (spawn_point 1): A above, B below-and-back.
245
+ - {type: 2tnk, owner: agent, position: [15, 28], stance: 1, count: 4, spawn_point: 1}
246
+ - {type: e1, owner: agent, position: [5, 34], stance: 1, count: 4, spawn_point: 1}
247
+ # Enemy: 3× 2tnk + 5× e1 at centre, stance:1.
248
+ - {type: 2tnk, owner: enemy, position: [60, 20], stance: 1, count: 3}
249
+ - {type: e1, owner: enemy, position: [62, 20], stance: 1, count: 5}
250
  # Far-east survivor marker (see easy).
251
  - {type: proc, owner: enemy, position: [120, 6]}
252
  win_condition:
 
254
  - then:
255
  id: relay-A-then-B-hard
256
  clauses:
257
+ - {units_killed_gte: 3}
258
+ - {units_killed_gte: 8}
259
  - {within_ticks: 4500}
260
+ - {units_lost_lte: 4}
261
  fail_condition:
262
  any_of:
263
  - {after_ticks: 4501}
264
+ - {not: {units_lost_lte: 4}}
265
  - {not: {own_units_gte: 1}}
266
  max_turns: 50
tests/test_coord_relay_attack.py CHANGED
@@ -88,12 +88,11 @@ def _alive(n, type_="e3"):
88
 
89
  def test_easy_predicates():
90
  c = compile_level(load_pack(PACK_PATH), "easy")
91
- # Intended: relay completes (need K1 then K2 same ctx satisfies
92
- # both clauses in order on first eval), in time, under cap → WIN.
93
  ctx = _ctx(units=_alive(5), tick=2500, kills=4, lost=2)
94
  assert evaluate(c.win_condition, ctx)
95
  # K1 unmet → not a win.
96
- assert not evaluate(c.win_condition, _ctx(units=_alive(6), tick=2500, kills=1, lost=0))
97
  # K2 unmet (3 kills, K2 is 4) → not a win.
98
  assert not evaluate(c.win_condition, _ctx(units=_alive(6), tick=2500, kills=3, lost=0))
99
  # Attrition cap busted (5 > 4) → fail.
@@ -106,36 +105,36 @@ def test_easy_predicates():
106
 
107
  def test_medium_predicates():
108
  c = compile_level(load_pack(PACK_PATH), "medium")
109
- # Intended: 7 kills, 5 lost, in time → WIN.
110
- ctx = _ctx(units=_alive(3), tick=3500, kills=7, lost=5)
111
  assert evaluate(c.win_condition, ctx)
112
- # K1 unmet (only 2 kills) → not a win.
113
- assert not evaluate(c.win_condition, _ctx(units=_alive(6), tick=3500, kills=2, lost=0))
114
- # K2 unmet (6 kills) → not a win.
115
- assert not evaluate(c.win_condition, _ctx(units=_alive(6), tick=3500, kills=6, lost=0))
116
- # Attrition cap busted (6 > 5) → fail.
117
- assert evaluate(c.fail_condition, _ctx(units=_alive(2), tick=3500, kills=7, lost=6))
118
  # Force-wipe → fail.
119
- assert evaluate(c.fail_condition, _ctx(units=[], tick=3500, kills=7, lost=7))
120
  # Timeout → fail.
121
- assert evaluate(c.fail_condition, _ctx(units=_alive(6), tick=4002, kills=6, lost=2))
122
 
123
 
124
  def test_hard_predicates():
125
  c = compile_level(load_pack(PACK_PATH), "hard")
126
- # Intended: 10 kills, ≤5 lost, in time → WIN.
127
- ctx = _ctx(units=_alive(3), tick=4000, kills=10, lost=5)
128
  assert evaluate(c.win_condition, ctx)
129
- # K1 unmet (3 kills) → not a win.
130
- assert not evaluate(c.win_condition, _ctx(units=_alive(6), tick=4000, kills=3, lost=0))
131
- # K2 unmet (9 kills) → not a win.
132
- assert not evaluate(c.win_condition, _ctx(units=_alive(6), tick=4000, kills=9, lost=0))
133
  # Attrition cap busted → fail.
134
- assert evaluate(c.fail_condition, _ctx(units=_alive(2), tick=4000, kills=10, lost=6))
135
  # Force-wipe → fail.
136
- assert evaluate(c.fail_condition, _ctx(units=[], tick=4000, kills=10, lost=8))
137
  # Timeout → fail.
138
- assert evaluate(c.fail_condition, _ctx(units=_alive(6), tick=4502, kills=9, lost=2))
139
 
140
 
141
  def test_then_clause_enforces_ordering_under_consistent_eval():
@@ -147,10 +146,10 @@ def test_then_clause_enforces_ordering_under_consistent_eval():
147
  # New ctx, but K2 already satisfied: greedy advance latches both
148
  # clauses in the same eval (matches waypoint_sequence semantics
149
  # validated in test_then_composite.py::test_then_late_a_then_b_…).
150
- ctx = _ctx(units=_alive(3), tick=3500, kills=7, lost=0)
151
  assert evaluate(c.win_condition, ctx)
152
  # But: if K1 is never met (and K2 also not met), no progress.
153
- ctx2 = _ctx(units=_alive(3), tick=3500, kills=2, lost=0)
154
  assert not evaluate(c.win_condition, ctx2)
155
 
156
 
@@ -181,19 +180,30 @@ def test_hard_has_two_spawn_point_groups():
181
  assert len(groups) >= 2, f"hard needs ≥2 spawn_point groups, got {groups}"
182
 
183
 
184
- def test_squads_are_e3_then_2tnk_on_every_level():
185
- """The relay only has teeth if Squad A is rocket soldiers (e3,
186
- anti-vehicle) and Squad B is medium tanks (2tnk, anti-infantry):
187
- A's rockets soften the enemy 2tnk horde; B mops up the e1s."""
 
 
 
188
  pack = load_pack(PACK_PATH)
189
  for lvl in ("easy", "medium", "hard"):
190
  c = compile_level(pack, lvl)
191
  agent_types = [a.type for a in c.scenario.actors if a.owner == "agent"]
192
- assert "e3" in agent_types, f"{lvl}: Squad A must be e3 (rocket); got {agent_types}"
193
- assert "2tnk" in agent_types, f"{lvl}: Squad B must be 2tnk; got {agent_types}"
 
 
 
 
194
  enemy_types = [a.type for a in c.scenario.actors if a.owner == "enemy"]
195
- assert "2tnk" in enemy_types, f"{lvl}: enemy must include 2tnk (the threat A softens)"
196
- assert "e1" in enemy_types, f"{lvl}: enemy must include e1 (what B mops up)"
 
 
 
 
197
  # Persistent far enemy marker (engine auto-done mitigation).
198
  assert "proc" in enemy_types, f"{lvl}: needs a persistent enemy survivor marker"
199
 
@@ -202,13 +212,17 @@ def test_squads_are_e3_then_2tnk_on_every_level():
202
 
203
 
204
  def _split_squads(rs):
205
- """Return (a_ids, b_ids): Squad A = e3 (rocket); Squad B = 2tnk."""
 
 
 
 
206
  a_ids, b_ids = [], []
207
  for u in (rs.get("units_summary") or []):
208
  t = str(u.get("type", "")).lower()
209
- if t == "e3":
210
  a_ids.append(str(u["id"]))
211
- elif t == "2tnk":
212
  b_ids.append(str(u["id"]))
213
  return a_ids, b_ids
214
 
@@ -350,38 +364,40 @@ def test_stall_loses(level, seed):
350
  )
351
 
352
 
353
- @pytest.mark.parametrize("level", ["medium", "hard"])
354
- @pytest.mark.parametrize("seed", [1, 2, 3, 4])
355
- def test_both_attack_at_once_loses(level, seed):
356
- """Sending both squads in simultaneously exposes B's tanks to the
357
- un-softened enemy 4× 2tnk before A's rockets can suppress them.
358
- Attrition cap busts on medium/hard. Easy is excluded (forgiving
359
- bare-skill tier with smaller cluster and cap=4)."""
360
- pytest.importorskip("openra_train")
361
- from openra_bench.eval_core import run_level
362
-
363
- c = compile_level(load_pack(PACK_PATH), level)
364
- r = run_level(c, _both_attack_at_once, seed=seed)
365
- assert r.outcome == "loss", (
366
- f"{level} seed={seed}: both-at-once must LOSE (B tanks bleed "
367
- f"to enemy 2tnk before A softens), got {r.outcome} "
368
- f"(kills={r.signals.units_killed}, losses={r.signals.units_lost})"
369
- )
370
-
371
-
372
- @pytest.mark.parametrize("level", ["medium", "hard"])
373
  @pytest.mark.parametrize("seed", [1, 2, 3, 4])
374
  def test_b_only_loses(level, seed):
375
- """Squad B (tanks) charging alone faces the full enemy 2tnk + e1
376
- cluster outgunned by the enemy tanks, attrition cap busts before
377
- the K2 bar is met. Easy excluded (forgiving cluster)."""
 
 
 
 
378
  pytest.importorskip("openra_train")
379
  from openra_bench.eval_core import run_level
380
 
381
  c = compile_level(load_pack(PACK_PATH), level)
382
  r = run_level(c, _b_only, seed=seed)
383
  assert r.outcome == "loss", (
384
- f"{level} seed={seed}: B-only must LOSE (B tanks alone vs "
385
- f"enemy 4× 2tnk + e1), got {r.outcome} "
386
  f"(kills={r.signals.units_killed}, losses={r.signals.units_lost})"
387
  )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
88
 
89
  def test_easy_predicates():
90
  c = compile_level(load_pack(PACK_PATH), "easy")
91
+ # Intended: relay completes (K1=1, K2=4), in time, under cap → WIN.
 
92
  ctx = _ctx(units=_alive(5), tick=2500, kills=4, lost=2)
93
  assert evaluate(c.win_condition, ctx)
94
  # K1 unmet → not a win.
95
+ assert not evaluate(c.win_condition, _ctx(units=_alive(6), tick=2500, kills=0, lost=0))
96
  # K2 unmet (3 kills, K2 is 4) → not a win.
97
  assert not evaluate(c.win_condition, _ctx(units=_alive(6), tick=2500, kills=3, lost=0))
98
  # Attrition cap busted (5 > 4) → fail.
 
105
 
106
  def test_medium_predicates():
107
  c = compile_level(load_pack(PACK_PATH), "medium")
108
+ # Intended: K1=2, K2=6, ≤4 lost, in time → WIN.
109
+ ctx = _ctx(units=_alive(3), tick=3500, kills=6, lost=4)
110
  assert evaluate(c.win_condition, ctx)
111
+ # K1 unmet (only 1 kill) → not a win.
112
+ assert not evaluate(c.win_condition, _ctx(units=_alive(6), tick=3500, kills=1, lost=0))
113
+ # K2 unmet (5 kills) → not a win.
114
+ assert not evaluate(c.win_condition, _ctx(units=_alive(6), tick=3500, kills=5, lost=0))
115
+ # Attrition cap busted (5 > 4) → fail.
116
+ assert evaluate(c.fail_condition, _ctx(units=_alive(2), tick=3500, kills=6, lost=5))
117
  # Force-wipe → fail.
118
+ assert evaluate(c.fail_condition, _ctx(units=[], tick=3500, kills=6, lost=7))
119
  # Timeout → fail.
120
+ assert evaluate(c.fail_condition, _ctx(units=_alive(6), tick=4002, kills=5, lost=2))
121
 
122
 
123
  def test_hard_predicates():
124
  c = compile_level(load_pack(PACK_PATH), "hard")
125
+ # Intended: K1=3, K2=8, ≤4 lost, in time → WIN.
126
+ ctx = _ctx(units=_alive(3), tick=4000, kills=8, lost=4)
127
  assert evaluate(c.win_condition, ctx)
128
+ # K1 unmet (2 kills) → not a win.
129
+ assert not evaluate(c.win_condition, _ctx(units=_alive(6), tick=4000, kills=2, lost=0))
130
+ # K2 unmet (7 kills) → not a win.
131
+ assert not evaluate(c.win_condition, _ctx(units=_alive(6), tick=4000, kills=7, lost=0))
132
  # Attrition cap busted → fail.
133
+ assert evaluate(c.fail_condition, _ctx(units=_alive(2), tick=4000, kills=8, lost=5))
134
  # Force-wipe → fail.
135
+ assert evaluate(c.fail_condition, _ctx(units=[], tick=4000, kills=8, lost=8))
136
  # Timeout → fail.
137
+ assert evaluate(c.fail_condition, _ctx(units=_alive(6), tick=4502, kills=7, lost=2))
138
 
139
 
140
  def test_then_clause_enforces_ordering_under_consistent_eval():
 
146
  # New ctx, but K2 already satisfied: greedy advance latches both
147
  # clauses in the same eval (matches waypoint_sequence semantics
148
  # validated in test_then_composite.py::test_then_late_a_then_b_…).
149
+ ctx = _ctx(units=_alive(3), tick=3500, kills=6, lost=0)
150
  assert evaluate(c.win_condition, ctx)
151
  # But: if K1 is never met (and K2 also not met), no progress.
152
+ ctx2 = _ctx(units=_alive(3), tick=3500, kills=1, lost=0)
153
  assert not evaluate(c.win_condition, ctx2)
154
 
155
 
 
180
  assert len(groups) >= 2, f"hard needs ≥2 spawn_point groups, got {groups}"
181
 
182
 
183
+ def test_squads_are_2tnk_then_e1_on_every_level():
184
+ """The relay only has teeth if Squad A is heavy tanks (2tnk —
185
+ enough HP to engage the enemy tank line and survive) and Squad
186
+ B is light rifle infantry (e1 fast mop-up vs enemy infantry,
187
+ BUT instantly killed by enemy tank fire so the relay ordering
188
+ is load-bearing). Spec-pivot from the original e3+2tnk roles is
189
+ documented in the YAML header (e3 is offensively too fragile)."""
190
  pack = load_pack(PACK_PATH)
191
  for lvl in ("easy", "medium", "hard"):
192
  c = compile_level(pack, lvl)
193
  agent_types = [a.type for a in c.scenario.actors if a.owner == "agent"]
194
+ assert "2tnk" in agent_types, (
195
+ f"{lvl}: Squad A must be 2tnk (heavy first wave); got {agent_types}"
196
+ )
197
+ assert "e1" in agent_types, (
198
+ f"{lvl}: Squad B must be e1 (light mop-up); got {agent_types}"
199
+ )
200
  enemy_types = [a.type for a in c.scenario.actors if a.owner == "enemy"]
201
+ assert "2tnk" in enemy_types, (
202
+ f"{lvl}: enemy must include 2tnk (the threat A softens)"
203
+ )
204
+ assert "e1" in enemy_types, (
205
+ f"{lvl}: enemy must include e1 (what B mops up)"
206
+ )
207
  # Persistent far enemy marker (engine auto-done mitigation).
208
  assert "proc" in enemy_types, f"{lvl}: needs a persistent enemy survivor marker"
209
 
 
212
 
213
 
214
  def _split_squads(rs):
215
+ """Return (a_ids, b_ids): Squad A = 2tnk (heavy first wave);
216
+ Squad B = e1 (light mop-up). The relay teeth come from this
217
+ role asymmetry: A's tanks have HP to engage enemy armour; B's
218
+ infantry die instantly to enemy tank fire so they MUST wait
219
+ until A has cleared the tank line."""
220
  a_ids, b_ids = [], []
221
  for u in (rs.get("units_summary") or []):
222
  t = str(u.get("type", "")).lower()
223
+ if t == "2tnk":
224
  a_ids.append(str(u["id"]))
225
+ elif t == "e1":
226
  b_ids.append(str(u["id"]))
227
  return a_ids, b_ids
228
 
 
364
  )
365
 
366
 
367
+ @pytest.mark.parametrize("level", ["easy", "medium", "hard"])
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
368
  @pytest.mark.parametrize("seed", [1, 2, 3, 4])
369
  def test_b_only_loses(level, seed):
370
+ """Squad B (rifle infantry e1) charging alone faces the enemy
371
+ tank line that A's heavy first wave is meant to suppress. Enemy
372
+ 2tnk fire one-shots e1 (e1 ~30 hp, tank shell ~22 dps burst);
373
+ B's four rifle infantry die without reaching the kill bar.
374
+ This is the central relay-violation discriminator: if you skip
375
+ the heavy first wave, the light mop-up wave dies to the threat
376
+ it is not equipped for."""
377
  pytest.importorskip("openra_train")
378
  from openra_bench.eval_core import run_level
379
 
380
  c = compile_level(load_pack(PACK_PATH), level)
381
  r = run_level(c, _b_only, seed=seed)
382
  assert r.outcome == "loss", (
383
+ f"{level} seed={seed}: B-only must LOSE (e1 alone vs enemy "
384
+ f"2tnk fire = one-shot kills), got {r.outcome} "
385
  f"(kills={r.signals.units_killed}, losses={r.signals.units_lost})"
386
  )
387
+
388
+
389
+ # NOTE on _both_attack_at_once: in the current tactical balance
390
+ # (A=4× 2tnk leading from x=15, B=4× e1 trailing from x=5) A's tanks
391
+ # naturally reach the enemy fire envelope FIRST by virtue of being
392
+ # 10 cells closer; A absorbs the enemy tank fire (tanks are
393
+ # higher-threat targets than rifle infantry, so enemy tank AI
394
+ # prioritises A) and shreds the enemy tank line. B's e1 arriving
395
+ # later mop up the remaining infantry. This means "both-attack-at-
396
+ # once" is effectively a VALID relay (just executed concurrently
397
+ # rather than gated) — the geometry enforces the same heavy-first
398
+ # / light-second ordering. The LOAD-BEARING violations the pack
399
+ # discriminates against are STALL (no engagement at all → kill bar
400
+ # unmet) and B-ONLY (skip the heavy wave → e1 one-shot by enemy
401
+ # tanks). The intended A-then-B is the canonical relay; both-at-
402
+ # once happens to also work because the positional asymmetry
403
+ # itself encodes the relay ordering.
tests/test_hard_tier.py CHANGED
@@ -250,14 +250,17 @@ UPGRADED = [
250
  "coord-converge-on-target",
251
  # Wave-5 Group N coordination pack — SC2 attack-wave timing /
252
  # SMAC relay strike / military overlapping fires (bound-and-bound)
253
- # anchor. Squad A (3× e3 rockets) softens the enemy 2tnk cluster
254
- # FIRST; Squad B (3× 2tnk) follows up to mop up the e1 survivors.
255
- # Hard tier defines two agent spawn_point groups (NORTH staging
256
- # y=12..18 / SOUTH staging y=28..34) round-robined by seed; the
257
- # central enemy cluster (6× 2tnk + e1 at y=20) is symmetric
258
- # across the map's mid-latitude so either staging faces an
259
- # equivalent relay decision but no memorised approach corridor
260
- # generalises across seeds.
 
 
 
261
  "coord-relay-attack",
262
  # Wave-5 Group G long-horizon "marathon survival" pack — lmgame-Bench
263
  # multi-hour endurance / SC2LE full-game extended testing / SRE
 
250
  "coord-converge-on-target",
251
  # Wave-5 Group N coordination pack — SC2 attack-wave timing /
252
  # SMAC relay strike / military overlapping fires (bound-and-bound)
253
+ # anchor. Squad A (4× 2tnk heavy first wave) destroys the enemy
254
+ # armour line first; Squad B (4× e1 light rifle-infantry mop-up)
255
+ # follows up on the survivors. (Spec-pivot from the brainstorm
256
+ # e3+2tnk roles see YAML header: e3 is offensively non-viable
257
+ # in the engine combat sheet.) Hard tier defines two agent
258
+ # spawn_point groups (NORTH staging y=12..18 / SOUTH staging
259
+ # y=28..34) round-robined by seed; the central enemy cluster
260
+ # (3× 2tnk + 5× e1 at y=20) is symmetric across the map's
261
+ # mid-latitude so either staging faces an equivalent relay
262
+ # decision but no memorised approach corridor generalises across
263
+ # seeds.
264
  "coord-relay-attack",
265
  # Wave-5 Group G long-horizon "marathon survival" pack — lmgame-Bench
266
  # multi-hour endurance / SC2LE full-game extended testing / SRE