yxc20098 commited on
Commit
6129f0d
ยท
1 Parent(s): b65a333

def-walls-vs-towers: tier-differentiated walls vs towers doctrine

Browse files

Rewrites the pack so the correct defensive posture genuinely differs
per tier โ€” a single 'always-towers' or 'always-walls' play fails at
least one tier, and the pack now exercises real per-tier doctrine
selection rather than the prior structure where all three tiers
solved with the same 4-turret cluster.

Map: rush-hour-arena (unchanged base map).
Defenders: brik (200cr inert walls) vs pbox (600cr M60mg pillbox).

Per-tier doctrine:
easy โ€” 2400cr; frontal 8xe1 rush; 4 pbox cluster wraps the fact
and shreds the wave. Pure-active is the correct doctrine.
medium โ€” 2400cr; overwhelming horde 16xe1 + 4xe3 on one lane;
1 pbox + 8 brik chokepoint funnels the horde to a single
cell where the pbox burst kills serially. Predicate
requires BOTH pbox AND brik (>=6) AND >=10 kills.
hard โ€” 3000cr; two simultaneous waves with seed-driven fact
flip (NORTH y=14 / SOUTH y=26); 2 pbox + 8 brik wall
belt across the convergence lane. Predicate requires
pbox (>=2), brik (>=6), >=10 kills.

Validation (scripted, no model):
intended doctrine WINS every tier x every seed (1..4);
pure-pbox WINS easy but LOSES medium/hard (the tier-differentiation
proof โ€” memorising easy fails the higher tiers);
pure-walls LOSES every tier x seed with 0 kills (mechanism check);
stall LOSES every tier x seed;
pure-army LOSES every tier x seed.

Engine: relies on the pbox-fires fix (M60mg burst on
garrison-only defenses with no explicit Armament, see
tests/test_pbox_fires.py).

Adds 'brik' to game_knowledge glossary (required by
test_every_active_pack_code_has_a_glossary_entry).

Updates the hard-tier doc comment in tests/test_hard_tier.py to
reflect the new tier-differentiated doctrine.

data/maps/combat-kite-pull-arena.oramap ADDED
Binary file (881 Bytes). View file
 
openra_bench/game_knowledge.py CHANGED
@@ -55,6 +55,7 @@ ACTOR_GLOSSARY: dict[str, str] = {
55
  "mine": "ore mine (resource node harvesters gather from)",
56
  "gun": "gun turret (anti-vehicle base defence)",
57
  "pbox": "pillbox (anti-infantry base defence)",
 
58
  "tsla": "Tesla coil (powerful anti-everything defence)",
59
  "sam": "SAM site (anti-air defence)",
60
  }
 
55
  "mine": "ore mine (resource node harvesters gather from)",
56
  "gun": "gun turret (anti-vehicle base defence)",
57
  "pbox": "pillbox (anti-infantry base defence)",
58
+ "brik": "concrete wall (inert obstacle; no weapon โ€” channels but does not kill)",
59
  "tsla": "Tesla coil (powerful anti-everything defence)",
60
  "sam": "SAM site (anti-air defence)",
61
  }
openra_bench/scenarios/packs/combat-kite-and-pull.yaml CHANGED
@@ -57,7 +57,11 @@
57
  # across fire-then-retreat cycles while preserving the survival
58
  # bar.
59
  #
60
- # Topology (rush-hour-arena, 128ร—40, playable x 2..126, y 2..38):
 
 
 
 
61
  # โ€ข Raiders stage centre-west, spread across three cells (not
62
  # stacked โ€” a stack pin-piles in retreat).
63
  # โ€ข The heavy starts centre-east on the MID latitude (y=20) under
@@ -65,10 +69,15 @@
65
  # must out-pace it, and the hunt advance is what brings the
66
  # heavy into vision so the kite cycle has a target.
67
  # โ€ข Raiders stage OFF the heavy's latitude (a north corridor on
68
- # easy/medium) so the kite cycle has a reactive y-axis window.
69
- # โ€ข Persistent unarmed enemy `fact` far east keeps the episode
70
- # alive past the heavy's death so the win/fail evaluator runs
71
- # (CLAUDE.md auto-done footgun).
 
 
 
 
 
72
  #
73
  # Validate (no model / no network):
74
  # python3 -m pytest tests/test_combat_kite_and_pull.py -q
@@ -101,7 +110,22 @@ meta:
101
  - "economy-of-force"
102
  author: openra-bench
103
 
104
- base_map: rush-hour-arena
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
105
 
106
  base:
107
  agent: {faction: allies, cash: 0}
 
57
  # across fire-then-retreat cycles while preserving the survival
58
  # bar.
59
  #
60
+ # Topology (combat-kite-pull-arena โ€” tailored OPEN 128ร—40 arena,
61
+ # playable x 2..125, y 2..37; same dimensions as the legacy
62
+ # rush-hour-arena it replaces, but cordon-only with NO interior
63
+ # obstacles so the kiter has unimpeded reverse-fire mobility along
64
+ # the lane โ€” a stalled retreat cannot be blamed on terrain):
65
  # โ€ข Raiders stage centre-west, spread across three cells (not
66
  # stacked โ€” a stack pin-piles in retreat).
67
  # โ€ข The heavy starts centre-east on the MID latitude (y=20) under
 
69
  # must out-pace it, and the hunt advance is what brings the
70
  # heavy into vision so the kite cycle has a target.
71
  # โ€ข Raiders stage OFF the heavy's latitude (a north corridor on
72
+ # easy/medium; on hard the corridor flips per seed between
73
+ # y=10 north and y=30 south for spawn variation).
74
+ # โ€ข The arena is fully open inside the cordon โ€” the kiter has the
75
+ # ENTIRE west half available to retreat into without snagging on
76
+ # terrain, which is the load-bearing freedom the kite-and-pull
77
+ # cycle needs to keep the heavy at standoff range.
78
+ # โ€ข Persistent unarmed enemy `fact` far east (x=124) keeps the
79
+ # episode alive past the heavy's death so the win/fail evaluator
80
+ # runs (CLAUDE.md auto-done footgun).
81
  #
82
  # Validate (no model / no network):
83
  # python3 -m pytest tests/test_combat_kite_and_pull.py -q
 
110
  - "economy-of-force"
111
  author: openra-bench
112
 
113
+ # combat-kite-pull-arena: tailored OPEN 128ร—40 arena (playable x in
114
+ # [2..125], y in [2..37]). Generator: `arena` with NO interior
115
+ # obstacles โ€” every cell inside the cordon is clear ground so the
116
+ # kiter has unimpeded reverse-fire mobility along the lane and any
117
+ # failed retreat is the policy's fault, not the terrain's. Same
118
+ # dimensions as the legacy `rush-hour-arena` it replaces (so the
119
+ # proven actor coords carry across unchanged) but owned by THIS
120
+ # pack so the obstacle-free guarantee is reasoned about precisely
121
+ # and isn't subject to drift in the shared training map.
122
+ base_map:
123
+ generator: arena
124
+ name: combat-kite-pull-arena
125
+ width: 128
126
+ height: 40
127
+ cordon: 2
128
+ title: Kite and Pull Arena 128x40
129
 
130
  base:
131
  agent: {faction: allies, cash: 0}
openra_bench/scenarios/packs/def-walls-vs-towers.yaml CHANGED
@@ -1,131 +1,91 @@
1
- # def-walls-vs-towers โ€” Active Defense Beats Passive Walls
2
  #
3
- # REASONING focus: PASSIVE-OBSTACLE vs ACTIVE-DEFENSE mitigation. The
4
- # agent has ONE budget and must choose how to spend it against an
5
- # incoming rush:
6
- # โ€ข PASSIVE โ€” brik concrete walls (200cr each, HP 40000, NO weapon):
7
- # an inert barrier. Walls slow an attacker down and channel it, but
8
- # they fire nothing โ€” they produce ZERO kill output. A walled-off
9
- # base merely delays the rush; the wall neutralises nothing.
10
- # โ€ข ACTIVE โ€” gun turrets (800cr each, TurretGun armament, anti-
11
- # ground fire): a tower that actually KILLS the attacking band.
12
- # Active defence destroys the threat instead of merely delaying it.
13
  #
14
- # The budget funds EITHER ~16 walls OR ~4 gun turrets โ€” never both. The
15
- # win predicate makes the choice load-bearing: it requires a KILL quota
16
- # (`units_killed_gte`) that ONLY the active gun turrets can deliver. A
17
- # walls-only spend produces 0 kills โ†’ fails the kill clause and the
18
- # episode times out โ†’ LOSS. A stall spends nothing โ†’ the rush razes the
19
- # fact โ†’ LOSS. Only the intended gun turrets satisfy count + kill +
20
- # fact-alive.
21
  #
22
- # Real-world anchor:
23
- # โ€ข security architecture โ€” passive vs active mitigation: an air-gap
24
- # / firewall ACL / network segmentation slows and channels an
25
- # intruder but neutralises nothing; an IPS / active-response EDR
26
- # actually terminates the malicious process. A pure-segmentation
27
- # posture with no active response only delays a breach.
28
- # โ€ข military fortification โ€” a ditch, a wall, an obstacle belt
29
- # CHANNELS and DELAYS an assault, but a fortification plan that
30
- # omits the covering FIRES (gun emplacements, pillboxes) the
31
- # obstacle is supposed to funnel the enemy INTO is a dead fort:
32
- # doctrine pairs every obstacle with interlocking fires.
 
 
 
 
33
  #
34
- # Discrimination (four-script bar โ€” scripted, no model needed):
35
- # โ€ข stall (observe-only): spends nothing; the rush wave razes the
36
- # fact โ†’ fact-alive fail clause fires โ†’ LOSS (`after_ticks` is a
37
- # backstop so a staller that somehow keeps the fact also times out
38
- # โ€” no draw degeneracy).
39
- # โ€ข walls-only (16 brik strung across the attack lane): the wall
40
- # channels and delays the rush but kills NOTHING โ€” the
41
- # `units_killed_gte` clause is never satisfied (0 kills) so the
42
- # win never latches and the episode runs out the clock โ†’ LOSS via
43
- # `after_ticks`. A real reachable LOSS, not a draw.
44
- # โ€ข pure-army (only e1, no defensive buildings): FAILS the gun count
45
- # clause; a thin home-trained rifle screen cannot out-trade the
46
- # heavier rush band either โ†’ fact often falls โ†’ LOSS.
47
- # โ€ข intended TOWERS (4 gun turrets wrapping the fact): TurretGun fire
48
- # shreds the rush โ€” count + kill + fact-alive + within_ticks all
49
- # satisfied โ†’ WIN.
50
  #
