yxc20098 commited on
Commit
3637451
·
1 Parent(s): b054ae5

feat(scenario): combat-tank-vs-tank-engagement — focus-fire mirror (Lanchester / SC2 anchor)

Browse files

Wave-7 Group D combat-micro pack (capability: action). 3-vs-3 medium
tank engagement with ASYMMETRIC geometry: 3 agent tanks bunched at
(30,19..21) facing 3 enemy tanks spread across three latitudes at
(50,15)/(51,20)/(50,25). Centre enemy at x=51 (not x=50) per the
CLAUDE.md silent-fail cell note for (50,20).

Discrimination (verified scripted on all 4 hard seeds):
- stall (only observe): tanks never close to firing range; kill
bar unmet; after_ticks LOSS on every level/seed.
- brute attack_move (drive into the centre): force-wipes on
easy/medium (3 tanks lost), busts within_ticks on hard; LOSS
on every level/seed.
- spread-attack-closest (each tank attacks its OWN nearest enemy):
once centre dies, surviving tanks chase flank enemies in 1-vs-1
duels and Lanchester linear collapses the trade; ends 1-of-3
alive => LOSS on medium (load-bearing: busts own_units_gte:2).
Permitted to squeak on easy (own_units_gte:1, inert-easy-teeth
convention). Collapses to focus on hard (flank spawn => unique
closest enemy from a flank perspective).
- intended focus-fire (ALL 3 tanks attack_unit the same target
each turn, closest first): 3-vs-1 concentrated cannon fire ends
each enemy in 1-2 decision turns; all 3 agent tanks survive;
WIN on every level/seed.

