yxc20098 commited on
Commit
ae1d089
·
1 Parent(s): 9b275f8

feat(scenario): build-tech-skip-decision — skip the unneeded tech tier (PlanBench step-pruning / YAGNI anchor)

Browse files
openra_bench/scenarios/packs/build-tech-skip-decision.yaml ADDED
@@ -0,0 +1,278 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # build-tech-skip-decision — skip an unnecessary tech tier (YAGNI)
2
+ #
3
+ # REASONING capability — PlanBench unnecessary-step PRUNING. The
4
+ # objective only needs BASIC infantry (e1, $100, trainable from turn 1
5
+ # off the pre-placed tent). Climbing the full tech chain to a war
6
+ # factory and medium tanks (powr → proc → weap + fix → 2tnk) is a
7
+ # whole tech tier the objective never asks for: it wastes cash AND —
8
+ # critically — the clock. The agent must recognise the goal does not
9
+ # require high tech and skip STRAIGHT to the cheap e1-swarm path.
10
+ #
11
+ # Real-world anchors:
12
+ # • PlanBench unnecessary-step pruning — find the plan that omits
13
+ # every action the goal does not require, not merely SOME plan.
14
+ # • Lean process / muda elimination — do not add a production step
15
+ # the deliverable never consumes.
16
+ # • YAGNI ("You Aren't Gonna Need It") — do not build capability the
17
+ # requirement does not call for; it costs time and budget now.
18
+ #
19
+ # THE DECISION (the whole test):
20
+ # The agent starts with a Construction Yard (fact) + an Allied
21
+ # barracks (tent) already standing — so `e1` rifle infantry are
22
+ # buildable from turn 1 with NO prior tech step. A light enemy
23
+ # garrison of rifle infantry is incoming from the east (stance 3,
24
+ # AttackAnything — it advances on the agent base). Killing K of them
25
+ # is the objective. An e1 swarm (cost $100 each) clears a light
26
+ # infantry garrison comfortably when massed at the base and left to
27
+ # auto-fire as the garrison closes; the agent need only keep
28
+ # training e1 and rallying them at the base front.
29
+ #
30
+ # The TECH-FULL-CHAIN play instead spends the budget and the clock
31
+ # building powr → proc → weap (+ fix) to reach medium tanks. That is
32
+ # a whole tech tier the garrison never required: by the deadline the
33
+ # tech play has only powr+proc standing and ZERO army — no tank has
34
+ # fielded — so the kill bar is unmet ⇒ LOSS on the clock. Stalling
35
+ # never kills anything ⇒ LOSS on the clock.
36
+ #
37
+ # Engine facts used (RA ruleset / prompt_v2 codex / CLAUDE.md):
38
+ # • e1 rifle infantry: cost $100, trainable from a barracks/tent.
39
+ # The tent is PRE-PLACED, so e1 is buildable on turn 1 with no
40
+ # prerequisite build — the cheap path is immediately actionable.
41
+ # • Full tech chain to medium tanks: powr ($300) → proc ($1400,
42
+ # prereq anypower) → weap ($2000, prereq proc); 2tnk ($850,
43
+ # prereq weap + fix). Three structures + a service depot before a
44
+ # single tank — many turns of placement + queue time; measured,
45
+ # the tech play has only reached powr+proc by the deadline.
46
+ # • A `stance:3` (AttackAnything) enemy advances toward the nearest
47
+ # visible foe (CLAUDE.md stance semantics) — so the garrison
48
+ # closes on the agent base on its own; the intended play just
49
+ # masses e1 at the base front and lets them auto-fire as
50
+ # defenders (concentrated fire beats the strung-out attacker).
51
+ # • `place_building` has no adjacency requirement (CLAUDE.md): the
52
+ # tech play's LOSS is from time cost, not placement difficulty.
53
+ # • Tick alignment: the engine advances ~90 ticks per decision turn,
54
+ # so within_ticks ≤ 93 + 90·(max_turns − 1). fail_condition uses
55
+ # after_ticks = within_ticks + 1 so a non-finisher LOSES one tick
56
+ # past the window — a real reachable timeout, never a DRAW.
57
+ # • A persistent unarmed enemy `fact` marker far east keeps the
58
+ # episode alive past the last garrison death so a non-winner
59
+ # reaches the deadline as a real LOSS (CLAUDE.md auto-done
60
+ # footgun).
61
+ # • `building_count_gte` (counts the live own_buildings list) is
62
+ # used for the fact-alive clause instead of `has_building`
63
+ # (CLAUDE.md own-elimination footgun).
64
+ # • Hard tier: the `spawn_point` filter applies ONLY to agent
65
+ # actors, and ANY agent actor with spawn_point filters out agent
66
+ # actors WITHOUT one (CLAUDE.md oramap.rs footgun). So the whole
67
+ # agent base (fact + tent) is duplicated across both spawn_point
68
+ # groups; the garrison + far enemy marker carry no spawn_point
69
+ # and always place.
70
+ #
71
+ # DISCRIMINATIONS (CLAUDE.md bar — no defect, no cheat, no draw):
72
+ # • stall (only observe): LOSS — 0 kills, the after_ticks deadline
73
+ # bites as a real reachable timeout.
74
+ # • tech-full-chain (build powr → proc → weap → fix, then 2tnk):
75
+ # LOSS — the unneeded tech tier burns the clock; by the deadline
76
+ # only powr+proc stand and no tank has fielded ⇒ kill bar unmet.
77
+ # • intended skip-to-e1-swarm (train e1 from the pre-placed tent
78
+ # from turn 1, rally them at the base front, let them auto-fire
79
+ # on the closing garrison): WIN — the kill bar is met AND the
80
+ # fact survives, well inside the deadline.
81
+ #
82
+ # Validate (no model / no network):
83
+ # python3 -m pytest tests/test_build_tech_skip_decision.py -q
84
+
85
+ meta:
86
+ id: build-tech-skip-decision
87
+ title: 'Skip the Unneeded Tech Tier — Clear a Light Garrison with a Basic e1 Swarm'
88
+ capability: reasoning
89
+ real_world_meaning: >
90
+ The objective only requires basic units. The agent starts with a
91
+ Construction Yard and an Allied barracks already standing, so cheap
92
+ rifle infantry (e1) are trainable from turn 1 with no prior tech
93
+ step, and a light enemy garrison — also basic infantry — is
94
+ incoming. An e1 swarm clears a light infantry garrison comfortably.
95
+ The trap is to climb the full tech chain (power plant → refinery →
96
+ war factory → service depot → medium tanks) to bring a higher tech
97
+ tier online that the objective never asked for: that whole tier
98
+ costs budget and, critically, clock — by the deadline no tank has
99
+ fielded at all. The pack tests whether the model recognises that
100
+ the goal does not require high tech and skips straight to the
101
+ cheap path.
102
+ robotics_analogue: >
103
+ Right-sizing the plan to the task. A delivery only needs a basic
104
+ pick-and-place cell that is already commissioned; provisioning a
105
+ full high-precision assembly line (extra power, feedstock, an
106
+ assembly station, a calibration depot) before starting work is a
107
+ whole capability tier the job never required — it burns budget and
108
+ blows the cycle-time deadline. The competent planner prunes every
109
+ step the goal does not consume and executes the minimal plan with
110
+ the capability already on hand.
111
+ benchmark_anchor:
112
+ - "PlanBench unnecessary-step pruning"
113
+ - "lean process"
114
+ - "YAGNI"
115
+ author: openra-bench
116
+
117
+ base_map: rush-hour-arena
118
+ # Cash covers a generous e1 swarm ($100 each) many times over AND, on
119
+ # its own, a full tech chain (powr 300 + proc 1400 + weap 2000 + fix +
120
+ # a 2tnk) — so the budget is NOT the binding constraint. The CLOCK is:
121
+ # the tech tier simply cannot come online in time. Cash is kept ample
122
+ # so the tech play's LOSS attributes cleanly to the wasted tier
123
+ # (time), not to running out of money.
124
+ starting_cash: 6000
125
+
126
+ base:
127
+ agent: {faction: allies}
128
+ # No scripted bot — the garrison is a fixed group of stance:3
129
+ # (AttackAnything) rifle infantry that advances on the agent base
130
+ # on its own. The intended e1 swarm fights it as a base defender.
131
+ enemy: {faction: soviet, bot_type: '', cash: 0}
132
+ tools: [observe, build, place_building, move_units, attack_move, stop]
133
+ spawn_mcvs: false
134
+ planning: true
135
+ # Re-decide the instant a garrison unit is spotted or one of our own
136
+ # is destroyed — the skip-to-cheap-path call is made from the live
137
+ # threat reading, not on fixed cadence.
138
+ interrupts:
139
+ enemy_unit_spotted: true
140
+ own_unit_destroyed: true
141
+ termination: {max_ticks: 12000}
142
+ actors: []
143
+
144
+ levels:
145
+ # ── EASY ─────────────────────────────────────────────────────────
146
+ # Bare step-pruning skill. Pre-placed fact + tent ⇒ e1 buildable
147
+ # turn 1. A light garrison of 5 rifle infantry (stance 3) advances
148
+ # from x=52. Training e1 and rallying them at the base front clears
149
+ # the 4-kill bar by ~tick 633 — a wide buffer under tick 1800. The
150
+ # tech-full-chain play has only powr+proc standing by the deadline
151
+ # and zero army ⇒ LOSS. max_turns 25 → reachable tick 93 + 90·24 =
152
+ # 2253 > 1801 ✓ (the deadline bites as a real LOSS).
153
+ easy:
154
+ description: >
155
+ Your base already has a Construction Yard (fact) and an Allied
156
+ barracks (tent) — so rifle infantry (e1, $100 each) are
157
+ buildable from turn 1 with NO prior tech step. A light enemy
158
+ garrison of 5 rifle infantry is advancing from the east. The
159
+ objective needs only basic units: train an e1 swarm and rally it
160
+ at your base front to meet the garrison. Do NOT climb the tech
161
+ chain to a war factory and tanks — that is a whole tech tier the
162
+ objective never asked for and it overruns the clock. Win when
163
+ ≥4 enemy units are killed AND your fact still stands, before
164
+ tick 1800. Stalling or teching to tanks both LOSE on the clock.
165
+ starting_cash: 6000
166
+ overrides:
167
+ actors:
168
+ - {type: fact, owner: agent, position: [10, 20]}
169
+ - {type: tent, owner: agent, position: [13, 20]}
170
+ # Light garrison — 5× e1, stance 3 (AttackAnything ⇒ advances
171
+ # on the agent base). An e1 swarm massed at the base clears
172
+ # them; the tech play never fields a tank in time.
173
+ - {type: e1, owner: enemy, position: [52, 20], stance: 3, count: 5}
174
+ # Far unarmed enemy fact marker — anti-DRAW. Keeps the
175
+ # episode alive past the last garrison death so a
176
+ # non-winner reaches the deadline as a real LOSS.
177
+ - {type: fact, owner: enemy, position: [120, 20]}
178
+ win_condition:
179
+ all_of:
180
+ - {units_killed_gte: 4}
181
+ - {building_count_gte: {type: fact, n: 1}}
182
+ - {within_ticks: 1800}
183
+ fail_condition:
184
+ any_of:
185
+ - {after_ticks: 1801}
186
+ - {not: {building_count_gte: {type: fact, n: 1}}}
187
+ max_turns: 25
188
+
189
+ # ── MEDIUM ───────────────────────────────────────────────────────
190
+ # +1 controlled variable: a LARGER garrison (7× e1) AND a TIGHTER
191
+ # deadline (tick 1600). The e1-swarm play still wins comfortably
192
+ # (~tick 633); the tech-full-chain play loses by an even wider
193
+ # margin. Same base. max_turns 23 → reachable tick 93 + 90·22 =
194
+ # 2073 > 1601 ✓.
195
+ medium:
196
+ description: >
197
+ Same base (fact + tent pre-placed, e1 buildable from turn 1) but
198
+ a larger enemy garrison (7 rifle infantry) and a tighter
199
+ deadline. The objective still needs only basic units: train an
200
+ e1 swarm and rally it at your base front. Climbing the tech
201
+ chain to a war factory and tanks is an unnecessary tier that
202
+ overruns the clock. Win when ≥6 enemy units are killed AND your
203
+ fact still stands, before tick 1600. Stalling or teching to
204
+ tanks both LOSE on the clock.
205
+ starting_cash: 6000
206
+ overrides:
207
+ actors:
208
+ - {type: fact, owner: agent, position: [10, 20]}
209
+ - {type: tent, owner: agent, position: [13, 20]}
210
+ - {type: e1, owner: enemy, position: [52, 20], stance: 3, count: 7}
211
+ - {type: fact, owner: enemy, position: [120, 20]}
212
+ win_condition:
213
+ all_of:
214
+ - {units_killed_gte: 6}
215
+ - {building_count_gte: {type: fact, n: 1}}
216
+ - {within_ticks: 1600}
217
+ fail_condition:
218
+ any_of:
219
+ - {after_ticks: 1601}
220
+ - {not: {building_count_gte: {type: fact, n: 1}}}
221
+ max_turns: 23
222
+
223
+ # ── HARD ─────────────────────────────────────────────────────────
224
+ # +1 controlled variable on top of medium: TWO agent spawn_point
225
+ # groups (NORTH base y=14 / SOUTH base y=26) round-robined by seed.
226
+ # The whole base (fact + tent) is duplicated at both latitudes so
227
+ # the e1 rally geometry must be read from the actual base position —
228
+ # a memorised opening cannot generalise. The garrison is duplicated
229
+ # at BOTH latitudes (enemy actors don't honour spawn_point —
230
+ # CLAUDE.md) so whichever spawn is chosen faces an equivalent
231
+ # garrison; the garrison at the agent's latitude advances on the
232
+ # base, the other angles toward the nearest foe. Same 6-kill bar
233
+ # and same tight tick-1600 deadline as medium. max_turns 23 →
234
+ # reachable tick 2073 > 1601 ✓.
235
+ hard:
236
+ description: >
237
+ Same step-pruning call as medium (clear a 7-rifle garrison with
238
+ a basic e1 swarm before tick 1600; skip the unnecessary war-
239
+ factory tech tier) but your base may begin in the NORTH band
240
+ (y≈14) or the SOUTH band (y≈26) depending on the seed. Read
241
+ your barracks' actual position from the observation, train the
242
+ e1 swarm and rally it at your base front. Win when ≥6 enemy
243
+ units are killed AND your fact still stands, before tick 1600.
244
+ Stalling or teching to tanks both LOSE on the clock.
245
+ starting_cash: 6000
246
+ overrides:
247
+ actors:
248
+ # ── AGENT spawn 0 — NORTH base (y=14) ─────────────────────
249
+ - {type: fact, owner: agent, position: [10, 14], spawn_point: 0}
250
+ - {type: tent, owner: agent, position: [13, 14], spawn_point: 0}
251
+ # Inert spawn-witness e1 (HoldFire, stance 0): required for
252
+ # the hard-tier units_summary spawn-variation check (the
253
+ # base is otherwise building-only). Placed base-adjacent;
254
+ # stance 0 means it never opens fire — it does not change
255
+ # the skip-vs-tech decision, only surfaces per-spawn data.
256
+ - {type: e1, owner: agent, position: [11, 14], spawn_point: 0, stance: 0}
257
+ # ── AGENT spawn 1 — SOUTH base (y=26) ─────────────────────
258
+ - {type: fact, owner: agent, position: [10, 26], spawn_point: 1}
259
+ - {type: tent, owner: agent, position: [13, 26], spawn_point: 1}
260
+ - {type: e1, owner: agent, position: [11, 26], spawn_point: 1, stance: 0}
261
+ # Garrison duplicated at both latitudes — enemy actors don't
262
+ # honour spawn_point, so both place every seed; the garrison
263
+ # at the agent's latitude advances on the base.
264
+ - {type: e1, owner: enemy, position: [52, 14], stance: 3, count: 7}
265
+ - {type: e1, owner: enemy, position: [52, 26], stance: 3, count: 7}
266
+ # Far unarmed enemy fact marker — anti-DRAW (no spawn_point
267
+ # ⇒ always places).
268
+ - {type: fact, owner: enemy, position: [120, 20]}
269
+ win_condition:
270
+ all_of:
271
+ - {units_killed_gte: 6}
272
+ - {building_count_gte: {type: fact, n: 1}}
273
+ - {within_ticks: 1600}
274
+ fail_condition:
275
+ any_of:
276
+ - {after_ticks: 1601}
277
+ - {not: {building_count_gte: {type: fact, n: 1}}}
278
+ max_turns: 23
tests/test_build_tech_skip_decision.py ADDED
@@ -0,0 +1,365 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """build-tech-skip-decision — REASONING capability validation.
2
+
3
+ Skip an unnecessary tech tier (PlanBench unnecessary-step pruning /
4
+ lean process / YAGNI). The objective only needs BASIC infantry: the
5
+ agent starts with a pre-placed Construction Yard (fact) + Allied
6
+ barracks (tent), so rifle infantry (e1, $100) are trainable from turn
7
+ 1 with no prior tech step. A light enemy garrison of rifle infantry
8
+ (stance 3, AttackAnything) advances on the base. Training e1 and
9
+ rallying them at the base front clears the kill bar comfortably.
10
+
11
+ The trap is to climb the full tech chain to medium tanks
12
+ (powr -> proc -> weap + fix -> 2tnk) — a whole tech tier the
13
+ objective never asked for. It burns the clock: by the deadline the
14
+ tech play has only powr+proc standing and ZERO army.
15
+
16
+ Bar (CLAUDE.md "no defect, no cheat, no draw"):
17
+
18
+ * stall (observe-only) LOSES every tier / every hard seed —
19
+ 0 kills, the `after_ticks` deadline bites.
20
+ * tech-full-chain (build powr -> proc -> weap -> fix, then 2tnk)
21
+ LOSES every tier / every hard seed — the unneeded tier burns the
22
+ clock; no tank fields before the deadline, kill bar unmet.
23
+ * intended skip-to-e1 (train e1 from the pre-placed tent from turn
24
+ 1, rally them at the base front, let them auto-fire on the
25
+ closing garrison) WINS every tier / every hard seed — the kill
26
+ bar is met AND the fact survives, well inside the deadline.
27
+ * hard tier defines >=2 agent spawn_point groups (NORTH base y=14
28
+ / SOUTH base y=26) round-robined by seed; the garrison is
29
+ duplicated at both latitudes (enemy actors don't honour
30
+ spawn_point — CLAUDE.md), so a memorised base-latitude opening
31
+ cannot generalise.
32
+ """
33
+
34
+ from __future__ import annotations
35
+
36
+ from pathlib import Path
37
+
38
+ import pytest
39
+
40
+ pytest.importorskip("openra_train", reason="Rust env wheel not installed")
41
+ pytest.importorskip("openra_rl_training", reason="Rust env wheel not installed")
42
+
43
+ from openra_bench.eval_core import run_level
44
+ from openra_bench.scenarios import load_pack
45
+ from openra_bench.scenarios.loader import PACKS_DIR, compile_level
46
+ from openra_bench.scenarios.win_conditions import WinContext, evaluate
47
+
48
+ PACK = PACKS_DIR / "build-tech-skip-decision.yaml"
49
+ LEVELS = ("easy", "medium", "hard")
50
+ SEEDS = (1, 2, 3, 4)
51
+
52
+
53
+ # ── scripted policies ───────────────────────────────────────────────
54
+
55
+
56
+ def _stall(rs, C):
57
+ """Observe-only — no production. 0 kills, the after_ticks
58
+ deadline bites -> LOSS."""
59
+ return [C.observe()]
60
+
61
+
62
+ def _tech_full_chain(rs, C):
63
+ """The wrong call: climb the full tech chain (powr -> proc ->
64
+ weap -> fix -> 2tnk) instead of training basic infantry. The
65
+ unneeded tier burns the clock — no tank fields before the
66
+ deadline -> the kill bar is unmet -> LOSS."""
67
+ ob = rs.get("own_buildings") or []
68
+ bt = {b.get("type") for b in ob}
69
+ fy = 20
70
+ for b in ob:
71
+ if b.get("type") == "fact":
72
+ fy = int(b["cell_y"])
73
+ prod = [p.get("item") for p in (rs.get("production") or []) if isinstance(p, dict)]
74
+ cmds = []
75
+ if "powr" not in bt:
76
+ if "powr" not in prod:
77
+ cmds.append(C.build("powr"))
78
+ cmds.append(C.place_building("powr", 17, fy))
79
+ elif "proc" not in bt:
80
+ if "proc" not in prod:
81
+ cmds.append(C.build("proc"))
82
+ cmds.append(C.place_building("proc", 20, fy))
83
+ elif "weap" not in bt:
84
+ if "weap" not in prod:
85
+ cmds.append(C.build("weap"))
86
+ cmds.append(C.place_building("weap", 23, fy))
87
+ elif "fix" not in bt:
88
+ if "fix" not in prod:
89
+ cmds.append(C.build("fix"))
90
+ cmds.append(C.place_building("fix", 23, fy + 4))
91
+ else:
92
+ cmds.append(C.build("2tnk"))
93
+ return cmds or [C.observe()]
94
+
95
+
96
+ def _skip_to_e1(rs, C):
97
+ """The intended capability — skip the tech tier. Train e1 from
98
+ the pre-placed tent from turn 1 and rally them at the base front;
99
+ they auto-fire on the closing garrison and clear the kill bar."""
100
+ units = rs.get("units_summary") or []
101
+ ob = rs.get("own_buildings") or []
102
+ fy = 20
103
+ for b in ob:
104
+ if b.get("type") == "tent":
105
+ fy = int(b["cell_y"])
106
+ ids = [u.get("id") for u in units if u.get("type") == "e1"]
107
+ cmds = [C.build("e1")]
108
+ if ids:
109
+ cmds.append(C.move_units(ids, 18, fy))
110
+ return cmds
111
+
112
+
113
+ # ── structural tests ────────────────────────────────────────────────
114
+
115
+
116
+ def test_pack_loads_and_meta_active():
117
+ pack = load_pack(PACK)
118
+ assert pack.meta.id == "build-tech-skip-decision"
119
+ assert pack.meta.capability == "reasoning"
120
+ assert pack.meta.real_world_meaning
121
+ assert pack.meta.robotics_analogue
122
+ anchors = " ".join(pack.meta.benchmark_anchor).lower()
123
+ assert "planbench" in anchors, anchors
124
+ assert "pruning" in anchors, anchors
125
+ assert "yagni" in anchors, anchors
126
+ assert "lean process" in anchors, anchors
127
+
128
+
129
+ def test_tools_include_build_and_combat_surface():
130
+ """Pack must expose [observe, build, place_building, move_units,
131
+ attack_move, stop] — the build-and-engage interaction surface."""
132
+ pack = load_pack(PACK)
133
+ tools = set(pack.base.get("tools", []) if isinstance(pack.base, dict) else [])
134
+ for required in ("observe", "build", "place_building", "move_units",
135
+ "attack_move", "stop"):
136
+ assert required in tools, f"missing tool: {required!r}"
137
+
138
+
139
+ def test_preplaced_base_is_fact_plus_tent_no_higher_tech():
140
+ """Every level pre-places fact + tent for the agent — the cheap
141
+ e1 path must be actionable on turn 1 (tent present) with NO
142
+ higher-tech building (powr / proc / weap / fix) handed to the
143
+ agent: building the tech tier is what the agent must AVOID. The
144
+ hard tier additionally carries an inert HoldFire e1 spawn-witness
145
+ per spawn group (units_summary spawn-variation contract)."""
146
+ pack = load_pack(PACK)
147
+ higher_tech = {"powr", "proc", "weap", "fix", "dome", "apwr"}
148
+ for lvl in LEVELS:
149
+ c = compile_level(pack, lvl)
150
+ agent_types = {a.type for a in c.scenario.actors if a.owner == "agent"}
151
+ assert "fact" in agent_types, f"{lvl}: agent base missing fact"
152
+ assert "tent" in agent_types, f"{lvl}: agent base missing tent"
153
+ assert not (agent_types & higher_tech), (
154
+ f"{lvl}: agent must NOT start with a higher-tech building; "
155
+ f"got {sorted(agent_types & higher_tech)}"
156
+ )
157
+ # only fact / tent / (hard) the inert e1 witness
158
+ assert agent_types <= {"fact", "tent", "e1"}, (
159
+ f"{lvl}: unexpected agent actor types {sorted(agent_types)}"
160
+ )
161
+
162
+
163
+ def test_garrison_is_light_infantry_stance3():
164
+ """The enemy garrison is basic rifle infantry (e1) at stance 3
165
+ (AttackAnything — advances on the agent base). No tanks / no
166
+ higher-tech enemy units."""
167
+ pack = load_pack(PACK)
168
+ for lvl in LEVELS:
169
+ c = compile_level(pack, lvl)
170
+ garrison = [
171
+ a for a in c.scenario.actors
172
+ if a.owner == "enemy" and a.type == "e1"
173
+ ]
174
+ assert garrison, f"{lvl}: must have an e1 garrison"
175
+ for a in garrison:
176
+ assert a.stance == 3, (
177
+ f"{lvl}: garrison e1 must be stance 3 (AttackAnything); "
178
+ f"got {a.stance}"
179
+ )
180
+ # only e1 + the far fact marker on the enemy side
181
+ enemy_types = {a.type for a in c.scenario.actors if a.owner == "enemy"}
182
+ assert enemy_types == {"e1", "fact"}, (
183
+ f"{lvl}: enemy actors must be {{e1, fact}}; got {sorted(enemy_types)}"
184
+ )
185
+
186
+
187
+ def test_far_enemy_fact_marker_present():
188
+ """A persistent unarmed enemy fact marker far east keeps the
189
+ episode alive past the last garrison death (CLAUDE.md auto-done
190
+ footgun) so a non-winner reaches the deadline as a real LOSS."""
191
+ pack = load_pack(PACK)
192
+ for lvl in LEVELS:
193
+ c = compile_level(pack, lvl)
194
+ far = [
195
+ a for a in c.scenario.actors
196
+ if a.owner == "enemy" and a.type == "fact" and a.position[0] >= 100
197
+ ]
198
+ assert far, f"{lvl}: missing far enemy fact marker (anti-DRAW)"
199
+
200
+
201
+ def test_every_level_has_reachable_timeout_fail():
202
+ """`after_ticks` fail must bite WITHIN max_turns (so stall / tech
203
+ are a real reachable LOSS, not a draw). within_ticks + 1 ==
204
+ after_ticks so a non-finisher LOSES one tick past the window."""
205
+ pack = load_pack(PACK)
206
+ for lvl in LEVELS:
207
+ L = pack.levels[lvl]
208
+ ceiling = 93 + 90 * (L.max_turns - 1)
209
+ wt = next(
210
+ int(c["within_ticks"])
211
+ for c in L.win_condition.model_dump()["all_of"]
212
+ if "within_ticks" in c
213
+ )
214
+ ft = next(
215
+ int(c["after_ticks"])
216
+ for c in L.fail_condition.model_dump()["any_of"]
217
+ if "after_ticks" in c
218
+ )
219
+ assert wt < ceiling, f"{lvl}: within_ticks {wt} >= ceiling {ceiling}"
220
+ assert ft <= ceiling, f"{lvl}: after_ticks {ft} > ceiling {ceiling}"
221
+ assert wt + 1 == ft, (
222
+ f"{lvl}: within_ticks {wt} / after_ticks {ft} mismatch "
223
+ "(boundary non-finisher must LOSE, not draw)"
224
+ )
225
+
226
+
227
+ def test_every_level_has_a_fail_condition():
228
+ pack = load_pack(PACK)
229
+ for lvl in LEVELS:
230
+ c = compile_level(pack, lvl)
231
+ assert c.fail_condition is not None, f"{lvl} needs a fail_condition"
232
+
233
+
234
+ def test_hard_has_two_seed_driven_spawn_groups():
235
+ c = compile_level(load_pack(PACK), "hard")
236
+ sp = {
237
+ (a.spawn_point if a.spawn_point is not None else 0)
238
+ for a in c.scenario.actors
239
+ if a.owner == "agent"
240
+ }
241
+ assert sp == {0, 1}, f"hard must define exactly {{0, 1}}; got {sorted(sp)}"
242
+
243
+
244
+ def test_in_bounds_actors_on_every_level():
245
+ """rush-hour-arena playable bounds ~ x:2..126, y:2..38."""
246
+ pack = load_pack(PACK)
247
+ for lvl in LEVELS:
248
+ c = compile_level(pack, lvl)
249
+ for a in c.scenario.actors:
250
+ x, y = a.position
251
+ assert 2 <= x <= 126 and 2 <= y <= 38, (
252
+ f"{lvl}: actor {a.type} at ({x},{y}) out of bounds"
253
+ )
254
+
255
+
256
+ # ── predicate-level (no engine) ─────────────────────────────────────
257
+
258
+
259
+ def _ctx(*, tick=0, kills=0, own_buildings=()):
260
+ import types
261
+
262
+ sig = types.SimpleNamespace(
263
+ game_tick=tick,
264
+ units_killed=kills,
265
+ units_lost=0,
266
+ cash=0,
267
+ resources=0,
268
+ own_buildings=list(own_buildings),
269
+ own_building_types={str(t).lower() for (t, _, _) in own_buildings},
270
+ enemies_seen_ids=set(),
271
+ enemy_buildings_seen_ids=set(),
272
+ )
273
+ return WinContext(signals=sig, render_state={"units_summary": []})
274
+
275
+
276
+ def test_predicates_enforce_capability():
277
+ """Win requires (kill bar AND fact alive AND in time); fail fires
278
+ on timeout OR fact destroyed."""
279
+ c = compile_level(load_pack(PACK), "easy")
280
+ base_b = [("fact", 10, 20), ("tent", 13, 20)]
281
+
282
+ # Intended: 4 kills, fact alive, in time -> WIN
283
+ assert evaluate(c.win_condition, _ctx(tick=900, kills=4, own_buildings=base_b))
284
+ # 3 kills (under bar) -> not win
285
+ assert not evaluate(
286
+ c.win_condition, _ctx(tick=900, kills=3, own_buildings=base_b)
287
+ )
288
+ # 4 kills but past within_ticks -> not win
289
+ assert not evaluate(
290
+ c.win_condition, _ctx(tick=1801, kills=4, own_buildings=base_b)
291
+ )
292
+ # 4 kills but fact destroyed -> not win
293
+ assert not evaluate(
294
+ c.win_condition, _ctx(tick=900, kills=4, own_buildings=base_b[1:])
295
+ )
296
+ # Past after_ticks deadline -> fail
297
+ assert evaluate(
298
+ c.fail_condition, _ctx(tick=1900, kills=0, own_buildings=base_b)
299
+ )
300
+ # Fact destroyed -> fail
301
+ assert evaluate(
302
+ c.fail_condition, _ctx(tick=900, kills=4, own_buildings=base_b[1:])
303
+ )
304
+ # Within deadline, fact alive -> not fail
305
+ assert not evaluate(
306
+ c.fail_condition, _ctx(tick=900, kills=0, own_buildings=base_b)
307
+ )
308
+
309
+
310
+ # ── engine-driven: every lazy / wrong policy LOSES, intended WINS ───
311
+
312
+
313
+ @pytest.mark.parametrize("level", LEVELS)
314
+ @pytest.mark.parametrize("seed", SEEDS)
315
+ def test_stall_loses_every_tier_and_seed(level, seed):
316
+ """Observe-only -> 0 kills + the after_ticks deadline bites ->
317
+ a real reachable LOSS, not a draw."""
318
+ c = compile_level(load_pack(PACK), level)
319
+ r = run_level(c, _stall, seed=seed)
320
+ assert r.outcome == "loss", (
321
+ f"{level}/seed{seed}: stall must LOSE; got {r.outcome} "
322
+ f"tick={r.signals.game_tick} kills={r.signals.units_killed}"
323
+ )
324
+
325
+
326
+ @pytest.mark.parametrize("level", LEVELS)
327
+ @pytest.mark.parametrize("seed", SEEDS)
328
+ def test_tech_full_chain_loses_every_tier_and_seed(level, seed):
329
+ """Climbing the full tech chain (powr -> proc -> weap -> fix ->
330
+ 2tnk) burns the clock on a tier the objective never required -> no
331
+ tank fields before the deadline -> kill bar unmet -> LOSS."""
332
+ c = compile_level(load_pack(PACK), level)
333
+ r = run_level(c, _tech_full_chain, seed=seed)
334
+ assert r.outcome == "loss", (
335
+ f"{level}/seed{seed}: tech-full-chain must LOSE (clock); "
336
+ f"got {r.outcome} tick={r.signals.game_tick} "
337
+ f"kills={r.signals.units_killed}"
338
+ )
339
+
340
+
341
+ @pytest.mark.parametrize("level", LEVELS)
342
+ @pytest.mark.parametrize("seed", SEEDS)
343
+ def test_skip_to_e1_wins_every_tier_and_seed(level, seed):
344
+ """The intended capability — skip the unneeded tech tier, train
345
+ e1 from the pre-placed tent and rally them at the base front.
346
+ Wins every tier and every hard seed, well inside the deadline."""
347
+ c = compile_level(load_pack(PACK), level)
348
+ r = run_level(c, _skip_to_e1, seed=seed)
349
+ assert r.outcome == "win", (
350
+ f"{level}/seed{seed}: skip-to-e1 must WIN; got {r.outcome} "
351
+ f"tick={r.signals.game_tick} kills={r.signals.units_killed}"
352
+ )
353
+
354
+
355
+ # ── determinism ─────────────────────────────────────────────────────
356
+
357
+
358
+ def test_skip_to_e1_run_is_deterministic_per_seed():
359
+ """Same seed, same policy -> identical outcome / kills / turns."""
360
+ c = compile_level(load_pack(PACK), "medium")
361
+ a = run_level(c, _skip_to_e1, seed=3)
362
+ b = run_level(c, _skip_to_e1, seed=3)
363
+ assert (a.outcome, a.turns, a.signals.units_killed) == (
364
+ b.outcome, b.turns, b.signals.units_killed
365
+ )