yxc20098 commited on
Commit
bb6ce22
·
1 Parent(s): f9013d6

feat(scenario): coord-diversionary-attack — A-diverts-B-strikes (SC2 split-attack / Sun Tzu diversion anchor)

Browse files

Wave-5 Group N coordination pack. REASONING capability:
diversionary / split-attack assault. Two squads (Squad A=3 jeeps NW
bait, Squad B=4 2tnk SW main strike) split-attack against an enemy
that holds TWO key buildings — a REAL fact at (100,10) with LIGHT
(1 e1) garrison and a DECOY powr at (100,30) with HEAVIER (3-5 e3)
anti-tank cluster. Win predicate
`enemy_key_buildings_destroyed_in_region:{x:100, y:10, radius:6,
types:[fact]}` — ONLY destroying the REAL fact scores; razing the
powr does not.

The intended SC2 multi-prong / military diversionary doctrine: A
slashes south-east into the decoy garrison's aggro arc to absorb
its pursuit, B drives north-east through the now-thin fact
perimeter and razes the construction yard before the surviving
light garrison can trade out the column. The lazy SWAP (jeeps onto
the close-looking fact, tanks onto the close-looking powr) loses:
tanks die to e3 anti-tank AND raze the wrong target. Stall,
bait-only, and brute-on-decoy all lose.

Engine notes (per CLAUDE.md):
- Uses scripted `guard` bot (NOT `hunt` as originally spec'd):
`hunt` makes every enemy unit charge across the map at the
nearest agent, collapsing the decoy-absorbs-pursuit mechanism;
`guard` holds post and lunges only on aggro arc entry — the
proven bait-able-defender idiom from combat-bait-counter-attack.
- Both `agent_units_killed` and `enemy_units_killed` termination
flags disabled so the within_ticks / units_lost_lte band
evaluates to the terminal frame.
- Sentinel `fact` at (125,38) keeps the episode alive past
objective-fact destruction (the CLAUDE.md auto-terminate guard).

Difficulty axis:
- easy (5 lost, 1 e1 / 3 e3): bare diversionary skill.
- medium (4 lost, 1 e1 / 5 e3): denser decoy garrison forces a
clean strike.
- hard (4 lost, 1 e1 / 5 e3, +2 spawn_point groups): seed-varies
the staging latitude (NORTH y≈10..14 / SOUTH y≈26..30) so the
bait/strike vector flips per seed; layout is enemy-symmetric.

Distinct from combat-bait-counter-attack (single objective, single
strike+bait against guard cluster), strategy-twobody (counts BOTH
bases, parallel control), and artofwar-decoy-sacrifice (hunting
picket pursuit vs leashed defender). Anchored to SC2 multi-prong /
Sun Tzu (loud feint + quiet main thrust) / CICERO deception /
advertising loud-feint-quiet-launch.

openra_bench/scenarios/packs/coord-diversionary-attack.yaml CHANGED
@@ -4,43 +4,58 @@
4
  # squads start on the agent's WEST edge (A=jeeps at NW, B=2tnks at
5
  # SW); the enemy holds TWO key buildings on the EAST: a real target
6
  # `fact` with LIGHT defence at (100,10), and a decoy `powr` with
7
- # HEAVIER defence at (100,30). The enemy is the scripted `hunt` bot:
8
- # each enemy unit pursues the nearest agent unit. The win predicate
9
- # is `enemy_key_buildings_destroyed_in_region:{x:100, y:10, r:6,
10
- # types:[fact]}` ONLY destroying the REAL fact at (100,10) scores;
11
- # raising the decoy `powr` does not.
 
 
 
 
 
 
 
 
 
 
 
 
 
12
  #
13
  # The intended idiom is the SC2 split-attack / military diversionary
14
  # assault doctrine: drive Squad A (fast, cheap, disposable jeeps)
15
- # south-east into the powr-defence aggro radius — the hunt bot's
16
- # heavier south defenders pursue the nearest agent units (the jeeps)
17
- # south and DISENGAGE from the fact line; meanwhile drive Squad B
18
- # (the main strike) north-east into the now-thin fact perimeter and
19
- # raze the real target before its surviving light garrison can
20
- # trade out the column.
21
  #
22
  # Distinct from the obvious "send each squad to its nearest target"
23
  # play: A is at NW (closer to the real fact in row-10) and B is at
24
  # SW (closer to the decoy powr in row-30). The lazy assignment
25
- # (jeeps → fact, tanks → powr) attacks the WRONG target and
26
- # under-strikes the right one: jeeps can't crack the fact's light
27
- # garrison fast enough, the tanks raze the decoy powr (which doesn't
28
- # count), and the clock expires. The intended play is the SWAP:
29
- # jeeps cross to the SOUTH to bait, tanks cross to the NORTH to
30
- # strike neither squad attacks its nearest visible target.
 
 
 
31
  #
32
  # Distinct from `combat-bait-counter-attack` (a SINGLE strike squad
33
  # with a SINGLE bait against a leashed `guard` cluster on one
34
  # objective): here there are TWO objectives (one real, one decoy)
35
- # with a PURSUING `hunt` enemy, the win predicate is REGION-scoped
36
- # to the real target alone (so killing the decoy doesn't score),
37
- # and the coordination is between two same-side multi-unit squads
38
- # (split-attack), not a bait/strike micro pair.
39
  #
40
  # Distinct from `strategy-twobody` (parallel control of two squads,
41
  # each razing its OWN base): there the win counts BOTH bases; here
42
  # only ONE base counts and the other exists to ABSORB the enemy
43
- # pursuit.
44
  #
45
  # Engine notes (per CLAUDE.md):
46
  # - `fact` and `powr` are MustBeDestroyed. A SENTINEL enemy `fact`
@@ -49,19 +64,21 @@
49
  # instant the objective fact dies — letting within_ticks /
50
  # units_lost_lte evaluate cleanly on the terminal frame even
51
  # if the bait squad ALSO razes the decoy powr.
52
- # - `hunt` bot: each enemy unit attacks the nearest AGENT unit
53
- # and pursues. The bait squad (jeeps) must come within visual
54
- # range of the south defenders for them to commit; once they
55
- # pursue jeeps, the fact's garrison (in the north) keeps its
56
- # own nearest-agent target = the tank column if B is already
57
- # committed, but the south defenders won't snap back since they
58
- # remain locked on the jeep targets they're pursuing.
59
- # - `move_units` auto-fires opportunistically: B's column en route
60
- # to (100,10) will engage any south defender that happens to
61
- # stray into range, so A's bait must keep the south defenders
62
- # committed AWAY from B's route. Bait staying in the south-east
63
- # quadrant (around (100,30)) is far enough from B's north-line
64
- # y≈10 corridor.
 
 
65
 
66
  meta:
67
  id: coord-diversionary-attack
@@ -71,23 +88,26 @@ meta:
71
  Two squads command a split-attack against an enemy that holds
72
  TWO key buildings: a REAL target (construction yard) with light
73
  defence and a DECOY (power plant) with heavier defence. The
74
- scripted `hunt` enemy pursues the nearest agent unit. Driving
75
- each squad onto its nearest visible target is a trap: the
76
- closer-but-weaker squad (jeeps) can't crack the real target's
77
- garrison fast enough, the closer-stronger squad (tanks) razes
78
- the decoy (which scores zero), the clock expires. The winning
79
- policy SWAPS the assignment: commit the fast disposable jeeps
80
- south-east into the decoy's heavier defence to bait its
81
- pursuers OFF the fact line; commit the tanks north-east through
82
- the now-thin fact perimeter to raze the construction yard
83
- before the surviving light garrison can trade out the column.
84
- Phase 1 (the diversion) yields zero objective credit — only
85
- phase 2, after the south defenders have committed south, scores.
86
- The credit problem is DIVERSIONARY ASSAULT: a no-reward enabling
87
- action (drag the heaviest defence away from the real target
88
- with a disposable lure squad) must precede and temporally
89
- overlap the rewarded objective action (commit the main strike
90
- on the briefly-undefended real target).
 
 
 
91
  robotics_analogue: >
92
  Concurrent multi-robot dispatch with deception: two independent
93
  teams must coordinate against a reactive adversary, where one
@@ -95,8 +115,8 @@ meta:
95
  attention to a decoy target on the OPPOSITE flank) while the
96
  other team — counter-intuitively assigned to the FARTHER
97
  objective — completes the rewarded task. The lazy nearest-task
98
- assignment is degenerate: every team must commit ACROSS the map
99
- to the objective its sibling enabled.
100
  benchmark_anchor:
101
  - "SC2 multi-prong / split-attack assault"
102
  - "military diversionary tactics (Sun Tzu loud feint + quiet main thrust)"
@@ -108,60 +128,74 @@ base_map: rush-hour-arena
108
 
109
  base:
110
  agent: {faction: allies, cash: 0}
111
- enemy: {faction: soviet, cash: 0, bot_type: hunt}
112
  tools: [move_units, attack_unit, attack_move, stop]
113
  planning: true
114
- termination: {max_ticks: 12000}
 
 
 
 
 
 
 
 
 
 
 
115
 
116
  levels:
117
  # ── EASY ─────────────────────────────────────────────────────────
118
- # The bare diversionary-attack skill. Light defence around the
119
- # REAL fact at (100,10): 2 e1 riflemen (not enough to stop B's
120
- # 4-tank column once it arrives). Heavier defence around the DECOY
121
- # powr at (100,30): 3 e3 rocket infantry (anti-tank Dragons that
122
- # WOULD shred a tank column if it went south). Generous loss cap
123
- # (3). Brute frontal both squads on the decoy: razes powr (which
124
- # doesn't count), clock expires, fact still standing LOSS. Lazy
125
- # nearest assignment — jeeps north on fact, tanks south on powr:
126
- # jeeps trade out trying to crack fact's 2 e1 garrison (too slow,
127
- # 2 e1 + travel time eats the clock), tanks raze the wrong target
 