Tiers:
- easy: own_units_gte:1, within_ticks 2400 (forgiving bare-skill
tier per SCENARIO_REVIEW_CHECKLIST inert-easy-teeth convention).
- medium: own_units_gte:2 (LOAD-BEARING — discriminates spread vs
focus via the survival cap), within_ticks 2400.
- hard: own_units_gte:1 RELAXED + within_ticks 1200 (tight kill-
speed pressure). Two seed-driven spawn_point groups (NORTH
y=11..13 / SOUTH y=27..29) round-robined by seed for anti-
memorisation; enemy line always places (CLAUDE.md: enemy actors
don't honour spawn_point). Registered in tests/test_hard_tier.py
UPGRADED.

Engine guardrails: agent fact at (4,20) (turn-1 own_units_gte mis-
fire footgun + engine auto-done mitigation on force-wipe); far enemy
fact at (124,20) (second auto-done mitigation). after_ticks 2401
<= 93+90*29 = 2703; after_ticks 1201 <= 93+90*14 = 1353. Enemy
bot_type empty (no scripted pursuit); stance:3 on enemies for auto-
fire on cannon-range entry; stance:1 on agents so a stall policy
doesn't pull stray shots.

openra_bench/scenarios/packs/combat-tank-vs-tank-engagement.yaml ADDED
@@ -0,0 +1,357 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # combat-tank-vs-tank-engagement — Mirror tank trade: WIN by focus-fire
2
+ # (concentrate ALL 3 agent tanks on ONE enemy at a time), LOSE by
3
+ # spread-fire (each tank picks its own nearest enemy). Lanchester
4
+ # square law on a 3-vs-3 medium-tank engagement.
5
+ #
6
+ # Wave-7 ACTION pack (capability: action — combat micro: target
7
+ # prioritization / focus-fire discipline).
8
+ #
9
+ # Real-world / benchmark anchors:
10
+ # - SC2 mirror micro (siege-tank-vs-siege-tank, marine-vs-marine):
11
+ # the side that concentrates fire on one target at a time wins the
12
+ # trade; the side that spreads damage across the whole enemy line
13
+ # trades 1-for-1 and loses the survivor count.
14
+ # - Lanchester's SQUARE LAW of attrition: combat power of a focused
15
+ # force scales as N² (per-kill removes one enemy's OUTPUT DPS for
16
+ # the rest of the fight); spreading fire collapses to the LINEAR
17
+ # law (mutual 1-for-1 annihilation).
18
+ # - Military "CONCENTRATION OF FORCE" doctrine (one of the Principles
19
+ # of War): a smaller or equal force concentrated at the decisive
20
+ # point can defeat a numerically equivalent dispersed enemy.
21
+ #
22
+ # Design — ASYMMETRIC GEOMETRY (engineered to discriminate):
23
+ # Prior MIRROR geometry (3 agents at x=30 stacked y=18..22 vs 3
24
+ # enemies at x=38 stacked y=18..22) was found engine-fragile: when
25
+ # both sides start fully in cannon range with identical rows, agent
26
+ # stance:1 ReturnFire causes the auto-target to collapse onto
27
+ # whichever single enemy fired first, so the spread-fire wrong-play
28
+ # accidentally focus-fires and the discrimination disappears. THIS
29
+ # pack uses ASYMMETRIC geometry:
30
+ # - Agent strike force: 3 medium tanks BUNCHED at (30,19..21) on
31
+ # a single column (one centroid, one engagement axis).
32
+ # - Enemy mirror: 3 medium tanks SPREAD across three latitudes
33
+ # at (50,15), (51,20), (50,25) — three distinct rows along the
34
+ # eastern engagement line. (Centre enemy at x=51 NOT (50,20)
35
+ # per the CLAUDE.md-documented silent-placement-fail at (50,20).)
36
+ # Each enemy is initially OUT of agent cannon range (cannon ≈ 5,
37
+ # agent at x=30 vs enemy at x=50/51 ⇒ MD ≥ 20), so tanks must close
38
+ # into engagement — at which point the asymmetry bites:
39
+ # * SPREAD policy (each agent tank attack_units its OWN nearest
40
+ # enemy): tank at (30,19) sees (50,15) at MD=24, (51,20) at
41
+ # MD=22, (50,25) at MD=26 ⇒ targets (51,20); tank at (30,21)
42
+ # targets (51,20); tank at (30,20) targets (51,20) — actually
43
+ # all 3 tanks target the centre INITIALLY, but as they advance
44
+ # and the centre dies, the surviving tanks fan out to chase
45
+ # the flank enemies one each ⇒ once the spread-fire chase
46
+ # begins, the trade collapses to 1-vs-1 duels and Lanchester
47
+ # linear ⇒ 2 of the 3 agent tanks die in the flank engagements.
48
+ # * FOCUS policy (ALL 3 tanks attack_unit the SAME target in
49
+ # sequence — closest first, then a flank, then the last):
50
+ # 3-vs-1 concentrated cannon fire ends each enemy in ~1-2
51
+ # decision turns; after kill #1 the trade is 3-vs-2 (Lanchester
52
+ # surplus 3²−2² = 5), after kill #2 it is 3-vs-1; all 3 agent
53
+ # tanks survive.
54
+ #
55
+ # Discrimination bar (four scripted-policy proxies — see test file):
56
+ # • stall (only observe): tanks idle at (30,*), enemies at (50,*)
57
+ # stay out of cannon range. Nothing dies on either side ⇒ kill
58
+ # bar unmet ⇒ after_ticks LOSS at tick 2401.
59
+ # • brute attack_move(51,20) (drive into the centre): tanks
60
+ # auto-target the nearest visible enemy en route (per CLAUDE.md
61
+ # "move_units auto-fires opportunistically en route regardless of
62
+ # agent stance"); they reach the engagement zone and trade with
63
+ # all 3 enemies firing back simultaneously ⇒ ≥2 agent tanks die ⇒
64
+ # LOSS via the survival cap (own_units_gte:2 on medium, force-wipe
65
+ # trips own_units_gte:1 on easy).
66
+ # • spread-attack-closest (each tank attack_units its own closest
67
+ # enemy): as above — once the centre dies, surviving tanks chase
68
+ # flank enemies on 1-vs-1 duels; Lanchester linear ⇒ 2 of 3 tanks
69
+ # die. On EASY (own_units_gte:1) the 1 survivor squeaks through
70
+ # and SPREAD wins (forgiving bare-skill tier, per the
71
+ # SCENARIO_REVIEW_CHECKLIST inert-easy-teeth convention). On
72
+ # MEDIUM (own_units_gte:2) the 1 survivor is below the bar ⇒
73
+ # LOSS — this is the load-bearing discrimination.
74
+ # • intended focus-fire (ALL 3 tanks attack_unit the SAME target
75
+ # each turn, starting with the closest enemy by agent centroid,
76
+ # then re-targeting the next-closest as enemies fall): all 3
77
+ # enemies die in ~700-900 ticks, all 3 agent tanks alive at the
78
+ # end ⇒ WIN on every level.
79
+ #
80
+ # Win-bar relaxation note (RELAXED per the task brief): on HARD the
81
+ # survival cap holds at own_units_gte:2 nominally, but the asymmetric
82
+ # discrimination weakens when the agent stack starts on a FLANK
83
+ # latitude (NORTH y=11..13 or SOUTH y=27..29) — from a flank the
84
+ # enemy line at y=15/20/25 has a unique closest enemy that all agent
85
+ # tanks naturally target (spread ≡ focus). Hard's discrimination is
86
+ # therefore primarily KILL-SPEED (within_ticks 1200) + brute / stall
87
+ # anti-cheat teeth + spawn-variation generalisation across NORTH and
88
+ # SOUTH approach axes — the focus-fire skill is what generalises;
89
+ # spread-as-focus on a flank is acceptable because it IS the
90
+ # intended capability when the geometry collapses to a unique
91
+ # closest target.
92
+ #
93
+ # Hard-tier spawn-variation (≥2 spawn_point groups, registered in
94
+ # tests/test_hard_tier.py::UPGRADED):
95
+ # - NORTH staging y=11..13 (agent at (30,11..13)).
96
+ # - SOUTH staging y=27..29 (agent at (30,27..29)).
97
+ # The asymmetric enemy line (3 enemies at y=15/y=20/y=25) is the
98
+ # SAME for both spawns (enemy actors don't honour spawn_point per
99
+ # CLAUDE.md / oramap.rs::expand_scenario_actors). From NORTH the
100
+ # closest enemy is (50,15) and the farthest is (50,25); from SOUTH
101
+ # the order inverts. A memorised single-target sequence cannot
102
+ # generalise across the spawn rotation.
103
+ #
104
+ # Engine guardrails (per CLAUDE.md):
105
+ # - Map: rush-hour-arena (128 × 40, playable x ∈ [2..126],
106
+ # y ∈ [2..38]). All actor coords inside bounds.
107
+ # - Agent owns a `fact` at (4,20): provides has_building:fact for
108
+ # the win predicate AND prevents the engine auto-`done` on
109
+ # enemy-units-wipe collapsing the run to DRAW before the win
110
+ # evaluator sees the terminal frame.
111
+ # - Far persistent enemy `fact` at (124,20) — out of reach in the
112
+ # declared tick budget; second engine auto-`done` mitigation
113
+ # (a MustBeDestroyed building stays alive on the enemy side so
114
+ # the episode persists to the win/fail evaluator).
115
+ # - Enemy centre tank is at (51,20) — NOT (50,20) — per CLAUDE.md
116
+ # "Certain mid-map cells silently fail to place enemy clusters
117
+ # (e.g. (50,20))"; (51,20) is a documented working cell.
118
+ # - `within_ticks: 2400` / `after_ticks: 2401` on easy+medium;
119
+ # max_turns=30 produces tick ≤ 93 + 90·29 = 2703 ⇒ stallers /
120
+ # brute / spread hit the real LOSS, not a DRAW. Hard uses
121
+ # `within_ticks: 1200` / `after_ticks: 1201` and max_turns=15
122
+ # (tick ≤ 93 + 90·14 = 1353 ≥ 1201) — kill-speed pressure for
123
+ # the focus-fire policy.
124
+ # - Enemy `bot_type: ''` (no scripted bot pursuit) — enemy tanks
125
+ # sit on stance:3 AttackAnything so they auto-fire the second a
126
+ # tank enters cannon range; the test is purely the agent's target
127
+ # prioritization (no bot lunge contaminating the geometry).
128
+ # - Agent tanks stance:1 ReturnFire so a stall policy (pure observe,
129
+ # no movement) doesn't accidentally pull fire from any agent tank
130
+ # before the enemy is in range — the stall remains a clean
131
+ # timeout LOSS.
132
+
133
+ meta:
134
+ id: combat-tank-vs-tank-engagement
135
+ title: 'Tank-vs-Tank Mirror — Focus-Fire, Lanchester Square Law'
136
+ capability: action
137
+ real_world_meaning: >
138
+ Three medium tanks face three enemy medium tanks at long range in
139
+ an ASYMMETRIC mirror engagement: the agent strike force is bunched
140
+ on one column at (30,19..21); the enemy mirror is spread across
141
+ three latitudes at (50,15), (51,20), (50,25). Per Lanchester's
142
+ SQUARE LAW, the side that concentrates fire on ONE enemy at a
143
+ time wins the trade with minimal losses (3-vs-1 cannon fire ends
144
+ each enemy tank in 1-2 decision turns; combat-power surplus grows
145
+ quadratically after each kill); the side that lets each tank pick
146
+ its own closest target — the spread-fire failure mode — collapses
147
+ to the linear attrition law, ends 1-of-3 alive, and busts the
148
+ survival bar. The decision under test is target prioritization:
149
+ concentrate ALL three tanks' fire on the closest enemy first,
150
+ eliminate it, then the next, then the last — not let each tank
151
+ pick its own nearest target. Stalling loses on the kill bar;
152
+ brute attack-move loses on the survival cap; spread-fire loses on
153
+ the survival cap (medium); only concentrated focus-fire wins.
154
+ robotics_analogue: >
155
+ Military "concentration of force" doctrine (one of the Principles
156
+ of War): a smaller or equal force concentrated at the decisive
157
+ point can defeat a numerically equivalent dispersed enemy. The
158
+ per-kill removal of enemy OUTPUT DPS is the closed-form Lanchester
159
+ square-law advantage of concentrated fire; the test mirrors the
160
+ SC2 mirror-tank micro / marine-vs-marine engagement where the
161
+ side that focus-fires one target at a time wins the trade against
162
+ a numerically equal foe spreading fire across the whole line.
163
+ benchmark_anchor:
164
+ - "SC2 mirror micro"
165
+ - "Lanchester square law"
166
+ - "concentration of force"
167
+ author: openra-bench
168
+
169
+ base_map: rush-hour-arena
170
+ starting_cash: 0
171
+
172
+ base:
173
+ agent: {faction: allies, cash: 0}
174
+ enemy: {faction: soviet, cash: 0, bot_type: ''}
175
+ tools: [move_units, attack_unit, attack_move, stop]
176
+ planning: true
177
+ # React the instant contact happens or a tank is lost — combat micro
178
+ # is reactive; without these interrupts the focus-fire policy only
179
+ # re-targets on the next turn boundary and the trade leaks DPS in
180
+ # the gap between turns.
181
+ interrupts:
182
+ enemy_unit_spotted: true
183
+ own_unit_destroyed: true
184
+ termination: {max_ticks: 6000}
185
+ actors: [] # every level supplies its own actor list via overrides.
186
+
187
+ levels:
188
+ # ── EASY ─────────────────────────────────────────────────────────────
189
+ # Bare focus-fire skill: 3-vs-3 asymmetric mirror, survival bar ≥1
190
+ # (forgiving — even if focus-fire loses 2 tanks in the trade, ≥1
191
+ # alive suffices). Stall LOSES (kill bar unmet → after_ticks LOSS).
192
+ # Brute attack-move LOSES (drives into a 3-tank crossfire and force-
193
+ # wipes). Spread-fire MAY squeak by with 1 survivor (the documented
194
+ # inert-easy-teeth pattern); the strong spread-vs-focus
195
+ # discrimination is at medium.
196
+ easy:
197
+ description: >
198
+ Three medium tanks (2tnk, allies) at (30,19..21) face THREE
199
+ enemy medium tanks (2tnk, soviet) spread across three latitudes
200
+ at (50,15), (51,20), and (50,25). You must close to firing
201
+ range (cannon range ~5) and eliminate all three. By the
202
+ Lanchester square law, concentrating ALL THREE tanks' fire on
203
+ ONE enemy at a time (start with the closest — the centre at
204
+ (51,20) — kills it in 1-2 decision turns, then a flank, then
205
+ the last) preserves your force; letting each tank pick its own
206
+ closest target trades 1-for-1. Win when all 3 enemy tanks are
207
+ killed AND at least ONE of your tanks survives AND your base
208
+ is intact, before tick 2400.
209
+ overrides:
210
+ actors:
211
+ # Agent base anchor (paranoia gate against the turn-1
212
+ # own_units_gte mis-fire footgun, AND engine auto-done
213
+ # mitigation when the strike force loses all units).
214
+ - {type: fact, owner: agent, position: [4, 20]}
215
+ # Strike force — 3 medium tanks BUNCHED on a single column
216
+ # (one centroid). stance:1 ReturnFire so a stall policy never
217
+ # pulls a stray shot.
218
+ - {type: 2tnk, owner: agent, position: [30, 19], stance: 1}
219
+ - {type: 2tnk, owner: agent, position: [30, 20], stance: 1}
220
+ - {type: 2tnk, owner: agent, position: [30, 21], stance: 1}
221
+ # Enemy mirror — 3 medium tanks SPREAD across y=15/y=20/y=25.
222
+ # Centre at (51,20) NOT (50,20) per CLAUDE.md silent-fail
223
+ # cell note. stance:3 AttackAnything so they auto-fire the
224
+ # second an agent tank enters cannon range (the test is
225
+ # purely about the agent's target prioritization, not bot
226
+ # pursuit).
227
+ - {type: 2tnk, owner: enemy, position: [50, 15], stance: 3}
228
+ - {type: 2tnk, owner: enemy, position: [51, 20], stance: 3}
229
+ - {type: 2tnk, owner: enemy, position: [50, 25], stance: 3}
230
+ # Far persistent enemy fact — prevents engine auto-done on
231
+ # enemy-units-wipe before the win evaluator sees the terminal
232
+ # frame.
233
+ - {type: fact, owner: enemy, position: [124, 20]}
234
+ win_condition:
235
+ all_of:
236
+ - {units_killed_gte: 3}
237
+ - {own_units_gte: 1}
238
+ - {has_building: fact}
239
+ - {within_ticks: 2400}
240
+ fail_condition:
241
+ any_of:
242
+ - {after_ticks: 2401}
243
+ - {not: {own_units_gte: 1}}
244
+ max_turns: 30
245
+
246
+ # ── MEDIUM ──────────────────────────────────────────────────────────
247
+ # +1 controlled variable vs easy: tighten the survival bar to ≥2
248
+ # (any TWO tank losses fails). Geometry is identical (3-vs-3
249
+ # asymmetric mirror). At 3-vs-3 with the asymmetric spread, the
250
+ # SPREAD outcome empirically ends 1-of-3 tanks alive — busts
251
+ # own_units_gte:2 ⇒ LOSS. The FOCUS outcome keeps all 3 tanks
252
+ # alive ⇒ WIN. This is the load-bearing discrimination of the pack.
253
+ medium:
254
+ description: >
255
+ Three medium tanks (2tnk, allies) at (30,19..21) face THREE
256
+ enemy medium tanks (2tnk, soviet) spread across three latitudes
257
+ at (50,15), (51,20), and (50,25). By Lanchester's square law,
258
+ concentrating ALL THREE tanks' fire on ONE enemy at a time
259
+ (start with the closest — the centre at (51,20) — then the
260
+ flanks) preserves your force; spreading fire (each tank picks
261
+ its own closest enemy) trades 1-for-1 and busts the survival
262
+ bar. Win when all 3 enemy tanks are killed AND at least TWO of
263
+ your tanks survive AND your base is intact, before tick 2400.
264
+ overrides:
265
+ actors:
266
+ - {type: fact, owner: agent, position: [4, 20]}
267
+ - {type: 2tnk, owner: agent, position: [30, 19], stance: 1}
268
+ - {type: 2tnk, owner: agent, position: [30, 20], stance: 1}
269
+ - {type: 2tnk, owner: agent, position: [30, 21], stance: 1}
270
+ - {type: 2tnk, owner: enemy, position: [50, 15], stance: 3}
271
+ - {type: 2tnk, owner: enemy, position: [51, 20], stance: 3}
272
+ - {type: 2tnk, owner: enemy, position: [50, 25], stance: 3}
273
+ - {type: fact, owner: enemy, position: [124, 20]}
274
+ win_condition:
275
+ all_of:
276
+ - {units_killed_gte: 3}
277
+ - {own_units_gte: 2}
278
+ - {has_building: fact}
279
+ - {within_ticks: 2400}
280
+ fail_condition:
281
+ any_of:
282
+ - {after_ticks: 2401}
283
+ - {not: {own_units_gte: 2}}
284
+ max_turns: 30
285
+
286
+ # ── HARD ────────────────────────────────────────────────────────────
287
+ # +2 controlled variables vs medium:
288
+ # 1. KILL-SPEED PRESSURE — within_ticks tightens from 2400 to
289
+ # 1200 (per the task-spec RELAXATION fallback: when the
290
+ # survival-cap discriminator weakens on a flank spawn
291
+ # because all enemies are co-linear from a flank perspective,
292
+ # the kill-speed timer becomes the load-bearing
293
+ # discriminator). Focus-fire ends the engagement in
294
+ # ~700-1000 ticks (3 cannons on 1 target each turn); brute
295
+ # drive-into-crossfire and stall both fail the clock.
296
+ # 2. TWO seed-driven spawn_point groups (NORTH staging y=11..13
297
+ # vs SOUTH staging y=27..29) round-robined by seed so a
298
+ # memorised single-target sequence cannot generalise. From
299
+ # NORTH the closest enemy is (50,15) and farthest is (50,25);
300
+ # from SOUTH the order inverts.
301
+ # The survival cap RELAXES to own_units_gte:1 on hard (per task
302
+ # brief): on a flank spawn the spread-fire policy naturally
303
+ # focus-fires the unique closest enemy, so the spread-vs-focus
304
+ # delta on hard is primarily kill-speed (within_ticks) rather
305
+ # than survivor count.
306
+ hard:
307
+ description: >
308
+ Three medium tanks (2tnk, allies) stage at ONE of two
309
+ staging corridors (NORTH y=11..13 OR SOUTH y=27..29, chosen
310
+ by seed, anti-memorisation), all bunched at x=30 on adjacent
311
+ rows. They face THREE enemy medium tanks (2tnk, soviet)
312
+ spread along the eastern line at (50,15), (51,20), and
313
+ (50,25). By Lanchester's square law, concentrating ALL THREE
314
+ tanks' fire on ONE enemy at a time (start with the closest,
315
+ then the next-closest, then the farthest) ends the
316
+ engagement fast and preserves your force. Win when all 3
317
+ enemy tanks are killed AND at least ONE of your tanks
318
+ survives AND your base is intact, before tick 1200 (kill-
319
+ speed pressure: stalling, driving into crossfire, or
320
+ anything slower than concentrated focus-fire busts the
321
+ clock).
322
+ overrides:
323
+ actors:
324
+ # Agent base anchor — duplicated under BOTH spawn_point
325
+ # groups because ANY agent actor with a spawn_point causes
326
+ # agent actors WITHOUT a spawn_point to be filtered out
327
+ # (CLAUDE.md / oramap.rs::expand_scenario_actors).
328
+ - {type: fact, owner: agent, position: [4, 20], spawn_point: 0}
329
+ - {type: fact, owner: agent, position: [4, 20], spawn_point: 1}
330
+ # spawn_point 0 — NORTH staging corridor (y=11..13). Bunched
331
+ # at x=30, three tanks on adjacent rows.
332
+ - {type: 2tnk, owner: agent, position: [30, 11], stance: 1, spawn_point: 0}
333
+ - {type: 2tnk, owner: agent, position: [30, 12], stance: 1, spawn_point: 0}
334
+ - {type: 2tnk, owner: agent, position: [30, 13], stance: 1, spawn_point: 0}
335
+ # spawn_point 1 — SOUTH staging corridor (y=27..29). Bunched
336
+ # at x=30.
337
+ - {type: 2tnk, owner: agent, position: [30, 27], stance: 1, spawn_point: 1}
338
+ - {type: 2tnk, owner: agent, position: [30, 28], stance: 1, spawn_point: 1}
339
+ - {type: 2tnk, owner: agent, position: [30, 29], stance: 1, spawn_point: 1}
340
+ # Enemy line — 3 tanks at y=15/y=20/y=25. Enemy actors don't
341
+ # honour spawn_point, so the SAME 3 enemy tanks place
342
+ # regardless of which agent spawn group is chosen.
343
+ - {type: 2tnk, owner: enemy, position: [50, 15], stance: 3}
344
+ - {type: 2tnk, owner: enemy, position: [51, 20], stance: 3}
345
+ - {type: 2tnk, owner: enemy, position: [50, 25], stance: 3}
346
+ - {type: fact, owner: enemy, position: [124, 20]}
347
+ win_condition:
348
+ all_of:
349
+ - {units_killed_gte: 3}
350
+ - {own_units_gte: 1}
351
+ - {has_building: fact}
352
+ - {within_ticks: 1200}
353
+ fail_condition:
354
+ any_of:
355
+ - {after_ticks: 1201}
356
+ - {not: {own_units_gte: 1}}
357
+ max_turns: 15
tests/test_combat_tank_vs_tank_engagement.py ADDED
@@ -0,0 +1,387 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """combat-tank-vs-tank-engagement — Mirror tank trade: focus-fire WINS,
2
+ spread-fire (and brute attack_move, and stall) LOSE.
3
+
4
+ The bar: intended FOCUS-fire WINS on every level and every hard seed
5
+ (1-4); STALL and BRUTE attack_move LOSE on every level and every hard
6
+ seed. SPREAD-fire (each tank picks its own closest enemy) LOSES on
7
+ MEDIUM (the load-bearing discrimination: survival cap own_units_gte:2
8
+ trips because spread bleeds 2 tanks in the asymmetric flank chase) —
9
+ SPREAD is permitted to squeak by on EASY (own_units_gte:1, forgiving
10
+ bare-skill tier per the SCENARIO_REVIEW_CHECKLIST inert-easy-teeth
11
+ convention) and on HARD (the asymmetric geometry collapses spread to
12
+ focus when the agent stack starts on a flank latitude — spread ≡
13
+ focus when there's a unique closest enemy from a flank perspective;
14
+ the hard discrimination is kill-speed + spawn-variation, not
15
+ spread-vs-focus survivor count).
16
+
17
+ Non-win is a real reachable timeout LOSS via the `after_ticks` fail
18
+ clause (within_ticks 2400 + after_ticks 2401 on easy/medium with
19
+ max_turns 30; within_ticks 1200 + after_ticks 1201 on hard with
20
+ max_turns 15).
21
+
22
+ Validation is scripted (no model / network).
23
+ """
24
+ from __future__ import annotations
25
+
26
+ from pathlib import Path
27
+
28
+ import pytest
29
+
30
+ pytest.importorskip("openra_rl_training", reason="Rust env wheel not installed")
31
+ from openra_bench.scenarios import load_pack
32
+ from openra_bench.scenarios.loader import compile_level
33
+ from openra_bench.scenarios.win_conditions import WinContext, evaluate
34
+
35
+ PACKS = Path(__file__).parent.parent / "openra_bench" / "scenarios" / "packs"
36
+ PACK_PATH = PACKS / "combat-tank-vs-tank-engagement.yaml"
37
+
38
+
39
+ # ── unit-level predicate / metadata checks (no engine) ──────────────
40
+
41
+
42
+ def test_pack_compiles_and_meta_fields_populated():
43
+ pack = load_pack(PACK_PATH)
44
+ assert pack.meta.id == "combat-tank-vs-tank-engagement"
45
+ assert pack.meta.capability == "action"
46
+ assert pack.meta.real_world_meaning, "real_world_meaning required"
47
+ assert pack.meta.robotics_analogue, "robotics_analogue required"
48
+ anchors = pack.meta.benchmark_anchor
49
+ assert isinstance(anchors, list) and len(anchors) == 3, (
50
+ f"benchmark_anchor must list all 3 anchors, got {anchors!r}"
51
+ )
52
+ joined = " ".join(anchors).lower()
53
+ for needle in ("sc2", "lanchester", "concentration"):
54
+ assert needle in joined, f"missing anchor keyword: {needle}"
55
+ for lvl in ("easy", "medium", "hard"):
56
+ c = compile_level(pack, lvl)
57
+ assert c.map_supported
58
+ assert c.win_condition is not None
59
+ assert c.fail_condition is not None
60
+
61
+
62
+ def _ctx(*, units=(), tick=1000, kills=0, lost=0, buildings=("fact",)):
63
+ """Synthesize a WinContext for predicate-level checks."""
64
+ import types
65
+
66
+ sig = types.SimpleNamespace(
67
+ game_tick=tick,
68
+ units_killed=kills,
69
+ units_lost=lost,
70
+ cash=0,
71
+ resources=0,
72
+ own_buildings=[{"type": b} for b in buildings],
73
+ own_building_types=set(buildings),
74
+ enemies_seen_ids=set(),
75
+ enemy_buildings_seen_ids=set(),
76
+ )
77
+ return WinContext(
78
+ signals=sig,
79
+ render_state={
80
+ "units_summary": list(units),
81
+ "buildings": [{"type": b} for b in buildings],
82
+ },
83
+ )
84
+
85
+
86
+ def _alive(n):
87
+ return [
88
+ {"cell_x": 30, "cell_y": 20, "type": "2tnk", "id": str(1000 + i)}
89
+ for i in range(n)
90
+ ]
91
+
92
+
93
+ def test_easy_predicates():
94
+ c = compile_level(load_pack(PACK_PATH), "easy")
95
+ # Intended focus: kills 3, 3 alive, in time → WIN
96
+ assert evaluate(c.win_condition, _ctx(units=_alive(3), tick=900, kills=3, lost=0))
97
+ # Spread allowed to squeak by on easy (own_units_gte:1, 1 survivor)
98
+ assert evaluate(c.win_condition, _ctx(units=_alive(1), tick=900, kills=3, lost=2))
99
+ # Kill bar unmet (only 2 kills) → not a win
100
+ assert not evaluate(c.win_condition, _ctx(units=_alive(3), tick=900, kills=2, lost=0))
101
+ # Force wipe → fail (own_units_gte:1 trips via fail clause)
102
+ assert evaluate(c.fail_condition, _ctx(units=[], tick=900, kills=3, lost=3))
103
+ # Timeout with bar unmet → fail (after_ticks 2401)
104
+ assert evaluate(c.fail_condition, _ctx(units=_alive(3), tick=2402, kills=2, lost=0))
105
+
106
+
107
+ def test_medium_predicates():
108
+ c = compile_level(load_pack(PACK_PATH), "medium")
109
+ # Intended focus: kills 3, 3 alive (cap met at ≥2) → WIN
110
+ assert evaluate(c.win_condition, _ctx(units=_alive(3), tick=900, kills=3, lost=0))
111
+ # ≥2 alive (cap met) with 3 kills → WIN
112
+ assert evaluate(c.win_condition, _ctx(units=_alive(2), tick=900, kills=3, lost=1))
113
+ # Spread-fire: only 1 alive < 2 → not a win (cap busted)
114
+ assert not evaluate(c.win_condition, _ctx(units=_alive(1), tick=900, kills=3, lost=2))
115
+ # And the fail clause trips when own_units_gte:2 fails
116
+ assert evaluate(c.fail_condition, _ctx(units=_alive(1), tick=900, kills=3, lost=2))
117
+ # Force wipe → fail
118
+ assert evaluate(c.fail_condition, _ctx(units=[], tick=900, kills=3, lost=3))
119
+ # Timeout → fail
120
+ assert evaluate(c.fail_condition, _ctx(units=_alive(3), tick=2402, kills=2, lost=0))
121
+
122
+
123
+ def test_hard_predicates():
124
+ c = compile_level(load_pack(PACK_PATH), "hard")
125
+ # Intended focus: kills 3, 3 alive, within tight tick budget → WIN
126
+ assert evaluate(c.win_condition, _ctx(units=_alive(3), tick=900, kills=3, lost=0))
127
+ # 1 survivor (own_units_gte:1) with full kill bar → WIN
128
+ assert evaluate(c.win_condition, _ctx(units=_alive(1), tick=900, kills=3, lost=2))
129
+ # Kill bar unmet → not a win
130
+ assert not evaluate(c.win_condition, _ctx(units=_alive(3), tick=900, kills=2, lost=0))
131
+ # Outside tight tick budget (kills met but slow) → not a win
132
+ assert not evaluate(c.win_condition, _ctx(units=_alive(3), tick=1300, kills=3, lost=0))
133
+ # Force wipe → fail
134
+ assert evaluate(c.fail_condition, _ctx(units=[], tick=900, kills=3, lost=3))
135
+ # Timeout → fail (tight after_ticks 1201)
136
+ assert evaluate(c.fail_condition, _ctx(units=_alive(3), tick=1202, kills=2, lost=0))
137
+
138
+
139
+ def test_timeout_reachable_inside_max_turns():
140
+ """No draw degeneracy: after_ticks ≤ 93 + 90·(max_turns-1)."""
141
+ pack = load_pack(PACK_PATH)
142
+ for lvl, want_after in [("easy", 2401), ("medium", 2401), ("hard", 1201)]:
143
+ c = compile_level(pack, lvl)
144
+ max_tick = 93 + 90 * (c.max_turns - 1)
145
+ assert want_after <= max_tick, (
146
+ f"{lvl}: after_ticks {want_after} > max reachable tick "
147
+ f"{max_tick} (max_turns={c.max_turns}); deadline never bites"
148
+ )
149
+
150
+
151
+ def test_hard_has_two_spawn_point_groups():
152
+ """Hard-tier curation: ≥2 distinct agent spawn_point groups so the
153
+ seed round-robins the staging corridor (NORTH y=11..13 / SOUTH
154
+ y=27..29). Engine-roundtrip is asserted by tests/test_hard_tier.py."""
155
+ c = compile_level(load_pack(PACK_PATH), "hard")
156
+ groups = {
157
+ (a.spawn_point if a.spawn_point is not None else 0)
158
+ for a in c.scenario.actors
159
+ if a.owner == "agent"
160
+ }
161
+ assert len(groups) >= 2, f"hard needs ≥2 spawn_point groups, got {groups}"
162
+
163
+
164
+ def test_enemy_line_is_3_tanks_asymmetric_spread():
165
+ """The asymmetric geometry is the load-bearing physics — the
166
+ enemy line MUST be 3 tanks spread across three distinct
167
+ latitudes (the spread vs focus discrimination depends on each
168
+ enemy being independently targetable). Centre enemy at x=51 (not
169
+ x=50) per the CLAUDE.md silent-fail-cell note for (50,20)."""
170
+ pack = load_pack(PACK_PATH)
171
+ for lvl in ("easy", "medium", "hard"):
172
+ c = compile_level(pack, lvl)
173
+ enemy_tanks = [
174
+ a for a in c.scenario.actors
175
+ if a.owner == "enemy" and a.type == "2tnk"
176
+ ]
177
+ assert len(enemy_tanks) == 3, (
178
+ f"{lvl}: must have exactly 3 enemy tanks, got {len(enemy_tanks)}"
179
+ )
180
+ ys = sorted(a.position[1] for a in enemy_tanks)
181
+ assert len(set(ys)) == 3, (
182
+ f"{lvl}: enemy tanks must be on 3 distinct latitudes "
183
+ f"(asymmetric spread), got ys={ys}"
184
+ )
185
+ # Verify the (50,20) silent-fail cell is NOT used.
186
+ positions = [tuple(a.position) for a in enemy_tanks]
187
+ assert (50, 20) not in positions, (
188
+ f"{lvl}: (50,20) is a CLAUDE.md-documented silent-fail "
189
+ f"cell — centre enemy must be at (51,20). Got {positions}"
190
+ )
191
+ types = [a.type for a in c.scenario.actors if a.owner == "enemy"]
192
+ assert "fact" in types, f"{lvl}: needs a persistent enemy fact"
193
+
194
+
195
+ def test_agent_strike_force_is_3_tanks_bunched():
196
+ """The agent strike force MUST be 3 medium tanks bunched on
197
+ adjacent rows at a single column (one centroid) — that bunched-
198
+ vs-spread asymmetry is what makes focus-fire load-bearing."""
199
+ pack = load_pack(PACK_PATH)
200
+ for lvl in ("easy", "medium"):
201
+ c = compile_level(pack, lvl)
202
+ agent_tanks = [
203
+ a for a in c.scenario.actors
204
+ if a.owner == "agent" and a.type == "2tnk"
205
+ ]
206
+ assert len(agent_tanks) == 3, (
207
+ f"{lvl}: must have exactly 3 agent tanks, got {len(agent_tanks)}"
208
+ )
209
+ xs = {a.position[0] for a in agent_tanks}
210
+ assert len(xs) == 1, (
211
+ f"{lvl}: agent tanks must be bunched on ONE column (same x), "
212
+ f"got xs={xs}"
213
+ )
214
+ # Hard: per-spawn-group bunching (each spawn_point has 3 tanks
215
+ # bunched on one column at a single latitude band).
216
+ c = compile_level(pack, "hard")
217
+ for sp in (0, 1):
218
+ agent_tanks = [
219
+ a for a in c.scenario.actors
220
+ if a.owner == "agent" and a.type == "2tnk" and a.spawn_point == sp
221
+ ]
222
+ assert len(agent_tanks) == 3, (
223
+ f"hard sp={sp}: must have 3 agent tanks, got {len(agent_tanks)}"
224
+ )
225
+ xs = {a.position[0] for a in agent_tanks}
226
+ assert len(xs) == 1, (
227
+ f"hard sp={sp}: agent tanks bunched on ONE column, got xs={xs}"
228
+ )
229
+
230
+
231
+ # ── engine-driven scripted policies ─────────────────────────────────
232
+
233
+
234
+ def _own_ids(rs):
235
+ return [str(u["id"]) for u in (rs.get("units_summary", []) or [])]
236
+
237
+
238
+ def _enemy_tanks(rs):
239
+ out = []
240
+ for e in (rs.get("enemy_summary") or []):
241
+ t = (e.get("type") or e.get("actor_type") or "").lower()
242
+ if t == "2tnk":
243
+ out.append(e)
244
+ return out
245
+
246
+
247
+ def _stall(rs, Command):
248
+ """Pure observe — tanks never close to firing range; nothing dies
249
+ either way ⇒ kill bar unmet ⇒ after_ticks LOSS."""
250
+ return [Command.observe()]
251
+
252
+
253
+ def _brute_attack_move(rs, Command):
254
+ """Brute: every tank attack_moves toward the centre enemy. The
255
+ bunched stack drives into the 3-tank crossfire at the engagement
256
+ line; concentrated incoming fire kills ≥2 agent tanks ⇒ LOSS."""
257
+ own = _own_ids(rs)
258
+ if not own:
259
+ return [Command.observe()]
260
+ return [Command.attack_move(own, 51, 20)]
261
+
262
+
263
+ def _spread_attack_closest(rs, Command):
264
+ """Spread: each agent tank attack_units ITS OWN nearest visible
265
+ enemy tank. With the asymmetric spread (3 enemies on three rows),
266
+ once the centre dies the surviving agent tanks chase different
267
+ flank enemies in 1-vs-1 duels — Lanchester linear law collapses
268
+ the trade to mutual annihilation, ending with 1-of-3 alive. On
269
+ MEDIUM (own_units_gte:2) this busts the survival cap ⇒ LOSS."""
270
+ own = _own_ids(rs)
271
+ if not own:
272
+ return [Command.observe()]
273
+ es = _enemy_tanks(rs)
274
+ if not es:
275
+ # No targets in sight — advance to contact.
276
+ return [Command.attack_move(own, 51, 20)]
277
+ cmds = []
278
+ for u in (rs.get("units_summary") or []):
279
+ uid = str(u["id"])
280
+ ux, uy = u["cell_x"], u["cell_y"]
281
+ es_sorted = sorted(
282
+ es, key=lambda e: (e["cell_x"] - ux) ** 2 + (e["cell_y"] - uy) ** 2
283
+ )
284
+ tid = es_sorted[0].get("id")
285
+ if tid is not None:
286
+ cmds.append(Command.attack_unit([uid], str(tid)))
287
+ return cmds or [Command.observe()]
288
+
289
+
290
+ def _focus_fire(rs, Command):
291
+ """Focus-fire: ALL agent tanks attack_unit the SAME target each
292
+ turn — the closest enemy to the agent centroid. Once that enemy
293
+ falls, the policy automatically re-targets the next-closest. This
294
+ is the Lanchester-square-law optimal: concentrate N output on 1
295
+ target, remove that target's DPS, then repeat — combat-power
296
+ surplus grows quadratically with each kill."""
297
+ own = _own_ids(rs)
298
+ if not own:
299
+ return [Command.observe()]
300
+ es = _enemy_tanks(rs)
301
+ if not es:
302
+ return [Command.attack_move(own, 51, 20)]
303
+ us = rs.get("units_summary") or []
304
+ cx = sum(u["cell_x"] for u in us) / len(us)
305
+ cy = sum(u["cell_y"] for u in us) / len(us)
306
+ es.sort(key=lambda e: (e["cell_x"] - cx) ** 2 + (e["cell_y"] - cy) ** 2)
307
+ tid = es[0].get("id")
308
+ if tid is not None:
309
+ return [Command.attack_unit(own, str(tid))]
310
+ return [Command.attack_move(own, 51, 20)]
311
+
312
+
313
+ @pytest.mark.parametrize("level", ["easy", "medium", "hard"])
314
+ @pytest.mark.parametrize("seed", [1, 2, 3, 4])
315
+ def test_intended_focus_fire_wins(level, seed):
316
+ pytest.importorskip("openra_train")
317
+ from openra_bench.eval_core import run_level
318
+
319
+ c = compile_level(load_pack(PACK_PATH), level)
320
+ r = run_level(c, _focus_fire, seed=seed)
321
+ assert r.outcome == "win", (
322
+ f"{level} seed={seed}: intended focus-fire should WIN, got "
323
+ f"{r.outcome} after {r.turns} turns "
324
+ f"(kills={r.signals.units_killed}, losses={r.signals.units_lost})"
325
+ )
326
+
327
+
328
+ @pytest.mark.parametrize("level", ["easy", "medium", "hard"])
329
+ @pytest.mark.parametrize("seed", [1, 2, 3, 4])
330
+ def test_stall_loses(level, seed):
331
+ pytest.importorskip("openra_train")
332
+ from openra_bench.eval_core import run_level
333
+
334
+ c = compile_level(load_pack(PACK_PATH), level)
335
+ r = run_level(c, _stall, seed=seed)
336
+ assert r.outcome == "loss", (
337
+ f"{level} seed={seed}: stall must be a real timeout LOSS "
338
+ f"(no engagement → kill bar unmet → after_ticks fires), got "
339
+ f"{r.outcome}"
340
+ )
341
+
342
+
343
+ @pytest.mark.parametrize("level", ["easy", "medium", "hard"])
344
+ @pytest.mark.parametrize("seed", [1, 2, 3, 4])
345
+ def test_brute_attack_move_loses(level, seed):
346
+ """Brute attack_move into the centre crosses through the 3-tank
347
+ crossfire and force-wipes the agent strike force on EASY (3 of 3
348
+ lost) ⇒ LOSS via not own_units_gte:1; on MEDIUM (2 lost) ⇒ LOSS
349
+ via not own_units_gte:2; on HARD (1-3 lost, but timing slow) ⇒
350
+ LOSS via the tight within_ticks 1200 + force-wipe."""
351
+ pytest.importorskip("openra_train")
352
+ from openra_bench.eval_core import run_level
353
+
354
+ c = compile_level(load_pack(PACK_PATH), level)
355
+ r = run_level(c, _brute_attack_move, seed=seed)
356
+ assert r.outcome == "loss", (
357
+ f"{level} seed={seed}: brute attack_move must LOSE (drive-into-"
358
+ f"crossfire force-wipe / tick budget bust), got {r.outcome} "
359
+ f"(kills={r.signals.units_killed}, losses={r.signals.units_lost})"
360
+ )
361
+
362
+
363
+ @pytest.mark.parametrize("level", ["medium"])
364
+ @pytest.mark.parametrize("seed", [1, 2, 3, 4])
365
+ def test_spread_attack_closest_loses_on_medium(level, seed):
366
+ """Spread-attack-closest must LOSE on MEDIUM — the asymmetric
367
+ flank chase ends with 1-of-3 agent tanks alive (2 lost), busting
368
+ the survival cap own_units_gte:2. EASY is excluded as the bare-
369
+ skill tier (own_units_gte:1 lets the 1 survivor squeak by — the
370
+ documented SCENARIO_REVIEW_CHECKLIST inert-easy-teeth pattern).
371
+ HARD is excluded because the asymmetric geometry collapses
372
+ spread to focus when the agent stack starts on a flank latitude
373
+ (NORTH or SOUTH) — from a flank there is a unique closest enemy
374
+ that all 3 agent tanks naturally target (spread ≡ focus); the
375
+ hard discrimination is kill-speed + spawn-variation, not the
376
+ survivor-count delta."""
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, _spread_attack_closest, seed=seed)
382
+ assert r.outcome == "loss", (
383
+ f"{level} seed={seed}: spread-attack-closest must LOSE on "
384
+ f"medium (flank chase bleeds 2 tanks, own_units_gte:2 fails), "
385
+ f"got {r.outcome} (kills={r.signals.units_killed}, "
386
+ f"losses={r.signals.units_lost})"
387
+ )
tests/test_hard_tier.py CHANGED
@@ -621,6 +621,20 @@ UPGRADED = [
621
  # equivalent column-vs-wedge decision from a flipped bearing — a
622
  # memorised "form wedge from y=14" opening cannot generalise.
623
  "combat-formation-tank-wedge",
 
 
 
 
 
 
 
 
 
 
 
 
 
 
624
  ]
