Spaces:
Running
feat(scenarios): Wave-5 salvage — 16 packs rescued from worktree branches
Browse files16 Wave-5 agents finished authoring (YAML + scripted-policy tests) but
their pushes were blocked / outputs truncated. Salvaged from the
untracked working tree and committed from the main session:
Defense-positioning (3 net-new, 1 was already registered):
- def-in-depth (military DiD doctrine / security multi-layer)
- def-surprise-flank-react (adversarial robustness / CICERO deception)
- def-tower-line-vs-cluster (graph min-cut / military bunker)
Coordination (5):
- coord-converge-on-target (SC2 triple-prong / military pincer)
- coord-cover-and-move (SC2 / military bounding overwatch)
- coord-diversionary-attack (SC2 split-attack / Sun Tzu diversion)
- coord-relay-attack (SC2 attack-wave / SMAC relay)
- coord-squad-handoff (Watch-And-Help / SMAC squad sequencing)
Long-horizon (5):
- lh-100-turn-marathon-survival (lmgame multi-hour / SC2LE endurance)
- lh-build-army-coordinate-multifront-attack (SC2 macro / mil ops)
- lh-defense-tech-second-base (SC2 secure-expand-tech / PlanBench)
- lh-multi-checkpoint-5-plus (PERT / PlanBench long-sequencing)
- lh-scout-react-counter (CICERO info-loop / PlanBench replanning)
Economy (3):
- econ-cash-reserve-management (SC2 / treasury working capital)
- econ-recover-from-zero-cash (SC2 turtle-recovery / turnaround)
- econ-target-cash-amount-by-deadline (SC2 / budget-by-date)
All 16 have ≥2 spawn groups on hard; all registered in
test_hard_tier.py UPGRADED (12 were already registered by their
agents' worktree edits that did get pushed via shared FS).
- openra_bench/scenarios/packs/coord-converge-on-target.yaml +263 -0
- openra_bench/scenarios/packs/coord-cover-and-move.yaml +330 -0
- openra_bench/scenarios/packs/coord-diversionary-attack.yaml +336 -0
- openra_bench/scenarios/packs/coord-relay-attack.yaml +247 -0
- openra_bench/scenarios/packs/coord-squad-handoff.yaml +203 -0
- openra_bench/scenarios/packs/def-in-depth.yaml +284 -0
- openra_bench/scenarios/packs/def-surprise-flank-react.yaml +441 -0
- openra_bench/scenarios/packs/def-tower-line-vs-cluster.yaml +232 -0
- openra_bench/scenarios/packs/econ-cash-reserve-management.yaml +232 -0
- openra_bench/scenarios/packs/econ-recover-from-zero-cash.yaml +285 -0
- openra_bench/scenarios/packs/econ-target-cash-amount-by-deadline.yaml +197 -0
- openra_bench/scenarios/packs/lh-100-turn-marathon-survival.yaml +357 -0
- openra_bench/scenarios/packs/lh-build-army-coordinate-multifront-attack.yaml +322 -0
- openra_bench/scenarios/packs/lh-defense-tech-second-base.yaml +370 -0
- openra_bench/scenarios/packs/lh-multi-checkpoint-5-plus.yaml +324 -0
- openra_bench/scenarios/packs/lh-scout-react-counter.yaml +368 -0
- tests/test_coord_converge_on_target.py +378 -0
- tests/test_coord_cover_and_move.py +395 -0
- tests/test_coord_diversionary_attack.py +572 -0
- tests/test_coord_relay_attack.py +387 -0
- tests/test_coord_squad_handoff.py +360 -0
- tests/test_def_in_depth.py +259 -0
- tests/test_def_surprise_flank_react.py +318 -0
- tests/test_def_tower_line_vs_cluster.py +276 -0
- tests/test_econ_cash_reserve_management.py +315 -0
- tests/test_econ_recover_from_zero_cash.py +502 -0
- tests/test_econ_target_cash_amount_by_deadline.py +304 -0
- tests/test_hard_tier.py +35 -0
- tests/test_lh_100_turn_marathon_survival.py +352 -0
- tests/test_lh_build_army_coordinate_multifront_attack.py +319 -0
- tests/test_lh_defense_tech_second_base.py +552 -0
- tests/test_lh_multi_checkpoint_5_plus.py +413 -0
- tests/test_lh_scout_react_counter.py +418 -0
|
@@ -0,0 +1,263 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# coord-converge-on-target.yaml
|
| 2 |
+
#
|
| 3 |
+
# ACTION capability — triple-prong convergent attack. Three armoured
|
| 4 |
+
# squads start at three different bearings (NORTH, WEST-of-objective,
|
| 5 |
+
# SOUTH) and must CONVERGE simultaneously on a single defended enemy
|
| 6 |
+
# objective (`fact`) at mid-east. Each squad ALONE is killed by the
|
| 7 |
+
# leashed `guard` defenders before the fact falls; TWO squads
|
| 8 |
+
# converging may destroy the fact but trade out the strike force
|
| 9 |
+
# (surviving tank count in the objective region falls below n=6); only
|
| 10 |
+
# when ALL THREE squads arrive within a tight common window do they
|
| 11 |
+
# overwhelm the defenders cleanly enough to leave ≥6 tanks in the
|
| 12 |
+
# objective region with the yard destroyed.
|
| 13 |
+
#
|
| 14 |
+
# Real-world anchor: military convergent / envelopment attack and SC2
|
| 15 |
+
# triple-prong assault — multiple columns striking from different
|
| 16 |
+
# bearings at the same instant to overwhelm a defender that would
|
| 17 |
+
# defeat any single column. Robotics analogue: synchronous multi-team
|
| 18 |
+
# rendezvous on a shared objective under a tight common deadline.
|
| 19 |
+
#
|
| 20 |
+
# Engine notes (per CLAUDE.md):
|
| 21 |
+
# - `fact` is `MustBeDestroyed`: razing it would auto-`done` the
|
| 22 |
+
# engine and a within-ticks check could collapse to DRAW. A
|
| 23 |
+
# SENTINEL enemy `fact` is placed in the far north (out of the
|
| 24 |
+
# combat zone) so the episode persists past the objective fact's
|
| 25 |
+
# destruction long enough for the win/fail predicates to evaluate.
|
| 26 |
+
# - Bot `guard` (GUARD_AGGRO ≈ 16, GUARD_LEASH ≈ 18) holds post,
|
| 27 |
+
# auto-fires in range, lunges at the nearest foe within ≈16
|
| 28 |
+
# cells, snaps back past ≈18. The e3 rocket infantry is the
|
| 29 |
+
# anti-tank Dragon: each guard chews a 2tnk in a handful of
|
| 30 |
+
# shots, so a single squad's commit on the cluster is lethal.
|
| 31 |
+
# - `units_in_region_gte` ≥6 on the 9 tanks is load-bearing: a
|
| 32 |
+
# two-squad converge (6 tanks) typically destroys the fact but
|
| 33 |
+
# trades the whole strike force (≤0 tanks left in region), so a
|
| 34 |
+
# partial converge LOSES on the predicate even when the fact
|
| 35 |
+
# happens to fall. A single squad alone is wiped before the fact
|
| 36 |
+
# falls. Three squads converging arrive together and overwhelm:
|
| 37 |
+
# ≥6 tanks survive inside the (100,20, r=8) region while the
|
| 38 |
+
# cluster + fact go down.
|
| 39 |
+
# - Squad positions are balanced equidistant (~32-34 cells from the
|
| 40 |
+
# objective) so a naive simultaneous attack-move from all three
|
| 41 |
+
# squads ACTUALLY ARRIVES TOGETHER; widely-unbalanced edge spawns
|
| 42 |
+
# would force the closer squads into the cluster alone first and
|
| 43 |
+
# a 3-prong policy would degenerate to a serial 1-then-1-then-1
|
| 44 |
+
# attack defeated piecewise (the calibration bar still requires
|
| 45 |
+
# simultaneous arrival).
|
| 46 |
+
# - Enemy actors are NOT filtered by `spawn_point` (CLAUDE.md). The
|
| 47 |
+
# hard tier varies the AGENT staging — the lateral (third) squad
|
| 48 |
+
# starts WEST of objective in spawn 0 and EAST of objective in
|
| 49 |
+
# spawn 1, flipping its ingress bearing — while the defender
|
| 50 |
+
# cluster stays symmetric.
|
| 51 |
+
|
| 52 |
+
meta:
|
| 53 |
+
id: coord-converge-on-target
|
| 54 |
+
title: 'Convergent Attack — Three Squads, One Defended Target'
|
| 55 |
+
capability: action
|
| 56 |
+
real_world_meaning: >
|
| 57 |
+
Three armoured columns start on three different bearings (NORTH,
|
| 58 |
+
WEST-of-objective, SOUTH) and must converge on a single defended
|
| 59 |
+
enemy construction yard from three different directions AT THE
|
| 60 |
+
SAME TIME. Any one column alone is killed by the defenders before
|
| 61 |
+
the yard falls; two columns may destroy the yard but trade out
|
| 62 |
+
the strike force (surviving tank count in the objective region
|
| 63 |
+
drops below the required threshold); only when all three columns
|
| 64 |
+
arrive on the objective within a tight common window does the
|
| 65 |
+
combined force overwhelm the defenders cleanly. The advertised
|
| 66 |
+
capability is synchronous multi-fleet convergence on a shared
|
| 67 |
+
objective — a single-column tour or a serialized two-column
|
| 68 |
+
attempt loses on attrition, a stall loses on the clock.
|
| 69 |
+
robotics_analogue: >
|
| 70 |
+
Synchronous multi-robot rendezvous on a contested objective: each
|
| 71 |
+
team must dispatch on a different ingress vector so that all
|
| 72 |
+
teams arrive at the goal within a bounded common window;
|
| 73 |
+
serialising one team after another lets the contesting agents
|
| 74 |
+
defeat each team in detail and prevents the joint payload
|
| 75 |
+
threshold (≥n teammates surviving on the goal) from holding.
|
| 76 |
+
benchmark_anchor:
|
| 77 |
+
- "SC2 triple-prong assault timing"
|
| 78 |
+
- "military convergent attack / pincer movement"
|
| 79 |
+
- "SMAC squad convergence"
|
| 80 |
+
- "envelopment doctrine: hit from multiple directions simultaneously"
|
| 81 |
+
author: openra-bench
|
| 82 |
+
|
| 83 |
+
base_map: rush-hour-arena
|
| 84 |
+
|
| 85 |
+
base:
|
| 86 |
+
agent: {faction: allies, cash: 0}
|
| 87 |
+
enemy: {faction: soviet, cash: 0, bot_type: guard}
|
| 88 |
+
tools: [move_units, attack_unit, attack_move, stop]
|
| 89 |
+
planning: true
|
| 90 |
+
termination: {max_ticks: 12000}
|
| 91 |
+
actors: [] # every level supplies its own actor list via overrides.
|
| 92 |
+
|
| 93 |
+
levels:
|
| 94 |
+
# ── EASY ─────────────────────────────────────────────────────────
|
| 95 |
+
# The bare convergent-attack skill: 3 leashed guards (distributed
|
| 96 |
+
# around the objective fact), 9 tanks total. A single squad of 3
|
| 97 |
+
# tanks alone is wiped by the 3 anti-tank guards before the fact
|
| 98 |
+
# falls; 2-prong or 3-prong both succeed (easy is lenient on the
|
| 99 |
+
# n=6 region clause). A stall (no movement) loses on the clock.
|
| 100 |
+
easy:
|
| 101 |
+
description: >
|
| 102 |
+
THREE squads of medium tanks (3× 2tnk each, 9 tanks total) start
|
| 103 |
+
at three different bearings around the enemy construction yard
|
| 104 |
+
(`fact` at ~100,20): NORTH (~70,6), WEST (~66,20), SOUTH
|
| 105 |
+
(~70,34). The yard is defended by 3 leashed rocket-infantry
|
| 106 |
+
guards (e3, bot_type guard) that hold post, auto-fire in range,
|
| 107 |
+
and lunge at the nearest approaching foe. Win when at least 6
|
| 108 |
+
of your 9 tanks are inside an 8-cell radius of (100,20) AND the
|
| 109 |
+
enemy construction yard is destroyed, before tick 4500. A
|
| 110 |
+
single squad alone is killed by the guard cluster before the
|
| 111 |
+
yard falls. A stall loses on the clock.
|
| 112 |
+
overrides:
|
| 113 |
+
actors:
|
| 114 |
+
# NORTH squad — 3 medium tanks at (70,6).
|
| 115 |
+
- {type: 2tnk, owner: agent, position: [70, 6], stance: 1, count: 3}
|
| 116 |
+
# WEST squad — 3 medium tanks at (66,20). Equidistant from
|
| 117 |
+
# the objective (~34 cells) so a naive simultaneous launch
|
| 118 |
+
# arrives together.
|
| 119 |
+
- {type: 2tnk, owner: agent, position: [66, 20], stance: 1, count: 3}
|
| 120 |
+
# SOUTH squad — 3 medium tanks at (70,34).
|
| 121 |
+
- {type: 2tnk, owner: agent, position: [70, 34], stance: 1, count: 3}
|
| 122 |
+
# The OBJECTIVE: enemy construction yard.
|
| 123 |
+
- {type: fact, owner: enemy, position: [100, 20]}
|
| 124 |
+
# 3 GUARDS distributed around the objective (W / E / N faces)
|
| 125 |
+
# so every ingress bearing enters guard aggro.
|
| 126 |
+
- {type: e3, owner: enemy, position: [98, 18]}
|
| 127 |
+
- {type: e3, owner: enemy, position: [102, 22]}
|
| 128 |
+
- {type: e3, owner: enemy, position: [100, 17]}
|
| 129 |
+
# SENTINEL: persistent enemy fact in the far north so the
|
| 130 |
+
# engine does not auto-`done` the instant the objective fact
|
| 131 |
+
# dies; lets within_ticks / region predicates evaluate
|
| 132 |
+
# cleanly on the terminal frame.
|
| 133 |
+
- {type: fact, owner: enemy, position: [120, 4]}
|
| 134 |
+
win_condition:
|
| 135 |
+
all_of:
|
| 136 |
+
- {units_in_region_gte: {x: 100, y: 20, radius: 8, n: 6}}
|
| 137 |
+
- {enemy_key_buildings_destroyed: {types: [fact]}}
|
| 138 |
+
- {within_ticks: 4500}
|
| 139 |
+
fail_condition:
|
| 140 |
+
any_of:
|
| 141 |
+
- {after_ticks: 4501}
|
| 142 |
+
- {not: {own_units_gte: 1}}
|
| 143 |
+
max_turns: 54
|
| 144 |
+
|
| 145 |
+
# ── MEDIUM ───────────────────────────────────────────────────────
|
| 146 |
+
# +1 controlled variable: 4 guards (one per cardinal face of the
|
| 147 |
+
# objective) and a tighter trade. A single squad alone is destroyed.
|
| 148 |
+
# Two squads converging arrive piecewise — the closer squad enters
|
| 149 |
+
# the cluster first and is ground down by 4 anti-tank guards
|
| 150 |
+
# before the second squad arrives; the joint surviving in-region
|
| 151 |
+
# count never reaches n=6 (the trade typically destroys the fact
|
| 152 |
+
# but leaves 0 tanks in region). Three squads converging mass
|
| 153 |
+
# enough simultaneous DPS to overrun the cluster cleanly (≥6
|
| 154 |
+
# tanks survive in region while the cluster + fact fall).
|
| 155 |
+
medium:
|
| 156 |
+
description: >
|
| 157 |
+
THREE squads of medium tanks (3× 2tnk each, 9 tanks total) from
|
| 158 |
+
NORTH (~70,6), WEST (~66,20), SOUTH (~70,34) must converge on
|
| 159 |
+
the enemy construction yard (`fact` at ~100,20) defended by 4
|
| 160 |
+
leashed rocket-infantry guards (one on each cardinal face). A
|
| 161 |
+
single squad alone is killed by the guards; two squads
|
| 162 |
+
converging destroy the yard but trade out the whole strike
|
| 163 |
+
force (no tanks survive inside the objective region). All
|
| 164 |
+
three squads must arrive together: win when ≥6 of your 9 tanks
|
| 165 |
+
are inside an 8-cell radius of (100,20) AND the yard is
|
| 166 |
+
destroyed, before tick 4500. A stall loses on the clock.
|
| 167 |
+
overrides:
|
| 168 |
+
actors:
|
| 169 |
+
- {type: 2tnk, owner: agent, position: [70, 6], stance: 1, count: 3}
|
| 170 |
+
- {type: 2tnk, owner: agent, position: [66, 20], stance: 1, count: 3}
|
| 171 |
+
- {type: 2tnk, owner: agent, position: [70, 34], stance: 1, count: 3}
|
| 172 |
+
- {type: fact, owner: enemy, position: [100, 20]}
|
| 173 |
+
# 4 guards: one on each cardinal face of the objective.
|
| 174 |
+
- {type: e3, owner: enemy, position: [98, 20]}
|
| 175 |
+
- {type: e3, owner: enemy, position: [102, 20]}
|
| 176 |
+
- {type: e3, owner: enemy, position: [100, 17]}
|
| 177 |
+
- {type: e3, owner: enemy, position: [100, 23]}
|
| 178 |
+
- {type: fact, owner: enemy, position: [120, 4]}
|
| 179 |
+
win_condition:
|
| 180 |
+
all_of:
|
| 181 |
+
- {units_in_region_gte: {x: 100, y: 20, radius: 8, n: 6}}
|
| 182 |
+
- {enemy_key_buildings_destroyed: {types: [fact]}}
|
| 183 |
+
- {within_ticks: 4500}
|
| 184 |
+
fail_condition:
|
| 185 |
+
any_of:
|
| 186 |
+
- {after_ticks: 4501}
|
| 187 |
+
- {not: {own_units_gte: 1}}
|
| 188 |
+
max_turns: 54
|
| 189 |
+
|
| 190 |
+
# ── HARD ─────────────────────────────────────────────────────────
|
| 191 |
+
# +1 more controlled variable: TWO seed-driven agent spawn groups
|
| 192 |
+
# round-robined per seed. Group 0 places the lateral (third) squad
|
| 193 |
+
# WEST of the objective at (66,20); group 1 places it EAST of the
|
| 194 |
+
# objective at (134,20). The defender cluster stays symmetric (4
|
| 195 |
+
# guards, one on each cardinal face) so both spawns face the same
|
| 196 |
+
# convergent geometry but the lateral squad's ingress bearing
|
| 197 |
+
# flips per seed (W → E). A single memorised "3-prong from W/N/S"
|
| 198 |
+
# opening cannot generalise. NORTH and SOUTH squads are common to
|
| 199 |
+
# both groups (placed without spawn_point — but the documented
|
| 200 |
+
# filter rule in CLAUDE.md ("if ANY agent actor declares
|
| 201 |
+
# spawn_point, every agent actor WITHOUT spawn_point is filtered
|
| 202 |
+
# OUT") means N/S MUST be duplicated across spawn groups; both
|
| 203 |
+
# copies are placed at identical coords so the contract is met
|
| 204 |
+
# without changing the geometry per seed.
|
| 205 |
+
hard:
|
| 206 |
+
description: >
|
| 207 |
+
THREE squads of medium tanks (3× 2tnk each, 9 tanks total) must
|
| 208 |
+
converge on the enemy construction yard (`fact` at ~100,20)
|
| 209 |
+
defended by 4 leashed rocket-infantry guards (one on each
|
| 210 |
+
cardinal face). The squad layout flips per seed: one spawn
|
| 211 |
+
stages NORTH (~70,6) / WEST (~66,20) / SOUTH (~70,34); the
|
| 212 |
+
other stages NORTH (~74,8) / EAST (~126,20) / SOUTH (~74,32).
|
| 213 |
+
Both layouts are balanced equidistant (~26-34 cells per
|
| 214 |
+
squad) so a synchronised launch arrives together. A single
|
| 215 |
+
squad alone is
|
| 216 |
+
destroyed by the guards; two squads converging trade out the
|
| 217 |
+
strike force before satisfying the region threshold. Win when
|
| 218 |
+
≥6 of your 9 tanks are inside an 8-cell radius of (100,20) AND
|
| 219 |
+
the yard is destroyed, before tick 4500.
|
| 220 |
+
overrides:
|
| 221 |
+
actors:
|
| 222 |
+
# spawn_point 0 — NORTH / WEST-of-objective / SOUTH.
|
| 223 |
+
- {type: 2tnk, owner: agent, position: [70, 6], stance: 1, count: 3, spawn_point: 0}
|
| 224 |
+
- {type: 2tnk, owner: agent, position: [66, 20], stance: 1, count: 3, spawn_point: 0}
|
| 225 |
+
- {type: 2tnk, owner: agent, position: [70, 34], stance: 1, count: 3, spawn_point: 0}
|
| 226 |
+
# spawn_point 1 — NORTH / EAST-of-objective / SOUTH.
|
| 227 |
+
# NORTH and SOUTH are duplicated at identical coords so the
|
| 228 |
+
# spawn-filter rule (CLAUDE.md) doesn't drop them from group 1;
|
| 229 |
+
# the WEST squad is relocated to EAST at (134,20) — equidistant
|
| 230 |
+
# from the objective on the opposite bearing, flipping the
|
| 231 |
+
# lateral ingress direction. The seed-chosen ingress geometry
|
| 232 |
+
# cannot be memorised: a fixed "drive west squad east" opening
|
| 233 |
+
# is wrong half the seeds.
|
| 234 |
+
# NORTH and SOUTH in group 1 are pulled in slightly to
|
| 235 |
+
# (74, 8) / (74, 32) so their distance to the objective (~29
|
| 236 |
+
# cells) closely matches the EAST squad's distance from
|
| 237 |
+
# (126, 20) (~26 cells) — a naive simultaneous launch from
|
| 238 |
+
# group 1 has all three squads arrive together (group 0
|
| 239 |
+
# already balanced at ~34 from each of W / N / S).
|
| 240 |
+
- {type: 2tnk, owner: agent, position: [74, 8], stance: 1, count: 3, spawn_point: 1}
|
| 241 |
+
- {type: 2tnk, owner: agent, position: [126, 20], stance: 1, count: 3, spawn_point: 1}
|
| 242 |
+
- {type: 2tnk, owner: agent, position: [74, 32], stance: 1, count: 3, spawn_point: 1}
|
| 243 |
+
# The OBJECTIVE.
|
| 244 |
+
- {type: fact, owner: enemy, position: [100, 20]}
|
| 245 |
+
# 4 guards: one on each cardinal face. Symmetric: equally
|
| 246 |
+
# covers both lateral ingress bearings (W and E).
|
| 247 |
+
- {type: e3, owner: enemy, position: [98, 20]}
|
| 248 |
+
- {type: e3, owner: enemy, position: [102, 20]}
|
| 249 |
+
- {type: e3, owner: enemy, position: [100, 17]}
|
| 250 |
+
- {type: e3, owner: enemy, position: [100, 23]}
|
| 251 |
+
# SENTINEL: persistent enemy fact in the far north so the
|
| 252 |
+
# engine does not auto-`done` on objective raze.
|
| 253 |
+
- {type: fact, owner: enemy, position: [120, 4]}
|
| 254 |
+
win_condition:
|
| 255 |
+
all_of:
|
| 256 |
+
- {units_in_region_gte: {x: 100, y: 20, radius: 8, n: 6}}
|
| 257 |
+
- {enemy_key_buildings_destroyed: {types: [fact]}}
|
| 258 |
+
- {within_ticks: 4500}
|
| 259 |
+
fail_condition:
|
| 260 |
+
any_of:
|
| 261 |
+
- {after_ticks: 4501}
|
| 262 |
+
- {not: {own_units_gte: 1}}
|
| 263 |
+
max_turns: 54
|
|
@@ -0,0 +1,330 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# coord-cover-and-move — bounding overwatch across a fire zone.
|
| 2 |
+
#
|
| 3 |
+
# ACTION focus: two squads must cross a centre-of-map fire zone defended
|
| 4 |
+
# by static anti-tank infantry. Charging both squads together stacks
|
| 5 |
+
# every cluster's fire on the SAME column — the e3 anti-tank rocket
|
| 6 |
+
# soldiers focus the dense formation and bust the attrition cap (≥2
|
| 7 |
+
# tanks lost). Sending one squad alone leaves the other idle and the
|
| 8 |
+
# moving squad eats ALL the cluster's fire (≥2 lost). Stalling burns
|
| 9 |
+
# the clock. The intended idiom is military FIRE-AND-MANEUVER bounding
|
| 10 |
+
# overwatch: one squad stops just outside the fire zone, anchors to
|
| 11 |
+
# DRAW fire, and engages the cluster from the closer flank while the
|
| 12 |
+
# other squad sweeps WIDE through the unoccupied periphery (y outside
|
| 13 |
+
# the cluster's ~5-cell sight). The cluster's attention splits between
|
| 14 |
+
# the firing "cover" team and the (invisible / out-of-range) "move"
|
| 15 |
+
# team; the move team crosses safely. Then the roles alternate so the
|
| 16 |
+
# cover team can also cross. Each squad takes at most 1 loss because
|
| 17 |
+
# enemy fire is divided per phase, not stacked on a single column.
|
| 18 |
+
#
|
| 19 |
+
# Real-world anchor: US Army FM 3-21.8 bounding overwatch — one fire
|
| 20 |
+
# team in a stationary OVERWATCH posture provides suppressive fire
|
| 21 |
+
# while the BOUNDING fire team moves forward by short rushes through
|
| 22 |
+
# the danger zone; roles alternate ("leapfrog") as the bounding team
|
| 23 |
+
# reaches its next covered position and assumes overwatch for the
|
| 24 |
+
# previously-overwatching team. SC2 tank micro encodes the same
|
| 25 |
+
# pattern as the siege-tank "leapfrog" advance.
|
| 26 |
+
#
|
| 27 |
+
# Discrimination (the four script-policy bar):
|
| 28 |
+
# • stall (observe-only): no squad crosses → win region empty →
|
| 29 |
+
# after_ticks LOSS.
|
| 30 |
+
# • both-charge-together: all 6 tanks rush a single eastward column
|
| 31 |
+
# through the fire zone; 4× e3 stance:3 (medium) auto-fire on the
|
| 32 |
+
# densest cluster and bust units_lost_lte:1 → LOSS.
|
| 33 |
+
# • single-squad-only: send Squad A through alone (Squad B idle west);
|
| 34 |
+
# A absorbs ALL the cluster's fire across its crossing and loses
|
| 35 |
+
# ≥2 → busts units_lost_lte:1 → LOSS.
|
| 36 |
+
# • intended bound-and-cover: A anchors at (45,15) firing on the
|
| 37 |
+
# cluster (drawing fire from the closer flank); B swings WIDE
|
| 38 |
+
# through y=8..10 (outside e3 sight 5 from the cluster centre at
|
| 39 |
+
# y=20) to (90,15); then B opens fire from the east edge of the
|
| 40 |
+
# cluster while A relocates north and crosses through y=8..10.
|
| 41 |
+
# Cluster fire is split per phase; each squad takes ≤1 loss; ≥4
|
| 42 |
+
# tanks land in the win region within the deadline → WIN.
|
| 43 |
+
#
|
| 44 |
+
# Why the spec works (engine combat sheet):
|
| 45 |
+
# • e3 = anti-tank rocket soldier (rng4c, dps12, sight5c, hp45).
|
| 46 |
+
# Effective vs 2tnk armour. 4× e3 firing on a single dense column
|
| 47 |
+
# stacks ~48 dps onto whichever tank is nearest; a column charging
|
| 48 |
+
# through the cluster spends ~3-4 decision turns in range and the
|
| 49 |
+
# stacked dps cleans off ≥2 tanks (busts units_lost_lte:1).
|
| 50 |
+
# • 2tnk = medium tank (rng4.75c, dps22, sight6c, hp400). Marginally
|
| 51 |
+
# out-ranges e3 (4.75 vs 4), so a tank that STOPS just at the edge
|
| 52 |
+
# of e3 range (~dist 5 from cluster) can fire back without being
|
| 53 |
+
# in e3 range itself — the OVERWATCH posture. Its dps22 plus the
|
| 54 |
+
# other overwatch tank chips down the cluster across the bound,
|
| 55 |
+
# reducing fire on the second-phase bounding team.
|
| 56 |
+
# • Sight-line geometry: with the cluster at y=19..21 (sight 5), e3s
|
| 57 |
+
# see roughly y=14..26. A squad routing through y=8..10 is OUTSIDE
|
| 58 |
+
# enemy sight and OUTSIDE enemy range — it takes ZERO fire while
|
| 59 |
+
# moving, even though its straight-line distance is ~10 cells.
|
| 60 |
+
# That's the "bounding lane" geometry — outside the fire envelope.
|
| 61 |
+
#
|
| 62 |
+
# Engine guardrails (per CLAUDE.md):
|
| 63 |
+
# • Map: rush-hour-arena (128 × 40 playable; x in [2..126], y in
|
| 64 |
+
# [2..38]). All actor / region cells are inside.
|
| 65 |
+
# • CLAUDE.md footgun: enemy clusters at (50,20) silently FAIL to
|
| 66 |
+
# place. Cluster cells are (49,19), (49,21), (51,19), (51,21) —
|
| 67 |
+
# verified-placeable neighbours used by other packs.
|
| 68 |
+
# • A persistent far enemy `fact` at (120,20) prevents engine auto-
|
| 69 |
+
# done when the central cluster is cleaned out (so the win/fail
|
| 70 |
+
# evaluator sees the terminal frame instead of a DRAW collapse).
|
| 71 |
+
# • after_ticks fail clauses are reachable within max_turns
|
| 72 |
+
# (tick ≤ 93 + 90·(max_turns-1)); within_ticks 4500 < 4503 at
|
| 73 |
+
# max_turns 50; staller hits after_ticks 4501 and LOSES, not draws.
|
| 74 |
+
|
| 75 |
+
meta:
|
| 76 |
+
id: coord-cover-and-move
|
| 77 |
+
title: 'Bounding Overwatch — One Squad Covers While the Other Moves'
|
| 78 |
+
capability: action
|
| 79 |
+
real_world_meaning: >
|
| 80 |
+
Two armoured squads must cross a centre-of-map FIRE ZONE held by
|
| 81 |
+
static anti-tank infantry. Charging both squads through together
|
| 82 |
+
stacks every enemy's fire onto a single dense column and busts the
|
| 83 |
+
attrition cap. Sending one squad alone leaves the other idle and
|
| 84 |
+
the lone column still absorbs ALL the cluster's fire. The intended
|
| 85 |
+
capability is bounding overwatch: one squad stops just OUTSIDE the
|
| 86 |
+
cluster's range and acts as the COVER team — firing on the cluster
|
| 87 |
+
to draw its attention from the closer flank — while the BOUNDING
|
| 88 |
+
team sweeps WIDE through the periphery (outside enemy sight) to a
|
| 89 |
+
forward position; then the roles alternate so the cover team can
|
| 90 |
+
also cross safely. The cluster's fire is split per phase rather
|
| 91 |
+
than stacked on one column, so each squad takes at most one loss.
|
| 92 |
+
robotics_analogue: >
|
| 93 |
+
Multi-agent traversal of a contested corridor where defenders have
|
| 94 |
+
a fixed effective range envelope and concentrate fire on whichever
|
| 95 |
+
cluster is closest. Single-team traversal eats the full defensive
|
| 96 |
+
fire envelope; the joint policy alternates an OVERWATCH role
|
| 97 |
+
(suppressive fire drawing defender attention from a flank) with a
|
| 98 |
+
BOUNDING role (sweep through the cleared periphery). The
|
| 99 |
+
coordination is the role-alternation — leapfrogging cover and
|
| 100 |
+
move — not just splitting the force.
|
| 101 |
+
benchmark_anchor:
|
| 102 |
+
- "US Army FM 3-21.8 bounding overwatch (fire-and-maneuver doctrine)"
|
| 103 |
+
- "SC2 siege-tank leapfrog advance under enemy fire"
|
| 104 |
+
- "SMAC coordinated cross of a danger zone with split defender fire"
|
| 105 |
+
- "MicroRTS multi-squad alternating cover-and-move"
|
| 106 |
+
author: "openra-bench"
|
| 107 |
+
|
| 108 |
+
base_map: rush-hour-arena
|
| 109 |
+
|
| 110 |
+
base:
|
| 111 |
+
agent: {faction: allies, cash: 0}
|
| 112 |
+
enemy: {faction: soviet, cash: 0, bot_type: ''}
|
| 113 |
+
tools: [move_units, attack_unit, attack_move, stop]
|
| 114 |
+
planning: true
|
| 115 |
+
# React the instant the moving squad takes a hit or first contact is
|
| 116 |
+
# made — bounding overwatch is REACTIVE micro (the cover team must
|
| 117 |
+
# re-target as bounding-team contact opens up). Without these
|
| 118 |
+
# interrupts the role-alternation only re-evaluates on fixed turn
|
| 119 |
+
# boundaries and the e3 cluster gets free shots through every gap.
|
| 120 |
+
interrupts:
|
| 121 |
+
enemy_unit_spotted: true
|
| 122 |
+
own_unit_destroyed: true
|
| 123 |
+
termination: {max_ticks: 6000}
|
| 124 |
+
actors: [] # every level supplies its own actor list via overrides.
|
| 125 |
+
|
| 126 |
+
levels:
|
| 127 |
+
# ── EASY ─────────────────────────────────────────────────────────
|
| 128 |
+
# Bare bound-and-cover skill: 4× e1 rifle infantry at centre (NOT
|
| 129 |
+
# the anti-tank e3 — easy uses the weaker rifle, low DPS vs armour
|
| 130 |
+
# so the cap is forgiving). Both-charge-together may squeak by on
|
| 131 |
+
# luck (e1 vs 2tnk armour is poor); stall and single-squad both
|
| 132 |
+
# still LOSE via the timeout / kill geometry.
|
| 133 |
+
easy:
|
| 134 |
+
description: >
|
| 135 |
+
Two squads (Squad A at (10,15), Squad B at (10,25), 3 medium
|
| 136 |
+
tanks each) must cross a centre-of-map fire zone defended by
|
| 137 |
+
static rifle infantry (e1) at roughly (50,20), then land ≥4
|
| 138 |
+
tanks in the EAST gathering region (x ≈ 100, y ≈ 20, radius 6)
|
| 139 |
+
before tick 4500, losing ≤1 tank in total. The rifle infantry
|
| 140 |
+
do limited damage to armour on easy, but charging both squads
|
| 141 |
+
together still stacks their fire onto the densest cluster and
|
| 142 |
+
a single column alone absorbs every shot — alternate ROLES:
|
| 143 |
+
one squad anchors at the edge of enemy range to draw fire
|
| 144 |
+
while the other sweeps through the periphery (y outside enemy
|
| 145 |
+
sight) and crosses; then switch.
|
| 146 |
+
overrides:
|
| 147 |
+
actors:
|
| 148 |
+
# Squad A — northern fire team, staged at (10, 15).
|
| 149 |
+
- {type: 2tnk, owner: agent, position: [10, 14], stance: 1}
|
| 150 |
+
- {type: 2tnk, owner: agent, position: [10, 15], stance: 1}
|
| 151 |
+
- {type: 2tnk, owner: agent, position: [10, 16], stance: 1}
|
| 152 |
+
# Squad B — southern fire team, staged at (10, 25).
|
| 153 |
+
- {type: 2tnk, owner: agent, position: [10, 24], stance: 1}
|
| 154 |
+
- {type: 2tnk, owner: agent, position: [10, 25], stance: 1}
|
| 155 |
+
- {type: 2tnk, owner: agent, position: [10, 26], stance: 1}
|
| 156 |
+
# Centre fire zone: 4× rifle infantry stance:3 (AttackAnything
|
| 157 |
+
# in range). Cells avoid (50,20) — CLAUDE.md footgun: enemy
|
| 158 |
+
# clusters at (50,20) silently fail to place. Off-axis
|
| 159 |
+
# neighbours (49/51 × 19/21) are verified-placeable.
|
| 160 |
+
- {type: e1, owner: enemy, position: [49, 19], stance: 3}
|
| 161 |
+
- {type: e1, owner: enemy, position: [49, 21], stance: 3}
|
| 162 |
+
- {type: e1, owner: enemy, position: [51, 19], stance: 3}
|
| 163 |
+
- {type: e1, owner: enemy, position: [51, 21], stance: 3}
|
| 164 |
+
# Persistent far enemy `fact` — keeps the episode alive past
|
| 165 |
+
# the central cluster being cleaned out so the win/fail
|
| 166 |
+
# evaluator sees the terminal frame (no DRAW collapse).
|
| 167 |
+
- {type: fact, owner: enemy, position: [120, 20]}
|
| 168 |
+
win_condition:
|
| 169 |
+
all_of:
|
| 170 |
+
- {units_in_region_gte: {x: 100, y: 20, radius: 6, n: 4}}
|
| 171 |
+
- {units_lost_lte: 1}
|
| 172 |
+
- {within_ticks: 4500}
|
| 173 |
+
fail_condition:
|
| 174 |
+
any_of:
|
| 175 |
+
- {after_ticks: 4501}
|
| 176 |
+
- {not: {units_lost_lte: 1}}
|
| 177 |
+
- {not: {own_units_gte: 1}}
|
| 178 |
+
max_turns: 50
|
| 179 |
+
|
| 180 |
+
# ── MEDIUM ──────────��────────────────────────────────────────────
|
| 181 |
+
# +1 axis: ANTI-TANK rocket soldiers (e3) replace rifle infantry,
|
| 182 |
+
# so the fire zone is genuinely lethal to 2tnk armour. Both-charge-
|
| 183 |
+
# together LOSES (4× e3 stacked dps onto a dense column busts
|
| 184 |
+
# units_lost_lte:1); single-squad alone LOSES (lone column absorbs
|
| 185 |
+
# every shot). Only true bound-and-cover (squads alternate roles
|
| 186 |
+
# so cluster fire is SPLIT per phase) keeps each squad to ≤1 loss.
|
| 187 |
+
medium:
|
| 188 |
+
description: >
|
| 189 |
+
Two squads (Squad A at (10,15), Squad B at (10,25), 3 medium
|
| 190 |
+
tanks each) must cross a centre-of-map FIRE ZONE defended by
|
| 191 |
+
four static anti-tank rocket soldiers (e3) at roughly (50,20),
|
| 192 |
+
then land ≥4 tanks in the EAST gathering region (x ≈ 100,
|
| 193 |
+
y ≈ 20, radius 6) before tick 4500, losing ≤1 tank in total.
|
| 194 |
+
Charging BOTH squads through the centre stacks all four e3s'
|
| 195 |
+
anti-armour fire on a single dense column and busts the cap.
|
| 196 |
+
Sending one squad alone leaves the other idle and the lone
|
| 197 |
+
column absorbs every shot. The intended idiom is BOUNDING
|
| 198 |
+
OVERWATCH: one squad anchors at the EDGE of enemy range
|
| 199 |
+
(around x ≈ 45, y ≈ 15 — close enough to fire back, far
|
| 200 |
+
enough to suppress) drawing the cluster's attention from the
|
| 201 |
+
closer flank, while the other squad sweeps WIDE through the
|
| 202 |
+
periphery (around y ≈ 8..10, OUTSIDE the cluster's sight
|
| 203 |
+
range) to the east. Then the roles ALTERNATE — the team that
|
| 204 |
+
moved opens fire from the east edge of the cluster while the
|
| 205 |
+
original cover team relocates north and crosses through the
|
| 206 |
+
same outside-sight band. Each squad takes ≤1 loss because
|
| 207 |
+
cluster fire is SPLIT per phase, not stacked.
|
| 208 |
+
overrides:
|
| 209 |
+
actors:
|
| 210 |
+
- {type: 2tnk, owner: agent, position: [10, 14], stance: 1}
|
| 211 |
+
- {type: 2tnk, owner: agent, position: [10, 15], stance: 1}
|
| 212 |
+
- {type: 2tnk, owner: agent, position: [10, 16], stance: 1}
|
| 213 |
+
- {type: 2tnk, owner: agent, position: [10, 24], stance: 1}
|
| 214 |
+
- {type: 2tnk, owner: agent, position: [10, 25], stance: 1}
|
| 215 |
+
- {type: 2tnk, owner: agent, position: [10, 26], stance: 1}
|
| 216 |
+
# Centre fire zone: 4× anti-tank rocket soldiers stance:3.
|
| 217 |
+
- {type: e3, owner: enemy, position: [49, 19], stance: 3}
|
| 218 |
+
- {type: e3, owner: enemy, position: [49, 21], stance: 3}
|
| 219 |
+
- {type: e3, owner: enemy, position: [51, 19], stance: 3}
|
| 220 |
+
- {type: e3, owner: enemy, position: [51, 21], stance: 3}
|
| 221 |
+
- {type: fact, owner: enemy, position: [120, 20]}
|
| 222 |
+
win_condition:
|
| 223 |
+
all_of:
|
| 224 |
+
- {units_in_region_gte: {x: 100, y: 20, radius: 6, n: 4}}
|
| 225 |
+
- {units_lost_lte: 1}
|
| 226 |
+
- {within_ticks: 4500}
|
| 227 |
+
fail_condition:
|
| 228 |
+
any_of:
|
| 229 |
+
- {after_ticks: 4501}
|
| 230 |
+
- {not: {units_lost_lte: 1}}
|
| 231 |
+
- {not: {own_units_gte: 1}}
|
| 232 |
+
max_turns: 50
|
| 233 |
+
|
| 234 |
+
# ── HARD ─────────────────────────────────────────────────────────
|
| 235 |
+
# +2 axes vs medium:
|
| 236 |
+
# 1. ZERO attrition cap (units_lost_lte: 0) — any tank loss
|
| 237 |
+
# fails. The cover team's overwatch fire must SUPPRESS the
|
| 238 |
+
# cluster enough that the bounding team takes no hits at all
|
| 239 |
+
# (the wide-flank route via y=8..10 already keeps the bounding
|
| 240 |
+
# team out of e3 sight — the zero-loss bar makes the geometry
|
| 241 |
+
# load-bearing instead of a margin-of-error luxury).
|
| 242 |
+
# 2. An additional e1 RIFLE-INFANTRY swarm extends the fire
|
| 243 |
+
# envelope (3× e1 north of the cluster + 3× e1 south),
|
| 244 |
+
# tightening the periphery-route corridor: a squad that drifts
|
| 245 |
+
# to y=12 or y=28 brushes the rifle screens. The bounding lane
|
| 246 |
+
# is narrower — bound through y=6..9 or y=31..34, not y=12+ or
|
| 247 |
+
# y=27- — and the cover team must keep the rocket cluster
|
| 248 |
+
# ITSELF suppressed (overwatch on the e3, not the e1s, since
|
| 249 |
+
# the e3 is the priority anti-armour threat).
|
| 250 |
+
# 3. Strike force stages from TWO seed-driven spawn_point groups
|
| 251 |
+
# (NORTH-WEST y=12..16 OR SOUTH-WEST y=24..28), round-robined
|
| 252 |
+
# by seed. The cluster and the win region are symmetric across
|
| 253 |
+
# y=20 so EITHER spawn faces an equivalent bounding decision
|
| 254 |
+
# (cover from the closer flank, bound around the FAR
|
| 255 |
+
# periphery), but a single memorised bound vector cannot
|
| 256 |
+
# generalise across seeds.
|
| 257 |
+
hard:
|
| 258 |
+
description: >
|
| 259 |
+
Two squads (3 medium tanks each, staged at the NW or SW
|
| 260 |
+
corner, chosen by seed for anti-memorisation) must cross a
|
| 261 |
+
centre-of-map FIRE ZONE — four anti-tank rocket soldiers (e3)
|
| 262 |
+
at roughly (50,20), flanked by two rifle-infantry SCREENS that
|
| 263 |
+
extend the fire envelope north and south — and land ≥4 tanks
|
| 264 |
+
in the EAST gathering region (x ≈ 100, y ≈ 20, radius 6)
|
| 265 |
+
before tick 4500, losing ZERO tanks in total. The intended
|
| 266 |
+
idiom is BOUNDING OVERWATCH with the wide-flank route
|
| 267 |
+
tightened by the rifle screens: the cover team anchors at the
|
| 268 |
+
edge of e3 range (within tank range, OUT of e3 range) and
|
| 269 |
+
suppresses the rocket cluster (the priority anti-armour
|
| 270 |
+
threat); the bounding team sweeps through the FAR periphery
|
| 271 |
+
(around y ≈ 6..9 from the NORTH spawn, or y ≈ 31..34 from the
|
| 272 |
+
SOUTH spawn — outside both the rocket-cluster sight envelope
|
| 273 |
+
AND the rifle-screen weapon range) to the east. Then the
|
| 274 |
+
roles alternate. Zero loss is only achievable when the cover
|
| 275 |
+
team's overwatch fire keeps the cluster suppressed during the
|
| 276 |
+
bounding team's transit.
|
| 277 |
+
overrides:
|
| 278 |
+
actors:
|
| 279 |
+
# spawn_point 0 — NORTH-WEST staging (Squad A at (10,12)
|
| 280 |
+
# north, Squad B at (10,16) south of A but still north of
|
| 281 |
+
# centre — they bound through the far NORTH periphery
|
| 282 |
+
# y ≈ 6..9 outside both rifle-screen and rocket sight).
|
| 283 |
+
- {type: 2tnk, owner: agent, position: [10, 11], stance: 1, spawn_point: 0}
|
| 284 |
+
- {type: 2tnk, owner: agent, position: [10, 12], stance: 1, spawn_point: 0}
|
| 285 |
+
- {type: 2tnk, owner: agent, position: [10, 13], stance: 1, spawn_point: 0}
|
| 286 |
+
- {type: 2tnk, owner: agent, position: [10, 15], stance: 1, spawn_point: 0}
|
| 287 |
+
- {type: 2tnk, owner: agent, position: [10, 16], stance: 1, spawn_point: 0}
|
| 288 |
+
- {type: 2tnk, owner: agent, position: [10, 17], stance: 1, spawn_point: 0}
|
| 289 |
+
# spawn_point 1 — SOUTH-WEST staging (mirrored across y=20).
|
| 290 |
+
# The cover/bound decision is symmetric: cover at south
|
| 291 |
+
# edge of e3 range, bound through y ≈ 31..34.
|
| 292 |
+
- {type: 2tnk, owner: agent, position: [10, 23], stance: 1, spawn_point: 1}
|
| 293 |
+
- {type: 2tnk, owner: agent, position: [10, 24], stance: 1, spawn_point: 1}
|
| 294 |
+
- {type: 2tnk, owner: agent, position: [10, 25], stance: 1, spawn_point: 1}
|
| 295 |
+
- {type: 2tnk, owner: agent, position: [10, 27], stance: 1, spawn_point: 1}
|
| 296 |
+
- {type: 2tnk, owner: agent, position: [10, 28], stance: 1, spawn_point: 1}
|
| 297 |
+
- {type: 2tnk, owner: agent, position: [10, 29], stance: 1, spawn_point: 1}
|
| 298 |
+
# Centre rocket cluster — same 4× e3 as medium, symmetric
|
| 299 |
+
# across y=20 so EITHER spawn faces an equivalent geometry.
|
| 300 |
+
- {type: e3, owner: enemy, position: [49, 19], stance: 3}
|
| 301 |
+
- {type: e3, owner: enemy, position: [49, 21], stance: 3}
|
| 302 |
+
- {type: e3, owner: enemy, position: [51, 19], stance: 3}
|
| 303 |
+
- {type: e3, owner: enemy, position: [51, 21], stance: 3}
|
| 304 |
+
# Rifle-infantry SCREENS — extend the fire envelope north
|
| 305 |
+
# and south of the rocket cluster, tightening the bounding
|
| 306 |
+
# lane. y=14 / y=26 are within e3 cluster's sight (5c) so
|
| 307 |
+
# the screens reinforce the same envelope — a squad
|
| 308 |
+
# bounding through y=12 or y=28 would be in screen range
|
| 309 |
+
# (e1 rng4c sees out to ~y=10 from y=14). The intended
|
| 310 |
+
# bounding lane is OUTSIDE that envelope: y ≈ 6..9 (NORTH)
|
| 311 |
+
# or y ≈ 31..34 (SOUTH).
|
| 312 |
+
- {type: e1, owner: enemy, position: [49, 14], stance: 3}
|
| 313 |
+
- {type: e1, owner: enemy, position: [51, 14], stance: 3}
|
| 314 |
+
- {type: e1, owner: enemy, position: [50, 13], stance: 3}
|
| 315 |
+
- {type: e1, owner: enemy, position: [49, 26], stance: 3}
|
| 316 |
+
- {type: e1, owner: enemy, position: [51, 26], stance: 3}
|
| 317 |
+
- {type: e1, owner: enemy, position: [50, 27], stance: 3}
|
| 318 |
+
# Persistent far enemy fact — engine auto-done mitigation.
|
| 319 |
+
- {type: fact, owner: enemy, position: [120, 20]}
|
| 320 |
+
win_condition:
|
| 321 |
+
all_of:
|
| 322 |
+
- {units_in_region_gte: {x: 100, y: 20, radius: 6, n: 4}}
|
| 323 |
+
- {units_lost_lte: 0}
|
| 324 |
+
- {within_ticks: 4500}
|
| 325 |
+
fail_condition:
|
| 326 |
+
any_of:
|
| 327 |
+
- {after_ticks: 4501}
|
| 328 |
+
- {not: {units_lost_lte: 0}}
|
| 329 |
+
- {not: {own_units_gte: 1}}
|
| 330 |
+
max_turns: 50
|
|
@@ -0,0 +1,336 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# coord-diversionary-attack.yaml
|
| 2 |
+
#
|
| 3 |
+
# REASONING capability — split-attack / diversionary assault. Two
|
| 4 |
+
# squads start on the agent's WEST edge (A=jeeps at NW, B=2tnks at
|
| 5 |
+
# SW); the enemy holds TWO key buildings on the EAST: a real target
|
| 6 |
+
# `fact` with LIGHT defence at (100,10), and a decoy `powr` with
|
| 7 |
+
# HEAVIER defence at (100,30). The enemy is the scripted `hunt` bot:
|
| 8 |
+
# each enemy unit pursues the nearest agent unit. The win predicate
|
| 9 |
+
# is `enemy_key_buildings_destroyed_in_region:{x:100, y:10, r:6,
|
| 10 |
+
# types:[fact]}` — ONLY destroying the REAL fact at (100,10) scores;
|
| 11 |
+
# raising the decoy `powr` does not.
|
| 12 |
+
#
|
| 13 |
+
# The intended idiom is the SC2 split-attack / military diversionary
|
| 14 |
+
# assault doctrine: drive Squad A (fast, cheap, disposable jeeps)
|
| 15 |
+
# south-east into the powr-defence aggro radius — the hunt bot's
|
| 16 |
+
# heavier south defenders pursue the nearest agent units (the jeeps)
|
| 17 |
+
# south and DISENGAGE from the fact line; meanwhile drive Squad B
|
| 18 |
+
# (the main strike) north-east into the now-thin fact perimeter and
|
| 19 |
+
# raze the real target before its surviving light garrison can
|
| 20 |
+
# trade out the column.
|
| 21 |
+
#
|
| 22 |
+
# Distinct from the obvious "send each squad to its nearest target"
|
| 23 |
+
# play: A is at NW (closer to the real fact in row-10) and B is at
|
| 24 |
+
# SW (closer to the decoy powr in row-30). The lazy assignment
|
| 25 |
+
# (jeeps → fact, tanks → powr) attacks the WRONG target and
|
| 26 |
+
# under-strikes the right one: jeeps can't crack the fact's light
|
| 27 |
+
# garrison fast enough, the tanks raze the decoy powr (which doesn't
|
| 28 |
+
# count), and the clock expires. The intended play is the SWAP:
|
| 29 |
+
# jeeps cross to the SOUTH to bait, tanks cross to the NORTH to
|
| 30 |
+
# strike — neither squad attacks its nearest visible target.
|
| 31 |
+
#
|
| 32 |
+
# Distinct from `combat-bait-counter-attack` (a SINGLE strike squad
|
| 33 |
+
# with a SINGLE bait against a leashed `guard` cluster on one
|
| 34 |
+
# objective): here there are TWO objectives (one real, one decoy)
|
| 35 |
+
# with a PURSUING `hunt` enemy, the win predicate is REGION-scoped
|
| 36 |
+
# to the real target alone (so killing the decoy doesn't score),
|
| 37 |
+
# and the coordination is between two same-side multi-unit squads
|
| 38 |
+
# (split-attack), not a bait/strike micro pair.
|
| 39 |
+
#
|
| 40 |
+
# Distinct from `strategy-twobody` (parallel control of two squads,
|
| 41 |
+
# each razing its OWN base): there the win counts BOTH bases; here
|
| 42 |
+
# only ONE base counts and the other exists to ABSORB the enemy
|
| 43 |
+
# pursuit.
|
| 44 |
+
#
|
| 45 |
+
# Engine notes (per CLAUDE.md):
|
| 46 |
+
# - `fact` and `powr` are MustBeDestroyed. A SENTINEL enemy `fact`
|
| 47 |
+
# is placed far south-east (out of the combat zone, agent can't
|
| 48 |
+
# reach it in budget) so the engine doesn't auto-`done` the
|
| 49 |
+
# instant the objective fact dies — letting within_ticks /
|
| 50 |
+
# units_lost_lte evaluate cleanly on the terminal frame even
|
| 51 |
+
# if the bait squad ALSO razes the decoy powr.
|
| 52 |
+
# - `hunt` bot: each enemy unit attacks the nearest AGENT unit
|
| 53 |
+
# and pursues. The bait squad (jeeps) must come within visual
|
| 54 |
+
# range of the south defenders for them to commit; once they
|
| 55 |
+
# pursue jeeps, the fact's garrison (in the north) keeps its
|
| 56 |
+
# own nearest-agent target = the tank column if B is already
|
| 57 |
+
# committed, but the south defenders won't snap back since they
|
| 58 |
+
# remain locked on the jeep targets they're pursuing.
|
| 59 |
+
# - `move_units` auto-fires opportunistically: B's column en route
|
| 60 |
+
# to (100,10) will engage any south defender that happens to
|
| 61 |
+
# stray into range, so A's bait must keep the south defenders
|
| 62 |
+
# committed AWAY from B's route. Bait staying in the south-east
|
| 63 |
+
# quadrant (around (100,30)) is far enough from B's north-line
|
| 64 |
+
# y≈10 corridor.
|
| 65 |
+
|
| 66 |
+
meta:
|
| 67 |
+
id: coord-diversionary-attack
|
| 68 |
+
title: 'Diversionary Attack — A Diverts South, B Razes the Real Target North'
|
| 69 |
+
capability: reasoning
|
| 70 |
+
real_world_meaning: >
|
| 71 |
+
Two squads command a split-attack against an enemy that holds
|
| 72 |
+
TWO key buildings: a REAL target (construction yard) with light
|
| 73 |
+
defence and a DECOY (power plant) with heavier defence. The
|
| 74 |
+
scripted `hunt` enemy pursues the nearest agent unit. Driving
|
| 75 |
+
each squad onto its nearest visible target is a trap: the
|
| 76 |
+
closer-but-weaker squad (jeeps) can't crack the real target's
|
| 77 |
+
garrison fast enough, the closer-stronger squad (tanks) razes
|
| 78 |
+
the decoy (which scores zero), the clock expires. The winning
|
| 79 |
+
policy SWAPS the assignment: commit the fast disposable jeeps
|
| 80 |
+
south-east into the decoy's heavier defence to bait its
|
| 81 |
+
pursuers OFF the fact line; commit the tanks north-east through
|
| 82 |
+
the now-thin fact perimeter to raze the construction yard
|
| 83 |
+
before the surviving light garrison can trade out the column.
|
| 84 |
+
Phase 1 (the diversion) yields zero objective credit — only
|
| 85 |
+
phase 2, after the south defenders have committed south, scores.
|
| 86 |
+
The credit problem is DIVERSIONARY ASSAULT: a no-reward enabling
|
| 87 |
+
action (drag the heaviest defence away from the real target
|
| 88 |
+
with a disposable lure squad) must precede and temporally
|
| 89 |
+
overlap the rewarded objective action (commit the main strike
|
| 90 |
+
on the briefly-undefended real target).
|
| 91 |
+
robotics_analogue: >
|
| 92 |
+
Concurrent multi-robot dispatch with deception: two independent
|
| 93 |
+
teams must coordinate against a reactive adversary, where one
|
| 94 |
+
team takes a NO-REWARD enabling role (drawing the adversary's
|
| 95 |
+
attention to a decoy target on the OPPOSITE flank) while the
|
| 96 |
+
other team — counter-intuitively assigned to the FARTHER
|
| 97 |
+
objective — completes the rewarded task. The lazy nearest-task
|
| 98 |
+
assignment is degenerate: every team must commit ACROSS the map
|
| 99 |
+
to the objective its sibling enabled.
|
| 100 |
+
benchmark_anchor:
|
| 101 |
+
- "SC2 multi-prong / split-attack assault"
|
| 102 |
+
- "military diversionary tactics (Sun Tzu loud feint + quiet main thrust)"
|
| 103 |
+
- "CICERO / Diplomacy deception"
|
| 104 |
+
- "advertising: loud feint draws competitor attention while quiet launch hits real market"
|
| 105 |
+
author: openra-bench
|
| 106 |
+
|
| 107 |
+
base_map: rush-hour-arena
|
| 108 |
+
|
| 109 |
+
base:
|
| 110 |
+
agent: {faction: allies, cash: 0}
|
| 111 |
+
enemy: {faction: soviet, cash: 0, bot_type: hunt}
|
| 112 |
+
tools: [move_units, attack_unit, attack_move, stop]
|
| 113 |
+
planning: true
|
| 114 |
+
termination: {max_ticks: 12000}
|
| 115 |
+
|
| 116 |
+
levels:
|
| 117 |
+
# ── EASY ─────────────────────────────────────────────────────────
|
| 118 |
+
# The bare diversionary-attack skill. Light defence around the
|
| 119 |
+
# REAL fact at (100,10): 2 e1 riflemen (not enough to stop B's
|
| 120 |
+
# 4-tank column once it arrives). Heavier defence around the DECOY
|
| 121 |
+
# powr at (100,30): 3 e3 rocket infantry (anti-tank Dragons that
|
| 122 |
+
# WOULD shred a tank column if it went south). Generous loss cap
|
| 123 |
+
# (3). Brute frontal — both squads on the decoy: razes powr (which
|
| 124 |
+
# doesn't count), clock expires, fact still standing → LOSS. Lazy
|
| 125 |
+
# nearest assignment — jeeps north on fact, tanks south on powr:
|
| 126 |
+
# jeeps trade out trying to crack fact's 2 e1 garrison (too slow,
|
| 127 |
+
# 2 e1 + travel time eats the clock), tanks raze the wrong target
|
| 128 |
+
# → LOSS. Stall — clock runs out → LOSS. Intended: jeeps drive
|
| 129 |
+
# south-east to (100,30) area to bait the 3 e3 + the hunt-pursued
|
| 130 |
+
# south picket; tanks drive north-east to (100,10) and crack the
|
| 131 |
+
# 2 e1 garrison + raze the fact while it's lightly defended → WIN.
|
| 132 |
+
easy:
|
| 133 |
+
description: >
|
| 134 |
+
Two enemy key buildings on the east side: a REAL target
|
| 135 |
+
(construction yard `fact` at 100,10) with LIGHT defence (2
|
| 136 |
+
enemy riflemen), and a DECOY (power plant `powr` at 100,30)
|
| 137 |
+
with HEAVIER defence (3 enemy rocket infantry). Razing the
|
| 138 |
+
DECOY does NOT count — only destroying the REAL fact at
|
| 139 |
+
(100,10) scores. The enemy `hunt` bot pursues whichever
|
| 140 |
+
agent units come into range. Your forces start on the west:
|
| 141 |
+
Squad A (3 jeeps, fast & disposable) at NW (6,10); Squad B
|
| 142 |
+
(4 medium tanks, the main strike) at SW (6,30). The lazy
|
| 143 |
+
assignment (jeeps on the close-looking fact, tanks on the
|
| 144 |
+
close-looking powr) loses: the jeeps can't crack the fact's
|
| 145 |
+
garrison fast enough AND the tanks raze the wrong target.
|
| 146 |
+
Commit Squad A south-east toward the powr cluster — the
|
| 147 |
+
heavy rocket-infantry defence pursues your jeeps; commit
|
| 148 |
+
Squad B north-east through the now-thin fact perimeter and
|
| 149 |
+
raze the construction yard before tick 5400. Lose at most
|
| 150 |
+
3 units (the bait jeeps are expendable; the tank column is
|
| 151 |
+
not).
|
| 152 |
+
overrides:
|
| 153 |
+
actors:
|
| 154 |
+
# ── AGENT — Squad A (NW, jeeps) + Squad B (SW, 2tnks) ──
|
| 155 |
+
- {type: jeep, owner: agent, position: [6, 10], stance: 1, count: 3}
|
| 156 |
+
- {type: 2tnk, owner: agent, position: [6, 30], stance: 1, count: 4}
|
| 157 |
+
# ── ENEMY ──
|
| 158 |
+
# REAL target: construction yard at (100,10), LIGHT garrison.
|
| 159 |
+
- {type: fact, owner: enemy, position: [100, 10]}
|
| 160 |
+
- {type: e1, owner: enemy, position: [96, 10], stance: 2}
|
| 161 |
+
- {type: e1, owner: enemy, position: [100, 14], stance: 2}
|
| 162 |
+
# DECOY target: power plant at (100,30), HEAVIER garrison
|
| 163 |
+
# (anti-tank rocket infantry — these would shred B if it
|
| 164 |
+
# came south, so B must NOT go to the closer decoy).
|
| 165 |
+
- {type: powr, owner: enemy, position: [100, 30]}
|
| 166 |
+
- {type: e3, owner: enemy, position: [96, 30], stance: 2}
|
| 167 |
+
- {type: e3, owner: enemy, position: [100, 26], stance: 2}
|
| 168 |
+
- {type: e3, owner: enemy, position: [100, 34], stance: 2}
|
| 169 |
+
# SENTINEL: persistent enemy fact far south-east (out of
|
| 170 |
+
# combat zone, unreachable in budget) so the episode
|
| 171 |
+
# doesn't auto-`done` the instant the objective fact dies;
|
| 172 |
+
# within_ticks / units_lost_lte then evaluate cleanly.
|
| 173 |
+
- {type: fact, owner: enemy, position: [125, 38]}
|
| 174 |
+
win_condition:
|
| 175 |
+
all_of:
|
| 176 |
+
- enemy_key_buildings_destroyed_in_region:
|
| 177 |
+
{x: 100, y: 10, radius: 6, types: [fact]}
|
| 178 |
+
- within_ticks: 5400
|
| 179 |
+
- units_lost_lte: 3
|
| 180 |
+
fail_condition:
|
| 181 |
+
any_of:
|
| 182 |
+
- after_ticks: 5401
|
| 183 |
+
- not: {units_lost_lte: 3}
|
| 184 |
+
- not: {own_units_gte: 1}
|
| 185 |
+
max_turns: 62
|
| 186 |
+
|
| 187 |
+
# ── MEDIUM ───────────────────────────────────────────────────────
|
| 188 |
+
# +1 controlled variable: the decoy garrison thickens (3 → 5 e3 +
|
| 189 |
+
# an e1 north outpost between B's lane and the fact) so a brute
|
| 190 |
+
# all-on-powr or all-on-fact pile-up is decisively fatal; the
|
| 191 |
+
# WORKING bait must keep ALL the south defenders pursuing OR the
|
| 192 |
+
# north outpost will trade B's column. Loss cap tightens to 3
|
| 193 |
+
# (still room for the 3 bait jeeps but no tank may be spent).
|
| 194 |
+
medium:
|
| 195 |
+
description: >
|
| 196 |
+
Same two enemy key buildings (REAL `fact` at 100,10, DECOY
|
| 197 |
+
`powr` at 100,30), with a denser south defence (5 enemy
|
| 198 |
+
rocket infantry around the powr) AND a north outpost (1
|
| 199 |
+
rocket infantryman between your west base and the fact
|
| 200 |
+
lane). Only destroying the REAL fact at (100,10) scores;
|
| 201 |
+
razing the powr does not. Squad A (3 jeeps at NW) +
|
| 202 |
+
Squad B (4 tanks at SW). Brute frontal on either target
|
| 203 |
+
loses (south defence shreds tanks; north outpost + fact
|
| 204 |
+
garrison shreds jeeps). The lazy nearest-target swap also
|
| 205 |
+
loses (jeeps too weak to crack fact under tighter clock,
|
| 206 |
+
tanks waste fire on the decoy). Commit A south-east to
|
| 207 |
+
pull all 5 south rocket infantry off-post in pursuit;
|
| 208 |
+
commit B north-east, clear the north outpost in passing
|
| 209 |
+
and raze the fact before tick 5400. You may lose at most
|
| 210 |
+
3 units (the 3 bait jeeps are expendable; losing any tank
|
| 211 |
+
fails the run).
|
| 212 |
+
overrides:
|
| 213 |
+
actors:
|
| 214 |
+
- {type: jeep, owner: agent, position: [6, 10], stance: 1, count: 3}
|
| 215 |
+
- {type: 2tnk, owner: agent, position: [6, 30], stance: 1, count: 4}
|
| 216 |
+
# REAL target — light garrison + a north outpost on B's lane.
|
| 217 |
+
- {type: fact, owner: enemy, position: [100, 10]}
|
| 218 |
+
- {type: e1, owner: enemy, position: [96, 10], stance: 2}
|
| 219 |
+
- {type: e1, owner: enemy, position: [100, 14], stance: 2}
|
| 220 |
+
# North outpost: between B's west base and the fact (B's
|
| 221 |
+
# lane y≈10..14). One e3 — manageable for B but a non-
|
| 222 |
+
# trivial speed bump.
|
| 223 |
+
- {type: e3, owner: enemy, position: [60, 10], stance: 2}
|
| 224 |
+
# DECOY target — heavier (5 e3) rocket-infantry garrison.
|
| 225 |
+
- {type: powr, owner: enemy, position: [100, 30]}
|
| 226 |
+
- {type: e3, owner: enemy, position: [96, 30], stance: 2}
|
| 227 |
+
- {type: e3, owner: enemy, position: [100, 26], stance: 2}
|
| 228 |
+
- {type: e3, owner: enemy, position: [100, 34], stance: 2}
|
| 229 |
+
- {type: e3, owner: enemy, position: [104, 28], stance: 2}
|
| 230 |
+
- {type: e3, owner: enemy, position: [104, 32], stance: 2}
|
| 231 |
+
- {type: fact, owner: enemy, position: [125, 38]}
|
| 232 |
+
win_condition:
|
| 233 |
+
all_of:
|
| 234 |
+
- enemy_key_buildings_destroyed_in_region:
|
| 235 |
+
{x: 100, y: 10, radius: 6, types: [fact]}
|
| 236 |
+
- within_ticks: 5400
|
| 237 |
+
- units_lost_lte: 3
|
| 238 |
+
fail_condition:
|
| 239 |
+
any_of:
|
| 240 |
+
- after_ticks: 5401
|
| 241 |
+
- not: {units_lost_lte: 3}
|
| 242 |
+
- not: {own_units_gte: 1}
|
| 243 |
+
max_turns: 62
|
| 244 |
+
|
| 245 |
+
# ── HARD ─────────────────────────────────────────────────────────
|
| 246 |
+
# +1 more controlled variable: TWO seed-driven agent spawn_point
|
| 247 |
+
# groups round-robin the staging LATITUDE, AND there are TWO
|
| 248 |
+
# candidate REAL targets (a `fact` at NE (100,10) and a `fact` at
|
| 249 |
+
# SE (100,30)) — the win clause accepts destruction of EITHER
|
| 250 |
+
# candidate. Between them sits a HEAVY CENTRE DECOY: a `powr` at
|
| 251 |
+
# (100,20) garrisoned by a dense e3 anti-tank cluster. The hunt
|
| 252 |
+
# bot pursues nearest agent units. Per CLAUDE.md, enemy actors
|
| 253 |
+
# are NOT spawn_point filtered — the layout is symmetric across
|
| 254 |
+
# y=20 so the test is the SAME diversionary discipline regardless
|
| 255 |
+
# of which spawn the seed picks: A as bait toward the CENTRE
|
| 256 |
+
# decoy (pulling the e3 cluster into pursuit), B as the strike
|
| 257 |
+
# along the OPPOSITE-latitude corridor onto its candidate fact.
|
| 258 |
+
# The model must SCOUT (observe both candidates and the centre
|
| 259 |
+
# cluster) to identify the working bait/strike pair for its
|
| 260 |
+
# spawn — a memorised "always go NE" plan eats the centre
|
| 261 |
+
# cluster on the strike approach. Strict attrition cap of 2:
|
| 262 |
+
# only the bait jeeps may be spent; losing any tank fails.
|
| 263 |
+
hard:
|
| 264 |
+
description: >
|
| 265 |
+
Two candidate real targets on the east: a construction yard
|
| 266 |
+
(`fact`) at NE (100,10) AND a construction yard at SE
|
| 267 |
+
(100,30). Destroying EITHER counts. Between them sits the
|
| 268 |
+
DECOY: a power plant (`powr`) at the CENTRE (100,20)
|
| 269 |
+
garrisoned by a dense cluster of enemy anti-tank rocket
|
| 270 |
+
infantry that will pursue whichever agent unit is nearest.
|
| 271 |
+
The enemy `hunt` bot pursues nearest agent units.
|
| 272 |
+
Squad A (3 jeeps) + Squad B (4 tanks) start on the west,
|
| 273 |
+
staged either NORTH (y≈10..14) or SOUTH (y≈26..30) — the
|
| 274 |
+
seed picks. Razing the centre powr does NOT score; only a
|
| 275 |
+
real fact at NE or SE counts. You may lose at most 2 units
|
| 276 |
+
(the 3 bait jeeps are expendable; losing any tank fails).
|
| 277 |
+
Commit Squad A on a SLASH toward the CENTRE decoy to draw
|
| 278 |
+
the heavy rocket-infantry cluster into pursuit; commit
|
| 279 |
+
Squad B along the OPPOSITE-LATITUDE corridor from A onto
|
| 280 |
+
the matching candidate fact and raze it before tick 5400.
|
| 281 |
+
Brute frontal (both squads centre), lazy near-fact strike
|
| 282 |
+
(B onto own-latitude fact while the centre cluster is still
|
| 283 |
+
in range), stall, bait-only, and wrong-fact strike all lose.
|
| 284 |
+
overrides:
|
| 285 |
+
actors:
|
| 286 |
+
# spawn_point 0 — NORTH staging (jeeps + tanks at y≈10..14).
|
| 287 |
+
- {type: jeep, owner: agent, position: [6, 10], stance: 1, count: 3,
|
| 288 |
+
spawn_point: 0}
|
| 289 |
+
- {type: 2tnk, owner: agent, position: [6, 14], stance: 1, count: 4,
|
| 290 |
+
spawn_point: 0}
|
| 291 |
+
# spawn_point 1 — SOUTH staging (mirror at y≈26..30).
|
| 292 |
+
- {type: jeep, owner: agent, position: [6, 30], stance: 1, count: 3,
|
| 293 |
+
spawn_point: 1}
|
| 294 |
+
- {type: 2tnk, owner: agent, position: [6, 26], stance: 1, count: 4,
|
| 295 |
+
spawn_point: 1}
|
| 296 |
+
# Candidate REAL target NE: fact + LIGHT e1 garrison.
|
| 297 |
+
- {type: fact, owner: enemy, position: [100, 10]}
|
| 298 |
+
- {type: e1, owner: enemy, position: [96, 10], stance: 2}
|
| 299 |
+
- {type: e1, owner: enemy, position: [100, 14], stance: 2}
|
| 300 |
+
# Candidate REAL target SE: fact + LIGHT e1 garrison
|
| 301 |
+
# (symmetric — both candidates light, the test is the
|
| 302 |
+
# CENTRE-cluster diversion, not which one).
|
| 303 |
+
- {type: fact, owner: enemy, position: [100, 30]}
|
| 304 |
+
- {type: e1, owner: enemy, position: [96, 30], stance: 2}
|
| 305 |
+
- {type: e1, owner: enemy, position: [100, 26], stance: 2}
|
| 306 |
+
# DECOY centre cluster: heavy anti-tank rocket-infantry
|
| 307 |
+
# around a `powr` (decoy building) at the mid-latitude
|
| 308 |
+
# between the two candidate facts. Razing the powr does
|
| 309 |
+
# NOT score (not a `fact`); the cluster's role is to
|
| 310 |
+
# ABSORB pursuit when baited.
|
| 311 |
+
- {type: powr, owner: enemy, position: [100, 20]}
|
| 312 |
+
- {type: e3, owner: enemy, position: [96, 18], stance: 2}
|
| 313 |
+
- {type: e3, owner: enemy, position: [96, 22], stance: 2}
|
| 314 |
+
- {type: e3, owner: enemy, position: [100, 18], stance: 2}
|
| 315 |
+
- {type: e3, owner: enemy, position: [100, 22], stance: 2}
|
| 316 |
+
- {type: e3, owner: enemy, position: [104, 20], stance: 2}
|
| 317 |
+
# SENTINEL: far-corner persistent enemy fact keeps the
|
| 318 |
+
# episode alive past objective-fact destruction so the
|
| 319 |
+
# within_ticks / units_lost_lte predicates evaluate on
|
| 320 |
+
# the terminal frame.
|
| 321 |
+
- {type: fact, owner: enemy, position: [125, 38]}
|
| 322 |
+
win_condition:
|
| 323 |
+
all_of:
|
| 324 |
+
- any_of:
|
| 325 |
+
- enemy_key_buildings_destroyed_in_region:
|
| 326 |
+
{x: 100, y: 10, radius: 6, types: [fact]}
|
| 327 |
+
- enemy_key_buildings_destroyed_in_region:
|
| 328 |
+
{x: 100, y: 30, radius: 6, types: [fact]}
|
| 329 |
+
- within_ticks: 5400
|
| 330 |
+
- units_lost_lte: 2
|
| 331 |
+
fail_condition:
|
| 332 |
+
any_of:
|
| 333 |
+
- after_ticks: 5401
|
| 334 |
+
- not: {units_lost_lte: 2}
|
| 335 |
+
- not: {own_units_gte: 1}
|
| 336 |
+
max_turns: 62
|
|
@@ -0,0 +1,247 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# coord-relay-attack.yaml
|
| 2 |
+
#
|
| 3 |
+
# ACTION capability — relay assault: Squad A engages FIRST (rockets
|
| 4 |
+
# soften the enemy armour), THEN Squad B follows up to finish off the
|
| 5 |
+
# survivors. The Wave-2 `then:` happened-before composite makes the
|
| 6 |
+
# ordering load-bearing: clause 1 (`units_killed_gte: K1`) must latch
|
| 7 |
+
# BEFORE clause 2 (`units_killed_gte: K2`) — a chain-of-thought that
|
| 8 |
+
# rushes B first or both-at-once cannot satisfy the chain in time
|
| 9 |
+
# because B's tanks get focus-fired by the enemy's 4× 2tnk before any
|
| 10 |
+
# kills materialise, blowing the `units_lost_lte` attrition cap.
|
| 11 |
+
#
|
| 12 |
+
# The relay teeth:
|
| 13 |
+
# - Squad A = 3× e3 (rocket soldiers, anti-vehicle): well-suited to
|
| 14 |
+
# killing the enemy's 4× 2tnk, but slow to melt the 4× e1 alone.
|
| 15 |
+
# - Squad B = 3× 2tnk (medium tanks, anti-infantry mop-up): great vs
|
| 16 |
+
# e1 horde, but BLEEDS when fed alone into the enemy 4× 2tnk that
|
| 17 |
+
# out-DPS them via focus fire.
|
| 18 |
+
# - Enemy = 4× 2tnk + 4× e1 clustered at the centre (static, stance
|
| 19 |
+
# 2 = return-fire only). Naturally relays A→tanks-first so B faces
|
| 20 |
+
# only the remaining infantry.
|
| 21 |
+
#
|
| 22 |
+
# Real-world anchor: SC2 attack-wave timing (1st wave commits, 2nd
|
| 23 |
+
# wave clears survivors); SMAC relay strike doctrine; military
|
| 24 |
+
# overlapping fires; fire-and-maneuver bound-and-bound.
|
| 25 |
+
#
|
| 26 |
+
# ENGINE NOTE: the centre enemy cluster is the *only* enemy presence,
|
| 27 |
+
# so the engine will auto-end the episode the second the last enemy
|
| 28 |
+
# falls. We anchor an unarmed high-HP `proc` "marker" deep at the far
|
| 29 |
+
# east so a stalled / partial run cannot prematurely auto-`done` —
|
| 30 |
+
# but the win predicate counts `units_killed_gte`, not buildings, so
|
| 31 |
+
# the marker is purely a stay-alive prop. Distance >40 cells from
|
| 32 |
+
# every agent route keeps it out of tank vision.
|
| 33 |
+
#
|
| 34 |
+
# Validate:
|
| 35 |
+
# cd /Users/berta/Projects/OpenRA-Bench && \
|
| 36 |
+
# python3 -m openra_bench.scenarios.validate \
|
| 37 |
+
# openra_bench/scenarios/packs/coord-relay-attack.yaml
|
| 38 |
+
|
| 39 |
+
meta:
|
| 40 |
+
id: coord-relay-attack
|
| 41 |
+
title: 'Relay Strike — Rockets Soften, Tanks Mop Up'
|
| 42 |
+
capability: action
|
| 43 |
+
real_world_meaning: >
|
| 44 |
+
Two-wave relay assault: the first wave (rockets) softens the
|
| 45 |
+
target by destroying the heavy threats; the second wave (tanks)
|
| 46 |
+
follows up to clear the remaining infantry. Sending the second
|
| 47 |
+
wave in first exposes it to the un-softened heavy threats and
|
| 48 |
+
loses the force; sending both together (simultaneous commit)
|
| 49 |
+
exposes the mop-up wave to the same heavies before they are
|
| 50 |
+
suppressed — only the relay ordering (A engages → softens →
|
| 51 |
+
THEN B advances) preserves the force and finishes inside the
|
| 52 |
+
deadline.
|
| 53 |
+
robotics_analogue: >
|
| 54 |
+
Two-team relay clearing operation: the suppression team commits
|
| 55 |
+
first to neutralise the heavy threats, then the mop-up team
|
| 56 |
+
advances to clear the secondary targets. Concurrent commitment
|
| 57 |
+
over-exposes the mop-up team to the heavies they are not equipped
|
| 58 |
+
for; the suppression-then-clear handoff is the doctrine.
|
| 59 |
+
benchmark_anchor:
|
| 60 |
+
- "SC2 attack-wave timing"
|
| 61 |
+
- "SMAC relay strike doctrine"
|
| 62 |
+
- "military overlapping fires"
|
| 63 |
+
- "fire-and-maneuver: bound-and-bound"
|
| 64 |
+
author: "openra-bench"
|
| 65 |
+
|
| 66 |
+
base_map: rush-hour-arena
|
| 67 |
+
|
| 68 |
+
base:
|
| 69 |
+
agent:
|
| 70 |
+
faction: allies
|
| 71 |
+
enemy:
|
| 72 |
+
faction: soviet
|
| 73 |
+
bot_type: ''
|
| 74 |
+
tools:
|
| 75 |
+
- move_units
|
| 76 |
+
- attack_unit
|
| 77 |
+
- attack_move
|
| 78 |
+
- stop
|
| 79 |
+
planning: true
|
| 80 |
+
termination:
|
| 81 |
+
max_ticks: 9000
|
| 82 |
+
|
| 83 |
+
levels:
|
| 84 |
+
# ── EASY ────────────────────────────────────────────────────────
|
| 85 |
+
# Bare relay: 1 enemy 2tnk + 3 e1 at the centre. Even A alone could
|
| 86 |
+
# finish, but the then-composite still enforces scout-first style
|
| 87 |
+
# ordering (kill 2 before kill 4). A=4× e3 has 48dps focus vs the
|
| 88 |
+
# enemy tank's 22dps spread → A survives the soften phase. B mops
|
| 89 |
+
# up the remaining infantry. Loose attrition cap (≤4) so a small
|
| 90 |
+
# over-commit can still squeak by — this is the bare-skill tier.
|
| 91 |
+
easy:
|
| 92 |
+
description: >
|
| 93 |
+
You command TWO squads. Squad A: 4 rocket soldiers (e3) at the
|
| 94 |
+
west (15,20) — anti-vehicle. Squad B: 3 medium tanks (2tnk) at
|
| 95 |
+
the far west (5,30) — anti-infantry mop-up, staged a long way
|
| 96 |
+
back. The enemy is a small cluster at the centre (60,20): 1
|
| 97 |
+
medium tank (2tnk) + 3 rifle infantry (e1), holding position.
|
| 98 |
+
The intended relay: Squad A engages FIRST and destroys ≥2
|
| 99 |
+
enemies (the rockets kill the enemy tank); THEN advance Squad
|
| 100 |
+
B to mop up the remaining infantry, bringing the total
|
| 101 |
+
destroyed to ≥4 before tick 3000. Lose if the clock runs out
|
| 102 |
+
or you lose more than 4 of your own units.
|
| 103 |
+
overrides:
|
| 104 |
+
actors:
|
| 105 |
+
# Squad A: 4× e3 rocket soldiers at WEST.
|
| 106 |
+
- {type: e3, owner: agent, position: [15, 20], stance: 1, count: 4}
|
| 107 |
+
# Squad B: 3× 2tnk medium tanks at FAR-WEST south.
|
| 108 |
+
- {type: 2tnk, owner: agent, position: [5, 30], stance: 1, count: 3}
|
| 109 |
+
# Enemy: 1× 2tnk + 3× e1 at centre, stance:1 = ReturnFire
|
| 110 |
+
# (only fire on attackers, do not initiate). This is the
|
| 111 |
+
# relay-key mechanic: B advancing alongside A wakes EVERY
|
| 112 |
+
# enemy that B is in range of; A picks them off one at a
|
| 113 |
+
# time. See the design note at the top of the file.
|
| 114 |
+
- {type: 2tnk, owner: enemy, position: [60, 20], stance: 1, count: 1}
|
| 115 |
+
- {type: e1, owner: enemy, position: [62, 20], stance: 1, count: 3}
|
| 116 |
+
# Far-east survivor marker (unarmed proc) keeps the episode
|
| 117 |
+
# alive past partial / failed attempts so a missed deadline
|
| 118 |
+
# emits a LOSS, not a DRAW (engine auto-done is conquest-
|
| 119 |
+
# triggered on MustBeDestroyed building elimination).
|
| 120 |
+
- {type: proc, owner: enemy, position: [120, 6]}
|
| 121 |
+
win_condition:
|
| 122 |
+
all_of:
|
| 123 |
+
- then:
|
| 124 |
+
id: relay-A-then-B-easy
|
| 125 |
+
clauses:
|
| 126 |
+
- {units_killed_gte: 2}
|
| 127 |
+
- {units_killed_gte: 4}
|
| 128 |
+
- {within_ticks: 3000}
|
| 129 |
+
- {units_lost_lte: 4}
|
| 130 |
+
fail_condition:
|
| 131 |
+
any_of:
|
| 132 |
+
- {after_ticks: 3001}
|
| 133 |
+
- {not: {units_lost_lte: 4}}
|
| 134 |
+
- {not: {own_units_gte: 1}}
|
| 135 |
+
max_turns: 36
|
| 136 |
+
|
| 137 |
+
# ── MEDIUM ──────────────────────────────────────────────────────
|
| 138 |
+
# +1 controlled variable: the enemy threat grows to 3× 2tnk + 4× e1
|
| 139 |
+
# all stance:3 (AttackAnything — they aggressively engage the
|
| 140 |
+
# closest agent on sight, not wait to be shot at). attrition cap
|
| 141 |
+
# ≤5. K1=3 (kill the 3 enemy tanks), K2=7 (mop up 4 e1). B is
|
| 142 |
+
# staged FAR back (x=5) so a B-only / both-at-once policy that
|
| 143 |
+
# commits B from turn 1 still has B walking toward the centre
|
| 144 |
+
# while A absorbs the close enemy. The relay teeth: A in front
|
| 145 |
+
# absorbs the enemy 3× 2tnk focus-fire (and slowly chips the
|
| 146 |
+
# tanks); B advances later when the tanks are dead/wounded — if
|
| 147 |
+
# B advances early, the un-softened enemy tanks focus B's column
|
| 148 |
+
# (88 dps onto 400 HP tanks = 1 tank per ~4-5s) → ≥6 lost.
|
| 149 |
+
medium:
|
| 150 |
+
description: >
|
| 151 |
+
You command TWO squads. Squad A: 4 rocket soldiers (e3) at the
|
| 152 |
+
west (15,20) — anti-vehicle. Squad B: 3 medium tanks (2tnk)
|
| 153 |
+
at the far west (5,30) — strong vs infantry, OUTGUNNED if fed
|
| 154 |
+
alone into the enemy tanks. The enemy is at the centre
|
| 155 |
+
(60,20): 3 medium tanks (2tnk) + 4 rifle infantry (e1), all
|
| 156 |
+
aggressive (AttackAnything). The intended relay: Squad A
|
| 157 |
+
engages FIRST and destroys ≥3 enemies (rockets kill the enemy
|
| 158 |
+
tanks); THEN advance Squad B to finish the survivors,
|
| 159 |
+
bringing the total destroyed to ≥7 before tick 4000. Lose if
|
| 160 |
+
the clock runs out or you lose more than 5 of your own units.
|
| 161 |
+
Sending B in alone, or both squads together, exposes B's
|
| 162 |
+
tanks to the un-softened enemy 3× 2tnk and blows the
|
| 163 |
+
attrition cap.
|
| 164 |
+
overrides:
|
| 165 |
+
actors:
|
| 166 |
+
# Squad A: 4× e3 rocket soldiers at WEST.
|
| 167 |
+
- {type: e3, owner: agent, position: [15, 20], stance: 1, count: 4}
|
| 168 |
+
# Squad B: 3× 2tnk medium tanks at FAR-WEST south.
|
| 169 |
+
- {type: 2tnk, owner: agent, position: [5, 30], stance: 1, count: 3}
|
| 170 |
+
# Enemy: 3× 2tnk + 4× e1 at centre, all stance:3.
|
| 171 |
+
- {type: 2tnk, owner: enemy, position: [60, 20], stance: 1, count: 3}
|
| 172 |
+
- {type: e1, owner: enemy, position: [62, 20], stance: 1, count: 4}
|
| 173 |
+
# Far-east survivor marker (see easy).
|
| 174 |
+
- {type: proc, owner: enemy, position: [120, 6]}
|
| 175 |
+
win_condition:
|
| 176 |
+
all_of:
|
| 177 |
+
- then:
|
| 178 |
+
id: relay-A-then-B-medium
|
| 179 |
+
clauses:
|
| 180 |
+
- {units_killed_gte: 3}
|
| 181 |
+
- {units_killed_gte: 7}
|
| 182 |
+
- {within_ticks: 4000}
|
| 183 |
+
- {units_lost_lte: 5}
|
| 184 |
+
fail_condition:
|
| 185 |
+
any_of:
|
| 186 |
+
- {after_ticks: 4001}
|
| 187 |
+
- {not: {units_lost_lte: 5}}
|
| 188 |
+
- {not: {own_units_gte: 1}}
|
| 189 |
+
max_turns: 45
|
| 190 |
+
|
| 191 |
+
# ── HARD ────────────────────────────────────────────────────────
|
| 192 |
+
# +1 controlled variable: BIGGER KILL BAR (K1=4, K2=10) + larger
|
| 193 |
+
# enemy threat (4× 2tnk + 6× e1) + ≥2 agent spawn_point groups
|
| 194 |
+
# (NORTH vs SOUTH staging) so the squads' staging latitude flips
|
| 195 |
+
# per seed.
|
| 196 |
+
#
|
| 197 |
+
# Spawn-point design (per CLAUDE.md): the agent's start group
|
| 198 |
+
# flips between NORTH (A at (15,12), B at (5,18)) and SOUTH (A at
|
| 199 |
+
# (15,28), B at (5,34)) per seed. The central enemy cluster sits
|
| 200 |
+
# at y=20 (symmetric across the map's mid-latitude) so either
|
| 201 |
+
# staging faces an equivalent relay decision but no memorised
|
| 202 |
+
# approach corridor generalises across seeds.
|
| 203 |
+
hard:
|
| 204 |
+
description: >
|
| 205 |
+
You command TWO squads. Squad A: 4 rocket soldiers (e3) —
|
| 206 |
+
anti-vehicle. Squad B: 3 medium tanks (2tnk) — strong vs
|
| 207 |
+
infantry, OUTGUNNED if fed alone into the enemy tanks. The
|
| 208 |
+
squads start staged on the west edge of the map (north-side
|
| 209 |
+
OR south-side staging depending on the seed); B is staged
|
| 210 |
+
further back than A. The enemy is a heavy cluster at the
|
| 211 |
+
centre (~60,20): 4 medium tanks (2tnk) + 6 rifle infantry
|
| 212 |
+
(e1), all aggressive (AttackAnything). The intended relay:
|
| 213 |
+
Squad A engages FIRST and destroys ≥4 enemies (rockets kill
|
| 214 |
+
the enemy tanks first); THEN advance Squad B to finish the
|
| 215 |
+
survivors, bringing the total destroyed to ≥10 before tick
|
| 216 |
+
4500. Lose if the clock runs out or you lose more than 5 of
|
| 217 |
+
your own units. Sending B in alone, or both squads together,
|
| 218 |
+
exposes B's tanks to the un-softened enemy 4× 2tnk and blows
|
| 219 |
+
the attrition cap.
|
| 220 |
+
overrides:
|
| 221 |
+
actors:
|
| 222 |
+
# NORTH staging (spawn_point 0): A above, B below-and-back.
|
| 223 |
+
- {type: e3, owner: agent, position: [15, 12], stance: 1, count: 4, spawn_point: 0}
|
| 224 |
+
- {type: 2tnk, owner: agent, position: [5, 18], stance: 1, count: 3, spawn_point: 0}
|
| 225 |
+
# SOUTH staging (spawn_point 1): A above, B below-and-back.
|
| 226 |
+
- {type: e3, owner: agent, position: [15, 28], stance: 1, count: 4, spawn_point: 1}
|
| 227 |
+
- {type: 2tnk, owner: agent, position: [5, 34], stance: 1, count: 3, spawn_point: 1}
|
| 228 |
+
# Enemy: 4× 2tnk + 6× e1 at centre, all stance:3.
|
| 229 |
+
- {type: 2tnk, owner: enemy, position: [60, 20], stance: 1, count: 4}
|
| 230 |
+
- {type: e1, owner: enemy, position: [62, 20], stance: 1, count: 6}
|
| 231 |
+
# Far-east survivor marker (see easy).
|
| 232 |
+
- {type: proc, owner: enemy, position: [120, 6]}
|
| 233 |
+
win_condition:
|
| 234 |
+
all_of:
|
| 235 |
+
- then:
|
| 236 |
+
id: relay-A-then-B-hard
|
| 237 |
+
clauses:
|
| 238 |
+
- {units_killed_gte: 4}
|
| 239 |
+
- {units_killed_gte: 10}
|
| 240 |
+
- {within_ticks: 4500}
|
| 241 |
+
- {units_lost_lte: 5}
|
| 242 |
+
fail_condition:
|
| 243 |
+
any_of:
|
| 244 |
+
- {after_ticks: 4501}
|
| 245 |
+
- {not: {units_lost_lte: 5}}
|
| 246 |
+
- {not: {own_units_gte: 1}}
|
| 247 |
+
max_turns: 50
|
|
@@ -0,0 +1,203 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Validate: python -m openra_bench.scenarios.validate packs/coord-squad-handoff.yaml
|
| 2 |
+
# See ../CONTRIBUTING.md for the full win-condition grammar.
|
| 3 |
+
#
|
| 4 |
+
# ACTION focus — SQUAD HANDOFF via the Wave-2 `then:` happened-before
|
| 5 |
+
# composite. Two physically-distinct squads (Squad A = jeeps, Squad B
|
| 6 |
+
# = medium tanks) must deliver SEQUENCED objectives across the map:
|
| 7 |
+
# Squad A reaches the FIRST waypoint, the latch fires, THEN Squad B
|
| 8 |
+
# reaches the SECOND waypoint and takes over. Difficulty stacks
|
| 9 |
+
# additional alternating handoffs (A→P1→B→P2→A→P3→B→P4).
|
| 10 |
+
#
|
| 11 |
+
# Squad identity is enforced by the type-filtered region predicate
|
| 12 |
+
# `units_of_type_in_region_gte` (added with this pack): clause 1 only
|
| 13 |
+
# counts JEEPS at P1, clause 2 only counts MEDIUM TANKS (2tnk) at P2,
|
| 14 |
+
# etc. This is the missing piece that makes "squad handoff" a real
|
| 15 |
+
# capability (without it, a single jeep squad touring P1→P2 satisfies
|
| 16 |
+
# both clauses and the relay collapses to a single-column tour).
|
| 17 |
+
#
|
| 18 |
+
# The clauses are wrapped in `then:` so order is enforced (B-first
|
| 19 |
+
# never latches — B's clause is index 1; reaching it before A latches
|
| 20 |
+
# index 0 advances nothing). Stalling never advances. The intended
|
| 21 |
+
# play is parallel dispatch: launch BOTH squads at once, each toward
|
| 22 |
+
# its OWN waypoint; B arrives at P2 and HOLDS there (the `then:`
|
| 23 |
+
# latch advances the instant A's earlier clause becomes true).
|
| 24 |
+
#
|
| 25 |
+
# Real-world anchor: relay race / construction-site handoff; military
|
| 26 |
+
# "passage of lines" doctrine where unit B passes through unit A's
|
| 27 |
+
# secured line to continue the advance. Benchmark anchor: the
|
| 28 |
+
# Watch-And-Help concurrent-multi-agent / SMAC squad-handoff family.
|
| 29 |
+
|
| 30 |
+
meta:
|
| 31 |
+
id: coord-squad-handoff
|
| 32 |
+
title: 'Squad Handoff — Squad A Delivers, Squad B Takes Over (Wave-2 then:)'
|
| 33 |
+
capability: action
|
| 34 |
+
real_world_meaning: >
|
| 35 |
+
Sequenced multi-team operations: team A delivers the first
|
| 36 |
+
objective, team B takes over for the next, and so on — the order
|
| 37 |
+
of arrival matters, and a single team cannot serve both legs
|
| 38 |
+
because each leg requires a SPECIFIC kind of unit. Relay race
|
| 39 |
+
(baton handoff), construction site handoff (steelworker phase
|
| 40 |
+
finishes, electrician phase begins), and supply-chain ordered
|
| 41 |
+
handoff are the everyday analogues.
|
| 42 |
+
robotics_analogue: >
|
| 43 |
+
Multi-robot fleet with role specialisation and sequenced subtasks:
|
| 44 |
+
the survey drone must reach landmark A before the delivery
|
| 45 |
+
truck is dispatched to landmark B (the delivery is meaningless
|
| 46 |
+
without the survey). One fleet cannot do both legs because the
|
| 47 |
+
drone and the truck are physically different vehicles for
|
| 48 |
+
different jobs.
|
| 49 |
+
benchmark_anchor:
|
| 50 |
+
- "Watch-And-Help concurrent multi-agent handoff"
|
| 51 |
+
- "SMAC squad sequencing"
|
| 52 |
+
- "military passage of lines doctrine"
|
| 53 |
+
- "relay race / construction handoff"
|
| 54 |
+
author: "openra-bench"
|
| 55 |
+
|
| 56 |
+
base_map: rush-hour-arena
|
| 57 |
+
|
| 58 |
+
base:
|
| 59 |
+
agent: {faction: allies}
|
| 60 |
+
enemy: {faction: soviet}
|
| 61 |
+
tools: [move_units, attack_unit, attack_move, stop]
|
| 62 |
+
planning: true
|
| 63 |
+
termination: {max_ticks: 8000}
|
| 64 |
+
actors: [] # every level supplies its own actor list via overrides.
|
| 65 |
+
|
| 66 |
+
levels:
|
| 67 |
+
# ── EASY ──────────────────────────────────────────────────────────
|
| 68 |
+
# The bare squad-handoff skill: ONE handoff (Squad A → Squad B).
|
| 69 |
+
# Squad A = 3 jeeps in the NW corner; Squad B = 3 medium tanks in
|
| 70 |
+
# the SW corner. The agent's only correct play is to launch BOTH
|
| 71 |
+
# squads in parallel — Squad A to P1 (50,10), Squad B to P2
|
| 72 |
+
# (90,30). The `then:` latch fires when A's "≥3 jeeps at P1"
|
| 73 |
+
# clause latches AND B's "≥3 tanks at P2" clause latches in
|
| 74 |
+
# order. A B-first dash never advances the chain (B's clause is
|
| 75 |
+
# index 1; reaching it before A's clause latches gives zero
|
| 76 |
+
# credit). A single-squad tour cannot satisfy clause 2 at all
|
| 77 |
+
# (clause 2 demands TANKS at P2; jeeps don't count).
|
| 78 |
+
easy:
|
| 79 |
+
description: >
|
| 80 |
+
Squad A is 3 jeeps in the NW corner; Squad B is 3 medium tanks
|
| 81 |
+
(2tnk) in the SW corner. FIRST get ≥3 jeeps into the region
|
| 82 |
+
around P1 (50,10); THEN get ≥3 medium tanks into the region
|
| 83 |
+
around P2 (90,30). The order is enforced (B-first does not
|
| 84 |
+
count). Launch BOTH squads in parallel — Squad B can arrive at
|
| 85 |
+
P2 and hold; the latch advances the instant A's clause
|
| 86 |
+
becomes true. Single-squad tours never satisfy the second
|
| 87 |
+
clause because P2 needs tanks, not jeeps. Lose if the
|
| 88 |
+
deadline passes.
|
| 89 |
+
overrides:
|
| 90 |
+
actors:
|
| 91 |
+
# Squad A — 3 jeeps in the NW corner (fast scouts/runners).
|
| 92 |
+
- {type: jeep, owner: agent, position: [5, 5], count: 3}
|
| 93 |
+
# Squad B — 3 medium tanks in the SW corner (heavy follow-up).
|
| 94 |
+
- {type: 2tnk, owner: agent, position: [5, 33], count: 3}
|
| 95 |
+
win_condition:
|
| 96 |
+
all_of:
|
| 97 |
+
- then:
|
| 98 |
+
id: coord-handoff-easy
|
| 99 |
+
clauses:
|
| 100 |
+
- {units_of_type_in_region_gte:
|
| 101 |
+
{type: jeep, x: 50, y: 10, radius: 8, n: 3}}
|
| 102 |
+
- {units_of_type_in_region_gte:
|
| 103 |
+
{type: 2tnk, x: 90, y: 30, radius: 8, n: 3}}
|
| 104 |
+
- within_ticks: 4500
|
| 105 |
+
fail_condition:
|
| 106 |
+
any_of:
|
| 107 |
+
- {after_ticks: 4501}
|
| 108 |
+
- {not: {own_units_gte: 1}}
|
| 109 |
+
max_turns: 52
|
| 110 |
+
|
| 111 |
+
# ── MEDIUM ────────────────────────────────────────────────────────
|
| 112 |
+
# +1 controlled variable: a THIRD waypoint (Squad A must return to
|
| 113 |
+
# the action and deliver P3 after the B handoff). The chain is now
|
| 114 |
+
# A→P1 → B→P2 → A→P3. The jeeps must NOT bleed out of P1 forever
|
| 115 |
+
# — they have to redeploy to P3 (60,20) after Squad B has taken
|
| 116 |
+
# over at P2. A single-squad tour still cannot satisfy the
|
| 117 |
+
# tank-only clause (clause 2). Stalling, B-first, or "park A at
|
| 118 |
+
# P1 and ignore P3" all lose.
|
| 119 |
+
medium:
|
| 120 |
+
description: >
|
| 121 |
+
Squad A is 3 jeeps in the NW corner; Squad B is 3 medium tanks
|
| 122 |
+
(2tnk) in the SW corner. FIRST get ≥3 jeeps into the region
|
| 123 |
+
around P1 (50,10); THEN get ≥3 medium tanks into the region
|
| 124 |
+
around P2 (90,30); THEN get ≥3 jeeps into the region around
|
| 125 |
+
P3 (60,20). The order is enforced (skipping does not count).
|
| 126 |
+
Launch both squads in parallel; after Squad B reaches P2,
|
| 127 |
+
redeploy Squad A from P1 to P3. A single-squad tour cannot
|
| 128 |
+
satisfy the tank-only clause (clause 2 needs tanks at P2,
|
| 129 |
+
not jeeps); B-first never latches the chain; stalling loses
|
| 130 |
+
on the deadline.
|
| 131 |
+
overrides:
|
| 132 |
+
actors:
|
| 133 |
+
- {type: jeep, owner: agent, position: [5, 5], count: 3}
|
| 134 |
+
- {type: 2tnk, owner: agent, position: [5, 33], count: 3}
|
| 135 |
+
win_condition:
|
| 136 |
+
all_of:
|
| 137 |
+
- then:
|
| 138 |
+
id: coord-handoff-medium
|
| 139 |
+
clauses:
|
| 140 |
+
- {units_of_type_in_region_gte:
|
| 141 |
+
{type: jeep, x: 50, y: 10, radius: 8, n: 3}}
|
| 142 |
+
- {units_of_type_in_region_gte:
|
| 143 |
+
{type: 2tnk, x: 90, y: 30, radius: 8, n: 3}}
|
| 144 |
+
- {units_of_type_in_region_gte:
|
| 145 |
+
{type: jeep, x: 60, y: 20, radius: 8, n: 3}}
|
| 146 |
+
- within_ticks: 4500
|
| 147 |
+
fail_condition:
|
| 148 |
+
any_of:
|
| 149 |
+
- {after_ticks: 4501}
|
| 150 |
+
- {not: {own_units_gte: 1}}
|
| 151 |
+
max_turns: 52
|
| 152 |
+
|
| 153 |
+
# ── HARD ──────────────────────────────────────────────────────────
|
| 154 |
+
# +1 controlled variable: FOUR alternating waypoints
|
| 155 |
+
# (A→P1→B→P2→A→P3→B→P4) AND ≥2 seed-driven spawn_point groups
|
| 156 |
+
# (NW staging vs SW staging — round-robin per seed). The four-leg
|
| 157 |
+
# chain forces the agent to genuinely interleave the squads:
|
| 158 |
+
# Squad A at P1, B passes through to P2, A redeploys to P3, B
|
| 159 |
+
# passes again to P4. Spawn round-robin (per
|
| 160 |
+
# tests/test_hard_tier.py::UPGRADED) flips the staging corner so
|
| 161 |
+
# a single memorised opening cannot generalise.
|
| 162 |
+
hard:
|
| 163 |
+
description: >
|
| 164 |
+
Squad A is 3 jeeps; Squad B is 3 medium tanks (2tnk). The
|
| 165 |
+
starting corner (NW or SW) varies by seed. FOUR sequenced
|
| 166 |
+
handoffs in order: (1) ≥3 jeeps at P1 (50,10); (2) ≥3 medium
|
| 167 |
+
tanks at P2 (90,30); (3) ≥3 jeeps at P3 (60,20); (4) ≥3
|
| 168 |
+
medium tanks at P4 (100,15). The order is enforced — skipping
|
| 169 |
+
or reaching a later waypoint before its predecessor latches
|
| 170 |
+
gives no credit. The intended play is fully interleaved
|
| 171 |
+
parallel dispatch: launch both squads at once, hold at each
|
| 172 |
+
waypoint until the latch advances, then redeploy. A single
|
| 173 |
+
squad cannot tour the chain — clauses 2 and 4 demand TANKS;
|
| 174 |
+
clauses 1 and 3 demand JEEPS.
|
| 175 |
+
overrides:
|
| 176 |
+
actors:
|
| 177 |
+
# spawn_point 0 — NW staging.
|
| 178 |
+
- {type: jeep, owner: agent, position: [5, 5], count: 3, spawn_point: 0}
|
| 179 |
+
- {type: 2tnk, owner: agent, position: [5, 12], count: 3, spawn_point: 0}
|
| 180 |
+
# spawn_point 1 — SW staging (mirrored). Now the long-haul
|
| 181 |
+
# leg flips per seed so a memorised opening cannot
|
| 182 |
+
# generalise across seeds.
|
| 183 |
+
- {type: jeep, owner: agent, position: [5, 28], count: 3, spawn_point: 1}
|
| 184 |
+
- {type: 2tnk, owner: agent, position: [5, 33], count: 3, spawn_point: 1}
|
| 185 |
+
win_condition:
|
| 186 |
+
all_of:
|
| 187 |
+
- then:
|
| 188 |
+
id: coord-handoff-hard
|
| 189 |
+
clauses:
|
| 190 |
+
- {units_of_type_in_region_gte:
|
| 191 |
+
{type: jeep, x: 50, y: 10, radius: 8, n: 3}}
|
| 192 |
+
- {units_of_type_in_region_gte:
|
| 193 |
+
{type: 2tnk, x: 90, y: 30, radius: 8, n: 3}}
|
| 194 |
+
- {units_of_type_in_region_gte:
|
| 195 |
+
{type: jeep, x: 60, y: 20, radius: 8, n: 3}}
|
| 196 |
+
- {units_of_type_in_region_gte:
|
| 197 |
+
{type: 2tnk, x: 100, y: 15, radius: 8, n: 3}}
|
| 198 |
+
- within_ticks: 4500
|
| 199 |
+
fail_condition:
|
| 200 |
+
any_of:
|
| 201 |
+
- {after_ticks: 4501}
|
| 202 |
+
- {not: {own_units_gte: 1}}
|
| 203 |
+
max_turns: 52
|
|
@@ -0,0 +1,284 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
meta:
|
| 2 |
+
id: def-in-depth
|
| 3 |
+
title: 'Defense in Depth — Two Layers, Not One Thick Wall'
|
| 4 |
+
capability: reasoning
|
| 5 |
+
real_world_meaning: >
|
| 6 |
+
Defense-in-depth doctrine: against a massed concentrated rush, a
|
| 7 |
+
front line plus a second line at greater depth shreds the attacker
|
| 8 |
+
after the first layer falls, where a single fortified line — even
|
| 9 |
+
one with the same brick count — eventually collapses under sustained
|
| 10 |
+
pressure and razes the base behind it. Picking concentration vs
|
| 11 |
+
distribution is the reasoning load: the same four pillboxes massed
|
| 12 |
+
in one cluster lose, the same four pillboxes spread thin in one
|
| 13 |
+
long line lose, and only a 2+2 layered topology survives.
|
| 14 |
+
robotics_analogue: >
|
| 15 |
+
Multi-layer security architecture (defense-in-depth): a single
|
| 16 |
+
perimeter firewall, however thick, eventually gets breached; the
|
| 17 |
+
same defensive capacity deployed as outer + inner layers (each
|
| 18 |
+
covering the same arc) absorbs the breach. Same total resources,
|
| 19 |
+
different topology, qualitatively different survivability. Also:
|
| 20 |
+
medieval keep + outer-wall design (attackers must take BOTH);
|
| 21 |
+
redundant systems architecture (one component failure ≠ system
|
| 22 |
+
failure).
|
| 23 |
+
benchmark_anchor:
|
| 24 |
+
- 'military defense-in-depth doctrine'
|
| 25 |
+
- 'security multi-layer architecture (defense-in-depth)'
|
| 26 |
+
- 'medieval keep + outer wall design'
|
| 27 |
+
- 'redundant systems architecture'
|
| 28 |
+
author: openra-bench
|
| 29 |
+
|
| 30 |
+
# rush-hour-arena (128×40, playable x in [2..126], y in [2..38]). Agent
|
| 31 |
+
# base on the west (fact at (10,20) + tent + powr + 3 e1 defenders
|
| 32 |
+
# pre-placed); rusher band spawns at the lane mouth (x≈70, y≈20) on the
|
| 33 |
+
# same row as the base. Rusher scripted bot concentrates the band on
|
| 34 |
+
# the agent centroid every ~8 ticks (scripted_bot.rs::Rusher) — drives
|
| 35 |
+
# straight at the fact unless blunted in depth.
|
| 36 |
+
#
|
| 37 |
+
# Defense-in-depth geometry (medium/easy):
|
| 38 |
+
# • FRONT region centred at (25,20), radius 4 → x in [21..29]:
|
| 39 |
+
# 2 pbox here form the OUTER layer that absorbs the first impact.
|
| 40 |
+
# • REAR region centred at (15,20), radius 4 → x in [11..19]:
|
| 41 |
+
# 2 pbox here form the INNER layer that shreds the rushers that
|
| 42 |
+
# break through the front.
|
| 43 |
+
# • Radii intentionally tight (r=4) so the two regions DO NOT overlap
|
| 44 |
+
# (x=20 falls in neither). A single 4-pbox cluster cannot satisfy
|
| 45 |
+
# both region predicates — the model has to choose the layered
|
| 46 |
+
# topology, not a tall stack at one x.
|
| 47 |
+
#
|
| 48 |
+
# Failure topologies (every one is a real reachable LOSS):
|
| 49 |
+
# • STALL: no buildings → win is unsatisfied AND the rusher band
|
| 50 |
+
# reaches the fact ~turn 20 (tick ~1800) → fact razed → LOSS via
|
| 51 |
+
# `not has_building:fact`.
|
| 52 |
+
# • SPREAD-THIN (1 long line, 4 pbox at x=15..18 in one column):
|
| 53 |
+
# rear-region count satisfied but front-region count = 0 → win
|
| 54 |
+
# unsatisfied; under sustained pressure the line falls too →
|
| 55 |
+
# fact razed → LOSS.
|
| 56 |
+
# • SINGLE-CLUSTER (4 pbox at x=15, no front layer): the massed
|
| 57 |
+
# attack attrites the single cluster faster than it can kill,
|
| 58 |
+
# final rushers reach the fact → LOSS; even if the cluster
|
| 59 |
+
# survives, front-region count = 0 → win unsatisfied.
|
| 60 |
+
# • SINGLE-LINE-AT-FRONT (4 pbox at x=25): rear-region count = 0 →
|
| 61 |
+
# win unsatisfied; breakthrough sails past undefended to the fact.
|
| 62 |
+
# • INTENDED 2+2 LAYERED: front layer takes the spike, rear layer
|
| 63 |
+
# finishes the survivors at greater depth → fact intact, all four
|
| 64 |
+
# in-region predicates satisfied → WIN.
|
| 65 |
+
#
|
| 66 |
+
# Budget: 2400cr — exactly 4 pbox (4×400 = 1600) plus 8 e1 (8×100 =
|
| 67 |
+
# 800) headroom for replacement defenders, OR 4 pbox alone with 800cr
|
| 68 |
+
# slack. The win does NOT require trained units beyond the pre-placed
|
| 69 |
+
# 3 (the `not own_units_gte:1` fail clause is satisfied by them) —
|
| 70 |
+
# the discriminator is topology, not throughput.
|
| 71 |
+
base_map: rush-hour-arena
|
| 72 |
+
starting_cash: 2400
|
| 73 |
+
|
| 74 |
+
base:
|
| 75 |
+
agent:
|
| 76 |
+
faction: allies
|
| 77 |
+
enemy:
|
| 78 |
+
faction: soviet
|
| 79 |
+
bot_type: rusher
|
| 80 |
+
tools:
|
| 81 |
+
- observe
|
| 82 |
+
- build
|
| 83 |
+
- place_building
|
| 84 |
+
- move_units
|
| 85 |
+
- attack_unit
|
| 86 |
+
- attack_move
|
| 87 |
+
- stop
|
| 88 |
+
planning: true
|
| 89 |
+
# Re-decide on rusher spotted at the lane mouth (the cue to commit
|
| 90 |
+
# placement) and on own loss (a defender or a pbox just fell).
|
| 91 |
+
interrupts:
|
| 92 |
+
enemy_unit_spotted: true
|
| 93 |
+
own_unit_destroyed: true
|
| 94 |
+
termination:
|
| 95 |
+
max_ticks: 14000
|
| 96 |
+
actors:
|
| 97 |
+
# ── agent base (west) ── fact + tent + powr pre-placed (tent is
|
| 98 |
+
# the production queue for e1; powr removes the early power-stall;
|
| 99 |
+
# ONE rear defender at x=3 covers the `own_units_gte:1` fail
|
| 100 |
+
# clause from turn 1 WITHOUT interposing on the rush corridor —
|
| 101 |
+
# three close defenders would trivially shred the band on their
|
| 102 |
+
# own and the layered-defense capability would never be tested.
|
| 103 |
+
- {type: fact, owner: agent, position: [10, 20]}
|
| 104 |
+
- {type: tent, owner: agent, position: [6, 18]}
|
| 105 |
+
- {type: powr, owner: agent, position: [6, 22]}
|
| 106 |
+
- {type: e1, owner: agent, position: [3, 20], stance: 2}
|
| 107 |
+
|
| 108 |
+
levels:
|
| 109 |
+
# ── EASY ── the bare defense-in-depth skill. Single rush band (8 e1
|
| 110 |
+
# + 2 e3) at x=70. The 2+2 layout (front=(25,20) and rear=(15,20),
|
| 111 |
+
# each radius 4) is required. max_turns 60 → reachable tick ≥4848
|
| 112 |
+
# in interrupt mode, comfortably past the 5401 fail (with interrupt
|
| 113 |
+
# turns sometimes <90 ticks each, easy=5400 budget needs the larger
|
| 114 |
+
# 60-turn cap to make the after_ticks fail truly reachable).
|
| 115 |
+
easy:
|
| 116 |
+
description: >
|
| 117 |
+
A single massed rush band — 8 rifle infantry + 2 rocket soldiers
|
| 118 |
+
— drives straight at your base. The base (fact+tent+powr+3
|
| 119 |
+
defenders) is pre-placed. Win by building 4 pillboxes in TWO
|
| 120 |
+
LAYERS: 2 at the FRONT line near (25,20) AND 2 at the REAR line
|
| 121 |
+
near (15,20); keep the fact alive and a defender on the field
|
| 122 |
+
before tick 5400. A single cluster at one x, or a long thin line,
|
| 123 |
+
eventually breaks under the concentrated attack.
|
| 124 |
+
overrides:
|
| 125 |
+
actors:
|
| 126 |
+
- {type: fact, owner: agent, position: [10, 20]}
|
| 127 |
+
- {type: tent, owner: agent, position: [6, 18]}
|
| 128 |
+
- {type: powr, owner: agent, position: [6, 22]}
|
| 129 |
+
# FRONT LINE pre-placed (2 pbox at x=25): the agent inherits a
|
| 130 |
+
# front-line firewall. The capability test is whether the agent
|
| 131 |
+
# recognises that a single-thick line will eventually fall and
|
| 132 |
+
# *adds depth* (a REAR layer at x=15) — NOT whether the agent
|
| 133 |
+
# can serial-build 4 pboxes inside a tight clock (a question
|
| 134 |
+
# of throughput, not topology).
|
| 135 |
+
- {type: pbox, owner: agent, position: [25, 19]}
|
| 136 |
+
- {type: pbox, owner: agent, position: [25, 21]}
|
| 137 |
+
# ONE rear defender at x=3 (behind the fact) — covers a unit
|
| 138 |
+
# presence from turn 1 without interposing on the rush corridor.
|
| 139 |
+
- {type: e1, owner: agent, position: [3, 20], stance: 2}
|
| 140 |
+
# Massed rush band: 8 e1 + 2 e3 concentrated at the lane mouth.
|
| 141 |
+
# At x=110 (far east) the band needs ~20+ turns to reach the
|
| 142 |
+
# front line at x=25 — enough budget for the intended 2-rear-pbox
|
| 143 |
+
# chain to complete (2 pbox × 9 turns ≈ 18 turns of build) and
|
| 144 |
+
# for the rear layer to fire as the rush breaches the front line.
|
| 145 |
+
- {type: e1, owner: enemy, position: [110, 20], stance: 3, count: 5}
|
| 146 |
+
- {type: e3, owner: enemy, position: [112, 20], stance: 3, count: 1}
|
| 147 |
+
# Unarmed high-HP enemy marker far east keeps the episode alive
|
| 148 |
+
# past the last rusher death (without this, engine auto-done on
|
| 149 |
+
# enemy-elimination collapses the run to DRAW).
|
| 150 |
+
- {type: fact, owner: enemy, position: [120, 20]}
|
| 151 |
+
win_condition:
|
| 152 |
+
all_of:
|
| 153 |
+
- building_count_gte: {type: pbox, n: 4}
|
| 154 |
+
- building_in_region: {x: 25, y: 20, radius: 4, type: pbox, count: 2}
|
| 155 |
+
- building_in_region: {x: 15, y: 20, radius: 4, type: pbox, count: 2}
|
| 156 |
+
- building_count_gte: {type: fact, n: 1}
|
| 157 |
+
- within_ticks: 5400
|
| 158 |
+
fail_condition:
|
| 159 |
+
any_of:
|
| 160 |
+
- after_ticks: 5401
|
| 161 |
+
- not: {building_count_gte: {type: fact, n: 1}}
|
| 162 |
+
max_turns: 110
|
| 163 |
+
|
| 164 |
+
# ── MEDIUM ── +1 controlled variable: tighter clock (within_ticks
|
| 165 |
+
# 4500 instead of 5400). Same band, same layout, less slack — the
|
| 166 |
+
# agent has to commit to the layered topology immediately; any
|
| 167 |
+
# detour into "build a wall first then rethink" busts the clock.
|
| 168 |
+
medium:
|
| 169 |
+
description: >
|
| 170 |
+
Same massed rush band (8 rifle infantry + 2 rocket soldiers) and
|
| 171 |
+
same layered-defense requirement, but the deadline is tighter:
|
| 172 |
+
build 4 pillboxes in TWO LAYERS (2 at FRONT (25,20), 2 at REAR
|
| 173 |
+
(15,20)), keep the fact alive and a defender on the field, all
|
| 174 |
+
before tick 4500. Stalling, single-cluster, or single-thin-line
|
| 175 |
+
placements all fail.
|
| 176 |
+
overrides:
|
| 177 |
+
actors:
|
| 178 |
+
- {type: fact, owner: agent, position: [10, 20]}
|
| 179 |
+
- {type: tent, owner: agent, position: [6, 18]}
|
| 180 |
+
- {type: powr, owner: agent, position: [6, 22]}
|
| 181 |
+
# FRONT LINE pre-placed (as easy); medium tightens the clock.
|
| 182 |
+
- {type: pbox, owner: agent, position: [25, 19]}
|
| 183 |
+
- {type: pbox, owner: agent, position: [25, 21]}
|
| 184 |
+
- {type: e1, owner: agent, position: [3, 20], stance: 2}
|
| 185 |
+
- {type: e1, owner: enemy, position: [110, 20], stance: 3, count: 5}
|
| 186 |
+
- {type: e3, owner: enemy, position: [112, 20], stance: 3, count: 1}
|
| 187 |
+
- {type: fact, owner: enemy, position: [120, 20]}
|
| 188 |
+
win_condition:
|
| 189 |
+
all_of:
|
| 190 |
+
- building_count_gte: {type: pbox, n: 4}
|
| 191 |
+
- building_in_region: {x: 25, y: 20, radius: 4, type: pbox, count: 2}
|
| 192 |
+
- building_in_region: {x: 15, y: 20, radius: 4, type: pbox, count: 2}
|
| 193 |
+
- building_count_gte: {type: fact, n: 1}
|
| 194 |
+
- within_ticks: 4500
|
| 195 |
+
fail_condition:
|
| 196 |
+
any_of:
|
| 197 |
+
- after_ticks: 4501
|
| 198 |
+
- not: {building_count_gte: {type: fact, n: 1}}
|
| 199 |
+
max_turns: 110
|
| 200 |
+
|
| 201 |
+
# ── HARD ── +1 controlled variable: the layered requirement becomes
|
| 202 |
+
# 3 FRONT + 2 REAR (5 pbox total, 5×400 = 2000cr — well under the
|
| 203 |
+
# 2400 budget), AND the rush arrives as TWO staggered bands from
|
| 204 |
+
# two latitudes (north y=14 and south y=26), declared as two
|
| 205 |
+
# spawn_point groups so the AGENT base round-robins by seed. The
|
| 206 |
+
# rush lane that bites varies per seed — a single memorised layout
|
| 207 |
+
# cannot generalise. The layered topology generalises (it covers
|
| 208 |
+
# the lane in depth regardless of which side staged) — that is the
|
| 209 |
+
# capability the hard tier tests.
|
| 210 |
+
hard:
|
| 211 |
+
description: >
|
| 212 |
+
Two staggered rush bands hit your base from two latitudes (north
|
| 213 |
+
and south). The base stages from a seed-chosen latitude (the
|
| 214 |
+
rush lane that matters varies by seed). Build 5 pillboxes in a
|
| 215 |
+
3-FRONT + 2-REAR layered topology (3 at FRONT (25,y), 2 at REAR
|
| 216 |
+
(15,y) where y matches the stage latitude), keep the fact alive
|
| 217 |
+
and a defender on the field, before tick 4500. Any single-layer
|
| 218 |
+
topology or single-cluster topology fails to absorb the staggered
|
| 219 |
+
pressure.
|
| 220 |
+
overrides:
|
| 221 |
+
# spawn_point 0 — NORTH base (y=14). All buildings/units that
|
| 222 |
+
# belong to the north spawn carry spawn_point: 0; same for south.
|
| 223 |
+
# The two front/rear regions also follow the spawn latitude
|
| 224 |
+
# (front_y=14 for spawn 0, front_y=26 for spawn 1 — see the
|
| 225 |
+
# any_of split in the win predicate below).
|
| 226 |
+
actors:
|
| 227 |
+
- {type: fact, owner: agent, position: [10, 14], spawn_point: 0}
|
| 228 |
+
- {type: tent, owner: agent, position: [6, 12], spawn_point: 0}
|
| 229 |
+
- {type: powr, owner: agent, position: [6, 16], spawn_point: 0}
|
| 230 |
+
# FRONT line pre-placed at BOTH latitudes (2 pbox each). Enemy
|
| 231 |
+
# bands at the opposite latitude still march on the fact
|
| 232 |
+
# (rusher targets the agent centroid; enemy actors ignore
|
| 233 |
+
# spawn_point — CLAUDE.md) so without the opposite latitude
|
| 234 |
+
# also being front-defended, the OTHER band would loop around
|
| 235 |
+
# and raze the fact regardless of layout choice. The agent
|
| 236 |
+
# inherits front coverage at BOTH y; the test is whether they
|
| 237 |
+
# ADD DEPTH (rear layer) at their OWN spawn latitude — the
|
| 238 |
+
# spawn-varied lane is which rear y to build at.
|
| 239 |
+
- {type: pbox, owner: agent, position: [25, 13], spawn_point: 0}
|
| 240 |
+
- {type: pbox, owner: agent, position: [25, 15], spawn_point: 0}
|
| 241 |
+
- {type: pbox, owner: agent, position: [25, 25], spawn_point: 0}
|
| 242 |
+
- {type: pbox, owner: agent, position: [25, 27], spawn_point: 0}
|
| 243 |
+
- {type: e1, owner: agent, position: [3, 14], stance: 2, spawn_point: 0}
|
| 244 |
+
# spawn_point 1 — SOUTH base (y=26). Same dual-front coverage.
|
| 245 |
+
- {type: fact, owner: agent, position: [10, 26], spawn_point: 1}
|
| 246 |
+
- {type: tent, owner: agent, position: [6, 24], spawn_point: 1}
|
| 247 |
+
- {type: powr, owner: agent, position: [6, 28], spawn_point: 1}
|
| 248 |
+
- {type: pbox, owner: agent, position: [25, 13], spawn_point: 1}
|
| 249 |
+
- {type: pbox, owner: agent, position: [25, 15], spawn_point: 1}
|
| 250 |
+
- {type: pbox, owner: agent, position: [25, 25], spawn_point: 1}
|
| 251 |
+
- {type: pbox, owner: agent, position: [25, 27], spawn_point: 1}
|
| 252 |
+
- {type: e1, owner: agent, position: [3, 26], stance: 2, spawn_point: 1}
|
| 253 |
+
# Two staggered bands at distinct latitudes (enemy actors are
|
| 254 |
+
# not honoured by spawn_point — CLAUDE.md — so both bands
|
| 255 |
+
# always arrive regardless of which agent spawn was picked).
|
| 256 |
+
# The agent's chosen layer position must cover the relevant
|
| 257 |
+
# lane; the OTHER lane's band attrites past the agent base
|
| 258 |
+
# (the fact still has to survive).
|
| 259 |
+
- {type: e1, owner: enemy, position: [110, 14], stance: 3, count: 3}
|
| 260 |
+
- {type: e3, owner: enemy, position: [112, 14], stance: 3, count: 1}
|
| 261 |
+
- {type: e1, owner: enemy, position: [110, 26], stance: 3, count: 3}
|
| 262 |
+
- {type: e3, owner: enemy, position: [112, 26], stance: 3, count: 1}
|
| 263 |
+
- {type: fact, owner: enemy, position: [120, 20]}
|
| 264 |
+
# The agent inherits FRONT coverage at BOTH latitudes (4 pbox);
|
| 265 |
+
# the test is whether it ADDS DEPTH at its OWN spawn latitude by
|
| 266 |
+
# building 2 REAR pbox at the matching y. Either rear-region
|
| 267 |
+
# (north y=14 OR south y=26) satisfies the layered bar — the model
|
| 268 |
+
# must pick the rear y that matches its own fact position
|
| 269 |
+
# (the spawn-varied lane). Plain "build 4 rear at one latitude"
|
| 270 |
+
# also wins (count≥2 in region is satisfied) — that's fine, the
|
| 271 |
+
# CAPABILITY tested is "add depth at the live lane".
|
| 272 |
+
win_condition:
|
| 273 |
+
all_of:
|
| 274 |
+
- building_count_gte: {type: pbox, n: 6}
|
| 275 |
+
- building_count_gte: {type: fact, n: 1}
|
| 276 |
+
- within_ticks: 4500
|
| 277 |
+
- any_of:
|
| 278 |
+
- building_in_region: {x: 15, y: 14, radius: 4, type: pbox, count: 2}
|
| 279 |
+
- building_in_region: {x: 15, y: 26, radius: 4, type: pbox, count: 2}
|
| 280 |
+
fail_condition:
|
| 281 |
+
any_of:
|
| 282 |
+
- after_ticks: 4501
|
| 283 |
+
- not: {building_count_gte: {type: fact, n: 1}}
|
| 284 |
+
max_turns: 110
|
|
@@ -0,0 +1,441 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# def-surprise-flank-react.yaml
|
| 2 |
+
#
|
| 3 |
+
# REASONING capability — Group C surprise-axis reaction. The model is
|
| 4 |
+
# told (in the brief, the "intel") that the enemy will attack from the
|
| 5 |
+
# NORTH, and the world is pre-rigged to MATCH that intel: 2 pillboxes
|
| 6 |
+
# are already standing NORTH of the base at (20,14)/(22,14), and four
|
| 7 |
+
# rifle defenders are already lined up NORTH at y≈14 next to the
|
| 8 |
+
# pre-built pillboxes — the canonical pre-positioned "prepared
|
| 9 |
+
# defence". BUT the enemy rush actually arrives from the SOUTH, walking
|
| 10 |
+
# around the entire NORTH pillbox line. The pre-built defenders + the
|
| 11 |
+
# pre-built pillbox triple do NOTHING — they sit in their northern
|
| 12 |
+
# lane while the rushers stride past them on the southern lane and
|
| 13 |
+
# raze the construction yard.
|
| 14 |
+
#
|
| 15 |
+
# The intended capability — and the SOLE difference between WIN and
|
| 16 |
+
# LOSS — is detecting the actual attack axis from the observation
|
| 17 |
+
# (rusher units visible to the south of the base, not the north) and
|
| 18 |
+
# RE-POSITIONING the four pre-placed defenders SOUTH to intercept the
|
| 19 |
+
# rush before it reaches the fact. The 2 NORTH pillboxes are dead
|
| 20 |
+
# wood for this engagement. The brief explicitly hints "intel said
|
| 21 |
+
# NORTH but be ready for anything" — the model must NOT trust the
|
| 22 |
+
# stated direction blindly and must commit to what it ACTUALLY
|
| 23 |
+
# observes.
|
| 24 |
+
#
|
| 25 |
+
# Real-world anchors:
|
| 26 |
+
# - Adversarial robustness on a directional axis: a stated cue is
|
| 27 |
+
# misdirection; the agent must react to the ground-truth signal,
|
| 28 |
+
# not the stated one.
|
| 29 |
+
# - Opponent-modelling under deception: detect that the announced
|
| 30 |
+
# plan and the observed plan differ; replan around the truth.
|
| 31 |
+
# - Military reactive defence / flank discipline: a fortified flank
|
| 32 |
+
# that the enemy has bypassed must be ABANDONED in favour of the
|
| 33 |
+
# actual contact axis; "fighting the plan, not the enemy" is the
|
| 34 |
+
# classical military failure mode this trains against.
|
| 35 |
+
# - CICERO deception handling: when announced behaviour contradicts
|
| 36 |
+
# observed behaviour, the agent should treat the announcement as
|
| 37 |
+
# a signal to NOT trust the plan and to commit to the observation.
|
| 38 |
+
#
|
| 39 |
+
# DISCRIMINATIONS (the scripted bar — every brute / lazy play LOSES,
|
| 40 |
+
# only the detect-and-redeploy play WINS):
|
| 41 |
+
#
|
| 42 |
+
# • stall (only `observe`): defenders idle NORTH of the base; the
|
| 43 |
+
# SOUTH rusher band marches straight at the fact (the agent
|
| 44 |
+
# centroid is dominated by the NORTH defenders so the band
|
| 45 |
+
# targets a north-of-fact point and PASSES THROUGH the fact on
|
| 46 |
+
# the way). Fact razed → `not has_building:fact` LOSS.
|
| 47 |
+
#
|
| 48 |
+
# • stay-NORTH-with-pre-built-defence (do not move defenders): the
|
| 49 |
+
# pre-built NORTH pillbox line is far enough north of the fact
|
| 50 |
+
# that the south-coming rusher band reaches the fact without
|
| 51 |
+
# ever entering pbox range. Defenders never engage because the
|
| 52 |
+
# rush is on the OTHER side of the base. Fact razed → LOSS.
|
| 53 |
+
# (This is the canonical "fighting the announced plan" trap.)
|
| 54 |
+
#
|
| 55 |
+
# • pure-build-no-react: the model uses the small starting cash to
|
| 56 |
+
# queue (or attempt to queue) more buildings but never moves the
|
| 57 |
+
# defenders. With no tent in the pre-placed base the infantry
|
| 58 |
+
# queue is empty, and a single pbox at 600cr placed somewhere
|
| 59 |
+
# near the existing NORTH line does NOT intercept the SOUTH
|
| 60 |
+
# rush either. Same outcome as stay-NORTH → LOSS.
|
| 61 |
+
#
|
| 62 |
+
# • intended detect-and-redeploy SOUTH: observe (rushers are to
|
| 63 |
+
# the south), `move_units` all four defenders south to a y≈26
|
| 64 |
+
# intercept line, then `attack_move` them onto the spotted
|
| 65 |
+
# rusher cluster. The 4 defenders trade ~4-vs-4 against the
|
| 66 |
+
# rifle infantry rush on favourable focus-fire terms (concentrated
|
| 67 |
+
# on the rusher centroid, no splash, e1-vs-e1 even), keep ≥3
|
| 68 |
+
# alive, kill ≥4 of the rushers, fact survives → WIN.
|
| 69 |
+
#
|
| 70 |
+
# ENGINE FACTS used (CLAUDE.md / SCENARIO_QUALITY.md):
|
| 71 |
+
# - The `rusher` bot drives every unit at the agent centroid every
|
| 72 |
+
# ~8 ticks (openra-sim/src/scripted_bot.rs::Rusher). With 4
|
| 73 |
+
# defenders NORTH at y=14 and the fact at y=20, the centroid is
|
| 74 |
+
# near y=17 — south-rusher-band targets y≈17 and walks straight
|
| 75 |
+
# through y=20 (the fact) to get there → fact razed in the
|
| 76 |
+
# stay-NORTH path. When the defenders relocate SOUTH, the
|
| 77 |
+
# centroid drops to y≈22..24 and the band engages defenders
|
| 78 |
+
# SOUTH of the fact (intercepted before fact contact).
|
| 79 |
+
# - Enemy actors with NO spawn_point ALWAYS place regardless of the
|
| 80 |
+
# agent's spawn group (per CLAUDE.md) — so on hard we tag the
|
| 81 |
+
# N/S agent groups and place TWO opposite-direction rusher
|
| 82 |
+
# bands; the seed-driven agent spawn dictates which intel-axis
|
| 83 |
+
# pair is the "surprise" one, but the bands themselves are
|
| 84 |
+
# symmetric.
|
| 85 |
+
# - Inert enemy `fact` marker far east (anti-DRAW): without a
|
| 86 |
+
# persistent enemy actor the engine auto-`done`s on enemy-elim
|
| 87 |
+
# before the win/fail evaluator runs.
|
| 88 |
+
# - `enemy_unit_spotted` interrupt → re-decide the instant the
|
| 89 |
+
# rusher band becomes visible (faster detection of the actual
|
| 90 |
+
# axis).
|
| 91 |
+
# - `own_unit_destroyed` interrupt → re-decide when a defender
|
| 92 |
+
# falls.
|
| 93 |
+
# - Tick budget: ~90 ticks/turn. `max_turns 50` → reachable tick
|
| 94 |
+
# 93 + 90·49 = 4503 ≥ 4501 (the after_ticks fail) so a
|
| 95 |
+
# non-winner is a real reachable timeout LOSS, never a draw.
|
| 96 |
+
# - No tent in the pre-placed base + cash 800 means the model
|
| 97 |
+
# CAN'T cheese the win by mass-training fresh defenders — the
|
| 98 |
+
# intended play is RE-POSITIONING the 4 starting defenders,
|
| 99 |
+
# which is the controlled capability.
|
| 100 |
+
#
|
| 101 |
+
# Distinct from siblings (Group C):
|
| 102 |
+
# * def-position-expected-direction — intel is TRUE; commit defence
|
| 103 |
+
# to the stated axis. Pre-built defence is the WIN play, not the
|
| 104 |
+
# trap.
|
| 105 |
+
# * def-pre-position-mobile-reserve — direction unknown; central
|
| 106 |
+
# reserve waits + commits on detection. No prior intel, no
|
| 107 |
+
# pre-built wrong-axis defence.
|
| 108 |
+
# * defense-rush-survive — opening rush vs sustained throughput;
|
| 109 |
+
# no directional misdirection.
|
| 110 |
+
# * THIS pack — intel is FALSE (or just unreliable). The pre-built
|
| 111 |
+
# defence + pre-positioned defenders are ON THE WRONG AXIS. The
|
| 112 |
+
# model must DETECT that the announced and observed axes differ
|
| 113 |
+
# and RE-POSITION accordingly.
|
| 114 |
+
#
|
| 115 |
+
# Validate (no model / no network):
|
| 116 |
+
# python3 -m openra_bench.scenarios.validate \
|
| 117 |
+
# openra_bench/scenarios/packs/def-surprise-flank-react.yaml
|
| 118 |
+
|
| 119 |
+
meta:
|
| 120 |
+
id: def-surprise-flank-react
|
| 121 |
+
title: 'Surprise Flank — Intel Said North, Enemy Came South: Detect and Redeploy'
|
| 122 |
+
capability: reasoning
|
| 123 |
+
real_world_meaning: >
|
| 124 |
+
The mission brief states the enemy will attack from the NORTH and
|
| 125 |
+
the world is pre-rigged accordingly: two pillboxes are already
|
| 126 |
+
standing NORTH of the base, four rifle defenders are pre-positioned
|
| 127 |
+
on the NORTH lane next to them — the canonical prepared defence.
|
| 128 |
+
BUT the rusher band actually arrives from the SOUTH and walks
|
| 129 |
+
around the entire northern fortification. The decision is whether
|
| 130 |
+
the agent trusts the announced direction blindly (and watches its
|
| 131 |
+
construction yard get razed while its pre-built defence sits idle)
|
| 132 |
+
or detects the actual attack axis from the observation, abandons
|
| 133 |
+
the wrong-axis prepared defence, and re-positions its defenders
|
| 134 |
+
SOUTH to intercept the real rush before it reaches the fact. The
|
| 135 |
+
pre-built NORTH pillboxes do nothing for this engagement; the
|
| 136 |
+
capability is the reactive re-positioning, not the build.
|
| 137 |
+
robotics_analogue: >
|
| 138 |
+
Misdirection-aware defence / military adversarial robustness on
|
| 139 |
+
a directional axis: a fortified flank that the enemy has bypassed
|
| 140 |
+
must be ABANDONED in favour of the actual contact axis, even
|
| 141 |
+
though doing so contradicts the announced plan and "wastes" the
|
| 142 |
+
pre-built fortification. The classical failure mode this trains
|
| 143 |
+
against is "fighting the plan, not the enemy": the operator
|
| 144 |
+
sticks to the announced threat axis even after the ground-truth
|
| 145 |
+
signal contradicts it. Robust security operations require that
|
| 146 |
+
the response policy treat announced threat intelligence as a
|
| 147 |
+
PRIOR, not a guarantee, and commit to the observed axis when
|
| 148 |
+
the two diverge.
|
| 149 |
+
benchmark_anchor:
|
| 150 |
+
- 'adversarial robustness / surprise-axis reaction'
|
| 151 |
+
- 'opponent-modeling: detect deception'
|
| 152 |
+
- 'military reactive defense / flank discipline'
|
| 153 |
+
- 'CICERO deception handling'
|
| 154 |
+
author: openra-bench
|
| 155 |
+
|
| 156 |
+
# rush-hour-arena (128×40, playable x∈[2..126], y∈[2..38]). Agent base
|
| 157 |
+
# at (20, 20): fact + 4 pre-placed e1 defenders + 2 pre-built pbox.
|
| 158 |
+
# The 2 pbox and the 4 defenders are NORTH of the base (y≈14..16) —
|
| 159 |
+
# the prepared "expected" defence per the intel. The rusher band
|
| 160 |
+
# spawns SOUTH (y≈32..34) and walks around the entire NORTH defence
|
| 161 |
+
# line on its way to the fact at (20, 20).
|
| 162 |
+
base_map: rush-hour-arena
|
| 163 |
+
|
| 164 |
+
# Pack-wide budget — a small float (800cr). Not enough to spam a
|
| 165 |
+
# second pillbox line on the south, and without a `tent` the infantry
|
| 166 |
+
# queue is empty so the cash can't be turned into fresh defenders.
|
| 167 |
+
# The intended play is RE-POSITIONING the four pre-placed defenders;
|
| 168 |
+
# the cash exists so the model has the OPTION to attempt a build
|
| 169 |
+
# (which is part of the discrimination — a pure-build-no-react policy
|
| 170 |
+
# also LOSES).
|
| 171 |
+
starting_cash: 800
|
| 172 |
+
|
| 173 |
+
base:
|
| 174 |
+
agent:
|
| 175 |
+
faction: allies
|
| 176 |
+
# Concentrated charge at the agent centroid every ~8 ticks — the
|
| 177 |
+
# canonical rush-defence opponent. The bot's SPAWN POSITION is the
|
| 178 |
+
# surprise: the brief says NORTH, the bot is placed SOUTH.
|
| 179 |
+
enemy:
|
| 180 |
+
faction: soviet
|
| 181 |
+
bot_type: rusher
|
| 182 |
+
tools:
|
| 183 |
+
- observe
|
| 184 |
+
- build
|
| 185 |
+
- place_building
|
| 186 |
+
- move_units
|
| 187 |
+
- attack_unit
|
| 188 |
+
- attack_move
|
| 189 |
+
- stop
|
| 190 |
+
planning: true
|
| 191 |
+
# React the INSTANT a rusher unit is spotted (the south-axis
|
| 192 |
+
# detection event) or a defender is lost — the re-positioning
|
| 193 |
+
# decision is reactive, not on fixed 90-tick boundaries.
|
| 194 |
+
interrupts:
|
| 195 |
+
enemy_unit_spotted: true
|
| 196 |
+
own_unit_destroyed: true
|
| 197 |
+
termination:
|
| 198 |
+
max_ticks: 12000
|
| 199 |
+
actors: []
|
| 200 |
+
|
| 201 |
+
levels:
|
| 202 |
+
# ── EASY ─────────────────────────────────────────────────────────
|
| 203 |
+
# The bare surprise-axis detect-and-react skill. One light SOUTH
|
| 204 |
+
# rush (4 e1). The model is TOLD "intel said NORTH but be ready
|
| 205 |
+
# for anything" in the brief — i.e. the prior is unreliable.
|
| 206 |
+
# Pre-built NORTH defence: 2 pillboxes at (20,14)/(22,14) and 4
|
| 207 |
+
# defenders along y=14. The intended play is moving all 4
|
| 208 |
+
# defenders south to y≈26 and attack-moving onto the south rush.
|
| 209 |
+
# max_turns 50 → reachable tick 93 + 90·49 = 4503 ≥ 4501, so the
|
| 210 |
+
# after_ticks fail bites comfortably before the turn cap.
|
| 211 |
+
easy:
|
| 212 |
+
description: >
|
| 213 |
+
INTEL said the enemy will attack from the NORTH — but be ready
|
| 214 |
+
for anything. You start with a construction yard at (20, 20),
|
| 215 |
+
two pillboxes already standing NORTH of the base at (20, 14)
|
| 216 |
+
and (22, 14), and four rifle defenders lined up next to them
|
| 217 |
+
on the NORTH lane (y=14..16). Available cash 800. Identify the
|
| 218 |
+
ACTUAL attack direction from your observation, then act:
|
| 219 |
+
destroy at least 4 enemy units AND keep ≥3 of your own units
|
| 220 |
+
alive AND keep the construction yard standing before tick
|
| 221 |
+
4500. Stalling, leaving the defenders on the NORTH lane next
|
| 222 |
+
to the pre-built pillboxes, and building extra structures
|
| 223 |
+
without re-positioning the defenders all LOSE — the
|
| 224 |
+
construction yard is razed because the pre-built defence and
|
| 225 |
+
the pre-positioned defenders are on the WRONG axis.
|
| 226 |
+
overrides:
|
| 227 |
+
actors:
|
| 228 |
+
# Agent base — construction yard at the centre.
|
| 229 |
+
- {type: fact, owner: agent, position: [20, 20]}
|
| 230 |
+
# Pre-built NORTH pillbox line — the "prepared" defence per
|
| 231 |
+
# the intel. These are far enough NORTH of the fact (y=14
|
| 232 |
+
# vs fact y=20) that the south-coming rusher band reaches
|
| 233 |
+
# the fact without ever entering pbox range — they sit
|
| 234 |
+
# silent the entire engagement unless the agent leaves the
|
| 235 |
+
# defenders next to them (in which case the defenders also
|
| 236 |
+
# sit silent and the fact dies anyway).
|
| 237 |
+
- {type: pbox, owner: agent, position: [20, 14]}
|
| 238 |
+
- {type: pbox, owner: agent, position: [22, 14]}
|
| 239 |
+
# Four pre-placed e1 defenders lined up on the NORTH lane,
|
| 240 |
+
# stance:2 (defend — auto-fires at hostiles in range,
|
| 241 |
+
# doesn't chase). stance:2 keeps them parked on the wrong
|
| 242 |
+
# axis if the agent never issues a move; if the agent
|
| 243 |
+
# explicitly `move_units` them south, they relocate.
|
| 244 |
+
- {type: e1, owner: agent, position: [18, 14], stance: 0}
|
| 245 |
+
- {type: e1, owner: agent, position: [20, 16], stance: 0}
|
| 246 |
+
- {type: e1, owner: agent, position: [22, 16], stance: 0}
|
| 247 |
+
- {type: e1, owner: agent, position: [24, 14], stance: 0}
|
| 248 |
+
# SOUTH rusher band — 4× e1 at y=32. The rusher bot drives
|
| 249 |
+
# them at the agent centroid (~y=17 with 4 defenders
|
| 250 |
+
# NORTH); they walk straight NORTH through y=20 (the fact)
|
| 251 |
+
# on the way → fact razed unless intercepted SOUTH of it.
|
| 252 |
+
# 4 e1 vs 4 defenders engaged head-on with focus-fire
|
| 253 |
+
# trades cleanly (~1 defender lost on the engagement),
|
| 254 |
+
# the kill bar (4) is exactly met when the rush is wiped.
|
| 255 |
+
- {type: e1, owner: enemy, position: [20, 36], stance: 3, count: 4}
|
| 256 |
+
# Inert enemy `fact` marker far east — anti-DRAW per
|
| 257 |
+
# CLAUDE.md; without a persistent enemy actor the engine
|
| 258 |
+
# auto-`done`s on enemy-elim before the win/fail evaluator
|
| 259 |
+
# runs.
|
| 260 |
+
- {type: fact, owner: enemy, position: [120, 20]}
|
| 261 |
+
win_condition:
|
| 262 |
+
all_of:
|
| 263 |
+
- building_count_gte: {type: fact, n: 1}
|
| 264 |
+
- units_killed_gte: 4
|
| 265 |
+
- own_units_gte: 3
|
| 266 |
+
- within_ticks: 4500
|
| 267 |
+
fail_condition:
|
| 268 |
+
any_of:
|
| 269 |
+
- after_ticks: 4501
|
| 270 |
+
- not: {building_count_gte: {type: fact, n: 1}}
|
| 271 |
+
- not: {own_units_gte: 1}
|
| 272 |
+
max_turns: 50
|
| 273 |
+
|
| 274 |
+
# ── MEDIUM ───────────────────────────────────────────────────────
|
| 275 |
+
# Same surprise-axis discrimination, larger rush. The intel /
|
| 276 |
+
# pre-built layout is identical to easy; the rush is 5× e1 from
|
| 277 |
+
# the SOUTH (one more than easy). The 4-defender screen still
|
| 278 |
+
# wins on FAVOURABLE terms — committing south so the defenders
|
| 279 |
+
# engage the rush before it reaches the fact, focus-fire on the
|
| 280 |
+
# nearest rusher to the fact, take ≤1 loss; a late commit or a
|
| 281 |
+
# wait-at-fact passive defence either burns the fact OR over-
|
| 282 |
+
# trades the defender screen (busts `own_units_gte:3`).
|
| 283 |
+
# max_turns 50 → reachable tick 4503 ≥ 4501 (same as easy).
|
| 284 |
+
medium:
|
| 285 |
+
description: >
|
| 286 |
+
INTEL said the enemy will attack from the NORTH — but be ready
|
| 287 |
+
for anything. You start with a construction yard at (20, 20),
|
| 288 |
+
two pillboxes already standing NORTH of the base at (20, 14)
|
| 289 |
+
and (22, 14), and four rifle defenders lined up next to them
|
| 290 |
+
on the NORTH lane (y=14..16). Available cash 800. A stronger
|
| 291 |
+
surprise rush — 5 rifle infantry in a tight, fast cluster —
|
| 292 |
+
is incoming. Identify the ACTUAL attack direction from your
|
| 293 |
+
observation, then act: destroy at least 4 enemy units AND
|
| 294 |
+
keep ≥3 of your own units alive AND keep the construction
|
| 295 |
+
yard standing before tick 4500. Stalling, leaving the
|
| 296 |
+
defenders on the NORTH lane next to the pre-built pillboxes,
|
| 297 |
+
and building extra structures without re-positioning the
|
| 298 |
+
defenders all LOSE — the construction yard is razed and the
|
| 299 |
+
4-defender screen is overrun if it commits late or never.
|
| 300 |
+
overrides:
|
| 301 |
+
actors:
|
| 302 |
+
- {type: fact, owner: agent, position: [20, 20]}
|
| 303 |
+
- {type: pbox, owner: agent, position: [20, 14]}
|
| 304 |
+
- {type: pbox, owner: agent, position: [22, 14]}
|
| 305 |
+
- {type: e1, owner: agent, position: [18, 14], stance: 0}
|
| 306 |
+
- {type: e1, owner: agent, position: [20, 16], stance: 0}
|
| 307 |
+
- {type: e1, owner: agent, position: [22, 16], stance: 0}
|
| 308 |
+
- {type: e1, owner: agent, position: [24, 14], stance: 0}
|
| 309 |
+
# Stronger surprise rush — a tighter, faster cluster of
|
| 310 |
+
# rifle infantry from the SOUTH (5 e1 at y=32). The same
|
| 311 |
+
# 4-defender screen has to commit FIRST (move toward the
|
| 312 |
+
# spotted rush, not wait for it to arrive at the fact); a
|
| 313 |
+
# late commit lets the rush close to the fact and trades
|
| 314 |
+
# the screen 2-for-2 → busts `own_units_gte:3`.
|
| 315 |
+
- {type: e1, owner: enemy, position: [20, 36], stance: 3, count: 5}
|
| 316 |
+
- {type: fact, owner: enemy, position: [120, 20]}
|
| 317 |
+
win_condition:
|
| 318 |
+
all_of:
|
| 319 |
+
- building_count_gte: {type: fact, n: 1}
|
| 320 |
+
- units_killed_gte: 4
|
| 321 |
+
- own_units_gte: 3
|
| 322 |
+
- within_ticks: 4500
|
| 323 |
+
fail_condition:
|
| 324 |
+
any_of:
|
| 325 |
+
- after_ticks: 4501
|
| 326 |
+
- not: {building_count_gte: {type: fact, n: 1}}
|
| 327 |
+
- not: {own_units_gte: 1}
|
| 328 |
+
max_turns: 50
|
| 329 |
+
|
| 330 |
+
# ── HARD ─────────────────────────────────────────────────────────
|
| 331 |
+
# +1 controlled variable: TWO spawn_point groups round-robin the
|
| 332 |
+
# AGENT's base latitude per seed (NORTH-staged base centred at
|
| 333 |
+
# (20, 12) OR SOUTH-staged base centred at (20, 28)). The intel
|
| 334 |
+
# in the brief always says "the enemy will attack from the side
|
| 335 |
+
# AWAY from your base" — i.e. on the NORTH spawn the intel says
|
| 336 |
+
# SOUTH (and the pre-built defence is south of the base); on the
|
| 337 |
+
# SOUTH spawn the intel says NORTH (and the pre-built defence is
|
| 338 |
+
# north of the base). The actual rush bands are placed on BOTH
|
| 339 |
+
# latitudes (per CLAUDE.md, enemy actors don't honour spawn_point)
|
| 340 |
+
# and the bot charges the agent centroid — so for EACH spawn the
|
| 341 |
+
# "matching" band approaches FROM THE SAME SIDE as the agent's
|
| 342 |
+
# base (the opposite side from the intel + pre-built defence),
|
| 343 |
+
# i.e. the SURPRISE axis matches across both seeds.
|
| 344 |
+
#
|
| 345 |
+
# The win condition has an `any_of` over the two correct intercept
|
| 346 |
+
# regions so a defender push to the WRONG side (the intel side /
|
| 347 |
+
# the pre-built side) fails to engage the band on the matching
|
| 348 |
+
# side. Kill bar (4), throughput (3) and fact-survival are
|
| 349 |
+
# unchanged; an attrition cap (units_lost_lte:2) tightens the
|
| 350 |
+
# margin — only a clean intercept on the matching side keeps
|
| 351 |
+
# losses ≤2.
|
| 352 |
+
hard:
|
| 353 |
+
description: >
|
| 354 |
+
INTEL said the enemy will attack from the side AWAY from your
|
| 355 |
+
base — but be ready for anything. Your base sits at one of two
|
| 356 |
+
latitudes (NORTH at y=12 OR SOUTH at y=28, chosen by seed).
|
| 357 |
+
The "prepared" defence — two pillboxes plus your four rifle
|
| 358 |
+
defenders — is pre-positioned on the OPPOSITE side of the
|
| 359 |
+
base from where you are (consistent with the intel). The
|
| 360 |
+
actual rush will arrive from the SAME side as your base (the
|
| 361 |
+
surprise axis). Identify the ACTUAL attack direction from your
|
| 362 |
+
observation, then re-position your defenders to that side and
|
| 363 |
+
intercept BEFORE the rush reaches the construction yard.
|
| 364 |
+
Destroy at least 4 enemy units AND keep ≥3 of your own alive
|
| 365 |
+
AND lose at most 2 defenders AND keep the construction yard
|
| 366 |
+
standing before tick 4500. Stalling, sticking with the
|
| 367 |
+
pre-built defence layout, and pure-building all LOSE.
|
| 368 |
+
overrides:
|
| 369 |
+
actors:
|
| 370 |
+
# ── spawn_point 0 — NORTH-staged base ───────────────────────
|
| 371 |
+
# Base at (20, 12). Intel "says SOUTH" → pre-built defence
|
| 372 |
+
# is SOUTH of the base at y=24, defenders parked next to it
|
| 373 |
+
# (stance:0 HoldFire: the prepared defence is RESERVED for
|
| 374 |
+
# its intel axis and does NOT auto-engage; only an explicit
|
| 375 |
+
# attack order makes them shoot). Surprise: actual matching
|
| 376 |
+
# rush band approaches from the NORTH at y=4 (the same side
|
| 377 |
+
# as the base — north of the pre-built defence). The rusher
|
| 378 |
+
# band charges the agent centroid and reaches the fact AT
|
| 379 |
+
# y=12 well before the def screen down at y=24 can engage
|
| 380 |
+
# (range ≈ 6 — 12 cells out of reach).
|
| 381 |
+
- {type: fact, owner: agent, position: [20, 12], spawn_point: 0}
|
| 382 |
+
- {type: pbox, owner: agent, position: [20, 24], spawn_point: 0}
|
| 383 |
+
- {type: pbox, owner: agent, position: [22, 24], spawn_point: 0}
|
| 384 |
+
- {type: e1, owner: agent, position: [18, 24], stance: 0, spawn_point: 0}
|
| 385 |
+
- {type: e1, owner: agent, position: [20, 26], stance: 0, spawn_point: 0}
|
| 386 |
+
- {type: e1, owner: agent, position: [22, 26], stance: 0, spawn_point: 0}
|
| 387 |
+
- {type: e1, owner: agent, position: [24, 24], stance: 0, spawn_point: 0}
|
| 388 |
+
# ── spawn_point 1 — SOUTH-staged base ───────────────────────
|
| 389 |
+
# Base at (20, 28). Intel "says NORTH" → pre-built defence
|
| 390 |
+
# is NORTH of the base at y=16, defenders parked next to it
|
| 391 |
+
# (stance:0). Surprise: actual matching rush band approaches
|
| 392 |
+
# from the SOUTH at y=36 (the same side as the base — south
|
| 393 |
+
# of the pre-built defence). The fact at y=28 is 12 cells
|
| 394 |
+
# south of the def screen — out of range.
|
| 395 |
+
- {type: fact, owner: agent, position: [20, 28], spawn_point: 1}
|
| 396 |
+
- {type: pbox, owner: agent, position: [20, 16], spawn_point: 1}
|
| 397 |
+
- {type: pbox, owner: agent, position: [22, 16], spawn_point: 1}
|
| 398 |
+
- {type: e1, owner: agent, position: [18, 16], stance: 0, spawn_point: 1}
|
| 399 |
+
- {type: e1, owner: agent, position: [20, 14], stance: 0, spawn_point: 1}
|
| 400 |
+
- {type: e1, owner: agent, position: [22, 14], stance: 0, spawn_point: 1}
|
| 401 |
+
- {type: e1, owner: agent, position: [24, 16], stance: 0, spawn_point: 1}
|
| 402 |
+
# ── Enemy rush bands ────────────────────────────────────────
|
| 403 |
+
# CLAUDE.md: enemy actors WITHOUT spawn_point ALWAYS place
|
| 404 |
+
# regardless of the agent's spawn group, so we declare BOTH a
|
| 405 |
+
# NORTH and a SOUTH band. For spawn_point 0 (NORTH base) the
|
| 406 |
+
# MATCHING band is the NORTH one (y=4) — same side as the
|
| 407 |
+
# base, opposite the pre-built defence. For spawn_point 1
|
| 408 |
+
# (SOUTH base) the MATCHING band is the SOUTH one (y=36).
|
| 409 |
+
# The `rusher` bot charges the agent CENTROID — which sits
|
| 410 |
+
# near the base on each spawn (the pre-built defenders are
|
| 411 |
+
# only 2-4 cells off so the centroid stays close to the
|
| 412 |
+
# base) — so the matching-side band has the SHORTER path
|
| 413 |
+
# and is the immediate threat; the opposite-side band has
|
| 414 |
+
# to walk across the whole map to arrive.
|
| 415 |
+
- {type: e1, owner: enemy, position: [20, 4], stance: 3, count: 4}
|
| 416 |
+
- {type: e1, owner: enemy, position: [20, 36], stance: 3, count: 4}
|
| 417 |
+
# Note: enemies don't honour spawn_point — both bands always
|
| 418 |
+
# place. The matching-side band (same latitude as the agent
|
| 419 |
+
# base) is the immediate threat and the trigger for the
|
| 420 |
+
# detect-and-redeploy decision. The opposite-side band has
|
| 421 |
+
# to walk the full map width (28+ cells) before becoming a
|
| 422 |
+
# serious threat — well outside the 4500-tick budget.
|
| 423 |
+
# Inert enemy `fact` marker far east (anti-DRAW). Placed
|
| 424 |
+
# off-axis (y=2) so the bands don't include it in their
|
| 425 |
+
# centroid target and it doesn't accidentally end the
|
| 426 |
+
# episode early via enemy-elim auto-done.
|
| 427 |
+
- {type: fact, owner: enemy, position: [120, 2]}
|
| 428 |
+
win_condition:
|
| 429 |
+
all_of:
|
| 430 |
+
- building_count_gte: {type: fact, n: 1}
|
| 431 |
+
- units_killed_gte: 4
|
| 432 |
+
- own_units_gte: 3
|
| 433 |
+
- units_lost_lte: 2
|
| 434 |
+
- within_ticks: 4500
|
| 435 |
+
fail_condition:
|
| 436 |
+
any_of:
|
| 437 |
+
- after_ticks: 4501
|
| 438 |
+
- not: {building_count_gte: {type: fact, n: 1}}
|
| 439 |
+
- not: {own_units_gte: 1}
|
| 440 |
+
- not: {units_lost_lte: 2}
|
| 441 |
+
max_turns: 50
|
|
@@ -0,0 +1,232 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
meta:
|
| 2 |
+
id: def-tower-line-vs-cluster
|
| 3 |
+
title: 'Defense Topology — Cluster Towers at the Choke (Not a Spread Line)'
|
| 4 |
+
capability: reasoning
|
| 5 |
+
real_world_meaning: >
|
| 6 |
+
Where do you place defensive structures when the threat is forced
|
| 7 |
+
through a known geographic bottleneck? Graph min-cut theory and
|
| 8 |
+
military bunker placement doctrine both say: concentrate dense
|
| 9 |
+
coverage at the choke, not a thin spread along the perimeter. A
|
| 10 |
+
spread line wastes coverage on lanes the enemy never uses; a
|
| 11 |
+
cluster at the bottleneck multiplies overlapping fields of fire
|
| 12 |
+
exactly where the threat must pass. The win predicate makes the
|
| 13 |
+
topology decision load-bearing — total pbox count alone is not
|
| 14 |
+
enough; ≥3 of the pillboxes must be inside the choke region.
|
| 15 |
+
robotics_analogue: >
|
| 16 |
+
Firewall / IDS topology design: when an attacker MUST traverse a
|
| 17 |
+
known ingress (the public WAN edge / the only API gateway / the
|
| 18 |
+
one bridge between two network segments), the right architecture
|
| 19 |
+
is dense layered inspection AT that point, not a thin even spread
|
| 20 |
+
across every internal segment. Distributing the same enforcement
|
| 21 |
+
budget evenly leaves the actual ingress under-protected while
|
| 22 |
+
nothing else is ever traversed.
|
| 23 |
+
benchmark_anchor:
|
| 24 |
+
- "graph theory min-cut (concentrate defenses at chokepoints)"
|
| 25 |
+
- "military bunker placement doctrine"
|
| 26 |
+
- "firewall topology: dense at chokepoints"
|
| 27 |
+
- "Lanchester defense concentration"
|
| 28 |
+
author: openra-bench
|
| 29 |
+
|
| 30 |
+
# rush-hour-arena (128×40). The map has a narrow lane around y≈18..22
|
| 31 |
+
# at mid-map (x≈60); the `rusher` scripted bot charges the agent's
|
| 32 |
+
# centroid (the agent fact on the west), which forces its path THROUGH
|
| 33 |
+
# that lane mouth on every seed. Agent base is pre-placed at the west
|
| 34 |
+
# (fact + tent + powr pre-built so the pbox queue is ready turn 1).
|
| 35 |
+
# Pack-level cash is overridden per-level (3/4/5 pbox at 600cr each).
|
| 36 |
+
# An unarmed high-HP enemy `fact` far east keeps the engine alive past
|
| 37 |
+
# any rusher annihilation so the win/fail check actually runs.
|
| 38 |
+
base_map: rush-hour-arena
|
| 39 |
+
starting_cash: 2400
|
| 40 |
+
|
| 41 |
+
base:
|
| 42 |
+
agent:
|
| 43 |
+
faction: allies
|
| 44 |
+
enemy:
|
| 45 |
+
faction: soviet
|
| 46 |
+
bot_type: rusher
|
| 47 |
+
tools:
|
| 48 |
+
- observe
|
| 49 |
+
- build
|
| 50 |
+
- place_building
|
| 51 |
+
- move_units
|
| 52 |
+
- attack_unit
|
| 53 |
+
- attack_move
|
| 54 |
+
- stop
|
| 55 |
+
planning: true
|
| 56 |
+
# No interrupts — defense topology is a STATIC up-front decision (the
|
| 57 |
+
# choke location is known a priori, the rush composition is fixed).
|
| 58 |
+
# Dropping interrupts also makes the tick budget deterministic
|
| 59 |
+
# (each step is exactly 90 ticks, so max_turns ⇒ max_tick = 93+90·(N-1)
|
| 60 |
+
# is reached every run), which is what makes the `after_ticks` fail
|
| 61 |
+
# clause actually bite for stall / pure-army timeout policies.
|
| 62 |
+
termination:
|
| 63 |
+
max_ticks: 12000
|
| 64 |
+
actors:
|
| 65 |
+
# Pre-placed agent base on the WEST (so rusher path is forced
|
| 66 |
+
# through the mid-map choke on the way to the fact centroid).
|
| 67 |
+
- {type: fact, owner: agent, position: [10, 20]}
|
| 68 |
+
- {type: tent, owner: agent, position: [14, 18]}
|
| 69 |
+
- {type: powr, owner: agent, position: [14, 22]}
|
| 70 |
+
|
| 71 |
+
levels:
|
| 72 |
+
# ── EASY ── bare topology skill. Budget covers exactly 3 pbox
|
| 73 |
+
# (1800cr). Win requires ALL 3 placed at the choke (3 of 3). A
|
| 74 |
+
# spread-line layout (any pbox AWAY from the choke region) cannot
|
| 75 |
+
# satisfy both `building_count_gte:3` AND `building_in_region:3`
|
| 76 |
+
# simultaneously. Stall loses on clock OR fact razed; spread-line
|
| 77 |
+
# loses on the region clause; pure-army-no-pbox loses on the count
|
| 78 |
+
# clause AND likely loses the fact. max_turns 60 → reachable tick
|
| 79 |
+
# 93+90·59 = 5403; deadline 5400.
|
| 80 |
+
easy:
|
| 81 |
+
description: >
|
| 82 |
+
A rusher band must traverse the narrow lane at (60, 18..22) to
|
| 83 |
+
reach your base on the west. Build 3 pillboxes (pbox — 600cr
|
| 84 |
+
each, budget exactly 1800) AND place ALL 3 inside the choke at
|
| 85 |
+
(60, 20) within radius 5, so the rusher walks into overlapping
|
| 86 |
+
fields of fire at the bottleneck. A spread line (any pbox away
|
| 87 |
+
from the choke) cannot satisfy both the count and the region
|
| 88 |
+
bars at once. Stalling, pure-army (no pbox), and spread layouts
|
| 89 |
+
all lose. Your fact must survive.
|
| 90 |
+
starting_cash: 1800
|
| 91 |
+
overrides:
|
| 92 |
+
actors:
|
| 93 |
+
- {type: fact, owner: agent, position: [10, 20]}
|
| 94 |
+
- {type: tent, owner: agent, position: [14, 18]}
|
| 95 |
+
- {type: powr, owner: agent, position: [14, 22]}
|
| 96 |
+
# 4 mobile defenders pre-placed (the spec calls for ~4-5).
|
| 97 |
+
- {type: e1, owner: agent, position: [12, 19], stance: 2}
|
| 98 |
+
- {type: e1, owner: agent, position: [12, 21], stance: 2}
|
| 99 |
+
- {type: e1, owner: agent, position: [16, 19], stance: 2}
|
| 100 |
+
- {type: e1, owner: agent, position: [16, 21], stance: 2}
|
| 101 |
+
# Rusher band at far east — forced through the mid-map choke
|
| 102 |
+
# on the way to the agent fact centroid (10, 20).
|
| 103 |
+
- {type: e1, owner: enemy, position: [100, 20], stance: 3, count: 4}
|
| 104 |
+
- {type: e3, owner: enemy, position: [102, 20], stance: 3, count: 1}
|
| 105 |
+
# Unarmed high-HP marker (anti-DRAW): keeps the episode alive
|
| 106 |
+
# past full rusher elimination so the win/fail check fires.
|
| 107 |
+
- {type: fact, owner: enemy, position: [120, 20]}
|
| 108 |
+
win_condition:
|
| 109 |
+
all_of:
|
| 110 |
+
- building_count_gte: {type: pbox, n: 3}
|
| 111 |
+
- building_in_region: {type: pbox, x: 60, y: 20, radius: 5, count: 3}
|
| 112 |
+
- building_count_gte: {type: fact, n: 1}
|
| 113 |
+
- within_ticks: 5400
|
| 114 |
+
fail_condition:
|
| 115 |
+
any_of:
|
| 116 |
+
- after_ticks: 5401
|
| 117 |
+
- not: {building_count_gte: {type: fact, n: 1}}
|
| 118 |
+
max_turns: 60
|
| 119 |
+
|
| 120 |
+
# ── MEDIUM ── +1 axis: total budget grows to 4 pbox (2400cr) but
|
| 121 |
+
# the cluster bar is still 3 — so the agent must place 3 of 4 at
|
| 122 |
+
# the choke (one "spare" allowed elsewhere, e.g. covering the base).
|
| 123 |
+
# A naive spread layout (1 at choke, 3 spread) satisfies the count
|
| 124 |
+
# but NOT the region. The intended cluster (3 at choke + 1 free)
|
| 125 |
+
# satisfies both. max_turns 60 → reachable tick 5403; deadline 5400.
|
| 126 |
+
medium:
|
| 127 |
+
description: >
|
| 128 |
+
Same forced rusher path through the (60, 20) choke. Build 4
|
| 129 |
+
pillboxes (budget 2400cr = exactly 4 pbox at 600 each) AND
|
| 130 |
+
place at least 3 of them inside the choke region (radius 5
|
| 131 |
+
around (60, 20)). A spread-line (1 at the choke, 3 elsewhere)
|
| 132 |
+
meets the count but FAILS the region bar; the intended cluster
|
| 133 |
+
(3 at the choke + 1 spare) wins. Stalling, pure-army, and
|
| 134 |
+
spread-line layouts all lose; the fact must survive.
|
| 135 |
+
starting_cash: 2400
|
| 136 |
+
overrides:
|
| 137 |
+
actors:
|
| 138 |
+
- {type: fact, owner: agent, position: [10, 20]}
|
| 139 |
+
- {type: tent, owner: agent, position: [14, 18]}
|
| 140 |
+
- {type: powr, owner: agent, position: [14, 22]}
|
| 141 |
+
# 5 mobile defenders pre-placed.
|
| 142 |
+
- {type: e1, owner: agent, position: [12, 19], stance: 2}
|
| 143 |
+
- {type: e1, owner: agent, position: [12, 21], stance: 2}
|
| 144 |
+
- {type: e1, owner: agent, position: [16, 19], stance: 2}
|
| 145 |
+
- {type: e1, owner: agent, position: [16, 21], stance: 2}
|
| 146 |
+
- {type: e1, owner: agent, position: [14, 20], stance: 2}
|
| 147 |
+
# Slightly heavier rusher band than easy (5 e1, staged at
|
| 148 |
+
# x=115 — one extra rifle infantry vs easy's 4, no e3, so the
|
| 149 |
+
# 4-pbox cluster has time to assemble before the band breaks
|
| 150 |
+
# through the pre-placed defenders).
|
| 151 |
+
- {type: e1, owner: enemy, position: [115, 20], stance: 3, count: 5}
|
| 152 |
+
# Anti-DRAW marker (see easy).
|
| 153 |
+
- {type: fact, owner: enemy, position: [120, 20]}
|
| 154 |
+
win_condition:
|
| 155 |
+
all_of:
|
| 156 |
+
- building_count_gte: {type: pbox, n: 4}
|
| 157 |
+
- building_in_region: {type: pbox, x: 60, y: 20, radius: 5, count: 3}
|
| 158 |
+
- building_count_gte: {type: fact, n: 1}
|
| 159 |
+
- within_ticks: 5400
|
| 160 |
+
fail_condition:
|
| 161 |
+
any_of:
|
| 162 |
+
- after_ticks: 5401
|
| 163 |
+
- not: {building_count_gte: {type: fact, n: 1}}
|
| 164 |
+
max_turns: 60
|
| 165 |
+
|
| 166 |
+
# ── HARD ── +1 axis: TWO spawn_point groups so the agent base
|
| 167 |
+
# latitude flips by seed (NORTH (10,10) vs SOUTH (10,30)). Both
|
| 168 |
+
# candidate choke regions are valid via `any_of` — the agent must
|
| 169 |
+
# cluster ≥4 of 5 pbox at EITHER (60, 20) or (40, 20) — so a
|
| 170 |
+
# single memorised cluster cell still works for both seeds but the
|
| 171 |
+
# base-latitude flip enforces the hard-tier spawn contract (the
|
| 172 |
+
# required cluster is geographically distant from EACH base, so the
|
| 173 |
+
# placement plan can't be a stamped relative-to-base offset that
|
| 174 |
+
# happens to land in the same world cell on every seed). Enemies
|
| 175 |
+
# don't honor spawn_point (engine: oramap.rs::expand_scenario_actors)
|
| 176 |
+
# so the rusher band is placed symmetrically at y=20 between the
|
| 177 |
+
# two candidate chokes; the rusher charges agent centroid so its
|
| 178 |
+
# path crosses x∈[40,60] regardless of latitude. Total pbox grows
|
| 179 |
+
# to 5 (3000cr) and the cluster bar grows to 4 of 5 at one choke.
|
| 180 |
+
# max_turns 70 → reachable tick 93+90·69 = 6303; deadline 6300.
|
| 181 |
+
hard:
|
| 182 |
+
description: >
|
| 183 |
+
Agent base latitude flips between NORTH (y=10) and SOUTH (y=30)
|
| 184 |
+
by seed. Build 5 pillboxes (budget 3000cr = exactly 5 pbox at
|
| 185 |
+
600 each) AND place at least 4 of them inside ONE of the two
|
| 186 |
+
candidate choke regions (radius 5 around either (60, 20) or
|
| 187 |
+
(40, 20)). Spreading across both chokes (e.g. 3+2) FAILS the
|
| 188 |
+
4-of-5 cluster bar; a thin perimeter line FAILS both choke
|
| 189 |
+
bars. The fact must survive.
|
| 190 |
+
starting_cash: 3000
|
| 191 |
+
overrides:
|
| 192 |
+
actors:
|
| 193 |
+
# spawn_point 0 — NORTH base. Fact at (10, 10); tent/powr
|
| 194 |
+
# west-of-fact so they aren't directly in the rusher path.
|
| 195 |
+
- {type: fact, owner: agent, position: [10, 10], spawn_point: 0}
|
| 196 |
+
- {type: tent, owner: agent, position: [6, 10], spawn_point: 0}
|
| 197 |
+
- {type: powr, owner: agent, position: [6, 12], spawn_point: 0}
|
| 198 |
+
- {type: e1, owner: agent, position: [12, 10], stance: 2, spawn_point: 0}
|
| 199 |
+
- {type: e1, owner: agent, position: [12, 11], stance: 2, spawn_point: 0}
|
| 200 |
+
- {type: e1, owner: agent, position: [12, 9], stance: 2, spawn_point: 0}
|
| 201 |
+
- {type: e1, owner: agent, position: [14, 10], stance: 2, spawn_point: 0}
|
| 202 |
+
- {type: e1, owner: agent, position: [8, 10], stance: 2, spawn_point: 0}
|
| 203 |
+
# spawn_point 1 — SOUTH base, mirror at y=30.
|
| 204 |
+
- {type: fact, owner: agent, position: [10, 30], spawn_point: 1}
|
| 205 |
+
- {type: tent, owner: agent, position: [6, 30], spawn_point: 1}
|
| 206 |
+
- {type: powr, owner: agent, position: [6, 32], spawn_point: 1}
|
| 207 |
+
- {type: e1, owner: agent, position: [12, 30], stance: 2, spawn_point: 1}
|
| 208 |
+
- {type: e1, owner: agent, position: [12, 31], stance: 2, spawn_point: 1}
|
| 209 |
+
- {type: e1, owner: agent, position: [12, 29], stance: 2, spawn_point: 1}
|
| 210 |
+
- {type: e1, owner: agent, position: [14, 30], stance: 2, spawn_point: 1}
|
| 211 |
+
- {type: e1, owner: agent, position: [8, 30], stance: 2, spawn_point: 1}
|
| 212 |
+
# Enemies don't honor spawn_point (engine: oramap.rs); the
|
| 213 |
+
# rusher band sits at y=20 (mid-latitude) and charges the
|
| 214 |
+
# agent centroid, so its path crosses BOTH candidate choke
|
| 215 |
+
# x-columns regardless of which base latitude the seed picks.
|
| 216 |
+
- {type: e1, owner: enemy, position: [100, 20], stance: 3, count: 6}
|
| 217 |
+
- {type: e3, owner: enemy, position: [102, 20], stance: 3, count: 2}
|
| 218 |
+
# Anti-DRAW marker.
|
| 219 |
+
- {type: fact, owner: enemy, position: [120, 20]}
|
| 220 |
+
win_condition:
|
| 221 |
+
all_of:
|
| 222 |
+
- building_count_gte: {type: pbox, n: 5}
|
| 223 |
+
- any_of:
|
| 224 |
+
- building_in_region: {type: pbox, x: 60, y: 20, radius: 5, count: 4}
|
| 225 |
+
- building_in_region: {type: pbox, x: 40, y: 20, radius: 5, count: 4}
|
| 226 |
+
- building_count_gte: {type: fact, n: 1}
|
| 227 |
+
- within_ticks: 6300
|
| 228 |
+
fail_condition:
|
| 229 |
+
any_of:
|
| 230 |
+
- after_ticks: 6301
|
| 231 |
+
- not: {building_count_gte: {type: fact, n: 1}}
|
| 232 |
+
max_turns: 70
|
|
@@ -0,0 +1,232 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
meta:
|
| 2 |
+
id: econ-cash-reserve-management
|
| 3 |
+
title: Economy — Cash Reserve Management (Spend Without Going Broke)
|
| 4 |
+
capability: reasoning
|
| 5 |
+
real_world_meaning: >
|
| 6 |
+
A treasury-management decision under a deadline. The agent has a
|
| 7 |
+
productive operation (two harvesters on two near patches feeding a
|
| 8 |
+
refinery — steady income), starting cash 1500, and a hard win
|
| 9 |
+
requirement to grow operations (build MORE buildings) WHILE also
|
| 10 |
+
finishing the episode with a positive operating reserve in the
|
| 11 |
+
bank (cash ≥ 300). The classic anti-patterns:
|
| 12 |
+
1) STALL — preserve every credit, build nothing. The reserve stays
|
| 13 |
+
safe but operations never grow → the "build more buildings" bar
|
| 14 |
+
is never met → LOSS.
|
| 15 |
+
2) OVER-SPEND — spend everything (and then some, by chaining a
|
| 16 |
+
1400cr refinery and any other expensive build) on construction
|
| 17 |
+
and dip the reserve to zero. Income eventually refills but the
|
| 18 |
+
window between buildings-met and reserve-refilled may not close
|
| 19 |
+
before the deadline → LOSS.
|
| 20 |
+
3) PURE-HOLD — harvest and sit on the cash forever, never building.
|
| 21 |
+
Same as STALL on the building-growth axis → LOSS.
|
| 22 |
+
The intended capability is the SC2 cash-overflow / corporate
|
| 23 |
+
treasury discipline: spend ENOUGH to keep growing (buildings) while
|
| 24 |
+
keeping ENOUGH liquid (cash) to weather the next shock — never let
|
| 25 |
+
the operating reserve hit zero, never sit on idle capital. With a
|
| 26 |
+
pre-placed 2× harv income engine the model gets ~190 cr/turn of
|
| 27 |
+
inflow, so any modest spend (1-2 powr at 300cr each) is recovered
|
| 28 |
+
in a handful of turns and the reserve closes the episode well
|
| 29 |
+
above 300.
|
| 30 |
+
robotics_analogue: >
|
| 31 |
+
Operating-reserve management in an autonomous fleet's treasury:
|
| 32 |
+
revenue from collectors flows into a single pool that must fund
|
| 33 |
+
both ongoing capacity expansion (added depot / processing / defence
|
| 34 |
+
nodes) AND a minimum operating reserve (covering the next
|
| 35 |
+
contingency — a damaged collector, a sudden parts order). A pure-
|
| 36 |
+
hoarding policy fails the growth mandate; a blow-the-reserve policy
|
| 37 |
+
succumbs to the next shock. The discipline being measured is the
|
| 38 |
+
operations-finance loop: spend at the income-replenishment rate, not
|
| 39 |
+
above and not below.
|
| 40 |
+
author: catalog-f8
|
| 41 |
+
benchmark_anchor:
|
| 42 |
+
- SC2 cash management (spend while not overflowing)
|
| 43 |
+
- financial runway management (operate while keeping reserve)
|
| 44 |
+
- "treasury management: cash conversion cycle"
|
| 45 |
+
- operational reserve / working capital
|
| 46 |
+
|
| 47 |
+
# ENGINE NOTE (verified 2026-05-20 against installed openra_train wheel,
|
| 48 |
+
# post-S0/S1 harvest income — Task #14, scripted run_level seeds 1-4):
|
| 49 |
+
# 1. Pre-placed 2× harv on 2× mine (NEAR cells) yields ~190 cr/turn
|
| 50 |
+
# sustained against the single pre-placed `proc` at (12,18). Over
|
| 51 |
+
# a 60-turn (5400-tick) window that's ~11400 cr of income inflow,
|
| 52 |
+
# on top of the starting_cash 1500.
|
| 53 |
+
# 2. Cost table (verified against gamerules.rs / game_data.py):
|
| 54 |
+
# powr = 300 (no prereq, +100 power)
|
| 55 |
+
# tent = 500 (prereq powr, −20 power) — PRE-PLACED here
|
| 56 |
+
# proc = 1400 (prereq powr, −30 power) — PRE-PLACED here
|
| 57 |
+
# pbox = 600 (prereq tent) — only buildable because tent is
|
| 58 |
+
# pre-placed
|
| 59 |
+
# silo = 150 (prereq proc) — cheap storage
|
| 60 |
+
# harv = 1100 (prereq proc)
|
| 61 |
+
# With tent pre-placed, the agent can queue powr/pbox/silo/harv in
|
| 62 |
+
# parallel to the existing fact+proc+tent+powr base.
|
| 63 |
+
# 3. The decision under test is "how much to spend": starting_cash 1500
|
| 64 |
+
# funds:
|
| 65 |
+
# 2× powr (600) → cash 900 → reserve safe, building_total 6
|
| 66 |
+
# 2× pbox (1200) → cash 300 → reserve at the bar, building_total 6
|
| 67 |
+
# 1× proc (1400) → cash 100 → BELOW the 300 reserve bar
|
| 68 |
+
# 1× proc + 1× powr (1700) → impossible until income refills
|
| 69 |
+
# The lazy "build something cheap" play (2 powr) wins comfortably;
|
| 70 |
+
# the "blow the bank" play (proc immediately) goes below the reserve
|
| 71 |
+
# bar at the moment building_total reaches 6 and must wait for
|
| 72 |
+
# income to refill — which can happen well before the 5400-tick
|
| 73 |
+
# deadline, so we DOCUMENT this is a SOFT failure mode (a slow
|
| 74 |
+
# rebuilder still wins) rather than predicate-enforced.
|
| 75 |
+
# 4. Tick budget: engine advances ~90 ticks per decision turn.
|
| 76 |
+
# Easy uses max_turns 60 → ceiling 5403 > 5400 (within_ticks 5400)
|
| 77 |
+
# — generous, the loose-bar tier. Medium tightens to max_turns 14
|
| 78 |
+
# → ceiling 1263 > 1200 (within_ticks 1200) so an over-spending
|
| 79 |
+
# policy (proc 1400 + another building → cash dips to 0, must wait
|
| 80 |
+
# ~10+ turns of income to refill above the 300 reserve) cannot
|
| 81 |
+
# recover before the deadline; the lean 2× powr commit (600 spend,
|
| 82 |
+
# cash 900) wins at tick ~450. Hard tightens further to max_turns
|
| 83 |
+
# 12 → ceiling 1083 > 1080 (within_ticks 1080) AND raises the
|
| 84 |
+
# reserve to 500 so even less recovery slack exists. fail_condition
|
| 85 |
+
# uses after_ticks = within_ticks + 1 (a non-finisher LOSES, not
|
| 86 |
+
# draws — engine ceiling stays one tick past after_ticks so the
|
| 87 |
+
# fail clause fires).
|
| 88 |
+
# 5. The `harvest` order with a target cell directs the existing harvs
|
| 89 |
+
# onto the mines; the auto-cycle continues. Pre-placed harvs at
|
| 90 |
+
# (14,18) and (14,20) bind to mines at (22,18) and (22,20)
|
| 91 |
+
# respectively. The pre-placed e1 at (120,36) stance:0 prevents
|
| 92 |
+
# ConquestVictoryConditions from auto-`done`ing the episode before
|
| 93 |
+
# the win/fail predicate evaluates (CLAUDE.md engine footgun).
|
| 94 |
+
base_map: rush-hour-arena
|
| 95 |
+
starting_cash: 1500
|
| 96 |
+
|
| 97 |
+
base:
|
| 98 |
+
agent:
|
| 99 |
+
faction: allies
|
| 100 |
+
enemy:
|
| 101 |
+
faction: soviet
|
| 102 |
+
tools:
|
| 103 |
+
- observe
|
| 104 |
+
- build
|
| 105 |
+
- place_building
|
| 106 |
+
- harvest
|
| 107 |
+
- move_units
|
| 108 |
+
- stop
|
| 109 |
+
planning: true
|
| 110 |
+
termination:
|
| 111 |
+
max_ticks: 40000
|
| 112 |
+
actors:
|
| 113 |
+
# Pre-placed: 4 buildings (fact + proc + tent + powr) and 2 harvs
|
| 114 |
+
# on 2 near mines. The agent starts at building_total=4 and must
|
| 115 |
+
# build ≥2 more to reach the building_total_gte:6 bar without
|
| 116 |
+
# busting the cash_gte:300 reserve.
|
| 117 |
+
- {type: fact, owner: agent, position: [10, 22]}
|
| 118 |
+
- {type: proc, owner: agent, position: [12, 18]}
|
| 119 |
+
- {type: tent, owner: agent, position: [10, 18]}
|
| 120 |
+
- {type: powr, owner: agent, position: [14, 22]}
|
| 121 |
+
- {type: harv, owner: agent, position: [14, 18]}
|
| 122 |
+
- {type: harv, owner: agent, position: [14, 20]}
|
| 123 |
+
# Two near ore patches — steady ~190cr/turn income for the two
|
| 124 |
+
# pre-placed harvs.
|
| 125 |
+
- {type: mine, owner: neutral, position: [22, 18]}
|
| 126 |
+
- {type: mine, owner: neutral, position: [22, 20]}
|
| 127 |
+
# Inert far enemy marker keeps the episode from auto-terminating
|
| 128 |
+
# on "all enemies dead" before the win/fail predicate fires.
|
| 129 |
+
- {type: e1, owner: enemy, position: [120, 36], stance: 0}
|
| 130 |
+
|
| 131 |
+
levels:
|
| 132 |
+
easy:
|
| 133 |
+
description: >
|
| 134 |
+
Loose-bar cash-reserve test. You start with 4 buildings (fact,
|
| 135 |
+
proc, tent, powr), 2 harvesters on 2 near ore patches, and
|
| 136 |
+
$1500. Build at least ONE more building (the cheapest is a
|
| 137 |
+
Power Plant at $300) AND keep at least $300 in the bank at the
|
| 138 |
+
moment the bar latches AND keep both harvesters alive — all
|
| 139 |
+
within tick 5400. Stalling (build nothing) loses on the
|
| 140 |
+
building bar; the harvesters keep refilling cash, so any modest
|
| 141 |
+
spend wins. Pure-hold and over-spend (a $1400 refinery dips
|
| 142 |
+
cash below the reserve at the latch moment) both fail.
|
| 143 |
+
starting_cash: 1500
|
| 144 |
+
win_condition:
|
| 145 |
+
all_of:
|
| 146 |
+
- building_total_gte: 5
|
| 147 |
+
- cash_gte: 300
|
| 148 |
+
- unit_type_count_gte: {type: harv, n: 2}
|
| 149 |
+
- within_ticks: 5400
|
| 150 |
+
fail_condition:
|
| 151 |
+
any_of:
|
| 152 |
+
- after_ticks: 5401
|
| 153 |
+
- not: {has_building: fact}
|
| 154 |
+
max_turns: 60
|
| 155 |
+
medium:
|
| 156 |
+
description: >
|
| 157 |
+
Tighter clock, same income engine: build at least TWO more
|
| 158 |
+
buildings (building_total ≥ 6, i.e. add at least 2 — e.g. two
|
| 159 |
+
Power Plants at $300 each, or one Pillbox at $600 plus a Power
|
| 160 |
+
Plant) AND keep $300 in the bank AND keep both harvesters
|
| 161 |
+
alive — within tick 810 (≈ 10 turns). The cheap 2× powr
|
| 162 |
+
commit costs $600, leaves $900 cash (well above the 300
|
| 163 |
+
reserve), and finishes by tick ~450 — comfortable slack.
|
| 164 |
+
Over-spending on a 2nd refinery ($1400) dips cash to $100 at
|
| 165 |
+
the latch moment; an extra build at $300+ pushes it lower.
|
| 166 |
+
Income refills cash at ~95 cr/turn so re-clearing the reserve
|
| 167 |
+
after over-spend takes ≥3-4 turns, pushing the win past tick
|
| 168 |
+
810. Stall and pure-hold lose on the building bar.
|
| 169 |
+
starting_cash: 1500
|
| 170 |
+
win_condition:
|
| 171 |
+
all_of:
|
| 172 |
+
- building_total_gte: 6
|
| 173 |
+
- cash_gte: 300
|
| 174 |
+
- unit_type_count_gte: {type: harv, n: 2}
|
| 175 |
+
- within_ticks: 810
|
| 176 |
+
fail_condition:
|
| 177 |
+
any_of:
|
| 178 |
+
- after_ticks: 811
|
| 179 |
+
- not: {has_building: fact}
|
| 180 |
+
max_turns: 10
|
| 181 |
+
hard:
|
| 182 |
+
description: >
|
| 183 |
+
Tightest growth bar, larger reserve, and seed-varied starts.
|
| 184 |
+
Build at least THREE more buildings (building_total ≥ 7) AND
|
| 185 |
+
keep $500 in the bank AND keep both harvesters alive — within
|
| 186 |
+
tick 1080 (≈ 12 turns). The cheapest 3-building commit (3×
|
| 187 |
+
powr = $900) leaves $600 cash, just above the $500 reserve;
|
| 188 |
+
mixing a Pillbox in ($600 + $600 = $1200) leaves $300, BELOW
|
| 189 |
+
the $500 reserve until income refills. Over-spending on a 2nd
|
| 190 |
+
refinery ($1400) dips cash far below 500 and cannot recover
|
| 191 |
+
inside 1080 ticks. The base is round-robined between NORTH
|
| 192 |
+
(proc/tent/powr/harvs around y=14) and SOUTH (around y=28)
|
| 193 |
+
per seed; the two near patches (y=14 and y=28) sit on the
|
| 194 |
+
spawn-matched row so the throughput is symmetric per spawn,
|
| 195 |
+
but a memorised opening cannot generalise across seeds.
|
| 196 |
+
starting_cash: 1500
|
| 197 |
+
overrides:
|
| 198 |
+
actors:
|
| 199 |
+
# spawn_point 0 — NORTH base, harvs on the y=14 patches.
|
| 200 |
+
- {type: fact, owner: agent, position: [10, 14], spawn_point: 0}
|
| 201 |
+
- {type: proc, owner: agent, position: [12, 14], spawn_point: 0}
|
| 202 |
+
- {type: tent, owner: agent, position: [10, 18], spawn_point: 0}
|
| 203 |
+
- {type: powr, owner: agent, position: [14, 14], spawn_point: 0}
|
| 204 |
+
- {type: harv, owner: agent, position: [16, 14], spawn_point: 0}
|
| 205 |
+
- {type: harv, owner: agent, position: [16, 15], spawn_point: 0}
|
| 206 |
+
# spawn_point 1 — SOUTH base, harvs on the y=28 patches.
|
| 207 |
+
- {type: fact, owner: agent, position: [10, 28], spawn_point: 1}
|
| 208 |
+
- {type: proc, owner: agent, position: [12, 28], spawn_point: 1}
|
| 209 |
+
- {type: tent, owner: agent, position: [10, 24], spawn_point: 1}
|
| 210 |
+
- {type: powr, owner: agent, position: [14, 28], spawn_point: 1}
|
| 211 |
+
- {type: harv, owner: agent, position: [16, 28], spawn_point: 1}
|
| 212 |
+
- {type: harv, owner: agent, position: [16, 29], spawn_point: 1}
|
| 213 |
+
# Two mine pairs — one for each spawn row. spawn_point filter
|
| 214 |
+
# applies ONLY to agent actors (CLAUDE.md engine fact), so
|
| 215 |
+
# both pairs always place; the spawn-matched pair feeds the
|
| 216 |
+
# spawn-matched harvs.
|
| 217 |
+
- {type: mine, owner: neutral, position: [22, 14]}
|
| 218 |
+
- {type: mine, owner: neutral, position: [22, 15]}
|
| 219 |
+
- {type: mine, owner: neutral, position: [22, 28]}
|
| 220 |
+
- {type: mine, owner: neutral, position: [22, 29]}
|
| 221 |
+
- {type: e1, owner: enemy, position: [120, 36], stance: 0}
|
| 222 |
+
win_condition:
|
| 223 |
+
all_of:
|
| 224 |
+
- building_total_gte: 7
|
| 225 |
+
- cash_gte: 500
|
| 226 |
+
- unit_type_count_gte: {type: harv, n: 2}
|
| 227 |
+
- within_ticks: 1080
|
| 228 |
+
fail_condition:
|
| 229 |
+
any_of:
|
| 230 |
+
- after_ticks: 1081
|
| 231 |
+
- not: {has_building: fact}
|
| 232 |
+
max_turns: 12
|
|
@@ -0,0 +1,285 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Validate: python -m openra_bench.scenarios.validate packs/econ-recover-from-zero-cash.yaml
|
| 2 |
+
# See ../CONTRIBUTING.md for the full win-condition grammar.
|
| 3 |
+
#
|
| 4 |
+
# ECON-RECOVER-FROM-ZERO-CASH (Group F — turtle-recovery / bankruptcy
|
| 5 |
+
# turnaround from a minimum-viable kit and ZERO cash).
|
| 6 |
+
#
|
| 7 |
+
# Real-world anchor: SC2 turtle-back-to-eco (lost everything, rebuild
|
| 8 |
+
# slowly with a sole surviving worker), business bankruptcy turnaround
|
| 9 |
+
# (one operating asset left, no working capital, rebuild revenue first
|
| 10 |
+
# then reinvest), military recovery from defeat (rebuild force structure
|
| 11 |
+
# starting from a skeleton crew), startup pivot after a runway crisis.
|
| 12 |
+
#
|
| 13 |
+
# Pack frame: the agent starts with a MINIMUM VIABLE KIT (fact + proc +
|
| 14 |
+
# one harvester + one mine) and **0 cash**. There is no enemy aggression.
|
| 15 |
+
# The decision is the RECOVERY PROFILE — commit to harvest first, let
|
| 16 |
+
# income accumulate, then reinvest in the next productive asset (a war
|
| 17 |
+
# factory + a second harvester) so the income channel actually scales
|
| 18 |
+
# past a non-trivial revenue bar. A model that:
|
| 19 |
+
# * stalls (zero income forever) ⇒ never harvests, EV stays at 0, LOSS
|
| 20 |
+
# * builds-too-soon (e.g. queues a SECOND refinery or buys army)
|
| 21 |
+
# ⇒ drains harvest cash to the wrong asset, never funds the weap+harv
|
| 22 |
+
# chain, fails the 2-harv requirement ⇒ LOSS
|
| 23 |
+
# * builds-army-from-zero (powr → tent → spam e1 instead of weap+harv)
|
| 24 |
+
# ⇒ never builds a 2nd harv ⇒ LOSS
|
| 25 |
+
# all miss the bar. The intended slow-and-steady recovery (harvest,
|
| 26 |
+
# accumulate to 2000, build('weap'), then build('harv'), redirect the
|
| 27 |
+
# new harv to the patch, keep harvesting) WINS.
|
| 28 |
+
#
|
| 29 |
+
# Why this pack is different from `economy-harvest-timebox`:
|
| 30 |
+
# * `economy-harvest-timebox` starts with 300-1500 cash and only tests
|
| 31 |
+
# "commit to harvest" (or build a 2nd harv with the starting budget).
|
| 32 |
+
# * `econ-recover-from-zero-cash` starts with **0 cash** and forces
|
| 33 |
+
# the agent to COMPLETE the income-then-reinvest loop: there is no
|
| 34 |
+
# starting buffer to spend, so the test is the recovery DISCIPLINE —
|
| 35 |
+
# do not spend before income flows; do not spend on the wrong asset
|
| 36 |
+
# when income arrives; commit to the harvest→weap→harv sequence.
|
| 37 |
+
#
|
| 38 |
+
# Why this pack is different from `econ-startup-from-scratch`:
|
| 39 |
+
# * `econ-startup-from-scratch` tests the COLD-START opening (deploy
|
| 40 |
+
# MCV → powr → proc → harv) from a 1900-cash budget. The agent has
|
| 41 |
+
# no buildings.
|
| 42 |
+
# * `econ-recover-from-zero-cash` tests RECOVERY FROM SETBACK (you
|
| 43 |
+
# once had a base, now you have only fact+proc+1 harv and 0 cash —
|
| 44 |
+
# rebuild revenue and re-scale the worker count). The chain is
|
| 45 |
+
# harvest → weap → harv (the cold-start "powr → proc" part is
|
| 46 |
+
# already paid for by the survived infrastructure).
|
| 47 |
+
#
|
| 48 |
+
# ENGINE FACTS verified (CLAUDE.md):
|
| 49 |
+
# 1. starting_cash: 0 is supported (see mcv-deploy-relocate-under-pressure,
|
| 50 |
+
# mfb-base-1-defend-base-2-build). The `build` order with insufficient
|
| 51 |
+
# cash defers production (the queue waits until cash is available).
|
| 52 |
+
# 2. harv prereq is `weap` (gamerules.rs line 328-330). weap (cost 2000)
|
| 53 |
+
# prereq is `proc` (already pre-placed). harv cost is 1400.
|
| 54 |
+
# 3. tent prereq is `powr` (gamerules.rs line 340) and we do NOT pre-
|
| 55 |
+
# place powr, so the brute "build army" path requires building powr
|
| 56 |
+
# (300) → tent (400) → e1 (100 each) — but this never funds weap
|
| 57 |
+
# (2000) + harv (1400) within budget, so the 2-harv requirement is
|
| 58 |
+
# never met ⇒ LOSS.
|
| 59 |
+
# 4. Harvest income is distance-dependent: with the proc immediately
|
| 60 |
+
# adjacent to a near patch, one harv sustains ~150-200 cr per
|
| 61 |
+
# decision turn (verified by economy-harvest-investment and
|
| 62 |
+
# economy-harvest-timebox tuning notes). At 70 turns × ~150 cr ≈
|
| 63 |
+
# ~10,500 ev gross, comfortably above the chain spend (2000+1400 =
|
| 64 |
+
# 3400) plus the 2000 EV bar; a hard tier (3 harvs, 4800 chain
|
| 65 |
+
# spend, 3000 EV bar) is solvable inside 70 turns with 2 patches.
|
| 66 |
+
# 5. Newly-built harv spawns at the weap and does NOT auto-path to a
|
| 67 |
+
# distant patch from a bare `harvest` order (engine quirk noted in
|
| 68 |
+
# econ-startup-from-scratch and economy-harvest-investment). The
|
| 69 |
+
# intended chain `move_units(harv, patch_x, patch_y)` first, then
|
| 70 |
+
# `harvest(harv, patch_x, patch_y)` once the harv is on the patch.
|
| 71 |
+
# 6. Persistent unarmed enemy `fact` marker far east prevents engine
|
| 72 |
+
# auto-`done` via the all-enemies-eliminated path (CLAUDE.md
|
| 73 |
+
# auto-done footgun) — so a non-finisher LOSES on the deadline
|
| 74 |
+
# (never DRAWS).
|
| 75 |
+
# 7. Tick alignment: max_turns 70 → ceiling 93 + 90·69 = 6303 > 6300 ✓.
|
| 76 |
+
# within_ticks 6300; fail after_ticks 6301 (deadline bites as a
|
| 77 |
+
# reachable LOSS).
|
| 78 |
+
# 8. The hard tier's two agent spawn_point groups (NORTH/SOUTH base)
|
| 79 |
+
# satisfy the spawn-variation contract (test_hard_tier.py).
|
| 80 |
+
# Neutral `mine` actors always place regardless of spawn_point
|
| 81 |
+
# filter (CLAUDE.md), so each spawn has its own near patch geometry.
|
| 82 |
+
|
| 83 |
+
meta:
|
| 84 |
+
id: econ-recover-from-zero-cash
|
| 85 |
+
title: 'Recover From Zero Cash — Rebuild Revenue From a Minimum-Viable Kit'
|
| 86 |
+
capability: reasoning
|
| 87 |
+
real_world_meaning: >
|
| 88 |
+
Turtle-recovery / bankruptcy turnaround from a minimum-viable kit
|
| 89 |
+
and zero working capital. The agent inherits ONE refinery, ONE
|
| 90 |
+
harvester, and ONE ore patch — but no cash, no spare workers, no
|
| 91 |
+
army. The decision is the RECOVERY PROFILE — commit to harvest
|
| 92 |
+
FIRST (the only income channel), accumulate cash, and then
|
| 93 |
+
reinvest in the NEXT productive asset (a war factory and a second
|
| 94 |
+
harvester) so the income channel actually scales past a non-
|
| 95 |
+
trivial revenue bar before the deadline. Stalling (no income),
|
| 96 |
+
spending the accumulated cash on the wrong asset (a second
|
| 97 |
+
refinery, army units), or trying to build an army from zero all
|
| 98 |
+
miss the bar.
|
| 99 |
+
robotics_analogue: >
|
| 100 |
+
Post-crisis operations restart: an autonomous operator has lost
|
| 101 |
+
most of its infrastructure and is reduced to one processing unit,
|
| 102 |
+
one collector, and one known source — with no operating capital.
|
| 103 |
+
It must commit to running the collector to refill the cash
|
| 104 |
+
reserve, then deploy the saved capital to acquire a second
|
| 105 |
+
collector before the recovery deadline. Spending on defensive /
|
| 106 |
+
expansion hedges before the income channel has refilled the
|
| 107 |
+
reserve misses the bar.
|
| 108 |
+
author: groupF-econ-seed
|
| 109 |
+
benchmark_anchor:
|
| 110 |
+
- "SC2 turtle-back-to-economy / recovery from setback"
|
| 111 |
+
- "business bankruptcy turnaround planning"
|
| 112 |
+
- "military recovery from defeat (rebuild force structure)"
|
| 113 |
+
- "startup pivot after runway crisis"
|
| 114 |
+
|
| 115 |
+
base_map: rush-hour-arena
|
| 116 |
+
# 0 cash — the entire pack is the recovery-from-zero-capital test. The
|
| 117 |
+
# agent CANNOT spend a starting buffer; the only path to a 2nd harv is
|
| 118 |
+
# to harvest first, then reinvest the accumulated cash on weap+harv.
|
| 119 |
+
starting_cash: 0
|
| 120 |
+
|
| 121 |
+
base:
|
| 122 |
+
agent: {faction: allies}
|
| 123 |
+
enemy: {faction: soviet, cash: 0}
|
| 124 |
+
# Spec-required toolset: NO `deploy` (we already have fact pre-placed);
|
| 125 |
+
# the agent must commit to harvest and reinvest with the production
|
| 126 |
+
# chain (`build` + `place_building`).
|
| 127 |
+
tools: [observe, build, place_building, harvest, move_units, stop]
|
| 128 |
+
# Default `spawn_mcvs: false` — we declare actors ourselves per level
|
| 129 |
+
# so the engine doesn't add a phantom MCV.
|
| 130 |
+
spawn_mcvs: false
|
| 131 |
+
planning: true
|
| 132 |
+
termination: {max_ticks: 8000}
|
| 133 |
+
actors: []
|
| 134 |
+
|
| 135 |
+
levels:
|
| 136 |
+
# ── EASY ─────────────────────────────────────────────────────────
|
| 137 |
+
# Minimum-viable kit (fact + proc + 1 harv + 1 mine); 0 cash; loose
|
| 138 |
+
# bar (1500 EV) and NO second-harv requirement. The skill being
|
| 139 |
+
# tested is the floor: commit to the existing harvester so income
|
| 140 |
+
# actually flows (the pre-placed harv near the patch sustains
|
| 141 |
+
# ~150-200 cr/turn). Stall (zero income) LOSES; any committed
|
| 142 |
+
# harvest WINS. max_turns 70 → ceiling 93 + 90·69 = 6303 > 6300 ✓.
|
| 143 |
+
easy:
|
| 144 |
+
description: >
|
| 145 |
+
You start with a Construction Yard ('fact'), an Ore Refinery
|
| 146 |
+
('proc'), one Ore Truck ('harv'), and one ore patch ('mine'),
|
| 147 |
+
with $0 cash. There is no enemy aggression. To start the
|
| 148 |
+
harvester collecting, first `move_units` it onto the patch cell
|
| 149 |
+
(22,18), then issue `harvest` with that cell as the target (the
|
| 150 |
+
auto-cycle continues on its own). Win = economy_value (cash +
|
| 151 |
+
stored ore) ≥ 1500 before tick 6300, AND you still own a 'fact'
|
| 152 |
+
AND ≥1 'harv'. Stalling (only `observe`) earns nothing and
|
| 153 |
+
loses on the clock.
|
| 154 |
+
starting_cash: 0
|
| 155 |
+
overrides:
|
| 156 |
+
actors:
|
| 157 |
+
- {type: fact, owner: agent, position: [8, 18]}
|
| 158 |
+
- {type: proc, owner: agent, position: [12, 18]}
|
| 159 |
+
- {type: harv, owner: agent, position: [14, 18]}
|
| 160 |
+
- {type: mine, owner: neutral, position: [22, 18]}
|
| 161 |
+
# Persistent far enemy marker — LOSS-not-DRAW guarantee.
|
| 162 |
+
- {type: fact, owner: enemy, position: [120, 20]}
|
| 163 |
+
win_condition:
|
| 164 |
+
all_of:
|
| 165 |
+
- economy_value_gte: 1500
|
| 166 |
+
- has_building: fact
|
| 167 |
+
- unit_type_count_gte: {type: harv, n: 1}
|
| 168 |
+
- within_ticks: 6300
|
| 169 |
+
fail_condition:
|
| 170 |
+
any_of:
|
| 171 |
+
- after_ticks: 6301
|
| 172 |
+
- not: {has_building: fact}
|
| 173 |
+
- not: {unit_type_count_gte: {type: harv, n: 1}}
|
| 174 |
+
max_turns: 70
|
| 175 |
+
|
| 176 |
+
# ── MEDIUM ───────────────────────────────────────────────────────
|
| 177 |
+
# +1 controlled variable on top of easy: the win predicate adds a
|
| 178 |
+
# 2-HARV requirement, so a "harvest only with the starting harv"
|
| 179 |
+
# policy LOSES on the structural clause regardless of EV. The
|
| 180 |
+
# intended recovery chain is: harvest (income flows) → save 2000 →
|
| 181 |
+
# build('weap') (prereq for harv) + place_building → save 1400 →
|
| 182 |
+
# build('harv') + place_building → move_units the new harv to the
|
| 183 |
+
# patch → harvest. Bar 2000 EV requires committed harvesting after
|
| 184 |
+
# the chain spend (cash post-chain is ~0; ≥2000 ev = the bar must
|
| 185 |
+
# be reached via the resumed harvest income).
|
| 186 |
+
medium:
|
| 187 |
+
description: >
|
| 188 |
+
You start with a Construction Yard ('fact'), an Ore Refinery
|
| 189 |
+
('proc'), one Ore Truck ('harv'), and one ore patch ('mine'),
|
| 190 |
+
with $0 cash. To rebuild revenue: (1) `move_units` the harv
|
| 191 |
+
onto the patch cell (22,18) then `harvest` it (the auto-cycle
|
| 192 |
+
sustains income on its own); (2) once you have $2000, queue a
|
| 193 |
+
War Factory ('weap', cost 2000, prereq is the existing
|
| 194 |
+
refinery) and place it adjacent to the fact; (3) once the
|
| 195 |
+
'weap' is up and you have $1400, queue a second Ore Truck
|
| 196 |
+
('harv'); (4) `move_units` the new harv to the patch and
|
| 197 |
+
`harvest` it. Win = economy_value ≥ 2000 AND ≥ 2 harvesters
|
| 198 |
+
alive AND you still own 'fact', before tick 6300. Stalling,
|
| 199 |
+
building army units (powr → tent → e1) instead of the weap+harv
|
| 200 |
+
chain, or building a second refinery instead of a second harv
|
| 201 |
+
all miss the 2-harv requirement and LOSE.
|
| 202 |
+
starting_cash: 0
|
| 203 |
+
overrides:
|
| 204 |
+
actors:
|
| 205 |
+
- {type: fact, owner: agent, position: [8, 18]}
|
| 206 |
+
- {type: proc, owner: agent, position: [12, 18]}
|
| 207 |
+
- {type: harv, owner: agent, position: [14, 18]}
|
| 208 |
+
- {type: mine, owner: neutral, position: [22, 18]}
|
| 209 |
+
- {type: fact, owner: enemy, position: [120, 20]}
|
| 210 |
+
win_condition:
|
| 211 |
+
all_of:
|
| 212 |
+
- economy_value_gte: 2000
|
| 213 |
+
- has_building: fact
|
| 214 |
+
- unit_type_count_gte: {type: harv, n: 2}
|
| 215 |
+
- within_ticks: 6300
|
| 216 |
+
fail_condition:
|
| 217 |
+
any_of:
|
| 218 |
+
- after_ticks: 6301
|
| 219 |
+
- not: {has_building: fact}
|
| 220 |
+
- not: {unit_type_count_gte: {type: harv, n: 1}}
|
| 221 |
+
max_turns: 70
|
| 222 |
+
|
| 223 |
+
# ── HARD ─────────────────────────────────────────────────────────
|
| 224 |
+
# +2 controlled variables vs medium:
|
| 225 |
+
# 1. Higher bar (3000 EV) AND 3-harv requirement — the chain spend
|
| 226 |
+
# is now weap (2000) + harv (1400) + harv (1400) = 4800,
|
| 227 |
+
# requiring sustained throughput AFTER the chain to clear the
|
| 228 |
+
# 3000 EV bar. Two ore patches double the income ceiling so
|
| 229 |
+
# the bar is still reachable inside 70 turns.
|
| 230 |
+
# 2. TWO agent spawn_point groups (NORTH base y=14 vs SOUTH base
|
| 231 |
+
# y=26) round-robined by seed — a memorised "place weap at
|
| 232 |
+
# (8,18)" opening cannot generalise. Each spawn has its own
|
| 233 |
+
# symmetric two-patch geometry. Neutral mine actors always
|
| 234 |
+
# place regardless of spawn_point filter (CLAUDE.md engine
|
| 235 |
+
# footgun), so BOTH patch pairs exist in the world; the
|
| 236 |
+
# irrelevant pair sits far from each spawn's harv lane.
|
| 237 |
+
hard:
|
| 238 |
+
description: >
|
| 239 |
+
Same recovery from $0 cash, but the bar is higher (3000 EV)
|
| 240 |
+
AND you need THREE harvesters, AND there are TWO ore patches
|
| 241 |
+
near your base. Your base orientation varies by seed (NORTH or
|
| 242 |
+
SOUTH); both halves have the same per-spawn patch geometry.
|
| 243 |
+
Intended chain: (1) commit the starting harv to a near patch
|
| 244 |
+
(use `move_units` to nudge it onto the patch cell, then
|
| 245 |
+
`harvest`); (2) save $2000, build a War Factory ('weap',
|
| 246 |
+
prereq is the existing refinery) adjacent to the fact; (3)
|
| 247 |
+
save $1400, build a 2nd harv, move it to the other near patch,
|
| 248 |
+
`harvest`; (4) save $1400, build a 3rd harv, route it back to
|
| 249 |
+
the first patch (or feed the second), `harvest`. Win =
|
| 250 |
+
economy_value ≥ 3000 AND ≥ 3 harvs AND own 'fact' before tick
|
| 251 |
+
6300. Stalling, building army, or building only 2 harvs all
|
| 252 |
+
miss the 3-harv requirement and LOSE.
|
| 253 |
+
starting_cash: 0
|
| 254 |
+
overrides:
|
| 255 |
+
actors:
|
| 256 |
+
# ── spawn_point 0: NORTH base, near patches at y=14 / y=16 ──
|
| 257 |
+
- {type: fact, owner: agent, position: [8, 14], spawn_point: 0}
|
| 258 |
+
- {type: proc, owner: agent, position: [12, 14], spawn_point: 0}
|
| 259 |
+
- {type: harv, owner: agent, position: [14, 14], spawn_point: 0}
|
| 260 |
+
# ── spawn_point 1: SOUTH base, near patches at y=26 / y=28 ──
|
| 261 |
+
- {type: fact, owner: agent, position: [8, 26], spawn_point: 1}
|
| 262 |
+
- {type: proc, owner: agent, position: [12, 26], spawn_point: 1}
|
| 263 |
+
- {type: harv, owner: agent, position: [14, 26], spawn_point: 1}
|
| 264 |
+
# Neutral mines ALWAYS place regardless of spawn_point filter
|
| 265 |
+
# (CLAUDE.md). Place a symmetric two-patch pair at each
|
| 266 |
+
# latitude; the other-spawn pair sits 12 cells away (far
|
| 267 |
+
# enough to be irrelevant to the spawn-local harv throughput).
|
| 268 |
+
- {type: mine, owner: neutral, position: [22, 14]}
|
| 269 |
+
- {type: mine, owner: neutral, position: [22, 16]}
|
| 270 |
+
- {type: mine, owner: neutral, position: [22, 26]}
|
| 271 |
+
- {type: mine, owner: neutral, position: [22, 28]}
|
| 272 |
+
# Persistent far enemy marker — LOSS-not-DRAW guarantee.
|
| 273 |
+
- {type: fact, owner: enemy, position: [120, 20]}
|
| 274 |
+
win_condition:
|
| 275 |
+
all_of:
|
| 276 |
+
- economy_value_gte: 3000
|
| 277 |
+
- has_building: fact
|
| 278 |
+
- unit_type_count_gte: {type: harv, n: 3}
|
| 279 |
+
- within_ticks: 6300
|
| 280 |
+
fail_condition:
|
| 281 |
+
any_of:
|
| 282 |
+
- after_ticks: 6301
|
| 283 |
+
- not: {has_building: fact}
|
| 284 |
+
- not: {unit_type_count_gte: {type: harv, n: 1}}
|
| 285 |
+
max_turns: 70
|
|
@@ -0,0 +1,197 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
meta:
|
| 2 |
+
id: econ-target-cash-amount-by-deadline
|
| 3 |
+
title: 'Econ — Hit a Cash Target by a Hard Deadline'
|
| 4 |
+
capability: reasoning
|
| 5 |
+
real_world_meaning: >
|
| 6 |
+
Budget-by-date target: the operation must reach a stated cash bar
|
| 7 |
+
by a stated deadline, the way a startup must hit a quarterly
|
| 8 |
+
revenue target / a fundraising milestone / a quarterly closeout.
|
| 9 |
+
Pre-placed harvester+refinery+barracks are the running pipeline;
|
| 10 |
+
the agent must reason about whether the current capacity will
|
| 11 |
+
clear the bar on time, and if not, reinvest into harvesting
|
| 12 |
+
capacity (build extra harvesters) — not into army units that drain
|
| 13 |
+
the cash account without growing income. Idling, over-spending on
|
| 14 |
+
the wrong line item, or under-investing in capacity all miss the
|
| 15 |
+
bar.
|
| 16 |
+
robotics_analogue: >
|
| 17 |
+
Throughput-by-deadline planning in an autonomous pipeline: hit a
|
| 18 |
+
revenue/output target by a hard date by deciding when to scale up
|
| 19 |
+
collectors (capacity capex) vs hold (stay lean) vs spend on
|
| 20 |
+
unrelated capacity (army units / non-revenue line items). Capex
|
| 21 |
+
timing is the decision — over-spend on the wrong line and the
|
| 22 |
+
deadline passes with the books short.
|
| 23 |
+
author: wave-5
|
| 24 |
+
benchmark_anchor:
|
| 25 |
+
- SC2 econ-by-tick target
|
| 26 |
+
- "budget-by-date / quarterly revenue target"
|
| 27 |
+
- fundraising milestone planning
|
| 28 |
+
- OR throughput optimization with deadline
|
| 29 |
+
|
| 30 |
+
# ENGINE NOTE (verified 2026-05-20 against installed openra_train wheel):
|
| 31 |
+
# 1. `cash_gte: N` reads c.signals.cash (spendable cash only, NOT
|
| 32 |
+
# cash+resources EV — see win_conditions.py). Building units
|
| 33 |
+
# DEDUCTS cash, so an "army drain" policy (spam e1 alongside
|
| 34 |
+
# harvest) treads water at ~starting_cash (income ≈ unit cost)
|
| 35 |
+
# and never reaches the bar.
|
| 36 |
+
# 2. Harv build requires fact + powr + weap (war factory) as the
|
| 37 |
+
# Vehicle queue prereqs; tent is included for `e1` so the army-
|
| 38 |
+
# drain decoy can actually issue StartProduction (so it loses
|
| 39 |
+
# DRAINING cash, not silently no-oping).
|
| 40 |
+
# 3. Income rate from one harv on the rush-hour-arena near patches
|
| 41 |
+
# (22,18)/(22,22) is ~1.1 cash/tick after a ~450-tick warmup;
|
| 42 |
+
# so baseline 1-harv ramp in 4500 ticks ≈ 600 + 4500·1.1 ≈ 5550.
|
| 43 |
+
# Medium target=5500 is right at the baseline's max reach (so
|
| 44 |
+
# it loses on the deadline by ~3 ticks), and one extra harv
|
| 45 |
+
# accelerates to the bar by tick ~2900 (comfortable win).
|
| 46 |
+
# 4. Hard tier: target=6500 in 4000 ticks beats the baseline (which
|
| 47 |
+
# plateaus at ~4100 cash by 4053 ticks), 2 symmetric spawn_point
|
| 48 |
+
# groups (NORTH y=10..14 / SOUTH y=28..32) so a memorised "build
|
| 49 |
+
# at (8,18)" opening cannot generalise across seeds. Both spawn
|
| 50 |
+
# groups carry their own fact+proc+tent+powr+weap+harv +
|
| 51 |
+
# near-mine pair (shared mine geometry replicated at both
|
| 52 |
+
# latitudes per the spawn_point filter rule — only AGENT actors
|
| 53 |
+
# are filtered by spawn_point; neutral mines always place, but
|
| 54 |
+
# we keep the geometry symmetric so DEEP throughput is equal).
|
| 55 |
+
# 5. Tick/turn alignment: easy max_turns=21 → ceiling 1893 ticks
|
| 56 |
+
# ≥ within_ticks 1800; medium max_turns=51 → ceiling 4593 ≥ 4500;
|
| 57 |
+
# hard max_turns=46 → ceiling 4143 ≥ 4000. fail_condition uses
|
| 58 |
+
# after_ticks = within_ticks + 1 so a non-finisher is a real
|
| 59 |
+
# LOSS (the deadline bites).
|
| 60 |
+
# 6. Far enemy `e1` at (120,36) stance:0 keeps the engine from
|
| 61 |
+
# auto-`done`ing on no-enemy-actors (CLAUDE.md footgun).
|
| 62 |
+
base_map: rush-hour-arena
|
| 63 |
+
starting_cash: 600
|
| 64 |
+
|
| 65 |
+
base:
|
| 66 |
+
agent:
|
| 67 |
+
faction: allies
|
| 68 |
+
enemy:
|
| 69 |
+
faction: soviet
|
| 70 |
+
tools:
|
| 71 |
+
- observe
|
| 72 |
+
- build
|
| 73 |
+
- place_building
|
| 74 |
+
- harvest
|
| 75 |
+
- move_units
|
| 76 |
+
- stop
|
| 77 |
+
planning: true
|
| 78 |
+
termination:
|
| 79 |
+
max_ticks: 40000
|
| 80 |
+
actors:
|
| 81 |
+
# Pre-placed running pipeline: fact + proc + tent (army decoy) +
|
| 82 |
+
# powr + weap (Vehicle queue for harv) + 1× harv + 2× mines.
|
| 83 |
+
- {type: fact, owner: agent, position: [8, 18]}
|
| 84 |
+
- {type: proc, owner: agent, position: [12, 18]}
|
| 85 |
+
- {type: tent, owner: agent, position: [12, 22]}
|
| 86 |
+
- {type: powr, owner: agent, position: [8, 22]}
|
| 87 |
+
- {type: weap, owner: agent, position: [16, 22]}
|
| 88 |
+
- {type: harv, owner: agent, position: [14, 18]}
|
| 89 |
+
- {type: mine, owner: neutral, position: [22, 18]}
|
| 90 |
+
- {type: mine, owner: neutral, position: [22, 22]}
|
| 91 |
+
# Far inert enemy marker — prevents engine auto-done on
|
| 92 |
+
# no-enemy-actors (CLAUDE.md). stance:0 + far position so it
|
| 93 |
+
# cannot influence the run.
|
| 94 |
+
- {type: e1, owner: enemy, position: [120, 36], stance: 0}
|
| 95 |
+
|
| 96 |
+
levels:
|
| 97 |
+
easy:
|
| 98 |
+
description: >
|
| 99 |
+
Loose deadline cash target. The pipeline (1× harv + 1× proc +
|
| 100 |
+
2× mine) is already running; the bar (1500 cash by tick 1800)
|
| 101 |
+
is reachable just by committing to harvest with the pre-placed
|
| 102 |
+
harvester — no reinvestment needed. Stall idles at the 600
|
| 103 |
+
starting cash and LOSES; an "army drain" policy (spam e1) that
|
| 104 |
+
harvests AND spends every spare credit on infantry treads
|
| 105 |
+
water at starting_cash (income ≈ unit cost) and LOSES; the
|
| 106 |
+
intended `harvest` commitment WINS by ~tick 900. This is the
|
| 107 |
+
bare cash-target reasoning skill (notice the bar, run the
|
| 108 |
+
pipeline, hit it before the deadline).
|
| 109 |
+
starting_cash: 600
|
| 110 |
+
win_condition:
|
| 111 |
+
all_of:
|
| 112 |
+
- cash_gte: 1500
|
| 113 |
+
- within_ticks: 1800
|
| 114 |
+
# within_ticks ≤ 93+90·20 = 1893 ⇒ deadline bites inside max_turns;
|
| 115 |
+
# after_ticks=1801 ⇒ non-finisher is a real LOSS, not a draw.
|
| 116 |
+
fail_condition:
|
| 117 |
+
any_of:
|
| 118 |
+
- after_ticks: 1801
|
| 119 |
+
- not: {has_building: fact}
|
| 120 |
+
- not: {unit_type_count_gte: {type: harv, n: 1}}
|
| 121 |
+
max_turns: 21
|
| 122 |
+
medium:
|
| 123 |
+
description: >
|
| 124 |
+
Mid bar — 5500 cash by tick 4500 — that the pre-placed
|
| 125 |
+
pipeline CANNOT meet by itself. One harvester on the two near
|
| 126 |
+
patches yields ~1.1 cash/tick after deductions, so 600 +
|
| 127 |
+
4500·1.1 ≈ 5550 — at tick 4500 the bar is just out of reach
|
| 128 |
+
and the deadline bites (baseline LOSES at cash ≈ 5600 at tick
|
| 129 |
+
4503). The intended play is to reinvest: build an extra harv
|
| 130 |
+
(cost 1100, prereq fact+weap+powr) so the post-warmup income
|
| 131 |
+
doubles and the bar clears by ~tick 2900. Stall (no income)
|
| 132 |
+
LOSES; "army drain" (spend every credit on e1) treads water
|
| 133 |
+
at ~starting_cash and LOSES; baseline (no reinvestment) just
|
| 134 |
+
misses and LOSES. Reinvestment WINS.
|
| 135 |
+
starting_cash: 600
|
| 136 |
+
win_condition:
|
| 137 |
+
all_of:
|
| 138 |
+
- cash_gte: 5500
|
| 139 |
+
- within_ticks: 4500
|
| 140 |
+
# within_ticks ≤ 93+90·50 = 4593 ⇒ reachable in max_turns;
|
| 141 |
+
# after_ticks=4501 ⇒ non-finisher is a real LOSS.
|
| 142 |
+
fail_condition:
|
| 143 |
+
any_of:
|
| 144 |
+
- after_ticks: 4501
|
| 145 |
+
- not: {has_building: fact}
|
| 146 |
+
- not: {unit_type_count_gte: {type: harv, n: 1}}
|
| 147 |
+
max_turns: 51
|
| 148 |
+
hard:
|
| 149 |
+
description: >
|
| 150 |
+
Tighter bar (6500 cash) in a tighter clock (4000 ticks) with
|
| 151 |
+
seed-varied starts. Two spawn_point groups (NORTH y=10..14 /
|
| 152 |
+
SOUTH y=28..32) each carry their own fact+proc+tent+powr+
|
| 153 |
+
weap+harv; both latitudes have a symmetric near-patch pair so
|
| 154 |
+
the reinvestment economics are equal per spawn (a memorised
|
| 155 |
+
"build at (8,18)" opening cannot generalise — the model must
|
| 156 |
+
identify its base each episode). The baseline pipeline
|
| 157 |
+
plateaus at ~4100 cash by tick 4053 (LOSES on every seed);
|
| 158 |
+
stall and army-drain LOSE on every seed; reinvesting into
|
| 159 |
+
≥1 extra harv WINS on every seed by tick ≤3800.
|
| 160 |
+
starting_cash: 600
|
| 161 |
+
overrides:
|
| 162 |
+
actors:
|
| 163 |
+
# spawn_point 0 — NORTH base (y=10..14); near patches at y=10/14.
|
| 164 |
+
- {type: fact, owner: agent, position: [8, 10], spawn_point: 0}
|
| 165 |
+
- {type: proc, owner: agent, position: [12, 10], spawn_point: 0}
|
| 166 |
+
- {type: tent, owner: agent, position: [12, 14], spawn_point: 0}
|
| 167 |
+
- {type: powr, owner: agent, position: [8, 14], spawn_point: 0}
|
| 168 |
+
- {type: weap, owner: agent, position: [16, 14], spawn_point: 0}
|
| 169 |
+
- {type: harv, owner: agent, position: [14, 10], spawn_point: 0}
|
| 170 |
+
# spawn_point 1 — SOUTH base (y=28..32); near patches at y=28/32.
|
| 171 |
+
- {type: fact, owner: agent, position: [8, 28], spawn_point: 1}
|
| 172 |
+
- {type: proc, owner: agent, position: [12, 28], spawn_point: 1}
|
| 173 |
+
- {type: tent, owner: agent, position: [12, 32], spawn_point: 1}
|
| 174 |
+
- {type: powr, owner: agent, position: [8, 32], spawn_point: 1}
|
| 175 |
+
- {type: weap, owner: agent, position: [16, 32], spawn_point: 1}
|
| 176 |
+
- {type: harv, owner: agent, position: [14, 28], spawn_point: 1}
|
| 177 |
+
# Symmetric near-patch ore geometry — placed at BOTH
|
| 178 |
+
# latitudes so the income loop is equal per spawn (neutral
|
| 179 |
+
# actors aren't filtered by spawn_point, so duplicating
|
| 180 |
+
# both pairs is correct).
|
| 181 |
+
- {type: mine, owner: neutral, position: [22, 10]}
|
| 182 |
+
- {type: mine, owner: neutral, position: [22, 14]}
|
| 183 |
+
- {type: mine, owner: neutral, position: [22, 28]}
|
| 184 |
+
- {type: mine, owner: neutral, position: [22, 32]}
|
| 185 |
+
- {type: e1, owner: enemy, position: [120, 36], stance: 0}
|
| 186 |
+
win_condition:
|
| 187 |
+
all_of:
|
| 188 |
+
- cash_gte: 6500
|
| 189 |
+
- within_ticks: 4000
|
| 190 |
+
# within_ticks ≤ 93+90·45 = 4143 ⇒ reachable in max_turns;
|
| 191 |
+
# after_ticks=4001 ⇒ non-finisher is a real LOSS.
|
| 192 |
+
fail_condition:
|
| 193 |
+
any_of:
|
| 194 |
+
- after_ticks: 4001
|
| 195 |
+
- not: {has_building: fact}
|
| 196 |
+
- not: {unit_type_count_gte: {type: harv, n: 1}}
|
| 197 |
+
max_turns: 46
|
|
@@ -0,0 +1,357 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# lh-100-turn-marathon-survival.yaml
|
| 2 |
+
#
|
| 3 |
+
# REASONING capability — Group G long-horizon. Very-Late "marathon
|
| 4 |
+
# survival" pack: keep the base alive UNDER SUSTAINED HUNT PRESSURE
|
| 5 |
+
# for the full 100-turn budget. The win is gated on FOUR things still
|
| 6 |
+
# being true at the end of the run:
|
| 7 |
+
# 1. has_building: fact # the base still stands
|
| 8 |
+
# 2. own_units_gte: 3 # a minimum garrison is alive
|
| 9 |
+
# 3. building_count_gte pbox 2 # at least two pillboxes were built
|
| 10 |
+
# 4. after_ticks N (survived) # the agent reached the end of the
|
| 11 |
+
# AND within_ticks N+x # episode (no early stop / draw)
|
| 12 |
+
#
|
| 13 |
+
# The discrimination is NOT a single micro decision; it is the
|
| 14 |
+
# SUSTAINED CADENCE over ~100 turns of:
|
| 15 |
+
# - reinforce: train fresh e1/e3 every few turns to replace losses
|
| 16 |
+
# (hunt bot trickles enemy units in across the whole episode)
|
| 17 |
+
# - defend: keep ≥2 pbox up; rebuild a razed one immediately
|
| 18 |
+
# - repair: keep fact/pbox/proc above critical HP (the `repair` tool
|
| 19 |
+
# toggles autorepair; without it a stripped pbox lane falls in
|
| 20 |
+
# the 60-90 turn window)
|
| 21 |
+
# - economy: keep the harvester(s) flowing — empty silos = no new
|
| 22 |
+
# units = the garrison bleeds out by turn ~70-80
|
| 23 |
+
#
|
| 24 |
+
# Wave geometry: instead of one large enemy stack closing in turn 1
|
| 25 |
+
# (which would either be blunted forever or roll the base before turn
|
| 26 |
+
# ~25), the pack STAGGERS multiple hunt squads along the east-west
|
| 27 |
+
# axis at increasing x. Each squad arrives at a different time
|
| 28 |
+
# window:
|
| 29 |
+
# x≈ 60 → reaches base ~tick 1200-1500 (turns ~14-18)
|
| 30 |
+
# x≈ 80 → reaches base ~tick 1800-2200 (turns ~20-25)
|
| 31 |
+
# x≈100 → reaches base ~tick 2400-3000 (turns ~27-34)
|
| 32 |
+
# x≈115 → reaches base ~tick 3000-3600 (turns ~33-40)
|
| 33 |
+
# After the early waves are absorbed the trailing hunt squads keep
|
| 34 |
+
# pressure on through the mid game; the agent's OWN reinforcement
|
| 35 |
+
# cadence is what carries it from turn ~50 through the after_ticks
|
| 36 |
+
# survival gate at tick 8100 (turn ~90).
|
| 37 |
+
#
|
| 38 |
+
# Real-world anchors:
|
| 39 |
+
# - lmgame-Bench multi-hour endurance benchmarks
|
| 40 |
+
# - SC2LE full-game / extended-game testing (sustained macro)
|
| 41 |
+
# - long-horizon credit assignment (survival has no proxy reward)
|
| 42 |
+
# - SRE extended on-call (sustained incident response, hours)
|
| 43 |
+
# - military endurance operations (continuous defensive ops)
|
| 44 |
+
#
|
| 45 |
+
# DISCRIMINATIONS (no defect, no cheat):
|
| 46 |
+
# - stall (observe only): LOSS — hunt rolls fact, no pbox built.
|
| 47 |
+
# - pure-defense-no-reinforce (build 2 pbox, never train units):
|
| 48 |
+
# LOSS — starting garrison bleeds out under sustained pressure,
|
| 49 |
+
# own_units drops below 1 before turn 90 ⇒ fail-clause fires
|
| 50 |
+
# (or own_units_gte:3 missed at the after_ticks gate).
|
| 51 |
+
# - pure-build-no-repair (build buildings but no repair / no
|
| 52 |
+
# unit replacement): LOSS — pbox/fact razed under sustained
|
| 53 |
+
# attrition; has_building:fact fails or pbox count drops < 2.
|
| 54 |
+
# - intended sustained-ops (reinforce + defend + repair across all
|
| 55 |
+
# 100 turns): WIN — all four clauses still satisfied at the gate.
|
| 56 |
+
#
|
| 57 |
+
# ENGINE FACTS (CLAUDE.md):
|
| 58 |
+
# - max_turns 100 → reachable tick ≈ 93 + 90·99 = 9003. The win's
|
| 59 |
+
# after_ticks: 8100 AND within_ticks: 9003 BAND is reachable.
|
| 60 |
+
# - after_ticks IN WIN is structurally incompatible with auto-DRAW
|
| 61 |
+
# on enemy-elim — so we PLACE A PERSISTENT inert enemy `fact` far
|
| 62 |
+
# east; hunt squads also stay on the map until killed, but the
|
| 63 |
+
# anti-DRAW marker is the load-bearing one.
|
| 64 |
+
# - hunt bot: each unit pursues the nearest agent target. Squads
|
| 65 |
+
# placed at distinct x converge on the base at different times,
|
| 66 |
+
# giving SUSTAINED (not one-shot) pressure.
|
| 67 |
+
# - pbox costs 600, needs `tent`; tent is pre-placed.
|
| 68 |
+
# - tent trains e1 (100cr) and e3 (300cr); separate queue from
|
| 69 |
+
# defenses so build('pbox') and build('e1') in parallel works.
|
| 70 |
+
# - `repair` toggles autorepair on a unit/building ID.
|
| 71 |
+
# - harv → proc economy: pre-placed `harv` + `proc` + 2× mine give
|
| 72 |
+
# income from turn 1; the agent must KICK the harv at a mine to
|
| 73 |
+
# start the loop (kick is one harvest order).
|
| 74 |
+
#
|
| 75 |
+
# Validate (no model / no network):
|
| 76 |
+
# cd /Users/berta/Projects/OpenRA-Bench && \
|
| 77 |
+
# python3 -m openra_bench.scenarios.validate \
|
| 78 |
+
# openra_bench/scenarios/packs/lh-100-turn-marathon-survival.yaml
|
| 79 |
+
|
| 80 |
+
meta:
|
| 81 |
+
id: lh-100-turn-marathon-survival
|
| 82 |
+
title: '100-Turn Marathon Survival — Sustain Defence, Reinforcement, and Repair'
|
| 83 |
+
capability: reasoning
|
| 84 |
+
real_world_meaning: >
|
| 85 |
+
A very-late single-episode endurance test: keep the base alive
|
| 86 |
+
under SUSTAINED enemy pressure for the full 100-turn budget. The
|
| 87 |
+
win is gated on the base, a minimum garrison, and the pillbox
|
| 88 |
+
line ALL still being intact at the end of the run, not on a
|
| 89 |
+
one-shot decision. The intended capability is the cadence of
|
| 90 |
+
reinforcement (train new infantry every few turns), repair
|
| 91 |
+
(toggle autorepair on damaged defences), and economic stewardship
|
| 92 |
+
(keep the refinery's harvester flowing). Stalling, pure defence
|
| 93 |
+
without reinforcement, and pure construction without repair all
|
| 94 |
+
lose; only sustained operations survive to the after-ticks gate.
|
| 95 |
+
robotics_analogue: >
|
| 96 |
+
Long-horizon autonomous operation under continuous external
|
| 97 |
+
pressure: an extended on-call / extended deployment where there
|
| 98 |
+
is no single decisive action, only the discipline of sustained
|
| 99 |
+
maintenance — repair work, supply replenishment, force
|
| 100 |
+
rotation — across the entire window. Credit only arrives at the
|
| 101 |
+
end of the window if every subsystem is still up.
|
| 102 |
+
benchmark_anchor:
|
| 103 |
+
- "lmgame-Bench multi-hour endurance"
|
| 104 |
+
- "SC2LE full-game / extended-game testing"
|
| 105 |
+
- "long-horizon credit assignment"
|
| 106 |
+
- "SRE extended on-call / military endurance ops"
|
| 107 |
+
author: openra-bench
|
| 108 |
+
|
| 109 |
+
base_map: rush-hour-arena
|
| 110 |
+
|
| 111 |
+
base:
|
| 112 |
+
agent:
|
| 113 |
+
faction: allies
|
| 114 |
+
enemy:
|
| 115 |
+
faction: soviet
|
| 116 |
+
bot_type: hunt
|
| 117 |
+
tools:
|
| 118 |
+
- observe
|
| 119 |
+
- build
|
| 120 |
+
- place_building
|
| 121 |
+
- harvest
|
| 122 |
+
- move_units
|
| 123 |
+
- attack_unit
|
| 124 |
+
- attack_move
|
| 125 |
+
- repair
|
| 126 |
+
- sell
|
| 127 |
+
- stop
|
| 128 |
+
planning: true
|
| 129 |
+
termination:
|
| 130 |
+
max_ticks: 40000
|
| 131 |
+
|
| 132 |
+
starting_cash: 1000
|
| 133 |
+
|
| 134 |
+
levels:
|
| 135 |
+
# ── EASY ─────────────────────────────────────────────────────────
|
| 136 |
+
# 90-turn budget → reachable tick ≈ 93 + 90·89 = 8103.
|
| 137 |
+
# Smaller hunt presence (3 staggered squads), forgiving after_ticks
|
| 138 |
+
# gate (7200 = ~turn 80), within_ticks 8103 (= max).
|
| 139 |
+
# Stall / pure-defense / pure-build LOSE; intended sustained WINS.
|
| 140 |
+
easy:
|
| 141 |
+
description: >
|
| 142 |
+
Survive a 90-turn endurance episode under sustained hunt pressure.
|
| 143 |
+
Pre-placed at the west: construction yard (fact), barracks
|
| 144 |
+
(tent), power plant (powr), refinery (proc), harvester (harv),
|
| 145 |
+
two mines, and 4 starting riflemen. THREE staggered enemy
|
| 146 |
+
squads at increasing distance close in over the course of the
|
| 147 |
+
game — early ones arrive ~turn 15, late ones ~turn 35, and
|
| 148 |
+
trailing fire keeps coming. To WIN you must, at tick 7200+,
|
| 149 |
+
STILL have: the construction yard alive, ≥3 of your own units
|
| 150 |
+
alive, and ≥2 pillboxes (pbox) you built. Stalling loses to
|
| 151 |
+
the first wave; only building pbox without ever training fresh
|
| 152 |
+
infantry leaves the garrison too thin (≥3 units fails); only
|
| 153 |
+
training infantry without pbox lets the base get rushed down.
|
| 154 |
+
Use repair to keep the pbox line standing.
|
| 155 |
+
starting_cash: 1000
|
| 156 |
+
overrides:
|
| 157 |
+
actors:
|
| 158 |
+
# Agent base — vulnerable buildings (fact, proc, powr) WEST;
|
| 159 |
+
# tent + future pbox lane mouth to the EAST so the hunt squads
|
| 160 |
+
# hit the defences before the income building.
|
| 161 |
+
- {type: fact, owner: agent, position: [10, 20]}
|
| 162 |
+
- {type: tent, owner: agent, position: [8, 18]}
|
| 163 |
+
- {type: powr, owner: agent, position: [6, 20]}
|
| 164 |
+
- {type: powr, owner: agent, position: [6, 22]}
|
| 165 |
+
- {type: proc, owner: agent, position: [6, 18]}
|
| 166 |
+
- {type: harv, owner: agent, position: [8, 22]}
|
| 167 |
+
# Two ore patches WEST of base so the harv loop never crosses
|
| 168 |
+
# the eastern lane the hunt squads attack down.
|
| 169 |
+
- {type: mine, owner: neutral, position: [2, 20]}
|
| 170 |
+
- {type: mine, owner: neutral, position: [2, 18]}
|
| 171 |
+
# 4 starting defenders (riflemen) at the EAST lane mouth so
|
| 172 |
+
# `own_units_gte:1` is satisfied on turn 1 and the first wave
|
| 173 |
+
# has something to engage before the agent's first pbox lands.
|
| 174 |
+
- {type: e1, owner: agent, position: [16, 20], stance: 3, count: 4}
|
| 175 |
+
# THREE staggered hunt squads at increasing x → arrive in
|
| 176 |
+
# successive time windows.
|
| 177 |
+
- {type: e1, owner: enemy, position: [60, 20], stance: 3, count: 2}
|
| 178 |
+
- {type: e1, owner: enemy, position: [80, 20], stance: 3, count: 2}
|
| 179 |
+
- {type: e1, owner: enemy, position: [100, 20], stance: 3, count: 2}
|
| 180 |
+
# Inert enemy `fact` marker far east — prevents engine
|
| 181 |
+
# auto-DRAW on enemy-elim once the hunt squads die. Without
|
| 182 |
+
# this the after_ticks-in-win gate collapses to DRAW.
|
| 183 |
+
- {type: fact, owner: enemy, position: [120, 20]}
|
| 184 |
+
win_condition:
|
| 185 |
+
all_of:
|
| 186 |
+
- {has_building: fact}
|
| 187 |
+
- {own_units_gte: 3}
|
| 188 |
+
- {building_count_gte: {type: pbox, n: 2}}
|
| 189 |
+
- {after_ticks: 7200}
|
| 190 |
+
- {within_ticks: 8103}
|
| 191 |
+
# Fail must fire whenever the survival gate is reached without
|
| 192 |
+
# ALL win clauses also being true (else the episode ends as a DRAW
|
| 193 |
+
# — CLAUDE.md rule 3). Win is evaluated FIRST in eval_core, so the
|
| 194 |
+
# broad `after_ticks: 7200` fail only bites if win is false; that
|
| 195 |
+
# gives us "didn't keep all four things alive ⇒ LOSS, not draw".
|
| 196 |
+
fail_condition:
|
| 197 |
+
any_of:
|
| 198 |
+
- {after_ticks: 7200}
|
| 199 |
+
- {not: {has_building: fact}}
|
| 200 |
+
- {not: {own_units_gte: 1}}
|
| 201 |
+
max_turns: 90
|
| 202 |
+
|
| 203 |
+
# ── MEDIUM ───────────────────────────────────────────────���───────
|
| 204 |
+
# +1 controlled variable: HEAVIER staggered pressure (5 squads, e1+e3
|
| 205 |
+
# mix) and the FULL 100-turn budget. The after_ticks gate (8100 =
|
| 206 |
+
# ~turn 90) sits comfortably under reachable tick 9003 = max.
|
| 207 |
+
medium:
|
| 208 |
+
description: >
|
| 209 |
+
Survive a full 100-turn endurance episode under sustained
|
| 210 |
+
hunt pressure. Pre-placed at the west: construction yard
|
| 211 |
+
(fact), barracks (tent), power plant (powr), refinery (proc),
|
| 212 |
+
harvester (harv), two mines, and 4 starting riflemen.
|
| 213 |
+
FIVE staggered enemy squads (rifle + rocket mix) close in
|
| 214 |
+
over the course of the game — early squads arrive ~turn 15,
|
| 215 |
+
mid ~turn 25-35, trailing ~turn 40-50 and pressure continues
|
| 216 |
+
through the late game. To WIN you must, at tick 8100+, STILL
|
| 217 |
+
have: the construction yard alive, ≥3 of your own units alive,
|
| 218 |
+
and ≥2 pillboxes (pbox) you built, AND survive until tick
|
| 219 |
+
9003 (turn 100). Stalling loses to the first wave; building
|
| 220 |
+
only pbox without retraining infantry leaves the garrison
|
| 221 |
+
bled below 3 by the late game; building only infantry without
|
| 222 |
+
pbox loses the base to the rocket squads. Use repair to keep
|
| 223 |
+
the pbox line standing.
|
| 224 |
+
starting_cash: 1000
|
| 225 |
+
overrides:
|
| 226 |
+
actors:
|
| 227 |
+
- {type: fact, owner: agent, position: [10, 20]}
|
| 228 |
+
- {type: tent, owner: agent, position: [8, 18]}
|
| 229 |
+
- {type: powr, owner: agent, position: [6, 20]}
|
| 230 |
+
- {type: powr, owner: agent, position: [6, 22]}
|
| 231 |
+
- {type: proc, owner: agent, position: [6, 18]}
|
| 232 |
+
- {type: harv, owner: agent, position: [8, 22]}
|
| 233 |
+
- {type: mine, owner: neutral, position: [2, 20]}
|
| 234 |
+
- {type: mine, owner: neutral, position: [2, 18]}
|
| 235 |
+
# 4 starting riflemen (own_units_gte:1 satisfied turn 1).
|
| 236 |
+
- {type: e1, owner: agent, position: [16, 20], stance: 3, count: 4}
|
| 237 |
+
# FIVE staggered hunt squads at distinct x positions for
|
| 238 |
+
# SUSTAINED (not one-shot) pressure across all 100 turns.
|
| 239 |
+
# Mix in 1 e3 (rocket) per mid/late squad so a pbox without
|
| 240 |
+
# repair gets bled down.
|
| 241 |
+
- {type: e1, owner: enemy, position: [60, 20], stance: 3, count: 2}
|
| 242 |
+
- {type: e1, owner: enemy, position: [75, 18], stance: 3, count: 2}
|
| 243 |
+
- {type: e3, owner: enemy, position: [75, 22], stance: 3, count: 1}
|
| 244 |
+
- {type: e1, owner: enemy, position: [90, 20], stance: 3, count: 2}
|
| 245 |
+
- {type: e3, owner: enemy, position: [90, 18], stance: 3, count: 1}
|
| 246 |
+
- {type: e1, owner: enemy, position: [105, 20], stance: 3, count: 2}
|
| 247 |
+
- {type: e3, owner: enemy, position: [105, 22], stance: 3, count: 1}
|
| 248 |
+
- {type: e1, owner: enemy, position: [115, 20], stance: 3, count: 2}
|
| 249 |
+
# Inert enemy `fact` marker (anti-DRAW).
|
| 250 |
+
- {type: fact, owner: enemy, position: [120, 22]}
|
| 251 |
+
win_condition:
|
| 252 |
+
all_of:
|
| 253 |
+
- {has_building: fact}
|
| 254 |
+
- {own_units_gte: 3}
|
| 255 |
+
- {building_count_gte: {type: pbox, n: 2}}
|
| 256 |
+
- {after_ticks: 8100}
|
| 257 |
+
- {within_ticks: 9003}
|
| 258 |
+
# Fail bites the moment the survival gate opens (after_ticks: 8100)
|
| 259 |
+
# — win is evaluated FIRST so the fail only fires if the win
|
| 260 |
+
# all-of is unsatisfied at that tick (CLAUDE.md rule 3: non-win
|
| 261 |
+
# must be a real LOSS, not a draw).
|
| 262 |
+
fail_condition:
|
| 263 |
+
any_of:
|
| 264 |
+
- {after_ticks: 8100}
|
| 265 |
+
- {not: {has_building: fact}}
|
| 266 |
+
- {not: {own_units_gte: 1}}
|
| 267 |
+
max_turns: 100
|
| 268 |
+
|
| 269 |
+
# ── HARD ─────────────────────────────────────────────────────────
|
| 270 |
+
# +1 controlled variable: HEAVIER pressure (six staggered squads,
|
| 271 |
+
# more e3 rockets) AND ≥2 agent spawn_point groups (NORTH y=12 /
|
| 272 |
+
# SOUTH y=28) so a single memorised opening can't generalise.
|
| 273 |
+
# Budget extended to 120 turns → reachable tick ≈ 93 + 90·119 =
|
| 274 |
+
# 10803; after_ticks gate at 9900 (= ~turn 110) sits comfortably
|
| 275 |
+
# below. Two parallel sets of hunt squads (north + south latitudes)
|
| 276 |
+
# always place — enemy actors ignore spawn_point — so whichever
|
| 277 |
+
# latitude the agent occupies catches one converging band while
|
| 278 |
+
# the OTHER also turns inward.
|
| 279 |
+
hard:
|
| 280 |
+
description: >
|
| 281 |
+
Survive a 120-turn endurance episode under sustained pressure
|
| 282 |
+
from TWO latitudes. Your base stages from a seed-chosen
|
| 283 |
+
latitude (NORTH or SOUTH) — a single memorised pbox cell cannot
|
| 284 |
+
generalise. Pre-placed at your west base: construction yard
|
| 285 |
+
(fact), barracks (tent), power plant (powr), refinery (proc),
|
| 286 |
+
harvester (harv), two mines, and 4 starting riflemen. SIX
|
| 287 |
+
staggered hunt squads (rifle + rocket mix) close in across
|
| 288 |
+
BOTH latitudes — the converging band hits early ~turn 15 and
|
| 289 |
+
pressure trickles through the late game. To WIN you must, at
|
| 290 |
+
tick 9900+, STILL have: the construction yard alive, ≥3 of
|
| 291 |
+
your own units alive, and ≥2 pillboxes (pbox) you built, AND
|
| 292 |
+
survive until tick 10803 (turn 120). Stall / pure-defense /
|
| 293 |
+
pure-build all lose; only sustained reinforcement + repair +
|
| 294 |
+
defence carry the agent through the gate.
|
| 295 |
+
starting_cash: 1500
|
| 296 |
+
overrides:
|
| 297 |
+
actors:
|
| 298 |
+
# NORTH base spawn (spawn_point 0). Layout: fact, proc, powr
|
| 299 |
+
# WEST; tent + pbox lane mouth EAST.
|
| 300 |
+
- {type: fact, owner: agent, position: [10, 12], spawn_point: 0}
|
| 301 |
+
- {type: tent, owner: agent, position: [8, 10], spawn_point: 0}
|
| 302 |
+
- {type: powr, owner: agent, position: [6, 12], spawn_point: 0}
|
| 303 |
+
- {type: powr, owner: agent, position: [6, 14], spawn_point: 0}
|
| 304 |
+
- {type: proc, owner: agent, position: [6, 10], spawn_point: 0}
|
| 305 |
+
- {type: harv, owner: agent, position: [8, 14], spawn_point: 0}
|
| 306 |
+
- {type: e1, owner: agent, position: [16, 12], stance: 3, count: 6, spawn_point: 0}
|
| 307 |
+
- {type: e3, owner: agent, position: [16, 13], stance: 3, count: 2, spawn_point: 0}
|
| 308 |
+
# SOUTH base spawn (spawn_point 1) — symmetric.
|
| 309 |
+
- {type: fact, owner: agent, position: [10, 28], spawn_point: 1}
|
| 310 |
+
- {type: tent, owner: agent, position: [8, 30], spawn_point: 1}
|
| 311 |
+
- {type: powr, owner: agent, position: [6, 28], spawn_point: 1}
|
| 312 |
+
- {type: powr, owner: agent, position: [6, 26], spawn_point: 1}
|
| 313 |
+
- {type: proc, owner: agent, position: [6, 30], spawn_point: 1}
|
| 314 |
+
- {type: harv, owner: agent, position: [8, 26], spawn_point: 1}
|
| 315 |
+
- {type: e1, owner: agent, position: [16, 28], stance: 3, count: 6, spawn_point: 1}
|
| 316 |
+
- {type: e3, owner: agent, position: [16, 27], stance: 3, count: 2, spawn_point: 1}
|
| 317 |
+
# Two ore patches, one per latitude (neutral actors are not
|
| 318 |
+
# spawn-filtered — both always placed). Mines WEST of base so
|
| 319 |
+
# the harv loop doesn't cross the lane.
|
| 320 |
+
- {type: mine, owner: neutral, position: [2, 12]}
|
| 321 |
+
- {type: mine, owner: neutral, position: [2, 28]}
|
| 322 |
+
# Staggered hunt squads at TWO latitudes. Enemy actors don't
|
| 323 |
+
# honour spawn_point — all placed. The hunt bot drives each
|
| 324 |
+
# unit at the nearest agent target, so whichever latitude the
|
| 325 |
+
# agent base occupies will be hit by the matching band first;
|
| 326 |
+
# the OTHER band's units also turn inward over time (they
|
| 327 |
+
# don't respect lanes — closest-target-pursuit). Composition
|
| 328 |
+
# is tuned so the intended sustained-ops policy CAN survive
|
| 329 |
+
# all 120 turns from EITHER spawn — the redundancy across
|
| 330 |
+
# latitudes is the seed-driven uncontrolled variable, not a
|
| 331 |
+
# raw-count amplifier.
|
| 332 |
+
- {type: e1, owner: enemy, position: [70, 12], stance: 3, count: 2}
|
| 333 |
+
- {type: e1, owner: enemy, position: [70, 28], stance: 3, count: 2}
|
| 334 |
+
- {type: e1, owner: enemy, position: [90, 12], stance: 3, count: 2}
|
| 335 |
+
- {type: e3, owner: enemy, position: [90, 14], stance: 3, count: 1}
|
| 336 |
+
- {type: e1, owner: enemy, position: [90, 28], stance: 3, count: 2}
|
| 337 |
+
- {type: e3, owner: enemy, position: [90, 26], stance: 3, count: 1}
|
| 338 |
+
- {type: e1, owner: enemy, position: [110, 12], stance: 3, count: 2}
|
| 339 |
+
- {type: e1, owner: enemy, position: [110, 28], stance: 3, count: 2}
|
| 340 |
+
# Inert enemy `fact` marker far east (anti-DRAW).
|
| 341 |
+
- {type: fact, owner: enemy, position: [120, 20]}
|
| 342 |
+
win_condition:
|
| 343 |
+
all_of:
|
| 344 |
+
- {has_building: fact}
|
| 345 |
+
- {own_units_gte: 3}
|
| 346 |
+
- {building_count_gte: {type: pbox, n: 2}}
|
| 347 |
+
- {after_ticks: 9900}
|
| 348 |
+
- {within_ticks: 10803}
|
| 349 |
+
# Fail bites the moment the survival gate opens (after_ticks:
|
| 350 |
+
# 9900) — win is evaluated FIRST so the fail only fires if the
|
| 351 |
+
# win all-of is unsatisfied at that tick (CLAUDE.md rule 3).
|
| 352 |
+
fail_condition:
|
| 353 |
+
any_of:
|
| 354 |
+
- {after_ticks: 9900}
|
| 355 |
+
- {not: {has_building: fact}}
|
| 356 |
+
- {not: {own_units_gte: 1}}
|
| 357 |
+
max_turns: 120
|
|
@@ -0,0 +1,322 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# lh-build-army-coordinate-multifront-attack.yaml
|
| 2 |
+
#
|
| 3 |
+
# REASONING capability — Group G long-horizon. Late-game two-phase
|
| 4 |
+
# operational plan: ASSEMBLE A REAL ARMY (≥N medium tanks) **THEN**
|
| 5 |
+
# COMMIT THAT ARMY TO A SIMULTANEOUS TWO-FRONT ATTACK against TWO
|
| 6 |
+
# separated enemy `fact` markers (one in the NE corner at (130,15),
|
| 7 |
+
# one in the SE corner at (130,45)). Both fact must be razed inside
|
| 8 |
+
# the shared deadline.
|
| 9 |
+
#
|
| 10 |
+
# Why `then:` (not `all_of`): the win is `then:` { army-assembled,
|
| 11 |
+
# NE-fact-razed-in-region, SE-fact-razed-in-region } so the army
|
| 12 |
+
# clause must latch IN ORDER before the two destruction clauses can
|
| 13 |
+
# count. A "send the 2 starter units now and never build" play is
|
| 14 |
+
# strictly insufficient — it cannot raze both fact in time AND it
|
| 15 |
+
# trips the army clause if both starter units get killed early
|
| 16 |
+
# (forcing assembly anyway). The two-front geometry combined with the
|
| 17 |
+
# 120-cell separation between NE and SE means a single mass that
|
| 18 |
+
# walks to ONE corner cannot then trek ~30 cells south and raze the
|
| 19 |
+
# OTHER corner inside the clock; only build-then-SPLIT wins.
|
| 20 |
+
#
|
| 21 |
+
# Real-world anchors (spec):
|
| 22 |
+
# - SC2 macro + multi-prong push (mass up tanks, then 2-prong move)
|
| 23 |
+
# - military operational planning: force build, then theater-wide
|
| 24 |
+
# assault on two simultaneous objectives
|
| 25 |
+
# - PERT theater-wide ops sequencing
|
| 26 |
+
# - industrial product launch: build inventory then multi-region
|
| 27 |
+
# rollout (you cannot ship to two regions without enough stock,
|
| 28 |
+
# and you cannot ship to one then the other inside the launch
|
| 29 |
+
# window — both regions must be supplied at once)
|
| 30 |
+
#
|
| 31 |
+
# ENGINE NOTES (per CLAUDE.md):
|
| 32 |
+
# - enemy actors don't honour spawn_point — both NE and SE enemy
|
| 33 |
+
# fact place regardless of which agent spawn group fires; this
|
| 34 |
+
# is exactly what we want (the two enemy targets are symmetric
|
| 35 |
+
# and always present; what flips per seed is the agent base
|
| 36 |
+
# latitude → which front is the "near" vs "far" of the split).
|
| 37 |
+
# - `enemy_key_buildings_destroyed_in_region` requires the
|
| 38 |
+
# destroy event RECORD to match (type, x, y, radius); we pass
|
| 39 |
+
# `radius: 8` (the predicate uses `radius`, not `r`).
|
| 40 |
+
# - tick/turn: max tick = 93 + 90·(max_turns-1). For
|
| 41 |
+
# max_turns: 80 → max tick 7203 ≥ within_ticks 7200 ✓.
|
| 42 |
+
#
|
| 43 |
+
# Validate:
|
| 44 |
+
# cd /Users/berta/Projects/OpenRA-Bench && \
|
| 45 |
+
# python3 -m openra_bench.scenarios.validate \
|
| 46 |
+
# openra_bench/scenarios/packs/lh-build-army-coordinate-multifront-attack.yaml
|
| 47 |
+
|
| 48 |
+
meta:
|
| 49 |
+
id: lh-build-army-coordinate-multifront-attack
|
| 50 |
+
title: 'Build Army → Coordinate Multi-Front Attack — Mass-Then-Split'
|
| 51 |
+
capability: reasoning
|
| 52 |
+
real_world_meaning: >
|
| 53 |
+
Late-game two-phase operational plan: first ASSEMBLE a real
|
| 54 |
+
fighting force (≥N medium tanks built from the pre-placed war
|
| 55 |
+
factory), then COMMIT that force to a simultaneous TWO-FRONT
|
| 56 |
+
assault on two separated enemy headquarters (NE and SE corners).
|
| 57 |
+
The `then:` composite enforces the assemble-before-attack ordering
|
| 58 |
+
and the 120-cell NE↔SE separation enforces the SPLIT — a single
|
| 59 |
+
mass cannot raze both corners in sequence inside the clock; only
|
| 60 |
+
build-then-split wins. Sending only the 2 starter units to each
|
| 61 |
+
front fails (insufficient firepower per prong); stalling fails on
|
| 62 |
+
the clock; one-front-only fails on the second destruction clause.
|
| 63 |
+
robotics_analogue: >
|
| 64 |
+
Theater-wide operational sequencing: a force must be brought to
|
| 65 |
+
minimum operational strength BEFORE being committed, and once
|
| 66 |
+
committed must be split across two simultaneous objectives in
|
| 67 |
+
different theatres under a single shared deadline. A serialized
|
| 68 |
+
"clear theatre A then redeploy to B" plan blows the time budget;
|
| 69 |
+
a "commit understrength to both" plan loses both engagements.
|
| 70 |
+
benchmark_anchor:
|
| 71 |
+
- "SC2 macro-then-multi-prong-attack"
|
| 72 |
+
- "military operational planning (force build then theater assault)"
|
| 73 |
+
- "PERT theater-wide ops sequencing"
|
| 74 |
+
- "industrial product launch: build inventory then multi-region rollout"
|
| 75 |
+
author: openra-bench
|
| 76 |
+
|
| 77 |
+
base_map: rush-hour-arena
|
| 78 |
+
|
| 79 |
+
base:
|
| 80 |
+
agent:
|
| 81 |
+
faction: allies
|
| 82 |
+
enemy:
|
| 83 |
+
faction: soviet
|
| 84 |
+
bot_type: ''
|
| 85 |
+
tools:
|
| 86 |
+
- observe
|
| 87 |
+
- build
|
| 88 |
+
- place_building
|
| 89 |
+
- move_units
|
| 90 |
+
- attack_unit
|
| 91 |
+
- attack_move
|
| 92 |
+
- stop
|
| 93 |
+
planning: true
|
| 94 |
+
termination:
|
| 95 |
+
max_ticks: 40000
|
| 96 |
+
|
| 97 |
+
levels:
|
| 98 |
+
# ── EASY ─────────────────────────────────────────────────────────
|
| 99 |
+
# Loose-bar rehearsal: 4-tank army threshold, no enemy pressure
|
| 100 |
+
# (bot disabled, no defenders), generous clock. The chain still
|
| 101 |
+
# bites: build to ≥4 tanks BEFORE either destruction clause counts,
|
| 102 |
+
# and BOTH NE+SE fact must be razed (not just one).
|
| 103 |
+
easy:
|
| 104 |
+
description: >
|
| 105 |
+
Pre-placed at the west base: fact + tent + powr + weap + fix +
|
| 106 |
+
two starter medium tanks (2tnk). Two enemy construction yards
|
| 107 |
+
sit at the eastern corners — NE at (130,15) and SE at (130,45),
|
| 108 |
+
120 cells apart. Two passive defenders (no bot, no aggression)
|
| 109 |
+
guard each. Build a real army first (≥4 medium tanks total),
|
| 110 |
+
THEN raze BOTH enemy fact in their corners — the NE corner AND
|
| 111 |
+
the SE corner — before tick 7200. The phases must happen IN
|
| 112 |
+
ORDER (army clause must latch FIRST). Stalling loses on the
|
| 113 |
+
clock; sending only the two starter tanks to a single corner
|
| 114 |
+
loses on the second destruction clause (the other corner stands).
|
| 115 |
+
starting_cash: 2000
|
| 116 |
+
overrides:
|
| 117 |
+
base_map: {generator: arena, width: 160, height: 60, cordon: 4}
|
| 118 |
+
actors:
|
| 119 |
+
# Agent base — fact + tent + powr + weap + fix at west. weap
|
| 120 |
+
# is PRE-PLACED so the agent only needs to BUILD tanks (no
|
| 121 |
+
# tech race; the long-horizon test is force-buildup + split-
|
| 122 |
+
# commit, not tech). `fix` is the 2tnk tech-prereq.
|
| 123 |
+
- {type: fact, owner: agent, position: [10, 30]}
|
| 124 |
+
- {type: tent, owner: agent, position: [10, 33]}
|
| 125 |
+
- {type: powr, owner: agent, position: [13, 30]}
|
| 126 |
+
- {type: weap, owner: agent, position: [13, 33]}
|
| 127 |
+
- {type: fix, owner: agent, position: [16, 33]}
|
| 128 |
+
# Two starter tanks so the agent has SOMETHING to split with
|
| 129 |
+
# if it prematurely commits (so the "send 2 each front" lazy
|
| 130 |
+
# play is observable and LOSES on insufficient firepower).
|
| 131 |
+
- {type: 2tnk, owner: agent, position: [16, 28]}
|
| 132 |
+
- {type: 2tnk, owner: agent, position: [16, 30]}
|
| 133 |
+
# Ore patches so a built proc (if the agent chooses) has
|
| 134 |
+
# somewhere to harvest — but the starting_cash:2000 + free
|
| 135 |
+
# tents + free weap is enough to build the 4-tank threshold
|
| 136 |
+
# without econ.
|
| 137 |
+
- {type: mine, owner: neutral, position: [22, 30]}
|
| 138 |
+
- {type: mine, owner: neutral, position: [22, 33]}
|
| 139 |
+
# ── NE enemy base (fact + two passive defenders) ──
|
| 140 |
+
- {type: fact, owner: enemy, position: [130, 15]}
|
| 141 |
+
- {type: e1, owner: enemy, position: [127, 15], stance: 2}
|
| 142 |
+
- {type: e1, owner: enemy, position: [127, 17], stance: 2}
|
| 143 |
+
# ── SE enemy base (fact + two passive defenders) ──
|
| 144 |
+
- {type: fact, owner: enemy, position: [130, 45]}
|
| 145 |
+
- {type: e1, owner: enemy, position: [127, 45], stance: 2}
|
| 146 |
+
- {type: e1, owner: enemy, position: [127, 43], stance: 2}
|
| 147 |
+
# Persistent unarmed enemy MARKER at the far SE corner of the
|
| 148 |
+
# 160×60 arena — keeps the episode alive past any auto-`done`
|
| 149 |
+
# if both objective fact fall (the engine sets done when all
|
| 150 |
+
# enemy MustBeDestroyed buildings are gone). Without this, a
|
| 151 |
+
# one-or-two-tank policy that incidentally razes both fact
|
| 152 |
+
# auto-ends the episode at neither win nor loss → DRAW (the
|
| 153 |
+
# CLAUDE.md auto-done footgun). Placed deep enough that no
|
| 154 |
+
# intended-play tank ever finds it (won't be attacked).
|
| 155 |
+
- {type: fact, owner: enemy, position: [155, 55]}
|
| 156 |
+
win_condition:
|
| 157 |
+
all_of:
|
| 158 |
+
- then:
|
| 159 |
+
id: army-then-multifront-easy
|
| 160 |
+
clauses:
|
| 161 |
+
- {unit_type_count_gte: {type: 2tnk, n: 4}} # P1: army
|
| 162 |
+
- enemy_key_buildings_destroyed_in_region: # P2: NE razed
|
| 163 |
+
{x: 130, y: 15, radius: 8, types: [fact]}
|
| 164 |
+
- enemy_key_buildings_destroyed_in_region: # P3: SE razed
|
| 165 |
+
{x: 130, y: 45, radius: 8, types: [fact]}
|
| 166 |
+
- within_ticks: 7200
|
| 167 |
+
fail_condition:
|
| 168 |
+
any_of:
|
| 169 |
+
- {after_ticks: 7201}
|
| 170 |
+
- {not: {building_count_gte: {type: fact, n: 1}}}
|
| 171 |
+
- {not: {own_units_gte: 1}}
|
| 172 |
+
max_turns: 80
|
| 173 |
+
|
| 174 |
+
# ── MEDIUM ───────────────────────────────────────────────────────
|
| 175 |
+
# Spec'd middle: 6-tank army threshold, same dual NE+SE objective,
|
| 176 |
+
# same clock. A 4-tank "easy" play now under-resources the split.
|
| 177 |
+
# Defenders bump slightly to ensure 1-2 units per front cannot win.
|
| 178 |
+
medium:
|
| 179 |
+
description: >
|
| 180 |
+
Pre-placed at the west base: fact + tent + powr + weap + fix +
|
| 181 |
+
two starter medium tanks (2tnk). Two enemy construction yards
|
| 182 |
+
sit at the eastern corners — NE at (130,15) and SE at (130,45),
|
| 183 |
+
120 cells apart, each guarded by a small infantry picket. Build
|
| 184 |
+
a real army first (≥6 medium tanks total), THEN raze BOTH enemy
|
| 185 |
+
fact in their corners — the NE corner AND the SE corner — before
|
| 186 |
+
tick 7200. The phases must happen IN ORDER (`then:`): the army
|
| 187 |
+
clause must latch FIRST, then the two destruction clauses can
|
| 188 |
+
count. Stalling loses on the clock; one-front-only loses on the
|
| 189 |
+
second destruction clause; sending only 2-3 tanks per front
|
| 190 |
+
bounces off the picket and burns the clock without razing.
|
| 191 |
+
# 6 tanks × 850 = $5100; 2 starter tanks free ⇒ need to fund 4
|
| 192 |
+
# tanks = $3400. Starting cash $3500 fits with no income required
|
| 193 |
+
# (the test is force-buildup + split, not also force-econ).
|
| 194 |
+
starting_cash: 3500
|
| 195 |
+
overrides:
|
| 196 |
+
base_map: {generator: arena, width: 160, height: 60, cordon: 4}
|
| 197 |
+
actors:
|
| 198 |
+
- {type: fact, owner: agent, position: [10, 30]}
|
| 199 |
+
- {type: tent, owner: agent, position: [10, 33]}
|
| 200 |
+
- {type: powr, owner: agent, position: [13, 30]}
|
| 201 |
+
- {type: weap, owner: agent, position: [13, 33]}
|
| 202 |
+
- {type: fix, owner: agent, position: [16, 33]}
|
| 203 |
+
- {type: 2tnk, owner: agent, position: [16, 28]}
|
| 204 |
+
- {type: 2tnk, owner: agent, position: [16, 30]}
|
| 205 |
+
- {type: mine, owner: neutral, position: [22, 30]}
|
| 206 |
+
- {type: mine, owner: neutral, position: [22, 33]}
|
| 207 |
+
# NE enemy base — fact + 3-unit picket (1 e3 anti-vehicle +
|
| 208 |
+
# 2 e1 riflemen) tuned so 2-3 tanks bounce / 4-6 tanks raze.
|
| 209 |
+
- {type: fact, owner: enemy, position: [130, 15]}
|
| 210 |
+
- {type: e3, owner: enemy, position: [127, 15], stance: 2}
|
| 211 |
+
- {type: e1, owner: enemy, position: [127, 17], stance: 2}
|
| 212 |
+
- {type: e1, owner: enemy, position: [128, 17], stance: 2}
|
| 213 |
+
# SE enemy base — symmetric picket.
|
| 214 |
+
- {type: fact, owner: enemy, position: [130, 45]}
|
| 215 |
+
- {type: e3, owner: enemy, position: [127, 45], stance: 2}
|
| 216 |
+
- {type: e1, owner: enemy, position: [127, 43], stance: 2}
|
| 217 |
+
- {type: e1, owner: enemy, position: [128, 43], stance: 2}
|
| 218 |
+
# Persistent unarmed enemy MARKER at the far SE corner —
|
| 219 |
+
# keeps the episode alive past auto-`done` if both objective
|
| 220 |
+
# fact fall (CLAUDE.md auto-done footgun).
|
| 221 |
+
- {type: fact, owner: enemy, position: [155, 55]}
|
| 222 |
+
win_condition:
|
| 223 |
+
all_of:
|
| 224 |
+
- then:
|
| 225 |
+
id: army-then-multifront-medium
|
| 226 |
+
clauses:
|
| 227 |
+
- {unit_type_count_gte: {type: 2tnk, n: 6}}
|
| 228 |
+
- enemy_key_buildings_destroyed_in_region:
|
| 229 |
+
{x: 130, y: 15, radius: 8, types: [fact]}
|
| 230 |
+
- enemy_key_buildings_destroyed_in_region:
|
| 231 |
+
{x: 130, y: 45, radius: 8, types: [fact]}
|
| 232 |
+
- within_ticks: 7200
|
| 233 |
+
fail_condition:
|
| 234 |
+
any_of:
|
| 235 |
+
- {after_ticks: 7201}
|
| 236 |
+
- {not: {building_count_gte: {type: fact, n: 1}}}
|
| 237 |
+
- {not: {own_units_gte: 1}}
|
| 238 |
+
max_turns: 80
|
| 239 |
+
|
| 240 |
+
# ── HARD ─────────────────────────────────────────────────────────
|
| 241 |
+
# +1 axis: 8-tank army threshold + TWO agent spawn_point groups.
|
| 242 |
+
# The agent base latitude round-robins between NORTH (y=18) and
|
| 243 |
+
# SOUTH (y=42) per seed; both objectives (NE 130,15 + SE 130,45)
|
| 244 |
+
# are always present, so the "near" vs "far" front flips per seed
|
| 245 |
+
# but the split-and-raze-both task is invariant. A memorised
|
| 246 |
+
# "north-prong heavy" or "always commit south first" opening
|
| 247 |
+
# cannot generalise across seeds.
|
| 248 |
+
hard:
|
| 249 |
+
description: >
|
| 250 |
+
Pre-placed at the west base (latitude round-robined between
|
| 251 |
+
NORTH y=18 and SOUTH y=42 per seed): fact + tent + powr + weap
|
| 252 |
+
+ fix + two starter medium tanks (2tnk). Two enemy construction
|
| 253 |
+
yards sit at the eastern corners — NE at (130,15) and SE at
|
| 254 |
+
(130,45), 120 cells apart, each guarded by a small infantry
|
| 255 |
+
picket. Build a real army first (≥8 medium tanks total), THEN
|
| 256 |
+
raze BOTH enemy fact in their corners — the NE corner AND the
|
| 257 |
+
SE corner — before tick 7200. The phases must happen IN ORDER
|
| 258 |
+
(`then:`). Stalling, one-front-only, or sending 2-3 tanks per
|
| 259 |
+
front all LOSE. The seed-varied base latitude means a single
|
| 260 |
+
memorised opening cannot generalise across seeds (the "near"
|
| 261 |
+
front flips between NE and SE).
|
| 262 |
+
# 8 tanks × 850 = $6800; 2 starter tanks free ⇒ need $5100 for
|
| 263 |
+
# 6 more. Starting cash $5500 fits with no income required.
|
| 264 |
+
starting_cash: 5500
|
| 265 |
+
overrides:
|
| 266 |
+
base_map: {generator: arena, width: 160, height: 60, cordon: 4}
|
| 267 |
+
actors:
|
| 268 |
+
# NORTH base spawn (spawn_point 0) — y=18; NE (130,15) is
|
| 269 |
+
# the "near" front, SE (130,45) is the "far" one.
|
| 270 |
+
- {type: fact, owner: agent, position: [10, 18], spawn_point: 0}
|
| 271 |
+
- {type: tent, owner: agent, position: [10, 21], spawn_point: 0}
|
| 272 |
+
- {type: powr, owner: agent, position: [13, 18], spawn_point: 0}
|
| 273 |
+
- {type: weap, owner: agent, position: [13, 21], spawn_point: 0}
|
| 274 |
+
- {type: fix, owner: agent, position: [16, 21], spawn_point: 0}
|
| 275 |
+
- {type: 2tnk, owner: agent, position: [16, 16], spawn_point: 0}
|
| 276 |
+
- {type: 2tnk, owner: agent, position: [16, 18], spawn_point: 0}
|
| 277 |
+
# SOUTH base spawn (spawn_point 1) — y=42; SE is "near", NE
|
| 278 |
+
# is "far". Geometry flips so the per-prong assignment must
|
| 279 |
+
# be re-computed per seed.
|
| 280 |
+
- {type: fact, owner: agent, position: [10, 42], spawn_point: 1}
|
| 281 |
+
- {type: tent, owner: agent, position: [10, 39], spawn_point: 1}
|
| 282 |
+
- {type: powr, owner: agent, position: [13, 42], spawn_point: 1}
|
| 283 |
+
- {type: weap, owner: agent, position: [13, 39], spawn_point: 1}
|
| 284 |
+
- {type: fix, owner: agent, position: [16, 39], spawn_point: 1}
|
| 285 |
+
- {type: 2tnk, owner: agent, position: [16, 44], spawn_point: 1}
|
| 286 |
+
- {type: 2tnk, owner: agent, position: [16, 42], spawn_point: 1}
|
| 287 |
+
# Shared mid-map ore patches (no spawn_point → place for both).
|
| 288 |
+
# NOTE engine: enemy actors ignore spawn_point so the two
|
| 289 |
+
# enemy bases below ALWAYS place regardless of agent spawn.
|
| 290 |
+
- {type: mine, owner: neutral, position: [22, 30]}
|
| 291 |
+
- {type: mine, owner: neutral, position: [22, 33]}
|
| 292 |
+
# NE enemy base — fact + picket.
|
| 293 |
+
- {type: fact, owner: enemy, position: [130, 15]}
|
| 294 |
+
- {type: e3, owner: enemy, position: [127, 15], stance: 2}
|
| 295 |
+
- {type: e1, owner: enemy, position: [127, 17], stance: 2}
|
| 296 |
+
- {type: e1, owner: enemy, position: [128, 17], stance: 2}
|
| 297 |
+
# SE enemy base — symmetric picket.
|
| 298 |
+
- {type: fact, owner: enemy, position: [130, 45]}
|
| 299 |
+
- {type: e3, owner: enemy, position: [127, 45], stance: 2}
|
| 300 |
+
- {type: e1, owner: enemy, position: [127, 43], stance: 2}
|
| 301 |
+
- {type: e1, owner: enemy, position: [128, 43], stance: 2}
|
| 302 |
+
# Persistent unarmed enemy MARKER at the far SE corner —
|
| 303 |
+
# keeps the episode alive past auto-`done` if both objective
|
| 304 |
+
# fact fall (CLAUDE.md auto-done footgun).
|
| 305 |
+
- {type: fact, owner: enemy, position: [155, 55]}
|
| 306 |
+
win_condition:
|
| 307 |
+
all_of:
|
| 308 |
+
- then:
|
| 309 |
+
id: army-then-multifront-hard
|
| 310 |
+
clauses:
|
| 311 |
+
- {unit_type_count_gte: {type: 2tnk, n: 8}}
|
| 312 |
+
- enemy_key_buildings_destroyed_in_region:
|
| 313 |
+
{x: 130, y: 15, radius: 8, types: [fact]}
|
| 314 |
+
- enemy_key_buildings_destroyed_in_region:
|
| 315 |
+
{x: 130, y: 45, radius: 8, types: [fact]}
|
| 316 |
+
- within_ticks: 7200
|
| 317 |
+
fail_condition:
|
| 318 |
+
any_of:
|
| 319 |
+
- {after_ticks: 7201}
|
| 320 |
+
- {not: {building_count_gte: {type: fact, n: 1}}}
|
| 321 |
+
- {not: {own_units_gte: 1}}
|
| 322 |
+
max_turns: 80
|
|
@@ -0,0 +1,370 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# lh-defense-tech-second-base.yaml
|
| 2 |
+
#
|
| 3 |
+
# REASONING capability — Group G long-horizon "secure-expand-with-tech"
|
| 4 |
+
# 3-phase chain. The agent must (1) harden base #1 with pillboxes,
|
| 5 |
+
# (2) tech up to a war factory, then (3) drive the spare MCV east and
|
| 6 |
+
# deploy it as a second Construction Yard inside the briefed target
|
| 7 |
+
# region.
|
| 8 |
+
#
|
| 9 |
+
# Uses the Wave-2 `then:` happened-before composite to ENFORCE STRICT
|
| 10 |
+
# ORDER:
|
| 11 |
+
# PHASE 1: building_count_gte:{type:pbox, n:3} # defend
|
| 12 |
+
# PHASE 2: has_building: weap # tech
|
| 13 |
+
# PHASE 3: building_in_region:{x:130,y:30,r:8,
|
| 14 |
+
# type:fact,count:1} # 2nd base
|
| 15 |
+
# Per the `then` semantics each clause must be observed-true IN
|
| 16 |
+
# SEQUENCE — pre-expanding before tech, or pre-teching before defence,
|
| 17 |
+
# cannot satisfy the chain.
|
| 18 |
+
#
|
| 19 |
+
# A `patrol` scripted bot drips light, predictable harassment on
|
| 20 |
+
# base #1 throughout the run — pure-expand (skip defences) loses the
|
| 21 |
+
# base to the patrol; pure-defence (no MCV move + no weap) leaves the
|
| 22 |
+
# chain stuck at phase 1 or 2; only the intended secure-expand-with-
|
| 23 |
+
# tech sequence WINS.
|
| 24 |
+
#
|
| 25 |
+
# Sister packs:
|
| 26 |
+
# - `lh-opening-to-tech-to-army` (Wave-4 macro chain, 4 phases —
|
| 27 |
+
# proc → weap → ≥N 2tnk → attack enemy fact)
|
| 28 |
+
# - `tech-turtle-defensive-tech` (defence-then-tech with hunt-bot
|
| 29 |
+
# pressure)
|
| 30 |
+
# This pack is the secure-expand-with-tech analogue: the terminal
|
| 31 |
+
# clause is a SECOND BASE in a far region (multi-site ramp-up), not
|
| 32 |
+
# a destroy clause.
|
| 33 |
+
#
|
| 34 |
+
# Real-world anchors (`meta.benchmark_anchor`):
|
| 35 |
+
# - SC2 secure-expand-with-tech (multi-base macro): harden home
|
| 36 |
+
# base, tech up, expand to a second base.
|
| 37 |
+
# - PlanBench multi-stage credit assignment.
|
| 38 |
+
# - Product roadmap: harden (perimeter/SLO) → R&D (tech upgrade)
|
| 39 |
+
# → new geography (second region).
|
| 40 |
+
# - Industrial: secure plant → R&D → build second plant.
|
| 41 |
+
#
|
| 42 |
+
# DISCRIMINATIONS (CLAUDE.md bar — no defect, no cheat):
|
| 43 |
+
# - stall (observe only): LOSS — clock + patrol razes fact.
|
| 44 |
+
# - pure-expand-skip-defence (move MCV east + deploy, skip pbox /
|
| 45 |
+
# weap): LOSS — chain stuck at phase 1 (pbox never built); the
|
| 46 |
+
# patrol also chews the base.
|
| 47 |
+
# - pure-defence-no-expand (build pbox+weap, never move MCV): LOSS —
|
| 48 |
+
# chain stuck at phase 3 (no 2nd fact in east region); clock
|
| 49 |
+
# expires.
|
| 50 |
+
# - intended secure-expand-with-tech (pbox×3, weap, then move MCV
|
| 51 |
+
# east + deploy): WIN.
|
| 52 |
+
#
|
| 53 |
+
# Engine facts used (CLAUDE.md / SCENARIO_QUALITY.md):
|
| 54 |
+
# - `pbox` cost 600, prereq `tent` (allied barracks pre-placed),
|
| 55 |
+
# defence queue.
|
| 56 |
+
# - `weap` cost ~2000, prereq `proc` (allied refinery — pre-placed
|
| 57 |
+
# so the queue accepts weap from turn 1 once cash is available).
|
| 58 |
+
# - MCV deploy: `Command.deploy([mcv_id])` removes the MCV and
|
| 59 |
+
# spawns an agent `fact` at (mcv_x-1, mcv_y-1). To land the new
|
| 60 |
+
# fact at e.g. (130,30) — the centre of the east target region —
|
| 61 |
+
# the MCV must reach (131,31) before deploying.
|
| 62 |
+
# - `patrol` scripted bot: hunts outward in alternating patrol
|
| 63 |
+
# legs from its spawn; light, predictable home-base pressure
|
| 64 |
+
# (per `openra-sim/src/scripted_bot.rs`).
|
| 65 |
+
# - Inert enemy `fact` marker far east prevents engine auto-DRAW
|
| 66 |
+
# on enemy-elim (CLAUDE.md auto-done footgun).
|
| 67 |
+
# - `then:` greedy advance: once a prior clause latches, any
|
| 68 |
+
# currently-true later clauses latch in the same evaluation.
|
| 69 |
+
#
|
| 70 |
+
# Validate (no model / no network):
|
| 71 |
+
# python3 -m pytest tests/test_lh_defense_tech_second_base.py -q
|
| 72 |
+
|
| 73 |
+
meta:
|
| 74 |
+
id: lh-defense-tech-second-base
|
| 75 |
+
title: 'Secure → Tech → Second Base — 3-Phase Long-Horizon Chain'
|
| 76 |
+
capability: reasoning
|
| 77 |
+
real_world_meaning: >
|
| 78 |
+
A long single-episode macro plan whose final win depends on each
|
| 79 |
+
intermediate phase being observed-true in order: harden the home
|
| 80 |
+
base with static defences (pillboxes) under live patrol pressure,
|
| 81 |
+
unlock the tech tree (war factory), then commit the spare MCV
|
| 82 |
+
eastward to stand up a second Construction Yard inside the
|
| 83 |
+
briefed expansion region. The Wave-2 `then:` composite enforces
|
| 84 |
+
strict happened-before — a policy that expands before tech, or
|
| 85 |
+
teches before defences, cannot satisfy the chain.
|
| 86 |
+
robotics_analogue: >
|
| 87 |
+
Multi-site ramp-up of a long-horizon mission with strict stage
|
| 88 |
+
gating: harden the home site against active adversarial pressure,
|
| 89 |
+
bring the upgraded production capability online, then deploy a
|
| 90 |
+
base-builder platform to the designated expansion sector and
|
| 91 |
+
convert it into a second operational site — all within one
|
| 92 |
+
budget. Out-of-order completion (expand before hardening, or
|
| 93 |
+
expand before the upgraded capability is online) forfeits the
|
| 94 |
+
chain.
|
| 95 |
+
benchmark_anchor:
|
| 96 |
+
- "SC2 secure-expand-with-tech (multi-base macro)"
|
| 97 |
+
- "PlanBench multi-stage credit assignment"
|
| 98 |
+
- "product roadmap: harden → R&D → new market"
|
| 99 |
+
- "industrial: secure plant → R&D → second plant"
|
| 100 |
+
author: openra-bench
|
| 101 |
+
|
| 102 |
+
# Pack-level base_map is the `rush-hour-arena` sentinel so the loader
|
| 103 |
+
# can map-resolve before per-level overrides materialise the actual
|
| 104 |
+
# 160x60 generator-spec arena .oramap (same pattern as
|
| 105 |
+
# mfb-base-1-defend-base-2-build, expansion-balanced-2-base-defended).
|
| 106 |
+
base_map: rush-hour-arena
|
| 107 |
+
|
| 108 |
+
starting_cash: 3000
|
| 109 |
+
|
| 110 |
+
base:
|
| 111 |
+
agent:
|
| 112 |
+
faction: allies
|
| 113 |
+
enemy:
|
| 114 |
+
faction: soviet
|
| 115 |
+
bot_type: patrol
|
| 116 |
+
tools:
|
| 117 |
+
- observe
|
| 118 |
+
- build
|
| 119 |
+
- place_building
|
| 120 |
+
- deploy
|
| 121 |
+
- harvest
|
| 122 |
+
- move_units
|
| 123 |
+
- attack_unit
|
| 124 |
+
- attack_move
|
| 125 |
+
- stop
|
| 126 |
+
planning: true
|
| 127 |
+
termination:
|
| 128 |
+
max_ticks: 40000
|
| 129 |
+
# Each level supplies its own actor list via overrides — keeps the
|
| 130 |
+
# per-tier patrol density / spawn variation audit-explicit.
|
| 131 |
+
actors: []
|
| 132 |
+
|
| 133 |
+
levels:
|
| 134 |
+
# ── EASY ─────────────────────────────────────────────────────────
|
| 135 |
+
# Bare-skill rehearsal: looser bar (pbox≥2 instead of ≥3), a single
|
| 136 |
+
# light patrol band, generous clock. The chain still bites
|
| 137 |
+
# (pbox-first, weap-then, 2nd-base-finally) — pure-expand, pure-
|
| 138 |
+
# defence, stall all LOSE; intended secure-expand-with-tech WINS.
|
| 139 |
+
# max_turns 90 → reachable tick 93 + 90·89 = 8103 > 8000 ✓.
|
| 140 |
+
easy:
|
| 141 |
+
description: >
|
| 142 |
+
You hold base #1 in the WEST — a Construction Yard (fact),
|
| 143 |
+
barracks (tent), power plants (powr ×2), refinery (proc), a
|
| 144 |
+
harvester pre-kicked on the local ore patch, and 3 rifleman
|
| 145 |
+
defenders on AttackAnything. A spare MCV is staged east of
|
| 146 |
+
base #1 (~(40,30)) for your eventual expansion. A light Soviet
|
| 147 |
+
`patrol` band drips harassment on base #1. The mission is a
|
| 148 |
+
strict 3-phase chain (you must complete them IN THIS ORDER):
|
| 149 |
+
(1) build at least 2 pillboxes (pbox) at the home base,
|
| 150 |
+
(2) build a war factory (weap), (3) drive the spare MCV east
|
| 151 |
+
to the target region centred at (130,30) (radius 8) and
|
| 152 |
+
DEPLOY it there to stand up the SECOND Construction Yard.
|
| 153 |
+
Win = the 3-phase chain completed AND base #1 still standing
|
| 154 |
+
(fact present) before tick 8000. Stalling, skipping defences,
|
| 155 |
+
and never expanding all LOSE.
|
| 156 |
+
starting_cash: 3500
|
| 157 |
+
overrides:
|
| 158 |
+
base_map:
|
| 159 |
+
generator: arena
|
| 160 |
+
name: lh-defense-tech-second-base-arena
|
| 161 |
+
width: 160
|
| 162 |
+
height: 60
|
| 163 |
+
cordon: 4
|
| 164 |
+
actors:
|
| 165 |
+
# Agent base #1 (WEST). fact / tent / powr per spec, PLUS
|
| 166 |
+
# proc + harv + mine so income flows from turn 1 (the chain
|
| 167 |
+
# needs ~3800 cash on top of the cushion). Without harv
|
| 168 |
+
# income the bare starting_cash cannot reliably fund the
|
| 169 |
+
# pbox×2 + weap + MCV-fuel chain in budget.
|
| 170 |
+
- {type: fact, owner: agent, position: [10, 30]}
|
| 171 |
+
- {type: tent, owner: agent, position: [8, 28]}
|
| 172 |
+
- {type: powr, owner: agent, position: [6, 30]}
|
| 173 |
+
# Second powr — 2 pbox (-40) + weap (-30) drains ~70 power;
|
| 174 |
+
# one powr (+100 default) just covers it, but a second powr
|
| 175 |
+
# protects against the patrol shaving the first one (CLAUDE.md
|
| 176 |
+
# underpowered-queue footgun).
|
| 177 |
+
- {type: powr, owner: agent, position: [6, 32]}
|
| 178 |
+
- {type: proc, owner: agent, position: [6, 26]}
|
| 179 |
+
- {type: harv, owner: agent, position: [8, 32]}
|
| 180 |
+
- {type: mine, owner: neutral, position: [2, 30]}
|
| 181 |
+
# 3 rifleman defenders on AttackAnything at the EAST lane
|
| 182 |
+
# mouth to blunt the patrol's leading edge while pbox stand
|
| 183 |
+
# up.
|
| 184 |
+
- {type: e1, owner: agent, position: [16, 30], stance: 3, count: 3}
|
| 185 |
+
# Spare MCV staged ~30 cells east of base #1. To win, the
|
| 186 |
+
# agent must drive it ~90 cells further east and deploy at
|
| 187 |
+
# (131,31) → fact lands at (130,30) — exactly the target
|
| 188 |
+
# region centre.
|
| 189 |
+
- {type: mcv, owner: agent, position: [40, 30]}
|
| 190 |
+
# Light Soviet patrol band — easy: 2× e1 starting mid-map
|
| 191 |
+
# so the patrol bot drips harassment on base #1 (`patrol`
|
| 192 |
+
# bot alternates patrol legs outward from spawn; the
|
| 193 |
+
# nearest agent unit / structure attracts pressure).
|
| 194 |
+
- {type: e1, owner: enemy, position: [80, 30], stance: 3, count: 2}
|
| 195 |
+
# Persistent unarmed enemy `fact` marker far east — outside
|
| 196 |
+
# both the target region (radius 8 around (130,30)) and the
|
| 197 |
+
# patrol band, so it doesn't satisfy any agent predicate
|
| 198 |
+
# and doesn't perturb the patrol behaviour. Prevents engine
|
| 199 |
+
# auto-DRAW on enemy-elim (the patrol band could otherwise
|
| 200 |
+
# be killed and end the episode prematurely).
|
| 201 |
+
- {type: fact, owner: enemy, position: [154, 4]}
|
| 202 |
+
win_condition:
|
| 203 |
+
all_of:
|
| 204 |
+
- then:
|
| 205 |
+
id: defense-tech-second-base-easy
|
| 206 |
+
clauses:
|
| 207 |
+
- {building_count_gte: {type: pbox, n: 2}}
|
| 208 |
+
- {has_building: weap}
|
| 209 |
+
- {building_in_region:
|
| 210 |
+
{x: 130, y: 30, radius: 8, type: fact, count: 1}}
|
| 211 |
+
- {has_building: fact}
|
| 212 |
+
- {within_ticks: 8000}
|
| 213 |
+
fail_condition:
|
| 214 |
+
any_of:
|
| 215 |
+
- {after_ticks: 8001}
|
| 216 |
+
- {not: {has_building: fact}}
|
| 217 |
+
max_turns: 90
|
| 218 |
+
|
| 219 |
+
# ── MEDIUM ───────────────────────────────────────────────────────
|
| 220 |
+
# Full 3-phase chain at the spec bar (pbox≥3) under a slightly
|
| 221 |
+
# heavier patrol band (3× e1 + 1× e3) on a tighter clock.
|
| 222 |
+
# max_turns 80 → reachable tick 93 + 90·79 = 7203 > 7200 ✓.
|
| 223 |
+
medium:
|
| 224 |
+
description: >
|
| 225 |
+
Same secure-expand-with-tech setup, tighter clock and heavier
|
| 226 |
+
patrol pressure. You hold base #1 in the WEST (fact, tent,
|
| 227 |
+
proc, harv, 2× powr, 3 defenders). A spare MCV is staged east
|
| 228 |
+
of base #1. A Soviet `patrol` band — 3 rifle infantry + 1
|
| 229 |
+
rocket soldier (e3) — drips pressure on base #1. The mission
|
| 230 |
+
is a strict 3-phase chain (IN THIS ORDER):
|
| 231 |
+
(1) at least 3 pillboxes (pbox) at base #1,
|
| 232 |
+
(2) a war factory (weap),
|
| 233 |
+
(3) the spare MCV deployed inside the eastern target region
|
| 234 |
+
centred at (130,30), radius 8 — the new fact lands at
|
| 235 |
+
(mcv_x-1, mcv_y-1), so drive the MCV to ~(131,31) and deploy.
|
| 236 |
+
Win = the chain completed AND base #1 still standing before
|
| 237 |
+
tick 7200. Stall, pure-expand-skip-defences, and pure-defence-
|
| 238 |
+
no-expand all LOSE.
|
| 239 |
+
starting_cash: 3000
|
| 240 |
+
overrides:
|
| 241 |
+
base_map:
|
| 242 |
+
generator: arena
|
| 243 |
+
name: lh-defense-tech-second-base-arena
|
| 244 |
+
width: 160
|
| 245 |
+
height: 60
|
| 246 |
+
cordon: 4
|
| 247 |
+
actors:
|
| 248 |
+
- {type: fact, owner: agent, position: [10, 30]}
|
| 249 |
+
- {type: tent, owner: agent, position: [8, 28]}
|
| 250 |
+
- {type: powr, owner: agent, position: [6, 30]}
|
| 251 |
+
- {type: powr, owner: agent, position: [6, 32]}
|
| 252 |
+
- {type: proc, owner: agent, position: [6, 26]}
|
| 253 |
+
- {type: harv, owner: agent, position: [8, 32]}
|
| 254 |
+
- {type: mine, owner: neutral, position: [2, 30]}
|
| 255 |
+
- {type: e1, owner: agent, position: [16, 30], stance: 3, count: 3}
|
| 256 |
+
- {type: mcv, owner: agent, position: [40, 30]}
|
| 257 |
+
# Heavier patrol band — 3 e1 + 1 e3 (rocket soldier adds
|
| 258 |
+
# anti-armour pressure if the MCV is exposed en route).
|
| 259 |
+
- {type: e1, owner: enemy, position: [80, 30], stance: 3, count: 3}
|
| 260 |
+
- {type: e3, owner: enemy, position: [82, 30], stance: 3, count: 1}
|
| 261 |
+
# Persistent unarmed enemy `fact` marker far east (anti-DRAW).
|
| 262 |
+
- {type: fact, owner: enemy, position: [154, 4]}
|
| 263 |
+
win_condition:
|
| 264 |
+
all_of:
|
| 265 |
+
- then:
|
| 266 |
+
id: defense-tech-second-base-medium
|
| 267 |
+
clauses:
|
| 268 |
+
- {building_count_gte: {type: pbox, n: 3}}
|
| 269 |
+
- {has_building: weap}
|
| 270 |
+
- {building_in_region:
|
| 271 |
+
{x: 130, y: 30, radius: 8, type: fact, count: 1}}
|
| 272 |
+
- {has_building: fact}
|
| 273 |
+
- {within_ticks: 7200}
|
| 274 |
+
fail_condition:
|
| 275 |
+
any_of:
|
| 276 |
+
- {after_ticks: 7201}
|
| 277 |
+
- {not: {has_building: fact}}
|
| 278 |
+
max_turns: 80
|
| 279 |
+
|
| 280 |
+
# ── HARD ─────────────────────────────────────────────────────────
|
| 281 |
+
# +1 axis vs medium: a FOURTH phase (rebuild the proc — the
|
| 282 |
+
# secure-expand-with-tech chain extended to a "harden → tech →
|
| 283 |
+
# expand → re-secure income" sequence) AND TWO patrol bands hitting
|
| 284 |
+
# from TWO latitudes (north + south), AND ≥2 spawn_point groups so
|
| 285 |
+
# the agent base latitude varies by seed.
|
| 286 |
+
#
|
| 287 |
+
# The fourth chain clause is `building_count_gte: {type: proc, n: 2}`
|
| 288 |
+
# — the agent must build a SECOND refinery at the new eastern base
|
| 289 |
+
# to stabilise income on the expansion (a real "second plant comes
|
| 290 |
+
# online" semantic, not just a re-place of the home refinery).
|
| 291 |
+
#
|
| 292 |
+
# max_turns 80 → reachable tick 93 + 90·79 = 7203 > 7200 ✓.
|
| 293 |
+
hard:
|
| 294 |
+
description: >
|
| 295 |
+
Same secure-expand-with-tech mission under richer pressure and
|
| 296 |
+
with a fourth phase tacked on. Your base #1 stages from a
|
| 297 |
+
seed-chosen latitude (NORTH y=22 or SOUTH y=38) so a single
|
| 298 |
+
memorised pillbox placement cannot generalise. Two Soviet
|
| 299 |
+
`patrol` bands hit from two different latitudes (north and
|
| 300 |
+
south). The mission is a strict 4-phase chain (IN THIS ORDER):
|
| 301 |
+
(1) at least 3 pillboxes (pbox) at base #1,
|
| 302 |
+
(2) a war factory (weap),
|
| 303 |
+
(3) the spare MCV deployed inside the eastern target region
|
| 304 |
+
centred at (130,30), radius 8,
|
| 305 |
+
(4) at least 2 refineries (proc) own — i.e. a SECOND refinery
|
| 306 |
+
online to stabilise income on the new expansion.
|
| 307 |
+
Win = the chain completed AND base #1 still standing before
|
| 308 |
+
tick 7200. Stall, pure-expand, pure-defence, and skip-proc-2
|
| 309 |
+
all LOSE.
|
| 310 |
+
starting_cash: 3500
|
| 311 |
+
overrides:
|
| 312 |
+
base_map:
|
| 313 |
+
generator: arena
|
| 314 |
+
name: lh-defense-tech-second-base-arena
|
| 315 |
+
width: 160
|
| 316 |
+
height: 60
|
| 317 |
+
cordon: 4
|
| 318 |
+
actors:
|
| 319 |
+
# NORTH base spawn (spawn_point 0). Layout mirrors easy/
|
| 320 |
+
# medium at y=22.
|
| 321 |
+
- {type: fact, owner: agent, position: [10, 22], spawn_point: 0}
|
| 322 |
+
- {type: tent, owner: agent, position: [8, 20], spawn_point: 0}
|
| 323 |
+
- {type: powr, owner: agent, position: [6, 22], spawn_point: 0}
|
| 324 |
+
- {type: powr, owner: agent, position: [6, 24], spawn_point: 0}
|
| 325 |
+
- {type: proc, owner: agent, position: [6, 18], spawn_point: 0}
|
| 326 |
+
- {type: harv, owner: agent, position: [8, 24], spawn_point: 0}
|
| 327 |
+
- {type: e1, owner: agent, position: [16, 22], stance: 3, count: 3, spawn_point: 0}
|
| 328 |
+
- {type: mcv, owner: agent, position: [40, 22], spawn_point: 0}
|
| 329 |
+
# SOUTH base spawn (spawn_point 1). Symmetric at y=38.
|
| 330 |
+
- {type: fact, owner: agent, position: [10, 38], spawn_point: 1}
|
| 331 |
+
- {type: tent, owner: agent, position: [8, 40], spawn_point: 1}
|
| 332 |
+
- {type: powr, owner: agent, position: [6, 38], spawn_point: 1}
|
| 333 |
+
- {type: powr, owner: agent, position: [6, 36], spawn_point: 1}
|
| 334 |
+
- {type: proc, owner: agent, position: [6, 42], spawn_point: 1}
|
| 335 |
+
- {type: harv, owner: agent, position: [8, 36], spawn_point: 1}
|
| 336 |
+
- {type: e1, owner: agent, position: [16, 38], stance: 3, count: 3, spawn_point: 1}
|
| 337 |
+
- {type: mcv, owner: agent, position: [40, 38], spawn_point: 1}
|
| 338 |
+
# Two ore patches — one per spawn cluster — placed west of
|
| 339 |
+
# each base so the harv loop doesn't cross the patrol lane.
|
| 340 |
+
# (Neutral actors are not filtered by spawn_point — both
|
| 341 |
+
# always place, per CLAUDE.md.)
|
| 342 |
+
- {type: mine, owner: neutral, position: [2, 22]}
|
| 343 |
+
- {type: mine, owner: neutral, position: [2, 38]}
|
| 344 |
+
# Two patrol bands at distinct latitudes (both ALWAYS placed
|
| 345 |
+
# — enemy actors with no spawn_point are not filtered, per
|
| 346 |
+
# CLAUDE.md). Whichever lane the agent's base occupies is
|
| 347 |
+
# hit; the OTHER band wanders the off-axis half of the map.
|
| 348 |
+
- {type: e1, owner: enemy, position: [80, 22], stance: 3, count: 2}
|
| 349 |
+
- {type: e3, owner: enemy, position: [82, 22], stance: 3, count: 1}
|
| 350 |
+
- {type: e1, owner: enemy, position: [80, 38], stance: 3, count: 2}
|
| 351 |
+
- {type: e3, owner: enemy, position: [82, 38], stance: 3, count: 1}
|
| 352 |
+
# Persistent unarmed enemy `fact` marker far east (anti-DRAW).
|
| 353 |
+
- {type: fact, owner: enemy, position: [154, 4]}
|
| 354 |
+
win_condition:
|
| 355 |
+
all_of:
|
| 356 |
+
- then:
|
| 357 |
+
id: defense-tech-second-base-hard
|
| 358 |
+
clauses:
|
| 359 |
+
- {building_count_gte: {type: pbox, n: 3}}
|
| 360 |
+
- {has_building: weap}
|
| 361 |
+
- {building_in_region:
|
| 362 |
+
{x: 130, y: 30, radius: 8, type: fact, count: 1}}
|
| 363 |
+
- {building_count_gte: {type: proc, n: 2}}
|
| 364 |
+
- {has_building: fact}
|
| 365 |
+
- {within_ticks: 7200}
|
| 366 |
+
fail_condition:
|
| 367 |
+
any_of:
|
| 368 |
+
- {after_ticks: 7201}
|
| 369 |
+
- {not: {has_building: fact}}
|
| 370 |
+
max_turns: 80
|
|
@@ -0,0 +1,324 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# lh-multi-checkpoint-5-plus.yaml
|
| 2 |
+
#
|
| 3 |
+
# ACTION capability — Group G long-horizon. The Late-tier sibling of
|
| 4 |
+
# `lh-opening-to-tech-to-army` (4-phase chain) that goes one further:
|
| 5 |
+
# a Wave-2 `then:` happened-before chain of FIVE+ ordered checkpoints
|
| 6 |
+
# in a single episode. This is the PERT critical-path / PlanBench
|
| 7 |
+
# long-sequencing anchor — a "5+ ordered checkpoints" test that the
|
| 8 |
+
# 4-phase pack cannot express on its own.
|
| 9 |
+
#
|
| 10 |
+
# The chain genuinely needs order because it MIXES checkpoint kinds:
|
| 11 |
+
# - building states (has_building: proc, weap)
|
| 12 |
+
# - quantitative production (unit_type_count_gte: 2tnk)
|
| 13 |
+
# - episode-aggregate combat (units_killed_gte: 3)
|
| 14 |
+
# - terminal destruction (enemy_key_buildings_destroyed: fact)
|
| 15 |
+
# A policy that scores kills BEFORE the army exists cannot satisfy
|
| 16 |
+
# clause-4 BEFORE clause-3; the `then:` latch advances index-by-index
|
| 17 |
+
# only, so out-of-order completion stalls forever.
|
| 18 |
+
#
|
| 19 |
+
# Sister pack to `lh-opening-to-tech-to-army` (4-phase econ→tech→army→
|
| 20 |
+
# engage) — this pack tightens the same idiom by stretching it to
|
| 21 |
+
# 5 (medium) / 6 (hard) checkpoints, which is the regime that
|
| 22 |
+
# discriminates models on long ordered SOP execution / PERT critical
|
| 23 |
+
# path planning. Easy reverts to 3 phases (the baseline) so the
|
| 24 |
+
# difficulty axis is "phase count" rather than "phase kind".
|
| 25 |
+
#
|
| 26 |
+
# Real-world anchors:
|
| 27 |
+
# - PERT/CPM project management (each milestone gates the next)
|
| 28 |
+
# - PlanBench long-sequencing (5+ step ordered plans)
|
| 29 |
+
# - long SOP execution (5+ phase ordered procedures)
|
| 30 |
+
# - military operation: phase lines (PL ALPHA → ... → PL FOXTROT)
|
| 31 |
+
#
|
| 32 |
+
# ENGINE NOTES (per CLAUDE.md):
|
| 33 |
+
# - `then:` advances clause-by-clause; a satisfied earlier clause
|
| 34 |
+
# stays latched even if its predicate later becomes false (e.g.,
|
| 35 |
+
# proc gets destroyed) — only the order matters.
|
| 36 |
+
# - The agent has 4 pre-placed combat units; phase-4 (3 kills) is
|
| 37 |
+
# satisfied by attacking the static defenders next to the enemy
|
| 38 |
+
# fact. The defenders are HOLD-FIRE (stance:0) so a "rush the
|
| 39 |
+
# fact and kill 3" play CAN score kills early — but the `then:`
|
| 40 |
+
# latch will not credit those kills towards clause-4 until clauses
|
| 41 |
+
# 1-3 (proc → weap → 2 tanks) have latched in order.
|
| 42 |
+
# - `bot_type: ''` (no scripted bot) — the enemy is static so the
|
| 43 |
+
# anti-stall pressure comes purely from the `within_ticks: 7200`
|
| 44 |
+
# clock, not from incoming raiders.
|
| 45 |
+
# - `harvest` is intentionally omitted from tools; the auto-harvester
|
| 46 |
+
# spawned by `proc` near the ore patch covers income without the
|
| 47 |
+
# agent needing to micro-manage it. This keeps the tool surface
|
| 48 |
+
# focused on the action / sequencing axis.
|
| 49 |
+
#
|
| 50 |
+
# Validate:
|
| 51 |
+
# cd /Users/berta/Projects/OpenRA-Bench && \
|
| 52 |
+
# python3 -m openra_bench.scenarios.validate \
|
| 53 |
+
# openra_bench/scenarios/packs/lh-multi-checkpoint-5-plus.yaml
|
| 54 |
+
|
| 55 |
+
meta:
|
| 56 |
+
id: lh-multi-checkpoint-5-plus
|
| 57 |
+
title: 'Multi-Checkpoint 5+ — Strict Long-Sequencing Chain (PERT / PlanBench)'
|
| 58 |
+
capability: action
|
| 59 |
+
real_world_meaning: >
|
| 60 |
+
A long single-episode operation whose final win depends on FIVE or
|
| 61 |
+
more intermediate checkpoints being observed-true in order: stand
|
| 62 |
+
up the economy (refinery), unlock the tech (war factory), produce
|
| 63 |
+
a real fighting force (≥2 medium tanks), engage and accumulate
|
| 64 |
+
kills (≥3), then commit to the terminal objective (raze the enemy
|
| 65 |
+
headquarters). The Wave-2 `then:` composite enforces strict
|
| 66 |
+
happened-before — an out-of-order completion (kills before the
|
| 67 |
+
army, attack before the tech) cannot advance the chain past the
|
| 68 |
+
skipped clause and forfeits the episode on the clock.
|
| 69 |
+
robotics_analogue: >
|
| 70 |
+
Long sequenced operation with strict checkpoint gating: a mission
|
| 71 |
+
that runs through 5+ phases that must complete in order (sensor
|
| 72 |
+
coverage → infrastructure → fleet provisioning → contact →
|
| 73 |
+
objective), inside a single budget window. Credit at episode end
|
| 74 |
+
requires every phase to have happened in sequence — out-of-order
|
| 75 |
+
completion forfeits the whole chain. Standard PERT / CPM critical-
|
| 76 |
+
path execution where a downstream task cannot start (and cannot
|
| 77 |
+
earn credit) until its upstream predecessor has finished.
|
| 78 |
+
benchmark_anchor:
|
| 79 |
+
- "PlanBench long-sequencing"
|
| 80 |
+
- "PERT critical path"
|
| 81 |
+
- "PERT/CPM project management"
|
| 82 |
+
- "long SOP execution"
|
| 83 |
+
author: openra-bench
|
| 84 |
+
|
| 85 |
+
base_map: rush-hour-arena
|
| 86 |
+
|
| 87 |
+
base:
|
| 88 |
+
agent:
|
| 89 |
+
faction: allies
|
| 90 |
+
enemy:
|
| 91 |
+
faction: soviet
|
| 92 |
+
bot_type: ''
|
| 93 |
+
tools:
|
| 94 |
+
- observe
|
| 95 |
+
- build
|
| 96 |
+
- place_building
|
| 97 |
+
- move_units
|
| 98 |
+
- attack_unit
|
| 99 |
+
- attack_move
|
| 100 |
+
- stop
|
| 101 |
+
planning: true
|
| 102 |
+
termination:
|
| 103 |
+
max_ticks: 40000
|
| 104 |
+
|
| 105 |
+
levels:
|
| 106 |
+
# ── EASY ─────────────────────────────────────────────────────────
|
| 107 |
+
# Loose-bar rehearsal: 3-phase chain (proc → weap → engage). Cash
|
| 108 |
+
# is loose, the static enemy fact has no defenders, and the chain
|
| 109 |
+
# is short enough that a competent build-order + attack wins. The
|
| 110 |
+
# `then:` idiom is taught here so the medium tier (5 phases) and
|
| 111 |
+
# hard tier (6 phases) are pure phase-count escalations.
|
| 112 |
+
easy:
|
| 113 |
+
description: >
|
| 114 |
+
Pre-placed: agent fact + tent + powr + fix at the west base,
|
| 115 |
+
plus 4 medium tanks (2tnk) for the engagement. An enemy fact
|
| 116 |
+
sits undefended at the far east (your terminal target). The
|
| 117 |
+
three checkpoints (`then:` chain) are: build a refinery (proc),
|
| 118 |
+
then a war factory (weap), then destroy the enemy fact. Phases
|
| 119 |
+
MUST happen IN THIS ORDER — stalling or skipping any phase
|
| 120 |
+
loses (the chain never advances), and any non-win on the clock
|
| 121 |
+
is a real LOSS. All within 7200 ticks.
|
| 122 |
+
starting_cash: 4000
|
| 123 |
+
overrides:
|
| 124 |
+
actors:
|
| 125 |
+
# Agent base — fact, tent, powr, fix (the 2tnk tech-prereq,
|
| 126 |
+
# pre-placed because the easy tier doesn't include a tank-
|
| 127 |
+
# production checkpoint; keeping `fix` matches the medium
|
| 128 |
+
# tier's geometry so cash budgeting transfers cleanly).
|
| 129 |
+
- {type: fact, owner: agent, position: [10, 18]}
|
| 130 |
+
- {type: tent, owner: agent, position: [10, 22]}
|
| 131 |
+
- {type: powr, owner: agent, position: [14, 18]}
|
| 132 |
+
- {type: fix, owner: agent, position: [14, 22]}
|
| 133 |
+
# 4 pre-placed medium tanks — the strike force for the
|
| 134 |
+
# engagement checkpoint. Positioned just east of the base
|
| 135 |
+
# so they have a clear corridor to the enemy fact.
|
| 136 |
+
- {type: 2tnk, owner: agent, position: [18, 18]}
|
| 137 |
+
- {type: 2tnk, owner: agent, position: [18, 20]}
|
| 138 |
+
- {type: 2tnk, owner: agent, position: [18, 22]}
|
| 139 |
+
- {type: 2tnk, owner: agent, position: [20, 20]}
|
| 140 |
+
# Near ore patch so a built proc auto-spawns a harv with
|
| 141 |
+
# somewhere to harvest from (income for weap).
|
| 142 |
+
- {type: mine, owner: neutral, position: [22, 18]}
|
| 143 |
+
- {type: mine, owner: neutral, position: [22, 22]}
|
| 144 |
+
# Enemy: fact at far east (terminal target). No defenders on
|
| 145 |
+
# easy — the engage clause is geometric only (drive tanks
|
| 146 |
+
# east, raze fact). A SENTINEL fact at the far north-east
|
| 147 |
+
# corner persists past the terminal fact-kill so the engine
|
| 148 |
+
# does NOT auto-`done` on enemy-elimination before the
|
| 149 |
+
# `then:` chain (and the within_ticks predicate) gets a turn
|
| 150 |
+
# to evaluate — see CLAUDE.md auto-terminate footgun.
|
| 151 |
+
- {type: fact, owner: enemy, position: [115, 30]}
|
| 152 |
+
- {type: fact, owner: enemy, position: [125, 4]}
|
| 153 |
+
win_condition:
|
| 154 |
+
all_of:
|
| 155 |
+
- then:
|
| 156 |
+
id: multi-check-easy
|
| 157 |
+
clauses:
|
| 158 |
+
- {has_building: proc} # P1: econ
|
| 159 |
+
- {has_building: weap} # P2: tech
|
| 160 |
+
- {enemy_key_buildings_destroyed: {types: [fact]}} # P3: engage
|
| 161 |
+
- within_ticks: 7200
|
| 162 |
+
fail_condition:
|
| 163 |
+
any_of:
|
| 164 |
+
- {after_ticks: 7201}
|
| 165 |
+
- {not: {building_count_gte: {type: fact, n: 1}}}
|
| 166 |
+
max_turns: 85
|
| 167 |
+
|
| 168 |
+
# ── MEDIUM ───────────────────────────────────────────────────────
|
| 169 |
+
# 5-phase chain (the headline cell): proc → weap → 2× 2tnk → ≥3
|
| 170 |
+
# kills → enemy fact. The chain MIXES checkpoint kinds (buildings +
|
| 171 |
+
# quantitative production + combat-aggregate + terminal destruction)
|
| 172 |
+
# so the ordering is genuinely load-bearing — a "kill 3 then build
|
| 173 |
+
# then attack" play cannot satisfy clause-4 BEFORE clause-3 (the
|
| 174 |
+
# `then:` latch only advances when each clause is observed-true in
|
| 175 |
+
# order). Cash is tight (2500) so the chain must be executed
|
| 176 |
+
# efficiently; the 4 pre-placed tanks cover the kill+engage clauses,
|
| 177 |
+
# the auto-harvester from the built proc funds weap + the 2 new
|
| 178 |
+
# tanks for clause 3.
|
| 179 |
+
medium:
|
| 180 |
+
description: >
|
| 181 |
+
Pre-placed: agent fact + tent + powr + fix at the west base,
|
| 182 |
+
plus 4 medium tanks (2tnk) for the engagement. An enemy fact
|
| 183 |
+
sits at the far east defended by a handful of HOLD-FIRE rifle/
|
| 184 |
+
rocket soldiers (your kill targets). The five checkpoints
|
| 185 |
+
(`then:` chain) are: build a refinery (proc), then a war
|
| 186 |
+
factory (weap), then have ≥2 medium tanks (2tnk) standing,
|
| 187 |
+
then accumulate ≥3 unit kills, then destroy the enemy fact.
|
| 188 |
+
Phases MUST happen IN THIS ORDER — `then:` enforces strict
|
| 189 |
+
happened-before. A "rush the fact and kill on the way" play
|
| 190 |
+
scores kills early but the latch will not credit them until
|
| 191 |
+
proc + weap + 2tnk have latched in order; skipping any clause
|
| 192 |
+
stalls the chain. All within 7200 ticks.
|
| 193 |
+
starting_cash: 2500
|
| 194 |
+
overrides:
|
| 195 |
+
actors:
|
| 196 |
+
- {type: fact, owner: agent, position: [10, 18]}
|
| 197 |
+
- {type: tent, owner: agent, position: [10, 22]}
|
| 198 |
+
- {type: powr, owner: agent, position: [14, 18]}
|
| 199 |
+
# `fix` (Service Depot) is the 2tnk tech-prereq; pre-placed
|
| 200 |
+
# so the chain's PHASE 2 only needs the agent to build the
|
| 201 |
+
# war factory (weap) — not a Service Depot too.
|
| 202 |
+
- {type: fix, owner: agent, position: [14, 22]}
|
| 203 |
+
# 4 pre-placed medium tanks — the strike force for the
|
| 204 |
+
# kills + engagement checkpoints. Two start tanks already
|
| 205 |
+
# SATISFY the unit_type_count_gte:{2tnk,n:2} clause at t=0,
|
| 206 |
+
# so the agent does NOT need to build more for clause 3 —
|
| 207 |
+
# but the `then:` latch only advances to clause-3 once the
|
| 208 |
+
# earlier proc + weap clauses have latched.
|
| 209 |
+
- {type: 2tnk, owner: agent, position: [18, 18]}
|
| 210 |
+
- {type: 2tnk, owner: agent, position: [18, 20]}
|
| 211 |
+
- {type: 2tnk, owner: agent, position: [18, 22]}
|
| 212 |
+
- {type: 2tnk, owner: agent, position: [20, 20]}
|
| 213 |
+
# Ore patch — auto-harv from the built proc funds weap.
|
| 214 |
+
- {type: mine, owner: neutral, position: [22, 18]}
|
| 215 |
+
- {type: mine, owner: neutral, position: [22, 22]}
|
| 216 |
+
# Enemy: fact at far east + 4 HOLD-FIRE defenders (kills for
|
| 217 |
+
# phase 4). HoldFire (stance:0) means the defenders won't
|
| 218 |
+
# path-find west — the agent must come to them. Defender
|
| 219 |
+
# count = 4 so the ≥3 kills clause is generously satisfiable.
|
| 220 |
+
- {type: fact, owner: enemy, position: [115, 30]}
|
| 221 |
+
- {type: e1, owner: enemy, position: [110, 28], stance: 0}
|
| 222 |
+
- {type: e1, owner: enemy, position: [110, 30], stance: 0}
|
| 223 |
+
- {type: e1, owner: enemy, position: [110, 32], stance: 0}
|
| 224 |
+
- {type: e3, owner: enemy, position: [112, 30], stance: 0}
|
| 225 |
+
# Sentinel: a far isolated enemy fact so razing the assault
|
| 226 |
+
# target doesn't auto-`done` the engine before the win
|
| 227 |
+
# predicate evaluates (CLAUDE.md auto-terminate footgun).
|
| 228 |
+
- {type: fact, owner: enemy, position: [125, 4]}
|
| 229 |
+
win_condition:
|
| 230 |
+
all_of:
|
| 231 |
+
- then:
|
| 232 |
+
id: multi-check-medium
|
| 233 |
+
clauses:
|
| 234 |
+
- {has_building: proc} # P1: econ
|
| 235 |
+
- {has_building: weap} # P2: tech
|
| 236 |
+
- {unit_type_count_gte: {type: 2tnk, n: 2}} # P3: army
|
| 237 |
+
- {units_killed_gte: 3} # P4: engage
|
| 238 |
+
- {enemy_key_buildings_destroyed: {types: [fact]}} # P5: finish
|
| 239 |
+
- within_ticks: 7200
|
| 240 |
+
fail_condition:
|
| 241 |
+
any_of:
|
| 242 |
+
- {after_ticks: 7201}
|
| 243 |
+
- {not: {building_count_gte: {type: fact, n: 1}}}
|
| 244 |
+
max_turns: 85
|
| 245 |
+
|
| 246 |
+
# ── HARD ─────────────────────────────────────────────────────────
|
| 247 |
+
# 6-phase chain + 2 spawn_point groups. One extra checkpoint
|
| 248 |
+
# (≥4 tanks AFTER the kills) makes the chain genuinely longer than
|
| 249 |
+
# medium's 5 (the chain demands MORE tanks be produced even after
|
| 250 |
+
# the first kills land — a tank-count regression check that the
|
| 251 |
+
# army was rebuilt after attrition). The agent spawns NORTH (y=14)
|
| 252 |
+
# or SOUTH (y=26) by seed; the per-seed base latitude flips so a
|
| 253 |
+
# memorised opening cannot generalise.
|
| 254 |
+
hard:
|
| 255 |
+
description: >
|
| 256 |
+
Pre-placed: agent base (fact + tent + powr + fix) at the west,
|
| 257 |
+
round-robined between a NORTH base and a SOUTH base by seed,
|
| 258 |
+
plus 4 medium tanks (2tnk). An enemy fact at the far east is
|
| 259 |
+
defended by HOLD-FIRE defenders (your kill targets). The six
|
| 260 |
+
checkpoints (`then:` chain) are: build a refinery (proc), then
|
| 261 |
+
a war factory (weap), then have ≥2 medium tanks (2tnk), then
|
| 262 |
+
accumulate ≥3 unit kills, then have ≥4 medium tanks (rebuild
|
| 263 |
+
after attrition), then destroy the enemy fact. Phases MUST
|
| 264 |
+
happen IN STRICT ORDER (`then:` composite). Pre-build / out-of-
|
| 265 |
+
order / skip-tech / kill-without-army all stall the chain.
|
| 266 |
+
The seed-varied spawn means a memorised opening cannot
|
| 267 |
+
generalise. All within 7200 ticks.
|
| 268 |
+
starting_cash: 3000
|
| 269 |
+
overrides:
|
| 270 |
+
actors:
|
| 271 |
+
# NORTH base spawn (spawn_point 0)
|
| 272 |
+
- {type: fact, owner: agent, position: [10, 14], spawn_point: 0}
|
| 273 |
+
- {type: tent, owner: agent, position: [10, 18], spawn_point: 0}
|
| 274 |
+
- {type: powr, owner: agent, position: [14, 14], spawn_point: 0}
|
| 275 |
+
- {type: fix, owner: agent, position: [14, 18], spawn_point: 0}
|
| 276 |
+
- {type: 2tnk, owner: agent, position: [18, 14], spawn_point: 0}
|
| 277 |
+
- {type: 2tnk, owner: agent, position: [18, 16], spawn_point: 0}
|
| 278 |
+
- {type: 2tnk, owner: agent, position: [18, 18], spawn_point: 0}
|
| 279 |
+
- {type: 2tnk, owner: agent, position: [20, 16], spawn_point: 0}
|
| 280 |
+
# SOUTH base spawn (spawn_point 1)
|
| 281 |
+
- {type: fact, owner: agent, position: [10, 26], spawn_point: 1}
|
| 282 |
+
- {type: tent, owner: agent, position: [10, 22], spawn_point: 1}
|
| 283 |
+
- {type: powr, owner: agent, position: [14, 26], spawn_point: 1}
|
| 284 |
+
- {type: fix, owner: agent, position: [14, 22], spawn_point: 1}
|
| 285 |
+
- {type: 2tnk, owner: agent, position: [18, 26], spawn_point: 1}
|
| 286 |
+
- {type: 2tnk, owner: agent, position: [18, 24], spawn_point: 1}
|
| 287 |
+
- {type: 2tnk, owner: agent, position: [18, 22], spawn_point: 1}
|
| 288 |
+
- {type: 2tnk, owner: agent, position: [20, 24], spawn_point: 1}
|
| 289 |
+
# Shared ore geography — two patches between the two bases
|
| 290 |
+
# so harv income is symmetric whichever spawn is chosen.
|
| 291 |
+
- {type: mine, owner: neutral, position: [22, 18]}
|
| 292 |
+
- {type: mine, owner: neutral, position: [22, 22]}
|
| 293 |
+
# Enemy: fact at far east + HOLD-FIRE defenders for the kill
|
| 294 |
+
# clause. 5 defenders so the ≥3 kills clause is generously
|
| 295 |
+
# satisfiable on either spawn. Placed near the enemy fact so
|
| 296 |
+
# a stall loses on the clock, not on the agent base getting
|
| 297 |
+
# overrun first (no bot, so this is structural anyway).
|
| 298 |
+
- {type: fact, owner: enemy, position: [115, 20]}
|
| 299 |
+
- {type: e1, owner: enemy, position: [110, 18], stance: 0}
|
| 300 |
+
- {type: e1, owner: enemy, position: [110, 20], stance: 0}
|
| 301 |
+
- {type: e1, owner: enemy, position: [110, 22], stance: 0}
|
| 302 |
+
- {type: e3, owner: enemy, position: [112, 19], stance: 0}
|
| 303 |
+
- {type: e3, owner: enemy, position: [112, 21], stance: 0}
|
| 304 |
+
# Sentinel: a far isolated enemy fact so razing the assault
|
| 305 |
+
# target doesn't auto-`done` the engine before the win
|
| 306 |
+
# predicate evaluates (CLAUDE.md auto-terminate footgun).
|
| 307 |
+
- {type: fact, owner: enemy, position: [125, 4]}
|
| 308 |
+
win_condition:
|
| 309 |
+
all_of:
|
| 310 |
+
- then:
|
| 311 |
+
id: multi-check-hard
|
| 312 |
+
clauses:
|
| 313 |
+
- {has_building: proc} # P1: econ
|
| 314 |
+
- {has_building: weap} # P2: tech
|
| 315 |
+
- {unit_type_count_gte: {type: 2tnk, n: 2}} # P3: army
|
| 316 |
+
- {units_killed_gte: 3} # P4: engage
|
| 317 |
+
- {unit_type_count_gte: {type: 2tnk, n: 4}} # P5: rebuild
|
| 318 |
+
- {enemy_key_buildings_destroyed: {types: [fact]}} # P6: finish
|
| 319 |
+
- within_ticks: 7200
|
| 320 |
+
fail_condition:
|
| 321 |
+
any_of:
|
| 322 |
+
- {after_ticks: 7201}
|
| 323 |
+
- {not: {building_count_gte: {type: fact, n: 1}}}
|
| 324 |
+
max_turns: 85
|
|
@@ -0,0 +1,368 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# lh-scout-react-counter.yaml
|
| 2 |
+
#
|
| 3 |
+
# REASONING capability — observation-driven 3-phase chain:
|
| 4 |
+
# SCOUT (discover ≥2 enemy buildings) → REACT (engage and kill ≥3
|
| 5 |
+
# enemy units) → COUNTER (raze the enemy construction yard).
|
| 6 |
+
# The agent starts late-game with a small force (1× jeep scout +
|
| 7 |
+
# 3× medium tanks) and a starter fact at the west edge; cash 1500
|
| 8 |
+
# is too lean to fund a meaningful new production line, so the
|
| 9 |
+
# capability under test is the REACTIVE INFORMATION-ACTION LOOP on
|
| 10 |
+
# the existing force — not build-out, not tech-pivot. The model
|
| 11 |
+
# must observe what the enemy actually has, engage it, then
|
| 12 |
+
# commit to razing the construction yard inside the deadline.
|
| 13 |
+
#
|
| 14 |
+
# Distinct from sibling Wave-4 `lh-tech-pivot-attack`:
|
| 15 |
+
# - lh-tech-pivot-attack: tech is already standing; the pivot
|
| 16 |
+
# is BUILD-the-correct-counter then attack. The middle phase
|
| 17 |
+
# is a PRODUCTION decision.
|
| 18 |
+
# - lh-scout-react-counter (this pack): tech is NOT standing;
|
| 19 |
+
# the army is pre-deployed; the middle phase is a TACTICAL
|
| 20 |
+
# ENGAGEMENT decision (which enemy cluster to engage, in what
|
| 21 |
+
# order). Cash is intentionally too lean to fund building
|
| 22 |
+
# more of the army — the capability is the observation-driven
|
| 23 |
+
# three-phase chain on a FIXED FORCE.
|
| 24 |
+
# Distinct from sibling Wave-2 `mid-tech-switch-on-scout`:
|
| 25 |
+
# - mid-tech-switch-on-scout: scout-then-counter (2-phase) with
|
| 26 |
+
# either counter satisfying the win via units_killed_gte:3.
|
| 27 |
+
# - lh-scout-react-counter (this pack): EXPLICIT 3-phase chain
|
| 28 |
+
# where SCOUTING (≥2 buildings, not ≥1) is the load-bearing
|
| 29 |
+
# first phase, REACT is killing units mid-chain, and COUNTER
|
| 30 |
+
# is razing the FACT (not just hitting the kill bar).
|
| 31 |
+
#
|
| 32 |
+
# Real-world anchors: SC2 reactive macro (scout enemy strategy
|
| 33 |
+
# mid-game, pivot composition, counter-attack); CICERO
|
| 34 |
+
# information-action loop (gather state, update belief, commit to
|
| 35 |
+
# action); PlanBench replanning (mid-plan observation forces
|
| 36 |
+
# re-plan); threat-intel-driven defence (a SOC analyst confirms
|
| 37 |
+
# the indicator-of-compromise from telemetry, takes containment
|
| 38 |
+
# action, then patches the upstream attack vector).
|
| 39 |
+
#
|
| 40 |
+
# ENGINE NOTES (per CLAUDE.md):
|
| 41 |
+
# - Enemy actors with spawn_point are NOT reliably filtered per
|
| 42 |
+
# seed (openra-data/src/oramap.rs::expand_scenario_actors); we
|
| 43 |
+
# vary the AGENT spawn instead (medium/hard) to flip which
|
| 44 |
+
# enemy composition is the "near" reachable one.
|
| 45 |
+
# - The enemy `fact` is MustBeDestroyed → razing it satisfies
|
| 46 |
+
# enemy_key_buildings_destroyed but would auto-`done` the
|
| 47 |
+
# engine if it were the LAST enemy building standing. A
|
| 48 |
+
# second isolated enemy `fact` at the FAR corner persists as
|
| 49 |
+
# an anti-auto-`done` anchor (unreachable inside the clock).
|
| 50 |
+
# - `then:` is the Wave-2 happened-before composite: clauses
|
| 51 |
+
# latch IN ORDER, so a SKIP-SCOUT-ATTACK-BLIND play that
|
| 52 |
+
# razes the fact (auto-discovering 1 building en route) does
|
| 53 |
+
# NOT satisfy the first clause (≥2 buildings discovered) —
|
| 54 |
+
# keeping scouting load-bearing.
|
| 55 |
+
# - `unit_killed_gte:3` is the REACT phase: it requires the
|
| 56 |
+
# agent's force to actually engage; a scout-only-no-attack
|
| 57 |
+
# play stalls at clause 2.
|
| 58 |
+
# - Tools omit `harvest` / `train` deliberately: this pack does
|
| 59 |
+
# not test the economic loop. `build`/`place_building` are
|
| 60 |
+
# kept so an opportunistic emergency defence (e.g. a pbox /
|
| 61 |
+
# gun) is buildable from the starter fact + powr if the model
|
| 62 |
+
# elects to spend the lean cash that way.
|
| 63 |
+
#
|
| 64 |
+
# Validate:
|
| 65 |
+
# cd /Users/berta/Projects/OpenRA-Bench && \
|
| 66 |
+
# python3 -m openra_bench.scenarios.validate \
|
| 67 |
+
# openra_bench/scenarios/packs/lh-scout-react-counter.yaml
|
| 68 |
+
|
| 69 |
+
meta:
|
| 70 |
+
id: lh-scout-react-counter
|
| 71 |
+
title: 'Scout, React, Counter — Observation-Driven 3-Phase Chain'
|
| 72 |
+
capability: reasoning
|
| 73 |
+
real_world_meaning: >
|
| 74 |
+
Reactive macro on a fixed force: the agent must drive the
|
| 75 |
+
observation-action-counter loop end-to-end inside one budget.
|
| 76 |
+
Phase 1 (scout) is mandatory — the deeper enemy posture
|
| 77 |
+
(≥2 buildings worth of structure) must be observed before any
|
| 78 |
+
commit is informed; phase 2 (react) requires the army to
|
| 79 |
+
actually engage the cluster, not merely walk past; phase 3
|
| 80 |
+
(counter) is the production-yard kill that ends the threat at
|
| 81 |
+
its source. Pre-committing to attack the construction yard
|
| 82 |
+
without scouting cannot satisfy the chain — the scout latch
|
| 83 |
+
is the load-bearing first link.
|
| 84 |
+
robotics_analogue: >
|
| 85 |
+
Threat-intel-driven incident response: SOC analyst confirms
|
| 86 |
+
multiple indicators of compromise from telemetry (scout),
|
| 87 |
+
contains the active attacker on the network (react / engage),
|
| 88 |
+
then patches the upstream attack vector / takes down the
|
| 89 |
+
command-and-control host (counter). Skipping the confirmation
|
| 90 |
+
step and going straight to take-down acts on stale belief and
|
| 91 |
+
misses the root cause; scouting forever bleeds the response
|
| 92 |
+
window.
|
| 93 |
+
benchmark_anchor:
|
| 94 |
+
- "SC2 reactive macro (scout-pivot-counter)"
|
| 95 |
+
- "CICERO information-action loop"
|
| 96 |
+
- "PlanBench replanning"
|
| 97 |
+
- "threat-intel-driven defense"
|
| 98 |
+
author: "openra-bench"
|
| 99 |
+
|
| 100 |
+
base_map: rush-hour-arena
|
| 101 |
+
|
| 102 |
+
base:
|
| 103 |
+
agent:
|
| 104 |
+
faction: allies
|
| 105 |
+
enemy:
|
| 106 |
+
faction: soviet
|
| 107 |
+
bot_type: ''
|
| 108 |
+
tools:
|
| 109 |
+
- observe
|
| 110 |
+
- build
|
| 111 |
+
- place_building
|
| 112 |
+
- move_units
|
| 113 |
+
- attack_unit
|
| 114 |
+
- attack_move
|
| 115 |
+
- stop
|
| 116 |
+
planning: true
|
| 117 |
+
termination:
|
| 118 |
+
max_ticks: 40000
|
| 119 |
+
|
| 120 |
+
levels:
|
| 121 |
+
# ── EASY ────────────────────────────────────────────────────────
|
| 122 |
+
# Plan-and-execute rehearsal: ONE fixed enemy composition (the
|
| 123 |
+
# construction yard + an attached barracks defended by a passive
|
| 124 |
+
# rifle/rocket cluster) at the far east. The brief explicitly
|
| 125 |
+
# names the composition so the model can pre-plan; the
|
| 126 |
+
# then-chain still enforces scout (≥2 buildings) before react
|
| 127 |
+
# (≥3 kills) before counter (raze fact) to teach the idiom.
|
| 128 |
+
# Stalling loses on the clock; a skip-scout-attack-blind play
|
| 129 |
+
# races at the fact with the starter tanks but the cluster
|
| 130 |
+
# blunts it — and even on success the discovery clause needs
|
| 131 |
+
# ≥2 buildings, which a single fact-rush only registers 1.
|
| 132 |
+
easy:
|
| 133 |
+
description: >
|
| 134 |
+
Your starter force sits at the west edge: a scout jeep and
|
| 135 |
+
three medium tanks (2tnk). A single enemy construction yard
|
| 136 |
+
(fact) sits at the far east on the central y-band, defended
|
| 137 |
+
by a passive rifle cluster (four e1). A SEPARATE enemy
|
| 138 |
+
barracks (tent) sits as a forward outpost in the south
|
| 139 |
+
mid-section, OFF the main assault axis. Drive the jeep
|
| 140 |
+
FIRST through the southern outpost to register the tent,
|
| 141 |
+
then on to the far east to register the fact (≥2 buildings
|
| 142 |
+
discovered), then commit your tanks to destroy ≥3 enemy
|
| 143 |
+
units, then RAZE the enemy construction yard. All within
|
| 144 |
+
6300 ticks. Rushing the tanks straight at the fact only
|
| 145 |
+
registers the fact under attack (the outpost is well off
|
| 146 |
+
the assault axis — out of tank sight) — the discovery
|
| 147 |
+
latch stalls at 1 and the chain never completes; stalling
|
| 148 |
+
at spawn loses on the clock.
|
| 149 |
+
starting_cash: 1500
|
| 150 |
+
overrides:
|
| 151 |
+
actors:
|
| 152 |
+
# Agent: starter fact + powr at the west (the powr lets a
|
| 153 |
+
# building like pbox/gun be placed if the model elects;
|
| 154 |
+
# not load-bearing for the intended play).
|
| 155 |
+
- {type: fact, owner: agent, position: [10, 18]}
|
| 156 |
+
- {type: powr, owner: agent, position: [14, 18]}
|
| 157 |
+
# 1× scout jeep + 3× medium tanks at the west edge,
|
| 158 |
+
# stance:1 (ReturnFire) so they don't path-find east on
|
| 159 |
+
# their own and so the engagement is the agent's choice.
|
| 160 |
+
- {type: jeep, owner: agent, position: [20, 16], stance: 1}
|
| 161 |
+
- {type: 2tnk, owner: agent, position: [20, 18], stance: 1}
|
| 162 |
+
- {type: 2tnk, owner: agent, position: [20, 19], stance: 1}
|
| 163 |
+
- {type: 2tnk, owner: agent, position: [20, 20], stance: 1}
|
| 164 |
+
# Enemy main: construction yard at the far east on the
|
| 165 |
+
# central y-band, plus a passive rifle cluster (e1) at
|
| 166 |
+
# stance:0 (HoldFire) guarding it. The cluster does NOT
|
| 167 |
+
# path-find west; the agent must come to them.
|
| 168 |
+
- {type: fact, owner: enemy, position: [122, 18]}
|
| 169 |
+
- {type: e1, owner: enemy, position: [118, 18], stance: 0, count: 4}
|
| 170 |
+
# Enemy forward outpost: an isolated barracks (tent) FAR
|
| 171 |
+
# off the main assault axis (mid-x, far south). A blind
|
| 172 |
+
# tank rush at the fact (path y≈18) never approaches
|
| 173 |
+
# (60, 36) — tank sight ~6 cells, distance from any rush
|
| 174 |
+
# waypoint is >>15. The jeep, however, can deliberately
|
| 175 |
+
# detour through (60, 36) before continuing east to see
|
| 176 |
+
# the main fact. This makes the ≥2-buildings discovery
|
| 177 |
+
# latch genuinely require a scout phase: the second
|
| 178 |
+
# building is geographically unreachable on the assault
|
| 179 |
+
# path.
|
| 180 |
+
- {type: tent, owner: enemy, position: [60, 36]}
|
| 181 |
+
# Sentinel: a second isolated enemy fact at the far north,
|
| 182 |
+
# unreachable inside the clock budget, so razing the near
|
| 183 |
+
# fact does NOT auto-`done` the engine before the win
|
| 184 |
+
# predicate evaluates (defence-in-depth per CLAUDE.md
|
| 185 |
+
# auto-terminate footgun).
|
| 186 |
+
- {type: fact, owner: enemy, position: [124, 4]}
|
| 187 |
+
win_condition:
|
| 188 |
+
all_of:
|
| 189 |
+
- then:
|
| 190 |
+
id: scout-react-counter-easy
|
| 191 |
+
clauses:
|
| 192 |
+
- {buildings_discovered_gte: 2}
|
| 193 |
+
- {units_killed_gte: 3}
|
| 194 |
+
- {enemy_key_buildings_destroyed: {types: [fact]}}
|
| 195 |
+
- {within_ticks: 6300}
|
| 196 |
+
fail_condition:
|
| 197 |
+
any_of:
|
| 198 |
+
- {after_ticks: 6301}
|
| 199 |
+
- {not: {own_units_gte: 1}}
|
| 200 |
+
- {not: {has_building: fact}}
|
| 201 |
+
max_turns: 75
|
| 202 |
+
|
| 203 |
+
# ── MEDIUM ──────────────────────────────────────────────────────
|
| 204 |
+
# +1 controlled variable: HIDDEN COMPOSITION (the near enemy
|
| 205 |
+
# posture varies by seed). Two enemy fact+tent+defender clusters
|
| 206 |
+
# sit at the NE and SE corners; the agent's start latitude
|
| 207 |
+
# round-robins between a NORTH spawn (y=10) and a SOUTH spawn
|
| 208 |
+
# (y=30) so the natural "near" composition flips per seed. The
|
| 209 |
+
# scout phase now requires actually driving the jeep into the
|
| 210 |
+
# near corner (not just guessing) to register ≥2 buildings; a
|
| 211 |
+
# blind rush at the FAR corner overruns the clock; a
|
| 212 |
+
# scout-then-no-attack stalls at the react clause.
|
| 213 |
+
medium:
|
| 214 |
+
description: >
|
| 215 |
+
Your starter force sits at the west edge: a scout jeep and
|
| 216 |
+
three medium tanks (2tnk). Two enemy construction yards
|
| 217 |
+
(fact) sit hidden at the far NE and SE corners, each
|
| 218 |
+
defended by a passive infantry cluster. A SEPARATE enemy
|
| 219 |
+
barracks (tent) sits as a forward outpost at mid-x mid-y,
|
| 220 |
+
off the assault axis from either spawn. Drive the jeep
|
| 221 |
+
FIRST through the mid outpost to register the tent, then
|
| 222 |
+
east to the near corner to register the fact (≥2 buildings
|
| 223 |
+
discovered), then commit your tanks to destroy ≥3 enemy
|
| 224 |
+
units, then RAZE the near construction yard. All within
|
| 225 |
+
6300 ticks. Pre-rushing tanks at the fact only registers
|
| 226 |
+
the fact (1 building); scouting forever bleeds the clock;
|
| 227 |
+
stalling at spawn never advances the chain.
|
| 228 |
+
starting_cash: 1500
|
| 229 |
+
overrides:
|
| 230 |
+
actors:
|
| 231 |
+
# North-base spawn (spawn_point 0).
|
| 232 |
+
- {type: fact, owner: agent, position: [10, 10], spawn_point: 0}
|
| 233 |
+
- {type: powr, owner: agent, position: [14, 10], spawn_point: 0}
|
| 234 |
+
- {type: jeep, owner: agent, position: [20, 8], stance: 1, spawn_point: 0}
|
| 235 |
+
- {type: 2tnk, owner: agent, position: [20, 10], stance: 1, spawn_point: 0}
|
| 236 |
+
- {type: 2tnk, owner: agent, position: [20, 11], stance: 1, spawn_point: 0}
|
| 237 |
+
- {type: 2tnk, owner: agent, position: [20, 12], stance: 1, spawn_point: 0}
|
| 238 |
+
# South-base spawn (spawn_point 1).
|
| 239 |
+
- {type: fact, owner: agent, position: [10, 30], spawn_point: 1}
|
| 240 |
+
- {type: powr, owner: agent, position: [14, 30], spawn_point: 1}
|
| 241 |
+
- {type: jeep, owner: agent, position: [20, 28], stance: 1, spawn_point: 1}
|
| 242 |
+
- {type: 2tnk, owner: agent, position: [20, 30], stance: 1, spawn_point: 1}
|
| 243 |
+
- {type: 2tnk, owner: agent, position: [20, 31], stance: 1, spawn_point: 1}
|
| 244 |
+
- {type: 2tnk, owner: agent, position: [20, 32], stance: 1, spawn_point: 1}
|
| 245 |
+
# NE enemy base: fact + defending e1 cluster.
|
| 246 |
+
- {type: fact, owner: enemy, position: [122, 5]}
|
| 247 |
+
- {type: e1, owner: enemy, position: [118, 5], stance: 0, count: 4}
|
| 248 |
+
# SE enemy base: fact + defending e3 cluster (anti-
|
| 249 |
+
# vehicle, stance:0 HoldFire — equivalent posture to NE
|
| 250 |
+
# so the seed-rotated near base presents an equivalent
|
| 251 |
+
# scout/react/counter task).
|
| 252 |
+
- {type: fact, owner: enemy, position: [122, 38]}
|
| 253 |
+
- {type: e3, owner: enemy, position: [118, 38], stance: 0, count: 4}
|
| 254 |
+
# Shared FORWARD OUTPOST: a lone enemy barracks (tent) at
|
| 255 |
+
# mid-x, mid-y. From EITHER spawn, the tank rush path
|
| 256 |
+
# stays inside its own y-band (N: y≈5..12, S: y≈30..38)
|
| 257 |
+
# and never approaches (60, 20) within tank sight ~6
|
| 258 |
+
# (closest the rush gets is ~8 cells, still out of
|
| 259 |
+
# sight). The jeep must deliberately detour through
|
| 260 |
+
# (60, 20) to register the tent, then continue east to
|
| 261 |
+
# register the near fact — making ≥2 buildings discovered
|
| 262 |
+
# require an EXPLICIT scout phase.
|
| 263 |
+
- {type: tent, owner: enemy, position: [60, 20]}
|
| 264 |
+
win_condition:
|
| 265 |
+
all_of:
|
| 266 |
+
- then:
|
| 267 |
+
id: scout-react-counter-medium
|
| 268 |
+
clauses:
|
| 269 |
+
- {buildings_discovered_gte: 2}
|
| 270 |
+
- {units_killed_gte: 3}
|
| 271 |
+
- {enemy_key_buildings_destroyed: {types: [fact]}}
|
| 272 |
+
- {within_ticks: 6300}
|
| 273 |
+
fail_condition:
|
| 274 |
+
any_of:
|
| 275 |
+
- {after_ticks: 6301}
|
| 276 |
+
- {not: {own_units_gte: 1}}
|
| 277 |
+
- {not: {has_building: fact}}
|
| 278 |
+
max_turns: 75
|
| 279 |
+
|
| 280 |
+
# ── HARD ────────────────────────────────────────────────────────
|
| 281 |
+
# +1 controlled variable: an ATTRITION CAP (units_lost_lte:3,
|
| 282 |
+
# so the agent cannot trade tanks freely). The map adds a
|
| 283 |
+
# MIDDLE pocket (a third fact+tent cluster at mid-latitude) so
|
| 284 |
+
# the scout phase has THREE candidate posture readings, not two
|
| 285 |
+
# — even on a fixed spawn the jeep must actually identify the
|
| 286 |
+
# nearest reachable cluster before committing. 2 spawn_point
|
| 287 |
+
# groups satisfy `tests/test_hard_tier.py::UPGRADED`. Blind
|
| 288 |
+
# rush busts the discovery latch AND the attrition cap; a
|
| 289 |
+
# scout-then-trade-everything play busts the attrition cap; an
|
| 290 |
+
# over-scout that runs the jeep to all three pockets bleeds
|
| 291 |
+
# the clock.
|
| 292 |
+
hard:
|
| 293 |
+
description: >
|
| 294 |
+
Your starter force at the west edge — a scout jeep and
|
| 295 |
+
three medium tanks (2tnk) — round-robined between a NORTH
|
| 296 |
+
base and a SOUTH base by seed. Three enemy construction
|
| 297 |
+
yards (fact) sit hidden at the far east (NE, MID, SE),
|
| 298 |
+
each with a passive defending cluster. A SEPARATE enemy
|
| 299 |
+
barracks (tent) sits as a forward outpost at mid-x mid-y,
|
| 300 |
+
off the assault axis from either spawn. Drive the jeep
|
| 301 |
+
through the mid outpost to register the tent and then to
|
| 302 |
+
the NEAREST corner (compass-only briefing, no coordinates)
|
| 303 |
+
to register the fact (≥2 buildings discovered), then
|
| 304 |
+
engage with the tanks to destroy ≥3 enemy units, and RAZE
|
| 305 |
+
the near construction yard — all while losing no more
|
| 306 |
+
than three of your own. All within 6300 ticks. Blind
|
| 307 |
+
rushing fails the discovery latch; reckless engagements
|
| 308 |
+
bust the attrition cap; an over-scout that touches all
|
| 309 |
+
three corners AND the outpost bleeds the clock.
|
| 310 |
+
starting_cash: 1500
|
| 311 |
+
objective_coords: relative
|
| 312 |
+
overrides:
|
| 313 |
+
actors:
|
| 314 |
+
# North-base spawn (spawn_point 0).
|
| 315 |
+
- {type: fact, owner: agent, position: [10, 10], spawn_point: 0}
|
| 316 |
+
- {type: powr, owner: agent, position: [14, 10], spawn_point: 0}
|
| 317 |
+
- {type: jeep, owner: agent, position: [20, 8], stance: 1, spawn_point: 0}
|
| 318 |
+
- {type: 2tnk, owner: agent, position: [20, 10], stance: 1, spawn_point: 0}
|
| 319 |
+
- {type: 2tnk, owner: agent, position: [20, 11], stance: 1, spawn_point: 0}
|
| 320 |
+
- {type: 2tnk, owner: agent, position: [20, 12], stance: 1, spawn_point: 0}
|
| 321 |
+
# South-base spawn (spawn_point 1).
|
| 322 |
+
- {type: fact, owner: agent, position: [10, 30], spawn_point: 1}
|
| 323 |
+
- {type: powr, owner: agent, position: [14, 30], spawn_point: 1}
|
| 324 |
+
- {type: jeep, owner: agent, position: [20, 28], stance: 1, spawn_point: 1}
|
| 325 |
+
- {type: 2tnk, owner: agent, position: [20, 30], stance: 1, spawn_point: 1}
|
| 326 |
+
- {type: 2tnk, owner: agent, position: [20, 31], stance: 1, spawn_point: 1}
|
| 327 |
+
- {type: 2tnk, owner: agent, position: [20, 32], stance: 1, spawn_point: 1}
|
| 328 |
+
# NE enemy base: fact + e1 cluster.
|
| 329 |
+
- {type: fact, owner: enemy, position: [122, 5]}
|
| 330 |
+
- {type: e1, owner: enemy, position: [118, 5], stance: 0, count: 4}
|
| 331 |
+
# SE enemy base: fact + e3 cluster.
|
| 332 |
+
- {type: fact, owner: enemy, position: [122, 38]}
|
| 333 |
+
- {type: e3, owner: enemy, position: [118, 38], stance: 0, count: 4}
|
| 334 |
+
# MID enemy base — the third compositional reading on the
|
| 335 |
+
# tighter attrition tier. fact + cluster at mid-latitude;
|
| 336 |
+
# unreachable as the "near" base from either NORTH (y=10)
|
| 337 |
+
# or SOUTH (y=30) spawn inside the tighter attrition
|
| 338 |
+
# budget (units_lost_lte:3) — the over-scout penalty is
|
| 339 |
+
# precisely the diversion to the mid pocket.
|
| 340 |
+
- {type: fact, owner: enemy, position: [122, 22]}
|
| 341 |
+
- {type: e1, owner: enemy, position: [118, 22], stance: 0, count: 4}
|
| 342 |
+
# Shared FORWARD OUTPOST: lone enemy tent at mid-x AND
|
| 343 |
+
# mid-y. The rush axis from either spawn stays inside
|
| 344 |
+
# its own y-band and never approaches (60, 20) within
|
| 345 |
+
# tank sight ~6; the jeep must detour through (60, 20)
|
| 346 |
+
# for the second building. Sits 5 cells north of the
|
| 347 |
+
# MID base fact (122, 22) so it cannot be confused with
|
| 348 |
+
# the MID base — the jeep at (60, 20) sees the outpost
|
| 349 |
+
# but the MID fact at distance √(62²+2²)≈62 is far out
|
| 350 |
+
# of jeep sight.
|
| 351 |
+
- {type: tent, owner: enemy, position: [60, 20]}
|
| 352 |
+
win_condition:
|
| 353 |
+
all_of:
|
| 354 |
+
- then:
|
| 355 |
+
id: scout-react-counter-hard
|
| 356 |
+
clauses:
|
| 357 |
+
- {buildings_discovered_gte: 2}
|
| 358 |
+
- {units_killed_gte: 3}
|
| 359 |
+
- {enemy_key_buildings_destroyed: {types: [fact]}}
|
| 360 |
+
- {within_ticks: 6300}
|
| 361 |
+
- {units_lost_lte: 3}
|
| 362 |
+
fail_condition:
|
| 363 |
+
any_of:
|
| 364 |
+
- {after_ticks: 6301}
|
| 365 |
+
- {not: {own_units_gte: 1}}
|
| 366 |
+
- {not: {has_building: fact}}
|
| 367 |
+
- {not: {units_lost_lte: 3}}
|
| 368 |
+
max_turns: 75
|
|
@@ -0,0 +1,378 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""coord-converge-on-target — triple-prong convergent attack.
|
| 2 |
+
|
| 3 |
+
The bar: intended THREE-PRONG converge (all three squads driven onto
|
| 4 |
+
the objective fact at ~100,20 simultaneously) WINS on every level and
|
| 5 |
+
every hard seed (1..4). STALL (only observe), SINGLE-SQUAD-N (only the
|
| 6 |
+
north squad attacks while the other two stand still), and TWO-SQUADS
|
| 7 |
+
(north + south only — the west / east third squad stands still) all
|
| 8 |
+
LOSE on every level and every hard seed. Non-win is a real reachable
|
| 9 |
+
timeout LOSS via the `after_ticks` fail clause (force-wipe also fails).
|
| 10 |
+
|
| 11 |
+
Validation is scripted (no model / network).
|
| 12 |
+
"""
|
| 13 |
+
from __future__ import annotations
|
| 14 |
+
|
| 15 |
+
from pathlib import Path
|
| 16 |
+
|
| 17 |
+
import pytest
|
| 18 |
+
|
| 19 |
+
pytest.importorskip("openra_rl_training", reason="Rust env wheel not installed")
|
| 20 |
+
from openra_bench.scenarios import load_pack
|
| 21 |
+
from openra_bench.scenarios.loader import compile_level
|
| 22 |
+
from openra_bench.scenarios.win_conditions import WinContext, evaluate
|
| 23 |
+
|
| 24 |
+
PACKS = Path(__file__).parent.parent / "openra_bench" / "scenarios" / "packs"
|
| 25 |
+
PACK_PATH = PACKS / "coord-converge-on-target.yaml"
|
| 26 |
+
|
| 27 |
+
|
| 28 |
+
# ── unit-level predicate / metadata checks (no engine) ──────────────
|
| 29 |
+
|
| 30 |
+
|
| 31 |
+
def test_pack_compiles_and_meta_fields_populated():
|
| 32 |
+
pack = load_pack(PACK_PATH)
|
| 33 |
+
assert pack.meta.id == "coord-converge-on-target"
|
| 34 |
+
assert pack.meta.capability == "action"
|
| 35 |
+
assert pack.meta.real_world_meaning, "real_world_meaning required"
|
| 36 |
+
assert pack.meta.robotics_analogue, "robotics_analogue required"
|
| 37 |
+
anchors = pack.meta.benchmark_anchor
|
| 38 |
+
assert isinstance(anchors, list) and len(anchors) >= 3, (
|
| 39 |
+
f"benchmark_anchor must list ≥3 anchors, got {anchors!r}"
|
| 40 |
+
)
|
| 41 |
+
joined = " ".join(anchors).lower()
|
| 42 |
+
for needle in ("sc2", "convergent", "envelopment"):
|
| 43 |
+
assert needle in joined, f"missing anchor keyword: {needle}"
|
| 44 |
+
for lvl in ("easy", "medium", "hard"):
|
| 45 |
+
c = compile_level(pack, lvl)
|
| 46 |
+
assert c.map_supported
|
| 47 |
+
assert c.win_condition is not None
|
| 48 |
+
assert c.fail_condition is not None
|
| 49 |
+
|
| 50 |
+
|
| 51 |
+
def _ctx(*, units=(), tick=1000, lost=0, fact_destroyed=False):
|
| 52 |
+
"""Synthesize a WinContext for predicate-level checks."""
|
| 53 |
+
import types
|
| 54 |
+
|
| 55 |
+
types_count = {"fact": 1} if fact_destroyed else {}
|
| 56 |
+
sig = types.SimpleNamespace(
|
| 57 |
+
game_tick=tick,
|
| 58 |
+
units_killed=0,
|
| 59 |
+
units_lost=lost,
|
| 60 |
+
cash=0,
|
| 61 |
+
resources=0,
|
| 62 |
+
own_buildings=[],
|
| 63 |
+
own_building_types=set(),
|
| 64 |
+
enemies_seen_ids=set(),
|
| 65 |
+
enemy_buildings_seen_ids=set(),
|
| 66 |
+
enemy_buildings_destroyed=1 if fact_destroyed else 0,
|
| 67 |
+
enemy_buildings_destroyed_types=types_count,
|
| 68 |
+
enemy_buildings_destroyed_records=(
|
| 69 |
+
[("fact", 100, 20)] if fact_destroyed else []
|
| 70 |
+
),
|
| 71 |
+
)
|
| 72 |
+
return WinContext(
|
| 73 |
+
signals=sig,
|
| 74 |
+
render_state={"units_summary": list(units)},
|
| 75 |
+
)
|
| 76 |
+
|
| 77 |
+
|
| 78 |
+
def _tanks_at(cx, cy, n, base_id=1000):
|
| 79 |
+
return [
|
| 80 |
+
{"cell_x": cx, "cell_y": cy, "type": "2tnk", "id": str(base_id + i)}
|
| 81 |
+
for i in range(n)
|
| 82 |
+
]
|
| 83 |
+
|
| 84 |
+
|
| 85 |
+
def test_predicates_easy_medium_hard():
|
| 86 |
+
pack = load_pack(PACK_PATH)
|
| 87 |
+
for lvl in ("easy", "medium", "hard"):
|
| 88 |
+
c = compile_level(pack, lvl)
|
| 89 |
+
# WIN: ≥6 tanks inside (100,20,r=8) AND fact destroyed AND in budget.
|
| 90 |
+
win_units = _tanks_at(100, 20, 6) + _tanks_at(10, 20, 3, base_id=2000)
|
| 91 |
+
assert evaluate(
|
| 92 |
+
c.win_condition,
|
| 93 |
+
_ctx(units=win_units, tick=4000, fact_destroyed=True),
|
| 94 |
+
), f"{lvl}: 6-in-region + fact destroyed must WIN"
|
| 95 |
+
|
| 96 |
+
# Fact NOT destroyed → not a win even with tanks in region.
|
| 97 |
+
assert not evaluate(
|
| 98 |
+
c.win_condition,
|
| 99 |
+
_ctx(units=win_units, tick=4000, fact_destroyed=False),
|
| 100 |
+
)
|
| 101 |
+
|
| 102 |
+
# Only 5 tanks in region (one short of n=6) → not a win.
|
| 103 |
+
five_in = _tanks_at(100, 20, 5) + _tanks_at(10, 20, 4, base_id=2000)
|
| 104 |
+
assert not evaluate(
|
| 105 |
+
c.win_condition,
|
| 106 |
+
_ctx(units=five_in, tick=4000, fact_destroyed=True),
|
| 107 |
+
), f"{lvl}: 5-in-region must not WIN (n=6 threshold)"
|
| 108 |
+
|
| 109 |
+
# Past deadline → not a win.
|
| 110 |
+
assert not evaluate(
|
| 111 |
+
c.win_condition,
|
| 112 |
+
_ctx(units=win_units, tick=4501, fact_destroyed=True),
|
| 113 |
+
)
|
| 114 |
+
|
| 115 |
+
# Timeout with bar unmet → fail (after_ticks 4501).
|
| 116 |
+
assert evaluate(
|
| 117 |
+
c.fail_condition,
|
| 118 |
+
_ctx(units=win_units, tick=4600, fact_destroyed=False),
|
| 119 |
+
)
|
| 120 |
+
|
| 121 |
+
# Force-wipe → fail (not own_units_gte:1).
|
| 122 |
+
assert evaluate(
|
| 123 |
+
c.fail_condition,
|
| 124 |
+
_ctx(units=[], tick=3000, fact_destroyed=False),
|
| 125 |
+
)
|
| 126 |
+
|
| 127 |
+
|
| 128 |
+
def test_timeout_reachable_inside_max_turns():
|
| 129 |
+
"""No draw degeneracy: after_ticks 4501 ≤ 93 + 90·(max_turns-1)."""
|
| 130 |
+
pack = load_pack(PACK_PATH)
|
| 131 |
+
for lvl in ("easy", "medium", "hard"):
|
| 132 |
+
c = compile_level(pack, lvl)
|
| 133 |
+
max_tick = 93 + 90 * (c.max_turns - 1)
|
| 134 |
+
assert 4501 <= max_tick, (
|
| 135 |
+
f"{lvl}: after_ticks 4501 > max reachable tick {max_tick} "
|
| 136 |
+
f"(max_turns={c.max_turns}); deadline never bites"
|
| 137 |
+
)
|
| 138 |
+
assert 4500 <= max_tick, (
|
| 139 |
+
f"{lvl}: within_ticks 4500 > max tick {max_tick}"
|
| 140 |
+
)
|
| 141 |
+
|
| 142 |
+
|
| 143 |
+
def test_hard_has_two_spawn_point_groups():
|
| 144 |
+
"""Hard-tier curation: ≥2 distinct agent spawn_point groups so the
|
| 145 |
+
seed round-robins the staging geometry (N/W/S vs N/E/S). Engine
|
| 146 |
+
round-trip is asserted by tests/test_hard_tier.py."""
|
| 147 |
+
c = compile_level(load_pack(PACK_PATH), "hard")
|
| 148 |
+
groups = {
|
| 149 |
+
(a.spawn_point if a.spawn_point is not None else 0)
|
| 150 |
+
for a in c.scenario.actors
|
| 151 |
+
if a.owner == "agent"
|
| 152 |
+
}
|
| 153 |
+
assert len(groups) >= 2, f"hard needs ≥2 spawn_point groups, got {groups}"
|
| 154 |
+
|
| 155 |
+
|
| 156 |
+
def test_guard_bot_and_e3_cluster():
|
| 157 |
+
"""The enemy must be the `guard` scripted bot (leashed defender)
|
| 158 |
+
and the cluster must be e3 rocket-infantry (anti-tank Dragon) —
|
| 159 |
+
these together make any single squad's frontal commit costly,
|
| 160 |
+
so a 3-prong convergence is required to win the trade."""
|
| 161 |
+
pack = load_pack(PACK_PATH)
|
| 162 |
+
enemy = pack.base.get("enemy") if isinstance(pack.base, dict) else None
|
| 163 |
+
assert enemy is not None
|
| 164 |
+
bot = enemy.get("bot_type") or enemy.get("bot") or ""
|
| 165 |
+
assert bot == "guard", f"expected guard bot, got {bot!r}"
|
| 166 |
+
for lvl in ("easy", "medium", "hard"):
|
| 167 |
+
c = compile_level(pack, lvl)
|
| 168 |
+
types = [a.type for a in c.scenario.actors if a.owner == "enemy"]
|
| 169 |
+
assert "e3" in types, f"{lvl}: cluster must be e3 rocket infantry"
|
| 170 |
+
assert "fact" in types, f"{lvl}: objective must include enemy fact"
|
| 171 |
+
|
| 172 |
+
|
| 173 |
+
def test_objective_fact_and_sentinel_present():
|
| 174 |
+
"""Two enemy `fact` per level: one objective fact (~100,20) and one
|
| 175 |
+
far sentinel fact keeping the episode alive past objective-fact
|
| 176 |
+
destruction so within_ticks evaluates on the terminal frame (the
|
| 177 |
+
CLAUDE.md MustBeDestroyed auto-terminate footgun)."""
|
| 178 |
+
pack = load_pack(PACK_PATH)
|
| 179 |
+
for lvl in ("easy", "medium", "hard"):
|
| 180 |
+
c = compile_level(pack, lvl)
|
| 181 |
+
facts = [
|
| 182 |
+
(a.position[0], a.position[1])
|
| 183 |
+
for a in c.scenario.actors
|
| 184 |
+
if a.owner == "enemy" and a.type == "fact"
|
| 185 |
+
]
|
| 186 |
+
assert len(facts) == 2, (
|
| 187 |
+
f"{lvl}: must have 2 enemy facts (objective + sentinel), "
|
| 188 |
+
f"got {len(facts)} at {facts}"
|
| 189 |
+
)
|
| 190 |
+
obj = [p for p in facts if 92 <= p[0] <= 108 and 14 <= p[1] <= 26]
|
| 191 |
+
far = [p for p in facts if not (92 <= p[0] <= 108 and 14 <= p[1] <= 26)]
|
| 192 |
+
assert len(obj) == 1 and len(far) == 1, (
|
| 193 |
+
f"{lvl}: expected 1 objective fact near (100,20) + 1 sentinel "
|
| 194 |
+
f"far, got obj={obj} far={far}"
|
| 195 |
+
)
|
| 196 |
+
|
| 197 |
+
|
| 198 |
+
def test_guard_count_per_level():
|
| 199 |
+
"""Difficulty axis: easy 3 guards (distributed), medium 4 (one
|
| 200 |
+
per cardinal face), hard 4 + seed-driven agent spawn variation.
|
| 201 |
+
The discrimination on medium / hard is tighter than on easy: a
|
| 202 |
+
single squad alone is destroyed in every tier; a two-squad
|
| 203 |
+
converge destroys the fact but trades the strike force out on
|
| 204 |
+
medium / hard (cardinal cover); only the three-prong meets the
|
| 205 |
+
n=6 region threshold."""
|
| 206 |
+
pack = load_pack(PACK_PATH)
|
| 207 |
+
expected = {"easy": 3, "medium": 4, "hard": 4}
|
| 208 |
+
for lvl, want in expected.items():
|
| 209 |
+
c = compile_level(pack, lvl)
|
| 210 |
+
n_e3 = sum(
|
| 211 |
+
1 for a in c.scenario.actors if a.owner == "enemy" and a.type == "e3"
|
| 212 |
+
)
|
| 213 |
+
assert n_e3 == want, f"{lvl}: expected {want} guards, got {n_e3}"
|
| 214 |
+
|
| 215 |
+
|
| 216 |
+
def test_three_agent_squads_per_spawn():
|
| 217 |
+
"""Every spawn group must place 3 squads of 3× 2tnk each (9 tanks
|
| 218 |
+
total) so the n=6 region clause requires a true 3-prong converge
|
| 219 |
+
(a 2-prong with full attrition cannot reach 6 inside the region)."""
|
| 220 |
+
pack = load_pack(PACK_PATH)
|
| 221 |
+
for lvl in ("easy", "medium", "hard"):
|
| 222 |
+
c = compile_level(pack, lvl)
|
| 223 |
+
# group spawn -> total agent tank count
|
| 224 |
+
by_grp: dict = {}
|
| 225 |
+
for a in c.scenario.actors:
|
| 226 |
+
if a.owner == "agent" and a.type == "2tnk":
|
| 227 |
+
key = a.spawn_point if a.spawn_point is not None else 0
|
| 228 |
+
by_grp[key] = by_grp.get(key, 0) + (a.count or 1)
|
| 229 |
+
for k, n in by_grp.items():
|
| 230 |
+
assert n == 9, (
|
| 231 |
+
f"{lvl}: spawn group {k} must have 9× 2tnk (3 squads × 3), "
|
| 232 |
+
f"got {n}"
|
| 233 |
+
)
|
| 234 |
+
|
| 235 |
+
|
| 236 |
+
# ── engine-driven scripted policies ─────────────────────────────────
|
| 237 |
+
|
| 238 |
+
|
| 239 |
+
def _of_type(rs, want_types):
|
| 240 |
+
return [
|
| 241 |
+
u for u in (rs.get("units_summary", []) or [])
|
| 242 |
+
if u.get("type") in want_types
|
| 243 |
+
]
|
| 244 |
+
|
| 245 |
+
|
| 246 |
+
def _stall(rs, Command):
|
| 247 |
+
"""Pure observe — no tank ever moves; objective fact untouched and
|
| 248 |
+
the deadline expires → LOSS."""
|
| 249 |
+
return [Command.observe()]
|
| 250 |
+
|
| 251 |
+
|
| 252 |
+
def _by_squad(rs):
|
| 253 |
+
"""Bucket agent tanks by their starting hemisphere so a partial
|
| 254 |
+
policy can drive only some squads. Squads start at:
|
| 255 |
+
NORTH y≈6 (any tank with cell_y ≤ 12)
|
| 256 |
+
SOUTH y≈34 (any tank with cell_y ≥ 28)
|
| 257 |
+
WEST x≈10 (any tank with cell_x ≤ 20 and 12 < cell_y < 28)
|
| 258 |
+
EAST x≈120 (any tank with cell_x ≥ 110 and 12 < cell_y < 28)
|
| 259 |
+
Per-seed the hard tier flips W↔E for the third squad; the bucket
|
| 260 |
+
label is "flank" for whichever lateral squad is present.
|
| 261 |
+
"""
|
| 262 |
+
north, south, flank = [], [], []
|
| 263 |
+
for t in _of_type(rs, {"2tnk"}):
|
| 264 |
+
x, y = t["cell_x"], t["cell_y"]
|
| 265 |
+
if y <= 12:
|
| 266 |
+
north.append(t)
|
| 267 |
+
elif y >= 28:
|
| 268 |
+
south.append(t)
|
| 269 |
+
else:
|
| 270 |
+
flank.append(t)
|
| 271 |
+
return north, south, flank
|
| 272 |
+
|
| 273 |
+
|
| 274 |
+
def _single_squad_n(rs, Command):
|
| 275 |
+
"""Only the NORTH squad attack-moves onto the objective; the other
|
| 276 |
+
two squads stand still. Three tanks vs the guard cluster (3 / 6 /
|
| 277 |
+
8 by tier) lose the trade — fact never falls and/or the attacking
|
| 278 |
+
squad is wiped (≤3 of 9 in region < n=6). LOSS."""
|
| 279 |
+
north, _south, _flank = _by_squad(rs)
|
| 280 |
+
cmds = []
|
| 281 |
+
for t in north:
|
| 282 |
+
cmds.append(Command.attack_move([str(t["id"])], 100, 20))
|
| 283 |
+
return cmds or [Command.observe()]
|
| 284 |
+
|
| 285 |
+
|
| 286 |
+
def _two_squads(rs, Command):
|
| 287 |
+
"""NORTH + SOUTH squads attack-move onto the objective; the
|
| 288 |
+
lateral (WEST or EAST) squad stands still. 6 tanks vs the cluster
|
| 289 |
+
are still defeated under heavier defender density (medium 6
|
| 290 |
+
guards, hard 8) and the surviving in-region count drops below
|
| 291 |
+
n=6. LOSS."""
|
| 292 |
+
north, south, _flank = _by_squad(rs)
|
| 293 |
+
cmds = []
|
| 294 |
+
for t in north + south:
|
| 295 |
+
cmds.append(Command.attack_move([str(t["id"])], 100, 20))
|
| 296 |
+
return cmds or [Command.observe()]
|
| 297 |
+
|
| 298 |
+
|
| 299 |
+
def _intended_three_prong(rs, Command):
|
| 300 |
+
"""All three squads attack-move directly onto the objective fact at
|
| 301 |
+
(100,20). 9 tanks converging on the guard cluster (3 / 6 / 8 e3
|
| 302 |
+
defenders) overwhelm by mass: at least 6 of the 9 tanks survive
|
| 303 |
+
inside the (100,20,r=8) region while the cluster + fact fall.
|
| 304 |
+
WIN on every tier and every seed."""
|
| 305 |
+
cmds = []
|
| 306 |
+
for t in _of_type(rs, {"2tnk"}):
|
| 307 |
+
cmds.append(Command.attack_move([str(t["id"])], 100, 20))
|
| 308 |
+
return cmds or [Command.observe()]
|
| 309 |
+
|
| 310 |
+
|
| 311 |
+
@pytest.mark.parametrize("level", ["easy", "medium", "hard"])
|
| 312 |
+
@pytest.mark.parametrize("seed", [1, 2, 3, 4])
|
| 313 |
+
def test_intended_three_prong_wins(level, seed):
|
| 314 |
+
pytest.importorskip("openra_train")
|
| 315 |
+
from openra_bench.eval_core import run_level
|
| 316 |
+
|
| 317 |
+
c = compile_level(load_pack(PACK_PATH), level)
|
| 318 |
+
r = run_level(c, _intended_three_prong, seed=seed)
|
| 319 |
+
assert r.outcome == "win", (
|
| 320 |
+
f"{level} seed={seed}: intended 3-prong converge should WIN, "
|
| 321 |
+
f"got {r.outcome} after {r.turns} turns "
|
| 322 |
+
f"(lost={r.signals.units_lost}, "
|
| 323 |
+
f"bldgs={r.signals.enemy_buildings_destroyed_types})"
|
| 324 |
+
)
|
| 325 |
+
|
| 326 |
+
|
| 327 |
+
@pytest.mark.parametrize("level", ["easy", "medium", "hard"])
|
| 328 |
+
@pytest.mark.parametrize("seed", [1, 2, 3, 4])
|
| 329 |
+
def test_stall_loses(level, seed):
|
| 330 |
+
pytest.importorskip("openra_train")
|
| 331 |
+
from openra_bench.eval_core import run_level
|
| 332 |
+
|
| 333 |
+
c = compile_level(load_pack(PACK_PATH), level)
|
| 334 |
+
r = run_level(c, _stall, seed=seed)
|
| 335 |
+
assert r.outcome == "loss", (
|
| 336 |
+
f"{level} seed={seed}: stall must be a real timeout LOSS "
|
| 337 |
+
f"(fact never takes damage), got {r.outcome}"
|
| 338 |
+
)
|
| 339 |
+
|
| 340 |
+
|
| 341 |
+
@pytest.mark.parametrize("level", ["medium", "hard"])
|
| 342 |
+
@pytest.mark.parametrize("seed", [1, 2, 3, 4])
|
| 343 |
+
def test_single_squad_north_loses(level, seed):
|
| 344 |
+
"""Single north squad alone vs the guard cluster must LOSE on
|
| 345 |
+
medium (6 guards) and hard (8 guards). Easy with only 3 guards
|
| 346 |
+
may occasionally let a single squad squeak through; the
|
| 347 |
+
discrimination tier is medium+."""
|
| 348 |
+
pytest.importorskip("openra_train")
|
| 349 |
+
from openra_bench.eval_core import run_level
|
| 350 |
+
|
| 351 |
+
c = compile_level(load_pack(PACK_PATH), level)
|
| 352 |
+
r = run_level(c, _single_squad_n, seed=seed)
|
| 353 |
+
assert r.outcome == "loss", (
|
| 354 |
+
f"{level} seed={seed}: single-squad north must LOSE "
|
| 355 |
+
f"(3 tanks vs guard cluster), got {r.outcome} "
|
| 356 |
+
f"(lost={r.signals.units_lost}, "
|
| 357 |
+
f"bldgs={r.signals.enemy_buildings_destroyed_types})"
|
| 358 |
+
)
|
| 359 |
+
|
| 360 |
+
|
| 361 |
+
@pytest.mark.parametrize("level", ["medium", "hard"])
|
| 362 |
+
@pytest.mark.parametrize("seed", [1, 2, 3, 4])
|
| 363 |
+
def test_two_squads_loses(level, seed):
|
| 364 |
+
"""Two squads (north + south) without the third must LOSE on
|
| 365 |
+
medium (6 guards) and hard (8 guards) — 6 tanks vs the cluster
|
| 366 |
+
trade out and the surviving in-region count drops below n=6 (or
|
| 367 |
+
the fact never falls)."""
|
| 368 |
+
pytest.importorskip("openra_train")
|
| 369 |
+
from openra_bench.eval_core import run_level
|
| 370 |
+
|
| 371 |
+
c = compile_level(load_pack(PACK_PATH), level)
|
| 372 |
+
r = run_level(c, _two_squads, seed=seed)
|
| 373 |
+
assert r.outcome == "loss", (
|
| 374 |
+
f"{level} seed={seed}: two-squad converge must LOSE on "
|
| 375 |
+
f"medium/hard (defender density overwhelms 6 tanks), "
|
| 376 |
+
f"got {r.outcome} (lost={r.signals.units_lost}, "
|
| 377 |
+
f"bldgs={r.signals.enemy_buildings_destroyed_types})"
|
| 378 |
+
)
|
|
@@ -0,0 +1,395 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""coord-cover-and-move — bounding-overwatch across a centre fire zone.
|
| 2 |
+
|
| 3 |
+
The bar (binding):
|
| 4 |
+
- intended bound-and-cover policy WINS on every level + every hard
|
| 5 |
+
seed (1..4);
|
| 6 |
+
- stall / both-charge-together / single-squad-only LOSE on every level
|
| 7 |
+
+ every hard seed (with one documented exception: easy uses e1 rifle
|
| 8 |
+
infantry which does limited damage to 2tnk armour, so both-charge
|
| 9 |
+
and single-squad may squeak through — bare-skill tier; matches the
|
| 10 |
+
pack design comment and SCENARIO_REVIEW_CHECKLIST.md note that
|
| 11 |
+
"inert anti-cheat teeth are acceptable on easy");
|
| 12 |
+
- non-win is a real reachable timeout LOSS via the `after_ticks`
|
| 13 |
+
fail clause (no DRAW degeneracy: 4501 ≤ 93 + 90·(max_turns − 1));
|
| 14 |
+
- hard ships ≥2 `spawn_point` groups (seed-driven start variation).
|
| 15 |
+
|
| 16 |
+
Validation is scripted (no model / network).
|
| 17 |
+
"""
|
| 18 |
+
from __future__ import annotations
|
| 19 |
+
|
| 20 |
+
from pathlib import Path
|
| 21 |
+
|
| 22 |
+
import pytest
|
| 23 |
+
|
| 24 |
+
pytest.importorskip("openra_rl_training", reason="Rust env wheel not installed")
|
| 25 |
+
from openra_bench.scenarios import load_pack
|
| 26 |
+
from openra_bench.scenarios.loader import compile_level
|
| 27 |
+
from openra_bench.scenarios.win_conditions import WinContext, evaluate
|
| 28 |
+
|
| 29 |
+
PACKS = Path(__file__).parent.parent / "openra_bench" / "scenarios" / "packs"
|
| 30 |
+
PACK_PATH = PACKS / "coord-cover-and-move.yaml"
|
| 31 |
+
|
| 32 |
+
|
| 33 |
+
# ── declarative / schema invariants (no engine needed) ─────────────
|
| 34 |
+
|
| 35 |
+
|
| 36 |
+
def test_pack_compiles_and_meta_fields_populated():
|
| 37 |
+
pack = load_pack(PACK_PATH)
|
| 38 |
+
assert pack.meta.id == "coord-cover-and-move"
|
| 39 |
+
assert pack.meta.capability == "action"
|
| 40 |
+
assert pack.meta.real_world_meaning, "real_world_meaning required"
|
| 41 |
+
assert pack.meta.robotics_analogue, "robotics_analogue required"
|
| 42 |
+
anchors = pack.meta.benchmark_anchor
|
| 43 |
+
assert isinstance(anchors, list) and len(anchors) == 4, (
|
| 44 |
+
f"benchmark_anchor must list all 4 anchors, got {anchors!r}"
|
| 45 |
+
)
|
| 46 |
+
joined = " ".join(anchors).lower()
|
| 47 |
+
# Spec-mandated anchors: military bounding overwatch + fire-and-
|
| 48 |
+
# maneuver + SC2 tank movement + SMAC danger-zone cross.
|
| 49 |
+
for needle in ("bounding overwatch", "fire-and-maneuver",
|
| 50 |
+
"sc2", "smac"):
|
| 51 |
+
assert needle in joined, f"missing anchor keyword: {needle}"
|
| 52 |
+
for lvl in ("easy", "medium", "hard"):
|
| 53 |
+
c = compile_level(pack, lvl)
|
| 54 |
+
assert c.map_supported
|
| 55 |
+
assert c.win_condition is not None
|
| 56 |
+
assert c.fail_condition is not None
|
| 57 |
+
|
| 58 |
+
|
| 59 |
+
def _ctx(*, units=(), tick=1000, kills=0, lost=0):
|
| 60 |
+
"""Synthesize a WinContext for predicate-level checks."""
|
| 61 |
+
import types
|
| 62 |
+
|
| 63 |
+
sig = types.SimpleNamespace(
|
| 64 |
+
game_tick=tick,
|
| 65 |
+
units_killed=kills,
|
| 66 |
+
units_lost=lost,
|
| 67 |
+
cash=0,
|
| 68 |
+
resources=0,
|
| 69 |
+
own_buildings=[],
|
| 70 |
+
own_building_types=set(),
|
| 71 |
+
enemies_seen_ids=set(),
|
| 72 |
+
enemy_buildings_seen_ids=set(),
|
| 73 |
+
)
|
| 74 |
+
return WinContext(
|
| 75 |
+
signals=sig,
|
| 76 |
+
render_state={"units_summary": list(units)},
|
| 77 |
+
)
|
| 78 |
+
|
| 79 |
+
|
| 80 |
+
def _tanks_at(n, x=100, y=20):
|
| 81 |
+
return [
|
| 82 |
+
{"cell_x": x, "cell_y": y, "type": "2tnk", "id": str(2000 + i)}
|
| 83 |
+
for i in range(n)
|
| 84 |
+
]
|
| 85 |
+
|
| 86 |
+
|
| 87 |
+
@pytest.mark.parametrize("lvl", ["easy", "medium", "hard"])
|
| 88 |
+
def test_win_requires_four_units_in_east_region(lvl):
|
| 89 |
+
c = compile_level(load_pack(PACK_PATH), lvl)
|
| 90 |
+
# 4 tanks in win region, 0 lost, in time → WIN
|
| 91 |
+
cap = 0 if lvl == "hard" else 1
|
| 92 |
+
assert evaluate(c.win_condition,
|
| 93 |
+
_ctx(units=_tanks_at(4), tick=2000, lost=0))
|
| 94 |
+
# Only 3 in region → not a win
|
| 95 |
+
assert not evaluate(c.win_condition,
|
| 96 |
+
_ctx(units=_tanks_at(3), tick=2000, lost=0))
|
| 97 |
+
# 4 in region but attrition cap busted → not a win
|
| 98 |
+
assert not evaluate(
|
| 99 |
+
c.win_condition,
|
| 100 |
+
_ctx(units=_tanks_at(4), tick=2000, lost=cap + 1),
|
| 101 |
+
)
|
| 102 |
+
# 4 in region but past the deadline → not a win
|
| 103 |
+
assert not evaluate(c.win_condition,
|
| 104 |
+
_ctx(units=_tanks_at(4), tick=4600, lost=0))
|
| 105 |
+
|
| 106 |
+
|
| 107 |
+
@pytest.mark.parametrize("lvl", ["easy", "medium", "hard"])
|
| 108 |
+
def test_fail_clauses_are_reachable_losses(lvl):
|
| 109 |
+
c = compile_level(load_pack(PACK_PATH), lvl)
|
| 110 |
+
cap = 0 if lvl == "hard" else 1
|
| 111 |
+
# Past deadline → fail
|
| 112 |
+
assert evaluate(c.fail_condition,
|
| 113 |
+
_ctx(units=_tanks_at(6, x=10), tick=4600, lost=0))
|
| 114 |
+
# Attrition cap busted → fail
|
| 115 |
+
assert evaluate(c.fail_condition,
|
| 116 |
+
_ctx(units=_tanks_at(5), tick=2000, lost=cap + 1))
|
| 117 |
+
# Force wipe → fail
|
| 118 |
+
assert evaluate(c.fail_condition,
|
| 119 |
+
_ctx(units=[], tick=2000, lost=6))
|
| 120 |
+
|
| 121 |
+
|
| 122 |
+
def test_timeout_reachable_inside_max_turns():
|
| 123 |
+
"""No draw degeneracy: after_ticks 4501 ≤ 93 + 90·(max_turns-1)."""
|
| 124 |
+
pack = load_pack(PACK_PATH)
|
| 125 |
+
for lvl in ("easy", "medium", "hard"):
|
| 126 |
+
c = compile_level(pack, lvl)
|
| 127 |
+
max_tick = 93 + 90 * (c.max_turns - 1)
|
| 128 |
+
assert 4501 <= max_tick, (
|
| 129 |
+
f"{lvl}: after_ticks 4501 > max reachable tick {max_tick} "
|
| 130 |
+
f"(max_turns={c.max_turns}); deadline never bites"
|
| 131 |
+
)
|
| 132 |
+
assert 4500 <= max_tick, (
|
| 133 |
+
f"{lvl}: within_ticks 4500 > max reachable tick {max_tick}"
|
| 134 |
+
)
|
| 135 |
+
|
| 136 |
+
|
| 137 |
+
def test_hard_has_two_spawn_point_groups():
|
| 138 |
+
"""Hard curation: ≥2 distinct agent spawn_point groups so the
|
| 139 |
+
seed round-robins the staging corner (NW / SW). Engine-roundtrip
|
| 140 |
+
asserted by tests/test_hard_tier.py."""
|
| 141 |
+
c = compile_level(load_pack(PACK_PATH), "hard")
|
| 142 |
+
groups = {
|
| 143 |
+
(a.spawn_point if a.spawn_point is not None else 0)
|
| 144 |
+
for a in c.scenario.actors
|
| 145 |
+
if a.owner == "agent"
|
| 146 |
+
}
|
| 147 |
+
assert len(groups) >= 2, f"hard needs ≥2 spawn_point groups, got {groups}"
|
| 148 |
+
|
| 149 |
+
|
| 150 |
+
def test_two_squads_six_tanks_each_level():
|
| 151 |
+
"""Two squads (3+3 = 6 tanks total per spawn) is the load-bearing
|
| 152 |
+
geometry — fewer tanks and the cover/bound roles cannot afford
|
| 153 |
+
losses; more and the cap is meaningless. Hard places the same 6
|
| 154 |
+
per spawn_point group across two groups."""
|
| 155 |
+
pack = load_pack(PACK_PATH)
|
| 156 |
+
for lvl in ("easy", "medium"):
|
| 157 |
+
c = compile_level(pack, lvl)
|
| 158 |
+
tanks = [a for a in c.scenario.actors
|
| 159 |
+
if a.owner == "agent" and a.type == "2tnk"]
|
| 160 |
+
assert len(tanks) == 6, f"{lvl}: expected 6 tanks, got {len(tanks)}"
|
| 161 |
+
c = compile_level(pack, "hard")
|
| 162 |
+
per_group = {}
|
| 163 |
+
for a in c.scenario.actors:
|
| 164 |
+
if a.owner != "agent" or a.type != "2tnk":
|
| 165 |
+
continue
|
| 166 |
+
sp = a.spawn_point if a.spawn_point is not None else 0
|
| 167 |
+
per_group[sp] = per_group.get(sp, 0) + 1
|
| 168 |
+
assert all(n == 6 for n in per_group.values()), (
|
| 169 |
+
f"hard: each spawn_point group should have 6 tanks, got {per_group}"
|
| 170 |
+
)
|
| 171 |
+
|
| 172 |
+
|
| 173 |
+
def test_fire_zone_uses_anti_tank_rocket_on_medium_and_hard():
|
| 174 |
+
"""The fire zone's lethality is the load-bearing property —
|
| 175 |
+
medium / hard must use e3 (anti-tank rocket soldier, dps12 vs
|
| 176 |
+
armour) at the centre cluster. Easy may use e1 (forgiving)."""
|
| 177 |
+
pack = load_pack(PACK_PATH)
|
| 178 |
+
for lvl in ("medium", "hard"):
|
| 179 |
+
c = compile_level(pack, lvl)
|
| 180 |
+
types = [a.type for a in c.scenario.actors if a.owner == "enemy"]
|
| 181 |
+
assert types.count("e3") >= 4, (
|
| 182 |
+
f"{lvl}: need ≥4 e3 (anti-tank rocket) for fire zone; got {types}"
|
| 183 |
+
)
|
| 184 |
+
# Persistent far enemy marker (engine auto-done mitigation).
|
| 185 |
+
assert "fact" in types, f"{lvl}: needs persistent enemy fact"
|
| 186 |
+
|
| 187 |
+
|
| 188 |
+
# ── engine-driven scripted policies ────────────────────────────────
|
| 189 |
+
|
| 190 |
+
|
| 191 |
+
def _own_units(rs):
|
| 192 |
+
return rs.get("units_summary", []) or []
|
| 193 |
+
|
| 194 |
+
|
| 195 |
+
def _own_ids(rs):
|
| 196 |
+
return [str(u["id"]) for u in _own_units(rs)]
|
| 197 |
+
|
| 198 |
+
|
| 199 |
+
def _stall(rs, Command):
|
| 200 |
+
"""Pure observe — no squad crosses, win region stays empty,
|
| 201 |
+
after_ticks LOSS."""
|
| 202 |
+
return [Command.observe()]
|
| 203 |
+
|
| 204 |
+
|
| 205 |
+
def _both_charge_together(rs, Command):
|
| 206 |
+
"""Both squads sprint east through the centre on the same y-band.
|
| 207 |
+
Engine spreads fire across the dense column → on medium/hard the
|
| 208 |
+
4× e3 anti-tank dps stacks enough to bust units_lost_lte:1 → LOSS."""
|
| 209 |
+
units = _own_units(rs)
|
| 210 |
+
if not units:
|
| 211 |
+
return [Command.observe()]
|
| 212 |
+
cmds = []
|
| 213 |
+
for u in units:
|
| 214 |
+
# Everyone barrels east toward the win region (100,20).
|
| 215 |
+
cmds.append(Command.move_units([str(u["id"])], 100, 20))
|
| 216 |
+
return cmds
|
| 217 |
+
|
| 218 |
+
|
| 219 |
+
def _single_squad_only(rs, Command):
|
| 220 |
+
"""Only the NORTHERN squad (y < 20 at spawn) crosses; the southern
|
| 221 |
+
squad is idle. The lone column absorbs ALL the centre cluster's
|
| 222 |
+
fire and busts the attrition cap → LOSS on medium/hard."""
|
| 223 |
+
units = _own_units(rs)
|
| 224 |
+
if not units:
|
| 225 |
+
return [Command.observe()]
|
| 226 |
+
cmds = []
|
| 227 |
+
for u in units:
|
| 228 |
+
if u["cell_y"] < 20:
|
| 229 |
+
cmds.append(Command.move_units([str(u["id"])], 100, 20))
|
| 230 |
+
else:
|
| 231 |
+
cmds.append(Command.stop([str(u["id"])]))
|
| 232 |
+
return cmds
|
| 233 |
+
|
| 234 |
+
|
| 235 |
+
def _intended_bound_and_cover(rs, Command):
|
| 236 |
+
"""Bounding overwatch:
|
| 237 |
+
|
| 238 |
+
Phase 1 — cover squad (northern at spawn, by y) advances to its
|
| 239 |
+
overwatch post at (~45, ~spawn_y) at the EDGE of the e3 range
|
| 240 |
+
(tank rng 4.75 vs e3 rng 4 — tanks fire from dist ~5);
|
| 241 |
+
bounding squad (southern) takes the FAR-NORTH wide-flank route
|
| 242 |
+
outside the fire envelope: detour to (~50, ~6 or ~34) then
|
| 243 |
+
east toward (~100, ~20).
|
| 244 |
+
Phase 2 — bounding squad pushed past the cluster onto the east
|
| 245 |
+
side; switch — original cover squad now relocates to the far
|
| 246 |
+
wide-flank route, then east.
|
| 247 |
+
|
| 248 |
+
The policy doesn't need ticks-aware role-flipping: it routes the
|
| 249 |
+
SOUTH squad through y≈6 (or y≈34 if south spawn) — the wide flank
|
| 250 |
+
— and the NORTH squad through y≈6 as well after a stagger. The
|
| 251 |
+
cover team's fire SUPPRESSES the cluster while the bounding team
|
| 252 |
+
is in transit. A simple implementation:
|
| 253 |
+
- sort own units by y; northern half = COVER, southern half = MOVE
|
| 254 |
+
- for the NORTH spawn (median y < 20): MOVE goes via y=8 then
|
| 255 |
+
east; COVER drives to (45, 15) and attack_moves on cluster.
|
| 256 |
+
- for the SOUTH spawn (median y > 20): mirror — MOVE goes via
|
| 257 |
+
y=32 then east; COVER drives to (45, 25) and attack_moves.
|
| 258 |
+
- once MOVE has cleared the fire zone (cell_x > 70), COVER also
|
| 259 |
+
starts the wide-flank route through the same outside-sight
|
| 260 |
+
band and follows east.
|
| 261 |
+
"""
|
| 262 |
+
units = _own_units(rs)
|
| 263 |
+
if not units:
|
| 264 |
+
return [Command.observe()]
|
| 265 |
+
|
| 266 |
+
# Identify spawn geometry by current median y. Squads start with
|
| 267 |
+
# y ∈ {11..17} (NORTH spawn) or {23..29} (SOUTH spawn) in hard.
|
| 268 |
+
# Easy/medium always use y ∈ {14..16, 24..26}.
|
| 269 |
+
ys = sorted(u["cell_y"] for u in units)
|
| 270 |
+
median_y = ys[len(ys) // 2]
|
| 271 |
+
|
| 272 |
+
if median_y < 20:
|
| 273 |
+
# NORTH spawn: COVER on the south-of-spawn flank near the
|
| 274 |
+
# cluster's NORTH edge; MOVE detours through y ≈ 6..8.
|
| 275 |
+
flank_y = 8
|
| 276 |
+
cover_y = 15
|
| 277 |
+
else:
|
| 278 |
+
# SOUTH spawn (hard only): mirror across y=20.
|
| 279 |
+
flank_y = 32
|
| 280 |
+
cover_y = 25
|
| 281 |
+
|
| 282 |
+
# Split units: the half closer to the cluster's y axis (i.e.
|
| 283 |
+
# closer to y=20) becomes COVER; the half farther from y=20
|
| 284 |
+
# becomes MOVE (these are the ones we'll route via the wide flank).
|
| 285 |
+
units_sorted = sorted(units, key=lambda u: abs(u["cell_y"] - 20))
|
| 286 |
+
half = len(units_sorted) // 2
|
| 287 |
+
cover_team = units_sorted[:half]
|
| 288 |
+
move_team = units_sorted[half:]
|
| 289 |
+
|
| 290 |
+
cmds = []
|
| 291 |
+
# Check whether MOVE team has cleared the fire zone (any move-team
|
| 292 |
+
# tank with x ≥ 75). When cleared, COVER also begins its bound.
|
| 293 |
+
move_cleared = any(u["cell_x"] >= 75 for u in move_team)
|
| 294 |
+
|
| 295 |
+
# COVER team: drive to overwatch post and attack_move onto the
|
| 296 |
+
# cluster centre (the engine auto-targets nearest hostile in
|
| 297 |
+
# range; with cover at (45, cover_y) and cluster at (50, 20), the
|
| 298 |
+
# e3s are the visible targets and tanks fire dps22 each).
|
| 299 |
+
for u in cover_team:
|
| 300 |
+
if not move_cleared:
|
| 301 |
+
if u["cell_x"] < 45:
|
| 302 |
+
# Still approaching the overwatch post — drive into
|
| 303 |
+
# firing range of the cluster.
|
| 304 |
+
cmds.append(Command.attack_move([str(u["id"])], 45, cover_y))
|
| 305 |
+
else:
|
| 306 |
+
# Posted: keep firing. attack_move onto cluster cell.
|
| 307 |
+
cmds.append(Command.attack_move([str(u["id"])], 50, 20))
|
| 308 |
+
else:
|
| 309 |
+
# MOVE team has crossed; COVER bounds through the wide
|
| 310 |
+
# flank too (same y-band the MOVE team used).
|
| 311 |
+
if u["cell_x"] < 60:
|
| 312 |
+
# Detour north/south away from the cluster first.
|
| 313 |
+
cmds.append(Command.move_units([str(u["id"])], 50, flank_y))
|
| 314 |
+
else:
|
| 315 |
+
cmds.append(Command.move_units([str(u["id"])], 100, 20))
|
| 316 |
+
|
| 317 |
+
# MOVE team: wide-flank route. Stage 1 detour to (50, flank_y) so
|
| 318 |
+
# the column stays outside the cluster's sight envelope; stage 2
|
| 319 |
+
# east toward the win region.
|
| 320 |
+
for u in move_team:
|
| 321 |
+
if u["cell_x"] < 60:
|
| 322 |
+
cmds.append(Command.move_units([str(u["id"])], 50, flank_y))
|
| 323 |
+
elif u["cell_x"] < 90:
|
| 324 |
+
cmds.append(Command.move_units([str(u["id"])], 90, 20))
|
| 325 |
+
else:
|
| 326 |
+
cmds.append(Command.move_units([str(u["id"])], 100, 20))
|
| 327 |
+
return cmds
|
| 328 |
+
|
| 329 |
+
|
| 330 |
+
@pytest.mark.parametrize("level", ["easy", "medium", "hard"])
|
| 331 |
+
@pytest.mark.parametrize("seed", [1, 2, 3, 4])
|
| 332 |
+
def test_intended_bound_and_cover_wins(level, seed):
|
| 333 |
+
pytest.importorskip("openra_train")
|
| 334 |
+
from openra_bench.eval_core import run_level
|
| 335 |
+
|
| 336 |
+
c = compile_level(load_pack(PACK_PATH), level)
|
| 337 |
+
r = run_level(c, _intended_bound_and_cover, seed=seed)
|
| 338 |
+
assert r.outcome == "win", (
|
| 339 |
+
f"{level} seed={seed}: bound-and-cover should WIN, got "
|
| 340 |
+
f"{r.outcome} after {r.turns} turns "
|
| 341 |
+
f"(losses={r.signals.units_lost})"
|
| 342 |
+
)
|
| 343 |
+
|
| 344 |
+
|
| 345 |
+
@pytest.mark.parametrize("level", ["easy", "medium", "hard"])
|
| 346 |
+
@pytest.mark.parametrize("seed", [1, 2, 3, 4])
|
| 347 |
+
def test_stall_loses(level, seed):
|
| 348 |
+
pytest.importorskip("openra_train")
|
| 349 |
+
from openra_bench.eval_core import run_level
|
| 350 |
+
|
| 351 |
+
c = compile_level(load_pack(PACK_PATH), level)
|
| 352 |
+
r = run_level(c, _stall, seed=seed)
|
| 353 |
+
assert r.outcome == "loss", (
|
| 354 |
+
f"{level} seed={seed}: stall must be a real timeout LOSS "
|
| 355 |
+
f"(no squad crosses → win region empty → after_ticks fires), "
|
| 356 |
+
f"got {r.outcome}"
|
| 357 |
+
)
|
| 358 |
+
|
| 359 |
+
|
| 360 |
+
@pytest.mark.parametrize("level", ["medium", "hard"])
|
| 361 |
+
@pytest.mark.parametrize("seed", [1, 2, 3, 4])
|
| 362 |
+
def test_both_charge_together_loses(level, seed):
|
| 363 |
+
"""Both squads sprint together → fire stacks on dense column →
|
| 364 |
+
busts units_lost_lte cap on medium and hard. Easy excluded
|
| 365 |
+
(e1 rifle does limited damage to armour; forgiving bare-skill
|
| 366 |
+
tier; matches SCENARIO_REVIEW_CHECKLIST.md note that "inert
|
| 367 |
+
anti-cheat teeth are acceptable on easy")."""
|
| 368 |
+
pytest.importorskip("openra_train")
|
| 369 |
+
from openra_bench.eval_core import run_level
|
| 370 |
+
|
| 371 |
+
c = compile_level(load_pack(PACK_PATH), level)
|
| 372 |
+
r = run_level(c, _both_charge_together, seed=seed)
|
| 373 |
+
assert r.outcome == "loss", (
|
| 374 |
+
f"{level} seed={seed}: both-charge must LOSE (stacked e3 fire "
|
| 375 |
+
f"busts attrition cap), got {r.outcome} "
|
| 376 |
+
f"(losses={r.signals.units_lost})"
|
| 377 |
+
)
|
| 378 |
+
|
| 379 |
+
|
| 380 |
+
@pytest.mark.parametrize("level", ["medium", "hard"])
|
| 381 |
+
@pytest.mark.parametrize("seed", [1, 2, 3, 4])
|
| 382 |
+
def test_single_squad_only_loses(level, seed):
|
| 383 |
+
"""Only one squad crosses → lone column absorbs ALL cluster fire
|
| 384 |
+
→ busts attrition cap on medium/hard. Easy excluded (same
|
| 385 |
+
forgiving-bare-skill reasoning as both-charge above)."""
|
| 386 |
+
pytest.importorskip("openra_train")
|
| 387 |
+
from openra_bench.eval_core import run_level
|
| 388 |
+
|
| 389 |
+
c = compile_level(load_pack(PACK_PATH), level)
|
| 390 |
+
r = run_level(c, _single_squad_only, seed=seed)
|
| 391 |
+
assert r.outcome == "loss", (
|
| 392 |
+
f"{level} seed={seed}: single-squad must LOSE (lone column "
|
| 393 |
+
f"absorbs full cluster fire), got {r.outcome} "
|
| 394 |
+
f"(losses={r.signals.units_lost})"
|
| 395 |
+
)
|
|
@@ -0,0 +1,572 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""coord-diversionary-attack — diversionary / split-attack assault.
|
| 2 |
+
|
| 3 |
+
The bar: intended diversionary-attack (Squad A jeeps drag the
|
| 4 |
+
heavier enemy garrison off the real target by attacking the decoy /
|
| 5 |
+
centre cluster; Squad B tanks raze the REAL fact while the south
|
| 6 |
+
defenders are committed in pursuit) WINS on every level and every
|
| 7 |
+
hard seed (1..4). STALL (only observe), BRUTE-FRONTAL (every unit
|
| 8 |
+
attack-moves onto the centre / decoy), LAZY NEAREST (jeeps onto the
|
| 9 |
+
near-looking fact, tanks onto the near-looking powr), and BAIT-ONLY
|
| 10 |
+
(jeeps slash south but tanks stand still) all LOSE on every level
|
| 11 |
+
and every hard seed. Non-win is a real reachable timeout LOSS via
|
| 12 |
+
the `after_ticks` fail clause; `units_lost_lte` provides the second
|
| 13 |
+
LOSS path (lazy/brute trades the strike force).
|
| 14 |
+
|
| 15 |
+
Validation is scripted (no model / network).
|
| 16 |
+
"""
|
| 17 |
+
from __future__ import annotations
|
| 18 |
+
|
| 19 |
+
from pathlib import Path
|
| 20 |
+
|
| 21 |
+
import pytest
|
| 22 |
+
|
| 23 |
+
pytest.importorskip("openra_rl_training", reason="Rust env wheel not installed")
|
| 24 |
+
from openra_bench.scenarios import load_pack
|
| 25 |
+
from openra_bench.scenarios.loader import compile_level
|
| 26 |
+
from openra_bench.scenarios.win_conditions import WinContext, evaluate
|
| 27 |
+
|
| 28 |
+
PACKS = Path(__file__).parent.parent / "openra_bench" / "scenarios" / "packs"
|
| 29 |
+
PACK_PATH = PACKS / "coord-diversionary-attack.yaml"
|
| 30 |
+
|
| 31 |
+
|
| 32 |
+
# ── unit-level predicate / metadata checks (no engine) ──────────────
|
| 33 |
+
|
| 34 |
+
|
| 35 |
+
def test_pack_compiles_and_meta_fields_populated():
|
| 36 |
+
pack = load_pack(PACK_PATH)
|
| 37 |
+
assert pack.meta.id == "coord-diversionary-attack"
|
| 38 |
+
assert pack.meta.capability == "reasoning"
|
| 39 |
+
assert pack.meta.real_world_meaning, "real_world_meaning required"
|
| 40 |
+
assert pack.meta.robotics_analogue, "robotics_analogue required"
|
| 41 |
+
anchors = pack.meta.benchmark_anchor
|
| 42 |
+
assert isinstance(anchors, list) and len(anchors) >= 3, (
|
| 43 |
+
f"benchmark_anchor must list ≥3 anchors, got {anchors!r}"
|
| 44 |
+
)
|
| 45 |
+
joined = " ".join(anchors).lower()
|
| 46 |
+
for needle in ("sc2", "diversion", "cicero"):
|
| 47 |
+
assert needle in joined, f"missing anchor keyword: {needle}"
|
| 48 |
+
for lvl in ("easy", "medium", "hard"):
|
| 49 |
+
c = compile_level(pack, lvl)
|
| 50 |
+
assert c.map_supported
|
| 51 |
+
assert c.win_condition is not None
|
| 52 |
+
assert c.fail_condition is not None
|
| 53 |
+
|
| 54 |
+
|
| 55 |
+
def _ctx(*, units=(), tick=1000, lost=0, destroyed_records=()):
|
| 56 |
+
"""Synthesize a WinContext for predicate-level checks."""
|
| 57 |
+
import types
|
| 58 |
+
|
| 59 |
+
types_count: dict[str, int] = {}
|
| 60 |
+
for t, _x, _y in destroyed_records:
|
| 61 |
+
types_count[t] = types_count.get(t, 0) + 1
|
| 62 |
+
sig = types.SimpleNamespace(
|
| 63 |
+
game_tick=tick,
|
| 64 |
+
units_killed=0,
|
| 65 |
+
units_lost=lost,
|
| 66 |
+
cash=0,
|
| 67 |
+
resources=0,
|
| 68 |
+
own_buildings=[],
|
| 69 |
+
own_building_types=set(),
|
| 70 |
+
enemies_seen_ids=set(),
|
| 71 |
+
enemy_buildings_seen_ids=set(),
|
| 72 |
+
enemy_buildings_destroyed=len(destroyed_records),
|
| 73 |
+
enemy_buildings_destroyed_types=types_count,
|
| 74 |
+
enemy_buildings_destroyed_records=list(destroyed_records),
|
| 75 |
+
)
|
| 76 |
+
return WinContext(
|
| 77 |
+
signals=sig,
|
| 78 |
+
render_state={"units_summary": list(units)},
|
| 79 |
+
)
|
| 80 |
+
|
| 81 |
+
|
| 82 |
+
def _alive(n, kind="2tnk"):
|
| 83 |
+
return [
|
| 84 |
+
{"cell_x": 50, "cell_y": 20, "type": kind, "id": str(1000 + i)}
|
| 85 |
+
for i in range(n)
|
| 86 |
+
]
|
| 87 |
+
|
| 88 |
+
|
| 89 |
+
def test_easy_predicates():
|
| 90 |
+
c = compile_level(load_pack(PACK_PATH), "easy")
|
| 91 |
+
# Intended: fact at (100,10) destroyed (in region), 3 lost, in budget → WIN
|
| 92 |
+
assert evaluate(
|
| 93 |
+
c.win_condition,
|
| 94 |
+
_ctx(units=_alive(4), tick=3000, lost=3,
|
| 95 |
+
destroyed_records=[("fact", 100, 10)]),
|
| 96 |
+
)
|
| 97 |
+
# Razing only the decoy powr at (100,30) → not a win
|
| 98 |
+
assert not evaluate(
|
| 99 |
+
c.win_condition,
|
| 100 |
+
_ctx(units=_alive(4), tick=3000, lost=1,
|
| 101 |
+
destroyed_records=[("powr", 100, 30)]),
|
| 102 |
+
)
|
| 103 |
+
# Razing the sentinel fact at (125,38) is outside the region → not a win
|
| 104 |
+
assert not evaluate(
|
| 105 |
+
c.win_condition,
|
| 106 |
+
_ctx(units=_alive(4), tick=3000, lost=1,
|
| 107 |
+
destroyed_records=[("fact", 125, 38)]),
|
| 108 |
+
)
|
| 109 |
+
# Loss cap (3) tripped (4 lost) → not a win
|
| 110 |
+
assert not evaluate(
|
| 111 |
+
c.win_condition,
|
| 112 |
+
_ctx(units=_alive(2), tick=3000, lost=4,
|
| 113 |
+
destroyed_records=[("fact", 100, 10)]),
|
| 114 |
+
)
|
| 115 |
+
# Past deadline → not a win
|
| 116 |
+
assert not evaluate(
|
| 117 |
+
c.win_condition,
|
| 118 |
+
_ctx(units=_alive(4), tick=5401, lost=1,
|
| 119 |
+
destroyed_records=[("fact", 100, 10)]),
|
| 120 |
+
)
|
| 121 |
+
# Timeout with bar unmet → fail
|
| 122 |
+
assert evaluate(
|
| 123 |
+
c.fail_condition,
|
| 124 |
+
_ctx(units=_alive(4), tick=5500, lost=1, destroyed_records=[]),
|
| 125 |
+
)
|
| 126 |
+
# Force-wipe → fail
|
| 127 |
+
assert evaluate(
|
| 128 |
+
c.fail_condition,
|
| 129 |
+
_ctx(units=[], tick=3000, lost=7, destroyed_records=[]),
|
| 130 |
+
)
|
| 131 |
+
# Loss cap (>3) → fail
|
| 132 |
+
assert evaluate(
|
| 133 |
+
c.fail_condition,
|
| 134 |
+
_ctx(units=_alive(1), tick=3000, lost=4,
|
| 135 |
+
destroyed_records=[("fact", 100, 10)]),
|
| 136 |
+
)
|
| 137 |
+
|
| 138 |
+
|
| 139 |
+
def test_medium_predicates():
|
| 140 |
+
c = compile_level(load_pack(PACK_PATH), "medium")
|
| 141 |
+
# Intended: fact destroyed, 3 lost (3 bait jeeps), within budget → WIN
|
| 142 |
+
assert evaluate(
|
| 143 |
+
c.win_condition,
|
| 144 |
+
_ctx(units=_alive(4), tick=4000, lost=3,
|
| 145 |
+
destroyed_records=[("fact", 100, 10)]),
|
| 146 |
+
)
|
| 147 |
+
# 4 lost (one tank lost) → not a win, AND fail
|
| 148 |
+
assert not evaluate(
|
| 149 |
+
c.win_condition,
|
| 150 |
+
_ctx(units=_alive(3), tick=4000, lost=4,
|
| 151 |
+
destroyed_records=[("fact", 100, 10)]),
|
| 152 |
+
)
|
| 153 |
+
assert evaluate(
|
| 154 |
+
c.fail_condition,
|
| 155 |
+
_ctx(units=_alive(3), tick=4000, lost=4,
|
| 156 |
+
destroyed_records=[("fact", 100, 10)]),
|
| 157 |
+
)
|
| 158 |
+
# Only decoy razed → not a win
|
| 159 |
+
assert not evaluate(
|
| 160 |
+
c.win_condition,
|
| 161 |
+
_ctx(units=_alive(4), tick=4000, lost=1,
|
| 162 |
+
destroyed_records=[("powr", 100, 30)]),
|
| 163 |
+
)
|
| 164 |
+
|
| 165 |
+
|
| 166 |
+
def test_hard_predicates():
|
| 167 |
+
c = compile_level(load_pack(PACK_PATH), "hard")
|
| 168 |
+
# Intended: NE fact destroyed → WIN
|
| 169 |
+
assert evaluate(
|
| 170 |
+
c.win_condition,
|
| 171 |
+
_ctx(units=_alive(4), tick=4000, lost=2,
|
| 172 |
+
destroyed_records=[("fact", 100, 10)]),
|
| 173 |
+
)
|
| 174 |
+
# Intended (mirror): SE fact destroyed → WIN
|
| 175 |
+
assert evaluate(
|
| 176 |
+
c.win_condition,
|
| 177 |
+
_ctx(units=_alive(4), tick=4000, lost=2,
|
| 178 |
+
destroyed_records=[("fact", 100, 30)]),
|
| 179 |
+
)
|
| 180 |
+
# Only decoy powr at centre → not a win
|
| 181 |
+
assert not evaluate(
|
| 182 |
+
c.win_condition,
|
| 183 |
+
_ctx(units=_alive(4), tick=4000, lost=1,
|
| 184 |
+
destroyed_records=[("powr", 100, 20)]),
|
| 185 |
+
)
|
| 186 |
+
# 3 lost → not a win, AND fail (cap is 2 on hard)
|
| 187 |
+
assert not evaluate(
|
| 188 |
+
c.win_condition,
|
| 189 |
+
_ctx(units=_alive(3), tick=4000, lost=3,
|
| 190 |
+
destroyed_records=[("fact", 100, 10)]),
|
| 191 |
+
)
|
| 192 |
+
assert evaluate(
|
| 193 |
+
c.fail_condition,
|
| 194 |
+
_ctx(units=_alive(3), tick=4000, lost=3,
|
| 195 |
+
destroyed_records=[("fact", 100, 10)]),
|
| 196 |
+
)
|
| 197 |
+
|
| 198 |
+
|
| 199 |
+
def test_timeout_reachable_inside_max_turns():
|
| 200 |
+
"""No draw degeneracy: after_ticks 5401 ≤ 93 + 90·(max_turns-1)."""
|
| 201 |
+
pack = load_pack(PACK_PATH)
|
| 202 |
+
for lvl in ("easy", "medium", "hard"):
|
| 203 |
+
c = compile_level(pack, lvl)
|
| 204 |
+
max_tick = 93 + 90 * (c.max_turns - 1)
|
| 205 |
+
assert 5401 <= max_tick, (
|
| 206 |
+
f"{lvl}: after_ticks 5401 > max reachable tick {max_tick} "
|
| 207 |
+
f"(max_turns={c.max_turns}); deadline never bites"
|
| 208 |
+
)
|
| 209 |
+
|
| 210 |
+
|
| 211 |
+
def test_hard_has_two_spawn_point_groups():
|
| 212 |
+
"""Hard-tier curation: ≥2 distinct agent spawn_point groups so the
|
| 213 |
+
seed round-robins the staging latitude (NORTH y≈10..14 / SOUTH
|
| 214 |
+
y≈26..30). Engine round-trip asserted by tests/test_hard_tier.py."""
|
| 215 |
+
c = compile_level(load_pack(PACK_PATH), "hard")
|
| 216 |
+
groups = {
|
| 217 |
+
(a.spawn_point if a.spawn_point is not None else 0)
|
| 218 |
+
for a in c.scenario.actors
|
| 219 |
+
if a.owner == "agent"
|
| 220 |
+
}
|
| 221 |
+
assert len(groups) >= 2, f"hard needs ≥2 spawn_point groups, got {groups}"
|
| 222 |
+
|
| 223 |
+
|
| 224 |
+
def test_hunt_bot_and_two_target_kinds():
|
| 225 |
+
"""Enemy must be the `hunt` scripted bot (pursues nearest agent
|
| 226 |
+
units — required for the bait to actually pull defenders). Each
|
| 227 |
+
level must include an enemy `fact` (the real / scoring target)
|
| 228 |
+
and an enemy `powr` (the decoy target that does NOT score)."""
|
| 229 |
+
pack = load_pack(PACK_PATH)
|
| 230 |
+
enemy = pack.base.get("enemy") if isinstance(pack.base, dict) else None
|
| 231 |
+
assert enemy is not None
|
| 232 |
+
bot = enemy.get("bot_type") or enemy.get("bot") or ""
|
| 233 |
+
assert bot == "hunt", f"expected hunt bot, got {bot!r}"
|
| 234 |
+
for lvl in ("easy", "medium", "hard"):
|
| 235 |
+
c = compile_level(pack, lvl)
|
| 236 |
+
etypes = [a.type for a in c.scenario.actors if a.owner == "enemy"]
|
| 237 |
+
assert "fact" in etypes, f"{lvl}: must include enemy fact"
|
| 238 |
+
assert "powr" in etypes, f"{lvl}: must include enemy powr decoy"
|
| 239 |
+
|
| 240 |
+
|
| 241 |
+
def test_sentinel_fact_present_per_level():
|
| 242 |
+
"""A persistent enemy `fact` placed far from the objective region
|
| 243 |
+
so the engine does not auto-`done` the instant the objective fact
|
| 244 |
+
dies; within_ticks / units_lost_lte then evaluate cleanly on the
|
| 245 |
+
terminal frame (CLAUDE.md auto-terminate footgun)."""
|
| 246 |
+
pack = load_pack(PACK_PATH)
|
| 247 |
+
for lvl in ("easy", "medium", "hard"):
|
| 248 |
+
c = compile_level(pack, lvl)
|
| 249 |
+
facts = [
|
| 250 |
+
(a.position[0], a.position[1])
|
| 251 |
+
for a in c.scenario.actors
|
| 252 |
+
if a.owner == "enemy" and a.type == "fact"
|
| 253 |
+
]
|
| 254 |
+
# at least one objective candidate near (100,10)/(100,30) AND a
|
| 255 |
+
# far sentinel (>=12 cells from any objective candidate)
|
| 256 |
+
objs = [
|
| 257 |
+
p for p in facts
|
| 258 |
+
if (p[0] - 100) ** 2 + (p[1] - 10) ** 2 <= 6 ** 2
|
| 259 |
+
or (p[0] - 100) ** 2 + (p[1] - 30) ** 2 <= 6 ** 2
|
| 260 |
+
]
|
| 261 |
+
sentinels = [p for p in facts if p not in objs]
|
| 262 |
+
assert objs, f"{lvl}: must place ≥1 enemy fact in an objective region"
|
| 263 |
+
assert sentinels, (
|
| 264 |
+
f"{lvl}: must place ≥1 sentinel enemy fact outside the "
|
| 265 |
+
f"objective region (engine auto-done guard); got facts={facts}"
|
| 266 |
+
)
|
| 267 |
+
|
| 268 |
+
|
| 269 |
+
def test_squad_composition_per_level():
|
| 270 |
+
"""Squad A = 3 jeeps (bait), Squad B = 4 2tnk (strike) per spawn
|
| 271 |
+
group. Each level must place at least one (jeep x3) and one
|
| 272 |
+
(2tnk x4) on the agent side."""
|
| 273 |
+
pack = load_pack(PACK_PATH)
|
| 274 |
+
for lvl in ("easy", "medium", "hard"):
|
| 275 |
+
c = compile_level(pack, lvl)
|
| 276 |
+
jeep_groups = [
|
| 277 |
+
a for a in c.scenario.actors
|
| 278 |
+
if a.owner == "agent" and a.type == "jeep"
|
| 279 |
+
]
|
| 280 |
+
tank_groups = [
|
| 281 |
+
a for a in c.scenario.actors
|
| 282 |
+
if a.owner == "agent" and a.type == "2tnk"
|
| 283 |
+
]
|
| 284 |
+
assert jeep_groups, f"{lvl}: missing agent jeep squad"
|
| 285 |
+
assert tank_groups, f"{lvl}: missing agent 2tnk squad"
|
| 286 |
+
for j in jeep_groups:
|
| 287 |
+
assert (j.count or 1) == 3, f"{lvl}: jeep squad must be 3"
|
| 288 |
+
for t in tank_groups:
|
| 289 |
+
assert (t.count or 1) == 4, f"{lvl}: tank squad must be 4"
|
| 290 |
+
|
| 291 |
+
|
| 292 |
+
# ── engine-driven scripted policies ─────────────────────────────────
|
| 293 |
+
|
| 294 |
+
|
| 295 |
+
def _of_type(rs, want_types):
|
| 296 |
+
out = []
|
| 297 |
+
for u in rs.get("units_summary", []) or []:
|
| 298 |
+
if u.get("type") in want_types:
|
| 299 |
+
out.append(u)
|
| 300 |
+
return out
|
| 301 |
+
|
| 302 |
+
|
| 303 |
+
def _enemies_of_type(rs, want_types):
|
| 304 |
+
out = []
|
| 305 |
+
for e in (rs.get("enemy_summary") or []):
|
| 306 |
+
t = (e.get("type") or e.get("actor_type") or "").lower()
|
| 307 |
+
if t in want_types:
|
| 308 |
+
out.append(e)
|
| 309 |
+
return out
|
| 310 |
+
|
| 311 |
+
|
| 312 |
+
def _enemy_buildings_in_region(rs, cx, cy, r):
|
| 313 |
+
"""Enemies of building kind ~ near (cx, cy) within radius r."""
|
| 314 |
+
out = []
|
| 315 |
+
for e in (rs.get("enemy_summary") or []):
|
| 316 |
+
t = (e.get("type") or e.get("actor_type") or "").lower()
|
| 317 |
+
ex = e.get("cell_x")
|
| 318 |
+
ey = e.get("cell_y")
|
| 319 |
+
if ex is None or ey is None:
|
| 320 |
+
continue
|
| 321 |
+
if (ex - cx) ** 2 + (ey - cy) ** 2 <= r * r:
|
| 322 |
+
out.append((t, ex, ey, e.get("id")))
|
| 323 |
+
return out
|
| 324 |
+
|
| 325 |
+
|
| 326 |
+
def _stall(rs, Command):
|
| 327 |
+
"""Pure observe — fact never takes damage; clock runs out → LOSS."""
|
| 328 |
+
return [Command.observe()]
|
| 329 |
+
|
| 330 |
+
|
| 331 |
+
def _brute_all_on_decoy(rs, Command):
|
| 332 |
+
"""Every unit attack-moves onto the decoy region (powr) — razes
|
| 333 |
+
the wrong target (which doesn't score), and tanks get shredded
|
| 334 |
+
by the heavy e3 anti-tank cluster en route → LOSS."""
|
| 335 |
+
cmds = []
|
| 336 |
+
# Target the decoy powr — easy/medium decoy is at (100,30);
|
| 337 |
+
# hard's decoy is at (100,20). Use centre y=20 for hard, y=30
|
| 338 |
+
# otherwise. Cheap detection: if a powr is at y=20 in vision,
|
| 339 |
+
# pick that; else use (100, 30).
|
| 340 |
+
target_x, target_y = 100, 30
|
| 341 |
+
for e in (rs.get("enemy_summary") or []):
|
| 342 |
+
t = (e.get("type") or "").lower()
|
| 343 |
+
if t == "powr" and e.get("cell_y") is not None:
|
| 344 |
+
target_x, target_y = e["cell_x"], e["cell_y"]
|
| 345 |
+
break
|
| 346 |
+
for u in (rs.get("units_summary", []) or []):
|
| 347 |
+
cmds.append(Command.attack_move([str(u["id"])], target_x, target_y))
|
| 348 |
+
return cmds or [Command.observe()]
|
| 349 |
+
|
| 350 |
+
|
| 351 |
+
def _lazy_nearest(rs, Command):
|
| 352 |
+
"""Lazy "send each squad to its nearest visible target":
|
| 353 |
+
• jeeps → nearest fact/powr in vision (typically the near-
|
| 354 |
+
latitude one — for easy/medium that's the fact at (100,10)
|
| 355 |
+
if NW spawn, or the powr at (100,30) if SW spawn).
|
| 356 |
+
• tanks → nearest fact/powr in vision (the OTHER one).
|
| 357 |
+
The lazy assignment puts jeeps on the wrong-strength target and
|
| 358 |
+
tanks on the WRONG type (the decoy). On easy/medium the lazy
|
| 359 |
+
jeeps-on-fact / tanks-on-powr means tanks raze the decoy (which
|
| 360 |
+
doesn't score) and jeeps can't crack the fact garrison fast
|
| 361 |
+
enough → clock expires → LOSS."""
|
| 362 |
+
cmds = []
|
| 363 |
+
jeeps = _of_type(rs, {"jeep"})
|
| 364 |
+
tanks = _of_type(rs, {"2tnk"})
|
| 365 |
+
if not (jeeps or tanks):
|
| 366 |
+
return [Command.observe()]
|
| 367 |
+
|
| 368 |
+
# Fixed-coord lazy nearest (model can read positions but lazy
|
| 369 |
+
# means send-to-nearest-visible-target without bait logic):
|
| 370 |
+
# jeeps from NW go to (100,10), tanks from SW go to (100,30).
|
| 371 |
+
# Easy/medium objective: fact (100,10) is the real target;
|
| 372 |
+
# this lazy plan razes only powr at (100,30) — fails.
|
| 373 |
+
for j in jeeps:
|
| 374 |
+
jy = j.get("cell_y", 20)
|
| 375 |
+
# nearest by latitude: jeep at y≈10 → (100,10); else (100,30)
|
| 376 |
+
tx, ty = (100, 10) if jy < 20 else (100, 30)
|
| 377 |
+
cmds.append(Command.attack_move([str(j["id"])], tx, ty))
|
| 378 |
+
for t in tanks:
|
| 379 |
+
ty_unit = t.get("cell_y", 20)
|
| 380 |
+
tx, ty = (100, 30) if ty_unit >= 20 else (100, 10)
|
| 381 |
+
cmds.append(Command.attack_move([str(t["id"])], tx, ty))
|
| 382 |
+
return cmds or [Command.observe()]
|
| 383 |
+
|
| 384 |
+
|
| 385 |
+
def _bait_only(rs, Command):
|
| 386 |
+
"""Jeeps slash toward the decoy region (pulls the heavy garrison
|
| 387 |
+
into pursuit), tanks stand still. The fact survives — clock runs
|
| 388 |
+
out → LOSS. Tests that the bait without the counter-attack does
|
| 389 |
+
not score."""
|
| 390 |
+
cmds = []
|
| 391 |
+
for j in _of_type(rs, {"jeep"}):
|
| 392 |
+
# Oscillate around the decoy region (south for easy/medium,
|
| 393 |
+
# centre for hard) so the jeeps stay engaged but moving.
|
| 394 |
+
jy = j.get("cell_y", 20)
|
| 395 |
+
# If on north start, dive into south decoy region; mirror.
|
| 396 |
+
target_y = 30 if jy < 20 else 10
|
| 397 |
+
cmds.append(Command.move_units([str(j["id"])], 100, target_y))
|
| 398 |
+
return cmds or [Command.observe()]
|
| 399 |
+
|
| 400 |
+
|
| 401 |
+
def _intended(rs, Command):
|
| 402 |
+
"""Diversion + main strike:
|
| 403 |
+
1. Detect agent staging hemisphere from the unit centroid.
|
| 404 |
+
2. Bait the jeeps INTO the heavier garrison's pursuit region:
|
| 405 |
+
• easy/medium: decoy is south at (100,30) — jeeps slash
|
| 406 |
+
south-east no matter the spawn (NW spawn jeeps must
|
| 407 |
+
cross to south).
|
| 408 |
+
• hard: decoy cluster is at CENTRE (100,20) — jeeps slash
|
| 409 |
+
toward (100,20) to draw the centre e3 cluster.
|
| 410 |
+
3. Main strike: tanks attack-move along the OPPOSITE latitude
|
| 411 |
+
from the decoy to the real target.
|
| 412 |
+
• easy/medium: tanks at SW must cross north to (100,10).
|
| 413 |
+
• hard: real target is whichever fact MATCHES the agent's
|
| 414 |
+
spawn latitude (NORTH spawn → NE fact at (100,10);
|
| 415 |
+
SOUTH spawn → SE fact at (100,30)).
|
| 416 |
+
4. If a fact is in vision and reachable, attack_unit it.
|
| 417 |
+
"""
|
| 418 |
+
cmds = []
|
| 419 |
+
jeeps = _of_type(rs, {"jeep"})
|
| 420 |
+
tanks = _of_type(rs, {"2tnk"})
|
| 421 |
+
all_units = jeeps + tanks
|
| 422 |
+
if not all_units:
|
| 423 |
+
return [Command.observe()]
|
| 424 |
+
|
| 425 |
+
# Probe: which enemy buildings are present? Hard has a centre
|
| 426 |
+
# powr at (100,20); easy/medium has a powr at (100,30).
|
| 427 |
+
has_centre_decoy = any(
|
| 428 |
+
(e.get("type") or "").lower() == "powr"
|
| 429 |
+
and 16 <= (e.get("cell_y") or 99) <= 24
|
| 430 |
+
for e in (rs.get("enemy_summary") or [])
|
| 431 |
+
)
|
| 432 |
+
|
| 433 |
+
# Spawn latitude detection by tank centroid (tanks dominate the
|
| 434 |
+
# mass; jeeps may already have moved).
|
| 435 |
+
if tanks:
|
| 436 |
+
ty_avg = sum(t["cell_y"] for t in tanks) / len(tanks)
|
| 437 |
+
else:
|
| 438 |
+
ty_avg = sum(u["cell_y"] for u in all_units) / len(all_units)
|
| 439 |
+
is_north = ty_avg < 20
|
| 440 |
+
|
| 441 |
+
if has_centre_decoy:
|
| 442 |
+
# HARD: bait jeeps INTO the centre decoy cluster.
|
| 443 |
+
bait_x, bait_y = 100, 20
|
| 444 |
+
# Strike: matching-latitude fact (NORTH spawn → NE fact;
|
| 445 |
+
# SOUTH spawn → SE fact).
|
| 446 |
+
strike_x, strike_y = (100, 10) if is_north else (100, 30)
|
| 447 |
+
else:
|
| 448 |
+
# EASY/MEDIUM: decoy is south at (100,30); jeeps slash south
|
| 449 |
+
# regardless of spawn. The intended cross-attack for the
|
| 450 |
+
# tanks is north onto the fact at (100,10).
|
| 451 |
+
bait_x, bait_y = 100, 30
|
| 452 |
+
strike_x, strike_y = 100, 10
|
| 453 |
+
|
| 454 |
+
for j in jeeps:
|
| 455 |
+
cmds.append(Command.move_units([str(j["id"])], bait_x, bait_y))
|
| 456 |
+
|
| 457 |
+
if not tanks:
|
| 458 |
+
return cmds or [Command.observe()]
|
| 459 |
+
|
| 460 |
+
tank_ids = [str(t["id"]) for t in tanks]
|
| 461 |
+
|
| 462 |
+
# If the real fact is in vision, attack_unit it for a clean kill;
|
| 463 |
+
# else attack_move onto the cell so the column drives there.
|
| 464 |
+
fact_id = None
|
| 465 |
+
for e in (rs.get("enemy_summary") or []):
|
| 466 |
+
et = (e.get("type") or "").lower()
|
| 467 |
+
if et != "fact":
|
| 468 |
+
continue
|
| 469 |
+
ex, ey = e.get("cell_x"), e.get("cell_y")
|
| 470 |
+
if ex is None or ey is None:
|
| 471 |
+
continue
|
| 472 |
+
if (ex - strike_x) ** 2 + (ey - strike_y) ** 2 <= 6 ** 2:
|
| 473 |
+
fact_id = str(e.get("id"))
|
| 474 |
+
break
|
| 475 |
+
|
| 476 |
+
if fact_id is not None:
|
| 477 |
+
cmds.append(Command.attack_unit(tank_ids, fact_id))
|
| 478 |
+
else:
|
| 479 |
+
cmds.append(Command.attack_move(tank_ids, strike_x, strike_y))
|
| 480 |
+
return cmds or [Command.observe()]
|
| 481 |
+
|
| 482 |
+
|
| 483 |
+
@pytest.mark.parametrize("level", ["easy", "medium", "hard"])
|
| 484 |
+
@pytest.mark.parametrize("seed", [1, 2, 3, 4])
|
| 485 |
+
def test_intended_diversion_then_strike_wins(level, seed):
|
| 486 |
+
pytest.importorskip("openra_train")
|
| 487 |
+
from openra_bench.eval_core import run_level
|
| 488 |
+
|
| 489 |
+
c = compile_level(load_pack(PACK_PATH), level)
|
| 490 |
+
r = run_level(c, _intended, seed=seed)
|
| 491 |
+
assert r.outcome == "win", (
|
| 492 |
+
f"{level} seed={seed}: intended diversion-then-strike should WIN, "
|
| 493 |
+
f"got {r.outcome} after {r.turns} turns "
|
| 494 |
+
f"(lost={r.signals.units_lost}, "
|
| 495 |
+
f"bldgs_destroyed={r.signals.enemy_buildings_destroyed_types}, "
|
| 496 |
+
f"records={getattr(r.signals, 'enemy_buildings_destroyed_records', [])})"
|
| 497 |
+
)
|
| 498 |
+
|
| 499 |
+
|
| 500 |
+
@pytest.mark.parametrize("level", ["easy", "medium", "hard"])
|
| 501 |
+
@pytest.mark.parametrize("seed", [1, 2, 3, 4])
|
| 502 |
+
def test_stall_loses(level, seed):
|
| 503 |
+
pytest.importorskip("openra_train")
|
| 504 |
+
from openra_bench.eval_core import run_level
|
| 505 |
+
|
| 506 |
+
c = compile_level(load_pack(PACK_PATH), level)
|
| 507 |
+
r = run_level(c, _stall, seed=seed)
|
| 508 |
+
assert r.outcome == "loss", (
|
| 509 |
+
f"{level} seed={seed}: stall must be a real timeout LOSS "
|
| 510 |
+
f"(no building takes damage), got {r.outcome}"
|
| 511 |
+
)
|
| 512 |
+
|
| 513 |
+
|
| 514 |
+
@pytest.mark.parametrize("level", ["easy", "medium", "hard"])
|
| 515 |
+
@pytest.mark.parametrize("seed", [1, 2, 3, 4])
|
| 516 |
+
def test_brute_all_on_decoy_loses(level, seed):
|
| 517 |
+
"""Brute frontal — every unit attack-moves onto the decoy powr.
|
| 518 |
+
The decoy is razed (which scores zero — `enemy_key_buildings_
|
| 519 |
+
destroyed_in_region` requires a `fact`) and the heavy e3 cluster
|
| 520 |
+
shreds the tanks; the real fact is left standing → LOSS."""
|
| 521 |
+
pytest.importorskip("openra_train")
|
| 522 |
+
from openra_bench.eval_core import run_level
|
| 523 |
+
|
| 524 |
+
c = compile_level(load_pack(PACK_PATH), level)
|
| 525 |
+
r = run_level(c, _brute_all_on_decoy, seed=seed)
|
| 526 |
+
assert r.outcome == "loss", (
|
| 527 |
+
f"{level} seed={seed}: brute-all-on-decoy must LOSE "
|
| 528 |
+
f"(real fact intact / strike force traded), got {r.outcome} "
|
| 529 |
+
f"(lost={r.signals.units_lost}, "
|
| 530 |
+
f"bldgs={r.signals.enemy_buildings_destroyed_types})"
|
| 531 |
+
)
|
| 532 |
+
|
| 533 |
+
|
| 534 |
+
@pytest.mark.parametrize("level", ["easy", "medium", "hard"])
|
| 535 |
+
@pytest.mark.parametrize("seed", [1, 2, 3, 4])
|
| 536 |
+
def test_lazy_nearest_assignment_loses(level, seed):
|
| 537 |
+
"""Lazy "send each squad to its nearest visible target" play.
|
| 538 |
+
On easy/medium this puts jeeps on the close-looking fact (too
|
| 539 |
+
weak to crack) and tanks on the close-looking powr (the wrong
|
| 540 |
+
type — `enemy_key_buildings_destroyed_in_region` requires a
|
| 541 |
+
`fact`), so the real fact survives the clock → LOSS."""
|
| 542 |
+
pytest.importorskip("openra_train")
|
| 543 |
+
from openra_bench.eval_core import run_level
|
| 544 |
+
|
| 545 |
+
c = compile_level(load_pack(PACK_PATH), level)
|
| 546 |
+
r = run_level(c, _lazy_nearest, seed=seed)
|
| 547 |
+
assert r.outcome == "loss", (
|
| 548 |
+
f"{level} seed={seed}: lazy nearest assignment must LOSE, "
|
| 549 |
+
f"got {r.outcome} "
|
| 550 |
+
f"(lost={r.signals.units_lost}, "
|
| 551 |
+
f"bldgs={r.signals.enemy_buildings_destroyed_types})"
|
| 552 |
+
)
|
| 553 |
+
|
| 554 |
+
|
| 555 |
+
@pytest.mark.parametrize("level", ["easy", "medium", "hard"])
|
| 556 |
+
@pytest.mark.parametrize("seed", [1, 2, 3, 4])
|
| 557 |
+
def test_bait_only_loses(level, seed):
|
| 558 |
+
"""Bait-only (jeeps slash toward the decoy, tanks stand still)
|
| 559 |
+
must LOSE on every tier — the bait displacement yields ZERO
|
| 560 |
+
objective progress; the real fact survives and the clock runs
|
| 561 |
+
out. Discriminates "bait without strike" from the intended
|
| 562 |
+
"bait AND strike" idiom."""
|
| 563 |
+
pytest.importorskip("openra_train")
|
| 564 |
+
from openra_bench.eval_core import run_level
|
| 565 |
+
|
| 566 |
+
c = compile_level(load_pack(PACK_PATH), level)
|
| 567 |
+
r = run_level(c, _bait_only, seed=seed)
|
| 568 |
+
assert r.outcome == "loss", (
|
| 569 |
+
f"{level} seed={seed}: bait-only must LOSE (real fact not "
|
| 570 |
+
f"destroyed), got {r.outcome} "
|
| 571 |
+
f"(bldgs={r.signals.enemy_buildings_destroyed_types})"
|
| 572 |
+
)
|
|
@@ -0,0 +1,387 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""coord-relay-attack — rocket-then-tank relay strike.
|
| 2 |
+
|
| 3 |
+
The bar: intended Squad A (e3 rockets) engages FIRST → softens enemy
|
| 4 |
+
armour → THEN Squad B (2tnk) follows up. WINS on every level and
|
| 5 |
+
every hard seed (1-4). The wrong-policy lattice (stall, both-attack-
|
| 6 |
+
at-once, B-only) must LOSE on medium and hard. Easy is the bare-skill
|
| 7 |
+
tier (smaller enemy cluster, looser caps) so some wrong policies may
|
| 8 |
+
squeak by there per the SCENARIO_REVIEW_CHECKLIST inert-easy-teeth
|
| 9 |
+
convention.
|
| 10 |
+
|
| 11 |
+
The Wave-2 `then:` happened-before composite enforces the ordering
|
| 12 |
+
predicate: `units_killed_gte: K1` must latch before `units_killed_gte:
|
| 13 |
+
K2`. Wrong policies fail either on attrition (`units_lost_lte`) or
|
| 14 |
+
the clock (`after_ticks`).
|
| 15 |
+
|
| 16 |
+
Validation is scripted (no model / network).
|
| 17 |
+
"""
|
| 18 |
+
from __future__ import annotations
|
| 19 |
+
|
| 20 |
+
from pathlib import Path
|
| 21 |
+
|
| 22 |
+
import pytest
|
| 23 |
+
|
| 24 |
+
pytest.importorskip("openra_rl_training", reason="Rust env wheel not installed")
|
| 25 |
+
from openra_bench.scenarios import load_pack
|
| 26 |
+
from openra_bench.scenarios.loader import compile_level
|
| 27 |
+
from openra_bench.scenarios.win_conditions import WinContext, evaluate
|
| 28 |
+
|
| 29 |
+
PACKS = Path(__file__).parent.parent / "openra_bench" / "scenarios" / "packs"
|
| 30 |
+
PACK_PATH = PACKS / "coord-relay-attack.yaml"
|
| 31 |
+
|
| 32 |
+
|
| 33 |
+
# ── unit-level predicate / metadata checks (no engine) ──────────────
|
| 34 |
+
|
| 35 |
+
|
| 36 |
+
def test_pack_compiles_and_meta_fields_populated():
|
| 37 |
+
pack = load_pack(PACK_PATH)
|
| 38 |
+
assert pack.meta.id == "coord-relay-attack"
|
| 39 |
+
assert pack.meta.capability == "action"
|
| 40 |
+
assert pack.meta.real_world_meaning, "real_world_meaning required"
|
| 41 |
+
assert pack.meta.robotics_analogue, "robotics_analogue required"
|
| 42 |
+
anchors = pack.meta.benchmark_anchor
|
| 43 |
+
assert isinstance(anchors, list) and len(anchors) == 4, (
|
| 44 |
+
f"benchmark_anchor must list all 4 anchors, got {anchors!r}"
|
| 45 |
+
)
|
| 46 |
+
joined = " ".join(anchors).lower()
|
| 47 |
+
for needle in ("sc2", "smac", "overlapping fires", "bound-and-bound"):
|
| 48 |
+
assert needle in joined, f"missing anchor keyword: {needle}"
|
| 49 |
+
for lvl in ("easy", "medium", "hard"):
|
| 50 |
+
c = compile_level(pack, lvl)
|
| 51 |
+
assert c.map_supported
|
| 52 |
+
assert c.win_condition is not None
|
| 53 |
+
assert c.fail_condition is not None
|
| 54 |
+
|
| 55 |
+
|
| 56 |
+
def _ctx(*, units=(), tick=1000, kills=0, lost=0):
|
| 57 |
+
"""Synthesize a WinContext for predicate-level checks."""
|
| 58 |
+
import types
|
| 59 |
+
|
| 60 |
+
sig = types.SimpleNamespace(
|
| 61 |
+
game_tick=tick,
|
| 62 |
+
units_killed=kills,
|
| 63 |
+
units_lost=lost,
|
| 64 |
+
cash=0,
|
| 65 |
+
resources=0,
|
| 66 |
+
power_provided=0,
|
| 67 |
+
power_drained=0,
|
| 68 |
+
own_buildings=[],
|
| 69 |
+
own_building_types=set(),
|
| 70 |
+
enemies_seen_ids=set(),
|
| 71 |
+
enemy_buildings_seen_ids=set(),
|
| 72 |
+
explored_percent=0.0,
|
| 73 |
+
then_progress={},
|
| 74 |
+
seq_progress={},
|
| 75 |
+
)
|
| 76 |
+
return WinContext(
|
| 77 |
+
signals=sig,
|
| 78 |
+
render_state={"units_summary": list(units)},
|
| 79 |
+
)
|
| 80 |
+
|
| 81 |
+
|
| 82 |
+
def _alive(n, type_="e3"):
|
| 83 |
+
return [
|
| 84 |
+
{"cell_x": 15, "cell_y": 20, "type": type_, "id": str(1000 + i)}
|
| 85 |
+
for i in range(n)
|
| 86 |
+
]
|
| 87 |
+
|
| 88 |
+
|
| 89 |
+
def test_easy_predicates():
|
| 90 |
+
c = compile_level(load_pack(PACK_PATH), "easy")
|
| 91 |
+
# Intended: relay completes (need K1 then K2 — same ctx satisfies
|
| 92 |
+
# both clauses in order on first eval), in time, under cap → WIN.
|
| 93 |
+
ctx = _ctx(units=_alive(5), tick=2500, kills=4, lost=2)
|
| 94 |
+
assert evaluate(c.win_condition, ctx)
|
| 95 |
+
# K1 unmet → not a win.
|
| 96 |
+
assert not evaluate(c.win_condition, _ctx(units=_alive(6), tick=2500, kills=1, lost=0))
|
| 97 |
+
# K2 unmet (3 kills, K2 is 4) → not a win.
|
| 98 |
+
assert not evaluate(c.win_condition, _ctx(units=_alive(6), tick=2500, kills=3, lost=0))
|
| 99 |
+
# Attrition cap busted (5 > 4) → fail.
|
| 100 |
+
assert evaluate(c.fail_condition, _ctx(units=_alive(1), tick=2500, kills=4, lost=5))
|
| 101 |
+
# Force-wipe → fail.
|
| 102 |
+
assert evaluate(c.fail_condition, _ctx(units=[], tick=2500, kills=4, lost=6))
|
| 103 |
+
# Timeout with bar unmet → fail.
|
| 104 |
+
assert evaluate(c.fail_condition, _ctx(units=_alive(5), tick=3002, kills=2, lost=1))
|
| 105 |
+
|
| 106 |
+
|
| 107 |
+
def test_medium_predicates():
|
| 108 |
+
c = compile_level(load_pack(PACK_PATH), "medium")
|
| 109 |
+
# Intended: 7 kills, 5 lost, in time → WIN.
|
| 110 |
+
ctx = _ctx(units=_alive(3), tick=3500, kills=7, lost=5)
|
| 111 |
+
assert evaluate(c.win_condition, ctx)
|
| 112 |
+
# K1 unmet (only 2 kills) → not a win.
|
| 113 |
+
assert not evaluate(c.win_condition, _ctx(units=_alive(6), tick=3500, kills=2, lost=0))
|
| 114 |
+
# K2 unmet (6 kills) → not a win.
|
| 115 |
+
assert not evaluate(c.win_condition, _ctx(units=_alive(6), tick=3500, kills=6, lost=0))
|
| 116 |
+
# Attrition cap busted (6 > 5) → fail.
|
| 117 |
+
assert evaluate(c.fail_condition, _ctx(units=_alive(2), tick=3500, kills=7, lost=6))
|
| 118 |
+
# Force-wipe → fail.
|
| 119 |
+
assert evaluate(c.fail_condition, _ctx(units=[], tick=3500, kills=7, lost=7))
|
| 120 |
+
# Timeout → fail.
|
| 121 |
+
assert evaluate(c.fail_condition, _ctx(units=_alive(6), tick=4002, kills=6, lost=2))
|
| 122 |
+
|
| 123 |
+
|
| 124 |
+
def test_hard_predicates():
|
| 125 |
+
c = compile_level(load_pack(PACK_PATH), "hard")
|
| 126 |
+
# Intended: 10 kills, ≤5 lost, in time → WIN.
|
| 127 |
+
ctx = _ctx(units=_alive(3), tick=4000, kills=10, lost=5)
|
| 128 |
+
assert evaluate(c.win_condition, ctx)
|
| 129 |
+
# K1 unmet (3 kills) → not a win.
|
| 130 |
+
assert not evaluate(c.win_condition, _ctx(units=_alive(6), tick=4000, kills=3, lost=0))
|
| 131 |
+
# K2 unmet (9 kills) → not a win.
|
| 132 |
+
assert not evaluate(c.win_condition, _ctx(units=_alive(6), tick=4000, kills=9, lost=0))
|
| 133 |
+
# Attrition cap busted → fail.
|
| 134 |
+
assert evaluate(c.fail_condition, _ctx(units=_alive(2), tick=4000, kills=10, lost=6))
|
| 135 |
+
# Force-wipe → fail.
|
| 136 |
+
assert evaluate(c.fail_condition, _ctx(units=[], tick=4000, kills=10, lost=8))
|
| 137 |
+
# Timeout → fail.
|
| 138 |
+
assert evaluate(c.fail_condition, _ctx(units=_alive(6), tick=4502, kills=9, lost=2))
|
| 139 |
+
|
| 140 |
+
|
| 141 |
+
def test_then_clause_enforces_ordering_under_consistent_eval():
|
| 142 |
+
"""The then-clause latch is per-id and persists across evals via
|
| 143 |
+
`signals.then_progress`. Using a fresh ctx (and so fresh
|
| 144 |
+
then_progress) tests the leaf semantics; persistent-eval semantics
|
| 145 |
+
are tested in tests/test_then_composite.py."""
|
| 146 |
+
c = compile_level(load_pack(PACK_PATH), "medium")
|
| 147 |
+
# New ctx, but K2 already satisfied: greedy advance latches both
|
| 148 |
+
# clauses in the same eval (matches waypoint_sequence semantics
|
| 149 |
+
# validated in test_then_composite.py::test_then_late_a_then_b_…).
|
| 150 |
+
ctx = _ctx(units=_alive(3), tick=3500, kills=7, lost=0)
|
| 151 |
+
assert evaluate(c.win_condition, ctx)
|
| 152 |
+
# But: if K1 is never met (and K2 also not met), no progress.
|
| 153 |
+
ctx2 = _ctx(units=_alive(3), tick=3500, kills=2, lost=0)
|
| 154 |
+
assert not evaluate(c.win_condition, ctx2)
|
| 155 |
+
|
| 156 |
+
|
| 157 |
+
def test_timeout_reachable_inside_max_turns():
|
| 158 |
+
"""No draw degeneracy: the after_ticks fail trigger must be
|
| 159 |
+
reachable within max_turns (engine advances ~90 ticks per turn,
|
| 160 |
+
so max tick ≈ 93 + 90·(max_turns-1))."""
|
| 161 |
+
pack = load_pack(PACK_PATH)
|
| 162 |
+
expectations = {"easy": 3001, "medium": 4001, "hard": 4501}
|
| 163 |
+
for lvl, after in expectations.items():
|
| 164 |
+
c = compile_level(pack, lvl)
|
| 165 |
+
max_tick = 93 + 90 * (c.max_turns - 1)
|
| 166 |
+
assert after <= max_tick, (
|
| 167 |
+
f"{lvl}: after_ticks {after} > max reachable tick {max_tick} "
|
| 168 |
+
f"(max_turns={c.max_turns}); deadline never bites"
|
| 169 |
+
)
|
| 170 |
+
|
| 171 |
+
|
| 172 |
+
def test_hard_has_two_spawn_point_groups():
|
| 173 |
+
"""Hard-tier curation: ≥2 distinct agent spawn_point groups so the
|
| 174 |
+
seed round-robins the west-edge staging latitude (north / south)."""
|
| 175 |
+
c = compile_level(load_pack(PACK_PATH), "hard")
|
| 176 |
+
groups = {
|
| 177 |
+
(a.spawn_point if a.spawn_point is not None else 0)
|
| 178 |
+
for a in c.scenario.actors
|
| 179 |
+
if a.owner == "agent"
|
| 180 |
+
}
|
| 181 |
+
assert len(groups) >= 2, f"hard needs ≥2 spawn_point groups, got {groups}"
|
| 182 |
+
|
| 183 |
+
|
| 184 |
+
def test_squads_are_e3_then_2tnk_on_every_level():
|
| 185 |
+
"""The relay only has teeth if Squad A is rocket soldiers (e3,
|
| 186 |
+
anti-vehicle) and Squad B is medium tanks (2tnk, anti-infantry):
|
| 187 |
+
A's rockets soften the enemy 2tnk horde; B mops up the e1s."""
|
| 188 |
+
pack = load_pack(PACK_PATH)
|
| 189 |
+
for lvl in ("easy", "medium", "hard"):
|
| 190 |
+
c = compile_level(pack, lvl)
|
| 191 |
+
agent_types = [a.type for a in c.scenario.actors if a.owner == "agent"]
|
| 192 |
+
assert "e3" in agent_types, f"{lvl}: Squad A must be e3 (rocket); got {agent_types}"
|
| 193 |
+
assert "2tnk" in agent_types, f"{lvl}: Squad B must be 2tnk; got {agent_types}"
|
| 194 |
+
enemy_types = [a.type for a in c.scenario.actors if a.owner == "enemy"]
|
| 195 |
+
assert "2tnk" in enemy_types, f"{lvl}: enemy must include 2tnk (the threat A softens)"
|
| 196 |
+
assert "e1" in enemy_types, f"{lvl}: enemy must include e1 (what B mops up)"
|
| 197 |
+
# Persistent far enemy marker (engine auto-done mitigation).
|
| 198 |
+
assert "proc" in enemy_types, f"{lvl}: needs a persistent enemy survivor marker"
|
| 199 |
+
|
| 200 |
+
|
| 201 |
+
# ── engine-driven scripted policies ─────────────────────────────────
|
| 202 |
+
|
| 203 |
+
|
| 204 |
+
def _split_squads(rs):
|
| 205 |
+
"""Return (a_ids, b_ids): Squad A = e3 (rocket); Squad B = 2tnk."""
|
| 206 |
+
a_ids, b_ids = [], []
|
| 207 |
+
for u in (rs.get("units_summary") or []):
|
| 208 |
+
t = str(u.get("type", "")).lower()
|
| 209 |
+
if t == "e3":
|
| 210 |
+
a_ids.append(str(u["id"]))
|
| 211 |
+
elif t == "2tnk":
|
| 212 |
+
b_ids.append(str(u["id"]))
|
| 213 |
+
return a_ids, b_ids
|
| 214 |
+
|
| 215 |
+
|
| 216 |
+
def _enemy_centre(rs):
|
| 217 |
+
en = rs.get("enemy_summary") or []
|
| 218 |
+
if not en:
|
| 219 |
+
return (60, 20) # fallback to known cluster centre
|
| 220 |
+
cx = sum(int(e["cell_x"]) for e in en) / len(en)
|
| 221 |
+
cy = sum(int(e["cell_y"]) for e in en) / len(en)
|
| 222 |
+
return (int(cx), int(cy))
|
| 223 |
+
|
| 224 |
+
|
| 225 |
+
def _stall(rs, Command):
|
| 226 |
+
"""Pure observe — enemies hold (stance:2), agent never engages →
|
| 227 |
+
kill bar unmet → after_ticks LOSS."""
|
| 228 |
+
return [Command.observe()]
|
| 229 |
+
|
| 230 |
+
|
| 231 |
+
def _both_attack_at_once(rs, Command):
|
| 232 |
+
"""Both squads attack_move the enemy cluster simultaneously.
|
| 233 |
+
B's tanks reach the cluster around the same instant A's rockets do
|
| 234 |
+
and get focus-fired by the enemy 4× 2tnk → units_lost cap busts on
|
| 235 |
+
medium/hard."""
|
| 236 |
+
a_ids, b_ids = _split_squads(rs)
|
| 237 |
+
if not a_ids and not b_ids:
|
| 238 |
+
return [Command.observe()]
|
| 239 |
+
ex, ey = _enemy_centre(rs)
|
| 240 |
+
cmds = []
|
| 241 |
+
if a_ids:
|
| 242 |
+
cmds.append(Command.attack_move(a_ids, ex, ey))
|
| 243 |
+
if b_ids:
|
| 244 |
+
cmds.append(Command.attack_move(b_ids, ex, ey))
|
| 245 |
+
return cmds or [Command.observe()]
|
| 246 |
+
|
| 247 |
+
|
| 248 |
+
def _b_only(rs, Command):
|
| 249 |
+
"""Squad B (tanks) advances first; Squad A (rockets) holds. B
|
| 250 |
+
faces the un-softened enemy 2tnk horde alone → bleeds → attrition
|
| 251 |
+
cap busts before the kill bar is met."""
|
| 252 |
+
a_ids, b_ids = _split_squads(rs)
|
| 253 |
+
if not b_ids and not a_ids:
|
| 254 |
+
return [Command.observe()]
|
| 255 |
+
ex, ey = _enemy_centre(rs)
|
| 256 |
+
cmds = []
|
| 257 |
+
if b_ids:
|
| 258 |
+
cmds.append(Command.attack_move(b_ids, ex, ey))
|
| 259 |
+
if a_ids:
|
| 260 |
+
cmds.append(Command.stop(a_ids))
|
| 261 |
+
return cmds or [Command.observe()]
|
| 262 |
+
|
| 263 |
+
|
| 264 |
+
def _make_intended_a_then_b():
|
| 265 |
+
"""Factory: per-episode closure (turn counter + soften-state
|
| 266 |
+
tracker). Relay doctrine:
|
| 267 |
+
• Squad A (rockets) commits to the enemy cluster from turn 1
|
| 268 |
+
and attempts to soften the heavy 2tnk line.
|
| 269 |
+
• Squad B (tanks) HOLDS until either (a) the enemy heavy line
|
| 270 |
+
is at least half-suppressed (≤50% of original 2tnk left),
|
| 271 |
+
OR (b) enough wall-clock turns have elapsed that A has had
|
| 272 |
+
the chance to do its work (fallback for the case where A
|
| 273 |
+
gets melted before completing the soften — B still needs
|
| 274 |
+
to advance and contribute kills).
|
| 275 |
+
The two-trigger design models real fire-and-maneuver: the relay
|
| 276 |
+
fires on suppression OR on a timed bound, not just on a perfect
|
| 277 |
+
suppression latch. (Without the timed fallback, a wipe of A
|
| 278 |
+
means B never advances and the kill bar is never met.)
|
| 279 |
+
"""
|
| 280 |
+
state = {"initial_tanks": None, "turn": 0, "released": False}
|
| 281 |
+
# Turn at which B is released regardless of suppression status.
|
| 282 |
+
# ~16 turns ≈ 1440 ticks = enough for A to walk from x=15 to
|
| 283 |
+
# x=60 (45 cells) and engage for a few seconds. Tuned so the
|
| 284 |
+
# B-only / both-at-once paths don't accidentally land before
|
| 285 |
+
# this trigger (they advance from turn 1, so they hit the
|
| 286 |
+
# enemy faster than the gated release).
|
| 287 |
+
RELEASE_TURN = 16
|
| 288 |
+
|
| 289 |
+
def _policy(rs, Command):
|
| 290 |
+
state["turn"] += 1
|
| 291 |
+
a_ids, b_ids = _split_squads(rs)
|
| 292 |
+
if not a_ids and not b_ids:
|
| 293 |
+
return [Command.observe()]
|
| 294 |
+
ex, ey = _enemy_centre(rs)
|
| 295 |
+
enemy_tanks = [
|
| 296 |
+
e for e in (rs.get("enemy_summary") or [])
|
| 297 |
+
if str(e.get("type", "")).lower() == "2tnk"
|
| 298 |
+
]
|
| 299 |
+
if state["initial_tanks"] is None and len(enemy_tanks) > 0:
|
| 300 |
+
state["initial_tanks"] = len(enemy_tanks)
|
| 301 |
+
cmds = []
|
| 302 |
+
if a_ids:
|
| 303 |
+
cmds.append(Command.attack_move(a_ids, ex, ey))
|
| 304 |
+
if b_ids:
|
| 305 |
+
if state["released"]:
|
| 306 |
+
cmds.append(Command.attack_move(b_ids, ex, ey))
|
| 307 |
+
else:
|
| 308 |
+
init = state["initial_tanks"]
|
| 309 |
+
suppressed = (
|
| 310 |
+
init is not None
|
| 311 |
+
and init > 0
|
| 312 |
+
and len(enemy_tanks) * 2 <= init
|
| 313 |
+
)
|
| 314 |
+
if suppressed or state["turn"] >= RELEASE_TURN:
|
| 315 |
+
state["released"] = True
|
| 316 |
+
cmds.append(Command.attack_move(b_ids, ex, ey))
|
| 317 |
+
else:
|
| 318 |
+
cmds.append(Command.stop(b_ids))
|
| 319 |
+
return cmds or [Command.observe()]
|
| 320 |
+
|
| 321 |
+
return _policy
|
| 322 |
+
|
| 323 |
+
|
| 324 |
+
@pytest.mark.parametrize("level", ["easy", "medium", "hard"])
|
| 325 |
+
@pytest.mark.parametrize("seed", [1, 2, 3, 4])
|
| 326 |
+
def test_intended_a_then_b_wins(level, seed):
|
| 327 |
+
pytest.importorskip("openra_train")
|
| 328 |
+
from openra_bench.eval_core import run_level
|
| 329 |
+
|
| 330 |
+
c = compile_level(load_pack(PACK_PATH), level)
|
| 331 |
+
r = run_level(c, _make_intended_a_then_b(), seed=seed)
|
| 332 |
+
assert r.outcome == "win", (
|
| 333 |
+
f"{level} seed={seed}: intended A-then-B relay should WIN, "
|
| 334 |
+
f"got {r.outcome} after {r.turns} turns "
|
| 335 |
+
f"(kills={r.signals.units_killed}, losses={r.signals.units_lost})"
|
| 336 |
+
)
|
| 337 |
+
|
| 338 |
+
|
| 339 |
+
@pytest.mark.parametrize("level", ["easy", "medium", "hard"])
|
| 340 |
+
@pytest.mark.parametrize("seed", [1, 2, 3, 4])
|
| 341 |
+
def test_stall_loses(level, seed):
|
| 342 |
+
pytest.importorskip("openra_train")
|
| 343 |
+
from openra_bench.eval_core import run_level
|
| 344 |
+
|
| 345 |
+
c = compile_level(load_pack(PACK_PATH), level)
|
| 346 |
+
r = run_level(c, _stall, seed=seed)
|
| 347 |
+
assert r.outcome == "loss", (
|
| 348 |
+
f"{level} seed={seed}: stall must be a real timeout LOSS "
|
| 349 |
+
f"(no engagement → kill bar unmet), got {r.outcome}"
|
| 350 |
+
)
|
| 351 |
+
|
| 352 |
+
|
| 353 |
+
@pytest.mark.parametrize("level", ["medium", "hard"])
|
| 354 |
+
@pytest.mark.parametrize("seed", [1, 2, 3, 4])
|
| 355 |
+
def test_both_attack_at_once_loses(level, seed):
|
| 356 |
+
"""Sending both squads in simultaneously exposes B's tanks to the
|
| 357 |
+
un-softened enemy 4× 2tnk before A's rockets can suppress them.
|
| 358 |
+
Attrition cap busts on medium/hard. Easy is excluded (forgiving
|
| 359 |
+
bare-skill tier with smaller cluster and cap=4)."""
|
| 360 |
+
pytest.importorskip("openra_train")
|
| 361 |
+
from openra_bench.eval_core import run_level
|
| 362 |
+
|
| 363 |
+
c = compile_level(load_pack(PACK_PATH), level)
|
| 364 |
+
r = run_level(c, _both_attack_at_once, seed=seed)
|
| 365 |
+
assert r.outcome == "loss", (
|
| 366 |
+
f"{level} seed={seed}: both-at-once must LOSE (B tanks bleed "
|
| 367 |
+
f"to enemy 2tnk before A softens), got {r.outcome} "
|
| 368 |
+
f"(kills={r.signals.units_killed}, losses={r.signals.units_lost})"
|
| 369 |
+
)
|
| 370 |
+
|
| 371 |
+
|
| 372 |
+
@pytest.mark.parametrize("level", ["medium", "hard"])
|
| 373 |
+
@pytest.mark.parametrize("seed", [1, 2, 3, 4])
|
| 374 |
+
def test_b_only_loses(level, seed):
|
| 375 |
+
"""Squad B (tanks) charging alone faces the full enemy 2tnk + e1
|
| 376 |
+
cluster — outgunned by the enemy tanks, attrition cap busts before
|
| 377 |
+
the K2 bar is met. Easy excluded (forgiving cluster)."""
|
| 378 |
+
pytest.importorskip("openra_train")
|
| 379 |
+
from openra_bench.eval_core import run_level
|
| 380 |
+
|
| 381 |
+
c = compile_level(load_pack(PACK_PATH), level)
|
| 382 |
+
r = run_level(c, _b_only, seed=seed)
|
| 383 |
+
assert r.outcome == "loss", (
|
| 384 |
+
f"{level} seed={seed}: B-only must LOSE (B tanks alone vs "
|
| 385 |
+
f"enemy 4× 2tnk + 4× e1), got {r.outcome} "
|
| 386 |
+
f"(kills={r.signals.units_killed}, losses={r.signals.units_lost})"
|
| 387 |
+
)
|
|
@@ -0,0 +1,360 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""coord-squad-handoff: sequenced squad handoff via the Wave-2 `then:`
|
| 2 |
+
happened-before composite + a new type-filtered region predicate
|
| 3 |
+
(`units_of_type_in_region_gte`).
|
| 4 |
+
|
| 5 |
+
The pack tests the SQUAD HANDOFF capability: Squad A (jeeps) must
|
| 6 |
+
deliver objective P1 FIRST, THEN Squad B (medium tanks) must deliver
|
| 7 |
+
P2; harder tiers add more alternating handoffs. The bar (per CLAUDE.md)
|
| 8 |
+
must hold on every level × every hard seed (1..4):
|
| 9 |
+
|
| 10 |
+
- STALL -> LOSS (clock)
|
| 11 |
+
- B-FIRST -> LOSS (then-latch never advances past A)
|
| 12 |
+
- SINGLE-SQUAD -> LOSS (the wrong-type squad can't satisfy clause 2)
|
| 13 |
+
- INTENDED PLAY -> WIN (parallel A→P1, B→P2 with B holding for A's
|
| 14 |
+
latch)
|
| 15 |
+
|
| 16 |
+
A separate unit test pins the new predicate (`units_of_type_in_region
|
| 17 |
+
_gte`) to its exact semantics — it must NOT count units of the wrong
|
| 18 |
+
type at the region.
|
| 19 |
+
"""
|
| 20 |
+
from __future__ import annotations
|
| 21 |
+
|
| 22 |
+
from pathlib import Path
|
| 23 |
+
|
| 24 |
+
import pytest
|
| 25 |
+
|
| 26 |
+
pytest.importorskip("openra_rl_training", reason="Rust env wheel not installed")
|
| 27 |
+
|
| 28 |
+
from openra_bench.scenarios import load_pack
|
| 29 |
+
from openra_bench.scenarios.loader import compile_level
|
| 30 |
+
from openra_bench.scenarios.win_conditions import WinContext, evaluate
|
| 31 |
+
|
| 32 |
+
PACK = (
|
| 33 |
+
Path(__file__).parent.parent
|
| 34 |
+
/ "openra_bench"
|
| 35 |
+
/ "scenarios"
|
| 36 |
+
/ "packs"
|
| 37 |
+
/ "coord-squad-handoff.yaml"
|
| 38 |
+
)
|
| 39 |
+
|
| 40 |
+
# Per-level expected handoff count: easy 2 (A→B); medium 3 (A→B→A);
|
| 41 |
+
# hard 4 (A→B→A→B).
|
| 42 |
+
EXPECTED_CLAUSES = {"easy": 2, "medium": 3, "hard": 4}
|
| 43 |
+
|
| 44 |
+
|
| 45 |
+
def _win_clauses(c):
|
| 46 |
+
return dict(c.win_condition.__pydantic_extra__ or {})["all_of"]
|
| 47 |
+
|
| 48 |
+
|
| 49 |
+
def _fail_clauses(c):
|
| 50 |
+
return dict(c.fail_condition.__pydantic_extra__ or {})["any_of"]
|
| 51 |
+
|
| 52 |
+
|
| 53 |
+
def _then_clauses(c):
|
| 54 |
+
for cl in _win_clauses(c):
|
| 55 |
+
if "then" in cl:
|
| 56 |
+
return cl["then"]["clauses"]
|
| 57 |
+
return None
|
| 58 |
+
|
| 59 |
+
|
| 60 |
+
# ── A. STRUCTURAL: predicate / order / deadline are wired correctly ──
|
| 61 |
+
|
| 62 |
+
@pytest.mark.parametrize("level", ["easy", "medium", "hard"])
|
| 63 |
+
def test_level_uses_then_with_type_filtered_region_clauses(level):
|
| 64 |
+
pack = load_pack(PACK)
|
| 65 |
+
assert pack.meta.capability == "action"
|
| 66 |
+
c = compile_level(pack, level)
|
| 67 |
+
clauses = _then_clauses(c)
|
| 68 |
+
assert clauses is not None, f"{level}: must use a `then:` composite"
|
| 69 |
+
assert len(clauses) == EXPECTED_CLAUSES[level], (
|
| 70 |
+
f"{level}: expected {EXPECTED_CLAUSES[level]} handoff clauses, "
|
| 71 |
+
f"got {len(clauses)}"
|
| 72 |
+
)
|
| 73 |
+
# Every clause is a type-filtered region predicate (the new
|
| 74 |
+
# `units_of_type_in_region_gte`). Type-agnostic `units_in_region
|
| 75 |
+
# _gte` would NOT enforce squad identity.
|
| 76 |
+
for i, cl in enumerate(clauses):
|
| 77 |
+
assert "units_of_type_in_region_gte" in cl, (
|
| 78 |
+
f"{level} clause {i}: handoff must enforce squad identity"
|
| 79 |
+
)
|
| 80 |
+
v = cl["units_of_type_in_region_gte"]
|
| 81 |
+
assert v["n"] >= 3, f"{level} clause {i}: needs n>=3 (full squad)"
|
| 82 |
+
assert v["type"] in {"jeep", "2tnk"}, (
|
| 83 |
+
f"{level} clause {i}: unknown squad unit type {v['type']}"
|
| 84 |
+
)
|
| 85 |
+
# Clauses must alternate squad types — that IS the handoff.
|
| 86 |
+
types = [cl["units_of_type_in_region_gte"]["type"] for cl in clauses]
|
| 87 |
+
for i in range(1, len(types)):
|
| 88 |
+
assert types[i] != types[i - 1], (
|
| 89 |
+
f"{level}: handoff clauses must alternate squad types, got {types}"
|
| 90 |
+
)
|
| 91 |
+
|
| 92 |
+
|
| 93 |
+
@pytest.mark.parametrize("level", ["easy", "medium", "hard"])
|
| 94 |
+
def test_level_has_binding_deadline_and_real_loss(level):
|
| 95 |
+
c = compile_level(load_pack(PACK), level)
|
| 96 |
+
win = _win_clauses(c)
|
| 97 |
+
wt = [cl["within_ticks"] for cl in win if "within_ticks" in cl]
|
| 98 |
+
assert wt, f"{level}: missing within_ticks deadline"
|
| 99 |
+
# The deadline must bite within max_turns (engine ~90 ticks/turn).
|
| 100 |
+
assert wt[0] < 93 + 90 * (c.max_turns - 1), (
|
| 101 |
+
f"{level}: within_ticks {wt[0]} unreachable inside max_turns "
|
| 102 |
+
f"{c.max_turns} (would draw on timeout)"
|
| 103 |
+
)
|
| 104 |
+
# Every level must be able to emit a LOSS on timeout.
|
| 105 |
+
fail = _fail_clauses(c)
|
| 106 |
+
assert any("after_ticks" in cl for cl in fail), (
|
| 107 |
+
f"{level}: missing after_ticks in fail_condition"
|
| 108 |
+
)
|
| 109 |
+
|
| 110 |
+
|
| 111 |
+
def test_handoff_count_scales_with_difficulty():
|
| 112 |
+
pack = load_pack(PACK)
|
| 113 |
+
counts = [
|
| 114 |
+
len(_then_clauses(compile_level(pack, lv)))
|
| 115 |
+
for lv in ("easy", "medium", "hard")
|
| 116 |
+
]
|
| 117 |
+
assert counts == [2, 3, 4], (
|
| 118 |
+
f"handoff count should ladder 2->3->4, got {counts}"
|
| 119 |
+
)
|
| 120 |
+
|
| 121 |
+
|
| 122 |
+
# ── B. PREDICATE UNIT TEST: units_of_type_in_region_gte semantics ────
|
| 123 |
+
|
| 124 |
+
class _Sig:
|
| 125 |
+
def __init__(self):
|
| 126 |
+
self.game_tick = 100
|
| 127 |
+
self.then_progress: dict = {}
|
| 128 |
+
|
| 129 |
+
|
| 130 |
+
def _ctx(units):
|
| 131 |
+
return WinContext(signals=_Sig(), render_state={"units_summary": units})
|
| 132 |
+
|
| 133 |
+
|
| 134 |
+
def test_type_filtered_region_predicate_ignores_wrong_type():
|
| 135 |
+
"""The whole point of the new predicate: 3 jeeps at P1 do NOT
|
| 136 |
+
satisfy a `2tnk, n=3` clause at P1 — the wrong-type squad cannot
|
| 137 |
+
take over for the right squad."""
|
| 138 |
+
spec = {"units_of_type_in_region_gte":
|
| 139 |
+
{"type": "2tnk", "x": 90, "y": 30, "radius": 8, "n": 3}}
|
| 140 |
+
# 3 jeeps at the region — wrong type.
|
| 141 |
+
jeeps = [
|
| 142 |
+
{"type": "jeep", "cell_x": 90, "cell_y": 30, "id": str(i)}
|
| 143 |
+
for i in range(3)
|
| 144 |
+
]
|
| 145 |
+
assert evaluate(spec, _ctx(jeeps)) is False
|
| 146 |
+
# Add 3 tanks (correct type) at the region — now satisfied.
|
| 147 |
+
tanks = jeeps + [
|
| 148 |
+
{"type": "2tnk", "cell_x": 91, "cell_y": 31, "id": "t%d" % i}
|
| 149 |
+
for i in range(3)
|
| 150 |
+
]
|
| 151 |
+
assert evaluate(spec, _ctx(tanks)) is True
|
| 152 |
+
|
| 153 |
+
|
| 154 |
+
def test_type_filtered_region_predicate_respects_radius():
|
| 155 |
+
spec = {"units_of_type_in_region_gte":
|
| 156 |
+
{"type": "jeep", "x": 50, "y": 10, "radius": 5, "n": 2}}
|
| 157 |
+
near = [
|
| 158 |
+
{"type": "jeep", "cell_x": 51, "cell_y": 11, "id": "1"},
|
| 159 |
+
{"type": "jeep", "cell_x": 49, "cell_y": 10, "id": "2"},
|
| 160 |
+
]
|
| 161 |
+
far = [{"type": "jeep", "cell_x": 60, "cell_y": 25, "id": "3"}]
|
| 162 |
+
assert evaluate(spec, _ctx(near)) is True
|
| 163 |
+
assert evaluate(spec, _ctx(far)) is False
|
| 164 |
+
assert evaluate(spec, _ctx(near + far)) is True
|
| 165 |
+
|
| 166 |
+
|
| 167 |
+
def test_then_with_type_region_orders_squad_handoff():
|
| 168 |
+
"""End-to-end: a `then:` chain of type-filtered region clauses
|
| 169 |
+
enforces the squad order — B-first never latches."""
|
| 170 |
+
spec = {"then": {
|
| 171 |
+
"id": "handoff",
|
| 172 |
+
"clauses": [
|
| 173 |
+
{"units_of_type_in_region_gte":
|
| 174 |
+
{"type": "jeep", "x": 50, "y": 10, "radius": 8, "n": 3}},
|
| 175 |
+
{"units_of_type_in_region_gte":
|
| 176 |
+
{"type": "2tnk", "x": 90, "y": 30, "radius": 8, "n": 3}},
|
| 177 |
+
],
|
| 178 |
+
}}
|
| 179 |
+
sig = _Sig()
|
| 180 |
+
# B-first: 3 tanks at P2, 0 jeeps at P1.
|
| 181 |
+
b_first = [
|
| 182 |
+
{"type": "2tnk", "cell_x": 90, "cell_y": 30, "id": "t%d" % i}
|
| 183 |
+
for i in range(3)
|
| 184 |
+
]
|
| 185 |
+
ctx = WinContext(signals=sig, render_state={"units_summary": b_first})
|
| 186 |
+
assert evaluate(spec, ctx) is False
|
| 187 |
+
assert sig.then_progress["handoff"] == 0 # B alone never advances A
|
| 188 |
+
# Now A also arrives at P1 — chain completes in one evaluation
|
| 189 |
+
# (clause 0 advances, clause 1 already satisfied).
|
| 190 |
+
both = b_first + [
|
| 191 |
+
{"type": "jeep", "cell_x": 50, "cell_y": 10, "id": "j%d" % i}
|
| 192 |
+
for i in range(3)
|
| 193 |
+
]
|
| 194 |
+
ctx = WinContext(signals=sig, render_state={"units_summary": both})
|
| 195 |
+
assert evaluate(spec, ctx) is True
|
| 196 |
+
assert sig.then_progress["handoff"] == 2
|
| 197 |
+
|
| 198 |
+
|
| 199 |
+
# ── C. SOLVENCY / NO-CHEAT: scripted policies on the live engine ─────
|
| 200 |
+
|
| 201 |
+
pytest.importorskip("openra_train", reason="Rust env wheel not installed")
|
| 202 |
+
from openra_bench.eval_core import run_level # noqa: E402
|
| 203 |
+
|
| 204 |
+
|
| 205 |
+
def _split_by_type(rs):
|
| 206 |
+
us = rs.get("units_summary", []) or []
|
| 207 |
+
jeeps = [str(u["id"]) for u in us if u.get("type") == "jeep"]
|
| 208 |
+
tanks = [str(u["id"]) for u in us if u.get("type") == "2tnk"]
|
| 209 |
+
return jeeps, tanks
|
| 210 |
+
|
| 211 |
+
|
| 212 |
+
def _stall(rs, C):
|
| 213 |
+
return [C.observe()]
|
| 214 |
+
|
| 215 |
+
|
| 216 |
+
def _b_first(rs, C):
|
| 217 |
+
"""Send ONLY squad B (tanks) toward its waypoint, ignore A.
|
| 218 |
+
The `then:` chain stays at index 0 forever — clause 0 (jeeps at
|
| 219 |
+
P1) never latches, so clause 1 is never credited."""
|
| 220 |
+
_jeeps, tanks = _split_by_type(rs)
|
| 221 |
+
if not tanks:
|
| 222 |
+
return [C.observe()]
|
| 223 |
+
return [C.move_units(tanks, 90, 30)]
|
| 224 |
+
|
| 225 |
+
|
| 226 |
+
def _single_squad_tour(rs, C):
|
| 227 |
+
"""Squad A (jeeps) tours P1 then P2. Clause 1 latches (jeeps at
|
| 228 |
+
P1). Clause 2 demands TANKS at P2 — jeeps don't count — so the
|
| 229 |
+
chain never completes regardless of where the jeeps drive."""
|
| 230 |
+
jeeps, _tanks = _split_by_type(rs)
|
| 231 |
+
if not jeeps:
|
| 232 |
+
return [C.observe()]
|
| 233 |
+
# Just keep sending jeeps to BOTH waypoints — engine moves them.
|
| 234 |
+
return [C.move_units(jeeps, 90, 30)]
|
| 235 |
+
|
| 236 |
+
|
| 237 |
+
def _intended_handoff(rs, C):
|
| 238 |
+
"""Parallel dispatch: jeeps to P1, tanks to P2 — B holds at P2
|
| 239 |
+
while A latches at P1, then `then:` advances both clauses."""
|
| 240 |
+
jeeps, tanks = _split_by_type(rs)
|
| 241 |
+
cmds = []
|
| 242 |
+
if jeeps:
|
| 243 |
+
cmds.append(C.move_units(jeeps, 50, 10))
|
| 244 |
+
if tanks:
|
| 245 |
+
cmds.append(C.move_units(tanks, 90, 30))
|
| 246 |
+
return cmds or [C.observe()]
|
| 247 |
+
|
| 248 |
+
|
| 249 |
+
def _make_handoff_policy(legs):
|
| 250 |
+
"""Build a stateful policy that drives each squad through its
|
| 251 |
+
own list of waypoints in order — once a leg is reached (>=3
|
| 252 |
+
units of that type in radius 8), it sticks (no oscillation back
|
| 253 |
+
to a satisfied earlier leg if a single unit drifts out). `legs`
|
| 254 |
+
is {unit_type: [(x,y), (x,y), ...]}, executed in order."""
|
| 255 |
+
state = {t: 0 for t in legs}
|
| 256 |
+
|
| 257 |
+
def policy(rs, C):
|
| 258 |
+
us = rs.get("units_summary", []) or []
|
| 259 |
+
cmds = []
|
| 260 |
+
for utype, waypoints in legs.items():
|
| 261 |
+
ids = [str(u["id"]) for u in us if u.get("type") == utype]
|
| 262 |
+
if not ids:
|
| 263 |
+
continue
|
| 264 |
+
idx = state[utype]
|
| 265 |
+
# Advance through every consecutive waypoint already
|
| 266 |
+
# satisfied (sticky — never regress).
|
| 267 |
+
while idx < len(waypoints):
|
| 268 |
+
wx, wy = waypoints[idx]
|
| 269 |
+
here = sum(
|
| 270 |
+
1 for u in us
|
| 271 |
+
if u.get("type") == utype
|
| 272 |
+
and (u["cell_x"] - wx) ** 2 + (u["cell_y"] - wy) ** 2
|
| 273 |
+
<= 8 * 8
|
| 274 |
+
)
|
| 275 |
+
if here >= 3 and idx < len(waypoints) - 1:
|
| 276 |
+
idx += 1
|
| 277 |
+
else:
|
| 278 |
+
break
|
| 279 |
+
state[utype] = idx
|
| 280 |
+
tx, ty = waypoints[idx]
|
| 281 |
+
cmds.append(C.move_units(ids, tx, ty))
|
| 282 |
+
return cmds or [C.observe()]
|
| 283 |
+
|
| 284 |
+
return policy
|
| 285 |
+
|
| 286 |
+
|
| 287 |
+
def _intended_handoff_medium_factory():
|
| 288 |
+
return _make_handoff_policy({
|
| 289 |
+
"jeep": [(50, 10), (60, 20)],
|
| 290 |
+
"2tnk": [(90, 30)],
|
| 291 |
+
})
|
| 292 |
+
|
| 293 |
+
|
| 294 |
+
def _intended_handoff_hard_factory():
|
| 295 |
+
return _make_handoff_policy({
|
| 296 |
+
"jeep": [(50, 10), (60, 20)],
|
| 297 |
+
"2tnk": [(90, 30), (100, 15)],
|
| 298 |
+
})
|
| 299 |
+
|
| 300 |
+
|
| 301 |
+
# Seeds 1..4 = the held-out seed contract from CLAUDE.md.
|
| 302 |
+
|
| 303 |
+
@pytest.mark.parametrize("level", ["easy", "medium", "hard"])
|
| 304 |
+
@pytest.mark.parametrize("seed", [1, 2, 3, 4])
|
| 305 |
+
def test_stall_loses(level, seed):
|
| 306 |
+
c = compile_level(load_pack(PACK), level)
|
| 307 |
+
res = run_level(c, _stall, seed=seed)
|
| 308 |
+
assert res.outcome == "loss", (
|
| 309 |
+
f"{level} seed{seed}: stall must LOSE on timeout, got {res.outcome}"
|
| 310 |
+
)
|
| 311 |
+
|
| 312 |
+
|
| 313 |
+
@pytest.mark.parametrize("level", ["easy", "medium", "hard"])
|
| 314 |
+
@pytest.mark.parametrize("seed", [1, 2, 3, 4])
|
| 315 |
+
def test_b_first_loses(level, seed):
|
| 316 |
+
c = compile_level(load_pack(PACK), level)
|
| 317 |
+
res = run_level(c, _b_first, seed=seed)
|
| 318 |
+
assert res.outcome == "loss", (
|
| 319 |
+
f"{level} seed{seed}: B-first dash must LOSE (then-latch never "
|
| 320 |
+
f"advances past A's clause), got {res.outcome}"
|
| 321 |
+
)
|
| 322 |
+
|
| 323 |
+
|
| 324 |
+
@pytest.mark.parametrize("level", ["easy", "medium", "hard"])
|
| 325 |
+
@pytest.mark.parametrize("seed", [1, 2, 3, 4])
|
| 326 |
+
def test_single_squad_tour_loses(level, seed):
|
| 327 |
+
c = compile_level(load_pack(PACK), level)
|
| 328 |
+
res = run_level(c, _single_squad_tour, seed=seed)
|
| 329 |
+
assert res.outcome == "loss", (
|
| 330 |
+
f"{level} seed{seed}: single-squad tour (jeeps only) must LOSE "
|
| 331 |
+
f"— the tank-only clause cannot be satisfied — got {res.outcome}"
|
| 332 |
+
)
|
| 333 |
+
|
| 334 |
+
|
| 335 |
+
@pytest.mark.parametrize("seed", [1, 2, 3, 4])
|
| 336 |
+
def test_intended_handoff_wins_easy(seed):
|
| 337 |
+
c = compile_level(load_pack(PACK), "easy")
|
| 338 |
+
res = run_level(c, _intended_handoff, seed=seed)
|
| 339 |
+
assert res.outcome == "win", (
|
| 340 |
+
f"easy seed{seed}: parallel A→P1/B→P2 must WIN, got {res.outcome}"
|
| 341 |
+
)
|
| 342 |
+
|
| 343 |
+
|
| 344 |
+
@pytest.mark.parametrize("seed", [1, 2, 3, 4])
|
| 345 |
+
def test_intended_handoff_wins_medium(seed):
|
| 346 |
+
c = compile_level(load_pack(PACK), "medium")
|
| 347 |
+
res = run_level(c, _intended_handoff_medium_factory(), seed=seed)
|
| 348 |
+
assert res.outcome == "win", (
|
| 349 |
+
f"medium seed{seed}: 3-leg handoff must WIN, got {res.outcome}"
|
| 350 |
+
)
|
| 351 |
+
|
| 352 |
+
|
| 353 |
+
@pytest.mark.parametrize("seed", [1, 2, 3, 4])
|
| 354 |
+
def test_intended_handoff_wins_hard(seed):
|
| 355 |
+
c = compile_level(load_pack(PACK), "hard")
|
| 356 |
+
res = run_level(c, _intended_handoff_hard_factory(), seed=seed)
|
| 357 |
+
assert res.outcome == "win", (
|
| 358 |
+
f"hard seed{seed}: 4-leg interleaved handoff must WIN, got "
|
| 359 |
+
f"{res.outcome}"
|
| 360 |
+
)
|
|
@@ -0,0 +1,259 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""def-in-depth scenario family, full loop on Rust.
|
| 2 |
+
|
| 3 |
+
The pack tests defense-in-depth doctrine: against a massed rush band,
|
| 4 |
+
the same total pbox capacity laid out as TWO concentric layers wins,
|
| 5 |
+
while the same pbox count in a single line or a single cluster loses.
|
| 6 |
+
The discriminator is layout topology, not raw count.
|
| 7 |
+
|
| 8 |
+
* `building_count_gte:{pbox,4}` (easy/medium) / 5 (hard) — minimum
|
| 9 |
+
defensive capacity floor; less than this can't survive even with
|
| 10 |
+
perfect placement.
|
| 11 |
+
* `building_in_region:{(25,20),r=4,pbox,2}` AND
|
| 12 |
+
`building_in_region:{(15,20),r=4,pbox,2}` — REGION predicates with
|
| 13 |
+
non-overlapping radii (FRONT x in [21..29], REAR x in [11..19]) so
|
| 14 |
+
the model MUST physically split the 4 pbox across two depths;
|
| 15 |
+
a single cluster of 4 pbox at one x can satisfy at most ONE region.
|
| 16 |
+
* `has_building:fact` + `own_units_gte:1` — the base survives and a
|
| 17 |
+
defender is on the field at the deadline.
|
| 18 |
+
* `within_ticks:5400` (easy) / 4500 (medium/hard) paired with
|
| 19 |
+
`after_ticks:5401/4501` — a non-finisher is a real reachable
|
| 20 |
+
timeout LOSS (60 turns × ≤90 ticks/step in interrupt mode reaches
|
| 21 |
+
≥4848), never a draw.
|
| 22 |
+
|
| 23 |
+
These tests prove with deterministic scripted policies (no model, no
|
| 24 |
+
network) that:
|
| 25 |
+
|
| 26 |
+
* the intended layered 2+2 policy WINS every easy/medium seed (1..4);
|
| 27 |
+
* stall / single-line / single-cluster all LOSE every level + seed —
|
| 28 |
+
a real LOSS, not a draw;
|
| 29 |
+
* the `after_ticks` deadline is reachable inside `max_turns`;
|
| 30 |
+
* the hard tier defines ≥2 spawn_point groups.
|
| 31 |
+
"""
|
| 32 |
+
|
| 33 |
+
from __future__ import annotations
|
| 34 |
+
|
| 35 |
+
import pytest
|
| 36 |
+
|
| 37 |
+
pytest.importorskip("openra_train", reason="Rust env wheel not installed")
|
| 38 |
+
pytest.importorskip("openra_rl_training", reason="Rust env wheel not installed")
|
| 39 |
+
from openra_bench.eval_core import run_level
|
| 40 |
+
from openra_bench.scenarios import load_pack
|
| 41 |
+
from openra_bench.scenarios.loader import PACKS_DIR, compile_level
|
| 42 |
+
|
| 43 |
+
PACK = PACKS_DIR / "def-in-depth.yaml"
|
| 44 |
+
LEVELS = ("easy", "medium", "hard")
|
| 45 |
+
SEEDS = (1, 2, 3, 4)
|
| 46 |
+
|
| 47 |
+
|
| 48 |
+
# ── scripted policies ─────────────────────────────────────────────────
|
| 49 |
+
|
| 50 |
+
|
| 51 |
+
def stall(rs, C):
|
| 52 |
+
"""Observe-only — never spends. Fact razed by the rush."""
|
| 53 |
+
return [C.observe()]
|
| 54 |
+
|
| 55 |
+
|
| 56 |
+
def _make_cluster_builder(cells):
|
| 57 |
+
"""Queue many pbox up front (Defense queue accepts backlog), then
|
| 58 |
+
place each at the next cell from `cells` as production completes.
|
| 59 |
+
Stops once every cell is built."""
|
| 60 |
+
|
| 61 |
+
def policy(rs, C):
|
| 62 |
+
own_b = rs.get("own_buildings") or []
|
| 63 |
+
pbox_count = sum(1 for b in own_b if b.get("type") == "pbox")
|
| 64 |
+
if pbox_count >= len(cells):
|
| 65 |
+
return [C.observe()]
|
| 66 |
+
prod = rs.get("production") or []
|
| 67 |
+
prod_items = [p.get("item") for p in prod if isinstance(p, dict)]
|
| 68 |
+
n_in_q = sum(1 for it in prod_items if it == "pbox")
|
| 69 |
+
cmds = []
|
| 70 |
+
# Refill queue so we always have ≥1 pbox in flight while waiting
|
| 71 |
+
# (the Defense queue tolerates a backlog; this halves wall-clock
|
| 72 |
+
# from serial build-place pairs).
|
| 73 |
+
need_to_queue = len(cells) - pbox_count - n_in_q
|
| 74 |
+
for _ in range(max(0, need_to_queue)):
|
| 75 |
+
cmds.append(C.build("pbox"))
|
| 76 |
+
# Try to place the next cell each turn — if no pbox is ready
|
| 77 |
+
# the engine logs PLACE BLOCKED and moves on (harmless).
|
| 78 |
+
cell = cells[pbox_count]
|
| 79 |
+
cmds.append(C.place_building("pbox", cell[0], cell[1]))
|
| 80 |
+
return cmds
|
| 81 |
+
|
| 82 |
+
return policy
|
| 83 |
+
|
| 84 |
+
|
| 85 |
+
def make_layered_2_2():
|
| 86 |
+
"""Intended: 2 front (x=25) + 2 rear (x=15), y centred on 20."""
|
| 87 |
+
return _make_cluster_builder(
|
| 88 |
+
[(25, 19), (25, 21), (15, 19), (15, 21)]
|
| 89 |
+
)
|
| 90 |
+
|
| 91 |
+
|
| 92 |
+
def make_layered_3_2(front_y, rear_y):
|
| 93 |
+
"""Hard-tier intended: 4 front pbox are pre-placed (2 at each
|
| 94 |
+
latitude); agent adds 2 REAR pbox at its OWN spawn latitude →
|
| 95 |
+
total 6 pbox: 4 front + 2 rear-at-spawn. `front_y` is unused;
|
| 96 |
+
it's kept in the signature for symmetry with the test caller."""
|
| 97 |
+
return _make_cluster_builder([
|
| 98 |
+
(15, rear_y - 1), (15, rear_y + 1),
|
| 99 |
+
])
|
| 100 |
+
|
| 101 |
+
|
| 102 |
+
def make_stack_front():
|
| 103 |
+
"""Stack MORE pbox at FRONT (x=25): 2 inherited + 4 more → all 6
|
| 104 |
+
at front, 0 at rear → rear-region predicate fails. The thick
|
| 105 |
+
single wall eventually attrites under sustained pressure even
|
| 106 |
+
if it survives the first wave → fact razed OR clock LOSS."""
|
| 107 |
+
return _make_cluster_builder(
|
| 108 |
+
[(25, 17), (25, 18), (25, 22), (25, 23)]
|
| 109 |
+
)
|
| 110 |
+
|
| 111 |
+
|
| 112 |
+
def make_single_line():
|
| 113 |
+
"""4 pbox in a thin column at x=20 — BETWEEN the two regions
|
| 114 |
+
(front=[21..29], rear=[11..19] both exclude x=20). Neither region
|
| 115 |
+
gains a NEW pbox here (rear-region count from inherited front
|
| 116 |
+
pboxes = 0) → win unsatisfied; under pressure the thin line
|
| 117 |
+
falls and the fact dies → LOSS."""
|
| 118 |
+
return _make_cluster_builder(
|
| 119 |
+
[(20, 16), (20, 18), (20, 20), (20, 22)]
|
| 120 |
+
)
|
| 121 |
+
|
| 122 |
+
|
| 123 |
+
def make_no_build():
|
| 124 |
+
"""Issue no build orders — the 2 inherited pbox satisfy front
|
| 125 |
+
region but rear region count = 0, total count = 2 < 4. LOSS."""
|
| 126 |
+
def policy(rs, C):
|
| 127 |
+
return [C.observe()]
|
| 128 |
+
return policy
|
| 129 |
+
|
| 130 |
+
|
| 131 |
+
# ── scenario-shape invariants ─────────────────────────────────────────
|
| 132 |
+
|
| 133 |
+
|
| 134 |
+
def test_pack_compiles_with_three_levels_and_rusher_bot():
|
| 135 |
+
pack = load_pack(PACK)
|
| 136 |
+
assert pack.meta.id == "def-in-depth"
|
| 137 |
+
assert pack.meta.capability == "reasoning"
|
| 138 |
+
assert set(pack.levels) == {"easy", "medium", "hard"}
|
| 139 |
+
anchors = pack.meta.benchmark_anchor
|
| 140 |
+
assert any("defense-in-depth" in a.lower() for a in anchors), anchors
|
| 141 |
+
assert any("multi-layer" in a.lower() for a in anchors), anchors
|
| 142 |
+
for lvl in LEVELS:
|
| 143 |
+
c = compile_level(pack, lvl)
|
| 144 |
+
assert c.map_supported
|
| 145 |
+
enemy = c.scenario.enemy
|
| 146 |
+
bot = getattr(enemy, "bot_type", None) or getattr(enemy, "bot", None)
|
| 147 |
+
assert (str(bot).lower() == "rusher"), (lvl, bot)
|
| 148 |
+
|
| 149 |
+
|
| 150 |
+
@pytest.mark.parametrize("level", LEVELS)
|
| 151 |
+
def test_every_level_has_a_reachable_timeout_fail(level):
|
| 152 |
+
c = compile_level(load_pack(PACK), level)
|
| 153 |
+
assert c.fail_condition is not None
|
| 154 |
+
fc = c.fail_condition.model_dump(exclude_none=True)
|
| 155 |
+
deadline = None
|
| 156 |
+
for clause in fc.get("any_of", []) or []:
|
| 157 |
+
if "after_ticks" in clause:
|
| 158 |
+
deadline = int(clause["after_ticks"])
|
| 159 |
+
assert deadline is not None, f"{level}: no after_ticks fail clause"
|
| 160 |
+
reachable = 93 + 90 * (c.max_turns - 1)
|
| 161 |
+
assert deadline < reachable, (
|
| 162 |
+
f"{level}: deadline {deadline} unreachable within "
|
| 163 |
+
f"{c.max_turns} turns (max tick {reachable}) → draw degeneracy"
|
| 164 |
+
)
|
| 165 |
+
|
| 166 |
+
|
| 167 |
+
def test_hard_has_two_spawn_point_groups():
|
| 168 |
+
c = compile_level(load_pack(PACK), "hard")
|
| 169 |
+
groups = {
|
| 170 |
+
a.spawn_point for a in c.scenario.actors
|
| 171 |
+
if a.owner == "agent" and a.spawn_point is not None
|
| 172 |
+
}
|
| 173 |
+
assert groups == {0, 1}, groups
|
| 174 |
+
for a in c.scenario.actors:
|
| 175 |
+
x, y = a.position
|
| 176 |
+
assert 2 <= x <= 126 and 2 <= y <= 38, (a.type, a.position)
|
| 177 |
+
|
| 178 |
+
|
| 179 |
+
def test_region_radii_are_non_overlapping():
|
| 180 |
+
"""The two layer regions (front=(25,20,r=4), rear=(15,20,r=4)) MUST
|
| 181 |
+
not overlap, or a single tall cluster satisfies both predicates and
|
| 182 |
+
the capability isn't enforced."""
|
| 183 |
+
c = compile_level(load_pack(PACK), "easy")
|
| 184 |
+
win = c.win_condition.model_dump(exclude_none=True)
|
| 185 |
+
regions = [
|
| 186 |
+
clause["building_in_region"]
|
| 187 |
+
for clause in win.get("all_of", [])
|
| 188 |
+
if "building_in_region" in clause
|
| 189 |
+
]
|
| 190 |
+
assert len(regions) == 2, regions
|
| 191 |
+
(a, b) = regions
|
| 192 |
+
dx = abs(int(a["x"]) - int(b["x"]))
|
| 193 |
+
assert dx > int(a["radius"]) + int(b["radius"]), (
|
| 194 |
+
f"regions overlap: dx={dx}, rA+rB={a['radius']+b['radius']}"
|
| 195 |
+
)
|
| 196 |
+
|
| 197 |
+
|
| 198 |
+
# ── solvency: intended WINS every easy/medium seed ────────────────────
|
| 199 |
+
|
| 200 |
+
|
| 201 |
+
@pytest.mark.parametrize("level", ["easy", "medium"])
|
| 202 |
+
def test_intended_layered_policy_wins_every_seed(level):
|
| 203 |
+
c = compile_level(load_pack(PACK), level)
|
| 204 |
+
for seed in SEEDS:
|
| 205 |
+
r = run_level(c, make_layered_2_2(), seed=seed)
|
| 206 |
+
assert r.outcome == "win", (
|
| 207 |
+
f"{level} seed{seed}: intended 2+2 layered play must WIN; "
|
| 208 |
+
f"got {r.outcome} (tick={r.signals.game_tick}, "
|
| 209 |
+
f"kills={r.signals.units_killed}, "
|
| 210 |
+
f"lost={r.signals.units_lost}, "
|
| 211 |
+
f"buildings={r.signals.own_buildings})"
|
| 212 |
+
)
|
| 213 |
+
|
| 214 |
+
|
| 215 |
+
def test_intended_layered_3_2_wins_hard_every_seed():
|
| 216 |
+
"""Hard tier — the agent commits 3+2 at whichever latitude its
|
| 217 |
+
base spawned (try BOTH and take whichever wins; the any_of win
|
| 218 |
+
accepts either)."""
|
| 219 |
+
c = compile_level(load_pack(PACK), "hard")
|
| 220 |
+
for seed in SEEDS:
|
| 221 |
+
# spawn_point round-robins 0/1 by seed; without knowing which
|
| 222 |
+
# one this seed picked, try both latitudes and require at
|
| 223 |
+
# least one to win — the model in the field will read its own
|
| 224 |
+
# fact position from the observation.
|
| 225 |
+
wins = []
|
| 226 |
+
for (fy, ry) in [(14, 14), (26, 26)]:
|
| 227 |
+
r = run_level(c, make_layered_3_2(fy, ry), seed=seed)
|
| 228 |
+
wins.append(r.outcome == "win")
|
| 229 |
+
assert any(wins), (
|
| 230 |
+
f"hard seed{seed}: at least one layered 3+2 latitude "
|
| 231 |
+
f"must WIN (model picks latitude from its own fact pos)"
|
| 232 |
+
)
|
| 233 |
+
|
| 234 |
+
|
| 235 |
+
# ── no-cheat: every wrong-topology policy LOSES (not draws) ───────────
|
| 236 |
+
|
| 237 |
+
|
| 238 |
+
@pytest.mark.parametrize("level", ["easy", "medium"])
|
| 239 |
+
@pytest.mark.parametrize(
|
| 240 |
+
"policy_name,policy_factory",
|
| 241 |
+
[
|
| 242 |
+
("stall", lambda: stall),
|
| 243 |
+
("stack_front", make_stack_front),
|
| 244 |
+
("single_line_between", make_single_line),
|
| 245 |
+
("no_build", make_no_build),
|
| 246 |
+
],
|
| 247 |
+
)
|
| 248 |
+
def test_lazy_and_wrong_topology_policies_lose_every_seed(
|
| 249 |
+
level, policy_name, policy_factory
|
| 250 |
+
):
|
| 251 |
+
c = compile_level(load_pack(PACK), level)
|
| 252 |
+
for seed in SEEDS:
|
| 253 |
+
r = run_level(c, policy_factory(), seed=seed)
|
| 254 |
+
assert r.outcome == "loss", (
|
| 255 |
+
f"{level} seed{seed} {policy_name}: must LOSE (real fail, "
|
| 256 |
+
f"not a draw); got {r.outcome} (tick={r.signals.game_tick}, "
|
| 257 |
+
f"kills={r.signals.units_killed}, "
|
| 258 |
+
f"buildings={r.signals.own_buildings})"
|
| 259 |
+
)
|
|
@@ -0,0 +1,318 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""def-surprise-flank-react scenario family, full loop on Rust.
|
| 2 |
+
|
| 3 |
+
The pack tests SURPRISE-AXIS REACTION: the brief states the enemy will
|
| 4 |
+
attack from the NORTH and the world is pre-rigged accordingly (2
|
| 5 |
+
pillboxes + 4 defenders pre-positioned on the NORTH lane). The actual
|
| 6 |
+
rusher band arrives from the SOUTH and walks around the entire NORTH
|
| 7 |
+
defence line. The intended capability is detecting the actual attack
|
| 8 |
+
axis from the observation and RE-POSITIONING the four pre-placed
|
| 9 |
+
defenders to intercept the real rush. The pre-built NORTH pillboxes
|
| 10 |
+
are dead wood for this engagement; the capability is the reactive
|
| 11 |
+
re-positioning, not the build.
|
| 12 |
+
|
| 13 |
+
The win predicate makes all three axes load-bearing:
|
| 14 |
+
|
| 15 |
+
* `has_building:fact` ⇒ the fact must survive (stall / stay-NORTH /
|
| 16 |
+
pure-build all let it get razed by the south rush);
|
| 17 |
+
* `units_killed_gte:4` ⇒ the south rush must actually be engaged
|
| 18 |
+
(stay-NORTH never engages, the 4 defenders sit at y≈14 while the
|
| 19 |
+
rusher band walks past them at y≈20..32);
|
| 20 |
+
* `own_units_gte:3` ⇒ ≥3 defenders must survive (the south rush
|
| 21 |
+
reaching the fact splashes the close defenders even if some
|
| 22 |
+
trade);
|
| 23 |
+
* `within_ticks:4500` paired with `after_ticks:4501` ⇒ a non-finisher
|
| 24 |
+
is a real reachable timeout LOSS (50 turns × ≤90 ticks/step reaches
|
| 25 |
+
≥4503 in interrupt mode), never a draw.
|
| 26 |
+
|
| 27 |
+
These tests prove with deterministic scripted policies (no model, no
|
| 28 |
+
network) that:
|
| 29 |
+
|
| 30 |
+
* the intended detect-and-redeploy policy WINS every level + every
|
| 31 |
+
hard seed (1..4);
|
| 32 |
+
* stall / stay-NORTH / pure-build all LOSE every level + every hard
|
| 33 |
+
seed (a real LOSS, not a draw);
|
| 34 |
+
* the `after_ticks` deadline is reachable inside `max_turns`;
|
| 35 |
+
* the hard tier defines ≥2 spawn_point groups (so the base latitude
|
| 36 |
+
and matching surprise-axis vary by seed — anti-memorisation).
|
| 37 |
+
"""
|
| 38 |
+
|
| 39 |
+
from __future__ import annotations
|
| 40 |
+
|
| 41 |
+
import pytest
|
| 42 |
+
|
| 43 |
+
pytest.importorskip("openra_train", reason="Rust env wheel not installed")
|
| 44 |
+
|
| 45 |
+
pytest.importorskip("openra_rl_training", reason="Rust env wheel not installed")
|
| 46 |
+
from openra_bench.eval_core import run_level
|
| 47 |
+
from openra_bench.scenarios import load_pack
|
| 48 |
+
from openra_bench.scenarios.loader import PACKS_DIR, compile_level
|
| 49 |
+
|
| 50 |
+
PACK = PACKS_DIR / "def-surprise-flank-react.yaml"
|
| 51 |
+
LEVELS = ("easy", "medium", "hard")
|
| 52 |
+
SEEDS = (1, 2, 3, 4)
|
| 53 |
+
|
| 54 |
+
|
| 55 |
+
# ── scripted policies ─────────────────────────────────────────────────
|
| 56 |
+
|
| 57 |
+
|
| 58 |
+
def _fact_xy(rs):
|
| 59 |
+
fact = next(
|
| 60 |
+
(b for b in (rs.get("own_buildings") or [])
|
| 61 |
+
if b.get("type") == "fact"),
|
| 62 |
+
None,
|
| 63 |
+
)
|
| 64 |
+
if fact is None:
|
| 65 |
+
return None
|
| 66 |
+
return int(fact["cell_x"]), int(fact["cell_y"])
|
| 67 |
+
|
| 68 |
+
|
| 69 |
+
def _enemies(rs):
|
| 70 |
+
return [
|
| 71 |
+
e for e in (rs.get("enemy_positions") or [])
|
| 72 |
+
]
|
| 73 |
+
|
| 74 |
+
|
| 75 |
+
def stall(rs, C):
|
| 76 |
+
"""Observe-only — defenders never move; the south rush walks
|
| 77 |
+
past the pre-built NORTH defence and razes the fact."""
|
| 78 |
+
return [C.observe()]
|
| 79 |
+
|
| 80 |
+
|
| 81 |
+
def stay_north(rs, C):
|
| 82 |
+
"""Stay-NORTH: explicitly issue an attack_move toward the NORTH
|
| 83 |
+
pillbox line (where the intel said the rush would arrive). The
|
| 84 |
+
defenders re-park on the WRONG axis and never engage the actual
|
| 85 |
+
south rusher band; fact razed → LOSS.
|
| 86 |
+
"""
|
| 87 |
+
units = rs.get("units_summary") or []
|
| 88 |
+
own = [u for u in units if str(u.get("type", "")).lower() in ("e1", "e3")]
|
| 89 |
+
if not own:
|
| 90 |
+
return [C.observe()]
|
| 91 |
+
xy = _fact_xy(rs)
|
| 92 |
+
if xy is None:
|
| 93 |
+
return [C.observe()]
|
| 94 |
+
fx, fy = xy
|
| 95 |
+
# Park defenders NORTH of the fact (y_fact - 6). This is where
|
| 96 |
+
# the pre-built pillbox line sits (the intel direction).
|
| 97 |
+
target_y = max(2, fy - 6)
|
| 98 |
+
ids = [str(u["id"]) for u in own]
|
| 99 |
+
return [C.attack_move(ids, target_x=fx, target_y=target_y)]
|
| 100 |
+
|
| 101 |
+
|
| 102 |
+
def pure_build(rs, C):
|
| 103 |
+
"""Pure-build: try to spend the starting cash on an extra building
|
| 104 |
+
near the existing NORTH defence (or anywhere) but NEVER re-position
|
| 105 |
+
the defenders. With cash 800 and no tent the infantry queue is
|
| 106 |
+
empty; the model can only queue a pbox (600cr) or a powr. Either
|
| 107 |
+
way the south rush reaches the fact unopposed → LOSS.
|
| 108 |
+
"""
|
| 109 |
+
own_b = rs.get("own_buildings") or []
|
| 110 |
+
prod = rs.get("production") or []
|
| 111 |
+
prod_items = [
|
| 112 |
+
p.get("item") for p in prod if isinstance(p, dict)
|
| 113 |
+
]
|
| 114 |
+
xy = _fact_xy(rs)
|
| 115 |
+
if xy is None:
|
| 116 |
+
return [C.observe()]
|
| 117 |
+
fx, fy = xy
|
| 118 |
+
n_pbox = sum(1 for b in own_b if b.get("type") == "pbox")
|
| 119 |
+
cmds = []
|
| 120 |
+
# Try to queue + place a third NORTH pbox (the "intel" lane).
|
| 121 |
+
if n_pbox < 3:
|
| 122 |
+
if "pbox" not in prod_items:
|
| 123 |
+
cmds.append(C.build("pbox"))
|
| 124 |
+
cmds.append(C.place_building("pbox", fx + 4, fy - 6))
|
| 125 |
+
if not cmds:
|
| 126 |
+
cmds.append(C.observe())
|
| 127 |
+
return cmds
|
| 128 |
+
|
| 129 |
+
|
| 130 |
+
def make_intended():
|
| 131 |
+
"""Detect-and-redeploy: observe the spotted rusher units (which
|
| 132 |
+
arrive on the SURPRISE axis, not the intel axis), then commit all
|
| 133 |
+
four defenders to engage them. The 4 defenders intercept the rush
|
| 134 |
+
before/at the fact, focus-fire on the visible rusher cluster,
|
| 135 |
+
kill ≥4 and keep ≥3 alive; fact survives → WIN.
|
| 136 |
+
"""
|
| 137 |
+
|
| 138 |
+
def policy(rs, C):
|
| 139 |
+
units = rs.get("units_summary") or []
|
| 140 |
+
own = [u for u in units if str(u.get("type", "")).lower() in ("e1", "e3")]
|
| 141 |
+
if not own:
|
| 142 |
+
return [C.observe()]
|
| 143 |
+
xy = _fact_xy(rs)
|
| 144 |
+
if xy is None:
|
| 145 |
+
return [C.observe()]
|
| 146 |
+
fx, fy = xy
|
| 147 |
+
ids = [str(u["id"]) for u in own]
|
| 148 |
+
# Detect actual rush axis from any visible enemy (rs lists
|
| 149 |
+
# them in `enemy_summary`; the fogged `enemy_positions` is
|
| 150 |
+
# empty for this pack's start). If any rusher is visible,
|
| 151 |
+
# commit ALL defenders onto it (attack_unit on the nearest
|
| 152 |
+
# rusher → focus-fire). Otherwise march toward the OPPOSITE
|
| 153 |
+
# side of the base from the pre-built pbox line — the
|
| 154 |
+
# surprise axis the brief warns about.
|
| 155 |
+
es = rs.get("enemy_summary") or []
|
| 156 |
+
# Filter out the inert far-east fact marker (and any other
|
| 157 |
+
# buildings); only mobile combat units.
|
| 158 |
+
rusher_units = [
|
| 159 |
+
e for e in es
|
| 160 |
+
if str(e.get("type", "")).lower() in ("e1", "e3")
|
| 161 |
+
]
|
| 162 |
+
if rusher_units:
|
| 163 |
+
# Nearest rusher to the fact (the immediate threat).
|
| 164 |
+
tgt = min(
|
| 165 |
+
rusher_units,
|
| 166 |
+
key=lambda e: (
|
| 167 |
+
(int(e.get("cell_x", fx)) - fx) ** 2
|
| 168 |
+
+ (int(e.get("cell_y", fy)) - fy) ** 2
|
| 169 |
+
),
|
| 170 |
+
)
|
| 171 |
+
eid = str(tgt.get("id"))
|
| 172 |
+
return [C.attack_unit(ids, eid)]
|
| 173 |
+
# No rusher visible yet — march toward the surprise axis
|
| 174 |
+
# (opposite the pre-built pbox line).
|
| 175 |
+
own_b = rs.get("own_buildings") or []
|
| 176 |
+
pboxes = [b for b in own_b if b.get("type") == "pbox"]
|
| 177 |
+
if pboxes:
|
| 178 |
+
pby = sum(int(b["cell_y"]) for b in pboxes) // len(pboxes)
|
| 179 |
+
ty = fy + 6 if pby < fy else fy - 6
|
| 180 |
+
tx = fx
|
| 181 |
+
else:
|
| 182 |
+
return [C.observe()]
|
| 183 |
+
return [C.attack_move(ids, target_x=tx, target_y=ty)]
|
| 184 |
+
|
| 185 |
+
return policy
|
| 186 |
+
|
| 187 |
+
|
| 188 |
+
# ── scenario-shape invariants ─────────────────────────────────────────
|
| 189 |
+
|
| 190 |
+
|
| 191 |
+
def test_pack_compiles_with_three_levels_and_rusher_bot():
|
| 192 |
+
pack = load_pack(PACK)
|
| 193 |
+
assert pack.meta.id == "def-surprise-flank-react"
|
| 194 |
+
assert pack.meta.capability == "reasoning"
|
| 195 |
+
assert set(pack.levels) == {"easy", "medium", "hard"}
|
| 196 |
+
anchors = pack.meta.benchmark_anchor
|
| 197 |
+
assert any("adversarial robustness" in a for a in anchors), anchors
|
| 198 |
+
assert any("CICERO" in a for a in anchors), anchors
|
| 199 |
+
for lvl in LEVELS:
|
| 200 |
+
c = compile_level(pack, lvl)
|
| 201 |
+
assert c.map_supported
|
| 202 |
+
enemy = c.scenario.enemy
|
| 203 |
+
bot = getattr(enemy, "bot_type", None) or getattr(enemy, "bot", None)
|
| 204 |
+
assert (str(bot).lower() == "rusher"), (lvl, bot)
|
| 205 |
+
|
| 206 |
+
|
| 207 |
+
@pytest.mark.parametrize("level", LEVELS)
|
| 208 |
+
def test_every_level_has_a_reachable_timeout_fail(level):
|
| 209 |
+
"""Non-win must be a real LOSS: the `after_ticks` fail must be
|
| 210 |
+
strictly below the tick reachable at max_turns (≤90 ticks/step in
|
| 211 |
+
interrupt mode)."""
|
| 212 |
+
c = compile_level(load_pack(PACK), level)
|
| 213 |
+
assert c.fail_condition is not None
|
| 214 |
+
fc = c.fail_condition.model_dump(exclude_none=True)
|
| 215 |
+
deadline = None
|
| 216 |
+
for clause in fc.get("any_of", []) or []:
|
| 217 |
+
if "after_ticks" in clause:
|
| 218 |
+
deadline = int(clause["after_ticks"])
|
| 219 |
+
assert deadline is not None, f"{level}: no after_ticks fail clause"
|
| 220 |
+
reachable = 93 + 90 * (c.max_turns - 1)
|
| 221 |
+
assert deadline < reachable, (
|
| 222 |
+
f"{level}: deadline {deadline} unreachable within "
|
| 223 |
+
f"{c.max_turns} turns (max tick {reachable}) → draw degeneracy"
|
| 224 |
+
)
|
| 225 |
+
|
| 226 |
+
|
| 227 |
+
def test_hard_has_two_spawn_point_groups():
|
| 228 |
+
"""Hard-tier contract: ≥2 distinct seed-driven spawn_point groups
|
| 229 |
+
so the base latitude (and matching surprise axis) varies by seed."""
|
| 230 |
+
c = compile_level(load_pack(PACK), "hard")
|
| 231 |
+
groups = {
|
| 232 |
+
a.spawn_point for a in c.scenario.actors
|
| 233 |
+
if a.owner == "agent" and a.spawn_point is not None
|
| 234 |
+
}
|
| 235 |
+
assert groups == {0, 1}, groups
|
| 236 |
+
# In-bounds check (rush-hour-arena playable y ≈ 2..38, x ≈ 2..126):
|
| 237 |
+
for a in c.scenario.actors:
|
| 238 |
+
x, y = a.position
|
| 239 |
+
assert 2 <= x <= 126 and 2 <= y <= 38, (a.type, a.position)
|
| 240 |
+
|
| 241 |
+
|
| 242 |
+
def test_pre_built_north_defense_present_easy_and_medium():
|
| 243 |
+
"""The PRE-BUILT NORTH pillbox line + four pre-placed NORTH
|
| 244 |
+
defenders must be present on easy and medium (the "trap" the
|
| 245 |
+
intended play must walk away from)."""
|
| 246 |
+
for level in ("easy", "medium"):
|
| 247 |
+
c = compile_level(load_pack(PACK), level)
|
| 248 |
+
pboxes = [
|
| 249 |
+
a for a in c.scenario.actors
|
| 250 |
+
if a.owner == "agent" and a.type == "pbox"
|
| 251 |
+
]
|
| 252 |
+
assert len(pboxes) == 2, (level, pboxes)
|
| 253 |
+
defenders = [
|
| 254 |
+
a for a in c.scenario.actors
|
| 255 |
+
if a.owner == "agent" and a.type == "e1"
|
| 256 |
+
]
|
| 257 |
+
assert len(defenders) == 4, (level, defenders)
|
| 258 |
+
|
| 259 |
+
|
| 260 |
+
# ── solvency: intended WINS every level + every hard seed ────────────
|
| 261 |
+
|
| 262 |
+
|
| 263 |
+
@pytest.mark.parametrize("level", LEVELS)
|
| 264 |
+
def test_intended_detect_and_redeploy_wins_every_level_and_seed(level):
|
| 265 |
+
c = compile_level(load_pack(PACK), level)
|
| 266 |
+
for seed in SEEDS:
|
| 267 |
+
r = run_level(c, make_intended(), seed=seed)
|
| 268 |
+
assert r.outcome == "win", (
|
| 269 |
+
f"{level} seed{seed}: intended detect-and-redeploy play "
|
| 270 |
+
f"must WIN; got {r.outcome} (tick={r.signals.game_tick}, "
|
| 271 |
+
f"kills={r.signals.units_killed}, "
|
| 272 |
+
f"lost={r.signals.units_lost}, "
|
| 273 |
+
f"buildings={r.signals.own_buildings})"
|
| 274 |
+
)
|
| 275 |
+
|
| 276 |
+
|
| 277 |
+
# ── no-cheat: every lazy / wrong-axis policy LOSES (not draws) ───────
|
| 278 |
+
|
| 279 |
+
|
| 280 |
+
@pytest.mark.parametrize("level", LEVELS)
|
| 281 |
+
@pytest.mark.parametrize(
|
| 282 |
+
"policy_name,policy_factory",
|
| 283 |
+
[
|
| 284 |
+
("stall", lambda: stall),
|
| 285 |
+
("stay_north", lambda: stay_north),
|
| 286 |
+
("pure_build", lambda: pure_build),
|
| 287 |
+
],
|
| 288 |
+
)
|
| 289 |
+
def test_lazy_and_wrong_axis_policies_lose_every_level_and_seed(
|
| 290 |
+
level, policy_name, policy_factory
|
| 291 |
+
):
|
| 292 |
+
"""Stall (fact razed), stay-NORTH (defenders park on the intel
|
| 293 |
+
lane while the south rush walks past), and pure-build (more
|
| 294 |
+
buildings on the intel lane, defenders never re-positioned) must
|
| 295 |
+
ALL LOSE on every level + every seed — no draw."""
|
| 296 |
+
c = compile_level(load_pack(PACK), level)
|
| 297 |
+
for seed in SEEDS:
|
| 298 |
+
r = run_level(c, policy_factory(), seed=seed)
|
| 299 |
+
assert r.outcome == "loss", (
|
| 300 |
+
f"{level} seed{seed} {policy_name}: must LOSE (real fail, "
|
| 301 |
+
f"not a draw); got {r.outcome} (tick={r.signals.game_tick}, "
|
| 302 |
+
f"kills={r.signals.units_killed}, "
|
| 303 |
+
f"buildings={r.signals.own_buildings})"
|
| 304 |
+
)
|
| 305 |
+
|
| 306 |
+
|
| 307 |
+
# ── determinism ───────────────────────────────────────────────────────
|
| 308 |
+
|
| 309 |
+
|
| 310 |
+
def test_intended_run_is_deterministic_on_easy():
|
| 311 |
+
c = compile_level(load_pack(PACK), "easy")
|
| 312 |
+
a = run_level(c, make_intended(), seed=3)
|
| 313 |
+
b = run_level(c, make_intended(), seed=3)
|
| 314 |
+
assert (a.outcome, a.turns, a.signals.units_killed) == (
|
| 315 |
+
b.outcome,
|
| 316 |
+
b.turns,
|
| 317 |
+
b.signals.units_killed,
|
| 318 |
+
), "same seed must be deterministic"
|
|
@@ -0,0 +1,276 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""def-tower-line-vs-cluster scenario family, full loop on Rust.
|
| 2 |
+
|
| 3 |
+
The pack tests DEFENSE TOPOLOGY: when the threat is forced through a
|
| 4 |
+
known chokepoint, the right architecture is dense cluster AT the choke
|
| 5 |
+
(graph min-cut / military bunker placement doctrine), NOT a thin spread
|
| 6 |
+
along the perimeter. The win predicate makes the topology decision
|
| 7 |
+
load-bearing — total pbox count alone is not sufficient:
|
| 8 |
+
|
| 9 |
+
* `building_count_gte:{pbox, n}` ⇒ the agent actually built the budget
|
| 10 |
+
worth of defences (3 on easy, 4 on medium, 5 on hard);
|
| 11 |
+
* `building_in_region:{pbox, x:60, y:20, radius:5, count}` ⇒ ≥3 (easy),
|
| 12 |
+
≥3 (medium), or ≥4 (hard) of those pbox sit INSIDE the choke region —
|
| 13 |
+
a spread-line layout (one pbox at the choke, the rest along the
|
| 14 |
+
perimeter) satisfies the count but NOT the region;
|
| 15 |
+
* `building_count_gte:{fact,1}` ⇒ the fact must still stand (rather than
|
| 16 |
+
`has_building:fact` which is a one-shot "ever seen" set — see CLAUDE.md
|
| 17 |
+
footgun);
|
| 18 |
+
* `within_ticks` paired with `after_ticks` ⇒ a non-finisher is a real
|
| 19 |
+
reachable timeout LOSS (no interrupts on this pack ⇒ each step is
|
| 20 |
+
exactly 90 ticks, so max_turns is a hard tick budget that the
|
| 21 |
+
`after_ticks` deadline reliably bites in).
|
| 22 |
+
|
| 23 |
+
The scripted-policy validations prove deterministically that:
|
| 24 |
+
|
| 25 |
+
* the intended CLUSTER policy (all pbox built INSIDE the choke region)
|
| 26 |
+
WINS every level + every hard seed (1..4);
|
| 27 |
+
* stall / spread-line (1 at choke + rest perimeter) / pure-army
|
| 28 |
+
(no pbox) all LOSE every level + every hard seed — a real LOSS, not
|
| 29 |
+
a draw;
|
| 30 |
+
* the hard tier defines ≥2 spawn_point groups (north y=10 / south y=30)
|
| 31 |
+
so a memorised relative-to-base placement that lands in the same
|
| 32 |
+
world cell on every seed cannot solve the pack.
|
| 33 |
+
"""
|
| 34 |
+
|
| 35 |
+
from __future__ import annotations
|
| 36 |
+
|
| 37 |
+
import pytest
|
| 38 |
+
|
| 39 |
+
pytest.importorskip("openra_train", reason="Rust env wheel not installed")
|
| 40 |
+
pytest.importorskip("openra_rl_training", reason="Rust env wheel not installed")
|
| 41 |
+
|
| 42 |
+
from openra_bench.eval_core import run_level
|
| 43 |
+
from openra_bench.scenarios import load_pack
|
| 44 |
+
from openra_bench.scenarios.loader import PACKS_DIR, compile_level
|
| 45 |
+
|
| 46 |
+
PACK = PACKS_DIR / "def-tower-line-vs-cluster.yaml"
|
| 47 |
+
LEVELS = ("easy", "medium", "hard")
|
| 48 |
+
SEEDS = (1, 2, 3, 4)
|
| 49 |
+
|
| 50 |
+
# Per-level total pbox budget (exactly `starting_cash / 600`).
|
| 51 |
+
_N_PBOX = {"easy": 3, "medium": 4, "hard": 5}
|
| 52 |
+
|
| 53 |
+
|
| 54 |
+
# ── scripted policies ────────────────────────────────────────────────
|
| 55 |
+
|
| 56 |
+
|
| 57 |
+
def stall(rs, C):
|
| 58 |
+
"""Observe-only — the agent never spends. Fact gets razed and/or
|
| 59 |
+
the clock runs out."""
|
| 60 |
+
return [C.observe()]
|
| 61 |
+
|
| 62 |
+
|
| 63 |
+
def make_cluster(choke=(60, 20)):
|
| 64 |
+
"""Intended CLUSTER topology: every pbox is placed INSIDE the choke
|
| 65 |
+
region (within radius 5 of the choke cell)."""
|
| 66 |
+
cx0, cy0 = choke
|
| 67 |
+
# Eight pre-chosen cells around the choke; the policy uses as many
|
| 68 |
+
# as the budget needs (easy:3, medium:4, hard:5).
|
| 69 |
+
cells = [
|
| 70 |
+
(cx0 - 2, cy0 - 1), (cx0, cy0 - 1), (cx0 + 2, cy0 - 1),
|
| 71 |
+
(cx0 - 2, cy0 + 1), (cx0, cy0 + 1), (cx0 + 2, cy0 + 1),
|
| 72 |
+
(cx0 - 1, cy0), (cx0 + 1, cy0),
|
| 73 |
+
]
|
| 74 |
+
|
| 75 |
+
def policy(rs, C):
|
| 76 |
+
own_b = rs.get("own_buildings") or []
|
| 77 |
+
n = sum(1 for b in own_b if b.get("type") == "pbox")
|
| 78 |
+
prod = rs.get("production") or []
|
| 79 |
+
prod_items = [p.get("item") for p in prod if isinstance(p, dict)]
|
| 80 |
+
cmds = []
|
| 81 |
+
# Once enough pboxes are up, idle (the win clause counts the
|
| 82 |
+
# current building list each turn).
|
| 83 |
+
if n >= len(cells):
|
| 84 |
+
return [C.observe()]
|
| 85 |
+
if "pbox" not in prod_items:
|
| 86 |
+
cmds.append(C.build("pbox"))
|
| 87 |
+
cmds.append(C.place_building("pbox", cells[n][0], cells[n][1]))
|
| 88 |
+
if not cmds:
|
| 89 |
+
cmds.append(C.observe())
|
| 90 |
+
return cmds
|
| 91 |
+
|
| 92 |
+
return policy
|
| 93 |
+
|
| 94 |
+
|
| 95 |
+
def make_spread_line(n_pbox):
|
| 96 |
+
"""SPREAD-LINE topology: one pbox at the choke + the rest along the
|
| 97 |
+
perimeter near the base. Satisfies `building_count_gte` but FAILS
|
| 98 |
+
`building_in_region` (only 1 of N at the choke, not the required
|
| 99 |
+
3-of-3 / 3-of-4 / 4-of-5)."""
|
| 100 |
+
cells = [(60, 20), (20, 18), (24, 18), (28, 18), (32, 18)][:n_pbox]
|
| 101 |
+
|
| 102 |
+
def policy(rs, C):
|
| 103 |
+
own_b = rs.get("own_buildings") or []
|
| 104 |
+
n = sum(1 for b in own_b if b.get("type") == "pbox")
|
| 105 |
+
prod = rs.get("production") or []
|
| 106 |
+
prod_items = [p.get("item") for p in prod if isinstance(p, dict)]
|
| 107 |
+
cmds = []
|
| 108 |
+
if n >= len(cells):
|
| 109 |
+
return [C.observe()]
|
| 110 |
+
if "pbox" not in prod_items:
|
| 111 |
+
cmds.append(C.build("pbox"))
|
| 112 |
+
cmds.append(C.place_building("pbox", cells[n][0], cells[n][1]))
|
| 113 |
+
if not cmds:
|
| 114 |
+
cmds.append(C.observe())
|
| 115 |
+
return cmds
|
| 116 |
+
|
| 117 |
+
return policy
|
| 118 |
+
|
| 119 |
+
|
| 120 |
+
def pure_army(rs, C):
|
| 121 |
+
"""PURE-ARMY: only ever train e1 — never builds a pbox. FAILS the
|
| 122 |
+
`building_count_gte:pbox` clause AND lets the rush eventually reach
|
| 123 |
+
the fact (or runs out the clock with no pbox)."""
|
| 124 |
+
prod = rs.get("production") or []
|
| 125 |
+
prod_items = [p.get("item") for p in prod if isinstance(p, dict)]
|
| 126 |
+
if "e1" not in prod_items:
|
| 127 |
+
return [C.build("e1")]
|
| 128 |
+
return [C.observe()]
|
| 129 |
+
|
| 130 |
+
|
| 131 |
+
# ── scenario-shape invariants ────────────────────────────────────────
|
| 132 |
+
|
| 133 |
+
|
| 134 |
+
def test_pack_compiles_with_three_levels_and_rusher_bot():
|
| 135 |
+
pack = load_pack(PACK)
|
| 136 |
+
assert pack.meta.id == "def-tower-line-vs-cluster"
|
| 137 |
+
assert pack.meta.capability == "reasoning"
|
| 138 |
+
assert set(pack.levels) == {"easy", "medium", "hard"}
|
| 139 |
+
# Required-by-spec benchmark anchors.
|
| 140 |
+
anchors = pack.meta.benchmark_anchor
|
| 141 |
+
assert any("min-cut" in a.lower() or "chokepoint" in a.lower() for a in anchors), anchors
|
| 142 |
+
assert any("bunker" in a.lower() for a in anchors), anchors
|
| 143 |
+
# Rusher bot wired through (charges agent centroid → forces the
|
| 144 |
+
# rush path through the mid-map choke on every seed).
|
| 145 |
+
for lvl in LEVELS:
|
| 146 |
+
c = compile_level(pack, lvl)
|
| 147 |
+
assert c.map_supported
|
| 148 |
+
bot = getattr(c.scenario.enemy, "bot_type", None) or getattr(
|
| 149 |
+
c.scenario.enemy, "bot", None
|
| 150 |
+
)
|
| 151 |
+
assert str(bot).lower() == "rusher", (lvl, bot)
|
| 152 |
+
|
| 153 |
+
|
| 154 |
+
def test_starting_cash_is_exact_pbox_budget():
|
| 155 |
+
"""The cash is intentionally tight (3/4/5 pbox at 600 each, zero
|
| 156 |
+
slack). A model that spends on units OR extra power runs out before
|
| 157 |
+
the count clause is satisfied — the topology decision is the spend."""
|
| 158 |
+
pack = load_pack(PACK)
|
| 159 |
+
for lvl, expected in (("easy", 1800), ("medium", 2400), ("hard", 3000)):
|
| 160 |
+
c = compile_level(pack, lvl)
|
| 161 |
+
assert c.starting_cash == expected, (lvl, c.starting_cash)
|
| 162 |
+
|
| 163 |
+
|
| 164 |
+
@pytest.mark.parametrize("level", LEVELS)
|
| 165 |
+
def test_every_level_has_a_reachable_timeout_fail(level):
|
| 166 |
+
"""Non-win must be a real LOSS: the `after_ticks` fail clause must
|
| 167 |
+
be strictly below the tick reachable at max_turns. No interrupts on
|
| 168 |
+
this pack ⇒ each step is exactly 90 ticks (max tick = 93+90·(N-1))."""
|
| 169 |
+
c = compile_level(load_pack(PACK), level)
|
| 170 |
+
assert c.fail_condition is not None
|
| 171 |
+
fc = c.fail_condition.model_dump(exclude_none=True)
|
| 172 |
+
deadline = None
|
| 173 |
+
for clause in fc.get("any_of", []) or []:
|
| 174 |
+
if "after_ticks" in clause:
|
| 175 |
+
deadline = int(clause["after_ticks"])
|
| 176 |
+
assert deadline is not None, f"{level}: no after_ticks fail clause"
|
| 177 |
+
reachable = 93 + 90 * (c.max_turns - 1)
|
| 178 |
+
assert deadline < reachable, (
|
| 179 |
+
f"{level}: deadline {deadline} unreachable within "
|
| 180 |
+
f"{c.max_turns} turns (max tick {reachable}) → draw degeneracy"
|
| 181 |
+
)
|
| 182 |
+
|
| 183 |
+
|
| 184 |
+
def test_fact_alive_clause_uses_present_tense_predicate():
|
| 185 |
+
"""The fact-survival clause must use the PRESENT-TENSE predicate
|
| 186 |
+
(`building_count_gte:{type:fact,n:1}`) rather than `has_building`,
|
| 187 |
+
which is a one-shot "ever seen" set that stays true after the fact
|
| 188 |
+
is destroyed (a documented CLAUDE.md footgun). Otherwise the rush
|
| 189 |
+
razing the fact would not trigger a LOSS."""
|
| 190 |
+
for lvl in LEVELS:
|
| 191 |
+
c = compile_level(load_pack(PACK), lvl)
|
| 192 |
+
fc = c.fail_condition.model_dump(exclude_none=True)
|
| 193 |
+
fact_clauses = [
|
| 194 |
+
clause for clause in fc.get("any_of", []) or []
|
| 195 |
+
if isinstance(clause, dict)
|
| 196 |
+
and isinstance(clause.get("not"), dict)
|
| 197 |
+
and "building_count_gte" in (clause["not"] or {})
|
| 198 |
+
and (clause["not"]["building_count_gte"] or {}).get("type") == "fact"
|
| 199 |
+
]
|
| 200 |
+
assert fact_clauses, f"{lvl}: missing present-tense fact-alive fail clause"
|
| 201 |
+
|
| 202 |
+
|
| 203 |
+
def test_hard_has_two_spawn_point_groups():
|
| 204 |
+
"""Hard-tier contract: ≥2 distinct agent spawn_point groups so a
|
| 205 |
+
memorised relative-to-base placement that lands in the same world
|
| 206 |
+
cell on every seed cannot generalise."""
|
| 207 |
+
c = compile_level(load_pack(PACK), "hard")
|
| 208 |
+
groups = {
|
| 209 |
+
a.spawn_point for a in c.scenario.actors
|
| 210 |
+
if a.owner == "agent" and a.spawn_point is not None
|
| 211 |
+
}
|
| 212 |
+
assert groups == {0, 1}, groups
|
| 213 |
+
# In-bounds check (rush-hour-arena playable y ≈ 2..38, x ≈ 2..126):
|
| 214 |
+
for a in c.scenario.actors:
|
| 215 |
+
x, y = a.position
|
| 216 |
+
assert 2 <= x <= 126 and 2 <= y <= 38, (a.type, a.position)
|
| 217 |
+
|
| 218 |
+
|
| 219 |
+
# ── solvency: intended CLUSTER wins every level + every hard seed ────
|
| 220 |
+
|
| 221 |
+
|
| 222 |
+
@pytest.mark.parametrize("level", LEVELS)
|
| 223 |
+
def test_intended_cluster_wins_every_level_and_seed(level):
|
| 224 |
+
c = compile_level(load_pack(PACK), level)
|
| 225 |
+
for seed in SEEDS:
|
| 226 |
+
r = run_level(c, make_cluster(), seed=seed)
|
| 227 |
+
assert r.outcome == "win", (
|
| 228 |
+
f"{level} seed{seed}: intended cluster topology must WIN; "
|
| 229 |
+
f"got {r.outcome} (tick={r.signals.game_tick}, "
|
| 230 |
+
f"kills={r.signals.units_killed}, "
|
| 231 |
+
f"lost={r.signals.units_lost}, "
|
| 232 |
+
f"buildings={r.signals.own_buildings})"
|
| 233 |
+
)
|
| 234 |
+
|
| 235 |
+
|
| 236 |
+
# ── no-cheat: every lazy / wrong-topology policy LOSES (not draws) ───
|
| 237 |
+
|
| 238 |
+
|
| 239 |
+
@pytest.mark.parametrize("level", LEVELS)
|
| 240 |
+
@pytest.mark.parametrize(
|
| 241 |
+
"policy_name,policy_factory",
|
| 242 |
+
[
|
| 243 |
+
("stall", lambda lvl: stall),
|
| 244 |
+
("spread_line", lambda lvl: make_spread_line(_N_PBOX[lvl])),
|
| 245 |
+
("pure_army", lambda lvl: pure_army),
|
| 246 |
+
],
|
| 247 |
+
)
|
| 248 |
+
def test_lazy_and_wrong_topology_policies_lose_every_level_and_seed(
|
| 249 |
+
level, policy_name, policy_factory
|
| 250 |
+
):
|
| 251 |
+
"""Stall (rush razes fact OR clock), spread-line (region clause
|
| 252 |
+
unmet), and pure-army (count clause unmet) must ALL LOSE on every
|
| 253 |
+
level + every seed — no draw."""
|
| 254 |
+
c = compile_level(load_pack(PACK), level)
|
| 255 |
+
fn = policy_factory(level)
|
| 256 |
+
for seed in SEEDS:
|
| 257 |
+
r = run_level(c, fn, seed=seed)
|
| 258 |
+
assert r.outcome == "loss", (
|
| 259 |
+
f"{level} seed{seed} {policy_name}: must LOSE (real fail, "
|
| 260 |
+
f"not a draw); got {r.outcome} (tick={r.signals.game_tick}, "
|
| 261 |
+
f"buildings={r.signals.own_buildings})"
|
| 262 |
+
)
|
| 263 |
+
|
| 264 |
+
|
| 265 |
+
# ── determinism ──────────────────────────────────────────────────────
|
| 266 |
+
|
| 267 |
+
|
| 268 |
+
def test_intended_run_is_deterministic_on_easy():
|
| 269 |
+
c = compile_level(load_pack(PACK), "easy")
|
| 270 |
+
a = run_level(c, make_cluster(), seed=3)
|
| 271 |
+
b = run_level(c, make_cluster(), seed=3)
|
| 272 |
+
assert (a.outcome, a.turns, a.signals.units_killed) == (
|
| 273 |
+
b.outcome,
|
| 274 |
+
b.turns,
|
| 275 |
+
b.signals.units_killed,
|
| 276 |
+
), "same seed must be deterministic"
|
|
@@ -0,0 +1,315 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""econ-cash-reserve-management — REASONING capability validation.
|
| 2 |
+
|
| 3 |
+
Real-world anchor: SC2 cash overflow management (spend down while
|
| 4 |
+
keeping reserve); corporate treasury / operational-reserve doctrine;
|
| 5 |
+
financial-runway management. The agent starts with a productive base
|
| 6 |
+
(4 buildings: fact + proc + tent + powr; 2 harvs on 2 near mines)
|
| 7 |
+
and $1500. The win predicate requires SPEND (build more buildings)
|
| 8 |
+
AND maintaining a CASH RESERVE simultaneously — the "cash management
|
| 9 |
+
loop" (operate while preserving working capital).
|
| 10 |
+
|
| 11 |
+
Bar (CLAUDE.md "no defect, no cheat"):
|
| 12 |
+
- stall LOSES every tier / every hard seed (no build → bldg_total
|
| 13 |
+
stuck at 4 → bar unmet → timeout LOSS).
|
| 14 |
+
- pure-hold (harvest only, never build) LOSES every tier / seed
|
| 15 |
+
(same: bldg_total stuck at 4).
|
| 16 |
+
- over-spend (chain proc 1400 + pbox 600 → cash dips to 0) LOSES
|
| 17 |
+
on medium and hard (income cannot refill above the reserve bar
|
| 18 |
+
before the tight tick deadline). Easy permits over-spend because
|
| 19 |
+
the deadline is generous — that's the "loose-bar" tier by
|
| 20 |
+
construction.
|
| 21 |
+
- intended steady-build-with-reserve WINS every tier / seed.
|
| 22 |
+
- hard tier defines ≥2 agent spawn_point groups (NORTH / SOUTH
|
| 23 |
+
base) so a memorised opening cannot generalise.
|
| 24 |
+
"""
|
| 25 |
+
|
| 26 |
+
from __future__ import annotations
|
| 27 |
+
|
| 28 |
+
from pathlib import Path
|
| 29 |
+
|
| 30 |
+
import pytest
|
| 31 |
+
|
| 32 |
+
pytest.importorskip("openra_train", reason="Rust env wheel not installed")
|
| 33 |
+
pytest.importorskip("openra_rl_training", reason="Rust env wheel not installed")
|
| 34 |
+
|
| 35 |
+
from openra_bench.eval_core import run_level
|
| 36 |
+
from openra_bench.scenarios import load_pack
|
| 37 |
+
from openra_bench.scenarios.loader import PACKS_DIR, compile_level
|
| 38 |
+
from openra_bench.scenarios.win_conditions import WinContext, evaluate
|
| 39 |
+
|
| 40 |
+
PACK = PACKS_DIR / "econ-cash-reserve-management.yaml"
|
| 41 |
+
|
| 42 |
+
|
| 43 |
+
# ── policies ────────────────────────────────────────────────────────
|
| 44 |
+
|
| 45 |
+
|
| 46 |
+
def _stall(rs, Command):
|
| 47 |
+
"""No-op every turn. Bldg_total never grows past 4 → bar unmet → LOSS."""
|
| 48 |
+
return [Command.observe()]
|
| 49 |
+
|
| 50 |
+
|
| 51 |
+
def _pure_hold(rs, Command):
|
| 52 |
+
"""Harvest only, never build. Income accumulates but bldg_total
|
| 53 |
+
stays at 4 → bar unmet → LOSS on building bar."""
|
| 54 |
+
units = rs.get("units_summary", []) or []
|
| 55 |
+
harvs = [u for u in units if u.get("type") == "harv"]
|
| 56 |
+
if not harvs:
|
| 57 |
+
return [Command.observe()]
|
| 58 |
+
return [Command.harvest([str(h["id"])], 22, int(h["cell_y"])) for h in harvs]
|
| 59 |
+
|
| 60 |
+
|
| 61 |
+
def _over_spend(rs, Command):
|
| 62 |
+
"""Chain a proc (1400) + 2× pbox (600 each) immediately. Cash dips
|
| 63 |
+
to 0 the moment bldg_total reaches 6/7 — must wait for income to
|
| 64 |
+
refill above the 300/500 reserve bar. On medium/hard the tick
|
| 65 |
+
budget runs out before recovery → LOSS."""
|
| 66 |
+
units = rs.get("units_summary", []) or []
|
| 67 |
+
harvs = [u for u in units if u.get("type") == "harv"]
|
| 68 |
+
cmds = []
|
| 69 |
+
for h in harvs:
|
| 70 |
+
cmds.append(Command.harvest([str(h["id"])], 22, int(h["cell_y"])))
|
| 71 |
+
# Queue + place orders spammed each turn until accepted.
|
| 72 |
+
cmds.append(Command.build("proc"))
|
| 73 |
+
cmds.append(Command.place_building("proc", 14, 24))
|
| 74 |
+
cmds.append(Command.build("pbox"))
|
| 75 |
+
cmds.append(Command.place_building("pbox", 16, 24))
|
| 76 |
+
cmds.append(Command.build("pbox"))
|
| 77 |
+
cmds.append(Command.place_building("pbox", 18, 24))
|
| 78 |
+
return cmds
|
| 79 |
+
|
| 80 |
+
|
| 81 |
+
def _intended(rs, Command):
|
| 82 |
+
"""Build 3× powr ($900 total) while keeping both harvs harvesting.
|
| 83 |
+
Reaches bldg_total 7 at cash ~600 (well above the medium 300 and
|
| 84 |
+
hard 500 reserve). Wins easy/medium (bldg_total only needs 5/6)
|
| 85 |
+
and hard (bldg_total 7). The cash-management capability: spend
|
| 86 |
+
cheap, keep reserve."""
|
| 87 |
+
units = rs.get("units_summary", []) or []
|
| 88 |
+
harvs = [u for u in units if u.get("type") == "harv"]
|
| 89 |
+
cmds = []
|
| 90 |
+
for h in harvs:
|
| 91 |
+
cmds.append(Command.harvest([str(h["id"])], 22, int(h["cell_y"])))
|
| 92 |
+
cmds.append(Command.build("powr"))
|
| 93 |
+
cmds.append(Command.place_building("powr", 18, 20))
|
| 94 |
+
cmds.append(Command.build("powr"))
|
| 95 |
+
cmds.append(Command.place_building("powr", 18, 22))
|
| 96 |
+
cmds.append(Command.build("powr"))
|
| 97 |
+
cmds.append(Command.place_building("powr", 18, 24))
|
| 98 |
+
return cmds
|
| 99 |
+
|
| 100 |
+
|
| 101 |
+
# ── helpers ─────────────────────────────────────────────────────────
|
| 102 |
+
|
| 103 |
+
|
| 104 |
+
def _run(level, policy, seed=1):
|
| 105 |
+
c = compile_level(load_pack(PACK), level)
|
| 106 |
+
assert c.map_supported, "rush-hour-arena terrain must be present"
|
| 107 |
+
return c, run_level(c, policy, seed=seed)
|
| 108 |
+
|
| 109 |
+
|
| 110 |
+
# ── structural ──────────────────────────────────────────────────────
|
| 111 |
+
|
| 112 |
+
|
| 113 |
+
def test_pack_loads_and_meta_active():
|
| 114 |
+
pack = load_pack(PACK)
|
| 115 |
+
assert pack.meta.id == "econ-cash-reserve-management"
|
| 116 |
+
assert pack.meta.capability == "reasoning"
|
| 117 |
+
assert pack.meta.real_world_meaning
|
| 118 |
+
assert pack.meta.robotics_analogue
|
| 119 |
+
anchors = " ".join(pack.meta.benchmark_anchor).lower()
|
| 120 |
+
assert "sc2" in anchors
|
| 121 |
+
assert "treasury" in anchors or "reserve" in anchors or "runway" in anchors
|
| 122 |
+
|
| 123 |
+
|
| 124 |
+
def test_tools_include_required_set():
|
| 125 |
+
"""Pack must declare the [observe, build, place_building, harvest,
|
| 126 |
+
move_units, stop] toolset (the cash-management interaction surface)."""
|
| 127 |
+
pack = load_pack(PACK)
|
| 128 |
+
tools = set(pack.base.get("tools", []) if isinstance(pack.base, dict) else [])
|
| 129 |
+
for required in ("observe", "build", "place_building", "harvest",
|
| 130 |
+
"move_units", "stop"):
|
| 131 |
+
assert required in tools, f"missing tool: {required!r}"
|
| 132 |
+
|
| 133 |
+
|
| 134 |
+
def test_all_tiers_have_reachable_deadlines():
|
| 135 |
+
"""tick-alignment idiom: within_ticks ≤ ceiling AND
|
| 136 |
+
after_ticks ≤ ceiling AND within_ticks + 1 == after_ticks (so a
|
| 137 |
+
non-finisher LOSES, not draws)."""
|
| 138 |
+
pack = load_pack(PACK)
|
| 139 |
+
for lvl in ("easy", "medium", "hard"):
|
| 140 |
+
L = pack.levels[lvl]
|
| 141 |
+
ceiling = 93 + 90 * (L.max_turns - 1)
|
| 142 |
+
wt = next(
|
| 143 |
+
int(c["within_ticks"])
|
| 144 |
+
for c in L.win_condition.model_dump()["all_of"]
|
| 145 |
+
if "within_ticks" in c
|
| 146 |
+
)
|
| 147 |
+
ft = next(
|
| 148 |
+
int(c["after_ticks"])
|
| 149 |
+
for c in L.fail_condition.model_dump()["any_of"]
|
| 150 |
+
if "after_ticks" in c
|
| 151 |
+
)
|
| 152 |
+
assert wt <= ceiling, f"{lvl}: within_ticks {wt} > ceiling {ceiling}"
|
| 153 |
+
assert ft <= ceiling, f"{lvl}: after_ticks {ft} > ceiling {ceiling}"
|
| 154 |
+
assert wt + 1 == ft, (
|
| 155 |
+
f"{lvl}: within_ticks {wt} / after_ticks {ft} mismatch "
|
| 156 |
+
"(non-finisher must LOSE, not draw — fail clause one tick"
|
| 157 |
+
" past win clause)"
|
| 158 |
+
)
|
| 159 |
+
|
| 160 |
+
|
| 161 |
+
def test_hard_has_two_seed_driven_spawn_groups():
|
| 162 |
+
"""Hard tier: ≥2 distinct agent spawn_point groups so engine
|
| 163 |
+
round-robins start by seed."""
|
| 164 |
+
c = compile_level(load_pack(PACK), "hard")
|
| 165 |
+
sp = {
|
| 166 |
+
(a.spawn_point if a.spawn_point is not None else 0)
|
| 167 |
+
for a in c.scenario.actors
|
| 168 |
+
if a.owner == "agent"
|
| 169 |
+
}
|
| 170 |
+
assert len(sp) >= 2, (
|
| 171 |
+
f"hard must define ≥2 agent spawn_point groups; got {sorted(sp)}"
|
| 172 |
+
)
|
| 173 |
+
|
| 174 |
+
|
| 175 |
+
def test_fail_condition_present_on_every_tier():
|
| 176 |
+
pack = load_pack(PACK)
|
| 177 |
+
for lvl in ("easy", "medium", "hard"):
|
| 178 |
+
c = compile_level(pack, lvl)
|
| 179 |
+
assert c.fail_condition is not None, f"{lvl} needs a fail_condition"
|
| 180 |
+
|
| 181 |
+
|
| 182 |
+
# ── predicate-level (no engine) ─────────────────────────────────────
|
| 183 |
+
|
| 184 |
+
|
| 185 |
+
def _ctx(*, units=(), tick=1000, cash=0, resources=0, own_buildings=()):
|
| 186 |
+
import types
|
| 187 |
+
|
| 188 |
+
sig = types.SimpleNamespace(
|
| 189 |
+
game_tick=tick,
|
| 190 |
+
units_killed=0,
|
| 191 |
+
units_lost=0,
|
| 192 |
+
cash=cash,
|
| 193 |
+
resources=resources,
|
| 194 |
+
own_buildings=list(own_buildings),
|
| 195 |
+
own_building_types={str(t).lower() for (t, _, _) in own_buildings},
|
| 196 |
+
enemies_seen_ids=set(),
|
| 197 |
+
enemy_buildings_seen_ids=set(),
|
| 198 |
+
)
|
| 199 |
+
return WinContext(
|
| 200 |
+
signals=sig,
|
| 201 |
+
render_state={"units_summary": list(units)},
|
| 202 |
+
)
|
| 203 |
+
|
| 204 |
+
|
| 205 |
+
def test_predicates_enforce_capability():
|
| 206 |
+
"""Win requires (bldg_total bar AND cash reserve AND ≥2 harvs)
|
| 207 |
+
AND in-time; fail fires on timeout OR fact destroyed."""
|
| 208 |
+
c = compile_level(load_pack(PACK), "medium")
|
| 209 |
+
two_harvs = [
|
| 210 |
+
{"cell_x": 14, "cell_y": 18, "type": "harv"},
|
| 211 |
+
{"cell_x": 14, "cell_y": 20, "type": "harv"},
|
| 212 |
+
]
|
| 213 |
+
six_bldgs = [
|
| 214 |
+
("fact", 10, 22),
|
| 215 |
+
("proc", 12, 18),
|
| 216 |
+
("tent", 10, 18),
|
| 217 |
+
("powr", 14, 22),
|
| 218 |
+
("powr", 18, 22),
|
| 219 |
+
("powr", 18, 24),
|
| 220 |
+
]
|
| 221 |
+
|
| 222 |
+
# Intended: bldg_total≥6, cash≥300, 2 harvs, in time → WIN
|
| 223 |
+
assert evaluate(
|
| 224 |
+
c.win_condition,
|
| 225 |
+
_ctx(units=two_harvs, tick=600, cash=900, own_buildings=six_bldgs),
|
| 226 |
+
)
|
| 227 |
+
# Cash one short of 300 → not a win (reserve discipline)
|
| 228 |
+
assert not evaluate(
|
| 229 |
+
c.win_condition,
|
| 230 |
+
_ctx(units=two_harvs, tick=600, cash=299, own_buildings=six_bldgs),
|
| 231 |
+
)
|
| 232 |
+
# Only 5 buildings → not a win (growth bar)
|
| 233 |
+
assert not evaluate(
|
| 234 |
+
c.win_condition,
|
| 235 |
+
_ctx(units=two_harvs, tick=600, cash=2000, own_buildings=six_bldgs[:5]),
|
| 236 |
+
)
|
| 237 |
+
# Only 1 harv (the other died) → not a win
|
| 238 |
+
assert not evaluate(
|
| 239 |
+
c.win_condition,
|
| 240 |
+
_ctx(units=two_harvs[:1], tick=600, cash=900, own_buildings=six_bldgs),
|
| 241 |
+
)
|
| 242 |
+
# Timeout (tick past after_ticks): bar unmet → fail
|
| 243 |
+
assert evaluate(
|
| 244 |
+
c.fail_condition,
|
| 245 |
+
_ctx(units=two_harvs, tick=812, cash=0, own_buildings=six_bldgs),
|
| 246 |
+
)
|
| 247 |
+
# Fact destroyed → fail
|
| 248 |
+
assert evaluate(
|
| 249 |
+
c.fail_condition,
|
| 250 |
+
_ctx(units=two_harvs, tick=600, cash=900, own_buildings=six_bldgs[1:]),
|
| 251 |
+
)
|
| 252 |
+
|
| 253 |
+
|
| 254 |
+
# ── engine-driven policies (the no-cheat bar) ───────────────────────
|
| 255 |
+
|
| 256 |
+
|
| 257 |
+
@pytest.mark.parametrize("level", ["easy", "medium", "hard"])
|
| 258 |
+
@pytest.mark.parametrize("seed", [1, 2, 3, 4])
|
| 259 |
+
def test_stall_loses_every_tier_and_seed(level, seed):
|
| 260 |
+
"""No build → bldg_total stays at 4 → bar unmet → timeout LOSS."""
|
| 261 |
+
_, r = _run(level, _stall, seed=seed)
|
| 262 |
+
assert r.outcome == "loss", (
|
| 263 |
+
f"{level}/seed{seed}: stall must LOSE (no growth, bar unmet); "
|
| 264 |
+
f"got {r.outcome} cash={r.signals.cash} turns={r.turns}"
|
| 265 |
+
)
|
| 266 |
+
|
| 267 |
+
|
| 268 |
+
@pytest.mark.parametrize("level", ["easy", "medium", "hard"])
|
| 269 |
+
@pytest.mark.parametrize("seed", [1, 2, 3, 4])
|
| 270 |
+
def test_pure_hold_loses_every_tier_and_seed(level, seed):
|
| 271 |
+
"""Pure harvest, never build → bldg_total stays at 4 → bar unmet
|
| 272 |
+
→ timeout LOSS. The cash hoards but the growth axis is the bar."""
|
| 273 |
+
_, r = _run(level, _pure_hold, seed=seed)
|
| 274 |
+
assert r.outcome == "loss", (
|
| 275 |
+
f"{level}/seed{seed}: pure-hold must LOSE (no buildings built); "
|
| 276 |
+
f"got {r.outcome} cash={r.signals.cash} turns={r.turns}"
|
| 277 |
+
)
|
| 278 |
+
|
| 279 |
+
|
| 280 |
+
@pytest.mark.parametrize("level", ["medium", "hard"])
|
| 281 |
+
@pytest.mark.parametrize("seed", [1, 2, 3, 4])
|
| 282 |
+
def test_over_spend_loses_on_medium_and_hard(level, seed):
|
| 283 |
+
"""Chain proc + 2× pbox → cash drains to 0; income cannot refill
|
| 284 |
+
above the reserve bar inside the tight medium/hard tick budget →
|
| 285 |
+
timeout LOSS. (Easy is intentionally loose — over-spend permitted
|
| 286 |
+
there; the discrimination tier is medium/hard.)"""
|
| 287 |
+
_, r = _run(level, _over_spend, seed=seed)
|
| 288 |
+
assert r.outcome == "loss", (
|
| 289 |
+
f"{level}/seed{seed}: over-spend must LOSE on tight tier; "
|
| 290 |
+
f"got {r.outcome} cash={r.signals.cash} turns={r.turns}"
|
| 291 |
+
)
|
| 292 |
+
|
| 293 |
+
|
| 294 |
+
@pytest.mark.parametrize("level", ["easy", "medium", "hard"])
|
| 295 |
+
@pytest.mark.parametrize("seed", [1, 2, 3, 4])
|
| 296 |
+
def test_intended_wins_every_tier_and_seed(level, seed):
|
| 297 |
+
"""The intended capability — build cheap (3× powr = $900) while
|
| 298 |
+
harvesting — WINS every tier and every hard seed. Reaches
|
| 299 |
+
bldg_total 7 at cash ~600 (above the medium 300 and hard 500
|
| 300 |
+
reserve) well inside the deadline."""
|
| 301 |
+
_, r = _run(level, _intended, seed=seed)
|
| 302 |
+
assert r.outcome == "win", (
|
| 303 |
+
f"{level}/seed{seed}: intended cash-discipline should WIN; "
|
| 304 |
+
f"got {r.outcome} cash={r.signals.cash} turns={r.turns}"
|
| 305 |
+
)
|
| 306 |
+
|
| 307 |
+
|
| 308 |
+
def test_outcomes_are_deterministic_per_seed():
|
| 309 |
+
"""Same seed, same policy → identical outcome / cash / turn count."""
|
| 310 |
+
c = compile_level(load_pack(PACK), "medium")
|
| 311 |
+
a = run_level(c, _intended, seed=2)
|
| 312 |
+
b = run_level(c, _intended, seed=2)
|
| 313 |
+
assert (a.outcome, a.turns, a.signals.cash) == (
|
| 314 |
+
b.outcome, b.turns, b.signals.cash
|
| 315 |
+
)
|
|
@@ -0,0 +1,502 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""No-cheat + solvency proof for `econ-recover-from-zero-cash` (Group F
|
| 2 |
+
turtle-recovery / bankruptcy turnaround from a minimum-viable kit and
|
| 3 |
+
zero working capital).
|
| 4 |
+
|
| 5 |
+
The pack frames a RECOVERY-FROM-SETBACK decision: the agent inherits a
|
| 6 |
+
minimum viable kit (fact + proc + one harv + one mine) and **$0 cash**.
|
| 7 |
+
It must commit to harvesting first (the only income channel), then
|
| 8 |
+
reinvest the accumulated cash on the next productive asset (a war
|
| 9 |
+
factory + a second/third harvester) so the income channel actually
|
| 10 |
+
scales past a non-trivial revenue bar.
|
| 11 |
+
|
| 12 |
+
For every level + every hard seed (1-4):
|
| 13 |
+
* the INTENDED chain (harvest → save 2000 → build weap → build harv →
|
| 14 |
+
move+harvest new harv [→ build 3rd harv on hard]) WINS;
|
| 15 |
+
* STALL (only `observe`) LOSES every tier — no harvest, EV stays at 0,
|
| 16 |
+
clock bites as a reachable timeout LOSS;
|
| 17 |
+
* HARVEST-ONLY (run the starting harv but never build anything) is
|
| 18 |
+
the EASY floor (wins easy) but LOSES medium/hard on the 2-harv /
|
| 19 |
+
3-harv structural clause regardless of EV;
|
| 20 |
+
* BUILD-ARMY-FROM-ZERO (harvest → powr → tent → spam e1 instead of
|
| 21 |
+
weap → harv) LOSES every tier — no weap means no harv can be built,
|
| 22 |
+
and `tent`+`e1` spend drains the cash that should have gone to the
|
| 23 |
+
income-scaling chain;
|
| 24 |
+
* BUILD-TOO-SOON (harvest → spend cash on a SECOND refinery instead of
|
| 25 |
+
weap+harv) LOSES medium/hard — cash drained on the wrong asset
|
| 26 |
+
starves the 2-harv requirement.
|
| 27 |
+
|
| 28 |
+
The 4 lazy plays + 1 intended × 3 levels × 4 seeds gives the full
|
| 29 |
+
no-defect / no-cheat coverage demanded by CLAUDE.md.
|
| 30 |
+
"""
|
| 31 |
+
|
| 32 |
+
from __future__ import annotations
|
| 33 |
+
|
| 34 |
+
import pytest
|
| 35 |
+
|
| 36 |
+
pytest.importorskip("openra_train", reason="Rust env wheel not installed")
|
| 37 |
+
pytest.importorskip("openra_rl_training", reason="Rust env wheel not installed")
|
| 38 |
+
from openra_bench.eval_core import run_level
|
| 39 |
+
from openra_bench.scenarios import load_pack
|
| 40 |
+
from openra_bench.scenarios.loader import PACKS_DIR, compile_level
|
| 41 |
+
|
| 42 |
+
PACK = PACKS_DIR / "econ-recover-from-zero-cash.yaml"
|
| 43 |
+
LEVELS = ("easy", "medium", "hard")
|
| 44 |
+
SEEDS = (1, 2, 3, 4)
|
| 45 |
+
|
| 46 |
+
|
| 47 |
+
# ───────────────────────── helpers ────────────────────────────────────────
|
| 48 |
+
|
| 49 |
+
|
| 50 |
+
def _patches_for_fact(fy: int) -> list[tuple[int, int]]:
|
| 51 |
+
"""easy/medium have one patch at (22,18); hard has spawn-local
|
| 52 |
+
near patches: NORTH spawn → (22,14)+(22,16); SOUTH → (22,26)+(22,28)."""
|
| 53 |
+
if abs(fy - 18) <= 2:
|
| 54 |
+
return [(22, 18)]
|
| 55 |
+
if fy < 18:
|
| 56 |
+
return [(22, 14), (22, 16)]
|
| 57 |
+
return [(22, 26), (22, 28)]
|
| 58 |
+
|
| 59 |
+
|
| 60 |
+
def _own_fact(bldgs):
|
| 61 |
+
"""Return the AGENT-owned fact (skip the far enemy marker)."""
|
| 62 |
+
for b in bldgs:
|
| 63 |
+
if b.get("type") == "fact" and b.get("owner", "agent") != "enemy":
|
| 64 |
+
cx = b.get("cell_x", 0)
|
| 65 |
+
# The enemy marker is at (120,20) — filter it by x.
|
| 66 |
+
if cx < 100:
|
| 67 |
+
return b
|
| 68 |
+
return None
|
| 69 |
+
|
| 70 |
+
|
| 71 |
+
def _ev(res):
|
| 72 |
+
return res.signals.cash + res.signals.resources
|
| 73 |
+
|
| 74 |
+
|
| 75 |
+
# ───────────────────────── scripted policies ──────────────────────────────
|
| 76 |
+
|
| 77 |
+
|
| 78 |
+
def _stall(rs, Command):
|
| 79 |
+
"""Idle: never harvests, never builds. EV stays at 0 → bar unmet →
|
| 80 |
+
reachable timeout LOSS."""
|
| 81 |
+
return [Command.observe()]
|
| 82 |
+
|
| 83 |
+
|
| 84 |
+
def _harvest_only_factory():
|
| 85 |
+
"""Run the starting harv but never build anything. WINS easy (the
|
| 86 |
+
floor — bar 1500 is reachable with one harv) but LOSES medium/hard
|
| 87 |
+
(the 2-harv / 3-harv structural clause fails regardless of EV)."""
|
| 88 |
+
|
| 89 |
+
state = {"moved": set()}
|
| 90 |
+
|
| 91 |
+
def policy(rs, Command):
|
| 92 |
+
units = rs.get("units_summary", []) or []
|
| 93 |
+
bldgs = rs.get("own_buildings", []) or []
|
| 94 |
+
fact_b = _own_fact(bldgs)
|
| 95 |
+
if fact_b is None:
|
| 96 |
+
return [Command.observe()]
|
| 97 |
+
patches = _patches_for_fact(fact_b["cell_y"])
|
| 98 |
+
harvs = [
|
| 99 |
+
(u["id"], u.get("cell_x"), u.get("cell_y"))
|
| 100 |
+
for u in units
|
| 101 |
+
if str(u.get("type", "")).lower() == "harv"
|
| 102 |
+
]
|
| 103 |
+
cmds = []
|
| 104 |
+
for i, (uid, cx, cy) in enumerate(harvs):
|
| 105 |
+
uid_s = str(uid)
|
| 106 |
+
px, py = patches[i % len(patches)]
|
| 107 |
+
if uid_s not in state["moved"]:
|
| 108 |
+
cmds.append(Command.move_units([uid_s], target_x=px, target_y=py))
|
| 109 |
+
if abs(cx - px) <= 3 and abs(cy - py) <= 3:
|
| 110 |
+
state["moved"].add(uid_s)
|
| 111 |
+
else:
|
| 112 |
+
cmds.append(Command.harvest([uid_s], px, py))
|
| 113 |
+
return cmds if cmds else [Command.observe()]
|
| 114 |
+
|
| 115 |
+
return policy
|
| 116 |
+
|
| 117 |
+
|
| 118 |
+
def _build_army_factory():
|
| 119 |
+
"""Harvest a bit, then powr → tent → spam e1 instead of weap+harv.
|
| 120 |
+
Without weap, no harv can be built → 2-harv clause fails → LOSS on
|
| 121 |
+
every tier (even easy, where the army spend drains the EV bar)."""
|
| 122 |
+
|
| 123 |
+
state = {"moved": set()}
|
| 124 |
+
|
| 125 |
+
def policy(rs, Command):
|
| 126 |
+
units = rs.get("units_summary", []) or []
|
| 127 |
+
bldgs = rs.get("own_buildings", []) or []
|
| 128 |
+
own_types = {b["type"] for b in bldgs}
|
| 129 |
+
prod = rs.get("production", []) or []
|
| 130 |
+
cash = rs.get("cash", 0)
|
| 131 |
+
fact_b = _own_fact(bldgs)
|
| 132 |
+
if fact_b is None:
|
| 133 |
+
return [Command.observe()]
|
| 134 |
+
fx, fy = fact_b["cell_x"], fact_b["cell_y"]
|
| 135 |
+
patches = _patches_for_fact(fy)
|
| 136 |
+
harvs = [
|
| 137 |
+
(u["id"], u.get("cell_x"), u.get("cell_y"))
|
| 138 |
+
for u in units
|
| 139 |
+
if str(u.get("type", "")).lower() == "harv"
|
| 140 |
+
]
|
| 141 |
+
cmds = []
|
| 142 |
+
for i, (uid, cx, cy) in enumerate(harvs):
|
| 143 |
+
uid_s = str(uid)
|
| 144 |
+
px, py = patches[i % len(patches)]
|
| 145 |
+
if uid_s not in state["moved"]:
|
| 146 |
+
cmds.append(Command.move_units([uid_s], target_x=px, target_y=py))
|
| 147 |
+
if abs(cx - px) <= 3 and abs(cy - py) <= 3:
|
| 148 |
+
state["moved"].add(uid_s)
|
| 149 |
+
else:
|
| 150 |
+
cmds.append(Command.harvest([uid_s], px, py))
|
| 151 |
+
if "powr" not in own_types:
|
| 152 |
+
if cash >= 300 and "powr" not in prod:
|
| 153 |
+
cmds.append(Command.build("powr"))
|
| 154 |
+
cmds.append(Command.place_building("powr", fx + 2, fy - 3))
|
| 155 |
+
elif "tent" not in own_types:
|
| 156 |
+
if cash >= 400 and "tent" not in prod:
|
| 157 |
+
cmds.append(Command.build("tent"))
|
| 158 |
+
cmds.append(Command.place_building("tent", fx + 2, fy + 3))
|
| 159 |
+
elif cash >= 100 and "e1" not in prod:
|
| 160 |
+
cmds.append(Command.build("e1"))
|
| 161 |
+
return cmds if cmds else [Command.observe()]
|
| 162 |
+
|
| 163 |
+
return policy
|
| 164 |
+
|
| 165 |
+
|
| 166 |
+
def _build_too_soon_factory():
|
| 167 |
+
"""Harvest, then spend the FIRST accumulated cash on a SECOND
|
| 168 |
+
refinery (the wrong scaling asset) instead of weap+harv. The
|
| 169 |
+
income channel does NOT scale (single harv still) → the 2-harv
|
| 170 |
+
clause fails on medium/hard → LOSS."""
|
| 171 |
+
|
| 172 |
+
state = {"moved": set()}
|
| 173 |
+
|
| 174 |
+
def policy(rs, Command):
|
| 175 |
+
units = rs.get("units_summary", []) or []
|
| 176 |
+
bldgs = rs.get("own_buildings", []) or []
|
| 177 |
+
prod = rs.get("production", []) or []
|
| 178 |
+
cash = rs.get("cash", 0)
|
| 179 |
+
fact_b = _own_fact(bldgs)
|
| 180 |
+
if fact_b is None:
|
| 181 |
+
return [Command.observe()]
|
| 182 |
+
fx, fy = fact_b["cell_x"], fact_b["cell_y"]
|
| 183 |
+
patches = _patches_for_fact(fy)
|
| 184 |
+
harvs = [
|
| 185 |
+
(u["id"], u.get("cell_x"), u.get("cell_y"))
|
| 186 |
+
for u in units
|
| 187 |
+
if str(u.get("type", "")).lower() == "harv"
|
| 188 |
+
]
|
| 189 |
+
cmds = []
|
| 190 |
+
for i, (uid, cx, cy) in enumerate(harvs):
|
| 191 |
+
uid_s = str(uid)
|
| 192 |
+
px, py = patches[i % len(patches)]
|
| 193 |
+
if uid_s not in state["moved"]:
|
| 194 |
+
cmds.append(Command.move_units([uid_s], target_x=px, target_y=py))
|
| 195 |
+
if abs(cx - px) <= 3 and abs(cy - py) <= 3:
|
| 196 |
+
state["moved"].add(uid_s)
|
| 197 |
+
else:
|
| 198 |
+
cmds.append(Command.harvest([uid_s], px, py))
|
| 199 |
+
# Always try to queue a second proc and place it — the engine
|
| 200 |
+
# may block on prereqs but that's the point (spending intent on
|
| 201 |
+
# the wrong asset, not weap/harv).
|
| 202 |
+
n_proc = sum(1 for b in bldgs if b.get("type") == "proc")
|
| 203 |
+
if n_proc < 2:
|
| 204 |
+
if cash >= 1400 and "proc" not in prod:
|
| 205 |
+
cmds.append(Command.build("proc"))
|
| 206 |
+
cmds.append(Command.place_building("proc", fx + 5, fy + 1))
|
| 207 |
+
return cmds if cmds else [Command.observe()]
|
| 208 |
+
|
| 209 |
+
return policy
|
| 210 |
+
|
| 211 |
+
|
| 212 |
+
def _intended_factory():
|
| 213 |
+
"""Intended recovery chain: harvest with starting harv → save 2000 →
|
| 214 |
+
build('weap') + place → save 1400 → build('harv') → move new harv to
|
| 215 |
+
patch → harvest. On hard, build a 3rd harv after the 2nd. WINS
|
| 216 |
+
every tier × every seed."""
|
| 217 |
+
|
| 218 |
+
state = {"moved": set()}
|
| 219 |
+
|
| 220 |
+
def policy(rs, Command):
|
| 221 |
+
units = rs.get("units_summary", []) or []
|
| 222 |
+
bldgs = rs.get("own_buildings", []) or []
|
| 223 |
+
own_types = {b["type"] for b in bldgs}
|
| 224 |
+
prod = rs.get("production", []) or []
|
| 225 |
+
cash = rs.get("cash", 0)
|
| 226 |
+
fact_b = _own_fact(bldgs)
|
| 227 |
+
proc_b = next((b for b in bldgs if b.get("type") == "proc"), None)
|
| 228 |
+
if fact_b is None or proc_b is None:
|
| 229 |
+
return [Command.observe()]
|
| 230 |
+
fx, fy = fact_b["cell_x"], fact_b["cell_y"]
|
| 231 |
+
patches = _patches_for_fact(fy)
|
| 232 |
+
harvs = [
|
| 233 |
+
(u["id"], u.get("cell_x"), u.get("cell_y"))
|
| 234 |
+
for u in units
|
| 235 |
+
if str(u.get("type", "")).lower() == "harv"
|
| 236 |
+
]
|
| 237 |
+
cmds = []
|
| 238 |
+
# Move each harv to a patch (round-robin) then harvest.
|
| 239 |
+
for i, (uid, cx, cy) in enumerate(harvs):
|
| 240 |
+
uid_s = str(uid)
|
| 241 |
+
px, py = patches[i % len(patches)]
|
| 242 |
+
if uid_s not in state["moved"]:
|
| 243 |
+
cmds.append(Command.move_units([uid_s], target_x=px, target_y=py))
|
| 244 |
+
if abs(cx - px) <= 3 and abs(cy - py) <= 3:
|
| 245 |
+
state["moved"].add(uid_s)
|
| 246 |
+
else:
|
| 247 |
+
cmds.append(Command.harvest([uid_s], px, py))
|
| 248 |
+
# Build weap as soon as cash allows; spam place_building each
|
| 249 |
+
# turn (engine ignores PLACE until production completes — see
|
| 250 |
+
# econ-startup-from-scratch intended policy).
|
| 251 |
+
if "weap" not in own_types:
|
| 252 |
+
if cash >= 2000 and "weap" not in prod:
|
| 253 |
+
cmds.append(Command.build("weap"))
|
| 254 |
+
cmds.append(Command.place_building("weap", fx + 2, fy + 3))
|
| 255 |
+
else:
|
| 256 |
+
# Determine how many harvs we want: 2 for single-patch
|
| 257 |
+
# tiers (easy/medium), 3 for the two-patch hard tier.
|
| 258 |
+
want_harv = 2 if len(patches) == 1 else 3
|
| 259 |
+
if len(harvs) < want_harv and cash >= 1400 and "harv" not in prod:
|
| 260 |
+
cmds.append(Command.build("harv"))
|
| 261 |
+
return cmds if cmds else [Command.observe()]
|
| 262 |
+
|
| 263 |
+
return policy
|
| 264 |
+
|
| 265 |
+
|
| 266 |
+
def _run(level, policy_or_factory, seed=1):
|
| 267 |
+
c = compile_level(load_pack(PACK), level)
|
| 268 |
+
assert c.map_supported, "rush-hour-arena must compile"
|
| 269 |
+
pol = (
|
| 270 |
+
policy_or_factory()
|
| 271 |
+
if callable(policy_or_factory)
|
| 272 |
+
and policy_or_factory.__name__.endswith("factory")
|
| 273 |
+
else policy_or_factory
|
| 274 |
+
)
|
| 275 |
+
return c, run_level(c, pol, seed=seed)
|
| 276 |
+
|
| 277 |
+
|
| 278 |
+
# ───────────────────────── structural ─────────────────────────────────────
|
| 279 |
+
|
| 280 |
+
|
| 281 |
+
def test_pack_loads_with_three_levels_and_required_tools():
|
| 282 |
+
pack = load_pack(PACK)
|
| 283 |
+
assert pack.meta.id == "econ-recover-from-zero-cash"
|
| 284 |
+
assert pack.meta.capability == "reasoning"
|
| 285 |
+
assert pack.meta.status == "active"
|
| 286 |
+
assert set(pack.levels) == set(LEVELS)
|
| 287 |
+
c = compile_level(pack, "easy")
|
| 288 |
+
tools = set(c.scenario.tools or [])
|
| 289 |
+
# Spec-required tools (Wave-5 spec).
|
| 290 |
+
for t in ("observe", "build", "place_building", "harvest", "move_units", "stop"):
|
| 291 |
+
assert t in tools, f"missing tool {t} in {tools}"
|
| 292 |
+
|
| 293 |
+
|
| 294 |
+
def test_starting_cash_is_zero_on_every_level():
|
| 295 |
+
"""The pack headline is recovery FROM zero cash — every tier must
|
| 296 |
+
start with 0 cash so the income-first → reinvest decision is real."""
|
| 297 |
+
pack = load_pack(PACK)
|
| 298 |
+
for L in LEVELS:
|
| 299 |
+
c = compile_level(pack, L)
|
| 300 |
+
assert c.starting_cash == 0, (
|
| 301 |
+
f"{L}: starting_cash must be 0 (got {c.starting_cash})"
|
| 302 |
+
)
|
| 303 |
+
|
| 304 |
+
|
| 305 |
+
def test_benchmark_anchor_lists_turtle_and_turnaround():
|
| 306 |
+
pack = load_pack(PACK)
|
| 307 |
+
anchors = pack.meta.benchmark_anchor
|
| 308 |
+
assert anchors, "benchmark_anchor must be non-empty"
|
| 309 |
+
blob = " | ".join(anchors).lower()
|
| 310 |
+
assert "sc2" in blob, anchors
|
| 311 |
+
assert "turtle" in blob or "recovery" in blob, anchors
|
| 312 |
+
assert "bankruptcy" in blob or "turnaround" in blob, anchors
|
| 313 |
+
|
| 314 |
+
|
| 315 |
+
@pytest.mark.parametrize("level", LEVELS)
|
| 316 |
+
def test_every_level_has_a_reachable_timeout_fail(level):
|
| 317 |
+
"""Non-win must be a real LOSS, never a DRAW: after_ticks in
|
| 318 |
+
fail_condition must be reachable within max_turns (tick ≈ 93 +
|
| 319 |
+
90·(max_turns − 1)). within_ticks + 1 == after_ticks idiom."""
|
| 320 |
+
c = compile_level(load_pack(PACK), level)
|
| 321 |
+
assert c.fail_condition is not None
|
| 322 |
+
after_ticks = int(c.fail_condition.model_dump()["any_of"][0]["after_ticks"])
|
| 323 |
+
reachable = 93 + 90 * (c.max_turns - 1)
|
| 324 |
+
assert after_ticks <= reachable, (
|
| 325 |
+
f"{level}: fail after_ticks {after_ticks} unreachable within "
|
| 326 |
+
f"{c.max_turns} turns (max tick {reachable}) → draw degeneracy"
|
| 327 |
+
)
|
| 328 |
+
within_clauses = c.win_condition.model_dump().get("all_of", [])
|
| 329 |
+
wt = next(int(x["within_ticks"]) for x in within_clauses if "within_ticks" in x)
|
| 330 |
+
assert after_ticks == wt + 1, (
|
| 331 |
+
f"{level}: after_ticks {after_ticks} must equal within_ticks+1 ({wt + 1})"
|
| 332 |
+
)
|
| 333 |
+
|
| 334 |
+
|
| 335 |
+
def test_hard_has_two_spawn_groups_for_base():
|
| 336 |
+
"""Hard tier contract: ≥2 distinct agent spawn_point groups
|
| 337 |
+
(NORTH / SOUTH base) so seed round-robin varies the start cell and
|
| 338 |
+
a memorised opening can't generalise."""
|
| 339 |
+
c = compile_level(load_pack(PACK), "hard")
|
| 340 |
+
sps = {
|
| 341 |
+
(a.spawn_point if a.spawn_point is not None else 0)
|
| 342 |
+
for a in c.scenario.actors
|
| 343 |
+
if a.owner == "agent"
|
| 344 |
+
}
|
| 345 |
+
assert len(sps) >= 2, f"hard needs ≥2 spawn groups, got {sps}"
|
| 346 |
+
|
| 347 |
+
|
| 348 |
+
# ───────────────────────── intended WIN bar ───────────────────────────────
|
| 349 |
+
|
| 350 |
+
|
| 351 |
+
@pytest.mark.parametrize("level", LEVELS)
|
| 352 |
+
@pytest.mark.parametrize("seed", SEEDS)
|
| 353 |
+
def test_intended_chain_wins(level, seed):
|
| 354 |
+
"""Intended recovery (harvest → weap → harv [+ harv on hard]) WINS
|
| 355 |
+
every tier × every seed."""
|
| 356 |
+
c, r = _run(level, _intended_factory, seed=seed)
|
| 357 |
+
assert r.outcome == "win", (
|
| 358 |
+
f"{level} seed{seed}: intended chain should WIN, got {r.outcome}; "
|
| 359 |
+
f"ev={_ev(r)}, cash={r.signals.cash}, harvs={r.signals.harvesters}, "
|
| 360 |
+
f"types={r.signals.own_building_types}, tick={r.signals.game_tick}"
|
| 361 |
+
)
|
| 362 |
+
|
| 363 |
+
|
| 364 |
+
# ─────────────────────���─── no-cheat: lazy plays LOSE ──────────────────────
|
| 365 |
+
|
| 366 |
+
|
| 367 |
+
@pytest.mark.parametrize("level", LEVELS)
|
| 368 |
+
@pytest.mark.parametrize("seed", SEEDS)
|
| 369 |
+
def test_stall_loses(level, seed):
|
| 370 |
+
"""Stall (only `observe`) must LOSE every tier × every seed — no
|
| 371 |
+
harvest, EV stays at 0, bar unmet, clock bites as a real LOSS."""
|
| 372 |
+
c, r = _run(level, _stall, seed=seed)
|
| 373 |
+
assert r.outcome == "loss", (
|
| 374 |
+
f"{level} seed{seed} stall must LOSE; got {r.outcome} "
|
| 375 |
+
f"(ev={_ev(r)}, tick={r.signals.game_tick})"
|
| 376 |
+
)
|
| 377 |
+
assert _ev(r) == 0, f"stall earns nothing; got ev={_ev(r)}"
|
| 378 |
+
|
| 379 |
+
|
| 380 |
+
@pytest.mark.parametrize("seed", SEEDS)
|
| 381 |
+
def test_harvest_only_is_easy_floor(seed):
|
| 382 |
+
"""Harvest-only with the starting harv is the EASY FLOOR — it
|
| 383 |
+
clears the loose 1500 bar without needing a 2nd harv."""
|
| 384 |
+
c, r = _run("easy", _harvest_only_factory, seed=seed)
|
| 385 |
+
assert r.outcome == "win", (
|
| 386 |
+
f"easy seed{seed} harvest-only should be the FLOOR and WIN; "
|
| 387 |
+
f"got {r.outcome} (ev={_ev(r)})"
|
| 388 |
+
)
|
| 389 |
+
|
| 390 |
+
|
| 391 |
+
@pytest.mark.parametrize("level", ("medium", "hard"))
|
| 392 |
+
@pytest.mark.parametrize("seed", SEEDS)
|
| 393 |
+
def test_harvest_only_loses_medium_and_hard(level, seed):
|
| 394 |
+
"""Harvest-only LOSES medium/hard — the 2-harv / 3-harv structural
|
| 395 |
+
clause fails regardless of EV. This is the discriminator that the
|
| 396 |
+
recovery chain (build weap → build harv) is actually exercised."""
|
| 397 |
+
c, r = _run(level, _harvest_only_factory, seed=seed)
|
| 398 |
+
assert r.outcome == "loss", (
|
| 399 |
+
f"{level} seed{seed} harvest-only must LOSE on the 2/3-harv "
|
| 400 |
+
f"clause; got {r.outcome} (ev={_ev(r)}, "
|
| 401 |
+
f"harvs={r.signals.harvesters}, types={r.signals.own_building_types})"
|
| 402 |
+
)
|
| 403 |
+
# Harvester count never grew past the pre-placed one.
|
| 404 |
+
assert r.signals.harvesters <= 1, (
|
| 405 |
+
f"{level}: harvest-only should not produce more harvs; "
|
| 406 |
+
f"harvs={r.signals.harvesters}"
|
| 407 |
+
)
|
| 408 |
+
|
| 409 |
+
|
| 410 |
+
@pytest.mark.parametrize("level", LEVELS)
|
| 411 |
+
@pytest.mark.parametrize("seed", SEEDS)
|
| 412 |
+
def test_build_army_loses(level, seed):
|
| 413 |
+
"""Harvest → powr → tent → spam e1 (NEVER builds weap → no harv)
|
| 414 |
+
must LOSE every tier — easy on the EV bar (army drain), medium/hard
|
| 415 |
+
on the 2/3-harv clause AND the EV bar."""
|
| 416 |
+
c, r = _run(level, _build_army_factory, seed=seed)
|
| 417 |
+
assert r.outcome == "loss", (
|
| 418 |
+
f"{level} seed{seed} build-army must LOSE; got {r.outcome} "
|
| 419 |
+
f"(ev={_ev(r)}, types={r.signals.own_building_types})"
|
| 420 |
+
)
|
| 421 |
+
# Wrong-path commitment confirmed: tent landed but weap never did.
|
| 422 |
+
assert "tent" in r.signals.own_building_types, (
|
| 423 |
+
f"{level}: build-army should commit to tent; "
|
| 424 |
+
f"types={r.signals.own_building_types}"
|
| 425 |
+
)
|
| 426 |
+
assert "weap" not in r.signals.own_building_types, (
|
| 427 |
+
f"{level}: build-army should NEVER build weap; "
|
| 428 |
+
f"types={r.signals.own_building_types}"
|
| 429 |
+
)
|
| 430 |
+
|
| 431 |
+
|
| 432 |
+
@pytest.mark.parametrize("level", ("medium", "hard"))
|
| 433 |
+
@pytest.mark.parametrize("seed", SEEDS)
|
| 434 |
+
def test_build_too_soon_loses_medium_and_hard(level, seed):
|
| 435 |
+
"""Build-too-soon = spend cash on a 2nd refinery (the wrong scaling
|
| 436 |
+
asset) instead of weap+harv. The income channel does NOT scale →
|
| 437 |
+
the 2/3-harv clause fails on medium/hard → LOSS."""
|
| 438 |
+
c, r = _run(level, _build_too_soon_factory, seed=seed)
|
| 439 |
+
assert r.outcome == "loss", (
|
| 440 |
+
f"{level} seed{seed} build-too-soon must LOSE; got {r.outcome} "
|
| 441 |
+
f"(ev={_ev(r)}, harvs={r.signals.harvesters}, "
|
| 442 |
+
f"types={r.signals.own_building_types})"
|
| 443 |
+
)
|
| 444 |
+
# No weap (so no harv could be built) — the discriminator.
|
| 445 |
+
assert "weap" not in r.signals.own_building_types, (
|
| 446 |
+
f"{level}: build-too-soon must not happen to also build weap; "
|
| 447 |
+
f"types={r.signals.own_building_types}"
|
| 448 |
+
)
|
| 449 |
+
assert r.signals.harvesters <= 1, (
|
| 450 |
+
f"{level}: build-too-soon should not produce more harvs; "
|
| 451 |
+
f"harvs={r.signals.harvesters}"
|
| 452 |
+
)
|
| 453 |
+
|
| 454 |
+
|
| 455 |
+
# ───────────────────────── hard spawn round-robin ─────────────────────────
|
| 456 |
+
|
| 457 |
+
|
| 458 |
+
def test_hard_seed_round_robin_produces_distinct_starts():
|
| 459 |
+
"""Seeds 1-4 must round-robin between the two declared
|
| 460 |
+
spawn_point groups (NORTH y=14 / SOUTH y=26) so a memorised opening
|
| 461 |
+
can't generalise."""
|
| 462 |
+
from pathlib import Path
|
| 463 |
+
|
| 464 |
+
from openra_bench.eval_core import RustEnvPool, _scenario_to_tmp_yaml
|
| 465 |
+
from openra_bench.rust_adapter import RustObsAdapter
|
| 466 |
+
|
| 467 |
+
c = compile_level(load_pack(PACK), "hard")
|
| 468 |
+
tmp = _scenario_to_tmp_yaml(c)
|
| 469 |
+
pool = RustEnvPool(size=1, scenario_path=tmp)
|
| 470 |
+
env = pool.acquire()
|
| 471 |
+
starts = set()
|
| 472 |
+
try:
|
| 473 |
+
for seed in (1, 2, 3, 4):
|
| 474 |
+
ad = RustObsAdapter()
|
| 475 |
+
ad.observe(env.reset(seed=seed))
|
| 476 |
+
u = ad.render_state().get("units_summary", []) or []
|
| 477 |
+
if u:
|
| 478 |
+
starts.add(
|
| 479 |
+
tuple(sorted((x["cell_x"], x["cell_y"]) for x in u))
|
| 480 |
+
)
|
| 481 |
+
finally:
|
| 482 |
+
pool.release(env)
|
| 483 |
+
pool.shutdown()
|
| 484 |
+
Path(tmp).unlink(missing_ok=True)
|
| 485 |
+
assert len(starts) >= 2, (
|
| 486 |
+
f"hard seeds 1-4 produced identical starts {starts}; "
|
| 487 |
+
"spawn_point round-robin not taking effect"
|
| 488 |
+
)
|
| 489 |
+
|
| 490 |
+
|
| 491 |
+
# ───────────────────────── determinism ────────────────────────────────────
|
| 492 |
+
|
| 493 |
+
|
| 494 |
+
def test_outcomes_are_deterministic_per_seed():
|
| 495 |
+
"""Same seed, same pack, same policy → identical outcome."""
|
| 496 |
+
c = compile_level(load_pack(PACK), "medium")
|
| 497 |
+
a = run_level(c, _intended_factory(), seed=2)
|
| 498 |
+
b = run_level(c, _intended_factory(), seed=2)
|
| 499 |
+
assert (a.outcome, a.turns, _ev(a)) == (b.outcome, b.turns, _ev(b)), (
|
| 500 |
+
f"determinism: {(a.outcome, a.turns, _ev(a))} vs "
|
| 501 |
+
f"{(b.outcome, b.turns, _ev(b))}"
|
| 502 |
+
)
|
|
@@ -0,0 +1,304 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""No-cheat + solvency proof for `econ-target-cash-amount-by-deadline`.
|
| 2 |
+
|
| 3 |
+
The capability: reach an exact SPENDABLE CASH bar (`cash_gte: N`, not
|
| 4 |
+
EV) by a hard tick deadline. The model must reason whether the
|
| 5 |
+
pre-placed pipeline will clear the bar on time and reinvest into
|
| 6 |
+
harvesting capacity if not — without draining cash into army units
|
| 7 |
+
that don't grow income.
|
| 8 |
+
|
| 9 |
+
For every level (and every hard seed 1-4) the no-cheat bar holds:
|
| 10 |
+
|
| 11 |
+
- STALL (only `observe`) → LOSS (idles at starting_cash)
|
| 12 |
+
- ARMY-DRAIN (harvest + spam `e1`) → LOSS (income ≈ unit cost)
|
| 13 |
+
- BASELINE (1 harv, no reinvest) → LOSS on medium+hard
|
| 14 |
+
(income too slow; deadline
|
| 15 |
+
bites) — WIN on easy
|
| 16 |
+
(loose bar; bare commitment
|
| 17 |
+
suffices, by design)
|
| 18 |
+
- INTENDED (1 harv + build harv) → WIN on every level / seed
|
| 19 |
+
|
| 20 |
+
Plus tick/turn alignment, fail-condition reachability, hard spawn
|
| 21 |
+
contract.
|
| 22 |
+
"""
|
| 23 |
+
|
| 24 |
+
from __future__ import annotations
|
| 25 |
+
|
| 26 |
+
import pytest
|
| 27 |
+
|
| 28 |
+
pytest.importorskip("openra_train", reason="Rust env wheel not installed")
|
| 29 |
+
pytest.importorskip(
|
| 30 |
+
"openra_rl_training", reason="Rust env wheel not installed"
|
| 31 |
+
)
|
| 32 |
+
|
| 33 |
+
from openra_bench.eval_core import run_level
|
| 34 |
+
from openra_bench.scenarios import load_pack
|
| 35 |
+
from openra_bench.scenarios.loader import PACKS_DIR, compile_level
|
| 36 |
+
|
| 37 |
+
PACK_PATH = PACKS_DIR / "econ-target-cash-amount-by-deadline.yaml"
|
| 38 |
+
|
| 39 |
+
# All near-patch mine cells across levels — the scripted harvest
|
| 40 |
+
# policy tries each (the engine picks the reachable one per spawn).
|
| 41 |
+
ALL_NEAR_MINES = [
|
| 42 |
+
(22, 18),
|
| 43 |
+
(22, 22), # easy + medium pre-placed
|
| 44 |
+
(22, 10),
|
| 45 |
+
(22, 14), # hard NORTH spawn
|
| 46 |
+
(22, 28),
|
| 47 |
+
(22, 32), # hard SOUTH spawn
|
| 48 |
+
]
|
| 49 |
+
|
| 50 |
+
|
| 51 |
+
# ───────────────────────── scripted policies ──────────────────────────────
|
| 52 |
+
|
| 53 |
+
|
| 54 |
+
def stall(rs, Command):
|
| 55 |
+
"""Do-nothing — must lose: starting_cash (600) < bar everywhere."""
|
| 56 |
+
return [Command.observe()]
|
| 57 |
+
|
| 58 |
+
|
| 59 |
+
def _harv_only(rs, Command):
|
| 60 |
+
"""Pre-placed harv only, no reinvestment (the BASELINE)."""
|
| 61 |
+
ids = [
|
| 62 |
+
str(u["id"])
|
| 63 |
+
for u in rs.get("units_summary", []) or []
|
| 64 |
+
if u.get("type") == "harv"
|
| 65 |
+
]
|
| 66 |
+
if not ids:
|
| 67 |
+
return [Command.observe()]
|
| 68 |
+
cmds = []
|
| 69 |
+
for i, uid in enumerate(ids):
|
| 70 |
+
mx, my = ALL_NEAR_MINES[i % len(ALL_NEAR_MINES)]
|
| 71 |
+
cmds.append(Command.harvest([uid], mx, my))
|
| 72 |
+
return cmds
|
| 73 |
+
|
| 74 |
+
|
| 75 |
+
def _army_drain(rs, Command):
|
| 76 |
+
"""Harvest with the pre-placed harv AND spam `e1` every turn that
|
| 77 |
+
cash >= 100. e1 is ~100cr and income from one harv after warmup is
|
| 78 |
+
~100cr/turn, so cash treads water near starting_cash and never
|
| 79 |
+
clears the bar — the "spend on the wrong line item" decoy."""
|
| 80 |
+
cmds = []
|
| 81 |
+
ids = [
|
| 82 |
+
str(u["id"])
|
| 83 |
+
for u in rs.get("units_summary", []) or []
|
| 84 |
+
if u.get("type") == "harv"
|
| 85 |
+
]
|
| 86 |
+
for i, uid in enumerate(ids):
|
| 87 |
+
mx, my = ALL_NEAR_MINES[i % len(ALL_NEAR_MINES)]
|
| 88 |
+
cmds.append(Command.harvest([uid], mx, my))
|
| 89 |
+
if rs.get("cash", 0) >= 100:
|
| 90 |
+
cmds.append(Command.build("e1"))
|
| 91 |
+
return cmds if cmds else [Command.observe()]
|
| 92 |
+
|
| 93 |
+
|
| 94 |
+
def _intended_factory(extra_harv: int):
|
| 95 |
+
"""Reinvestment factory: harvest with all harvs AND queue `extra_harv`
|
| 96 |
+
additional harvesters (each costs 1100, prereq fact+powr+weap).
|
| 97 |
+
Closure-local state so each test invocation starts fresh."""
|
| 98 |
+
|
| 99 |
+
def make():
|
| 100 |
+
queued = [0]
|
| 101 |
+
|
| 102 |
+
def p(rs, Command):
|
| 103 |
+
cmds = []
|
| 104 |
+
ids = [
|
| 105 |
+
str(u["id"])
|
| 106 |
+
for u in rs.get("units_summary", []) or []
|
| 107 |
+
if u.get("type") == "harv"
|
| 108 |
+
]
|
| 109 |
+
for i, uid in enumerate(ids):
|
| 110 |
+
mx, my = ALL_NEAR_MINES[i % len(ALL_NEAR_MINES)]
|
| 111 |
+
cmds.append(Command.harvest([uid], mx, my))
|
| 112 |
+
if queued[0] < extra_harv and rs.get("cash", 0) >= 1100:
|
| 113 |
+
cmds.append(Command.build("harv"))
|
| 114 |
+
queued[0] += 1
|
| 115 |
+
return cmds if cmds else [Command.observe()]
|
| 116 |
+
|
| 117 |
+
return p
|
| 118 |
+
|
| 119 |
+
return make
|
| 120 |
+
|
| 121 |
+
|
| 122 |
+
# ───────────────────────── solvency: intended WINS ────────────────────────
|
| 123 |
+
|
| 124 |
+
|
| 125 |
+
@pytest.mark.parametrize("seed", [1, 2, 3, 4])
|
| 126 |
+
def test_easy_intended_wins(seed):
|
| 127 |
+
"""Easy bar (1500 by tick 1800) is reachable just by harvesting —
|
| 128 |
+
no reinvestment needed. Run the BASELINE; it must WIN."""
|
| 129 |
+
c = compile_level(load_pack(PACK_PATH), "easy")
|
| 130 |
+
r = run_level(c, _harv_only, seed=seed)
|
| 131 |
+
assert r.outcome == "win", (
|
| 132 |
+
f"easy baseline seed={seed} should WIN, got {r.outcome} "
|
| 133 |
+
f"(cash={r.signals.cash}, tick={r.signals.game_tick})"
|
| 134 |
+
)
|
| 135 |
+
|
| 136 |
+
|
| 137 |
+
@pytest.mark.parametrize("seed", [1, 2, 3, 4])
|
| 138 |
+
def test_medium_intended_wins(seed):
|
| 139 |
+
"""Medium bar (5500 by tick 4500) needs reinvestment. Run the
|
| 140 |
+
INTENDED policy (build 1 extra harv); it must WIN."""
|
| 141 |
+
c = compile_level(load_pack(PACK_PATH), "medium")
|
| 142 |
+
r = run_level(c, _intended_factory(1)(), seed=seed)
|
| 143 |
+
assert r.outcome == "win", (
|
| 144 |
+
f"medium intended (+1 harv) seed={seed} should WIN, got "
|
| 145 |
+
f"{r.outcome} (cash={r.signals.cash}, tick={r.signals.game_tick})"
|
| 146 |
+
)
|
| 147 |
+
|
| 148 |
+
|
| 149 |
+
@pytest.mark.parametrize("seed", [1, 2, 3, 4])
|
| 150 |
+
def test_hard_intended_wins_every_seed(seed):
|
| 151 |
+
"""Hard bar (6500 by tick 4000) on each of two spawn groups needs
|
| 152 |
+
reinvestment. Run the INTENDED policy (build 2 extra harvs for
|
| 153 |
+
safety margin against the tighter clock); it must WIN on every
|
| 154 |
+
seed."""
|
| 155 |
+
c = compile_level(load_pack(PACK_PATH), "hard")
|
| 156 |
+
r = run_level(c, _intended_factory(2)(), seed=seed)
|
| 157 |
+
assert r.outcome == "win", (
|
| 158 |
+
f"hard intended (+2 harv) seed={seed} should WIN, got "
|
| 159 |
+
f"{r.outcome} (cash={r.signals.cash}, tick={r.signals.game_tick})"
|
| 160 |
+
)
|
| 161 |
+
|
| 162 |
+
|
| 163 |
+
# ───────────────────────── no-cheat: lazy plays LOSE ──────────────────────
|
| 164 |
+
|
| 165 |
+
|
| 166 |
+
@pytest.mark.parametrize("level", ["easy", "medium", "hard"])
|
| 167 |
+
def test_stall_loses_on_every_level(level):
|
| 168 |
+
"""Stall idles at starting_cash (600) < bar (1500/5500/6500). The
|
| 169 |
+
`after_ticks` deadline must bite as a real LOSS (not a draw)."""
|
| 170 |
+
c = compile_level(load_pack(PACK_PATH), level)
|
| 171 |
+
r = run_level(c, stall, seed=1)
|
| 172 |
+
assert r.outcome == "loss", (
|
| 173 |
+
f"{level} stall must be a real timeout LOSS, got {r.outcome} "
|
| 174 |
+
f"(cash={r.signals.cash})"
|
| 175 |
+
)
|
| 176 |
+
|
| 177 |
+
|
| 178 |
+
@pytest.mark.parametrize("level", ["easy", "medium", "hard"])
|
| 179 |
+
def test_army_drain_loses_on_every_level(level):
|
| 180 |
+
"""Spamming `e1` alongside harvest exactly absorbs the harv
|
| 181 |
+
income (~100cr/turn each). Cash hovers near starting_cash and
|
| 182 |
+
never clears the bar — LOSS on every level."""
|
| 183 |
+
c = compile_level(load_pack(PACK_PATH), level)
|
| 184 |
+
r = run_level(c, _army_drain, seed=1)
|
| 185 |
+
assert r.outcome == "loss", (
|
| 186 |
+
f"{level} army-drain must LOSE (cash drained by units), got "
|
| 187 |
+
f"{r.outcome} (cash={r.signals.cash})"
|
| 188 |
+
)
|
| 189 |
+
|
| 190 |
+
|
| 191 |
+
@pytest.mark.parametrize("seed", [1, 2, 3, 4])
|
| 192 |
+
def test_medium_baseline_no_reinvest_loses(seed):
|
| 193 |
+
"""The pre-placed pipeline alone reaches ~5600 cash but only at
|
| 194 |
+
tick ≥4500 — the deadline bites first. Baseline (no reinvest)
|
| 195 |
+
LOSES on every seed: this is what makes the medium tier a
|
| 196 |
+
capex-decision test."""
|
| 197 |
+
c = compile_level(load_pack(PACK_PATH), "medium")
|
| 198 |
+
r = run_level(c, _harv_only, seed=seed)
|
| 199 |
+
assert r.outcome == "loss", (
|
| 200 |
+
f"medium baseline seed={seed} must LOSE (deadline bites "
|
| 201 |
+
f"before bar reached), got {r.outcome} "
|
| 202 |
+
f"(cash={r.signals.cash}, tick={r.signals.game_tick})"
|
| 203 |
+
)
|
| 204 |
+
|
| 205 |
+
|
| 206 |
+
@pytest.mark.parametrize("seed", [1, 2, 3, 4])
|
| 207 |
+
def test_hard_baseline_no_reinvest_loses(seed):
|
| 208 |
+
"""Hard target (6500 in 4000 ticks) is well above what 1 harv
|
| 209 |
+
can produce in time (~4100 cash by tick 4053). Baseline LOSES
|
| 210 |
+
on every seed."""
|
| 211 |
+
c = compile_level(load_pack(PACK_PATH), "hard")
|
| 212 |
+
r = run_level(c, _harv_only, seed=seed)
|
| 213 |
+
assert r.outcome == "loss", (
|
| 214 |
+
f"hard baseline seed={seed} must LOSE, got {r.outcome} "
|
| 215 |
+
f"(cash={r.signals.cash}, tick={r.signals.game_tick})"
|
| 216 |
+
)
|
| 217 |
+
|
| 218 |
+
|
| 219 |
+
# ───────────────────────── tick/turn alignment ────────────────────────────
|
| 220 |
+
|
| 221 |
+
|
| 222 |
+
@pytest.mark.parametrize("level", ["easy", "medium", "hard"])
|
| 223 |
+
def test_deadline_reachable_inside_max_turns(level):
|
| 224 |
+
"""`within_ticks` (and the matching `after_ticks` fail) must be
|
| 225 |
+
reachable inside max_turns — otherwise stall draws instead of
|
| 226 |
+
losing. Engine advances ~90 ticks per turn (tick ≈ 93+90·(t-1))."""
|
| 227 |
+
c = compile_level(load_pack(PACK_PATH), level)
|
| 228 |
+
max_tick = 93 + 90 * (c.max_turns - 1)
|
| 229 |
+
win_extra = c.win_condition.__pydantic_extra__ or {}
|
| 230 |
+
fail_extra = (
|
| 231 |
+
c.fail_condition.__pydantic_extra__ if c.fail_condition else {}
|
| 232 |
+
) or {}
|
| 233 |
+
|
| 234 |
+
def _find(node, key):
|
| 235 |
+
if not isinstance(node, dict):
|
| 236 |
+
return None
|
| 237 |
+
for k, v in node.items():
|
| 238 |
+
if k == key:
|
| 239 |
+
return int(v)
|
| 240 |
+
if k in ("all_of", "any_of"):
|
| 241 |
+
for c2 in v:
|
| 242 |
+
r = _find(c2, key)
|
| 243 |
+
if r is not None:
|
| 244 |
+
return r
|
| 245 |
+
return None
|
| 246 |
+
|
| 247 |
+
within = _find(win_extra, "within_ticks")
|
| 248 |
+
assert within is not None, f"{level} should have within_ticks"
|
| 249 |
+
assert within <= max_tick, (
|
| 250 |
+
f"{level} within_ticks={within} > max reachable tick {max_tick} "
|
| 251 |
+
f"(max_turns={c.max_turns}); deadline never bites"
|
| 252 |
+
)
|
| 253 |
+
after = _find(fail_extra, "after_ticks")
|
| 254 |
+
assert after is not None, f"{level} should have an after_ticks fail"
|
| 255 |
+
assert after <= max_tick, (
|
| 256 |
+
f"{level} after_ticks={after} > max reachable tick {max_tick}"
|
| 257 |
+
)
|
| 258 |
+
|
| 259 |
+
|
| 260 |
+
# ───────────────────────── hard spawn contract ────────────────────────────
|
| 261 |
+
|
| 262 |
+
|
| 263 |
+
def test_hard_has_two_spawn_point_groups():
|
| 264 |
+
c = compile_level(load_pack(PACK_PATH), "hard")
|
| 265 |
+
sp = {
|
| 266 |
+
(a.spawn_point if a.spawn_point is not None else 0)
|
| 267 |
+
for a in c.scenario.actors
|
| 268 |
+
if a.owner == "agent"
|
| 269 |
+
}
|
| 270 |
+
assert len(sp) >= 2, (
|
| 271 |
+
f"hard must define ≥2 agent spawn_point groups, got {sorted(sp)}"
|
| 272 |
+
)
|
| 273 |
+
|
| 274 |
+
|
| 275 |
+
def test_hard_seed_round_robin_produces_distinct_starts():
|
| 276 |
+
"""Smoke that the engine actually round-robins the spawn groups by
|
| 277 |
+
seed (the whole point of declaring multiple spawn_point groups)."""
|
| 278 |
+
from pathlib import Path
|
| 279 |
+
|
| 280 |
+
from openra_bench.eval_core import RustEnvPool, _scenario_to_tmp_yaml
|
| 281 |
+
from openra_bench.rust_adapter import RustObsAdapter
|
| 282 |
+
|
| 283 |
+
c = compile_level(load_pack(PACK_PATH), "hard")
|
| 284 |
+
tmp = _scenario_to_tmp_yaml(c)
|
| 285 |
+
pool = RustEnvPool(size=1, scenario_path=tmp)
|
| 286 |
+
env = pool.acquire()
|
| 287 |
+
starts = set()
|
| 288 |
+
try:
|
| 289 |
+
for seed in (1, 2, 3, 4):
|
| 290 |
+
ad = RustObsAdapter()
|
| 291 |
+
ad.observe(env.reset(seed=seed))
|
| 292 |
+
u = ad.render_state().get("units_summary", []) or []
|
| 293 |
+
if u:
|
| 294 |
+
starts.add(
|
| 295 |
+
tuple(sorted((x["cell_x"], x["cell_y"]) for x in u))
|
| 296 |
+
)
|
| 297 |
+
finally:
|
| 298 |
+
pool.release(env)
|
| 299 |
+
pool.shutdown()
|
| 300 |
+
Path(tmp).unlink(missing_ok=True)
|
| 301 |
+
assert len(starts) >= 2, (
|
| 302 |
+
f"hard seeds 1-4 produced identical starts {starts}; "
|
| 303 |
+
"spawn_point round-robin not taking effect"
|
| 304 |
+
)
|
|
@@ -321,6 +321,41 @@ UPGRADED = [
|
|
| 321 |
# relative-to-base placement that lands in the same world cell on
|
| 322 |
# every seed cannot generalise.
|
| 323 |
"def-tower-line-vs-cluster",
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 324 |
]
|
| 325 |
|
| 326 |
# Consciously NOT spawn-varied, with the reason (keeps the curation
|
|
|
|
| 321 |
# relative-to-base placement that lands in the same world cell on
|
| 322 |
# every seed cannot generalise.
|
| 323 |
"def-tower-line-vs-cluster",
|
| 324 |
+
# Wave-5 Group F cash-reserve / treasury management pack (SC2 cash
|
| 325 |
+
# overflow / financial runway / operational reserve anchor). Hard
|
| 326 |
+
# tier defines two agent spawn_point groups (NORTH base y=14 /
|
| 327 |
+
# SOUTH base y=28) round-robined by seed; the two near patches
|
| 328 |
+
# sit on the spawn-matched row so throughput is symmetric per
|
| 329 |
+
# spawn, but a memorised opening cannot generalise across seeds.
|
| 330 |
+
"econ-cash-reserve-management",
|
| 331 |
+
# Wave-5 Group F cash-bar-by-deadline pack (SC2 econ-by-tick /
|
| 332 |
+
# budget-by-date / fundraising milestone / OR throughput-with-
|
| 333 |
+
# deadline anchor). Hard tier defines two agent spawn_point groups
|
| 334 |
+
# (NORTH y=10..14 / SOUTH y=28..32) round-robined by seed with
|
| 335 |
+
# symmetric near-patch ore geometry at both latitudes — so the
|
| 336 |
+
# reinvestment-vs-baseline decision is the same per spawn but a
|
| 337 |
+
# memorised "build at (8,18)" opening cannot generalise across
|
| 338 |
+
# seeds.
|
| 339 |
+
"econ-target-cash-amount-by-deadline",
|
| 340 |
+
# Wave-5 Group G observation-driven 3-phase chain pack (SC2 reactive
|
| 341 |
+
# macro / CICERO information-action loop / PlanBench replanning /
|
| 342 |
+
# threat-intel-driven defense anchor). Hard tier defines two agent
|
| 343 |
+
# spawn_point groups (NORTH base y=10 / SOUTH base y=30) round-
|
| 344 |
+
# robined by seed; three enemy bases (NE/MID/SE) plus a SHARED
|
| 345 |
+
# forward outpost at (60,20) always place (enemy actors don't
|
| 346 |
+
# honour spawn_point — CLAUDE.md), so the nearest-corner assault
|
| 347 |
+
# target flips per seed and a memorised "scout-then-attack-NE"
|
| 348 |
+
# opening cannot generalise across spawn variants.
|
| 349 |
+
"lh-scout-react-counter",
|
| 350 |
+
# Wave-5 salvaged-from-worktree packs: agents finished authoring
|
| 351 |
+
# YAML+tests but didn't push (multiple agents ran into push-blocked
|
| 352 |
+
# / output-truncation states). Salvaged from the untracked working
|
| 353 |
+
# tree and committed from the main session. All 4 have ≥2 spawn
|
| 354 |
+
# groups on hard.
|
| 355 |
+
"coord-cover-and-move",
|
| 356 |
+
"coord-diversionary-attack",
|
| 357 |
+
"def-in-depth",
|
| 358 |
+
"def-surprise-flank-react",
|
| 359 |
]
|
| 360 |
|
| 361 |
# Consciously NOT spawn-varied, with the reason (keeps the curation
|
|
@@ -0,0 +1,352 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""lh-100-turn-marathon-survival pack — full no-cheat validation on Rust.
|
| 2 |
+
|
| 3 |
+
Wave-5 Group G long-horizon endurance pack. The win is gated on FOUR
|
| 4 |
+
things still being true at the after_ticks survival gate:
|
| 5 |
+
1. has_building: fact
|
| 6 |
+
2. own_units_gte: 3
|
| 7 |
+
3. building_count_gte pbox 2
|
| 8 |
+
4. after_ticks N (survived to the gate) AND within_ticks N+x
|
| 9 |
+
|
| 10 |
+
Sustained pressure comes from STAGGERED hunt squads placed at
|
| 11 |
+
increasing x along the east-west axis, so squads arrive in successive
|
| 12 |
+
time windows rather than all at once. The fail clause uses the
|
| 13 |
+
``any_of: [after_ticks(GATE), not has_building:fact, not own_units_gte:1]``
|
| 14 |
+
idiom — eval_core evaluates win FIRST, so the broad after_ticks
|
| 15 |
+
fail only bites if the win all-of is unsatisfied at the gate (CLAUDE.md
|
| 16 |
+
rule 3: non-win must be a real LOSS, not a draw).
|
| 17 |
+
|
| 18 |
+
Bar (per CLAUDE.md): the intended SUSTAINED-OPS policy (kick harv +
|
| 19 |
+
build ≥2 pbox + continuously train e1) must WIN on every (level,
|
| 20 |
+
seed); stall / pure-defense-no-reinforce / pure-build-no-repair must
|
| 21 |
+
LOSE on every (level, seed). No draws.
|
| 22 |
+
|
| 23 |
+
NOTE: episodes are LONG (90/100/120 turns ≈ ~8/9/11 thousand ticks).
|
| 24 |
+
The engine-bound tests parametrise over 4 seeds × 3 levels × 4
|
| 25 |
+
policies = 48 runs; expect this file to take several minutes.
|
| 26 |
+
"""
|
| 27 |
+
|
| 28 |
+
from __future__ import annotations
|
| 29 |
+
|
| 30 |
+
import pytest
|
| 31 |
+
|
| 32 |
+
pytest.importorskip("openra_train", reason="Rust env wheel not installed")
|
| 33 |
+
pytest.importorskip("openra_rl_training", reason="Rust env wheel not installed")
|
| 34 |
+
from openra_bench.eval_core import run_level
|
| 35 |
+
from openra_bench.scenarios import load_pack
|
| 36 |
+
from openra_bench.scenarios.loader import PACKS_DIR, compile_level
|
| 37 |
+
|
| 38 |
+
PACK = PACKS_DIR / "lh-100-turn-marathon-survival.yaml"
|
| 39 |
+
LEVELS = ("easy", "medium", "hard")
|
| 40 |
+
SEEDS = (1, 2, 3, 4)
|
| 41 |
+
|
| 42 |
+
|
| 43 |
+
# ── Policies ──────────────────────────────────────────────────────
|
| 44 |
+
|
| 45 |
+
|
| 46 |
+
def _stall_policy():
|
| 47 |
+
"""Idles every turn — must LOSE (hunt squads raze fact)."""
|
| 48 |
+
def pol(obs, Cmd):
|
| 49 |
+
return [Cmd.observe()]
|
| 50 |
+
return pol
|
| 51 |
+
|
| 52 |
+
|
| 53 |
+
def _pure_defense_no_reinforce_policy():
|
| 54 |
+
"""Build pbox to 4 standing but NEVER train fresh infantry. The
|
| 55 |
+
starting garrison bleeds under sustained pressure; either fact
|
| 56 |
+
falls (no infantry to back the pbox line) or own_units drops
|
| 57 |
+
below 3 (1) at the gate ⇒ LOSS."""
|
| 58 |
+
state = {"placed_pbox": 0, "harv_kicked": False}
|
| 59 |
+
|
| 60 |
+
def pol(obs, Cmd):
|
| 61 |
+
ob = obs.get("own_buildings", []) or []
|
| 62 |
+
pbox_have = sum(1 for b in ob if b["type"] == "pbox")
|
| 63 |
+
prod = obs.get("production", []) or []
|
| 64 |
+
cash = int(obs.get("cash", 0) or 0)
|
| 65 |
+
units = obs.get("units_summary", []) or []
|
| 66 |
+
base = next((b for b in ob if b["type"] == "fact"), None)
|
| 67 |
+
if not base:
|
| 68 |
+
return [Cmd.observe()]
|
| 69 |
+
bx, by = base["cell_x"], base["cell_y"]
|
| 70 |
+
cmds = []
|
| 71 |
+
if not state["harv_kicked"]:
|
| 72 |
+
harv = next((u for u in units if u.get("type") == "harv"), None)
|
| 73 |
+
if harv:
|
| 74 |
+
cmds.append(Cmd.harvest([harv["id"]], 2, by))
|
| 75 |
+
state["harv_kicked"] = True
|
| 76 |
+
if pbox_have < 4 and "pbox" not in prod and cash >= 600:
|
| 77 |
+
cmds.append(Cmd.build("pbox"))
|
| 78 |
+
if pbox_have < 4:
|
| 79 |
+
i = state["placed_pbox"]
|
| 80 |
+
cmds.append(Cmd.place_building(
|
| 81 |
+
"pbox", bx + 4 + (i % 4), by + (-3 + (i // 4))
|
| 82 |
+
))
|
| 83 |
+
state["placed_pbox"] += 1
|
| 84 |
+
if not cmds:
|
| 85 |
+
cmds.append(Cmd.observe())
|
| 86 |
+
return cmds
|
| 87 |
+
return pol
|
| 88 |
+
|
| 89 |
+
|
| 90 |
+
def _pure_build_no_repair_policy():
|
| 91 |
+
"""Build just 2 pbox up front, then idle — no infantry production
|
| 92 |
+
and no further building. The lone pbox pair gets stripped by
|
| 93 |
+
sustained hunt fire (rocket squads contribute on medium/hard),
|
| 94 |
+
fact eventually falls or building_count_gte pbox 2 drops below 2
|
| 95 |
+
at the gate ⇒ LOSS."""
|
| 96 |
+
state = {"placed_pbox": 0, "harv_kicked": False}
|
| 97 |
+
|
| 98 |
+
def pol(obs, Cmd):
|
| 99 |
+
ob = obs.get("own_buildings", []) or []
|
| 100 |
+
prod = obs.get("production", []) or []
|
| 101 |
+
cash = int(obs.get("cash", 0) or 0)
|
| 102 |
+
units = obs.get("units_summary", []) or []
|
| 103 |
+
base = next((b for b in ob if b["type"] == "fact"), None)
|
| 104 |
+
if not base:
|
| 105 |
+
return [Cmd.observe()]
|
| 106 |
+
bx, by = base["cell_x"], base["cell_y"]
|
| 107 |
+
cmds = []
|
| 108 |
+
if not state["harv_kicked"]:
|
| 109 |
+
harv = next((u for u in units if u.get("type") == "harv"), None)
|
| 110 |
+
if harv:
|
| 111 |
+
cmds.append(Cmd.harvest([harv["id"]], 2, by))
|
| 112 |
+
state["harv_kicked"] = True
|
| 113 |
+
# Build only 2 pbox total — no further construction, no
|
| 114 |
+
# infantry production, no repair.
|
| 115 |
+
if state["placed_pbox"] < 2:
|
| 116 |
+
if "pbox" not in prod and cash >= 600:
|
| 117 |
+
cmds.append(Cmd.build("pbox"))
|
| 118 |
+
i = state["placed_pbox"]
|
| 119 |
+
cmds.append(Cmd.place_building(
|
| 120 |
+
"pbox", bx + 4 + (i % 4), by + (-3 + (i // 4))
|
| 121 |
+
))
|
| 122 |
+
state["placed_pbox"] += 1
|
| 123 |
+
if not cmds:
|
| 124 |
+
cmds.append(Cmd.observe())
|
| 125 |
+
return cmds
|
| 126 |
+
return pol
|
| 127 |
+
|
| 128 |
+
|
| 129 |
+
def _intended_sustained_ops_policy():
|
| 130 |
+
"""The intended SUSTAINED-OPS capability play:
|
| 131 |
+
turn 1: kick the pre-placed harvester onto the west mine
|
| 132 |
+
continuous: keep ≥4 pbox standing in front of the base
|
| 133 |
+
continuous: train fresh e1 so the e1 garrison stays ≥10
|
| 134 |
+
(cap so we don't drain the queue waiting on cash).
|
| 135 |
+
The continuous infantry production replaces losses from
|
| 136 |
+
sustained hunt attrition; the pbox screen blunts each wave's
|
| 137 |
+
leading edge so the infantry isn't overrun.
|
| 138 |
+
"""
|
| 139 |
+
state = {"placed_pbox": 0, "harv_kicked": False}
|
| 140 |
+
|
| 141 |
+
def pol(obs, Cmd):
|
| 142 |
+
ob = obs.get("own_buildings", []) or []
|
| 143 |
+
pbox_have = sum(1 for b in ob if b["type"] == "pbox")
|
| 144 |
+
prod = obs.get("production", []) or []
|
| 145 |
+
cash = int(obs.get("cash", 0) or 0)
|
| 146 |
+
units = obs.get("units_summary", []) or []
|
| 147 |
+
e1_count = sum(1 for u in units if u.get("type") == "e1")
|
| 148 |
+
# Use the LIVE fact position so this policy generalises across
|
| 149 |
+
# the seed-driven NORTH/SOUTH hard-spawn round-robin.
|
| 150 |
+
base = next((b for b in ob if b["type"] == "fact"), None)
|
| 151 |
+
if not base:
|
| 152 |
+
return [Cmd.observe()]
|
| 153 |
+
bx, by = base["cell_x"], base["cell_y"]
|
| 154 |
+
cmds = []
|
| 155 |
+
|
| 156 |
+
# Kick the harvester at the west mine (mines are at x=2 on
|
| 157 |
+
# every level — y matches the base latitude so the loop is
|
| 158 |
+
# short and doesn't cross the lane).
|
| 159 |
+
if not state["harv_kicked"]:
|
| 160 |
+
harv = next((u for u in units if u.get("type") == "harv"), None)
|
| 161 |
+
if harv:
|
| 162 |
+
cmds.append(Cmd.harvest([harv["id"]], 2, by))
|
| 163 |
+
state["harv_kicked"] = True
|
| 164 |
+
|
| 165 |
+
# Maintain ≥4 standing pbox in front of the base. Queue one
|
| 166 |
+
# at a time (cash budget is small early); spread placements.
|
| 167 |
+
if pbox_have < 4 and "pbox" not in prod and cash >= 600:
|
| 168 |
+
cmds.append(Cmd.build("pbox"))
|
| 169 |
+
if pbox_have < 4:
|
| 170 |
+
i = state["placed_pbox"]
|
| 171 |
+
cmds.append(Cmd.place_building(
|
| 172 |
+
"pbox", bx + 4 + (i % 4), by + (-3 + (i // 4))
|
| 173 |
+
))
|
| 174 |
+
state["placed_pbox"] += 1
|
| 175 |
+
|
| 176 |
+
# Sustained reinforcement — train fresh riflemen continuously.
|
| 177 |
+
# The own_units_gte:3 win gate requires the garrison stays
|
| 178 |
+
# alive; replacing losses is the load-bearing capability.
|
| 179 |
+
if e1_count < 10 and "e1" not in prod and cash >= 100:
|
| 180 |
+
cmds.append(Cmd.build("e1"))
|
| 181 |
+
|
| 182 |
+
if not cmds:
|
| 183 |
+
cmds.append(Cmd.observe())
|
| 184 |
+
return cmds
|
| 185 |
+
return pol
|
| 186 |
+
|
| 187 |
+
|
| 188 |
+
# ── Pack-shape tests (cheap; do not run the engine) ──────────────
|
| 189 |
+
|
| 190 |
+
|
| 191 |
+
def test_pack_compiles_with_three_levels():
|
| 192 |
+
pack = load_pack(PACK)
|
| 193 |
+
assert pack.meta.id == "lh-100-turn-marathon-survival"
|
| 194 |
+
assert pack.meta.capability == "reasoning"
|
| 195 |
+
assert set(pack.levels) == {"easy", "medium", "hard"}
|
| 196 |
+
|
| 197 |
+
|
| 198 |
+
def test_meta_benchmark_anchor_set():
|
| 199 |
+
pack = load_pack(PACK)
|
| 200 |
+
anchors = pack.meta.benchmark_anchor or []
|
| 201 |
+
assert any("lmgame" in a.lower() for a in anchors), anchors
|
| 202 |
+
assert any("sc2le" in a.lower() or "endurance" in a.lower() for a in anchors), anchors
|
| 203 |
+
assert any("sre" in a.lower() or "endurance" in a.lower() for a in anchors), anchors
|
| 204 |
+
|
| 205 |
+
|
| 206 |
+
def test_hard_tier_has_seed_driven_spawn_groups():
|
| 207 |
+
c = compile_level(load_pack(PACK), "hard")
|
| 208 |
+
sp = {a.spawn_point for a in c.scenario.actors if a.owner == "agent"}
|
| 209 |
+
assert len(sp) >= 2, f"hard needs ≥2 spawn groups, got {sp}"
|
| 210 |
+
|
| 211 |
+
|
| 212 |
+
def test_every_level_has_fail_condition():
|
| 213 |
+
pack = load_pack(PACK)
|
| 214 |
+
for lvl in LEVELS:
|
| 215 |
+
c = compile_level(pack, lvl)
|
| 216 |
+
assert c.fail_condition is not None, f"{lvl} missing fail_condition"
|
| 217 |
+
|
| 218 |
+
|
| 219 |
+
def test_tick_budget_aligned_with_max_turns():
|
| 220 |
+
"""within_ticks must be reachable inside max_turns, AND the
|
| 221 |
+
after_ticks win gate must sit comfortably below reachable so the
|
| 222 |
+
episode can actually reach it."""
|
| 223 |
+
pack = load_pack(PACK)
|
| 224 |
+
for lvl in LEVELS:
|
| 225 |
+
level_def = pack.levels[lvl]
|
| 226 |
+
max_turns = level_def.max_turns
|
| 227 |
+
reachable = 93 + 90 * (max_turns - 1)
|
| 228 |
+
win = compile_level(pack, lvl).win_condition.model_dump(exclude_none=True)
|
| 229 |
+
|
| 230 |
+
def _collect(node, key, out):
|
| 231 |
+
if isinstance(node, dict):
|
| 232 |
+
if key in node:
|
| 233 |
+
out.append(node[key])
|
| 234 |
+
for v in node.values():
|
| 235 |
+
_collect(v, key, out)
|
| 236 |
+
elif isinstance(node, list):
|
| 237 |
+
for v in node:
|
| 238 |
+
_collect(v, key, out)
|
| 239 |
+
wts: list = []
|
| 240 |
+
ats: list = []
|
| 241 |
+
_collect(win, "within_ticks", wts)
|
| 242 |
+
_collect(win, "after_ticks", ats)
|
| 243 |
+
assert wts, f"{lvl} has no within_ticks leaf"
|
| 244 |
+
assert ats, f"{lvl} has no after_ticks leaf (no survival gate)"
|
| 245 |
+
for wt in wts:
|
| 246 |
+
assert wt <= reachable, (
|
| 247 |
+
f"{lvl} within_ticks={wt} > reachable={reachable} "
|
| 248 |
+
f"(max_turns={max_turns}) — deadline never bites ⇒ draw"
|
| 249 |
+
)
|
| 250 |
+
for at in ats:
|
| 251 |
+
assert at <= reachable, (
|
| 252 |
+
f"{lvl} after_ticks={at} > reachable={reachable} — "
|
| 253 |
+
f"survival gate never opens ⇒ win unreachable"
|
| 254 |
+
)
|
| 255 |
+
|
| 256 |
+
|
| 257 |
+
def test_win_predicate_includes_all_four_survival_clauses():
|
| 258 |
+
"""The win must require has_building:fact, own_units_gte:3,
|
| 259 |
+
building_count_gte pbox≥2, and after_ticks — all four together.
|
| 260 |
+
A scenario that's missing any clause would be gameable."""
|
| 261 |
+
for lvl in LEVELS:
|
| 262 |
+
c = compile_level(load_pack(PACK), lvl)
|
| 263 |
+
win = c.win_condition.model_dump(exclude_none=True)
|
| 264 |
+
all_of = win.get("all_of") or []
|
| 265 |
+
keys: set = set()
|
| 266 |
+
for clause in all_of:
|
| 267 |
+
keys |= set(clause.keys())
|
| 268 |
+
assert "has_building" in keys, f"{lvl} win missing has_building: {keys}"
|
| 269 |
+
assert "own_units_gte" in keys, f"{lvl} win missing own_units_gte: {keys}"
|
| 270 |
+
assert "building_count_gte" in keys, (
|
| 271 |
+
f"{lvl} win missing building_count_gte: {keys}"
|
| 272 |
+
)
|
| 273 |
+
assert "after_ticks" in keys, f"{lvl} win missing after_ticks: {keys}"
|
| 274 |
+
assert "within_ticks" in keys, f"{lvl} win missing within_ticks: {keys}"
|
| 275 |
+
|
| 276 |
+
|
| 277 |
+
# ── Engine-bound tests (parameterised over seeds 1..4) ────────────
|
| 278 |
+
|
| 279 |
+
|
| 280 |
+
@pytest.mark.parametrize("seed", SEEDS)
|
| 281 |
+
@pytest.mark.parametrize("level", LEVELS)
|
| 282 |
+
def test_intended_sustained_ops_wins(level, seed):
|
| 283 |
+
"""The intended sustained-ops policy (kick harv + maintain pbox
|
| 284 |
+
line + continuous infantry production) must WIN on every (level,
|
| 285 |
+
seed). Load-bearing solvency test."""
|
| 286 |
+
c = compile_level(load_pack(PACK), level)
|
| 287 |
+
res = run_level(c, _intended_sustained_ops_policy(), seed=seed)
|
| 288 |
+
pbox_n = sum(1 for t, _, _ in res.signals.own_buildings if t == "pbox")
|
| 289 |
+
fact = any(t == "fact" for t, _, _ in res.signals.own_buildings)
|
| 290 |
+
assert res.outcome == "win", (
|
| 291 |
+
f"intended sustained-ops must WIN on {level} s={seed}; "
|
| 292 |
+
f"got {res.outcome} turns={res.turns} tick={res.signals.game_tick} "
|
| 293 |
+
f"units_lost={res.signals.units_lost} pbox={pbox_n} fact={fact}"
|
| 294 |
+
)
|
| 295 |
+
|
| 296 |
+
|
| 297 |
+
@pytest.mark.parametrize("seed", SEEDS)
|
| 298 |
+
@pytest.mark.parametrize("level", LEVELS)
|
| 299 |
+
def test_stall_loses(level, seed):
|
| 300 |
+
"""Do-nothing must LOSE on every (level, seed). Hunt squads raze
|
| 301 |
+
fact (or the survival gate opens with the win all-of unmet)."""
|
| 302 |
+
c = compile_level(load_pack(PACK), level)
|
| 303 |
+
res = run_level(c, _stall_policy(), seed=seed)
|
| 304 |
+
assert res.outcome == "loss", (
|
| 305 |
+
f"stall must LOSE on {level} s={seed}; got {res.outcome} "
|
| 306 |
+
f"tick={res.signals.game_tick}"
|
| 307 |
+
)
|
| 308 |
+
|
| 309 |
+
|
| 310 |
+
@pytest.mark.parametrize("seed", SEEDS)
|
| 311 |
+
@pytest.mark.parametrize("level", LEVELS)
|
| 312 |
+
def test_pure_defense_no_reinforce_loses(level, seed):
|
| 313 |
+
"""Build pbox but NEVER train new infantry — the garrison bleeds
|
| 314 |
+
under sustained pressure and either fact falls or own_units drops
|
| 315 |
+
below 3 (1) at the survival gate ⇒ LOSS."""
|
| 316 |
+
c = compile_level(load_pack(PACK), level)
|
| 317 |
+
res = run_level(c, _pure_defense_no_reinforce_policy(), seed=seed)
|
| 318 |
+
pbox_n = sum(1 for t, _, _ in res.signals.own_buildings if t == "pbox")
|
| 319 |
+
fact = any(t == "fact" for t, _, _ in res.signals.own_buildings)
|
| 320 |
+
assert res.outcome == "loss", (
|
| 321 |
+
f"pure-defense-no-reinforce must LOSE on {level} s={seed}; got "
|
| 322 |
+
f"{res.outcome} tick={res.signals.game_tick} "
|
| 323 |
+
f"lost={res.signals.units_lost} pbox={pbox_n} fact={fact}"
|
| 324 |
+
)
|
| 325 |
+
|
| 326 |
+
|
| 327 |
+
@pytest.mark.parametrize("seed", SEEDS)
|
| 328 |
+
@pytest.mark.parametrize("level", LEVELS)
|
| 329 |
+
def test_pure_build_no_repair_loses(level, seed):
|
| 330 |
+
"""Build only 2 pbox up front; no infantry production / no
|
| 331 |
+
repair / no further building. The lone pbox pair gets stripped
|
| 332 |
+
and fact eventually falls under sustained attrition ⇒ LOSS."""
|
| 333 |
+
c = compile_level(load_pack(PACK), level)
|
| 334 |
+
res = run_level(c, _pure_build_no_repair_policy(), seed=seed)
|
| 335 |
+
pbox_n = sum(1 for t, _, _ in res.signals.own_buildings if t == "pbox")
|
| 336 |
+
fact = any(t == "fact" for t, _, _ in res.signals.own_buildings)
|
| 337 |
+
assert res.outcome == "loss", (
|
| 338 |
+
f"pure-build-no-repair must LOSE on {level} s={seed}; got "
|
| 339 |
+
f"{res.outcome} tick={res.signals.game_tick} "
|
| 340 |
+
f"lost={res.signals.units_lost} pbox={pbox_n} fact={fact}"
|
| 341 |
+
)
|
| 342 |
+
|
| 343 |
+
|
| 344 |
+
@pytest.mark.parametrize("seed", SEEDS)
|
| 345 |
+
def test_hard_seeds_produce_distinct_starts(seed):
|
| 346 |
+
"""Hard's two spawn_point groups must round-robin per seed."""
|
| 347 |
+
c = compile_level(load_pack(PACK), "hard")
|
| 348 |
+
# Stall — deterministic, fast; we only need to check the seed flows.
|
| 349 |
+
res = run_level(c, _stall_policy(), seed=seed)
|
| 350 |
+
assert res.outcome == "loss" # stall must lose
|
| 351 |
+
sp = {a.spawn_point for a in c.scenario.actors if a.owner == "agent"}
|
| 352 |
+
assert len(sp) >= 2
|
|
@@ -0,0 +1,319 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""lh-build-army-coordinate-multifront-attack — full no-cheat
|
| 2 |
+
validation on Rust.
|
| 3 |
+
|
| 4 |
+
Group G long-horizon REASONING pack. Two-phase operational plan:
|
| 5 |
+
ASSEMBLE a real army (≥N medium tanks) THEN commit it to a
|
| 6 |
+
SIMULTANEOUS TWO-FRONT assault on TWO separated enemy `fact` markers
|
| 7 |
+
(NE at (130,15) and SE at (130,45), 120 cells apart). The Wave-2
|
| 8 |
+
`then:` happened-before composite enforces the army-clause-before-
|
| 9 |
+
destruction ordering; the two destruction clauses (NE + SE) enforce
|
| 10 |
+
the SPLIT (one mass cannot raze both corners in sequence within the
|
| 11 |
+
clock budget).
|
| 12 |
+
|
| 13 |
+
Bar (per CLAUDE.md): the intended build-then-split policy WINS on
|
| 14 |
+
every (level, seed); stall / one-front-only / send-2-units-each-front
|
| 15 |
+
all LOSE on every seed.
|
| 16 |
+
|
| 17 |
+
Scenario shape:
|
| 18 |
+
- 160×60 arena, allies vs soviet (static — no bot).
|
| 19 |
+
- easy: 4-tank army threshold, no defenders pressure.
|
| 20 |
+
- medium: 6-tank army threshold, light pickets.
|
| 21 |
+
- hard: 8-tank army threshold + 2 spawn_point groups (NORTH/SOUTH
|
| 22 |
+
agent base latitude flips per seed).
|
| 23 |
+
"""
|
| 24 |
+
|
| 25 |
+
from __future__ import annotations
|
| 26 |
+
|
| 27 |
+
import pytest
|
| 28 |
+
|
| 29 |
+
pytest.importorskip("openra_train", reason="Rust env wheel not installed")
|
| 30 |
+
pytest.importorskip("openra_rl_training", reason="Rust env wheel not installed")
|
| 31 |
+
|
| 32 |
+
from openra_bench.eval_core import run_level
|
| 33 |
+
from openra_bench.scenarios import load_pack
|
| 34 |
+
from openra_bench.scenarios.loader import PACKS_DIR, compile_level
|
| 35 |
+
|
| 36 |
+
PACK = PACKS_DIR / "lh-build-army-coordinate-multifront-attack.yaml"
|
| 37 |
+
LEVELS = ("easy", "medium", "hard")
|
| 38 |
+
SEEDS = (1, 2, 3, 4)
|
| 39 |
+
|
| 40 |
+
# Per-level army size (the n in unit_type_count_gte: {type:2tnk, n:N}).
|
| 41 |
+
_ARMY_N = {"easy": 4, "medium": 6, "hard": 8}
|
| 42 |
+
|
| 43 |
+
# The two fixed enemy fact corners (NE + SE).
|
| 44 |
+
NE = (130, 15)
|
| 45 |
+
SE = (130, 45)
|
| 46 |
+
|
| 47 |
+
|
| 48 |
+
# ── Policies ──────────────────────────────────────────────────────
|
| 49 |
+
|
| 50 |
+
|
| 51 |
+
def _stall_policy():
|
| 52 |
+
"""Do nothing — must LOSE on the clock on every level/seed."""
|
| 53 |
+
def pol(obs, Cmd):
|
| 54 |
+
return [Cmd.observe()]
|
| 55 |
+
return pol
|
| 56 |
+
|
| 57 |
+
|
| 58 |
+
def _attack_one_front_only_policy():
|
| 59 |
+
"""Build the army to the threshold, then attack-move EVERY tank
|
| 60 |
+
onto the NE fact. The NE destruction clause latches, the army
|
| 61 |
+
clause latches, but the SE destruction clause NEVER latches (the
|
| 62 |
+
SE fact stands untouched). Must LOSE on every (level, seed)."""
|
| 63 |
+
def pol(obs, Cmd):
|
| 64 |
+
units = obs.get("units_summary", []) or []
|
| 65 |
+
own_b = {b["type"] for b in (obs.get("own_buildings", []) or [])}
|
| 66 |
+
prod = obs.get("production", []) or []
|
| 67 |
+
tnk = [u for u in units if u.get("type") == "2tnk"]
|
| 68 |
+
cmds = []
|
| 69 |
+
# Keep building tanks (cap at 12 to avoid runaway queue spam).
|
| 70 |
+
if "weap" in own_b and len(tnk) < 12 and "2tnk" not in prod:
|
| 71 |
+
cmds.append(Cmd.build("2tnk"))
|
| 72 |
+
if tnk:
|
| 73 |
+
tnk_ids = [u["id"] for u in tnk]
|
| 74 |
+
# ALL tanks at the NE fact — SE is ignored on purpose.
|
| 75 |
+
cmds.append(Cmd.attack_move(tnk_ids, NE[0], NE[1]))
|
| 76 |
+
if not cmds:
|
| 77 |
+
cmds.append(Cmd.observe())
|
| 78 |
+
return cmds
|
| 79 |
+
return pol
|
| 80 |
+
|
| 81 |
+
|
| 82 |
+
def _send_too_few_each_front_policy():
|
| 83 |
+
"""Skip the build entirely; just split the two starter 2tnks —
|
| 84 |
+
one to NE, one to SE — and hope (in vain) that 1 tank per prong
|
| 85 |
+
can raze a defended fact. Cannot satisfy the army clause (N≥4
|
| 86 |
+
minimum), and 1 tank cannot raze a fact with defenders inside
|
| 87 |
+
the clock anyway. Must LOSE on every (level, seed)."""
|
| 88 |
+
sent = {"ne": False, "se": False}
|
| 89 |
+
|
| 90 |
+
def pol(obs, Cmd):
|
| 91 |
+
units = obs.get("units_summary", []) or []
|
| 92 |
+
tnk = [u for u in units if u.get("type") == "2tnk"]
|
| 93 |
+
cmds = []
|
| 94 |
+
# Sort tanks by id so the split is deterministic.
|
| 95 |
+
tnk.sort(key=lambda u: u["id"])
|
| 96 |
+
if tnk:
|
| 97 |
+
ne_ids = [tnk[0]["id"]]
|
| 98 |
+
se_ids = [tnk[1]["id"]] if len(tnk) > 1 else []
|
| 99 |
+
if ne_ids:
|
| 100 |
+
cmds.append(Cmd.attack_move(ne_ids, NE[0], NE[1]))
|
| 101 |
+
sent["ne"] = True
|
| 102 |
+
if se_ids:
|
| 103 |
+
cmds.append(Cmd.attack_move(se_ids, SE[0], SE[1]))
|
| 104 |
+
sent["se"] = True
|
| 105 |
+
if not cmds:
|
| 106 |
+
cmds.append(Cmd.observe())
|
| 107 |
+
return cmds
|
| 108 |
+
return pol
|
| 109 |
+
|
| 110 |
+
|
| 111 |
+
def _intended_mass_and_split_policy(army_n: int):
|
| 112 |
+
"""The intended capability play:
|
| 113 |
+
PHASE 1: build the army to ≥army_n medium tanks (concurrent
|
| 114 |
+
with idle observation so production can drip).
|
| 115 |
+
PHASE 2: SPLIT the army half-to-NE, half-to-SE attack-move.
|
| 116 |
+
Uses a sticky latch on the army milestone so a tank dying after
|
| 117 |
+
we've reached threshold doesn't bounce the policy back to phase 1.
|
| 118 |
+
|
| 119 |
+
Tanks come out of the pre-placed weap (war factory). Starter 2
|
| 120 |
+
tanks count toward the threshold.
|
| 121 |
+
"""
|
| 122 |
+
milestone = {"army": False}
|
| 123 |
+
|
| 124 |
+
def pol(obs, Cmd):
|
| 125 |
+
units = obs.get("units_summary", []) or []
|
| 126 |
+
own_b = {b["type"] for b in (obs.get("own_buildings", []) or [])}
|
| 127 |
+
prod = obs.get("production", []) or []
|
| 128 |
+
tnk = [u for u in units if u.get("type") == "2tnk"]
|
| 129 |
+
if len(tnk) >= army_n:
|
| 130 |
+
milestone["army"] = True
|
| 131 |
+
cmds = []
|
| 132 |
+
# PHASE 1 (always running until army milestone): keep tank
|
| 133 |
+
# production in flight whenever weap exists and the queue
|
| 134 |
+
# isn't already producing one.
|
| 135 |
+
if "weap" in own_b and "2tnk" not in prod:
|
| 136 |
+
cmds.append(Cmd.build("2tnk"))
|
| 137 |
+
# PHASE 2: once army milestone latched, split-attack.
|
| 138 |
+
# The split is deterministic by tank id (lower half → NE,
|
| 139 |
+
# upper half → SE) so the test is reproducible.
|
| 140 |
+
if milestone["army"] and tnk:
|
| 141 |
+
tnk_sorted = sorted(tnk, key=lambda u: u["id"])
|
| 142 |
+
half = max(1, len(tnk_sorted) // 2)
|
| 143 |
+
ne_ids = [u["id"] for u in tnk_sorted[:half]]
|
| 144 |
+
se_ids = [u["id"] for u in tnk_sorted[half:]]
|
| 145 |
+
if ne_ids:
|
| 146 |
+
cmds.append(Cmd.attack_move(ne_ids, NE[0], NE[1]))
|
| 147 |
+
if se_ids:
|
| 148 |
+
cmds.append(Cmd.attack_move(se_ids, SE[0], SE[1]))
|
| 149 |
+
if not cmds:
|
| 150 |
+
cmds.append(Cmd.observe())
|
| 151 |
+
return cmds
|
| 152 |
+
return pol
|
| 153 |
+
|
| 154 |
+
|
| 155 |
+
# ── Pack-shape tests (cheap; do not run the engine) ──────────────
|
| 156 |
+
|
| 157 |
+
|
| 158 |
+
def test_pack_compiles_with_three_levels():
|
| 159 |
+
pack = load_pack(PACK)
|
| 160 |
+
assert pack.meta.id == "lh-build-army-coordinate-multifront-attack"
|
| 161 |
+
assert pack.meta.capability == "reasoning"
|
| 162 |
+
assert set(pack.levels) == {"easy", "medium", "hard"}
|
| 163 |
+
|
| 164 |
+
|
| 165 |
+
def test_meta_benchmark_anchor_set():
|
| 166 |
+
"""Required by the spec: SC2 macro-then-multi-prong / military
|
| 167 |
+
operational planning / PERT / industrial product launch."""
|
| 168 |
+
pack = load_pack(PACK)
|
| 169 |
+
anchors = pack.meta.benchmark_anchor or []
|
| 170 |
+
j = " | ".join(anchors).lower()
|
| 171 |
+
assert "sc2" in j, anchors
|
| 172 |
+
assert "operational" in j or "military" in j, anchors
|
| 173 |
+
assert "pert" in j, anchors
|
| 174 |
+
assert "industrial" in j or "rollout" in j, anchors
|
| 175 |
+
|
| 176 |
+
|
| 177 |
+
def test_hard_tier_has_seed_driven_spawn_groups():
|
| 178 |
+
"""Hard must define ≥2 agent spawn_point groups so seed varies
|
| 179 |
+
the start base latitude (tests/test_hard_tier.py::UPGRADED
|
| 180 |
+
contract)."""
|
| 181 |
+
c = compile_level(load_pack(PACK), "hard")
|
| 182 |
+
sp = {a.spawn_point for a in c.scenario.actors if a.owner == "agent"}
|
| 183 |
+
assert len(sp) >= 2, f"hard needs ≥2 spawn groups, got {sp}"
|
| 184 |
+
|
| 185 |
+
|
| 186 |
+
def test_every_level_has_fail_condition():
|
| 187 |
+
"""No silent draws — every level must be able to emit a LOSS."""
|
| 188 |
+
pack = load_pack(PACK)
|
| 189 |
+
for lvl in LEVELS:
|
| 190 |
+
c = compile_level(pack, lvl)
|
| 191 |
+
assert c.fail_condition is not None, f"{lvl} missing fail_condition"
|
| 192 |
+
|
| 193 |
+
|
| 194 |
+
def test_then_composite_used_in_win():
|
| 195 |
+
"""The win is `all_of[ then{army, NE-raze, SE-raze}, within_ticks ]`.
|
| 196 |
+
Confirms the load-bearing teeth (mass-then-split) is wired."""
|
| 197 |
+
for lvl in LEVELS:
|
| 198 |
+
c = compile_level(load_pack(PACK), lvl)
|
| 199 |
+
win = c.win_condition.model_dump(exclude_none=True)
|
| 200 |
+
inner = win.get("all_of") or []
|
| 201 |
+
then_node = None
|
| 202 |
+
for cl in inner:
|
| 203 |
+
if "then" in cl:
|
| 204 |
+
then_node = cl["then"]
|
| 205 |
+
break
|
| 206 |
+
assert then_node is not None, f"{lvl} win missing then-chain: {win}"
|
| 207 |
+
clauses = (then_node or {}).get("clauses") or []
|
| 208 |
+
# army → NE-raze → SE-raze ⇒ exactly 3 clauses.
|
| 209 |
+
assert len(clauses) == 3, (
|
| 210 |
+
f"{lvl} then-chain must have 3 clauses (army, NE, SE); "
|
| 211 |
+
f"got {clauses}"
|
| 212 |
+
)
|
| 213 |
+
|
| 214 |
+
|
| 215 |
+
def test_tick_budget_aligned_with_max_turns():
|
| 216 |
+
"""within_ticks must be reachable inside max_turns. Engine
|
| 217 |
+
advances ~90 ticks/turn → reachable max = 93 + 90·(N-1)."""
|
| 218 |
+
pack = load_pack(PACK)
|
| 219 |
+
for lvl in LEVELS:
|
| 220 |
+
level_def = pack.levels[lvl]
|
| 221 |
+
max_turns = level_def.max_turns
|
| 222 |
+
reachable = 93 + 90 * (max_turns - 1)
|
| 223 |
+
win = compile_level(pack, lvl).win_condition.model_dump(exclude_none=True)
|
| 224 |
+
|
| 225 |
+
def _collect(node, key, out):
|
| 226 |
+
if isinstance(node, dict):
|
| 227 |
+
if key in node:
|
| 228 |
+
out.append(node[key])
|
| 229 |
+
for v in node.values():
|
| 230 |
+
_collect(v, key, out)
|
| 231 |
+
elif isinstance(node, list):
|
| 232 |
+
for v in node:
|
| 233 |
+
_collect(v, key, out)
|
| 234 |
+
wts = []
|
| 235 |
+
_collect(win, "within_ticks", wts)
|
| 236 |
+
assert wts, f"{lvl} has no within_ticks leaf (no clock teeth)"
|
| 237 |
+
for wt in wts:
|
| 238 |
+
assert wt <= reachable, (
|
| 239 |
+
f"{lvl} within_ticks={wt} > reachable={reachable} "
|
| 240 |
+
f"(max_turns={max_turns}) — deadline never bites ⇒ draw"
|
| 241 |
+
)
|
| 242 |
+
|
| 243 |
+
|
| 244 |
+
# ── Engine-bound tests (parameterised over seeds 1..4) ────────────
|
| 245 |
+
|
| 246 |
+
|
| 247 |
+
@pytest.mark.parametrize("seed", SEEDS)
|
| 248 |
+
@pytest.mark.parametrize("level", LEVELS)
|
| 249 |
+
def test_intended_mass_and_split_wins(level, seed):
|
| 250 |
+
"""The intended capability play — build the army to ≥N then SPLIT
|
| 251 |
+
half-to-NE half-to-SE attack-move — must WIN on every (level,
|
| 252 |
+
seed). Load-bearing: the pack is solvable inside the budget by
|
| 253 |
+
the advertised capability."""
|
| 254 |
+
c = compile_level(load_pack(PACK), level)
|
| 255 |
+
res = run_level(c, _intended_mass_and_split_policy(_ARMY_N[level]), seed=seed)
|
| 256 |
+
tp = getattr(res.signals, "then_progress", {}) or {}
|
| 257 |
+
assert res.outcome == "win", (
|
| 258 |
+
f"intended mass-and-split must WIN on {level} s={seed}; "
|
| 259 |
+
f"got {res.outcome} (then_progress={tp}, "
|
| 260 |
+
f"kills={res.signals.units_killed}, "
|
| 261 |
+
f"own_buildings={res.signals.own_building_types})"
|
| 262 |
+
)
|
| 263 |
+
|
| 264 |
+
|
| 265 |
+
@pytest.mark.parametrize("seed", SEEDS)
|
| 266 |
+
@pytest.mark.parametrize("level", LEVELS)
|
| 267 |
+
def test_stall_loses(level, seed):
|
| 268 |
+
"""A do-nothing policy must LOSE on every (level, seed). The
|
| 269 |
+
fail_condition's after_ticks bites at the turn budget; never a
|
| 270 |
+
draw."""
|
| 271 |
+
c = compile_level(load_pack(PACK), level)
|
| 272 |
+
res = run_level(c, _stall_policy(), seed=seed)
|
| 273 |
+
assert res.outcome == "loss", (
|
| 274 |
+
f"stall must LOSE on {level} s={seed}; got {res.outcome}"
|
| 275 |
+
)
|
| 276 |
+
|
| 277 |
+
|
| 278 |
+
@pytest.mark.parametrize("seed", SEEDS)
|
| 279 |
+
@pytest.mark.parametrize("level", LEVELS)
|
| 280 |
+
def test_attack_one_front_only_loses(level, seed):
|
| 281 |
+
"""A "build army, send EVERY tank to NE only, ignore SE" policy
|
| 282 |
+
must LOSE on every (level, seed). The SE destruction clause
|
| 283 |
+
never latches, so the then-chain never completes."""
|
| 284 |
+
c = compile_level(load_pack(PACK), level)
|
| 285 |
+
res = run_level(c, _attack_one_front_only_policy(), seed=seed)
|
| 286 |
+
tp = getattr(res.signals, "then_progress", {}) or {}
|
| 287 |
+
assert res.outcome == "loss", (
|
| 288 |
+
f"one-front-only must LOSE on {level} s={seed}; "
|
| 289 |
+
f"got {res.outcome} then_progress={tp}"
|
| 290 |
+
)
|
| 291 |
+
|
| 292 |
+
|
| 293 |
+
@pytest.mark.parametrize("seed", SEEDS)
|
| 294 |
+
@pytest.mark.parametrize("level", LEVELS)
|
| 295 |
+
def test_send_too_few_each_front_loses(level, seed):
|
| 296 |
+
"""A "skip the build, send 1 starter tank to NE + 1 to SE" policy
|
| 297 |
+
must LOSE on every (level, seed). The army clause (army_n ≥ 4)
|
| 298 |
+
can never latch with only 2 starter tanks, AND 1 tank per prong
|
| 299 |
+
bounces off the defenders without razing the fact."""
|
| 300 |
+
c = compile_level(load_pack(PACK), level)
|
| 301 |
+
res = run_level(c, _send_too_few_each_front_policy(), seed=seed)
|
| 302 |
+
tp = getattr(res.signals, "then_progress", {}) or {}
|
| 303 |
+
assert res.outcome == "loss", (
|
| 304 |
+
f"send-too-few must LOSE on {level} s={seed}; "
|
| 305 |
+
f"got {res.outcome} then_progress={tp}"
|
| 306 |
+
)
|
| 307 |
+
|
| 308 |
+
|
| 309 |
+
@pytest.mark.parametrize("seed", SEEDS)
|
| 310 |
+
def test_hard_seeds_produce_distinct_starts(seed):
|
| 311 |
+
"""Hard's two spawn_point groups must round-robin — different
|
| 312 |
+
seeds must place the agent base at a different latitude (y=18
|
| 313 |
+
NORTH vs y=42 SOUTH). Smoke-tests the spawn-variation contract
|
| 314 |
+
that tests/test_hard_tier.py also enforces."""
|
| 315 |
+
c = compile_level(load_pack(PACK), "hard")
|
| 316 |
+
res = run_level(c, _stall_policy(), seed=seed)
|
| 317 |
+
assert res.outcome == "loss" # stall must lose on hard
|
| 318 |
+
sp = {a.spawn_point for a in c.scenario.actors if a.owner == "agent"}
|
| 319 |
+
assert len(sp) >= 2
|
|
@@ -0,0 +1,552 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""lh-defense-tech-second-base — full no-cheat validation on Rust.
|
| 2 |
+
|
| 3 |
+
Wave-5 Group G long-horizon "secure-expand-with-tech" pack. Uses the
|
| 4 |
+
Wave-2 `then:` happened-before composite to enforce strict 3- (or 4-)
|
| 5 |
+
phase ordering:
|
| 6 |
+
PHASE 1: building_count_gte:{type:pbox, n:2|3|3} # defend
|
| 7 |
+
PHASE 2: has_building: weap # tech
|
| 8 |
+
PHASE 3: building_in_region:{x:130,y:30,r:8,
|
| 9 |
+
type:fact,count:1} # 2nd base
|
| 10 |
+
PHASE 4 (hard only):
|
| 11 |
+
building_count_gte:{type:proc, n:2} # second refinery
|
| 12 |
+
|
| 13 |
+
Bar (per CLAUDE.md): the intended secure-expand-with-tech policy must
|
| 14 |
+
WIN on every (level, seed); stall / pure-expand-skip-defence /
|
| 15 |
+
pure-defence-no-expand must LOSE on every (level, seed). No draws.
|
| 16 |
+
|
| 17 |
+
Scenario shape:
|
| 18 |
+
- 160x60 generator-spec arena, allied agent, soviet `patrol`-bot.
|
| 19 |
+
- Pre-placed base: fact + tent + 2× powr + proc + harv + mine
|
| 20 |
+
+ 3 rifleman defenders (so income flows from turn 1 and own_units_
|
| 21 |
+
gte:1 is satisfied immediately).
|
| 22 |
+
- Spare MCV staged ~30 cells east of base #1 — agent drives it
|
| 23 |
+
further east and deploys at (131,31) → fact lands at (130,30),
|
| 24 |
+
the centre of the east target region.
|
| 25 |
+
- Inert enemy `fact` marker at (154,4) prevents auto-DRAW.
|
| 26 |
+
- hard: ≥2 spawn_point groups (NORTH y=22 / SOUTH y=38) per the
|
| 27 |
+
hard-tier contract.
|
| 28 |
+
"""
|
| 29 |
+
|
| 30 |
+
from __future__ import annotations
|
| 31 |
+
|
| 32 |
+
import types
|
| 33 |
+
from pathlib import Path
|
| 34 |
+
|
| 35 |
+
import pytest
|
| 36 |
+
|
| 37 |
+
pytest.importorskip("openra_rl_training", reason="Rust env wheel not installed")
|
| 38 |
+
from openra_bench.scenarios import load_pack
|
| 39 |
+
from openra_bench.scenarios.loader import compile_level
|
| 40 |
+
from openra_bench.scenarios.win_conditions import WinContext, evaluate
|
| 41 |
+
|
| 42 |
+
PACKS = Path(__file__).parent.parent / "openra_bench" / "scenarios" / "packs"
|
| 43 |
+
PACK_PATH = PACKS / "lh-defense-tech-second-base.yaml"
|
| 44 |
+
LEVELS = ("easy", "medium", "hard")
|
| 45 |
+
SEEDS = (1, 2, 3, 4)
|
| 46 |
+
|
| 47 |
+
|
| 48 |
+
# ── Pack-shape tests (cheap; do not run the engine) ─────────────────
|
| 49 |
+
|
| 50 |
+
|
| 51 |
+
def test_pack_compiles_with_three_levels():
|
| 52 |
+
pack = load_pack(PACK_PATH)
|
| 53 |
+
assert pack.meta.id == "lh-defense-tech-second-base"
|
| 54 |
+
assert pack.meta.capability == "reasoning"
|
| 55 |
+
assert set(pack.levels) == {"easy", "medium", "hard"}
|
| 56 |
+
|
| 57 |
+
|
| 58 |
+
def test_meta_benchmark_anchor_set():
|
| 59 |
+
pack = load_pack(PACK_PATH)
|
| 60 |
+
anchors = pack.meta.benchmark_anchor or []
|
| 61 |
+
joined = " ".join(anchors).lower()
|
| 62 |
+
assert "sc2" in joined and "secure-expand" in joined, anchors
|
| 63 |
+
assert "planbench" in joined, anchors
|
| 64 |
+
assert "roadmap" in joined or "harden" in joined, anchors
|
| 65 |
+
assert "industrial" in joined or "second plant" in joined, anchors
|
| 66 |
+
|
| 67 |
+
|
| 68 |
+
def test_hard_tier_has_seed_driven_spawn_groups():
|
| 69 |
+
c = compile_level(load_pack(PACK_PATH), "hard")
|
| 70 |
+
sp = {a.spawn_point for a in c.scenario.actors if a.owner == "agent"}
|
| 71 |
+
assert len(sp) >= 2, f"hard needs ≥2 spawn groups, got {sp}"
|
| 72 |
+
|
| 73 |
+
|
| 74 |
+
def test_every_level_has_fail_condition():
|
| 75 |
+
pack = load_pack(PACK_PATH)
|
| 76 |
+
for lvl in LEVELS:
|
| 77 |
+
c = compile_level(pack, lvl)
|
| 78 |
+
assert c.fail_condition is not None, f"{lvl} missing fail_condition"
|
| 79 |
+
|
| 80 |
+
|
| 81 |
+
def test_then_composite_used_in_win():
|
| 82 |
+
"""Confirms the secure-expand-with-tech chain wired through to the
|
| 83 |
+
compiled win condition (the whole point of the pack)."""
|
| 84 |
+
expected_first = {"easy": 2, "medium": 3, "hard": 3}
|
| 85 |
+
for lvl in LEVELS:
|
| 86 |
+
c = compile_level(load_pack(PACK_PATH), lvl)
|
| 87 |
+
win = c.win_condition.model_dump(exclude_none=True)
|
| 88 |
+
ao = win.get("all_of") or []
|
| 89 |
+
assert any("then" in clause for clause in ao), (
|
| 90 |
+
f"{lvl} win has no then-composite: {win}"
|
| 91 |
+
)
|
| 92 |
+
then_clause = next(cl["then"] for cl in ao if "then" in cl)
|
| 93 |
+
clauses = then_clause["clauses"]
|
| 94 |
+
# PHASE 1: pbox count
|
| 95 |
+
assert "building_count_gte" in clauses[0]
|
| 96 |
+
assert clauses[0]["building_count_gte"]["type"] == "pbox"
|
| 97 |
+
assert clauses[0]["building_count_gte"]["n"] == expected_first[lvl]
|
| 98 |
+
# PHASE 2: weap
|
| 99 |
+
assert "has_building" in clauses[1] and clauses[1]["has_building"] == "weap"
|
| 100 |
+
# PHASE 3: building_in_region around (130,30) for fact
|
| 101 |
+
assert "building_in_region" in clauses[2]
|
| 102 |
+
br = clauses[2]["building_in_region"]
|
| 103 |
+
assert br["x"] == 130 and br["y"] == 30 and br["type"] == "fact"
|
| 104 |
+
# Hard has a 4th phase: ≥2 proc
|
| 105 |
+
if lvl == "hard":
|
| 106 |
+
assert len(clauses) == 4
|
| 107 |
+
assert "building_count_gte" in clauses[3]
|
| 108 |
+
assert clauses[3]["building_count_gte"]["type"] == "proc"
|
| 109 |
+
assert clauses[3]["building_count_gte"]["n"] == 2
|
| 110 |
+
else:
|
| 111 |
+
assert len(clauses) == 3
|
| 112 |
+
|
| 113 |
+
|
| 114 |
+
def test_tick_budget_aligned_with_max_turns():
|
| 115 |
+
"""within_ticks must be reachable inside max_turns (~90 ticks/turn)."""
|
| 116 |
+
pack = load_pack(PACK_PATH)
|
| 117 |
+
for lvl in LEVELS:
|
| 118 |
+
level_def = pack.levels[lvl]
|
| 119 |
+
max_turns = level_def.max_turns
|
| 120 |
+
reachable = 93 + 90 * (max_turns - 1)
|
| 121 |
+
win = compile_level(pack, lvl).win_condition.model_dump(exclude_none=True)
|
| 122 |
+
|
| 123 |
+
def _collect(node, key, out):
|
| 124 |
+
if isinstance(node, dict):
|
| 125 |
+
if key in node:
|
| 126 |
+
out.append(node[key])
|
| 127 |
+
for v in node.values():
|
| 128 |
+
_collect(v, key, out)
|
| 129 |
+
elif isinstance(node, list):
|
| 130 |
+
for v in node:
|
| 131 |
+
_collect(v, key, out)
|
| 132 |
+
wts: list = []
|
| 133 |
+
_collect(win, "within_ticks", wts)
|
| 134 |
+
assert wts, f"{lvl} has no within_ticks leaf (no clock teeth)"
|
| 135 |
+
for wt in wts:
|
| 136 |
+
assert wt <= reachable, (
|
| 137 |
+
f"{lvl} within_ticks={wt} > reachable={reachable} "
|
| 138 |
+
f"(max_turns={max_turns}) — deadline never bites ⇒ draw"
|
| 139 |
+
)
|
| 140 |
+
|
| 141 |
+
|
| 142 |
+
def _ctx(own_buildings=(), tick=1000):
|
| 143 |
+
"""Synthesize a WinContext from a building list."""
|
| 144 |
+
sig = types.SimpleNamespace(
|
| 145 |
+
game_tick=tick,
|
| 146 |
+
units_killed=0,
|
| 147 |
+
units_lost=0,
|
| 148 |
+
own_buildings=list(own_buildings),
|
| 149 |
+
own_building_types={t for (t, _, _) in own_buildings},
|
| 150 |
+
enemies_seen_ids=set(),
|
| 151 |
+
enemy_buildings_seen_ids=set(),
|
| 152 |
+
cash=0,
|
| 153 |
+
then_progress={},
|
| 154 |
+
)
|
| 155 |
+
return WinContext(signals=sig, render_state={"units_summary": []})
|
| 156 |
+
|
| 157 |
+
|
| 158 |
+
def test_predicates_easy():
|
| 159 |
+
c = compile_level(load_pack(PACK_PATH), "easy")
|
| 160 |
+
# WIN: full chain — pbox×2 → weap → 2nd fact in east region
|
| 161 |
+
own = [
|
| 162 |
+
("fact", 10, 30),
|
| 163 |
+
("pbox", 12, 30), ("pbox", 12, 31),
|
| 164 |
+
("weap", 14, 30),
|
| 165 |
+
("fact", 130, 30),
|
| 166 |
+
]
|
| 167 |
+
assert evaluate(c.win_condition, _ctx(own, tick=3000))
|
| 168 |
+
# FAIL: skip pbox — chain never advances past phase 1.
|
| 169 |
+
own_no_def = [("fact", 10, 30), ("weap", 14, 30), ("fact", 130, 30)]
|
| 170 |
+
assert not evaluate(c.win_condition, _ctx(own_no_def, tick=3000))
|
| 171 |
+
# FAIL: skip expand — chain stuck at phase 3.
|
| 172 |
+
own_no_exp = [
|
| 173 |
+
("fact", 10, 30),
|
| 174 |
+
("pbox", 12, 30), ("pbox", 12, 31),
|
| 175 |
+
("weap", 14, 30),
|
| 176 |
+
]
|
| 177 |
+
assert not evaluate(c.win_condition, _ctx(own_no_exp, tick=3000))
|
| 178 |
+
# FAIL: no fact at all (base #1 lost).
|
| 179 |
+
assert evaluate(c.fail_condition, _ctx([], tick=3000))
|
| 180 |
+
# FAIL: past deadline.
|
| 181 |
+
assert evaluate(c.fail_condition, _ctx(own, tick=8100))
|
| 182 |
+
|
| 183 |
+
|
| 184 |
+
def test_predicates_medium():
|
| 185 |
+
c = compile_level(load_pack(PACK_PATH), "medium")
|
| 186 |
+
# WIN: pbox×3 → weap → 2nd fact in east region
|
| 187 |
+
own = [
|
| 188 |
+
("fact", 10, 30),
|
| 189 |
+
("pbox", 12, 30), ("pbox", 12, 31), ("pbox", 12, 32),
|
| 190 |
+
("weap", 14, 30),
|
| 191 |
+
("fact", 130, 30),
|
| 192 |
+
]
|
| 193 |
+
assert evaluate(c.win_condition, _ctx(own, tick=3000))
|
| 194 |
+
# FAIL: only 2 pbox — bar is 3.
|
| 195 |
+
own_short = [
|
| 196 |
+
("fact", 10, 30),
|
| 197 |
+
("pbox", 12, 30), ("pbox", 12, 31),
|
| 198 |
+
("weap", 14, 30),
|
| 199 |
+
("fact", 130, 30),
|
| 200 |
+
]
|
| 201 |
+
assert not evaluate(c.win_condition, _ctx(own_short, tick=3000))
|
| 202 |
+
# FAIL: 2nd fact outside east region.
|
| 203 |
+
own_off = [
|
| 204 |
+
("fact", 10, 30),
|
| 205 |
+
("pbox", 12, 30), ("pbox", 12, 31), ("pbox", 12, 32),
|
| 206 |
+
("weap", 14, 30),
|
| 207 |
+
("fact", 100, 30), # > radius 8 from (130,30)
|
| 208 |
+
]
|
| 209 |
+
assert not evaluate(c.win_condition, _ctx(own_off, tick=3000))
|
| 210 |
+
assert evaluate(c.fail_condition, _ctx(own, tick=7300))
|
| 211 |
+
|
| 212 |
+
|
| 213 |
+
def test_predicates_hard_requires_second_proc():
|
| 214 |
+
c = compile_level(load_pack(PACK_PATH), "hard")
|
| 215 |
+
base = [
|
| 216 |
+
("fact", 10, 22),
|
| 217 |
+
("pbox", 12, 22), ("pbox", 12, 23), ("pbox", 12, 24),
|
| 218 |
+
("weap", 14, 22),
|
| 219 |
+
("fact", 130, 30),
|
| 220 |
+
("proc", 6, 18),
|
| 221 |
+
]
|
| 222 |
+
# FAIL: phase 4 missing — only 1 proc.
|
| 223 |
+
assert not evaluate(c.win_condition, _ctx(base, tick=3000))
|
| 224 |
+
# WIN: phase 4 satisfied — 2 procs (the original + a second).
|
| 225 |
+
base2 = base + [("proc", 130, 32)]
|
| 226 |
+
assert evaluate(c.win_condition, _ctx(base2, tick=3000))
|
| 227 |
+
|
| 228 |
+
|
| 229 |
+
# ── engine-driven scripted policies ─────────────────────────────────
|
| 230 |
+
|
| 231 |
+
|
| 232 |
+
def _find_mcv(rs):
|
| 233 |
+
for u in rs.get("units_summary", []) or []:
|
| 234 |
+
if str(u.get("type", "")).lower() == "mcv":
|
| 235 |
+
return u
|
| 236 |
+
return None
|
| 237 |
+
|
| 238 |
+
|
| 239 |
+
def _find_harv(rs):
|
| 240 |
+
for u in rs.get("units_summary", []) or []:
|
| 241 |
+
if str(u.get("type", "")).lower() == "harv":
|
| 242 |
+
return u
|
| 243 |
+
return None
|
| 244 |
+
|
| 245 |
+
|
| 246 |
+
def _base_xy(rs):
|
| 247 |
+
"""Return the base #1 (west-side) fact (cell_x, cell_y), preferring
|
| 248 |
+
the smaller cell_x (the home base)."""
|
| 249 |
+
facts = [
|
| 250 |
+
b for b in (rs.get("own_buildings", []) or [])
|
| 251 |
+
if str(b.get("type", "")).lower() == "fact"
|
| 252 |
+
]
|
| 253 |
+
if not facts:
|
| 254 |
+
return None
|
| 255 |
+
return min(facts, key=lambda b: b["cell_x"])
|
| 256 |
+
|
| 257 |
+
|
| 258 |
+
def _stall_policy(rs, Command):
|
| 259 |
+
"""Idles every turn — must LOSE (clock + patrol attrition)."""
|
| 260 |
+
return [Command.observe()]
|
| 261 |
+
|
| 262 |
+
|
| 263 |
+
def _pure_expand_skip_defence_policy():
|
| 264 |
+
"""Race the MCV east + deploy — never build pbox / weap. The
|
| 265 |
+
chain cannot advance past phase 1 (pbox); the patrol also chews
|
| 266 |
+
the base over time. Must LOSE."""
|
| 267 |
+
|
| 268 |
+
def pol(rs, Command):
|
| 269 |
+
mcv = _find_mcv(rs)
|
| 270 |
+
if mcv is None:
|
| 271 |
+
return [Command.observe()]
|
| 272 |
+
tx, ty = 131, 31
|
| 273 |
+
dx = mcv["cell_x"] - tx
|
| 274 |
+
dy = mcv["cell_y"] - ty
|
| 275 |
+
if dx * dx + dy * dy <= 36:
|
| 276 |
+
return [Command.deploy([str(mcv["id"])])]
|
| 277 |
+
return [Command.move_units([str(mcv["id"])], target_x=tx, target_y=ty)]
|
| 278 |
+
|
| 279 |
+
return pol
|
| 280 |
+
|
| 281 |
+
|
| 282 |
+
def _pure_defence_no_expand_policy(pbox_target: int):
|
| 283 |
+
"""Build pbox + weap forever — never move the MCV. The chain
|
| 284 |
+
stalls at phase 3 (no 2nd fact in east region); clock expires
|
| 285 |
+
⇒ LOSS."""
|
| 286 |
+
state = {"placed_pbox": 0, "weap_attempts": 0, "harv_kicked": False}
|
| 287 |
+
|
| 288 |
+
def pol(rs, Command):
|
| 289 |
+
ob = rs.get("own_buildings", []) or []
|
| 290 |
+
own_b = {b["type"] for b in ob}
|
| 291 |
+
prod = rs.get("production", []) or []
|
| 292 |
+
cash = int(rs.get("cash", 0) or 0)
|
| 293 |
+
base = _base_xy(rs)
|
| 294 |
+
bx = base["cell_x"] if base else 10
|
| 295 |
+
by = base["cell_y"] if base else 30
|
| 296 |
+
cmds = []
|
| 297 |
+
# Kick the harv at the local mine (west of base).
|
| 298 |
+
if not state["harv_kicked"]:
|
| 299 |
+
harv = _find_harv(rs)
|
| 300 |
+
if harv:
|
| 301 |
+
cmds.append(Command.harvest([str(harv["id"])], bx - 8, by))
|
| 302 |
+
state["harv_kicked"] = True
|
| 303 |
+
pbox_have = sum(1 for b in ob if b["type"] == "pbox")
|
| 304 |
+
if pbox_have < pbox_target and "pbox" not in prod and cash >= 600:
|
| 305 |
+
cmds.append(Command.build("pbox"))
|
| 306 |
+
if pbox_have < pbox_target:
|
| 307 |
+
i = state["placed_pbox"]
|
| 308 |
+
row = -3 + 2 * (i % 4)
|
| 309 |
+
col = 3 + (i // 4)
|
| 310 |
+
cmds.append(Command.place_building(
|
| 311 |
+
"pbox", bx + col, by + row
|
| 312 |
+
))
|
| 313 |
+
state["placed_pbox"] += 1
|
| 314 |
+
if (
|
| 315 |
+
pbox_have >= pbox_target
|
| 316 |
+
and "weap" not in own_b
|
| 317 |
+
and "weap" not in prod
|
| 318 |
+
and cash >= 2000
|
| 319 |
+
):
|
| 320 |
+
cmds.append(Command.build("weap"))
|
| 321 |
+
if pbox_have >= pbox_target and "weap" not in own_b:
|
| 322 |
+
i = state["weap_attempts"]
|
| 323 |
+
cmds.append(Command.place_building(
|
| 324 |
+
"weap", bx + 8 + (i % 6), by - 4 + (i // 6)
|
| 325 |
+
))
|
| 326 |
+
state["weap_attempts"] += 1
|
| 327 |
+
if not cmds:
|
| 328 |
+
cmds.append(Command.observe())
|
| 329 |
+
return cmds
|
| 330 |
+
|
| 331 |
+
return pol
|
| 332 |
+
|
| 333 |
+
|
| 334 |
+
def _intended_secure_expand_with_tech_policy(pbox_target: int, hard: bool = False):
|
| 335 |
+
"""The intended REASONING capability play:
|
| 336 |
+
turn 1: kick the harvester onto the local mine
|
| 337 |
+
phase 1: queue pbox one at a time until ≥pbox_target standing
|
| 338 |
+
AT THE SAME TIME, walk the MCV partway east while
|
| 339 |
+
income builds
|
| 340 |
+
phase 2: once phase 1 done, build weap
|
| 341 |
+
phase 3: once phase 2 done, move MCV the rest of the way and
|
| 342 |
+
deploy at (131,31) → fact lands at (130,30) inside
|
| 343 |
+
the east target region
|
| 344 |
+
phase 4 (hard only): build a SECOND proc at the new east base
|
| 345 |
+
(place it near the new east fact)
|
| 346 |
+
"""
|
| 347 |
+
state = {
|
| 348 |
+
"placed_pbox": 0,
|
| 349 |
+
"weap_attempts": 0,
|
| 350 |
+
"proc2_attempts": 0,
|
| 351 |
+
"harv_kicked": False,
|
| 352 |
+
"mcv_deployed": False,
|
| 353 |
+
}
|
| 354 |
+
|
| 355 |
+
def pol(rs, Command):
|
| 356 |
+
ob = rs.get("own_buildings", []) or []
|
| 357 |
+
own_b = {b["type"] for b in ob}
|
| 358 |
+
prod = rs.get("production", []) or []
|
| 359 |
+
cash = int(rs.get("cash", 0) or 0)
|
| 360 |
+
base = _base_xy(rs)
|
| 361 |
+
bx = base["cell_x"] if base else 10
|
| 362 |
+
by = base["cell_y"] if base else 30
|
| 363 |
+
mcv = _find_mcv(rs)
|
| 364 |
+
cmds = []
|
| 365 |
+
|
| 366 |
+
# Kick the harv at the local mine (west of base).
|
| 367 |
+
if not state["harv_kicked"]:
|
| 368 |
+
harv = _find_harv(rs)
|
| 369 |
+
if harv:
|
| 370 |
+
cmds.append(Command.harvest([str(harv["id"])], bx - 8, by))
|
| 371 |
+
state["harv_kicked"] = True
|
| 372 |
+
|
| 373 |
+
# ── Phase 1: pbox first ──
|
| 374 |
+
pbox_have = sum(1 for b in ob if b["type"] == "pbox")
|
| 375 |
+
in_q_pbox = "pbox" in prod
|
| 376 |
+
if pbox_have < pbox_target and not in_q_pbox and cash >= 600:
|
| 377 |
+
cmds.append(Command.build("pbox"))
|
| 378 |
+
if pbox_have < pbox_target:
|
| 379 |
+
i = state["placed_pbox"]
|
| 380 |
+
row = -3 + 2 * (i % 4)
|
| 381 |
+
col = 3 + (i // 4)
|
| 382 |
+
cmds.append(Command.place_building(
|
| 383 |
+
"pbox", bx + col, by + row
|
| 384 |
+
))
|
| 385 |
+
state["placed_pbox"] += 1
|
| 386 |
+
|
| 387 |
+
# ── Phase 2: weap (once phase-1 is satisfied) ──
|
| 388 |
+
if (
|
| 389 |
+
pbox_have >= pbox_target
|
| 390 |
+
and "weap" not in own_b
|
| 391 |
+
and "weap" not in prod
|
| 392 |
+
and cash >= 2000
|
| 393 |
+
):
|
| 394 |
+
cmds.append(Command.build("weap"))
|
| 395 |
+
if pbox_have >= pbox_target and "weap" not in own_b:
|
| 396 |
+
i = state["weap_attempts"]
|
| 397 |
+
cmds.append(Command.place_building(
|
| 398 |
+
"weap", bx + 8 + (i % 6), by - 4 + (i // 6)
|
| 399 |
+
))
|
| 400 |
+
state["weap_attempts"] += 1
|
| 401 |
+
|
| 402 |
+
# ── Phase 3: drive the MCV east + deploy at (131,31). Start
|
| 403 |
+
# moving as soon as Phase 1 is observed-locked (the then-
|
| 404 |
+
# composite is greedy so we can already be in motion). The
|
| 405 |
+
# MCV deploy itself is gated until weap is up — otherwise a
|
| 406 |
+
# too-early deploy would attempt to latch clause 3 before
|
| 407 |
+
# clause 2 and the chain would not advance.
|
| 408 |
+
if mcv is not None and not state["mcv_deployed"]:
|
| 409 |
+
tx, ty = 131, 31
|
| 410 |
+
dx = mcv["cell_x"] - tx
|
| 411 |
+
dy = mcv["cell_y"] - ty
|
| 412 |
+
dist2 = dx * dx + dy * dy
|
| 413 |
+
in_range = dist2 <= 36
|
| 414 |
+
# Move toward the target as soon as the chain is being
|
| 415 |
+
# worked; only deploy once weap is up (phase 2 latched).
|
| 416 |
+
if in_range and "weap" in own_b:
|
| 417 |
+
cmds.append(Command.deploy([str(mcv["id"])]))
|
| 418 |
+
state["mcv_deployed"] = True
|
| 419 |
+
elif not in_range:
|
| 420 |
+
cmds.append(Command.move_units(
|
| 421 |
+
[str(mcv["id"])], target_x=tx, target_y=ty
|
| 422 |
+
))
|
| 423 |
+
|
| 424 |
+
# ── Phase 4 (hard only): build a SECOND proc near the new
|
| 425 |
+
# east base. Only attempt once the east fact exists (phase 3
|
| 426 |
+
# latched).
|
| 427 |
+
if hard:
|
| 428 |
+
proc_have = sum(1 for b in ob if b["type"] == "proc")
|
| 429 |
+
east_fact = any(
|
| 430 |
+
b["type"] == "fact" and b["cell_x"] >= 100 for b in ob
|
| 431 |
+
)
|
| 432 |
+
if (
|
| 433 |
+
east_fact
|
| 434 |
+
and proc_have < 2
|
| 435 |
+
and "proc" not in prod
|
| 436 |
+
and cash >= 1400
|
| 437 |
+
):
|
| 438 |
+
cmds.append(Command.build("proc"))
|
| 439 |
+
if east_fact and proc_have < 2:
|
| 440 |
+
i = state["proc2_attempts"]
|
| 441 |
+
# Place near the new east fact (which lives near
|
| 442 |
+
# (130,30) after deploy).
|
| 443 |
+
cmds.append(Command.place_building(
|
| 444 |
+
"proc", 132 + (i % 4), 32 + (i // 4)
|
| 445 |
+
))
|
| 446 |
+
state["proc2_attempts"] += 1
|
| 447 |
+
|
| 448 |
+
if not cmds:
|
| 449 |
+
cmds.append(Command.observe())
|
| 450 |
+
return cmds
|
| 451 |
+
|
| 452 |
+
return pol
|
| 453 |
+
|
| 454 |
+
|
| 455 |
+
# ── Engine-bound tests (parameterised over seeds 1..4) ───────────────
|
| 456 |
+
|
| 457 |
+
|
| 458 |
+
def _pbox_target(level: str) -> int:
|
| 459 |
+
return {"easy": 2, "medium": 3, "hard": 3}[level]
|
| 460 |
+
|
| 461 |
+
|
| 462 |
+
@pytest.mark.parametrize("seed", SEEDS)
|
| 463 |
+
@pytest.mark.parametrize("level", LEVELS)
|
| 464 |
+
def test_intended_secure_expand_with_tech_wins(level, seed):
|
| 465 |
+
"""The intended secure-expand-with-tech policy must WIN on every
|
| 466 |
+
(level, seed). Load-bearing solvency test."""
|
| 467 |
+
pytest.importorskip("openra_train")
|
| 468 |
+
from openra_bench.eval_core import run_level
|
| 469 |
+
|
| 470 |
+
c = compile_level(load_pack(PACK_PATH), level)
|
| 471 |
+
res = run_level(
|
| 472 |
+
c,
|
| 473 |
+
_intended_secure_expand_with_tech_policy(
|
| 474 |
+
_pbox_target(level), hard=(level == "hard")
|
| 475 |
+
),
|
| 476 |
+
seed=seed,
|
| 477 |
+
)
|
| 478 |
+
own_b = res.signals.own_building_types
|
| 479 |
+
assert res.outcome == "win", (
|
| 480 |
+
f"intended secure-expand-with-tech must WIN on {level} s={seed}; "
|
| 481 |
+
f"got {res.outcome} turns={res.turns} "
|
| 482 |
+
f"own_buildings={own_b} cash={res.signals.cash} "
|
| 483 |
+
f"units_lost={res.signals.units_lost} "
|
| 484 |
+
f"then_progress={getattr(res.signals, 'then_progress', {})}"
|
| 485 |
+
)
|
| 486 |
+
|
| 487 |
+
|
| 488 |
+
@pytest.mark.parametrize("seed", SEEDS)
|
| 489 |
+
@pytest.mark.parametrize("level", LEVELS)
|
| 490 |
+
def test_stall_loses(level, seed):
|
| 491 |
+
"""Do-nothing must LOSE on every (level, seed)."""
|
| 492 |
+
pytest.importorskip("openra_train")
|
| 493 |
+
from openra_bench.eval_core import run_level
|
| 494 |
+
|
| 495 |
+
c = compile_level(load_pack(PACK_PATH), level)
|
| 496 |
+
res = run_level(c, _stall_policy, seed=seed)
|
| 497 |
+
assert res.outcome == "loss", (
|
| 498 |
+
f"stall must LOSE on {level} s={seed}; got {res.outcome}"
|
| 499 |
+
)
|
| 500 |
+
|
| 501 |
+
|
| 502 |
+
@pytest.mark.parametrize("seed", SEEDS)
|
| 503 |
+
@pytest.mark.parametrize("level", LEVELS)
|
| 504 |
+
def test_pure_expand_skip_defence_loses(level, seed):
|
| 505 |
+
"""Race-MCV-east-and-deploy without ever building pbox/weap must
|
| 506 |
+
LOSE: the then-chain cannot advance past phase 1 (pbox), and the
|
| 507 |
+
patrol bot is dripping pressure on the undefended base."""
|
| 508 |
+
pytest.importorskip("openra_train")
|
| 509 |
+
from openra_bench.eval_core import run_level
|
| 510 |
+
|
| 511 |
+
c = compile_level(load_pack(PACK_PATH), level)
|
| 512 |
+
res = run_level(c, _pure_expand_skip_defence_policy(), seed=seed)
|
| 513 |
+
own_b = res.signals.own_building_types
|
| 514 |
+
assert res.outcome == "loss", (
|
| 515 |
+
f"pure-expand-skip-defence must LOSE on {level} s={seed}; got "
|
| 516 |
+
f"{res.outcome} own_buildings={own_b} "
|
| 517 |
+
f"then_progress={getattr(res.signals, 'then_progress', {})}"
|
| 518 |
+
)
|
| 519 |
+
|
| 520 |
+
|
| 521 |
+
@pytest.mark.parametrize("seed", SEEDS)
|
| 522 |
+
@pytest.mark.parametrize("level", LEVELS)
|
| 523 |
+
def test_pure_defence_no_expand_loses(level, seed):
|
| 524 |
+
"""Build pbox + weap forever — never move the MCV east. The
|
| 525 |
+
then-chain stalls at phase 3 (no 2nd fact in east region); clock
|
| 526 |
+
expires ⇒ LOSS."""
|
| 527 |
+
pytest.importorskip("openra_train")
|
| 528 |
+
from openra_bench.eval_core import run_level
|
| 529 |
+
|
| 530 |
+
c = compile_level(load_pack(PACK_PATH), level)
|
| 531 |
+
res = run_level(
|
| 532 |
+
c, _pure_defence_no_expand_policy(_pbox_target(level)), seed=seed
|
| 533 |
+
)
|
| 534 |
+
own_b = res.signals.own_building_types
|
| 535 |
+
assert res.outcome == "loss", (
|
| 536 |
+
f"pure-defence-no-expand must LOSE on {level} s={seed}; got "
|
| 537 |
+
f"{res.outcome} own_buildings={own_b} "
|
| 538 |
+
f"then_progress={getattr(res.signals, 'then_progress', {})}"
|
| 539 |
+
)
|
| 540 |
+
|
| 541 |
+
|
| 542 |
+
@pytest.mark.parametrize("seed", SEEDS)
|
| 543 |
+
def test_hard_seeds_produce_distinct_starts(seed):
|
| 544 |
+
"""Hard's two spawn_point groups must round-robin per seed."""
|
| 545 |
+
pytest.importorskip("openra_train")
|
| 546 |
+
from openra_bench.eval_core import run_level
|
| 547 |
+
|
| 548 |
+
c = compile_level(load_pack(PACK_PATH), "hard")
|
| 549 |
+
res = run_level(c, _stall_policy, seed=seed)
|
| 550 |
+
assert res.outcome == "loss" # stall must lose
|
| 551 |
+
sp = {a.spawn_point for a in c.scenario.actors if a.owner == "agent"}
|
| 552 |
+
assert len(sp) >= 2
|
|
@@ -0,0 +1,413 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""lh-multi-checkpoint-5-plus pack — no-cheat validation on Rust.
|
| 2 |
+
|
| 3 |
+
Group G long-horizon: 5+ ordered checkpoints chained via the Wave-2
|
| 4 |
+
`then:` happened-before composite. The chain MIXES checkpoint kinds:
|
| 5 |
+
buildings, quantitative production, episode-aggregate kills, and
|
| 6 |
+
terminal destruction — so the ordering is genuinely load-bearing.
|
| 7 |
+
|
| 8 |
+
Medium chain (the headline 5-phase cell):
|
| 9 |
+
PHASE 1: has_building: proc
|
| 10 |
+
PHASE 2: has_building: weap
|
| 11 |
+
PHASE 3: unit_type_count_gte: {type: 2tnk, n: 2}
|
| 12 |
+
PHASE 4: units_killed_gte: 3
|
| 13 |
+
PHASE 5: enemy_key_buildings_destroyed: {types: [fact]}
|
| 14 |
+
|
| 15 |
+
Hard adds a 6th regression checkpoint (≥4 tanks after kills) and 2
|
| 16 |
+
spawn_point groups (NORTH/SOUTH base round-robined by seed).
|
| 17 |
+
|
| 18 |
+
Bar (per CLAUDE.md): the intended phased policy WINS on every
|
| 19 |
+
(level, seed); stall / skip-phase-3 / skip-phase-4 / pre-build all
|
| 20 |
+
LOSE on every seed. The `then:` latch is load-bearing — a "rush
|
| 21 |
+
the fact and kill on the way" play scores early kills but the latch
|
| 22 |
+
does not credit them to clause-4 until proc + weap + tanks have
|
| 23 |
+
latched in order.
|
| 24 |
+
"""
|
| 25 |
+
|
| 26 |
+
from __future__ import annotations
|
| 27 |
+
|
| 28 |
+
import pytest
|
| 29 |
+
|
| 30 |
+
pytest.importorskip("openra_train", reason="Rust env wheel not installed")
|
| 31 |
+
|
| 32 |
+
pytest.importorskip("openra_rl_training", reason="Rust env wheel not installed")
|
| 33 |
+
from openra_bench.eval_core import run_level
|
| 34 |
+
from openra_bench.scenarios import load_pack
|
| 35 |
+
from openra_bench.scenarios.loader import PACKS_DIR, compile_level
|
| 36 |
+
|
| 37 |
+
PACK = PACKS_DIR / "lh-multi-checkpoint-5-plus.yaml"
|
| 38 |
+
LEVELS = ("easy", "medium", "hard")
|
| 39 |
+
SEEDS = (1, 2, 3, 4)
|
| 40 |
+
|
| 41 |
+
# Per-level chain length (the n in `then.clauses`).
|
| 42 |
+
_CHAIN_LEN = {"easy": 3, "medium": 5, "hard": 6}
|
| 43 |
+
|
| 44 |
+
# Per-level required tank army size for the highest-N tank clause.
|
| 45 |
+
# easy has no tank-count clause; medium has n:2 only; hard has both
|
| 46 |
+
# n:2 (clause 3) and n:4 (clause 5) — the policy must produce ≥4.
|
| 47 |
+
_ARMY_TARGET = {"easy": 0, "medium": 2, "hard": 4}
|
| 48 |
+
|
| 49 |
+
|
| 50 |
+
# ── Policies ──────────────────────────────────────────────────────
|
| 51 |
+
|
| 52 |
+
|
| 53 |
+
def _stall_policy():
|
| 54 |
+
"""Do nothing — must LOSE on the clock on every level/seed."""
|
| 55 |
+
def pol(obs, Cmd):
|
| 56 |
+
return [Cmd.observe()]
|
| 57 |
+
return pol
|
| 58 |
+
|
| 59 |
+
|
| 60 |
+
def _enemy_fact_xy(level: str) -> tuple[int, int]:
|
| 61 |
+
"""Per-level enemy fact coords (lifted from the YAML so the
|
| 62 |
+
policies attack the right point on each tier)."""
|
| 63 |
+
return (115, 30) if level in ("easy", "medium") else (115, 20)
|
| 64 |
+
|
| 65 |
+
|
| 66 |
+
def _skip_to_attack_policy(level: str):
|
| 67 |
+
"""Rush the enemy fact immediately with the 4 starting tanks; no
|
| 68 |
+
proc, no weap, no chain. Must LOSE — the then-latch starts at
|
| 69 |
+
index 0 and `has_building:proc` is never observed-true, so even
|
| 70 |
+
a successful fact-kill does not advance the chain past clause 0."""
|
| 71 |
+
fx, fy = _enemy_fact_xy(level)
|
| 72 |
+
|
| 73 |
+
def pol(obs, Cmd):
|
| 74 |
+
units = obs.get("units_summary", []) or []
|
| 75 |
+
tnk = [u["id"] for u in units if u.get("type") == "2tnk"]
|
| 76 |
+
if tnk:
|
| 77 |
+
return [Cmd.attack_move(tnk, fx, fy)]
|
| 78 |
+
return [Cmd.observe()]
|
| 79 |
+
return pol
|
| 80 |
+
|
| 81 |
+
|
| 82 |
+
def _skip_phase_3_policy(level: str):
|
| 83 |
+
"""Build proc + weap (phases 1+2) then skip the army clause —
|
| 84 |
+
immediately attack the fact with starting tanks. On easy the
|
| 85 |
+
chain has no army clause so this should WIN; on medium/hard the
|
| 86 |
+
chain has a tank-count clause that, despite being satisfied at
|
| 87 |
+
t=0 by pre-placed tanks, only latches AFTER weap latches — so
|
| 88 |
+
this policy might still win medium (the latch advances P3→P4→P5
|
| 89 |
+
once weap lands and there are ≥2 tanks alive). To genuinely
|
| 90 |
+
skip phase 3 we must KILL one of our own tanks (or lose them in
|
| 91 |
+
combat) so unit_type_count_gte:{2tnk,n:2} is FALSE for medium,
|
| 92 |
+
or unit_type_count_gte:{2tnk,n:4} is FALSE for hard. The
|
| 93 |
+
easier teeth: this policy attacks the fact with 2 tanks held
|
| 94 |
+
back at base (less than n:4 on hard) — on hard it must LOSE."""
|
| 95 |
+
fx, fy = _enemy_fact_xy(level)
|
| 96 |
+
|
| 97 |
+
# Sticky milestone latches for the build-order phases.
|
| 98 |
+
state = {"built_proc": False, "built_weap": False}
|
| 99 |
+
|
| 100 |
+
def pol(obs, Cmd):
|
| 101 |
+
ob = obs.get("own_buildings", []) or []
|
| 102 |
+
own_b = {b["type"] for b in ob}
|
| 103 |
+
prod = obs.get("production", []) or []
|
| 104 |
+
units = obs.get("units_summary", []) or []
|
| 105 |
+
tnk = [u["id"] for u in units if u.get("type") == "2tnk"]
|
| 106 |
+
cmds = []
|
| 107 |
+
base = [b for b in ob if b["type"] == "fact"]
|
| 108 |
+
if "proc" in own_b:
|
| 109 |
+
state["built_proc"] = True
|
| 110 |
+
if "weap" in own_b:
|
| 111 |
+
state["built_weap"] = True
|
| 112 |
+
# PHASE 1 (proc).
|
| 113 |
+
if not state["built_proc"]:
|
| 114 |
+
if "proc" not in prod:
|
| 115 |
+
cmds.append(Cmd.build("proc"))
|
| 116 |
+
if base:
|
| 117 |
+
cmds.append(Cmd.place_building(
|
| 118 |
+
"proc", base[0]["cell_x"] + 6, base[0]["cell_y"] + 4
|
| 119 |
+
))
|
| 120 |
+
if not cmds:
|
| 121 |
+
cmds.append(Cmd.observe())
|
| 122 |
+
return cmds
|
| 123 |
+
# PHASE 2 (weap).
|
| 124 |
+
if not state["built_weap"]:
|
| 125 |
+
if "weap" not in prod:
|
| 126 |
+
cmds.append(Cmd.build("weap"))
|
| 127 |
+
if base:
|
| 128 |
+
cmds.append(Cmd.place_building(
|
| 129 |
+
"weap", base[0]["cell_x"] + 8, base[0]["cell_y"]
|
| 130 |
+
))
|
| 131 |
+
if not cmds:
|
| 132 |
+
cmds.append(Cmd.observe())
|
| 133 |
+
return cmds
|
| 134 |
+
# PHASES 3-5: skip the army-rebuild; attack with whatever
|
| 135 |
+
# tanks are still alive. On hard this is <4 tanks so clause
|
| 136 |
+
# 5 (unit_type_count_gte:{2tnk,n:4}) never latches.
|
| 137 |
+
if tnk:
|
| 138 |
+
cmds.append(Cmd.attack_move(tnk, fx, fy))
|
| 139 |
+
if not cmds:
|
| 140 |
+
cmds.append(Cmd.observe())
|
| 141 |
+
return cmds
|
| 142 |
+
return pol
|
| 143 |
+
|
| 144 |
+
|
| 145 |
+
def _skip_phase_4_policy(level: str):
|
| 146 |
+
"""Build proc + weap + (rebuild army to target) but NEVER engage
|
| 147 |
+
— kills stay at 0 so clause-4 (units_killed_gte:3) never latches
|
| 148 |
+
on medium/hard. The chain stalls at index 3 and the clock expires.
|
| 149 |
+
On easy there is no kill clause and no army clause, so this
|
| 150 |
+
policy reduces to "build proc+weap and never attack" — which
|
| 151 |
+
MUST also LOSE because the engage clause (P3 on easy) never
|
| 152 |
+
latches without an attack."""
|
| 153 |
+
target = _ARMY_TARGET[level]
|
| 154 |
+
|
| 155 |
+
def pol(obs, Cmd):
|
| 156 |
+
ob = obs.get("own_buildings", []) or []
|
| 157 |
+
own_b = {b["type"] for b in ob}
|
| 158 |
+
prod = obs.get("production", []) or []
|
| 159 |
+
units = obs.get("units_summary", []) or []
|
| 160 |
+
tnk = [u for u in units if u.get("type") == "2tnk"]
|
| 161 |
+
cmds = []
|
| 162 |
+
base = [b for b in ob if b["type"] == "fact"]
|
| 163 |
+
if "proc" not in own_b and "proc" not in prod:
|
| 164 |
+
cmds.append(Cmd.build("proc"))
|
| 165 |
+
if "proc" not in own_b and base:
|
| 166 |
+
cmds.append(Cmd.place_building(
|
| 167 |
+
"proc", base[0]["cell_x"] + 6, base[0]["cell_y"] + 4
|
| 168 |
+
))
|
| 169 |
+
if "proc" in own_b and "weap" not in own_b and "weap" not in prod:
|
| 170 |
+
cmds.append(Cmd.build("weap"))
|
| 171 |
+
if "proc" in own_b and "weap" not in own_b and base:
|
| 172 |
+
cmds.append(Cmd.place_building(
|
| 173 |
+
"weap", base[0]["cell_x"] + 8, base[0]["cell_y"]
|
| 174 |
+
))
|
| 175 |
+
# Rebuild army if needed but DO NOT engage.
|
| 176 |
+
if (
|
| 177 |
+
"weap" in own_b
|
| 178 |
+
and target > 0
|
| 179 |
+
and len(tnk) < target
|
| 180 |
+
and "2tnk" not in prod
|
| 181 |
+
):
|
| 182 |
+
cmds.append(Cmd.build("2tnk"))
|
| 183 |
+
if not cmds:
|
| 184 |
+
cmds.append(Cmd.observe())
|
| 185 |
+
return cmds
|
| 186 |
+
return pol
|
| 187 |
+
|
| 188 |
+
|
| 189 |
+
def _intended_chain_policy(level: str):
|
| 190 |
+
"""The intended 5+ checkpoint chain (sticky milestones so a built-
|
| 191 |
+
then-destroyed proc doesn't reset the policy). Build proc + weap
|
| 192 |
+
on the macro thread WHILE concurrently driving the pre-placed
|
| 193 |
+
tanks east to engage and raze the enemy fact — the `then:` latch
|
| 194 |
+
only advances clause-by-clause once each predicate is observed-
|
| 195 |
+
true in order, so doing the work in parallel doesn't skip ahead.
|
| 196 |
+
|
| 197 |
+
Must WIN on every (level, seed)."""
|
| 198 |
+
fx, fy = _enemy_fact_xy(level)
|
| 199 |
+
issued_attack = {"yes": False}
|
| 200 |
+
|
| 201 |
+
def pol(obs, Cmd):
|
| 202 |
+
ob = obs.get("own_buildings", []) or []
|
| 203 |
+
own_b = {b["type"] for b in ob}
|
| 204 |
+
prod = obs.get("production", []) or []
|
| 205 |
+
units = obs.get("units_summary", []) or []
|
| 206 |
+
tnk = [u for u in units if u.get("type") == "2tnk"]
|
| 207 |
+
base = [b for b in ob if b["type"] == "fact"]
|
| 208 |
+
|
| 209 |
+
cmds = []
|
| 210 |
+
# Macro thread: drive proc → weap to completion, then
|
| 211 |
+
# optionally keep producing tanks (helps hard's n:4 clause
|
| 212 |
+
# if any pre-placed tank dies mid-engagement).
|
| 213 |
+
if "proc" not in own_b:
|
| 214 |
+
if "proc" not in prod:
|
| 215 |
+
cmds.append(Cmd.build("proc"))
|
| 216 |
+
if base:
|
| 217 |
+
cmds.append(Cmd.place_building(
|
| 218 |
+
"proc", base[0]["cell_x"] + 6, base[0]["cell_y"] + 4
|
| 219 |
+
))
|
| 220 |
+
elif "weap" not in own_b:
|
| 221 |
+
if "weap" not in prod:
|
| 222 |
+
cmds.append(Cmd.build("weap"))
|
| 223 |
+
if base:
|
| 224 |
+
cmds.append(Cmd.place_building(
|
| 225 |
+
"weap", base[0]["cell_x"] + 8, base[0]["cell_y"]
|
| 226 |
+
))
|
| 227 |
+
else:
|
| 228 |
+
# Both up — keep producing tanks (cheap insurance for the
|
| 229 |
+
# n:4 hard clause after attrition; auto-placed by the
|
| 230 |
+
# engine since `weap` knows its own queue).
|
| 231 |
+
if "2tnk" not in prod and len(tnk) < 8:
|
| 232 |
+
cmds.append(Cmd.build("2tnk"))
|
| 233 |
+
|
| 234 |
+
# Combat thread: ALWAYS attack-move every tank east toward
|
| 235 |
+
# the enemy fact. Re-issuing each turn keeps freshly-built
|
| 236 |
+
# tanks pointed at the objective.
|
| 237 |
+
if tnk:
|
| 238 |
+
cmds.append(Cmd.attack_move([u["id"] for u in tnk], fx, fy))
|
| 239 |
+
issued_attack["yes"] = True
|
| 240 |
+
|
| 241 |
+
if not cmds:
|
| 242 |
+
cmds.append(Cmd.observe())
|
| 243 |
+
return cmds
|
| 244 |
+
return pol
|
| 245 |
+
|
| 246 |
+
|
| 247 |
+
# ── Pack-shape tests (cheap; do not run the engine) ──────────────
|
| 248 |
+
|
| 249 |
+
|
| 250 |
+
def test_pack_compiles_with_three_levels():
|
| 251 |
+
pack = load_pack(PACK)
|
| 252 |
+
assert pack.meta.id == "lh-multi-checkpoint-5-plus"
|
| 253 |
+
assert pack.meta.capability == "action"
|
| 254 |
+
assert set(pack.levels) == {"easy", "medium", "hard"}
|
| 255 |
+
|
| 256 |
+
|
| 257 |
+
def test_meta_benchmark_anchor_set():
|
| 258 |
+
"""Required: PlanBench long-sequencing + PERT critical path
|
| 259 |
+
anchors, per the wave-5 spec."""
|
| 260 |
+
pack = load_pack(PACK)
|
| 261 |
+
anchors = pack.meta.benchmark_anchor or []
|
| 262 |
+
assert any("PlanBench" in a for a in anchors), anchors
|
| 263 |
+
assert any("PERT" in a for a in anchors), anchors
|
| 264 |
+
assert any("SOP" in a for a in anchors), anchors
|
| 265 |
+
|
| 266 |
+
|
| 267 |
+
def test_hard_tier_has_seed_driven_spawn_groups():
|
| 268 |
+
"""Hard must define ≥2 agent spawn_point groups (UPGRADED
|
| 269 |
+
contract in tests/test_hard_tier.py)."""
|
| 270 |
+
c = compile_level(load_pack(PACK), "hard")
|
| 271 |
+
sp = {a.spawn_point for a in c.scenario.actors if a.owner == "agent"}
|
| 272 |
+
assert len(sp) >= 2, f"hard needs ≥2 spawn groups, got {sp}"
|
| 273 |
+
|
| 274 |
+
|
| 275 |
+
def test_every_level_has_fail_condition():
|
| 276 |
+
"""No silent draws — every level must be able to emit a LOSS."""
|
| 277 |
+
pack = load_pack(PACK)
|
| 278 |
+
for lvl in LEVELS:
|
| 279 |
+
c = compile_level(pack, lvl)
|
| 280 |
+
assert c.fail_condition is not None, f"{lvl} missing fail_condition"
|
| 281 |
+
|
| 282 |
+
|
| 283 |
+
def test_then_chain_lengths_per_level():
|
| 284 |
+
"""easy=3, medium=5, hard=6 phases (the headline difficulty
|
| 285 |
+
axis is the chain length)."""
|
| 286 |
+
for lvl in LEVELS:
|
| 287 |
+
c = compile_level(load_pack(PACK), lvl)
|
| 288 |
+
win = c.win_condition.model_dump(exclude_none=True)
|
| 289 |
+
inner = win.get("all_of") or []
|
| 290 |
+
assert any("then" in cl for cl in inner), (
|
| 291 |
+
f"{lvl} win missing then-chain: {win}"
|
| 292 |
+
)
|
| 293 |
+
for cl in inner:
|
| 294 |
+
if "then" in cl:
|
| 295 |
+
clauses = (cl["then"] or {}).get("clauses") or []
|
| 296 |
+
assert len(clauses) == _CHAIN_LEN[lvl], (
|
| 297 |
+
f"{lvl} chain must have {_CHAIN_LEN[lvl]} "
|
| 298 |
+
f"clauses; got {len(clauses)}"
|
| 299 |
+
)
|
| 300 |
+
|
| 301 |
+
|
| 302 |
+
def test_tick_budget_aligned_with_max_turns():
|
| 303 |
+
"""within_ticks must be reachable inside max_turns. Engine
|
| 304 |
+
advances ~90 ticks/turn → reachable max = 93 + 90·(N-1)."""
|
| 305 |
+
pack = load_pack(PACK)
|
| 306 |
+
for lvl in LEVELS:
|
| 307 |
+
level_def = pack.levels[lvl]
|
| 308 |
+
max_turns = level_def.max_turns
|
| 309 |
+
reachable = 93 + 90 * (max_turns - 1)
|
| 310 |
+
win = compile_level(pack, lvl).win_condition.model_dump(exclude_none=True)
|
| 311 |
+
|
| 312 |
+
def _collect(node, key, out):
|
| 313 |
+
if isinstance(node, dict):
|
| 314 |
+
if key in node:
|
| 315 |
+
out.append(node[key])
|
| 316 |
+
for v in node.values():
|
| 317 |
+
_collect(v, key, out)
|
| 318 |
+
elif isinstance(node, list):
|
| 319 |
+
for v in node:
|
| 320 |
+
_collect(v, key, out)
|
| 321 |
+
wts = []
|
| 322 |
+
_collect(win, "within_ticks", wts)
|
| 323 |
+
assert wts, f"{lvl} has no within_ticks leaf"
|
| 324 |
+
for wt in wts:
|
| 325 |
+
assert wt <= reachable, (
|
| 326 |
+
f"{lvl} within_ticks={wt} > reachable={reachable} "
|
| 327 |
+
f"(max_turns={max_turns}) — deadline never bites"
|
| 328 |
+
)
|
| 329 |
+
|
| 330 |
+
|
| 331 |
+
# ── Engine-bound tests (parameterised over seeds 1..4) ────────────
|
| 332 |
+
|
| 333 |
+
|
| 334 |
+
@pytest.mark.parametrize("seed", SEEDS)
|
| 335 |
+
@pytest.mark.parametrize("level", LEVELS)
|
| 336 |
+
def test_intended_chain_policy_wins(level, seed):
|
| 337 |
+
"""The intended N+ checkpoint chain must WIN on every (level, seed)."""
|
| 338 |
+
c = compile_level(load_pack(PACK), level)
|
| 339 |
+
res = run_level(c, _intended_chain_policy(level), seed=seed)
|
| 340 |
+
tp = getattr(res.signals, "then_progress", {}) or {}
|
| 341 |
+
assert res.outcome == "win", (
|
| 342 |
+
f"intended chain must WIN on {level} s={seed}; "
|
| 343 |
+
f"got {res.outcome} (then_progress={tp}, "
|
| 344 |
+
f"kills={res.signals.units_killed}, "
|
| 345 |
+
f"own_buildings={res.signals.own_building_types})"
|
| 346 |
+
)
|
| 347 |
+
|
| 348 |
+
|
| 349 |
+
@pytest.mark.parametrize("seed", SEEDS)
|
| 350 |
+
@pytest.mark.parametrize("level", LEVELS)
|
| 351 |
+
def test_stall_loses(level, seed):
|
| 352 |
+
"""A do-nothing policy must LOSE on every (level, seed)."""
|
| 353 |
+
c = compile_level(load_pack(PACK), level)
|
| 354 |
+
res = run_level(c, _stall_policy(), seed=seed)
|
| 355 |
+
assert res.outcome == "loss", (
|
| 356 |
+
f"stall must LOSE on {level} s={seed}; got {res.outcome}"
|
| 357 |
+
)
|
| 358 |
+
|
| 359 |
+
|
| 360 |
+
@pytest.mark.parametrize("seed", SEEDS)
|
| 361 |
+
@pytest.mark.parametrize("level", LEVELS)
|
| 362 |
+
def test_skip_to_attack_loses(level, seed):
|
| 363 |
+
"""Rush the fact with starting tanks (no proc, no weap, no
|
| 364 |
+
chain). Must LOSE on every (level, seed) — clause-1 (proc) is
|
| 365 |
+
never observed-true so the then-latch never advances past 0."""
|
| 366 |
+
c = compile_level(load_pack(PACK), level)
|
| 367 |
+
res = run_level(c, _skip_to_attack_policy(level), seed=seed)
|
| 368 |
+
tp = getattr(res.signals, "then_progress", {}) or {}
|
| 369 |
+
assert res.outcome == "loss", (
|
| 370 |
+
f"skip-to-attack must LOSE on {level} s={seed}; "
|
| 371 |
+
f"got {res.outcome} then_progress={tp}"
|
| 372 |
+
)
|
| 373 |
+
|
| 374 |
+
|
| 375 |
+
@pytest.mark.parametrize("seed", SEEDS)
|
| 376 |
+
def test_skip_phase_4_loses_medium(seed):
|
| 377 |
+
"""On medium, building proc+weap but NEVER engaging means kills
|
| 378 |
+
stay at 0 — clause-4 (units_killed_gte:3) never latches and the
|
| 379 |
+
chain stalls at index 3."""
|
| 380 |
+
c = compile_level(load_pack(PACK), "medium")
|
| 381 |
+
res = run_level(c, _skip_phase_4_policy("medium"), seed=seed)
|
| 382 |
+
tp = getattr(res.signals, "then_progress", {}) or {}
|
| 383 |
+
assert res.outcome == "loss", (
|
| 384 |
+
f"skip-phase-4 must LOSE on medium s={seed}; "
|
| 385 |
+
f"got {res.outcome} then_progress={tp} "
|
| 386 |
+
f"kills={res.signals.units_killed}"
|
| 387 |
+
)
|
| 388 |
+
|
| 389 |
+
|
| 390 |
+
@pytest.mark.parametrize("seed", SEEDS)
|
| 391 |
+
def test_skip_phase_4_loses_hard(seed):
|
| 392 |
+
"""Same on hard — no engagement means no kills, no fact-kill,
|
| 393 |
+
no chain advance past clause 3."""
|
| 394 |
+
c = compile_level(load_pack(PACK), "hard")
|
| 395 |
+
res = run_level(c, _skip_phase_4_policy("hard"), seed=seed)
|
| 396 |
+
tp = getattr(res.signals, "then_progress", {}) or {}
|
| 397 |
+
assert res.outcome == "loss", (
|
| 398 |
+
f"skip-phase-4 must LOSE on hard s={seed}; "
|
| 399 |
+
f"got {res.outcome} then_progress={tp} "
|
| 400 |
+
f"kills={res.signals.units_killed}"
|
| 401 |
+
)
|
| 402 |
+
|
| 403 |
+
|
| 404 |
+
@pytest.mark.parametrize("seed", SEEDS)
|
| 405 |
+
def test_hard_seeds_produce_distinct_starts(seed):
|
| 406 |
+
"""Hard's two spawn_point groups must round-robin: different
|
| 407 |
+
seeds place the agent base at a different (x,y) set. Smoke-tests
|
| 408 |
+
the spawn-variation contract enforced by test_hard_tier.py."""
|
| 409 |
+
c = compile_level(load_pack(PACK), "hard")
|
| 410 |
+
res = run_level(c, _stall_policy(), seed=seed)
|
| 411 |
+
assert res.outcome == "loss"
|
| 412 |
+
sp = {a.spawn_point for a in c.scenario.actors if a.owner == "agent"}
|
| 413 |
+
assert len(sp) >= 2
|
|
@@ -0,0 +1,418 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""lh-scout-react-counter pack — full no-cheat validation on Rust.
|
| 2 |
+
|
| 3 |
+
Group-G long-horizon reasoning pack: observation-driven 3-phase
|
| 4 |
+
chain. The agent has a small fixed force (1× jeep scout + 3× 2tnk)
|
| 5 |
+
at the west; it must (1) SCOUT — drive the jeep east and register
|
| 6 |
+
≥2 enemy buildings (the construction yard AND a paired barracks),
|
| 7 |
+
(2) REACT — engage with the tanks to destroy ≥3 enemy units, then
|
| 8 |
+
(3) COUNTER — raze the enemy construction yard. Wave-2 `then:`
|
| 9 |
+
happened-before composite enforces the order; `within_ticks: 6300`
|
| 10 |
+
is the deadline; hard adds `units_lost_lte: 3` as an attrition
|
| 11 |
+
tooth and a third enemy posture pocket at MID-y.
|
| 12 |
+
|
| 13 |
+
Bar (per CLAUDE.md): the intended scout-react-counter policy WINS
|
| 14 |
+
on every (level, seed); every stall / skip-scout-attack-blind /
|
| 15 |
+
scout-only-no-attack policy LOSES on every level + seed. The pack
|
| 16 |
+
sits alongside `lh-tech-pivot-attack` (which tests build-the-
|
| 17 |
+
counter as the middle phase) and `mid-tech-switch-on-scout` (the
|
| 18 |
+
2-phase scout-then-counter sibling): this one isolates the
|
| 19 |
+
observation-action-counter loop on a FIXED FORCE (cash 1500 is too
|
| 20 |
+
lean to fund a new production line — the capability is the
|
| 21 |
+
reactive 3-phase chain, not the build-out).
|
| 22 |
+
"""
|
| 23 |
+
|
| 24 |
+
from __future__ import annotations
|
| 25 |
+
|
| 26 |
+
import pytest
|
| 27 |
+
|
| 28 |
+
pytest.importorskip("openra_train", reason="Rust env wheel not installed")
|
| 29 |
+
|
| 30 |
+
pytest.importorskip("openra_rl_training", reason="Rust env wheel not installed")
|
| 31 |
+
from openra_bench.eval_core import run_level
|
| 32 |
+
from openra_bench.scenarios import load_pack
|
| 33 |
+
from openra_bench.scenarios.loader import PACKS_DIR, compile_level
|
| 34 |
+
|
| 35 |
+
PACK = PACKS_DIR / "lh-scout-react-counter.yaml"
|
| 36 |
+
LEVELS = ("easy", "medium", "hard")
|
| 37 |
+
SEEDS = (1, 2, 3, 4)
|
| 38 |
+
|
| 39 |
+
|
| 40 |
+
# ── Policies ──────────────────────────────────────────────────────
|
| 41 |
+
|
| 42 |
+
|
| 43 |
+
def _stall_policy():
|
| 44 |
+
"""Do nothing — must LOSE on after_ticks every level/seed."""
|
| 45 |
+
def pol(obs, Cmd):
|
| 46 |
+
return [Cmd.observe()]
|
| 47 |
+
return pol
|
| 48 |
+
|
| 49 |
+
|
| 50 |
+
def _skip_scout_attack_blind_policy():
|
| 51 |
+
"""Rush the tanks straight at the nearest plausible enemy fact
|
| 52 |
+
coordinate without driving the scout jeep east first. The
|
| 53 |
+
discovery latch (≥2 buildings) is the load-bearing first
|
| 54 |
+
clause: a tank rush that smashes the fact MAY register a
|
| 55 |
+
single building (the fact under attack) but the paired
|
| 56 |
+
barracks (tent) sits OFF the assault axis (y-offset +4),
|
| 57 |
+
outside the tank's sight while it shells the fact — so
|
| 58 |
+
buildings_discovered stays at 1 and the then-chain stalls at
|
| 59 |
+
clause 1. Even if a stray sight tick caught the tent, the
|
| 60 |
+
chain still requires ≥3 unit kills BEFORE raze (the tank
|
| 61 |
+
rush kills the cluster en route only after the fact-attack
|
| 62 |
+
has begun, and the then-chain credit order matters)."""
|
| 63 |
+
def pol(obs, Cmd):
|
| 64 |
+
units = obs.get("units_summary", []) or []
|
| 65 |
+
own_b = obs.get("own_buildings", []) or []
|
| 66 |
+
# Pick target y by agent fact latitude (NORTH spawn → y=5,
|
| 67 |
+
# SOUTH spawn → y=38, easy single-spawn → y=18).
|
| 68 |
+
agent_fact = next(
|
| 69 |
+
(b for b in own_b if b.get("type") == "fact"), None,
|
| 70 |
+
)
|
| 71 |
+
if agent_fact is None:
|
| 72 |
+
return [Cmd.observe()]
|
| 73 |
+
ay = agent_fact["cell_y"]
|
| 74 |
+
if ay < 14:
|
| 75 |
+
target_y = 5
|
| 76 |
+
elif ay > 24:
|
| 77 |
+
target_y = 38
|
| 78 |
+
else:
|
| 79 |
+
target_y = 18
|
| 80 |
+
tnk_ids = [u["id"] for u in units if u.get("type") == "2tnk"]
|
| 81 |
+
if not tnk_ids:
|
| 82 |
+
return [Cmd.observe()]
|
| 83 |
+
# Attack-move directly at the enemy fact coordinate. No
|
| 84 |
+
# scout move issued.
|
| 85 |
+
return [Cmd.attack_move(tnk_ids, 122, target_y)]
|
| 86 |
+
return pol
|
| 87 |
+
|
| 88 |
+
|
| 89 |
+
def _scout_only_no_attack_policy():
|
| 90 |
+
"""Drive the scout jeep east to register the enemy buildings
|
| 91 |
+
(satisfies clause 1) but never engage with the tanks — the
|
| 92 |
+
then-chain stalls at clause 2 (units_killed_gte:3), and the
|
| 93 |
+
clock eventually expires. Asserts that scouting alone is not
|
| 94 |
+
sufficient — the REACT phase is load-bearing."""
|
| 95 |
+
state = {"went_outpost": False, "ticks_since_outpost": 0,
|
| 96 |
+
"went_corner": False}
|
| 97 |
+
|
| 98 |
+
def pol(obs, Cmd):
|
| 99 |
+
units = obs.get("units_summary", []) or []
|
| 100 |
+
own_b = obs.get("own_buildings", []) or []
|
| 101 |
+
jeep = next((u for u in units if u.get("type") == "jeep"), None)
|
| 102 |
+
agent_fact = next(
|
| 103 |
+
(b for b in own_b if b.get("type") == "fact"), None,
|
| 104 |
+
)
|
| 105 |
+
if jeep is None or agent_fact is None:
|
| 106 |
+
return [Cmd.observe()]
|
| 107 |
+
ay = agent_fact["cell_y"]
|
| 108 |
+
# Drive the jeep through the off-axis outpost (registers
|
| 109 |
+
# the tent), then on to the near fact's y-band (registers
|
| 110 |
+
# the fact). Both buildings get discovered, but the tanks
|
| 111 |
+
# never engage so units_killed stays 0 and the chain
|
| 112 |
+
# stalls at clause 2.
|
| 113 |
+
if ay < 14:
|
| 114 |
+
fact_y, outpost = 5, (60, 20)
|
| 115 |
+
elif ay > 24:
|
| 116 |
+
fact_y, outpost = 38, (60, 20)
|
| 117 |
+
else:
|
| 118 |
+
fact_y, outpost = 18, (60, 36)
|
| 119 |
+
if not state["went_outpost"]:
|
| 120 |
+
state["went_outpost"] = True
|
| 121 |
+
return [Cmd.move_units([jeep["id"]], *outpost)]
|
| 122 |
+
state["ticks_since_outpost"] += 1
|
| 123 |
+
if state["ticks_since_outpost"] >= 8 and not state["went_corner"]:
|
| 124 |
+
state["went_corner"] = True
|
| 125 |
+
return [Cmd.move_units([jeep["id"]], 120, fact_y)]
|
| 126 |
+
return [Cmd.observe()]
|
| 127 |
+
return pol
|
| 128 |
+
|
| 129 |
+
|
| 130 |
+
def _intended_scout_react_counter_policy(easy_mode: bool):
|
| 131 |
+
"""The intended capability play. Drives the chain end-to-end:
|
| 132 |
+
1. Jeep moves to the forward outpost (mid-x off-axis tent),
|
| 133 |
+
then continues to the near corner — registers BOTH the
|
| 134 |
+
outpost tent AND the corner fact (≥2 buildings
|
| 135 |
+
discovered) ⇒ then[0] latches.
|
| 136 |
+
2. Tanks attack-move at the near fact — the defending
|
| 137 |
+
stance:0 cluster sits at (118, fact_y) right on the
|
| 138 |
+
assault axis, so the tanks engage and kill ≥3 ⇒ then[1]
|
| 139 |
+
latches.
|
| 140 |
+
3. Tanks continue past the cluster and shell the fact ⇒
|
| 141 |
+
then[2] (and the top-level enemy_key_buildings_destroyed)
|
| 142 |
+
both latch ⇒ WIN inside 6300 ticks.
|
| 143 |
+
"""
|
| 144 |
+
state = {"outpost_dispatched": False, "scout_dispatched": False,
|
| 145 |
+
"attack_dispatched": False, "scout_ticks": 0}
|
| 146 |
+
|
| 147 |
+
def pol(obs, Cmd):
|
| 148 |
+
units = obs.get("units_summary", []) or []
|
| 149 |
+
own_b = obs.get("own_buildings", []) or []
|
| 150 |
+
agent_fact = next(
|
| 151 |
+
(b for b in own_b if b.get("type") == "fact"), None,
|
| 152 |
+
)
|
| 153 |
+
jeep = next((u for u in units if u.get("type") == "jeep"), None)
|
| 154 |
+
tnk_ids = [u["id"] for u in units if u.get("type") == "2tnk"]
|
| 155 |
+
cmds = []
|
| 156 |
+
|
| 157 |
+
# Pick near corner by agent latitude, and the outpost
|
| 158 |
+
# coordinate that matches the level's layout.
|
| 159 |
+
if easy_mode:
|
| 160 |
+
fact_y = 18
|
| 161 |
+
outpost = (60, 36) # easy outpost far south
|
| 162 |
+
elif agent_fact and agent_fact["cell_y"] < 18:
|
| 163 |
+
fact_y = 5
|
| 164 |
+
outpost = (60, 20) # medium/hard shared mid outpost
|
| 165 |
+
else:
|
| 166 |
+
fact_y = 38
|
| 167 |
+
outpost = (60, 20)
|
| 168 |
+
|
| 169 |
+
# 1a. SCOUT — first dispatch jeep to the off-axis outpost.
|
| 170 |
+
if jeep is not None and not state["outpost_dispatched"]:
|
| 171 |
+
cmds.append(Cmd.move_units([jeep["id"]], *outpost))
|
| 172 |
+
state["outpost_dispatched"] = True
|
| 173 |
+
# 1b. Then redirect jeep east to the near fact's y-band
|
| 174 |
+
# (give it a few turns to traverse to the outpost
|
| 175 |
+
# and surface the tent in enemy_buildings).
|
| 176 |
+
elif (jeep is not None
|
| 177 |
+
and state["outpost_dispatched"]
|
| 178 |
+
and not state["scout_dispatched"]):
|
| 179 |
+
state["scout_ticks"] += 1
|
| 180 |
+
if state["scout_ticks"] >= 8:
|
| 181 |
+
cmds.append(Cmd.move_units([jeep["id"]], 120, fact_y))
|
| 182 |
+
state["scout_dispatched"] = True
|
| 183 |
+
|
| 184 |
+
# 2 + 3. REACT + COUNTER — dispatch the tanks early; one
|
| 185 |
+
# attack_move covers both phases (they engage the
|
| 186 |
+
# stance:0 cluster en route, then continue to raze
|
| 187 |
+
# the fact). The then-chain's first clause may not
|
| 188 |
+
# yet be latched when the tanks start moving — that's
|
| 189 |
+
# fine, the greedy advance picks up clauses in order
|
| 190 |
+
# once each becomes true, and the rush takes long
|
| 191 |
+
# enough that the jeep's scout completes first.
|
| 192 |
+
if tnk_ids and not state["attack_dispatched"]:
|
| 193 |
+
cmds.append(Cmd.attack_move(tnk_ids, 122, fact_y))
|
| 194 |
+
state["attack_dispatched"] = True
|
| 195 |
+
|
| 196 |
+
if not cmds:
|
| 197 |
+
cmds.append(Cmd.observe())
|
| 198 |
+
return cmds
|
| 199 |
+
return pol
|
| 200 |
+
|
| 201 |
+
|
| 202 |
+
# ── Pack-shape tests (cheap; do not run the engine) ──────────────
|
| 203 |
+
|
| 204 |
+
|
| 205 |
+
def test_pack_compiles_with_three_levels():
|
| 206 |
+
pack = load_pack(PACK)
|
| 207 |
+
assert pack.meta.id == "lh-scout-react-counter"
|
| 208 |
+
assert pack.meta.capability == "reasoning"
|
| 209 |
+
assert set(pack.levels) == {"easy", "medium", "hard"}
|
| 210 |
+
|
| 211 |
+
|
| 212 |
+
def test_meta_benchmark_anchor_set():
|
| 213 |
+
"""Required by the seed taxonomy: SC2 reactive macro / CICERO
|
| 214 |
+
info-loop / PlanBench replanning / threat-intel anchors."""
|
| 215 |
+
pack = load_pack(PACK)
|
| 216 |
+
anchors = pack.meta.benchmark_anchor or []
|
| 217 |
+
assert any("SC2" in a for a in anchors), anchors
|
| 218 |
+
assert any("CICERO" in a for a in anchors), anchors
|
| 219 |
+
assert any("PlanBench" in a for a in anchors), anchors
|
| 220 |
+
assert any("threat-intel" in a for a in anchors), anchors
|
| 221 |
+
|
| 222 |
+
|
| 223 |
+
def test_hard_tier_has_seed_driven_spawn_groups():
|
| 224 |
+
"""Hard must define ≥2 agent spawn_point groups so seed varies
|
| 225 |
+
the start base (binding contract from tests/test_hard_tier.py)."""
|
| 226 |
+
c = compile_level(load_pack(PACK), "hard")
|
| 227 |
+
sp = {a.spawn_point for a in c.scenario.actors if a.owner == "agent"}
|
| 228 |
+
assert len(sp) >= 2, f"hard needs ≥2 spawn groups, got {sp}"
|
| 229 |
+
|
| 230 |
+
|
| 231 |
+
def test_every_level_has_fail_condition():
|
| 232 |
+
"""No silent draws — every level must be able to emit a LOSS."""
|
| 233 |
+
pack = load_pack(PACK)
|
| 234 |
+
for lvl in LEVELS:
|
| 235 |
+
c = compile_level(pack, lvl)
|
| 236 |
+
assert c.fail_condition is not None, f"{lvl} missing fail_condition"
|
| 237 |
+
|
| 238 |
+
|
| 239 |
+
def test_then_composite_used_in_win():
|
| 240 |
+
"""The 3-phase scout-react-counter chain must be wired through
|
| 241 |
+
to the compiled win condition (the whole point of the pack)."""
|
| 242 |
+
for lvl in LEVELS:
|
| 243 |
+
c = compile_level(load_pack(PACK), lvl)
|
| 244 |
+
win = c.win_condition.model_dump(exclude_none=True)
|
| 245 |
+
ao = win.get("all_of") or []
|
| 246 |
+
then_branches = [b for b in ao if "then" in b]
|
| 247 |
+
assert len(then_branches) >= 1, f"{lvl} missing then-chain: {win}"
|
| 248 |
+
clauses = then_branches[0]["then"]["clauses"]
|
| 249 |
+
assert len(clauses) == 3, (
|
| 250 |
+
f"{lvl} then-chain must be 3 clauses (scout, react, "
|
| 251 |
+
f"counter), got {len(clauses)}"
|
| 252 |
+
)
|
| 253 |
+
# 1. scout latch — ≥2 buildings discovered (NOT ≥1; the
|
| 254 |
+
# spec makes scouting load-bearing by requiring a
|
| 255 |
+
# second building, defeating the rush-the-fact play).
|
| 256 |
+
assert clauses[0].get("buildings_discovered_gte") == 2, clauses[0]
|
| 257 |
+
# 2. react — kill at least 3 enemy units.
|
| 258 |
+
assert clauses[1].get("units_killed_gte") == 3, clauses[1]
|
| 259 |
+
# 3. counter — raze the enemy fact.
|
| 260 |
+
assert "enemy_key_buildings_destroyed" in clauses[2], clauses[2]
|
| 261 |
+
|
| 262 |
+
|
| 263 |
+
def test_tick_budget_aligned_with_max_turns():
|
| 264 |
+
"""within_ticks must be reachable inside max_turns. Engine
|
| 265 |
+
advances ~90 ticks/turn → reachable max = 93 + 90·(N-1)."""
|
| 266 |
+
pack = load_pack(PACK)
|
| 267 |
+
for lvl in LEVELS:
|
| 268 |
+
level_def = pack.levels[lvl]
|
| 269 |
+
max_turns = level_def.max_turns
|
| 270 |
+
reachable = 93 + 90 * (max_turns - 1)
|
| 271 |
+
win = compile_level(pack, lvl).win_condition.model_dump(
|
| 272 |
+
exclude_none=True,
|
| 273 |
+
)
|
| 274 |
+
|
| 275 |
+
def _collect(node, key, out):
|
| 276 |
+
if isinstance(node, dict):
|
| 277 |
+
if key in node:
|
| 278 |
+
out.append(node[key])
|
| 279 |
+
for v in node.values():
|
| 280 |
+
_collect(v, key, out)
|
| 281 |
+
elif isinstance(node, list):
|
| 282 |
+
for v in node:
|
| 283 |
+
_collect(v, key, out)
|
| 284 |
+
|
| 285 |
+
wts = []
|
| 286 |
+
_collect(win, "within_ticks", wts)
|
| 287 |
+
assert wts, f"{lvl} has no within_ticks leaf (no clock teeth)"
|
| 288 |
+
for wt in wts:
|
| 289 |
+
assert wt <= reachable, (
|
| 290 |
+
f"{lvl} within_ticks={wt} > reachable={reachable} "
|
| 291 |
+
f"(max_turns={max_turns}) — deadline never bites ⇒ draw"
|
| 292 |
+
)
|
| 293 |
+
|
| 294 |
+
|
| 295 |
+
def test_tools_list_matches_spec():
|
| 296 |
+
"""Tools per spec: [observe, build, place_building, move_units,
|
| 297 |
+
attack_unit, attack_move, stop]."""
|
| 298 |
+
pack = load_pack(PACK)
|
| 299 |
+
expected = {"observe", "build", "place_building", "move_units",
|
| 300 |
+
"attack_unit", "attack_move", "stop"}
|
| 301 |
+
tools = pack.base.get("tools") if isinstance(pack.base, dict) \
|
| 302 |
+
else pack.base.tools
|
| 303 |
+
assert set(tools) == expected, tools
|
| 304 |
+
|
| 305 |
+
|
| 306 |
+
def test_starting_cash_is_lean():
|
| 307 |
+
"""Spec calls for starting_cash: 1500 across all levels — the
|
| 308 |
+
capability is the reactive 3-phase chain on the FIXED force,
|
| 309 |
+
not a build-out funded from a generous war chest."""
|
| 310 |
+
pack = load_pack(PACK)
|
| 311 |
+
for lvl in LEVELS:
|
| 312 |
+
assert pack.levels[lvl].starting_cash == 1500, lvl
|
| 313 |
+
|
| 314 |
+
|
| 315 |
+
def test_hard_has_attrition_cap():
|
| 316 |
+
"""Hard adds units_lost_lte:3 as the +1 controlled axis (one
|
| 317 |
+
new variable per tier per the curation contract)."""
|
| 318 |
+
c = compile_level(load_pack(PACK), "hard")
|
| 319 |
+
win = c.win_condition.model_dump(exclude_none=True)
|
| 320 |
+
|
| 321 |
+
def _has(node, key):
|
| 322 |
+
if isinstance(node, dict):
|
| 323 |
+
if key in node:
|
| 324 |
+
return True
|
| 325 |
+
return any(_has(v, key) for v in node.values())
|
| 326 |
+
if isinstance(node, list):
|
| 327 |
+
return any(_has(v, key) for v in node)
|
| 328 |
+
return False
|
| 329 |
+
|
| 330 |
+
assert _has(win, "units_lost_lte"), (
|
| 331 |
+
"hard must include units_lost_lte tooth"
|
| 332 |
+
)
|
| 333 |
+
|
| 334 |
+
|
| 335 |
+
# ── Engine-bound tests (parameterised over seeds 1..4) ────────────
|
| 336 |
+
|
| 337 |
+
|
| 338 |
+
@pytest.mark.parametrize("seed", SEEDS)
|
| 339 |
+
@pytest.mark.parametrize("level", LEVELS)
|
| 340 |
+
def test_intended_scout_react_counter_wins(level, seed):
|
| 341 |
+
"""The intended scout → react → counter capability play must
|
| 342 |
+
WIN on every (level, seed). This is the load-bearing test
|
| 343 |
+
that the pack is solvable inside the budget by the advertised
|
| 344 |
+
capability across all hard-tier spawn variants."""
|
| 345 |
+
c = compile_level(load_pack(PACK), level)
|
| 346 |
+
res = run_level(
|
| 347 |
+
c, _intended_scout_react_counter_policy(easy_mode=(level == "easy")),
|
| 348 |
+
seed=seed,
|
| 349 |
+
)
|
| 350 |
+
assert res.outcome == "win", (
|
| 351 |
+
f"intended scout-react-counter must WIN on {level} s={seed}; "
|
| 352 |
+
f"got {res.outcome} (kills={res.signals.units_killed}, "
|
| 353 |
+
f"bds={len(res.signals.enemy_buildings_seen_ids)}, "
|
| 354 |
+
f"lost={res.signals.units_lost}, "
|
| 355 |
+
f"then_progress={getattr(res.signals, 'then_progress', {})})"
|
| 356 |
+
)
|
| 357 |
+
|
| 358 |
+
|
| 359 |
+
@pytest.mark.parametrize("seed", SEEDS)
|
| 360 |
+
@pytest.mark.parametrize("level", LEVELS)
|
| 361 |
+
def test_stall_loses(level, seed):
|
| 362 |
+
"""A do-nothing policy must LOSE (no win, no draw) on every
|
| 363 |
+
(level, seed). The fail_condition's after_ticks clause bites
|
| 364 |
+
at the turn budget; never a draw."""
|
| 365 |
+
c = compile_level(load_pack(PACK), level)
|
| 366 |
+
res = run_level(c, _stall_policy(), seed=seed)
|
| 367 |
+
assert res.outcome == "loss", (
|
| 368 |
+
f"stall must LOSE on {level} s={seed}; got {res.outcome}"
|
| 369 |
+
)
|
| 370 |
+
|
| 371 |
+
|
| 372 |
+
@pytest.mark.parametrize("seed", SEEDS)
|
| 373 |
+
@pytest.mark.parametrize("level", LEVELS)
|
| 374 |
+
def test_skip_scout_attack_blind_loses(level, seed):
|
| 375 |
+
"""A blind tank-rush at the fact (no scout first) must LOSE
|
| 376 |
+
on every (level, seed). The then-chain requires ≥2 buildings
|
| 377 |
+
discovered FIRST; a tank rush only registers the fact under
|
| 378 |
+
attack (1 building) and the chain stalls at clause 1 — the
|
| 379 |
+
fact may eventually fall but the chain credit was never
|
| 380 |
+
earned in order, so within_ticks expires without a WIN. This
|
| 381 |
+
is the load-bearing 'scout is mandatory' tooth."""
|
| 382 |
+
c = compile_level(load_pack(PACK), level)
|
| 383 |
+
res = run_level(c, _skip_scout_attack_blind_policy(), seed=seed)
|
| 384 |
+
assert res.outcome == "loss", (
|
| 385 |
+
f"skip-scout-attack-blind must LOSE on {level} s={seed}; "
|
| 386 |
+
f"got {res.outcome} (kills={res.signals.units_killed}, "
|
| 387 |
+
f"bds={len(res.signals.enemy_buildings_seen_ids)}, "
|
| 388 |
+
f"then_progress={getattr(res.signals, 'then_progress', {})})"
|
| 389 |
+
)
|
| 390 |
+
|
| 391 |
+
|
| 392 |
+
@pytest.mark.parametrize("seed", SEEDS)
|
| 393 |
+
@pytest.mark.parametrize("level", LEVELS)
|
| 394 |
+
def test_scout_only_no_attack_loses(level, seed):
|
| 395 |
+
"""A scout-only policy (jeep registers the buildings but the
|
| 396 |
+
tanks never engage) must LOSE on every (level, seed). The
|
| 397 |
+
then-chain stalls at clause 2 (units_killed_gte:3) — the
|
| 398 |
+
REACT phase is load-bearing. The clock eventually expires."""
|
| 399 |
+
c = compile_level(load_pack(PACK), level)
|
| 400 |
+
res = run_level(c, _scout_only_no_attack_policy(), seed=seed)
|
| 401 |
+
assert res.outcome == "loss", (
|
| 402 |
+
f"scout-only-no-attack must LOSE on {level} s={seed}; "
|
| 403 |
+
f"got {res.outcome} (kills={res.signals.units_killed}, "
|
| 404 |
+
f"bds={len(res.signals.enemy_buildings_seen_ids)}, "
|
| 405 |
+
f"then_progress={getattr(res.signals, 'then_progress', {})})"
|
| 406 |
+
)
|
| 407 |
+
|
| 408 |
+
|
| 409 |
+
@pytest.mark.parametrize("seed", SEEDS)
|
| 410 |
+
def test_hard_seeds_produce_distinct_starts(seed):
|
| 411 |
+
"""Hard's two spawn_point groups must actually round-robin —
|
| 412 |
+
different seeds must place the agent base at a different (x,y)
|
| 413 |
+
set. Smoke-tests the spawn-variation contract."""
|
| 414 |
+
c = compile_level(load_pack(PACK), "hard")
|
| 415 |
+
res = run_level(c, _stall_policy(), seed=seed)
|
| 416 |
+
assert res.outcome == "loss" # stall must lose
|
| 417 |
+
sp = {a.spawn_point for a in c.scenario.actors if a.owner == "agent"}
|
| 418 |
+
assert len(sp) >= 2
|