yxc20098 commited on
Commit
8b07323
·
1 Parent(s): 6defda6

feat(scenario): combat-bait-counter-attack — sacrificial bait + flanking strike (SC2 bait / military feint anchor)

Browse files

Wave-4 Group D reasoning pack. A leashed GUARD cluster (engine
scripted bot 'guard', GUARD_AGGRO ≈ 16 / GUARD_LEASH ≈ 18) sits
clumped on the west face of the enemy construction yard (`fact`).
The intended idiom commits a cheap jeep as bait on a divergent
hemisphere flank, pulling a SIDE of the cluster off post; the strike
tanks engage the matching-side guards (now away from post, vulnerable)
in a favourable trade, then destroy the briefly-undefended yard. The
discrimination:

• stall LOSES (clock runs out, fact intact)
• brute frontal LOSES (anti-tank Dragon fire shreds the column,
units_lost_lte cap fails)
• bait-only LOSES (fact not destroyed → enemy_key_buildings_destroyed
unsatisfied → clock runs out)
• intended bait + counter-attack WINS

Engine notes: an unarmed sentinel fact at (120,4) keeps the episode
alive past the moment the objective fact dies so the within_ticks /
units_lost_lte predicates evaluate cleanly on the terminal frame
(CLAUDE.md MustBeDestroyed auto-terminate footgun).

Difficulty axis (one controlled variable per tier):
easy: 3 guards on the west face, cap 3
medium: 5 guards on west + south faces, cap 2
hard: 6 guards split NW+SW + 2 spawn_point groups round-robin'd by
seed so the working bait-direction / counter-attack-target
pair flips per seed (cannot be memorised), cap 2

Bar verified by scripted policies on every level × seed 1..4. Hard-
tier registered in tests/test_hard_tier.py::UPGRADED.

Benchmark anchors: SC2 bait micro / sacrificial pull; military
feint-and-flank doctrine; CICERO / Diplomacy deception.