625
 
626
  # Consciously NOT spawn-varied, with the reason (keeps the curation
 
621
  # equivalent column-vs-wedge decision from a flipped bearing — a
622
  # memorised "form wedge from y=14" opening cannot generalise.
623
  "combat-formation-tank-wedge",
624
+ # Wave-7 Group D combat-micro pack: SC2 mirror micro / Lanchester
625
+ # square law / concentration-of-force doctrine. ASYMMETRIC geometry
626
+ # (3 agent tanks bunched at (30,*) vs 3 enemy tanks spread across
627
+ # 3 latitudes at (50,15)/(51,20)/(50,25)) so focus-fire on the
628
+ # closest enemy preserves the force while spread-fire (each tank
629
+ # picks its own nearest enemy) collapses to Lanchester linear and
630
+ # loses 2 of 3 tanks (the load-bearing discrimination on medium).
631
+ # Hard tier defines two agent spawn_point groups (NORTH y=11..13 /
632
+ # SOUTH y=27..29) round-robined by seed; the enemy line always
633
+ # places (enemy actors don't honour spawn_point — CLAUDE.md) so
634
+ # both spawns face the same focus-fire decision from a flipped
635
+ # bearing. Hard discriminates via kill-speed (tight within_ticks
636
+ # 1200) + spawn-variation generalisation.
637
+ "combat-tank-vs-tank-engagement",
638
  ]
639
 
640
  # Consciously NOT spawn-varied, with the reason (keeps the curation