File size: 11,911 Bytes
4654597
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
5cfed54
4654597
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
"""mcv-deploy-second-base โ€” multi-site ramp-up via 2nd MCV.

The bar: the intended drive-MCV-to-eastern-target-and-deploy policy
WINS on every level and every hard seed; stall (only observe),
deploy-next-to-existing-base (just deploy in place at the center,
keeping the new fact OUTSIDE the eastern region), and on hard,
deploy-in-the-wrong-corner (cross the map to the FAR candidate
region โ€” fails the tick budget and/or eats the wrong-corner patrol)
all LOSE on every level. Non-win is a real reachable timeout LOSS.

Validation is scripted (no model / network).
"""

from __future__ import annotations

import types
from pathlib import Path

import pytest

pytest.importorskip("openra_rl_training", reason="Rust env wheel not installed")
from openra_bench.scenarios import load_pack
from openra_bench.scenarios.loader import compile_level
from openra_bench.scenarios.win_conditions import WinContext, evaluate

PACKS = Path(__file__).parent.parent / "openra_bench" / "scenarios" / "packs"
PACK_PATH = PACKS / "mcv-deploy-second-base.yaml"


# โ”€โ”€ unit-level predicate checks โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€

def _ctx(own_buildings=(), tick=1000):
    """Synthesize a WinContext from a building list.
    own_buildings: iterable of (type, cell_x, cell_y).
    """
    sig = types.SimpleNamespace(
        game_tick=tick,
        units_killed=0,
        units_lost=0,
        own_buildings=list(own_buildings),
        own_building_types={str(t).lower() for (t, _, _) in own_buildings},
        enemies_seen_ids=set(),
        enemy_buildings_seen_ids=set(),
    )
    return WinContext(signals=sig, render_state={"units_summary": []})


def test_predicates_easy():
    c = compile_level(load_pack(PACK_PATH), "easy")
    base1 = ("fact", 15, 20)
    # WIN: 2 facts, one at east (90,20) within radius 8 โ€” in time.
    east = ("fact", 90, 20)
    assert evaluate(c.win_condition, _ctx([base1, east], tick=3000))
    # FAIL: only 1 fact (never deployed)
    assert not evaluate(c.win_condition, _ctx([base1], tick=3000))
    # FAIL: 2 facts but second one is in the CENTER (next to MCV start
    # ~ (60,20)) โ€” well outside the east region radius-8.
    next_to_mcv = ("fact", 60, 20)
    assert not evaluate(c.win_condition, _ctx([base1, next_to_mcv], tick=3000))
    # FAIL: 2 facts but second one is in NW corner (wrong region).
    nw = ("fact", 20, 5)
    assert not evaluate(c.win_condition, _ctx([base1, nw], tick=3000))
    # Past deadline โ‡’ fail.
    assert evaluate(c.fail_condition, _ctx([base1, east], tick=5402))
    # No fact at all โ‡’ fail (lost base #1 AND never deployed).
    assert evaluate(c.fail_condition, _ctx([], tick=3000))
    # Deadline reachable inside max_turns (โˆผ90 ticks/turn).
    assert 5401 <= 93 + 90 * (c.max_turns - 1), (
        "after_ticks 5401 must be reachable within max_turns"
    )


def test_predicates_medium():
    c = compile_level(load_pack(PACK_PATH), "medium")
    base1 = ("fact", 15, 20)
    east = ("fact", 92, 18)  # inside radius-8 around (90,20)
    assert evaluate(c.win_condition, _ctx([base1, east], tick=2500))
    # Just outside the radius
    outside = ("fact", 99, 20)  # distance 9 > 8
    assert not evaluate(c.win_condition, _ctx([base1, outside], tick=2500))
    # Timeout reachable
    assert 4501 <= 93 + 90 * (c.max_turns - 1)


def test_predicates_hard_either_corner_wins():
    c = compile_level(load_pack(PACK_PATH), "hard")
    base1 = ("fact", 15, 20)
    ne = ("fact", 95, 10)
    se = ("fact", 95, 30)
    # WIN: deploy at NE candidate region
    assert evaluate(c.win_condition, _ctx([base1, ne], tick=2000))
    # WIN: deploy at SE candidate region
    assert evaluate(c.win_condition, _ctx([base1, se], tick=2000))
    # FAIL: deploy at center (neither candidate)
    center = ("fact", 60, 20)
    assert not evaluate(c.win_condition, _ctx([base1, center], tick=2000))
    # FAIL: deploy next to base #1 (still outside both candidate regions)
    near_base = ("fact", 22, 20)
    assert not evaluate(c.win_condition, _ctx([base1, near_base], tick=2000))
    # Past deadline โ‡’ fail.
    assert evaluate(c.fail_condition, _ctx([base1, ne], tick=3802))
    # Deadline reachable
    assert 3801 <= 93 + 90 * (c.max_turns - 1)