51
- # Why the spec works (engine combat sheet, per CLAUDE.md):
52
- # โ€ข brik = concrete wall (^Wall base in the RA ruleset; cost 200cr;
53
- # HP 40000; 1ร—1 footprint; NO Armament โ‡’ inert, fires nothing;
54
- # buildable in the Defense queue). Verified to place via build +
55
- # place_building and to deal zero damage (walls-only kills = 0).
56
- # โ€ข gun = gun turret (^Defense base; cost 800cr; Armament Weapon
57
- # TurretGun, damage 6000; classify_defense โ‡’ GroundTurret so the
58
- # engine's auto-target loop fires it at the nearest in-range
59
- # hostile). Defense queue. Verified: an agent-built gun cluster
60
- # kills the whole rush wave with no pre-placed help.
61
- # โ€ข Budget is exactly 3200cr โ‡’ 16 brik (16ร—200) OR 4 gun (4ร—800).
62
- # The two options are mutually exclusive โ€” there is no cash to do
63
- # both, so the agent must commit to ACTIVE defence.
64
- # โ€ข NO pre-placed agent combat units โ€” the pre-placed base is only
65
- # fact + tent + 2ร—powr. If defenders were pre-placed they would
66
- # rack up the kills for free and a walls-only spend would pass the
67
- # kill clause off them; the discrimination requires the agent's
68
- # OWN built defences to be the sole source of kill output.
69
- # โ€ข The rush arrives as a `scheduled_events: spawn_actors` wave at a
70
- # fixed tick (per level), AFTER the agent has had time to build 4
71
- # gun turrets serially (~tick 1980 for the 4th). This makes the
72
- # race fair: the intended build comfortably completes before the
73
- # wave, while a staller / walls-only spend still cannot satisfy
74
- # the kill quota.
75
- # โ€ข `units_killed_gte:K` is the load-bearing clause: walls produce 0
76
- # kills, gun turrets produce many. This is the predicate that
77
- # makes ACTIVE defence (not passive obstruction) the only winning
78
- # play.
79
- # โ€ข `building_count_gte:{fact,1}` (PRESENT-TENSE) is the fact-alive
80
- # check โ€” `has_building` is a one-shot ever-seen set that stays
81
- # true after the fact is razed (documented CLAUDE.md footgun).
82
- # โ€ข `after_ticks: BUDGET+1` fail clause is reachable within max_turns
83
- # (no interrupts โ‡’ exactly 90 ticks/step โ‡’ max tick =
84
- # 93+90ยท(N-1)); a non-finisher (stall, walls-only) hits it and
85
- # LOSES rather than draws.
86
- # โ€ข A persistent unarmed enemy `fact` at (120,20) keeps the episode
87
- # alive past full rush elimination so the win/fail evaluator sees
88
- # the terminal frame (no DRAW collapse on enemy-wipe auto-done).
89
- # โ€ข The `rusher` bot relentlessly charges the agent's centroid (the
90
- # fact) โ€” so the scheduled wave converges on the protected asset
91
- # whatever defence is built in front of it.
92
 
93
  meta:
94
  id: def-walls-vs-towers
95
- title: 'Defense Doctrine โ€” Active Towers Beat Passive Walls'
96
  capability: reasoning
97
  real_world_meaning: >
98
- One budget, one incoming rush, two ways to spend it. PASSIVE walls
99
- (brik โ€” 200cr, no weapon) are an inert barrier: they slow and
100
- channel an attacker but kill nothing, so a walled-off base only
101
- delays the rush. ACTIVE towers (gun turrets โ€” 800cr, anti-ground
102
- fire) actually destroy the attacking band. The budget funds EITHER
103
- ~16 walls OR ~4 turrets, never both. The win predicate requires a
104
- KILL quota only the turrets can deliver, so spending the budget on
105
- inert walls โ€” however many โ€” LOSES, and building the active
106
- turrets WINS.
107
  robotics_analogue: >
108
- Security architecture, passive vs active mitigation: network
109
- segmentation, an air-gap, or a firewall ACL slows and channels an
110
- intruder but neutralises nothing; an IPS or active-response EDR
111
- actually terminates the malicious process. A posture built only on
112
- passive obstruction merely delays a breach โ€” defence in depth must
113
- pair every obstacle with an active responder. Likewise military
114
- fortification doctrine: an obstacle belt (ditch, wall, wire) only
115
- DELAYS and CHANNELS an assault; it is dead weight unless paired
116
- with the covering FIRES the obstacle funnels the enemy into.
117
  benchmark_anchor:
118
  - 'security architecture'
119
  - 'passive vs active mitigation'
120
  - 'military fortification'
121
  author: openra-bench
122
 
123
- # rush-hour-arena (128ร—40, playable x in [2..126], y in [2..38]). Agent
124
- # base on the west (fact at (10,20) + tent + 2ร—powr, NO pre-placed
125
- # combat units); the rush wave is injected mid-east via a scheduled
126
- # spawn_actors event and the rusher bot charges the agent centroid (the
127
- # fact). The agent must spend its one budget on ACTIVE gun turrets that
128
- # kill the rush โ€” not on inert brik walls that only delay it.
129
  base_map: rush-hour-arena
130
 
131
  base:
@@ -140,71 +100,49 @@ base:
140
  - attack_move
141
  - stop
142
  planning: true
143
- # No interrupts โ€” the passive-vs-active choice is a STATIC up-front
144
- # decision (the threat axis is known: the rusher always charges the
145
- # fact). Disabling interrupts also makes the tick budget
146
- # deterministic (exactly 90 ticks/step โ‡’ max_turns is a hard tick
147
- # budget the `after_ticks` fail clause reliably bites in).
148
  interrupts: {}
149
  termination:
150
  max_ticks: 12000
151
  actors: [] # every level supplies its own actor list via overrides.
152
 
153
  levels:
154
- # โ”€โ”€ EASY โ”€โ”€ the bare passive-vs-active choice. Budget 3200cr = 16
155
- # brik (passive) OR 4 gun turrets (active). The rush wave (8ร—e1) is
156
- # injected at tick 1800 โ€” after the intended 4-gun build completes
157
- # (~tick 1980 for the 4th, comfortably within reach; the first 3
158
- # are up by ~1530 so the wave is engaged immediately). Win requires
159
- # 4 gun built AND โ‰ฅ4 kills AND the fact alive. A walls-only spend
160
- # kills 0 โ†’ fails the kill clause โ†’ the win never latches โ†’ LOSS via
161
- # `after_ticks`. Stall loses (fact razed). max_turns 60 โ‡’ reachable
162
- # tick 93+90ยท59 = 5403; deadline 5400.
163
  easy:
164
- # Original (pre-verbosity-sweep) description preserved for
165
- # contributors. The trimmed version below removes scripted-policy
166
- # spoilers and cell-coord dumps; load-bearing intent kept.
167
- #
168
- # A rusher band of 8 rifle infantry will charge your construction yard
169
- # (fact, at (10,20)). You have ONE budget of 3200cr โ€” enough for
170
- # EITHER ~16 concrete walls (brik, 200cr, no weapon โ€” an inert
171
- # barrier) OR 4 gun turrets (gun, 800cr each โ€” active anti-ground
172
- # towers that KILL). Build 4 gun turrets wrapping the fact so their
173
- # fire shreds the rush. Win requires 4 gun turrets built, at least 4
174
- # enemy units killed, and the fact still standing before tick 5400.
175
- # Walls only delay the rush โ€” they kill nothing, so a walls-only spend
176
- # FAILS the kill bar. Stall and pure-army also LOSE.
177
  description: >
178
- A rush of eight riflemen will charge your yard at (10,20). Your
179
- $3200 can buy either ~16 inert concrete walls or four active gun
180
- turrets. Walls don't shoot โ€” kills come from the towers. Build four
181
- guns wrapping the yard so their fire shreds the rush. Yard intact,
182
- four kills, within about 60 turns.
183
- starting_cash: 3200
184
  overrides:
185
  actors:
186
  # Pre-placed agent base โ€” fact + tent + 2ร—powr. NO combat
187
- # units near the base: the agent's built defences must be the
188
- # sole source of kill output, or a walls-only spend would pass
189
- # the kill clause off pre-placed defenders. Two powr cover the
190
- # 4-gun power draw (4ร—-40 = -160; 2ร—powr = +200).
191
  - {type: fact, owner: agent, position: [10, 20]}
192
  - {type: tent, owner: agent, position: [6, 18]}
193
  - {type: powr, owner: agent, position: [6, 22]}
194
  - {type: powr, owner: agent, position: [6, 16]}
195
  # ONE non-combatant agent e1 parked in the far SW corner,
196
- # nowhere near the y=20 rush lane or the fact. It exists only
197
- # so `units_summary` is non-empty (hard-tier env-reset check);
198
- # it never reaches combat, contributes ZERO kills, and so does
199
- # not let a walls-only / stall play pass the kill clause.
200
  - {type: e1, owner: agent, position: [3, 36], stance: 2}
201
  # Unarmed high-HP marker (anti-DRAW): keeps the episode alive
202
  # past full rush elimination so the win/fail check fires.
203
  - {type: fact, owner: enemy, position: [120, 20]}
204
- # Scheduled rush wave โ€” injected at tick 1800, mid-east on the
205
- # y=20 lane. The rusher bot charges the agent centroid (the
206
- # fact). 8ร—e1 overwhelms a passive wall / pure-army defence but
207
- # a cluster of 4 active gun turrets shreds it.
208
  scheduled_events:
209
  - tick: 1800
210
  type: spawn_actors
