Spaces:
Running
feat(scenario): scout-multiple-fog-areas — parallel multi-region scout (Watch-And-Help / SMAC distributed-recon anchor)
Browse filesWave-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).
|
@@ -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
|
|
@@ -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
|
|
@@ -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
|