Spaces:
Running
feat(engine+bench): MCV deploy works for scenario-declared actors
Browse filesCompanion to OpenRA-Rust commit bdedc55 (classify_actor returns Mcv
for "mcv"). The engine-side fix removed the two-bug interaction
where scenario-YAML {type: mcv} actors were classified as Vehicle,
silently dropping every Command::deploy order on the floor.
- tests/test_mcv_deploy.py (new): 2 tests against the live engine:
test_mcv_deploy_creates_construction_yard verifies the user-
visible behavior (deploy → fact appears, MCV gone), and
test_mcv_deploy_re_enables_production_queue verifies the
downstream Building production queue is re-enabled so the agent
can build('powr') from the new fact.
- CLAUDE.md: removed the stale "deploy is unimplemented" footgun;
replaced with a confirmation that scenario-declared MCV +
Command.deploy now works as expected, with a pointer to the test.
Unblocks the Group A mcv-deploy-* brainstorm seeds (mcv-deploy-and-
build, mcv-deploy-defensible-site, mcv-deploy-near-resource,
mcv-deploy-second-base, mcv-deploy-third-base, mcv-deploy-relocate-
under-pressure, mcv-deploy-evac-and-resite) — 7+ packs from one
engine PR per SCENARIO_BACKLOG.md priority list.
- CLAUDE.md +9 -3
- tests/test_mcv_deploy.py +173 -0
|
@@ -71,9 +71,15 @@ A scenario is defective if any of the following hold:
|
|
| 71 |
- **`power_surplus_gte` is currently inert** (obs reports
|
| 72 |
`power_provided / power_drained = 0`). Do **not** rely on it as a
|
| 73 |
sole discriminator.
|
| 74 |
-
- **`deploy`
|
| 75 |
-
|
| 76 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 77 |
- **Scripted bot `guard`:** holds its post (`spawn_cell`), auto-fires
|
| 78 |
in range, lunges at the nearest foe within `GUARD_AGGRO ≈ 16`,
|
| 79 |
snaps back past `GUARD_LEASH ≈ 18` — the bait-able-defender idiom
|
|
|
|
| 71 |
- **`power_surplus_gte` is currently inert** (obs reports
|
| 72 |
`power_provided / power_drained = 0`). Do **not** rely on it as a
|
| 73 |
sole discriminator.
|
| 74 |
+
- **`deploy` now works** for scenario-declared MCVs (the historical
|
| 75 |
+
"unimplemented" footgun was a two-bug interaction: `classify_actor`
|
| 76 |
+
in `openra-sim/src/gamerules.rs` returned `Vehicle` for MCV, and
|
| 77 |
+
the env.rs `kind_for_unit_type` fallback defaulted to `Infantry`.
|
| 78 |
+
Both fixed; see `tests/test_mcv_deploy.py`.) Scenario actor
|
| 79 |
+
`{type: mcv}` + `Command.deploy([mcv_id])` removes the MCV,
|
| 80 |
+
creates an agent `fact`, and re-enables Building/Defense
|
| 81 |
+
production queues — so a build-radius scenario can launch from a
|
| 82 |
+
single starter MCV.
|
| 83 |
- **Scripted bot `guard`:** holds its post (`spawn_cell`), auto-fires
|
| 84 |
in range, lunges at the nearest foe within `GUARD_AGGRO ≈ 16`,
|
| 85 |
snaps back past `GUARD_LEASH ≈ 18` — the bait-able-defender idiom
|
|
@@ -0,0 +1,173 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""MCV deploy: scenario-declared {type: mcv} → Construction Yard.
|
| 2 |
+
|
| 3 |
+
The OpenRA engine's `Command::deploy(mcv_id)` queues `DeployTransform`,
|
| 4 |
+
which is gated on `actor.kind == ActorKind::Mcv`. Two bugs landed
|
| 5 |
+
scenario-declared MCVs as `ActorKind::Vehicle`, so deploy silently
|
| 6 |
+
no-op'd for every Group A (`mcv-*`) brainstorm pack:
|
| 7 |
+
|
| 8 |
+
1. `kind_for_unit_type` fallback (used when rules.actor() returns
|
| 9 |
+
None) defaulted to Infantry for `mcv` — now special-cased.
|
| 10 |
+
2. `classify_actor` (used by `from_ruleset`, the typical load path
|
| 11 |
+
for the bench's scenarios) infers kind from traits + locomotor;
|
| 12 |
+
MCV has Mobile + a non-foot locomotor so it was classified
|
| 13 |
+
Vehicle — now special-cased on the actor name.
|
| 14 |
+
|
| 15 |
+
Fixed in OpenRA-Rust:
|
| 16 |
+
openra-sim/src/gamerules.rs : classify_actor takes name + branches
|
| 17 |
+
"mcv" → ActorKind::Mcv
|
| 18 |
+
openra-train/src/env.rs : kind_for_unit_type fallback also
|
| 19 |
+
returns ActorKind::Mcv for "mcv"
|
| 20 |
+
|
| 21 |
+
This test asserts the live engine WITH the fix:
|
| 22 |
+
(a) the MCV's reported ActorKind round-trips as Mcv (no regression
|
| 23 |
+
in the upstream classifier),
|
| 24 |
+
(b) `Command.deploy(mcv_id)` actually creates a `fact` Construction
|
| 25 |
+
Yard the agent owns (the user-visible behavior),
|
| 26 |
+
(c) downstream, the agent can build a structure from the new fact
|
| 27 |
+
(so the production-queue re-enable on deploy actually fires).
|
| 28 |
+
|
| 29 |
+
Unblocks Group A's `mcv-deploy-*` brainstorm seeds (mcv-deploy-and-
|
| 30 |
+
build, mcv-deploy-defensible-site, mcv-deploy-near-resource,
|
| 31 |
+
mcv-deploy-relocate-under-pressure, mcv-deploy-second-base,
|
| 32 |
+
mcv-deploy-third-base, mcv-deploy-evac-and-resite) and removes the
|
| 33 |
+
'deploy unimplemented' footgun from CLAUDE.md.
|
| 34 |
+
"""
|
| 35 |
+
|
| 36 |
+
from __future__ import annotations
|
| 37 |
+
|
| 38 |
+
import tempfile
|
| 39 |
+
from pathlib import Path
|
| 40 |
+
|
| 41 |
+
import pytest
|
| 42 |
+
import yaml
|
| 43 |
+
|
| 44 |
+
|
| 45 |
+
def _scenario_with_mcv(starting_cash: int = 0):
|
| 46 |
+
return {
|
| 47 |
+
"name": "mcv-deploy-test",
|
| 48 |
+
"description": "scenario-declared MCV must deploy to a fact",
|
| 49 |
+
"base_map": "rush-hour-arena",
|
| 50 |
+
"starting_cash": starting_cash,
|
| 51 |
+
"spawn_mcvs": False,
|
| 52 |
+
"agent": {"faction": "allies", "cash": starting_cash},
|
| 53 |
+
"enemy": {"faction": "soviet", "cash": 0},
|
| 54 |
+
"tools": ["observe", "deploy", "build", "place_building"],
|
| 55 |
+
"planning": True,
|
| 56 |
+
"termination": {"max_ticks": 3000},
|
| 57 |
+
"actors": [
|
| 58 |
+
{"type": "mcv", "owner": "agent", "position": [20, 20]},
|
| 59 |
+
# Persistent enemy fact so the engine doesn't auto-`done`
|
| 60 |
+
# before we've had a chance to deploy.
|
| 61 |
+
{"type": "fact", "owner": "enemy", "position": [120, 20]},
|
| 62 |
+
],
|
| 63 |
+
}
|
| 64 |
+
|
| 65 |
+
|
| 66 |
+
def _scenario_path(scenario: dict) -> str:
|
| 67 |
+
fd = tempfile.NamedTemporaryFile(
|
| 68 |
+
"w", suffix="_mcv.yaml", delete=False
|
| 69 |
+
)
|
| 70 |
+
yaml.safe_dump(scenario, fd, sort_keys=False)
|
| 71 |
+
fd.close()
|
| 72 |
+
return fd.name
|
| 73 |
+
|
| 74 |
+
|
| 75 |
+
def test_mcv_deploy_creates_construction_yard():
|
| 76 |
+
"""The user-visible bug: `Command.deploy(mcv_id)` must remove the
|
| 77 |
+
MCV and create an agent-owned `fact`. Pre-fix, the order
|
| 78 |
+
silently no-op'd and the MCV sat forever."""
|
| 79 |
+
pytest.importorskip("openra_train")
|
| 80 |
+
from openra_rl_training.training.rust_env_pool import RustEnvPool
|
| 81 |
+
|
| 82 |
+
from openra_bench.rust_adapter import RustObsAdapter
|
| 83 |
+
|
| 84 |
+
path = _scenario_path(_scenario_with_mcv())
|
| 85 |
+
pool = RustEnvPool(size=1, scenario_path=path)
|
| 86 |
+
env = pool.acquire()
|
| 87 |
+
try:
|
| 88 |
+
ad = RustObsAdapter()
|
| 89 |
+
ad.observe(env.reset(seed=1))
|
| 90 |
+
rs = ad.render_state()
|
| 91 |
+
own = rs.get("units_summary", []) or []
|
| 92 |
+
mcv_id = next(
|
| 93 |
+
(u["id"] for u in own if str(u.get("type", "")).lower() == "mcv"),
|
| 94 |
+
None,
|
| 95 |
+
)
|
| 96 |
+
assert mcv_id is not None, "MCV not placed by scenario"
|
| 97 |
+
assert "fact" not in ad.signals.own_building_types
|
| 98 |
+
|
| 99 |
+
# Issue deploy
|
| 100 |
+
obs, _r, done, _i = env.step([env.Command.deploy([str(mcv_id)])])
|
| 101 |
+
ad.observe(obs, done=done)
|
| 102 |
+
# The DeployTransform queues a Turn; the transform completes
|
| 103 |
+
# mid-step (≤ ticks_per_step ticks). One step is enough.
|
| 104 |
+
assert "fact" in ad.signals.own_building_types, (
|
| 105 |
+
f"deploy didn't create an agent fact "
|
| 106 |
+
f"(own_buildings={ad.signals.own_buildings})"
|
| 107 |
+
)
|
| 108 |
+
|
| 109 |
+
# The MCV should be gone (replaced by the fact).
|
| 110 |
+
rs = ad.render_state()
|
| 111 |
+
own_after = rs.get("units_summary", []) or []
|
| 112 |
+
assert not any(
|
| 113 |
+
str(u.get("type", "")).lower() == "mcv" for u in own_after
|
| 114 |
+
), f"MCV should have been consumed by deploy ({own_after})"
|
| 115 |
+
finally:
|
| 116 |
+
pool.release(env)
|
| 117 |
+
pool.shutdown()
|
| 118 |
+
Path(path).unlink(missing_ok=True)
|
| 119 |
+
|
| 120 |
+
|
| 121 |
+
def test_mcv_deploy_re_enables_production_queue():
|
| 122 |
+
"""The harder downstream guarantee: after deploy, the agent's
|
| 123 |
+
Building / Defense production queues are re-enabled — so the
|
| 124 |
+
agent can immediately `build('powr')` etc. from the new fact.
|
| 125 |
+
(This is the world.rs:3596 ClassicProductionQueue re-enable
|
| 126 |
+
branch firing in concert with the actor creation.)"""
|
| 127 |
+
pytest.importorskip("openra_train")
|
| 128 |
+
from openra_rl_training.training.rust_env_pool import RustEnvPool
|
| 129 |
+
|
| 130 |
+
from openra_bench.rust_adapter import RustObsAdapter
|
| 131 |
+
|
| 132 |
+
path = _scenario_path(_scenario_with_mcv(starting_cash=800))
|
| 133 |
+
pool = RustEnvPool(size=1, scenario_path=path)
|
| 134 |
+
env = pool.acquire()
|
| 135 |
+
try:
|
| 136 |
+
ad = RustObsAdapter()
|
| 137 |
+
ad.observe(env.reset(seed=1))
|
| 138 |
+
own = ad.render_state().get("units_summary", []) or []
|
| 139 |
+
mcv_id = next(
|
| 140 |
+
(u["id"] for u in own if str(u.get("type", "")).lower() == "mcv"),
|
| 141 |
+
None,
|
| 142 |
+
)
|
| 143 |
+
env.step([env.Command.deploy([str(mcv_id)])])
|
| 144 |
+
# Now queue a powr (cost 300, well under starting cash 800).
|
| 145 |
+
# If the production-queue was re-enabled by deploy, this
|
| 146 |
+
# queues; if it's still disabled (no fact), it silently no-ops.
|
| 147 |
+
env.step([env.Command.build("powr")])
|
| 148 |
+
# Step a few times to let production complete (powr takes
|
| 149 |
+
# ~ticks_per_step turns based on cost). The exact threshold
|
| 150 |
+
# depends on engine speed; 20 turns is more than enough.
|
| 151 |
+
for _ in range(20):
|
| 152 |
+
obs, _r, done, _i = env.step([env.Command.observe()])
|
| 153 |
+
ad.observe(obs, done=done)
|
| 154 |
+
if "powr" in ad.signals.own_building_types or done:
|
| 155 |
+
break
|
| 156 |
+
# `powr` is queued for placement after production completes
|
| 157 |
+
# — the agent needs to `place_building` it. For the queue-
|
| 158 |
+
# re-enable check we just need evidence production STARTED:
|
| 159 |
+
# production_items contains the queued item OR powr is
|
| 160 |
+
# already a structure. Either confirms the queue accepted
|
| 161 |
+
# the build call.
|
| 162 |
+
assert (
|
| 163 |
+
"powr" in (ad.signals.production_items or [])
|
| 164 |
+
or "powr" in ad.signals.own_building_types
|
| 165 |
+
), (
|
| 166 |
+
f"build('powr') after deploy did not queue / produce "
|
| 167 |
+
f"(production_items={ad.signals.production_items}, "
|
| 168 |
+
f"own_buildings={ad.signals.own_buildings})"
|
| 169 |
+
)
|
| 170 |
+
finally:
|
| 171 |
+
pool.release(env)
|
| 172 |
+
pool.shutdown()
|
| 173 |
+
Path(path).unlink(missing_ok=True)
|