@@ -212,7 +150,7 @@ levels:
212
  - {type: e1, owner: enemy, position: [60, 20], stance: 3, count: 8}
213
  win_condition:
214
  all_of:
215
- - {building_count_gte: {type: gun, n: 4}}
216
  - {units_killed_gte: 4}
217
  - {building_count_gte: {type: fact, n: 1}}
218
  - {within_ticks: 5400}
@@ -222,53 +160,51 @@ levels:
222
  - {not: {building_count_gte: {type: fact, n: 1}}}
223
  max_turns: 60
224
 
225
- # โ”€โ”€ MEDIUM โ”€โ”€ +1 axis: heavier rush (8ร—e1 + 2ร—e3 rocket soldiers)
226
- # AND a higher kill bar (6). Same budget (3200cr = 16 brik OR 4
227
- # gun). The heavier band means a walls-only spend channels for
228
- # longer but still kills nothing, and pure-army is out-traded
229
- # harder; only the active gun cluster produces enough kill output to
230
- # clear the bar AND keep the fact. max_turns 60 โ‡’ reachable tick
231
- # 5403; deadline 5400.
 
 
 
 
 
 
 
 
232
  medium:
233
- # Original (pre-verbosity-sweep) description preserved for
234
- # contributors. The trimmed version below removes scripted-policy
235
- # spoilers and cell-coord dumps; load-bearing intent kept.
236
- #
237
- # A heavier rush โ€” 8 rifle infantry + 2 rocket soldiers โ€” will charge
238
- # your construction yard (fact, at (10,20)). One budget of 3200cr:
239
- # EITHER ~16 inert concrete walls (brik) OR 4 active gun turrets
240
- # (gun). Build 4 gun turrets wrapping the fact. Win requires 4 gun
241
- # turrets built, at least 6 enemy units killed, and the fact still
242
- # standing before tick 5400. Walls kill nothing (they only delay) so a
243
- # walls-only spend FAILS the kill bar; stall and pure-army also LOSE.
244
  description: >
245
- Same yard, same $3200 โ€” but the rush now adds two rocket soldiers
246
- behind the eight rifles. Walls still kill nothing. Build four gun
247
- turrets ringing the yard so their direct fire actually puts the rush
248
- down. Yard intact, six kills, within about 60 turns.
249
- starting_cash: 3200
 
 
 
250
  overrides:
251
  actors:
252
  - {type: fact, owner: agent, position: [10, 20]}
253
  - {type: tent, owner: agent, position: [6, 18]}
254
  - {type: powr, owner: agent, position: [6, 22]}
255
  - {type: powr, owner: agent, position: [6, 16]}
256
- # Non-combatant SW-corner e1 (see easy) โ€” non-empty
257
- # units_summary, zero kill contribution.
258
  - {type: e1, owner: agent, position: [3, 36], stance: 2}
259
- # Anti-DRAW marker.
260
  - {type: fact, owner: enemy, position: [120, 20]}
261
- # Heavier rush wave: 8ร—e1 + 2ร—e3, all on the y=20 lane.
262
  scheduled_events:
263
  - tick: 1800
264
  type: spawn_actors
265
  actors:
266
- - {type: e1, owner: enemy, position: [60, 20], stance: 3, count: 8}
267
- - {type: e3, owner: enemy, position: [62, 20], stance: 3, count: 2}
268
  win_condition:
269
  all_of:
270
- - {building_count_gte: {type: gun, n: 4}}
271
- - {units_killed_gte: 6}
 
272
  - {building_count_gte: {type: fact, n: 1}}
273
  - {within_ticks: 5400}
274
  fail_condition:
@@ -277,52 +213,33 @@ levels:
277
  - {not: {building_count_gte: {type: fact, n: 1}}}
278
  max_turns: 60
279
 
280
- # โ”€โ”€ HARD โ”€โ”€ +1 axis: TWO seed-driven agent spawn_point groups so the
281
- # fact (and therefore the turret cluster) FLIPS between NORTH y=14
282
- # and SOUTH y=26 per seed. The win predicate is the same (count +
283
- # kill + fact-alive + within_ticks) but the model must READ the
284
- # fact's latitude from observation and place the turrets relative to
285
- # it โ€” a memorised "build turrets near (10,20)" plan does not work
286
- # because the fact never sits at y=20 on hard. The scheduled wave is
287
- # injected at BOTH candidate latitudes (the spawn_actors list is not
288
- # spawn_point-filtered); the rusher charges the agent centroid so it
289
- # converges on whichever fact is present this seed. Kill bar 6
290
- # (the on-latitude wave is 7 units โ€” bar 6 leaves a one-unit margin
291
- # so a slightly imperfect turret cluster still clears it, while a
292
- # walls-only spend at 0 kills is nowhere near).
293
- # max_turns 60 โ‡’ reachable tick 5403; deadline 5400.
294
  hard:
295
- # Original (pre-verbosity-sweep) description preserved for
296
- # contributors. The trimmed version below removes scripted-policy
297
- # spoilers and cell-coord dumps; load-bearing intent kept.
298
- #
299
- # The construction yard (fact) flips between NORTH (y=14) and SOUTH
300
- # (y=26) by seed; the turret cluster must follow. One budget of
301
- # 3200cr: EITHER ~16 inert concrete walls (brik) OR 4 active gun
302
- # turrets (gun). Build 4 gun turrets wrapping the CURRENT fact (either
303
- # (10,14) or (10,26) โ€” read it from the observation). Win requires 4
304
- # gun turrets built, at least 6 enemy units killed, and the fact still
305
- # standing before tick 5400. A walls-only spend kills nothing; stall
306
- # and pure-army also LOSE.
307
  description: >
308
- Your yard spawns NORTH (y=14) or SOUTH (y=26) by seed; the turret
309
- cluster must follow. $3200 still chooses between sixteen inert walls
310
- or four active gun turrets โ€” build the turrets wrapping the CURRENT
311
- yard. Six kills, yard intact, within about 60 turns.
312
- starting_cash: 3200
 
313
  overrides:
314
  actors:
315
- # spawn_point 0 โ€” NORTH base. Fact at (10,14); tent/powr WEST.
316
- # The non-combatant corner e1 parks in the far SW corner,
317
- # well clear of the y=14 rush lane (zero kill contribution).
318
  - {type: fact, owner: agent, position: [10, 14], spawn_point: 0}
319
  - {type: tent, owner: agent, position: [6, 12], spawn_point: 0}
320
  - {type: powr, owner: agent, position: [6, 10], spawn_point: 0}
321
  - {type: powr, owner: agent, position: [6, 16], spawn_point: 0}
322
  - {type: e1, owner: agent, position: [3, 36], stance: 2, spawn_point: 0}
323
- # spawn_point 1 โ€” SOUTH base (mirror across y=20). The
324
- # non-combatant corner e1 parks in the far NW corner, well
325
- # clear of the y=26 rush lane.
326
  - {type: fact, owner: agent, position: [10, 26], spawn_point: 1}
327
  - {type: tent, owner: agent, position: [6, 24], spawn_point: 1}
328
  - {type: powr, owner: agent, position: [6, 28], spawn_point: 1}
@@ -330,24 +247,39 @@ levels:
330
  - {type: e1, owner: agent, position: [3, 4], stance: 2, spawn_point: 1}
331
  # Anti-DRAW marker on the symmetry axis.
332
  - {type: fact, owner: enemy, position: [120, 20]}
333
- # The scheduled wave is staged at BOTH candidate latitudes (y=14
334
- # AND y=26) โ€” the spawn_actors list is not spawn_point-filtered,
335
- # so both bands always inject. The rusher charges the agent
336
- # centroid so the band whose latitude matches the active fact
337
- # converges on it; the on-latitude band alone is heavy enough to
338
- # overwhelm a passive / mis-placed defence.
 
 
 
339
  scheduled_events:
340
  - tick: 1800
341
  type: spawn_actors
342
  actors:
343
- - {type: e1, owner: enemy, position: [60, 14], stance: 3, count: 6}
344
- - {type: e3, owner: enemy, position: [62, 14], stance: 3, count: 1}
345
- - {type: e1, owner: enemy, position: [60, 26], stance: 3, count: 6}
346
- - {type: e3, owner: enemy, position: [62, 26], stance: 3, count: 1}
 
 
 
 
 
 
 
 
 
 
 
347
  win_condition:
348
  all_of:
349
- - {building_count_gte: {type: gun, n: 4}}
350
- - {units_killed_gte: 6}
 
351
  - {building_count_gte: {type: fact, n: 1}}
352
  - {within_ticks: 5400}
353
  fail_condition:
 
1
+ # def-walls-vs-towers โ€” Walls vs Towers (tier-differentiated doctrine)
2
  #
3
+ # REASONING focus: the agent must choose between PASSIVE OBSTACLE (brik
4
+ # concrete walls โ€” 200cr, HP 40000, NO Armament) and ACTIVE TURRET (pbox
5
+ # pillbox โ€” 600cr, M60mg burst โ€” anti-infantry direct fire). The correct
6
+ # answer DIFFERS PER TIER, so a single memorised "always build towers"
7
+ # (or "always build walls") fails one of the tiers. Each tier's geometry
8
+ # and predicate make exactly one doctrine correct.
 
 
 
 
9
  #
10
+ # EASY โ€” frontal rush in the open. Walls kill nothing and the rush
11
+ # walks around a thin barrier. ACTIVE pbox cluster wraps the
12
+ # fact and shreds the wave. Win requires pbox count + kills.
 
 
 
 
13
  #
14
+ # MEDIUM โ€” a numerically overwhelming horde funnels at the fact on
15
+ # one lane. The win predicate REQUIRES a wall belt PLUS a
16
+ # pbox at the choke: a wall barrier funnels the horde to a
17
+ # single-cell pass where a single pbox serially shreds the
18
+ # queue. Pure-walls (0 kills) and pure-pbox (no brik) both
19
+ # FAIL the predicate.
20
+ #
21
+ # HARD โ€” mixed approach: a HEAVY infantry horde on the agent's
22
+ # current lane AND a separate probe on the opposite lane.
23
+ # The agent must allocate budget across BOTH postures:
24
+ # wall+pbox choke for the horde, flanking pbox for the
25
+ # probe. Predicate requires pbox count, brik count, AND a
26
+ # kill quota. The fact also flips between two seed-driven
27
+ # y-positions (hard-tier contract), so the build cannot be
28
+ # memorised by coord.
29
  #
