Spaces:
Running
Running
feat(scenario): combat-protect-vip-escort — escort fragile VIP through cumulative arty walls (SC2 medivac / VIP protection anchor)
Browse filesWave-6 ACTION cell. VIP=unique harv (unarmed); escort=4× 2tnk.
Cumulative arty walls scale 1/2/3 per tier; hard adds 2 agent
spawn_point groups (NORTH y=14 / SOUTH y=26) round-robined by seed.
Salvaged from main-worktree drop by W6 agent.
openra_bench/scenarios/packs/combat-protect-vip-escort.yaml
ADDED
|
@@ -0,0 +1,379 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# combat-protect-vip-escort — Get the fragile VIP through, preserve it.
|
| 2 |
+
#
|
| 3 |
+
# ACTION focus: a single unique `harv` is the VIP — the only one of
|
| 4 |
+
# its type on the map AND structurally unarmed (the harvester
|
| 5 |
+
# carries no weapon: it can never return fire even on stance:3).
|
| 6 |
+
# It must reach the east extraction point (~100, 20). Four 2tnk
|
| 7 |
+
# escorts at the same west staging zone must clear a long-range
|
| 8 |
+
# artillery threat AND the rifle infantry walls strung along the
|
| 9 |
+
# route AND keep the VIP behind their advance, so the arty + e1
|
| 10 |
+
# squads burn DPS into 400-HP tank armour rather than the fragile
|
| 11 |
+
# harvester. The VIP cannot engage directly (no weapon). If the
|
| 12 |
+
# agent drives it into the arty's range envelope without the
|
| 13 |
+
# tanks suppressing the arty first, the arty (dps45) shreds the
|
| 14 |
+
# VIP in two-three decision turns; the escort tanks must lead,
|
| 15 |
+
# suppress the arty + the rifle walls, and only then does the
|
| 16 |
+
# VIP cross safely.
|
| 17 |
+
#
|
| 18 |
+
# Real-world anchor: VIP-protection / diplomatic-escort detail —
|
| 19 |
+
# the principal (POTUS limo, ambassador convoy, SecDef movement)
|
| 20 |
+
# is unarmed and must not engage; the protective detail moves in
|
| 21 |
+
# bounding formation ahead and to the flanks, neutralising threats
|
| 22 |
+
# along the route before the principal traverses. SC2 VIP-survival
|
| 23 |
+
# missions, MicroRTS fragile-target preservation, and military
|
| 24 |
+
# convoy doctrine all encode the same constraint: the high-value
|
| 25 |
+
# unit is preserved by the escort's offence, not by its own fight.
|
| 26 |
+
#
|
| 27 |
+
# Discrimination (the four-script bar — scripted, no model):
|
| 28 |
+
# • stall (observe-only): nothing moves; the VIP never reaches
|
| 29 |
+
# the extraction → after_ticks 4501 LOSS (no draw degeneracy).
|
| 30 |
+
# • VIP-alone: only the harvester is moved; with no escort it
|
| 31 |
+
# enters the arty envelope (rng 7c) unaccompanied and is the
|
| 32 |
+
# only target the arty has — arty dps45 vs harv burns the
|
| 33 |
+
# harvester down in 2-3 decision turns → `not unit_type_count_
|
| 34 |
+
# gte:{harv,1}` fail clause fires → LOSS.
|
| 35 |
+
# • VIP-leads-charge: harv + tanks dispatched in a single column.
|
| 36 |
+
# On medium and hard the cumulative arty + rifle-wall fire
|
| 37 |
+
# onto the column kills the VIP before extraction → LOSS. On
|
| 38 |
+
# easy the single rifle wall isn't enough cumulative damage,
|
| 39 |
+
# so charge MAY squeak by — the forgiving bare-skill tier
|
| 40 |
+
# (SCENARIO_REVIEW_CHECKLIST.md: "inert anti-cheat teeth are
|
| 41 |
+
# acceptable on easy").
|
| 42 |
+
# • intended escort-clears-path: escort tanks attack_move east
|
| 43 |
+
# AHEAD of the harv (4× 2tnk dps22 = 88 dps focus on the arty
|
| 44 |
+
# first — arty hp75 dies in ~1 decision turn of concentrated
|
| 45 |
+
# fire; then the tanks pick off the e1 walls). The harv hangs
|
| 46 |
+
# ~10 cells behind the rear-most tank so the arty + rifle fire
|
| 47 |
+
# auto-target the closer tanks; once tanks have cleared the
|
| 48 |
+
# route, the harv crosses safely → harv arrives at extraction
|
| 49 |
+
# alive within budget → WIN.
|
| 50 |
+
#
|
| 51 |
+
# Why the spec works (engine combat sheet):
|
| 52 |
+
# • harv = ore harvester (hp600 nominal, heavily armoured, slow,
|
| 53 |
+
# UNARMED — game_knowledge: "ore harvester (gathers ore for
|
| 54 |
+
# credits; unarmed)"; prompt_v2: "$1100 hp600 unarmed sight4c
|
| 55 |
+
# tracked"). With no weapon it cannot return fire even on
|
| 56 |
+
# stance:3 — "the VIP must not engage" is enforced by the unit
|
| 57 |
+
# kit. The harv is reasonably tough vs rifle fire (e1 dps6 vs
|
| 58 |
+
# armour) but FRAGILE vs arty (dps45) — arty puts the harv to
|
| 59 |
+
# 0 hp in ~2 decision turns of standing exposure.
|
| 60 |
+
# • e1 = basic rifle infantry (rng3c, dps6 vs armour, sight4c,
|
| 61 |
+
# hp50). Cheap to clear (one tank shot per e1). Their role
|
| 62 |
+
# here is to extend the fire envelope so a charge column
|
| 63 |
+
# accumulates attrition damage, AND so a focus-fire policy
|
| 64 |
+
# must pick the arty SPECIFICALLY rather than relying on
|
| 65 |
+
# attack_move auto-target (which spreads damage across all
|
| 66 |
+
# visible threats).
|
| 67 |
+
# • arty = artillery (rng7c, dps45 splash, hp75, sight5c). Long
|
| 68 |
+
# range (outranges 2tnk's 4.75c by 2.25c) — a stationary arty
|
| 69 |
+
# fires on closing tanks BEFORE they can return fire, so the
|
| 70 |
+
# escort takes attrition while approaching. Anti-armour DPS is
|
| 71 |
+
# high enough to kill the harv in ~2 decision turns of standing
|
| 72 |
+
# exposure. 4 tanks concentrating fire can kill 1 arty in ~1
|
| 73 |
+
# decision turn (88 dps vs hp75).
|
| 74 |
+
# • 2tnk = medium tank (rng4.75c, dps22, hp 400). Four tanks vs
|
| 75 |
+
# an e1 wall: combined dps 88 onto hp200 cleans a 4-e1 wall in
|
| 76 |
+
# ~2-3 decision turns.
|
| 77 |
+
# • Distance / time budget: extraction at (100,20), staging at
|
| 78 |
+
# (8,20) ⇒ ~92 cells. A 2tnk moves ~5-6 cells per decision turn
|
| 79 |
+
# uncontested, ~3-4 cells per turn while engaging. Escort
|
| 80 |
+
# timeline: ~5 turns to first contact (wall at x=30), ~3 turns
|
| 81 |
+
# to clear wall 1, ~2 turns to arty contact (x=49), ~2 turns
|
| 82 |
+
# focus-fire arty, ~5 turns to wall 2 (x=70), ~3 turns to
|
| 83 |
+
# clear it, ~5 turns final push — ~25 turns ⇒ ~2300 ticks,
|
| 84 |
+
# comfortably under within_ticks 4500.
|
| 85 |
+
#
|
| 86 |
+
# Engine guardrails (per CLAUDE.md):
|
| 87 |
+
# • Map: rush-hour-arena (128 × 40 playable; x in [2..126], y in
|
| 88 |
+
# [2..38]). All actor / region cells are inside.
|
| 89 |
+
# • CLAUDE.md footgun: enemy clusters at (50,20) silently FAIL to
|
| 90 |
+
# place — every enemy y row avoids that exact cell.
|
| 91 |
+
# • A persistent far enemy `fact` at (120,20) keeps the episode
|
| 92 |
+
# alive past the route enemies being cleaned out (so the
|
| 93 |
+
# win/fail evaluator sees the terminal frame; no DRAW collapse).
|
| 94 |
+
# • after_ticks fail clauses are reachable within max_turns
|
| 95 |
+
# (tick ≤ 93 + 90·(max_turns-1)); within_ticks 4500 < 4503 at
|
| 96 |
+
# max_turns 50; staller hits after_ticks 4501 and LOSES, not
|
| 97 |
+
# draws.
|
| 98 |
+
# • unit_type_count_gte:{type:harv,n:1} as a NEGATED fail clause
|
| 99 |
+
# fires the instant the VIP dies (signals.units_summary loses
|
| 100 |
+
# the harv entry → count == 0 → not(...) == True). That is the
|
| 101 |
+
# "VIP died" loss condition, distinct from total force wipe.
|
| 102 |
+
# • Interrupts are LEFT DISABLED in this pack: enemy_unit_spotted
|
| 103 |
+
# fires on each route enemy and step_until_event advances only
|
| 104 |
+
# ~5 ticks per interrupt — with 10+ enemies strung along the
|
| 105 |
+
# route the per-turn tick rate collapses and even the intended
|
| 106 |
+
# escort cannot reach the extraction inside the 4500-tick
|
| 107 |
+
# budget. Without interrupts each turn advances ~90 ticks
|
| 108 |
+
# uniformly and the escort completes in ~25 turns. The model
|
| 109 |
+
# still sees the full per-turn state every decision; reactive
|
| 110 |
+
# micro is not the controlled capability of this pack — escort
|
| 111 |
+
# LEADERSHIP (tanks-before-VIP) is.
|
| 112 |
+
# • The engine ignores the `health` placement field (verified by
|
| 113 |
+
# direct comparison: health=10/50/100 all give the same initial
|
| 114 |
+
# hp_ratio 1.0 and identical decay under enemy fire). The VIP's
|
| 115 |
+
# fragility is enforced via ENEMY CHOICE (arty dps45 vs harv's
|
| 116 |
+
# hp600 = ~13s to kill = ~2 decision turns) rather than reduced
|
| 117 |
+
# max-HP.
|
| 118 |
+
# • Spawn-driven hard tier: enemy actors don't honour spawn_point
|
| 119 |
+
# (CLAUDE.md), so the route enemies always place identically.
|
| 120 |
+
# Variation comes from AGENT staging row (NORTH y=14 / SOUTH
|
| 121 |
+
# y=26). The route enemies' y-band is widened (y=13..27 in hard)
|
| 122 |
+
# so both staging rows face an equivalent contested corridor.
|
| 123 |
+
|
| 124 |
+
meta:
|
| 125 |
+
id: combat-protect-vip-escort
|
| 126 |
+
title: 'Protect the VIP — Escort a Fragile Unit Through the Hazard'
|
| 127 |
+
capability: action
|
| 128 |
+
real_world_meaning: >
|
| 129 |
+
A single fragile high-value unit (the VIP — an unarmed
|
| 130 |
+
harvester, the only one of its kind on the map) must reach
|
| 131 |
+
the east extraction point. Four medium tanks at the same west
|
| 132 |
+
staging zone are the protective detail. The VIP cannot fight
|
| 133 |
+
(no weapon) and dies under sustained artillery fire, so the
|
| 134 |
+
escort must move AHEAD, clear the rifle infantry and the
|
| 135 |
+
long-range artillery strung along the route, and only then
|
| 136 |
+
let the VIP cross. Sending the VIP alone or at the head of
|
| 137 |
+
the column gets it killed; the escort's offence preserves
|
| 138 |
+
the VIP.
|
| 139 |
+
robotics_analogue: >
|
| 140 |
+
Protected-asset transit / VIP detail / diplomatic convoy: a
|
| 141 |
+
high-value principal (unarmed, fragile) must traverse a
|
| 142 |
+
contested route. The protective detail moves in bounding
|
| 143 |
+
formation ahead and to the flanks, neutralising threats along
|
| 144 |
+
the route before the principal traverses. The principal does
|
| 145 |
+
not engage directly — its survival depends on the detail's
|
| 146 |
+
offence, not its own fight.
|
| 147 |
+
benchmark_anchor:
|
| 148 |
+
- "SC2 VIP-survival missions"
|
| 149 |
+
- "military VIP protection doctrine"
|
| 150 |
+
- "diplomatic security / convoy escort"
|
| 151 |
+
- "fragile-target preservation tactics"
|
| 152 |
+
author: "openra-bench"
|
| 153 |
+
|
| 154 |
+
base_map: rush-hour-arena
|
| 155 |
+
|
| 156 |
+
base:
|
| 157 |
+
agent: {faction: allies, cash: 0}
|
| 158 |
+
enemy: {faction: soviet, cash: 0, bot_type: ''}
|
| 159 |
+
tools: [move_units, attack_unit, attack_move, stop]
|
| 160 |
+
planning: true
|
| 161 |
+
# Interrupts DISABLED — enemy_unit_spotted on a route with 10+
|
| 162 |
+
# spread-out enemies collapses per-turn ticks (step_until_event
|
| 163 |
+
# advances only ~5 ticks per interrupt) and the deadline becomes
|
| 164 |
+
# unreachable even for the intended escort. The model still sees
|
| 165 |
+
# the full per-turn state on every decision boundary; reactive
|
| 166 |
+
# micro is not the controlled capability of this pack.
|
| 167 |
+
interrupts: {}
|
| 168 |
+
termination: {max_ticks: 6000}
|
| 169 |
+
actors: [] # every level supplies its own actor list via overrides.
|
| 170 |
+
|
| 171 |
+
levels:
|
| 172 |
+
# ── EASY ─────────────────────────────────────────────────────────
|
| 173 |
+
# Bare VIP-escort skill: ONE rifle wall along the route (~x=70,
|
| 174 |
+
# 4× e1 across y=17..23) AND one long-range artillery piece at
|
| 175 |
+
# the centre (~x=49, off the (50,20) footgun column). The arty
|
| 176 |
+
# is what makes "VIP alone" a real death sentence — without it,
|
| 177 |
+
# the harv's 600 hp and rifle's dps6 vs armour lets the VIP
|
| 178 |
+
# outrun rifle fire entirely. Charge plays may squeak by on
|
| 179 |
+
# easy because there is only one rifle wall after the arty —
|
| 180 |
+
# the cumulative DPS into the VIP under charge isn't enough to
|
| 181 |
+
# kill it before extraction (forgiving bare-skill tier).
|
| 182 |
+
easy:
|
| 183 |
+
description: >
|
| 184 |
+
A single ore harvester (the VIP — unique unit type, unarmed
|
| 185 |
+
and fragile under sustained heavy fire) is staged at (8,20)
|
| 186 |
+
with four medium tank escorts. The VIP must reach the EAST
|
| 187 |
+
extraction point (x ≈ 100, y ≈ 20, radius 6) before tick
|
| 188 |
+
4500, and MUST be alive on arrival (the harv is the only
|
| 189 |
+
one of its type; a destroyed harv fails the run). One rifle-
|
| 190 |
+
infantry wall (4× e1, vertically arrayed across y=17..23)
|
| 191 |
+
defends the route at x ≈ 70, and a single long-range
|
| 192 |
+
artillery piece (arty, range 7c, dps45) sits at the centre
|
| 193 |
+
(x ≈ 49). The escort tanks must move AHEAD of the VIP and
|
| 194 |
+
clear the artillery first; the unarmed VIP cannot return
|
| 195 |
+
fire and is shredded by the arty if it enters the envelope
|
| 196 |
+
unaccompanied.
|
| 197 |
+
overrides:
|
| 198 |
+
actors:
|
| 199 |
+
# VIP — the single unique harvester at the west staging
|
| 200 |
+
# zone. Unique-unit-type marker: the only harv on the map,
|
| 201 |
+
# so the "VIP alive" check is exactly
|
| 202 |
+
# `unit_type_count_gte:{harv,1}`. stance:0 (HoldFire) is
|
| 203 |
+
# the convoy-discipline posture (the harv is unarmed
|
| 204 |
+
# anyway, but the stance documents intent).
|
| 205 |
+
- {type: harv, owner: agent, position: [8, 20], stance: 0}
|
| 206 |
+
# Escorts — 4 medium tanks at the same west staging zone,
|
| 207 |
+
# in a 2-wide formation around the VIP so the agent can
|
| 208 |
+
# spawn them in a leading wedge. stance:1 (return fire) so
|
| 209 |
+
# the escorts retaliate when contacted but don't wander.
|
| 210 |
+
- {type: 2tnk, owner: agent, position: [8, 18], stance: 1}
|
| 211 |
+
- {type: 2tnk, owner: agent, position: [8, 19], stance: 1}
|
| 212 |
+
- {type: 2tnk, owner: agent, position: [8, 21], stance: 1}
|
| 213 |
+
- {type: 2tnk, owner: agent, position: [8, 22], stance: 1}
|
| 214 |
+
# Centre artillery — the load-bearing anti-VIP threat.
|
| 215 |
+
# arty rng 7c outranges 2tnk's 4.75c by 2.25c, so it gets
|
| 216 |
+
# opening shots while the escort closes. Off the (50,20)
|
| 217 |
+
# silently-failing column.
|
| 218 |
+
- {type: arty, owner: enemy, position: [49, 20], stance: 3}
|
| 219 |
+
# Rifle wall — extends the fire envelope and adds attrition
|
| 220 |
+
# damage to a charge play. 4× e1 across y=17..23 so the
|
| 221 |
+
# harv pathfinder cannot trivially detour around the wall.
|
| 222 |
+
- {type: e1, owner: enemy, position: [70, 17], stance: 3}
|
| 223 |
+
- {type: e1, owner: enemy, position: [70, 19], stance: 3}
|
| 224 |
+
- {type: e1, owner: enemy, position: [70, 21], stance: 3}
|
| 225 |
+
- {type: e1, owner: enemy, position: [70, 23], stance: 3}
|
| 226 |
+
# Persistent far enemy `fact` — keeps the episode alive
|
| 227 |
+
# past the route enemies being cleaned out so the win/fail
|
| 228 |
+
# evaluator sees the terminal frame (no DRAW collapse).
|
| 229 |
+
- {type: fact, owner: enemy, position: [120, 20]}
|
| 230 |
+
win_condition:
|
| 231 |
+
all_of:
|
| 232 |
+
- {units_of_type_in_region_gte: {type: harv, x: 100, y: 20, radius: 6, n: 1}}
|
| 233 |
+
- {unit_type_count_gte: {type: harv, n: 1}}
|
| 234 |
+
- {within_ticks: 4500}
|
| 235 |
+
fail_condition:
|
| 236 |
+
any_of:
|
| 237 |
+
- {after_ticks: 4501}
|
| 238 |
+
- {not: {unit_type_count_gte: {type: harv, n: 1}}}
|
| 239 |
+
max_turns: 50
|
| 240 |
+
|
| 241 |
+
# ── MEDIUM ───────────────────────────────────────────────────────
|
| 242 |
+
# +1 axis: a SECOND rifle wall (x=30) extends the fire envelope
|
| 243 |
+
# both before AND after the central artillery, so a single
|
| 244 |
+
# charging column accumulates more attrition while crossing.
|
| 245 |
+
# VIP-leads-charge LOSES on medium (the cumulative arty + 2-wall
|
| 246 |
+
# rifle fire onto the harv is enough to kill it before extraction)
|
| 247 |
+
# while easy's single-wall configuration may let charge squeak
|
| 248 |
+
# by. Stall / VIP-alone LOSE; intended escort WINS.
|
| 249 |
+
medium:
|
| 250 |
+
description: >
|
| 251 |
+
A single ore harvester (the VIP — unique unit type, unarmed
|
| 252 |
+
and fragile under sustained heavy fire) is staged at (8,20)
|
| 253 |
+
with four medium tank escorts. The VIP must reach the EAST
|
| 254 |
+
extraction point (x ≈ 100, y ≈ 20, radius 6) before tick
|
| 255 |
+
4500, and MUST be alive on arrival. TWO rifle-infantry walls
|
| 256 |
+
(4× e1 each, vertically arrayed across y=17..23) defend the
|
| 257 |
+
route at x ≈ 30 and x ≈ 70, AND a long-range artillery
|
| 258 |
+
piece (arty, range 7c, dps45) sits at the centre (x ≈ 49).
|
| 259 |
+
The escort tanks must lead — focus-fire the artillery first
|
| 260 |
+
(it is the anti-VIP threat), then clear each rifle wall —
|
| 261 |
+
because the unarmed VIP dies under the cumulative arty +
|
| 262 |
+
rifle fire if it enters either wall's envelope without
|
| 263 |
+
escort suppression.
|
| 264 |
+
overrides:
|
| 265 |
+
actors:
|
| 266 |
+
- {type: harv, owner: agent, position: [8, 20], stance: 0}
|
| 267 |
+
- {type: 2tnk, owner: agent, position: [8, 18], stance: 1}
|
| 268 |
+
- {type: 2tnk, owner: agent, position: [8, 19], stance: 1}
|
| 269 |
+
- {type: 2tnk, owner: agent, position: [8, 21], stance: 1}
|
| 270 |
+
- {type: 2tnk, owner: agent, position: [8, 22], stance: 1}
|
| 271 |
+
# Centre artillery — the load-bearing anti-VIP threat
|
| 272 |
+
# (same as easy).
|
| 273 |
+
- {type: arty, owner: enemy, position: [49, 20], stance: 3}
|
| 274 |
+
# Rifle wall 1 — x ≈ 30 (BEFORE the arty so the charge
|
| 275 |
+
# column takes attrition before reaching the arty kill
|
| 276 |
+
# zone).
|
| 277 |
+
- {type: e1, owner: enemy, position: [30, 17], stance: 3}
|
| 278 |
+
- {type: e1, owner: enemy, position: [30, 19], stance: 3}
|
| 279 |
+
- {type: e1, owner: enemy, position: [30, 21], stance: 3}
|
| 280 |
+
- {type: e1, owner: enemy, position: [30, 23], stance: 3}
|
| 281 |
+
# Rifle wall 2 — x ≈ 70 (AFTER the arty).
|
| 282 |
+
- {type: e1, owner: enemy, position: [70, 17], stance: 3}
|
| 283 |
+
- {type: e1, owner: enemy, position: [70, 19], stance: 3}
|
| 284 |
+
- {type: e1, owner: enemy, position: [70, 21], stance: 3}
|
| 285 |
+
- {type: e1, owner: enemy, position: [70, 23], stance: 3}
|
| 286 |
+
- {type: fact, owner: enemy, position: [120, 20]}
|
| 287 |
+
win_condition:
|
| 288 |
+
all_of:
|
| 289 |
+
- {units_of_type_in_region_gte: {type: harv, x: 100, y: 20, radius: 6, n: 1}}
|
| 290 |
+
- {unit_type_count_gte: {type: harv, n: 1}}
|
| 291 |
+
- {within_ticks: 4500}
|
| 292 |
+
fail_condition:
|
| 293 |
+
any_of:
|
| 294 |
+
- {after_ticks: 4501}
|
| 295 |
+
- {not: {unit_type_count_gte: {type: harv, n: 1}}}
|
| 296 |
+
max_turns: 50
|
| 297 |
+
|
| 298 |
+
# ── HARD ─────────────────────────────────────────────────────────
|
| 299 |
+
# +1 axis vs medium: TWO seed-driven spawn groups (NORTH staging
|
| 300 |
+
# y≈14 OR SOUTH staging y≈26 — round-robined per seed) AND a
|
| 301 |
+
# THIRD rifle wall (x=55) tightens the route lethality enough
|
| 302 |
+
# that VIP-leads-charge LOSES on BOTH spawn variants (the
|
| 303 |
+
# cumulative arty + 3-wall rifle fire is unsurvivable). The
|
| 304 |
+
# rifle walls are vertically widened to y=13..27 so BOTH spawns
|
| 305 |
+
# face an equivalent contested corridor — the harv from EITHER
|
| 306 |
+
# staging row must still traverse the artillery + 3-wall
|
| 307 |
+
# gauntlet. The artillery and extraction at (100,20) are on the
|
| 308 |
+
# symmetry axis, so both spawn variants face the same anti-VIP
|
| 309 |
+
# geometry but a single memorised staging-to-extraction vector
|
| 310 |
+
# cannot generalise across seeds.
|
| 311 |
+
hard:
|
| 312 |
+
description: >
|
| 313 |
+
A single ore harvester (the VIP — unique unit type, unarmed
|
| 314 |
+
and fragile under sustained heavy fire) is staged with four
|
| 315 |
+
medium tank escorts at the NORTH staging row (y ≈ 14) or
|
| 316 |
+
the SOUTH staging row (y ≈ 26), chosen by seed for anti-
|
| 317 |
+
memorisation. The VIP must reach the EAST extraction point
|
| 318 |
+
(x ≈ 100, y ≈ 20, radius 6) before tick 4500, alive. THREE
|
| 319 |
+
vertically-widened rifle-infantry walls (6× e1 each, across
|
| 320 |
+
y=13..27) defend the route at x ≈ 30, x ≈ 55, and x ≈ 75,
|
| 321 |
+
AND a long-range artillery piece (arty, range 7c, dps45)
|
| 322 |
+
sits at the centre (x ≈ 49) — both NORTH and SOUTH staging
|
| 323 |
+
rows face an equivalent contested corridor. The escort
|
| 324 |
+
tanks must lead the way; the unarmed VIP dies under
|
| 325 |
+
sustained arty + 3-wall rifle fire if it enters any wall's
|
| 326 |
+
envelope unaccompanied or at the head of the column.
|
| 327 |
+
overrides:
|
| 328 |
+
actors:
|
| 329 |
+
# spawn_point 0 — NORTH staging at y ≈ 14.
|
| 330 |
+
- {type: harv, owner: agent, position: [8, 14], stance: 0, spawn_point: 0}
|
| 331 |
+
- {type: 2tnk, owner: agent, position: [8, 12], stance: 1, spawn_point: 0}
|
| 332 |
+
- {type: 2tnk, owner: agent, position: [8, 13], stance: 1, spawn_point: 0}
|
| 333 |
+
- {type: 2tnk, owner: agent, position: [8, 15], stance: 1, spawn_point: 0}
|
| 334 |
+
- {type: 2tnk, owner: agent, position: [8, 16], stance: 1, spawn_point: 0}
|
| 335 |
+
# spawn_point 1 — SOUTH staging at y ≈ 26 (mirrored across
|
| 336 |
+
# y=20).
|
| 337 |
+
- {type: harv, owner: agent, position: [8, 26], stance: 0, spawn_point: 1}
|
| 338 |
+
- {type: 2tnk, owner: agent, position: [8, 24], stance: 1, spawn_point: 1}
|
| 339 |
+
- {type: 2tnk, owner: agent, position: [8, 25], stance: 1, spawn_point: 1}
|
| 340 |
+
- {type: 2tnk, owner: agent, position: [8, 27], stance: 1, spawn_point: 1}
|
| 341 |
+
- {type: 2tnk, owner: agent, position: [8, 28], stance: 1, spawn_point: 1}
|
| 342 |
+
# Centre artillery — same anti-VIP threat as medium, on
|
| 343 |
+
# the y=20 symmetry axis so both spawn variants face it.
|
| 344 |
+
- {type: arty, owner: enemy, position: [49, 20], stance: 3}
|
| 345 |
+
# Rifle wall 1 — x=30, vertically widened y=13..27 so both
|
| 346 |
+
# NORTH and SOUTH staging rows face an equivalent corridor.
|
| 347 |
+
- {type: e1, owner: enemy, position: [30, 13], stance: 3}
|
| 348 |
+
- {type: e1, owner: enemy, position: [30, 16], stance: 3}
|
| 349 |
+
- {type: e1, owner: enemy, position: [30, 18], stance: 3}
|
| 350 |
+
- {type: e1, owner: enemy, position: [30, 22], stance: 3}
|
| 351 |
+
- {type: e1, owner: enemy, position: [30, 24], stance: 3}
|
| 352 |
+
- {type: e1, owner: enemy, position: [30, 27], stance: 3}
|
| 353 |
+
# Rifle wall 2 — x=55, same vertical span (off the (50,20)
|
| 354 |
+
# footgun column).
|
| 355 |
+
- {type: e1, owner: enemy, position: [55, 13], stance: 3}
|
| 356 |
+
- {type: e1, owner: enemy, position: [55, 16], stance: 3}
|
| 357 |
+
- {type: e1, owner: enemy, position: [55, 18], stance: 3}
|
| 358 |
+
- {type: e1, owner: enemy, position: [55, 22], stance: 3}
|
| 359 |
+
- {type: e1, owner: enemy, position: [55, 24], stance: 3}
|
| 360 |
+
- {type: e1, owner: enemy, position: [55, 27], stance: 3}
|
| 361 |
+
# Rifle wall 3 — x=75, same vertical span.
|
| 362 |
+
- {type: e1, owner: enemy, position: [75, 13], stance: 3}
|
| 363 |
+
- {type: e1, owner: enemy, position: [75, 16], stance: 3}
|
| 364 |
+
- {type: e1, owner: enemy, position: [75, 18], stance: 3}
|
| 365 |
+
- {type: e1, owner: enemy, position: [75, 22], stance: 3}
|
| 366 |
+
- {type: e1, owner: enemy, position: [75, 24], stance: 3}
|
| 367 |
+
- {type: e1, owner: enemy, position: [75, 27], stance: 3}
|
| 368 |
+
# Persistent far enemy fact — engine auto-done mitigation.
|
| 369 |
+
- {type: fact, owner: enemy, position: [120, 20]}
|
| 370 |
+
win_condition:
|
| 371 |
+
all_of:
|
| 372 |
+
- {units_of_type_in_region_gte: {type: harv, x: 100, y: 20, radius: 6, n: 1}}
|
| 373 |
+
- {unit_type_count_gte: {type: harv, n: 1}}
|
| 374 |
+
- {within_ticks: 4500}
|
| 375 |
+
fail_condition:
|
| 376 |
+
any_of:
|
| 377 |
+
- {after_ticks: 4501}
|
| 378 |
+
- {not: {unit_type_count_gte: {type: harv, n: 1}}}
|
| 379 |
+
max_turns: 50
|
tests/test_combat_protect_vip_escort.py
ADDED
|
@@ -0,0 +1,407 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""combat-protect-vip-escort — escort a fragile VIP through hazards.
|
| 2 |
+
|
| 3 |
+
The bar (binding, applied seeds 1..4 on every level):
|
| 4 |
+
- intended escort-clears-path policy WINS;
|
| 5 |
+
- stall / VIP-alone / VIP-leads-charge LOSE on every level + every
|
| 6 |
+
hard seed (easy may be forgiving for some failure modes given the
|
| 7 |
+
sparser hazard — see per-test docstrings);
|
| 8 |
+
- non-win is a real reachable timeout / VIP-died LOSS (no DRAW
|
| 9 |
+
degeneracy: after_ticks 4501 ≤ 93 + 90·(max_turns − 1));
|
| 10 |
+
- hard ships ≥2 `spawn_point` groups (seed-driven start variation).
|
| 11 |
+
|
| 12 |
+
Validation is scripted (no model / network).
|
| 13 |
+
"""
|
| 14 |
+
from __future__ import annotations
|
| 15 |
+
|
| 16 |
+
from pathlib import Path
|
| 17 |
+
|
| 18 |
+
import pytest
|
| 19 |
+
|
| 20 |
+
pytest.importorskip("openra_rl_training", reason="Rust env wheel not installed")
|
| 21 |
+
from openra_bench.scenarios import load_pack
|
| 22 |
+
from openra_bench.scenarios.loader import compile_level
|
| 23 |
+
from openra_bench.scenarios.win_conditions import WinContext, evaluate
|
| 24 |
+
|
| 25 |
+
PACKS = Path(__file__).parent.parent / "openra_bench" / "scenarios" / "packs"
|
| 26 |
+
PACK_PATH = PACKS / "combat-protect-vip-escort.yaml"
|
| 27 |
+
|
| 28 |
+
|
| 29 |
+
# ── declarative / schema invariants (no engine needed) ─────────────
|
| 30 |
+
|
| 31 |
+
|
| 32 |
+
def test_pack_compiles_and_meta_fields_populated():
|
| 33 |
+
pack = load_pack(PACK_PATH)
|
| 34 |
+
assert pack.meta.id == "combat-protect-vip-escort"
|
| 35 |
+
assert pack.meta.capability == "action"
|
| 36 |
+
assert pack.meta.real_world_meaning, "real_world_meaning required"
|
| 37 |
+
assert pack.meta.robotics_analogue, "robotics_analogue required"
|
| 38 |
+
anchors = pack.meta.benchmark_anchor
|
| 39 |
+
assert isinstance(anchors, list) and len(anchors) == 4, (
|
| 40 |
+
f"benchmark_anchor must list all 4 anchors, got {anchors!r}"
|
| 41 |
+
)
|
| 42 |
+
joined = " ".join(anchors).lower()
|
| 43 |
+
# Spec-mandated anchors: SC2 VIP-survival, military VIP doctrine,
|
| 44 |
+
# diplomatic convoy escort, fragile-target preservation.
|
| 45 |
+
for needle in ("sc2 vip", "vip protection",
|
| 46 |
+
"diplomatic", "fragile-target"):
|
| 47 |
+
assert needle in joined, f"missing anchor keyword: {needle}"
|
| 48 |
+
for lvl in ("easy", "medium", "hard"):
|
| 49 |
+
c = compile_level(pack, lvl)
|
| 50 |
+
assert c.map_supported
|
| 51 |
+
assert c.win_condition is not None
|
| 52 |
+
assert c.fail_condition is not None
|
| 53 |
+
|
| 54 |
+
|
| 55 |
+
def _ctx(*, units=(), tick=1000, kills=0, lost=0):
|
| 56 |
+
"""Synthesize a WinContext for predicate-level checks."""
|
| 57 |
+
import types
|
| 58 |
+
|
| 59 |
+
sig = types.SimpleNamespace(
|
| 60 |
+
game_tick=tick,
|
| 61 |
+
units_killed=kills,
|
| 62 |
+
units_lost=lost,
|
| 63 |
+
cash=0,
|
| 64 |
+
resources=0,
|
| 65 |
+
own_buildings=[],
|
| 66 |
+
own_building_types=set(),
|
| 67 |
+
enemies_seen_ids=set(),
|
| 68 |
+
enemy_buildings_seen_ids=set(),
|
| 69 |
+
)
|
| 70 |
+
return WinContext(
|
| 71 |
+
signals=sig,
|
| 72 |
+
render_state={"units_summary": list(units)},
|
| 73 |
+
)
|
| 74 |
+
|
| 75 |
+
|
| 76 |
+
def _harv_at(x=100, y=20):
|
| 77 |
+
return {"cell_x": x, "cell_y": y, "type": "harv", "id": "9001"}
|
| 78 |
+
|
| 79 |
+
|
| 80 |
+
def _tank_at(i, x=100, y=20):
|
| 81 |
+
return {"cell_x": x, "cell_y": y, "type": "2tnk", "id": str(2000 + i)}
|
| 82 |
+
|
| 83 |
+
|
| 84 |
+
@pytest.mark.parametrize("lvl", ["easy", "medium", "hard"])
|
| 85 |
+
def test_win_requires_harv_in_east_region_and_alive(lvl):
|
| 86 |
+
c = compile_level(load_pack(PACK_PATH), lvl)
|
| 87 |
+
# VIP in region, alive, in time → WIN.
|
| 88 |
+
assert evaluate(
|
| 89 |
+
c.win_condition,
|
| 90 |
+
_ctx(units=[_harv_at(100, 20), _tank_at(0, 99, 20)], tick=2000),
|
| 91 |
+
)
|
| 92 |
+
# Tanks arrive but VIP didn't → not a WIN (VIP missing).
|
| 93 |
+
assert not evaluate(
|
| 94 |
+
c.win_condition,
|
| 95 |
+
_ctx(units=[_tank_at(0, 100, 20), _tank_at(1, 100, 21)], tick=2000),
|
| 96 |
+
)
|
| 97 |
+
# VIP elsewhere on the map → not a WIN (not in region).
|
| 98 |
+
assert not evaluate(
|
| 99 |
+
c.win_condition,
|
| 100 |
+
_ctx(units=[_harv_at(50, 20)], tick=2000),
|
| 101 |
+
)
|
| 102 |
+
# VIP in region but past the deadline → not a WIN.
|
| 103 |
+
assert not evaluate(
|
| 104 |
+
c.win_condition,
|
| 105 |
+
_ctx(units=[_harv_at(100, 20)], tick=4600),
|
| 106 |
+
)
|
| 107 |
+
|
| 108 |
+
|
| 109 |
+
@pytest.mark.parametrize("lvl", ["easy", "medium", "hard"])
|
| 110 |
+
def test_fail_clauses_are_reachable_losses(lvl):
|
| 111 |
+
c = compile_level(load_pack(PACK_PATH), lvl)
|
| 112 |
+
# Past deadline → fail (the VIP is still in the west).
|
| 113 |
+
assert evaluate(
|
| 114 |
+
c.fail_condition,
|
| 115 |
+
_ctx(units=[_harv_at(8, 20)], tick=4600),
|
| 116 |
+
)
|
| 117 |
+
# VIP killed (no harv left, even if escorts survive) → fail.
|
| 118 |
+
assert evaluate(
|
| 119 |
+
c.fail_condition,
|
| 120 |
+
_ctx(units=[_tank_at(0, 50, 20)], tick=2000),
|
| 121 |
+
)
|
| 122 |
+
# Force wipe (no harv among the wipe) → fail.
|
| 123 |
+
assert evaluate(c.fail_condition, _ctx(units=[], tick=2000))
|
| 124 |
+
|
| 125 |
+
|
| 126 |
+
def test_timeout_reachable_inside_max_turns():
|
| 127 |
+
"""No draw degeneracy: after_ticks 4501 ≤ 93 + 90·(max_turns-1)."""
|
| 128 |
+
pack = load_pack(PACK_PATH)
|
| 129 |
+
for lvl in ("easy", "medium", "hard"):
|
| 130 |
+
c = compile_level(pack, lvl)
|
| 131 |
+
max_tick = 93 + 90 * (c.max_turns - 1)
|
| 132 |
+
assert 4501 <= max_tick, (
|
| 133 |
+
f"{lvl}: after_ticks 4501 > max reachable tick {max_tick} "
|
| 134 |
+
f"(max_turns={c.max_turns}); deadline never bites"
|
| 135 |
+
)
|
| 136 |
+
assert 4500 <= max_tick, (
|
| 137 |
+
f"{lvl}: within_ticks 4500 > max reachable tick {max_tick}"
|
| 138 |
+
)
|
| 139 |
+
|
| 140 |
+
|
| 141 |
+
def test_hard_has_two_spawn_point_groups():
|
| 142 |
+
"""Hard curation: ≥2 distinct agent spawn_point groups so the seed
|
| 143 |
+
round-robins the staging row (NORTH y≈14 / SOUTH y≈26). Engine-
|
| 144 |
+
roundtrip asserted by tests/test_hard_tier.py."""
|
| 145 |
+
c = compile_level(load_pack(PACK_PATH), "hard")
|
| 146 |
+
groups = {
|
| 147 |
+
(a.spawn_point if a.spawn_point is not None else 0)
|
| 148 |
+
for a in c.scenario.actors
|
| 149 |
+
if a.owner == "agent"
|
| 150 |
+
}
|
| 151 |
+
assert len(groups) >= 2, f"hard needs ≥2 spawn_point groups, got {groups}"
|
| 152 |
+
|
| 153 |
+
|
| 154 |
+
def test_exactly_one_harv_per_spawn_group():
|
| 155 |
+
"""The VIP is a UNIQUE unit type — exactly one harv per spawn
|
| 156 |
+
group (so `unit_type_count_gte:{harv,1}` collapses to a single
|
| 157 |
+
"VIP alive" check; multiple harvs would let the win predicate
|
| 158 |
+
accept partial loss)."""
|
| 159 |
+
pack = load_pack(PACK_PATH)
|
| 160 |
+
for lvl in ("easy", "medium"):
|
| 161 |
+
c = compile_level(pack, lvl)
|
| 162 |
+
harvs = [
|
| 163 |
+
a for a in c.scenario.actors
|
| 164 |
+
if a.owner == "agent" and a.type == "harv"
|
| 165 |
+
]
|
| 166 |
+
assert len(harvs) == 1, f"{lvl}: must have exactly 1 harv VIP, got {len(harvs)}"
|
| 167 |
+
c = compile_level(pack, "hard")
|
| 168 |
+
per_group = {}
|
| 169 |
+
for a in c.scenario.actors:
|
| 170 |
+
if a.owner != "agent" or a.type != "harv":
|
| 171 |
+
continue
|
| 172 |
+
sp = a.spawn_point if a.spawn_point is not None else 0
|
| 173 |
+
per_group[sp] = per_group.get(sp, 0) + 1
|
| 174 |
+
assert all(n == 1 for n in per_group.values()), (
|
| 175 |
+
f"hard: each spawn group should have exactly 1 harv, got {per_group}"
|
| 176 |
+
)
|
| 177 |
+
|
| 178 |
+
|
| 179 |
+
def test_escort_is_four_medium_tanks_per_group():
|
| 180 |
+
"""The escort is 4× 2tnk per spawn group (sized so the escort can
|
| 181 |
+
actually clear the route's rifle walls in budget — fewer tanks
|
| 182 |
+
and the route doesn't clean in time)."""
|
| 183 |
+
pack = load_pack(PACK_PATH)
|
| 184 |
+
for lvl in ("easy", "medium"):
|
| 185 |
+
c = compile_level(pack, lvl)
|
| 186 |
+
tanks = [a for a in c.scenario.actors
|
| 187 |
+
if a.owner == "agent" and a.type == "2tnk"]
|
| 188 |
+
assert len(tanks) == 4, f"{lvl}: expected 4 escort tanks, got {len(tanks)}"
|
| 189 |
+
c = compile_level(pack, "hard")
|
| 190 |
+
per_group = {}
|
| 191 |
+
for a in c.scenario.actors:
|
| 192 |
+
if a.owner != "agent" or a.type != "2tnk":
|
| 193 |
+
continue
|
| 194 |
+
sp = a.spawn_point if a.spawn_point is not None else 0
|
| 195 |
+
per_group[sp] = per_group.get(sp, 0) + 1
|
| 196 |
+
assert all(n == 4 for n in per_group.values()), (
|
| 197 |
+
f"hard: each spawn group should have 4 escort tanks, got {per_group}"
|
| 198 |
+
)
|
| 199 |
+
|
| 200 |
+
|
| 201 |
+
def test_each_level_has_an_arty_anti_vip_threat():
|
| 202 |
+
"""The VIP's fragility is enforced via ENEMY CHOICE — specifically
|
| 203 |
+
an artillery piece (arty, dps45 splash) along the route. The
|
| 204 |
+
engine ignores the `health` placement field (verified: health=10
|
| 205 |
+
vs health=100 give the same initial hp_ratio 1.0 and identical
|
| 206 |
+
decay under enemy fire), so the harv's hp600 + armour withstands
|
| 207 |
+
a rifle-only gauntlet. Without an arty the discrimination
|
| 208 |
+
collapses (VIP-alone outruns rifle fire entirely)."""
|
| 209 |
+
pack = load_pack(PACK_PATH)
|
| 210 |
+
for lvl in ("easy", "medium", "hard"):
|
| 211 |
+
c = compile_level(pack, lvl)
|
| 212 |
+
artys = [
|
| 213 |
+
a for a in c.scenario.actors
|
| 214 |
+
if a.owner == "enemy" and a.type == "arty"
|
| 215 |
+
]
|
| 216 |
+
assert len(artys) >= 1, (
|
| 217 |
+
f"{lvl}: must have ≥1 arty (anti-VIP threat); got {len(artys)}"
|
| 218 |
+
)
|
| 219 |
+
|
| 220 |
+
|
| 221 |
+
def test_interrupts_disabled_so_time_budget_is_real():
|
| 222 |
+
"""enemy_unit_spotted via step_until_event advances only ~5 ticks
|
| 223 |
+
per interrupt, so on a route with 10+ enemies the per-turn tick
|
| 224 |
+
rate collapses and even the intended escort cannot reach the
|
| 225 |
+
extraction inside the 4500-tick budget. With interrupts disabled,
|
| 226 |
+
each turn advances ~90 ticks uniformly and the budget is real."""
|
| 227 |
+
pack = load_pack(PACK_PATH)
|
| 228 |
+
for lvl in ("easy", "medium", "hard"):
|
| 229 |
+
c = compile_level(pack, lvl)
|
| 230 |
+
ints = c.scenario.interrupts or {}
|
| 231 |
+
enabled = [k for k, v in ints.items() if v]
|
| 232 |
+
assert not enabled, (
|
| 233 |
+
f"{lvl}: interrupts must be disabled (the route has 10+ "
|
| 234 |
+
f"enemies; step_until_event would collapse the tick "
|
| 235 |
+
f"budget); got enabled={enabled}"
|
| 236 |
+
)
|
| 237 |
+
|
| 238 |
+
|
| 239 |
+
# ── engine-driven scripted policies ────────────────────────────────
|
| 240 |
+
|
| 241 |
+
|
| 242 |
+
def _own_units(rs):
|
| 243 |
+
return rs.get("units_summary", []) or []
|
| 244 |
+
|
| 245 |
+
|
| 246 |
+
def _harv(rs):
|
| 247 |
+
for u in _own_units(rs):
|
| 248 |
+
if str(u.get("type", "")).lower() == "harv":
|
| 249 |
+
return u
|
| 250 |
+
return None
|
| 251 |
+
|
| 252 |
+
|
| 253 |
+
def _escorts(rs):
|
| 254 |
+
return [u for u in _own_units(rs)
|
| 255 |
+
if str(u.get("type", "")).lower() == "2tnk"]
|
| 256 |
+
|
| 257 |
+
|
| 258 |
+
def _stall(rs, Command):
|
| 259 |
+
"""Pure observe — no unit moves; VIP never reaches the
|
| 260 |
+
extraction → after_ticks LOSS."""
|
| 261 |
+
return [Command.observe()]
|
| 262 |
+
|
| 263 |
+
|
| 264 |
+
def _vip_alone(rs, Command):
|
| 265 |
+
"""Only the harvester is moved east; escorts stay idle in the
|
| 266 |
+
west. The unarmed VIP enters the first rifle wall
|
| 267 |
+
unaccompanied → dies → `not unit_type_count_gte:{harv,1}` fail
|
| 268 |
+
clause fires → LOSS."""
|
| 269 |
+
units = _own_units(rs)
|
| 270 |
+
if not units:
|
| 271 |
+
return [Command.observe()]
|
| 272 |
+
cmds = []
|
| 273 |
+
for u in units:
|
| 274 |
+
if str(u.get("type", "")).lower() == "harv":
|
| 275 |
+
cmds.append(Command.move_units([str(u["id"])], 100, 20))
|
| 276 |
+
else:
|
| 277 |
+
cmds.append(Command.stop([str(u["id"])]))
|
| 278 |
+
return cmds
|
| 279 |
+
|
| 280 |
+
|
| 281 |
+
def _vip_leads_charge(rs, Command):
|
| 282 |
+
"""Every unit (harv + tanks) charges east on the same y-band
|
| 283 |
+
with the harv at the front. The first rifle wall auto-targets
|
| 284 |
+
the NEAREST visible hostile — the harvester — and kills it
|
| 285 |
+
before the tanks finish suppressing the e1s. → LOSS."""
|
| 286 |
+
units = _own_units(rs)
|
| 287 |
+
if not units:
|
| 288 |
+
return [Command.observe()]
|
| 289 |
+
cmds = []
|
| 290 |
+
for u in units:
|
| 291 |
+
cmds.append(Command.move_units([str(u["id"])], 100, 20))
|
| 292 |
+
return cmds
|
| 293 |
+
|
| 294 |
+
|
| 295 |
+
def _intended_escort_clears_path(rs, Command):
|
| 296 |
+
"""Escort-clears-path:
|
| 297 |
+
|
| 298 |
+
1. Tanks attack_move east AHEAD of the VIP, sweeping each
|
| 299 |
+
rifle wall (~x=35, ~x=55, ~x=75) until cleared. attack_move
|
| 300 |
+
auto-fires on the nearest hostile in range.
|
| 301 |
+
2. The VIP holds ~10 cells behind the rear-most tank so the
|
| 302 |
+
rifle walls auto-target the closer high-armour tanks.
|
| 303 |
+
3. Once the rear-most tank is at the extraction (x≥92), the
|
| 304 |
+
VIP completes the cross to (100,20).
|
| 305 |
+
|
| 306 |
+
The VIP target_x is clamped to never push it past the rear-most
|
| 307 |
+
tank — if the tanks haven't cleared the next wall, the VIP
|
| 308 |
+
waits.
|
| 309 |
+
"""
|
| 310 |
+
units = _own_units(rs)
|
| 311 |
+
if not units:
|
| 312 |
+
return [Command.observe()]
|
| 313 |
+
h = _harv(rs)
|
| 314 |
+
tanks = _escorts(rs)
|
| 315 |
+
cmds = []
|
| 316 |
+
|
| 317 |
+
# Escort tanks: attack_move east to the extraction, engaging
|
| 318 |
+
# anything in the way.
|
| 319 |
+
for t in tanks:
|
| 320 |
+
cmds.append(Command.attack_move([str(t["id"])], 100, 20))
|
| 321 |
+
|
| 322 |
+
if h is not None:
|
| 323 |
+
if tanks:
|
| 324 |
+
# The harv hangs 10 cells behind the rear-most tank.
|
| 325 |
+
rear_x = min(t["cell_x"] for t in tanks)
|
| 326 |
+
tx = min(rear_x - 10, 100)
|
| 327 |
+
# Never drive the harv backwards past its own start.
|
| 328 |
+
tx = max(tx, h["cell_x"])
|
| 329 |
+
# If tanks are already at the extraction (rear_x≥92), VIP
|
| 330 |
+
# finishes the cross.
|
| 331 |
+
if rear_x >= 92:
|
| 332 |
+
tx = 100
|
| 333 |
+
cmds.append(Command.move_units([str(h["id"])], int(tx), 20))
|
| 334 |
+
else:
|
| 335 |
+
# No escorts left — just sprint (best-effort).
|
| 336 |
+
cmds.append(Command.move_units([str(h["id"])], 100, 20))
|
| 337 |
+
return cmds
|
| 338 |
+
|
| 339 |
+
|
| 340 |
+
@pytest.mark.parametrize("level", ["easy", "medium", "hard"])
|
| 341 |
+
@pytest.mark.parametrize("seed", [1, 2, 3, 4])
|
| 342 |
+
def test_intended_escort_clears_path_wins(level, seed):
|
| 343 |
+
pytest.importorskip("openra_train")
|
| 344 |
+
from openra_bench.eval_core import run_level
|
| 345 |
+
|
| 346 |
+
c = compile_level(load_pack(PACK_PATH), level)
|
| 347 |
+
r = run_level(c, _intended_escort_clears_path, seed=seed)
|
| 348 |
+
assert r.outcome == "win", (
|
| 349 |
+
f"{level} seed={seed}: escort-clears-path should WIN, got "
|
| 350 |
+
f"{r.outcome} after {r.turns} turns "
|
| 351 |
+
f"(losses={r.signals.units_lost})"
|
| 352 |
+
)
|
| 353 |
+
|
| 354 |
+
|
| 355 |
+
@pytest.mark.parametrize("level", ["easy", "medium", "hard"])
|
| 356 |
+
@pytest.mark.parametrize("seed", [1, 2, 3, 4])
|
| 357 |
+
def test_stall_loses(level, seed):
|
| 358 |
+
pytest.importorskip("openra_train")
|
| 359 |
+
from openra_bench.eval_core import run_level
|
| 360 |
+
|
| 361 |
+
c = compile_level(load_pack(PACK_PATH), level)
|
| 362 |
+
r = run_level(c, _stall, seed=seed)
|
| 363 |
+
assert r.outcome == "loss", (
|
| 364 |
+
f"{level} seed={seed}: stall must be a real timeout LOSS "
|
| 365 |
+
f"(VIP never reaches extraction → after_ticks fires), "
|
| 366 |
+
f"got {r.outcome}"
|
| 367 |
+
)
|
| 368 |
+
|
| 369 |
+
|
| 370 |
+
@pytest.mark.parametrize("level", ["easy", "medium", "hard"])
|
| 371 |
+
@pytest.mark.parametrize("seed", [1, 2, 3, 4])
|
| 372 |
+
def test_vip_alone_loses(level, seed):
|
| 373 |
+
"""VIP sent alone enters the first rifle wall unaccompanied;
|
| 374 |
+
unarmed harv can't return fire and the rifle squad burns it
|
| 375 |
+
down (health:25 → ~150 effective hp dies in 2-3 turns of
|
| 376 |
+
standing exposure) → `not unit_type_count_gte:{harv,1}` fail
|
| 377 |
+
clause fires → LOSS."""
|
| 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, _vip_alone, seed=seed)
|
| 383 |
+
assert r.outcome == "loss", (
|
| 384 |
+
f"{level} seed={seed}: VIP-alone must LOSE (unarmed harv "
|
| 385 |
+
f"dies in the first wall), got {r.outcome} "
|
| 386 |
+
f"(losses={r.signals.units_lost})"
|
| 387 |
+
)
|
| 388 |
+
|
| 389 |
+
|
| 390 |
+
@pytest.mark.parametrize("level", ["medium", "hard"])
|
| 391 |
+
@pytest.mark.parametrize("seed", [1, 2, 3, 4])
|
| 392 |
+
def test_vip_leads_charge_loses(level, seed):
|
| 393 |
+
"""Harv + tanks charge in a single column with harv at the
|
| 394 |
+
front; rifle wall auto-targets the closer harv → VIP dies →
|
| 395 |
+
LOSS. Easy excluded (sparser hazard may let the tanks suppress
|
| 396 |
+
fast enough to keep the harv alive; SCENARIO_REVIEW_CHECKLIST.md
|
| 397 |
+
accepts inert anti-cheat teeth on easy for the bare-skill tier)."""
|
| 398 |
+
pytest.importorskip("openra_train")
|
| 399 |
+
from openra_bench.eval_core import run_level
|
| 400 |
+
|
| 401 |
+
c = compile_level(load_pack(PACK_PATH), level)
|
| 402 |
+
r = run_level(c, _vip_leads_charge, seed=seed)
|
| 403 |
+
assert r.outcome == "loss", (
|
| 404 |
+
f"{level} seed={seed}: VIP-leads-charge must LOSE (harv at "
|
| 405 |
+
f"the front of the column is the nearest target), got "
|
| 406 |
+
f"{r.outcome} (losses={r.signals.units_lost})"
|
| 407 |
+
)
|
tests/test_hard_tier.py
CHANGED
|
@@ -397,6 +397,10 @@ UPGRADED = [
|
|
| 397 |
# is symmetric across y=20 so both spawns face the same pincer
|
| 398 |
# decision.
|
| 399 |
"combat-pincer-coordination",
|
|
|
|
|
|
|
|
|
|
|
|
|
| 400 |
# Wave-6 Group D combat-micro pack: SC2 flank micro / military
|
| 401 |
# flank-maneuver doctrine / force-multiplier-through-angle-of-
|
| 402 |
# attack anchor. Hard tier defines two agent spawn_point groups
|
|
|
|
| 397 |
# is symmetric across y=20 so both spawns face the same pincer
|
| 398 |
# decision.
|
| 399 |
"combat-pincer-coordination",
|
| 400 |
+
# Wave-6 ACTION cell — escort fragile VIP (harv) through cumulative
|
| 401 |
+
# arty walls; hard tier defines 2 agent spawn_point groups (NORTH y=14
|
| 402 |
+
# / SOUTH y=26) round-robined by seed.
|
| 403 |
+
"combat-protect-vip-escort",
|
| 404 |
# Wave-6 Group D combat-micro pack: SC2 flank micro / military
|
| 405 |
# flank-maneuver doctrine / force-multiplier-through-angle-of-
|
| 406 |
# attack anchor. Hard tier defines two agent spawn_point groups
|