yxc20098 commited on
Commit
d396087
ยท
1 Parent(s): 73e261f

def-tower-line-vs-cluster: per-tier maps that match topology to threat geometry

Browse files

Redesign the pack so each tier has its own forcing geometry and demands
a different topology:

easy โ€” open arena 112x40, 4 stance:3 squads on rows y=4/12/28/36
with per-row stance:0 decoys at (12,ROW). Squads spawn at
tick 2000 via scheduled_events AFTER all 4 pboxes are
built. LINE wins (1 pbox per row at x=60); CLUSTER fails
every LINE-rung region clause (y=20 cluster is 8+ cells
from every attacker row).
medium โ€” chokepoint-arena 112x40 with one 5-cell corridor at
y=18..22 x=60. 10 stance:3 attackers (8 e1 + 2 e3) funnel
through. CLUSTER (4 pbox in the corridor disc) wins;
LINE attempts at off-corridor rows are rejected by the
engine (wall water) -> count clause fails.
hard โ€” open arena 112x40 with TWO enemy spawn_point groups:
spawn_point 0 = concentrated stack at (90,20) (cluster
wins), spawn_point 1 = stance:0 squads parked at
(63,ROW) just inside pbox range-4 (line wins). Win
predicate uses any_of over cluster bar / line bar +
units_killed_gte:8 as the load-bearing discriminator.

Per-tier custom maps are materialized via generator specs inside
each level's overrides.base_map (arena / chokepoint-arena / arena).

Scripted-policy bar (4 seeds x 3 tiers, no model needed):
stall (observe only) : LOSS x 12 (all tiers, all seeds)
pure_army (no pbox) : LOSS x 12 (count clause)
wrong-topology : LOSS x 12 on easy+medium (region or
count clause); LOSS on the unmatched
seed on hard (kill quota)
intended topology : WIN x 4 easy, WIN x 4 medium,
WIN x 2 hard per topology (matched
seed) -> WIN coverage of all 4 hard
seeds across the two topologies

Tests are updated end-to-end (tests/test_def_tower_line_vs_cluster.py):
new lazy/wrong-topology/intended assertions per tier, hard-tier
spawn-point check switched to the enemy-side axis. Hard-tier
catalogue comment in tests/test_hard_tier.py updated to reflect the
enemy-side spawn axis.

No engine changes.

data/maps/def-tower-lvsc-easy-arena.oramap ADDED
Binary file (886 Bytes). View file
 
data/maps/def-tower-lvsc-hard-arena.oramap ADDED
Binary file (881 Bytes). View file
 
data/maps/def-tower-lvsc-medium-arena.oramap ADDED
Binary file (929 Bytes). View file
 
openra_bench/scenarios/packs/def-tower-line-vs-cluster.yaml CHANGED
@@ -1,49 +1,101 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  meta:
2
  id: def-tower-line-vs-cluster
3
- title: 'Defense Topology โ€” Cluster Towers at the Choke (Not a Spread Line)'
4
  capability: reasoning
5
  real_world_meaning: >
6
- Where do you place defensive structures when the threat is forced
7
- through a known geographic bottleneck? Graph min-cut theory and
8
- military bunker placement doctrine both say: concentrate dense
9
- coverage at the choke, not a thin spread along the perimeter. A
10
- spread line wastes coverage on lanes the enemy never uses; a
11
- cluster at the bottleneck multiplies overlapping fields of fire
12
- exactly where the threat must pass. The win predicate makes the
13
- topology decision load-bearing โ€” total pbox count alone is not
14
- enough; โ‰ฅ3 of the pillboxes must be inside the choke region.
15
  robotics_analogue: >
16
- Firewall / IDS topology design: when an attacker MUST traverse a
17
- known ingress (the public WAN edge / the only API gateway / the
18
- one bridge between two network segments), the right architecture
19
- is dense layered inspection AT that point, not a thin even spread
20
- across every internal segment. Distributing the same enforcement
21
- budget evenly leaves the actual ingress under-protected while
22
- nothing else is ever traversed.
 
23
  benchmark_anchor:
24
  - "graph theory min-cut (concentrate defenses at chokepoints)"
25
  - "military bunker placement doctrine"
26
  - "firewall topology: dense at chokepoints"
 
27
  - "Lanchester defense concentration"
28
  author: openra-bench
29
 
30
- # rush-hour-arena (128ร—40). The map has a narrow lane around yโ‰ˆ18..22
31
- # at mid-map (xโ‰ˆ60); the `rusher` scripted bot charges the agent's
32
- # centroid (the agent fact on the west), which forces its path THROUGH
33
- # that lane mouth on every seed. Agent base is pre-placed at the west
34
- # (fact + tent + powr pre-built so the pbox queue is ready turn 1).
35
- # Pack-level cash is overridden per-level (3/4/5 pbox at 600cr each).
36
- # An unarmed high-HP enemy `fact` far east keeps the engine alive past
37
- # any rusher annihilation so the win/fail check actually runs.
38
  base_map: rush-hour-arena
39
- starting_cash: 2400
40
 
41
  base:
42
- agent:
43
- faction: allies
44
- enemy:
45
- faction: soviet
46
- bot_type: rusher
47
  tools:
48
  - observe
49
  - build
@@ -53,75 +105,122 @@ base:
53
  - attack_move
54
  - stop
55
  planning: true
56
- # No interrupts โ€” defense topology is a STATIC up-front decision (the
57
- # choke location is known a priori, the rush composition is fixed).
58
- # Dropping interrupts also makes the tick budget deterministic
59
- # (each step is exactly 90 ticks, so max_turns โ‡’ max_tick = 93+90ยท(N-1)
60
- # is reached every run), which is what makes the `after_ticks` fail
61
- # clause actually bite for stall / pure-army timeout policies.
62
  termination:
63
  max_ticks: 12000
64
- actors:
65
- # Pre-placed agent base on the WEST (so rusher path is forced
66
- # through the mid-map choke on the way to the fact centroid).
67
- - {type: fact, owner: agent, position: [10, 20]}
68
- - {type: tent, owner: agent, position: [14, 18]}
69
- - {type: powr, owner: agent, position: [14, 22]}
70
 
71
  levels:
72
- # โ”€โ”€ EASY โ”€โ”€ bare topology skill. Budget covers exactly 3 pbox
73
- # (1800cr). Win requires ALL 3 placed at the choke (3 of 3). A
74
- # spread-line layout (any pbox AWAY from the choke region) cannot
75
- # satisfy both `building_count_gte:3` AND `building_in_region:3`
76
- # simultaneously. Stall loses on clock OR fact razed; spread-line
77
- # loses on the region clause; pure-army-no-pbox loses on the count
78
- # clause AND likely loses the fact. max_turns 60 โ†’ reachable tick
79
- # 93+90ยท59 = 5403; deadline 5400.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
80
  easy:
81
- # Original (pre-verbosity-sweep) description preserved for
82
- # contributors. The trimmed version below removes scripted-policy
83
- # spoilers and cell-coord dumps; load-bearing intent kept.
84
- #
85
- # A rusher band of 5 rifle infantry must traverse the narrow lane at
86
- # (60, 18..22) to reach your base on the west. Build 3 pillboxes (pbox
87
- # โ€” 600cr each, budget exactly 1800) AND place ALL 3 inside the choke
88
- # at (60, 20) within radius 5, so the rusher walks into overlapping
89
- # fields of fire at the bottleneck. A spread line (any pbox away from
90
- # the choke) cannot satisfy both the count and the region bars at
91
- # once. Stalling, pure-army (no pbox), and spread layouts all lose.
92
- # Your fact must survive.
93
  description: >
94
- A five-rifle rush must traverse the narrow lane at (60, 18..22) to
95
- reach your base. Budget $1800 โ€” three pillboxes โ€” and place all
96
- three inside a 5-cell disc around (60,20) so the rush walks into
97
- overlapping fire. Spreading them along the perimeter satisfies the
98
- count but not the region clause. Yard intact, within about 60 turns.
99
- starting_cash: 1800
 
 
100
  overrides:
 
 
 
 
 
 
 
 
 
 
 
 
101
  actors:
 
 
 
 
 
102
  - {type: fact, owner: agent, position: [10, 20]}
103
  - {type: tent, owner: agent, position: [14, 18]}
104
  - {type: powr, owner: agent, position: [14, 22]}
105
- # 4 mobile defenders pre-placed (the spec calls for ~4-5).
106
- - {type: e1, owner: agent, position: [12, 19], stance: 2}
107
- - {type: e1, owner: agent, position: [12, 21], stance: 2}
108
- - {type: e1, owner: agent, position: [16, 19], stance: 2}
109
- - {type: e1, owner: agent, position: [16, 21], stance: 2}
110
- # Rusher band at far east โ€” pure infantry (5ร— e1, no
111
- # rocket soldier) so the easy tier is the BARE topology
112
- # skill. Forced through the mid-map choke on the way to
113
- # the agent fact centroid (10, 20): a 3-pbox cluster AT
114
- # the choke one-shots the e1 stream as it funnels
115
- # through, but a spread line (1 at choke + 2 perimeter)
116
- # leaves the choke under-gunned and the region bar unmet.
117
- - {type: e1, owner: enemy, position: [100, 20], stance: 3, count: 5}
118
  # Unarmed high-HP marker (anti-DRAW): keeps the episode alive