def test_hard_has_two_spawn_point_groups():
    """Hard-tier curation contract: โ‰ฅ2 distinct agent spawn_point
    groups so the seed round-robins the staging latitude (NE vs SE
    MCV)."""
    c = compile_level(load_pack(PACK_PATH), "hard")
    groups = {
        (a.spawn_point if a.spawn_point is not None else 0)
        for a in c.scenario.actors
        if a.owner == "agent"
    }
    assert len(groups) >= 2, f"hard needs โ‰ฅ2 spawn_point groups, got {groups}"


def test_pack_compiles_and_meta_fields_populated():
    pack = load_pack(PACK_PATH)
    assert pack.meta.capability == "reasoning"
    assert pack.meta.id == "mcv-deploy-second-base"
    anchors = pack.meta.benchmark_anchor
    assert isinstance(anchors, list) and anchors, "benchmark_anchor required"
    joined = " ".join(anchors).lower()
    assert "sc2le" in joined and "microrts" in joined
    assert "geographic" in joined or "multi-site" in joined
    for lvl in ("easy", "medium", "hard"):
        c = compile_level(pack, lvl)
        assert c.map_supported
        assert c.win_condition is not None and c.fail_condition is not None


# โ”€โ”€ engine-driven scripted policies โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€


def _find_mcv(rs):
    for u in rs.get("units_summary", []) or []:
        if str(u.get("type", "")).lower() == "mcv":
            return u
    return None


def _nearest_target(mcv, level):
    """Return (tx, ty) the deploy-region center the MCV is nearer to."""
    if level != "hard":
        return (90, 20)
    if mcv is None:
        return (95, 10)
    # On hard, NE (95,10) vs SE (95,30); pick nearer to MCV start.
    dx_n = (mcv["cell_x"] - 95) ** 2 + (mcv["cell_y"] - 10) ** 2
    dx_s = (mcv["cell_x"] - 95) ** 2 + (mcv["cell_y"] - 30) ** 2
    return (95, 10) if dx_n <= dx_s else (95, 30)


def _intended_policy_for(level):
    """Drive the MCV to the (nearest) target region; deploy when in
    range. After deploy the MCV is consumed and the episode just
    observes."""

    def _policy(rs, Command):
        mcv = _find_mcv(rs)
        if mcv is None:
            return [Command.observe()]
        tx, ty = _nearest_target(mcv, level)
        dx = mcv["cell_x"] - tx
        dy = mcv["cell_y"] - ty
        dist2 = dx * dx + dy * dy
        # Within deploy region (radius 8 in YAML; deploy a bit inside
        # to be safe).
        if dist2 <= 36:
            return [Command.deploy([str(mcv["id"])])]
        return [Command.move_units([str(mcv["id"])], target_x=tx, target_y=ty)]

    return _policy


def _stall_policy(rs, Command):
    return [Command.observe()]


def _deploy_in_place_policy(rs, Command):
    """Deploy MCV immediately wherever it stands (~(60,20) for
    easy/medium, ~(60,10) or (60,30) for hard). The new fact ends up
    well outside the east target region โ†’ win predicate fails."""
    mcv = _find_mcv(rs)
    if mcv is None:
        return [Command.observe()]
    return [Command.deploy([str(mcv["id"])])]


def _deploy_next_to_existing_policy(rs, Command):
    """Move MCV BACK toward base #1 (west) and deploy there. The new
    fact ends up near (18,20) โ€” clearly outside the east region."""
    mcv = _find_mcv(rs)
    if mcv is None:
        return [Command.observe()]
    # Once we're nearly on top of the existing west base, deploy.
    if abs(mcv["cell_x"] - 22) <= 1 and abs(mcv["cell_y"] - 20) <= 2:
        return [Command.deploy([str(mcv["id"])])]
    return [Command.move_units([str(mcv["id"])], target_x=22, target_y=20)]