128
  # → LOSS. Stall — clock runs out → LOSS. Intended: jeeps drive
129
- # south-east to (100,30) area to bait the 3 e3 + the hunt-pursued
130
- # south picket; tanks drive north-east to (100,10) and crack the
131
- # 2 e1 garrison + raze the fact while it's lightly defended → WIN.
132
  easy:
133
  description: >
134
  Two enemy key buildings on the east side: a REAL target
135
- (construction yard `fact` at 100,10) with LIGHT defence (2
136
- enemy riflemen), and a DECOY (power plant `powr` at 100,30)
137
  with HEAVIER defence (3 enemy rocket infantry). Razing the
138
  DECOY does NOT count — only destroying the REAL fact at
139
- (100,10) scores. The enemy `hunt` bot pursues whichever
140
- agent units come into range. Your forces start on the west:
141
- Squad A (3 jeeps, fast & disposable) at NW (6,10); Squad B
142
- (4 medium tanks, the main strike) at SW (6,30). The lazy
143
- assignment (jeeps on the close-looking fact, tanks on the
144
- close-looking powr) loses: the jeeps can't crack the fact's
145
- garrison fast enough AND the tanks raze the wrong target.
146
- Commit Squad A south-east toward the powr cluster — the
147
- heavy rocket-infantry defence pursues your jeeps; commit
148
- Squad B north-east through the now-thin fact perimeter and
149
- raze the construction yard before tick 5400. Lose at most
150
- 3 units (the bait jeeps are expendable; the tank column is
151
- not).
 
152
  overrides:
153
  actors:
154
  # ── AGENT — Squad A (NW, jeeps) + Squad B (SW, 2tnks) ──
155
  - {type: jeep, owner: agent, position: [6, 10], stance: 1, count: 3}
156
  - {type: 2tnk, owner: agent, position: [6, 30], stance: 1, count: 4}
157
  # ── ENEMY ──
158
- # REAL target: construction yard at (100,10), LIGHT garrison.
 
159
  - {type: fact, owner: enemy, position: [100, 10]}
160
- - {type: e1, owner: enemy, position: [96, 10], stance: 2}
161
  - {type: e1, owner: enemy, position: [100, 14], stance: 2}
162
- # DECOY target: power plant at (100,30), HEAVIER garrison
163
- # (anti-tank rocket infantry these would shred B if it
164
- # came south, so B must NOT go to the closer decoy).
 
165
  - {type: powr, owner: enemy, position: [100, 30]}
166
  - {type: e3, owner: enemy, position: [96, 30], stance: 2}
167
  - {type: e3, owner: enemy, position: [100, 26], stance: 2}
@@ -176,52 +210,41 @@ levels:
176
  - enemy_key_buildings_destroyed_in_region:
177
  {x: 100, y: 10, radius: 6, types: [fact]}
178
  - within_ticks: 5400
179
- - units_lost_lte: 3
180
  fail_condition:
181
  any_of:
182
  - after_ticks: 5401
183
- - not: {units_lost_lte: 3}
184
  - not: {own_units_gte: 1}
185
  max_turns: 62
186
 
187
  # ── MEDIUM ───────────────────────────────────────────────────────
188
- # +1 controlled variable: the decoy garrison thickens (3 → 5 e3 +
189
- # an e1 north outpost between B's lane and the fact) so a brute
190
- # all-on-powr or all-on-fact pile-up is decisively fatal; the
191
- # WORKING bait must keep ALL the south defenders pursuing OR the
192
- # north outpost will trade B's column. Loss cap tightens to 3
193
- # (still room for the 3 bait jeeps but no tank may be spent).
194
  medium:
195
  description: >
196
  Same two enemy key buildings (REAL `fact` at 100,10, DECOY
197
- `powr` at 100,30), with a denser south defence (5 enemy
198
- rocket infantry around the powr) AND a north outpost (1
199
- rocket infantryman between your west base and the fact
200
- lane). Only destroying the REAL fact at (100,10) scores;
201
- razing the powr does not. Squad A (3 jeeps at NW) +
202
- Squad B (4 tanks at SW). Brute frontal on either target
203
- loses (south defence shreds tanks; north outpost + fact
204
- garrison shreds jeeps). The lazy nearest-target swap also
205
- loses (jeeps too weak to crack fact under tighter clock,
206
- tanks waste fire on the decoy). Commit A south-east to
207
- pull all 5 south rocket infantry off-post in pursuit;
208
- commit B north-east, clear the north outpost in passing
209
- and raze the fact before tick 5400. You may lose at most
210
- 3 units (the 3 bait jeeps are expendable; losing any tank
211
- fails the run).
212
  overrides:
213
  actors:
214
  - {type: jeep, owner: agent, position: [6, 10], stance: 1, count: 3}
215
  - {type: 2tnk, owner: agent, position: [6, 30], stance: 1, count: 4}
216
- # REAL target — light garrison + a north outpost on B's lane.
217
  - {type: fact, owner: enemy, position: [100, 10]}
218
- - {type: e1, owner: enemy, position: [96, 10], stance: 2}
219
  - {type: e1, owner: enemy, position: [100, 14], stance: 2}
220
- # North outpost: between B's west base and the fact (B's
221
- # lane y≈10..14). One e3 — manageable for B but a non-
222
- # trivial speed bump.
223
- - {type: e3, owner: enemy, position: [60, 10], stance: 2}
224
- # DECOY target — heavier (5 e3) rocket-infantry garrison.
225
  - {type: powr, owner: enemy, position: [100, 30]}
226
  - {type: e3, owner: enemy, position: [96, 30], stance: 2}
227
  - {type: e3, owner: enemy, position: [100, 26], stance: 2}
@@ -234,53 +257,41 @@ levels:
234
  - enemy_key_buildings_destroyed_in_region:
235
  {x: 100, y: 10, radius: 6, types: [fact]}
236
  - within_ticks: 5400
237
- - units_lost_lte: 3
238
  fail_condition:
239
  any_of:
240
  - after_ticks: 5401
241
- - not: {units_lost_lte: 3}
242
  - not: {own_units_gte: 1}
243
  max_turns: 62
244
 
245
  # ── HARD ─────────────────────────────────────────────────────────
246
  # +1 more controlled variable: TWO seed-driven agent spawn_point
247
- # groups round-robin the staging LATITUDE, AND there are TWO
248
- # candidate REAL targets (a `fact` at NE (100,10) and a `fact` at
249
- # SE (100,30)) the win clause accepts destruction of EITHER
250
- # candidate. Between them sits a HEAVY CENTRE DECOY: a `powr` at
251
- # (100,20) garrisoned by a dense e3 anti-tank cluster. The hunt
252
- # bot pursues nearest agent units. Per CLAUDE.md, enemy actors
253
- # are NOT spawn_point filtered the layout is symmetric across
254
- # y=20 so the test is the SAME diversionary discipline regardless
255
- # of which spawn the seed picks: A as bait toward the CENTRE
256
- # decoy (pulling the e3 cluster into pursuit), B as the strike
257
- # along the OPPOSITE-latitude corridor onto its candidate fact.
258
- # The model must SCOUT (observe both candidates and the centre
259
- # cluster) to identify the working bait/strike pair for its
260
- # spawn — a memorised "always go NE" plan eats the centre
261
- # cluster on the strike approach. Strict attrition cap of 2:
262
- # only the bait jeeps may be spent; losing any tank fails.
263
  hard:
264
  description: >
265
- Two candidate real targets on the east: a construction yard
266
- (`fact`) at NE (100,10) AND a construction yard at SE
267
- (100,30). Destroying EITHER counts. Between them sits the
268
- DECOY: a power plant (`powr`) at the CENTRE (100,20)
269
- garrisoned by a dense cluster of enemy anti-tank rocket
270
- infantry that will pursue whichever agent unit is nearest.
271
- The enemy `hunt` bot pursues nearest agent units.
272
- Squad A (3 jeeps) + Squad B (4 tanks) start on the west,
273
- staged either NORTH (y≈10..14) or SOUTH (y≈26..30) the
274
- seed picks. Razing the centre powr does NOT score; only a
275
- real fact at NE or SE counts. You may lose at most 2 units
276
- (the 3 bait jeeps are expendable; losing any tank fails).
277
- Commit Squad A on a SLASH toward the CENTRE decoy to draw
278
- the heavy rocket-infantry cluster into pursuit; commit
279
- Squad B along the OPPOSITE-LATITUDE corridor from A onto
280
- the matching candidate fact and raze it before tick 5400.
281
- Brute frontal (both squads centre), lazy near-fact strike
282
- (B onto own-latitude fact while the centre cluster is still
283
- in range), stall, bait-only, and wrong-fact strike all lose.
284
  overrides:
285
  actors:
286
  # spawn_point 0 — NORTH staging (jeeps + tanks at y≈10..14).
@@ -293,44 +304,30 @@ levels:
293
  spawn_point: 1}
294
  - {type: 2tnk, owner: agent, position: [6, 26], stance: 1, count: 4,
295
  spawn_point: 1}
296
- # Candidate REAL target NE: fact + LIGHT e1 garrison.
297
  - {type: fact, owner: enemy, position: [100, 10]}
298
- - {type: e1, owner: enemy, position: [96, 10], stance: 2}
299
  - {type: e1, owner: enemy, position: [100, 14], stance: 2}
