yxc20098 commited on
Commit
29fc4b5
·
1 Parent(s): 51c9ac1

feat(scenario): build-power-online-first — power-grid bring-up sequencing (PlanBench / SOP anchor)

Browse files
openra_bench/scenarios/packs/build-power-online-first.yaml ADDED
@@ -0,0 +1,243 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Validate: python -m openra_bench.scenarios.validate packs/build-power-online-first.yaml
2
+ # See ../CONTRIBUTING.md for the full win-condition grammar.
3
+ #
4
+ # BUILD-POWER-ONLINE-FIRST (Wave-7 Group I REASONING: opening-phase
5
+ # build-order — power-grid bring-up sequencing).
6
+ #
7
+ # Real-world anchor: electrical-grid bring-up sequencing — the grid
8
+ # (powr) must be ONLINE before any powered load (proc/barr/tent/weap)
9
+ # can come up. Benchmark anchor: PlanBench task-ordering / SOP
10
+ # (standard-operating-procedure) compliance.
11
+ #
12
+ # The decision is the OPENING order: from a pre-placed Construction
13
+ # Yard (`fact`) and a tight cash budget, the agent must build the
14
+ # Power Plant (`powr`) FIRST and only THEN the first production /
15
+ # economy building (`proc`). The order is enforced by the `then:[A,B]`
16
+ # composite (Wave-2 stateful happened-before latch) AND by the engine
17
+ # itself: `proc`/`tent`/`barr` all have `powr` as a hard prerequisite
18
+ # (OpenRA-Rust/openra-sim/src/gamerules.rs:340-343), so a
19
+ # StartProduction call for any of them is silently BLOCKED (`ORDER:
20
+ # StartProduction BLOCKED — missing prerequisites for proc`) when
21
+ # powr isn't up yet. A "build proc/barr/weap first" policy therefore
22
+ # never places ANY production building and never satisfies clause B
23
+ # of the `then` latch — and the tick deadline bites as a real LOSS
24
+ # (not a draw).
25
+ #
26
+ # Engine facts used (verified against the installed openra_train wheel):
27
+ #
28
+ # 1. Build costs (gamerules.rs `actor!` macros):
29
+ # powr = 300 (no prereq, +100 power)
30
+ # proc = 1400 (prereq powr, −30 power)
31
+ # tent = 400 (prereq powr)
32
+ # barr = 300 (prereq powr)
33
+ # weap = 2000 (prereq proc)
34
+ # Intended chain spend: 300 (powr) + 1400 (proc) = $1700. The
35
+ # levels' starting_cash is sized to cover EXACTLY this chain
36
+ # (easy=1900 slack, medium=1750 tight, hard=1750 tight).
37
+ # 2. `proc` / `tent` / `barr` / `weap` all have `powr` as a
38
+ # prerequisite (gamerules.rs:340-343, and via proc for weap). A
39
+ # `Command.build('proc')` before any `powr` is queued is silently
40
+ # rejected by the engine — the production queue stays empty, the
41
+ # cash stays in the bank, no building ever appears.
42
+ # 3. The `then:[A,B]` composite has greedy late-credit semantics
43
+ # (see `tests/test_then_composite.py`); the discrimination here
44
+ # is provided by the TICK BUDGET, not by the latch alone. A wrong-
45
+ # order policy that endlessly retries proc-without-powr (or one
46
+ # that builds an army first and never gets to powr+proc) burns the
47
+ # clock and times out as a real LOSS.
48
+ # 4. Tick alignment: engine advances ~90 ticks per decision turn,
49
+ # so within_ticks ≤ 93 + 90·(max_turns − 1) is required (CLAUDE.md).
50
+ # fail_condition uses `after_ticks = within_ticks + 1` so a non-
51
+ # finisher LOSES on the very next tick after the window closes.
52
+ # 5. The persistent unarmed enemy `fact` MARKER far away keeps the
53
+ # engine's auto-`done` (all-enemies-eliminated) path gated, so
54
+ # non-winners reach the deadline as LOSS (never DRAW). The agent
55
+ # has no offensive tools, so the enemy `fact` cannot be destroyed.
56
+ # 6. The `not has_building: fact` fail clause guards against the
57
+ # edge case where the agent's own pre-placed `fact` is destroyed
58
+ # (here essentially impossible — no enemy aggression — but kept
59
+ # as a defensive guard and per CLAUDE.md idiom).
60
+ # 7. Hard tier: `spawn_point` filter applies ONLY to AGENT actors,
61
+ # and ANY agent actor with spawn_point causes agent actors WITHOUT
62
+ # spawn_point to be filtered out (CLAUDE.md oramap.rs footgun).
63
+ # So the agent `fact` must be DUPLICATED across both spawn_point
64
+ # groups at the spawn-matched coords; the enemy `fact` marker
65
+ # has no spawn_point and always places.
66
+ # 8. Hard tier carries one inert `e1` infantry per spawn group
67
+ # (HoldFire stance, no `move_units`/`attack_unit` tool exposed,
68
+ # far from the enemy `fact` and out of any auto-fire range) so
69
+ # `tests/test_hard_tier.py::test_curated_hard_still_compiles_and_runs`
70
+ # can observe the seed-driven spawn variation via `units_summary`
71
+ # (its assertion reads unit cells, not building cells — a
72
+ # building-only pack would silently produce empty `units_summary`
73
+ # on every seed and break the contract). The `e1` is functionally
74
+ # inert: with `move_units`/`attack_unit` absent from `base.tools`
75
+ # it cannot be ordered, and with HoldFire stance + the enemy
76
+ # `fact` ~100 cells away it cannot auto-fire either, so it does
77
+ # not interact with the SOP-compliance decision.
78
+
79
+ meta:
80
+ id: build-power-online-first
81
+ title: 'Build Power Online First — Grid Bring-Up Sequencing'
82
+ capability: reasoning
83
+ real_world_meaning: >
84
+ Standard-operating-procedure (SOP) compliance under a strict
85
+ happened-before constraint, exemplified by electrical-grid bring-
86
+ up: the grid (the Power Plant) must be ONLINE before any powered
87
+ load (the Refinery, Barracks, War Factory) can come up. The
88
+ opening decision is the entire test — from a pre-placed
89
+ Construction Yard and a tight budget, the agent must queue the
90
+ Power Plant FIRST and only THEN the first production / economy
91
+ building. A model that "just builds the goal building" (the
92
+ refinery) first finds the order silently blocked (the engine
93
+ enforces powr as a prerequisite of proc), wastes the budget
94
+ retrying, and times out. A model that stalls or builds an army
95
+ instead also misses the bar.
96
+ robotics_analogue: >
97
+ Standard operating procedure with a hard happened-before
98
+ constraint: a robotic operator commissioning a new processing
99
+ site must energise the substation BEFORE bringing up the
100
+ refinery (or any other powered load). Attempting the refinery
101
+ first is rejected by the interlock; reasoning about the order
102
+ of operations from the spec is the entire decision.
103
+ author: wave7-groupI-buildorder
104
+ benchmark_anchor:
105
+ - "PlanBench"
106
+ - "SOP compliance"
107
+ - "electrical-grid bring-up"
108
+
109
+ base_map: rush-hour-arena
110
+ starting_cash: 1900
111
+
112
+ base:
113
+ agent: {faction: allies}
114
+ enemy: {faction: soviet, cash: 0}
115
+ # Pure build-order test: no deploy, no harvest, no move/attack tools.
116
+ # The decision is exclusively the SEQUENCE of build + place_building.
117
+ tools: [observe, build, place_building, stop]
118
+ spawn_mcvs: false
119
+ planning: true
120
+ termination: {max_ticks: 8000}
121
+ actors: []
122
+
123
+ levels:
124
+ # ── EASY ─────────────────────────────────────────────────────────
125
+ # Bare SOP compliance: powr THEN proc, generous clock. fact pre-
126
+ # placed at (20,20). max_turns 40 → reachable tick 93 + 90·39 = 3603
127
+ # > 3600 ✓. Generous slack ($1900 − $1700 = $200) covers exactly
128
+ # the chain; the model cannot also blow the budget on a single 400-
129
+ # cost mis-build (tent) and still complete the chain.
130
+ easy:
131
+ description: >
132
+ You start with a Construction Yard ('fact') at (20,20) and
133
+ $1900. Build a Power Plant ('powr', cost 300) FIRST, then an
134
+ Ore Refinery ('proc', cost 1400). Win = own a 'powr' AND own
135
+ a 'proc' IN THAT ORDER (the powr must appear FIRST), before
136
+ tick 3600. The Refinery REQUIRES a Power Plant — queuing
137
+ 'proc' first is silently rejected by the engine. Stalling,
138
+ building infantry/army, or attempting the Refinery before the
139
+ Power Plant all miss the clock and LOSE.
140
+ starting_cash: 1900
141
+ overrides:
142
+ actors:
143
+ - {type: fact, owner: agent, position: [20, 20]}
144
+ # Persistent far enemy marker — LOSS-not-DRAW guarantee.
145
+ - {type: fact, owner: enemy, position: [120, 20]}
146
+ win_condition:
147
+ all_of:
148
+ - then:
149
+ id: build-power-online-first-E
150
+ clauses:
151
+ - {has_building: powr}
152
+ - {building_count_gte: {type: proc, n: 1}}
153
+ - within_ticks: 3600
154
+ fail_condition:
155
+ any_of:
156
+ - after_ticks: 3601
157
+ - not: {has_building: fact}
158
+ max_turns: 40
159
+
160
+ # ── MEDIUM ───────────────────────────────────────────────────────
161
+ # +1 controlled variable: TIGHTER CASH ($1750 = $50 slack only) AND
162
+ # tighter clock. A single mis-build (e.g. a $400 tent attempt that
163
+ # gets QUEUED with no power and never completes — cash still spent
164
+ # by StartProduction? actually engine refunds blocked; but a $300
165
+ # powr that the model rebuilds twice does drain) tightens the
166
+ # margin. max_turns 32 → reachable tick 93 + 90·31 = 2883 > 2880 ✓.
167
+ medium:
168
+ description: >
169
+ Same opening: 'fact' at (20,20), no power, no economy. Build
170
+ 'powr' FIRST (cost 300), THEN 'proc' (cost 1400). Tighter
171
+ budget ($1750 — covers the chain with $50 to spare) and
172
+ tighter clock (tick 2880). The Refinery REQUIRES the Power
173
+ Plant — queuing 'proc' first is silently rejected. Stalling,
174
+ army-building, or attempting 'proc' before 'powr' all run out
175
+ the clock and LOSE.
176
+ starting_cash: 1750
177
+ overrides:
178
+ actors:
179
+ - {type: fact, owner: agent, position: [20, 20]}
180
+ - {type: fact, owner: enemy, position: [120, 20]}
181
+ win_condition:
182
+ all_of:
183
+ - then:
184
+ id: build-power-online-first-M
185
+ clauses:
186
+ - {has_building: powr}
187
+ - {building_count_gte: {type: proc, n: 1}}
188
+ - within_ticks: 2880
189
+ fail_condition:
190
+ any_of:
191
+ - after_ticks: 2881
192
+ - not: {has_building: fact}
193
+ max_turns: 32
194
+
195
+ # ── HARD ─────────────────────────────────────────────────────────
196
+ # +1 controlled variable on top of medium: TWO `fact` spawn_point
197
+ # groups (NORTH y=10..14 / SOUTH y=26..30) round-robined by seed.
198
+ # The agent's pre-placed `fact` (and therefore the build radius and
199
+ # the placement coords for powr/proc) flips per seed, so a
200
+ # memorised "(20,20) opening" cannot generalise. Same tight cash
201
+ # ($1750) and same tight clock as medium; the SOP-compliance
202
+ # decision is identical, but the placement coordinates must be
203
+ # read from the actual fact position. max_turns 32 → reachable
204
+ # tick 2883 > 2880 ✓.
205
+ hard:
206
+ description: >
207
+ Same opening as medium (powr FIRST then proc, tight $1750
208
+ budget, tick 2880 clock) but your Construction Yard may begin
209
+ in the NORTH band (y≈12) or the SOUTH band (y≈28) of the map
210
+ depending on the seed. Read the fact's actual position from
211
+ the observation and place 'powr' and 'proc' adjacent to it.
212
+ A memorised "(20,20)" opening will mis-place the buildings
213
+ out-of-radius on one of the two spawns. The Refinery still
214
+ REQUIRES the Power Plant; out-of-order builds are silently
215
+ rejected and the clock runs out as a LOSS.
216
+ starting_cash: 1750
217
+ overrides:
218
+ actors:
219
+ # Two agent `fact` spawn groups (round-robin by seed).
220
+ # NORTH band y=10..14; SOUTH band y=26..30.
221
+ - {type: fact, owner: agent, position: [20, 12], spawn_point: 0}
222
+ - {type: fact, owner: agent, position: [20, 28], spawn_point: 1}
223
+ # Inert spawn-witness e1 per group (see engine fact #8 above
224
+ # — needed for the hard-tier units_summary spawn-variation
225
+ # check; HoldFire + no tools to command + enemy ~100 cells
226
+ # away makes this functionally inert for the SOP test).
227
+ - {type: e1, owner: agent, position: [22, 12], spawn_point: 0, stance: 0}
228
+ - {type: e1, owner: agent, position: [22, 28], spawn_point: 1, stance: 0}
229
+ # Persistent enemy marker — LOSS-not-DRAW guarantee.
230
+ - {type: fact, owner: enemy, position: [120, 20]}
231
+ win_condition:
232
+ all_of:
233
+ - then:
234
+ id: build-power-online-first-H
235
+ clauses:
236
+ - {has_building: powr}
237
+ - {building_count_gte: {type: proc, n: 1}}
238
+ - within_ticks: 2880
239
+ fail_condition:
240
+ any_of:
241
+ - after_ticks: 2881
242
+ - not: {has_building: fact}
243
+ max_turns: 32
tests/test_build_power_online_first.py ADDED
@@ -0,0 +1,335 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """No-cheat + solvency proof for `build-power-online-first` (Wave-7
2
+ Group I REASONING: opening-phase build-order — power-grid bring-up
3
+ sequencing).
4
+
5
+ The pack tests the SOP-compliance opening: from a pre-placed
6
+ Construction Yard and a tight cash budget, the agent must build the
7
+ Power Plant (`powr`) FIRST and only THEN the Refinery (`proc`). The
8
+ order is enforced both by the `then:[A,B]` composite (Wave-2 stateful
9
+ happened-before latch) AND by the engine itself (powr is a hard
10
+ prerequisite of proc/tent/barr; weap needs proc).
11
+
12
+ For every level + every hard seed (1-4):
13
+ * the INTENDED order (build powr → place powr → build proc → place
14
+ proc) WINS;
15
+ * STALL (only `observe`) LOSES — never builds anything → win
16
+ clauses fail, deadline bites as a real timeout LOSS;
17
+ * PROC-FIRST (always tries `build('proc')` first, never powr) LOSES
18
+ — the engine silently rejects StartProduction for proc without
19
+ powr; no production building ever appears; deadline bites;
20
+ * ARMY-SPAM (only `build('e1')`, never builds any structure) LOSES
21
+ — no barracks, no powr, no proc; deadline bites.
22
+
23
+ The 3 lazy plays + 1 intended × 3 levels × 4 seeds gives the full
24
+ no-defect / no-cheat coverage demanded by CLAUDE.md.
25
+ """
26
+
27
+ from __future__ import annotations
28
+
29
+ import pytest
30
+
31
+ pytest.importorskip("openra_train", reason="Rust env wheel not installed")
32
+ pytest.importorskip("openra_rl_training", reason="Rust env wheel not installed")
33
+
34
+ from openra_bench.eval_core import run_level
35
+ from openra_bench.scenarios import load_pack
36
+ from openra_bench.scenarios.loader import PACKS_DIR, compile_level
37
+
38
+ PACK = PACKS_DIR / "build-power-online-first.yaml"
39
+ LEVELS = ("easy", "medium", "hard")
40
+ SEEDS = (1, 2, 3, 4)
41
+
42
+
43
+ # ───────────────────────── scripted policies ──────────────────────────────
44
+
45
+
46
+ def _stall(rs, Command):
47
+ """Idle: never builds anything → win clauses fail → LOSS on the
48
+ deadline."""
49
+ return [Command.observe()]
50
+
51
+
52
+ def _proc_first(rs, Command):
53
+ """Always tries `build('proc')` first; NEVER tries powr. The engine
54
+ silently rejects StartProduction for proc without powr (CLAUDE.md
55
+ engine fact #2; world.rs:768 'ORDER: StartProduction BLOCKED —
56
+ missing prerequisites for proc'). No proc ever appears → clause B
57
+ of the `then` latch never satisfies → LOSS on the deadline. This
58
+ is the headline wrong-order discriminator."""
59
+
60
+ def _fact(rs):
61
+ return next(
62
+ (b for b in (rs.get("own_buildings", []) or []) if b["type"] == "fact"),
63
+ None,
64
+ )
65
+
66
+ fact_b = _fact(rs)
67
+ if fact_b is None:
68
+ return [Command.observe()]
69
+ fx, fy = fact_b["cell_x"], fact_b["cell_y"]
70
+ prod = rs.get("production", []) or []
71
+ own_types = {
72
+ b["type"] for b in (rs.get("own_buildings", []) or [])
73
+ }
74
+ cmds = []
75
+ if "proc" not in prod and "proc" not in own_types:
76
+ cmds.append(Command.build("proc"))
77
+ cmds.append(Command.place_building("proc", fx + 3, fy + 3))
78
+ return cmds
79
+
80
+
81
+ def _army_spam(rs, Command):
82
+ """Brute army-spam: only `build('e1')`. e1 needs a tent (barracks),
83
+ which needs powr — neither exists, so every `build('e1')` is
84
+ silently blocked. No powr / no proc / no barracks ever appear →
85
+ LOSS on the deadline."""
86
+ bldgs = rs.get("own_buildings", []) or []
87
+ fact_b = next((b for b in bldgs if b["type"] == "fact"), None)
88
+ if fact_b is None:
89
+ return [Command.observe()]
90
+ return [Command.build("e1")]
91
+
92
+
93
+ def _intended(rs, Command):
94
+ """Intended SOP: build powr FIRST (cost 300), place it adjacent to
95
+ the fact; then build proc (cost 1400, now unblocked), place it
96
+ adjacent to the fact. Wins every tier × every seed before the
97
+ deadline."""
98
+ bldgs = rs.get("own_buildings", []) or []
99
+ own_types = {b["type"] for b in bldgs}
100
+ prod = rs.get("production", []) or []
101
+ fact_b = next((b for b in bldgs if b["type"] == "fact"), None)
102
+ if fact_b is None:
103
+ return [Command.observe()]
104
+ fx, fy = fact_b["cell_x"], fact_b["cell_y"]
105
+ if "powr" not in own_types:
106
+ cmds = []
107
+ if "powr" not in prod:
108
+ cmds.append(Command.build("powr"))
109
+ cmds.append(Command.place_building("powr", fx + 3, fy + 1))
110
+ return cmds
111
+ if "proc" not in own_types:
112
+ cmds = []
113
+ if "proc" not in prod:
114
+ cmds.append(Command.build("proc"))
115
+ cmds.append(Command.place_building("proc", fx + 3, fy + 3))
116
+ return cmds
117
+ return [Command.observe()]
118
+
119
+
120
+ # ───────────────────────── helpers ────────────────────────────────────────
121
+
122
+
123
+ def _run(level, policy, seed=1):
124
+ c = compile_level(load_pack(PACK), level)
125
+ assert c.map_supported, "rush-hour-arena must compile"
126
+ return c, run_level(c, policy, seed=seed)
127
+
128
+
129
+ # ───────────────────────── structural ─────────────────────────────────────
130
+
131
+
132
+ def test_pack_loads_with_three_levels_and_required_tools():
133
+ pack = load_pack(PACK)
134
+ assert pack.meta.id == "build-power-online-first"
135
+ assert pack.meta.capability == "reasoning"
136
+ assert pack.meta.status == "active"
137
+ assert set(pack.levels) == set(LEVELS)
138
+ c = compile_level(pack, "easy")
139
+ tools = set(c.scenario.tools or [])
140
+ # Wave-7 spec: pure build-order test — no deploy / harvest /
141
+ # move_units / attack_unit. Just observe + build + place + stop.
142
+ for t in ("observe", "build", "place_building", "stop"):
143
+ assert t in tools, f"missing tool {t} in {tools}"
144
+ for forbidden in ("move_units", "attack_unit", "deploy", "harvest"):
145
+ assert forbidden not in tools, (
146
+ f"tool {forbidden!r} must NOT be in the SOP-only palette"
147
+ )
148
+
149
+
150
+ def test_benchmark_anchor_lists_planbench_and_grid_bringup():
151
+ pack = load_pack(PACK)
152
+ anchors = pack.meta.benchmark_anchor
153
+ assert anchors, "benchmark_anchor must be non-empty"
154
+ blob = " | ".join(anchors).lower()
155
+ assert "planbench" in blob, anchors
156
+ assert "sop" in blob, anchors
157
+ assert "electrical" in blob and "grid" in blob, anchors
158
+
159
+
160
+ @pytest.mark.parametrize("level", LEVELS)
161
+ def test_every_level_has_a_reachable_timeout_fail(level):
162
+ """Non-win must be a real LOSS, never a DRAW: after_ticks in
163
+ fail_condition must be reachable within max_turns (tick ≈ 93 +
164
+ 90·(max_turns − 1))."""
165
+ c = compile_level(load_pack(PACK), level)
166
+ assert c.fail_condition is not None
167
+ after_ticks = int(c.fail_condition.model_dump()["any_of"][0]["after_ticks"])
168
+ reachable = 93 + 90 * (c.max_turns - 1)
169
+ assert after_ticks <= reachable, (
170
+ f"{level}: fail after_ticks {after_ticks} unreachable within "
171
+ f"{c.max_turns} turns (max tick {reachable}) → draw degeneracy"
172
+ )
173
+ # within_ticks + 1 == after_ticks (non-finisher LOSES on the very
174
+ # next tick after the win window closes).
175
+ within_clauses = c.win_condition.model_dump().get("all_of", [])
176
+ wt = next(
177
+ int(x["within_ticks"]) for x in within_clauses if "within_ticks" in x
178
+ )
179
+ assert after_ticks == wt + 1, (
180
+ f"{level}: after_ticks {after_ticks} must equal within_ticks+1 ({wt+1})"
181
+ )
182
+
183
+
184
+ @pytest.mark.parametrize("level", LEVELS)
185
+ def test_win_uses_then_composite_powr_before_proc(level):
186
+ """Structural: the win clause uses a `then` composite with two
187
+ clauses in the exact order [powr, proc] — this is the engine-side
188
+ SOP-compliance enforcement (CLAUDE.md ordering idiom)."""
189
+ c = compile_level(load_pack(PACK), level)
190
+ win = c.win_condition.model_dump()
191
+ all_of = win.get("all_of", [])
192
+ then_node = next(
193
+ (x["then"] for x in all_of if "then" in x), None
194
+ )
195
+ assert then_node is not None, f"{level}: win must include a `then` composite"
196
+ clauses = then_node.get("clauses", [])
197
+ assert len(clauses) == 2, f"{level}: then must have 2 clauses (powr, proc)"
198
+ # First clause: has_building powr; second: building_count_gte proc>=1.
199
+ assert clauses[0].get("has_building") == "powr", clauses[0]
200
+ bc = clauses[1].get("building_count_gte") or {}
201
+ assert bc.get("type") == "proc" and int(bc.get("n", 0)) >= 1, clauses[1]
202
+
203
+
204
+ def test_hard_has_two_spawn_groups_for_fact():
205
+ """Hard tier contract (CLAUDE.md + tests/test_hard_tier.py): ≥2
206
+ distinct agent spawn_point groups (NORTH y≈12 / SOUTH y≈28) so
207
+ seed round-robin varies the pre-placed fact and a memorised
208
+ "(20,20)" opening can't generalise."""
209
+ c = compile_level(load_pack(PACK), "hard")
210
+ sps = {
211
+ (a.spawn_point if a.spawn_point is not None else 0)
212
+ for a in c.scenario.actors
213
+ if a.owner == "agent"
214
+ }
215
+ assert len(sps) >= 2, f"hard needs ≥2 spawn groups, got {sps}"
216
+
217
+
218
+ # ───────────────────────── intended WIN ───────────────────────────────────
219
+
220
+
221
+ @pytest.mark.parametrize("level", LEVELS)
222
+ @pytest.mark.parametrize("seed", SEEDS)
223
+ def test_intended_powr_first_then_proc_wins(level, seed):
224
+ """Intended build powr → place powr → build proc → place proc WINS
225
+ every level × every seed before the deadline."""
226
+ c, r = _run(level, _intended, seed=seed)
227
+ assert r.outcome == "win", (
228
+ f"{level} seed{seed}: intended powr-first chain should WIN, "
229
+ f"got {r.outcome}; types={r.signals.own_building_types}, "
230
+ f"cash={r.signals.cash}, tick={r.signals.game_tick}"
231
+ )
232
+ types = set(r.signals.own_building_types)
233
+ assert "powr" in types, types
234
+ assert "proc" in types, types
235
+
236
+
237
+ # ───────────────────────── no-cheat: lazy plays LOSE ──────────────────────
238
+
239
+
240
+ @pytest.mark.parametrize("level", LEVELS)
241
+ @pytest.mark.parametrize("seed", SEEDS)
242
+ def test_stall_loses(level, seed):
243
+ """Stall must LOSE every level × every seed — never builds anything
244
+ → both then-clauses fail → reachable timeout LOSS (not a draw)."""
245
+ c, r = _run(level, _stall, seed=seed)
246
+ assert r.outcome == "loss", (
247
+ f"{level} seed{seed} stall must LOSE; got {r.outcome} "
248
+ f"(types={r.signals.own_building_types}, tick={r.signals.game_tick})"
249
+ )
250
+ # Never built powr or proc.
251
+ assert "powr" not in r.signals.own_building_types
252
+ assert "proc" not in r.signals.own_building_types
253
+
254
+
255
+ @pytest.mark.parametrize("level", LEVELS)
256
+ @pytest.mark.parametrize("seed", SEEDS)
257
+ def test_proc_first_wrong_order_loses(level, seed):
258
+ """The headline wrong-order discriminator: always tries `proc`
259
+ first, never powr. Engine silently rejects proc-without-powr
260
+ (world.rs:768 'StartProduction BLOCKED — missing prerequisites
261
+ for proc'); no production building ever appears → both then-
262
+ clauses fail → LOSS on the deadline."""
263
+ c, r = _run(level, _proc_first, seed=seed)
264
+ assert r.outcome == "loss", (
265
+ f"{level} seed{seed} proc-first must LOSE; got {r.outcome} "
266
+ f"(types={r.signals.own_building_types}, tick={r.signals.game_tick})"
267
+ )
268
+ assert "powr" not in r.signals.own_building_types
269
+ assert "proc" not in r.signals.own_building_types
270
+
271
+
272
+ @pytest.mark.parametrize("level", LEVELS)
273
+ @pytest.mark.parametrize("seed", SEEDS)
274
+ def test_army_spam_brute_loses(level, seed):
275
+ """Brute army-spam (only `build('e1')`, never any structure) must
276
+ LOSE — e1 needs tent which needs powr; no chain ever exists; no
277
+ proc; LOSS on the deadline."""
278
+ c, r = _run(level, _army_spam, seed=seed)
279
+ assert r.outcome == "loss", (
280
+ f"{level} seed{seed} army-spam must LOSE; got {r.outcome} "
281
+ f"(types={r.signals.own_building_types})"
282
+ )
283
+ assert "powr" not in r.signals.own_building_types
284
+ assert "proc" not in r.signals.own_building_types
285
+
286
+
287
+ # ───────────────────────── hard spawn round-robin ─────────────────────────
288
+
289
+
290
+ def test_hard_seed_round_robin_produces_distinct_starts():
291
+ """Seeds 1-4 must round-robin between the two declared spawn_point
292
+ groups (NORTH (20,12) / SOUTH (20,28)) so a memorised opening
293
+ cannot generalise. The inert spawn-witness `e1` per group surfaces
294
+ the variation via units_summary (the building-only pack would
295
+ otherwise be invisible to the hard-tier shared test)."""
296
+ from pathlib import Path
297
+
298
+ from openra_bench.eval_core import RustEnvPool, _scenario_to_tmp_yaml
299
+ from openra_bench.rust_adapter import RustObsAdapter
300
+
301
+ c = compile_level(load_pack(PACK), "hard")
302
+ tmp = _scenario_to_tmp_yaml(c)
303
+ pool = RustEnvPool(size=1, scenario_path=tmp)
304
+ env = pool.acquire()
305
+ starts = set()
306
+ try:
307
+ for seed in (1, 2, 3, 4):
308
+ ad = RustObsAdapter()
309
+ ad.observe(env.reset(seed=seed))
310
+ u = ad.render_state().get("units_summary", []) or []
311
+ if u:
312
+ starts.add(
313
+ tuple(sorted((x["cell_x"], x["cell_y"]) for x in u))
314
+ )
315
+ finally:
316
+ pool.release(env)
317
+ pool.shutdown()
318
+ Path(tmp).unlink(missing_ok=True)
319
+ assert len(starts) >= 2, (
320
+ f"hard seeds 1-4 produced identical starts {starts}; "
321
+ "spawn_point round-robin not taking effect"
322
+ )
323
+
324
+
325
+ # ───────────────────────── determinism ────────────────────────────────────
326
+
327
+
328
+ def test_outcomes_are_deterministic_per_seed():
329
+ """Same seed, same pack, same policy → identical outcome."""
330
+ c = compile_level(load_pack(PACK), "easy")
331
+ a = run_level(c, _intended, seed=2)
332
+ b = run_level(c, _intended, seed=2)
333
+ assert (a.outcome, a.turns) == (b.outcome, b.turns), (
334
+ f"determinism: {(a.outcome, a.turns)} vs {(b.outcome, b.turns)}"
335
+ )