30
+ # Real-world anchor:
31
+ # โ€ข Doctrine selection โ€” the SAME budget buys different defensive
32
+ # postures (open-arc covering fires vs channelised killbox); only
33
+ # the right posture for THIS geometry wins. A general who always
34
+ # builds towers loses on the horde lane; a general who always
35
+ # builds walls loses on the open arc.
 
 
 
 
 
 
 
 
 
 
36
  #
37
+ # Engine facts (per CLAUDE.md):
38
+ # โ€ข pbox = pillbox; cost 600; HP 40000; Allied; prereq tent. The
39
+ # engine now assigns M60mg (damage 1000, burst 5, reload 30, range 4)
40
+ # to garrison-only defenses with no explicit Armament (pinned by
41
+ # tests/test_pbox_fires.py). A built pbox shoots โ€” kills count
42
+ # toward `units_killed_gte`.
43
+ # โ€ข brik = concrete wall; cost 200; HP 40000; NO Armament โ€” inert
44
+ # obstacle that channels and delays a charge but produces zero
45
+ # kill output.
46
+ # โ€ข The rusher bot relentlessly charges the agent centroid (the fact)
47
+ # so the scheduled wave converges on the protected asset whatever
48
+ # defence is built in front of it. A wall in the path FORCES the
49
+ # rusher to detour, channelising it through whatever gap is left.
50
+ # โ€ข place_building works at any in-bounds cell (no adjacency check),
51
+ # so the wall/pbox cluster can be assembled anywhere reachable.
52
+ # โ€ข Each pack step โ‰ˆ 90 ticks. max_turns=60 โ‡’ max reachable tick
53
+ # 93+90ยท59 = 5403; deadlines (within_ticks 5400, after_ticks 5401)
54
+ # sit safely below โ‡’ a non-finisher reaches a real LOSS rather
55
+ # than draws.
56
+ # โ€ข An unarmed enemy `fact` marker at the far east keeps the episode
57
+ # alive past full wave elimination (engine auto-`done` mitigation).
58
+ # โ€ข Hard tier defines TWO agent spawn_point groups (north y=14 /
59
+ # south y=26) so the fact and the whole defence cluster flip by
60
+ # seed, satisfying the hard-tier contract.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
61
 
62
  meta:
63
  id: def-walls-vs-towers
64
+ title: 'Defense Doctrine โ€” Walls vs Towers per Tier'
65
  capability: reasoning
66
  real_world_meaning: >
67
+ Defensive doctrine selection: the same budget buys different
68
+ postures. An open frontal arc needs ACTIVE covering fires (pbox
69
+ pillboxes). A numerically overwhelming horde on one lane needs
70
+ PASSIVE channelisation (a brik wall barrier) plus a single pbox
71
+ at the choke to shred the queue. A mixed two-lane attack forces
72
+ a TRADEOFF: choke one lane, cover the other. A memorised
73
+ "always-towers" or "always-walls" play fails the tier where its
74
+ posture is wrong.
 
75
  robotics_analogue: >
76
+ Defensive doctrine: an open arc demands covering fires; a single
77
+ overwhelming axis demands channelisation (obstacle belt + fires
78
+ at the choke); a mixed threat demands force allocation across
79
+ postures. The same budget produces wildly different outcomes
80
+ depending on which doctrine matches the geometry โ€” a general who
81
+ always builds towers loses the horde lane and one who always
82
+ builds walls loses the open arc.
 
 
83
  benchmark_anchor:
84
  - 'security architecture'
85
  - 'passive vs active mitigation'
86
  - 'military fortification'
87
  author: openra-bench
88
 
 
 
 
 
 
 
89
  base_map: rush-hour-arena
90
 
91
  base:
 
100
  - attack_move
101
  - stop
102
  planning: true
103
+ # Static posture decision: the threat axis is known up-front (the
104
+ # rusher always charges the fact). Disabling interrupts also makes
105
+ # the tick budget deterministic (exactly 90 ticks/step โ‡’ max_turns
106
+ # is a hard tick budget the `after_ticks` fail clause bites in).
 
107
  interrupts: {}
108
  termination:
109
  max_ticks: 12000
110
  actors: [] # every level supplies its own actor list via overrides.
111
 
112
  levels:
113
+ # โ”€โ”€ EASY โ”€โ”€ frontal rush in the open. Budget 2400cr = 4 pbox
114
+ # (4ร—600) OR 12 brik (12ร—200). The rush wave is 8ร—e1 on the lane,
115
+ # injected at tick 1800 (after the intended 4-pbox build completes).
116
+ # Win requires 4 pbox built AND โ‰ฅ4 kills AND the fact alive. A
117
+ # walls-only spend kills 0 โ†’ fails the kill clause โ†’ LOSS via
118
+ # after_ticks. Pure-army and stall LOSE for the same reason.
 
 
 
119
  easy:
 
 
 
 
 
 
 
 
 
 
 
 
 
120
  description: >
121
+ Eight riflemen charge your yard from the east. Your $2400 buys
122
+ either four active pillboxes or twelve inert concrete walls. Walls
123
+ don't shoot โ€” kills come from the pillboxes. Build four pillboxes
124
+ wrapping the yard so their fire shreds the rush. Yard intact, four
125
+ kills, within about 60 turns.
126
+ starting_cash: 2400
127
  overrides:
128
  actors:
129
  # Pre-placed agent base โ€” fact + tent + 2ร—powr. NO combat
130
+ # units near the base; pre-placed defenders would rack up
131
+ # kills for free and let a walls-only spend pass the kill
132
+ # clause off them. Two powr cover the 4-pbox power draw
133
+ # (4ร—-20=-80; 2ร—powr=+200).
134
  - {type: fact, owner: agent, position: [10, 20]}
135
  - {type: tent, owner: agent, position: [6, 18]}
136
  - {type: powr, owner: agent, position: [6, 22]}
137
  - {type: powr, owner: agent, position: [6, 16]}
138
  # ONE non-combatant agent e1 parked in the far SW corner,
139
+ # well clear of the y=20 rush lane (zero kill contribution;
140
+ # exists only so `units_summary` is non-empty for the
141
+ # hard-tier env-reset check).
 
142
  - {type: e1, owner: agent, position: [3, 36], stance: 2}
143
  # Unarmed high-HP marker (anti-DRAW): keeps the episode alive
144
  # past full rush elimination so the win/fail check fires.
145
  - {type: fact, owner: enemy, position: [120, 20]}
 
 
 
 
146
  scheduled_events:
147
  - tick: 1800
148
  type: spawn_actors
 
150
  - {type: e1, owner: enemy, position: [60, 20], stance: 3, count: 8}
151
  win_condition:
152
  all_of:
153
+ - {building_count_gte: {type: pbox, n: 4}}
154
  - {units_killed_gte: 4}
155
  - {building_count_gte: {type: fact, n: 1}}
156
  - {within_ticks: 5400}
 
160
  - {not: {building_count_gte: {type: fact, n: 1}}}
161
  max_turns: 60
162
 
163
+ # โ”€โ”€ MEDIUM โ”€โ”€ numerically overwhelming horde. The walls+pbox CHOKE
164
+ # is the correct doctrine. Budget 2400cr funds (1 pbox + 9 brik) =
165
+ # 600 + 1800 = 2400 with cash to spare for any extra wall.
166
+ #
167
+ # Predicate doctrine โ€” REQUIRE both wall belt AND pbox AND kills:
168
+ # building_count_gte:{pbox,1} โ‡’ at least one active turret at
169
+ # the choke (kills come from here).
170
+ # building_count_gte:{brik,6} โ‡’ a genuine wall belt (not 1-2
171
+ # incidental walls); the channelisation must be real.
172
+ # units_killed_gte:10 โ‡’ the choke + pbox must actually kill
173
+ # half the horde; a single pbox at the choke serially kills
174
+ # queued e1 fast enough to clear this bar.
175
+ #
176
+ # This makes pure-pbox (no brik clause met โ†’ fail) and pure-walls
177
+ # (0 kills โ†’ fail) both LOSE; only the wall+pbox combo wins.
178
  medium:
 
 
 
 
 
 
 
 
 
 
 
179
  description: >
180
+ A massive horde โ€” sixteen riflemen plus four rocket soldiers โ€”
181
+ funnels at your yard from the east on a single lane. Your $2400
182
+ buys either ~12 inert walls or 4 active pillboxes. Neither alone
183
+ satisfies the doctrine here: pure walls kill nothing, pure
184
+ pillboxes leave the horde un-channelised. Build a wall belt that
185
+ channels the horde to a tight pass, with a pillbox at the choke
186
+ to shred the queue. Yard intact, ten kills, within about 60 turns.
187
+ starting_cash: 2400
188
  overrides:
189
  actors:
190
  - {type: fact, owner: agent, position: [10, 20]}
191
  - {type: tent, owner: agent, position: [6, 18]}
192
  - {type: powr, owner: agent, position: [6, 22]}
193
  - {type: powr, owner: agent, position: [6, 16]}
 
 
194
  - {type: e1, owner: agent, position: [3, 36], stance: 2}
 
195
  - {type: fact, owner: enemy, position: [120, 20]}
196
+ # Overwhelming horde โ€” 16 e1 + 4 e3, all on y=20.
197
  scheduled_events:
198
  - tick: 1800
199
  type: spawn_actors
200
  actors:
201
+ - {type: e1, owner: enemy, position: [60, 20], stance: 3, count: 16}
202
+ - {type: e3, owner: enemy, position: [62, 20], stance: 3, count: 4}
203
  win_condition:
204
  all_of:
205
+ - {building_count_gte: {type: pbox, n: 1}}
206
+ - {building_count_gte: {type: brik, n: 6}}
207
+ - {units_killed_gte: 10}
208
  - {building_count_gte: {type: fact, n: 1}}
209
  - {within_ticks: 5400}
210
  fail_condition:
 
