Spaces:
Running
feat(scenario): combat-pincer-coordination — synchronized two-prong attack (SC2 / military pincer anchor)
Browse filesWave-6 Group N coordination pack — SC2 multi-prong / two-prong attack
timing, military pincer movement doctrine, envelopment from multiple
angles, synchronisation of dispersed forces.
Two 3-tank squads (3× 2tnk each) start on the SAME west edge at
OPPOSING latitudes (north y=8, south y=32). A central enemy cluster
sits at the mid-map (around (50,20)). The win predicate REQUIRES both
squads to engage simultaneously:
- units_in_region_gte:{x:50,y:20,radius:8,n:4} — a single squad has
only 3 tanks so single-squad-A cannot satisfy the region clause
regardless of attrition (structural pincer teeth).
- units_lost_lte:2 — sequenced (A first, B late) is shredded by the
cluster's anti-armour mass (2× 2tnk + 2× e3 + 4× e1) and loses ≥3
tanks before B arrives → busts the cap (timing teeth).
- within_ticks:4500 / after_ticks:4501 (max_tick=4863 with
max_turns:54 — deadline bites, no DRAW degeneracy) — stall hits
the deadline.
Difficulty axis (one new controlled variable per tier):
- easy: lighter cluster (4× e1 + 2× e3, no enemy armour; kill bar 6).
Sequenced may squeak by here per the inert-easy-teeth
convention; stall + single-squad still LOSE.
- medium: full cluster (4× e1 + 2× e3 + 2× 2tnk = 8 enemies; kill
bar 8). Sequenced now LOSES on the attrition cap.
- hard: +2 seed-driven agent spawn_point groups (group 0:
(5,8)/(5,32); group 1: (3,5)/(3,35)) so a memorised
single-cell opening cannot generalise; cluster symmetric
across y=20 so both spawns face the same pincer decision.
Engine guardrails (per CLAUDE.md):
- Enemy cluster anchored at (50,19) / (50,21) — avoids the (50,20)
silent-no-place footgun; win region (50,20, r=8) still covers
both halves.
- Far persistent enemy fact at (120,4) keeps the episode alive past
full cluster elimination so within_ticks / region predicates
evaluate on the terminal frame (engine auto-done mitigation).
- All agent actors declare spawn_point on hard (CLAUDE.md: 'if ANY
agent actor declares spawn_point, every agent actor WITHOUT
spawn_point is filtered OUT'); two full layouts listed explicitly.
Scripted-policy validation (tests/test_combat_pincer_coordination.py
covers all 4 seeds per level on every policy):
- stall → LOSS on every level/seed (clock).
- single-squad-A → LOSS on every level/seed (region n=4).
- sequenced-A-then-B-late → LOSS on medium/hard every seed (cap).
- intended pincer-sync → WIN on every level/seed (0 losses).
Model smoke (Qwen/Qwen3.6-Plus, medium, seed 1):
combat-pincer-coordination:medium:public#1 loss comp=0.2187
(expected discrimination — pack is meant to detect synchronization)
Registered in tests/test_hard_tier.py::UPGRADED.
|
@@ -0,0 +1,299 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# combat-pincer-coordination.yaml
|
| 2 |
+
#
|
| 3 |
+
# ACTION capability — synchronised two-prong pincer attack. Two armoured
|
| 4 |
+
# squads start on the SAME west edge but at OPPOSING latitudes
|
| 5 |
+
# (north y≈8 and south y≈32). A central enemy cluster sits at the mid-
|
| 6 |
+
# map. The win predicate requires BOTH squads to engage simultaneously:
|
| 7 |
+
# `units_in_region_gte:{n:4}` cannot be satisfied by a single squad
|
| 8 |
+
# (only 3 tanks per squad — even arriving intact is 1 short of n=4),
|
| 9 |
+
# and `units_lost_lte:2` makes a serialised "Squad A engages first,
|
| 10 |
+
# Squad B follows" attempt fail because A is shredded by the cluster
|
| 11 |
+
# before B arrives.
|
| 12 |
+
#
|
| 13 |
+
# Real-world anchor: military pincer movement / SC2 two-prong attack /
|
| 14 |
+
# envelopment from two directions. The capability is the timing
|
| 15 |
+
# coordination — both prongs must commit at the same moment so the
|
| 16 |
+
# defender cannot focus on either prong in detail.
|
| 17 |
+
#
|
| 18 |
+
# Engine notes (per CLAUDE.md):
|
| 19 |
+
# - Enemy cluster anchored at (50,19) / (50,21) — CLAUDE.md warns
|
| 20 |
+
# that (50,20) silently fails to place enemy clusters; the off-by-
|
| 21 |
+
# one cells are confirmed working. The win region centre at
|
| 22 |
+
# (50,20) r=8 still covers both halves of the cluster (and both
|
| 23 |
+
# ingress tanks once they engage the cluster face).
|
| 24 |
+
# - Far persistent enemy `fact` at (120,4) keeps the episode alive
|
| 25 |
+
# past full enemy elimination so within_ticks / region predicates
|
| 26 |
+
# evaluate on the terminal frame (engine auto-`done` would
|
| 27 |
+
# otherwise collapse to DRAW the instant the last MustBeDestroyed
|
| 28 |
+
# enemy actor falls).
|
| 29 |
+
# - `units_killed_gte: 8` matches the central-cluster enemy count
|
| 30 |
+
# on medium/hard (the far sentinel fact at (120,4) is OUT of all
|
| 31 |
+
# squad routes so it survives and never counts toward kills);
|
| 32 |
+
# easy uses a lighter 6-enemy cluster with kill bar 6.
|
| 33 |
+
# - Enemy stance:3 (AttackAnything) so the cluster fires on any
|
| 34 |
+
# tank in range from the instant of contact — no bot_type needed.
|
| 35 |
+
# For the squad-A-then-B-late timing teeth, the enemy 2× 2tnk +
|
| 36 |
+
# 2× e3 anti-armour mass MUST be sufficient to shred a 3-tank
|
| 37 |
+
# squad arriving alone (~12s to clear with 3 attackers = ~800
|
| 38 |
+
# damage absorbed = 2+ tanks lost on the lone squad). With both
|
| 39 |
+
# squads synchronised the same cluster falls in ~6s and the
|
| 40 |
+
# 6-tank force loses ≤1 tank — well inside the
|
| 41 |
+
# `units_lost_lte: 2` cap and with ≥4 tanks (in fact ≥5) inside
|
| 42 |
+
# the central region.
|
| 43 |
+
# - Tick budget: 4500 within / 4501 fail. With max_turns 54 the max
|
| 44 |
+
# reachable tick is 93 + 90·53 = 4863 → the `after_ticks` clause
|
| 45 |
+
# fires before the run ends → no DRAW degeneracy.
|
| 46 |
+
#
|
| 47 |
+
# Discrimination (the four-script bar; scripted-policy validation in
|
| 48 |
+
# tests/test_combat_pincer_coordination.py covers all 4 seeds per
|
| 49 |
+
# level on every policy):
|
| 50 |
+
# • stall (Command.observe only) — agent units never engage → kill
|
| 51 |
+
# bar unmet → after_ticks LOSS.
|
| 52 |
+
# • single-squad-A — Squad B held back; A walks alone into the
|
| 53 |
+
# cluster. Even if A clears the cluster, only 3 tanks (max) can
|
| 54 |
+
# occupy the objective region while n=4 is required → LOSS. On
|
| 55 |
+
# medium/hard A is also shredded by the cluster's anti-armour →
|
| 56 |
+
# units_lost_lte:2 busts as a second teeth.
|
| 57 |
+
# • sequenced-A-then-B-late — A advances first, takes attrition,
|
| 58 |
+
# B follows after a long delay. On medium/hard A loses 3 of 3
|
| 59 |
+
# tanks before B arrives → units_lost_lte:2 busts → LOSS. On
|
| 60 |
+
# easy with no enemy armour A may clear cleanly and B walks in →
|
| 61 |
+
# this may squeak by (acceptable per SCENARIO_REVIEW_CHECKLIST
|
| 62 |
+
# inert-easy-teeth convention — easy is the bare-skill tier).
|
| 63 |
+
# • intended pincer-sync — both squads attack_move the cluster on
|
| 64 |
+
# turn 1; their staging latitudes (y=8, y=32) put them
|
| 65 |
+
# equidistant (~45 cells each) from the cluster so a naive
|
| 66 |
+
# simultaneous launch arrives together; mass DPS clears the
|
| 67 |
+
# cluster before either side bleeds → WIN.
|
| 68 |
+
#
|
| 69 |
+
# Validate:
|
| 70 |
+
# cd /Users/berta/Projects/OpenRA-Bench && \
|
| 71 |
+
# python3 -m openra_bench.scenarios.validate \
|
| 72 |
+
# openra_bench/scenarios/packs/combat-pincer-coordination.yaml
|
| 73 |
+
|
| 74 |
+
meta:
|
| 75 |
+
id: combat-pincer-coordination
|
| 76 |
+
title: 'Pincer Attack — Two Squads Strike Simultaneously From Two Sides'
|
| 77 |
+
capability: action
|
| 78 |
+
real_world_meaning: >
|
| 79 |
+
Two armoured squads start at OPPOSING latitudes on the west edge
|
| 80 |
+
(one to the north, one to the south) and must converge on a
|
| 81 |
+
central enemy cluster simultaneously, hitting the defender from
|
| 82 |
+
two sides at once. Sending a single squad alone fails on two
|
| 83 |
+
counts: only 3 tanks (not the required 4) can occupy the
|
| 84 |
+
objective region, and the lone squad is shredded by the cluster's
|
| 85 |
+
mass anti-armour before clearing it. Sending both squads but not
|
| 86 |
+
synchronised (one held back, the other commits first) lets the
|
| 87 |
+
cluster focus on the lead squad and destroy it before the trailing
|
| 88 |
+
squad arrives, busting the attrition cap. Only a true
|
| 89 |
+
simultaneous two-prong commit clears the cluster cleanly and
|
| 90 |
+
leaves the joint force standing on the objective.
|
| 91 |
+
robotics_analogue: >
|
| 92 |
+
Synchronous two-team pincer attack on a contested objective: each
|
| 93 |
+
team approaches on a different bearing and both must arrive within
|
| 94 |
+
a common window so the defending agents cannot focus on either
|
| 95 |
+
team in detail. Serialising one team behind the other lets the
|
| 96 |
+
defender concentrate fire and destroy the lead team before
|
| 97 |
+
reinforcement; only the joint simultaneous commit overwhelms
|
| 98 |
+
the defence and preserves the strike force.
|
| 99 |
+
benchmark_anchor:
|
| 100 |
+
- "SC2 multi-prong / two-prong attack timing"
|
| 101 |
+
- "military pincer movement doctrine"
|
| 102 |
+
- "envelopment from multiple angles"
|
| 103 |
+
- "synchronisation of dispersed forces"
|
| 104 |
+
author: openra-bench
|
| 105 |
+
|
| 106 |
+
base_map: rush-hour-arena
|
| 107 |
+
|
| 108 |
+
base:
|
| 109 |
+
agent: {faction: allies, cash: 0}
|
| 110 |
+
enemy: {faction: soviet, cash: 0, bot_type: ''}
|
| 111 |
+
tools: [move_units, attack_unit, attack_move, stop]
|
| 112 |
+
planning: true
|
| 113 |
+
# React the instant contact happens or a tank is lost — pincer
|
| 114 |
+
# micro is reactive; without these interrupts the sync policy only
|
| 115 |
+
# re-targets on the next turn boundary and any gap is fatal to the
|
| 116 |
+
# already-thin attrition cap.
|
| 117 |
+
interrupts:
|
| 118 |
+
enemy_unit_spotted: true
|
| 119 |
+
own_unit_destroyed: true
|
| 120 |
+
termination: {max_ticks: 6000}
|
| 121 |
+
actors: [] # every level supplies its own actor list via overrides.
|
| 122 |
+
|
| 123 |
+
levels:
|
| 124 |
+
# ── EASY ─────────────────────────────────────────────────────────
|
| 125 |
+
# Bare pincer skill: lighter cluster (4× e1 + 2× e3, no enemy
|
| 126 |
+
# armour; 6 enemies total, kill bar 6). Stall still LOSES on the
|
| 127 |
+
# clock; single-squad still LOSES on the region predicate (3 tanks
|
| 128 |
+
# cannot satisfy n=4). Sequenced may squeak by on this lighter
|
| 129 |
+
# cluster — that is the SCENARIO_REVIEW_CHECKLIST "inert anti-
|
| 130 |
+
# cheat teeth acceptable on easy" convention: the bare-skill tier
|
| 131 |
+
# only enforces stall-loses and intended-wins; the sequence-vs-
|
| 132 |
+
# sync delta lives on medium and hard.
|
| 133 |
+
easy:
|
| 134 |
+
description: >
|
| 135 |
+
You command TWO squads of medium tanks (3× 2tnk each, 6 tanks
|
| 136 |
+
total). Squad A stages at the NORTH-WEST corner (5,8); Squad B
|
| 137 |
+
stages at the SOUTH-WEST corner (5,32). A central enemy cluster
|
| 138 |
+
sits at the mid-map (around (50,20)): 4 rifle infantry (e1) +
|
| 139 |
+
2 anti-tank rocket soldiers (e3). Both squads must converge on
|
| 140 |
+
the cluster SIMULTANEOUSLY from the north and the south
|
| 141 |
+
(a pincer). Win when you have killed 6 enemies AND at least 4
|
| 142 |
+
of your 6 tanks are inside an 8-cell radius of (50,20) AND no
|
| 143 |
+
more than 2 tanks are lost, before tick 4500. Sending only one
|
| 144 |
+
squad cannot satisfy the 4-in-region clause. A stall loses on
|
| 145 |
+
the clock.
|
| 146 |
+
overrides:
|
| 147 |
+
actors:
|
| 148 |
+
# Squad A (NORTH-WEST) — 3 medium tanks at (5,8).
|
| 149 |
+
- {type: 2tnk, owner: agent, position: [5, 8], stance: 1, count: 3}
|
| 150 |
+
# Squad B (SOUTH-WEST) — 3 medium tanks at (5,32).
|
| 151 |
+
- {type: 2tnk, owner: agent, position: [5, 32], stance: 1, count: 3}
|
| 152 |
+
# Enemy cluster — anchored at (50,19) / (50,21) (CLAUDE.md
|
| 153 |
+
# notes (50,20) silently fails to place enemy clusters; the
|
| 154 |
+
# off-by-one cells work). The win region (50,20, r=8) still
|
| 155 |
+
# covers both halves.
|
| 156 |
+
- {type: e1, owner: enemy, position: [50, 19], stance: 3, count: 2}
|
| 157 |
+
- {type: e1, owner: enemy, position: [50, 21], stance: 3, count: 2}
|
| 158 |
+
- {type: e3, owner: enemy, position: [50, 19], stance: 3}
|
| 159 |
+
- {type: e3, owner: enemy, position: [50, 21], stance: 3}
|
| 160 |
+
# Far persistent enemy marker (sentinel) — out of every
|
| 161 |
+
# squad route, keeps the episode alive past full cluster
|
| 162 |
+
# elimination so within_ticks / region predicates evaluate
|
| 163 |
+
# on the terminal frame (no DRAW collapse).
|
| 164 |
+
- {type: fact, owner: enemy, position: [120, 4]}
|
| 165 |
+
win_condition:
|
| 166 |
+
all_of:
|
| 167 |
+
- {units_killed_gte: 6}
|
| 168 |
+
- {units_in_region_gte: {x: 50, y: 20, radius: 8, n: 4}}
|
| 169 |
+
- {units_lost_lte: 2}
|
| 170 |
+
- {within_ticks: 4500}
|
| 171 |
+
fail_condition:
|
| 172 |
+
any_of:
|
| 173 |
+
- {after_ticks: 4501}
|
| 174 |
+
- {not: {units_lost_lte: 2}}
|
| 175 |
+
- {not: {own_units_gte: 1}}
|
| 176 |
+
max_turns: 54
|
| 177 |
+
|
| 178 |
+
# ── MEDIUM ───────────────────────────────────────────────────────
|
| 179 |
+
# +1 controlled variable: full enemy cluster — 4× e1 + 2× e3 +
|
| 180 |
+
# 2× 2tnk (8 enemies total; kill bar = 8). The enemy armour gives
|
| 181 |
+
# the cluster enough sustained anti-armour DPS to shred a lone
|
| 182 |
+
# 3-tank squad (~12s to clear, ~800 damage absorbed) → A-only or
|
| 183 |
+
# A-then-B-late busts units_lost_lte:2. The 6-tank synchronised
|
| 184 |
+
# commit halves the clear time and the per-tank damage share, so
|
| 185 |
+
# the sync wins comfortably (validated: 0 losses on every seed).
|
| 186 |
+
medium:
|
| 187 |
+
description: >
|
| 188 |
+
You command TWO squads of medium tanks (3× 2tnk each, 6 tanks
|
| 189 |
+
total). Squad A stages at the NORTH-WEST corner (5,8); Squad B
|
| 190 |
+
stages at the SOUTH-WEST corner (5,32). A central enemy cluster
|
| 191 |
+
sits at the mid-map (around (50,20)): 4 rifle infantry (e1) +
|
| 192 |
+
2 anti-tank rocket soldiers (e3, Dragon launcher) + 2 medium
|
| 193 |
+
tanks (2tnk) — the enemy armour gives the cluster enough
|
| 194 |
+
sustained anti-armour DPS to shred a lone 3-tank squad before
|
| 195 |
+
it can clear the cluster. Both squads must converge
|
| 196 |
+
SIMULTANEOUSLY (a pincer): attack-moving with both squads at
|
| 197 |
+
once on turn 1 doubles the strike force and halves the per-tank
|
| 198 |
+
damage share, clearing the cluster before either side bleeds
|
| 199 |
+
out. Sending only one squad cannot satisfy the 4-in-region
|
| 200 |
+
clause; sending A first and B late lets the cluster shred A
|
| 201 |
+
before B arrives. Win when you have killed 8 enemies AND at
|
| 202 |
+
least 4 of your 6 tanks are inside an 8-cell radius of (50,20)
|
| 203 |
+
AND no more than 2 tanks are lost, before tick 4500.
|
| 204 |
+
overrides:
|
| 205 |
+
actors:
|
| 206 |
+
- {type: 2tnk, owner: agent, position: [5, 8], stance: 1, count: 3}
|
| 207 |
+
- {type: 2tnk, owner: agent, position: [5, 32], stance: 1, count: 3}
|
| 208 |
+
# 4× e1 + 2× e3 + 2× 2tnk = 8 enemies. Avoid the (50,20) cell
|
| 209 |
+
# (CLAUDE.md silent-no-place footgun); split between (50,19)
|
| 210 |
+
# and (50,21). The enemy 2tnks are the load-bearing anti-
|
| 211 |
+
# armour that shreds a lone 3-tank squad arriving alone.
|
| 212 |
+
- {type: e1, owner: enemy, position: [50, 19], stance: 3, count: 2}
|
| 213 |
+
- {type: e1, owner: enemy, position: [50, 21], stance: 3, count: 2}
|
| 214 |
+
- {type: e3, owner: enemy, position: [50, 19], stance: 3}
|
| 215 |
+
- {type: e3, owner: enemy, position: [50, 21], stance: 3}
|
| 216 |
+
- {type: 2tnk, owner: enemy, position: [50, 19], stance: 3}
|
| 217 |
+
- {type: 2tnk, owner: enemy, position: [50, 21], stance: 3}
|
| 218 |
+
- {type: fact, owner: enemy, position: [120, 4]}
|
| 219 |
+
win_condition:
|
| 220 |
+
all_of:
|
| 221 |
+
- {units_killed_gte: 8}
|
| 222 |
+
- {units_in_region_gte: {x: 50, y: 20, radius: 8, n: 4}}
|
| 223 |
+
- {units_lost_lte: 2}
|
| 224 |
+
- {within_ticks: 4500}
|
| 225 |
+
fail_condition:
|
| 226 |
+
any_of:
|
| 227 |
+
- {after_ticks: 4501}
|
| 228 |
+
- {not: {units_lost_lte: 2}}
|
| 229 |
+
- {not: {own_units_gte: 1}}
|
| 230 |
+
max_turns: 54
|
| 231 |
+
|
| 232 |
+
# ── HARD ─────────────────────────────────────────────────────────
|
| 233 |
+
# +1 more controlled variable: TWO seed-driven agent spawn groups
|
| 234 |
+
# with DIFFERENT cell coordinates. Group 0 stages Squad A NORTH
|
| 235 |
+
# (5,8) / Squad B SOUTH (5,32). Group 1 SHIFTS the squads to (8,6)
|
| 236 |
+
# / (8,34) — same west-edge / opposing-latitude pincer shape but
|
| 237 |
+
# the exact start cells differ across seeds so a memorised single-
|
| 238 |
+
# cell opening cannot generalise. Both groups remain approximately
|
| 239 |
+
# equidistant from the central cluster (~46-47 cells) so the naive
|
| 240 |
+
# simultaneous launch still arrives together. Per CLAUDE.md, "if
|
| 241 |
+
# ANY agent actor declares spawn_point, every agent actor WITHOUT
|
| 242 |
+
# spawn_point is filtered OUT", so the two full squad layouts are
|
| 243 |
+
# listed explicitly. The defender cluster is symmetric across y=20
|
| 244 |
+
# so both spawns face the same simultaneous-pincer decision.
|
| 245 |
+
hard:
|
| 246 |
+
description: >
|
| 247 |
+
You command TWO squads of medium tanks (3× 2tnk each, 6 tanks
|
| 248 |
+
total) staged on the west edge — one squad to the NORTH, one
|
| 249 |
+
to the SOUTH (the exact staging cells vary by seed; treat the
|
| 250 |
+
two squads symmetrically). A central enemy cluster
|
| 251 |
+
sits at the mid-map (around (50,20)): 4 rifle infantry (e1) +
|
| 252 |
+
2 anti-tank rocket soldiers (e3, Dragon launcher) + 2 medium
|
| 253 |
+
tanks (2tnk) — the enemy armour gives the cluster enough
|
| 254 |
+
sustained anti-armour DPS to shred a lone 3-tank squad. Both
|
| 255 |
+
squads must converge SIMULTANEOUSLY (a pincer): attack-moving
|
| 256 |
+
with both squads at once on turn 1 clears the cluster before
|
| 257 |
+
either side bleeds out. Win when you have killed 8 enemies AND
|
| 258 |
+
at least 4 of your 6 tanks are inside an 8-cell radius of
|
| 259 |
+
(50,20) AND no more than 2 tanks are lost, before tick 4500.
|
| 260 |
+
overrides:
|
| 261 |
+
actors:
|
| 262 |
+
# spawn_point 0 — Squad A NORTH (5,8), Squad B SOUTH (5,32).
|
| 263 |
+
- {type: 2tnk, owner: agent, position: [5, 8], stance: 1, count: 3, spawn_point: 0}
|
| 264 |
+
- {type: 2tnk, owner: agent, position: [5, 32], stance: 1, count: 3, spawn_point: 0}
|
| 265 |
+
# spawn_point 1 — SHIFTED layout: NORTH squad at (3, 5) and
|
| 266 |
+
# SOUTH squad at (3, 35). Both remain on the west edge and
|
| 267 |
+
# approximately equidistant (~49-50 cells) from the central
|
| 268 |
+
# cluster at (50,20); the slight extra distance vs group 0
|
| 269 |
+
# keeps the synchronised launch viable while ensuring the
|
| 270 |
+
# squad arriving alone takes ≥ the same attrition (still
|
| 271 |
+
# loses ≥3 of 3 tanks on the sequenced policy). The exact
|
| 272 |
+
# start cells DIFFER from group 0, satisfying the hard-tier
|
| 273 |
+
# invariant (≥2 distinct seed-driven starts) and ensuring a
|
| 274 |
+
# memorised single-cell opening cannot generalise across
|
| 275 |
+
# seeds.
|
| 276 |
+
- {type: 2tnk, owner: agent, position: [3, 5], stance: 1, count: 3, spawn_point: 1}
|
| 277 |
+
- {type: 2tnk, owner: agent, position: [3, 35], stance: 1, count: 3, spawn_point: 1}
|
| 278 |
+
# Central cluster — 4× e1 + 2× e3 + 2× 2tnk = 8 enemies,
|
| 279 |
+
# symmetric across y=20 so both spawn groups face the same
|
| 280 |
+
# pincer decision.
|
| 281 |
+
- {type: e1, owner: enemy, position: [50, 19], stance: 3, count: 2}
|
| 282 |
+
- {type: e1, owner: enemy, position: [50, 21], stance: 3, count: 2}
|
| 283 |
+
- {type: e3, owner: enemy, position: [50, 19], stance: 3}
|
| 284 |
+
- {type: e3, owner: enemy, position: [50, 21], stance: 3}
|
| 285 |
+
- {type: 2tnk, owner: enemy, position: [50, 19], stance: 3}
|
| 286 |
+
- {type: 2tnk, owner: enemy, position: [50, 21], stance: 3}
|
| 287 |
+
- {type: fact, owner: enemy, position: [120, 4]}
|
| 288 |
+
win_condition:
|
| 289 |
+
all_of:
|
| 290 |
+
- {units_killed_gte: 8}
|
| 291 |
+
- {units_in_region_gte: {x: 50, y: 20, radius: 8, n: 4}}
|
| 292 |
+
- {units_lost_lte: 2}
|
| 293 |
+
- {within_ticks: 4500}
|
| 294 |
+
fail_condition:
|
| 295 |
+
any_of:
|
| 296 |
+
- {after_ticks: 4501}
|
| 297 |
+
- {not: {units_lost_lte: 2}}
|
| 298 |
+
- {not: {own_units_gte: 1}}
|
| 299 |
+
max_turns: 54
|
|
@@ -0,0 +1,364 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""combat-pincer-coordination — synchronised two-prong pincer attack.
|
| 2 |
+
|
| 3 |
+
The bar: TWO 3-tank squads start on the SAME west edge at OPPOSING
|
| 4 |
+
latitudes (north y=8, south y=32). A central enemy cluster sits at
|
| 5 |
+
the mid-map (around (50,20)). The win predicate REQUIRES both squads
|
| 6 |
+
to engage simultaneously:
|
| 7 |
+
|
| 8 |
+
• `units_in_region_gte:{x:50,y:20,radius:8,n:4}` — at least 4 tanks
|
| 9 |
+
must be inside the central region; a single squad has only 3 tanks
|
| 10 |
+
so single-squad-A cannot satisfy this clause regardless of
|
| 11 |
+
attrition.
|
| 12 |
+
• `units_lost_lte:2` — sequenced (A first, B late) is shredded by
|
| 13 |
+
the cluster's anti-armour mass and loses ≥3 tanks before B
|
| 14 |
+
arrives → busts the cap.
|
| 15 |
+
• `within_ticks:4500` — stall hits the deadline → after_ticks LOSS.
|
| 16 |
+
|
| 17 |
+
Discrimination (the four-script bar; the scripted runs cover all
|
| 18 |
+
seeds 1–4 per level on every policy):
|
| 19 |
+
|
| 20 |
+
• stall — every level/seed must LOSE (clock).
|
| 21 |
+
• single-squad-A — every level/seed must LOSE (region n=4).
|
| 22 |
+
• sequenced-A-then-B-late — medium/hard must LOSE (attrition cap
|
| 23 |
+
busted by lone Squad A); easy is the
|
| 24 |
+
bare-skill tier where this may squeak
|
| 25 |
+
by (SCENARIO_REVIEW_CHECKLIST inert-
|
| 26 |
+
easy-teeth convention).
|
| 27 |
+
• intended pincer-sync — every level/seed must WIN.
|
| 28 |
+
|
| 29 |
+
Validation is scripted (no model / network)."""
|
| 30 |
+
from __future__ import annotations
|
| 31 |
+
|
| 32 |
+
from pathlib import Path
|
| 33 |
+
|
| 34 |
+
import pytest
|
| 35 |
+
|
| 36 |
+
pytest.importorskip("openra_rl_training", reason="Rust env wheel not installed")
|
| 37 |
+
from openra_bench.scenarios import load_pack
|
| 38 |
+
from openra_bench.scenarios.loader import compile_level
|
| 39 |
+
from openra_bench.scenarios.win_conditions import WinContext, evaluate
|
| 40 |
+
|
| 41 |
+
PACKS = Path(__file__).parent.parent / "openra_bench" / "scenarios" / "packs"
|
| 42 |
+
PACK_PATH = PACKS / "combat-pincer-coordination.yaml"
|
| 43 |
+
|
| 44 |
+
|
| 45 |
+
# ── unit-level predicate / metadata checks (no engine) ──────────────
|
| 46 |
+
|
| 47 |
+
|
| 48 |
+
def test_pack_compiles_and_meta_fields_populated():
|
| 49 |
+
pack = load_pack(PACK_PATH)
|
| 50 |
+
assert pack.meta.id == "combat-pincer-coordination"
|
| 51 |
+
assert pack.meta.capability == "action"
|
| 52 |
+
assert pack.meta.real_world_meaning, "real_world_meaning required"
|
| 53 |
+
assert pack.meta.robotics_analogue, "robotics_analogue required"
|
| 54 |
+
anchors = pack.meta.benchmark_anchor
|
| 55 |
+
assert isinstance(anchors, list) and len(anchors) == 4, (
|
| 56 |
+
f"benchmark_anchor must list 4 anchors, got {anchors!r}"
|
| 57 |
+
)
|
| 58 |
+
joined = " ".join(anchors).lower()
|
| 59 |
+
for needle in ("sc2", "pincer", "envelopment", "synchronisation"):
|
| 60 |
+
assert needle in joined, f"missing anchor keyword: {needle}"
|
| 61 |
+
for lvl in ("easy", "medium", "hard"):
|
| 62 |
+
c = compile_level(pack, lvl)
|
| 63 |
+
assert c.map_supported
|
| 64 |
+
assert c.win_condition is not None
|
| 65 |
+
assert c.fail_condition is not None
|
| 66 |
+
|
| 67 |
+
|
| 68 |
+
def _ctx(*, units=(), tick=1000, kills=0, lost=0):
|
| 69 |
+
"""Synthesize a WinContext for predicate-level checks."""
|
| 70 |
+
import types
|
| 71 |
+
|
| 72 |
+
sig = types.SimpleNamespace(
|
| 73 |
+
game_tick=tick,
|
| 74 |
+
units_killed=kills,
|
| 75 |
+
units_lost=lost,
|
| 76 |
+
cash=0,
|
| 77 |
+
resources=0,
|
| 78 |
+
power_provided=0,
|
| 79 |
+
power_drained=0,
|
| 80 |
+
own_buildings=[],
|
| 81 |
+
own_building_types=set(),
|
| 82 |
+
enemies_seen_ids=set(),
|
| 83 |
+
enemy_buildings_seen_ids=set(),
|
| 84 |
+
explored_percent=0.0,
|
| 85 |
+
then_progress={},
|
| 86 |
+
seq_progress={},
|
| 87 |
+
)
|
| 88 |
+
return WinContext(
|
| 89 |
+
signals=sig,
|
| 90 |
+
render_state={"units_summary": list(units)},
|
| 91 |
+
)
|
| 92 |
+
|
| 93 |
+
|
| 94 |
+
def _tanks_at(n, x=50, y=20):
|
| 95 |
+
return [
|
| 96 |
+
{"cell_x": x, "cell_y": y, "type": "2tnk", "id": str(2000 + i)}
|
| 97 |
+
for i in range(n)
|
| 98 |
+
]
|
| 99 |
+
|
| 100 |
+
|
| 101 |
+
def test_predicates_per_level():
|
| 102 |
+
"""Region n=4 + kill bar + attrition + deadline all load-bearing."""
|
| 103 |
+
pack = load_pack(PACK_PATH)
|
| 104 |
+
expectations = {
|
| 105 |
+
# (kill_bar, region_n, lost_cap, within, after_fail)
|
| 106 |
+
"easy": (6, 4, 2, 4500, 4501),
|
| 107 |
+
"medium": (8, 4, 2, 4500, 4501),
|
| 108 |
+
"hard": (8, 4, 2, 4500, 4501),
|
| 109 |
+
}
|
| 110 |
+
for lvl, (kb, n, cap, w, af) in expectations.items():
|
| 111 |
+
c = compile_level(pack, lvl)
|
| 112 |
+
# Intended win: ≥n tanks in region, ≥kb kills, ≤cap lost, in time.
|
| 113 |
+
ctx_win = _ctx(units=_tanks_at(n + 1), tick=w - 100, kills=kb, lost=cap)
|
| 114 |
+
assert evaluate(c.win_condition, ctx_win), f"{lvl}: intended should WIN"
|
| 115 |
+
# Region clause unmet (only 3 tanks at objective) → not a win
|
| 116 |
+
# — this is the load-bearing pincer teeth (single squad has only 3 tanks).
|
| 117 |
+
ctx_3 = _ctx(units=_tanks_at(3), tick=w - 100, kills=kb, lost=0)
|
| 118 |
+
assert not evaluate(c.win_condition, ctx_3), (
|
| 119 |
+
f"{lvl}: 3 tanks in region < n={n} must not satisfy win"
|
| 120 |
+
)
|
| 121 |
+
# Kill bar unmet → not a win.
|
| 122 |
+
ctx_low_kills = _ctx(units=_tanks_at(n + 1), tick=w - 100, kills=kb - 1, lost=0)
|
| 123 |
+
assert not evaluate(c.win_condition, ctx_low_kills), (
|
| 124 |
+
f"{lvl}: kills < {kb} must not satisfy win"
|
| 125 |
+
)
|
| 126 |
+
# Attrition cap busted → not a win + fails.
|
| 127 |
+
ctx_lost = _ctx(units=_tanks_at(n + 1), tick=w - 100, kills=kb, lost=cap + 1)
|
| 128 |
+
assert not evaluate(c.win_condition, ctx_lost)
|
| 129 |
+
assert evaluate(c.fail_condition, ctx_lost), f"{lvl}: lost>{cap} must FAIL"
|
| 130 |
+
# Timeout busted → fails.
|
| 131 |
+
ctx_late = _ctx(units=_tanks_at(n + 1), tick=af + 1, kills=kb, lost=0)
|
| 132 |
+
assert evaluate(c.fail_condition, ctx_late), f"{lvl}: tick>after must FAIL"
|
| 133 |
+
# Force-wipe → fails.
|
| 134 |
+
ctx_wipe = _ctx(units=[], tick=w - 100, kills=kb, lost=6)
|
| 135 |
+
assert evaluate(c.fail_condition, ctx_wipe)
|
| 136 |
+
|
| 137 |
+
|
| 138 |
+
def test_timeout_reachable_inside_max_turns():
|
| 139 |
+
"""No DRAW degeneracy: after_ticks fail trigger must be reachable
|
| 140 |
+
within max_turns (engine advances ~90 ticks per turn ⇒ max tick
|
| 141 |
+
≈ 93 + 90·(max_turns-1))."""
|
| 142 |
+
pack = load_pack(PACK_PATH)
|
| 143 |
+
for lvl in ("easy", "medium", "hard"):
|
| 144 |
+
c = compile_level(pack, lvl)
|
| 145 |
+
max_tick = 93 + 90 * (c.max_turns - 1)
|
| 146 |
+
# After-ticks fail clause is 4501 on every level here.
|
| 147 |
+
assert 4501 <= max_tick, (
|
| 148 |
+
f"{lvl}: after_ticks 4501 > max reachable tick {max_tick} "
|
| 149 |
+
f"(max_turns={c.max_turns}); deadline never bites"
|
| 150 |
+
)
|
| 151 |
+
|
| 152 |
+
|
| 153 |
+
def test_two_squads_on_every_level():
|
| 154 |
+
"""Two 3-tank squads at opposing latitudes (north y≈8 vs south
|
| 155 |
+
y≈32); the pincer mechanism requires both prongs."""
|
| 156 |
+
pack = load_pack(PACK_PATH)
|
| 157 |
+
for lvl in ("easy", "medium", "hard"):
|
| 158 |
+
c = compile_level(pack, lvl)
|
| 159 |
+
agent_actors = [a for a in c.scenario.actors if a.owner == "agent"]
|
| 160 |
+
# All agent units are medium tanks (2tnk).
|
| 161 |
+
for a in agent_actors:
|
| 162 |
+
assert a.type == "2tnk", (
|
| 163 |
+
f"{lvl}: agent must be 2tnk (medium tanks); got {a.type}"
|
| 164 |
+
)
|
| 165 |
+
# Two latitudes represented (north y<20 and south y>20).
|
| 166 |
+
latitudes = {("N" if a.position[1] < 20 else "S") for a in agent_actors}
|
| 167 |
+
assert latitudes == {"N", "S"}, (
|
| 168 |
+
f"{lvl}: agent squads must span both latitudes; got {latitudes}"
|
| 169 |
+
)
|
| 170 |
+
|
| 171 |
+
|
| 172 |
+
def test_enemy_cluster_avoids_silent_no_place_cell():
|
| 173 |
+
"""CLAUDE.md: (50,20) silently fails to place enemy clusters.
|
| 174 |
+
Cluster must anchor on the off-by-one cells (50,19) / (50,21)."""
|
| 175 |
+
pack = load_pack(PACK_PATH)
|
| 176 |
+
for lvl in ("easy", "medium", "hard"):
|
| 177 |
+
c = compile_level(pack, lvl)
|
| 178 |
+
cluster = [
|
| 179 |
+
a for a in c.scenario.actors
|
| 180 |
+
if a.owner == "enemy" and a.type in ("e1", "e3", "2tnk")
|
| 181 |
+
]
|
| 182 |
+
assert cluster, f"{lvl}: no central cluster declared"
|
| 183 |
+
for a in cluster:
|
| 184 |
+
assert tuple(a.position) != (50, 20), (
|
| 185 |
+
f"{lvl}: enemy cluster member at (50,20) — CLAUDE.md "
|
| 186 |
+
f"silent-no-place footgun"
|
| 187 |
+
)
|
| 188 |
+
assert a.position[0] == 50 and a.position[1] in (19, 21), (
|
| 189 |
+
f"{lvl}: cluster member off-anchor; got {a.position}"
|
| 190 |
+
)
|
| 191 |
+
|
| 192 |
+
|
| 193 |
+
def test_persistent_far_enemy_marker_present():
|
| 194 |
+
"""Engine auto-`done` mitigation: a far unarmed enemy `fact` keeps
|
| 195 |
+
the episode alive past full cluster elimination so within_ticks /
|
| 196 |
+
region predicates evaluate on the terminal frame (no DRAW collapse)."""
|
| 197 |
+
pack = load_pack(PACK_PATH)
|
| 198 |
+
for lvl in ("easy", "medium", "hard"):
|
| 199 |
+
c = compile_level(pack, lvl)
|
| 200 |
+
far_facts = [
|
| 201 |
+
a for a in c.scenario.actors
|
| 202 |
+
if a.owner == "enemy" and a.type == "fact"
|
| 203 |
+
and a.position[0] >= 100 # far east of the cluster
|
| 204 |
+
]
|
| 205 |
+
assert far_facts, f"{lvl}: needs a far persistent enemy fact sentinel"
|
| 206 |
+
|
| 207 |
+
|
| 208 |
+
def test_hard_has_two_spawn_point_groups():
|
| 209 |
+
"""Hard-tier curation: ≥2 distinct agent spawn_point groups so the
|
| 210 |
+
seed round-robins which squad ID is north vs south."""
|
| 211 |
+
c = compile_level(load_pack(PACK_PATH), "hard")
|
| 212 |
+
groups = {
|
| 213 |
+
(a.spawn_point if a.spawn_point is not None else 0)
|
| 214 |
+
for a in c.scenario.actors
|
| 215 |
+
if a.owner == "agent"
|
| 216 |
+
}
|
| 217 |
+
assert len(groups) >= 2, f"hard needs ≥2 spawn_point groups, got {groups}"
|
| 218 |
+
|
| 219 |
+
|
| 220 |
+
# ── engine-driven scripted policies ─────────────────────────────────
|
| 221 |
+
|
| 222 |
+
|
| 223 |
+
def _split_squads(rs):
|
| 224 |
+
"""Return (north_ids, south_ids) — split agent 2tnk units by
|
| 225 |
+
latitude. The agent's two staging latitudes are y≈8 (north) and
|
| 226 |
+
y≈32 (south); the cluster sits at y=20."""
|
| 227 |
+
n_ids, s_ids = [], []
|
| 228 |
+
for u in (rs.get("units_summary") or []):
|
| 229 |
+
if str(u.get("type", "")).lower() != "2tnk":
|
| 230 |
+
continue
|
| 231 |
+
if int(u["cell_y"]) < 20:
|
| 232 |
+
n_ids.append(str(u["id"]))
|
| 233 |
+
else:
|
| 234 |
+
s_ids.append(str(u["id"]))
|
| 235 |
+
return n_ids, s_ids
|
| 236 |
+
|
| 237 |
+
|
| 238 |
+
def _stall(rs, Command):
|
| 239 |
+
"""Pure observe — agent units never engage → kill bar unmet AND
|
| 240 |
+
region clause unmet → after_ticks LOSS."""
|
| 241 |
+
return [Command.observe()]
|
| 242 |
+
|
| 243 |
+
|
| 244 |
+
def _single_squad_a(rs, Command):
|
| 245 |
+
"""Send only the NORTH squad; hold the SOUTH squad. Even if A
|
| 246 |
+
clears the cluster on its own, only 3 tanks can be in the
|
| 247 |
+
objective region → `units_in_region_gte:{n:4}` cannot be
|
| 248 |
+
satisfied → LOSS. On medium/hard the cluster's enemy 2tnks also
|
| 249 |
+
shred A → units_lost_lte:2 busts as a second teeth."""
|
| 250 |
+
a, b = _split_squads(rs)
|
| 251 |
+
cmds = []
|
| 252 |
+
if a:
|
| 253 |
+
cmds.append(Command.attack_move(a, 50, 20))
|
| 254 |
+
if b:
|
| 255 |
+
cmds.append(Command.stop(b))
|
| 256 |
+
return cmds or [Command.observe()]
|
| 257 |
+
|
| 258 |
+
|
| 259 |
+
def _make_sequenced_a_then_b_late(delay_turns=20):
|
| 260 |
+
"""Squad A commits first; Squad B holds for `delay_turns` then
|
| 261 |
+
advances. The cluster shreds A in detail before B arrives →
|
| 262 |
+
units_lost_lte:2 busts on medium/hard. On easy with no enemy
|
| 263 |
+
armour A may clear the cluster cleanly and B walks in → may WIN
|
| 264 |
+
(acceptable per the inert-easy-teeth convention)."""
|
| 265 |
+
state = {"turn": 0}
|
| 266 |
+
|
| 267 |
+
def policy(rs, Command):
|
| 268 |
+
state["turn"] += 1
|
| 269 |
+
a, b = _split_squads(rs)
|
| 270 |
+
cmds = []
|
| 271 |
+
if a:
|
| 272 |
+
cmds.append(Command.attack_move(a, 50, 20))
|
| 273 |
+
if b:
|
| 274 |
+
if state["turn"] >= delay_turns:
|
| 275 |
+
cmds.append(Command.attack_move(b, 50, 20))
|
| 276 |
+
else:
|
| 277 |
+
cmds.append(Command.stop(b))
|
| 278 |
+
return cmds or [Command.observe()]
|
| 279 |
+
|
| 280 |
+
return policy
|
| 281 |
+
|
| 282 |
+
|
| 283 |
+
def _intended_pincer_sync(rs, Command):
|
| 284 |
+
"""Both squads attack_move the cluster on turn 1. The two squads
|
| 285 |
+
are equidistant from the cluster (~45 cells each) so a naive
|
| 286 |
+
simultaneous launch arrives together; mass DPS clears the
|
| 287 |
+
cluster in ~6s before either side bleeds out."""
|
| 288 |
+
a, b = _split_squads(rs)
|
| 289 |
+
cmds = []
|
| 290 |
+
if a:
|
| 291 |
+
cmds.append(Command.attack_move(a, 50, 20))
|
| 292 |
+
if b:
|
| 293 |
+
cmds.append(Command.attack_move(b, 50, 20))
|
| 294 |
+
return cmds or [Command.observe()]
|
| 295 |
+
|
| 296 |
+
|
| 297 |
+
@pytest.mark.parametrize("level", ["easy", "medium", "hard"])
|
| 298 |
+
@pytest.mark.parametrize("seed", [1, 2, 3, 4])
|
| 299 |
+
def test_stall_loses(level, seed):
|
| 300 |
+
pytest.importorskip("openra_train")
|
| 301 |
+
from openra_bench.eval_core import run_level
|
| 302 |
+
|
| 303 |
+
c = compile_level(load_pack(PACK_PATH), level)
|
| 304 |
+
r = run_level(c, _stall, seed=seed)
|
| 305 |
+
assert r.outcome == "loss", (
|
| 306 |
+
f"{level} seed={seed}: stall must be a real timeout LOSS "
|
| 307 |
+
f"(no engagement → kill bar + region clause unmet), got {r.outcome}"
|
| 308 |
+
)
|
| 309 |
+
|
| 310 |
+
|
| 311 |
+
@pytest.mark.parametrize("level", ["easy", "medium", "hard"])
|
| 312 |
+
@pytest.mark.parametrize("seed", [1, 2, 3, 4])
|
| 313 |
+
def test_single_squad_a_loses(level, seed):
|
| 314 |
+
"""Single-squad-A cannot satisfy `units_in_region_gte:{n:4}` —
|
| 315 |
+
only 3 tanks exist in Squad A so the region clause is structurally
|
| 316 |
+
unsatisfiable. On medium/hard the cluster also shreds A as a
|
| 317 |
+
second teeth (units_lost_lte:2 busts)."""
|
| 318 |
+
pytest.importorskip("openra_train")
|
| 319 |
+
from openra_bench.eval_core import run_level
|
| 320 |
+
|
| 321 |
+
c = compile_level(load_pack(PACK_PATH), level)
|
| 322 |
+
r = run_level(c, _single_squad_a, seed=seed)
|
| 323 |
+
assert r.outcome == "loss", (
|
| 324 |
+
f"{level} seed={seed}: single-squad-A must LOSE "
|
| 325 |
+
f"(region n=4 impossible with 3 tanks); got {r.outcome} "
|
| 326 |
+
f"(kills={r.signals.units_killed}, lost={r.signals.units_lost})"
|
| 327 |
+
)
|
| 328 |
+
|
| 329 |
+
|
| 330 |
+
@pytest.mark.parametrize("level", ["medium", "hard"])
|
| 331 |
+
@pytest.mark.parametrize("seed", [1, 2, 3, 4])
|
| 332 |
+
def test_sequenced_a_then_b_late_loses_on_medium_hard(level, seed):
|
| 333 |
+
"""A commits first, B holds 20 turns. The cluster's enemy 2tnks
|
| 334 |
+
shred A (lose 3 of 3 tanks) before B arrives → units_lost_lte:2
|
| 335 |
+
busts → LOSS. Easy has no enemy armour so this may squeak by
|
| 336 |
+
there (bare-skill-tier inert-easy-teeth convention)."""
|
| 337 |
+
pytest.importorskip("openra_train")
|
| 338 |
+
from openra_bench.eval_core import run_level
|
| 339 |
+
|
| 340 |
+
c = compile_level(load_pack(PACK_PATH), level)
|
| 341 |
+
r = run_level(c, _make_sequenced_a_then_b_late(20), seed=seed)
|
| 342 |
+
assert r.outcome == "loss", (
|
| 343 |
+
f"{level} seed={seed}: sequenced-A-then-B-late must LOSE "
|
| 344 |
+
f"(A shredded by enemy 2tnks before B arrives); got {r.outcome} "
|
| 345 |
+
f"(kills={r.signals.units_killed}, lost={r.signals.units_lost})"
|
| 346 |
+
)
|
| 347 |
+
|
| 348 |
+
|
| 349 |
+
@pytest.mark.parametrize("level", ["easy", "medium", "hard"])
|
| 350 |
+
@pytest.mark.parametrize("seed", [1, 2, 3, 4])
|
| 351 |
+
def test_intended_pincer_sync_wins(level, seed):
|
| 352 |
+
"""Both squads attack_move the cluster on turn 1 — naive
|
| 353 |
+
simultaneous launch with equidistant staging arrives together,
|
| 354 |
+
mass DPS clears the cluster before either side bleeds out."""
|
| 355 |
+
pytest.importorskip("openra_train")
|
| 356 |
+
from openra_bench.eval_core import run_level
|
| 357 |
+
|
| 358 |
+
c = compile_level(load_pack(PACK_PATH), level)
|
| 359 |
+
r = run_level(c, _intended_pincer_sync, seed=seed)
|
| 360 |
+
assert r.outcome == "win", (
|
| 361 |
+
f"{level} seed={seed}: intended pincer-sync should WIN, "
|
| 362 |
+
f"got {r.outcome} after {r.turns} turns "
|
| 363 |
+
f"(kills={r.signals.units_killed}, lost={r.signals.units_lost})"
|
| 364 |
+
)
|
|
@@ -539,6 +539,20 @@ UPGRADED = [
|
|
| 539 |
# filtered out), so the strike geometry is symmetric but a
|
| 540 |
# memorised opening cannot generalise.
|
| 541 |
"rob-unit-loss-recovery",
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 542 |
]
|
| 543 |
|
| 544 |
# Consciously NOT spawn-varied, with the reason (keeps the curation
|
|
|
|
| 539 |
# filtered out), so the strike geometry is symmetric but a
|
| 540 |
# memorised opening cannot generalise.
|
| 541 |
"rob-unit-loss-recovery",
|
| 542 |
+
# Wave-6 Group N coordination pack — military pincer movement /
|
| 543 |
+
# SC2 two-prong attack / envelopment from two directions. Two
|
| 544 |
+
# 3-tank squads start at OPPOSING west-edge latitudes (north y=8 /
|
| 545 |
+
# south y=32) and must converge on a central enemy cluster
|
| 546 |
+
# SIMULTANEOUSLY: the win predicate requires ≥4 tanks in the
|
| 547 |
+
# central region (single squad has only 3 — structurally
|
| 548 |
+
# impossible) AND ≤2 tanks lost (sequenced A-then-B-late shreds A
|
| 549 |
+
# before B arrives → cap busted). Hard defines two agent
|
| 550 |
+
# spawn_point groups round-robined by seed with DIFFERENT cell
|
| 551 |
+
# coordinates (group 0: (5,8)/(5,32); group 1: (3,5)/(3,35)) so
|
| 552 |
+
# a memorised single-cell opening cannot generalise; the cluster
|
| 553 |
+
# is symmetric across y=20 so both spawns face the same pincer
|
| 554 |
+
# decision.
|
| 555 |
+
"combat-pincer-coordination",
|
| 556 |
]
|
| 557 |
|
| 558 |
# Consciously NOT spawn-varied, with the reason (keeps the curation
|