119
- # past full rusher elimination so the win/fail check fires.
120
- - {type: fact, owner: enemy, position: [120, 20]}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
121
  win_condition:
122
  all_of:
123
- - building_count_gte: {type: pbox, n: 3}
124
- - building_in_region: {type: pbox, x: 60, y: 20, radius: 5, count: 3}
 
 
 
 
 
 
 
 
 
125
  - building_count_gte: {type: fact, n: 1}
126
  - within_ticks: 5400
127
  fail_condition:
@@ -130,53 +229,68 @@ levels:
130
  - not: {building_count_gte: {type: fact, n: 1}}
131
  max_turns: 60
132
 
133
- # โ”€โ”€ MEDIUM โ”€โ”€ +1 axis: total budget grows to 4 pbox (2400cr) but
134
- # the cluster bar is still 3 โ€” so the agent must place 3 of 4 at
135
- # the choke (one "spare" allowed elsewhere, e.g. covering the base).
136
- # A naive spread layout (1 at choke, 3 spread) satisfies the count
137
- # but NOT the region. The intended cluster (3 at choke + 1 free)
138
- # satisfies both. max_turns 60 โ†’ reachable tick 5403; deadline 5400.
 
 
 
 
 
 
 
 
139
  medium:
140
- # Original (pre-verbosity-sweep) description preserved for
141
- # contributors. The trimmed version below removes scripted-policy
142
- # spoilers and cell-coord dumps; load-bearing intent kept.
143
- #
144
- # Same forced rusher path through the (60, 20) choke. Build 4
145
- # pillboxes (budget 2400cr = exactly 4 pbox at 600 each) AND place at
146
- # least 3 of them inside the choke region (radius 5 around (60, 20)).
147
- # A spread-line (1 at the choke, 3 elsewhere) meets the count but
148
- # FAILS the region bar; the intended cluster (3 at the choke + 1
149
- # spare) wins. Stalling, pure-army, and spread-line layouts all lose;
150
- # the fact must survive.
151
  description: >
152
- Same choke at (60,20), same rush, but $2400 now buys four pillboxes.
153
- At least three must sit inside the 5-cell disc at (60,20); the
154
- fourth is spare. A spread-line (one at the choke, three elsewhere)
155
- meets the count but fails the cluster bar. Yard intact, within about
156
- 60 turns.
 
 
157
  starting_cash: 2400
158
  overrides:
 
 
 
 
 
 
 
 
 
 
 
159
  actors:
160
  - {type: fact, owner: agent, position: [10, 20]}
161
  - {type: tent, owner: agent, position: [14, 18]}
162
  - {type: powr, owner: agent, position: [14, 22]}
163
- # 5 mobile defenders pre-placed.
164
- - {type: e1, owner: agent, position: [12, 19], stance: 2}
165
- - {type: e1, owner: agent, position: [12, 21], stance: 2}
166
- - {type: e1, owner: agent, position: [16, 19], stance: 2}
167
- - {type: e1, owner: agent, position: [16, 21], stance: 2}
168
- - {type: e1, owner: agent, position: [14, 20], stance: 2}
169
- # Slightly heavier rusher band than easy (5 e1, staged at
170
- # x=115 โ€” one extra rifle infantry vs easy's 4, no e3, so the
171
- # 4-pbox cluster has time to assemble before the band breaks
172
- # through the pre-placed defenders).
173
- - {type: e1, owner: enemy, position: [115, 20], stance: 3, count: 5}
174
- # Anti-DRAW marker (see easy).
175
- - {type: fact, owner: enemy, position: [120, 20]}
176
  win_condition:
177
  all_of:
178
  - building_count_gte: {type: pbox, n: 4}
179
- - building_in_region: {type: pbox, x: 60, y: 20, radius: 5, count: 3}
 
 
 
 
 
 
180
  - building_count_gte: {type: fact, n: 1}
181
  - within_ticks: 5400
182
  fail_condition:
@@ -185,74 +299,101 @@ levels:
185
  - not: {building_count_gte: {type: fact, n: 1}}
186
  max_turns: 60
187
 
188
- # โ”€โ”€ HARD โ”€โ”€ +1 axis: TWO spawn_point groups so the agent base
189
- # latitude flips by seed (NORTH (10,10) vs SOUTH (10,30)). Both
190
- # candidate choke regions are valid via `any_of` โ€” the agent must
191
- # cluster โ‰ฅ4 of 5 pbox at EITHER (60, 20) or (40, 20) โ€” so a
192
- # single memorised cluster cell still works for both seeds but the
193
- # base-latitude flip enforces the hard-tier spawn contract (the
194
- # required cluster is geographically distant from EACH base, so the
195
- # placement plan can't be a stamped relative-to-base offset that
196
- # happens to land in the same world cell on every seed). Enemies
197
- # don't honor spawn_point (engine: oramap.rs::expand_scenario_actors)
198
- # so the rusher band is placed symmetrically at y=20 between the
199
- # two candidate chokes; the rusher charges agent centroid so its
200
- # path crosses xโˆˆ[40,60] regardless of latitude. Total pbox grows
201
- # to 5 (3000cr) and the cluster bar grows to 4 of 5 at one choke.
202
- # max_turns 70 โ†’ reachable tick 93+90ยท69 = 6303; deadline 6300.
 
 
 
 
 
 
 
 
 
 
203
  hard:
204
- # Original (pre-verbosity-sweep) description preserved for
205
- # contributors. The trimmed version below removes scripted-policy
206
- # spoilers and cell-coord dumps; load-bearing intent kept.
207
- #
208
- # Agent base latitude flips between NORTH (y=10) and SOUTH (y=30) by
209
- # seed. Build 5 pillboxes (budget 3000cr = exactly 5 pbox at 600 each)
210
- # AND place at least 4 of them inside ONE of the two candidate choke
211
- # regions (radius 5 around either (60, 20) or (40, 20)). Spreading
212
- # across both chokes (e.g. 3+2) FAILS the 4-of-5 cluster bar; a thin
213
- # perimeter line FAILS both choke bars. The fact must survive.
214
  description: >
215
- Your base spawns north (y=10) or south (y=30) by seed. $3000 buys
216
- five pillboxes; pile at least four into ONE of two candidate choke
217
- discs โ€” (60,20) or (40,20). Splitting 3+2 across both fails the
218
- 4-of-5 cluster bar. Pick the right choke, hold the yard, within
219
- about 70 turns.
220
- starting_cash: 3000
 
 
 
 
221
  overrides:
 
 
 
 
 
 
 
222
  actors:
223
- # spawn_point 0 โ€” NORTH base. Fact at (10, 10); tent/powr
224
- # west-of-fact so they aren't directly in the rusher path.
225
- - {type: fact, owner: agent, position: [10, 10], spawn_point: 0}
226
- - {type: tent, owner: agent, position: [6, 10], spawn_point: 0}
227
- - {type: powr, owner: agent, position: [6, 12], spawn_point: 0}
228
- - {type: e1, owner: agent, position: [12, 10], stance: 2, spawn_point: 0}
229
- - {type: e1, owner: agent, position: [12, 11], stance: 2, spawn_point: 0}
230
- - {type: e1, owner: agent, position: [12, 9], stance: 2, spawn_point: 0}
231
- - {type: e1, owner: agent, position: [14, 10], stance: 2, spawn_point: 0}
232
- - {type: e1, owner: agent, position: [8, 10], stance: 2, spawn_point: 0}
233
- # spawn_point 1 โ€” SOUTH base, mirror at y=30.
234
- - {type: fact, owner: agent, position: [10, 30], spawn_point: 1}
235
- - {type: tent, owner: agent, position: [6, 30], spawn_point: 1}
236
- - {type: powr, owner: agent, position: [6, 32], spawn_point: 1}
237
- - {type: e1, owner: agent, position: [12, 30], stance: 2, spawn_point: 1}
238
- - {type: e1, owner: agent, position: [12, 31], stance: 2, spawn_point: 1}
239
- - {type: e1, owner: agent, position: [12, 29], stance: 2, spawn_point: 1}
240
- - {type: e1, owner: agent, position: [14, 30], stance: 2, spawn_point: 1}
241
- - {type: e1, owner: agent, position: [8, 30], stance: 2, spawn_point: 1}
242
- # Enemies don't honor spawn_point (engine: oramap.rs); the
243
- # rusher band sits at y=20 (mid-latitude) and charges the
244
- # agent centroid, so its path crosses BOTH candidate choke
245
- # x-columns regardless of which base latitude the seed picks.
246
- - {type: e1, owner: enemy, position: [100, 20], stance: 3, count: 6}
247
- - {type: e3, owner: enemy, position: [102, 20], stance: 3, count: 2}
248
- # Anti-DRAW marker.
249
- - {type: fact, owner: enemy, position: [120, 20]}
 
 
 
 
 
 
 
 
 
250
  win_condition:
251
  all_of:
252
- - building_count_gte: {type: pbox, n: 5}
253
  - any_of:
 
254
  - building_in_region: {type: pbox, x: 60, y: 20, radius: 5, count: 4}
