Spaces:
Running
Running
feat(scenario): def-multi-direction — split defense across lanes (graph min-cut / multi-front anchor)
Browse files
openra_bench/scenarios/packs/def-multi-direction.yaml
ADDED
|
@@ -0,0 +1,439 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# def-multi-direction.yaml
|
| 2 |
+
#
|
| 3 |
+
# REASONING capability — Wave-7 Group C distributed-defense seed.
|
| 4 |
+
# THREE rusher bands attack the construction yard CONCURRENTLY from
|
| 5 |
+
# three latitudes (NORTH y=8, MID y=20, SOUTH y=32). The agent
|
| 6 |
+
# commands SIX medium tanks (2tnk) staged centrally and must SPLIT
|
| 7 |
+
# them 2/2/2 across three defensive zones — one per lane — to absorb
|
| 8 |
+
# each wave before it reaches the fact. Concentrating all six tanks
|
| 9 |
+
# on a single lane wins THAT lane but the other two lanes' waves
|
| 10 |
+
# stride untouched to the construction yard and raze it. Sibling of
|
| 11 |
+
# `def-pre-position-mobile-reserve` (the FORWARD-INTERCEPT variant
|
| 12 |
+
# at the lane mouth) — this pack tests DISTRIBUTED DEFENSE at the
|
| 13 |
+
# base perimeter instead: every lane needs ≥2 tanks of static
|
| 14 |
+
# coverage or its wave breaks through.
|
| 15 |
+
#
|
| 16 |
+
# Real-world anchors (binding meta.benchmark_anchor):
|
| 17 |
+
# - Graph min-cut / multi-source attack: the optimal defence
|
| 18 |
+
# allocates capacity to EVERY active cut, not the loudest one.
|
| 19 |
+
# A concentrated defence at one cut leaves the others wide open.
|
| 20 |
+
# - Distributed-systems load balancing: serving traffic across N
|
| 21 |
+
# concurrent request streams requires AT LEAST one worker per
|
| 22 |
+
# stream — pinning all workers to a single stream starves the
|
| 23 |
+
# others (queue-builds, SLA breaches, eventual outage).
|
| 24 |
+
# - Military multi-front doctrine: an army facing concurrent
|
| 25 |
+
# advances on multiple axes MUST hold each axis (the canonical
|
| 26 |
+
# "two-front war" failure mode is leaving one axis uncontested
|
| 27 |
+
# and being enveloped). The same defensive capacity, distributed
|
| 28 |
+
# across all active fronts, beats the same capacity concentrated
|
| 29 |
+
# on the loudest one.
|
| 30 |
+
# - Air-defence sector coverage: each sector needs its own SAM
|
| 31 |
+
# battery; pooling all batteries in one sector means the other
|
| 32 |
+
# sectors are wide open to attack.
|
| 33 |
+
#
|
| 34 |
+
# DISCRIMINATIONS (the bar — every lazy / wrong-commit play LOSES,
|
| 35 |
+
# only the intended 2/2/2 split WINS):
|
| 36 |
+
#
|
| 37 |
+
# • stall (only `observe`): the 6 tanks idle at central staging;
|
| 38 |
+
# each rusher band crosses to the agent centroid (centroid is
|
| 39 |
+
# central by construction). The MID band reaches the fact first
|
| 40 |
+
# (shortest straight path along y=20), N and S bands arrive a
|
| 41 |
+
# few turns later. Auto-engagement at stance:3 racks up some
|
| 42 |
+
# kills but the centrally-clustered tanks cannot defend three
|
| 43 |
+
# zones simultaneously — the fact is razed by the combined
|
| 44 |
+
# pressure → `not has_building:fact` LOSS, AND the three
|
| 45 |
+
# defensive-zone clauses all stay empty.
|
| 46 |
+
#
|
| 47 |
+
# • concentrate-NORTH (whole reserve sent to the N defensive zone):
|
| 48 |
+
# the N zone clause is satisfied (6 ≥ 2 tanks at (25,8)) but the
|
| 49 |
+
# MID and S defensive zones are EMPTY (0 < 2). Each of the MID
|
| 50 |
+
# and S clauses fails on its own. Beyond the predicate-level
|
| 51 |
+
# LOSS, the MID and S waves walk untouched into the fact (no
|
| 52 |
+
# defenders in their path) and raze it → `not has_building:fact`
|
| 53 |
+
# also bites. Real reachable LOSS.
|
| 54 |
+
#
|
| 55 |
+
# • concentrate-MID (whole reserve at (25,20)): same shape —
|
| 56 |
+
# N and S zone clauses fail (0 tanks), AND the N + S waves
|
| 57 |
+
# swing wide around the central cluster (rusher bot targets
|
| 58 |
+
# centroid which is at MID; N band approaches via y=10..14 and
|
| 59 |
+
# S band via y=26..30 — both reach the fact before the central
|
| 60 |
+
# cluster can chase them down). Fact razed → LOSS.
|
| 61 |
+
#
|
| 62 |
+
# • concentrate-SOUTH: symmetric to concentrate-NORTH; N and MID
|
| 63 |
+
# zone clauses fail; N and MID waves take the fact → LOSS.
|
| 64 |
+
#
|
| 65 |
+
# • split-2/3 (no MID defenders, e.g. 3 N + 3 S): MID zone clause
|
| 66 |
+
# is empty (0 < 2); the MID wave on the straight y=20 line
|
| 67 |
+
# reaches the fact unopposed (the N and S defenders are too far
|
| 68 |
+
# off-axis to intercept the central column in time) → fact
|
| 69 |
+
# razed → LOSS.
|
| 70 |
+
#
|
| 71 |
+
# • intended 2/2/2 split (≥1 tank in EACH of N, MID, S defensive
|
| 72 |
+
# zones at (25,8), (25,20), (25,32) — the 2-per-zone doctrine
|
| 73 |
+
# builds in attrition headroom on top of the n=1 floor): all
|
| 74 |
+
# three defensive-zone clauses hold; each wave is engaged by 2
|
| 75 |
+
# tanks (2tnk vs e1 is ~1-shot dominance per CLAUDE.md /
|
| 76 |
+
# combat-focus-fire-priority); the kill bar clears (easy ≥4,
|
| 77 |
+
# medium/hard ≥6); fact intact → WIN.
|
| 78 |
+
#
|
| 79 |
+
# ENGINE FACTS used (CLAUDE.md / SCENARIO_QUALITY.md):
|
| 80 |
+
# - `rusher` bot — each enemy band converges on the agent
|
| 81 |
+
# centroid. The DISTRIBUTED-DEFENCE test here is enforced by
|
| 82 |
+
# the THREE `units_in_region_gte` zone clauses (a concentration
|
| 83 |
+
# satisfies one and fails two), NOT by the lane geometry of the
|
| 84 |
+
# incoming bands. `hunt` was tried first but its e1s idle at
|
| 85 |
+
# spawn for most of the tick budget on this geometry, so the
|
| 86 |
+
# kill bar and the engagement-mediated zone-coverage attrition
|
| 87 |
+
# never materialise; `rusher` reliably walks the bands into
|
| 88 |
+
# engagement range every seed and the three zone clauses still
|
| 89 |
+
# do all of the discrimination work.
|
| 90 |
+
# - Enemy actors with NO spawn_point ALWAYS place regardless of
|
| 91 |
+
# the agent's spawn group (CLAUDE.md). All three enemy bands
|
| 92 |
+
# always materialise every seed — the "react to all three lanes"
|
| 93 |
+
# capability is enforced by having ALL THREE LANES active on
|
| 94 |
+
# every seed (the strictly harder test).
|
| 95 |
+
# - Inert enemy `fact` marker far east (anti-DRAW): without a
|
| 96 |
+
# persistent enemy actor the engine auto-`done`s on enemy-elim
|
| 97 |
+
# before the win/fail evaluator runs (CLAUDE.md footgun).
|
| 98 |
+
# - `enemy_unit_spotted` interrupt → defenders re-decide the
|
| 99 |
+
# instant any rusher band becomes visible.
|
| 100 |
+
# - `own_unit_destroyed` interrupt → re-decide when a defender
|
| 101 |
+
# falls (so a damaged lane can be reinforced from a quiet one).
|
| 102 |
+
# - `units_in_region_gte: {x, y, radius, n}` — load-bearing
|
| 103 |
+
# distribution clause: REQUIRES ≥1 tank in EACH of three
|
| 104 |
+
# defensive zones (n=1, not n=2 — the 2-per-lane doctrine
|
| 105 |
+
# remains the agent's recommended allocation but the predicate
|
| 106 |
+
# floors at n=1 to absorb the inevitable one-per-lane attrition
|
| 107 |
+
# from the heavier waves). A concentration in one zone fails
|
| 108 |
+
# the other two clauses regardless of n.
|
| 109 |
+
# - 2tnk vs e1 dominance per CLAUDE.md / combat-focus-fire-
|
| 110 |
+
# priority. 2 tanks reliably clear a 3-rifle wave without
|
| 111 |
+
# losing the lane; this is what makes 2/2/2 a tight floor (1
|
| 112 |
+
# tank per lane bleeds through).
|
| 113 |
+
# - Tick budget: ~90 ticks/turn fixed-step, ~60 ticks/turn in
|
| 114 |
+
# interrupt mode. max_turns 50 → reachable tick ≈ 3000 in
|
| 115 |
+
# interrupt mode and ≈ 4503 fixed-step; `within_ticks: 2700`
|
| 116 |
+
# and `after_ticks: 2701` are both reachable well inside
|
| 117 |
+
# max_turns so stall hits the deadline as a real LOSS (not a
|
| 118 |
+
# draw degeneracy).
|
| 119 |
+
#
|
| 120 |
+
# Validate (no model / no network):
|
| 121 |
+
# python3 -m openra_bench.scenarios.validate \
|
| 122 |
+
# openra_bench/scenarios/packs/def-multi-direction.yaml
|
| 123 |
+
|
| 124 |
+
meta:
|
| 125 |
+
id: def-multi-direction
|
| 126 |
+
title: 'Distributed Defense — Split Across Concurrent Lanes'
|
| 127 |
+
capability: reasoning
|
| 128 |
+
real_world_meaning: >
|
| 129 |
+
Three enemy waves attack the base concurrently from three
|
| 130 |
+
latitudes (north, mid, south). With six defenders against three
|
| 131 |
+
concurrent waves, distributing 2/2/2 holds every lane while
|
| 132 |
+
concentrating on one lane leaves the other two uncontested —
|
| 133 |
+
those waves stride untouched to the construction yard and raze
|
| 134 |
+
it. This is the canonical multi-front / graph min-cut allocation
|
| 135 |
+
problem: defensive capacity must be matched to the cardinality
|
| 136 |
+
of concurrent threats, not to the loudest single threat.
|
| 137 |
+
robotics_analogue: >
|
| 138 |
+
Distributed-systems load balancing under N concurrent request
|
| 139 |
+
streams: each stream needs at least one worker, or its queue
|
| 140 |
+
builds without bound and the SLA breaks regardless of how well
|
| 141 |
+
the other streams are served. Likewise air-defence sector
|
| 142 |
+
coverage: pooling all batteries in one sector leaves the others
|
| 143 |
+
open. Same total capacity, different topology, qualitatively
|
| 144 |
+
different system survivability.
|
| 145 |
+
benchmark_anchor:
|
| 146 |
+
- "distributed-systems load balancing"
|
| 147 |
+
- "graph min-cut"
|
| 148 |
+
- "military multi-front"
|
| 149 |
+
author: openra-bench
|
| 150 |
+
|
| 151 |
+
# rush-hour-arena: 128×40 playable (x in [2..126], y in [2..38]).
|
| 152 |
+
# Agent base at the west (fact at x=10). Three rush bands spawn at
|
| 153 |
+
# x=80 across three latitudes (NORTH y=8, MID y=20, SOUTH y=32) and
|
| 154 |
+
# drive west at the agent centroid. The three defensive zones sit at
|
| 155 |
+
# x=25 (well east of the fact at x=10 — defenders must MOVE OUT of
|
| 156 |
+
# the central staging cluster at (30,20) to occupy them, but stay
|
| 157 |
+
# close enough to the fact that an under-defended zone breaks through
|
| 158 |
+
# in time for the lane's wave to raze the fact before the clock).
|
| 159 |
+
base_map: rush-hour-arena
|
| 160 |
+
starting_cash: 0
|
| 161 |
+
|
| 162 |
+
base:
|
| 163 |
+
agent:
|
| 164 |
+
faction: allies
|
| 165 |
+
cash: 0
|
| 166 |
+
enemy:
|
| 167 |
+
faction: soviet
|
| 168 |
+
cash: 0
|
| 169 |
+
# `rusher`: each enemy band converges on the agent centroid.
|
| 170 |
+
# The DISTRIBUTED-DEFENCE discriminator here is NOT enemy lane
|
| 171 |
+
# geometry — it's the THREE `units_in_region_gte` zone clauses
|
| 172 |
+
# in the win predicate: tanks MUST be in all three (25,*) zones
|
| 173 |
+
# simultaneously, so a concentration policy satisfies one zone
|
| 174 |
+
# and fails the other two regardless of where the rusher bands
|
| 175 |
+
# converge. (Smoke-tested: `hunt` leaves e1s idle at spawn for
|
| 176 |
+
# most of the tick budget here — they don't actively path to
|
| 177 |
+
# the nearest tank — so the kill bar and the engagement-mediated
|
| 178 |
+
# zone-coverage attrition never materialise; `rusher` reliably
|
| 179 |
+
# walks the bands into engagement range every seed.)
|
| 180 |
+
bot_type: rusher
|
| 181 |
+
# Combat-only toolset (no build / no harvest): the test is
|
| 182 |
+
# ALLOCATION of a fixed defender pool across concurrent threats,
|
| 183 |
+
# not production.
|
| 184 |
+
tools:
|
| 185 |
+
- observe
|
| 186 |
+
- move_units
|
| 187 |
+
- attack_unit
|
| 188 |
+
- attack_move
|
| 189 |
+
- stop
|
| 190 |
+
planning: true
|
| 191 |
+
# React the INSTANT a rusher band becomes visible or a defender
|
| 192 |
+
# falls — the multi-lane allocation needs immediate re-decision
|
| 193 |
+
# the moment the agent learns one lane is contested.
|
| 194 |
+
interrupts:
|
| 195 |
+
enemy_unit_spotted: true
|
| 196 |
+
own_unit_destroyed: true
|
| 197 |
+
termination:
|
| 198 |
+
max_ticks: 6000
|
| 199 |
+
actors: []
|
| 200 |
+
|
| 201 |
+
levels:
|
| 202 |
+
# ── EASY ─────────────────────────────────────────────────────────
|
| 203 |
+
# Bare distributed-defense skill: three CONCURRENT lanes, LIGHT
|
| 204 |
+
# waves (2 e1 per lane = 6 total). 2tnk-vs-e1 dominance means
|
| 205 |
+
# 2 tanks per lane reliably clears the 2-rifle wave without
|
| 206 |
+
# losing a defender. The load-bearing clauses are THREE
|
| 207 |
+
# `units_in_region_gte` zones (N (25,8), MID (25,20), S (25,32)),
|
| 208 |
+
# each requiring ≥1 tank — any concentration policy satisfies
|
| 209 |
+
# one zone and fails two. max_turns 50 → reachable tick ~3000
|
| 210 |
+
# in interrupt mode (~4503 fixed-step); `after_ticks: 2701`
|
| 211 |
+
# bites well before max_turns.
|
| 212 |
+
easy:
|
| 213 |
+
description: >
|
| 214 |
+
Your construction yard (fact) sits at the west edge (10,20). A
|
| 215 |
+
defensive force of 6 medium tanks (2tnk) is staged at the
|
| 216 |
+
central choke (30,20). THREE rifle-infantry rush bands are
|
| 217 |
+
incoming concurrently — NORTH (y=8), MID (y=20), and SOUTH
|
| 218 |
+
(y=32). SPLIT the defenders 2/2/2 across three forward
|
| 219 |
+
defensive zones at (25,8), (25,20), and (25,32) — each zone
|
| 220 |
+
must hold AT LEAST 1 tank at win time (the 2-per-zone
|
| 221 |
+
doctrine builds in attrition headroom), AND you must destroy
|
| 222 |
+
≥4 of the 6 rushers AND keep ≥3 tanks alive AND keep the fact
|
| 223 |
+
standing, AND finish before tick 2700. Holding the reserve at
|
| 224 |
+
the centre satisfies at most the MID zone (and even that only
|
| 225 |
+
on the boundary); concentrating all six tanks on a single
|
| 226 |
+
lane satisfies one zone but fails the other two clauses.
|
| 227 |
+
overrides:
|
| 228 |
+
actors:
|
| 229 |
+
# Agent construction yard — WEST edge, the rusher target.
|
| 230 |
+
- {type: fact, owner: agent, position: [10, 20]}
|
| 231 |
+
# 6× 2tnk defensive force staged at the CENTRAL CHOKE
|
| 232 |
+
# (30,20). stance:3 (AttackAnything) — auto-engages enemies
|
| 233 |
+
# in vision range. The discriminator is NOT auto-engagement
|
| 234 |
+
# but the THREE defensive-zone clauses: a passive central
|
| 235 |
+
# cluster never reaches ANY of the (25,*) zones, and a
|
| 236 |
+
# concentration in one zone fails the other two clauses.
|
| 237 |
+
- {type: 2tnk, owner: agent, position: [30, 19], stance: 3}
|
| 238 |
+
- {type: 2tnk, owner: agent, position: [30, 20], stance: 3}
|
| 239 |
+
- {type: 2tnk, owner: agent, position: [30, 21], stance: 3}
|
| 240 |
+
- {type: 2tnk, owner: agent, position: [31, 19], stance: 3}
|
| 241 |
+
- {type: 2tnk, owner: agent, position: [31, 20], stance: 3}
|
| 242 |
+
- {type: 2tnk, owner: agent, position: [31, 21], stance: 3}
|
| 243 |
+
# THREE CONCURRENT rush bands — NORTH (y=8), MID (y=20),
|
| 244 |
+
# SOUTH (y=32). e1-only so 2 tanks per lane dominates the
|
| 245 |
+
# engagement (per CLAUDE.md / combat-focus-fire-priority).
|
| 246 |
+
# 2 e1 per lane = 6 e1 total; the LIGHT load means 2 tanks
|
| 247 |
+
# per lane reliably hold without losing a defender (so the
|
| 248 |
+
# n=2 zone clause keeps holding through the engagement).
|
| 249 |
+
- {type: e1, owner: enemy, position: [80, 8], stance: 3, count: 2}
|
| 250 |
+
- {type: e1, owner: enemy, position: [80, 20], stance: 3, count: 2}
|
| 251 |
+
- {type: e1, owner: enemy, position: [80, 32], stance: 3, count: 2}
|
| 252 |
+
# Inert enemy `fact` marker far east (anti-DRAW): without it
|
| 253 |
+
# the engine auto-`done`s on enemy-elim before the win/fail
|
| 254 |
+
# evaluator runs (CLAUDE.md footgun). Placed off-axis (y=4)
|
| 255 |
+
# so the head-on MID band doesn't path through it.
|
| 256 |
+
- {type: fact, owner: enemy, position: [120, 4]}
|
| 257 |
+
win_condition:
|
| 258 |
+
all_of:
|
| 259 |
+
# Kill bar (4 of 6 e1) is a soft floor — the LOAD-BEARING
|
| 260 |
+
# discriminator is the THREE zone clauses below. With the
|
| 261 |
+
# intended 2/2/2 split, the lanes' e1 attrition across 2700
|
| 262 |
+
# ticks reliably clears 4 kills. Concentration policies
|
| 263 |
+
# fail two of the three zone clauses anyway (and a stall
|
| 264 |
+
# never reaches any zone).
|
| 265 |
+
- {units_killed_gte: 4}
|
| 266 |
+
- {own_units_gte: 3}
|
| 267 |
+
- {has_building: fact}
|
| 268 |
+
# THREE defensive zones — each MUST hold ≥1 tank. The
|
| 269 |
+
# n=1 floor (rather than n=2) builds in attrition headroom:
|
| 270 |
+
# the 2/2/2 doctrine survives a single defender loss per
|
| 271 |
+
# lane without dropping a zone clause. Stall (everyone at
|
| 272 |
+
# centre) puts 0 tanks in N+S zones (distance
|
| 273 |
+
# sqrt(5²+12²) ≈ 13 > radius 5 to the N/S zones) — only
|
| 274 |
+
# MID is on the boundary; concentrate-NORTH puts 6 in N
|
| 275 |
+
# but 0 in MID and SOUTH — those two clauses fail.
|
| 276 |
+
- {units_in_region_gte: {x: 25, y: 8, radius: 5, n: 1}}
|
| 277 |
+
- {units_in_region_gte: {x: 25, y: 20, radius: 5, n: 1}}
|
| 278 |
+
- {units_in_region_gte: {x: 25, y: 32, radius: 5, n: 1}}
|
| 279 |
+
- {within_ticks: 2700}
|
| 280 |
+
fail_condition:
|
| 281 |
+
any_of:
|
| 282 |
+
- {after_ticks: 2701}
|
| 283 |
+
- {not: {has_building: fact}}
|
| 284 |
+
- {not: {own_units_gte: 1}}
|
| 285 |
+
max_turns: 50
|
| 286 |
+
|
| 287 |
+
# ── MEDIUM ─────��─────────────────────────────────────────────────
|
| 288 |
+
# +1 controlled variable vs easy: HEAVIER waves (3 e1 per lane =
|
| 289 |
+
# 9 total vs easy's 6) AND a LOSS CAP (`units_lost_lte: 3` — at
|
| 290 |
+
# most 3 tanks lost). The 2/2/2 split still wins but a sloppy
|
| 291 |
+
# intercept that bleeds more than 3 defenders busts the cap. A
|
| 292 |
+
# wrong allocation (e.g. 6/0/0) doesn't just fail the empty zone
|
| 293 |
+
# clauses — the centroid-converging rusher pile on the single
|
| 294 |
+
# cluster builds up enough attrition to also bust the loss cap.
|
| 295 |
+
# Same clock as easy (within_ticks 2700 / after_ticks 2701).
|
| 296 |
+
medium:
|
| 297 |
+
description: >
|
| 298 |
+
Same THREE concurrent rush bands but each band is heavier
|
| 299 |
+
(3 rifle infantry per lane = 9 total vs easy's 6). Same
|
| 300 |
+
SIX-tank defensive force at central staging (30,20); same
|
| 301 |
+
2/2/2 split requirement across N (25,8), MID (25,20), S
|
| 302 |
+
(25,32) defensive zones. Win additionally requires losing
|
| 303 |
+
AT MOST 3 tanks — a sloppy intercept that bleeds the line
|
| 304 |
+
busts the cap. The kill bar is ≥6, ≥3 tanks alive, fact
|
| 305 |
+
standing, finish before tick 2700. Concentration policies
|
| 306 |
+
fail two zone clauses; a sloppy split that bleeds the line
|
| 307 |
+
fails the loss cap.
|
| 308 |
+
overrides:
|
| 309 |
+
actors:
|
| 310 |
+
- {type: fact, owner: agent, position: [10, 20]}
|
| 311 |
+
- {type: 2tnk, owner: agent, position: [30, 19], stance: 3}
|
| 312 |
+
- {type: 2tnk, owner: agent, position: [30, 20], stance: 3}
|
| 313 |
+
- {type: 2tnk, owner: agent, position: [30, 21], stance: 3}
|
| 314 |
+
- {type: 2tnk, owner: agent, position: [31, 19], stance: 3}
|
| 315 |
+
- {type: 2tnk, owner: agent, position: [31, 20], stance: 3}
|
| 316 |
+
- {type: 2tnk, owner: agent, position: [31, 21], stance: 3}
|
| 317 |
+
# Heavier waves vs easy (3 e1 per lane = 9 total vs easy's 6).
|
| 318 |
+
# Still e1-only so 2 tanks per lane remains the floor (no
|
| 319 |
+
# grenadiers / rockets — those would break the 2tnk
|
| 320 |
+
# dominance). The bleed risk grows with the heavier wave →
|
| 321 |
+
# the units_lost_lte:3 cap is the medium-specific axis.
|
| 322 |
+
- {type: e1, owner: enemy, position: [80, 8], stance: 3, count: 3}
|
| 323 |
+
- {type: e1, owner: enemy, position: [80, 20], stance: 3, count: 3}
|
| 324 |
+
- {type: e1, owner: enemy, position: [80, 32], stance: 3, count: 3}
|
| 325 |
+
- {type: fact, owner: enemy, position: [120, 4]}
|
| 326 |
+
win_condition:
|
| 327 |
+
all_of:
|
| 328 |
+
# Kill bar 6 of 9 e1 — the LOAD-BEARING axis remains the
|
| 329 |
+
# zone clauses below; the kill bar is a soft floor.
|
| 330 |
+
- {units_killed_gte: 6}
|
| 331 |
+
- {own_units_gte: 3}
|
| 332 |
+
- {units_lost_lte: 3}
|
| 333 |
+
- {has_building: fact}
|
| 334 |
+
- {units_in_region_gte: {x: 25, y: 8, radius: 5, n: 1}}
|
| 335 |
+
- {units_in_region_gte: {x: 25, y: 20, radius: 5, n: 1}}
|
| 336 |
+
- {units_in_region_gte: {x: 25, y: 32, radius: 5, n: 1}}
|
| 337 |
+
- {within_ticks: 2700}
|
| 338 |
+
fail_condition:
|
| 339 |
+
any_of:
|
| 340 |
+
- {after_ticks: 2701}
|
| 341 |
+
- {not: {has_building: fact}}
|
| 342 |
+
- {not: {own_units_gte: 1}}
|
| 343 |
+
- {not: {units_lost_lte: 3}}
|
| 344 |
+
max_turns: 50
|
| 345 |
+
|
| 346 |
+
# ── HARD ─────────────────────────────────────────────────────────
|
| 347 |
+
# +1 controlled variable on top of medium: ≥2 agent spawn_point
|
| 348 |
+
# groups so the BASE LATITUDE (and thus the lane GEOMETRY) flips
|
| 349 |
+
# by seed (NORTH base y=14 ⇒ lanes at y=2, y=14, y=26; SOUTH
|
| 350 |
+
# base y=26 ⇒ lanes at y=14, y=26, y=38). The 2/2/2 distributed-
|
| 351 |
+
# defence DOCTRINE generalises across either spawn; a memorised
|
| 352 |
+
# "send 2 tanks to (25,8)" cell does not — the canonical
|
| 353 |
+
# defensive zones flip with the base. Win uses `any_of` across
|
| 354 |
+
# the two candidate three-zone layouts so a model that picks the
|
| 355 |
+
# base-matched zones wins on both seeds. Loss cap and kill bar
|
| 356 |
+
# match medium (units_lost_lte:3, units_killed_gte:6); same clock.
|
| 357 |
+
hard:
|
| 358 |
+
description: >
|
| 359 |
+
Your construction yard sits at a seed-chosen latitude (NORTH
|
| 360 |
+
base y=14 or SOUTH base y=26 — the lane geometry flips with
|
| 361 |
+
the base). SIX defenders are staged at central staging
|
| 362 |
+
(30, base_y). THREE concurrent rush bands attack at the
|
| 363 |
+
base-matched latitudes (3 rifle infantry each). Split the
|
| 364 |
+
defenders 2/2/2 across the three matching defensive zones at
|
| 365 |
+
x=25, keep ≥3 tanks alive, lose AT MOST 3 tanks, destroy ≥6
|
| 366 |
+
rushers, keep the fact standing, finish before tick 2700. A
|
| 367 |
+
single memorised "send to (25,8)/(25,20)/(25,32)" plan wins
|
| 368 |
+
one seed and loses the other; the 2/2/2 doctrine matched to
|
| 369 |
+
the actual base latitude generalises.
|
| 370 |
+
overrides:
|
| 371 |
+
actors:
|
| 372 |
+
# spawn_point 0 — NORTH base (y=14). All buildings/units
|
| 373 |
+
# tagged spawn_point: 0; lane geometry is y=2 / y=14 / y=26.
|
| 374 |
+
- {type: fact, owner: agent, position: [10, 14], spawn_point: 0}
|
| 375 |
+
- {type: 2tnk, owner: agent, position: [30, 13], stance: 3, spawn_point: 0}
|
| 376 |
+
- {type: 2tnk, owner: agent, position: [30, 14], stance: 3, spawn_point: 0}
|
| 377 |
+
- {type: 2tnk, owner: agent, position: [30, 15], stance: 3, spawn_point: 0}
|
| 378 |
+
- {type: 2tnk, owner: agent, position: [31, 13], stance: 3, spawn_point: 0}
|
| 379 |
+
- {type: 2tnk, owner: agent, position: [31, 14], stance: 3, spawn_point: 0}
|
| 380 |
+
- {type: 2tnk, owner: agent, position: [31, 15], stance: 3, spawn_point: 0}
|
| 381 |
+
# spawn_point 1 — SOUTH base (y=26). Mirrored layout; lane
|
| 382 |
+
# geometry is y=14 / y=26 / y=38.
|
| 383 |
+
- {type: fact, owner: agent, position: [10, 26], spawn_point: 1}
|
| 384 |
+
- {type: 2tnk, owner: agent, position: [30, 25], stance: 3, spawn_point: 1}
|
| 385 |
+
- {type: 2tnk, owner: agent, position: [30, 26], stance: 3, spawn_point: 1}
|
| 386 |
+
- {type: 2tnk, owner: agent, position: [30, 27], stance: 3, spawn_point: 1}
|
| 387 |
+
- {type: 2tnk, owner: agent, position: [31, 25], stance: 3, spawn_point: 1}
|
| 388 |
+
- {type: 2tnk, owner: agent, position: [31, 26], stance: 3, spawn_point: 1}
|
| 389 |
+
- {type: 2tnk, owner: agent, position: [31, 27], stance: 3, spawn_point: 1}
|
| 390 |
+
# SIX enemy bands (3 per candidate base): NORTH base lanes
|
| 391 |
+
# at y=2 / y=14 / y=26 AND SOUTH base lanes at y=14 / y=26
|
| 392 |
+
# / y=38. The shared y=14 + y=26 bands appear once each
|
| 393 |
+
# (they serve as the OUTER lane for one base and the
|
| 394 |
+
# CENTRE lane for the other). y=2 is the NORTH-base
|
| 395 |
+
# northern outer; y=38 is the SOUTH-base southern outer.
|
| 396 |
+
# The agent rusher centroid sits at the spawn-active y so
|
| 397 |
+
# each band's centroid-seeking approach lands in the
|
| 398 |
+
# spawn-matched defensive geometry.
|
| 399 |
+
- {type: e1, owner: enemy, position: [80, 2], stance: 3, count: 3}
|
| 400 |
+
- {type: e1, owner: enemy, position: [80, 14], stance: 3, count: 3}
|
| 401 |
+
- {type: e1, owner: enemy, position: [80, 26], stance: 3, count: 3}
|
| 402 |
+
- {type: e1, owner: enemy, position: [80, 38], stance: 3, count: 3}
|
| 403 |
+
# Inert enemy `fact` marker far east (anti-DRAW). Placed at
|
| 404 |
+
# (120,20) — between the two candidate base latitudes so it
|
| 405 |
+
# never sits on a rusher-band approach line.
|
| 406 |
+
- {type: fact, owner: enemy, position: [120, 20]}
|
| 407 |
+
# `any_of` across the two candidate three-zone layouts. Spawn 0
|
| 408 |
+
# (NORTH base y=14) ⇒ zones at (25,2), (25,14), (25,26).
|
| 409 |
+
# Spawn 1 (SOUTH base y=26) ⇒ zones at (25,14), (25,26), (25,38).
|
| 410 |
+
# A model that always picks the same three zones wins one seed
|
| 411 |
+
# and loses the other; matching zones to the actual base
|
| 412 |
+
# latitude generalises.
|
| 413 |
+
win_condition:
|
| 414 |
+
all_of:
|
| 415 |
+
# Kill bar 6 of 9 e1 reaching the active-spawn lanes (the
|
| 416 |
+
# off-spawn outer band doesn't engage in time — its targets
|
| 417 |
+
# are the wrong-latitude defenders). LOAD-BEARING axis is
|
| 418 |
+
# the `any_of` over the two candidate three-zone layouts.
|
| 419 |
+
- {units_killed_gte: 6}
|
| 420 |
+
- {own_units_gte: 3}
|
| 421 |
+
- {units_lost_lte: 3}
|
| 422 |
+
- {has_building: fact}
|
| 423 |
+
- {within_ticks: 2700}
|
| 424 |
+
- any_of:
|
| 425 |
+
- all_of:
|
| 426 |
+
- {units_in_region_gte: {x: 25, y: 2, radius: 5, n: 1}}
|
| 427 |
+
- {units_in_region_gte: {x: 25, y: 14, radius: 5, n: 1}}
|
| 428 |
+
- {units_in_region_gte: {x: 25, y: 26, radius: 5, n: 1}}
|
| 429 |
+
- all_of:
|
| 430 |
+
- {units_in_region_gte: {x: 25, y: 14, radius: 5, n: 1}}
|
| 431 |
+
- {units_in_region_gte: {x: 25, y: 26, radius: 5, n: 1}}
|
| 432 |
+
- {units_in_region_gte: {x: 25, y: 38, radius: 5, n: 1}}
|
| 433 |
+
fail_condition:
|
| 434 |
+
any_of:
|
| 435 |
+
- {after_ticks: 2701}
|
| 436 |
+
- {not: {has_building: fact}}
|
| 437 |
+
- {not: {own_units_gte: 1}}
|
| 438 |
+
- {not: {units_lost_lte: 3}}
|
| 439 |
+
max_turns: 50
|
tests/test_def_multi_direction.py
ADDED
|
@@ -0,0 +1,328 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""def-multi-direction scenario pack, full loop on Rust.
|
| 2 |
+
|
| 3 |
+
REASONING capability — DISTRIBUTED DEFENSE across three concurrent
|
| 4 |
+
attack lanes (NORTH y=8, MID y=20, SOUTH y=32 — or the spawn-rotated
|
| 5 |
+
hard-tier equivalents). The load-bearing predicate is THREE
|
| 6 |
+
`units_in_region_gte` defensive-zone clauses, each requiring ≥2
|
| 7 |
+
tanks: any concentration policy satisfies one clause and fails the
|
| 8 |
+
other two, AND the unopposed lanes' rushers raze the construction
|
| 9 |
+
yard (`has_building:fact` also bites).
|
| 10 |
+
|
| 11 |
+
The bar:
|
| 12 |
+
|
| 13 |
+
* Three `units_in_region_gte: n: 1` clauses ⇒ the 2/2/2 distributed
|
| 14 |
+
allocation is the safe solution (the n=1 floor leaves attrition
|
| 15 |
+
headroom on top of the 2-per-zone doctrine); concentration
|
| 16 |
+
policies satisfy at most one zone and fail the other two.
|
| 17 |
+
* `units_killed_gte: N` paired with the band sizes so an active
|
| 18 |
+
2/2/2 defence clears the bar comfortably while stall / wrong-
|
| 19 |
+
concentration do not (kills mostly come from the bot's rusher
|
| 20 |
+
funnel reaching whichever cluster the agent left at the centre,
|
| 21 |
+
but the zone clauses still gate the win).
|
| 22 |
+
* `units_lost_lte: 3` (medium + hard) ⇒ attrition cap so a sloppy
|
| 23 |
+
intercept that bleeds the line busts even if the zone clauses
|
| 24 |
+
hold.
|
| 25 |
+
* `has_building: fact` ⇒ the fact survival clause stays as an
|
| 26 |
+
always-on safety net.
|
| 27 |
+
* `within_ticks: 2700` paired with `after_ticks: 2701` ⇒ a non-
|
| 28 |
+
finisher is a real reachable timeout LOSS, never a draw.
|
| 29 |
+
* Hard tier defines TWO agent spawn_point groups (NORTH base y=14 /
|
| 30 |
+
SOUTH base y=26) so the lane geometry flips per seed; the win
|
| 31 |
+
predicate is `any_of` over the two matching three-zone layouts,
|
| 32 |
+
enforcing the doctrine over a memorised single-zone trio.
|
| 33 |
+
|
| 34 |
+
These tests prove deterministically (no model / no network):
|
| 35 |
+
|
| 36 |
+
* the intended 2/2/2 split distributed-defence policy WINS every
|
| 37 |
+
level + every hard seed;
|
| 38 |
+
* stall, concentrate-NORTH, concentrate-MID, and concentrate-SOUTH
|
| 39 |
+
all LOSE (real LOSS, not a draw);
|
| 40 |
+
* the hard tier defines ≥2 spawn_point groups so a memorised
|
| 41 |
+
lane-zone trio cannot generalise across seeds.
|
| 42 |
+
"""
|
| 43 |
+
|
| 44 |
+
from __future__ import annotations
|
| 45 |
+
|
| 46 |
+
import pytest
|
| 47 |
+
|
| 48 |
+
pytest.importorskip("openra_train", reason="Rust env wheel not installed")
|
| 49 |
+
pytest.importorskip(
|
| 50 |
+
"openra_rl_training", reason="Rust env wheel not installed"
|
| 51 |
+
)
|
| 52 |
+
|
| 53 |
+
from openra_bench.eval_core import run_level
|
| 54 |
+
from openra_bench.scenarios import load_pack
|
| 55 |
+
from openra_bench.scenarios.loader import PACKS_DIR, compile_level
|
| 56 |
+
|
| 57 |
+
PACK = PACKS_DIR / "def-multi-direction.yaml"
|
| 58 |
+
LEVELS = ("easy", "medium", "hard")
|
| 59 |
+
SEEDS = (1, 2, 3, 4)
|
| 60 |
+
|
| 61 |
+
|
| 62 |
+
# ── scripted policies ────────────────────────────────────────────────
|
| 63 |
+
|
| 64 |
+
|
| 65 |
+
def stall(rs, C):
|
| 66 |
+
"""Observe-only. Tanks stay at central staging, never reach the
|
| 67 |
+
(25, *) defensive zones; auto-engagement at stance:3 racks up
|
| 68 |
+
some kills as the MID band arrives, but the N and S waves walk
|
| 69 |
+
untouched to the fact and raze it → LOSS."""
|
| 70 |
+
return [C.observe()]
|
| 71 |
+
|
| 72 |
+
|
| 73 |
+
def _own_tank_ids(rs):
|
| 74 |
+
own = [u for u in rs.get("units_summary", []) if u.get("type") == "2tnk"]
|
| 75 |
+
return sorted(str(u["id"]) for u in own)
|
| 76 |
+
|
| 77 |
+
|
| 78 |
+
def _own_tank_centroid_y(rs):
|
| 79 |
+
own = [u for u in rs.get("units_summary", []) if u.get("type") == "2tnk"]
|
| 80 |
+
if not own:
|
| 81 |
+
return 20
|
| 82 |
+
return sum(u["cell_y"] for u in own) // len(own)
|
| 83 |
+
|
| 84 |
+
|
| 85 |
+
def make_concentrate(zone_y):
|
| 86 |
+
"""Send ALL tanks to the single defensive zone at (25, zone_y).
|
| 87 |
+
Satisfies ONE zone clause; the other two clauses (and the
|
| 88 |
+
unopposed-lane fact-survival clause) both fail → LOSS."""
|
| 89 |
+
|
| 90 |
+
def policy(rs, C):
|
| 91 |
+
ids = _own_tank_ids(rs)
|
| 92 |
+
if not ids:
|
| 93 |
+
return [C.observe()]
|
| 94 |
+
return [C.attack_move(ids, target_x=25, target_y=zone_y)]
|
| 95 |
+
|
| 96 |
+
return policy
|
| 97 |
+
|
| 98 |
+
|
| 99 |
+
def make_intended():
|
| 100 |
+
"""Stable per-tank 2/2/2 assignment across the three matching
|
| 101 |
+
defensive zones (N / MID / S relative to the agent's actual base
|
| 102 |
+
latitude — read from the fact's cell_y so the hard-tier spawn
|
| 103 |
+
rotation generalises). On the first call, sort tank ids, detect
|
| 104 |
+
base latitude from the fact position, and assign 2 tanks per
|
| 105 |
+
lane. Reissue commands only for tanks still alive."""
|
| 106 |
+
|
| 107 |
+
state = {"assignments": None}
|
| 108 |
+
|
| 109 |
+
def _zone_ys(rs):
|
| 110 |
+
# Detect base latitude from the tank centroid (the tanks
|
| 111 |
+
# always start on the base row; `buildings_summary` is not
|
| 112 |
+
# exposed in `RustObsAdapter.render_state` so we rely on the
|
| 113 |
+
# tank cluster's median y on turn 1).
|
| 114 |
+
base_y = _own_tank_centroid_y(rs)
|
| 115 |
+
# Outer lanes ±6 from base (NORTH base y=14 ⇒ 2/14/26;
|
| 116 |
+
# SOUTH base y=26 ⇒ 14/26/38; default base y=20 ⇒ 8/20/32).
|
| 117 |
+
if base_y <= 16:
|
| 118 |
+
return (2, 14, 26)
|
| 119 |
+
if base_y >= 24:
|
| 120 |
+
return (14, 26, 38)
|
| 121 |
+
return (8, 20, 32)
|
| 122 |
+
|
| 123 |
+
def policy(rs, C):
|
| 124 |
+
ids = _own_tank_ids(rs)
|
| 125 |
+
if not ids:
|
| 126 |
+
return [C.observe()]
|
| 127 |
+
if state["assignments"] is None:
|
| 128 |
+
n = len(ids)
|
| 129 |
+
two = max(1, n // 3)
|
| 130 |
+
n_zone, m_zone, s_zone = _zone_ys(rs)
|
| 131 |
+
state["assignments"] = {
|
| 132 |
+
("N", n_zone): ids[:two],
|
| 133 |
+
("M", m_zone): ids[two:2 * two],
|
| 134 |
+
("S", s_zone): ids[2 * two:],
|
| 135 |
+
}
|
| 136 |
+
alive = set(ids)
|
| 137 |
+
cmds = []
|
| 138 |
+
for (_lane, zy), tids in state["assignments"].items():
|
| 139 |
+
live = [i for i in tids if i in alive]
|
| 140 |
+
if live:
|
| 141 |
+
cmds.append(C.attack_move(live, target_x=25, target_y=zy))
|
| 142 |
+
return cmds or [C.observe()]
|
| 143 |
+
|
| 144 |
+
return policy
|
| 145 |
+
|
| 146 |
+
|
| 147 |
+
# ── structural checks (no engine) ────────────────────────────────────
|
| 148 |
+
|
| 149 |
+
|
| 150 |
+
def test_pack_loads_and_metadata_is_complete():
|
| 151 |
+
pack = load_pack(PACK)
|
| 152 |
+
assert pack.meta.id == "def-multi-direction"
|
| 153 |
+
assert pack.meta.capability == "reasoning"
|
| 154 |
+
anchors = pack.meta.benchmark_anchor or []
|
| 155 |
+
# The required anchors from the Wave-7 spec.
|
| 156 |
+
joined = " | ".join(a.lower() for a in anchors)
|
| 157 |
+
assert "distributed-systems load balancing" in joined, anchors
|
| 158 |
+
assert "graph min-cut" in joined, anchors
|
| 159 |
+
assert "military multi-front" in joined, anchors
|
| 160 |
+
# Rusher bot wired through to the engine for every level.
|
| 161 |
+
for lvl in LEVELS:
|
| 162 |
+
c = compile_level(pack, lvl)
|
| 163 |
+
assert c.map_supported
|
| 164 |
+
enemy = c.scenario.enemy
|
| 165 |
+
bot = getattr(enemy, "bot_type", None) or getattr(enemy, "bot", None)
|
| 166 |
+
# `rusher`: bands converge on the agent centroid. The
|
| 167 |
+
# distributed-defence test is enforced by the THREE
|
| 168 |
+
# `units_in_region_gte` zone clauses in the win predicate
|
| 169 |
+
# (a concentration satisfies one zone and fails two);
|
| 170 |
+
# `hunt` was smoke-tested but leaves the e1 bands idle at
|
| 171 |
+
# spawn for most of the tick budget so neither the kill bar
|
| 172 |
+
# nor the engagement-mediated zone-coverage attrition fire
|
| 173 |
+
# (see pack docstring).
|
| 174 |
+
assert str(bot).lower() == "rusher", (lvl, bot)
|
| 175 |
+
|
| 176 |
+
|
| 177 |
+
@pytest.mark.parametrize("level", LEVELS)
|
| 178 |
+
def test_every_level_has_a_reachable_timeout_fail(level):
|
| 179 |
+
"""Non-win must be a real LOSS: the `after_ticks` fail must be
|
| 180 |
+
strictly below the tick reachable at max_turns (≤90 ticks/step
|
| 181 |
+
in interrupt mode)."""
|
| 182 |
+
c = compile_level(load_pack(PACK), level)
|
| 183 |
+
assert c.fail_condition is not None
|
| 184 |
+
fc = c.fail_condition.model_dump(exclude_none=True)
|
| 185 |
+
deadline = None
|
| 186 |
+
for clause in fc.get("any_of", []) or []:
|
| 187 |
+
if "after_ticks" in clause:
|
| 188 |
+
deadline = int(clause["after_ticks"])
|
| 189 |
+
assert deadline is not None, f"{level}: no after_ticks fail clause"
|
| 190 |
+
reachable = 93 + 90 * (c.max_turns - 1)
|
| 191 |
+
assert deadline < reachable, (
|
| 192 |
+
f"{level}: deadline {deadline} unreachable within "
|
| 193 |
+
f"{c.max_turns} turns (max tick {reachable}) → draw degeneracy"
|
| 194 |
+
)
|
| 195 |
+
|
| 196 |
+
|
| 197 |
+
@pytest.mark.parametrize("level", LEVELS)
|
| 198 |
+
def test_every_level_requires_three_defensive_zones(level):
|
| 199 |
+
"""The distribution axis must be load-bearing: every level's win
|
| 200 |
+
condition must require ≥3 `units_in_region_gte` clauses (the
|
| 201 |
+
three concurrent-lane defensive-zone discriminators). Any
|
| 202 |
+
concentration policy fails at least two of them."""
|
| 203 |
+
c = compile_level(load_pack(PACK), level)
|
| 204 |
+
win = c.win_condition.model_dump(exclude_none=True)
|
| 205 |
+
flat = str(win)
|
| 206 |
+
assert flat.count("units_in_region_gte") >= 3, (level, win)
|
| 207 |
+
|
| 208 |
+
|
| 209 |
+
def test_hard_has_two_spawn_point_groups():
|
| 210 |
+
"""Hard-tier contract: ≥2 distinct seed-driven spawn_point groups
|
| 211 |
+
so the base latitude (and lane geometry) flips by seed
|
| 212 |
+
(anti-memorisation of an absolute lane trio — the distributed-
|
| 213 |
+
defence doctrine must generalise)."""
|
| 214 |
+
c = compile_level(load_pack(PACK), "hard")
|
| 215 |
+
groups = {
|
| 216 |
+
a.spawn_point for a in c.scenario.actors
|
| 217 |
+
if a.owner == "agent" and a.spawn_point is not None
|
| 218 |
+
}
|
| 219 |
+
assert groups == {0, 1}, groups
|
| 220 |
+
# In-bounds check (rush-hour-arena playable y ≈ 2..38).
|
| 221 |
+
for a in c.scenario.actors:
|
| 222 |
+
x, y = a.position
|
| 223 |
+
assert 2 <= x <= 126 and 2 <= y <= 38, (a.type, a.position)
|
| 224 |
+
|
| 225 |
+
|
| 226 |
+
# ── solvency: intended WINS every level + every hard seed ────────────
|
| 227 |
+
|
| 228 |
+
|
| 229 |
+
@pytest.mark.parametrize("level", LEVELS)
|
| 230 |
+
def test_intended_split_wins_every_level_and_seed(level):
|
| 231 |
+
c = compile_level(load_pack(PACK), level)
|
| 232 |
+
for seed in SEEDS:
|
| 233 |
+
r = run_level(c, make_intended(), seed=seed)
|
| 234 |
+
assert r.outcome == "win", (
|
| 235 |
+
f"{level} seed{seed}: intended 2/2/2 distributed defence "
|
| 236 |
+
f"must WIN; got {r.outcome} "
|
| 237 |
+
f"(tick={r.signals.game_tick}, "
|
| 238 |
+
f"kills={r.signals.units_killed}, "
|
| 239 |
+
f"lost={r.signals.units_lost})"
|
| 240 |
+
)
|
| 241 |
+
|
| 242 |
+
|
| 243 |
+
# ── no-cheat: every wrong / lazy policy LOSES (real LOSS, not draw) ──
|
| 244 |
+
|
| 245 |
+
|
| 246 |
+
@pytest.mark.parametrize("level", LEVELS)
|
| 247 |
+
def test_stall_loses_every_level_and_seed(level):
|
| 248 |
+
"""Stall (observe only) — tanks never reach any defensive zone,
|
| 249 |
+
and the three concurrent waves (especially the unopposed N + S
|
| 250 |
+
bands) raze the fact → real LOSS on `not has_building:fact`
|
| 251 |
+
and/or all three zone clauses empty + `after_ticks` deadline."""
|
| 252 |
+
c = compile_level(load_pack(PACK), level)
|
| 253 |
+
for seed in SEEDS:
|
| 254 |
+
r = run_level(c, stall, seed=seed)
|
| 255 |
+
assert r.outcome == "loss", (
|
| 256 |
+
f"{level} seed{seed} stall: must LOSE (real fail, not draw); "
|
| 257 |
+
f"got {r.outcome} (tick={r.signals.game_tick}, "
|
| 258 |
+
f"kills={r.signals.units_killed}, "
|
| 259 |
+
f"lost={r.signals.units_lost})"
|
| 260 |
+
)
|
| 261 |
+
|
| 262 |
+
|
| 263 |
+
@pytest.mark.parametrize("level", LEVELS)
|
| 264 |
+
def test_concentrate_north_loses_every_level_and_seed(level):
|
| 265 |
+
"""Concentrate-NORTH: all 6 tanks at the N defensive zone. The
|
| 266 |
+
N zone clause satisfies but MID and SOUTH zone clauses fail
|
| 267 |
+
(0 < 2), AND the unopposed MID + SOUTH waves raze the fact
|
| 268 |
+
→ real LOSS."""
|
| 269 |
+
c = compile_level(load_pack(PACK), level)
|
| 270 |
+
# Use the N zone latitude matched to default base (easy/medium
|
| 271 |
+
# base at y=20 ⇒ N=8). On hard the default N (8) is OFF either
|
| 272 |
+
# candidate layout, so concentrate-NORTH at y=8 fails ALL zone
|
| 273 |
+
# clauses on both spawns — strictly the harder failure mode.
|
| 274 |
+
pol = make_concentrate(8)
|
| 275 |
+
for seed in SEEDS:
|
| 276 |
+
r = run_level(c, pol, seed=seed)
|
| 277 |
+
assert r.outcome == "loss", (
|
| 278 |
+
f"{level} seed{seed} concentrate-NORTH: must LOSE; got "
|
| 279 |
+
f"{r.outcome} (tick={r.signals.game_tick}, "
|
| 280 |
+
f"kills={r.signals.units_killed}, "
|
| 281 |
+
f"lost={r.signals.units_lost})"
|
| 282 |
+
)
|
| 283 |
+
|
| 284 |
+
|
| 285 |
+
@pytest.mark.parametrize("level", LEVELS)
|
| 286 |
+
def test_concentrate_mid_loses_every_level_and_seed(level):
|
| 287 |
+
"""Concentrate-MID: all 6 tanks at the MID defensive zone
|
| 288 |
+
(25,20). The N and S zone clauses fail and the N + S waves walk
|
| 289 |
+
around the central cluster to raze the fact → LOSS."""
|
| 290 |
+
c = compile_level(load_pack(PACK), level)
|
| 291 |
+
pol = make_concentrate(20)
|
| 292 |
+
for seed in SEEDS:
|
| 293 |
+
r = run_level(c, pol, seed=seed)
|
| 294 |
+
assert r.outcome == "loss", (
|
| 295 |
+
f"{level} seed{seed} concentrate-MID: must LOSE; got "
|
| 296 |
+
f"{r.outcome} (tick={r.signals.game_tick}, "
|
| 297 |
+
f"kills={r.signals.units_killed}, "
|
| 298 |
+
f"lost={r.signals.units_lost})"
|
| 299 |
+
)
|
| 300 |
+
|
| 301 |
+
|
| 302 |
+
@pytest.mark.parametrize("level", LEVELS)
|
| 303 |
+
def test_concentrate_south_loses_every_level_and_seed(level):
|
| 304 |
+
"""Concentrate-SOUTH: all 6 tanks at the S defensive zone. Same
|
| 305 |
+
failure mode as concentrate-NORTH but mirrored — N and MID zone
|
| 306 |
+
clauses fail and the unopposed N + MID waves raze the fact."""
|
| 307 |
+
c = compile_level(load_pack(PACK), level)
|
| 308 |
+
pol = make_concentrate(32)
|
| 309 |
+
for seed in SEEDS:
|
| 310 |
+
r = run_level(c, pol, seed=seed)
|
| 311 |
+
assert r.outcome == "loss", (
|
| 312 |
+
f"{level} seed{seed} concentrate-SOUTH: must LOSE; got "
|
| 313 |
+
f"{r.outcome} (tick={r.signals.game_tick}, "
|
| 314 |
+
f"kills={r.signals.units_killed}, "
|
| 315 |
+
f"lost={r.signals.units_lost})"
|
| 316 |
+
)
|
| 317 |
+
|
| 318 |
+
|
| 319 |
+
# ── determinism ──────────────────────────────────────────────────────
|
| 320 |
+
|
| 321 |
+
|
| 322 |
+
def test_intended_run_is_deterministic_on_easy():
|
| 323 |
+
c = compile_level(load_pack(PACK), "easy")
|
| 324 |
+
a = run_level(c, make_intended(), seed=2)
|
| 325 |
+
b = run_level(c, make_intended(), seed=2)
|
| 326 |
+
assert (a.outcome, a.turns) == (b.outcome, b.turns), (
|
| 327 |
+
"same seed must be deterministic"
|
| 328 |
+
)
|
tests/test_hard_tier.py
CHANGED
|
@@ -241,6 +241,16 @@ UPGRADED = [
|
|
| 241 |
# bait/strike vector flips per seed.
|
| 242 |
"coord-diversionary-attack",
|
| 243 |
"def-in-depth",
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 244 |
"def-surprise-flank-react",
|
| 245 |
# Wave-5 Group F cash-reserve / treasury management pack (SC2 cash
|
| 246 |
# overflow / financial runway / operational reserve anchor). Hard
|
|
|
|
| 241 |
# bait/strike vector flips per seed.
|
| 242 |
"coord-diversionary-attack",
|
| 243 |
"def-in-depth",
|
| 244 |
+
# Wave-7 reasoning pack — distributed defence across three
|
| 245 |
+
# concurrent attack lanes (graph min-cut / military multi-front /
|
| 246 |
+
# distributed-systems load-balancing anchor). Hard tier defines
|
| 247 |
+
# two agent spawn_point groups (NORTH base y=14 / SOUTH base y=26)
|
| 248 |
+
# round-robined by seed; the base latitude (and thus the matching
|
| 249 |
+
# three-zone defensive layout) flips per seed, so a single
|
| 250 |
+
# memorised "send to (25,8)/(25,20)/(25,32)" plan wins one seed
|
| 251 |
+
# and loses the other — the 2/2/2 doctrine must be matched to
|
| 252 |
+
# the actual base latitude.
|
| 253 |
+
"def-multi-direction",
|
| 254 |
"def-surprise-flank-react",
|
| 255 |
# Wave-5 Group F cash-reserve / treasury management pack (SC2 cash
|
| 256 |
# overflow / financial runway / operational reserve anchor). Hard
|