Spaces:
Running
Running
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 |
+
)
|