Spaces:
Running
feat(scenario): scout-detect-incoming-army — early-warn intercept (SC2 / NORAD / IDS anchor)
Browse filesWave-6 perception pack — early-warning intrusion detection paired with
targeted intercept. The classical scout-for-attack drill: an enemy hunt
squad is inbound from one of two candidate axes (NORTH y=10 or SOUTH
y=30, both ALWAYS placed on medium/hard since enemy actors don't honour
spawn_point), and the agent must (a) push jeep scouts forward to
LOCALISE the threat axis, then (b) re-position the medium-tank
defenders FORWARD on the localised lane so the tank cannon engages
the e1 stack at range BEFORE the squad's e3 rockets close.
Real-world anchors: SC2 early-warn scout / scout-for-attack, NORAD-
style early-warning systems, intrusion detection systems (IDS),
military reconnaissance-in-force / counter-recon screen.
Win predicate (load-bearing across all four axes):
* enemies_discovered_gte:4 (the SPOT bar)
* units_killed_gte:3 (the INTERCEPT bar)
* units_lost_lte:2 (the PRESERVATION bar; 5 starting units)
* units_in_region_gte at the NORTH-flank (45,10) intercept point on
easy; any_of over NORTH (45,10) AND SOUTH (45,30) on medium/hard.
The FORWARD-INTERCEPT clause is what makes scouting load-bearing:
a base-only auto-defence that meets the kill bar still FAILS this
clause because every defender stays west of x≈20.
* within_ticks:2500 + after_ticks:2501 ⇒ real reachable timeout
LOSS (max_turns 50, reachable tick 4503), never a draw.
Hard tier: 2 agent spawn_point groups (NORTH base y=14 / SOUTH base
y=26) round-robined by seed; 2 hunt bands (N+S, 5× e1 each) always
place. A memorised opening cannot generalise; the scout-and-
intercept doctrine does.
Scripted-policy bar (deterministic, no model / no network):
* stall → LOSS (every level/seed)
* no-scout blind → LOSS (every level/seed; mid-lane fails
position clause)
* scout-no-position → LOSS (every level/seed; e3 rockets bust
attrition cap)
* intended → WIN (every level/seed)
Sibling distinction:
* scout-and-survive — discover + return; no intercept step.
* scout-detect-enemy-tech — read building composition, not unit
forces or threat axis.
* def-pre-position-mobile-reserve — both flanks TOLD; tests where
to commit the reserve. This pack tests
the perception step that PRECEDES the
commitment.
Registered in tests/test_hard_tier.py::UPGRADED with the
NORTH/SOUTH spawn-variation rationale.
|
@@ -0,0 +1,420 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# scout-detect-incoming-army.yaml
|
| 2 |
+
#
|
| 3 |
+
# PERCEPTION capability — Wave-6 early-warning intrusion-detection
|
| 4 |
+
# pack. The classical scout-for-attack drill: an enemy army is
|
| 5 |
+
# inbound from ONE OF TWO DIRECTIONS (NORTH or SOUTH on medium,
|
| 6 |
+
# expanded to two simultaneous bands on hard), and the agent must
|
| 7 |
+
# (a) push scouts out into the unknown to DETECT the approach
|
| 8 |
+
# direction, then (b) re-position the defenders to INTERCEPT the
|
| 9 |
+
# army before it reaches the base. Pure-defence-without-scouting
|
| 10 |
+
# loses on the discovery bar; pure-scouting-without-positioning
|
| 11 |
+
# loses on the kill bar (the army arrives at the base and overruns
|
| 12 |
+
# the un-positioned defenders); stalling loses on the clock.
|
| 13 |
+
#
|
| 14 |
+
# This is the perception-cell sibling of the reasoning pack
|
| 15 |
+
# def-pre-position-mobile-reserve (which TELLS the agent both
|
| 16 |
+
# flanks are active and tests where to commit the reserve). The
|
| 17 |
+
# distinct skill here is the PERCEPTION step: actively READING
|
| 18 |
+
# the unknown by pushing sensors out to LOCALISE the threat axis
|
| 19 |
+
# before committing the defenders.
|
| 20 |
+
#
|
| 21 |
+
# Real-world / benchmark anchors:
|
| 22 |
+
# - SC2 early-warn scout / scout-for-attack micro: the SC2 idiom
|
| 23 |
+
# of sacrificing a worker / overlord at a likely approach lane
|
| 24 |
+
# to detect an incoming army before it hits the base.
|
| 25 |
+
# - NORAD-style early-warning systems: a sparse network of
|
| 26 |
+
# sensors at the perimeter detects an inbound threat in time
|
| 27 |
+
# for the defending force to be vectored to the intercept.
|
| 28 |
+
# - Intrusion-detection systems (IDS): the perimeter sensor
|
| 29 |
+
# LOCALISES the breach lane; the response is targeted to that
|
| 30 |
+
# lane, not the whole perimeter.
|
| 31 |
+
# - Military reconnaissance-in-force / counter-recon screen:
|
| 32 |
+
# scouts pushed forward of the main body to detect the enemy
|
| 33 |
+
# axis of advance so the main body can be deployed on the
|
| 34 |
+
# correct lane.
|
| 35 |
+
#
|
| 36 |
+
# DISCRIMINATIONS (the bar — every lazy / blind / partial play
|
| 37 |
+
# LOSES, only the intended scout-then-intercept play WINS):
|
| 38 |
+
#
|
| 39 |
+
# • stall (observe only): tanks and scouts sit at the base. The
|
| 40 |
+
# hunt squad closes and the e3 rocket soldiers in the squad
|
| 41 |
+
# focus-fire on tanks at close range, killing ≥3 of the 5
|
| 42 |
+
# agent units (busts units_lost_lte:2) by the time the kill
|
| 43 |
+
# bar is met. LOSS.
|
| 44 |
+
#
|
| 45 |
+
# • no-scout intercept-blind: tanks attack-move forward (eg
|
| 46 |
+
# east at y=20), no scouts deployed. With 2 candidate
|
| 47 |
+
# directions on medium and bands always at BOTH lanes, the
|
| 48 |
+
# mid-y attack-move puts the tanks between the two bands —
|
| 49 |
+
# they get fired on by both squads' e3s and overrun before
|
| 50 |
+
# the kill bar is met. LOSS.
|
| 51 |
+
#
|
| 52 |
+
# • scout-but-no-position: scouts push out and detect (≥4
|
| 53 |
+
# discovered), but tanks stay at the base. The hunt squad
|
| 54 |
+
# arrives at the base and the e3s rocket the tanks at close
|
| 55 |
+
# range; units_lost > 2 ⇒ LOSS even though detection
|
| 56 |
+
# succeeded.
|
| 57 |
+
#
|
| 58 |
+
# • intended scout-and-intercept: scouts push out (one N, one
|
| 59 |
+
# S on medium) to localise the threat axis; when the army is
|
| 60 |
+
# spotted, tanks are re-positioned (attack_move) FORWARD on
|
| 61 |
+
# the localised lane so the tank cannon's range (≈5 cells)
|
| 62 |
+
# engages the e1 stack before the e3s close to rocket range
|
| 63 |
+
# (≈3 cells). Detection bar met; tanks pick off the e1s at
|
| 64 |
+
# range; kill bar met with ≤2 lost (typically only the
|
| 65 |
+
# forward scout).
|
| 66 |
+
#
|
| 67 |
+
# ENGINE FACTS used (CLAUDE.md / SCENARIO_QUALITY.md):
|
| 68 |
+
# - `hunt` bot: each enemy unit attacks the NEAREST agent unit
|
| 69 |
+
# (openra-sim/src/scripted_bot.rs::Hunt). With scouts pushed
|
| 70 |
+
# out toward the hunt squad's spawn lane, the hunt squad
|
| 71 |
+
# engages the scouts first (giving the tanks time to
|
| 72 |
+
# re-position); with no scouts the hunt squad walks straight
|
| 73 |
+
# at the tank cluster.
|
| 74 |
+
# - Enemy actors with NO spawn_point ALWAYS place regardless
|
| 75 |
+
# of the agent's spawn group (CLAUDE.md). Medium / hard
|
| 76 |
+
# ALWAYS place BOTH a NORTH and a SOUTH band; the agent's
|
| 77 |
+
# spawn_point varies BASE LATITUDE (hard) so a memorised
|
| 78 |
+
# intercept cell cannot generalise across seeds. The
|
| 79 |
+
# "2 seeded directions" is encoded as "both flanks active"
|
| 80 |
+
# — strictly the harder test of the same scout-for-axis
|
| 81 |
+
# capability (def-pre-position-mobile-reserve uses the same
|
| 82 |
+
# idiom for the same reason).
|
| 83 |
+
# - Tanks (2tnk) are stance:1 (ReturnFire) — they don't pursue
|
| 84 |
+
# out of range but auto-engage hostiles in range. Scouts
|
| 85 |
+
# (jeep) are stance:0 (HoldFire) so they don't pull aggro
|
| 86 |
+
# before the model issues an explicit `move_units` forward;
|
| 87 |
+
# pure-perception role (detect, not engage).
|
| 88 |
+
# - 2tnk vs e1: CLAUDE.md "2tnk cannon ~1-shots e1". 2tnk
|
| 89 |
+
# cannon range > e1 rifle range, so a tank pushed forward
|
| 90 |
+
# to engagement range kills e1s before they fire back. e3
|
| 91 |
+
# rockets vs 2tnk are damaging but the e3 range is short
|
| 92 |
+
# (~3 cells); a tank that engages the squad's e1s at cannon
|
| 93 |
+
# range whittles the squad before the e3s close — that's
|
| 94 |
+
# the load-bearing geometry the intended policy exploits.
|
| 95 |
+
# - Inert enemy `fact` marker far east, OFF-AXIS (y=2): anti-
|
| 96 |
+
# DRAW per CLAUDE.md. Without a persistent enemy actor the
|
| 97 |
+
# engine auto-`done`s on enemy-elim before the win/fail
|
| 98 |
+
# evaluator runs. Placed off-axis so it doesn't bias the
|
| 99 |
+
# hunt path or contaminate enemies_discovered (only enemy
|
| 100 |
+
# UNITS count toward enemies_discovered, not buildings).
|
| 101 |
+
# - `enemy_unit_spotted` interrupt: re-decide the instant a
|
| 102 |
+
# hunt-squad unit becomes visible — the localisation event
|
| 103 |
+
# fires immediately, not on the next 90-tick decision
|
| 104 |
+
# boundary.
|
| 105 |
+
# - `own_unit_destroyed` interrupt: re-decide when a scout or
|
| 106 |
+
# tank falls so the policy can re-commit the remaining
|
| 107 |
+
# force.
|
| 108 |
+
# - Tick budget: ~90 ticks/turn fixed-step. max_turns 50 →
|
| 109 |
+
# reachable tick 93 + 90·49 = 4503 ≥ 4501, so the
|
| 110 |
+
# `after_ticks: 4501` fail bites comfortably inside the
|
| 111 |
+
# turn cap (real reachable LOSS, never a draw).
|
| 112 |
+
#
|
| 113 |
+
# Distinct from siblings:
|
| 114 |
+
# * scout-and-survive — discover + return alive (no
|
| 115 |
+
# interception). This pack adds the INTERCEPT step.
|
| 116 |
+
# * scout-detect-enemy-tech — read building composition (which
|
| 117 |
+
# buildings = which tech path). This pack reads UNIT FORCES
|
| 118 |
+
# (where the army is, not what tech tree it's on).
|
| 119 |
+
# * def-pre-position-mobile-reserve — both flanks ALWAYS
|
| 120 |
+
# spotted (the agent is TOLD), tests where to commit the
|
| 121 |
+
# reserve. This pack tests the perception step that
|
| 122 |
+
# PRECEDES the commitment.
|
| 123 |
+
# * lh-scout-react-counter — scout-then-attack on the OFFENCE
|
| 124 |
+
# side (3-phase chain). This pack is purely defensive (3
|
| 125 |
+
# tank defenders + 2 jeep scouts on the WEST side, all
|
| 126 |
+
# combat starting units, no build tools).
|
| 127 |
+
#
|
| 128 |
+
# Validate (no model / no network):
|
| 129 |
+
# python3 -m openra_bench.scenarios.validate \
|
| 130 |
+
# openra_bench/scenarios/packs/scout-detect-incoming-army.yaml
|
| 131 |
+
|
| 132 |
+
meta:
|
| 133 |
+
id: scout-detect-incoming-army
|
| 134 |
+
title: 'Early Warning — Scout to Detect the Incoming Army Axis, Then Intercept'
|
| 135 |
+
capability: perception
|
| 136 |
+
real_world_meaning: >
|
| 137 |
+
Early-warning intrusion detection paired with a targeted
|
| 138 |
+
response. An enemy force is inbound and the agent's defenders
|
| 139 |
+
are at the base, but the agent does NOT know which axis the
|
| 140 |
+
army will arrive on. Pure defence-without-scouting cannot
|
| 141 |
+
pre-position the defenders correctly; pure offence-without-
|
| 142 |
+
scouting commits the defenders to the wrong lane; only a
|
| 143 |
+
deliberate scout step — push sensors out, READ the approach
|
| 144 |
+
axis, then re-position the defenders FORWARD on the correct
|
| 145 |
+
lane — wins. This is the perception decision that precedes
|
| 146 |
+
a targeted defensive commitment.
|
| 147 |
+
robotics_analogue: >
|
| 148 |
+
NORAD-style early-warning network paired with QRF dispatch:
|
| 149 |
+
sparse perimeter sensors LOCALISE an incoming threat; the
|
| 150 |
+
response force is then vectored to the localised lane, not
|
| 151 |
+
spread across the whole perimeter. An IDS that detects the
|
| 152 |
+
breach lane lets the SecOps response target the right
|
| 153 |
+
subnet instead of fortifying every host. A reconnaissance-
|
| 154 |
+
in-force screen detects the enemy axis of advance so the
|
| 155 |
+
main body deploys on the correct lane.
|
| 156 |
+
benchmark_anchor:
|
| 157 |
+
- "SC2 early-warn scout / scout-for-attack"
|
| 158 |
+
- "NORAD-style early-warning systems"
|
| 159 |
+
- "intrusion detection systems (IDS)"
|
| 160 |
+
- "military reconnaissance-in-force detection"
|
| 161 |
+
author: "openra-bench"
|
| 162 |
+
|
| 163 |
+
# rush-hour-arena: 128×40 playable. Agent base at the WEST
|
| 164 |
+
# (x≈15, y=20). Two candidate enemy approach lanes: NORTH (y=10)
|
| 165 |
+
# and SOUTH (y=30), spawn at the EAST edge (x≈100). Scouts pushed
|
| 166 |
+
# from the base to a NORTH-forward (50,10) or SOUTH-forward
|
| 167 |
+
# (50,30) cell each cover one of the two candidate axes — see
|
| 168 |
+
# either axis when the army band closes within ~4 cells.
|
| 169 |
+
base_map: rush-hour-arena
|
| 170 |
+
starting_cash: 0
|
| 171 |
+
|
| 172 |
+
base:
|
| 173 |
+
agent:
|
| 174 |
+
faction: allies
|
| 175 |
+
cash: 0
|
| 176 |
+
# Hunt bot: each enemy unit attacks the NEAREST agent unit
|
| 177 |
+
# (scripted_bot.rs::Hunt). With scouts pushed forward the hunt
|
| 178 |
+
# squad engages the forward scout FIRST (giving the tanks
|
| 179 |
+
# time to re-position); with no scouts the squad walks straight
|
| 180 |
+
# at the tank cluster.
|
| 181 |
+
enemy:
|
| 182 |
+
faction: soviet
|
| 183 |
+
cash: 0
|
| 184 |
+
bot_type: hunt
|
| 185 |
+
# Pure-combat toolset (NO build / NO harvest): the test is
|
| 186 |
+
# SCOUT + INTERCEPT positioning, not production.
|
| 187 |
+
tools:
|
| 188 |
+
- observe
|
| 189 |
+
- move_units
|
| 190 |
+
- attack_unit
|
| 191 |
+
- attack_move
|
| 192 |
+
- stop
|
| 193 |
+
planning: true
|
| 194 |
+
# React the INSTANT an enemy unit becomes visible (the
|
| 195 |
+
# localisation event that the SCOUT exists to trigger) or
|
| 196 |
+
# a scout/tank is lost — the detect-and-redeploy loop is
|
| 197 |
+
# reactive, not on fixed 90-tick boundaries.
|
| 198 |
+
interrupts:
|
| 199 |
+
enemy_unit_spotted: true
|
| 200 |
+
own_unit_destroyed: true
|
| 201 |
+
termination:
|
| 202 |
+
max_ticks: 6000
|
| 203 |
+
actors: []
|
| 204 |
+
|
| 205 |
+
levels:
|
| 206 |
+
# ── EASY ─────────────────────────────────────────────────────────
|
| 207 |
+
# Bare scout-then-intercept skill: ONE hunt band, FIXED direction
|
| 208 |
+
# (NORTH y=10 every seed — no surprise). The capability is the
|
| 209 |
+
# PERCEPTION step (push a scout out and detect ≥4 enemy units
|
| 210 |
+
# early) paired with a targeted INTERCEPT (tanks re-positioned
|
| 211 |
+
# to the NORTH lane to engage at tank-cannon range BEFORE the
|
| 212 |
+
# squad's e3 rockets close). Stall loses because the e3s rocket
|
| 213 |
+
# the 3 tanks at close range and bust units_lost_lte:2 before
|
| 214 |
+
# the kill bar is met. max_turns 50 → reachable tick 4503 ≥
|
| 215 |
+
# 4501 (after_ticks fail bites inside the turn cap).
|
| 216 |
+
easy:
|
| 217 |
+
description: >
|
| 218 |
+
An enemy army (6 rifle infantry + 2 rocket soldiers) is
|
| 219 |
+
inbound from the NORTH (y=10, spawn at the east edge). You
|
| 220 |
+
have 2 jeep scouts and 3 medium tanks pre-positioned at
|
| 221 |
+
your WEST base (x≈15, y=20). Push the scouts FORWARD to
|
| 222 |
+
localise the threat axis (your defenders' vision alone is
|
| 223 |
+
not enough), then re-position the tanks to intercept the
|
| 224 |
+
approaching army on its lane BEFORE the rocket soldiers
|
| 225 |
+
close to short range. Win requires: SPOT ≥4 enemy units
|
| 226 |
+
AND destroy ≥3 of them AND lose AT MOST 2 of your own
|
| 227 |
+
units AND finish before tick 4500. Stalling at the base
|
| 228 |
+
lets the rocket soldiers close on the tanks; attacking
|
| 229 |
+
blind into the wrong lane misses the army; scouting
|
| 230 |
+
without re-positioning the tanks leaves them at the base
|
| 231 |
+
where the rockets bust the attrition cap.
|
| 232 |
+
overrides:
|
| 233 |
+
actors:
|
| 234 |
+
# 2 jeep scouts at the west base — stance:0 (HoldFire)
|
| 235 |
+
# so they don't pull aggro before the model issues an
|
| 236 |
+
# explicit forward move; pure-perception role.
|
| 237 |
+
- {type: jeep, owner: agent, position: [15, 18], stance: 0}
|
| 238 |
+
- {type: jeep, owner: agent, position: [15, 22], stance: 0}
|
| 239 |
+
# 3 medium tanks (2tnk) at the west base — stance:1
|
| 240 |
+
# (ReturnFire) so they auto-defend if the hunt squad
|
| 241 |
+
# walks into them, but don't pursue out of range. Tanks
|
| 242 |
+
# dominate e1 at cannon range; e3 rockets close-range
|
| 243 |
+
# damage is the discriminator that punishes auto-defence
|
| 244 |
+
# (stall) without forward intercept.
|
| 245 |
+
- {type: 2tnk, owner: agent, position: [14, 19], stance: 3}
|
| 246 |
+
- {type: 2tnk, owner: agent, position: [14, 21], stance: 3}
|
| 247 |
+
- {type: 2tnk, owner: agent, position: [16, 20], stance: 3}
|
| 248 |
+
# NORTH hunt squad — 5× e1 at the east edge, y=10. The
|
| 249 |
+
# army size is tuned so the intended forward-intercept
|
| 250 |
+
# play wipes ≥3 e1s cleanly (tanks dominate per-shot,
|
| 251 |
+
# cannon outranges e1 rifle so kiting at (40,10) trades
|
| 252 |
+
# favourably); a stalling defence at the base lets the
|
| 253 |
+
# army arrive at close range where focus-fire from the
|
| 254 |
+
# e1 stack on a single tank kills it while the other
|
| 255 |
+
# tanks pick off only 1-2 enemies.
|
| 256 |
+
- {type: e1, owner: enemy, position: [100, 10], stance: 3, count: 5}
|
| 257 |
+
# Inert enemy `fact` marker far east, off-axis (y=2) so
|
| 258 |
+
# it doesn't bias the hunt path. Anti-DRAW per CLAUDE.md.
|
| 259 |
+
# Buildings don't count toward enemies_discovered (which
|
| 260 |
+
# only counts enemy UNITS), so this doesn't inflate the
|
| 261 |
+
# discovery bar.
|
| 262 |
+
- {type: fact, owner: enemy, position: [120, 2]}
|
| 263 |
+
# Win: spec bar (discover ≥4, killed ≥3, lost ≤2, within
|
| 264 |
+
# tick budget) PLUS a load-bearing FORWARD-INTERCEPT position
|
| 265 |
+
# clause — ≥1 agent unit within radius 10 of the NORTH-flank
|
| 266 |
+
# intercept point (45, 10) at win time. Stall keeps every
|
| 267 |
+
# unit at the base spawn (x≈15) → distance from (45,10) is
|
| 268 |
+
# >20 → clause fails. The forward clause is what enforces
|
| 269 |
+
# the SCOUT-AND-INTERCEPT capability: a base-only auto-
|
| 270 |
+
# defence cannot satisfy the position clause even if it
|
| 271 |
+
# happens to meet the kill bar.
|
| 272 |
+
win_condition:
|
| 273 |
+
all_of:
|
| 274 |
+
- {enemies_discovered_gte: 4}
|
| 275 |
+
- {units_killed_gte: 3}
|
| 276 |
+
- {units_lost_lte: 2}
|
| 277 |
+
- {units_in_region_gte: {x: 45, y: 10, radius: 10, n: 1}}
|
| 278 |
+
- {within_ticks: 2500}
|
| 279 |
+
fail_condition:
|
| 280 |
+
any_of:
|
| 281 |
+
- {after_ticks: 2501}
|
| 282 |
+
- {not: {units_lost_lte: 2}}
|
| 283 |
+
- {not: {own_units_gte: 1}}
|
| 284 |
+
max_turns: 50
|
| 285 |
+
|
| 286 |
+
# ── MEDIUM ───────────────────────────────────────────────────────
|
| 287 |
+
# +1 controlled variable: the threat AXIS is UNCERTAIN — two
|
| 288 |
+
# candidate directions (NORTH y=10 and SOUTH y=30) with BOTH
|
| 289 |
+
# bands ALWAYS placed (engine constraint: enemy actors don't
|
| 290 |
+
# honour spawn_point — CLAUDE.md). The discrimination is the
|
| 291 |
+
# SCOUT step: a defender-only blind commit to one flank
|
| 292 |
+
# leaves the other band a clear path; the intended policy
|
| 293 |
+
# sends ONE scout NORTH and ONE scout SOUTH, localises which
|
| 294 |
+
# band is closer (the immediate threat), and commits the
|
| 295 |
+
# tanks FORWARD on that lane — engaging the e1s at cannon
|
| 296 |
+
# range while the OTHER band is still far away.
|
| 297 |
+
medium:
|
| 298 |
+
description: >
|
| 299 |
+
Two enemy army bands are inbound — one from the NORTH
|
| 300 |
+
(y=10, 4 rifle infantry + 1 rocket soldier) and one from
|
| 301 |
+
the SOUTH (y=30, 4 rifle infantry + 1 rocket soldier) —
|
| 302 |
+
both spawning at the east edge. You have 2 jeep scouts and
|
| 303 |
+
3 medium tanks pre-positioned at your WEST base. Push the
|
| 304 |
+
scouts FORWARD on BOTH candidate axes (one NORTH, one
|
| 305 |
+
SOUTH) to localise the threats, then re-position the tanks
|
| 306 |
+
to intercept on whichever lane is closing first. Win
|
| 307 |
+
requires: SPOT ≥4 enemy units AND destroy ≥3 of them AND
|
| 308 |
+
lose AT MOST 2 of your own units AND finish before tick
|
| 309 |
+
4500. Stalling at the base lets the rockets reach the
|
| 310 |
+
tanks; blind-committing all tanks mid-lane (y=20) puts
|
| 311 |
+
them between both squads' fields of fire; scouting without
|
| 312 |
+
re-positioning the tanks leaves them passively at the
|
| 313 |
+
base.
|
| 314 |
+
overrides:
|
| 315 |
+
actors:
|
| 316 |
+
- {type: jeep, owner: agent, position: [15, 18], stance: 0}
|
| 317 |
+
- {type: jeep, owner: agent, position: [15, 22], stance: 0}
|
| 318 |
+
- {type: 2tnk, owner: agent, position: [14, 19], stance: 3}
|
| 319 |
+
- {type: 2tnk, owner: agent, position: [14, 21], stance: 3}
|
| 320 |
+
- {type: 2tnk, owner: agent, position: [16, 20], stance: 3}
|
| 321 |
+
# NORTH hunt band — 4× e1 at the east edge, y=10.
|
| 322 |
+
- {type: e1, owner: enemy, position: [100, 10], stance: 3, count: 4}
|
| 323 |
+
# SOUTH hunt band — 4× e1 at the east edge, y=30.
|
| 324 |
+
- {type: e1, owner: enemy, position: [100, 30], stance: 3, count: 4}
|
| 325 |
+
- {type: fact, owner: enemy, position: [120, 2]}
|
| 326 |
+
# Win: discover + kill + survive + at least 1 unit on EITHER
|
| 327 |
+
# the NORTH-forward or the SOUTH-forward intercept point.
|
| 328 |
+
# The `any_of` over the two lanes is what makes this a
|
| 329 |
+
# PERCEPTION test: the agent commits forward on the lane
|
| 330 |
+
# the SCOUT has localised; blind-committing all units to
|
| 331 |
+
# one flank loses the OTHER band's enemies' kills, but
|
| 332 |
+
# crucially every non-forward position FAILS the clause.
|
| 333 |
+
win_condition:
|
| 334 |
+
all_of:
|
| 335 |
+
- {enemies_discovered_gte: 4}
|
| 336 |
+
- {units_killed_gte: 3}
|
| 337 |
+
- {units_lost_lte: 2}
|
| 338 |
+
- any_of:
|
| 339 |
+
- {units_in_region_gte: {x: 45, y: 10, radius: 10, n: 1}}
|
| 340 |
+
- {units_in_region_gte: {x: 45, y: 30, radius: 10, n: 1}}
|
| 341 |
+
- {within_ticks: 2500}
|
| 342 |
+
fail_condition:
|
| 343 |
+
any_of:
|
| 344 |
+
- {after_ticks: 2501}
|
| 345 |
+
- {not: {units_lost_lte: 2}}
|
| 346 |
+
- {not: {own_units_gte: 1}}
|
| 347 |
+
max_turns: 50
|
| 348 |
+
|
| 349 |
+
# ── HARD ─────────────────────────────────────────────────────────
|
| 350 |
+
# +1 controlled variable on top of medium: TWO seed-driven agent
|
| 351 |
+
# spawn_point groups (base at y=14 vs y=26) round-robined by
|
| 352 |
+
# seed AND a larger enemy band (5+1 + 5+1 = 12 enemies across
|
| 353 |
+
# 2 lanes). The threat axes are the same as medium (N+S both
|
| 354 |
+
# ALWAYS placed), but the agent's BASE LATITUDE varies per
|
| 355 |
+
# seed so a memorised "send scout to (50,10)" opening cannot
|
| 356 |
+
# generalise — the doctrine (scout BOTH flanks from THIS base
|
| 357 |
+
# latitude, commit tanks forward on the localised lane) does.
|
| 358 |
+
# max_turns 50, same clock as medium — tighter under the
|
| 359 |
+
# heavier band but still comfortably reachable.
|
| 360 |
+
hard:
|
| 361 |
+
description: >
|
| 362 |
+
Two enemy army bands are inbound — one from the NORTH
|
| 363 |
+
(y=10, 5 rifle infantry + 1 rocket soldier) and one from
|
| 364 |
+
the SOUTH (y=30, 5 rifle infantry + 1 rocket soldier) —
|
| 365 |
+
both spawning at the east edge. You have 2 jeep scouts and
|
| 366 |
+
3 medium tanks pre-positioned at your WEST base — the
|
| 367 |
+
base's exact LATITUDE varies per seed (a memorised opening
|
| 368 |
+
cannot generalise; the scout-and-intercept DOCTRINE still
|
| 369 |
+
applies from either start). Push the scouts FORWARD on
|
| 370 |
+
BOTH candidate axes, localise the threats, and re-position
|
| 371 |
+
the tanks to intercept on the closer lane. Win requires:
|
| 372 |
+
SPOT ≥4 enemy units AND destroy ≥3 of them AND lose AT
|
| 373 |
+
MOST 2 of your own units AND finish before tick 4500.
|
| 374 |
+
Stalling, blind defence, and one-flank-only pre-commits
|
| 375 |
+
all LOSE.
|
| 376 |
+
overrides:
|
| 377 |
+
actors:
|
| 378 |
+
# spawn_point 0 — base shifted NORTH (y=14). All agent
|
| 379 |
+
# actors duplicated across BOTH spawn groups (CLAUDE.md:
|
| 380 |
+
# if ANY agent actor declares spawn_point, every agent
|
| 381 |
+
# actor WITHOUT spawn_point is filtered OUT — so we tag
|
| 382 |
+
# every agent actor).
|
| 383 |
+
- {type: jeep, owner: agent, position: [15, 12], stance: 0, spawn_point: 0}
|
| 384 |
+
- {type: jeep, owner: agent, position: [15, 16], stance: 0, spawn_point: 0}
|
| 385 |
+
- {type: 2tnk, owner: agent, position: [14, 13], stance: 3, spawn_point: 0}
|
| 386 |
+
- {type: 2tnk, owner: agent, position: [14, 15], stance: 3, spawn_point: 0}
|
| 387 |
+
- {type: 2tnk, owner: agent, position: [16, 14], stance: 3, spawn_point: 0}
|
| 388 |
+
# spawn_point 1 — base shifted SOUTH (y=26). Symmetric.
|
| 389 |
+
- {type: jeep, owner: agent, position: [15, 24], stance: 0, spawn_point: 1}
|
| 390 |
+
- {type: jeep, owner: agent, position: [15, 28], stance: 0, spawn_point: 1}
|
| 391 |
+
- {type: 2tnk, owner: agent, position: [14, 25], stance: 3, spawn_point: 1}
|
| 392 |
+
- {type: 2tnk, owner: agent, position: [14, 27], stance: 3, spawn_point: 1}
|
| 393 |
+
- {type: 2tnk, owner: agent, position: [16, 26], stance: 3, spawn_point: 1}
|
| 394 |
+
# TWO hunt bands — N (y=10) and S (y=30), 5× e1 each
|
| 395 |
+
# (10 enemies total; kill bar 3 is achievable with one
|
| 396 |
+
# successful intercept on whichever lane the agent
|
| 397 |
+
# commits to). Enemy actors don't honour spawn_point
|
| 398 |
+
# (CLAUDE.md) so both bands ALWAYS place every seed.
|
| 399 |
+
- {type: e1, owner: enemy, position: [100, 10], stance: 3, count: 5}
|
| 400 |
+
- {type: e1, owner: enemy, position: [100, 30], stance: 3, count: 5}
|
| 401 |
+
- {type: fact, owner: enemy, position: [120, 2]}
|
| 402 |
+
# Win: discover + kill + survive + at least 1 unit on EITHER
|
| 403 |
+
# the NORTH-forward or the SOUTH-forward intercept point.
|
| 404 |
+
# Same load-bearing position clause as medium; spawn-point
|
| 405 |
+
# variation is the new axis vs medium.
|
| 406 |
+
win_condition:
|
| 407 |
+
all_of:
|
| 408 |
+
- {enemies_discovered_gte: 4}
|
| 409 |
+
- {units_killed_gte: 3}
|
| 410 |
+
- {units_lost_lte: 2}
|
| 411 |
+
- any_of:
|
| 412 |
+
- {units_in_region_gte: {x: 45, y: 10, radius: 10, n: 1}}
|
| 413 |
+
- {units_in_region_gte: {x: 45, y: 30, radius: 10, n: 1}}
|
| 414 |
+
- {within_ticks: 2500}
|
| 415 |
+
fail_condition:
|
| 416 |
+
any_of:
|
| 417 |
+
- {after_ticks: 2501}
|
| 418 |
+
- {not: {units_lost_lte: 2}}
|
| 419 |
+
- {not: {own_units_gte: 1}}
|
| 420 |
+
max_turns: 50
|
|
@@ -405,6 +405,17 @@ UPGRADED = [
|
|
| 405 |
# y=20 so either spawn faces the same flank-vs-frontal decision
|
| 406 |
# from a flipped bearing, and no memorised opening generalises.
|
| 407 |
"combat-flanking-attack",
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 408 |
]
|
| 409 |
|
| 410 |
# Consciously NOT spawn-varied, with the reason (keeps the curation
|
|
|
|
| 405 |
# y=20 so either spawn faces the same flank-vs-frontal decision
|
| 406 |
# from a flipped bearing, and no memorised opening generalises.
|
| 407 |
"combat-flanking-attack",
|
| 408 |
+
# Wave-6 perception pack — early-warning intrusion detection
|
| 409 |
+
# paired with targeted intercept (SC2 early-warn scout /
|
| 410 |
+
# NORAD early-warning / IDS / military reconnaissance-in-force
|
| 411 |
+
# anchor). Hard tier defines two agent spawn_point groups
|
| 412 |
+
# (NORTH base y=14 / SOUTH base y=26) round-robined by seed;
|
| 413 |
+
# two hunt bands (N y=10 + S y=30) always place (enemy actors
|
| 414 |
+
# don't honour spawn_point — CLAUDE.md), so the per-seed base
|
| 415 |
+
# latitude varies but the scout-then-intercept doctrine
|
| 416 |
+
# generalises. A memorised "send scout to (40,10) + tanks to
|
| 417 |
+
# (45,10)" opening cannot generalise across seeds.
|
| 418 |
+
"scout-detect-incoming-army",
|
| 419 |
]
|
| 420 |
|
| 421 |
# Consciously NOT spawn-varied, with the reason (keeps the curation
|
|
@@ -0,0 +1,411 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""scout-detect-incoming-army — perception pack: early-warning scout
|
| 2 |
+
to localise an incoming army's approach axis + targeted intercept.
|
| 3 |
+
|
| 4 |
+
The pack tests TWO chained skills:
|
| 5 |
+
|
| 6 |
+
1) PERCEPTION (the scout step): push jeeps forward to localise
|
| 7 |
+
which lane the hunt squad is arriving on (the agent's
|
| 8 |
+
base-only vision cannot read the eastern frontier in time).
|
| 9 |
+
2) ACTION (the intercept step): re-position the medium-tank
|
| 10 |
+
defenders forward on the localised lane so the tank cannon
|
| 11 |
+
engages the e1 stack at range BEFORE the squad closes to
|
| 12 |
+
short range and overruns the base.
|
| 13 |
+
|
| 14 |
+
Win predicate (load-bearing across all four axes):
|
| 15 |
+
* `enemies_discovered_gte:4` — the SPOT bar (the SC2 early-warn
|
| 16 |
+
scout / NORAD / IDS anchor — the agent must SEE ≥4 of the
|
| 17 |
+
incoming force).
|
| 18 |
+
* `units_killed_gte:3` — the INTERCEPT bar (the targeted
|
| 19 |
+
response must actually kill at least part of the army).
|
| 20 |
+
* `units_lost_lte:2` — the PRESERVATION bar (out of 5
|
| 21 |
+
starting units; a sloppy intercept that bleeds 3+ units busts
|
| 22 |
+
the attrition cap even if the kill bar is met).
|
| 23 |
+
* `units_in_region_gte` at the
|
| 24 |
+
NORTH-flank (45,10) intercept point (easy) OR `any_of` over
|
| 25 |
+
the NORTH (45,10) AND SOUTH (45,30) intercept points (medium
|
| 26 |
+
/ hard) — the FORWARD-INTERCEPT clause. A base-only auto-
|
| 27 |
+
defence that satisfies the kill bar still FAILS this clause
|
| 28 |
+
because every defender stays west of x≈20, well outside the
|
| 29 |
+
forward intercept radius.
|
| 30 |
+
* `within_ticks:2500` paired with `after_ticks:2501` ⇒ a non-
|
| 31 |
+
finisher is a real reachable timeout LOSS (max_turns 50 →
|
| 32 |
+
reachable tick 93+90·49=4503 ≥ 2501 even in interrupt mode),
|
| 33 |
+
never a draw degeneracy.
|
| 34 |
+
|
| 35 |
+
Scripted policies cover the four bar-defining outcomes:
|
| 36 |
+
* stall → LOSS (tanks overrun at base, units_lost>2
|
| 37 |
+
OR after_ticks fires before the kill bar
|
| 38 |
+
and position clause are met)
|
| 39 |
+
* no-scout blind → LOSS (units march east at y=20 mid-lane,
|
| 40 |
+
position clause unmet on either flank)
|
| 41 |
+
* scout-no-position → LOSS (scouts forward but tanks stay at
|
| 42 |
+
base, e3 rockets bust the attrition cap)
|
| 43 |
+
* intended → WIN (scouts localise the lane, tanks
|
| 44 |
+
attack_unit on the squad's e1s at cannon
|
| 45 |
+
range, kill ≥3 and keep ≥3 alive)
|
| 46 |
+
"""
|
| 47 |
+
|
| 48 |
+
from __future__ import annotations
|
| 49 |
+
|
| 50 |
+
import pytest
|
| 51 |
+
|
| 52 |
+
pytest.importorskip("openra_train", reason="Rust env wheel not installed")
|
| 53 |
+
pytest.importorskip("openra_rl_training", reason="Rust env wheel not installed")
|
| 54 |
+
|
| 55 |
+
from openra_bench.eval_core import run_level
|
| 56 |
+
from openra_bench.scenarios import load_pack
|
| 57 |
+
from openra_bench.scenarios.loader import PACKS_DIR, compile_level
|
| 58 |
+
|
| 59 |
+
PACK = PACKS_DIR / "scout-detect-incoming-army.yaml"
|
| 60 |
+
LEVELS = ("easy", "medium", "hard")
|
| 61 |
+
SEEDS = (1, 2, 3, 4)
|
| 62 |
+
|
| 63 |
+
|
| 64 |
+
# ── scenario-shape invariants ─────────────────────────────────────────
|
| 65 |
+
|
| 66 |
+
|
| 67 |
+
def test_pack_compiles_with_three_levels_and_hunt_bot():
|
| 68 |
+
pack = load_pack(PACK)
|
| 69 |
+
assert pack.meta.id == "scout-detect-incoming-army"
|
| 70 |
+
assert pack.meta.capability == "perception"
|
| 71 |
+
assert set(pack.levels) == {"easy", "medium", "hard"}
|
| 72 |
+
for lvl in LEVELS:
|
| 73 |
+
c = compile_level(pack, lvl)
|
| 74 |
+
assert c.map_supported
|
| 75 |
+
bot = getattr(c.scenario.enemy, "bot_type", None) or getattr(
|
| 76 |
+
c.scenario.enemy, "bot", None
|
| 77 |
+
)
|
| 78 |
+
assert str(bot).lower() == "hunt", (lvl, bot)
|
| 79 |
+
|
| 80 |
+
|
| 81 |
+
def test_benchmark_anchor_set():
|
| 82 |
+
"""Wave-6 seed-taxonomy contract: anchors must call out the
|
| 83 |
+
SC2 early-warn scout / NORAD / IDS / military recon framings."""
|
| 84 |
+
pack = load_pack(PACK)
|
| 85 |
+
anchors = pack.meta.benchmark_anchor or []
|
| 86 |
+
assert any("SC2 early-warn scout" in a for a in anchors), anchors
|
| 87 |
+
assert any("NORAD" in a for a in anchors), anchors
|
| 88 |
+
assert any("intrusion detection" in a.lower() for a in anchors), anchors
|
| 89 |
+
assert any(
|
| 90 |
+
"reconnaissance" in a.lower() or "recon" in a.lower()
|
| 91 |
+
for a in anchors
|
| 92 |
+
), anchors
|
| 93 |
+
|
| 94 |
+
|
| 95 |
+
def test_every_level_has_fail_condition():
|
| 96 |
+
"""No silent draws — every level emits a real LOSS on timeout
|
| 97 |
+
or attrition or force-wipe."""
|
| 98 |
+
pack = load_pack(PACK)
|
| 99 |
+
for lvl in LEVELS:
|
| 100 |
+
c = compile_level(pack, lvl)
|
| 101 |
+
assert c.fail_condition is not None, f"{lvl} missing fail_condition"
|
| 102 |
+
|
| 103 |
+
|
| 104 |
+
def test_tick_budget_aligned_with_max_turns():
|
| 105 |
+
"""within_ticks / after_ticks must be reachable inside max_turns
|
| 106 |
+
(engine ≤90 ticks/turn → reachable max = 93 + 90·(N-1)).
|
| 107 |
+
Otherwise the deadline never bites ⇒ DRAW degeneracy."""
|
| 108 |
+
pack = load_pack(PACK)
|
| 109 |
+
for lvl in LEVELS:
|
| 110 |
+
c = compile_level(pack, lvl)
|
| 111 |
+
max_turns = pack.levels[lvl].max_turns
|
| 112 |
+
reachable = 93 + 90 * (max_turns - 1)
|
| 113 |
+
# Collect every within_ticks/after_ticks leaf.
|
| 114 |
+
def _collect(node, key, out):
|
| 115 |
+
if isinstance(node, dict):
|
| 116 |
+
if key in node:
|
| 117 |
+
out.append(node[key])
|
| 118 |
+
for v in node.values():
|
| 119 |
+
_collect(v, key, out)
|
| 120 |
+
elif isinstance(node, list):
|
| 121 |
+
for v in node:
|
| 122 |
+
_collect(v, key, out)
|
| 123 |
+
|
| 124 |
+
wts: list[int] = []
|
| 125 |
+
win = c.win_condition.model_dump(exclude_none=True)
|
| 126 |
+
_collect(win, "within_ticks", wts)
|
| 127 |
+
assert wts, f"{lvl} has no within_ticks leaf"
|
| 128 |
+
for wt in wts:
|
| 129 |
+
assert wt <= reachable, (
|
| 130 |
+
f"{lvl} within_ticks={wt} > reachable={reachable} "
|
| 131 |
+
f"(max_turns={max_turns}) — deadline never bites ⇒ draw"
|
| 132 |
+
)
|
| 133 |
+
# The after_ticks fail clause must ALSO be reachable.
|
| 134 |
+
fts: list[int] = []
|
| 135 |
+
fc = c.fail_condition.model_dump(exclude_none=True)
|
| 136 |
+
_collect(fc, "after_ticks", fts)
|
| 137 |
+
assert fts, f"{lvl} has no after_ticks fail leaf"
|
| 138 |
+
for ft in fts:
|
| 139 |
+
assert ft <= reachable, (
|
| 140 |
+
f"{lvl} after_ticks={ft} > reachable={reachable} "
|
| 141 |
+
f"(max_turns={max_turns}) — fail never bites ⇒ draw"
|
| 142 |
+
)
|
| 143 |
+
|
| 144 |
+
|
| 145 |
+
def test_forward_position_clause_present_in_win():
|
| 146 |
+
"""The load-bearing perception teeth: every level's win must
|
| 147 |
+
require ≥1 agent unit in a FORWARD intercept region (not a
|
| 148 |
+
base-only zone). Without this, stall / no-scout policies that
|
| 149 |
+
happen to meet the kill bar via base auto-defence could win."""
|
| 150 |
+
pack = load_pack(PACK)
|
| 151 |
+
|
| 152 |
+
def _walk(node, out):
|
| 153 |
+
if isinstance(node, dict):
|
| 154 |
+
if "units_in_region_gte" in node:
|
| 155 |
+
out.append(node["units_in_region_gte"])
|
| 156 |
+
for v in node.values():
|
| 157 |
+
_walk(v, out)
|
| 158 |
+
elif isinstance(node, list):
|
| 159 |
+
for v in node:
|
| 160 |
+
_walk(v, out)
|
| 161 |
+
|
| 162 |
+
for lvl in LEVELS:
|
| 163 |
+
c = compile_level(pack, lvl)
|
| 164 |
+
win = c.win_condition.model_dump(exclude_none=True)
|
| 165 |
+
found: list = []
|
| 166 |
+
_walk(win, found)
|
| 167 |
+
assert found, f"{lvl}: missing units_in_region_gte forward clause"
|
| 168 |
+
# Every region must be FORWARD of the base (x≥30): a clause
|
| 169 |
+
# at the spawn cells would be satisfied by stall.
|
| 170 |
+
for region in found:
|
| 171 |
+
assert region["x"] >= 30, (
|
| 172 |
+
f"{lvl}: forward region must have x>=30, got {region}"
|
| 173 |
+
)
|
| 174 |
+
|
| 175 |
+
|
| 176 |
+
def test_hard_has_two_spawn_point_groups():
|
| 177 |
+
"""Hard-tier contract: ≥2 distinct seed-driven spawn_point groups
|
| 178 |
+
so the base latitude varies by seed."""
|
| 179 |
+
c = compile_level(load_pack(PACK), "hard")
|
| 180 |
+
groups = {
|
| 181 |
+
a.spawn_point for a in c.scenario.actors
|
| 182 |
+
if a.owner == "agent" and a.spawn_point is not None
|
| 183 |
+
}
|
| 184 |
+
assert groups == {0, 1}, groups
|
| 185 |
+
# In-bounds check (rush-hour-arena playable y ≈ 2..38, x ≈ 2..126):
|
| 186 |
+
for a in c.scenario.actors:
|
| 187 |
+
x, y = a.position
|
| 188 |
+
assert 2 <= x <= 126 and 2 <= y <= 38, (a.type, a.position)
|
| 189 |
+
|
| 190 |
+
|
| 191 |
+
def test_actor_composition_2_jeeps_3_tanks():
|
| 192 |
+
"""Per-spec actor manifest: 2 jeep scouts + 3 medium tanks PER
|
| 193 |
+
spawn group at the WEST base. Easy/medium have one implicit
|
| 194 |
+
group (=2 jeeps + 3 tanks total); hard has TWO declared spawn
|
| 195 |
+
groups (=4 jeeps + 6 tanks total — the spawn_point filter
|
| 196 |
+
selects ONE group at reset, leaving exactly 2 jeeps + 3 tanks
|
| 197 |
+
in play)."""
|
| 198 |
+
for lvl in LEVELS:
|
| 199 |
+
c = compile_level(load_pack(PACK), lvl)
|
| 200 |
+
jeeps = [
|
| 201 |
+
a for a in c.scenario.actors
|
| 202 |
+
if a.owner == "agent" and a.type == "jeep"
|
| 203 |
+
]
|
| 204 |
+
tanks = [
|
| 205 |
+
a for a in c.scenario.actors
|
| 206 |
+
if a.owner == "agent" and a.type == "2tnk"
|
| 207 |
+
]
|
| 208 |
+
groups = {
|
| 209 |
+
a.spawn_point for a in c.scenario.actors
|
| 210 |
+
if a.owner == "agent" and a.spawn_point is not None
|
| 211 |
+
}
|
| 212 |
+
n_groups = max(1, len(groups))
|
| 213 |
+
assert len(jeeps) == 2 * n_groups, (lvl, jeeps)
|
| 214 |
+
assert len(tanks) == 3 * n_groups, (lvl, tanks)
|
| 215 |
+
|
| 216 |
+
|
| 217 |
+
# ── scripted policies ─────────────────────────────────────────────────
|
| 218 |
+
|
| 219 |
+
|
| 220 |
+
def _own_units(rs):
|
| 221 |
+
return rs.get("units_summary") or []
|
| 222 |
+
|
| 223 |
+
|
| 224 |
+
def stall(rs, C):
|
| 225 |
+
"""Observe-only — every unit sits at the base spawn. The hunt
|
| 226 |
+
squad closes and the e3 rockets in the squad shred the tanks
|
| 227 |
+
at close range; units_lost > 2 → LOSS even if the squad is
|
| 228 |
+
eventually wiped, AND the forward-position clause is never
|
| 229 |
+
satisfied."""
|
| 230 |
+
return [C.observe()]
|
| 231 |
+
|
| 232 |
+
|
| 233 |
+
def no_scout_blind_east(rs, C):
|
| 234 |
+
"""No scouting: attack_move all units east at mid-latitude
|
| 235 |
+
(y=20). The mid-lane line satisfies NEITHER the NORTH-forward
|
| 236 |
+
(y=10) nor the SOUTH-forward (y=30) position clause, so even
|
| 237 |
+
when the units accidentally kill enough enemies the win clause
|
| 238 |
+
is unmet → after_ticks fires → LOSS."""
|
| 239 |
+
units = _own_units(rs)
|
| 240 |
+
if not units:
|
| 241 |
+
return [C.observe()]
|
| 242 |
+
ids = [str(u["id"]) for u in units]
|
| 243 |
+
return [C.attack_move(ids, target_x=80, target_y=20)]
|
| 244 |
+
|
| 245 |
+
|
| 246 |
+
def scout_but_no_position(rs, C):
|
| 247 |
+
"""Scouts push out (one NORTH, one SOUTH) — detection bar met
|
| 248 |
+
— but tanks STAY at the base. The hunt squad closes on the
|
| 249 |
+
tanks; e3 rockets bust the attrition cap → LOSS even though
|
| 250 |
+
detection succeeded."""
|
| 251 |
+
units = _own_units(rs)
|
| 252 |
+
if not units:
|
| 253 |
+
return [C.observe()]
|
| 254 |
+
jeeps = [u for u in units if str(u.get("type", "")).lower() == "jeep"]
|
| 255 |
+
cmds = []
|
| 256 |
+
if len(jeeps) >= 1:
|
| 257 |
+
cmds.append(C.move_units([str(jeeps[0]["id"])], target_x=70, target_y=10))
|
| 258 |
+
if len(jeeps) >= 2:
|
| 259 |
+
cmds.append(C.move_units([str(jeeps[1]["id"])], target_x=70, target_y=30))
|
| 260 |
+
if not cmds:
|
| 261 |
+
cmds.append(C.observe())
|
| 262 |
+
return cmds
|
| 263 |
+
|
| 264 |
+
|
| 265 |
+
def make_intended():
|
| 266 |
+
"""Scout-and-react: jeeps push forward on BOTH candidate lanes
|
| 267 |
+
on turn 1; the moment any enemy unit is spotted, lock in the
|
| 268 |
+
commit lane (the one with the closer enemy) and have tanks
|
| 269 |
+
attack_unit on the nearest visible hostile on that lane (or
|
| 270 |
+
any hostile if the commit lane has none visible yet). The
|
| 271 |
+
attack_unit verb keeps tanks engaged rather than parking at
|
| 272 |
+
a static cell."""
|
| 273 |
+
state = {"turn": 0, "commit_y": None}
|
| 274 |
+
|
| 275 |
+
def policy(rs, C):
|
| 276 |
+
state["turn"] += 1
|
| 277 |
+
units = _own_units(rs)
|
| 278 |
+
if not units:
|
| 279 |
+
return [C.observe()]
|
| 280 |
+
defs = [u for u in units if str(u.get("type", "")).lower() == "2tnk"]
|
| 281 |
+
jeeps = [u for u in units if str(u.get("type", "")).lower() == "jeep"]
|
| 282 |
+
es = rs.get("enemy_summary") or []
|
| 283 |
+
rusher_units = [
|
| 284 |
+
e for e in es
|
| 285 |
+
if str(e.get("type", "")).lower() in ("e1", "e3")
|
| 286 |
+
]
|
| 287 |
+
# Localise threat axis from the first visible enemy(s).
|
| 288 |
+
if rusher_units and state["commit_y"] is None:
|
| 289 |
+
n = [e for e in rusher_units if int(e.get("cell_y", 20)) < 20]
|
| 290 |
+
s = [e for e in rusher_units if int(e.get("cell_y", 20)) >= 20]
|
| 291 |
+
if n and s:
|
| 292 |
+
nx = min(int(e.get("cell_x", 100)) for e in n)
|
| 293 |
+
sx = min(int(e.get("cell_x", 100)) for e in s)
|
| 294 |
+
state["commit_y"] = 10 if nx <= sx else 30
|
| 295 |
+
elif n:
|
| 296 |
+
state["commit_y"] = 10
|
| 297 |
+
elif s:
|
| 298 |
+
state["commit_y"] = 30
|
| 299 |
+
cmds = []
|
| 300 |
+
# Turn 1: scout push (one NORTH, one SOUTH).
|
| 301 |
+
if jeeps and state["turn"] == 1:
|
| 302 |
+
cmds.append(C.move_units([str(jeeps[0]["id"])], target_x=40, target_y=10))
|
| 303 |
+
if len(jeeps) > 1:
|
| 304 |
+
cmds.append(C.move_units([str(jeeps[1]["id"])], target_x=40, target_y=30))
|
| 305 |
+
# Tanks: attack_unit on nearest visible hostile if any (the
|
| 306 |
+
# attack_unit verb keeps tanks engaged), else attack_move
|
| 307 |
+
# forward toward the commit lane (or NORTH by default).
|
| 308 |
+
if defs:
|
| 309 |
+
def_ids = [str(d["id"]) for d in defs]
|
| 310 |
+
if rusher_units:
|
| 311 |
+
# Filter to the committed lane if known.
|
| 312 |
+
if state["commit_y"] is not None:
|
| 313 |
+
lane = [
|
| 314 |
+
e for e in rusher_units
|
| 315 |
+
if abs(int(e.get("cell_y", 20)) - state["commit_y"]) < 10
|
| 316 |
+
]
|
| 317 |
+
if not lane:
|
| 318 |
+
lane = rusher_units
|
| 319 |
+
else:
|
| 320 |
+
lane = rusher_units
|
| 321 |
+
tx = sum(int(d["cell_x"]) for d in defs) // len(defs)
|
| 322 |
+
ty = sum(int(d["cell_y"]) for d in defs) // len(defs)
|
| 323 |
+
tgt = min(
|
| 324 |
+
lane,
|
| 325 |
+
key=lambda e: (int(e.get("cell_x", 100)) - tx) ** 2
|
| 326 |
+
+ (int(e.get("cell_y", 20)) - ty) ** 2,
|
| 327 |
+
)
|
| 328 |
+
cmds.append(C.attack_unit(def_ids, str(tgt.get("id"))))
|
| 329 |
+
else:
|
| 330 |
+
ty = state["commit_y"] if state["commit_y"] is not None else 10
|
| 331 |
+
cmds.append(C.attack_move(def_ids, target_x=40, target_y=ty))
|
| 332 |
+
if not cmds:
|
| 333 |
+
cmds.append(C.observe())
|
| 334 |
+
return cmds
|
| 335 |
+
|
| 336 |
+
return policy
|
| 337 |
+
|
| 338 |
+
|
| 339 |
+
# ── solvency: intended WINS every level + every hard seed ─────────────
|
| 340 |
+
|
| 341 |
+
|
| 342 |
+
@pytest.mark.parametrize("seed", SEEDS)
|
| 343 |
+
@pytest.mark.parametrize("level", LEVELS)
|
| 344 |
+
def test_intended_scout_and_intercept_wins(level, seed):
|
| 345 |
+
c = compile_level(load_pack(PACK), level)
|
| 346 |
+
r = run_level(c, make_intended(), seed=seed)
|
| 347 |
+
assert r.outcome == "win", (
|
| 348 |
+
f"intended scout-and-intercept must WIN on {level} s={seed}; "
|
| 349 |
+
f"got {r.outcome} (tick={r.signals.game_tick}, "
|
| 350 |
+
f"kills={r.signals.units_killed}, "
|
| 351 |
+
f"lost={r.signals.units_lost}, "
|
| 352 |
+
f"seen={len(r.signals.enemies_seen_ids)})"
|
| 353 |
+
)
|
| 354 |
+
|
| 355 |
+
|
| 356 |
+
# ── no-cheat: every lazy / blind / partial policy LOSES (not draws) ──
|
| 357 |
+
|
| 358 |
+
|
| 359 |
+
@pytest.mark.parametrize("seed", SEEDS)
|
| 360 |
+
@pytest.mark.parametrize("level", LEVELS)
|
| 361 |
+
def test_stall_loses(level, seed):
|
| 362 |
+
c = compile_level(load_pack(PACK), level)
|
| 363 |
+
r = run_level(c, stall, seed=seed)
|
| 364 |
+
assert r.outcome == "loss", (
|
| 365 |
+
f"stall must LOSE on {level} s={seed}; got {r.outcome} "
|
| 366 |
+
f"(tick={r.signals.game_tick}, kills={r.signals.units_killed}, "
|
| 367 |
+
f"lost={r.signals.units_lost})"
|
| 368 |
+
)
|
| 369 |
+
|
| 370 |
+
|
| 371 |
+
@pytest.mark.parametrize("seed", SEEDS)
|
| 372 |
+
@pytest.mark.parametrize("level", LEVELS)
|
| 373 |
+
def test_no_scout_blind_east_loses(level, seed):
|
| 374 |
+
"""Blind mid-lane attack-move never satisfies the forward
|
| 375 |
+
position clause on N or S — even when it accidentally wipes
|
| 376 |
+
the army it still LOSES."""
|
| 377 |
+
c = compile_level(load_pack(PACK), level)
|
| 378 |
+
r = run_level(c, no_scout_blind_east, seed=seed)
|
| 379 |
+
assert r.outcome == "loss", (
|
| 380 |
+
f"no-scout blind must LOSE on {level} s={seed}; got {r.outcome} "
|
| 381 |
+
f"(tick={r.signals.game_tick}, kills={r.signals.units_killed}, "
|
| 382 |
+
f"lost={r.signals.units_lost})"
|
| 383 |
+
)
|
| 384 |
+
|
| 385 |
+
|
| 386 |
+
@pytest.mark.parametrize("seed", SEEDS)
|
| 387 |
+
@pytest.mark.parametrize("level", LEVELS)
|
| 388 |
+
def test_scout_but_no_position_loses(level, seed):
|
| 389 |
+
"""Scouts forward but tanks STAY at the base — the squad
|
| 390 |
+
closes and the e3 rockets bust the attrition cap → LOSS."""
|
| 391 |
+
c = compile_level(load_pack(PACK), level)
|
| 392 |
+
r = run_level(c, scout_but_no_position, seed=seed)
|
| 393 |
+
assert r.outcome == "loss", (
|
| 394 |
+
f"scout-no-position must LOSE on {level} s={seed}; got {r.outcome} "
|
| 395 |
+
f"(tick={r.signals.game_tick}, kills={r.signals.units_killed}, "
|
| 396 |
+
f"lost={r.signals.units_lost})"
|
| 397 |
+
)
|
| 398 |
+
|
| 399 |
+
|
| 400 |
+
# ── Spawn-variation contract (hard) ───────────────────────────────────
|
| 401 |
+
|
| 402 |
+
|
| 403 |
+
@pytest.mark.parametrize("seed", SEEDS)
|
| 404 |
+
def test_hard_seeds_run_to_terminal(seed):
|
| 405 |
+
"""Hard's two spawn_point groups must actually round-robin
|
| 406 |
+
cleanly — stall on each seed completes deterministically (the
|
| 407 |
+
cross-seed start-cell distinctness contract is enforced by
|
| 408 |
+
tests/test_hard_tier.py::test_curated_hard_still_compiles_and_runs)."""
|
| 409 |
+
c = compile_level(load_pack(PACK), "hard")
|
| 410 |
+
r = run_level(c, stall, seed=seed)
|
| 411 |
+
assert r.outcome == "loss"
|