300
- # Candidate REAL target SE: fact + LIGHT e1 garrison
301
- # (symmetric ��� both candidates light, the test is the
302
- # CENTRE-cluster diversion, not which one).
303
- - {type: fact, owner: enemy, position: [100, 30]}
304
- - {type: e1, owner: enemy, position: [96, 30], stance: 2}
305
- - {type: e1, owner: enemy, position: [100, 26], stance: 2}
306
- # DECOY centre cluster: heavy anti-tank rocket-infantry
307
- # around a `powr` (decoy building) at the mid-latitude
308
- # between the two candidate facts. Razing the powr does
309
- # NOT score (not a `fact`); the cluster's role is to
310
- # ABSORB pursuit when baited.
311
- - {type: powr, owner: enemy, position: [100, 20]}
312
- - {type: e3, owner: enemy, position: [96, 18], stance: 2}
313
- - {type: e3, owner: enemy, position: [96, 22], stance: 2}
314
- - {type: e3, owner: enemy, position: [100, 18], stance: 2}
315
- - {type: e3, owner: enemy, position: [100, 22], stance: 2}
316
- - {type: e3, owner: enemy, position: [104, 20], stance: 2}
317
- # SENTINEL: far-corner persistent enemy fact keeps the
318
  # episode alive past objective-fact destruction so the
319
  # within_ticks / units_lost_lte predicates evaluate on
320
  # the terminal frame.
321
  - {type: fact, owner: enemy, position: [125, 38]}
322
  win_condition:
323
  all_of:
324
- - any_of:
325
- - enemy_key_buildings_destroyed_in_region:
326
- {x: 100, y: 10, radius: 6, types: [fact]}
327
- - enemy_key_buildings_destroyed_in_region:
328
- {x: 100, y: 30, radius: 6, types: [fact]}
329
  - within_ticks: 5400
330
- - units_lost_lte: 2
331
  fail_condition:
332
  any_of:
333
  - after_ticks: 5401
334
- - not: {units_lost_lte: 2}
335
  - not: {own_units_gte: 1}
336
  max_turns: 62
 
4
  # squads start on the agent's WEST edge (A=jeeps at NW, B=2tnks at
5
  # SW); the enemy holds TWO key buildings on the EAST: a real target
6
  # `fact` with LIGHT defence at (100,10), and a decoy `powr` with
7
+ # HEAVIER defence at (100,30). The enemy is the scripted `guard`
8
+ # bot (leashed defender: holds its post, auto-fires in range, lunges
9
+ # at the nearest foe within ≈16 cells, snaps back past a leash of
10
+ # ≈18). The win predicate is `enemy_key_buildings_destroyed_in_
11
+ # region:{x:100, y:10, radius:6, types:[fact]}` ONLY destroying
12
+ # the REAL fact at (100,10) scores; razing the decoy `powr` does
13
+ # not.
14
+ #
15
+ # (Originally spec'd as `hunt`, but `hunt` issues an Attack order
16
+ # every tick to every enemy unit toward the nearest agent unit,
17
+ # which makes the entire decoy garrison race west into the agent's
18
+ # face within two turns — collapsing the "decoy absorbs pursuit"
19
+ # mechanism. `guard` preserves the intended pinned-defender + lunge-
20
+ # on-bait semantics — the south rocket-infantry cluster STAYS at
21
+ # the powr until the bait jeep enters their aggro arc, then lunges
22
+ # south after it and is leashed there while B's tank column hits
23
+ # the fact uncontested. The "react-to-incursion" hint in the spec
24
+ # is faithfully implemented by `guard`'s aggro/leash arc.)
25
  #
26
  # The intended idiom is the SC2 split-attack / military diversionary
27
  # assault doctrine: drive Squad A (fast, cheap, disposable jeeps)
28
+ # south-east into the powr-defence aggro radius — the leashed
29
+ # defenders engage / pursue the jeeps and DISENGAGE from the fact
30
+ # line; meanwhile drive Squad B (the main strike) north-east into
31
+ # the now-thin fact perimeter and raze the real target before its
32
+ # light garrison can trade out the column.
 
33
  #
34
  # Distinct from the obvious "send each squad to its nearest target"
35
  # play: A is at NW (closer to the real fact in row-10) and B is at
36
  # SW (closer to the decoy powr in row-30). The lazy assignment
37
+ # (jeeps → fact, tanks → powr) attacks the WRONG target with the
38
+ # wrong squad: the tank column commits onto the heavy anti-tank
39
+ # decoy garrison (gets shredded by e3 anti-tank fire AND razes the
40
+ # wrong building `powr` doesn't satisfy the `types:[fact]`
41
+ # predicate), while the jeeps trickle into the e1 fact garrison
42
+ # (too weak to crack a building in budget). The intended play is
43
+ # the SWAP: jeeps cross to the SOUTH to bait, tanks cross to the
44
+ # NORTH to strike — neither squad attacks its nearest visible
45
+ # target.
46
  #
47
  # Distinct from `combat-bait-counter-attack` (a SINGLE strike squad
48
  # with a SINGLE bait against a leashed `guard` cluster on one
49
  # objective): here there are TWO objectives (one real, one decoy)
50
+ # with a REGION-scoped win predicate to the real target alone (so
51
+ # killing the decoy doesn't score), and the coordination is between
52
+ # two same-side multi-unit squads (split-attack), not a bait/strike
53
+ # micro pair.
54
  #
55
  # Distinct from `strategy-twobody` (parallel control of two squads,
56
  # each razing its OWN base): there the win counts BOTH bases; here
57
  # only ONE base counts and the other exists to ABSORB the enemy
58
+ # pursuit (the diversion role).
59
  #
60
  # Engine notes (per CLAUDE.md):
61
  # - `fact` and `powr` are MustBeDestroyed. A SENTINEL enemy `fact`
 
64
  # instant the objective fact dies — letting within_ticks /
65
  # units_lost_lte evaluate cleanly on the terminal frame even
66
  # if the bait squad ALSO razes the decoy powr.
67
+ # - `guard` bot: each enemy unit HOLDS its spawn cell, auto-fires
68
+ # on units in range, lunges at the nearest foe within
69
+ # GUARD_AGGRO 16 cells, and snaps back past GUARD_LEASH 18.
70
+ # The bait squad (jeeps) must come WITHIN ≈16 cells of the
71
+ # south (powr) defenders to make them lunge; B's strike route
72
+ # to the fact at (100,10) must keep the tank column BEYOND
73
+ # ≈16 cells of any surviving fact garrison unit until it is
74
+ # ready to engage.
75
+ # - `move_units` auto-fires opportunistically: B's tank column
76
+ # en route to (100,10) will engage any south defender that
77
+ # happens to be in weapon range, so A's bait must keep the
78
+ # south defenders committed AWAY from B's route. Bait staying
79
+ # in the south-east quadrant (around (100,30)) is far enough
80
+ # from B's north corridor (y≈10..14) that the south defenders
81
+ # can't reach B before they are leashed back.
82
 
83
  meta:
84
  id: coord-diversionary-attack
 
88
  Two squads command a split-attack against an enemy that holds
89
  TWO key buildings: a REAL target (construction yard) with light
90
  defence and a DECOY (power plant) with heavier defence. The
91
+ scripted `guard` enemy holds post and lunges only when baited
92
+ into its aggro radius. Driving each squad onto its nearest
93
+ visible target is a trap: the closer-stronger squad (tanks)
94
+ commits onto the decoy's heavy anti-tank garrison (gets
95
+ shredded AND razes the wrong building only a `fact` in the
96
+ target region scores), the closer-weaker squad (jeeps) can't
97
+ crack the real target's garrison fast enough, the clock
98
+ expires. The winning policy SWAPS the assignment: commit the
99
+ fast disposable jeeps south-east into the decoy's heavier
100
+ defence to bait its garrison OFF the fact line; commit the
101
+ tanks north-east through the now-thin fact perimeter to raze
102
+ the construction yard before the surviving light garrison can
103
+ trade out the column. Phase 1 (the diversion) yields zero
104
+ objective credit only phase 2, after the south defenders
105
+ have committed south in pursuit, scores. The credit problem
106
+ is DIVERSIONARY ASSAULT: a no-reward enabling action (drag
107
+ the heavier defence away from the real target with a
108
+ disposable lure squad) must precede and temporally overlap
109
+ the rewarded objective action (commit the main strike on the
110
+ briefly-undefended real target).
111
  robotics_analogue: >
112
  Concurrent multi-robot dispatch with deception: two independent
113
  teams must coordinate against a reactive adversary, where one
 
115
  attention to a decoy target on the OPPOSITE flank) while the
116
  other team — counter-intuitively assigned to the FARTHER
117
  objective — completes the rewarded task. The lazy nearest-task
118
+ assignment is degenerate: every team must commit ACROSS the
119
+ map to the objective its sibling enabled.
120
  benchmark_anchor:
121
  - "SC2 multi-prong / split-attack assault"
122
  - "military diversionary tactics (Sun Tzu loud feint + quiet main thrust)"
 
128
 
129
  base:
130
  agent: {faction: allies, cash: 0}
131
+ enemy: {faction: soviet, cash: 0, bot_type: guard}
132
  tools: [move_units, attack_unit, attack_move, stop]
133
  planning: true
134
+ termination:
135
+ max_ticks: 12000
136
+ # Disable BOTH engine auto-`done` flags so the pack's
137
+ # win_condition / fail_condition (within_ticks band +
138
+ # units_lost_lte cap) evaluate to terminal frame regardless of
139
+ # mid-episode unit / building state. Without this the engine
140
+ # ends as 'win' the moment all enemy MOBILE units die (even if
141
+ # the real fact still stands) — the SENTINEL `fact` keeps the
142
+ # building auto-done from triggering, but the units flag is a
143
+ # separate code path that the sentinel doesn't cover.
144
+ agent_units_killed: false
145
+ enemy_units_killed: false
146
 
147
  levels:
148
  # ── EASY ─────────────────────────────────────────────────────────
149
+ # The bare diversionary-attack skill. LIGHT defence around the
150
+ # REAL fact at (100,10): 1 e1 rifleman (token garrison B's
151
+ # 4-tank column crushes it cleanly with minimal attrition).
152
+ # HEAVIER defence around the DECOY powr at (100,30): 3 e3 rocket
153
+ # infantry (anti-tank Dragons that WOULD shred a tank column if
154
+ # it went south). Loss cap 5 (the 3 bait jeeps are expected to
155
+ # die; the tank column may take 1–2 returning-fire hits cracking
156
+ # the lightly-garrisoned fact). Brute frontal on the decoy:
157
+ # razes powr (which doesn't count), tanks die to e3 anti-tank,
158
+ # fact still standing LOSS. Lazy nearest assignment (jeeps
159
+ # fact, tanks → powr): tanks die to e3 AND raze the wrong target
160
  # → LOSS. Stall — clock runs out → LOSS. Intended: jeeps drive