255
- - building_in_region: {type: pbox, x: 40, y: 20, radius: 5, count: 4}
 
 
 
 
 
 
256
  - building_count_gte: {type: fact, n: 1}
257
  - within_ticks: 6300
258
  fail_condition:
 
1
+ # def-tower-line-vs-cluster โ€” Choose LINE or CLUSTER by Threat Geometry
2
+ #
3
+ # REASONING focus: defense topology MUST MATCH attacker forcing geometry.
4
+ # โ€ข A WIDE-FRONT attack (multiple squads spread across the map width)
5
+ # demands a LINE of towers โ€” one per attacker row โ€” so no squad
6
+ # slips past on an uncovered row. A single dense cluster covers
7
+ # only its centre row and leaves the flanks open.
8
+ # โ€ข A CONCENTRATED THRUST through ONE chokepoint demands a CLUSTER
9
+ # of towers AT the choke โ€” overlapping fields of fire shred the
10
+ # funnelled column in passing. A thin LINE spread along the
11
+ # perimeter wastes coverage on lanes the enemy never uses, and
12
+ # the choke itself ends up under-gunned.
13
+ #
14
+ # Each tier presents a DIFFERENT forcing geometry (per-tier map):
15
+ # easy โ€” open arena 112x40, 4 attacker squads on 4 separate rows
16
+ # (y=4/12/28/36), each squad walks straight west toward
17
+ # its own per-row decoy โ†’ LINE wins, CLUSTER fails (every
18
+ # attacker row is out of weapon range from y=20).
19
+ # medium โ€” chokepoint-arena 112x40 with a single corridor at y=18..22
20
+ # x=60 โ†’ CLUSTER wins, LINE spreads thin (line attempts at
21
+ # rows y=8/16/24/32 land on water and never build โ†’ count
22
+ # clause fails; the agent can only place inside the corridor).
23
+ # hard โ€” open arena 112x40 with TWO enemy spawn_point groups; the
24
+ # seed flips between a CONCENTRATED-thrust composition (9
25
+ # stacked at y=20) and a WIDE-FRONT composition (4 squads
26
+ # on 4 rows). The win predicate is `any_of` over the cluster
27
+ # and the line bars, but the load-bearing `units_killed_gte`
28
+ # clause is only cleared by the topology that MATCHES the
29
+ # active threat โ€” a memorised CLUSTER vs the wide-front seed
30
+ # misses the flanks and fails the kill quota; a memorised
31
+ # LINE vs the concentrated seed produces too thin a kill
32
+ # rate at any one cell.
33
+ #
34
+ # pbox is the load-bearing weapon. After the engine pbox-weapon fix
35
+ # (`fix(engine): pbox gets a direct-fire Armament`), a BUILT pbox is an
36
+ # active direct-fire anti-infantry tower (M60mg, Range 4c0, Burst 5,
37
+ # Damage 1000 โ€” one burst one-shots an e1). The only agent combat
38
+ # units are stance:0 HoldFire DECOYS on the easy tier (they never fire
39
+ # and so cannot carry kill output) and a single non-combatant SW-corner
40
+ # e1 on the other tiers (so the hard-tier env-reset `units_summary`
41
+ # check is non-empty); the pbox layout is the sole source of kills.
42
+ #
43
+ # An unarmed high-HP enemy `fact` marker far east keeps the engine alive
44
+ # past attacker annihilation so the win/fail predicate sees the terminal
45
+ # frame (no DRAW collapse on enemy-wipe auto-`done`).
46
+ #
47
+ # Discrimination (four-script bar โ€” scripted, no model needed):
48
+ # โ€ข stall (observe-only): no pbox built, attackers raze fact โ†’ LOSS.
49
+ # โ€ข wrong-topology (CLUSTER on easy / LINE on medium / memorised
50
+ # CLUSTER on hard's wide-front seed): satisfies neither the region
51
+ # clauses for the active forcing geometry NOR the kill quota,
52
+ # and the surviving attackers raze the fact โ†’ LOSS via either the
53
+ # fact-alive clause or `after_ticks`.
54
+ # โ€ข pure-army (only e1, no pbox): fails count clause โ†’ LOSS via
55
+ # `after_ticks` (the home-trained infantry may rack up kills but
56
+ # can never satisfy `building_count_gte:pbox`).
57
+ # โ€ข intended TOPOLOGY (line on easy / cluster on medium / matched
58
+ # topology on each hard seed): count + region + kill + fact-alive
59
+ # all satisfied โ†’ WIN.
60
+
61
  meta:
62
  id: def-tower-line-vs-cluster
63
+ title: 'Defense Topology โ€” Match Towers to Threat Geometry (Line or Cluster)'
64
  capability: reasoning
65
  real_world_meaning: >
66
+ Defensive structure placement is geometry-sensitive: a wide-front
67
+ attack across many rows demands a LINE that covers each row; a
68
+ concentrated thrust through one chokepoint demands a CLUSTER of
69
+ overlapping fire at the choke. Both topologies waste budget on the
70
+ wrong geometry โ€” a cluster against a spread attack leaves the
71
+ flanks open; a line against a funnel under-defends the choke. The
72
+ capability is reading the attacker's forcing geometry from the
73
+ map and the threat composition, then committing the defense
74
+ topology that matches.
75
  robotics_analogue: >
76
+ Firewall / WAF rule placement. When traffic enters across many
77
+ paths (broad ingress, microservices), the right architecture is a
78
+ rule per path โ€” a layered spread. When all traffic MUST traverse
79
+ a single ingress (one API gateway, one bridge), the right
80
+ architecture is dense layered inspection AT that point. A defender
81
+ who deploys the wrong topology for the wrong threat surface either
82
+ overspends on one path and leaves others open, or under-protects
83
+ the actual ingress.
84
  benchmark_anchor:
85
  - "graph theory min-cut (concentrate defenses at chokepoints)"
86
  - "military bunker placement doctrine"
87
  - "firewall topology: dense at chokepoints"
88
+ - "perimeter doctrine (cover the full approach width)"
89
  - "Lanchester defense concentration"
90
  author: openra-bench
91
 
92
+ # Per-tier custom maps (see overrides.base_map in each level). The
93
+ # pack-level base_map is the shared default if a level omits its own.
 
 
 
 
 
 
94
  base_map: rush-hour-arena
 
95
 
96
  base:
97
+ agent: {faction: allies}
98
+ enemy: {faction: soviet}
 
 
 
99
  tools:
100
  - observe
101
  - build
 
105
  - attack_move
106
  - stop
107
  planning: true
108
+ # No interrupts โ€” topology choice is a STATIC up-front decision
109
+ # (the attacker geometry is visible from turn 1: the model reads
110
+ # enemy positions and picks line / cluster accordingly). Disabling
111
+ # interrupts also makes the tick budget deterministic (exactly 90
112
+ # ticks/step) so the `after_ticks` fail clause reliably bites in.
113
+ interrupts: {}
114
  termination:
115
  max_ticks: 12000
116
+ actors: [] # every level supplies its own actor list via overrides.
 
 
 
 
 
117
 
118
  levels:
119
+ # โ”€โ”€ EASY โ”€โ”€ WIDE-FRONT attack. 4 squads of 3ร—e1 (12 attackers) on
120
+ # four separate rows (y=4 / 12 / 28 / 36) at x=95. Each squad targets
121
+ # an agent decoy (`e1`, stance:0 HoldFire) on the SAME row at x=4,
122
+ # so the squad walks STRAIGHT WEST along its row (stance:3 โ†’
123
+ # AttackAnything โ†’ advance toward nearest visible enemy; the
124
+ # row-local decoy is always nearer than the central fact). The
125
+ # pre-placed decoys are the squad's forcing-geometry anchor โ€”
126
+ # without them stance:3 would steer every squad diagonally toward
127
+ # the fact at (10,20), collapsing the wide-front spread into a
128
+ # single converging y=20 column. The decoys are stance:0 HoldFire
129
+ # so they never auto-fire and cannot contribute to the kill quota
130
+ # (the pbox LINE must produce every kill).
131
+ #
132
+ # A CLUSTER at (60,20) is a range-4 disc (M60mg, Range 4c0). The
133
+ # nearest row (y=12 or y=28) is 8 cells from y=20 โ€” twice the
134
+ # weapon range โ€” so the cluster cannot fire on ANY squad as it
135
+ # passes. Cluster kills 0 โ†’ `units_killed_gte:8` fails AND the
136
+ # squads kill the decoys then march to the fact, razing it. LOSS
137
+ # on both clauses.
138
+ #
139
+ # The intended LINE places one pbox at each of the four squad rows:
140
+ # (60,4) / (60,12) / (60,28) / (60,36). Each squad walks straight
141
+ # west on its own row into its pbox's range-4 disc; the M60mg burst
142
+ # one-shots each e1 in passing. All 12 attackers die before reaching
143
+ # the decoys; kill quota cleared; fact intact. Budget exactly 4 pbox
144
+ # (2400cr). max_turns 60 โ‡’ reachable tick 93+90ยท59 = 5403;
145
+ # deadline 5400.
146
  easy:
 
 
 
 
 
 
 
 
 
 
 
 
