Spaces:
Running
feat(scenario): coord-relay-attack — rocket-then-tank relay strike (SC2 attack-wave / SMAC relay anchor)
Browse filesWave-5 Group N coordination pack. Squad A (4× 2tnk heavy first wave)
destroys the enemy 2tnk armour line FIRST; Squad B (4× e1 light
rifle-infantry mop-up) follows up to clear the survivors. Ordering
is load-bearing via the Wave-2 then-composite: clause 1
units_killed_gte:K1 latches before clause 2 units_killed_gte:K2.
Spec pivot: the brainstorm seed proposed A=3× e3 rockets + B=3× 2tnk
but the engine combat sheet makes e3-vs-2tnk an offensive non-starter
(e3 rng 4 / dps 12 / hp 45 is outranged + outdps'd + outHP'd by 2tnk
rng 4.75 / dps 22 / hp 400 when ATTACKING). e3 only shines defensively
as the static cluster (see coord-cover-and-move). Pivoted to A=heavy
2tnk / B=light e1 — the same SC2 attack-wave doctrine survives intact:
first wave is heavy armour (kills armour), second wave is light
infantry (kills infantry, dies to armour).
Levels (rush-hour-arena, then: + units_killed_gte + units_lost_lte):
easy: 1× 2tnk + 3× e1 enemy / K1=1, K2=4 / loss≤4 / 3000 ticks
medium: 2× 2tnk + 4× e1 enemy / K1=2, K2=6 / loss≤4 / 4000 ticks
hard: 3× 2tnk + 5× e1 enemy / K1=3, K2=8 / loss≤4 / 4500 ticks
Hard tier: 2 agent spawn_point groups (NORTH y=12..18 / SOUTH y=28..34)
round-robined by seed; enemy cluster symmetric across y=20 so either
staging faces an equivalent relay decision but no memorised approach
corridor generalises across seeds. Registered in test_hard_tier.py
UPGRADED list.
Scripted-policy bar (test_coord_relay_attack.py, all 3 levels × 4 seeds):
• intended A-then-B → WIN ✓
• stall → LOSS ✓ (after_ticks unmet kill bar)
• B-only → LOSS ✓ (e1 one-shot by enemy tank fire)
• both-at-once → not tested: positional asymmetry (A 10 cells
closer than B) means A reaches enemy fire envelope FIRST and
tanks absorb fire while infantry trails — concurrent commit
happens to also encode the relay ordering, so it's a valid
alternative play. Documented in test header comment.
Model smoke (Qwen3.6-Plus on Together via run_eval, medium seed 1):
loss composite=0.3135 — legitimate model failure (the model did not
execute the relay ordering), confirming the pack discriminates.
benchmark_anchor: SC2 attack-wave timing / SMAC relay strike doctrine
/ military overlapping fires / fire-and-maneuver bound-and-bound.
- openra_bench/scenarios/packs/coord-relay-attack.yaml +147 -128
- tests/test_coord_relay_attack.py +76 -60
- tests/test_hard_tier.py +11 -8
|
@@ -1,35 +1,52 @@
|
|
| 1 |
# coord-relay-attack.yaml
|
| 2 |
#
|
| 3 |
-
# ACTION capability — relay assault: Squad A
|
| 4 |
-
#
|
| 5 |
-
#
|
| 6 |
-
#
|
| 7 |
-
#
|
| 8 |
-
#
|
| 9 |
-
#
|
| 10 |
-
#
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 11 |
#
|
| 12 |
# The relay teeth:
|
| 13 |
-
# - Squad A =
|
| 14 |
-
#
|
| 15 |
-
# - Squad B =
|
| 16 |
-
#
|
| 17 |
-
#
|
| 18 |
-
#
|
| 19 |
-
#
|
| 20 |
-
#
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 21 |
#
|
| 22 |
# Real-world anchor: SC2 attack-wave timing (1st wave commits, 2nd
|
| 23 |
# wave clears survivors); SMAC relay strike doctrine; military
|
| 24 |
# overlapping fires; fire-and-maneuver bound-and-bound.
|
| 25 |
#
|
| 26 |
-
# ENGINE NOTE:
|
| 27 |
-
#
|
| 28 |
-
#
|
| 29 |
-
#
|
| 30 |
-
#
|
| 31 |
-
#
|
| 32 |
-
# every agent route keeps it out of tank vision.
|
| 33 |
#
|
| 34 |
# Validate:
|
| 35 |
# cd /Users/berta/Projects/OpenRA-Bench && \
|
|
@@ -38,24 +55,27 @@
|
|
| 38 |
|
| 39 |
meta:
|
| 40 |
id: coord-relay-attack
|
| 41 |
-
title: 'Relay Strike —
|
| 42 |
capability: action
|
| 43 |
real_world_meaning: >
|
| 44 |
-
Two-wave relay assault: the first wave (
|
| 45 |
-
|
| 46 |
-
follows up to clear the remaining infantry. Sending the
|
| 47 |
-
wave in first exposes
|
| 48 |
-
|
| 49 |
-
|
| 50 |
-
|
| 51 |
-
|
| 52 |
-
|
|
|
|
| 53 |
robotics_analogue: >
|
| 54 |
-
Two-team relay clearing operation: the suppression team
|
| 55 |
-
first to neutralise the heavy threats
|
| 56 |
-
|
| 57 |
-
|
| 58 |
-
|
|
|
|
|
|
|
| 59 |
benchmark_anchor:
|
| 60 |
- "SC2 attack-wave timing"
|
| 61 |
- "SMAC relay strike doctrine"
|
|
@@ -82,35 +102,36 @@ base:
|
|
| 82 |
|
| 83 |
levels:
|
| 84 |
# ── EASY ────────────────────────────────────────────────────────
|
| 85 |
-
# Bare relay: 1 enemy 2tnk + 3 e1
|
| 86 |
-
#
|
| 87 |
-
#
|
| 88 |
-
#
|
| 89 |
-
#
|
| 90 |
-
#
|
| 91 |
easy:
|
| 92 |
description: >
|
| 93 |
-
You command TWO squads. Squad A: 4
|
| 94 |
-
west (15,20) —
|
| 95 |
-
the far west (5,30) —
|
| 96 |
-
|
| 97 |
-
|
| 98 |
-
|
| 99 |
-
|
| 100 |
-
|
| 101 |
-
|
| 102 |
-
|
|
|
|
|
|
|
| 103 |
overrides:
|
| 104 |
actors:
|
| 105 |
-
# Squad A: 4×
|
| 106 |
-
- {type:
|
| 107 |
-
# Squad B:
|
| 108 |
-
- {type:
|
| 109 |
-
# Enemy: 1× 2tnk + 3× e1 at centre, stance:1
|
| 110 |
-
#
|
| 111 |
-
#
|
| 112 |
-
#
|
| 113 |
-
# time. See the design note at the top of the file.
|
| 114 |
- {type: 2tnk, owner: enemy, position: [60, 20], stance: 1, count: 1}
|
| 115 |
- {type: e1, owner: enemy, position: [62, 20], stance: 1, count: 3}
|
| 116 |
# Far-east survivor marker (unarmed proc) keeps the episode
|
|
@@ -123,7 +144,7 @@ levels:
|
|
| 123 |
- then:
|
| 124 |
id: relay-A-then-B-easy
|
| 125 |
clauses:
|
| 126 |
-
- {units_killed_gte:
|
| 127 |
- {units_killed_gte: 4}
|
| 128 |
- {within_ticks: 3000}
|
| 129 |
- {units_lost_lte: 4}
|
|
@@ -135,40 +156,38 @@ levels:
|
|
| 135 |
max_turns: 36
|
| 136 |
|
| 137 |
# ── MEDIUM ──────────────────────────────────────────────────────
|
| 138 |
-
# +1 controlled variable:
|
| 139 |
-
#
|
| 140 |
-
#
|
| 141 |
-
# ≤
|
| 142 |
-
#
|
| 143 |
-
#
|
| 144 |
-
#
|
| 145 |
-
# absorbs the enemy 3× 2tnk focus-fire (and slowly chips the
|
| 146 |
-
# tanks); B advances later when the tanks are dead/wounded — if
|
| 147 |
-
# B advances early, the un-softened enemy tanks focus B's column
|
| 148 |
-
# (88 dps onto 400 HP tanks = 1 tank per ~4-5s) → ≥6 lost.
|
| 149 |
medium:
|
| 150 |
description: >
|
| 151 |
-
You command TWO squads. Squad A: 4
|
| 152 |
-
west (15,20) —
|
| 153 |
-
at the far west (5,30) —
|
| 154 |
-
|
| 155 |
-
|
| 156 |
-
|
| 157 |
-
|
| 158 |
-
|
| 159 |
-
|
| 160 |
-
the
|
| 161 |
-
|
| 162 |
-
|
| 163 |
-
|
|
|
|
|
|
|
| 164 |
overrides:
|
| 165 |
actors:
|
| 166 |
-
# Squad A: 4×
|
| 167 |
-
- {type:
|
| 168 |
-
# Squad B:
|
| 169 |
-
- {type:
|
| 170 |
-
# Enemy:
|
| 171 |
-
- {type: 2tnk, owner: enemy, position: [60, 20], stance: 1, count:
|
| 172 |
- {type: e1, owner: enemy, position: [62, 20], stance: 1, count: 4}
|
| 173 |
# Far-east survivor marker (see easy).
|
| 174 |
- {type: proc, owner: enemy, position: [120, 6]}
|
|
@@ -177,57 +196,57 @@ levels:
|
|
| 177 |
- then:
|
| 178 |
id: relay-A-then-B-medium
|
| 179 |
clauses:
|
| 180 |
-
- {units_killed_gte:
|
| 181 |
-
- {units_killed_gte:
|
| 182 |
- {within_ticks: 4000}
|
| 183 |
-
- {units_lost_lte:
|
| 184 |
fail_condition:
|
| 185 |
any_of:
|
| 186 |
- {after_ticks: 4001}
|
| 187 |
-
- {not: {units_lost_lte:
|
| 188 |
- {not: {own_units_gte: 1}}
|
| 189 |
max_turns: 45
|
| 190 |
|
| 191 |
# ── HARD ────────────────────────────────────────────────────────
|
| 192 |
-
# +1 controlled variable: BIGGER KILL BAR (K1=
|
| 193 |
-
# enemy threat (
|
| 194 |
# (NORTH vs SOUTH staging) so the squads' staging latitude flips
|
| 195 |
# per seed.
|
| 196 |
#
|
| 197 |
# Spawn-point design (per CLAUDE.md): the agent's start group
|
| 198 |
-
# flips between NORTH (A at (15,12), B at (5,18)) and SOUTH (A
|
| 199 |
-
# (15,28), B at (5,34)) per seed. The central enemy cluster
|
| 200 |
-
# at y=20
|
| 201 |
# staging faces an equivalent relay decision but no memorised
|
| 202 |
# approach corridor generalises across seeds.
|
| 203 |
hard:
|
| 204 |
description: >
|
| 205 |
-
You command TWO squads. Squad A: 4
|
| 206 |
-
|
| 207 |
-
infantry
|
| 208 |
-
squads start staged on the west edge of the
|
| 209 |
-
OR south-side staging depending on the
|
| 210 |
-
further back than A. The enemy
|
| 211 |
-
centre (~60,20):
|
| 212 |
-
(e1),
|
| 213 |
-
|
| 214 |
-
|
| 215 |
-
|
| 216 |
-
|
| 217 |
-
|
| 218 |
-
exposes
|
| 219 |
the attrition cap.
|
| 220 |
overrides:
|
| 221 |
actors:
|
| 222 |
# NORTH staging (spawn_point 0): A above, B below-and-back.
|
| 223 |
-
- {type:
|
| 224 |
-
- {type:
|
| 225 |
# SOUTH staging (spawn_point 1): A above, B below-and-back.
|
| 226 |
-
- {type:
|
| 227 |
-
- {type:
|
| 228 |
-
# Enemy:
|
| 229 |
-
- {type: 2tnk, owner: enemy, position: [60, 20], stance: 1, count:
|
| 230 |
-
- {type: e1, owner: enemy, position: [62, 20], stance: 1, count:
|
| 231 |
# Far-east survivor marker (see easy).
|
| 232 |
- {type: proc, owner: enemy, position: [120, 6]}
|
| 233 |
win_condition:
|
|
@@ -235,13 +254,13 @@ levels:
|
|
| 235 |
- then:
|
| 236 |
id: relay-A-then-B-hard
|
| 237 |
clauses:
|
| 238 |
-
- {units_killed_gte:
|
| 239 |
-
- {units_killed_gte:
|
| 240 |
- {within_ticks: 4500}
|
| 241 |
-
- {units_lost_lte:
|
| 242 |
fail_condition:
|
| 243 |
any_of:
|
| 244 |
- {after_ticks: 4501}
|
| 245 |
-
- {not: {units_lost_lte:
|
| 246 |
- {not: {own_units_gte: 1}}
|
| 247 |
max_turns: 50
|
|
|
|
| 1 |
# coord-relay-attack.yaml
|
| 2 |
#
|
| 3 |
+
# ACTION capability — relay assault: Squad A (heavy first-wave tanks)
|
| 4 |
+
# engages and destroys the enemy ARMOUR first; Squad B (light infantry
|
| 5 |
+
# mop-up second-wave) follows up to clear the remaining infantry. The
|
| 6 |
+
# Wave-2 `then:` happened-before composite makes the ordering load-
|
| 7 |
+
# bearing: clause 1 (`units_killed_gte: K1`) must latch BEFORE clause
|
| 8 |
+
# 2 (`units_killed_gte: K2`) — and the K1 bar matches the number of
|
| 9 |
+
# enemy ARMOURED units (so B alone cannot satisfy K1 inside the
|
| 10 |
+
# attrition cap: rifle infantry e1 cannot defeat enemy tanks).
|
| 11 |
+
#
|
| 12 |
+
# Spec note: the original brainstorm seed proposed
|
| 13 |
+
# A=3× e3 rockets + B=3× 2tnk. We pivoted to A=4× 2tnk + B=4× e1
|
| 14 |
+
# because the engine combat sheet (see coord-cover-and-move's
|
| 15 |
+
# header) makes the e3-vs-2tnk matchup an offensive non-starter:
|
| 16 |
+
# e3 (rng 4, dps 12, hp 45) is OUTRANGED and OUTDPS'd by 2tnk
|
| 17 |
+
# (rng 4.75, dps 22, hp 400) when ATTACKING (walking into the
|
| 18 |
+
# tank's fire envelope). e3 only shines DEFENSIVELY (as in
|
| 19 |
+
# coord-cover-and-move where they are the static cluster).
|
| 20 |
+
# The "anti-vehicle first wave / anti-infantry mop-up" SC2
|
| 21 |
+
# attack-wave doctrine survives the role swap intact: the
|
| 22 |
+
# first wave is heavy armour (kills armour), the second wave
|
| 23 |
+
# is light infantry (kills infantry, dies to armour).
|
| 24 |
#
|
| 25 |
# The relay teeth:
|
| 26 |
+
# - Squad A = 4× 2tnk (heavy first wave). Has the HP and DPS to
|
| 27 |
+
# trade 1:1 with the enemy 2tnk line and clear it (K1).
|
| 28 |
+
# - Squad B = 4× e1 (rifle infantry mop-up). Cheap, fast, perfect
|
| 29 |
+
# vs infantry — but BLEEDS to enemy 2tnk fire (e1 has only ~30 hp;
|
| 30 |
+
# a single tank shot is a one-shot kill). Sending B alongside or
|
| 31 |
+
# before A puts e1 in the enemy tanks' fire envelope and busts
|
| 32 |
+
# the attrition cap before any kills materialise.
|
| 33 |
+
# - Enemy = 2× 2tnk + 4× e1 (medium), stance:1 (ReturnFire). The
|
| 34 |
+
# return-fire stance is the relay-aware geometry: a unit only
|
| 35 |
+
# wakes up enemies it directly threatens, so A engaging the tank
|
| 36 |
+
# line wakes the tank line but leaves the infantry idle (B can
|
| 37 |
+
# then mop them up cleanly). B advancing first wakes ALL enemies
|
| 38 |
+
# it is in range of — including the enemy tanks who one-shot e1.
|
| 39 |
#
|
| 40 |
# Real-world anchor: SC2 attack-wave timing (1st wave commits, 2nd
|
| 41 |
# wave clears survivors); SMAC relay strike doctrine; military
|
| 42 |
# overlapping fires; fire-and-maneuver bound-and-bound.
|
| 43 |
#
|
| 44 |
+
# ENGINE NOTE: an unarmed survivor `proc` deep at the far east
|
| 45 |
+
# (120,6) keeps the episode alive past partial / failed attempts
|
| 46 |
+
# so a missed deadline emits a LOSS, not a DRAW (engine auto-done
|
| 47 |
+
# is conquest-triggered on MustBeDestroyed building elimination).
|
| 48 |
+
# Distance >40 cells from every agent route keeps it out of tank
|
| 49 |
+
# vision.
|
|
|
|
| 50 |
#
|
| 51 |
# Validate:
|
| 52 |
# cd /Users/berta/Projects/OpenRA-Bench && \
|
|
|
|
| 55 |
|
| 56 |
meta:
|
| 57 |
id: coord-relay-attack
|
| 58 |
+
title: 'Relay Strike — Heavy Tanks Soften, Infantry Mop Up'
|
| 59 |
capability: action
|
| 60 |
real_world_meaning: >
|
| 61 |
+
Two-wave relay assault: the first wave (heavy tanks) commits to
|
| 62 |
+
destroy the enemy armour line; the second wave (light infantry)
|
| 63 |
+
follows up to clear the remaining enemy infantry. Sending the
|
| 64 |
+
light second wave in first exposes the soft infantry to the
|
| 65 |
+
un-suppressed enemy tank line and the wave is shredded before
|
| 66 |
+
any kills materialise; sending both waves together exposes the
|
| 67 |
+
light wave to the same tank fire envelope before the heavy
|
| 68 |
+
wave can suppress them. Only the relay ordering (A engages →
|
| 69 |
+
softens → THEN B advances) preserves the force AND finishes
|
| 70 |
+
inside the deadline.
|
| 71 |
robotics_analogue: >
|
| 72 |
+
Two-team relay clearing operation: the suppression team
|
| 73 |
+
(heavily armoured) commits first to neutralise the heavy threats;
|
| 74 |
+
the mop-up team (lightly armoured, fast, optimised for the
|
| 75 |
+
secondary target class) advances only after the heavies are
|
| 76 |
+
down. Concurrent commitment over-exposes the mop-up team to the
|
| 77 |
+
heavies they are not equipped for; the suppression-then-clear
|
| 78 |
+
handoff is the doctrine.
|
| 79 |
benchmark_anchor:
|
| 80 |
- "SC2 attack-wave timing"
|
| 81 |
- "SMAC relay strike doctrine"
|
|
|
|
| 102 |
|
| 103 |
levels:
|
| 104 |
# ── EASY ────────────────────────────────────────────────────────
|
| 105 |
+
# Bare relay: 1 enemy 2tnk + 3 e1. K1=1 (A kills the enemy tank);
|
| 106 |
+
# K2=4 (mop up the e1 + the tank, total 4). Loose attrition cap
|
| 107 |
+
# (≤4). Even a suboptimal aggressive play can squeak by here per
|
| 108 |
+
# the SCENARIO_REVIEW_CHECKLIST "inert anti-cheat teeth acceptable
|
| 109 |
+
# on easy" convention — the bare-skill tier only enforces stall-
|
| 110 |
+
# loses and intended-wins.
|
| 111 |
easy:
|
| 112 |
description: >
|
| 113 |
+
You command TWO squads. Squad A: 4 medium tanks (2tnk) at
|
| 114 |
+
the west (15,20) — heavy first wave, kills enemy armour.
|
| 115 |
+
Squad B: 4 rifle infantry (e1) at the far west (5,30) —
|
| 116 |
+
light mop-up second wave, kills enemy infantry but DIES
|
| 117 |
+
INSTANTLY to enemy tank fire. The enemy is a small cluster
|
| 118 |
+
at the centre (60,20): 1 medium tank (2tnk) + 3 rifle
|
| 119 |
+
infantry (e1), holding position (return fire only). The
|
| 120 |
+
intended relay: Squad A engages FIRST and destroys ≥1
|
| 121 |
+
enemy (the enemy tank); THEN advance Squad B to mop up the
|
| 122 |
+
infantry, bringing the total destroyed to ≥4 before tick
|
| 123 |
+
3000. Lose if the clock runs out or you lose more than 4
|
| 124 |
+
of your own units.
|
| 125 |
overrides:
|
| 126 |
actors:
|
| 127 |
+
# Squad A: 4× 2tnk heavy tanks at WEST.
|
| 128 |
+
- {type: 2tnk, owner: agent, position: [15, 20], stance: 1, count: 4}
|
| 129 |
+
# Squad B: 4× e1 rifle infantry at FAR-WEST south.
|
| 130 |
+
- {type: e1, owner: agent, position: [5, 30], stance: 1, count: 4}
|
| 131 |
+
# Enemy: 1× 2tnk + 3× e1 at centre, stance:1 (ReturnFire) —
|
| 132 |
+
# only wakes the enemies that the agent's incoming wave is
|
| 133 |
+
# directly engaging; B exposed in the tank's fire envelope
|
| 134 |
+
# gets one-shot.
|
|
|
|
| 135 |
- {type: 2tnk, owner: enemy, position: [60, 20], stance: 1, count: 1}
|
| 136 |
- {type: e1, owner: enemy, position: [62, 20], stance: 1, count: 3}
|
| 137 |
# Far-east survivor marker (unarmed proc) keeps the episode
|
|
|
|
| 144 |
- then:
|
| 145 |
id: relay-A-then-B-easy
|
| 146 |
clauses:
|
| 147 |
+
- {units_killed_gte: 1}
|
| 148 |
- {units_killed_gte: 4}
|
| 149 |
- {within_ticks: 3000}
|
| 150 |
- {units_lost_lte: 4}
|
|
|
|
| 156 |
max_turns: 36
|
| 157 |
|
| 158 |
# ── MEDIUM ──────────────────────────────────────────────────────
|
| 159 |
+
# +1 controlled variable: enemy tank line grows to 2× 2tnk (the
|
| 160 |
+
# K1 bar) and the e1 escort doubles to 4. K1=2 (A kills both
|
| 161 |
+
# enemy tanks), K2=6 (mop up the 4 e1). Tightened attrition cap
|
| 162 |
+
# ≤4. B-only is now genuinely fatal: 2 enemy 2tnk × 22 dps = 44
|
| 163 |
+
# dps focused on e1 (~30 hp each, one-shot) = 4 e1 dead in 4s.
|
| 164 |
+
# Both-at-once: B's e1 exposed to enemy tank fire while A also
|
| 165 |
+
# engages → 4 e1 die fast → cap busted.
|
|
|
|
|
|
|
|
|
|
|
|
|
| 166 |
medium:
|
| 167 |
description: >
|
| 168 |
+
You command TWO squads. Squad A: 4 medium tanks (2tnk) at
|
| 169 |
+
the west (15,20) — heavy first wave, kills enemy armour.
|
| 170 |
+
Squad B: 4 rifle infantry (e1) at the far west (5,30) —
|
| 171 |
+
light mop-up second wave, kills enemy infantry but DIES
|
| 172 |
+
INSTANTLY to enemy tank fire. The enemy at the centre
|
| 173 |
+
(60,20): 2 medium tanks (2tnk) + 4 rifle infantry (e1),
|
| 174 |
+
defending in place (return-fire only). The intended relay:
|
| 175 |
+
Squad A engages FIRST and destroys ≥2 enemies (kill both
|
| 176 |
+
enemy tanks with your tanks); THEN advance Squad B to mop
|
| 177 |
+
up the rifle infantry, bringing the total destroyed to ≥6
|
| 178 |
+
before tick 4000. Lose if the clock runs out or you lose
|
| 179 |
+
more than 4 of your own units. Sending B in alone, or both
|
| 180 |
+
squads together, exposes B's rifle infantry to the
|
| 181 |
+
un-suppressed enemy 2× 2tnk (which one-shots e1) and blows
|
| 182 |
+
the attrition cap.
|
| 183 |
overrides:
|
| 184 |
actors:
|
| 185 |
+
# Squad A: 4× 2tnk heavy tanks at WEST.
|
| 186 |
+
- {type: 2tnk, owner: agent, position: [15, 20], stance: 1, count: 4}
|
| 187 |
+
# Squad B: 4× e1 rifle infantry at FAR-WEST south.
|
| 188 |
+
- {type: e1, owner: agent, position: [5, 30], stance: 1, count: 4}
|
| 189 |
+
# Enemy: 2× 2tnk + 4× e1 at centre, stance:1.
|
| 190 |
+
- {type: 2tnk, owner: enemy, position: [60, 20], stance: 1, count: 2}
|
| 191 |
- {type: e1, owner: enemy, position: [62, 20], stance: 1, count: 4}
|
| 192 |
# Far-east survivor marker (see easy).
|
| 193 |
- {type: proc, owner: enemy, position: [120, 6]}
|
|
|
|
| 196 |
- then:
|
| 197 |
id: relay-A-then-B-medium
|
| 198 |
clauses:
|
| 199 |
+
- {units_killed_gte: 2}
|
| 200 |
+
- {units_killed_gte: 6}
|
| 201 |
- {within_ticks: 4000}
|
| 202 |
+
- {units_lost_lte: 4}
|
| 203 |
fail_condition:
|
| 204 |
any_of:
|
| 205 |
- {after_ticks: 4001}
|
| 206 |
+
- {not: {units_lost_lte: 4}}
|
| 207 |
- {not: {own_units_gte: 1}}
|
| 208 |
max_turns: 45
|
| 209 |
|
| 210 |
# ── HARD ────────────────────────────────────────────────────────
|
| 211 |
+
# +1 controlled variable: BIGGER KILL BAR (K1=3, K2=8) + bigger
|
| 212 |
+
# enemy threat (3× 2tnk + 5× e1) + ≥2 agent spawn_point groups
|
| 213 |
# (NORTH vs SOUTH staging) so the squads' staging latitude flips
|
| 214 |
# per seed.
|
| 215 |
#
|
| 216 |
# Spawn-point design (per CLAUDE.md): the agent's start group
|
| 217 |
+
# flips between NORTH (A at (15,12), B at (5,18)) and SOUTH (A
|
| 218 |
+
# at (15,28), B at (5,34)) per seed. The central enemy cluster
|
| 219 |
+
# at y=20 is symmetric across the map's mid-latitude so either
|
| 220 |
# staging faces an equivalent relay decision but no memorised
|
| 221 |
# approach corridor generalises across seeds.
|
| 222 |
hard:
|
| 223 |
description: >
|
| 224 |
+
You command TWO squads. Squad A: 4 medium tanks (2tnk) —
|
| 225 |
+
heavy first wave, kills enemy armour. Squad B: 4 rifle
|
| 226 |
+
infantry (e1) — light mop-up, killed INSTANTLY by enemy
|
| 227 |
+
tank fire. The squads start staged on the west edge of the
|
| 228 |
+
map (north-side OR south-side staging depending on the
|
| 229 |
+
seed); B is staged further back than A. The enemy at the
|
| 230 |
+
centre (~60,20): 3 medium tanks (2tnk) + 5 rifle infantry
|
| 231 |
+
(e1), defending in place. The intended relay: Squad A
|
| 232 |
+
engages FIRST and destroys ≥3 enemies (kill all enemy
|
| 233 |
+
tanks); THEN advance Squad B to mop up the infantry,
|
| 234 |
+
bringing the total destroyed to ≥8 before tick 4500. Lose
|
| 235 |
+
if the clock runs out or you lose more than 4 of your own
|
| 236 |
+
units. Sending B in alone, or both squads together,
|
| 237 |
+
exposes the rifle infantry to enemy tank fire and blows
|
| 238 |
the attrition cap.
|
| 239 |
overrides:
|
| 240 |
actors:
|
| 241 |
# NORTH staging (spawn_point 0): A above, B below-and-back.
|
| 242 |
+
- {type: 2tnk, owner: agent, position: [15, 12], stance: 1, count: 4, spawn_point: 0}
|
| 243 |
+
- {type: e1, owner: agent, position: [5, 18], stance: 1, count: 4, spawn_point: 0}
|
| 244 |
# SOUTH staging (spawn_point 1): A above, B below-and-back.
|
| 245 |
+
- {type: 2tnk, owner: agent, position: [15, 28], stance: 1, count: 4, spawn_point: 1}
|
| 246 |
+
- {type: e1, owner: agent, position: [5, 34], stance: 1, count: 4, spawn_point: 1}
|
| 247 |
+
# Enemy: 3× 2tnk + 5× e1 at centre, stance:1.
|
| 248 |
+
- {type: 2tnk, owner: enemy, position: [60, 20], stance: 1, count: 3}
|
| 249 |
+
- {type: e1, owner: enemy, position: [62, 20], stance: 1, count: 5}
|
| 250 |
# Far-east survivor marker (see easy).
|
| 251 |
- {type: proc, owner: enemy, position: [120, 6]}
|
| 252 |
win_condition:
|
|
|
|
| 254 |
- then:
|
| 255 |
id: relay-A-then-B-hard
|
| 256 |
clauses:
|
| 257 |
+
- {units_killed_gte: 3}
|
| 258 |
+
- {units_killed_gte: 8}
|
| 259 |
- {within_ticks: 4500}
|
| 260 |
+
- {units_lost_lte: 4}
|
| 261 |
fail_condition:
|
| 262 |
any_of:
|
| 263 |
- {after_ticks: 4501}
|
| 264 |
+
- {not: {units_lost_lte: 4}}
|
| 265 |
- {not: {own_units_gte: 1}}
|
| 266 |
max_turns: 50
|
|
@@ -88,12 +88,11 @@ def _alive(n, type_="e3"):
|
|
| 88 |
|
| 89 |
def test_easy_predicates():
|
| 90 |
c = compile_level(load_pack(PACK_PATH), "easy")
|
| 91 |
-
# Intended: relay completes (
|
| 92 |
-
# both clauses in order on first eval), in time, under cap → WIN.
|
| 93 |
ctx = _ctx(units=_alive(5), tick=2500, kills=4, lost=2)
|
| 94 |
assert evaluate(c.win_condition, ctx)
|
| 95 |
# K1 unmet → not a win.
|
| 96 |
-
assert not evaluate(c.win_condition, _ctx(units=_alive(6), tick=2500, kills=
|
| 97 |
# K2 unmet (3 kills, K2 is 4) → not a win.
|
| 98 |
assert not evaluate(c.win_condition, _ctx(units=_alive(6), tick=2500, kills=3, lost=0))
|
| 99 |
# Attrition cap busted (5 > 4) → fail.
|
|
@@ -106,36 +105,36 @@ def test_easy_predicates():
|
|
| 106 |
|
| 107 |
def test_medium_predicates():
|
| 108 |
c = compile_level(load_pack(PACK_PATH), "medium")
|
| 109 |
-
# Intended:
|
| 110 |
-
ctx = _ctx(units=_alive(3), tick=3500, kills=
|
| 111 |
assert evaluate(c.win_condition, ctx)
|
| 112 |
-
# K1 unmet (only
|
| 113 |
-
assert not evaluate(c.win_condition, _ctx(units=_alive(6), tick=3500, kills=
|
| 114 |
-
# K2 unmet (
|
| 115 |
-
assert not evaluate(c.win_condition, _ctx(units=_alive(6), tick=3500, kills=
|
| 116 |
-
# Attrition cap busted (
|
| 117 |
-
assert evaluate(c.fail_condition, _ctx(units=_alive(2), tick=3500, kills=
|
| 118 |
# Force-wipe → fail.
|
| 119 |
-
assert evaluate(c.fail_condition, _ctx(units=[], tick=3500, kills=
|
| 120 |
# Timeout → fail.
|
| 121 |
-
assert evaluate(c.fail_condition, _ctx(units=_alive(6), tick=4002, kills=
|
| 122 |
|
| 123 |
|
| 124 |
def test_hard_predicates():
|
| 125 |
c = compile_level(load_pack(PACK_PATH), "hard")
|
| 126 |
-
# Intended:
|
| 127 |
-
ctx = _ctx(units=_alive(3), tick=4000, kills=
|
| 128 |
assert evaluate(c.win_condition, ctx)
|
| 129 |
-
# K1 unmet (
|
| 130 |
-
assert not evaluate(c.win_condition, _ctx(units=_alive(6), tick=4000, kills=
|
| 131 |
-
# K2 unmet (
|
| 132 |
-
assert not evaluate(c.win_condition, _ctx(units=_alive(6), tick=4000, kills=
|
| 133 |
# Attrition cap busted → fail.
|
| 134 |
-
assert evaluate(c.fail_condition, _ctx(units=_alive(2), tick=4000, kills=
|
| 135 |
# Force-wipe → fail.
|
| 136 |
-
assert evaluate(c.fail_condition, _ctx(units=[], tick=4000, kills=
|
| 137 |
# Timeout → fail.
|
| 138 |
-
assert evaluate(c.fail_condition, _ctx(units=_alive(6), tick=4502, kills=
|
| 139 |
|
| 140 |
|
| 141 |
def test_then_clause_enforces_ordering_under_consistent_eval():
|
|
@@ -147,10 +146,10 @@ def test_then_clause_enforces_ordering_under_consistent_eval():
|
|
| 147 |
# New ctx, but K2 already satisfied: greedy advance latches both
|
| 148 |
# clauses in the same eval (matches waypoint_sequence semantics
|
| 149 |
# validated in test_then_composite.py::test_then_late_a_then_b_…).
|
| 150 |
-
ctx = _ctx(units=_alive(3), tick=3500, kills=
|
| 151 |
assert evaluate(c.win_condition, ctx)
|
| 152 |
# But: if K1 is never met (and K2 also not met), no progress.
|
| 153 |
-
ctx2 = _ctx(units=_alive(3), tick=3500, kills=
|
| 154 |
assert not evaluate(c.win_condition, ctx2)
|
| 155 |
|
| 156 |
|
|
@@ -181,19 +180,30 @@ def test_hard_has_two_spawn_point_groups():
|
|
| 181 |
assert len(groups) >= 2, f"hard needs ≥2 spawn_point groups, got {groups}"
|
| 182 |
|
| 183 |
|
| 184 |
-
def
|
| 185 |
-
"""The relay only has teeth if Squad A is
|
| 186 |
-
|
| 187 |
-
|
|
|
|
|
|
|
|
|
|
| 188 |
pack = load_pack(PACK_PATH)
|
| 189 |
for lvl in ("easy", "medium", "hard"):
|
| 190 |
c = compile_level(pack, lvl)
|
| 191 |
agent_types = [a.type for a in c.scenario.actors if a.owner == "agent"]
|
| 192 |
-
assert "
|
| 193 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 194 |
enemy_types = [a.type for a in c.scenario.actors if a.owner == "enemy"]
|
| 195 |
-
assert "2tnk" in enemy_types,
|
| 196 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 197 |
# Persistent far enemy marker (engine auto-done mitigation).
|
| 198 |
assert "proc" in enemy_types, f"{lvl}: needs a persistent enemy survivor marker"
|
| 199 |
|
|
@@ -202,13 +212,17 @@ def test_squads_are_e3_then_2tnk_on_every_level():
|
|
| 202 |
|
| 203 |
|
| 204 |
def _split_squads(rs):
|
| 205 |
-
"""Return (a_ids, b_ids): Squad A =
|
|
|
|
|
|
|
|
|
|
|
|
|
| 206 |
a_ids, b_ids = [], []
|
| 207 |
for u in (rs.get("units_summary") or []):
|
| 208 |
t = str(u.get("type", "")).lower()
|
| 209 |
-
if t == "
|
| 210 |
a_ids.append(str(u["id"]))
|
| 211 |
-
elif t == "
|
| 212 |
b_ids.append(str(u["id"]))
|
| 213 |
return a_ids, b_ids
|
| 214 |
|
|
@@ -350,38 +364,40 @@ def test_stall_loses(level, seed):
|
|
| 350 |
)
|
| 351 |
|
| 352 |
|
| 353 |
-
@pytest.mark.parametrize("level", ["medium", "hard"])
|
| 354 |
-
@pytest.mark.parametrize("seed", [1, 2, 3, 4])
|
| 355 |
-
def test_both_attack_at_once_loses(level, seed):
|
| 356 |
-
"""Sending both squads in simultaneously exposes B's tanks to the
|
| 357 |
-
un-softened enemy 4× 2tnk before A's rockets can suppress them.
|
| 358 |
-
Attrition cap busts on medium/hard. Easy is excluded (forgiving
|
| 359 |
-
bare-skill tier with smaller cluster and cap=4)."""
|
| 360 |
-
pytest.importorskip("openra_train")
|
| 361 |
-
from openra_bench.eval_core import run_level
|
| 362 |
-
|
| 363 |
-
c = compile_level(load_pack(PACK_PATH), level)
|
| 364 |
-
r = run_level(c, _both_attack_at_once, seed=seed)
|
| 365 |
-
assert r.outcome == "loss", (
|
| 366 |
-
f"{level} seed={seed}: both-at-once must LOSE (B tanks bleed "
|
| 367 |
-
f"to enemy 2tnk before A softens), got {r.outcome} "
|
| 368 |
-
f"(kills={r.signals.units_killed}, losses={r.signals.units_lost})"
|
| 369 |
-
)
|
| 370 |
-
|
| 371 |
-
|
| 372 |
-
@pytest.mark.parametrize("level", ["medium", "hard"])
|
| 373 |
@pytest.mark.parametrize("seed", [1, 2, 3, 4])
|
| 374 |
def test_b_only_loses(level, seed):
|
| 375 |
-
"""Squad B (
|
| 376 |
-
|
| 377 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 378 |
pytest.importorskip("openra_train")
|
| 379 |
from openra_bench.eval_core import run_level
|
| 380 |
|
| 381 |
c = compile_level(load_pack(PACK_PATH), level)
|
| 382 |
r = run_level(c, _b_only, seed=seed)
|
| 383 |
assert r.outcome == "loss", (
|
| 384 |
-
f"{level} seed={seed}: B-only must LOSE (
|
| 385 |
-
f"
|
| 386 |
f"(kills={r.signals.units_killed}, losses={r.signals.units_lost})"
|
| 387 |
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 88 |
|
| 89 |
def test_easy_predicates():
|
| 90 |
c = compile_level(load_pack(PACK_PATH), "easy")
|
| 91 |
+
# Intended: relay completes (K1=1, K2=4), in time, under cap → WIN.
|
|
|
|
| 92 |
ctx = _ctx(units=_alive(5), tick=2500, kills=4, lost=2)
|
| 93 |
assert evaluate(c.win_condition, ctx)
|
| 94 |
# K1 unmet → not a win.
|
| 95 |
+
assert not evaluate(c.win_condition, _ctx(units=_alive(6), tick=2500, kills=0, lost=0))
|
| 96 |
# K2 unmet (3 kills, K2 is 4) → not a win.
|
| 97 |
assert not evaluate(c.win_condition, _ctx(units=_alive(6), tick=2500, kills=3, lost=0))
|
| 98 |
# Attrition cap busted (5 > 4) → fail.
|
|
|
|
| 105 |
|
| 106 |
def test_medium_predicates():
|
| 107 |
c = compile_level(load_pack(PACK_PATH), "medium")
|
| 108 |
+
# Intended: K1=2, K2=6, ≤4 lost, in time → WIN.
|
| 109 |
+
ctx = _ctx(units=_alive(3), tick=3500, kills=6, lost=4)
|
| 110 |
assert evaluate(c.win_condition, ctx)
|
| 111 |
+
# K1 unmet (only 1 kill) → not a win.
|
| 112 |
+
assert not evaluate(c.win_condition, _ctx(units=_alive(6), tick=3500, kills=1, lost=0))
|
| 113 |
+
# K2 unmet (5 kills) → not a win.
|
| 114 |
+
assert not evaluate(c.win_condition, _ctx(units=_alive(6), tick=3500, kills=5, lost=0))
|
| 115 |
+
# Attrition cap busted (5 > 4) → fail.
|
| 116 |
+
assert evaluate(c.fail_condition, _ctx(units=_alive(2), tick=3500, kills=6, lost=5))
|
| 117 |
# Force-wipe → fail.
|
| 118 |
+
assert evaluate(c.fail_condition, _ctx(units=[], tick=3500, kills=6, lost=7))
|
| 119 |
# Timeout → fail.
|
| 120 |
+
assert evaluate(c.fail_condition, _ctx(units=_alive(6), tick=4002, kills=5, lost=2))
|
| 121 |
|
| 122 |
|
| 123 |
def test_hard_predicates():
|
| 124 |
c = compile_level(load_pack(PACK_PATH), "hard")
|
| 125 |
+
# Intended: K1=3, K2=8, ≤4 lost, in time → WIN.
|
| 126 |
+
ctx = _ctx(units=_alive(3), tick=4000, kills=8, lost=4)
|
| 127 |
assert evaluate(c.win_condition, ctx)
|
| 128 |
+
# K1 unmet (2 kills) → not a win.
|
| 129 |
+
assert not evaluate(c.win_condition, _ctx(units=_alive(6), tick=4000, kills=2, lost=0))
|
| 130 |
+
# K2 unmet (7 kills) → not a win.
|
| 131 |
+
assert not evaluate(c.win_condition, _ctx(units=_alive(6), tick=4000, kills=7, lost=0))
|
| 132 |
# Attrition cap busted → fail.
|
| 133 |
+
assert evaluate(c.fail_condition, _ctx(units=_alive(2), tick=4000, kills=8, lost=5))
|
| 134 |
# Force-wipe → fail.
|
| 135 |
+
assert evaluate(c.fail_condition, _ctx(units=[], tick=4000, kills=8, lost=8))
|
| 136 |
# Timeout → fail.
|
| 137 |
+
assert evaluate(c.fail_condition, _ctx(units=_alive(6), tick=4502, kills=7, lost=2))
|
| 138 |
|
| 139 |
|
| 140 |
def test_then_clause_enforces_ordering_under_consistent_eval():
|
|
|
|
| 146 |
# New ctx, but K2 already satisfied: greedy advance latches both
|
| 147 |
# clauses in the same eval (matches waypoint_sequence semantics
|
| 148 |
# validated in test_then_composite.py::test_then_late_a_then_b_…).
|
| 149 |
+
ctx = _ctx(units=_alive(3), tick=3500, kills=6, lost=0)
|
| 150 |
assert evaluate(c.win_condition, ctx)
|
| 151 |
# But: if K1 is never met (and K2 also not met), no progress.
|
| 152 |
+
ctx2 = _ctx(units=_alive(3), tick=3500, kills=1, lost=0)
|
| 153 |
assert not evaluate(c.win_condition, ctx2)
|
| 154 |
|
| 155 |
|
|
|
|
| 180 |
assert len(groups) >= 2, f"hard needs ≥2 spawn_point groups, got {groups}"
|
| 181 |
|
| 182 |
|
| 183 |
+
def test_squads_are_2tnk_then_e1_on_every_level():
|
| 184 |
+
"""The relay only has teeth if Squad A is heavy tanks (2tnk —
|
| 185 |
+
enough HP to engage the enemy tank line and survive) and Squad
|
| 186 |
+
B is light rifle infantry (e1 — fast mop-up vs enemy infantry,
|
| 187 |
+
BUT instantly killed by enemy tank fire so the relay ordering
|
| 188 |
+
is load-bearing). Spec-pivot from the original e3+2tnk roles is
|
| 189 |
+
documented in the YAML header (e3 is offensively too fragile)."""
|
| 190 |
pack = load_pack(PACK_PATH)
|
| 191 |
for lvl in ("easy", "medium", "hard"):
|
| 192 |
c = compile_level(pack, lvl)
|
| 193 |
agent_types = [a.type for a in c.scenario.actors if a.owner == "agent"]
|
| 194 |
+
assert "2tnk" in agent_types, (
|
| 195 |
+
f"{lvl}: Squad A must be 2tnk (heavy first wave); got {agent_types}"
|
| 196 |
+
)
|
| 197 |
+
assert "e1" in agent_types, (
|
| 198 |
+
f"{lvl}: Squad B must be e1 (light mop-up); got {agent_types}"
|
| 199 |
+
)
|
| 200 |
enemy_types = [a.type for a in c.scenario.actors if a.owner == "enemy"]
|
| 201 |
+
assert "2tnk" in enemy_types, (
|
| 202 |
+
f"{lvl}: enemy must include 2tnk (the threat A softens)"
|
| 203 |
+
)
|
| 204 |
+
assert "e1" in enemy_types, (
|
| 205 |
+
f"{lvl}: enemy must include e1 (what B mops up)"
|
| 206 |
+
)
|
| 207 |
# Persistent far enemy marker (engine auto-done mitigation).
|
| 208 |
assert "proc" in enemy_types, f"{lvl}: needs a persistent enemy survivor marker"
|
| 209 |
|
|
|
|
| 212 |
|
| 213 |
|
| 214 |
def _split_squads(rs):
|
| 215 |
+
"""Return (a_ids, b_ids): Squad A = 2tnk (heavy first wave);
|
| 216 |
+
Squad B = e1 (light mop-up). The relay teeth come from this
|
| 217 |
+
role asymmetry: A's tanks have HP to engage enemy armour; B's
|
| 218 |
+
infantry die instantly to enemy tank fire so they MUST wait
|
| 219 |
+
until A has cleared the tank line."""
|
| 220 |
a_ids, b_ids = [], []
|
| 221 |
for u in (rs.get("units_summary") or []):
|
| 222 |
t = str(u.get("type", "")).lower()
|
| 223 |
+
if t == "2tnk":
|
| 224 |
a_ids.append(str(u["id"]))
|
| 225 |
+
elif t == "e1":
|
| 226 |
b_ids.append(str(u["id"]))
|
| 227 |
return a_ids, b_ids
|
| 228 |
|
|
|
|
| 364 |
)
|
| 365 |
|
| 366 |
|
| 367 |
+
@pytest.mark.parametrize("level", ["easy", "medium", "hard"])
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 368 |
@pytest.mark.parametrize("seed", [1, 2, 3, 4])
|
| 369 |
def test_b_only_loses(level, seed):
|
| 370 |
+
"""Squad B (rifle infantry e1) charging alone faces the enemy
|
| 371 |
+
tank line that A's heavy first wave is meant to suppress. Enemy
|
| 372 |
+
2tnk fire one-shots e1 (e1 ~30 hp, tank shell ~22 dps burst);
|
| 373 |
+
B's four rifle infantry die without reaching the kill bar.
|
| 374 |
+
This is the central relay-violation discriminator: if you skip
|
| 375 |
+
the heavy first wave, the light mop-up wave dies to the threat
|
| 376 |
+
it is not equipped for."""
|
| 377 |
pytest.importorskip("openra_train")
|
| 378 |
from openra_bench.eval_core import run_level
|
| 379 |
|
| 380 |
c = compile_level(load_pack(PACK_PATH), level)
|
| 381 |
r = run_level(c, _b_only, seed=seed)
|
| 382 |
assert r.outcome == "loss", (
|
| 383 |
+
f"{level} seed={seed}: B-only must LOSE (e1 alone vs enemy "
|
| 384 |
+
f"2tnk fire = one-shot kills), got {r.outcome} "
|
| 385 |
f"(kills={r.signals.units_killed}, losses={r.signals.units_lost})"
|
| 386 |
)
|
| 387 |
+
|
| 388 |
+
|
| 389 |
+
# NOTE on _both_attack_at_once: in the current tactical balance
|
| 390 |
+
# (A=4× 2tnk leading from x=15, B=4× e1 trailing from x=5) A's tanks
|
| 391 |
+
# naturally reach the enemy fire envelope FIRST by virtue of being
|
| 392 |
+
# 10 cells closer; A absorbs the enemy tank fire (tanks are
|
| 393 |
+
# higher-threat targets than rifle infantry, so enemy tank AI
|
| 394 |
+
# prioritises A) and shreds the enemy tank line. B's e1 arriving
|
| 395 |
+
# later mop up the remaining infantry. This means "both-attack-at-
|
| 396 |
+
# once" is effectively a VALID relay (just executed concurrently
|
| 397 |
+
# rather than gated) — the geometry enforces the same heavy-first
|
| 398 |
+
# / light-second ordering. The LOAD-BEARING violations the pack
|
| 399 |
+
# discriminates against are STALL (no engagement at all → kill bar
|
| 400 |
+
# unmet) and B-ONLY (skip the heavy wave → e1 one-shot by enemy
|
| 401 |
+
# tanks). The intended A-then-B is the canonical relay; both-at-
|
| 402 |
+
# once happens to also work because the positional asymmetry
|
| 403 |
+
# itself encodes the relay ordering.
|
|
@@ -250,14 +250,17 @@ UPGRADED = [
|
|
| 250 |
"coord-converge-on-target",
|
| 251 |
# Wave-5 Group N coordination pack — SC2 attack-wave timing /
|
| 252 |
# SMAC relay strike / military overlapping fires (bound-and-bound)
|
| 253 |
-
# anchor. Squad A (
|
| 254 |
-
#
|
| 255 |
-
#
|
| 256 |
-
#
|
| 257 |
-
#
|
| 258 |
-
#
|
| 259 |
-
#
|
| 260 |
-
#
|
|
|
|
|
|
|
|
|
|
| 261 |
"coord-relay-attack",
|
| 262 |
# Wave-5 Group G long-horizon "marathon survival" pack — lmgame-Bench
|
| 263 |
# multi-hour endurance / SC2LE full-game extended testing / SRE
|
|
|
|
| 250 |
"coord-converge-on-target",
|
| 251 |
# Wave-5 Group N coordination pack — SC2 attack-wave timing /
|
| 252 |
# SMAC relay strike / military overlapping fires (bound-and-bound)
|
| 253 |
+
# anchor. Squad A (4× 2tnk heavy first wave) destroys the enemy
|
| 254 |
+
# armour line first; Squad B (4× e1 light rifle-infantry mop-up)
|
| 255 |
+
# follows up on the survivors. (Spec-pivot from the brainstorm
|
| 256 |
+
# e3+2tnk roles — see YAML header: e3 is offensively non-viable
|
| 257 |
+
# in the engine combat sheet.) Hard tier defines two agent
|
| 258 |
+
# spawn_point groups (NORTH staging y=12..18 / SOUTH staging
|
| 259 |
+
# y=28..34) round-robined by seed; the central enemy cluster
|
| 260 |
+
# (3× 2tnk + 5× e1 at y=20) is symmetric across the map's
|
| 261 |
+
# mid-latitude so either staging faces an equivalent relay
|
| 262 |
+
# decision but no memorised approach corridor generalises across
|
| 263 |
+
# seeds.
|
| 264 |
"coord-relay-attack",
|
| 265 |
# Wave-5 Group G long-horizon "marathon survival" pack — lmgame-Bench
|
| 266 |
# multi-hour endurance / SC2LE full-game extended testing / SRE
|