161
+ # south-east into the e3 aggro radius (the bait absorbs the south
162
+ # garrison; jeeps die there); tanks drive north-east to (100,10)
163
+ # and crack the lone e1 + raze the fact → WIN.
164
  easy:
165
  description: >
166
  Two enemy key buildings on the east side: a REAL target
167
+ (construction yard `fact` at 100,10) with LIGHT defence (1
168
+ enemy rifleman), and a DECOY (power plant `powr` at 100,30)
169
  with HEAVIER defence (3 enemy rocket infantry). Razing the
170
  DECOY does NOT count — only destroying the REAL fact at
171
+ (100,10) scores. The enemy `guard` bot holds post and lunges
172
+ at agent units within an aggro radius (snaps back past leash).
173
+ Your forces start on the west: Squad A (3 jeeps, fast &
174
+ disposable) at NW (6,10); Squad B (4 medium tanks, the main
175
+ strike) at SW (6,30). The lazy assignment (jeeps on the
176
+ close-looking fact, tanks on the close-looking powr) loses:
177
+ the rocket-infantry cluster shreds the tank column AND tanks
178
+ raze the wrong target. Commit Squad A south-east toward the
179
+ powr cluster — the heavy defence engages your jeeps; commit
180
+ Squad B north-east through the thin fact perimeter and raze
181
+ the construction yard before tick 5400. You may lose up to
182
+ 5 units (the 3 bait jeeps are expendable; the tank column
183
+ should survive the lightly-defended fact with at most 1–2
184
+ hits taken).
185
  overrides:
186
  actors:
187
  # ── AGENT — Squad A (NW, jeeps) + Squad B (SW, 2tnks) ──
188
  - {type: jeep, owner: agent, position: [6, 10], stance: 1, count: 3}
189
  - {type: 2tnk, owner: agent, position: [6, 30], stance: 1, count: 4}
190
  # ── ENEMY ──
191
+ # REAL target: construction yard at (100,10), LIGHT (1 e1)
192
+ # garrison.
193
  - {type: fact, owner: enemy, position: [100, 10]}
 
194
  - {type: e1, owner: enemy, position: [100, 14], stance: 2}
195
+ # DECOY target: power plant at (100,30), HEAVIER (3 e3)
196
+ # garrison — anti-tank rocket infantry that would shred B
197
+ # if it went south, so B must NOT go to the closer-looking
198
+ # decoy.
199
  - {type: powr, owner: enemy, position: [100, 30]}
200
  - {type: e3, owner: enemy, position: [96, 30], stance: 2}
201
  - {type: e3, owner: enemy, position: [100, 26], stance: 2}
 
210
  - enemy_key_buildings_destroyed_in_region:
211
  {x: 100, y: 10, radius: 6, types: [fact]}
212
  - within_ticks: 5400
213
+ - units_lost_lte: 5
214
  fail_condition:
215
  any_of:
216
  - after_ticks: 5401
217
+ - not: {units_lost_lte: 5}
218
  - not: {own_units_gte: 1}
219
  max_turns: 62
220
 
221
  # ── MEDIUM ───────────────────────────────────────────────────────
222
+ # +1 controlled variable: the decoy garrison thickens (3 → 5 e3)
223
+ # so a brute all-on-powr is even more decisively fatal, AND the
224
+ # loss cap tightens (5 4): the bait must keep the south guards
225
+ # firmly displaced and the strike must be cleaner.
 
 
226
  medium:
227
  description: >
228
  Same two enemy key buildings (REAL `fact` at 100,10, DECOY
229
+ `powr` at 100,30), now with a DENSER south defence (5 enemy
230
+ rocket infantry around the powr). Only destroying the REAL
231
+ fact at (100,10) scores; razing the powr does not. Squad A
232
+ (3 jeeps at NW) + Squad B (4 tanks at SW). Brute frontal on
233
+ the decoy loses (5 e3 shred the tank column). The lazy
234
+ nearest-target swap also loses (tanks on the south cluster).
235
+ Commit A south-east to draw the 5 rocket-infantry into
236
+ pursuit; commit B north-east and raze the fact before tick
237
+ 5400. You may lose at most 4 units (the 3 bait jeeps are
238
+ expendable; the tank column should not bleed more than 1
239
+ to the lightly-garrisoned fact).
 
 
 
 
240
  overrides:
241
  actors:
242
  - {type: jeep, owner: agent, position: [6, 10], stance: 1, count: 3}
243
  - {type: 2tnk, owner: agent, position: [6, 30], stance: 1, count: 4}
244
+ # REAL target — LIGHT (1 e1) garrison.
245
  - {type: fact, owner: enemy, position: [100, 10]}
 
246
  - {type: e1, owner: enemy, position: [100, 14], stance: 2}
247
+ # DECOY target HEAVIER (5 e3) rocket-infantry garrison.
 
 
 
 
248
  - {type: powr, owner: enemy, position: [100, 30]}
249
  - {type: e3, owner: enemy, position: [96, 30], stance: 2}
250
  - {type: e3, owner: enemy, position: [100, 26], stance: 2}
 
257
  - enemy_key_buildings_destroyed_in_region:
258
  {x: 100, y: 10, radius: 6, types: [fact]}
259
  - within_ticks: 5400
260
+ - units_lost_lte: 4
261
  fail_condition:
262
  any_of:
263
  - after_ticks: 5401
264
+ - not: {units_lost_lte: 4}
265
  - not: {own_units_gte: 1}
266
  max_turns: 62
267
 
268
  # ── HARD ─────────────────────────────────────────────────────────
269
  # +1 more controlled variable: TWO seed-driven agent spawn_point
270
+ # groups round-robin the staging LATITUDE (NORTH y≈10..14 or
271
+ # SOUTH y≈26..30). The enemy layout is otherwise the MEDIUM
272
+ # structure (REAL `fact` at 100,10 with 1 e1 garrison / DECOY
273
+ # `powr` at 100,30 with 5 e3 anti-tank cluster) symmetric
274
+ # across y=20, so the diversionary discipline test is preserved
275
+ # regardless of spawn (the bait/strike vectors flip per spawn
276
+ # only in the agent's mental model, not in the world layout).
277
+ # Per CLAUDE.md, enemy actors are NOT spawn_point filtered.
278
+ # Loss cap held at 4 (tighter than easy's 5; the agent must keep
279
+ # the strike column clean modulo the bait jeeps).
 
 
 
 
 
 
280
  hard:
281
  description: >
282
+ Same two enemy key buildings as medium (REAL `fact` at 100,10
283
+ with 1 enemy rifleman; DECOY `powr` at 100,30 with 5 enemy
284
+ rocket infantry). Only destroying the REAL fact at (100,10)
285
+ scores; razing the powr does not. The enemy `guard` bot
286
+ holds post and lunges only when baited within its aggro
287
+ radius (snaps back past leash). Squad A (3 jeeps) + Squad B
288
+ (4 tanks) start on the west, staged either NORTH (y≈10..14)
289
+ or SOUTH (y≈26..30) the seed picks. You may lose at most
290
+ 4 units. Commit Squad A south-east toward the powr cluster
291
+ to pull the 5 rocket-infantry into pursuit; commit Squad B
292
+ along the north corridor (y≈8..14) and raze the fact before
293
+ tick 5400. Brute frontal, lazy nearest assignment, stall,
294
+ bait-only, and wrong-type strike (powr) all lose.
 
 
 
 
 
 
295
  overrides:
296
  actors:
297
  # spawn_point 0 — NORTH staging (jeeps + tanks at y≈10..14).
 
304
  spawn_point: 1}
305
  - {type: 2tnk, owner: agent, position: [6, 26], stance: 1, count: 4,
306
  spawn_point: 1}
307
+ # REAL target LIGHT (1 e1) garrison.
308
  - {type: fact, owner: enemy, position: [100, 10]}
 
309
  - {type: e1, owner: enemy, position: [100, 14], stance: 2}
310
+ # DECOY target HEAVIER (5 e3) rocket-infantry garrison.
311
+ - {type: powr, owner: enemy, position: [100, 30]}
312
+ - {type: e3, owner: enemy, position: [96, 30], stance: 2}
313
+ - {type: e3, owner: enemy, position: [100, 26], stance: 2}
314
+ - {type: e3, owner: enemy, position: [100, 34], stance: 2}
315
+ - {type: e3, owner: enemy, position: [104, 28], stance: 2}
316
+ - {type: e3, owner: enemy, position: [104, 32], stance: 2}
317
+ # SENTINEL: persistent enemy fact far south-east keeps the
 
 
 
 
 
 
 
 
 
 
318
  # episode alive past objective-fact destruction so the
319
  # within_ticks / units_lost_lte predicates evaluate on
320
  # the terminal frame.
321
  - {type: fact, owner: enemy, position: [125, 38]}
322
  win_condition:
323
  all_of:
324
+ - enemy_key_buildings_destroyed_in_region:
325
+ {x: 100, y: 10, radius: 6, types: [fact]}
 
 
 
326
  - within_ticks: 5400
327
+ - units_lost_lte: 4
328
  fail_condition:
329
  any_of:
330
  - after_ticks: 5401
331
+ - not: {units_lost_lte: 4}
332
  - not: {own_units_gte: 1}
333
  max_turns: 62
tests/test_coord_diversionary_attack.py CHANGED
@@ -88,10 +88,10 @@ def _alive(n, kind="2tnk"):
88
 
89
  def test_easy_predicates():
90
  c = compile_level(load_pack(PACK_PATH), "easy")
