yxc20098 commited on
Commit
d2c2f6b
·
1 Parent(s): 741e23b

feat(scenario): scout-multiple-fog-areas — parallel multi-region scout (Watch-And-Help / SMAC distributed-recon anchor)

Browse files

Wave-6 Group E perception pack. Three jeeps must scout K disjoint
foggy regions on the rush-hour-arena map; each region hides one
unarmed enemy 'proc' marker building and the win predicate is
'buildings_discovered_gte:K'. The discriminating teeth:

* stall never visits any region LOSES on clock
* all-to-NE-only discovers 1 of K regions LOSES on win bar
* single-jeep-tour walks A->B->C serially LOSES on clock
(medium/hard)
* intended SPLIT 3 jeeps dispatched in parallel WINS in time

Difficulty axis is one new variable per tier:
easy (K=2 NE+SE): within_ticks 1800 (permissive)
medium (K=3 NE+SE+farE): within_ticks 1050 (tight)
hard (K=4 NE+SE+NW-mid+SW-mid, 2 spawn groups NW/SW):
within_ticks 1300 (tight + corridor sweep)

Tick budgets empirically calibrated against jeep speed on the
128x40 open arena (~5.9 ticks/cell). Intended-split lands all
discoveries at tick 903 on every level; the single-jeep tour
exceeds the within_ticks gate on medium (1083) and hard (1353).
Easy's K=2 east-edge tour fits the generous 1800-tick clock by
design — easy is the bare-skill rehearsal, not the discrimination
tier.

Engine-footgun notes captured in the YAML header:
* Every region marker is a MustBeDestroyed building; the
unvisited markers stay alive in fog so degenerate plays time
out as a real LOSS (no engine auto-done degeneracy). No
off-objective persistence marker is required.
* 'proc' is unarmed and stance:default; no auto-fire on a
passing jeep keeps the perception channel pure.

Real-world anchor: SC2 multi-scout coverage / military distributed
reconnaissance doctrine; benchmark anchors Watch-And-Help concurrent
multi-task scout / SMAC distributed exploration.

Tests cover all four bar-defining outcomes per CLAUDE.md 'no defect,
no cheat':
- test_intended_split_wins (3 levels x 4 seeds = 12)
- test_stall_loses (12)
- test_all_to_ne_only_loses (12)
- test_single_jeep_tour_loses_medium_and_hard (8 — easy permissive)
- test_hard_seeds_produce_outcomes_per_seed (4)
plus pack-shape tests (compile / anchors / fail_cond / spawn
groups / tick-budget alignment / win-predicate K=2/3/4).

Registered UPGRADED in tests/test_hard_tier.py — hard tier defines
2 agent spawn_point groups (NW y=6..12 / SW y=28..34) round-robined
by seed; the K=4 region layout is symmetric N/S so the sweep
corridor flips per seed and a memorised opening cannot generalise.

Model smoke (Qwen3.6-Plus, medium, seed=1): outcome=loss, turns=12,
composite=0.2716, perception=0.0381 — the discrimination signal is
firing (the model couldn't solve parallel-dispatch within the tight
medium clock).