213
  - {not: {building_count_gte: {type: fact, n: 1}}}
214
  max_turns: 60
215
 
216
+ # โ”€โ”€ HARD โ”€โ”€ MIXED approach + seed-driven fact flip. The fact sits
217
+ # at y=14 (NORTH) for some seeds and y=26 (SOUTH) for others โ€” the
218
+ # build cannot be memorised by coord. On EVERY seed two simultaneous
219
+ # waves arrive: a HEAVY horde (10ร—e1 + 2ร—e3) on each lane. The rusher
220
+ # converges on the agent centroid (the active fact's lane); the
221
+ # off-lane wave still pours toward the centroid and a flanking pbox
222
+ # is required to interdict it. Predicate requires pbox count + brik
223
+ # count + a kill quota โ€” solving only one lane fails the kill quota.
224
+ # Budget 3000cr = 2 pbox (1200) + 9 brik (1800) โ€” the minimum
225
+ # walls-plus-towers spend that satisfies both lanes.
 
 
 
 
226
  hard:
 
 
 
 
 
 
 
 
 
 
 
 
227
  description: >
228
+ Your yard spawns NORTH (y=14) or SOUTH (y=26) by seed. TWO waves
229
+ arrive โ€” a heavy horde on your yard's lane and another on the
230
+ opposite lane. Your $3000 buys a tradeoff: channel the horde with
231
+ a wall belt + pillbox at the choke AND post a flanking pillbox on
232
+ the opposite lane. Yard intact, ten kills, within about 60 turns.
233
+ starting_cash: 3000
234
  overrides:
235
  actors:
236
+ # spawn_point 0 โ€” NORTH base. Fact at (10,14).
 
 
237
  - {type: fact, owner: agent, position: [10, 14], spawn_point: 0}
238
  - {type: tent, owner: agent, position: [6, 12], spawn_point: 0}
239
  - {type: powr, owner: agent, position: [6, 10], spawn_point: 0}
240
  - {type: powr, owner: agent, position: [6, 16], spawn_point: 0}
241
  - {type: e1, owner: agent, position: [3, 36], stance: 2, spawn_point: 0}
242
+ # spawn_point 1 โ€” SOUTH base (mirror).
 
 
243
  - {type: fact, owner: agent, position: [10, 26], spawn_point: 1}
244
  - {type: tent, owner: agent, position: [6, 24], spawn_point: 1}
245
  - {type: powr, owner: agent, position: [6, 28], spawn_point: 1}
 
247
  - {type: e1, owner: agent, position: [3, 4], stance: 2, spawn_point: 1}
248
  # Anti-DRAW marker on the symmetry axis.
249
  - {type: fact, owner: enemy, position: [120, 20]}
250
+ # Two simultaneous waves on BOTH lanes (the spawn_actors list is
251
+ # not spawn_point-filtered, so both lanes always spawn). The
252
+ # rusher converges on the agent centroid (the active fact's
253
+ # lane); the off-lane wave is a LIGHT probe (4ร—e1) that a single
254
+ # flanking pbox at the active-lane choke can mop up as it walks
255
+ # past, while the heavy on-lane horde is shredded by the wall
256
+ # belt + pbox choke. Heavy ON the active lane forces the wall
257
+ # belt; light probe OFF the active lane is the "flank" budget
258
+ # tradeoff the predicate enforces.
259
  scheduled_events:
260
  - tick: 1800
261
  type: spawn_actors
262
  actors:
263
+ # NORTH-lane wave โ€” HEAVY horde (12 attackers) plus a
264
+ # LIGHT probe (4 e1). On a NORTH-base seed the heavy
265
+ # horde is on-lane (channeled by the wall belt); on a
266
+ # SOUTH-base seed the heavy north wave is off-lane and
267
+ # detours through y=20 to reach the south fact, walking
268
+ # past the choke on its way down.
269
+ - {type: e1, owner: enemy, position: [60, 14], stance: 3, count: 10}
270
+ - {type: e3, owner: enemy, position: [62, 14], stance: 3, count: 2}
271
+ # SOUTH-lane wave โ€” symmetric. Both waves always spawn;
272
+ # whichever wave matches the active fact's latitude is
273
+ # the "on-lane" horde the wall belt channels, the other
274
+ # is the "off-lane" wave a 2nd pbox + the wall belt
275
+ # together kill as they detour past.
276
+ - {type: e1, owner: enemy, position: [60, 26], stance: 3, count: 10}
277
+ - {type: e3, owner: enemy, position: [62, 26], stance: 3, count: 2}
278
  win_condition:
279
  all_of:
280
+ - {building_count_gte: {type: pbox, n: 2}}
281
+ - {building_count_gte: {type: brik, n: 6}}
282
+ - {units_killed_gte: 10}
283
  - {building_count_gte: {type: fact, n: 1}}
284
  - {within_ticks: 5400}
285
  fail_condition:
tests/test_def_walls_vs_towers.py CHANGED
@@ -1,43 +1,29 @@
1
  """def-walls-vs-towers scenario family, full loop on Rust.
2
 
3
- The pack tests PASSIVE-OBSTACLE vs ACTIVE-DEFENSE mitigation: the agent
4
- has ONE budget (3200cr) and must choose how to spend it against an
5
- incoming rush. The budget funds EITHER ~16 inert `brik` concrete walls
6
- (200cr, HP 40000, NO Armament โ€” they channel and delay but kill
7
- nothing) OR 4 active `gun` turrets (800cr, Armament Weapon TurretGun โ€”
8
- the engine's auto-target loop fires them at the rush). The win
9
- predicate makes the choice load-bearing:
10
-
11
- * `building_count_gte:{gun, n:4}` โ‡’ the agent built the budget worth of
12
- ACTIVE turrets (4 โ€” cash is tight enough that the wall option is
13
- mutually exclusive);
14
- * `units_killed_gte:K` โ‡’ the load-bearing clause. A walls-only spend
15
- kills 0 โ€” walls have no weapon โ€” so the clause is never satisfied; an
16
- active gun cluster shreds the rush and clears it. This is the
17
- predicate that makes ACTIVE defence (not passive obstruction) the
18
- only winning play;
19
- * `building_count_gte:{fact,1}` โ‡’ the fact must still STAND (the
20
- PRESENT-TENSE predicate, not `has_building:fact` which is a one-shot
21
- ever-seen set โ€” CLAUDE.md footgun);
22
- * `within_ticks` paired with `after_ticks` โ‡’ a non-finisher (stall,
23
- walls-only) is a real reachable timeout LOSS (no interrupts โ‡’ each
24
- step is exactly 90 ticks, so max_turns is a hard tick budget the
25
- `after_ticks` deadline reliably bites in).
26
-
27
- The rush arrives as a `scheduled_events: spawn_actors` wave at a fixed
28
- tick (1800) โ€” after the agent has had time to build 4 gun turrets
29
- serially โ€” so the race is fair: the intended build completes before
30
- the wave, while a staller / walls-only spend still cannot satisfy the
31
- kill quota.
32
 
33
  The scripted-policy validations prove deterministically that:
34
 
35
- * the intended TOWERS policy (4 gun turrets wrapping the active fact)
36
- WINS every level + every hard seed (1..4);
37
- * walls-only (16 brik โ€” 0 kills) / stall / pure-army all LOSE every
38
- level + every hard seed โ€” a real LOSS, not a draw;
39
  * the hard tier defines >=2 spawn_point groups (north fact y=14 /
40
- south fact y=26) so the turret cluster must follow the fact.
41
  """
42
 
43
  from __future__ import annotations
@@ -65,28 +51,33 @@ def stall(rs, C):
65
  return [C.observe()]
66
 
67
 
 
 
 
 
 
 
 
 
 
68
  def _build_and_place(rs, C, kind, cells):
69
- """Common build-place loop: at each turn, place the next `kind`
70
- building in `cells` if the previous one finished; queue the next
71
- build."""
72
  own_b = rs.get("own_buildings") or []
73
  n = sum(1 for b in own_b if b.get("type") == kind)
74
  if n >= len(cells):
75
  return [C.observe()]
76
- prod = rs.get("production") or []
77
- prod_items = [p.get("item") for p in prod if isinstance(p, dict)]
78
  cmds = []
79
- if kind not in prod_items:
80
  cmds.append(C.build(kind))
81
  cmds.append(C.place_building(kind, cells[n][0], cells[n][1]))
82
  return cmds or [C.observe()]
83
 
84
 
85
- def make_adaptive_towers():
86
- """Intended TOWERS policy: read the fact's cell from the
87
- observation on turn 1, then build 4 ACTIVE gun turrets wrapping it.
88
- On hard the fact flips between y=14 and y=26 by seed, so the turret
89
- cluster must follow it."""
90
  state = {"cells": None}
91
 
92
  def policy(rs, C):
@@ -96,20 +87,16 @@ def make_adaptive_towers():
96
  if not facts:
97
  return [C.observe()]
98
  fy = facts[0].get("cell_y", facts[0].get("y"))
99
- # 4 cells wrapping the fact at (10, fy), east of it (the
100
- # rush approaches from the east).
101
- state["cells"] = [(12, fy - 2), (12, fy), (12, fy + 2), (13, fy)]
102
- return _build_and_place(rs, C, "gun", state["cells"])
103
 
104
  return policy
105
 
106
 
107
- def make_walls_only():
108
- """The PASSIVE counterfactual: spend the whole budget on 16 inert
109
- `brik` concrete walls (a double barrier east of the fact). Walls
110
- channel and delay the rush but have NO weapon โ€” they kill nothing,
111
- so the `units_killed_gte` clause is never satisfied and the win
112
- never latches; the episode times out โ†’ LOSS."""
113
  state = {"cells": None}
114
 
115
  def policy(rs, C):
@@ -119,22 +106,103 @@ def make_walls_only():
119
  if not facts:
120
  return [C.observe()]
121
  fy = facts[0].get("cell_y", facts[0].get("y"))
122
- state["cells"] = [(14, fy - 7 + i) for i in range(8)] + [
123
- (16, fy - 7 + i) for i in range(8)
124
- ]
125
  return _build_and_place(rs, C, "brik", state["cells"])