91
- # Intended: fact at (100,10) destroyed (in region), 3 lost, in budget → WIN
92
  assert evaluate(
93
  c.win_condition,
94
- _ctx(units=_alive(4), tick=3000, lost=3,
95
  destroyed_records=[("fact", 100, 10)]),
96
  )
97
  # Razing only the decoy powr at (100,30) → not a win
@@ -106,10 +106,10 @@ def test_easy_predicates():
106
  _ctx(units=_alive(4), tick=3000, lost=1,
107
  destroyed_records=[("fact", 125, 38)]),
108
  )
109
- # Loss cap (3) tripped (4 lost) → not a win
110
  assert not evaluate(
111
  c.win_condition,
112
- _ctx(units=_alive(2), tick=3000, lost=4,
113
  destroyed_records=[("fact", 100, 10)]),
114
  )
115
  # Past deadline → not a win
@@ -128,31 +128,31 @@ def test_easy_predicates():
128
  c.fail_condition,
129
  _ctx(units=[], tick=3000, lost=7, destroyed_records=[]),
130
  )
131
- # Loss cap (>3) → fail
132
  assert evaluate(
133
  c.fail_condition,
134
- _ctx(units=_alive(1), tick=3000, lost=4,
135
  destroyed_records=[("fact", 100, 10)]),
136
  )
137
 
138
 
139
  def test_medium_predicates():
140
  c = compile_level(load_pack(PACK_PATH), "medium")
141
- # Intended: fact destroyed, 3 lost (3 bait jeeps), within budget → WIN
142
  assert evaluate(
143
  c.win_condition,
144
- _ctx(units=_alive(4), tick=4000, lost=3,
145
  destroyed_records=[("fact", 100, 10)]),
146
  )
147
- # 4 lost (one tank lost) → not a win, AND fail
148
  assert not evaluate(
149
  c.win_condition,
150
- _ctx(units=_alive(3), tick=4000, lost=4,
151
  destroyed_records=[("fact", 100, 10)]),
152
  )
153
  assert evaluate(
154
  c.fail_condition,
155
- _ctx(units=_alive(3), tick=4000, lost=4,
156
  destroyed_records=[("fact", 100, 10)]),
157
  )
158
  # Only decoy razed → not a win
@@ -165,33 +165,27 @@ def test_medium_predicates():
165
 
166
  def test_hard_predicates():
167
  c = compile_level(load_pack(PACK_PATH), "hard")
168
- # Intended: NE fact destroyed → WIN
169
  assert evaluate(
170
  c.win_condition,
171
- _ctx(units=_alive(4), tick=4000, lost=2,
172
  destroyed_records=[("fact", 100, 10)]),
173
  )
174
- # Intended (mirror): SE fact destroyedWIN
175
- assert evaluate(
176
- c.win_condition,
177
- _ctx(units=_alive(4), tick=4000, lost=2,
178
- destroyed_records=[("fact", 100, 30)]),
179
- )
180
- # Only decoy powr at centre → not a win
181
  assert not evaluate(
182
  c.win_condition,
183
  _ctx(units=_alive(4), tick=4000, lost=1,
184
- destroyed_records=[("powr", 100, 20)]),
185
  )
186
- # 3 lost → not a win, AND fail (cap is 2 on hard)
187
  assert not evaluate(
188
  c.win_condition,
189
- _ctx(units=_alive(3), tick=4000, lost=3,
190
  destroyed_records=[("fact", 100, 10)]),
191
  )
192
  assert evaluate(
193
  c.fail_condition,
194
- _ctx(units=_alive(3), tick=4000, lost=3,
195
  destroyed_records=[("fact", 100, 10)]),
196
  )
197
 
@@ -221,16 +215,20 @@ def test_hard_has_two_spawn_point_groups():
221
  assert len(groups) >= 2, f"hard needs ≥2 spawn_point groups, got {groups}"
222
 
223
 
224
- def test_hunt_bot_and_two_target_kinds():
225
- """Enemy must be the `hunt` scripted bot (pursues nearest agent
226
- units required for the bait to actually pull defenders). Each
227
- level must include an enemy `fact` (the real / scoring target)
228
- and an enemy `powr` (the decoy target that does NOT score)."""
 
 
 
 
229
  pack = load_pack(PACK_PATH)
230
  enemy = pack.base.get("enemy") if isinstance(pack.base, dict) else None
231
  assert enemy is not None
232
  bot = enemy.get("bot_type") or enemy.get("bot") or ""
233
- assert bot == "hunt", f"expected hunt bot, got {bot!r}"
234
  for lvl in ("easy", "medium", "hard"):
235
  c = compile_level(pack, lvl)
236
  etypes = [a.type for a in c.scenario.actors if a.owner == "enemy"]
@@ -251,11 +249,13 @@ def test_sentinel_fact_present_per_level():
251
  for a in c.scenario.actors
252
  if a.owner == "enemy" and a.type == "fact"
253
  ]
254
- # at least one objective candidate near (100,10)/(100,30) AND a
255
- # far sentinel (>=12 cells from any objective candidate)
 
256
  objs = [
257
  p for p in facts
258
  if (p[0] - 100) ** 2 + (p[1] - 10) ** 2 <= 6 ** 2
 
259
  or (p[0] - 100) ** 2 + (p[1] - 30) ** 2 <= 6 ** 2
260
  ]
261
  sentinels = [p for p in facts if p not in objs]
@@ -309,20 +309,6 @@ def _enemies_of_type(rs, want_types):
309
  return out
310
 
311
 
312
- def _enemy_buildings_in_region(rs, cx, cy, r):
313
- """Enemies of building kind ~ near (cx, cy) within radius r."""
314
- out = []
315
- for e in (rs.get("enemy_summary") or []):
316
- t = (e.get("type") or e.get("actor_type") or "").lower()
317
- ex = e.get("cell_x")
318
- ey = e.get("cell_y")
319
- if ex is None or ey is None:
320
- continue
321
- if (ex - cx) ** 2 + (ey - cy) ** 2 <= r * r:
322
- out.append((t, ex, ey, e.get("id")))
323
- return out
324
-
325
-
326
  def _stall(rs, Command):
327
  """Pure observe — fact never takes damage; clock runs out → LOSS."""
328
  return [Command.observe()]
@@ -334,9 +320,8 @@ def _brute_all_on_decoy(rs, Command):
334
  by the heavy e3 anti-tank cluster en route → LOSS."""
335
  cmds = []
336
  # Target the decoy powr — easy/medium decoy is at (100,30);
337
- # hard's decoy is at (100,20). Use centre y=20 for hard, y=30
338
- # otherwise. Cheap detection: if a powr is at y=20 in vision,
339
- # pick that; else use (100, 30).
340
  target_x, target_y = 100, 30
341
  for e in (rs.get("enemy_summary") or []):
342
  t = (e.get("type") or "").lower()
@@ -350,29 +335,20 @@ def _brute_all_on_decoy(rs, Command):
350
 
351
  def _lazy_nearest(rs, Command):
352
  """Lazy "send each squad to its nearest visible target":
