OpenRA-Bench / tests /test_build_power_online_first.py
yxc20098's picture
feat(scenario): build-power-online-first โ€” power-grid bring-up sequencing (PlanBench / SOP anchor)
29fc4b5
Raw
History Blame Contribute Delete
14.1 kB
"""No-cheat + solvency proof for `build-power-online-first` (Wave-7
Group I REASONING: opening-phase build-order โ€” power-grid bring-up
sequencing).
The pack tests the SOP-compliance opening: from a pre-placed
Construction Yard and a tight cash budget, the agent must build the
Power Plant (`powr`) FIRST and only THEN the Refinery (`proc`). The
order is enforced both by the `then:[A,B]` composite (Wave-2 stateful
happened-before latch) AND by the engine itself (powr is a hard
prerequisite of proc/tent/barr; weap needs proc).
For every level + every hard seed (1-4):
* the INTENDED order (build powr โ†’ place powr โ†’ build proc โ†’ place
proc) WINS;
* STALL (only `observe`) LOSES โ€” never builds anything โ†’ win
clauses fail, deadline bites as a real timeout LOSS;
* PROC-FIRST (always tries `build('proc')` first, never powr) LOSES
โ€” the engine silently rejects StartProduction for proc without
powr; no production building ever appears; deadline bites;
* ARMY-SPAM (only `build('e1')`, never builds any structure) LOSES
โ€” no barracks, no powr, no proc; deadline bites.
The 3 lazy plays + 1 intended ร— 3 levels ร— 4 seeds gives the full
no-defect / no-cheat coverage demanded by CLAUDE.md.
"""
from __future__ import annotations
import pytest
pytest.importorskip("openra_train", reason="Rust env wheel not installed")
pytest.importorskip("openra_rl_training", reason="Rust env wheel not installed")
from openra_bench.eval_core import run_level
from openra_bench.scenarios import load_pack
from openra_bench.scenarios.loader import PACKS_DIR, compile_level
PACK = PACKS_DIR / "build-power-online-first.yaml"
LEVELS = ("easy", "medium", "hard")
SEEDS = (1, 2, 3, 4)
# โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ scripted policies โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
def _stall(rs, Command):
"""Idle: never builds anything โ†’ win clauses fail โ†’ LOSS on the
deadline."""
return [Command.observe()]
def _proc_first(rs, Command):
"""Always tries `build('proc')` first; NEVER tries powr. The engine
silently rejects StartProduction for proc without powr (CLAUDE.md
engine fact #2; world.rs:768 'ORDER: StartProduction BLOCKED โ€”
missing prerequisites for proc'). No proc ever appears โ†’ clause B
of the `then` latch never satisfies โ†’ LOSS on the deadline. This
is the headline wrong-order discriminator."""
def _fact(rs):
return next(
(b for b in (rs.get("own_buildings", []) or []) if b["type"] == "fact"),
None,
)
fact_b = _fact(rs)
if fact_b is None:
return [Command.observe()]
fx, fy = fact_b["cell_x"], fact_b["cell_y"]
prod = rs.get("production", []) or []
own_types = {
b["type"] for b in (rs.get("own_buildings", []) or [])
}
cmds = []
if "proc" not in prod and "proc" not in own_types:
cmds.append(Command.build("proc"))
cmds.append(Command.place_building("proc", fx + 3, fy + 3))
return cmds
def _army_spam(rs, Command):
"""Brute army-spam: only `build('e1')`. e1 needs a tent (barracks),
which needs powr โ€” neither exists, so every `build('e1')` is
silently blocked. No powr / no proc / no barracks ever appear โ†’
LOSS on the deadline."""
bldgs = rs.get("own_buildings", []) or []
fact_b = next((b for b in bldgs if b["type"] == "fact"), None)
if fact_b is None:
return [Command.observe()]
return [Command.build("e1")]
def _intended(rs, Command):
"""Intended SOP: build powr FIRST (cost 300), place it adjacent to
the fact; then build proc (cost 1400, now unblocked), place it
adjacent to the fact. Wins every tier ร— every seed before the
deadline."""
bldgs = rs.get("own_buildings", []) or []
own_types = {b["type"] for b in bldgs}
prod = rs.get("production", []) or []
fact_b = next((b for b in bldgs if b["type"] == "fact"), None)
if fact_b is None:
return [Command.observe()]
fx, fy = fact_b["cell_x"], fact_b["cell_y"]
if "powr" not in own_types:
cmds = []
if "powr" not in prod:
cmds.append(Command.build("powr"))
cmds.append(Command.place_building("powr", fx + 3, fy + 1))
return cmds
if "proc" not in own_types:
cmds = []
if "proc" not in prod:
cmds.append(Command.build("proc"))
cmds.append(Command.place_building("proc", fx + 3, fy + 3))
return cmds
return [Command.observe()]
# โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ helpers โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
def _run(level, policy, seed=1):
c = compile_level(load_pack(PACK), level)
assert c.map_supported, "rush-hour-arena must compile"
return c, run_level(c, policy, seed=seed)
# โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ structural โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
def test_pack_loads_with_three_levels_and_required_tools():
pack = load_pack(PACK)
assert pack.meta.id == "build-power-online-first"
assert pack.meta.capability == "reasoning"
assert pack.meta.status == "active"
assert set(pack.levels) == set(LEVELS)
c = compile_level(pack, "easy")
tools = set(c.scenario.tools or [])
# Wave-7 spec: pure build-order test โ€” no deploy / harvest /
# move_units / attack_unit. Just observe + build + place + stop.
for t in ("observe", "build", "place_building", "stop"):
assert t in tools, f"missing tool {t} in {tools}"
for forbidden in ("move_units", "attack_unit", "deploy", "harvest"):
assert forbidden not in tools, (
f"tool {forbidden!r} must NOT be in the SOP-only palette"
)
def test_benchmark_anchor_lists_planbench_and_grid_bringup():
pack = load_pack(PACK)
anchors = pack.meta.benchmark_anchor
assert anchors, "benchmark_anchor must be non-empty"
blob = " | ".join(anchors).lower()
assert "planbench" in blob, anchors
assert "sop" in blob, anchors
assert "electrical" in blob and "grid" in blob, anchors
@pytest.mark.parametrize("level", LEVELS)
def test_every_level_has_a_reachable_timeout_fail(level):
"""Non-win must be a real LOSS, never a DRAW: after_ticks in
fail_condition must be reachable within max_turns (tick โ‰ˆ 93 +
90ยท(max_turns โˆ’ 1))."""
c = compile_level(load_pack(PACK), level)
assert c.fail_condition is not None
after_ticks = int(c.fail_condition.model_dump()["any_of"][0]["after_ticks"])
reachable = 93 + 90 * (c.max_turns - 1)
assert after_ticks <= reachable, (
f"{level}: fail after_ticks {after_ticks} unreachable within "
f"{c.max_turns} turns (max tick {reachable}) โ†’ draw degeneracy"
)
# within_ticks + 1 == after_ticks (non-finisher LOSES on the very
# next tick after the win window closes).
within_clauses = c.win_condition.model_dump().get("all_of", [])
wt = next(
int(x["within_ticks"]) for x in within_clauses if "within_ticks" in x
)
assert after_ticks == wt + 1, (
f"{level}: after_ticks {after_ticks} must equal within_ticks+1 ({wt+1})"
)
@pytest.mark.parametrize("level", LEVELS)
def test_win_uses_then_composite_powr_before_proc(level):
"""Structural: the win clause uses a `then` composite with two
clauses in the exact order [powr, proc] โ€” this is the engine-side
SOP-compliance enforcement (CLAUDE.md ordering idiom)."""
c = compile_level(load_pack(PACK), level)
win = c.win_condition.model_dump()
all_of = win.get("all_of", [])
then_node = next(
(x["then"] for x in all_of if "then" in x), None
)
assert then_node is not None, f"{level}: win must include a `then` composite"
clauses = then_node.get("clauses", [])
assert len(clauses) == 2, f"{level}: then must have 2 clauses (powr, proc)"
# First clause: has_building powr; second: building_count_gte proc>=1.
assert clauses[0].get("has_building") == "powr", clauses[0]
bc = clauses[1].get("building_count_gte") or {}
assert bc.get("type") == "proc" and int(bc.get("n", 0)) >= 1, clauses[1]
def test_hard_has_two_spawn_groups_for_fact():
"""Hard tier contract (CLAUDE.md + tests/test_hard_tier.py): โ‰ฅ2
distinct agent spawn_point groups (NORTH yโ‰ˆ12 / SOUTH yโ‰ˆ28) so
seed round-robin varies the pre-placed fact and a memorised
"(20,20)" opening can't generalise."""
c = compile_level(load_pack(PACK), "hard")
sps = {
(a.spawn_point if a.spawn_point is not None else 0)
for a in c.scenario.actors
if a.owner == "agent"
}
assert len(sps) >= 2, f"hard needs โ‰ฅ2 spawn groups, got {sps}"
# โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ intended WIN โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
@pytest.mark.parametrize("level", LEVELS)
@pytest.mark.parametrize("seed", SEEDS)
def test_intended_powr_first_then_proc_wins(level, seed):
"""Intended build powr โ†’ place powr โ†’ build proc โ†’ place proc WINS
every level ร— every seed before the deadline."""
c, r = _run(level, _intended, seed=seed)
assert r.outcome == "win", (
f"{level} seed{seed}: intended powr-first chain should WIN, "
f"got {r.outcome}; types={r.signals.own_building_types}, "
f"cash={r.signals.cash}, tick={r.signals.game_tick}"
)
types = set(r.signals.own_building_types)
assert "powr" in types, types
assert "proc" in types, types
# โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ no-cheat: lazy plays LOSE โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
@pytest.mark.parametrize("level", LEVELS)
@pytest.mark.parametrize("seed", SEEDS)
def test_stall_loses(level, seed):
"""Stall must LOSE every level ร— every seed โ€” never builds anything
โ†’ both then-clauses fail โ†’ reachable timeout LOSS (not a draw)."""
c, r = _run(level, _stall, seed=seed)
assert r.outcome == "loss", (
f"{level} seed{seed} stall must LOSE; got {r.outcome} "
f"(types={r.signals.own_building_types}, tick={r.signals.game_tick})"
)
# Never built powr or proc.
assert "powr" not in r.signals.own_building_types
assert "proc" not in r.signals.own_building_types
@pytest.mark.parametrize("level", LEVELS)
@pytest.mark.parametrize("seed", SEEDS)
def test_proc_first_wrong_order_loses(level, seed):
"""The headline wrong-order discriminator: always tries `proc`
first, never powr. Engine silently rejects proc-without-powr
(world.rs:768 'StartProduction BLOCKED โ€” missing prerequisites
for proc'); no production building ever appears โ†’ both then-
clauses fail โ†’ LOSS on the deadline."""
c, r = _run(level, _proc_first, seed=seed)
assert r.outcome == "loss", (
f"{level} seed{seed} proc-first must LOSE; got {r.outcome} "
f"(types={r.signals.own_building_types}, tick={r.signals.game_tick})"
)
assert "powr" not in r.signals.own_building_types
assert "proc" not in r.signals.own_building_types
@pytest.mark.parametrize("level", LEVELS)
@pytest.mark.parametrize("seed", SEEDS)
def test_army_spam_brute_loses(level, seed):
"""Brute army-spam (only `build('e1')`, never any structure) must
LOSE โ€” e1 needs tent which needs powr; no chain ever exists; no
proc; LOSS on the deadline."""
c, r = _run(level, _army_spam, seed=seed)
assert r.outcome == "loss", (
f"{level} seed{seed} army-spam must LOSE; got {r.outcome} "
f"(types={r.signals.own_building_types})"
)
assert "powr" not in r.signals.own_building_types
assert "proc" not in r.signals.own_building_types
# โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ hard spawn round-robin โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
def test_hard_seed_round_robin_produces_distinct_starts():
"""Seeds 1-4 must round-robin between the two declared spawn_point
groups (NORTH (20,12) / SOUTH (20,28)) so a memorised opening
cannot generalise. The inert spawn-witness `e1` per group surfaces
the variation via units_summary (the building-only pack would
otherwise be invisible to the hard-tier shared test)."""
from pathlib import Path
from openra_bench.eval_core import RustEnvPool, _scenario_to_tmp_yaml
from openra_bench.rust_adapter import RustObsAdapter
c = compile_level(load_pack(PACK), "hard")
tmp = _scenario_to_tmp_yaml(c)
pool = RustEnvPool(size=1, scenario_path=tmp)
env = pool.acquire()
starts = set()
try:
for seed in (1, 2, 3, 4):
ad = RustObsAdapter()
ad.observe(env.reset(seed=seed))
u = ad.render_state().get("units_summary", []) or []
if u:
starts.add(
tuple(sorted((x["cell_x"], x["cell_y"]) for x in u))
)
finally:
pool.release(env)
pool.shutdown()
Path(tmp).unlink(missing_ok=True)
assert len(starts) >= 2, (
f"hard seeds 1-4 produced identical starts {starts}; "
"spawn_point round-robin not taking effect"
)
# โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ determinism โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
def test_outcomes_are_deterministic_per_seed():
"""Same seed, same pack, same policy โ†’ identical outcome."""
c = compile_level(load_pack(PACK), "easy")
a = run_level(c, _intended, seed=2)
b = run_level(c, _intended, seed=2)
assert (a.outcome, a.turns) == (b.outcome, b.turns), (
f"determinism: {(a.outcome, a.turns)} vs {(b.outcome, b.turns)}"
)