Spaces:
Running
Running
feat(scenario): def-reinforce-the-breach — reactive defense, shift the reserve to the breached lane (military reserve commitment)
Browse filesACTION pack: a two-lane defence with a mobile reserve held at the
centre. Mid-episode a heavy breach wave is injected on the south lane
via scheduled_events; the agent must read which lane was breached and
shift the reserve forward into it. Stall and brute LOSE; reinforcing
the wrong (un-breached) lane LOSES on medium/hard; only shifting the
reserve into the breached lane WINS. The win carries an after_ticks
gate so it cannot latch before the breach develops. Hard defines two
seed-driven reserve spawn_point columns.
openra_bench/scenarios/packs/def-reinforce-the-breach.yaml
ADDED
|
@@ -0,0 +1,331 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# def-reinforce-the-breach — ACTION, reactive defense: shift the
|
| 2 |
+
# mobile reserve to the lane that gets breached mid-episode
|
| 3 |
+
# (Wave-12).
|
| 4 |
+
#
|
| 5 |
+
# The capability under test is REACTIVE RESERVE COMMITMENT. The
|
| 6 |
+
# agent holds a multi-lane defence: a north lane and a south lane,
|
| 7 |
+
# each with a light fixed garrison, plus a mobile armoured reserve
|
| 8 |
+
# held UNCOMMITTED at the centre. At t=0 both lanes face only a
|
| 9 |
+
# light probe. Mid-episode a HEAVY breach wave is injected on ONE
|
| 10 |
+
# lane (the south lane) via a `scheduled_events: spawn_actors` hook
|
| 11 |
+
# — the lane is breached. The light garrison there cannot hold the
|
| 12 |
+
# breach alone; the agent must REACT, read which lane was breached,
|
| 13 |
+
# and shift the mobile reserve FORWARD into that lane to reinforce
|
| 14 |
+
# it before the line collapses.
|
| 15 |
+
#
|
| 16 |
+
# Real-world anchors:
|
| 17 |
+
# • Military "reinforce the breach" doctrine / reserve commitment:
|
| 18 |
+
# the general reserve is held uncommitted until the enemy's main
|
| 19 |
+
# effort localises; the instant a sector is breached the reserve
|
| 20 |
+
# is committed FORWARD to that sector to restore the line.
|
| 21 |
+
# • Incident surge response: an on-call response team is staged
|
| 22 |
+
# centrally; when one site escalates, the surge capacity is
|
| 23 |
+
# committed to THAT site — not held back, not sent to a quiet
|
| 24 |
+
# site.
|
| 25 |
+
# • SC2 reactive defence: hold an army at a central position, then
|
| 26 |
+
# react to the opponent's attack by reinforcing the lane that is
|
| 27 |
+
# actually being hit.
|
| 28 |
+
#
|
| 29 |
+
# Mechanism (the load-bearing design):
|
| 30 |
+
# • TWO lanes (north y≈12 / south y≈28), each with a 2-tank fixed
|
| 31 |
+
# garrison. A 4-tank mobile reserve is held at the CENTRE
|
| 32 |
+
# (x≈44, y=20) — equidistant from both lanes.
|
| 33 |
+
# • At t=0 both lanes face only a light 3×e1 probe. The breach
|
| 34 |
+
# wave (a heavier e1 wave) is injected on the SOUTH lane at a
|
| 35 |
+
# scheduled tick via `scheduled_events: spawn_actors`.
|
| 36 |
+
# • The enemy uses the `hunt` bot so each enemy unit advances on
|
| 37 |
+
# and engages the NEAREST agent unit — the two lanes are
|
| 38 |
+
# genuinely INDEPENDENT fights (a `rusher` bot would pool every
|
| 39 |
+
# band onto the agent centroid and collapse the multi-lane
|
| 40 |
+
# test; CLAUDE.md / def-pre-position-mobile-reserve).
|
| 41 |
+
# • The win is a FORWARD-ZONE clause: `units_in_region_gte` at the
|
| 42 |
+
# SOUTH lane's intercept zone, requiring N tanks there. The
|
| 43 |
+
# 2-tank south garrison alone cannot fill the zone — the
|
| 44 |
+
# reserve must reinforce it. A reserve held at the centre, or
|
| 45 |
+
# sent to the un-breached NORTH lane, never fills the south
|
| 46 |
+
# zone ⇒ LOSS. Plus an `own_units_gte` survival clause: a
|
| 47 |
+
# breach left un-reinforced overruns the south garrison ⇒ the
|
| 48 |
+
# survival clause busts too.
|
| 49 |
+
#
|
| 50 |
+
# Discrimination (four-policy bar — scripted, no model needed):
|
| 51 |
+
# • stall (observe-only): the reserve sits at the centre; the
|
| 52 |
+
# forward-zone clause is never satisfied AND the un-reinforced
|
| 53 |
+
# south garrison is overrun by the breach wave ⇒ LOSS (the
|
| 54 |
+
# `after_ticks` deadline is a backstop — no draw degeneracy).
|
| 55 |
+
# • reinforce-the-wrong-lane (commit the reserve to the NORTH
|
| 56 |
+
# lane): the south forward zone is left short ⇒ the
|
| 57 |
+
# `units_in_region_gte` clause fails ⇒ LOSS (medium/hard, where
|
| 58 |
+
# the zone needs the reserve's tanks; on easy the bar is looser
|
| 59 |
+
# — see the level notes).
|
| 60 |
+
# • brute (one attack_move straight east, ignore the lanes): the
|
| 61 |
+
# reserve drives off the breached lane's intercept zone ⇒ the
|
| 62 |
+
# forward-zone clause fails ⇒ LOSS.
|
| 63 |
+
# • intended reinforce-the-breach (read the mid-episode breach,
|
| 64 |
+
# shift the reserve FORWARD into the SOUTH lane's intercept
|
| 65 |
+
# zone): the forward-zone clause is satisfied AND the
|
| 66 |
+
# reinforced line holds ⇒ WIN.
|
| 67 |
+
#
|
| 68 |
+
# Engine facts used (CLAUDE.md / verified against the installed
|
| 69 |
+
# openra_train wheel, 2026-05-20):
|
| 70 |
+
# • `scheduled_events: spawn_actors` injects fresh-id actors at a
|
| 71 |
+
# scheduled tick (Wave-9 feature; test_scheduled_events). The
|
| 72 |
+
# breach wave is the only way to make ONE lane heavier
|
| 73 |
+
# MID-episode — a t=0 placement cannot exercise "react to a
|
| 74 |
+
# breach that develops".
|
| 75 |
+
# • `hunt` bot: each enemy unit advances on and attacks the
|
| 76 |
+
# NEAREST agent unit, so the two lanes are independent fights;
|
| 77 |
+
# a far-off breach wave still advances (a `stance:3` actor only
|
| 78 |
+
# advances toward a VISIBLE enemy and would stall in fog).
|
| 79 |
+
# • `units_in_region_gte: {x, y, radius, n}` — the load-bearing
|
| 80 |
+
# forward-zone clause: it is pure geometry and does not depend
|
| 81 |
+
# on combat resolution, so the discrimination is deterministic.
|
| 82 |
+
# • Tick alignment (~90 ticks/turn): within_ticks ≤ 93+90·
|
| 83 |
+
# (max_turns−1); fail after_ticks = within_ticks+1 ⇒ a
|
| 84 |
+
# non-finisher is a real reachable LOSS, never a DRAW. The win
|
| 85 |
+
# also carries an `after_ticks: 720` GATE — the win cannot
|
| 86 |
+
# latch until the breach wave has developed (it spawns at tick
|
| 87 |
+
# 450 and is fully in play by ~720), so a reserve that rushes
|
| 88 |
+
# the south zone BEFORE the breach cannot win prematurely. The
|
| 89 |
+
# win is purely positional (no conquest clause) and the far
|
| 90 |
+
# enemy `fact` is never destroyed, so the `after_ticks` win
|
| 91 |
+
# gate is safe from the engine's enemy-elimination auto-done
|
| 92 |
+
# (CLAUDE.md after_ticks/Conquest footgun does not apply).
|
| 93 |
+
# • A far inert enemy `fact` marker keeps the engine from
|
| 94 |
+
# auto-`done`ing on enemy-elimination before the win/fail
|
| 95 |
+
# evaluator runs (CLAUDE.md auto-done footgun).
|
| 96 |
+
# • Hard tier: two agent spawn_point groups round-robin the
|
| 97 |
+
# mobile reserve's start COLUMN (x=40 vs x=50 — both remain
|
| 98 |
+
# equidistant from the two lanes) so a memorised reserve cell
|
| 99 |
+
# cannot generalise; the centre-then-react DOCTRINE does. The
|
| 100 |
+
# garrisons and the breach wave (no spawn_point) place every
|
| 101 |
+
# seed.
|
| 102 |
+
#
|
| 103 |
+
# Validate (no model / no network):
|
| 104 |
+
# python3 -m pytest tests/test_def_reinforce_the_breach.py -q
|
| 105 |
+
|
| 106 |
+
meta:
|
| 107 |
+
id: def-reinforce-the-breach
|
| 108 |
+
title: 'Reactive Defense — Reinforce the Breached Lane'
|
| 109 |
+
capability: action
|
| 110 |
+
real_world_meaning: >
|
| 111 |
+
A multi-lane defence with a mobile armoured reserve held
|
| 112 |
+
uncommitted at the centre. At first both lanes face only a light
|
| 113 |
+
probe, but mid-episode one lane is BREACHED by a heavier wave.
|
| 114 |
+
The light garrison there cannot hold the breach alone; the agent
|
| 115 |
+
must react — read which lane was breached — and shift the mobile
|
| 116 |
+
reserve FORWARD into that lane to reinforce it before the line
|
| 117 |
+
collapses. Holding the reserve at the centre, or committing it to
|
| 118 |
+
the quiet lane, lets the breach overrun the line. The pack tests
|
| 119 |
+
reactive reserve commitment: identify the developing breach and
|
| 120 |
+
commit the reserve to the sector that is actually being hit.
|
| 121 |
+
robotics_analogue: >
|
| 122 |
+
Incident surge response: an on-call response team is staged
|
| 123 |
+
centrally, equidistant from candidate sites. When one site
|
| 124 |
+
escalates into a breach, the surge capacity must be committed to
|
| 125 |
+
THAT site — promptly and in force. Holding the reserve back or
|
| 126 |
+
dispatching it to a quiet site both fail the response: the skill
|
| 127 |
+
is reading where the main effort has localised and committing
|
| 128 |
+
the reserve there.
|
| 129 |
+
benchmark_anchor:
|
| 130 |
+
- "military reserve commitment"
|
| 131 |
+
- "reinforce the breach doctrine"
|
| 132 |
+
- "incident surge response"
|
| 133 |
+
author: openra-bench
|
| 134 |
+
|
| 135 |
+
base_map: rush-hour-arena
|
| 136 |
+
|
| 137 |
+
base:
|
| 138 |
+
agent: {faction: allies, cash: 0}
|
| 139 |
+
# `hunt`: each enemy unit advances on and engages the NEAREST
|
| 140 |
+
# agent unit, so the north and south lanes are genuinely
|
| 141 |
+
# independent fights (a `rusher` bot pools every band onto the
|
| 142 |
+
# agent centroid and collapses the multi-lane test).
|
| 143 |
+
enemy: {faction: soviet, cash: 0, bot_type: hunt}
|
| 144 |
+
tools: [observe, move_units, attack_unit, attack_move, stop]
|
| 145 |
+
planning: true
|
| 146 |
+
termination: {max_ticks: 8000}
|
| 147 |
+
actors: []
|
| 148 |
+
|
| 149 |
+
levels:
|
| 150 |
+
# ── EASY ────────────────────────────────────────────────────────
|
| 151 |
+
# The bare reactive-reserve skill: REACT AT ALL. Two lanes, a
|
| 152 |
+
# 4-tank reserve at the centre. The south lane is breached at tick
|
| 153 |
+
# 450 by an 8×e1 wave. The forward-zone bar is n=3 — the 2-tank
|
| 154 |
+
# south garrison plus ONE reinforcing reserve tank fills it. The
|
| 155 |
+
# discrimination at this tier is stall (reserve never moves ⇒ zone
|
| 156 |
+
# unfilled + garrison overrun ⇒ LOSS) vs reinforce (move the
|
| 157 |
+
# reserve south ⇒ WIN). Picking the WRONG lane is punished at
|
| 158 |
+
# medium; easy only tests that the agent reacts.
|
| 159 |
+
easy:
|
| 160 |
+
description: >
|
| 161 |
+
You hold a two-lane defence — a NORTH lane (y≈12) and a SOUTH
|
| 162 |
+
lane (y≈28), each with a 2-tank garrison — plus a 4-tank
|
| 163 |
+
mobile reserve held at the CENTRE (x≈44, y=20). At first both
|
| 164 |
+
lanes face only a light probe. Mid-episode the SOUTH lane is
|
| 165 |
+
BREACHED by a heavier wave. React: shift the mobile reserve
|
| 166 |
+
FORWARD into the SOUTH lane to reinforce it — at least 3 tanks
|
| 167 |
+
must be within radius 12 of the south intercept point
|
| 168 |
+
(62, 28) at win time, you must keep at least 4 tanks alive,
|
| 169 |
+
and finish before tick 2400. Holding the reserve at the centre
|
| 170 |
+
LOSES — the breach overruns the un-reinforced south garrison.
|
| 171 |
+
Stalling LOSES.
|
| 172 |
+
overrides:
|
| 173 |
+
actors:
|
| 174 |
+
- {type: fact, owner: agent, position: [10, 20]}
|
| 175 |
+
# NORTH lane garrison.
|
| 176 |
+
- {type: 2tnk, owner: agent, position: [30, 11], stance: 2}
|
| 177 |
+
- {type: 2tnk, owner: agent, position: [30, 13], stance: 2}
|
| 178 |
+
# SOUTH lane garrison.
|
| 179 |
+
- {type: 2tnk, owner: agent, position: [30, 27], stance: 2}
|
| 180 |
+
- {type: 2tnk, owner: agent, position: [30, 29], stance: 2}
|
| 181 |
+
# Mobile reserve at the CENTRE — equidistant from both lanes.
|
| 182 |
+
- {type: 2tnk, owner: agent, position: [44, 19], stance: 2}
|
| 183 |
+
- {type: 2tnk, owner: agent, position: [44, 20], stance: 2}
|
| 184 |
+
- {type: 2tnk, owner: agent, position: [44, 21], stance: 2}
|
| 185 |
+
- {type: 2tnk, owner: agent, position: [45, 20], stance: 2}
|
| 186 |
+
# Initial light probes — one per lane.
|
| 187 |
+
- {type: e1, owner: enemy, position: [78, 12], stance: 3, count: 3}
|
| 188 |
+
- {type: e1, owner: enemy, position: [78, 28], stance: 3, count: 3}
|
| 189 |
+
# Far inert enemy marker — anti-DRAW.
|
| 190 |
+
- {type: fact, owner: enemy, position: [120, 20]}
|
| 191 |
+
# Breach wave — heavier wave injected on the SOUTH lane.
|
| 192 |
+
scheduled_events:
|
| 193 |
+
- tick: 450
|
| 194 |
+
type: spawn_actors
|
| 195 |
+
actors:
|
| 196 |
+
- {type: e1, owner: enemy, position: [78, 28], stance: 3, count: 8}
|
| 197 |
+
win_condition:
|
| 198 |
+
all_of:
|
| 199 |
+
# `after_ticks` gate: the win cannot latch until the breach
|
| 200 |
+
# wave has developed (it spawns at tick 450 and is fully in
|
| 201 |
+
# play by ~720) — so a reserve that rushes the zone before
|
| 202 |
+
# the breach cannot win prematurely.
|
| 203 |
+
- {after_ticks: 720}
|
| 204 |
+
- {units_in_region_gte: {x: 62, y: 28, radius: 12, n: 3}}
|
| 205 |
+
- {own_units_gte: 4}
|
| 206 |
+
- {within_ticks: 2400}
|
| 207 |
+
fail_condition:
|
| 208 |
+
any_of:
|
| 209 |
+
- {after_ticks: 2401}
|
| 210 |
+
- {not: {own_units_gte: 1}}
|
| 211 |
+
max_turns: 40
|
| 212 |
+
|
| 213 |
+
# ── MEDIUM ──────────────────────────────────────────────────────
|
| 214 |
+
# +1 controlled variable: REACT TO THE RIGHT LANE. The forward-
|
| 215 |
+
# zone bar rises to n=4 — the 2-tank south garrison can no longer
|
| 216 |
+
# fill it alone, and a reserve committed to the NORTH lane leaves
|
| 217 |
+
# the south zone two tanks short. Now reinforcing the WRONG lane
|
| 218 |
+
# LOSES, not just stalling. Same breach (south, tick 450, 8×e1),
|
| 219 |
+
# same clock.
|
| 220 |
+
medium:
|
| 221 |
+
description: >
|
| 222 |
+
You hold a two-lane defence — a NORTH lane and a SOUTH lane,
|
| 223 |
+
each with a 2-tank garrison — plus a 4-tank mobile reserve at
|
| 224 |
+
the CENTRE. Mid-episode one lane is BREACHED by a heavier
|
| 225 |
+
wave. React to the lane that is actually hit: shift the mobile
|
| 226 |
+
reserve FORWARD into the BREACHED lane — at least 4 tanks must
|
| 227 |
+
be within radius 12 of that lane's intercept point at win
|
| 228 |
+
time, you must keep at least 4 tanks alive, and finish before
|
| 229 |
+
tick 2400. Holding the reserve at the centre LOSES; committing
|
| 230 |
+
it to the QUIET lane LOSES (the breached lane is left two
|
| 231 |
+
tanks short); stalling LOSES.
|
| 232 |
+
overrides:
|
| 233 |
+
actors:
|
| 234 |
+
- {type: fact, owner: agent, position: [10, 20]}
|
| 235 |
+
- {type: 2tnk, owner: agent, position: [30, 11], stance: 2}
|
| 236 |
+
- {type: 2tnk, owner: agent, position: [30, 13], stance: 2}
|
| 237 |
+
- {type: 2tnk, owner: agent, position: [30, 27], stance: 2}
|
| 238 |
+
- {type: 2tnk, owner: agent, position: [30, 29], stance: 2}
|
| 239 |
+
- {type: 2tnk, owner: agent, position: [44, 19], stance: 2}
|
| 240 |
+
- {type: 2tnk, owner: agent, position: [44, 20], stance: 2}
|
| 241 |
+
- {type: 2tnk, owner: agent, position: [44, 21], stance: 2}
|
| 242 |
+
- {type: 2tnk, owner: agent, position: [45, 20], stance: 2}
|
| 243 |
+
- {type: e1, owner: enemy, position: [78, 12], stance: 3, count: 3}
|
| 244 |
+
- {type: e1, owner: enemy, position: [78, 28], stance: 3, count: 3}
|
| 245 |
+
- {type: fact, owner: enemy, position: [120, 20]}
|
| 246 |
+
# Breach wave — SOUTH lane, tick 450.
|
| 247 |
+
scheduled_events:
|
| 248 |
+
- tick: 450
|
| 249 |
+
type: spawn_actors
|
| 250 |
+
actors:
|
| 251 |
+
- {type: e1, owner: enemy, position: [78, 28], stance: 3, count: 8}
|
| 252 |
+
win_condition:
|
| 253 |
+
all_of:
|
| 254 |
+
# `after_ticks` gate: the win cannot latch until the breach
|
| 255 |
+
# wave has developed (it spawns at tick 450 and is fully in
|
| 256 |
+
# play by ~720) — so a reserve that rushes the zone before
|
| 257 |
+
# the breach cannot win prematurely.
|
| 258 |
+
- {after_ticks: 720}
|
| 259 |
+
- {units_in_region_gte: {x: 62, y: 28, radius: 12, n: 4}}
|
| 260 |
+
- {own_units_gte: 4}
|
| 261 |
+
- {within_ticks: 2400}
|
| 262 |
+
fail_condition:
|
| 263 |
+
any_of:
|
| 264 |
+
- {after_ticks: 2401}
|
| 265 |
+
- {not: {own_units_gte: 1}}
|
| 266 |
+
max_turns: 40
|
| 267 |
+
|
| 268 |
+
# ── HARD ────────────────────────────────────────────────────────
|
| 269 |
+
# +2 controlled variables vs medium:
|
| 270 |
+
# 1. Tighter deadline (~1900 ticks instead of ~2400) — the
|
| 271 |
+
# reserve must be committed PROMPTLY; a dawdling shift misses
|
| 272 |
+
# the clock.
|
| 273 |
+
# 2. TWO agent spawn_point groups round-robin the mobile
|
| 274 |
+
# reserve's start COLUMN (x=40 vs x=50 — both equidistant
|
| 275 |
+
# from the two lanes) so a memorised reserve cell cannot
|
| 276 |
+
# generalise; the centre-then-react doctrine does. The
|
| 277 |
+
# garrisons and the breach wave (no spawn_point) place every
|
| 278 |
+
# seed.
|
| 279 |
+
hard:
|
| 280 |
+
description: >
|
| 281 |
+
You hold a two-lane defence — a NORTH lane and a SOUTH lane,
|
| 282 |
+
each with a 2-tank garrison — plus a 4-tank mobile reserve at
|
| 283 |
+
the CENTRE (its exact start column varies by seed). Mid-
|
| 284 |
+
episode one lane is BREACHED by a heavier wave. React
|
| 285 |
+
promptly: shift the mobile reserve FORWARD into the BREACHED
|
| 286 |
+
lane — at least 4 tanks must be within radius 12 of that
|
| 287 |
+
lane's intercept point at win time, you must keep at least 4
|
| 288 |
+
tanks alive, and finish before tick 1900 (a tight window).
|
| 289 |
+
Holding the reserve at the centre LOSES; committing it to the
|
| 290 |
+
QUIET lane LOSES; stalling LOSES.
|
| 291 |
+
overrides:
|
| 292 |
+
actors:
|
| 293 |
+
- {type: fact, owner: agent, position: [10, 20]}
|
| 294 |
+
- {type: 2tnk, owner: agent, position: [30, 11], stance: 2}
|
| 295 |
+
- {type: 2tnk, owner: agent, position: [30, 13], stance: 2}
|
| 296 |
+
- {type: 2tnk, owner: agent, position: [30, 27], stance: 2}
|
| 297 |
+
- {type: 2tnk, owner: agent, position: [30, 29], stance: 2}
|
| 298 |
+
# spawn_point 0 — reserve start column x=40.
|
| 299 |
+
- {type: 2tnk, owner: agent, position: [40, 19], stance: 2, spawn_point: 0}
|
| 300 |
+
- {type: 2tnk, owner: agent, position: [40, 20], stance: 2, spawn_point: 0}
|
| 301 |
+
- {type: 2tnk, owner: agent, position: [40, 21], stance: 2, spawn_point: 0}
|
| 302 |
+
- {type: 2tnk, owner: agent, position: [41, 20], stance: 2, spawn_point: 0}
|
| 303 |
+
# spawn_point 1 — reserve start column x=50.
|
| 304 |
+
- {type: 2tnk, owner: agent, position: [50, 19], stance: 2, spawn_point: 1}
|
| 305 |
+
- {type: 2tnk, owner: agent, position: [50, 20], stance: 2, spawn_point: 1}
|
| 306 |
+
- {type: 2tnk, owner: agent, position: [50, 21], stance: 2, spawn_point: 1}
|
| 307 |
+
- {type: 2tnk, owner: agent, position: [51, 20], stance: 2, spawn_point: 1}
|
| 308 |
+
- {type: e1, owner: enemy, position: [78, 12], stance: 3, count: 3}
|
| 309 |
+
- {type: e1, owner: enemy, position: [78, 28], stance: 3, count: 3}
|
| 310 |
+
- {type: fact, owner: enemy, position: [120, 20]}
|
| 311 |
+
# Breach wave — SOUTH lane, tick 450.
|
| 312 |
+
scheduled_events:
|
| 313 |
+
- tick: 450
|
| 314 |
+
type: spawn_actors
|
| 315 |
+
actors:
|
| 316 |
+
- {type: e1, owner: enemy, position: [78, 28], stance: 3, count: 8}
|
| 317 |
+
win_condition:
|
| 318 |
+
all_of:
|
| 319 |
+
# `after_ticks` gate: the win cannot latch until the breach
|
| 320 |
+
# wave has developed (it spawns at tick 450 and is fully in
|
| 321 |
+
# play by ~720) — so a reserve that rushes the zone before
|
| 322 |
+
# the breach cannot win prematurely.
|
| 323 |
+
- {after_ticks: 720}
|
| 324 |
+
- {units_in_region_gte: {x: 62, y: 28, radius: 12, n: 4}}
|
| 325 |
+
- {own_units_gte: 4}
|
| 326 |
+
- {within_ticks: 1900}
|
| 327 |
+
fail_condition:
|
| 328 |
+
any_of:
|
| 329 |
+
- {after_ticks: 1901}
|
| 330 |
+
- {not: {own_units_gte: 1}}
|
| 331 |
+
max_turns: 30
|
tests/test_def_reinforce_the_breach.py
ADDED
|
@@ -0,0 +1,327 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""def-reinforce-the-breach — ACTION capability validation.
|
| 2 |
+
|
| 3 |
+
Reactive reserve commitment: a two-lane defence with a mobile
|
| 4 |
+
reserve held at the centre. Mid-episode a HEAVY breach wave is
|
| 5 |
+
injected on the SOUTH lane via `scheduled_events: spawn_actors`.
|
| 6 |
+
The agent must read which lane was breached and shift the reserve
|
| 7 |
+
FORWARD into that lane's intercept zone.
|
| 8 |
+
|
| 9 |
+
Bar (CLAUDE.md "no defect, no cheat, no draw"):
|
| 10 |
+
|
| 11 |
+
* stall (observe-only) LOSES every tier / every hard seed — the
|
| 12 |
+
reserve sits at the centre; the forward-zone clause is never
|
| 13 |
+
satisfied and the un-reinforced south garrison is overrun.
|
| 14 |
+
* brute (one attack_move straight east) LOSES every tier / seed
|
| 15 |
+
— the reserve drives off the breached lane's intercept zone.
|
| 16 |
+
* reinforce-the-wrong-lane (commit the reserve to the NORTH
|
| 17 |
+
lane) LOSES on medium/hard — the south forward zone is left
|
| 18 |
+
two tanks short of the n=4 bar.
|
| 19 |
+
* intended reinforce-the-breach (shift the reserve into the
|
| 20 |
+
SOUTH lane's intercept zone) WINS every tier / every hard
|
| 21 |
+
seed.
|
| 22 |
+
* the win carries an `after_ticks: 720` gate so it cannot latch
|
| 23 |
+
before the breach (tick 450) has developed — no premature win.
|
| 24 |
+
* hard tier defines ≥2 agent spawn_point groups (reserve start
|
| 25 |
+
column x=40 / x=50) round-robined by seed.
|
| 26 |
+
"""
|
| 27 |
+
|
| 28 |
+
from __future__ import annotations
|
| 29 |
+
|
| 30 |
+
from pathlib import Path
|
| 31 |
+
|
| 32 |
+
import pytest
|
| 33 |
+
|
| 34 |
+
pytest.importorskip("openra_train", reason="Rust env wheel not installed")
|
| 35 |
+
pytest.importorskip("openra_rl_training", reason="Rust env wheel not installed")
|
| 36 |
+
|
| 37 |
+
from openra_bench.eval_core import run_level
|
| 38 |
+
from openra_bench.scenarios import load_pack
|
| 39 |
+
from openra_bench.scenarios.loader import PACKS_DIR, compile_level
|
| 40 |
+
from openra_bench.scenarios.win_conditions import WinContext, evaluate
|
| 41 |
+
|
| 42 |
+
PACK = PACKS_DIR / "def-reinforce-the-breach.yaml"
|
| 43 |
+
LEVELS = ("easy", "medium", "hard")
|
| 44 |
+
SEEDS = (1, 2, 3, 4)
|
| 45 |
+
|
| 46 |
+
|
| 47 |
+
# ── scripted policies ───────────────────────────────────────────────
|
| 48 |
+
|
| 49 |
+
|
| 50 |
+
def _tanks(rs):
|
| 51 |
+
return [u for u in (rs.get("units_summary") or []) if u.get("type") == "2tnk"]
|
| 52 |
+
|
| 53 |
+
|
| 54 |
+
def _reserve(own):
|
| 55 |
+
"""The centre reserve: tanks on the mid latitude, x ≥ 37."""
|
| 56 |
+
return [u for u in own if 17 <= u["cell_y"] <= 23 and u["cell_x"] >= 37]
|
| 57 |
+
|
| 58 |
+
|
| 59 |
+
def _stall(rs, C):
|
| 60 |
+
"""Observe-only — the reserve never moves; the forward-zone clause
|
| 61 |
+
is never satisfied and the south garrison is overrun → LOSS."""
|
| 62 |
+
return [C.observe()]
|
| 63 |
+
|
| 64 |
+
|
| 65 |
+
def _brute(rs, C):
|
| 66 |
+
"""One attack_move straight east — the reserve drives off the
|
| 67 |
+
breached lane's intercept zone → the forward-zone clause fails →
|
| 68 |
+
LOSS."""
|
| 69 |
+
own = _tanks(rs)
|
| 70 |
+
if not own:
|
| 71 |
+
return [C.observe()]
|
| 72 |
+
return [
|
| 73 |
+
C.attack_move([str(u["id"]) for u in own], target_x=110, target_y=own[0]["cell_y"])
|
| 74 |
+
]
|
| 75 |
+
|
| 76 |
+
|
| 77 |
+
def _make_reinforce(lane_y):
|
| 78 |
+
"""Shift the reserve forward into the lane at `lane_y` (28 = the
|
| 79 |
+
breached SOUTH lane; 12 = the un-breached NORTH lane). The
|
| 80 |
+
garrisons attack the nearest visible enemy."""
|
| 81 |
+
|
| 82 |
+
def p(rs, C):
|
| 83 |
+
own = _tanks(rs)
|
| 84 |
+
if not own:
|
| 85 |
+
return [C.observe()]
|
| 86 |
+
enemies = rs.get("enemy_summary") or []
|
| 87 |
+
reserve = _reserve(own)
|
| 88 |
+
cmds = []
|
| 89 |
+
for u in reserve:
|
| 90 |
+
if enemies and lane_y == 28:
|
| 91 |
+
t = min(
|
| 92 |
+
enemies,
|
| 93 |
+
key=lambda e: abs(e["cell_x"] - u["cell_x"])
|
| 94 |
+
+ abs(e["cell_y"] - u["cell_y"]),
|
| 95 |
+
)
|
| 96 |
+
cmds.append(C.attack_unit([str(u["id"])], str(t["id"])))
|
| 97 |
+
else:
|
| 98 |
+
cmds.append(
|
| 99 |
+
C.attack_move([str(u["id"])], target_x=58, target_y=lane_y)
|
| 100 |
+
)
|
| 101 |
+
for u in own:
|
| 102 |
+
if u not in reserve and enemies:
|
| 103 |
+
t = min(
|
| 104 |
+
enemies,
|
| 105 |
+
key=lambda e: abs(e["cell_x"] - u["cell_x"])
|
| 106 |
+
+ abs(e["cell_y"] - u["cell_y"]),
|
| 107 |
+
)
|
| 108 |
+
cmds.append(C.attack_unit([str(u["id"])], str(t["id"])))
|
| 109 |
+
return cmds or [C.observe()]
|
| 110 |
+
|
| 111 |
+
return p
|
| 112 |
+
|
| 113 |
+
|
| 114 |
+
# ── structural tests ────────────────────────────────────────────────
|
| 115 |
+
|
| 116 |
+
|
| 117 |
+
def test_pack_loads_and_meta_action():
|
| 118 |
+
pack = load_pack(PACK)
|
| 119 |
+
assert pack.meta.id == "def-reinforce-the-breach"
|
| 120 |
+
assert pack.meta.capability == "action"
|
| 121 |
+
assert pack.meta.real_world_meaning
|
| 122 |
+
assert pack.meta.robotics_analogue
|
| 123 |
+
anchors = " ".join(pack.meta.benchmark_anchor).lower()
|
| 124 |
+
assert "military reserve commitment" in anchors, anchors
|
| 125 |
+
assert "reinforce the breach doctrine" in anchors, anchors
|
| 126 |
+
assert "incident surge response" in anchors, anchors
|
| 127 |
+
|
| 128 |
+
|
| 129 |
+
def test_enemy_uses_hunt_bot():
|
| 130 |
+
pack = load_pack(PACK)
|
| 131 |
+
for lvl in LEVELS:
|
| 132 |
+
c = compile_level(pack, lvl)
|
| 133 |
+
enemy = c.scenario.enemy
|
| 134 |
+
bot = getattr(enemy, "bot_type", None) or getattr(enemy, "bot", None)
|
| 135 |
+
assert str(bot).lower() == "hunt", f"{lvl}: enemy bot must be 'hunt'; got {bot}"
|
| 136 |
+
|
| 137 |
+
|
| 138 |
+
def test_every_level_has_a_scheduled_breach_wave():
|
| 139 |
+
"""Every level injects the breach via scheduled_events:
|
| 140 |
+
spawn_actors — the breach must develop MID-episode."""
|
| 141 |
+
pack = load_pack(PACK)
|
| 142 |
+
for lvl in LEVELS:
|
| 143 |
+
c = compile_level(pack, lvl)
|
| 144 |
+
ev = getattr(c, "scheduled_events", None)
|
| 145 |
+
assert ev, f"{lvl}: must declare scheduled_events"
|
| 146 |
+
spawns = [e for e in ev if e.get("type") == "spawn_actors"]
|
| 147 |
+
assert spawns, f"{lvl}: must have a spawn_actors breach wave"
|
| 148 |
+
# The breach wave fires after t=0 (mid-episode).
|
| 149 |
+
for e in spawns:
|
| 150 |
+
assert int(e["tick"]) > 0, f"{lvl}: breach tick must be > 0"
|
| 151 |
+
|
| 152 |
+
|
| 153 |
+
def test_win_has_after_ticks_gate_before_the_breach_resolves():
|
| 154 |
+
"""The win carries an `after_ticks` gate so it cannot latch
|
| 155 |
+
before the breach wave (scheduled) has developed."""
|
| 156 |
+
pack = load_pack(PACK)
|
| 157 |
+
for lvl in LEVELS:
|
| 158 |
+
c = compile_level(pack, lvl)
|
| 159 |
+
win = c.win_condition.model_dump()["all_of"]
|
| 160 |
+
gate = next((int(x["after_ticks"]) for x in win if "after_ticks" in x), None)
|
| 161 |
+
assert gate is not None, f"{lvl}: win must carry an after_ticks gate"
|
| 162 |
+
breach_tick = min(
|
| 163 |
+
int(e["tick"])
|
| 164 |
+
for e in c.scheduled_events
|
| 165 |
+
if e.get("type") == "spawn_actors"
|
| 166 |
+
)
|
| 167 |
+
assert gate > breach_tick, (
|
| 168 |
+
f"{lvl}: win gate {gate} must be after the breach tick {breach_tick}"
|
| 169 |
+
)
|
| 170 |
+
|
| 171 |
+
|
| 172 |
+
def test_forward_zone_clause_is_load_bearing():
|
| 173 |
+
"""Every level's win requires a units_in_region_gte forward-zone
|
| 174 |
+
clause at the south lane intercept point."""
|
| 175 |
+
pack = load_pack(PACK)
|
| 176 |
+
for lvl in LEVELS:
|
| 177 |
+
c = compile_level(pack, lvl)
|
| 178 |
+
win = c.win_condition.model_dump()["all_of"]
|
| 179 |
+
region = next(
|
| 180 |
+
(x["units_in_region_gte"] for x in win if "units_in_region_gte" in x),
|
| 181 |
+
None,
|
| 182 |
+
)
|
| 183 |
+
assert region is not None, f"{lvl}: win needs a units_in_region_gte clause"
|
| 184 |
+
assert region["y"] == 28, f"{lvl}: forward zone must be the SOUTH lane"
|
| 185 |
+
|
| 186 |
+
|
| 187 |
+
def test_every_level_has_reachable_timeout_fail():
|
| 188 |
+
pack = load_pack(PACK)
|
| 189 |
+
for lvl in LEVELS:
|
| 190 |
+
L = pack.levels[lvl]
|
| 191 |
+
ceiling = 93 + 90 * (L.max_turns - 1)
|
| 192 |
+
wt = next(
|
| 193 |
+
int(c["within_ticks"])
|
| 194 |
+
for c in L.win_condition.model_dump()["all_of"]
|
| 195 |
+
if "within_ticks" in c
|
| 196 |
+
)
|
| 197 |
+
ft = next(
|
| 198 |
+
int(c["after_ticks"])
|
| 199 |
+
for c in L.fail_condition.model_dump()["any_of"]
|
| 200 |
+
if "after_ticks" in c
|
| 201 |
+
)
|
| 202 |
+
assert wt < ceiling, f"{lvl}: within_ticks {wt} >= ceiling {ceiling}"
|
| 203 |
+
assert ft <= ceiling, f"{lvl}: after_ticks {ft} > ceiling {ceiling}"
|
| 204 |
+
assert wt + 1 == ft, f"{lvl}: within/after mismatch {wt}/{ft}"
|
| 205 |
+
|
| 206 |
+
|
| 207 |
+
def test_every_level_has_a_fail_condition():
|
| 208 |
+
pack = load_pack(PACK)
|
| 209 |
+
for lvl in LEVELS:
|
| 210 |
+
c = compile_level(pack, lvl)
|
| 211 |
+
assert c.fail_condition is not None, f"{lvl} needs a fail_condition"
|
| 212 |
+
|
| 213 |
+
|
| 214 |
+
def test_hard_has_two_seed_driven_spawn_groups():
|
| 215 |
+
c = compile_level(load_pack(PACK), "hard")
|
| 216 |
+
sp = {
|
| 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 sp == {0, 1}, f"hard must define spawn_point groups {{0,1}}; got {sorted(sp)}"
|
| 222 |
+
|
| 223 |
+
|
| 224 |
+
def test_in_bounds_actors_on_every_level():
|
| 225 |
+
pack = load_pack(PACK)
|
| 226 |
+
for lvl in LEVELS:
|
| 227 |
+
c = compile_level(pack, lvl)
|
| 228 |
+
for a in c.scenario.actors:
|
| 229 |
+
x, y = a.position
|
| 230 |
+
assert 2 <= x <= 126 and 2 <= y <= 38, (
|
| 231 |
+
f"{lvl}: actor {a.type} at ({x},{y}) out of bounds"
|
| 232 |
+
)
|
| 233 |
+
|
| 234 |
+
|
| 235 |
+
# ── predicate-level (no engine) ─────────────────────────────────────
|
| 236 |
+
|
| 237 |
+
|
| 238 |
+
def _ctx(*, tick=1000, units_xy=()):
|
| 239 |
+
import types
|
| 240 |
+
|
| 241 |
+
sig = types.SimpleNamespace(
|
| 242 |
+
game_tick=tick,
|
| 243 |
+
units_killed=0,
|
| 244 |
+
units_lost=0,
|
| 245 |
+
own_buildings=[],
|
| 246 |
+
own_building_types=set(),
|
| 247 |
+
enemies_seen_ids=set(),
|
| 248 |
+
enemy_buildings_seen_ids=set(),
|
| 249 |
+
)
|
| 250 |
+
return WinContext(
|
| 251 |
+
signals=sig,
|
| 252 |
+
render_state={
|
| 253 |
+
"units_summary": [{"cell_x": x, "cell_y": y} for x, y in units_xy]
|
| 254 |
+
},
|
| 255 |
+
)
|
| 256 |
+
|
| 257 |
+
|
| 258 |
+
def test_predicates_enforce_forward_zone_and_gate():
|
| 259 |
+
pm = compile_level(load_pack(PACK), "medium")
|
| 260 |
+
# 4 tanks in the south zone, after the gate, in time → WIN
|
| 261 |
+
south4 = [(62, 28), (61, 27), (63, 29), (60, 28)]
|
| 262 |
+
assert evaluate(pm.win_condition, _ctx(tick=1000, units_xy=south4))
|
| 263 |
+
# Same 4 tanks but BEFORE the after_ticks gate → not win
|
| 264 |
+
assert not evaluate(pm.win_condition, _ctx(tick=300, units_xy=south4))
|
| 265 |
+
# Only 2 tanks in the south zone (wrong-lane) → not win (need 4)
|
| 266 |
+
assert not evaluate(pm.win_condition, _ctx(tick=1000, units_xy=south4[:2]))
|
| 267 |
+
# Past the deadline → fail
|
| 268 |
+
assert evaluate(pm.fail_condition, _ctx(tick=2402, units_xy=south4))
|
| 269 |
+
|
| 270 |
+
|
| 271 |
+
# ── engine-driven: every lazy/wrong policy LOSES, intended WINS ──────
|
| 272 |
+
|
| 273 |
+
|
| 274 |
+
@pytest.mark.parametrize("level", LEVELS)
|
| 275 |
+
@pytest.mark.parametrize("seed", SEEDS)
|
| 276 |
+
def test_stall_loses_every_tier_and_seed(level, seed):
|
| 277 |
+
c = compile_level(load_pack(PACK), level)
|
| 278 |
+
r = run_level(c, _stall, seed=seed)
|
| 279 |
+
assert r.outcome == "loss", (
|
| 280 |
+
f"{level}/seed{seed}: stall must LOSE; got {r.outcome} "
|
| 281 |
+
f"lost={r.signals.units_lost} tick={r.signals.game_tick}"
|
| 282 |
+
)
|
| 283 |
+
|
| 284 |
+
|
| 285 |
+
@pytest.mark.parametrize("level", LEVELS)
|
| 286 |
+
@pytest.mark.parametrize("seed", SEEDS)
|
| 287 |
+
def test_brute_loses_every_tier_and_seed(level, seed):
|
| 288 |
+
c = compile_level(load_pack(PACK), level)
|
| 289 |
+
r = run_level(c, _brute, seed=seed)
|
| 290 |
+
assert r.outcome == "loss", (
|
| 291 |
+
f"{level}/seed{seed}: brute must LOSE; got {r.outcome} "
|
| 292 |
+
f"lost={r.signals.units_lost} tick={r.signals.game_tick}"
|
| 293 |
+
)
|
| 294 |
+
|
| 295 |
+
|
| 296 |
+
@pytest.mark.parametrize("level", ("medium", "hard"))
|
| 297 |
+
@pytest.mark.parametrize("seed", SEEDS)
|
| 298 |
+
def test_wrong_lane_loses_on_multi_tank_zone_tiers(level, seed):
|
| 299 |
+
"""Reinforcing the un-breached NORTH lane leaves the south
|
| 300 |
+
forward zone two tanks short of the n=4 bar → LOSS on
|
| 301 |
+
medium/hard."""
|
| 302 |
+
c = compile_level(load_pack(PACK), level)
|
| 303 |
+
r = run_level(c, _make_reinforce(12), seed=seed)
|
| 304 |
+
assert r.outcome == "loss", (
|
| 305 |
+
f"{level}/seed{seed}: reinforce-wrong-lane must LOSE; got {r.outcome} "
|
| 306 |
+
f"lost={r.signals.units_lost} tick={r.signals.game_tick}"
|
| 307 |
+
)
|
| 308 |
+
|
| 309 |
+
|
| 310 |
+
@pytest.mark.parametrize("level", LEVELS)
|
| 311 |
+
@pytest.mark.parametrize("seed", SEEDS)
|
| 312 |
+
def test_reinforce_the_breach_wins_every_tier_and_seed(level, seed):
|
| 313 |
+
"""The intended capability — shift the reserve into the breached
|
| 314 |
+
SOUTH lane — WINS every tier and every hard seed."""
|
| 315 |
+
c = compile_level(load_pack(PACK), level)
|
| 316 |
+
r = run_level(c, _make_reinforce(28), seed=seed)
|
| 317 |
+
assert r.outcome == "win", (
|
| 318 |
+
f"{level}/seed{seed}: reinforce-the-breach must WIN; got {r.outcome} "
|
| 319 |
+
f"lost={r.signals.units_lost} tick={r.signals.game_tick}"
|
| 320 |
+
)
|
| 321 |
+
|
| 322 |
+
|
| 323 |
+
def test_reinforce_run_is_deterministic_per_seed():
|
| 324 |
+
c = compile_level(load_pack(PACK), "medium")
|
| 325 |
+
a = run_level(c, _make_reinforce(28), seed=2)
|
| 326 |
+
b = run_level(c, _make_reinforce(28), seed=2)
|
| 327 |
+
assert (a.outcome, a.turns) == (b.outcome, b.turns)
|
tests/test_hard_tier.py
CHANGED
|
@@ -1451,6 +1451,7 @@ UPGRADED = [
|
|
| 1451 |
"combat-kite-and-pull", # hard: 2 agent spawn_point groups (Wave-12)
|
| 1452 |
"econ-tech-vs-expand-decision", # hard: 2 agent spawn_point groups (Wave-12)
|
| 1453 |
"scout-deny-enemy-vision", # hard: 2 agent spawn_point groups (Wave-12)
|
|
|
|
| 1454 |
]
|
| 1455 |
|
| 1456 |
# Consciously NOT spawn-varied, with the reason (keeps the curation
|
|
|
|
| 1451 |
"combat-kite-and-pull", # hard: 2 agent spawn_point groups (Wave-12)
|
| 1452 |
"econ-tech-vs-expand-decision", # hard: 2 agent spawn_point groups (Wave-12)
|
| 1453 |
"scout-deny-enemy-vision", # hard: 2 agent spawn_point groups (Wave-12)
|
| 1454 |
+
"def-reinforce-the-breach", # hard: 2 agent spawn_point groups (Wave-12)
|
| 1455 |
]
|
| 1456 |
|
| 1457 |
# Consciously NOT spawn-varied, with the reason (keeps the curation
|