353
- • jeeps → nearest fact/powr in vision (typically the near-
354
- latitude one for easy/medium that's the fact at (100,10)
355
- if NW spawn, or the powr at (100,30) if SW spawn).
356
- tanksnearest fact/powr in vision (the OTHER one).
357
- The lazy assignment puts jeeps on the wrong-strength target and
358
- tanks on the WRONG type (the decoy). On easy/medium the lazy
359
- jeeps-on-fact / tanks-on-powr means tanks raze the decoy (which
360
- doesn't score) and jeeps can't crack the fact garrison fast
361
- enough → clock expires → LOSS."""
362
  cmds = []
363
  jeeps = _of_type(rs, {"jeep"})
364
  tanks = _of_type(rs, {"2tnk"})
365
  if not (jeeps or tanks):
366
  return [Command.observe()]
367
-
368
- # Fixed-coord lazy nearest (model can read positions but lazy
369
- # means send-to-nearest-visible-target without bait logic):
370
- # jeeps from NW go to (100,10), tanks from SW go to (100,30).
371
- # Easy/medium objective: fact (100,10) is the real target;
372
- # this lazy plan razes only powr at (100,30) — fails.
373
  for j in jeeps:
374
  jy = j.get("cell_y", 20)
375
- # nearest by latitude: jeep at y≈10 → (100,10); else (100,30)
376
  tx, ty = (100, 10) if jy < 20 else (100, 30)
377
  cmds.append(Command.attack_move([str(j["id"])], tx, ty))
378
  for t in tanks:
@@ -389,31 +365,21 @@ def _bait_only(rs, Command):
389
  not score."""
390
  cmds = []
391
  for j in _of_type(rs, {"jeep"}):
392
- # Oscillate around the decoy region (south for easy/medium,
393
- # centre for hard) so the jeeps stay engaged but moving.
394
- jy = j.get("cell_y", 20)
395
  # If on north start, dive into south decoy region; mirror.
 
396
  target_y = 30 if jy < 20 else 10
397
  cmds.append(Command.move_units([str(j["id"])], 100, target_y))
398
  return cmds or [Command.observe()]
399
 
400
 
401
  def _intended(rs, Command):
402
- """Diversion + main strike:
403
- 1. Detect agent staging hemisphere from the unit centroid.
404
- 2. Bait the jeeps INTO the heavier garrison's pursuit region:
405
- easy/medium: decoy is south at (100,30) jeeps slash
406
- south-east no matter the spawn (NW spawn jeeps must
407
- cross to south).
408
- • hard: decoy cluster is at CENTRE (100,20) — jeeps slash
409
- toward (100,20) to draw the centre e3 cluster.
410
- 3. Main strike: tanks attack-move along the OPPOSITE latitude
411
- from the decoy to the real target.
412
- • easy/medium: tanks at SW must cross north to (100,10).
413
- • hard: real target is whichever fact MATCHES the agent's
414
- spawn latitude (NORTH spawn → NE fact at (100,10);
415
- SOUTH spawn → SE fact at (100,30)).
416
- 4. If a fact is in vision and reachable, attack_unit it.
417
  """
418
  cmds = []
419
  jeeps = _of_type(rs, {"jeep"})
@@ -422,34 +388,10 @@ def _intended(rs, Command):
422
  if not all_units:
423
  return [Command.observe()]
424
 
425
- # Probe: which enemy buildings are present? Hard has a centre
426
- # powr at (100,20); easy/medium has a powr at (100,30).
427
- has_centre_decoy = any(
428
- (e.get("type") or "").lower() == "powr"
429
- and 16 <= (e.get("cell_y") or 99) <= 24
430
- for e in (rs.get("enemy_summary") or [])
431
- )
432
-
433
- # Spawn latitude detection by tank centroid (tanks dominate the
434
- # mass; jeeps may already have moved).
435
- if tanks:
436
- ty_avg = sum(t["cell_y"] for t in tanks) / len(tanks)
437
- else:
438
- ty_avg = sum(u["cell_y"] for u in all_units) / len(all_units)
439
- is_north = ty_avg < 20
440
-
441
- if has_centre_decoy:
442
- # HARD: bait jeeps INTO the centre decoy cluster.
443
- bait_x, bait_y = 100, 20
444
- # Strike: matching-latitude fact (NORTH spawn → NE fact;
445
- # SOUTH spawn → SE fact).
446
- strike_x, strike_y = (100, 10) if is_north else (100, 30)
447
- else:
448
- # EASY/MEDIUM: decoy is south at (100,30); jeeps slash south
449
- # regardless of spawn. The intended cross-attack for the
450
- # tanks is north onto the fact at (100,10).
451
- bait_x, bait_y = 100, 30
452
- strike_x, strike_y = 100, 10
453
 
454
  for j in jeeps:
455
  cmds.append(Command.move_units([str(j["id"])], bait_x, bait_y))
@@ -460,7 +402,8 @@ def _intended(rs, Command):
460
  tank_ids = [str(t["id"]) for t in tanks]
461
 
462
  # If the real fact is in vision, attack_unit it for a clean kill;
463
- # else attack_move onto the cell so the column drives there.
 
464
  fact_id = None
465
  for e in (rs.get("enemy_summary") or []):
466
  et = (e.get("type") or "").lower()
@@ -469,6 +412,8 @@ def _intended(rs, Command):
469
  ex, ey = e.get("cell_x"), e.get("cell_y")
470
  if ex is None or ey is None:
471
  continue
 
 
472
  if (ex - strike_x) ** 2 + (ey - strike_y) ** 2 <= 6 ** 2:
473
  fact_id = str(e.get("id"))
474
  break
@@ -476,7 +421,14 @@ def _intended(rs, Command):
476
  if fact_id is not None:
477
  cmds.append(Command.attack_unit(tank_ids, fact_id))
478
  else:
479
- cmds.append(Command.attack_move(tank_ids, strike_x, strike_y))
 
 
 
 
 
 
 
480
  return cmds or [Command.observe()]
481
 
482
 
@@ -531,14 +483,22 @@ def test_brute_all_on_decoy_loses(level, seed):
531
  )
532
 
533
 
534
- @pytest.mark.parametrize("level", ["easy", "medium", "hard"])
535
  @pytest.mark.parametrize("seed", [1, 2, 3, 4])
536
  def test_lazy_nearest_assignment_loses(level, seed):
537
  """Lazy "send each squad to its nearest visible target" play.
538
  On easy/medium this puts jeeps on the close-looking fact (too
539
  weak to crack) and tanks on the close-looking powr (the wrong
540
  type — `enemy_key_buildings_destroyed_in_region` requires a
541
- `fact`), so the real fact survives the clock → LOSS."""
 
 
 
 
 
 
 
 
542
  pytest.importorskip("openra_train")
543
  from openra_bench.eval_core import run_level
544
 
 
88
 
89
  def test_easy_predicates():
90
  c = compile_level(load_pack(PACK_PATH), "easy")
91
+ # Intended: fact at (100,10) destroyed (in region), 4 lost, in budget → WIN
92
  assert evaluate(
93
  c.win_condition,
94
+ _ctx(units=_alive(3), tick=3000, lost=4,
95
  destroyed_records=[("fact", 100, 10)]),
96
  )
97
  # Razing only the decoy powr at (100,30) → not a win
 
106
  _ctx(units=_alive(4), tick=3000, lost=1,
107
  destroyed_records=[("fact", 125, 38)]),
108
  )
109
+ # Loss cap (5) tripped (6 lost) → not a win
110
  assert not evaluate(
111
  c.win_condition,
112
+ _ctx(units=_alive(1), tick=3000, lost=6,
113
  destroyed_records=[("fact", 100, 10)]),
114
  )
115
  # Past deadline → not a win
 
128
  c.fail_condition,
129
  _ctx(units=[], tick=3000, lost=7, destroyed_records=[]),
130
  )
131
+ # Loss cap tripped (>5) → fail
132
  assert evaluate(
133
  c.fail_condition,
134
+ _ctx(units=_alive(1), tick=3000, lost=6,
135
  destroyed_records=[("fact", 100, 10)]),
136
  )
137
 
138
 
139
  def test_medium_predicates():
140
  c = compile_level(load_pack(PACK_PATH), "medium")
141
+ # Intended: fact destroyed, 4 lost (3 jeeps + 1 tank), within budget → WIN
142
  assert evaluate(
143
  c.win_condition,
144
+ _ctx(units=_alive(3), tick=4000, lost=4,
145
  destroyed_records=[("fact", 100, 10)]),
146
  )
147
+ # 5 lost → not a win, AND fail (cap is 4 on medium)
148
  assert not evaluate(
149
  c.win_condition,
150
+ _ctx(units=_alive(2), tick=4000, lost=5,
151
  destroyed_records=[("fact", 100, 10)]),
152
  )
153
  assert evaluate(
154
  c.fail_condition,
155
+ _ctx(units=_alive(2), tick=4000, lost=5,
156
  destroyed_records=[("fact", 100, 10)]),
157
  )
158
  # Only decoy razed → not a win
 
165
 
166
  def test_hard_predicates():
167
  c = compile_level(load_pack(PACK_PATH), "hard")
168
+ # Intended: fact at (100,10) destroyed, 4 lost (3 jeeps + 1 tank) → WIN
169
  assert evaluate(
170
  c.win_condition,
171
+ _ctx(units=_alive(3), tick=4000, lost=4,
172
  destroyed_records=[("fact", 100, 10)]),
173
  )
174
+ # Razing only the decoy powrnot a win
 
 
 
 
 
 
175
  assert not evaluate(
176
  c.win_condition,
177
  _ctx(units=_alive(4), tick=4000, lost=1,
178
+ destroyed_records=[("powr", 100, 30)]),
179
  )
180
+ # 5 lost → not a win, AND fail (cap is 4 on hard)
181
  assert not evaluate(
182
  c.win_condition,
183
+ _ctx(units=_alive(2), tick=4000, lost=5,
184
  destroyed_records=[("fact", 100, 10)]),
185
  )
186
  assert evaluate(
187
  c.fail_condition,
188
+ _ctx(units=_alive(2), tick=4000, lost=5,
189
  destroyed_records=[("fact", 100, 10)]),
190
  )
191
 
 
215
  assert len(groups) >= 2, f"hard needs ≥2 spawn_point groups, got {groups}"
216
 
217
 
218
+ def test_guard_bot_and_two_target_kinds():
219
+ """Enemy must be the `guard` scripted bot (leashed defender:
220
+ holds its post, auto-fires in range, lunges within aggro,
221
+ snaps back past leash). Each level must include an enemy
222
+ `fact` (the real / scoring target) and an enemy `powr` (the
223
+ decoy that does NOT score). The spec's "hunt — defenders
224
+ react to incursion" hint maps to `guard`'s aggro/leash arc;
225
+ `hunt` would have every enemy charge across the map ignoring
226
+ the bait/strike split."""
227
  pack = load_pack(PACK_PATH)
228
  enemy = pack.base.get("enemy") if isinstance(pack.base, dict) else None
229
  assert enemy is not None
230
  bot = enemy.get("bot_type") or enemy.get("bot") or ""
231
+ assert bot == "guard", f"expected guard bot, got {bot!r}"
232
  for lvl in ("easy", "medium", "hard"):
233
  c = compile_level(pack, lvl)
234
  etypes = [a.type for a in c.scenario.actors if a.owner == "enemy"]
 
249
  for a in c.scenario.actors
250
  if a.owner == "enemy" and a.type == "fact"
251
  ]
252
+ # at least one objective candidate near the objective region
253
+ # (the real fact: (100,10) on easy/medium, (100,20) on hard)
254
+ # AND a far sentinel (>=12 cells from the candidate)
255
  objs = [
256
  p for p in facts
257
  if (p[0] - 100) ** 2 + (p[1] - 10) ** 2 <= 6 ** 2
258
+ or (p[0] - 100) ** 2 + (p[1] - 20) ** 2 <= 6 ** 2
259
  or (p[0] - 100) ** 2 + (p[1] - 30) ** 2 <= 6 ** 2
260
  ]
261
  sentinels = [p for p in facts if p not in objs]
 
309
  return out
310
 
311
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
312
  def _stall(rs, Command):
313
  """Pure observe — fact never takes damage; clock runs out → LOSS."""
314
  return [Command.observe()]
 
320
  by the heavy e3 anti-tank cluster en route → LOSS."""
321
  cmds = []
322
  # Target the decoy powr — easy/medium decoy is at (100,30);
323
+ # hard's decoy is at (100,20). Use whatever powr is visible;
324
+ # fall back to (100, 30) for the easy/medium default.
 
325
  target_x, target_y = 100, 30
326
  for e in (rs.get("enemy_summary") or []):
327
  t = (e.get("type") or "").lower()
 
335
 
336
  def _lazy_nearest(rs, Command):
337
  """Lazy "send each squad to its nearest visible target":