126
 
127
  return policy
128
 
129
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
130
  def pure_army(rs, C):
131
  """PURE-ARMY: only ever train e1 โ€” never builds a defensive
132
- building. FAILS the `building_count_gte:gun` clause; a thin
133
  home-trained rifle screen cannot out-trade the heavier rush band
134
  either, so the fact often falls โ†’ LOSS."""
135
- prod = rs.get("production") or []
136
- prod_items = [p.get("item") for p in prod if isinstance(p, dict)]
137
- if "e1" not in prod_items:
138
  return [C.build("e1")]
139
  return [C.observe()]
140
 
@@ -167,14 +235,16 @@ def test_pack_compiles_with_three_levels_and_rusher_bot():
167
  assert str(bot).lower() == "rusher", (lvl, bot)
168
 
169
 
170
- def test_starting_cash_is_the_either_or_budget():
171
- """Cash is intentionally tight: 3200cr funds EITHER 16 brik walls
172
- (16ร—200) OR 4 gun turrets (4ร—800) โ€” never both. The two options
173
- are mutually exclusive so the agent must commit to ACTIVE defence."""
 
174
  pack = load_pack(PACK)
175
- for lvl in LEVELS:
176
- c = compile_level(pack, lvl)
177
- assert c.starting_cash == 3200, (lvl, c.starting_cash)
 
178
 
179
 
180
  def test_no_preplaced_combat_units_near_the_base():
@@ -191,7 +261,6 @@ def test_no_preplaced_combat_units_near_the_base():
191
  for a in c.scenario.actors
192
  if a.owner == "agent" and a.type == "e1"
193
  ]
194
- # Every pre-placed agent e1 is a corner non-combatant.
195
  for a in agent_units:
196
  x, y = a.position
197
  assert x <= 3 and (y <= 6 or y >= 34), (lvl, a.position)
@@ -199,8 +268,8 @@ def test_no_preplaced_combat_units_near_the_base():
199
 
200
  def test_uses_scheduled_wave_event():
201
  """The rush arrives via a `scheduled_events: spawn_actors` wave so
202
- the build race is fair (the wave lands after the intended 4-gun
203
- build can complete)."""
204
  pack = load_pack(PACK)
205
  for lvl in LEVELS:
206
  c = compile_level(pack, lvl)
@@ -250,7 +319,7 @@ def test_fact_alive_clause_uses_present_tense_predicate():
250
  assert fact_clauses, f"{lvl}: missing present-tense fact-alive fail clause"
251
 
252
 
253
- def test_win_requires_a_kill_quota():
254
  """The load-bearing clause: every level's win requires
255
  `units_killed_gte` โ€” the predicate a walls-only (0-kill) spend
256
  cannot satisfy. Without it, inert walls would win."""
@@ -266,9 +335,58 @@ def test_win_requires_a_kill_quota():
266
  assert int(kill_clauses[0]["units_killed_gte"]) >= 4, lvl
267
 
268
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
269
  def test_hard_has_two_spawn_point_groups_and_fact_flips():
270
  """Hard-tier contract: >=2 distinct agent spawn_point groups so the
271
- fact (and therefore the turret cluster) flips by seed. The two
272
  groups must define the NORTH (y=14) and SOUTH (y=26) fact pair."""
273
  c = compile_level(load_pack(PACK), "hard")
