Spaces:
Running
feat(scenario): combat-tank-vs-tank-engagement — focus-fire mirror (Lanchester / SC2 anchor)
Browse filesWave-7 Group D combat-micro pack (capability: action). 3-vs-3 medium
tank engagement with ASYMMETRIC geometry: 3 agent tanks bunched at
(30,19..21) facing 3 enemy tanks spread across three latitudes at
(50,15)/(51,20)/(50,25). Centre enemy at x=51 (not x=50) per the
CLAUDE.md silent-fail cell note for (50,20).
Discrimination (verified scripted on all 4 hard seeds):
- stall (only observe): tanks never close to firing range; kill
bar unmet; after_ticks LOSS on every level/seed.
- brute attack_move (drive into the centre): force-wipes on
easy/medium (3 tanks lost), busts within_ticks on hard; LOSS
on every level/seed.
- spread-attack-closest (each tank attacks its OWN nearest enemy):
once centre dies, surviving tanks chase flank enemies in 1-vs-1
duels and Lanchester linear collapses the trade; ends 1-of-3
alive => LOSS on medium (load-bearing: busts own_units_gte:2).
Permitted to squeak on easy (own_units_gte:1, inert-easy-teeth
convention). Collapses to focus on hard (flank spawn => unique
closest enemy from a flank perspective).
- intended focus-fire (ALL 3 tanks attack_unit the same target
each turn, closest first): 3-vs-1 concentrated cannon fire ends
each enemy in 1-2 decision turns; all 3 agent tanks survive;
WIN on every level/seed.
Tiers:
- easy: own_units_gte:1, within_ticks 2400 (forgiving bare-skill
tier per SCENARIO_REVIEW_CHECKLIST inert-easy-teeth convention).
- medium: own_units_gte:2 (LOAD-BEARING — discriminates spread vs
focus via the survival cap), within_ticks 2400.
- hard: own_units_gte:1 RELAXED + within_ticks 1200 (tight kill-
speed pressure). Two seed-driven spawn_point groups (NORTH
y=11..13 / SOUTH y=27..29) round-robined by seed for anti-
memorisation; enemy line always places (CLAUDE.md: enemy actors
don't honour spawn_point). Registered in tests/test_hard_tier.py
UPGRADED.
Engine guardrails: agent fact at (4,20) (turn-1 own_units_gte mis-
fire footgun + engine auto-done mitigation on force-wipe); far enemy
fact at (124,20) (second auto-done mitigation). after_ticks 2401
<= 93+90*29 = 2703; after_ticks 1201 <= 93+90*14 = 1353. Enemy
bot_type empty (no scripted pursuit); stance:3 on enemies for auto-
fire on cannon-range entry; stance:1 on agents so a stall policy
doesn't pull stray shots.
|
@@ -0,0 +1,357 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# combat-tank-vs-tank-engagement — Mirror tank trade: WIN by focus-fire
|
| 2 |
+
# (concentrate ALL 3 agent tanks on ONE enemy at a time), LOSE by
|
| 3 |
+
# spread-fire (each tank picks its own nearest enemy). Lanchester
|
| 4 |
+
# square law on a 3-vs-3 medium-tank engagement.
|
| 5 |
+
#
|
| 6 |
+
# Wave-7 ACTION pack (capability: action — combat micro: target
|
| 7 |
+
# prioritization / focus-fire discipline).
|
| 8 |
+
#
|
| 9 |
+
# Real-world / benchmark anchors:
|
| 10 |
+
# - SC2 mirror micro (siege-tank-vs-siege-tank, marine-vs-marine):
|
| 11 |
+
# the side that concentrates fire on one target at a time wins the
|
| 12 |
+
# trade; the side that spreads damage across the whole enemy line
|
| 13 |
+
# trades 1-for-1 and loses the survivor count.
|
| 14 |
+
# - Lanchester's SQUARE LAW of attrition: combat power of a focused
|
| 15 |
+
# force scales as N² (per-kill removes one enemy's OUTPUT DPS for
|
| 16 |
+
# the rest of the fight); spreading fire collapses to the LINEAR
|
| 17 |
+
# law (mutual 1-for-1 annihilation).
|
| 18 |
+
# - Military "CONCENTRATION OF FORCE" doctrine (one of the Principles
|
| 19 |
+
# of War): a smaller or equal force concentrated at the decisive
|
| 20 |
+
# point can defeat a numerically equivalent dispersed enemy.
|
| 21 |
+
#
|
| 22 |
+
# Design — ASYMMETRIC GEOMETRY (engineered to discriminate):
|
| 23 |
+
# Prior MIRROR geometry (3 agents at x=30 stacked y=18..22 vs 3
|
| 24 |
+
# enemies at x=38 stacked y=18..22) was found engine-fragile: when
|
| 25 |
+
# both sides start fully in cannon range with identical rows, agent
|
| 26 |
+
# stance:1 ReturnFire causes the auto-target to collapse onto
|
| 27 |
+
# whichever single enemy fired first, so the spread-fire wrong-play
|
| 28 |
+
# accidentally focus-fires and the discrimination disappears. THIS
|
| 29 |
+
# pack uses ASYMMETRIC geometry:
|
| 30 |
+
# - Agent strike force: 3 medium tanks BUNCHED at (30,19..21) on
|
| 31 |
+
# a single column (one centroid, one engagement axis).
|
| 32 |
+
# - Enemy mirror: 3 medium tanks SPREAD across three latitudes
|
| 33 |
+
# at (50,15), (51,20), (50,25) — three distinct rows along the
|
| 34 |
+
# eastern engagement line. (Centre enemy at x=51 NOT (50,20)
|
| 35 |
+
# per the CLAUDE.md-documented silent-placement-fail at (50,20).)
|
| 36 |
+
# Each enemy is initially OUT of agent cannon range (cannon ≈ 5,
|
| 37 |
+
# agent at x=30 vs enemy at x=50/51 ⇒ MD ≥ 20), so tanks must close
|
| 38 |
+
# into engagement — at which point the asymmetry bites:
|
| 39 |
+
# * SPREAD policy (each agent tank attack_units its OWN nearest
|
| 40 |
+
# enemy): tank at (30,19) sees (50,15) at MD=24, (51,20) at
|
| 41 |
+
# MD=22, (50,25) at MD=26 ⇒ targets (51,20); tank at (30,21)
|
| 42 |
+
# targets (51,20); tank at (30,20) targets (51,20) — actually
|
| 43 |
+
# all 3 tanks target the centre INITIALLY, but as they advance
|
| 44 |
+
# and the centre dies, the surviving tanks fan out to chase
|
| 45 |
+
# the flank enemies one each ⇒ once the spread-fire chase
|
| 46 |
+
# begins, the trade collapses to 1-vs-1 duels and Lanchester
|
| 47 |
+
# linear ⇒ 2 of the 3 agent tanks die in the flank engagements.
|
| 48 |
+
# * FOCUS policy (ALL 3 tanks attack_unit the SAME target in
|
| 49 |
+
# sequence — closest first, then a flank, then the last):
|
| 50 |
+
# 3-vs-1 concentrated cannon fire ends each enemy in ~1-2
|
| 51 |
+
# decision turns; after kill #1 the trade is 3-vs-2 (Lanchester
|
| 52 |
+
# surplus 3²−2² = 5), after kill #2 it is 3-vs-1; all 3 agent
|
| 53 |
+
# tanks survive.
|
| 54 |
+
#
|
| 55 |
+
# Discrimination bar (four scripted-policy proxies — see test file):
|
| 56 |
+
# • stall (only observe): tanks idle at (30,*), enemies at (50,*)
|
| 57 |
+
# stay out of cannon range. Nothing dies on either side ⇒ kill
|
| 58 |
+
# bar unmet ⇒ after_ticks LOSS at tick 2401.
|
| 59 |
+
# • brute attack_move(51,20) (drive into the centre): tanks
|
| 60 |
+
# auto-target the nearest visible enemy en route (per CLAUDE.md
|
| 61 |
+
# "move_units auto-fires opportunistically en route regardless of
|
| 62 |
+
# agent stance"); they reach the engagement zone and trade with
|
| 63 |
+
# all 3 enemies firing back simultaneously ⇒ ≥2 agent tanks die ⇒
|
| 64 |
+
# LOSS via the survival cap (own_units_gte:2 on medium, force-wipe
|
| 65 |
+
# trips own_units_gte:1 on easy).
|
| 66 |
+
# • spread-attack-closest (each tank attack_units its own closest
|
| 67 |
+
# enemy): as above — once the centre dies, surviving tanks chase
|
| 68 |
+
# flank enemies on 1-vs-1 duels; Lanchester linear ⇒ 2 of 3 tanks
|
| 69 |
+
# die. On EASY (own_units_gte:1) the 1 survivor squeaks through
|
| 70 |
+
# and SPREAD wins (forgiving bare-skill tier, per the
|
| 71 |
+
# SCENARIO_REVIEW_CHECKLIST inert-easy-teeth convention). On
|
| 72 |
+
# MEDIUM (own_units_gte:2) the 1 survivor is below the bar ⇒
|
| 73 |
+
# LOSS — this is the load-bearing discrimination.
|
| 74 |
+
# • intended focus-fire (ALL 3 tanks attack_unit the SAME target
|
| 75 |
+
# each turn, starting with the closest enemy by agent centroid,
|
| 76 |
+
# then re-targeting the next-closest as enemies fall): all 3
|
| 77 |
+
# enemies die in ~700-900 ticks, all 3 agent tanks alive at the
|
| 78 |
+
# end ⇒ WIN on every level.
|
| 79 |
+
#
|
| 80 |
+
# Win-bar relaxation note (RELAXED per the task brief): on HARD the
|
| 81 |
+
# survival cap holds at own_units_gte:2 nominally, but the asymmetric
|
| 82 |
+
# discrimination weakens when the agent stack starts on a FLANK
|
| 83 |
+
# latitude (NORTH y=11..13 or SOUTH y=27..29) — from a flank the
|
| 84 |
+
# enemy line at y=15/20/25 has a unique closest enemy that all agent
|
| 85 |
+
# tanks naturally target (spread ≡ focus). Hard's discrimination is
|
| 86 |
+
# therefore primarily KILL-SPEED (within_ticks 1200) + brute / stall
|
| 87 |
+
# anti-cheat teeth + spawn-variation generalisation across NORTH and
|
| 88 |
+
# SOUTH approach axes — the focus-fire skill is what generalises;
|
| 89 |
+
# spread-as-focus on a flank is acceptable because it IS the
|
| 90 |
+
# intended capability when the geometry collapses to a unique
|
| 91 |
+
# closest target.
|
| 92 |
+
#
|
| 93 |
+
# Hard-tier spawn-variation (≥2 spawn_point groups, registered in
|
| 94 |
+
# tests/test_hard_tier.py::UPGRADED):
|
| 95 |
+
# - NORTH staging y=11..13 (agent at (30,11..13)).
|
| 96 |
+
# - SOUTH staging y=27..29 (agent at (30,27..29)).
|
| 97 |
+
# The asymmetric enemy line (3 enemies at y=15/y=20/y=25) is the
|
| 98 |
+
# SAME for both spawns (enemy actors don't honour spawn_point per
|
| 99 |
+
# CLAUDE.md / oramap.rs::expand_scenario_actors). From NORTH the
|
| 100 |
+
# closest enemy is (50,15) and the farthest is (50,25); from SOUTH
|
| 101 |
+
# the order inverts. A memorised single-target sequence cannot
|
| 102 |
+
# generalise across the spawn rotation.
|
| 103 |
+
#
|
| 104 |
+
# Engine guardrails (per CLAUDE.md):
|
| 105 |
+
# - Map: rush-hour-arena (128 × 40, playable x ∈ [2..126],
|
| 106 |
+
# y ∈ [2..38]). All actor coords inside bounds.
|
| 107 |
+
# - Agent owns a `fact` at (4,20): provides has_building:fact for
|
| 108 |
+
# the win predicate AND prevents the engine auto-`done` on
|
| 109 |
+
# enemy-units-wipe collapsing the run to DRAW before the win
|
| 110 |
+
# evaluator sees the terminal frame.
|
| 111 |
+
# - Far persistent enemy `fact` at (124,20) — out of reach in the
|
| 112 |
+
# declared tick budget; second engine auto-`done` mitigation
|
| 113 |
+
# (a MustBeDestroyed building stays alive on the enemy side so
|
| 114 |
+
# the episode persists to the win/fail evaluator).
|
| 115 |
+
# - Enemy centre tank is at (51,20) — NOT (50,20) — per CLAUDE.md
|
| 116 |
+
# "Certain mid-map cells silently fail to place enemy clusters
|
| 117 |
+
# (e.g. (50,20))"; (51,20) is a documented working cell.
|
| 118 |
+
# - `within_ticks: 2400` / `after_ticks: 2401` on easy+medium;
|
| 119 |
+
# max_turns=30 produces tick ≤ 93 + 90·29 = 2703 ⇒ stallers /
|
| 120 |
+
# brute / spread hit the real LOSS, not a DRAW. Hard uses
|
| 121 |
+
# `within_ticks: 1200` / `after_ticks: 1201` and max_turns=15
|
| 122 |
+
# (tick ≤ 93 + 90·14 = 1353 ≥ 1201) — kill-speed pressure for
|
| 123 |
+
# the focus-fire policy.
|
| 124 |
+
# - Enemy `bot_type: ''` (no scripted bot pursuit) — enemy tanks
|
| 125 |
+
# sit on stance:3 AttackAnything so they auto-fire the second a
|
| 126 |
+
# tank enters cannon range; the test is purely the agent's target
|
| 127 |
+
# prioritization (no bot lunge contaminating the geometry).
|
| 128 |
+
# - Agent tanks stance:1 ReturnFire so a stall policy (pure observe,
|
| 129 |
+
# no movement) doesn't accidentally pull fire from any agent tank
|
| 130 |
+
# before the enemy is in range — the stall remains a clean
|
| 131 |
+
# timeout LOSS.
|
| 132 |
+
|
| 133 |
+
meta:
|
| 134 |
+
id: combat-tank-vs-tank-engagement
|
| 135 |
+
title: 'Tank-vs-Tank Mirror — Focus-Fire, Lanchester Square Law'
|
| 136 |
+
capability: action
|
| 137 |
+
real_world_meaning: >
|
| 138 |
+
Three medium tanks face three enemy medium tanks at long range in
|
| 139 |
+
an ASYMMETRIC mirror engagement: the agent strike force is bunched
|
| 140 |
+
on one column at (30,19..21); the enemy mirror is spread across
|
| 141 |
+
three latitudes at (50,15), (51,20), (50,25). Per Lanchester's
|
| 142 |
+
SQUARE LAW, the side that concentrates fire on ONE enemy at a
|
| 143 |
+
time wins the trade with minimal losses (3-vs-1 cannon fire ends
|
| 144 |
+
each enemy tank in 1-2 decision turns; combat-power surplus grows
|
| 145 |
+
quadratically after each kill); the side that lets each tank pick
|
| 146 |
+
its own closest target — the spread-fire failure mode — collapses
|
| 147 |
+
to the linear attrition law, ends 1-of-3 alive, and busts the
|
| 148 |
+
survival bar. The decision under test is target prioritization:
|
| 149 |
+
concentrate ALL three tanks' fire on the closest enemy first,
|
| 150 |
+
eliminate it, then the next, then the last — not let each tank
|
| 151 |
+
pick its own nearest target. Stalling loses on the kill bar;
|
| 152 |
+
brute attack-move loses on the survival cap; spread-fire loses on
|
| 153 |
+
the survival cap (medium); only concentrated focus-fire wins.
|
| 154 |
+
robotics_analogue: >
|
| 155 |
+
Military "concentration of force" doctrine (one of the Principles
|
| 156 |
+
of War): a smaller or equal force concentrated at the decisive
|
| 157 |
+
point can defeat a numerically equivalent dispersed enemy. The
|
| 158 |
+
per-kill removal of enemy OUTPUT DPS is the closed-form Lanchester
|
| 159 |
+
square-law advantage of concentrated fire; the test mirrors the
|
| 160 |
+
SC2 mirror-tank micro / marine-vs-marine engagement where the
|
| 161 |
+
side that focus-fires one target at a time wins the trade against
|
| 162 |
+
a numerically equal foe spreading fire across the whole line.
|
| 163 |
+
benchmark_anchor:
|
| 164 |
+
- "SC2 mirror micro"
|
| 165 |
+
- "Lanchester square law"
|
| 166 |
+
- "concentration of force"
|
| 167 |
+
author: openra-bench
|
| 168 |
+
|
| 169 |
+
base_map: rush-hour-arena
|
| 170 |
+
starting_cash: 0
|
| 171 |
+
|
| 172 |
+
base:
|
| 173 |
+
agent: {faction: allies, cash: 0}
|
| 174 |
+
enemy: {faction: soviet, cash: 0, bot_type: ''}
|
| 175 |
+
tools: [move_units, attack_unit, attack_move, stop]
|
| 176 |
+
planning: true
|
| 177 |
+
# React the instant contact happens or a tank is lost — combat micro
|
| 178 |
+
# is reactive; without these interrupts the focus-fire policy only
|
| 179 |
+
# re-targets on the next turn boundary and the trade leaks DPS in
|
| 180 |
+
# the gap between turns.
|
| 181 |
+
interrupts:
|
| 182 |
+
enemy_unit_spotted: true
|
| 183 |
+
own_unit_destroyed: true
|
| 184 |
+
termination: {max_ticks: 6000}
|
| 185 |
+
actors: [] # every level supplies its own actor list via overrides.
|
| 186 |
+
|
| 187 |
+
levels:
|
| 188 |
+
# ── EASY ─────────────────────────────────────────────────────────────
|
| 189 |
+
# Bare focus-fire skill: 3-vs-3 asymmetric mirror, survival bar ≥1
|
| 190 |
+
# (forgiving — even if focus-fire loses 2 tanks in the trade, ≥1
|
| 191 |
+
# alive suffices). Stall LOSES (kill bar unmet → after_ticks LOSS).
|
| 192 |
+
# Brute attack-move LOSES (drives into a 3-tank crossfire and force-
|
| 193 |
+
# wipes). Spread-fire MAY squeak by with 1 survivor (the documented
|
| 194 |
+
# inert-easy-teeth pattern); the strong spread-vs-focus
|
| 195 |
+
# discrimination is at medium.
|
| 196 |
+
easy:
|
| 197 |
+
description: >
|
| 198 |
+
Three medium tanks (2tnk, allies) at (30,19..21) face THREE
|
| 199 |
+
enemy medium tanks (2tnk, soviet) spread across three latitudes
|
| 200 |
+
at (50,15), (51,20), and (50,25). You must close to firing
|
| 201 |
+
range (cannon range ~5) and eliminate all three. By the
|
| 202 |
+
Lanchester square law, concentrating ALL THREE tanks' fire on
|
| 203 |
+
ONE enemy at a time (start with the closest — the centre at
|
| 204 |
+
(51,20) — kills it in 1-2 decision turns, then a flank, then
|
| 205 |
+
the last) preserves your force; letting each tank pick its own
|
| 206 |
+
closest target trades 1-for-1. Win when all 3 enemy tanks are
|
| 207 |
+
killed AND at least ONE of your tanks survives AND your base
|
| 208 |
+
is intact, before tick 2400.
|
| 209 |
+
overrides:
|
| 210 |
+
actors:
|
| 211 |
+
# Agent base anchor (paranoia gate against the turn-1
|
| 212 |
+
# own_units_gte mis-fire footgun, AND engine auto-done
|
| 213 |
+
# mitigation when the strike force loses all units).
|
| 214 |
+
- {type: fact, owner: agent, position: [4, 20]}
|
| 215 |
+
# Strike force — 3 medium tanks BUNCHED on a single column
|
| 216 |
+
# (one centroid). stance:1 ReturnFire so a stall policy never
|
| 217 |
+
# pulls a stray shot.
|
| 218 |
+
- {type: 2tnk, owner: agent, position: [30, 19], stance: 1}
|
| 219 |
+
- {type: 2tnk, owner: agent, position: [30, 20], stance: 1}
|
| 220 |
+
- {type: 2tnk, owner: agent, position: [30, 21], stance: 1}
|
| 221 |
+
# Enemy mirror — 3 medium tanks SPREAD across y=15/y=20/y=25.
|
| 222 |
+
# Centre at (51,20) NOT (50,20) per CLAUDE.md silent-fail
|
| 223 |
+
# cell note. stance:3 AttackAnything so they auto-fire the
|
| 224 |
+
# second an agent tank enters cannon range (the test is
|
| 225 |
+
# purely about the agent's target prioritization, not bot
|
| 226 |
+
# pursuit).
|
| 227 |
+
- {type: 2tnk, owner: enemy, position: [50, 15], stance: 3}
|
| 228 |
+
- {type: 2tnk, owner: enemy, position: [51, 20], stance: 3}
|
| 229 |
+
- {type: 2tnk, owner: enemy, position: [50, 25], stance: 3}
|
| 230 |
+
# Far persistent enemy fact — prevents engine auto-done on
|
| 231 |
+
# enemy-units-wipe before the win evaluator sees the terminal
|
| 232 |
+
# frame.
|
| 233 |
+
- {type: fact, owner: enemy, position: [124, 20]}
|
| 234 |
+
win_condition:
|
| 235 |
+
all_of:
|
| 236 |
+
- {units_killed_gte: 3}
|
| 237 |
+
- {own_units_gte: 1}
|
| 238 |
+
- {has_building: fact}
|
| 239 |
+
- {within_ticks: 2400}
|
| 240 |
+
fail_condition:
|
| 241 |
+
any_of:
|
| 242 |
+
- {after_ticks: 2401}
|
| 243 |
+
- {not: {own_units_gte: 1}}
|
| 244 |
+
max_turns: 30
|
| 245 |
+
|
| 246 |
+
# ── MEDIUM ──────────────────────────────────────────────────────────
|
| 247 |
+
# +1 controlled variable vs easy: tighten the survival bar to ≥2
|
| 248 |
+
# (any TWO tank losses fails). Geometry is identical (3-vs-3
|
| 249 |
+
# asymmetric mirror). At 3-vs-3 with the asymmetric spread, the
|
| 250 |
+
# SPREAD outcome empirically ends 1-of-3 tanks alive — busts
|
| 251 |
+
# own_units_gte:2 ⇒ LOSS. The FOCUS outcome keeps all 3 tanks
|
| 252 |
+
# alive ⇒ WIN. This is the load-bearing discrimination of the pack.
|
| 253 |
+
medium:
|
| 254 |
+
description: >
|
| 255 |
+
Three medium tanks (2tnk, allies) at (30,19..21) face THREE
|
| 256 |
+
enemy medium tanks (2tnk, soviet) spread across three latitudes
|
| 257 |
+
at (50,15), (51,20), and (50,25). By Lanchester's square law,
|
| 258 |
+
concentrating ALL THREE tanks' fire on ONE enemy at a time
|
| 259 |
+
(start with the closest — the centre at (51,20) — then the
|
| 260 |
+
flanks) preserves your force; spreading fire (each tank picks
|
| 261 |
+
its own closest enemy) trades 1-for-1 and busts the survival
|
| 262 |
+
bar. Win when all 3 enemy tanks are killed AND at least TWO of
|
| 263 |
+
your tanks survive AND your base is intact, before tick 2400.
|
| 264 |
+
overrides:
|
| 265 |
+
actors:
|
| 266 |
+
- {type: fact, owner: agent, position: [4, 20]}
|
| 267 |
+
- {type: 2tnk, owner: agent, position: [30, 19], stance: 1}
|
| 268 |
+
- {type: 2tnk, owner: agent, position: [30, 20], stance: 1}
|
| 269 |
+
- {type: 2tnk, owner: agent, position: [30, 21], stance: 1}
|
| 270 |
+
- {type: 2tnk, owner: enemy, position: [50, 15], stance: 3}
|
| 271 |
+
- {type: 2tnk, owner: enemy, position: [51, 20], stance: 3}
|
| 272 |
+
- {type: 2tnk, owner: enemy, position: [50, 25], stance: 3}
|
| 273 |
+
- {type: fact, owner: enemy, position: [124, 20]}
|
| 274 |
+
win_condition:
|
| 275 |
+
all_of:
|
| 276 |
+
- {units_killed_gte: 3}
|
| 277 |
+
- {own_units_gte: 2}
|
| 278 |
+
- {has_building: fact}
|
| 279 |
+
- {within_ticks: 2400}
|
| 280 |
+
fail_condition:
|
| 281 |
+
any_of:
|
| 282 |
+
- {after_ticks: 2401}
|
| 283 |
+
- {not: {own_units_gte: 2}}
|
| 284 |
+
max_turns: 30
|
| 285 |
+
|
| 286 |
+
# ── HARD ────────────────────────────────────────────────────────────
|
| 287 |
+
# +2 controlled variables vs medium:
|
| 288 |
+
# 1. KILL-SPEED PRESSURE — within_ticks tightens from 2400 to
|
| 289 |
+
# 1200 (per the task-spec RELAXATION fallback: when the
|
| 290 |
+
# survival-cap discriminator weakens on a flank spawn
|
| 291 |
+
# because all enemies are co-linear from a flank perspective,
|
| 292 |
+
# the kill-speed timer becomes the load-bearing
|
| 293 |
+
# discriminator). Focus-fire ends the engagement in
|
| 294 |
+
# ~700-1000 ticks (3 cannons on 1 target each turn); brute
|
| 295 |
+
# drive-into-crossfire and stall both fail the clock.
|
| 296 |
+
# 2. TWO seed-driven spawn_point groups (NORTH staging y=11..13
|
| 297 |
+
# vs SOUTH staging y=27..29) round-robined by seed so a
|
| 298 |
+
# memorised single-target sequence cannot generalise. From
|
| 299 |
+
# NORTH the closest enemy is (50,15) and farthest is (50,25);
|
| 300 |
+
# from SOUTH the order inverts.
|
| 301 |
+
# The survival cap RELAXES to own_units_gte:1 on hard (per task
|
| 302 |
+
# brief): on a flank spawn the spread-fire policy naturally
|
| 303 |
+
# focus-fires the unique closest enemy, so the spread-vs-focus
|
| 304 |
+
# delta on hard is primarily kill-speed (within_ticks) rather
|
| 305 |
+
# than survivor count.
|
| 306 |
+
hard:
|
| 307 |
+
description: >
|
| 308 |
+
Three medium tanks (2tnk, allies) stage at ONE of two
|
| 309 |
+
staging corridors (NORTH y=11..13 OR SOUTH y=27..29, chosen
|
| 310 |
+
by seed, anti-memorisation), all bunched at x=30 on adjacent
|
| 311 |
+
rows. They face THREE enemy medium tanks (2tnk, soviet)
|
| 312 |
+
spread along the eastern line at (50,15), (51,20), and
|
| 313 |
+
(50,25). By Lanchester's square law, concentrating ALL THREE
|
| 314 |
+
tanks' fire on ONE enemy at a time (start with the closest,
|
| 315 |
+
then the next-closest, then the farthest) ends the
|
| 316 |
+
engagement fast and preserves your force. Win when all 3
|
| 317 |
+
enemy tanks are killed AND at least ONE of your tanks
|
| 318 |
+
survives AND your base is intact, before tick 1200 (kill-
|
| 319 |
+
speed pressure: stalling, driving into crossfire, or
|
| 320 |
+
anything slower than concentrated focus-fire busts the
|
| 321 |
+
clock).
|
| 322 |
+
overrides:
|
| 323 |
+
actors:
|
| 324 |
+
# Agent base anchor — duplicated under BOTH spawn_point
|
| 325 |
+
# groups because ANY agent actor with a spawn_point causes
|
| 326 |
+
# agent actors WITHOUT a spawn_point to be filtered out
|
| 327 |
+
# (CLAUDE.md / oramap.rs::expand_scenario_actors).
|
| 328 |
+
- {type: fact, owner: agent, position: [4, 20], spawn_point: 0}
|
| 329 |
+
- {type: fact, owner: agent, position: [4, 20], spawn_point: 1}
|
| 330 |
+
# spawn_point 0 — NORTH staging corridor (y=11..13). Bunched
|
| 331 |
+
# at x=30, three tanks on adjacent rows.
|
| 332 |
+
- {type: 2tnk, owner: agent, position: [30, 11], stance: 1, spawn_point: 0}
|
| 333 |
+
- {type: 2tnk, owner: agent, position: [30, 12], stance: 1, spawn_point: 0}
|
| 334 |
+
- {type: 2tnk, owner: agent, position: [30, 13], stance: 1, spawn_point: 0}
|
| 335 |
+
# spawn_point 1 — SOUTH staging corridor (y=27..29). Bunched
|
| 336 |
+
# at x=30.
|
| 337 |
+
- {type: 2tnk, owner: agent, position: [30, 27], stance: 1, spawn_point: 1}
|
| 338 |
+
- {type: 2tnk, owner: agent, position: [30, 28], stance: 1, spawn_point: 1}
|
| 339 |
+
- {type: 2tnk, owner: agent, position: [30, 29], stance: 1, spawn_point: 1}
|
| 340 |
+
# Enemy line — 3 tanks at y=15/y=20/y=25. Enemy actors don't
|
| 341 |
+
# honour spawn_point, so the SAME 3 enemy tanks place
|
| 342 |
+
# regardless of which agent spawn group is chosen.
|
| 343 |
+
- {type: 2tnk, owner: enemy, position: [50, 15], stance: 3}
|
| 344 |
+
- {type: 2tnk, owner: enemy, position: [51, 20], stance: 3}
|
| 345 |
+
- {type: 2tnk, owner: enemy, position: [50, 25], stance: 3}
|
| 346 |
+
- {type: fact, owner: enemy, position: [124, 20]}
|
| 347 |
+
win_condition:
|
| 348 |
+
all_of:
|
| 349 |
+
- {units_killed_gte: 3}
|
| 350 |
+
- {own_units_gte: 1}
|
| 351 |
+
- {has_building: fact}
|
| 352 |
+
- {within_ticks: 1200}
|
| 353 |
+
fail_condition:
|
| 354 |
+
any_of:
|
| 355 |
+
- {after_ticks: 1201}
|
| 356 |
+
- {not: {own_units_gte: 1}}
|
| 357 |
+
max_turns: 15
|
|
@@ -0,0 +1,387 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""combat-tank-vs-tank-engagement — Mirror tank trade: focus-fire WINS,
|
| 2 |
+
spread-fire (and brute attack_move, and stall) LOSE.
|
| 3 |
+
|
| 4 |
+
The bar: intended FOCUS-fire WINS on every level and every hard seed
|
| 5 |
+
(1-4); STALL and BRUTE attack_move LOSE on every level and every hard
|
| 6 |
+
seed. SPREAD-fire (each tank picks its own closest enemy) LOSES on
|
| 7 |
+
MEDIUM (the load-bearing discrimination: survival cap own_units_gte:2
|
| 8 |
+
trips because spread bleeds 2 tanks in the asymmetric flank chase) —
|
| 9 |
+
SPREAD is permitted to squeak by on EASY (own_units_gte:1, forgiving
|
| 10 |
+
bare-skill tier per the SCENARIO_REVIEW_CHECKLIST inert-easy-teeth
|
| 11 |
+
convention) and on HARD (the asymmetric geometry collapses spread to
|
| 12 |
+
focus when the agent stack starts on a flank latitude — spread ≡
|
| 13 |
+
focus when there's a unique closest enemy from a flank perspective;
|
| 14 |
+
the hard discrimination is kill-speed + spawn-variation, not
|
| 15 |
+
spread-vs-focus survivor count).
|
| 16 |
+
|
| 17 |
+
Non-win is a real reachable timeout LOSS via the `after_ticks` fail
|
| 18 |
+
clause (within_ticks 2400 + after_ticks 2401 on easy/medium with
|
| 19 |
+
max_turns 30; within_ticks 1200 + after_ticks 1201 on hard with
|
| 20 |
+
max_turns 15).
|
| 21 |
+
|
| 22 |
+
Validation is scripted (no model / network).
|
| 23 |
+
"""
|
| 24 |
+
from __future__ import annotations
|
| 25 |
+
|
| 26 |
+
from pathlib import Path
|
| 27 |
+
|
| 28 |
+
import pytest
|
| 29 |
+
|
| 30 |
+
pytest.importorskip("openra_rl_training", reason="Rust env wheel not installed")
|
| 31 |
+
from openra_bench.scenarios import load_pack
|
| 32 |
+
from openra_bench.scenarios.loader import compile_level
|
| 33 |
+
from openra_bench.scenarios.win_conditions import WinContext, evaluate
|
| 34 |
+
|
| 35 |
+
PACKS = Path(__file__).parent.parent / "openra_bench" / "scenarios" / "packs"
|
| 36 |
+
PACK_PATH = PACKS / "combat-tank-vs-tank-engagement.yaml"
|
| 37 |
+
|
| 38 |
+
|
| 39 |
+
# ── unit-level predicate / metadata checks (no engine) ──────────────
|
| 40 |
+
|
| 41 |
+
|
| 42 |
+
def test_pack_compiles_and_meta_fields_populated():
|
| 43 |
+
pack = load_pack(PACK_PATH)
|
| 44 |
+
assert pack.meta.id == "combat-tank-vs-tank-engagement"
|
| 45 |
+
assert pack.meta.capability == "action"
|
| 46 |
+
assert pack.meta.real_world_meaning, "real_world_meaning required"
|
| 47 |
+
assert pack.meta.robotics_analogue, "robotics_analogue required"
|
| 48 |
+
anchors = pack.meta.benchmark_anchor
|
| 49 |
+
assert isinstance(anchors, list) and len(anchors) == 3, (
|
| 50 |
+
f"benchmark_anchor must list all 3 anchors, got {anchors!r}"
|
| 51 |
+
)
|
| 52 |
+
joined = " ".join(anchors).lower()
|
| 53 |
+
for needle in ("sc2", "lanchester", "concentration"):
|
| 54 |
+
assert needle in joined, f"missing anchor keyword: {needle}"
|
| 55 |
+
for lvl in ("easy", "medium", "hard"):
|
| 56 |
+
c = compile_level(pack, lvl)
|
| 57 |
+
assert c.map_supported
|
| 58 |
+
assert c.win_condition is not None
|
| 59 |
+
assert c.fail_condition is not None
|
| 60 |
+
|
| 61 |
+
|
| 62 |
+
def _ctx(*, units=(), tick=1000, kills=0, lost=0, buildings=("fact",)):
|
| 63 |
+
"""Synthesize a WinContext for predicate-level checks."""
|
| 64 |
+
import types
|
| 65 |
+
|
| 66 |
+
sig = types.SimpleNamespace(
|
| 67 |
+
game_tick=tick,
|
| 68 |
+
units_killed=kills,
|
| 69 |
+
units_lost=lost,
|
| 70 |
+
cash=0,
|
| 71 |
+
resources=0,
|
| 72 |
+
own_buildings=[{"type": b} for b in buildings],
|
| 73 |
+
own_building_types=set(buildings),
|
| 74 |
+
enemies_seen_ids=set(),
|
| 75 |
+
enemy_buildings_seen_ids=set(),
|
| 76 |
+
)
|
| 77 |
+
return WinContext(
|
| 78 |
+
signals=sig,
|
| 79 |
+
render_state={
|
| 80 |
+
"units_summary": list(units),
|
| 81 |
+
"buildings": [{"type": b} for b in buildings],
|
| 82 |
+
},
|
| 83 |
+
)
|
| 84 |
+
|
| 85 |
+
|
| 86 |
+
def _alive(n):
|
| 87 |
+
return [
|
| 88 |
+
{"cell_x": 30, "cell_y": 20, "type": "2tnk", "id": str(1000 + i)}
|
| 89 |
+
for i in range(n)
|
| 90 |
+
]
|
| 91 |
+
|
| 92 |
+
|
| 93 |
+
def test_easy_predicates():
|
| 94 |
+
c = compile_level(load_pack(PACK_PATH), "easy")
|
| 95 |
+
# Intended focus: kills 3, 3 alive, in time → WIN
|
| 96 |
+
assert evaluate(c.win_condition, _ctx(units=_alive(3), tick=900, kills=3, lost=0))
|
| 97 |
+
# Spread allowed to squeak by on easy (own_units_gte:1, 1 survivor)
|
| 98 |
+
assert evaluate(c.win_condition, _ctx(units=_alive(1), tick=900, kills=3, lost=2))
|
| 99 |
+
# Kill bar unmet (only 2 kills) → not a win
|
| 100 |
+
assert not evaluate(c.win_condition, _ctx(units=_alive(3), tick=900, kills=2, lost=0))
|
| 101 |
+
# Force wipe → fail (own_units_gte:1 trips via fail clause)
|
| 102 |
+
assert evaluate(c.fail_condition, _ctx(units=[], tick=900, kills=3, lost=3))
|
| 103 |
+
# Timeout with bar unmet → fail (after_ticks 2401)
|
| 104 |
+
assert evaluate(c.fail_condition, _ctx(units=_alive(3), tick=2402, kills=2, lost=0))
|
| 105 |
+
|
| 106 |
+
|
| 107 |
+
def test_medium_predicates():
|
| 108 |
+
c = compile_level(load_pack(PACK_PATH), "medium")
|
| 109 |
+
# Intended focus: kills 3, 3 alive (cap met at ≥2) → WIN
|
| 110 |
+
assert evaluate(c.win_condition, _ctx(units=_alive(3), tick=900, kills=3, lost=0))
|
| 111 |
+
# ≥2 alive (cap met) with 3 kills → WIN
|
| 112 |
+
assert evaluate(c.win_condition, _ctx(units=_alive(2), tick=900, kills=3, lost=1))
|
| 113 |
+
# Spread-fire: only 1 alive < 2 → not a win (cap busted)
|
| 114 |
+
assert not evaluate(c.win_condition, _ctx(units=_alive(1), tick=900, kills=3, lost=2))
|
| 115 |
+
# And the fail clause trips when own_units_gte:2 fails
|
| 116 |
+
assert evaluate(c.fail_condition, _ctx(units=_alive(1), tick=900, kills=3, lost=2))
|
| 117 |
+
# Force wipe → fail
|
| 118 |
+
assert evaluate(c.fail_condition, _ctx(units=[], tick=900, kills=3, lost=3))
|
| 119 |
+
# Timeout → fail
|
| 120 |
+
assert evaluate(c.fail_condition, _ctx(units=_alive(3), tick=2402, kills=2, lost=0))
|
| 121 |
+
|
| 122 |
+
|
| 123 |
+
def test_hard_predicates():
|
| 124 |
+
c = compile_level(load_pack(PACK_PATH), "hard")
|
| 125 |
+
# Intended focus: kills 3, 3 alive, within tight tick budget → WIN
|
| 126 |
+
assert evaluate(c.win_condition, _ctx(units=_alive(3), tick=900, kills=3, lost=0))
|
| 127 |
+
# 1 survivor (own_units_gte:1) with full kill bar → WIN
|
| 128 |
+
assert evaluate(c.win_condition, _ctx(units=_alive(1), tick=900, kills=3, lost=2))
|
| 129 |
+
# Kill bar unmet → not a win
|
| 130 |
+
assert not evaluate(c.win_condition, _ctx(units=_alive(3), tick=900, kills=2, lost=0))
|
| 131 |
+
# Outside tight tick budget (kills met but slow) → not a win
|
| 132 |
+
assert not evaluate(c.win_condition, _ctx(units=_alive(3), tick=1300, kills=3, lost=0))
|
| 133 |
+
# Force wipe → fail
|
| 134 |
+
assert evaluate(c.fail_condition, _ctx(units=[], tick=900, kills=3, lost=3))
|
| 135 |
+
# Timeout → fail (tight after_ticks 1201)
|
| 136 |
+
assert evaluate(c.fail_condition, _ctx(units=_alive(3), tick=1202, kills=2, lost=0))
|
| 137 |
+
|
| 138 |
+
|
| 139 |
+
def test_timeout_reachable_inside_max_turns():
|
| 140 |
+
"""No draw degeneracy: after_ticks ≤ 93 + 90·(max_turns-1)."""
|
| 141 |
+
pack = load_pack(PACK_PATH)
|
| 142 |
+
for lvl, want_after in [("easy", 2401), ("medium", 2401), ("hard", 1201)]:
|
| 143 |
+
c = compile_level(pack, lvl)
|
| 144 |
+
max_tick = 93 + 90 * (c.max_turns - 1)
|
| 145 |
+
assert want_after <= max_tick, (
|
| 146 |
+
f"{lvl}: after_ticks {want_after} > max reachable tick "
|
| 147 |
+
f"{max_tick} (max_turns={c.max_turns}); deadline never bites"
|
| 148 |
+
)
|
| 149 |
+
|
| 150 |
+
|
| 151 |
+
def test_hard_has_two_spawn_point_groups():
|
| 152 |
+
"""Hard-tier curation: ≥2 distinct agent spawn_point groups so the
|
| 153 |
+
seed round-robins the staging corridor (NORTH y=11..13 / SOUTH
|
| 154 |
+
y=27..29). Engine-roundtrip is asserted by tests/test_hard_tier.py."""
|
| 155 |
+
c = compile_level(load_pack(PACK_PATH), "hard")
|
| 156 |
+
groups = {
|
| 157 |
+
(a.spawn_point if a.spawn_point is not None else 0)
|
| 158 |
+
for a in c.scenario.actors
|
| 159 |
+
if a.owner == "agent"
|
| 160 |
+
}
|
| 161 |
+
assert len(groups) >= 2, f"hard needs ≥2 spawn_point groups, got {groups}"
|
| 162 |
+
|
| 163 |
+
|
| 164 |
+
def test_enemy_line_is_3_tanks_asymmetric_spread():
|
| 165 |
+
"""The asymmetric geometry is the load-bearing physics — the
|
| 166 |
+
enemy line MUST be 3 tanks spread across three distinct
|
| 167 |
+
latitudes (the spread vs focus discrimination depends on each
|
| 168 |
+
enemy being independently targetable). Centre enemy at x=51 (not
|
| 169 |
+
x=50) per the CLAUDE.md silent-fail-cell note for (50,20)."""
|
| 170 |
+
pack = load_pack(PACK_PATH)
|
| 171 |
+
for lvl in ("easy", "medium", "hard"):
|
| 172 |
+
c = compile_level(pack, lvl)
|
| 173 |
+
enemy_tanks = [
|
| 174 |
+
a for a in c.scenario.actors
|
| 175 |
+
if a.owner == "enemy" and a.type == "2tnk"
|
| 176 |
+
]
|
| 177 |
+
assert len(enemy_tanks) == 3, (
|
| 178 |
+
f"{lvl}: must have exactly 3 enemy tanks, got {len(enemy_tanks)}"
|
| 179 |
+
)
|
| 180 |
+
ys = sorted(a.position[1] for a in enemy_tanks)
|
| 181 |
+
assert len(set(ys)) == 3, (
|
| 182 |
+
f"{lvl}: enemy tanks must be on 3 distinct latitudes "
|
| 183 |
+
f"(asymmetric spread), got ys={ys}"
|
| 184 |
+
)
|
| 185 |
+
# Verify the (50,20) silent-fail cell is NOT used.
|
| 186 |
+
positions = [tuple(a.position) for a in enemy_tanks]
|
| 187 |
+
assert (50, 20) not in positions, (
|
| 188 |
+
f"{lvl}: (50,20) is a CLAUDE.md-documented silent-fail "
|
| 189 |
+
f"cell — centre enemy must be at (51,20). Got {positions}"
|
| 190 |
+
)
|
| 191 |
+
types = [a.type for a in c.scenario.actors if a.owner == "enemy"]
|
| 192 |
+
assert "fact" in types, f"{lvl}: needs a persistent enemy fact"
|
| 193 |
+
|
| 194 |
+
|
| 195 |
+
def test_agent_strike_force_is_3_tanks_bunched():
|
| 196 |
+
"""The agent strike force MUST be 3 medium tanks bunched on
|
| 197 |
+
adjacent rows at a single column (one centroid) — that bunched-
|
| 198 |
+
vs-spread asymmetry is what makes focus-fire load-bearing."""
|
| 199 |
+
pack = load_pack(PACK_PATH)
|
| 200 |
+
for lvl in ("easy", "medium"):
|
| 201 |
+
c = compile_level(pack, lvl)
|
| 202 |
+
agent_tanks = [
|
| 203 |
+
a for a in c.scenario.actors
|
| 204 |
+
if a.owner == "agent" and a.type == "2tnk"
|
| 205 |
+
]
|
| 206 |
+
assert len(agent_tanks) == 3, (
|
| 207 |
+
f"{lvl}: must have exactly 3 agent tanks, got {len(agent_tanks)}"
|
| 208 |
+
)
|
| 209 |
+
xs = {a.position[0] for a in agent_tanks}
|
| 210 |
+
assert len(xs) == 1, (
|
| 211 |
+
f"{lvl}: agent tanks must be bunched on ONE column (same x), "
|
| 212 |
+
f"got xs={xs}"
|
| 213 |
+
)
|
| 214 |
+
# Hard: per-spawn-group bunching (each spawn_point has 3 tanks
|
| 215 |
+
# bunched on one column at a single latitude band).
|
| 216 |
+
c = compile_level(pack, "hard")
|
| 217 |
+
for sp in (0, 1):
|
| 218 |
+
agent_tanks = [
|
| 219 |
+
a for a in c.scenario.actors
|
| 220 |
+
if a.owner == "agent" and a.type == "2tnk" and a.spawn_point == sp
|
| 221 |
+
]
|
| 222 |
+
assert len(agent_tanks) == 3, (
|
| 223 |
+
f"hard sp={sp}: must have 3 agent tanks, got {len(agent_tanks)}"
|
| 224 |
+
)
|
| 225 |
+
xs = {a.position[0] for a in agent_tanks}
|
| 226 |
+
assert len(xs) == 1, (
|
| 227 |
+
f"hard sp={sp}: agent tanks bunched on ONE column, got xs={xs}"
|
| 228 |
+
)
|
| 229 |
+
|
| 230 |
+
|
| 231 |
+
# ── engine-driven scripted policies ─────────────────────────────────
|
| 232 |
+
|
| 233 |
+
|
| 234 |
+
def _own_ids(rs):
|
| 235 |
+
return [str(u["id"]) for u in (rs.get("units_summary", []) or [])]
|
| 236 |
+
|
| 237 |
+
|
| 238 |
+
def _enemy_tanks(rs):
|
| 239 |
+
out = []
|
| 240 |
+
for e in (rs.get("enemy_summary") or []):
|
| 241 |
+
t = (e.get("type") or e.get("actor_type") or "").lower()
|
| 242 |
+
if t == "2tnk":
|
| 243 |
+
out.append(e)
|
| 244 |
+
return out
|
| 245 |
+
|
| 246 |
+
|
| 247 |
+
def _stall(rs, Command):
|
| 248 |
+
"""Pure observe — tanks never close to firing range; nothing dies
|
| 249 |
+
either way ⇒ kill bar unmet ⇒ after_ticks LOSS."""
|
| 250 |
+
return [Command.observe()]
|
| 251 |
+
|
| 252 |
+
|
| 253 |
+
def _brute_attack_move(rs, Command):
|
| 254 |
+
"""Brute: every tank attack_moves toward the centre enemy. The
|
| 255 |
+
bunched stack drives into the 3-tank crossfire at the engagement
|
| 256 |
+
line; concentrated incoming fire kills ≥2 agent tanks ⇒ LOSS."""
|
| 257 |
+
own = _own_ids(rs)
|
| 258 |
+
if not own:
|
| 259 |
+
return [Command.observe()]
|
| 260 |
+
return [Command.attack_move(own, 51, 20)]
|
| 261 |
+
|
| 262 |
+
|
| 263 |
+
def _spread_attack_closest(rs, Command):
|
| 264 |
+
"""Spread: each agent tank attack_units ITS OWN nearest visible
|
| 265 |
+
enemy tank. With the asymmetric spread (3 enemies on three rows),
|
| 266 |
+
once the centre dies the surviving agent tanks chase different
|
| 267 |
+
flank enemies in 1-vs-1 duels — Lanchester linear law collapses
|
| 268 |
+
the trade to mutual annihilation, ending with 1-of-3 alive. On
|
| 269 |
+
MEDIUM (own_units_gte:2) this busts the survival cap ⇒ LOSS."""
|
| 270 |
+
own = _own_ids(rs)
|
| 271 |
+
if not own:
|
| 272 |
+
return [Command.observe()]
|
| 273 |
+
es = _enemy_tanks(rs)
|
| 274 |
+
if not es:
|
| 275 |
+
# No targets in sight — advance to contact.
|
| 276 |
+
return [Command.attack_move(own, 51, 20)]
|
| 277 |
+
cmds = []
|
| 278 |
+
for u in (rs.get("units_summary") or []):
|
| 279 |
+
uid = str(u["id"])
|
| 280 |
+
ux, uy = u["cell_x"], u["cell_y"]
|
| 281 |
+
es_sorted = sorted(
|
| 282 |
+
es, key=lambda e: (e["cell_x"] - ux) ** 2 + (e["cell_y"] - uy) ** 2
|
| 283 |
+
)
|
| 284 |
+
tid = es_sorted[0].get("id")
|
| 285 |
+
if tid is not None:
|
| 286 |
+
cmds.append(Command.attack_unit([uid], str(tid)))
|
| 287 |
+
return cmds or [Command.observe()]
|
| 288 |
+
|
| 289 |
+
|
| 290 |
+
def _focus_fire(rs, Command):
|
| 291 |
+
"""Focus-fire: ALL agent tanks attack_unit the SAME target each
|
| 292 |
+
turn — the closest enemy to the agent centroid. Once that enemy
|
| 293 |
+
falls, the policy automatically re-targets the next-closest. This
|
| 294 |
+
is the Lanchester-square-law optimal: concentrate N output on 1
|
| 295 |
+
target, remove that target's DPS, then repeat — combat-power
|
| 296 |
+
surplus grows quadratically with each kill."""
|
| 297 |
+
own = _own_ids(rs)
|
| 298 |
+
if not own:
|
| 299 |
+
return [Command.observe()]
|
| 300 |
+
es = _enemy_tanks(rs)
|
| 301 |
+
if not es:
|
| 302 |
+
return [Command.attack_move(own, 51, 20)]
|
| 303 |
+
us = rs.get("units_summary") or []
|
| 304 |
+
cx = sum(u["cell_x"] for u in us) / len(us)
|
| 305 |
+
cy = sum(u["cell_y"] for u in us) / len(us)
|
| 306 |
+
es.sort(key=lambda e: (e["cell_x"] - cx) ** 2 + (e["cell_y"] - cy) ** 2)
|
| 307 |
+
tid = es[0].get("id")
|
| 308 |
+
if tid is not None:
|
| 309 |
+
return [Command.attack_unit(own, str(tid))]
|
| 310 |
+
return [Command.attack_move(own, 51, 20)]
|
| 311 |
+
|
| 312 |
+
|
| 313 |
+
@pytest.mark.parametrize("level", ["easy", "medium", "hard"])
|
| 314 |
+
@pytest.mark.parametrize("seed", [1, 2, 3, 4])
|
| 315 |
+
def test_intended_focus_fire_wins(level, seed):
|
| 316 |
+
pytest.importorskip("openra_train")
|
| 317 |
+
from openra_bench.eval_core import run_level
|
| 318 |
+
|
| 319 |
+
c = compile_level(load_pack(PACK_PATH), level)
|
| 320 |
+
r = run_level(c, _focus_fire, seed=seed)
|
| 321 |
+
assert r.outcome == "win", (
|
| 322 |
+
f"{level} seed={seed}: intended focus-fire should WIN, got "
|
| 323 |
+
f"{r.outcome} after {r.turns} turns "
|
| 324 |
+
f"(kills={r.signals.units_killed}, losses={r.signals.units_lost})"
|
| 325 |
+
)
|
| 326 |
+
|
| 327 |
+
|
| 328 |
+
@pytest.mark.parametrize("level", ["easy", "medium", "hard"])
|
| 329 |
+
@pytest.mark.parametrize("seed", [1, 2, 3, 4])
|
| 330 |
+
def test_stall_loses(level, seed):
|
| 331 |
+
pytest.importorskip("openra_train")
|
| 332 |
+
from openra_bench.eval_core import run_level
|
| 333 |
+
|
| 334 |
+
c = compile_level(load_pack(PACK_PATH), level)
|
| 335 |
+
r = run_level(c, _stall, seed=seed)
|
| 336 |
+
assert r.outcome == "loss", (
|
| 337 |
+
f"{level} seed={seed}: stall must be a real timeout LOSS "
|
| 338 |
+
f"(no engagement → kill bar unmet → after_ticks fires), got "
|
| 339 |
+
f"{r.outcome}"
|
| 340 |
+
)
|
| 341 |
+
|
| 342 |
+
|
| 343 |
+
@pytest.mark.parametrize("level", ["easy", "medium", "hard"])
|
| 344 |
+
@pytest.mark.parametrize("seed", [1, 2, 3, 4])
|
| 345 |
+
def test_brute_attack_move_loses(level, seed):
|
| 346 |
+
"""Brute attack_move into the centre crosses through the 3-tank
|
| 347 |
+
crossfire and force-wipes the agent strike force on EASY (3 of 3
|
| 348 |
+
lost) ⇒ LOSS via not own_units_gte:1; on MEDIUM (2 lost) ⇒ LOSS
|
| 349 |
+
via not own_units_gte:2; on HARD (1-3 lost, but timing slow) ⇒
|
| 350 |
+
LOSS via the tight within_ticks 1200 + force-wipe."""
|
| 351 |
+
pytest.importorskip("openra_train")
|
| 352 |
+
from openra_bench.eval_core import run_level
|
| 353 |
+
|
| 354 |
+
c = compile_level(load_pack(PACK_PATH), level)
|
| 355 |
+
r = run_level(c, _brute_attack_move, seed=seed)
|
| 356 |
+
assert r.outcome == "loss", (
|
| 357 |
+
f"{level} seed={seed}: brute attack_move must LOSE (drive-into-"
|
| 358 |
+
f"crossfire force-wipe / tick budget bust), got {r.outcome} "
|
| 359 |
+
f"(kills={r.signals.units_killed}, losses={r.signals.units_lost})"
|
| 360 |
+
)
|
| 361 |
+
|
| 362 |
+
|
| 363 |
+
@pytest.mark.parametrize("level", ["medium"])
|
| 364 |
+
@pytest.mark.parametrize("seed", [1, 2, 3, 4])
|
| 365 |
+
def test_spread_attack_closest_loses_on_medium(level, seed):
|
| 366 |
+
"""Spread-attack-closest must LOSE on MEDIUM — the asymmetric
|
| 367 |
+
flank chase ends with 1-of-3 agent tanks alive (2 lost), busting
|
| 368 |
+
the survival cap own_units_gte:2. EASY is excluded as the bare-
|
| 369 |
+
skill tier (own_units_gte:1 lets the 1 survivor squeak by — the
|
| 370 |
+
documented SCENARIO_REVIEW_CHECKLIST inert-easy-teeth pattern).
|
| 371 |
+
HARD is excluded because the asymmetric geometry collapses
|
| 372 |
+
spread to focus when the agent stack starts on a flank latitude
|
| 373 |
+
(NORTH or SOUTH) — from a flank there is a unique closest enemy
|
| 374 |
+
that all 3 agent tanks naturally target (spread ≡ focus); the
|
| 375 |
+
hard discrimination is kill-speed + spawn-variation, not the
|
| 376 |
+
survivor-count delta."""
|
| 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, _spread_attack_closest, seed=seed)
|
| 382 |
+
assert r.outcome == "loss", (
|
| 383 |
+
f"{level} seed={seed}: spread-attack-closest must LOSE on "
|
| 384 |
+
f"medium (flank chase bleeds 2 tanks, own_units_gte:2 fails), "
|
| 385 |
+
f"got {r.outcome} (kills={r.signals.units_killed}, "
|
| 386 |
+
f"losses={r.signals.units_lost})"
|
| 387 |
+
)
|
|
@@ -621,6 +621,20 @@ UPGRADED = [
|
|
| 621 |
# equivalent column-vs-wedge decision from a flipped bearing — a
|
| 622 |
# memorised "form wedge from y=14" opening cannot generalise.
|
| 623 |
"combat-formation-tank-wedge",
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 624 |
]
|
| 625 |
|
| 626 |
# Consciously NOT spawn-varied, with the reason (keeps the curation
|
|
|
|
| 621 |
# equivalent column-vs-wedge decision from a flipped bearing — a
|
| 622 |
# memorised "form wedge from y=14" opening cannot generalise.
|
| 623 |
"combat-formation-tank-wedge",
|
| 624 |
+
# Wave-7 Group D combat-micro pack: SC2 mirror micro / Lanchester
|
| 625 |
+
# square law / concentration-of-force doctrine. ASYMMETRIC geometry
|
| 626 |
+
# (3 agent tanks bunched at (30,*) vs 3 enemy tanks spread across
|
| 627 |
+
# 3 latitudes at (50,15)/(51,20)/(50,25)) so focus-fire on the
|
| 628 |
+
# closest enemy preserves the force while spread-fire (each tank
|
| 629 |
+
# picks its own nearest enemy) collapses to Lanchester linear and
|
| 630 |
+
# loses 2 of 3 tanks (the load-bearing discrimination on medium).
|
| 631 |
+
# Hard tier defines two agent spawn_point groups (NORTH y=11..13 /
|
| 632 |
+
# SOUTH y=27..29) round-robined by seed; the enemy line always
|
| 633 |
+
# places (enemy actors don't honour spawn_point — CLAUDE.md) so
|
| 634 |
+
# both spawns face the same focus-fire decision from a flipped
|
| 635 |
+
# bearing. Hard discriminates via kill-speed (tight within_ticks
|
| 636 |
+
# 1200) + spawn-variation generalisation.
|
| 637 |
+
"combat-tank-vs-tank-engagement",
|
| 638 |
]
|
| 639 |
|
| 640 |
# Consciously NOT spawn-varied, with the reason (keeps the curation
|