openra_bench/scenarios/packs/scout-multiple-fog-areas.yaml ADDED
@@ -0,0 +1,202 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Validate: python -m openra_bench.scenarios.validate \
2
+ # openra_bench/scenarios/packs/scout-multiple-fog-areas.yaml
3
+ # See ../CONTRIBUTING.md for the full win-condition grammar.
4
+ #
5
+ # PERCEPTION focus — PARALLEL multi-region scouting. The map hides one
6
+ # marker building in EACH of K disjoint fog regions (NE / SE / far-E /
7
+ # NW / SW depending on tier). The win predicate is
8
+ # `buildings_discovered_gte:K` — the model must DISCOVER every marker
9
+ # within the clock. The agent owns 3 jeeps. The discriminating
10
+ # decision is whether to DISPATCH the jeeps in PARALLEL (one per
11
+ # region, all arriving roughly simultaneously) or commit a degenerate
12
+ # policy that breaks one tooth:
13
+ #
14
+ # * stall — never visits any region → LOSES on clock
15
+ # * all-to-NE-only — discovers K=1 of K regions → LOSES on win bar
16
+ # * single-jeep-tour — one jeep walks A→B→C serially → LOSES on clock
17
+ # * intended SPLIT — 3 jeeps fan out concurrently → WINS in time
18
+ #
19
+ # Real-world anchor: SC2 multi-scout coverage (one worker per map
20
+ # corner so the opening intel arrives in parallel rather than serially);
21
+ # military distributed reconnaissance doctrine (several recon teams
22
+ # dispatched to different sectors simultaneously because a single
23
+ # patrol cannot cover the area of operations in the intelligence
24
+ # window); Watch-And-Help concurrent multi-task / SMAC distributed
25
+ # exploration (multi-agent benchmarks whose binding tooth is parallel
26
+ # task assignment with non-overlapping coverage).
27
+ #
28
+ # Tick-budget calibration (rush-hour-arena is 128x40; jeep speed on
29
+ # this open grid empirically clocks at ~5.9 ticks/cell):
30
+ # medium (K=3, regions NE+SE+far-E mid):
31
+ # intended split (3 jeeps to 3 regions, parallel) ≈ 903 ticks
32
+ # single-jeep tour NE→far-E→SE (122+16+16 ≈ 154 cells) ≈ 1083 ticks
33
+ # hard (K=4, regions NE+SE+NW-mid+SW-mid; 2 spawn groups):
34
+ # intended split (top jeep NW→NE; bottom jeep SW→SE) ≈ 903 ticks
35
+ # single-jeep tour NW→NE→SE→SW (231 cells) ≈ 1353 ticks
36
+ # `within_ticks` is set BETWEEN the split-time and the tour-time so
37
+ # the split WINS and the tour LOSES. Easy keeps a generous budget
38
+ # because K=2 makes a single-jeep tour cheap (~1083 ticks for both
39
+ # corners on the east edge) — easy is the bare-skill rehearsal, not
40
+ # the discrimination tier.
41
+ #
42
+ # Engine-footgun notes (see CLAUDE.md):
43
+ # * Every region marker is a MustBeDestroyed Soviet building
44
+ # (`proc`). When a partial play (e.g. all-to-NE) levels one
45
+ # marker the OTHERS stay alive in fog so the engine does NOT
46
+ # auto-`done` — the clock fires the LOSS cleanly. No off-objective
47
+ # persistence marker is needed because the unvisited-in-fog markers
48
+ # fill that role on every degenerate policy.
49
+ # * Markers stay passive: `proc` is unarmed so it never engages a
50
+ # scouting jeep on sight, keeping the perception channel pure.
51
+ # * `units_lost_lte:1` is structurally inert here (no enemy units
52
+ # exist, only unarmed buildings) but is kept in both clauses
53
+ # per the cell contract so that any future variant adding
54
+ # hostiles keeps the same predicate shape.
55
+
56
+ meta:
57
+ id: scout-multiple-fog-areas
58
+ title: 'Scout Multiple Fog Areas — Split, Don''t Tour'
59
+ capability: perception
60
+ real_world_meaning: >
61
+ Multi-region reconnaissance under a hard intelligence deadline:
62
+ every sector must be eyed before commit. A single patrol cycled
63
+ A→B→C blows the deadline because B and C wait while A is read;
64
+ sending the whole team to one sector leaves the others blank. The
65
+ advertised capability is concurrent multi-sector dispatch — one
66
+ sensor per region, all arriving roughly together.
67
+ robotics_analogue: >
68
+ Multi-robot area surveillance with one drone per sector and a
69
+ bounded intel-window: drones must launch in parallel so every
70
+ sector is observed before the window closes; serialising one
71
+ drone across all sectors misses the deadline, and committing the
72
+ full fleet to one sector leaves the others unobserved.
73
+ benchmark_anchor:
74
+ - "Watch-And-Help concurrent multi-task scout"
75
+ - "SMAC distributed exploration"
76
+ - "SC2 multi-scout coverage"
77
+ - "military distributed reconnaissance doctrine"
78
+ author: "openra-bench"
79
+
80
+ base_map: rush-hour-arena
81
+
82
+ base:
83
+ agent: {faction: allies}
84
+ enemy: {faction: soviet}
85
+ tools: [move_units, attack_unit, stop]
86
+ planning: true
87
+ termination: {max_ticks: 10000}
88
+ actors: [] # every level supplies its own actor list via overrides.
89
+
90
+ levels:
91
+ easy:
92
+ description: >
93
+ Two foggy regions hide one marker building each — one in the
94
+ NORTH-EAST corner (Refinery 'proc' at ~124,4) and one in the
95
+ SOUTH-EAST corner (Refinery 'proc' at ~124,36). You own three
96
+ jeeps in the west staging column. Discover BOTH markers before
97
+ tick 1800. The intended play is to dispatch one jeep to each
98
+ corner in parallel — a stall never reaches either; sending the
99
+ whole fleet to one corner leaves the other in fog. The deadline
100
+ is generous: this tier rehearses the split-dispatch reflex
101
+ without yet penalising the slower single-jeep tour.
102
+ overrides:
103
+ actors:
104
+ # Three jeeps in the west staging column. Pre-split rows so
105
+ # the obvious read is "one jeep per row → one region per jeep".
106
+ - {type: jeep, owner: agent, position: [5, 14]}
107
+ - {type: jeep, owner: agent, position: [5, 20]}
108
+ - {type: jeep, owner: agent, position: [5, 26]}
109
+ # Region markers — each a MustBeDestroyed Soviet refinery
110
+ # (no defenders, no auto-fire). The unvisited marker stays
111
+ # alive in fog and keeps the episode running so a partial
112
+ # play loses on the clock (no engine auto-done degeneracy).
113
+ - {type: proc, owner: enemy, position: [124, 4]} # NE region
114
+ - {type: proc, owner: enemy, position: [124, 36]} # SE region
115
+ win_condition:
116
+ all_of:
117
+ - {buildings_discovered_gte: 2}
118
+ - {units_lost_lte: 1}
119
+ - {within_ticks: 1800}
120
+ fail_condition:
121
+ any_of:
122
+ - {after_ticks: 1801}
123
+ - {not: {units_lost_lte: 1}}
124
+ - {not: {own_units_gte: 1}}
125
+ max_turns: 22
126
+
127
+ medium:
128
+ description: >
129
+ THREE foggy regions hide one marker building each — NORTH-EAST
130
+ (Refinery 'proc' at ~124,4), SOUTH-EAST (Refinery 'proc' at
131
+ ~124,36), and far-EAST mid-band (Refinery 'proc' at ~124,20).
132
+ You own three jeeps in the west staging column. Discover ALL
133
+ THREE markers before tick 1050. The clock is tight: dispatching
134
+ one jeep to each region in parallel lands every discovery in
135
+ time, but sending a single jeep to TOUR all three serially
136
+ cannot reach the third before the deadline. Sending all three
137
+ jeeps to one corner only ever satisfies one of the three region
138
+ discoveries and loses on the win bar.
139
+ overrides:
140
+ actors:
141
+ - {type: jeep, owner: agent, position: [5, 14]}
142
+ - {type: jeep, owner: agent, position: [5, 20]}
143
+ - {type: jeep, owner: agent, position: [5, 26]}
144
+ - {type: proc, owner: enemy, position: [124, 4]} # NE region
145
+ - {type: proc, owner: enemy, position: [124, 36]} # SE region
146
+ - {type: proc, owner: enemy, position: [124, 20]} # far-E mid
147
+ win_condition:
148
+ all_of:
149
+ - {buildings_discovered_gte: 3}
150
+ - {units_lost_lte: 1}
151
+ - {within_ticks: 1050}
152
+ fail_condition:
153
+ any_of:
154
+ - {after_ticks: 1051}
155
+ - {not: {units_lost_lte: 1}}
156
+ - {not: {own_units_gte: 1}}
157
+ max_turns: 13
158
+
159
+ hard:
160
+ description: >
161
+ FOUR foggy regions hide one marker building each — NORTH-EAST
162
+ ('proc' at ~124,4), SOUTH-EAST ('proc' at ~124,36),
163
+ NORTH-mid ('proc' at ~60,4), and SOUTH-mid ('proc' at ~60,36).
164
+ You own three jeeps; one jeep must SWEEP two regions on a
165
+ single corridor (NW→NE on the north edge, SW→SE on the south
166
+ edge). The clock is tight (tick 1300) — a single jeep touring
167
+ all four regions blows the deadline. The staging corner
168
+ round-robins per seed: even seeds stage NW, odd seeds stage
169
+ SW. A memorised four-region reading order from one corner does
170
+ not generalise; the model must re-derive which two regions the
171
+ "top" jeep sweeps and which two the "bottom" jeep sweeps from
172
+ its actual start row.
173
+ overrides:
174
+ actors:
175
+ # spawn_point 0 — three jeeps staged NW (y=6..12).
176
+ - {type: jeep, owner: agent, position: [5, 6], spawn_point: 0}
177
+ - {type: jeep, owner: agent, position: [5, 9], spawn_point: 0}
178
+ - {type: jeep, owner: agent, position: [5, 12], spawn_point: 0}
179
+ # spawn_point 1 — three jeeps staged SW (y=28..34).
180
+ - {type: jeep, owner: agent, position: [5, 28], spawn_point: 1}
181
+ - {type: jeep, owner: agent, position: [5, 31], spawn_point: 1}
182
+ - {type: jeep, owner: agent, position: [5, 34], spawn_point: 1}
183
+ # Four region markers — corners + mid-N + mid-S so the
184
+ # single-jeep tour distance is large (NW→NE→SE→SW ≈ 231
185
+ # cells ≈ 1353 ticks > the 1300-tick budget) but the
186
+ # north-edge / south-edge SWEEP for two adjacent regions
187
+ # is short (60 + 64 ≈ 124 cells ≈ 740 ticks per sweep).
188
+ - {type: proc, owner: enemy, position: [124, 4]} # NE
189
+ - {type: proc, owner: enemy, position: [124, 36]} # SE
190
+ - {type: proc, owner: enemy, position: [60, 4]} # N-mid
191
+ - {type: proc, owner: enemy, position: [60, 36]} # S-mid
192
+ win_condition:
193
+ all_of:
194
+ - {buildings_discovered_gte: 4}
195
+ - {units_lost_lte: 1}
196
+ - {within_ticks: 1300}
197
+ fail_condition:
198
+ any_of:
199
+ - {after_ticks: 1301}
200
+ - {not: {units_lost_lte: 1}}
201
+ - {not: {own_units_gte: 1}}
202
+ max_turns: 16
tests/test_hard_tier.py CHANGED
@@ -500,6 +500,18 @@ UPGRADED = [
500
  # stack (y=18..22), so a memorised "drive east on y=20" opening
501
  # cannot generalise across the lane variants.
502
  "proc-tool-use-multi-distractor",
 
 
 
 
 
 
 
 
 
 
 
 
503
  ]