openra_bench/scenarios/packs/combat-bait-counter-attack.yaml ADDED
@@ -0,0 +1,271 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # combat-bait-counter-attack.yaml
2
+ #
3
+ # REASONING capability — bait-and-flank counter-attack micro. A
4
+ # leashed GUARD cluster (scripted `guard` bot, GUARD_AGGRO ≈ 16 /
5
+ # GUARD_LEASH ≈ 18 from spawn cell) sits clumped on the WEST face of
6
+ # the enemy construction yard (`fact`) and forms a wall of anti-tank
7
+ # rocket infantry (e3) directly between the agent and the objective.
8
+ # Drive the strike tanks straight in and every guard auto-fires on
9
+ # the approach — e3 anti-tank DPS shreds a 2tnk column before the
10
+ # fact falls and the attrition cap (units_lost_lte:N) trips. Sit
11
+ # still and the clock runs out (within_ticks 5400).
12
+ #
13
+ # The intended idiom is the SC2 bait-micro / military feint-and-flank
14
+ # doctrine: commit the cheap fast jeep as bait on a divergent vector
15
+ # that comes WITHIN aggro radius of a SEGMENT of the cluster (e.g.
16
+ # south-flank approach), pulling those guards OFF post; in the
17
+ # transient window while they're off-post chasing the jeep toward
18
+ # their leash, swing the strike tanks around through the now-vacated
19
+ # slot (north flank) to hit the briefly-undefended `fact`. Phase 1
20
+ # (the bait pull) yields zero objective progress and looks locally
21
+ # negative (the jeep is drawing fire); only phase 2, through the
22
+ # vacated slot, scores.
23
+ #
24
+ # Distinct from `artofwar-lure-the-tiger` (a LINE-of-guards walling
25
+ # a corridor: the bait pulls a segment off the wall to open a passage
26
+ # for the main body to TRAVERSE through to a far landmark) and
27
+ # `artofwar-decoy-sacrifice` (a HUNTING picket that actively pursues:
28
+ # bait spends to draw chasers off a corridor): here the cluster
29
+ # GUARDS a fixed objective `fact` and the win is the FACT BEING
30
+ # DESTROYED — the bait displaces a side of the cluster off post and
31
+ # the strike force exploits the gap to attack the objective itself,
32
+ # not pass through.
33
+ #
34
+ # Engine notes (per CLAUDE.md):
35
+ # - `fact` is `MustBeDestroyed`: razing it would auto-`done` the
36
+ # engine. A SENTINEL enemy `fact` is placed in the far north so
37
+ # the episode persists past the objective `fact` destruction
38
+ # until the engine evaluates win/fail predicates — including the
39
+ # attrition cap (units_lost_lte) and the deadline (within_ticks).
40
+ # - GUARD_AGGRO ≈ 16, GUARD_LEASH ≈ 18 from spawn_cell. The bait
41
+ # vector must come WITHIN ≈16 of a guard's spawn cell to lunge;
42
+ # the strike vector must keep the strike tanks beyond aggro of
43
+ # the remaining guards on the loop-around path.
44
+ # - `move_units` auto-fires opportunistically; tanks travelling
45
+ # close to a remaining guard will engage en route. The flank
46
+ # route must keep the column out of every remaining guard's
47
+ # weapon range until it reaches the fact.
48
+
49
+ meta:
50
+ id: combat-bait-counter-attack
51
+ title: 'Bait + Counter-Attack — Pull the Guards Off Post, Strike the Undefended Yard'
52
+ capability: reasoning
53
+ real_world_meaning: >
54
+ A reactive defending cluster (scripted `guard` bot: holds its post,
55
+ auto-fires in range, lunges at the nearest foe within an aggro
56
+ radius, snaps back past a leash) is bunched on the approach side
57
+ of the enemy construction yard. Driving the strike force straight
58
+ in eats anti-tank fire from the whole cluster and runs the
59
+ attrition cap before the objective falls. The winning policy
60
+ commits a cheap fast bait (jeep) on a DIVERGENT vector that comes
61
+ just close enough to make a SIDE of the cluster lunge off post
62
+ after the bait, then runs the strike tanks around through the
63
+ now-vacated flank to destroy the construction yard while it is
64
+ briefly undefended. Phase 1 (the bait pull) yields zero objective
65
+ progress and looks locally negative; phase 2, through the slot
66
+ the bait opened, scores. The credit problem is FEINT-AND-FLANK:
67
+ the bait must displace a segment of the cluster and the strike
68
+ must exploit the reversible vacancy in time; bait-only never
69
+ razes the yard, brute frontal trades the strike force against
70
+ the full cluster, stalling loses the clock.
71
+ robotics_analogue: >
72
+ Sacrificial-decoy planning against a reactive guarding obstacle:
73
+ a no-reward enabling action (entice a leashed interceptor off the
74
+ objective with a decoy on a divergent vector) must precede and
75
+ temporally overlap the rewarded objective action (commit the
76
+ payload force on the briefly-cleared flank to destroy the
77
+ objective before the interceptor snaps back).
78
+ benchmark_anchor:
79
+ - "SC2 bait micro / sacrificial pull"
80
+ - "military feint-and-flank doctrine"
81
+ - "CICERO / Diplomacy deception"
82
+ author: openra-bench
83
+
84
+ base_map: rush-hour-arena
85
+
86
+ base:
87
+ agent: {faction: allies, cash: 0}
88
+ enemy: {faction: soviet, cash: 0, bot_type: guard}
89
+ tools: [move_units, attack_unit, attack_move, stop]
90
+ planning: true
91
+ termination: {max_ticks: 12000}
92
+
93
+ levels:
94
+ # ── EASY ─────────────────────────────────────────────────────────
95
+ # The bare bait-and-flank skill: 3 leashed guards lined on the
96
+ # WEST face of the enemy fact (the natural approach side), generous
97
+ # loss cap (3). Brute frontal: the 3-tank column eats anti-tank
98
+ # fire from all 3 guards on the approach, trades the whole strike
99
+ # force before the fact falls, trips the attrition cap. Stall:
100
+ # clock runs out (within_ticks 5400). Bait-only: jeep pulls all 3
101
+ # guards south off post but no strike — fact survives, clock runs
102
+ # out. Intended: jeep dives south-east to (78, 32); all three
103
+ # west-face guards are within ≈16 cells (distances 12/14/10) and
104
+ # lunge south after it. While the jeep kites them at their leash,
105
+ # the strike tanks drive east along y=20 through the now-cleared
106
+ # west face and destroy the undefended yard.
107
+ easy:
108
+ description: >
109
+ Three leashed rocket-infantry guards stand on the west face of
110
+ the enemy construction yard (`fact` at 80,20), directly
111
+ between you and the objective. They hold post, auto-fire on
112
+ units in range, lunge at the nearest foe within ≈16 cells, and
113
+ snap back past a leash of ≈18. Destroy the enemy construction
114
+ yard with at most 3 of your units lost before tick 5400.
115
+ Driving the 3 tanks straight east at the yard puts them in
116
+ anti-tank fire from the whole guard line; stalling runs the
117
+ clock. Commit the fast jeep on a divergent south-east vector
118
+ so all three guards lunge south after it; while the jeep
119
+ kites them at their leash, run the strike tanks straight
120
+ through the now-undefended west face to destroy the yard.
121
+ overrides:
122
+ actors:
123
+ # Strike force: 3 medium tanks at west, central staging.
124
+ - {type: 2tnk, owner: agent, position: [6, 20], stance: 1, count: 3}
125
+ # Bait: 1 fast jeep at west, south staging.
126
+ - {type: jeep, owner: agent, position: [6, 28], stance: 1}
127
+ # The OBJECTIVE: enemy construction yard at mid-map.
128
+ - {type: fact, owner: enemy, position: [80, 20]}
129
+ # The GUARDS: 3 leashed rocket infantry on the WEST face of
130
+ # the fact. A south-flank jeep at (78,32) is within aggro
131
+ # ≈16 cells of all three (distances ≈14/12/10), so all
132
+ # three lunge south after it, clearing the west approach.
133
+ - {type: e3, owner: enemy, position: [76, 18], stance: 2}
134
+ - {type: e3, owner: enemy, position: [76, 20], stance: 2}
135
+ - {type: e3, owner: enemy, position: [76, 22], stance: 2}
136
+ # SENTINEL: persistent enemy fact at far north (out of the
137
+ # combat zone, agent can't reach it in budget) so the engine
138
+ # does not auto-`done` the instant the objective fact dies,
139
+ # letting the within_ticks / units_lost_lte predicates
140
+ # evaluate cleanly on the terminal frame.
141
+ - {type: fact, owner: enemy, position: [120, 4]}
142
+ win_condition:
143
+ all_of:
144
+ - {enemy_key_buildings_destroyed: {types: [fact]}}
145
+ - {within_ticks: 5400}
146
+ - {units_lost_lte: 3}
147
+ fail_condition:
148
+ any_of:
149
+ - {after_ticks: 5401}
150
+ - {not: {units_lost_lte: 3}}
151
+ - {not: {own_units_gte: 1}}
152
+ max_turns: 62
153
+
154
+ # ── MEDIUM ───────────────────────────────────────────────────────
155
+ # +1 controlled variable: 5 guards forming a south + west L-cover
156
+ # of the yard (the two natural west-staging approaches: head-on
157
+ # along y=20 OR a south sweep along y=24..28), so a single south
158
+ # bait pulls the south arc off while the west arc still covers
159
+ # the head-on lane — the strike must LOOP NORTH to come in via
160
+ # the cleared y=10..16 corridor. Strict attrition cap (only the
161
+ # bait jeep + at most one tank may be spent).
162
+ medium:
163
+ description: >
164
+ A denser cluster of 5 leashed rocket-infantry guards covers
165
+ the enemy construction yard (`fact` at 80,20) on the WEST and
166
+ SOUTH faces (the two natural approaches from your west base).
167
+ A head-on push along y=20 is now decisively fatal. You may
168
+ spend at most 2 units — losing a second tank fails. Commit the
169
+ fast jeep on a south flank vector to pull the south arc of
170
+ guards off post after it; the west arc still covers the y=20
171
+ head-on lane, so loop the strike tanks NORTH through the
172
+ cleared y=10 corridor and dive south onto the yard from
173
+ above. Destroy the yard before tick 5400. Bait-only (no
174
+ strike) loses on the clock; brute frontal trades the strike
175
+ force.
176
+ overrides:
177
+ actors:
178
+ - {type: 2tnk, owner: agent, position: [6, 20], stance: 1, count: 3}
179
+ - {type: jeep, owner: agent, position: [6, 28], stance: 1}
180
+ - {type: fact, owner: enemy, position: [80, 20]}
181
+ # 5 guards on the WEST + SOUTH faces. Bait at (78,32) pulls
182
+ # the SOUTH arc (76,24), (80,24), (84,24) — distances ≤12;
183
+ # the WEST arc (76,18), (76,20) stays but is OUT of weapon
184
+ # range of a north-flank y=10 approach (distance ≥22).
185
+ - {type: e3, owner: enemy, position: [76, 18], stance: 2}
186
+ - {type: e3, owner: enemy, position: [76, 20], stance: 2}
187
+ - {type: e3, owner: enemy, position: [76, 24], stance: 2}
188
+ - {type: e3, owner: enemy, position: [80, 24], stance: 2}
189
+ - {type: e3, owner: enemy, position: [84, 24], stance: 2}
190
+ - {type: fact, owner: enemy, position: [120, 4]}
191
+ win_condition:
192
+ all_of:
193
+ - {enemy_key_buildings_destroyed: {types: [fact]}}
194
+ - {within_ticks: 5400}
195
+ - {units_lost_lte: 2}
196
+ fail_condition:
197
+ any_of:
198
+ - {after_ticks: 5401}
199
+ - {not: {units_lost_lte: 2}}
200
+ - {not: {own_units_gte: 1}}
201
+ max_turns: 62
202
+
203
+ # ── HARD ─────────────────────────────────────────────────────────
204
+ # +1 more controlled variable: 6 guards split into a NORTH-WEST
205
+ # half-cluster (y=16..18) and a SOUTH-WEST half-cluster (y=22..24),
206
+ # covering BOTH the north-flank y=10 corridor and the south-flank
207
+ # y=30 corridor (so a single fixed bait/flank pair doesn't work
208
+ # across seeds). Two spawn_point groups round-robin the agent's
209
+ # staging latitude — from the NORTH spawn the bait pulls the NW
210
+ # half off and the strike loops SOUTH; from the SOUTH spawn the
211
+ # bait pulls the SW half off and the strike loops NORTH. Strict
212
+ # loss cap of 1 — only the bait jeep may be spent; burning ANY
213
+ # tank fails. The strike must hit the yard essentially untouched.
214
+ hard:
215
+ description: >
216
+ Densest guard cover (6 leashed rocket-infantry split into a
217
+ NW half and a SW half around the enemy construction yard at
218
+ 80,20 — covering both the north-flank y=10 corridor and the
219
+ south-flank y=30 corridor). The force stages from a seed-
220
+ chosen latitude (north or south), so the working bait/strike
221
+ pair varies per seed and can't be memorised. You may spend at
222
+ most 2 units (the bait jeep + at most one tank) — losing a
223
+ second tank fails. Destroy the yard before tick 5400. Brute
224
+ frontal trades armour and fails; bait-only never razes the
225
+ yard; stalling loses the clock.
226
+ overrides:
227
+ actors:
228
+ # spawn_point 0 — NORTH staging (y≈10): the bait dives
229
+ # further north into the NW half's aggro arc (pulling it
230
+ # off post), the strike loops SOUTH through the cleared
231
+ # y=10 corridor over the top and down onto the yard.
232
+ - {type: 2tnk, owner: agent, position: [6, 10], stance: 1, count: 3,
233
+ spawn_point: 0,
234
+ randomize: {position: {base: [6, 10], offset: 2}}}
235
+ - {type: jeep, owner: agent, position: [6, 14], stance: 1,
236
+ spawn_point: 0}
237
+ # spawn_point 1 — SOUTH staging (y≈30): mirror layout. Bait
238
+ # dives further south into the SW half's aggro arc, strike
239
+ # loops NORTH through the cleared y=30 corridor.
240
+ - {type: 2tnk, owner: agent, position: [6, 30], stance: 1, count: 3,
241
+ spawn_point: 1,
242
+ randomize: {position: {base: [6, 30], offset: 2}}}
243
+ - {type: jeep, owner: agent, position: [6, 26], stance: 1,
244
+ spawn_point: 1}
245
+ # The OBJECTIVE.
246
+ - {type: fact, owner: enemy, position: [80, 20]}
247
+ # 6 guards in TWO cluster halves on the WEST face of the
248
+ # yard: NW half (y=16..18) and SW half (y=22..24). Each
249
+ # half covers its matching flank corridor (y=10 from above,
250
+ # y=30 from below) within aggro ≈16. Both halves always
251
+ # place — enemy actors are not spawn_point filtered (per
252
+ # CLAUDE.md). The seed varies which staging latitude the
253
+ # agent starts from, so the natural bait/strike sides flip.
254
+ - {type: e3, owner: enemy, position: [76, 16], stance: 2}
255
+ - {type: e3, owner: enemy, position: [78, 17], stance: 2}
256
+ - {type: e3, owner: enemy, position: [76, 18], stance: 2}
257
+ - {type: e3, owner: enemy, position: [76, 22], stance: 2}
258
+ - {type: e3, owner: enemy, position: [78, 23], stance: 2}
259
+ - {type: e3, owner: enemy, position: [76, 24], stance: 2}
260
+ - {type: fact, owner: enemy, position: [120, 4]}
261
+ win_condition:
262
+ all_of:
263
+ - {enemy_key_buildings_destroyed: {types: [fact]}}
264
+ - {within_ticks: 5400}
265
+ - {units_lost_lte: 2}
266
+ fail_condition:
267
+ any_of:
268
+ - {after_ticks: 5401}
269
+ - {not: {units_lost_lte: 2}}
270
+ - {not: {own_units_gte: 1}}
271
+ max_turns: 62
tests/test_combat_bait_counter_attack.py ADDED
@@ -0,0 +1,443 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """combat-bait-counter-attack — bait-and-flank counter-attack micro.
2
+
3
+ The bar: intended bait-then-strike (bait jeep pulls a SIDE of a leashed
4
+ GUARD cluster off the enemy fact; strike tanks engage the now-isolated
5
+ guards and then destroy the briefly-undefended fact) WINS on every
6
+ level and every hard seed (1..4). STALL (only observe), BRUTE FRONTAL
7
+ (attack-move all units east at the fact), and BAIT-ONLY (jeep flanks
8
+ south but no strike) all LOSE on every level and every hard seed.
9
+ Non-win is a real reachable timeout LOSS via the `after_ticks` fail
10
+ clause; `units_lost_lte` provides the second LOSS path (brute frontal
11
+ trades the strike force).
12
+
13
+ Validation is scripted (no model / network).
14
+ """
15
+ from __future__ import annotations
16
+
17
+ from pathlib import Path
18
+
19
+ import pytest
20
+
21
+ from openra_bench.scenarios import load_pack
22
+ from openra_bench.scenarios.loader import compile_level
23
+ from openra_bench.scenarios.win_conditions import WinContext, evaluate
24
+
25
+ PACKS = Path(__file__).parent.parent / "openra_bench" / "scenarios" / "packs"
26
+ PACK_PATH = PACKS / "combat-bait-counter-attack.yaml"
27
+
28
+
29
+ # ── unit-level predicate / metadata checks (no engine) ──────────────
30
+
31
+
32
+ def test_pack_compiles_and_meta_fields_populated():
33
+ pack = load_pack(PACK_PATH)
34
+ assert pack.meta.id == "combat-bait-counter-attack"
35
+ assert pack.meta.capability == "reasoning"
36
+ assert pack.meta.real_world_meaning, "real_world_meaning required"
37
+ assert pack.meta.robotics_analogue, "robotics_analogue required"
38
+ anchors = pack.meta.benchmark_anchor
39
+ assert isinstance(anchors, list) and len(anchors) == 3, (
40
+ f"benchmark_anchor must list all 3 anchors, got {anchors!r}"
41
+ )
42
+ joined = " ".join(anchors).lower()
43
+ for needle in ("sc2", "feint", "cicero"):
44
+ assert needle in joined, f"missing anchor keyword: {needle}"
45
+ for lvl in ("easy", "medium", "hard"):
46
+ c = compile_level(pack, lvl)
47
+ assert c.map_supported
48
+ assert c.win_condition is not None
49
+ assert c.fail_condition is not None
50
+
51
+
52
+ def _ctx(*, units=(), tick=1000, lost=0, fact_destroyed=False):
53
+ """Synthesize a WinContext for predicate-level checks."""
54
+ import types
55
+
56
+ types_count = {"fact": 1} if fact_destroyed else {}
57
+ sig = types.SimpleNamespace(
58
+ game_tick=tick,
59
+ units_killed=0,
60
+ units_lost=lost,
61
+ cash=0,
62
+ resources=0,
63
+ own_buildings=[],
64
+ own_building_types=set(),
65
+ enemies_seen_ids=set(),
66
+ enemy_buildings_seen_ids=set(),
67
+ enemy_buildings_destroyed=1 if fact_destroyed else 0,
68
+ enemy_buildings_destroyed_types=types_count,
69
+ enemy_buildings_destroyed_records=(
70
+ [("fact", 80, 20)] if fact_destroyed else []
71
+ ),
72
+ )
73
+ return WinContext(
74
+ signals=sig,
75
+ render_state={"units_summary": list(units)},
76
+ )
77
+
78
+
79
+ def _alive(n):
80
+ return [
81
+ {"cell_x": 10, "cell_y": 20, "type": "2tnk", "id": str(1000 + i)}
82
+ for i in range(n)
83
+ ]
84
+
85
+
86
+ def test_easy_predicates():
87
+ c = compile_level(load_pack(PACK_PATH), "easy")
88
+ # Intended: fact destroyed, 1 unit lost (bait), in budget → WIN
89
+ assert evaluate(
90
+ c.win_condition,
91
+ _ctx(units=_alive(3), tick=3000, lost=1, fact_destroyed=True),
92
+ )
93
+ # Fact NOT destroyed → not a win even if in budget and few losses
94
+ assert not evaluate(
95
+ c.win_condition,
96
+ _ctx(units=_alive(3), tick=3000, lost=1, fact_destroyed=False),
97
+ )
98
+ # Loss cap (3) tripped → not a win
99
+ assert not evaluate(
100
+ c.win_condition,
101
+ _ctx(units=_alive(0), tick=3000, lost=4, fact_destroyed=True),
102
+ )
103
+ # Past deadline → not a win
104
+ assert not evaluate(
105
+ c.win_condition,
106
+ _ctx(units=_alive(3), tick=5401, lost=1, fact_destroyed=True),
107
+ )
108
+ # Timeout with bar unmet → fail (after_ticks 5401)
109
+ assert evaluate(
110
+ c.fail_condition,
111
+ _ctx(units=_alive(3), tick=5500, lost=1, fact_destroyed=False),
112
+ )
113
+ # Force-wipe → fail (not own_units_gte:1)
114
+ assert evaluate(
115
+ c.fail_condition,
116
+ _ctx(units=[], tick=3000, lost=4, fact_destroyed=False),
117
+ )
118
+ # Loss cap tripped (>3) → fail
119
+ assert evaluate(
120
+ c.fail_condition,
121
+ _ctx(units=_alive(0), tick=3000, lost=4, fact_destroyed=True),
122
+ )
123
+
124
+
125
+ def test_medium_predicates():
126
+ c = compile_level(load_pack(PACK_PATH), "medium")
127
+ # Intended: fact destroyed, ≤2 lost → WIN
128
+ assert evaluate(
129
+ c.win_condition,
130
+ _ctx(units=_alive(3), tick=3000, lost=2, fact_destroyed=True),
131
+ )
132
+ # 3 lost (one too many) → not a win, AND fail
133
+ assert not evaluate(
134
+ c.win_condition,
135
+ _ctx(units=_alive(2), tick=3000, lost=3, fact_destroyed=True),
136
+ )
137
+ assert evaluate(
138
+ c.fail_condition,
139
+ _ctx(units=_alive(2), tick=3000, lost=3, fact_destroyed=True),
140
+ )
141
+ # Timeout with fact intact → fail
142
+ assert evaluate(
143
+ c.fail_condition,
144
+ _ctx(units=_alive(4), tick=5500, lost=1, fact_destroyed=False),
145
+ )
146
+
147
+
148
+ def test_hard_predicates():
149
+ c = compile_level(load_pack(PACK_PATH), "hard")
150
+ # Intended: fact destroyed, ≤2 lost → WIN (cap is 2: bait + ≤1 tank)
151
+ assert evaluate(
152
+ c.win_condition,
153
+ _ctx(units=_alive(3), tick=3500, lost=2, fact_destroyed=True),
154
+ )
155
+ # 3 lost → not a win, AND fail
156
+ assert not evaluate(
157
+ c.win_condition,
158
+ _ctx(units=_alive(1), tick=3500, lost=3, fact_destroyed=True),
159
+ )
160
+ assert evaluate(
161
+ c.fail_condition,
162
+ _ctx(units=_alive(1), tick=3500, lost=3, fact_destroyed=True),
163
+ )
164
+
165
+
166
+ def test_timeout_reachable_inside_max_turns():
167
+ """No draw degeneracy: after_ticks 5401 ≤ 93 + 90·(max_turns-1)."""
168
+ pack = load_pack(PACK_PATH)
169
+ for lvl in ("easy", "medium", "hard"):
170
+ c = compile_level(pack, lvl)
171
+ max_tick = 93 + 90 * (c.max_turns - 1)
172
+ assert 5401 <= max_tick, (
173
+ f"{lvl}: after_ticks 5401 > max reachable tick {max_tick} "
174
+ f"(max_turns={c.max_turns}); deadline never bites"
175
+ )
176
+ assert 5400 <= max_tick, (
177
+ f"{lvl}: within_ticks 5400 > max tick {max_tick}"
178
+ )
179
+
180
+
181
+ def test_hard_has_two_spawn_point_groups():
182
+ """Hard-tier curation: ≥2 distinct agent spawn_point groups so the
183
+ seed round-robins the staging latitude (north y=10 / south y=30).
184
+ Engine-roundtrip is asserted by tests/test_hard_tier.py."""
185
+ c = compile_level(load_pack(PACK_PATH), "hard")
186
+ groups = {
187
+ (a.spawn_point if a.spawn_point is not None else 0)
188
+ for a in c.scenario.actors
189
+ if a.owner == "agent"
190
+ }
191
+ assert len(groups) >= 2, f"hard needs ≥2 spawn_point groups, got {groups}"
192
+
193
+
194
+ def test_guard_bot_and_e3_cluster():
195
+ """The enemy must be the `guard` scripted bot (leashed defender) and
196
+ the cluster must be e3 rocket-infantry (anti-tank Dragon, range 5,
197
+ damage 5000 vs Heavy) — these together make the bait-and-flank idiom
198
+ load-bearing (a hunter or patrol bot would not snap back, and an e1
199
+ cluster would not threaten the strike tanks)."""
200
+ pack = load_pack(PACK_PATH)
201
+ enemy = pack.base.get("enemy") if isinstance(pack.base, dict) else None
202
+ assert enemy is not None
203
+ bot = enemy.get("bot_type") or enemy.get("bot") or ""
204
+ assert bot == "guard", f"expected guard bot, got {bot!r}"
205
+ for lvl in ("easy", "medium", "hard"):
206
+ c = compile_level(pack, lvl)
207
+ types = [a.type for a in c.scenario.actors if a.owner == "enemy"]
208
+ assert "e3" in types, f"{lvl}: cluster must be e3 rocket infantry"
209
+ assert "fact" in types, f"{lvl}: objective must include enemy fact"
210
+
211
+
212
+ def test_objective_fact_and_sentinel_present():
213
+ """Two enemy `fact` per level: one objective fact (mid-map, ~80,20)
214
+ and one sentinel fact (far north ~120,4) keeping the episode alive
215
+ past objective-fact destruction so within_ticks / units_lost_lte
216
+ can evaluate on the terminal frame (the CLAUDE.md auto-terminate
217
+ footgun on MustBeDestroyed)."""
218
+ pack = load_pack(PACK_PATH)
219
+ for lvl in ("easy", "medium", "hard"):
220
+ c = compile_level(pack, lvl)
221
+ facts = [
222
+ (a.position[0], a.position[1])
223
+ for a in c.scenario.actors
224
+ if a.owner == "enemy" and a.type == "fact"
225
+ ]
226
+ assert len(facts) == 2, (
227
+ f"{lvl}: must have 2 enemy facts (objective + sentinel), "
228
+ f"got {len(facts)} at {facts}"
229
+ )
230
+ # One ~mid-map (objective), one far from that one (sentinel).
231
+ # Use the rough centre of the playable arena.
232
+ mid = [p for p in facts if 60 <= p[0] <= 100 and 14 <= p[1] <= 26]
233
+ far = [p for p in facts if not (60 <= p[0] <= 100 and 14 <= p[1] <= 26)]
234
+ assert len(mid) == 1 and len(far) == 1, (
235
+ f"{lvl}: expected 1 objective fact mid-map + 1 sentinel far, "
236
+ f"got mid={mid} far={far}"
237
+ )
238
+
239
+
240
+ def test_guard_count_per_level():
241
+ """Difficulty axis: easy 3 guards, medium 5, hard 6 — one new
242
+ controlled variable per tier (cluster density). The loss cap also
243
+ tightens (3 → 2 → 2 with the hard tier additionally adding spawn
244
+ variation)."""
245
+ pack = load_pack(PACK_PATH)
246
+ expected = {"easy": 3, "medium": 5, "hard": 6}
247
+ for lvl, want in expected.items():
248
+ c = compile_level(pack, lvl)
249
+ n_e3 = sum(
250
+ 1 for a in c.scenario.actors if a.owner == "enemy" and a.type == "e3"
251
+ )
252
+ assert n_e3 == want, f"{lvl}: expected {want} guards, got {n_e3}"
253
+
254
+
255
+ # ── engine-driven scripted policies ─────────────────────────────────
256
+
257
+
258
+ def _own_ids(rs):
259
+ return [str(u["id"]) for u in (rs.get("units_summary", []) or [])]
260
+
261
+
262
+ def _of_type(rs, want_types):
263
+ out = []
264
+ for u in rs.get("units_summary", []) or []:
265
+ if u.get("type") in want_types:
266
+ out.append(u)
267
+ return out
268
+
269
+
270
+ def _enemies_of_type(rs, want_types):
271
+ out = []
272
+ for e in (rs.get("enemy_summary") or []):
273
+ t = (e.get("type") or e.get("actor_type") or "").lower()
274
+ if t in want_types:
275
+ out.append(e)
276
+ return out
277
+
278
+
279
+ def _stall(rs, Command):
280
+ """Pure observe — fact never takes damage; clock runs out → LOSS."""
281
+ return [Command.observe()]
282
+
283
+
284
+ def _brute(rs, Command):
285
+ """Attack-move every unit straight east at the fact. The strike
286
+ column eats anti-tank fire from the whole guard cluster on
287
+ approach, trades 4 units (1 jeep + 3 tanks) before the fact
288
+ falls, trips the units_lost_lte cap → LOSS."""
289
+ cmds = []
290
+ for u in (rs.get("units_summary", []) or []):
291
+ cmds.append(Command.attack_move([str(u["id"])], 80, 20))
292
+ return cmds or [Command.observe()]
293
+
294
+
295
+ def _bait_only(rs, Command):
296
+ """Jeep flanks south (pulls a side of the guards off), strike
297
+ tanks stand still. The fact survives — clock runs out → LOSS.
298
+ Tests that the bait without the counter-attack does not score."""
299
+ cmds = []
300
+ for u in (rs.get("units_summary", []) or []):
301
+ if u.get("type") == "jeep":
302
+ jx, jy = u["cell_x"], u["cell_y"]
303
+ if jy >= 31:
304
+ cmds.append(Command.move_units([str(u["id"])], 80, 28))
305
+ else:
306
+ cmds.append(Command.move_units([str(u["id"])], 80, 33))
307
+ return cmds or [Command.observe()]
308
+
309
+
310
+ def _intended(rs, Command):
311
+ """Bait + counter-attack:
312
+ 1. Detect the spawn hemisphere from the agent unit centroid
313
+ (avg_y < 20 = north spawn).
314
+ 2. Bait the jeep into the MATCHING hemisphere flank, oscillating
315
+ on the far cell — this stays within GUARD_AGGRO (~16) of the
316
+ matching half of the guard cluster so they lunge off post,
317
+ but outside the e3 anti-tank Dragon range (5 cells) so the
318
+ jeep survives the kite.
319
+ 3. Strike: pick the guard NEAREST the tank centroid (the most
320
+ immediate threat: typically one of the matching-side guards
321
+ being pulled OUT of formation toward the bait, so engaging it
322
+ away from its post is a favourable 3-vs-1 trade per kill).
323
+ Cycle until the cluster is broken.
324
+ 4. Once no guards are in vision, attack-move onto the fact
325
+ coordinate. If a near fact is in vision, attack_unit it.
326
+ """
327
+ cmds = []
328
+ tanks = _of_type(rs, {"2tnk"})
329
+ jeeps = _of_type(rs, {"jeep"})
330
+ tank_ids = [str(t["id"]) for t in tanks]
331
+ if not (tanks or jeeps):
332
+ return [Command.observe()]
333
+
334
+ all_units = tanks + jeeps
335
+ avg_y = sum(u["cell_y"] for u in all_units) / len(all_units)
336
+ is_north = avg_y < 20
337
+
338
+ # Bait: oscillate at the matching-hemisphere flank to keep guards
339
+ # engaged without dying.
340
+ for j in jeeps:
341
+ jy = j["cell_y"]
342
+ if is_north:
343
+ target_y = 8 if jy > 9 else 12
344
+ else:
345
+ target_y = 33 if jy < 32 else 28
346
+ cmds.append(Command.move_units([str(j["id"])], 80, target_y))
347
+
348
+ if not tanks:
349
+ return cmds or [Command.observe()]
350
+
351
+ tx = sum(t["cell_x"] for t in tanks) / len(tanks)
352
+ ty = sum(t["cell_y"] for t in tanks) / len(tanks)
353
+
354
+ guards = _enemies_of_type(rs, {"e3"})
355
+ fact_id = None
356
+ for e in (rs.get("enemy_summary", []) or []):
357
+ et = (e.get("type") or "").lower()
358
+ if et == "fact" and e.get("cell_x", 200) < 100:
359
+ fact_id = str(e.get("id"))
360
+ break
361
+
362
+ if guards:
363
+ guards.sort(
364
+ key=lambda g: (g["cell_x"] - tx) ** 2 + (g["cell_y"] - ty) ** 2
365
+ )
366
+ cmds.append(Command.attack_unit(tank_ids, str(guards[0]["id"])))
367
+ elif fact_id is not None:
368
+ cmds.append(Command.attack_unit(tank_ids, fact_id))
369
+ else:
370
+ # Cluster broken but fact not yet in vision — push onto its
371
+ # known coordinate (it stays put as a building).
372
+ cmds.append(Command.attack_move(tank_ids, 80, 20))
373
+ return cmds or [Command.observe()]
374
+
375
+
376
+ @pytest.mark.parametrize("level", ["easy", "medium", "hard"])
377
+ @pytest.mark.parametrize("seed", [1, 2, 3, 4])
378
+ def test_intended_bait_then_strike_wins(level, seed):
379
+ pytest.importorskip("openra_train")
380
+ from openra_bench.eval_core import run_level
381
+
382
+ c = compile_level(load_pack(PACK_PATH), level)
383
+ r = run_level(c, _intended, seed=seed)
384
+ assert r.outcome == "win", (
385
+ f"{level} seed={seed}: intended bait-then-strike should WIN, "
386
+ f"got {r.outcome} after {r.turns} turns "
387
+ f"(lost={r.signals.units_lost}, "
388
+ f"bldgs_destroyed={r.signals.enemy_buildings_destroyed_types})"
389
+ )
390
+
391
+
392
+ @pytest.mark.parametrize("level", ["easy", "medium", "hard"])
393
+ @pytest.mark.parametrize("seed", [1, 2, 3, 4])
394
+ def test_stall_loses(level, seed):
395
+ pytest.importorskip("openra_train")
396
+ from openra_bench.eval_core import run_level
397
+
398
+ c = compile_level(load_pack(PACK_PATH), level)
399
+ r = run_level(c, _stall, seed=seed)
400
+ assert r.outcome == "loss", (
401
+ f"{level} seed={seed}: stall must be a real timeout LOSS "
402
+ f"(fact never takes damage), got {r.outcome}"
403
+ )
404
+
405
+
406
+ @pytest.mark.parametrize("level", ["easy", "medium", "hard"])
407
+ @pytest.mark.parametrize("seed", [1, 2, 3, 4])
408
+ def test_brute_frontal_loses(level, seed):
409
+ """Attack-move all units straight east at the fact must LOSE on
410
+ every tier — the e3 anti-tank Dragons (range 5, damage 5000 vs
411
+ Heavy armour) shred the column on approach, trading the whole
412
+ strike force (4 lost) before the fact can be destroyed; the
413
+ units_lost_lte cap fails."""
414
+ pytest.importorskip("openra_train")
415
+ from openra_bench.eval_core import run_level
416
+
417
+ c = compile_level(load_pack(PACK_PATH), level)
418
+ r = run_level(c, _brute, seed=seed)
419
+ assert r.outcome == "loss", (
420
+ f"{level} seed={seed}: brute frontal must LOSE "
421
+ f"(strike force traded), got {r.outcome} "
422
+ f"(lost={r.signals.units_lost}, "
423
+ f"bldgs={r.signals.enemy_buildings_destroyed_types})"
424
+ )
425
+
426
+
427
+ @pytest.mark.parametrize("level", ["easy", "medium", "hard"])
428
+ @pytest.mark.parametrize("seed", [1, 2, 3, 4])
429
+ def test_bait_only_loses(level, seed):
430
+ """Bait-only (jeep kites south, strike tanks stand still) must LOSE
431
+ on every tier — the bait displacement yields ZERO objective
432
+ progress; the fact survives and the clock runs out. Discriminates
433
+ "bait without counter-attack" from the intended "bait AND counter-
434
+ attack" idiom."""
435
+ pytest.importorskip("openra_train")
436
+ from openra_bench.eval_core import run_level
437
+
438
+ c = compile_level(load_pack(PACK_PATH), level)
439
+ r = run_level(c, _bait_only, seed=seed)
440
+ assert r.outcome == "loss", (
441
+ f"{level} seed={seed}: bait-only must LOSE (fact not destroyed), "
442
+ f"got {r.outcome} (bldgs={r.signals.enemy_buildings_destroyed_types})"
443
+ )
tests/test_hard_tier.py CHANGED
@@ -177,6 +177,14 @@ UPGRADED = [
177
  # always place and hit whichever latitude the agent occupies, so
178
  # a memorised pbox-lane placement cannot generalise across seeds.
179
  "tech-turtle-defensive-tech",
 
 
 
 
 
 
 
 
180
  ]
181
 
182
  # Consciously NOT spawn-varied, with the reason (keeps the curation
 
177
  # always place and hit whichever latitude the agent occupies, so
178
  # a memorised pbox-lane placement cannot generalise across seeds.
179
  "tech-turtle-defensive-tech",
180
+ # Wave-4 Group D reasoning pack — SC2 bait-micro / military feint-
181
+ # and-flank doctrine. Hard tier defines two agent spawn_point groups
182
+ # (NORTH staging y=10 / SOUTH staging y=30) round-robined by seed;
183
+ # 6 enemy guards are split into NW + SW half-clusters around the
184
+ # objective `fact` so the working bait-direction + counter-attack-
185
+ # target pair flips per seed and a single memorised bait line
186
+ # cannot generalise across seeds.
187
+ "combat-bait-counter-attack",
188
  ]
189
 
190
  # Consciously NOT spawn-varied, with the reason (keeps the curation