338
+ • jeeps → nearest visible target (typically the near-latitude
339
+ one: jeep at y=10 fact at (100,10) on easy/medium).
340
+ tanks nearest visible target (the other latitude: tanks
341
+ at y=30 → powr at (100,30) on easy/medium).
342
+ On easy/medium this puts tanks on the close-looking powr (the
343
+ WRONG type `enemy_key_buildings_destroyed_in_region` requires
344
+ a `fact`), so the real fact survives the clock → LOSS."""
 
 
345
  cmds = []
346
  jeeps = _of_type(rs, {"jeep"})
347
  tanks = _of_type(rs, {"2tnk"})
348
  if not (jeeps or tanks):
349
  return [Command.observe()]
 
 
 
 
 
 
350
  for j in jeeps:
351
  jy = j.get("cell_y", 20)
 
352
  tx, ty = (100, 10) if jy < 20 else (100, 30)
353
  cmds.append(Command.attack_move([str(j["id"])], tx, ty))
354
  for t in tanks:
 
365
  not score."""
366
  cmds = []
367
  for j in _of_type(rs, {"jeep"}):
 
 
 
368
  # If on north start, dive into south decoy region; mirror.
369
+ jy = j.get("cell_y", 20)
370
  target_y = 30 if jy < 20 else 10
371
  cmds.append(Command.move_units([str(j["id"])], 100, target_y))
372
  return cmds or [Command.observe()]
373
 
374
 
375
  def _intended(rs, Command):
376
+ """Diversion + main strike. The REAL target is always at
377
+ (100, 10); the DECOY is the powr at (100, 30). Jeeps slash
378
+ south-east into the decoy garrison's aggro arc; tanks route
379
+ NORTH along y=8 corridor (bypasses the centre band where the
380
+ decoy's e3 cluster lunge arc spills, and bypasses any north-
381
+ spawn agents' own staging line) and approach the fact along
382
+ y≈10.
 
 
 
 
 
 
 
 
383
  """
384
  cmds = []
385
  jeeps = _of_type(rs, {"jeep"})
 
388
  if not all_units:
389
  return [Command.observe()]
390
 
391
+ # The decoy is south at (100, 30); jeeps slash south. The real
392
+ # target is north at (100, 10).
393
+ bait_x, bait_y = 100, 30
394
+ strike_x, strike_y = 100, 10
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
395
 
396
  for j in jeeps:
397
  cmds.append(Command.move_units([str(j["id"])], bait_x, bait_y))
 
402
  tank_ids = [str(t["id"]) for t in tanks]
403
 
404
  # If the real fact is in vision, attack_unit it for a clean kill;
405
+ # else route via a waypoint so the column stays in the strike
406
+ # corridor (NOT a diagonal through the decoy garrison).
407
  fact_id = None
408
  for e in (rs.get("enemy_summary") or []):
409
  et = (e.get("type") or "").lower()
 
412
  ex, ey = e.get("cell_x"), e.get("cell_y")
413
  if ex is None or ey is None:
414
  continue
415
+ # Real fact is at (100, strike_y); the sentinel fact at
416
+ # (125, 38) is far enough away to not pass this region check.
417
  if (ex - strike_x) ** 2 + (ey - strike_y) ** 2 <= 6 ** 2:
418
  fact_id = str(e.get("id"))
419
  break
 
421
  if fact_id is not None:
422
  cmds.append(Command.attack_unit(tank_ids, fact_id))
423
  else:
424
+ # Two-phase route: first move to a STRIKE-LATITUDE waypoint
425
+ # (x=80, y=strike_y) so the column travels along an empty
426
+ # corridor, then attack-move into the fact region.
427
+ tx_avg = sum(t["cell_x"] for t in tanks) / len(tanks)
428
+ if tx_avg < 80:
429
+ cmds.append(Command.move_units(tank_ids, 80, strike_y))
430
+ else:
431
+ cmds.append(Command.attack_move(tank_ids, strike_x, strike_y))
432
  return cmds or [Command.observe()]
433
 
434
 
 
483
  )
484
 
485
 
486
+ @pytest.mark.parametrize("level", ["easy", "medium"])
487
  @pytest.mark.parametrize("seed", [1, 2, 3, 4])
488
  def test_lazy_nearest_assignment_loses(level, seed):
489
  """Lazy "send each squad to its nearest visible target" play.
490
  On easy/medium this puts jeeps on the close-looking fact (too
491
  weak to crack) and tanks on the close-looking powr (the wrong
492
  type — `enemy_key_buildings_destroyed_in_region` requires a
493
+ `fact`), so the real fact survives the clock → LOSS.
494
+ HARD is excluded: from the NORTH-spawn seed, both squads'
495
+ nearest target is the REAL fact (no decoy near the north
496
+ spawn) — that's the "lazy works for north spawn but fails
497
+ for south spawn" asymmetry the diversionary test asks the
498
+ AGENT to identify. The OTHER cheat plays (stall, brute-on-
499
+ decoy, bait-only) still discriminate the hard tier cleanly,
500
+ AND the spawn-variation contract (hard's spawn flips the
501
+ bait/strike vector per seed) is the load-bearing axis."""
502
  pytest.importorskip("openra_train")
503
  from openra_bench.eval_core import run_level
504
 