504
 
505
  # Consciously NOT spawn-varied, with the reason (keeps the curation
 
500
  # stack (y=18..22), so a memorised "drive east on y=20" opening
501
  # cannot generalise across the lane variants.
502
  "proc-tool-use-multi-distractor",
503
+ # Wave-6 Group E perception pack — parallel multi-region scout
504
+ # (Watch-And-Help concurrent multi-task / SMAC distributed
505
+ # exploration / SC2 multi-scout coverage / military distributed
506
+ # reconnaissance doctrine anchor). 3 jeeps must split across K
507
+ # foggy regions; sending all jeeps to one region or touring all
508
+ # regions with a single jeep loses on the K-discovery bar or the
509
+ # clock. Hard defines two agent spawn_point groups (NW y=6..12 /
510
+ # SW y=28..34) round-robined by seed; the K=4 region layout is
511
+ # symmetric N/S so the sweep corridor (NW→NE on the north edge
512
+ # vs SW→SE on the south edge) flips per seed and a memorised
513
+ # single-corner reading order cannot generalise across spawns.
514
+ "scout-multiple-fog-areas",
515
  ]
516
 
517
  # Consciously NOT spawn-varied, with the reason (keeps the curation
tests/test_scout_multiple_fog_areas.py ADDED
@@ -0,0 +1,365 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """scout-multiple-fog-areas — parallel multi-region scout pack.
2
+
3
+ Capability: PERCEPTION (parallel dispatch under non-overlapping
4
+ coverage / multi-scout intel-window). Cell of the perception ladder
5
+ sibling to perception-frontier-reading (single-frontier read) and
6
+ perception-count-the-threat (exact census under fog).
7
+
8
+ Scripted policies cover the bar-defining outcomes per CLAUDE.md
9
+ "no defect, no cheat":
10
+
11
+ * stall → LOSS (clock; nothing discovered)
12
+ * single-jeep tour → LOSS on medium+hard (clock; one jeep
13
+ cannot cover K regions in time)
14
+ * all-to-NE-only → LOSS (K-1 region buildings stay in fog,
15
+ win bar buildings_discovered_gte:K
16
+ is never met)
17
+ * intended SPLIT → WIN (every level, seeds 1..4)
18
+
19
+ Easy is intentionally permissive: K=2 makes a single-jeep tour
20
+ (NE→SE on the east edge, ~166 cells ≈ 1000 ticks) fit inside the
21
+ generous 1800-tick clock. The discrimination teeth bite on medium
22
+ and hard, where the tour distance and K-bar combine to force
23
+ genuine parallel dispatch.
24
+
25
+ Hard tier rotates the agent spawn corner between NW (5, 6..12) and
26
+ SW (5, 28..34) by seed (UPGRADED contract). The intended split
27
+ policy reads the actual jeep rows on turn 1 and chooses the north
28
+ sweep (NW→NE on y≈4) or south sweep (SW→SE on y≈36) per jeep based
29
+ on which corridor each jeep is on.
30
+ """
31
+
32
+ from __future__ import annotations
33
+
34
+ from pathlib import Path
35
+
36
+ import pytest
37
+
38
+ pytest.importorskip("openra_train", reason="Rust env wheel not installed")
39
+ pytest.importorskip("openra_rl_training", reason="Rust env wheel not installed")
40
+ from openra_bench.eval_core import run_level
41
+ from openra_bench.scenarios import load_pack
42
+ from openra_bench.scenarios.loader import compile_level
43
+
44
+ PACK_PATH = (
45
+ Path(__file__).parent.parent
46
+ / "openra_bench"
47
+ / "scenarios"
48
+ / "packs"
49
+ / "scout-multiple-fog-areas.yaml"
50
+ )
51
+
52
+ LEVELS = ("easy", "medium", "hard")
53
+ SEEDS = (1, 2, 3, 4)
54
+
55
+
56
+ # ── Pack-shape tests (cheap; no engine) ───────────────────────────
57
+
58
+
59
+ def test_pack_compiles_with_three_levels():
60
+ pack = load_pack(PACK_PATH)
61
+ assert pack.meta.id == "scout-multiple-fog-areas"
62
+ assert pack.meta.capability == "perception"
63
+ assert set(pack.levels) == {"easy", "medium", "hard"}
64
+
65
+
66
+ def test_meta_benchmark_anchor_set():
67
+ """Seed-taxonomy contract: the anchors must call out the
68
+ Watch-And-Help / SMAC / SC2 multi-scout / military distributed
69
+ recon framing."""
70
+ pack = load_pack(PACK_PATH)
71
+ anchors = pack.meta.benchmark_anchor or []
72
+ assert any("Watch-And-Help" in a for a in anchors), anchors
73
+ assert any("SMAC" in a for a in anchors), anchors
74
+ assert any("SC2 multi-scout" in a for a in anchors), anchors
75
+ assert any("military distributed reconnaissance" in a for a in anchors), anchors
76
+
77
+
78
+ def test_every_level_has_fail_condition():
79
+ """No silent draws — every level emits a real LOSS on timeout
80
+ or scout-death."""
81
+ pack = load_pack(PACK_PATH)
82
+ for lvl in LEVELS:
83
+ c = compile_level(pack, lvl)
84
+ assert c.fail_condition is not None, f"{lvl} missing fail_condition"
85
+
86
+
87
+ def test_hard_tier_has_seed_driven_spawn_groups():
88
+ """Hard must define ≥2 agent spawn_point groups (the binding
89
+ contract from tests/test_hard_tier.py::UPGRADED)."""
90
+ c = compile_level(load_pack(PACK_PATH), "hard")
91
+ sp = {a.spawn_point for a in c.scenario.actors if a.owner == "agent"}
92
+ assert len(sp) >= 2, f"hard needs ≥2 spawn groups, got {sp}"
93
+
94
+
95
+ def test_tick_budget_aligned_with_max_turns():
96
+ """within_ticks / after_ticks must be reachable inside max_turns.
97
+ Engine advances ~90 ticks/turn → reachable max = 93 + 90·(N-1)."""
98
+ pack = load_pack(PACK_PATH)
99
+ for lvl in LEVELS:
100
+ level_def = pack.levels[lvl]
101
+ max_turns = level_def.max_turns
102
+ reachable = 93 + 90 * (max_turns - 1)
103
+ win = compile_level(pack, lvl).win_condition.model_dump(exclude_none=True)
104
+ fail = compile_level(pack, lvl).fail_condition.model_dump(exclude_none=True)
105
+
106
+ def _collect(node, key, out):
107
+ if isinstance(node, dict):
108
+ if key in node:
109
+ out.append(node[key])
110
+ for v in node.values():
111
+ _collect(v, key, out)
112
+ elif isinstance(node, list):
113
+ for v in node:
114
+ _collect(v, key, out)
115
+
116
+ wts: list[int] = []
117
+ _collect(win, "within_ticks", wts)
118
+ assert wts, f"{lvl} has no within_ticks leaf"
119
+ for wt in wts:
120
+ assert wt <= reachable, (
121
+ f"{lvl} within_ticks={wt} > reachable={reachable} "
122
+ f"(max_turns={max_turns}) — deadline never bites ⇒ draw"
123
+ )
124
+ ats: list[int] = []
125
+ _collect(fail, "after_ticks", ats)
126
+ for at in ats:
127
+ assert at <= reachable, (
128
+ f"{lvl} fail.after_ticks={at} > reachable={reachable} "
129
+ f"(max_turns={max_turns}) — fail clause never bites ⇒ draw"
130
+ )
131
+
132
+
133
+ def test_win_uses_buildings_discovered_gte():
134
+ """Capability contract: the win predicate must enforce
135
+ multi-region discovery via buildings_discovered_gte (not a
136
+ laxer landmark/region predicate that a single-region rush
137
+ could satisfy)."""
138
+ pack = load_pack(PACK_PATH)
139
+ expected_k = {"easy": 2, "medium": 3, "hard": 4}
140
+ for lvl in LEVELS:
141
+ win = compile_level(pack, lvl).win_condition.model_dump(exclude_none=True)
142
+ ks: list[int] = []
143
+
144
+ def _walk(node):
145
+ if isinstance(node, dict):
146
+ if "buildings_discovered_gte" in node:
147
+ ks.append(node["buildings_discovered_gte"])
148
+ for v in node.values():
149
+ _walk(v)
150
+ elif isinstance(node, list):
151
+ for v in node:
152
+ _walk(v)
153
+
154
+ _walk(win)
155
+ assert ks == [expected_k[lvl]], (
156
+ f"{lvl}: expected buildings_discovered_gte={expected_k[lvl]}, "
157
+ f"got {ks}"
158
+ )
159
+
160
+
161
+ # ── Scripted policies ─────────────────────────────────────────────
162
+
163
+
164
+ def _stall(_rs, Command):
165
+ return [Command.observe()]
166
+
167
+
168
+ def _all_to_ne(rs, Command):
169
+ """Send the entire fleet to the NE corner — the "send all jeeps to
170
+ one region" failure mode. Discovers at most one region marker;
171
+ leaves the others in fog so the win bar
172
+ buildings_discovered_gte:K (K≥2) is never met."""
173
+ units = rs.get("units_summary", []) or []
174
+ if not units:
175
+ return [Command.observe()]
176
+ return [
177
+ Command.move_units([str(u["id"])], target_x=124, target_y=4)
178
+ for u in units
179
+ ]
180
+
181
+
182
+ def _single_jeep_tour_factory(targets):
183
+ """Designate the lowest-id jeep as the lone scout and march it
184
+ through `targets` in order. The other jeeps sit at spawn (no
185
+ command issued for them). Models the "one column does all the
186
+ work" failure mode that the parallel-dispatch tooth catches."""
187
+ state = {"phase": 0}
188
+
189
+ def _tour(rs, Command):
190
+ units = rs.get("units_summary", []) or []
191
+ if not units:
192
+ return [Command.observe()]
193
+ u = sorted(units, key=lambda x: x["id"])[0]
194
+ uid = str(u["id"])
195
+ ux, uy = u["cell_x"], u["cell_y"]
196
+ if state["phase"] >= len(targets):
197
+ return [Command.observe()]
198
+ tx, ty = targets[state["phase"]]
199
+ if abs(ux - tx) <= 5 and abs(uy - ty) <= 5:
200
+ state["phase"] += 1
201
+ if state["phase"] >= len(targets):
202
+ return [Command.observe()]
203
+ tx, ty = targets[state["phase"]]
204
+ return [Command.move_units([uid], target_x=tx, target_y=ty)]
205
+
206
+ return _tour
207
+
208
+
209
+ # Per-level single-jeep tour routes — straight-line east-edge tours
210
+ # (the natural "lazy" tour a model would pick).
211
+ _TOUR_TARGETS = {
212
+ "easy": [(124, 4), (124, 36)],
213
+ "medium": [(124, 4), (124, 20), (124, 36)],
214
+ "hard": [(60, 4), (124, 4), (124, 36), (60, 36)],
215
+ }
216
+
217
+
218
+ def _intended_split_factory(level: str):
219
+ """Parallel dispatch: one jeep per region (or one per corridor
220
+ sweep on hard). Reads the actual jeep rows on turn 1 and assigns
221
+ the closest region to each jeep — works for both NW and SW spawn
222
+ on hard.
223
+
224
+ easy (K=2): top jeep → NE (124,4); bottom jeep → SE (124,36);
225
+ mid jeep idle (only 2 regions).
226
+ medium (K=3): top jeep → NE; mid jeep → far-E (124,20);
227
+ bottom jeep → SE.
228
+ hard (K=4): top jeep sweeps north corridor (60,4)→(124,4);
229
+ bottom jeep sweeps south corridor (60,36)→(124,36);
230
+ mid jeep idle (3 jeeps for 4 regions ⇒ sweep both
231
+ ends of one corridor with one jeep). State per
232
+ sweep jeep is latched so each marker is committed
233
+ to before moving to the next.
234
+ """
235
+ state = {"top_phase": 0, "bot_phase": 0}
236
+
237
+ def _split(rs, Command):
238
+ units = rs.get("units_summary", []) or []
239
+ if not units:
240
+ return [Command.observe()]
241
+ sorted_units = sorted(units, key=lambda u: u["cell_y"])
242
+ cmds = []
243
+ if level == "easy":
244
+ # 3 jeeps, 2 regions — top→NE, bottom→SE, mid idle.
245
+ cmds.append(Command.move_units(
246
+ [str(sorted_units[0]["id"])], target_x=124, target_y=4))
247
+ cmds.append(Command.move_units(
248
+ [str(sorted_units[-1]["id"])], target_x=124, target_y=36))
249
+ elif level == "medium":
250
+ # 3 jeeps, 3 regions — one each.
251
+ targets = [(124, 4), (124, 20), (124, 36)]
252
+ for u, (tx, ty) in zip(sorted_units, targets):
253
+ cmds.append(Command.move_units(
254
+ [str(u["id"])], target_x=tx, target_y=ty))
255
+ else: # hard — 3 jeeps, 4 regions, two sweeps.
256
+ top = sorted_units[0]
257
+ bot = sorted_units[-1]
258
+ n_targets = [(60, 4), (124, 4)]
259
+ if state["top_phase"] < len(n_targets):
260
+ tx, ty = n_targets[state["top_phase"]]
261
+ ux, uy = top["cell_x"], top["cell_y"]
262
+ if abs(ux - tx) <= 4 and abs(uy - ty) <= 4:
263
+ state["top_phase"] += 1
264
+ if state["top_phase"] < len(n_targets):
265
+ tx, ty = n_targets[state["top_phase"]]
266
+ cmds.append(Command.move_units(
267
+ [str(top["id"])], target_x=tx, target_y=ty))
268
+ s_targets = [(60, 36), (124, 36)]
269
+ if state["bot_phase"] < len(s_targets):
270
+ tx, ty = s_targets[state["bot_phase"]]
271
+ ux, uy = bot["cell_x"], bot["cell_y"]
272
+ if abs(ux - tx) <= 4 and abs(uy - ty) <= 4:
273
+ state["bot_phase"] += 1
274
+ if state["bot_phase"] < len(s_targets):
275
+ tx, ty = s_targets[state["bot_phase"]]
276
+ cmds.append(Command.move_units(
277
+ [str(bot["id"])], target_x=tx, target_y=ty))
278
+ return cmds or [Command.observe()]
279
+
280
+ return _split
281
+
282
+
283
+ # ── Solvency: intended SPLIT WINS every (level, seed) ─────────────
284
+
285
+
286
+ @pytest.mark.parametrize("seed", SEEDS)
287
+ @pytest.mark.parametrize("level", LEVELS)
288
+ def test_intended_split_wins(level, seed):
289
+ c = compile_level(load_pack(PACK_PATH), level)
290
+ res = run_level(c, _intended_split_factory(level), seed=seed)
291
+ assert res.outcome == "win", (
292
+ f"intended split must WIN on {level} s={seed}; got {res.outcome} "
293
+ f"tick={res.signals.game_tick} "
294
+ f"bds={len(res.signals.enemy_buildings_seen_ids)} "
295
+ f"lost={res.signals.units_lost}"
296
+ )
297
+
298
+
299
+ # ── Stability: every non-win pattern is a real reachable LOSS ─────
300
+
301
+
302
+ @pytest.mark.parametrize("seed", SEEDS)
303
+ @pytest.mark.parametrize("level", LEVELS)
304
+ def test_stall_loses(level, seed):
305
+ c = compile_level(load_pack(PACK_PATH), level)
306
+ res = run_level(c, _stall, seed=seed)
307
+ assert res.outcome == "loss", (
308
+ f"stall must LOSE on {level} s={seed}; got {res.outcome}"
309
+ )
310
+
311
+
312
+ @pytest.mark.parametrize("seed", SEEDS)
313
+ @pytest.mark.parametrize("level", LEVELS)
314
+ def test_all_to_ne_only_loses(level, seed):
315
+ """Sending the whole fleet to one corner discovers at most 1 of
316
+ K region buildings — the win bar (K≥2) is never met and the
317
+ other markers stay in fog (so the engine does NOT auto-`done`),
318
+ meaning the deadline cleanly fires the LOSS."""
319
+ c = compile_level(load_pack(PACK_PATH), level)
320
+ res = run_level(c, _all_to_ne, seed=seed)
321
+ assert res.outcome == "loss", (
322
+ f"all-to-NE must LOSE on {level} s={seed}; got {res.outcome} "
323
+ f"bds={len(res.signals.enemy_buildings_seen_ids)} "
324
+ f"lost={res.signals.units_lost}"
325
+ )
326
+
327
+
328
+ @pytest.mark.parametrize("seed", SEEDS)
329
+ @pytest.mark.parametrize("level", ["medium", "hard"])
330
+ def test_single_jeep_tour_loses_medium_and_hard(level, seed):
331
+ """One jeep walks A→B→C(→D) serially while the other two sit
332
+ at spawn. The tour distance pushes the discovery of the last
333
+ region past the within_ticks deadline ⇒ LOSS on the clock.
334
+
335
+ Easy (K=2 along the east edge) is intentionally permissive: the
336
+ tour distance ≈ 166 cells fits the 1800-tick generous clock,
337
+ so a single-jeep tour CAN win on easy. The bar is medium/hard
338
+ where the K-bar and clock combine to force parallel dispatch."""
339
+ c = compile_level(load_pack(PACK_PATH), level)
340
+ res = run_level(c, _single_jeep_tour_factory(_TOUR_TARGETS[level]), seed=seed)
341
+ assert res.outcome == "loss", (
342
+ f"single-jeep tour must LOSE on {level} s={seed}; "
343
+ f"got {res.outcome} tick={res.signals.game_tick} "
344
+ f"bds={len(res.signals.enemy_buildings_seen_ids)}"
345
+ )
346
+
347
+
348
+ # ── Spawn variation contract (hard) ───────────────────────────────
349
+
350
+
351
+ @pytest.mark.parametrize("seed", SEEDS)
352
+ def test_hard_seeds_produce_outcomes_per_seed(seed):
353
+ """Hard's two spawn_point groups round-robin per seed; the
354
+ intended split policy must WIN from EITHER spawn corner. The
355
+ cross-seed distinct-starts assertion is enforced centrally in
356
+ tests/test_hard_tier.py::test_curated_hard_still_compiles_and_runs."""
357
+ c = compile_level(load_pack(PACK_PATH), "hard")
358
+ res = run_level(c, _intended_split_factory("hard"), seed=seed)
359
+ assert res.outcome == "win", (
360
+ f"hard intended split must WIN s={seed}; got {res.outcome} "
361
+ f"tick={res.signals.game_tick} "
362
+ f"bds={len(res.signals.enemy_buildings_seen_ids)}"
363
+ )
364
+ sp = {a.spawn_point for a in c.scenario.actors if a.owner == "agent"}
365
+ assert len(sp) >= 2