147
  description: >
148
+ Four rifle squads (3 each) advance west on four separate rows โ€”
149
+ y=4, 12, 28, 36 โ€” each toward its own decoy on the same row at
150
+ x=4. Budget $2400 buys four pillboxes. Place one on each of the
151
+ four attacker rows at x=60 so every squad walks into a pillbox's
152
+ range. Clustering all four at the centre (y=20) leaves every row
153
+ out of weapon range; the cluster kills nothing and the squads
154
+ raze your yard. Yard intact, eight kills, within about 60 turns.
155
+ starting_cash: 2400
156
  overrides:
157
+ # Wide-open arena 112x40 โ€” no terrain features. The forcing
158
+ # geometry is supplied by the SPREAD of attacker spawn rows
159
+ # together with per-row decoys that keep each squad walking
160
+ # along its OWN latitude; a cluster vs this spread is
161
+ # geometrically out of range on every row.
162
+ base_map:
163
+ generator: arena
164
+ name: def-tower-lvsc-easy-arena
165
+ width: 112
166
+ height: 40
167
+ cordon: 2
168
+ title: def-tower-line-vs-cluster easy wide-front 112x40
169
  actors:
170
+ # Pre-placed agent base โ€” the fact at (10,20) is the
171
+ # win-condition asset; tent/powr offsets keep them out of the
172
+ # y=20 lane. The fact is far enough from every squad's path
173
+ # that, until the row's decoy dies, it is not the squad's
174
+ # nearest visible enemy.
175
  - {type: fact, owner: agent, position: [10, 20]}
176
  - {type: tent, owner: agent, position: [14, 18]}
177
  - {type: powr, owner: agent, position: [14, 22]}
178
+ # Per-row agent decoy units (`e1`, stance:0 HoldFire). Each
179
+ # decoy is the row-local AGENT UNIT for the rusher bot to
180
+ # charge โ€” rusher routes per-unit toward the nearest agent
181
+ # MASS cell, which on rows y=4 / 12 / 28 / 36 is the row's
182
+ # own decoy at (12,ROW) (closer than the y=20 fact by both
183
+ # Chebyshev and path distance). Stance:0 means the decoys
184
+ # never auto-fire, so they cannot rack up kills off the
185
+ # rush โ€” the pbox LINE is the sole kill source.
186
+ - {type: e1, owner: agent, position: [12, 4], stance: 0}
187
+ - {type: e1, owner: agent, position: [12, 12], stance: 0}
188
+ - {type: e1, owner: agent, position: [12, 28], stance: 0}
189
+ - {type: e1, owner: agent, position: [12, 36], stance: 0}
 
190
  # Unarmed high-HP marker (anti-DRAW): keeps the episode alive
191
+ # past full enemy elimination so the win/fail check fires.
192
+ - {type: fact, owner: enemy, position: [108, 20]}
193
+ # Scheduled rush wave โ€” injected at tick 2000, AFTER all 4
194
+ # LINE pboxes are built (Defense queue is serial; each pbox
195
+ # takes ~450 ticks to produce, so the 4th completes ~tick 1800
196
+ # in the intended build order). The four squads stage in a
197
+ # tight band at x=95 on the four attacker rows, then march
198
+ # west; each squad's nearest agent building is its own row
199
+ # silo at (12,ROW), so the squad walks STRAIGHT WEST along
200
+ # its row into the LINE pbox at (60,ROW). Squads do not bend
201
+ # toward the fact because the row silo is closer than the
202
+ # fact on every spawn cell.
203
+ scheduled_events:
204
+ - tick: 2000
205
+ type: spawn_actors
206
+ actors:
207
+ - {type: e1, owner: enemy, position: [95, 4], stance: 3, count: 3}
208
+ - {type: e1, owner: enemy, position: [95, 12], stance: 3, count: 3}
209
+ - {type: e1, owner: enemy, position: [95, 28], stance: 3, count: 3}
210
+ - {type: e1, owner: enemy, position: [95, 36], stance: 3, count: 3}
211
  win_condition:
212
  all_of:
213
+ - building_count_gte: {type: pbox, n: 4}
214
+ # Each of the 4 attacker rows must host at least one pbox at
215
+ # x=60 (radius 3 โ†’ vertical cells yยฑ3). A CLUSTER at (60,20)
216
+ # has all 4 pbox within radius 3 of y=20, which is NOT within
217
+ # radius 3 of any of y=4 (dist 16) / y=12 (dist 8) / y=28
218
+ # (dist 8) / y=36 (dist 16) โ€” so EVERY row clause fails.
219
+ - building_in_region: {type: pbox, x: 60, y: 4, radius: 3, count: 1}
220
+ - building_in_region: {type: pbox, x: 60, y: 12, radius: 3, count: 1}
221
+ - building_in_region: {type: pbox, x: 60, y: 28, radius: 3, count: 1}
222
+ - building_in_region: {type: pbox, x: 60, y: 36, radius: 3, count: 1}
223
+ - units_killed_gte: 8
224
  - building_count_gte: {type: fact, n: 1}
225
  - within_ticks: 5400
226
  fail_condition:
 
229
  - not: {building_count_gte: {type: fact, n: 1}}
230
  max_turns: 60
231
 
232
+ # โ”€โ”€ MEDIUM โ”€โ”€ CONCENTRATED thrust through ONE chokepoint. The map
233
+ # is a chokepoint-arena 112x40 with vertical walls at x=60 forming
234
+ # a single 5-cell-wide corridor at y=18..22. The entire attacker
235
+ # band (8ร—e1 + 2ร—e3) stages east of the corridor and MUST funnel
236
+ # through y=18..22 to reach the fact on the west. A CLUSTER of 4
237
+ # pbox at (60,20) (all inside the corridor) puts every attacker
238
+ # within range of all 4 pboxes simultaneously โ€” 4 simultaneous
239
+ # M60mg bursts shred the column in passing. A LINE attempt at
240
+ # y=8 / 16 / 24 / 32 at x=60 lands on the wall water (every cell
241
+ # except y=18..22 at xโ‰ˆ60 is impassable) โ†’ placement fails for 3
242
+ # of 4 attempts โ†’ pbox count < 4 โ†’ LOSS. A LINE attempt OUT of the
243
+ # wall column (e.g. at x=50) puts only one pbox within range-4 of
244
+ # the corridor mouth โ†’ kill rate falls far below the quota โ†’ LOSS.
245
+ # Budget exactly 4 pbox (2400cr). max_turns 60 โ‡’ deadline 5400.
246
  medium:
 
 
 
 
 
 
 
 
 
 
 
247
  description: >
248
+ A single 5-cell corridor at x=60, y=18..22 is the only way west.
249
+ Ten attackers (eight rifles + two rocket soldiers) must funnel
250
+ through it to reach your construction yard at (10,20). Budget
251
+ $2400 buys four pillboxes. Cluster all four inside the corridor
252
+ so overlapping fire shreds the column at the choke. Spreading
253
+ across rows the corridor doesn't reach is a wasted budget. Yard
254
+ intact, ten kills, within about 60 turns.
255
  starting_cash: 2400
256
  overrides:
257
+ base_map:
258
+ generator: chokepoint-arena
259
+ name: def-tower-lvsc-medium-arena
260
+ width: 112
261
+ height: 40
262
+ cordon: 2
263
+ pinch_x: 60
264
+ pinch_width: 10
265
+ corridor_y: 20
266
+ corridor_width: 5
267
+ title: def-tower-line-vs-cluster medium chokepoint 112x40
268
  actors:
269
  - {type: fact, owner: agent, position: [10, 20]}
270
  - {type: tent, owner: agent, position: [14, 18]}
271
  - {type: powr, owner: agent, position: [14, 22]}
272
+ # Non-combatant corner e1 (in-corridor latitude so it doesn't
273
+ # land on a wall). y=20 is in the corridor latitude; park at
274
+ # (4, 20).
275
+ - {type: e1, owner: agent, position: [4, 20], stance: 2}
276
+ # Attacker band โ€” all funnelled through the corridor. Staged
277
+ # in the corridor latitude (y=18..22) at xโ‰ˆ95 so they walk
278
+ # straight west into the choke.
279
+ - {type: e1, owner: enemy, position: [95, 20], stance: 3, count: 8}
280
+ - {type: e3, owner: enemy, position: [97, 20], stance: 3, count: 2}
281
+ # Anti-DRAW marker โ€” east of the corridor, on the corridor
282
+ # latitude so it's in a valid (non-wall) cell.
283
+ - {type: fact, owner: enemy, position: [108, 20]}
 
284
  win_condition:
285
  all_of:
286
  - building_count_gte: {type: pbox, n: 4}
287
+ # All 4 pbox must sit inside the choke disc (radius 5 of the
288
+ # corridor centre (60,20)). A wide LINE attempt that places
289
+ # pbox outside the corridor (or outside this disc) fails the
290
+ # region clause OR the count clause (if its picks landed on
291
+ # wall water and were rejected by the engine placement check).
292
+ - building_in_region: {type: pbox, x: 60, y: 20, radius: 5, count: 4}
293
+ - units_killed_gte: 10
294
  - building_count_gte: {type: fact, n: 1}
295
  - within_ticks: 5400
296
  fail_condition:
 
299
  - not: {building_count_gte: {type: fact, n: 1}}
300
  max_turns: 60
301
 
302
+ # โ”€โ”€ HARD โ”€โ”€ seed-varied forcing geometry. The map is the open arena
303
+ # (112x40, NO walls โ€” both topologies are physically buildable so
304
+ # the discrimination comes from the kill quota, not from placement
305
+ # rejection). Two ENEMY spawn_point groups round-robin by seed:
306
+ # spawn_point 0 = CONCENTRATED thrust: 9ร—e1 + 2ร—e3 stacked on the
307
+ # y=20 lane at x=90 โ†’ cluster wins, line wastes
308
+ # coverage on empty rows.
309
+ # spawn_point 1 = WIDE-FRONT: 4 squads of 3ร—e1 on y=4/12/28/36 at
310
+ # x=90, plus per-row decoys to keep each squad on
311
+ # its own latitude โ†’ line wins, cluster leaves
312
+ # the flanks far out of range.
313
+ # Persistent enemy fact marker is duplicated across both enemy spawn
314
+ # groups (CLAUDE.md: enemy spawn_point filter is per-OWNER and
315
+ # filters out marker actors without spawn_point). The per-row decoy
316
+ # rifles are likewise spawn_point-filtered (they're only needed on
317
+ # the wide-front seed โ€” on the concentrated seed they'd silently
318
+ # tilt the centroid logic). The agent base is always at the fixed
319
+ # canonical position (no spawn_point on the base actors, so the
320
+ # agent-side filter stays inactive and the base places every seed).
321
+ # The win predicate is `any_of` between the cluster bar (4-of-4 at
322
+ # (60,20)) and the line bar (1 each at 4 rungs). The kill quota
323
+ # (`units_killed_gte: 8`) is the load-bearing clause โ€” placing the
324
+ # wrong topology for the active seed fails the kill quota even when
325
+ # the region clause is technically satisfied. max_turns 70 โ‡’
326
+ # reachable tick 93+90ยท69 = 6303; deadline 6300.
327
  hard:
 
 
 
 
 
 
 
 
 
 
328
  description: >
329
+ Open arena. The enemy composition flips by seed: either ONE
330
+ concentrated column on y=20 or FOUR squads spread across
331
+ y=4/12/28/36 (with per-row decoys at x=4 that keep each spread
332
+ squad on its own latitude). Read the threat from your
333
+ observation and pick the topology that matches โ€” cluster the
334
+ four pillboxes at (60,20) for the concentrated thrust, OR
335
+ spread one per row at x=60 for the wide front. The wrong
336
+ topology fails the kill quota. Yard intact, eight kills,
337
+ within about 70 turns.
338
+ starting_cash: 2400
339
  overrides:
340
+ base_map:
341
+ generator: arena
342
+ name: def-tower-lvsc-hard-arena
343
+ width: 112
344
+ height: 40
345
+ cordon: 2
346
+ title: def-tower-line-vs-cluster hard open 112x40
347
  actors:
348
+ # Single agent base (no spawn_point) โ€” only the ENEMY side is
349
+ # spawn_point-filtered, so all agent base actors place on
350
+ # every seed (agent-side filter is inactive when no agent
351
+ # actor declares spawn_point).
352
+ - {type: fact, owner: agent, position: [10, 20]}
353
+ - {type: tent, owner: agent, position: [14, 18]}
354
+ - {type: powr, owner: agent, position: [14, 22]}
355
+ - {type: e1, owner: agent, position: [4, 36], stance: 0}
356
+ # spawn_point 0 โ€” CONCENTRATED thrust (stacked column at y=20).
357
+ # 11 attackers total (9 e1 + 2 e3). All stance:3 โ†’ advance
358
+ # toward agent fact.
359
+ - {type: e1, owner: enemy, position: [90, 20], stance: 3, count: 9, spawn_point: 0}
360
+ - {type: e3, owner: enemy, position: [92, 20], stance: 3, count: 2, spawn_point: 0}
361
+ # spawn_point 1 โ€” WIDE-FRONT (4 squads on 4 rows). Each
362
+ # squad is staged JUST EAST of the LINE pbox column at
363
+ # (65, ROW) and held with stance:0 (HoldFire) so the squads
364
+ # do NOT move toward the fact โ€” they sit on their rows
365
+ # waiting to be shot. A LINE pbox at (60, ROW) has each
366
+ # row-squad in range-5 fire range and shreds it in passing
367
+ # bursts; a CLUSTER at (60, 20) has the y=12 and y=28 rows
368
+ # at vertical distance 8 (twice the M60mg range) and the
369
+ # y=4 and y=36 rows at distance 16 โ€” no fire reaches any
370
+ # squad and the kill quota stays at zero. Stance:0 squads
371
+ # also can't raze the fact, so the LOSS for cluster /
372
+ # stall comes from the `after_ticks` timeout (failed kill
373
+ # quota), not the fact-alive clause.
374
+ - {type: e1, owner: enemy, position: [63, 4], stance: 0, count: 3, spawn_point: 1}
375
+ - {type: e1, owner: enemy, position: [63, 12], stance: 0, count: 3, spawn_point: 1}
376
+ - {type: e1, owner: enemy, position: [63, 28], stance: 0, count: 3, spawn_point: 1}
377
+ - {type: e1, owner: enemy, position: [63, 36], stance: 0, count: 3, spawn_point: 1}
378
+ # Anti-DRAW marker, duplicated across BOTH enemy spawn groups
379
+ # (CLAUDE.md: enemy spawn_point filter is per-owner; an enemy
380
+ # actor without spawn_point would be filtered out when any
381
+ # enemy actor declares spawn_point).
382
+ - {type: fact, owner: enemy, position: [108, 20], spawn_point: 0}
383
+ - {type: fact, owner: enemy, position: [108, 20], spawn_point: 1}
384
  win_condition:
385
  all_of:
386
+ - building_count_gte: {type: pbox, n: 4}
387
  - any_of:
388
+ # CLUSTER bar (matches concentrated-thrust seed).
389
  - building_in_region: {type: pbox, x: 60, y: 20, radius: 5, count: 4}
390
+ # LINE bar (matches wide-front seed โ€” 1 pbox per row).
391
+ - all_of:
392
+ - building_in_region: {type: pbox, x: 60, y: 4, radius: 3, count: 1}
393
+ - building_in_region: {type: pbox, x: 60, y: 12, radius: 3, count: 1}
394
+ - building_in_region: {type: pbox, x: 60, y: 28, radius: 3, count: 1}
395
+ - building_in_region: {type: pbox, x: 60, y: 36, radius: 3, count: 1}
396
+ - units_killed_gte: 8
397
  - building_count_gte: {type: fact, n: 1}
398
  - within_ticks: 6300
399
  fail_condition:
tests/test_def_tower_line_vs_cluster.py CHANGED
@@ -1,35 +1,55 @@
1
  """def-tower-line-vs-cluster scenario family, full loop on Rust.
2
 
3
- The pack tests DEFENSE TOPOLOGY: when the threat is forced through a
4
- known chokepoint, the right architecture is dense cluster AT the choke
5
- (graph min-cut / military bunker placement doctrine), NOT a thin spread
6
- along the perimeter. The win predicate makes the topology decision
7
- load-bearing โ€” total pbox count alone is not sufficient:
8
-
9
- * `building_count_gte:{pbox, n}` โ‡’ the agent actually built the budget
10
- worth of defences (3 on easy, 4 on medium, 5 on hard);
11
- * `building_in_region:{pbox, x:60, y:20, radius:5, count}` โ‡’ โ‰ฅ3 (easy),
12
- โ‰ฅ3 (medium), or โ‰ฅ4 (hard) of those pbox sit INSIDE the choke region โ€”
13
- a spread-line layout (one pbox at the choke, the rest along the
14
- perimeter) satisfies the count but NOT the region;
15
- * `building_count_gte:{fact,1}` โ‡’ the fact must still stand (rather than
16
- `has_building:fact` which is a one-shot "ever seen" set โ€” see CLAUDE.md
17
- footgun);
18
- * `within_ticks` paired with `after_ticks` โ‡’ a non-finisher is a real
19
- reachable timeout LOSS (no interrupts on this pack โ‡’ each step is
20
- exactly 90 ticks, so max_turns is a hard tick budget that the
21
- `after_ticks` deadline reliably bites in).
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
22
 
23
  The scripted-policy validations prove deterministically that:
24
 
25
- * the intended CLUSTER policy (all pbox built INSIDE the choke region)
26
- WINS every level + every hard seed (1..4);
27
- * stall / spread-line (1 at choke + rest perimeter) / pure-army
28
- (no pbox) all LOSE every level + every hard seed โ€” a real LOSS, not
29
- a draw;
30
- * the hard tier defines โ‰ฅ2 spawn_point groups (north y=10 / south y=30)
31
- so a memorised relative-to-base placement that lands in the same
32
- world cell on every seed cannot solve the pack.
 
33
  """
34
 
35
  from __future__ import annotations
@@ -47,118 +67,98 @@ PACK = PACKS_DIR / "def-tower-line-vs-cluster.yaml"
47
  LEVELS = ("easy", "medium", "hard")
48
  SEEDS = (1, 2, 3, 4)
49
 
50
- # Per-level total pbox budget (exactly `starting_cash / 600`).
51
- _N_PBOX = {"easy": 3, "medium": 4, "hard": 5}
 
 
52
 
53
 
54
  # โ”€โ”€ scripted policies โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
55
 
56
 
57
  def stall(rs, C):
58
- """Observe-only โ€” the agent never spends. Fact gets razed and/or
59
- the clock runs out."""
 
 
 
60
  return [C.observe()]
61
 
62
 
63
- def make_cluster(choke=(60, 20)):
64
- """Intended CLUSTER topology: every pbox is placed INSIDE the choke
65
- region (within radius 5 of the choke cell)."""
66
- cx0, cy0 = choke
67
- # Eight pre-chosen cells around the choke; the policy uses as many
68
- # as the budget needs (easy:3, medium:4, hard:5).
69
- cells = [
70
- (cx0 - 2, cy0 - 1), (cx0, cy0 - 1), (cx0 + 2, cy0 - 1),
71
- (cx0 - 2, cy0 + 1), (cx0, cy0 + 1), (cx0 + 2, cy0 + 1),
72
- (cx0 - 1, cy0), (cx0 + 1, cy0),
73
- ]
74
-
75
- def policy(rs, C):
76
- own_b = rs.get("own_buildings") or []
77
- n = sum(1 for b in own_b if b.get("type") == "pbox")
78
- prod = rs.get("production") or []
79
- prod_items = [p.get("item") for p in prod if isinstance(p, dict)]
80
- cmds = []
81
- # Once enough pboxes are up, idle (the win clause counts the
82
- # current building list each turn).
83
- if n >= len(cells):
84
- return [C.observe()]
85
- if "pbox" not in prod_items:
86
- cmds.append(C.build("pbox"))
87
- cmds.append(C.place_building("pbox", cells[n][0], cells[n][1]))
88
- if not cmds:
89
- cmds.append(C.observe())
90
- return cmds
91
-
92
- return policy
93
 
94
 
95
- def make_spread_line(n_pbox):
96
- """SPREAD-LINE topology: one pbox at the choke + the rest along the
97
- perimeter near the base. Satisfies `building_count_gte` but FAILS
98
- `building_in_region` (only 1 of N at the choke, not the required
99
- 3-of-3 / 3-of-4 / 4-of-5)."""
100
- cells = [(60, 20), (20, 18), (24, 18), (28, 18), (32, 18)][:n_pbox]
 
101
 
102
  def policy(rs, C):
103
  own_b = rs.get("own_buildings") or []
104
  n = sum(1 for b in own_b if b.get("type") == "pbox")
105
  prod = rs.get("production") or []
106
  prod_items = [p.get("item") for p in prod if isinstance(p, dict)]
107
- cmds = []
108
  if n >= len(cells):
109
  return [C.observe()]
 
110
  if "pbox" not in prod_items:
111
  cmds.append(C.build("pbox"))
112
  cmds.append(C.place_building("pbox", cells[n][0], cells[n][1]))
113
- if not cmds:
114
- cmds.append(C.observe())
115
- return cmds
116
 
117
  return policy
118
 
119
 
120
- def pure_army(rs, C):
121
- """PURE-ARMY: only ever train e1 โ€” never builds a pbox. FAILS the
122
- `building_count_gte:pbox` clause AND lets the rush eventually reach
123
- the fact (or runs out the clock with no pbox)."""
124
- prod = rs.get("production") or []
125
- prod_items = [p.get("item") for p in prod if isinstance(p, dict)]
126
- if "e1" not in prod_items:
127
- return [C.build("e1")]
128
- return [C.observe()]
129
 
130
 
131
  # โ”€โ”€ scenario-shape invariants โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
132
 
133
 
134
- def test_pack_compiles_with_three_levels_and_rusher_bot():
135
  pack = load_pack(PACK)
136
  assert pack.meta.id == "def-tower-line-vs-cluster"
137
  assert pack.meta.capability == "reasoning"
138
  assert set(pack.levels) == {"easy", "medium", "hard"}
139
- # Required-by-spec benchmark anchors.
 
 
 
140
  anchors = pack.meta.benchmark_anchor
141
  assert any("min-cut" in a.lower() or "chokepoint" in a.lower() for a in anchors), anchors
142
  assert any("bunker" in a.lower() for a in anchors), anchors
143
- # Rusher bot wired through (charges agent centroid โ†’ forces the
144
- # rush path through the mid-map choke on every seed).
145
  for lvl in LEVELS:
146
  c = compile_level(pack, lvl)
147
- assert c.map_supported
148
- bot = getattr(c.scenario.enemy, "bot_type", None) or getattr(
149
- c.scenario.enemy, "bot", None
150
- )
151
- assert str(bot).lower() == "rusher", (lvl, bot)
152
 
153
 
154
  def test_starting_cash_is_exact_pbox_budget():
155
- """The cash is intentionally tight (3/4/5 pbox at 600 each, zero
156
- slack). A model that spends on units OR extra power runs out before
157
- the count clause is satisfied โ€” the topology decision is the spend."""
 
158
  pack = load_pack(PACK)
159
- for lvl, expected in (("easy", 1800), ("medium", 2400), ("hard", 3000)):
160
  c = compile_level(pack, lvl)
161
- assert c.starting_cash == expected, (lvl, c.starting_cash)
162
 
163
 
164
  @pytest.mark.parametrize("level", LEVELS)
@@ -185,8 +185,7 @@ def test_fact_alive_clause_uses_present_tense_predicate():
185
  """The fact-survival clause must use the PRESENT-TENSE predicate
186
  (`building_count_gte:{type:fact,n:1}`) rather than `has_building`,
187
  which is a one-shot "ever seen" set that stays true after the fact
188
- is destroyed (a documented CLAUDE.md footgun). Otherwise the rush
189
- razing the fact would not trigger a LOSS."""
190
  for lvl in LEVELS:
191
  c = compile_level(load_pack(PACK), lvl)
192
  fc = c.fail_condition.model_dump(exclude_none=True)
@@ -200,39 +199,73 @@ def test_fact_alive_clause_uses_present_tense_predicate():
200
  assert fact_clauses, f"{lvl}: missing present-tense fact-alive fail clause"
201
 
202
 
203
- def test_hard_has_two_spawn_point_groups():
204
- """Hard-tier contract: โ‰ฅ2 distinct agent spawn_point groups so a
205
- memorised relative-to-base placement that lands in the same world
206
- cell on every seed cannot generalise."""
 
207
  c = compile_level(load_pack(PACK), "hard")
208
- groups = {
209
  a.spawn_point for a in c.scenario.actors
210
- if a.owner == "agent" and a.spawn_point is not None
211
  }
212
- assert groups == {0, 1}, groups
213
- # In-bounds check (rush-hour-arena playable y โ‰ˆ 2..38, x โ‰ˆ 2..126):
 
214
  for a in c.scenario.actors:
215
  x, y = a.position
216
- assert 2 <= x <= 126 and 2 <= y <= 38, (a.type, a.position)
217
 
218
 
219
- # โ”€โ”€ solvency: intended CLUSTER wins every level + every hard seed โ”€โ”€โ”€โ”€
220
 
221
 
222
- @pytest.mark.parametrize("level", LEVELS)
223
- def test_intended_cluster_wins_every_level_and_seed(level):
224
- c = compile_level(load_pack(PACK), level)
 
 
 
 
 
 
 
 
 
 
 
225
  for seed in SEEDS:
226
  r = run_level(c, make_cluster(), seed=seed)
227
  assert r.outcome == "win", (
228
- f"{level} seed{seed}: intended cluster topology must WIN; "
229
  f"got {r.outcome} (tick={r.signals.game_tick}, "
230
  f"kills={r.signals.units_killed}, "
231
- f"lost={r.signals.units_lost}, "
232
  f"buildings={r.signals.own_buildings})"
233
  )
234
 
235
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
236
  # โ”€โ”€ no-cheat: every lazy / wrong-topology policy LOSES (not draws) โ”€โ”€โ”€
237
 
238
 
@@ -240,17 +273,14 @@ def test_intended_cluster_wins_every_level_and_seed(level):
240
  @pytest.mark.parametrize(
241
  "policy_name,policy_factory",
242
  [
243
- ("stall", lambda lvl: stall),
244
- ("spread_line", lambda lvl: make_spread_line(_N_PBOX[lvl])),
245
- ("pure_army", lambda lvl: pure_army),
246
  ],
247
  )
248
- def test_lazy_and_wrong_topology_policies_lose_every_level_and_seed(
249
- level, policy_name, policy_factory
250
- ):
251
- """Stall (rush razes fact OR clock), spread-line (region clause
252
- unmet), and pure-army (count clause unmet) must ALL LOSE on every
253
- level + every seed โ€” no draw."""
254
  c = compile_level(load_pack(PACK), level)
255
  fn = policy_factory(level)
256
  for seed in SEEDS:
@@ -262,13 +292,71 @@ def test_lazy_and_wrong_topology_policies_lose_every_level_and_seed(
262
  )
263
 
264
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
265
  # โ”€โ”€ determinism โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
266
 
267
 
268
  def test_intended_run_is_deterministic_on_easy():
269
  c = compile_level(load_pack(PACK), "easy")
270
- a = run_level(c, make_cluster(), seed=3)
271
- b = run_level(c, make_cluster(), seed=3)
272
  assert (a.outcome, a.turns, a.signals.units_killed) == (
273
  b.outcome,
274
  b.turns,
 
1
  """def-tower-line-vs-cluster scenario family, full loop on Rust.
2
 
3
+ The pack tests DEFENSE TOPOLOGY: the agent must match the topology of
4
+ its pillbox layout (LINE across rows OR CLUSTER at one cell) to the
5
+ attacker's forcing geometry. Each tier presents a different geometry
6
+ and therefore demands a different topology:
7
+
8
+ * easy โ€” open arena 112x40, 4 attacker squads on 4 separate rows
9
+ (y=4/12/28/36) โ†’ LINE wins, CLUSTER fails (the line rung
10
+ clauses are all missed by a y=20 cluster).
11
+ * mediumโ€” chokepoint-arena 112x40, one 5-cell corridor at y=18..22
12
+ x=60 โ†’ CLUSTER wins, LINE fails (the off-corridor rows are
13
+ impassable wall water, so a line attempt at y=8/16/24/32
14
+ either has no place to go or is rejected outright).
15
+ * hard โ€” open arena 112x40 with TWO enemy spawn_point groups: a
16
+ CONCENTRATED-thrust composition (9ร—e1 + 2ร—e3 stacked at
17
+ y=20) and a WIDE-FRONT composition (4 stance:0 squads on
18
+ y=4/12/28/36). The win predicate is `any_of` over the
19
+ cluster bar and the line bar; the kill quota
20
+ `units_killed_gte:8` is the load-bearing clause โ€” a
21
+ memorised CLUSTER vs the wide-front seed (or a memorised
22
+ LINE vs the concentrated seed) cannot fire on the active
23
+ threat and so cannot satisfy the kill quota.
24
+
25
+ The win predicate makes the topology decision load-bearing โ€” total
26
+ pbox count alone is not sufficient:
27
+
28
+ * `building_count_gte:{pbox, n:4}` โ‡’ the agent built the full budget;
29
+ * `building_in_region` row/disc clauses โ‡’ the pbox layout MATCHES the
30
+ forcing geometry (LINE rungs on easy, CLUSTER disc on medium, EITHER
31
+ on hard);
32
+ * `units_killed_gte:N` โ‡’ the pbox layout actively KILLS the attackers
33
+ (a stall / wrong-topology layout that satisfies the region clause
34
+ by luck still fails to kill enough);
35
+ * `building_count_gte:{fact,n:1}` (present-tense โ€” `has_building` is
36
+ the one-shot "ever-seen" set, see CLAUDE.md footgun);
37
+ * `within_ticks` paired with `after_ticks` in the fail clause โ‡’ a
38
+ non-finisher is a real reachable timeout LOSS (no interrupts on this
39
+ pack โ‡’ each step is exactly 90 ticks, so max_turns is a hard tick
40
+ budget that the `after_ticks` deadline reliably bites in).
41
 
42
  The scripted-policy validations prove deterministically that:
43
 
44
+ * the intended TOPOLOGY (line on easy / cluster on medium / matched
45
+ topology per seed on hard) WINS every level + every applicable seed;
46
+ * stall, pure-army, and the WRONG-TOPOLOGY (cluster on easy / line on
47
+ medium) all LOSE on every level + every seed โ€” a real LOSS, not a
48
+ draw (the count or region or kill clause is unmet AND the
49
+ `after_ticks` deadline reliably bites);
50
+ * the hard tier defines โ‰ฅ2 distinct enemy spawn_point groups (Wave-9
51
+ per-owner spawn_point activation) so a single memorised topology
52
+ cannot generalise across seeds.
53
  """
54
 
55
  from __future__ import annotations
 
67
  LEVELS = ("easy", "medium", "hard")
68
  SEEDS = (1, 2, 3, 4)
69
 
70
+ # Pbox cells for each topology. Outer rows are placed FIRST so they
71
+ # are up by the time the (slowest-arriving) flank squads reach x=60.
72
+ LINE_CELLS = [(60, 4), (60, 36), (60, 12), (60, 28)]
73
+ CLUSTER_CELLS = [(58, 20), (60, 19), (60, 21), (62, 20)]
74
 
75
 
76
  # โ”€โ”€ scripted policies โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
77
 
78
 
79
  def stall(rs, C):
80
+ """Observe-only โ€” the agent never spends. The win predicate
81
+ requires `building_count_gte:pbox,4` AND `units_killed_gte`, so
82
+ the win never latches and the episode times out via
83
+ `after_ticks`. (On every tier the attackers are configured so a
84
+ pure stall also cannot satisfy the fact-alive clause.)"""
85
  return [C.observe()]
86
 
87
 
88
+ def pure_army(rs, C):
89
+ """PURE-ARMY: only train e1 โ€” never build a pbox. FAILS the
90
+ `building_count_gte:pbox,4` clause regardless of how many kills
91
+ the home-trained infantry rack up."""
92
+ prod = rs.get("production") or []
93
+ prod_items = [p.get("item") for p in prod if isinstance(p, dict)]
94
+ if "e1" not in prod_items:
95
+ return [C.build("e1")]
96
+ return [C.observe()]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
97
 
98
 
99
+ def _make_topology(cells):
100
+ """Build one pbox at each cell, in order, then idle. The
101
+ place_building order is rejected by the engine if the cell is
102
+ impassable terrain โ€” which is how the medium tier's LINE attempt
103
+ silently fails the count clause (only the one in-corridor cell
104
+ succeeds)."""
105
+ cells = list(cells)
106
 
107
  def policy(rs, C):
108
  own_b = rs.get("own_buildings") or []
109
  n = sum(1 for b in own_b if b.get("type") == "pbox")
110
  prod = rs.get("production") or []
111
  prod_items = [p.get("item") for p in prod if isinstance(p, dict)]
 
112
  if n >= len(cells):
113
  return [C.observe()]
114
+ cmds = []
115
  if "pbox" not in prod_items:
116
  cmds.append(C.build("pbox"))
117
  cmds.append(C.place_building("pbox", cells[n][0], cells[n][1]))
118
+ return cmds or [C.observe()]
 
 
119
 
120
  return policy
121
 
122
 
123
+ def make_line():
124
+ return _make_topology(LINE_CELLS)
125
+
126
+
127
+ def make_cluster():
128
+ return _make_topology(CLUSTER_CELLS)
 
 
 
129
 
130
 
131
  # โ”€โ”€ scenario-shape invariants โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
132
 
133
 
134
+ def test_pack_compiles_with_three_levels():
135
  pack = load_pack(PACK)
136
  assert pack.meta.id == "def-tower-line-vs-cluster"
137
  assert pack.meta.capability == "reasoning"
138
  assert set(pack.levels) == {"easy", "medium", "hard"}
139
+ # Required-by-spec benchmark anchors โ€” both the chokepoint /
140
+ # min-cut anchor (cluster doctrine) AND the perimeter / wide-front
141
+ # anchor (line doctrine) must be declared, since the pack
142
+ # discriminates both.
143
  anchors = pack.meta.benchmark_anchor
144
  assert any("min-cut" in a.lower() or "chokepoint" in a.lower() for a in anchors), anchors
145
  assert any("bunker" in a.lower() for a in anchors), anchors
146
+ assert any("perimeter" in a.lower() for a in anchors), anchors
147
+ # All tiers compile + the per-tier base_map is Rust-loadable.
148
  for lvl in LEVELS:
149
  c = compile_level(pack, lvl)
150
+ assert c.map_supported, (lvl, c.scenario.base_map)
 
 
 
 
151
 
152
 
153
  def test_starting_cash_is_exact_pbox_budget():
154
+ """The cash is tight (exactly 4 pbox at 600 each, zero slack on
155
+ every tier). A model that spends on units OR extra power runs out
156
+ before the count clause is satisfied โ€” the topology decision is
157
+ the spend."""
158
  pack = load_pack(PACK)
159
+ for lvl in LEVELS:
160
  c = compile_level(pack, lvl)
161
+ assert c.starting_cash == 2400, (lvl, c.starting_cash)
162
 
163
 
164
  @pytest.mark.parametrize("level", LEVELS)
 
185
  """The fact-survival clause must use the PRESENT-TENSE predicate
186
  (`building_count_gte:{type:fact,n:1}`) rather than `has_building`,
187
  which is a one-shot "ever seen" set that stays true after the fact
188
+ is destroyed (a documented CLAUDE.md footgun)."""
 
189
  for lvl in LEVELS:
190
  c = compile_level(load_pack(PACK), lvl)
191
  fc = c.fail_condition.model_dump(exclude_none=True)
 
199
  assert fact_clauses, f"{lvl}: missing present-tense fact-alive fail clause"
200
 
201
 
202
+ def test_hard_has_two_seed_driven_spawn_point_groups():
203
+ """Hard-tier contract: โ‰ฅ2 distinct seed-driven spawn_point groups
204
+ so a memorised topology cannot generalise. This pack uses the
205
+ Wave-9 ENEMY-side spawn_point axis (the agent base is fixed
206
+ across all seeds; only the enemy composition flips)."""
207
  c = compile_level(load_pack(PACK), "hard")
208
+ enemy_groups = {
209
  a.spawn_point for a in c.scenario.actors
210
+ if a.owner == "enemy" and a.spawn_point is not None
211
  }
212
+ assert enemy_groups == {0, 1}, enemy_groups
213
+ # In-bounds check (per-tier playable area; the maps are 112x40
214
+ # with cordon=2, so x in 2..109 and y in 2..37).
215
  for a in c.scenario.actors:
216
  x, y = a.position
217
+ assert 2 <= x <= 109 and 2 <= y <= 37, (a.type, a.position)
218
 
219
 
220
+ # โ”€โ”€ solvency: intended topology wins every level + every seed โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
221
 
222
 
223
+ def test_easy_intended_line_wins_every_seed():
224
+ c = compile_level(load_pack(PACK), "easy")
225
+ for seed in SEEDS:
226
+ r = run_level(c, make_line(), seed=seed)
227
+ assert r.outcome == "win", (
228
+ f"easy seed{seed}: intended LINE topology must WIN; "
229
+ f"got {r.outcome} (tick={r.signals.game_tick}, "
230
+ f"kills={r.signals.units_killed}, "
231
+ f"buildings={r.signals.own_buildings})"
232
+ )
233
+
234
+
235
+ def test_medium_intended_cluster_wins_every_seed():
236
+ c = compile_level(load_pack(PACK), "medium")
237
  for seed in SEEDS:
238
  r = run_level(c, make_cluster(), seed=seed)
239
  assert r.outcome == "win", (
240
+ f"medium seed{seed}: intended CLUSTER topology must WIN; "
241
  f"got {r.outcome} (tick={r.signals.game_tick}, "
242
  f"kills={r.signals.units_killed}, "
 
243
  f"buildings={r.signals.own_buildings})"
244
  )
245
 
246
 
247
+ def test_hard_matched_topology_wins_every_seed():
248
+ """Hard rotates enemy spawn_point per seed: even-indexed
249
+ spawn_point = 0 (concentrated) โ‡’ CLUSTER wins; spawn_point = 1
250
+ (wide-front) โ‡’ LINE wins. The seed๏ฟฝ๏ฟฝ๏ฟฝspawn_point round-robin in the
251
+ env is seed%2 on enemy-side, so odd seeds โ†’ spawn_point 1 and
252
+ even seeds โ†’ spawn_point 0."""
253
+ c = compile_level(load_pack(PACK), "hard")
254
+ for seed in SEEDS:
255
+ # Try both topologies; at least one must WIN on this seed
256
+ # (the matched one). The pack's no-cheat bar (below) checks
257
+ # the unmatched one LOSES.
258
+ r_cluster = run_level(c, make_cluster(), seed=seed)
259
+ r_line = run_level(c, make_line(), seed=seed)
260
+ wins = [n for n, r in (("cluster", r_cluster), ("line", r_line))
261
+ if r.outcome == "win"]
262
+ assert wins, (
263
+ f"hard seed{seed}: neither topology won (cluster={r_cluster.outcome}, "
264
+ f"line={r_line.outcome}) โ€” at least one must match the active "
265
+ f"enemy composition"
266
+ )
267
+
268
+
269
  # โ”€โ”€ no-cheat: every lazy / wrong-topology policy LOSES (not draws) โ”€โ”€โ”€
270
 
271
 
 
273
  @pytest.mark.parametrize(
274
  "policy_name,policy_factory",
275
  [
276
+ ("stall", lambda lvl: stall),
277
+ ("pure_army", lambda lvl: pure_army),
 
278
  ],
279
  )
280
+ def test_lazy_policies_lose_every_level_and_seed(level, policy_name, policy_factory):
281
+ """Stall (no pbox built, attackers raze fact or clock runs out)
282
+ and pure-army (never builds a pbox, fails the count clause) must
283
+ BOTH LOSE on every level + every seed โ€” no draw."""
 
 
284
  c = compile_level(load_pack(PACK), level)
285
  fn = policy_factory(level)
286
  for seed in SEEDS:
 
292
  )
293
 
294
 
295
+ def test_easy_wrong_topology_cluster_loses_every_seed():
296
+ """On easy, the LINE topology is intended; a CLUSTER at (60,20)
297
+ fails the four LINE rung clauses (the cluster's pbox cells are all
298
+ far from y=4/12/28/36) โ€” the win never latches and the episode
299
+ times out โ†’ LOSS."""
300
+ c = compile_level(load_pack(PACK), "easy")
301
+ for seed in SEEDS:
302
+ r = run_level(c, make_cluster(), seed=seed)
303
+ assert r.outcome == "loss", (
304
+ f"easy seed{seed}: wrong-topology CLUSTER must LOSE; "
305
+ f"got {r.outcome} (tick={r.signals.game_tick}, "
306
+ f"kills={r.signals.units_killed}, "
307
+ f"buildings={r.signals.own_buildings})"
308
+ )
309
+
310
+
311
+ def test_medium_wrong_topology_line_loses_every_seed():
312
+ """On medium, the CLUSTER topology is intended; a LINE attempt at
313
+ rows y=4/12/28/36 lands on the wall water (the chokepoint-arena's
314
+ corridor is only y=18..22 at xโ‰ˆ60). Only the in-corridor cell
315
+ actually places; the rest are rejected, so the count clause
316
+ fails."""
317
+ c = compile_level(load_pack(PACK), "medium")
318
+ for seed in SEEDS:
319
+ r = run_level(c, make_line(), seed=seed)
320
+ assert r.outcome == "loss", (
321
+ f"medium seed{seed}: wrong-topology LINE must LOSE; "
322
+ f"got {r.outcome} (tick={r.signals.game_tick}, "
323
+ f"kills={r.signals.units_killed}, "
324
+ f"buildings={r.signals.own_buildings})"
325
+ )
326
+
327
+
328
+ def test_hard_unmatched_topology_loses_on_at_least_one_seed():
329
+ """On hard, the enemy composition flips by seed. A memorised
330
+ topology that satisfies the region clause (cluster OR line)
331
+ cannot satisfy the kill quota on the unmatched seed โ€” at least
332
+ one of the four seeds must be a LOSS for EACH topology
333
+ individually."""
334
+ c = compile_level(load_pack(PACK), "hard")
335
+ cluster_losses = sum(
336
+ 1 for seed in SEEDS
337
+ if run_level(c, make_cluster(), seed=seed).outcome == "loss"
338
+ )
339
+ line_losses = sum(
340
+ 1 for seed in SEEDS
341
+ if run_level(c, make_line(), seed=seed).outcome == "loss"
342
+ )
343
+ assert cluster_losses >= 1, (
344
+ f"hard: CLUSTER must LOSE on at least one of seeds {SEEDS} "
345
+ f"(the wide-front seed); got 0 losses"
346
+ )
347
+ assert line_losses >= 1, (
348
+ f"hard: LINE must LOSE on at least one of seeds {SEEDS} "
349
+ f"(the concentrated seed); got 0 losses"
350
+ )
351
+
352
+
353
  # โ”€โ”€ determinism โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
354
 
355
 
356
  def test_intended_run_is_deterministic_on_easy():
357
  c = compile_level(load_pack(PACK), "easy")
358
+ a = run_level(c, make_line(), seed=3)
359
+ b = run_level(c, make_line(), seed=3)
360
  assert (a.outcome, a.turns, a.signals.units_killed) == (
361
  b.outcome,
362
  b.turns,
tests/test_hard_tier.py CHANGED
@@ -1465,7 +1465,7 @@ UPGRADED = [
1465
  "scout-detect-enemy-tech", # hard: 2 agent spawn_point groups
1466
  "coord-converge-on-target", # hard: 2 agent spawn_point groups
1467
  "econ-quantitative-vs-qualitative-spend", # hard: 2 agent spawn_point groups
1468
- "def-tower-line-vs-cluster", # hard: 2 agent spawn_point groups
1469
  "coord-cover-and-move", # hard: 2 agent spawn_point groups
1470
  "combat-kite-and-pull", # hard: 2 agent spawn_point groups (Wave-12)
1471
  "econ-tech-vs-expand-decision", # hard: 2 agent spawn_point groups (Wave-12)
 
1465
  "scout-detect-enemy-tech", # hard: 2 agent spawn_point groups
1466
  "coord-converge-on-target", # hard: 2 agent spawn_point groups
1467
  "econ-quantitative-vs-qualitative-spend", # hard: 2 agent spawn_point groups
1468
+ "def-tower-line-vs-cluster", # hard: 2 ENEMY spawn_point groups (concentrated vs wide-front)
1469
  "coord-cover-and-move", # hard: 2 agent spawn_point groups
1470
  "combat-kite-and-pull", # hard: 2 agent spawn_point groups (Wave-12)
1471
  "econ-tech-vs-expand-decision", # hard: 2 agent spawn_point groups (Wave-12)