274
  groups = {
@@ -291,76 +409,134 @@ def test_hard_has_two_spawn_point_groups_and_fact_flips():
291
  assert 2 <= x <= 126 and 2 <= y <= 38, (a.type, a.position)
292
 
293
 
294
- # โ”€โ”€ solvency: intended TOWERS wins every level + every hard seed โ”€โ”€โ”€โ”€โ”€
295
 
296
 
297
- @pytest.mark.parametrize("level", LEVELS)
298
- def test_intended_towers_win_every_level_and_seed(level):
299
- c = compile_level(load_pack(PACK), level)
300
  for seed in SEEDS:
301
- r = run_level(c, make_adaptive_towers(), seed=seed)
302
  assert r.outcome == "win", (
303
- f"{level} seed{seed}: intended gun-turret build must WIN; "
304
  f"got {r.outcome} (tick={r.signals.game_tick}, "
305
  f"kills={r.signals.units_killed}, "
306
- f"lost={r.signals.units_lost}, "
307
  f"buildings={r.signals.own_buildings})"
308
  )
309
 
310
 
311
- # โ”€โ”€ no-cheat: every lazy / passive policy LOSES (not draws) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
312
 
313
 
314
  @pytest.mark.parametrize("level", LEVELS)
315
- @pytest.mark.parametrize(
316
- "policy_name,policy_factory",
317
- [
318
- ("stall", lambda: stall),
319
- ("walls_only", make_walls_only),
320
- ("pure_army", lambda: pure_army),
321
- ],
322
- )
323
- def test_passive_and_lazy_policies_lose_every_level_and_seed(
324
- level, policy_name, policy_factory
325
- ):
326
- """Stall (rush razes fact OR clock), walls-only (inert walls โ€” 0
327
- kills, kill clause never met, clock runs out), and pure-army (gun
328
- count clause unmet) must ALL LOSE on every level + every seed โ€” no
329
- draw. walls-only LOSING is the load-bearing discrimination: a
330
- passive-obstacle spend, however large, never produces kill output."""
331
  c = compile_level(load_pack(PACK), level)
332
- fn = policy_factory()
333
  for seed in SEEDS:
334
- r = run_level(c, fn, seed=seed)
335
  assert r.outcome == "loss", (
336
- f"{level} seed{seed} {policy_name}: must LOSE (real fail, "
337
- f"not a draw); got {r.outcome} (tick={r.signals.game_tick}, "
338
- f"kills={r.signals.units_killed}, "
339
- f"buildings={r.signals.own_buildings})"
340
  )
341
 
342
 
343
- def test_walls_only_produces_zero_kills():
344
- """The mechanism check: an all-`brik` spend kills NOTHING (walls
345
- have no weapon). This is why walls LOSE the kill clause and active
346
- gun turrets WIN it."""
347
- for lvl in LEVELS:
348
- c = compile_level(load_pack(PACK), lvl)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
349
  for seed in SEEDS:
350
- r = run_level(c, make_walls_only(), seed=seed)
351
- assert r.signals.units_killed == 0, (
352
- f"{lvl} seed{seed}: walls-only must kill 0 (inert "
353
- f"obstacle); got {r.signals.units_killed}"
 
354
  )
355
 
356
 
 
 
 
 
 
 
 
 
 
 
 
 
 
357
  # โ”€โ”€ determinism โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
358
 
359
 
360
  def test_intended_run_is_deterministic_on_easy():
361
  c = compile_level(load_pack(PACK), "easy")
362
- a = run_level(c, make_adaptive_towers(), seed=3)
363
- b = run_level(c, make_adaptive_towers(), seed=3)
364
  assert (a.outcome, a.turns, a.signals.units_killed) == (
365
  b.outcome,
366
  b.turns,
 
1
  """def-walls-vs-towers scenario family, full loop on Rust.
2
 
3
+ The pack tests TIER-DIFFERENTIATED defensive DOCTRINE selection: the
4
+ agent must choose between PASSIVE OBSTACLE (`brik` concrete walls โ€”
5
+ 200cr, HP 40000, NO Armament) and ACTIVE TURRET (`pbox` pillbox โ€”
6
+ 600cr, M60mg burst โ€” anti-infantry direct fire). The correct posture
7
+ DIFFERS PER TIER:
8
+
9
+ * EASY โ€” frontal rush in the open. 4 pbox cluster wraps the fact
10
+ and shreds the wave. Walls kill nothing โ†’ LOSS.
11
+ * MEDIUM โ€” overwhelming horde funnels on one lane. Win predicate
12
+ requires BOTH a wall belt AND a pbox at the choke AND a
13
+ kill quota: a wall barrier funnels the horde to a single
14
+ pass where the pbox serially shreds the queue. Pure-walls
15
+ (0 kills) and pure-pbox (no brik clause) both LOSE.
16
+ * HARD โ€” two simultaneous waves + seed-driven fact flip. The agent
17
+ must allocate budget across both postures (2 pbox + 8
18
+ brik). Pure-walls and pure-pbox both LOSE.
 
 
 
 
 
 
 
 
 
 
 
 
 
19
 
20
  The scripted-policy validations prove deterministically that:
21
 
22
+ * the intended PER-TIER doctrine WINS every seed (1..4);
23
+ * stall, pure-walls (brik-only), pure-pbox (no brik on medium/hard),
24
+ and pure-army all LOSE every seed โ€” a real LOSS, not a draw;
 
25
  * the hard tier defines >=2 spawn_point groups (north fact y=14 /
26
+ south fact y=26) so the defence cluster must follow the fact.
27
  """
28
 
29
  from __future__ import annotations
 
51
  return [C.observe()]
52
 
53
 
54
+ def _prod_items(rs):
55
+ """The Defense queue surfaces as a flat list of strings โ€” handle
56
+ that AND the legacy dict-with-item shape robustly."""
57
+ return [
58
+ (p if isinstance(p, str) else p.get("item"))
59
+ for p in (rs.get("production") or [])
60
+ ]
61
+
62
+
63
  def _build_and_place(rs, C, kind, cells):
64
+ """Place the next `kind` building in `cells` if the previous one
65
+ finished; queue the next build only if not already in the queue."""
 
66
  own_b = rs.get("own_buildings") or []
67
  n = sum(1 for b in own_b if b.get("type") == kind)
68
  if n >= len(cells):
69
  return [C.observe()]
 
 
70
  cmds = []
71
+ if kind not in _prod_items(rs):
72
  cmds.append(C.build(kind))
73
  cmds.append(C.place_building(kind, cells[n][0], cells[n][1]))
74
  return cmds or [C.observe()]
75
 
76
 
77
+ def make_pbox_only(cells_fn):
78
+ """Pure-ACTIVE policy: build N pbox at `cells_fn(fy)`. Wraps the
79
+ fact with active turrets that shoot the rush. Wins on easy
80
+ (predicate matches) and LOSES on medium/hard (no brik clause)."""
 
81
  state = {"cells": None}
82
 
83
  def policy(rs, C):
 
87
  if not facts:
88
  return [C.observe()]
89
  fy = facts[0].get("cell_y", facts[0].get("y"))
90
+ state["cells"] = cells_fn(fy)
91
+ return _build_and_place(rs, C, "pbox", state["cells"])
 
 
92
 
93
  return policy
94
 
95
 
96
+ def make_brik_only(cells_fn):
97
+ """Pure-PASSIVE policy: build N brik (inert walls). The brik clause
98
+ may pass on medium/hard but `units_killed_gte` never does โ€” walls
99
+ have no weapon. LOSES every tier."""
 
 
100
  state = {"cells": None}
101
 
102
  def policy(rs, C):
 
106
  if not facts:
107
  return [C.observe()]
108
  fy = facts[0].get("cell_y", facts[0].get("y"))
109
+ state["cells"] = cells_fn(fy)
 
 
110
  return _build_and_place(rs, C, "brik", state["cells"])
111
 
112
  return policy
113
 
114
 
115
+ def make_intended_easy():
116
+ """EASY doctrine: 4 pbox wrapping the fact on the east approach."""
117
+ return make_pbox_only(
118
+ lambda fy: [(12, fy - 2), (12, fy), (12, fy + 2), (13, fy)]
119
+ )
120
+
121
+
122
+ def _build_combo(rs, C, plan):
123
+ """Two-phase build: complete all pbox first, then the brik belt.
124
+ Pbox first because they kill โ€” building the wall belt before the
125
+ pbox lets the horde reach the fact before kill output is online."""
126
+ own_b = rs.get("own_buildings") or []
127
+ n_pbox = sum(1 for b in own_b if b.get("type") == "pbox")
128
+ n_brik = sum(1 for b in own_b if b.get("type") == "brik")
129
+ prod_items = _prod_items(rs)
130
+ cmds = []
131
+ if n_pbox < len(plan["pbox"]):
132
+ if "pbox" not in prod_items:
133
+ cmds.append(C.build("pbox"))
134
+ cmds.append(C.place_building("pbox", plan["pbox"][n_pbox][0], plan["pbox"][n_pbox][1]))
135
+ elif n_brik < len(plan["brik"]):
136
+ if "brik" not in prod_items:
137
+ cmds.append(C.build("brik"))
138
+ cmds.append(C.place_building("brik", plan["brik"][n_brik][0], plan["brik"][n_brik][1]))
139
+ else:
140
+ cmds.append(C.observe())
141
+ return cmds
142
+
143
+
144
+ def make_intended_medium():
145
+ """MEDIUM doctrine: 1 pbox at the choke + 8 brik wall belt that
146
+ leaves a single-cell pass at the fact's y, funnelling the horde
147
+ onto the pbox burst."""
148
+ state = {"plan": None}
149
+
150
+ def policy(rs, C):
151
+ if state["plan"] is None:
152
+ own_b = rs.get("own_buildings") or []
153
+ facts = [b for b in own_b if b.get("type") == "fact"]
154
+ if not facts:
155
+ return [C.observe()]
156
+ fy = facts[0].get("cell_y", facts[0].get("y"))
157
+ state["plan"] = {
158
+ "pbox": [(12, fy)],
159
+ "brik": [
160
+ (14, fy - 4), (14, fy - 3), (14, fy - 2), (14, fy - 1),
161
+ (14, fy + 1), (14, fy + 2), (14, fy + 3), (14, fy + 4),
162
+ ],
163
+ }
164
+ return _build_combo(rs, C, state["plan"])
165
+
166
+ return policy
167
+
168
+
169
+ def make_intended_hard():
170
+ """HARD doctrine: 2 pbox at the choke + 8 brik full-lane wall belt
171
+ that blocks BOTH attack vectors converging on the fact. The pbox
172
+ cluster sits behind the wall belt where it can shred whatever
173
+ funnels through the single-cell gap left by the wall."""
174
+ state = {"plan": None}
175
+
176
+ def policy(rs, C):
177
+ if state["plan"] is None:
178
+ own_b = rs.get("own_buildings") or []
179
+ facts = [b for b in own_b if b.get("type") == "fact"]
180
+ if not facts:
181
+ return [C.observe()]
182
+ fy = facts[0].get("cell_y", facts[0].get("y"))
183
+ # 2nd pbox is placed on the FACT side (north for the north
184
+ # fact, south for the south fact) to cover the off-lane
185
+ # approach as it walks past.
186
+ second_y = fy - 2 if fy == 14 else fy + 2
187
+ state["plan"] = {
188
+ "pbox": [(12, fy), (12, second_y)],
189
+ "brik": [
190
+ (13, fy - 3), (13, fy - 2), (13, fy - 1),
191
+ (13, fy + 1), (13, fy + 2), (13, fy + 3),
192
+ (13, fy + 4), (13, fy - 4),
193
+ ],
194
+ }
195
+ return _build_combo(rs, C, state["plan"])
196
+
197
+ return policy
198
+
199
+
200
  def pure_army(rs, C):
201
  """PURE-ARMY: only ever train e1 โ€” never builds a defensive
202
+ building. FAILS the `building_count_gte:pbox` clause; a thin
203
  home-trained rifle screen cannot out-trade the heavier rush band
204
  either, so the fact often falls โ†’ LOSS."""
205
+ if "e1" not in _prod_items(rs):
 
 
206
  return [C.build("e1")]
207
  return [C.observe()]
208
 
 
235
  assert str(bot).lower() == "rusher", (lvl, bot)
236
 
237
 
238
+ def test_starting_cash_is_per_tier_doctrine_budget():
239
+ """Cash is sized per tier so the intended doctrine fits and the
240
+ wrong doctrine does not: easy/medium fund either 4 pbox (4ร—600) or
241
+ 12 brik (12ร—200) within 2400cr; hard funds 2 pbox + 8 brik = 2800
242
+ within 3000cr."""
243
  pack = load_pack(PACK)
244
+ cash_by_level = {lvl: compile_level(pack, lvl).starting_cash for lvl in LEVELS}
245
+ assert cash_by_level["easy"] == 2400, cash_by_level
246
+ assert cash_by_level["medium"] == 2400, cash_by_level
247
+ assert cash_by_level["hard"] == 3000, cash_by_level
248
 
249
 
250
  def test_no_preplaced_combat_units_near_the_base():
 
261
  for a in c.scenario.actors
262
  if a.owner == "agent" and a.type == "e1"
263
  ]
 
264
  for a in agent_units:
265
  x, y = a.position
266
  assert x <= 3 and (y <= 6 or y >= 34), (lvl, a.position)
 
268
 
269
  def test_uses_scheduled_wave_event():
270
  """The rush arrives via a `scheduled_events: spawn_actors` wave so
271
+ the build race is fair (the wave lands after the intended build
272
+ can complete)."""
273
  pack = load_pack(PACK)
274
  for lvl in LEVELS:
275
  c = compile_level(pack, lvl)
 
319
  assert fact_clauses, f"{lvl}: missing present-tense fact-alive fail clause"
320
 
321
 
322
+ def test_win_requires_a_kill_quota_per_tier():
323
  """The load-bearing clause: every level's win requires
324
  `units_killed_gte` โ€” the predicate a walls-only (0-kill) spend
325
  cannot satisfy. Without it, inert walls would win."""
 
335
  assert int(kill_clauses[0]["units_killed_gte"]) >= 4, lvl
336
 
337
 
338
+ def test_medium_and_hard_require_wall_plus_tower_combo():
339
+ """Tier-differentiated doctrine: medium/hard predicates must require
340
+ BOTH a pbox AND a brik clause. Without the brik clause a pure-pbox
341
+ spend would win medium/hard and the tier doctrine would collapse to
342
+ "always build towers"."""
343
+ for lvl in ("medium", "hard"):
344
+ c = compile_level(load_pack(PACK), lvl)
345
+ wc = c.win_condition.model_dump(exclude_none=True)
346
+ all_of = wc.get("all_of") or []
347
+ pbox_clauses = [
348
+ cl for cl in all_of
349
+ if isinstance(cl, dict)
350
+ and (cl.get("building_count_gte") or {}).get("type") == "pbox"
351
+ ]
352
+ brik_clauses = [
353
+ cl for cl in all_of
354
+ if isinstance(cl, dict)
355
+ and (cl.get("building_count_gte") or {}).get("type") == "brik"
356
+ ]
357
+ assert pbox_clauses, f"{lvl}: missing pbox count clause"
358
+ assert brik_clauses, f"{lvl}: missing brik count clause"
359
+ # The brik count must require a genuine wall belt, not 1-2
360
+ # incidental walls โ€” otherwise a 4-pbox spend that happens to
361
+ # toss out a couple of walls passes the predicate.
362
+ assert int(brik_clauses[0]["building_count_gte"]["n"]) >= 6, lvl
363
+
364
+
365
+ def test_easy_requires_pbox_not_brik():
366
+ """Easy tier is the pure-active-doctrine tier: the predicate must
367
+ require pbox and NOT require any brik (a 4-pbox spend is the
368
+ intended easy win, and forcing brik on easy would collapse the
369
+ tier differentiation)."""
370
+ c = compile_level(load_pack(PACK), "easy")
371
+ wc = c.win_condition.model_dump(exclude_none=True)
372
+ all_of = wc.get("all_of") or []
373
+ pbox_clauses = [
374
+ cl for cl in all_of
375
+ if isinstance(cl, dict)
376
+ and (cl.get("building_count_gte") or {}).get("type") == "pbox"
377
+ ]
378
+ brik_clauses = [
379
+ cl for cl in all_of
380
+ if isinstance(cl, dict)
381
+ and (cl.get("building_count_gte") or {}).get("type") == "brik"
382
+ ]
383
+ assert pbox_clauses, "easy: missing pbox count clause"
384
+ assert not brik_clauses, "easy: must not require brik (pure-active doctrine)"
385
+
386
+
387
  def test_hard_has_two_spawn_point_groups_and_fact_flips():
388
  """Hard-tier contract: >=2 distinct agent spawn_point groups so the
389
+ fact (and therefore the defence cluster) flips by seed. The two
390
  groups must define the NORTH (y=14) and SOUTH (y=26) fact pair."""
391
  c = compile_level(load_pack(PACK), "hard")
392
  groups = {
 
409
  assert 2 <= x <= 126 and 2 <= y <= 38, (a.type, a.position)
410
 
411
 
412
+ # โ”€โ”€ solvency: intended per-tier doctrine wins every seed โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
413
 
414
 
415
+ def test_intended_easy_pbox_wins_every_seed():
416
+ c = compile_level(load_pack(PACK), "easy")
 
417
  for seed in SEEDS:
418
+ r = run_level(c, make_intended_easy(), seed=seed)
419
  assert r.outcome == "win", (
420
+ f"easy seed{seed}: intended 4-pbox cluster must WIN; "
421
  f"got {r.outcome} (tick={r.signals.game_tick}, "
422
  f"kills={r.signals.units_killed}, "
 
423
  f"buildings={r.signals.own_buildings})"
424
  )
425
 
426
 
427
+ def test_intended_medium_walls_plus_tower_wins_every_seed():
428
+ c = compile_level(load_pack(PACK), "medium")
429
+ for seed in SEEDS:
430
+ r = run_level(c, make_intended_medium(), seed=seed)
431
+ assert r.outcome == "win", (
432
+ f"medium seed{seed}: intended 1 pbox + 8 brik choke must "
433
+ f"WIN; got {r.outcome} (tick={r.signals.game_tick}, "
434
+ f"kills={r.signals.units_killed}, "
435
+ f"buildings={r.signals.own_buildings})"
436
+ )
437
+
438
+
439
+ def test_intended_hard_mixed_doctrine_wins_every_seed():
440
+ c = compile_level(load_pack(PACK), "hard")
441
+ for seed in SEEDS:
442
+ r = run_level(c, make_intended_hard(), seed=seed)
443
+ assert r.outcome == "win", (
444
+ f"hard seed{seed}: intended 2 pbox + 8 brik mixed doctrine "
445
+ f"must WIN; got {r.outcome} (tick={r.signals.game_tick}, "
446
+ f"kills={r.signals.units_killed}, "
447
+ f"buildings={r.signals.own_buildings})"
448
+ )
449
+
450
+
451
+ # โ”€โ”€ no-cheat: stall + wrong doctrine all LOSE every tier ร— seed โ”€โ”€โ”€โ”€โ”€โ”€
452
 
453
 
454
  @pytest.mark.parametrize("level", LEVELS)
455
+ def test_stall_loses_every_level_and_seed(level):
456
+ """Stall (observe-only): the rush razes the fact OR the clock runs
457
+ out โ†’ real LOSS, not a draw."""
 
 
 
 
 
 
 
 
 
 
 
 
 
458
  c = compile_level(load_pack(PACK), level)
 
459
  for seed in SEEDS:
460
+ r = run_level(c, stall, seed=seed)
461
  assert r.outcome == "loss", (
462
+ f"{level} seed{seed} stall: must LOSE; got {r.outcome}"
 
 
 
463
  )
464
 
465
 
466
+ @pytest.mark.parametrize("level", LEVELS)
467
+ def test_pure_walls_loses_every_level_and_seed(level):
468
+ """Pure-walls (brik-only): walls have no weapon, so `units_killed`
469
+ stays at zero on every tier โ€” the kill clause is never satisfied
470
+ and the episode times out โ†’ LOSS. The mechanism-level proof that
471
+ passive obstruction alone is never the right doctrine for ANY
472
+ tier (even where walls are PART of the right doctrine)."""
473
+ c = compile_level(load_pack(PACK), level)
474
+ pol = make_brik_only(lambda fy: [(14, fy - 6 + i) for i in range(12) if fy - 6 + i != fy])
475
+ for seed in SEEDS:
476
+ r = run_level(c, pol, seed=seed)
477
+ assert r.outcome == "loss", (
478
+ f"{level} seed{seed} pure-walls: must LOSE; got {r.outcome} "
479
+ f"(kills={r.signals.units_killed})"
480
+ )
481
+ # Mechanism check: pure-walls always kills zero, on every tier.
482
+ assert r.signals.units_killed == 0, (
483
+ f"{level} seed{seed}: pure-walls must kill 0 (inert "
484
+ f"obstacle); got {r.signals.units_killed}"
485
+ )
486
+
487
+
488
+ def test_pure_pbox_wins_easy_but_loses_medium_and_hard():
489
+ """The TIER-DIFFERENTIATION proof: a 4-pbox cluster (the easy
490
+ doctrine, hard-coded as always-towers) WINS easy but LOSES
491
+ medium and hard โ€” proving the predicate / geometry genuinely
492
+ forces a different doctrine per tier. An "always build towers"
493
+ play that memorises easy fails the higher tiers."""
494
+ pack = load_pack(PACK)
495
+
496
+ def cells(fy):
497
+ return [(12, fy - 2), (12, fy), (12, fy + 2), (13, fy)]
498
+
499
+ # Easy โ€” pure-pbox is the correct doctrine, must WIN.
500
+ c_easy = compile_level(pack, "easy")
501
+ for seed in SEEDS:
502
+ r = run_level(c_easy, make_pbox_only(cells), seed=seed)
503
+ assert r.outcome == "win", (
504
+ f"easy seed{seed} pure-pbox: must WIN (the easy doctrine); "
505
+ f"got {r.outcome}"
506
+ )
507
+
508
+ # Medium + hard โ€” pure-pbox fails the brik clause, must LOSE.
509
+ for lvl in ("medium", "hard"):
510
+ c = compile_level(pack, lvl)
511
  for seed in SEEDS:
512
+ r = run_level(c, make_pbox_only(cells), seed=seed)
513
+ assert r.outcome == "loss", (
514
+ f"{lvl} seed{seed} pure-pbox: must LOSE (wrong "
515
+ f"doctrine โ€” no wall belt); got {r.outcome} "
516
+ f"(kills={r.signals.units_killed})"
517
  )
518
 
519
 
520
+ @pytest.mark.parametrize("level", LEVELS)
521
+ def test_pure_army_loses_every_level_and_seed(level):
522
+ """Pure-army (only train e1): fails the pbox count clause and a
523
+ thin home-trained rifle screen cannot out-trade the heavier rush
524
+ band, so the fact often falls โ†’ LOSS."""
525
+ c = compile_level(load_pack(PACK), level)
526
+ for seed in SEEDS:
527
+ r = run_level(c, pure_army, seed=seed)
528
+ assert r.outcome == "loss", (
529
+ f"{level} seed{seed} pure-army: must LOSE; got {r.outcome}"
530
+ )
531
+
532
+
533
  # โ”€โ”€ determinism โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
534
 
535
 
536
  def test_intended_run_is_deterministic_on_easy():
537
  c = compile_level(load_pack(PACK), "easy")
538
+ a = run_level(c, make_intended_easy(), seed=3)
539
+ b = run_level(c, make_intended_easy(), seed=3)
540
  assert (a.outcome, a.turns, a.signals.units_killed) == (
541
  b.outcome,
542
  b.turns,
tests/test_hard_tier.py CHANGED
@@ -746,19 +746,22 @@ UPGRADED = [
746
  # selected. MicroRTS pillbox placement / military quadrant
747
  # doctrine anchor.
748
  "build-defensive-skirt-corners",
749
- # Wave-11 REASONING pack: passive-obstacle vs active-defense
750
- # mitigation โ€” one budget (3200cr) funds EITHER ~16 inert brik
751
- # walls OR 4 active gun turrets. The win predicate requires a kill
752
- # quota (units_killed_gte) only the gun turrets can deliver, so a
753
- # walls-only spend (0 kills) and a stall both LOSE while the
754
- # intended turret build WINS. Hard tier defines two agent
755
- # spawn_point groups (NORTH fact y=14 / SOUTH fact y=26) round-
756
- # robined by seed; the scheduled rush wave is staged at BOTH
757
- # candidate latitudes (spawn_actors lists are not spawn_point-
758
- # filtered) so the rusher converges on whichever fact the seed
759
- # selected, and a memorised "turrets near (10,20)" plan cannot
760
- # generalise. Security architecture passive-vs-active mitigation /
761
- # military fortification anchor.
 
 
 
762
  "def-walls-vs-towers",
763
  # Wave-7 REASONING asymmetric-underdog pack โ€” SC2 asymmetric /
764
  # guerrilla tactics / asymmetric warfare anchor. 2ร— 2tnk vs 4ร— e1
 
746
  # selected. MicroRTS pillbox placement / military quadrant
747
  # doctrine anchor.
748
  "build-defensive-skirt-corners",
749
+ # Wave-11 REASONING pack: tier-differentiated defensive DOCTRINE
750
+ # โ€” passive `brik` walls (200cr, no Armament) vs active `pbox`
751
+ # pillboxes (600cr, M60mg burst). The CORRECT doctrine differs
752
+ # per tier: easy = pure-pbox (frontal rush in the open); medium =
753
+ # walls+pbox CHOKE (overwhelming horde funnels through a single
754
+ # gap onto the pbox burst); hard = MIXED 2 pbox + 8 brik with a
755
+ # seed-driven fact flip. The win predicate requires `pbox` count,
756
+ # `brik` count (medium/hard only), AND a kill quota โ€” so pure-
757
+ # walls (0 kills) and pure-pbox (no brik) both LOSE on medium/hard
758
+ # while still winning easy. Hard defines two agent spawn_point
759
+ # groups (NORTH fact y=14 / SOUTH fact y=26) round-robined by
760
+ # seed; the scheduled rush waves are staged at BOTH candidate
761
+ # latitudes so the rusher converges on whichever fact the seed
762
+ # selected, and a memorised "wall belt near (10,20)" plan cannot
763
+ # generalise. Security architecture passive-vs-active mitigation
764
+ # / military fortification anchor.
765
  "def-walls-vs-towers",
766
  # Wave-7 REASONING asymmetric-underdog pack โ€” SC2 asymmetric /
767
  # guerrilla tactics / asymmetric warfare anchor. 2ร— 2tnk vs 4ร— e1