def _deploy_wrong_corner_policy(rs, Command):
    """Hard-only: drive MCV to the OPPOSITE corner (the candidate
    region that is NOT the nearest one) and deploy. The cross-map
    haul + wrong-corner patrol busts the tick budget or kills the
    MCV en route."""

    def _policy(rs, Command):
        mcv = _find_mcv(rs)
        if mcv is None:
            return [Command.observe()]
        # Pick the FARTHER corner.
        dx_n = (mcv["cell_x"] - 95) ** 2 + (mcv["cell_y"] - 10) ** 2
        dx_s = (mcv["cell_x"] - 95) ** 2 + (mcv["cell_y"] - 30) ** 2
        tx, ty = (95, 10) if dx_n > dx_s else (95, 30)
        dx = mcv["cell_x"] - tx
        dy = mcv["cell_y"] - ty
        if dx * dx + dy * dy <= 36:
            return [Command.deploy([str(mcv["id"])])]
        return [Command.move_units([str(mcv["id"])], target_x=tx, target_y=ty)]

    return _policy(rs, Command)


@pytest.mark.parametrize("level", ["easy", "medium", "hard"])
def test_intended_policy_wins(level):
    pytest.importorskip("openra_train")
    from openra_bench.eval_core import run_level

    c = compile_level(load_pack(PACK_PATH), level)
    seeds = (1, 2, 3, 4) if level == "hard" else (1,)
    for s in seeds:
        res = run_level(c, _intended_policy_for(level), seed=s)
        assert res.outcome == "win", (
            f"{level} seed={s}: intended deploy-MCV-at-target-region "
            f"should WIN, got {res.outcome} after {res.turns} turns "
            f"(buildings={sorted(res.signals.own_building_types)}, "
            f"own_buildings={res.signals.own_buildings})"
        )


@pytest.mark.parametrize("level", ["easy", "medium", "hard"])
def test_stall_policy_loses(level):
    pytest.importorskip("openra_train")
    from openra_bench.eval_core import run_level

    c = compile_level(load_pack(PACK_PATH), level)
    seeds = (1, 2, 3, 4) if level == "hard" else (1,)
    for s in seeds:
        res = run_level(c, _stall_policy, seed=s)
        assert res.outcome == "loss", (
            f"{level} seed={s}: stall must LOSE (no 2nd fact ever), "
            f"got {res.outcome} after {res.turns} turns; "
            f"buildings={sorted(res.signals.own_building_types)}"
        )


@pytest.mark.parametrize("level", ["easy", "medium", "hard"])
def test_deploy_next_to_existing_base_loses(level):
    pytest.importorskip("openra_train")
    from openra_bench.eval_core import run_level

    c = compile_level(load_pack(PACK_PATH), level)
    seeds = (1, 2, 3, 4) if level == "hard" else (1,)
    for s in seeds:
        res = run_level(c, _deploy_next_to_existing_policy, seed=s)
        assert res.outcome == "loss", (
            f"{level} seed={s}: deploy-next-to-existing-base must "
            f"LOSE (2nd fact not in east region), got {res.outcome} "
            f"after {res.turns} turns; "
            f"buildings={res.signals.own_buildings}"
        )


def test_deploy_wrong_corner_loses_hard():
    """Hard-only: deploying at the FAR candidate region (cross-map
    diagonal) must LOSE โ€” tick budget + wrong-corner patrol attrition
    is the discrimination axis vs the intended NEAREST-corner play."""
    pytest.importorskip("openra_train")
    from openra_bench.eval_core import run_level

    c = compile_level(load_pack(PACK_PATH), "hard")
    for s in (1, 2, 3, 4):
        res = run_level(c, _deploy_wrong_corner_policy, seed=s)
        assert res.outcome == "loss", (
            f"hard seed={s}: deploy-in-wrong-corner must LOSE "
            f"(tick budget blown or MCV killed by far-corner patrol), "
            f"got {res.outcome} after {res.turns} turns; "
            f"buildings={res.signals.own_buildings}"
        )


def test_timeout_loss_is_reachable_on_every_level():
    """No draw degeneracy: the level's after_ticks fail must fit
    inside max_turns on every level (โˆผ90 ticks/turn)."""
    pack = load_pack(PACK_PATH)
    bars = {"easy": 5401, "medium": 4501, "hard": 3801}
    for lvl, bar in bars.items():
        c = compile_level(pack, lvl)
        assert bar <= 93 + 90 * (c.max_turns - 1), lvl