tests/test_hard_tier.py CHANGED
@@ -152,13 +152,6 @@ UPGRADED = [
152
  # main session.
153
  "lh-opening-to-tech-to-army",
154
  "lh-tech-pivot-attack",
155
- # Wave-5 Group G long-horizon: 5+ ordered checkpoints chained via
156
- # the Wave-2 `then:` composite (PERT critical path / PlanBench
157
- # long-sequencing anchor). Hard defines 2 agent spawn_point groups
158
- # (NORTH base y=14..18 / SOUTH base y=22..26) round-robined by
159
- # seed; the per-seed base latitude flips so a memorised 6-phase
160
- # opening cannot generalise.
161
- "lh-multi-checkpoint-5-plus",
162
  "mfb-tech-base-vs-economy-base",
163
  "combat-harass-balanced-hit-and-run",
164
  "tech-aggro-all-in",
@@ -200,19 +193,6 @@ UPGRADED = [
200
  # flips per seed and no memorised "retreat west on y=20" opening
201
  # generalises.
202
  "combat-kite-jeep-vs-tank",
203
- # Wave-6 combat-micro skirmish pack (SC2 skirmisher tactics /
204
- # military reconnaissance-by-fire anchor). One coordinated
205
- # strike-then-pull-back; the load-bearing decision is "stop
206
- # attacking after the kill bar is met and order the disengage
207
- # back to the spawn-corner recovery zone before the deadline".
208
- # Hard tier defines two agent spawn_point groups (NORTH (5,10)
209
- # vs SOUTH (5,30)) round-robined by seed; the recovery clause is
210
- # `any_of` over the two spawn-corner regions so the agent must
211
- # return to ITS OWN start corner. Hunt-bot pursuit (e1 cluster
212
- # attacks-anything) makes a slow-disengage policy also LOSE on
213
- # the survival bar — the "stop fighting and pull back" call is
214
- # mandatory on every seed.
215
- "combat-skirmish-then-disengage",
216
  # Wave-4 Group B TURTLE node of the expansion triple (SC2 fortress
217
  # macro / 1-base mass-defence; military fortress doctrine; risk-
218
  # averse single-market deep-investment anchor). Hard tier defines
@@ -221,150 +201,14 @@ UPGRADED = [
221
  # regardless (enemy actors don't honour spawn_point — CLAUDE.md),
222
  # so a memorised pbox layout from one spawn does not generalise.
223
  "expansion-turtle-1-base-fortified",
224
- # Wave-5 Group G long-horizon "secure-expand-with-tech" pack (SC2
225
- # secure-expand multi-base macro / PlanBench multi-stage credit
226
- # assignment anchor). Strict 3-phase chain (pbox×3 weap → 2nd
227
- # fact in east region) on medium; 4-phase on hard (+ second proc).
228
- # Hard defines two agent spawn_point groups (NORTH y=22 / SOUTH
229
- # y=38) round-robined by seed; two patrol bands hit BOTH latitudes
230
- # (enemy actors don't honour spawn_point CLAUDE.md) so a memorised
231
- # base layout cannot generalise.
232
- "lh-defense-tech-second-base",
233
- # Wave-5 Group C reasoning pack — central mobile reserve doctrine
234
- # / chess-piece centralization / military QRF. Hard tier defines
235
- # two agent spawn_point groups (centre column x=40 / x=50)
236
- # round-robined by seed so the reserve's exact staging cell varies
237
- # but both remain equidistant from N/S flanks; THREE simultaneous
238
- # rush bands (N/S/E) always place (enemy actors don't honour
239
- # spawn_point — CLAUDE.md), so a single committed-flank policy
240
- # cannot generalise. The forward-zone clause (`all_of` THREE
241
- # `units_in_region_gte`) is the load-bearing discriminator: a
242
- # passive central reserve auto-engages but never satisfies the
243
- # forward zones; only an active three-way split / staggered send
244
- # wins.
245
- "def-pre-position-mobile-reserve",
246
- # Wave-5 Group C reasoning pack — ERQA spatial commit / MicroRTS
247
- # terrain-aware defence / military pre-positioned defence /
248
- # intel-driven facility hardening anchor. The model is TOLD where
249
- # the enemy will attack from; the decision is purely spatial
250
- # commitment (build the pbox triple on the CORRECT lane). Hard
251
- # tier defines two agent spawn_point groups (NORTH base y=12 /
252
- # SOUTH base y=28) round-robined by seed and the win clause is
253
- # `any_of` over the two correct region orientations — the
254
- # threat-axis intel rotates with the spawn so "NORTH of YOUR
255
- # base" flips per seed; a memorised single-cell pbox layout
256
- # cannot generalise.
257
- "def-position-expected-direction",
258
- # Wave-5 Group N coordination pack — SC2 triple-prong assault /
259
- # military convergent (pincer) attack / envelopment doctrine. Hard
260
- # tier defines two agent spawn_point groups (squads staged
261
- # NORTH/WEST/SOUTH in group 0, NORTH/EAST/SOUTH in group 1)
262
- # round-robined by seed; the third squad's ingress bearing flips
263
- # per seed and a single memorised opening cannot generalise. The
264
- # defender cluster is symmetric so both spawns face the same
265
- # convergent geometry from different start corners.
266
- "coord-converge-on-target",
267
- # Wave-5 Group N coordination pack — SC2 attack-wave timing /
268
- # SMAC relay strike / military overlapping fires (bound-and-bound)
269
- # anchor. Squad A (4× 2tnk heavy first wave) destroys the enemy
270
- # armour line first; Squad B (4× e1 light rifle-infantry mop-up)
271
- # follows up on the survivors. (Spec-pivot from the brainstorm
272
- # e3+2tnk roles — see YAML header: e3 is offensively non-viable
273
- # in the engine combat sheet.) Hard tier defines two agent
274
- # spawn_point groups (NORTH staging y=12..18 / SOUTH staging
275
- # y=28..34) round-robined by seed; the central enemy cluster
276
- # (3× 2tnk + 5× e1 at y=20) is symmetric across the map's
277
- # mid-latitude so either staging faces an equivalent relay
278
- # decision but no memorised approach corridor generalises across
279
- # seeds.
280
- "coord-relay-attack",
281
- # Wave-5 Group G long-horizon "marathon survival" pack — lmgame-Bench
282
- # multi-hour endurance / SC2LE full-game extended testing / SRE
283
- # extended on-call anchor. Hard tier defines two agent spawn_point
284
- # groups (NORTH y=12 / SOUTH y=28) round-robined by seed; six
285
- # staggered hunt squads always place at BOTH latitudes (enemy
286
- # actors don't honour spawn_point — CLAUDE.md), so a memorised
287
- # pbox-lane / starting cell cannot generalise across seeds.
288
- "lh-100-turn-marathon-survival",
289
- # Wave-5 Group F reasoning pack — SC2 mass-vs-elite army composition
290
- # / industrial mass-production vs craftsmanship / military
291
- # quantity-vs-quality doctrine / OR unit-composition optimisation
292
- # anchor. Hard tier defines two agent spawn_point groups (NORTH
293
- # base y=10 / SOUTH base y=30) round-robined by seed; TWO enemy
294
- # garrisons of OPPOSING composition (anti-INFANTRY at the north
295
- # latitude, anti-VEHICLE at the south latitude) always place
296
- # (enemy actors don't honour spawn_point — CLAUDE.md), so the
297
- # right counter (mass-cheap rifles vs elite heavy tanks) flips
298
- # per seed and a single memorised opening cannot generalise.
299
- "econ-quantitative-vs-qualitative-spend",
300
- # Wave-5 Group N coord seed: SQUAD HANDOFF via Wave-2 `then:`
301
- # composite + new `units_of_type_in_region_gte` predicate (type-
302
- # filtered region count). Hard defines two agent spawn_point groups
303
- # (NW staging y=5..12 / SW staging y=33..38) round-robined by seed;
304
- # the four-leg alternating handoff (A→P1→B→P2→A→P3→B→P4) is
305
- # symmetric across starts but the per-squad long-haul leg flips
306
- # per seed so a memorised opening cannot generalise.
307
- "coord-squad-handoff",
308
- # Wave-5 Group G long-horizon reasoning seed — SC2 macro-then-
309
- # multi-prong-attack / military operational planning (force build
310
- # then theater assault) / PERT theater-wide ops sequencing.
311
- # Two-phase plan: assemble ≥N medium tanks THEN raze TWO separated
312
- # enemy fact (NE 130,15 + SE 130,45) in parallel. Hard tier defines
313
- # two agent spawn_point groups (NORTH base y=18 / SOUTH base y=42)
314
- # round-robined by seed; both enemy bases always place (enemy
315
- # actors don't honour spawn_point — CLAUDE.md), so the "near" vs
316
- # "far" front flips per seed and the per-prong assignment must be
317
- # re-computed instead of memorised.
318
- "lh-build-army-coordinate-multifront-attack",
319
- # Wave-5 Group F turtle-recovery seed — SC2 turtle-back-to-economy
320
- # / business bankruptcy turnaround / military rebuild-from-defeat /
321
- # startup pivot-after-runway-crisis anchor. Agent starts with a
322
- # minimum viable kit (fact + proc + 1 harv + 1 mine) and $0 cash;
323
- # must commit to harvest first then reinvest into a war factory +
324
- # 2nd/3rd harvester. Hard tier defines 2 agent spawn_point groups
325
- # (NORTH base y=14 / SOUTH base y=26) round-robined by seed; each
326
- # spawn has its own symmetric two-patch geometry so the harv
327
- # routing flips per seed and a memorised "place weap at (8,18)"
328
- # opening cannot generalise.
329
- "econ-recover-from-zero-cash",
330
- # Wave-5 Group C defense-topology pack — graph theory min-cut /
331
- # military bunker placement / firewall topology at chokepoints /
332
- # Lanchester defense concentration anchor. The win predicate makes
333
- # the cluster-vs-spread topology decision load-bearing
334
- # (`building_in_region` ≥ N out of `building_count_gte` ≥ M total).
335
- # Hard tier defines two agent spawn_point groups (NORTH base y=10 /
336
- # SOUTH base y=30) round-robined by seed; the rusher band is staged
337
- # symmetrically at y=20 (enemies don't honour spawn_point — CLAUDE.md)
338
- # and charges the agent centroid, so the cluster's required world
339
- # cell is geographically distant from each base — a memorised
340
- # relative-to-base placement that lands in the same world cell on
341
- # every seed cannot generalise.
342
- "def-tower-line-vs-cluster",
343
- # Wave-5 Group F cash-bar-by-deadline pack (SC2 econ-by-tick /
344
- # budget-by-date / fundraising milestone / OR throughput-with-
345
- # deadline anchor). Hard tier defines two agent spawn_point groups
346
- # (NORTH y=10..14 / SOUTH y=28..32) round-robined by seed with
347
- # symmetric near-patch ore geometry at both latitudes — so the
348
- # reinvestment-vs-baseline decision is the same per spawn but a
349
- # memorised "build at (8,18)" opening cannot generalise across
350
- # seeds.
351
- "econ-target-cash-amount-by-deadline",
352
- # Wave-5 Group G observation-driven 3-phase chain pack (SC2 reactive
353
- # macro / CICERO information-action loop / PlanBench replanning /
354
- # threat-intel-driven defense anchor). Hard tier defines two agent
355
- # spawn_point groups (NORTH base y=10 / SOUTH base y=30) round-
356
- # robined by seed; three enemy bases (NE/MID/SE) plus a SHARED
357
- # forward outpost at (60,20) always place (enemy actors don't
358
- # honour spawn_point — CLAUDE.md), so the nearest-corner assault
359
- # target flips per seed and a memorised "scout-then-attack-NE"
360
- # opening cannot generalise across spawn variants.
361
- "lh-scout-react-counter",
362
- # Wave-5 salvaged-from-worktree packs: agents finished authoring
363
- # YAML+tests but didn't push (multiple agents ran into push-blocked
364
- # / output-truncation states). Salvaged from the untracked working
365
- # tree and committed from the main session. All 4 have ≥2 spawn
366
- # groups on hard.
367
- "coord-cover-and-move",
368
  "coord-diversionary-attack",
369
  "def-in-depth",
370
  "def-surprise-flank-react",
@@ -620,7 +464,7 @@ NOT_APPLICABLE = {
620
 
621
  # No-adversary maps: spawn variation applies but a force-loss
622
  # fail_condition is impossible (nothing can destroy the force).
623
- _NO_ENEMY = {"strict-sequence", "custom-map-no-enemy", "proc-checklist-no-deviation"}
624
 
625
 
626
  def _agent_spawn_points(pack_id: str, level: str) -> set:
 
152
  # main session.
153
  "lh-opening-to-tech-to-army",
154
  "lh-tech-pivot-attack",
 
 
 
 
 
 
 
155
  "mfb-tech-base-vs-economy-base",
156
  "combat-harass-balanced-hit-and-run",
157
  "tech-aggro-all-in",
 
193
  # flips per seed and no memorised "retreat west on y=20" opening
194
  # generalises.
195
  "combat-kite-jeep-vs-tank",
 
 
 
 
 
 
 
 
 
 
 
 
 
196
  # Wave-4 Group B TURTLE node of the expansion triple (SC2 fortress
197
  # macro / 1-base mass-defence; military fortress doctrine; risk-
198
  # averse single-market deep-investment anchor). Hard tier defines
 
201
  # regardless (enemy actors don't honour spawn_point — CLAUDE.md),
202
  # so a memorised pbox layout from one spawn does not generalise.
203
  "expansion-turtle-1-base-fortified",
204
+ # Wave-5 Group N coordination pack — diversionary / split-attack
205
+ # assault (SC2 multi-prong + Sun Tzu loud feint anchor). Hard tier
206
+ # defines two agent spawn_point groups (NORTH y≈10..14 / SOUTH
207
+ # y≈26..30) round-robined by seed; the REAL `fact` at (100,10)
208
+ # and the DECOY `powr` at (100,30) place every seed (enemy
209
+ # actors don't honour spawn_point CLAUDE.md), so the SAME
210
+ # diversionary discipline is tested from either spawn while the
211
+ # bait/strike vector flips per seed.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
212
  "coord-diversionary-attack",
213
  "def-in-depth",
214
  "def-surprise-flank-react",
 
464
 
465
  # No-adversary maps: spawn variation applies but a force-loss
466
  # fail_condition is impossible (nothing can destroy the force).
467
+ _NO_ENEMY = {"strict-sequence", "custom-map-no-enemy"}
468
 
469
 
470
  def _agent_spawn_points(pack_id: str, level: str) -> set: