Spaces:
Running
Running
feat(scenario): rob-unit-loss-recovery — rebuild force after attrition (PlanBench replanning / SC2 force-regen anchor)
Browse filesWave-6 ROBUSTNESS / reasoning capability pack. Agent loses 1+ tank to
an opening-tick enemy salvo and must commission replacements while
continuing the eastward assault. Salvaged from untracked files dropped
in main worktree by W6 agent rob-unit-loss-recovery.
openra_bench/scenarios/packs/rob-unit-loss-recovery.yaml
ADDED
|
@@ -0,0 +1,309 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# rob-unit-loss-recovery — rebuild force structure after attrition
|
| 2 |
+
#
|
| 3 |
+
# REASONING capability — react to an exogenous COMBAT-UNIT loss event by
|
| 4 |
+
# (1) recognising the surviving force is below establishment, (2)
|
| 5 |
+
# commissioning replacements from the war-factory with the reserve cash,
|
| 6 |
+
# and (3) executing the original offensive (kill the eastern garrison
|
| 7 |
+
# defending the enemy fact) — fast enough that the deadline still bites.
|
| 8 |
+
#
|
| 9 |
+
# Real-world anchor: PlanBench replanning after exogenous loss (gold
|
| 10 |
+
# standard); SC2 rebuild-after-trade; military force-regen / unit
|
| 11 |
+
# replacement under mid-mission attrition; ScienceWorld error recovery.
|
| 12 |
+
#
|
| 13 |
+
# Discrimination (the four-script policy bar):
|
| 14 |
+
# • stall (only `observe`): the enemy strike force walks into the
|
| 15 |
+
# pre-placed agent column, kills 1-4 of the agent's combat tanks,
|
| 16 |
+
# and the enemy garrison at x=80 is never touched → kill bar (5)
|
| 17 |
+
# unmet → after_ticks 5401 fires → LOSS.
|
| 18 |
+
# • no-rebuild (attack_move east but skip `build`): the strike force
|
| 19 |
+
# kills ≥1 combat tank on the way past; the surviving 3tnks reach
|
| 20 |
+
# the garrison and clear the kill bar, BUT the type-count clause
|
| 21 |
+
# (`unit_type_count_gte: {type: 3tnk, n: 4}`) is busted because the
|
| 22 |
+
# dead tank was never replaced → LOSS.
|
| 23 |
+
# • intended (rebuild + attack_move east): build('3tnk') whenever the
|
| 24 |
+
# 3tnk count drops below 4, attack_move the live force east. The
|
| 25 |
+
# replacement budget covers the per-tier expected losses; the
|
| 26 |
+
# fresh-built tank rejoins the column before the deadline, the
|
| 27 |
+
# garrison falls, and unit-type-count and kill-bar both pass → WIN.
|
| 28 |
+
#
|
| 29 |
+
# ENGINE NOTES (verified 2026-05-20 with scripted run_level + raw smoke):
|
| 30 |
+
# - Combat unit is `3tnk` (heavy tank, Soviet faction). Production
|
| 31 |
+
# requires `fact + weap + fix`. Cost 1150 cr per replacement.
|
| 32 |
+
# - The enemy uses the `turtle` scripted bot (botgen.py / scripted_
|
| 33 |
+
# bot.rs) so the eastern e1 garrison HOLDS POSITION instead of
|
| 34 |
+
# surging west; this isolates the loss event to the pre-placed
|
| 35 |
+
# `4tnk` striker(s) at x=24 and keeps the kill bar reachable from
|
| 36 |
+
# a single east-bound assault.
|
| 37 |
+
# - Strikers are stance:3 (AttackAnything) so they fire on the agent
|
| 38 |
+
# column from tick 0 — the loss event lands BEFORE the agent's
|
| 39 |
+
# first decision turn (~tick 90), so even a perfectly-timed flee
|
| 40 |
+
# cannot fully evade.
|
| 41 |
+
# - The far enemy `fact` at (120,20) is the engine-auto-done guard
|
| 42 |
+
# (CLAUDE.md): without a persistent MustBeDestroyed enemy, the
|
| 43 |
+
# engine ends the episode the moment the last garrison e1 falls,
|
| 44 |
+
# which can collapse a clean run to DRAW before the win/fail
|
| 45 |
+
# evaluator sees the terminal frame.
|
| 46 |
+
# - Tick budget (CLAUDE.md): max_turns=60 ⇒ ceiling tick 5403 ⇒
|
| 47 |
+
# within_ticks 5400 + after_ticks 5401 both reachable; a non-
|
| 48 |
+
# finisher LOSES (not draws).
|
| 49 |
+
# - `not own_units_gte: 1` mis-fires on turn 1 when the agent starts
|
| 50 |
+
# unit-less (CLAUDE.md footgun). This pack starts with 4 3tnks +
|
| 51 |
+
# fact, so `own_units_gte: 1` is safe.
|
| 52 |
+
|
| 53 |
+
meta:
|
| 54 |
+
id: rob-unit-loss-recovery
|
| 55 |
+
title: 'Force-Regen After Attrition — Rebuild Combat Force Mid-Episode'
|
| 56 |
+
capability: reasoning
|
| 57 |
+
real_world_meaning: >
|
| 58 |
+
Reactive replan after an exogenous combat-unit loss. An ongoing
|
| 59 |
+
offensive operation has its force structure unexpectedly cut by an
|
| 60 |
+
enemy strike (2 units of 4 die in the opening); the agent has just
|
| 61 |
+
enough reserve cash to commission replacements from the war
|
| 62 |
+
factory; the offensive deadline is real. The model must notice the
|
| 63 |
+
loss (units_summary shows fewer combat tanks than the establishment
|
| 64 |
+
of 4), commit the reserve to rebuilding the combat type (not to a
|
| 65 |
+
different unit or to nothing), AND continue the original mission
|
| 66 |
+
(clear the eastern garrison defending the enemy construction yard)
|
| 67 |
+
fast enough that the deadline still bites.
|
| 68 |
+
robotics_analogue: >
|
| 69 |
+
Military force-regen / SC2 rebuild-after-trade. A theater operation
|
| 70 |
+
loses combat assets to an enemy strike mid-mission; the supervisor
|
| 71 |
+
has a discrete replacement budget for new units from the rear-area
|
| 72 |
+
production base; the operational objective and timeline are fixed.
|
| 73 |
+
The discrimination is whether the model recognises the gap in the
|
| 74 |
+
advertised force structure AND issues the production order in time
|
| 75 |
+
to keep the offensive on schedule; ignoring the loss (continuing
|
| 76 |
+
with a reduced force) under-strengths the assault and busts the
|
| 77 |
+
establishment bar; spending the budget on the wrong unit type
|
| 78 |
+
breaks the offensive composition; doing nothing busts the deadline.
|
| 79 |
+
benchmark_anchor:
|
| 80 |
+
- "PlanBench replanning under exogenous loss"
|
| 81 |
+
- "ScienceWorld error recovery"
|
| 82 |
+
- "military force-regen / unit replacement"
|
| 83 |
+
- "SC2 rebuild-after-trade"
|
| 84 |
+
author: "openra-bench"
|
| 85 |
+
|
| 86 |
+
base_map: rush-hour-arena
|
| 87 |
+
starting_cash: 1500 # per-level override below
|
| 88 |
+
|
| 89 |
+
base:
|
| 90 |
+
agent:
|
| 91 |
+
faction: soviet
|
| 92 |
+
enemy:
|
| 93 |
+
faction: allies
|
| 94 |
+
# `turtle` (Wave-2 scripted bot) — enemy units hold position and
|
| 95 |
+
# return fire only. This keeps the eastern e1 garrison at x=80
|
| 96 |
+
# from surging west, so the kill bar is satisfied by an eastbound
|
| 97 |
+
# assault rather than by absorbing waves into the base. The
|
| 98 |
+
# pre-placed `4tnk` strikers (stance:3 AttackAnything) handle the
|
| 99 |
+
# loss event independently — they fire on contact at tick 0.
|
| 100 |
+
bot_type: turtle
|
| 101 |
+
tools:
|
| 102 |
+
- observe
|
| 103 |
+
- build
|
| 104 |
+
- place_building
|
| 105 |
+
- move_units
|
| 106 |
+
- attack_unit
|
| 107 |
+
- attack_move
|
| 108 |
+
- stop
|
| 109 |
+
planning: true
|
| 110 |
+
termination:
|
| 111 |
+
max_ticks: 7000
|
| 112 |
+
|
| 113 |
+
levels:
|
| 114 |
+
# ── EASY ────────────────────────────────────────────────────────
|
| 115 |
+
# The bare skill: 1 expected loss (2 strikers land ~1 tank kill
|
| 116 |
+
# under stall; intended attack-move + rebuild restores the
|
| 117 |
+
# establishment of 4 3tnks well inside the budget). Generous
|
| 118 |
+
# garrison (5 e1) means the kill bar is easily reachable.
|
| 119 |
+
easy:
|
| 120 |
+
description: >
|
| 121 |
+
You command 4 heavy tanks (3tnk) at a forward staging area
|
| 122 |
+
(x=22, y=18..24) with a production base (fact + powr + weap +
|
| 123 |
+
fix) behind you. Two enemy heavy tanks (4tnk) are staged at the
|
| 124 |
+
lane mouth (x=24) and will engage the moment the episode starts
|
| 125 |
+
— expect ONE of your tanks to die in the opening salvo. An
|
| 126 |
+
eastern garrison of 5 rifle infantry (e1) at x=80 defends the
|
| 127 |
+
enemy construction yard. Your reserve cash (1500 cr) covers ONE
|
| 128 |
+
replacement heavy tank (1150 cr); the intended play is to build
|
| 129 |
+
a replacement once your 3tnk count drops below 4 AND attack_move
|
| 130 |
+
east to clear the garrison. Win when you have killed ≥5 enemy
|
| 131 |
+
units AND you have ≥4 heavy tanks alive AND your construction
|
| 132 |
+
yard is intact, before tick 5400. Stalling, or assaulting
|
| 133 |
+
without replacing the loss, both LOSE.
|
| 134 |
+
starting_cash: 1500
|
| 135 |
+
overrides:
|
| 136 |
+
actors:
|
| 137 |
+
# Agent base: fact + powr + weap + fix at the west edge.
|
| 138 |
+
- {type: fact, owner: agent, position: [8, 18]}
|
| 139 |
+
- {type: powr, owner: agent, position: [10, 18]}
|
| 140 |
+
- {type: weap, owner: agent, position: [12, 18]}
|
| 141 |
+
- {type: fix, owner: agent, position: [14, 18]}
|
| 142 |
+
# 4 combat tanks (3tnk) at the forward staging line.
|
| 143 |
+
- {type: '3tnk', owner: agent, position: [22, 18], stance: 2}
|
| 144 |
+
- {type: '3tnk', owner: agent, position: [22, 20], stance: 2}
|
| 145 |
+
- {type: '3tnk', owner: agent, position: [22, 22], stance: 2}
|
| 146 |
+
- {type: '3tnk', owner: agent, position: [22, 24], stance: 2}
|
| 147 |
+
# Strike force: 2× 4tnk at the lane mouth (stance:3 fire on
|
| 148 |
+
# contact; range 5 covers the agent column at x=22).
|
| 149 |
+
- {type: '4tnk', owner: enemy, position: [24, 19], stance: 3}
|
| 150 |
+
- {type: '4tnk', owner: enemy, position: [24, 23], stance: 3}
|
| 151 |
+
# Eastern garrison: 5 rifle infantry defending the enemy fact.
|
| 152 |
+
# Stance:1 ReturnFire so they hold but engage when fired upon.
|
| 153 |
+
- {type: e1, owner: enemy, position: [80, 18], stance: 1}
|
| 154 |
+
- {type: e1, owner: enemy, position: [80, 20], stance: 1}
|
| 155 |
+
- {type: e1, owner: enemy, position: [80, 22], stance: 1}
|
| 156 |
+
- {type: e1, owner: enemy, position: [80, 24], stance: 1}
|
| 157 |
+
- {type: e1, owner: enemy, position: [82, 20], stance: 1}
|
| 158 |
+
# Persistent enemy `fact` far east (engine auto-done guard).
|
| 159 |
+
- {type: fact, owner: enemy, position: [120, 20]}
|
| 160 |
+
win_condition:
|
| 161 |
+
all_of:
|
| 162 |
+
- {unit_type_count_gte: {type: '3tnk', n: 4}}
|
| 163 |
+
- {units_killed_gte: 5}
|
| 164 |
+
- {has_building: fact}
|
| 165 |
+
- {within_ticks: 5400}
|
| 166 |
+
fail_condition:
|
| 167 |
+
any_of:
|
| 168 |
+
- {after_ticks: 5401}
|
| 169 |
+
- {not: {own_units_gte: 1}}
|
| 170 |
+
- {not: {has_building: fact}}
|
| 171 |
+
max_turns: 60
|
| 172 |
+
|
| 173 |
+
# ── MEDIUM ──────────────────────────────────────────────────────
|
| 174 |
+
# +1 controlled variable: TIGHTER strike (3× 4tnk strikers instead
|
| 175 |
+
# of 2) AND larger garrison (6 e1 vs 5). The replacement budget is
|
| 176 |
+
# held at 1500 cr — STILL one replacement, so the agent must absorb
|
| 177 |
+
# the same expected single-tank loss but the strike is harder to
|
| 178 |
+
# outrun and the kill bar requires a longer dwell at the garrison.
|
| 179 |
+
medium:
|
| 180 |
+
description: >
|
| 181 |
+
Tougher version: THREE enemy heavy tanks (4tnk) are staged at
|
| 182 |
+
the lane mouth (x=24, y=18..24) and will engage the moment the
|
| 183 |
+
episode starts — expect ONE of your tanks to die in the opening
|
| 184 |
+
salvo (the additional strikers spread fire, but still resolve to
|
| 185 |
+
~1 lost agent tank against the same 4-tank establishment). The
|
| 186 |
+
eastern garrison is now 6 rifle infantry at x=80. Reserve cash
|
| 187 |
+
(1500 cr) still covers ONE replacement 3tnk. Win when you have
|
| 188 |
+
killed ≥5 enemy units AND you have ≥4 heavy tanks alive AND
|
| 189 |
+
your construction yard is intact, before tick 5400. Stalling
|
| 190 |
+
and assault-without-rebuild both LOSE.
|
| 191 |
+
starting_cash: 1500
|
| 192 |
+
overrides:
|
| 193 |
+
actors:
|
| 194 |
+
- {type: fact, owner: agent, position: [8, 18]}
|
| 195 |
+
- {type: powr, owner: agent, position: [10, 18]}
|
| 196 |
+
- {type: weap, owner: agent, position: [12, 18]}
|
| 197 |
+
- {type: fix, owner: agent, position: [14, 18]}
|
| 198 |
+
- {type: '3tnk', owner: agent, position: [22, 18], stance: 2}
|
| 199 |
+
- {type: '3tnk', owner: agent, position: [22, 20], stance: 2}
|
| 200 |
+
- {type: '3tnk', owner: agent, position: [22, 22], stance: 2}
|
| 201 |
+
- {type: '3tnk', owner: agent, position: [22, 24], stance: 2}
|
| 202 |
+
# 3-tank strike: spreads fire across the agent column for a
|
| 203 |
+
# broader engagement footprint than easy.
|
| 204 |
+
- {type: '4tnk', owner: enemy, position: [24, 18], stance: 3}
|
| 205 |
+
- {type: '4tnk', owner: enemy, position: [24, 21], stance: 3}
|
| 206 |
+
- {type: '4tnk', owner: enemy, position: [24, 24], stance: 3}
|
| 207 |
+
# Garrison: 6 e1 at x=80..82.
|
| 208 |
+
- {type: e1, owner: enemy, position: [80, 18], stance: 1}
|
| 209 |
+
- {type: e1, owner: enemy, position: [80, 20], stance: 1}
|
| 210 |
+
- {type: e1, owner: enemy, position: [80, 22], stance: 1}
|
| 211 |
+
- {type: e1, owner: enemy, position: [80, 24], stance: 1}
|
| 212 |
+
- {type: e1, owner: enemy, position: [82, 20], stance: 1}
|
| 213 |
+
- {type: e1, owner: enemy, position: [82, 22], stance: 1}
|
| 214 |
+
- {type: fact, owner: enemy, position: [120, 20]}
|
| 215 |
+
win_condition:
|
| 216 |
+
all_of:
|
| 217 |
+
- {unit_type_count_gte: {type: '3tnk', n: 4}}
|
| 218 |
+
- {units_killed_gte: 5}
|
| 219 |
+
- {has_building: fact}
|
| 220 |
+
- {within_ticks: 5400}
|
| 221 |
+
fail_condition:
|
| 222 |
+
any_of:
|
| 223 |
+
- {after_ticks: 5401}
|
| 224 |
+
- {not: {own_units_gte: 1}}
|
| 225 |
+
- {not: {has_building: fact}}
|
| 226 |
+
max_turns: 60
|
| 227 |
+
|
| 228 |
+
# ── HARD ────────────────────────────────────────────────────────
|
| 229 |
+
# +2 controlled variables vs medium:
|
| 230 |
+
# 1. TWO seed-driven AGENT spawn_point groups (NORTH y=14 vs
|
| 231 |
+
# SOUTH y=28 e1 scout). The central combat force (4× 3tnk at
|
| 232 |
+
# y=18..24) and base buildings are SHARED across the two
|
| 233 |
+
# groups so the strike geometry is symmetric, but a memorised
|
| 234 |
+
# opening cannot generalise: the scout's location flips per
|
| 235 |
+
# seed and reveals it. (CLAUDE.md: enemy actors are NOT
|
| 236 |
+
# spawn-filtered, so the 3 strikers + garrison + far fact
|
| 237 |
+
# always place; only AGENT actors honour `spawn_point`, and
|
| 238 |
+
# ANY agent actor with a spawn_point causes every agent
|
| 239 |
+
# actor WITHOUT one to be filtered out — so base buildings
|
| 240 |
+
# are duplicated across BOTH spawn groups at identical cells.)
|
| 241 |
+
# 2. Larger replacement budget (3450 cr ⇒ 3× 3tnk rebuilds) to
|
| 242 |
+
# absorb the staggered strike (3 strikers spread fire across
|
| 243 |
+
# the column for a broader engagement — empirically still
|
| 244 |
+
# lands ~1 tank kill on intended-attack-move, but the budget
|
| 245 |
+
# lets the agent rebuild aggressively if more are lost).
|
| 246 |
+
hard:
|
| 247 |
+
description: >
|
| 248 |
+
Hard tier: TWO seed-driven base orientations (a NORTH-flank
|
| 249 |
+
scout at (16,14) on spawn 0; a SOUTH-flank scout at (16,28) on
|
| 250 |
+
spawn 1) keep the strike geometry symmetric but defeat a
|
| 251 |
+
memorised opening. Three enemy heavy tanks (4tnk) strike the
|
| 252 |
+
central column; the eastern garrison is 6 rifle infantry at
|
| 253 |
+
x=80. Reserve cash (3450 cr) covers up to THREE replacement
|
| 254 |
+
heavy tanks. Win when you have killed ≥5 enemy units AND you
|
| 255 |
+
have ≥4 heavy tanks alive AND your construction yard is intact,
|
| 256 |
+
before tick 5400. Stalling and assault-without-rebuild both
|
| 257 |
+
LOSE; the intended play is to rebuild every fallen tank AND
|
| 258 |
+
attack_move east in parallel.
|
| 259 |
+
starting_cash: 3450
|
| 260 |
+
overrides:
|
| 261 |
+
actors:
|
| 262 |
+
# SPAWN 0 — NORTH-flank scout at (16,14).
|
| 263 |
+
# Base buildings + central combat force duplicated across
|
| 264 |
+
# both spawn groups (per CLAUDE.md: ANY agent actor with a
|
| 265 |
+
# spawn_point causes agent actors WITHOUT spawn_point to be
|
| 266 |
+
# filtered out — so EVERY agent actor must declare
|
| 267 |
+
# spawn_point).
|
| 268 |
+
- {type: fact, owner: agent, position: [8, 18], spawn_point: 0}
|
| 269 |
+
- {type: powr, owner: agent, position: [10, 18], spawn_point: 0}
|
| 270 |
+
- {type: weap, owner: agent, position: [12, 18], spawn_point: 0}
|
| 271 |
+
- {type: fix, owner: agent, position: [14, 18], spawn_point: 0}
|
| 272 |
+
- {type: '3tnk', owner: agent, position: [22, 18], stance: 2, spawn_point: 0}
|
| 273 |
+
- {type: '3tnk', owner: agent, position: [22, 20], stance: 2, spawn_point: 0}
|
| 274 |
+
- {type: '3tnk', owner: agent, position: [22, 22], stance: 2, spawn_point: 0}
|
| 275 |
+
- {type: '3tnk', owner: agent, position: [22, 24], stance: 2, spawn_point: 0}
|
| 276 |
+
- {type: e1, owner: agent, position: [16, 14], stance: 0, spawn_point: 0}
|
| 277 |
+
# SPAWN 1 — SOUTH-flank scout at (16,28).
|
| 278 |
+
- {type: fact, owner: agent, position: [8, 18], spawn_point: 1}
|
| 279 |
+
- {type: powr, owner: agent, position: [10, 18], spawn_point: 1}
|
| 280 |
+
- {type: weap, owner: agent, position: [12, 18], spawn_point: 1}
|
| 281 |
+
- {type: fix, owner: agent, position: [14, 18], spawn_point: 1}
|
| 282 |
+
- {type: '3tnk', owner: agent, position: [22, 18], stance: 2, spawn_point: 1}
|
| 283 |
+
- {type: '3tnk', owner: agent, position: [22, 20], stance: 2, spawn_point: 1}
|
| 284 |
+
- {type: '3tnk', owner: agent, position: [22, 22], stance: 2, spawn_point: 1}
|
| 285 |
+
- {type: '3tnk', owner: agent, position: [22, 24], stance: 2, spawn_point: 1}
|
| 286 |
+
- {type: e1, owner: agent, position: [16, 28], stance: 0, spawn_point: 1}
|
| 287 |
+
# 3-tank strike (enemy actors place on every seed).
|
| 288 |
+
- {type: '4tnk', owner: enemy, position: [24, 18], stance: 3}
|
| 289 |
+
- {type: '4tnk', owner: enemy, position: [24, 21], stance: 3}
|
| 290 |
+
- {type: '4tnk', owner: enemy, position: [24, 24], stance: 3}
|
| 291 |
+
- {type: e1, owner: enemy, position: [80, 18], stance: 1}
|
| 292 |
+
- {type: e1, owner: enemy, position: [80, 20], stance: 1}
|
| 293 |
+
- {type: e1, owner: enemy, position: [80, 22], stance: 1}
|
| 294 |
+
- {type: e1, owner: enemy, position: [80, 24], stance: 1}
|
| 295 |
+
- {type: e1, owner: enemy, position: [82, 20], stance: 1}
|
| 296 |
+
- {type: e1, owner: enemy, position: [82, 22], stance: 1}
|
| 297 |
+
- {type: fact, owner: enemy, position: [120, 20]}
|
| 298 |
+
win_condition:
|
| 299 |
+
all_of:
|
| 300 |
+
- {unit_type_count_gte: {type: '3tnk', n: 4}}
|
| 301 |
+
- {units_killed_gte: 5}
|
| 302 |
+
- {has_building: fact}
|
| 303 |
+
- {within_ticks: 5400}
|
| 304 |
+
fail_condition:
|
| 305 |
+
any_of:
|
| 306 |
+
- {after_ticks: 5401}
|
| 307 |
+
- {not: {own_units_gte: 1}}
|
| 308 |
+
- {not: {has_building: fact}}
|
| 309 |
+
max_turns: 60
|
tests/test_hard_tier.py
CHANGED
|
@@ -522,6 +522,23 @@ UPGRADED = [
|
|
| 522 |
# the surprise wave arrives FROM flips per seed and a memorised
|
| 523 |
# "expect Wave 2 from NE" disposition cannot generalise.
|
| 524 |
"rob-unexpected-enemy-spawn",
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 525 |
]
|
| 526 |
|
| 527 |
# Consciously NOT spawn-varied, with the reason (keeps the curation
|
|
|
|
| 522 |
# the surprise wave arrives FROM flips per seed and a memorised
|
| 523 |
# "expect Wave 2 from NE" disposition cannot generalise.
|
| 524 |
"rob-unexpected-enemy-spawn",
|
| 525 |
+
# Wave-6 ROBUSTNESS / reasoning seed — PlanBench replanning under
|
| 526 |
+
# exogenous loss / SC2 rebuild-after-trade / military force-regen /
|
| 527 |
+
# ScienceWorld error recovery anchor. Agent commands 4 heavy tanks
|
| 528 |
+
# (3tnk) + a production base (fact + powr + weap + fix); a pre-
|
| 529 |
+
# placed enemy 4tnk strike force at the lane mouth lands its
|
| 530 |
+
# opening salvo on tick 0 (stance:3) and kills 1+ agent tanks;
|
| 531 |
+
# the agent must commission replacements from the war factory with
|
| 532 |
+
# the reserve cash AND continue the eastward assault to clear the
|
| 533 |
+
# e1 garrison. Hard tier defines two agent spawn_point groups
|
| 534 |
+
# (NORTH-flank scout (16,14) vs SOUTH-flank scout (16,28)) round-
|
| 535 |
+
# robined by seed; the central combat force + base buildings are
|
| 536 |
+
# SHARED across both groups (per CLAUDE.md, base buildings are
|
| 537 |
+
# duplicated under both spawn_points because ANY agent actor with
|
| 538 |
+
# a spawn_point causes agent actors WITHOUT a spawn_point to be
|
| 539 |
+
# filtered out), so the strike geometry is symmetric but a
|
| 540 |
+
# memorised opening cannot generalise.
|
| 541 |
+
"rob-unit-loss-recovery",
|
| 542 |
]
|
| 543 |
|
| 544 |
# Consciously NOT spawn-varied, with the reason (keeps the curation
|
tests/test_rob_unit_loss_recovery.py
ADDED
|
@@ -0,0 +1,310 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""rob-unit-loss-recovery — REASONING capability validation.
|
| 2 |
+
|
| 3 |
+
Real-world anchor: PlanBench replanning under exogenous loss (gold
|
| 4 |
+
standard); SC2 rebuild-after-trade; military force-regen / unit
|
| 5 |
+
replacement; ScienceWorld error recovery. The agent starts with 4
|
| 6 |
+
combat heavy tanks (3tnk) + a production base (fact + powr + weap +
|
| 7 |
+
fix) and a discrete reserve cash budget; an enemy 4tnk strike force
|
| 8 |
+
staged at the lane mouth (stance:3 AttackAnything) lands its opening
|
| 9 |
+
salvo on tick 0 and kills 1-4 agent tanks. The agent must
|
| 10 |
+
1. notice the establishment is below 4 (units_summary count drops),
|
| 11 |
+
2. build('3tnk') to commission replacements from the war factory
|
| 12 |
+
with the indivisible reserve cash, AND
|
| 13 |
+
3. continue the eastward offensive to clear the e1 garrison
|
| 14 |
+
defending the enemy fact at x=120,
|
| 15 |
+
so the kill bar (5 enemy units) + the establishment bar (≥4 3tnks) +
|
| 16 |
+
the construction-yard intact bar all pass before the tick deadline.
|
| 17 |
+
|
| 18 |
+
Bar (CLAUDE.md "no defect, no cheat"):
|
| 19 |
+
- stall LOSES every tier / every hard seed (no attack, no kills →
|
| 20 |
+
kill bar unmet → after_ticks 5401 fires → timeout LOSS).
|
| 21 |
+
- no_rebuild LOSES every tier (the strike kills ≥1 agent 3tnk; the
|
| 22 |
+
surviving 3 tanks clear the kill bar but the type-count clause
|
| 23 |
+
`unit_type_count_gte: {type: 3tnk, n: 4}` is busted because the
|
| 24 |
+
dead tank was never replaced → LOSS even though kills ≥ 5).
|
| 25 |
+
- intended-rebuild-and-attack WINS every tier / seed.
|
| 26 |
+
- hard tier defines ≥2 agent spawn_point groups (NORTH-flank scout
|
| 27 |
+
vs SOUTH-flank scout) so a memorised opening cannot generalise.
|
| 28 |
+
"""
|
| 29 |
+
|
| 30 |
+
from __future__ import annotations
|
| 31 |
+
|
| 32 |
+
from pathlib import Path
|
| 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 |
+
|
| 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 |
+
from openra_bench.scenarios.win_conditions import WinContext, evaluate
|
| 43 |
+
|
| 44 |
+
PACK = PACKS_DIR / "rob-unit-loss-recovery.yaml"
|
| 45 |
+
|
| 46 |
+
|
| 47 |
+
# ── policies ────────────────────────────────────────────────────────
|
| 48 |
+
|
| 49 |
+
|
| 50 |
+
def _stall(rs, Command):
|
| 51 |
+
"""Pure observe — no orders → no kills → kill bar unmet →
|
| 52 |
+
after_ticks 5401 fires → timeout LOSS."""
|
| 53 |
+
return [Command.observe()]
|
| 54 |
+
|
| 55 |
+
|
| 56 |
+
def _no_rebuild(rs, Command):
|
| 57 |
+
"""Attack-move east WITHOUT ever issuing build. The strike kills
|
| 58 |
+
≥1 3tnk on the way past (the agent now has <4 of the combat type);
|
| 59 |
+
the surviving force reaches the garrison and clears the kill bar
|
| 60 |
+
BUT the `unit_type_count_gte: {type: 3tnk, n: 4}` clause is busted
|
| 61 |
+
→ LOSS even though kills ≥ 5."""
|
| 62 |
+
units = rs.get("units_summary", []) or []
|
| 63 |
+
tanks = [u for u in units if u.get("type") == "3tnk"]
|
| 64 |
+
if not tanks:
|
| 65 |
+
return [Command.observe()]
|
| 66 |
+
ids = [str(u["id"]) for u in tanks]
|
| 67 |
+
return [Command.attack_move(ids, 80, 20)]
|
| 68 |
+
|
| 69 |
+
|
| 70 |
+
def _intended(rs, Command):
|
| 71 |
+
"""React to the loss: if 3tnk count drops below 4, commission a
|
| 72 |
+
replacement via build('3tnk'); always attack_move the live 3tnk
|
| 73 |
+
force toward the eastern garrison at (80,20). The replacement
|
| 74 |
+
budget covers the per-tier expected losses; the fresh-built tank
|
| 75 |
+
rejoins the column before the deadline; the type-count + kill-bar
|
| 76 |
+
+ construction-yard bars all pass → WIN."""
|
| 77 |
+
units = rs.get("units_summary", []) or []
|
| 78 |
+
tanks = [u for u in units if u.get("type") == "3tnk"]
|
| 79 |
+
cmds = []
|
| 80 |
+
if len(tanks) < 4:
|
| 81 |
+
cmds.append(Command.build("3tnk"))
|
| 82 |
+
ids = [str(u["id"]) for u in tanks]
|
| 83 |
+
if ids:
|
| 84 |
+
cmds.append(Command.attack_move(ids, 80, 20))
|
| 85 |
+
return cmds or [Command.observe()]
|
| 86 |
+
|
| 87 |
+
|
| 88 |
+
# ── helpers ─────────────────────────────────────────────────────────
|
| 89 |
+
|
| 90 |
+
|
| 91 |
+
def _run(level, policy, seed=1):
|
| 92 |
+
c = compile_level(load_pack(PACK), level)
|
| 93 |
+
assert c.map_supported, "rush-hour-arena terrain must be present"
|
| 94 |
+
return c, run_level(c, policy, seed=seed)
|
| 95 |
+
|
| 96 |
+
|
| 97 |
+
# ── structural ──────────────────────────────────────────────────────
|
| 98 |
+
|
| 99 |
+
|
| 100 |
+
def test_pack_loads_and_meta_active():
|
| 101 |
+
pack = load_pack(PACK)
|
| 102 |
+
assert pack.meta.id == "rob-unit-loss-recovery"
|
| 103 |
+
assert pack.meta.capability == "reasoning"
|
| 104 |
+
assert pack.meta.real_world_meaning
|
| 105 |
+
assert pack.meta.robotics_analogue
|
| 106 |
+
anchors = pack.meta.benchmark_anchor
|
| 107 |
+
joined = " ".join(anchors).lower()
|
| 108 |
+
assert "planbench" in joined
|
| 109 |
+
assert "scienceworld" in joined
|
| 110 |
+
assert "sc2" in joined
|
| 111 |
+
assert "force-regen" in joined or "force regen" in joined or "rebuild" in joined
|
| 112 |
+
|
| 113 |
+
|
| 114 |
+
def test_uses_turtle_bot():
|
| 115 |
+
"""The pack must declare the Wave-2 `turtle` bot — the holding-
|
| 116 |
+
in-place idiom is what isolates the loss event to the pre-placed
|
| 117 |
+
strikers and keeps the kill bar reachable from a single eastbound
|
| 118 |
+
assault (a surging garrison would either overrun the base or be
|
| 119 |
+
killed without the agent moving, breaking both bars)."""
|
| 120 |
+
pack = load_pack(PACK)
|
| 121 |
+
enemy = pack.base.get("enemy") if isinstance(pack.base, dict) else None
|
| 122 |
+
assert enemy is not None
|
| 123 |
+
bot = enemy.get("bot_type") or enemy.get("bot")
|
| 124 |
+
assert bot == "turtle", f"expected turtle bot, got {bot!r}"
|
| 125 |
+
|
| 126 |
+
|
| 127 |
+
def test_all_tiers_have_reachable_deadlines():
|
| 128 |
+
"""tick-alignment idiom: within_ticks ≤ ceiling AND
|
| 129 |
+
after_ticks ≤ ceiling AND within_ticks + 1 == after_ticks (so a
|
| 130 |
+
non-finisher LOSES, not draws)."""
|
| 131 |
+
pack = load_pack(PACK)
|
| 132 |
+
for lvl in ("easy", "medium", "hard"):
|
| 133 |
+
L = pack.levels[lvl]
|
| 134 |
+
ceiling = 93 + 90 * (L.max_turns - 1)
|
| 135 |
+
wt = next(
|
| 136 |
+
int(c["within_ticks"])
|
| 137 |
+
for c in L.win_condition.model_dump()["all_of"]
|
| 138 |
+
if "within_ticks" in c
|
| 139 |
+
)
|
| 140 |
+
ft = next(
|
| 141 |
+
int(c["after_ticks"])
|
| 142 |
+
for c in L.fail_condition.model_dump()["any_of"]
|
| 143 |
+
if "after_ticks" in c
|
| 144 |
+
)
|
| 145 |
+
assert wt <= ceiling, f"{lvl}: within_ticks {wt} > ceiling {ceiling}"
|
| 146 |
+
assert ft <= ceiling, f"{lvl}: after_ticks {ft} > ceiling {ceiling}"
|
| 147 |
+
assert wt + 1 == ft, (
|
| 148 |
+
f"{lvl}: within_ticks {wt} / after_ticks {ft} mismatch "
|
| 149 |
+
"(non-finisher must LOSE, not draw — fail clause one tick"
|
| 150 |
+
" past win clause)"
|
| 151 |
+
)
|
| 152 |
+
|
| 153 |
+
|
| 154 |
+
def test_hard_has_two_seed_driven_spawn_groups():
|
| 155 |
+
"""Hard tier: ≥2 distinct agent spawn_point groups so engine
|
| 156 |
+
round-robins start by seed. The central combat force + base
|
| 157 |
+
buildings are SHARED across both groups so the strike geometry is
|
| 158 |
+
symmetric, but the spawn-distinguishing scout (NORTH (16,14) vs
|
| 159 |
+
SOUTH (16,28)) reveals which seed the engine picked — a memorised
|
| 160 |
+
opening cannot generalise."""
|
| 161 |
+
c = compile_level(load_pack(PACK), "hard")
|
| 162 |
+
sp = {
|
| 163 |
+
(a.spawn_point if a.spawn_point is not None else 0)
|
| 164 |
+
for a in c.scenario.actors
|
| 165 |
+
if a.owner == "agent"
|
| 166 |
+
}
|
| 167 |
+
assert len(sp) >= 2, (
|
| 168 |
+
f"hard must define ≥2 agent spawn_point groups; got {sorted(sp)}"
|
| 169 |
+
)
|
| 170 |
+
|
| 171 |
+
|
| 172 |
+
def test_fail_condition_present_on_every_tier():
|
| 173 |
+
pack = load_pack(PACK)
|
| 174 |
+
for lvl in ("easy", "medium", "hard"):
|
| 175 |
+
c = compile_level(pack, lvl)
|
| 176 |
+
assert c.fail_condition is not None, f"{lvl} needs a fail_condition"
|
| 177 |
+
|
| 178 |
+
|
| 179 |
+
def test_tools_match_spec():
|
| 180 |
+
"""The advertised toolset is exactly the rebuild-and-assault kit:
|
| 181 |
+
observe + build + place_building + move_units + attack_unit +
|
| 182 |
+
attack_move + stop. No `harvest` (this is a no-economy scenario —
|
| 183 |
+
the rebuild budget is the starting_cash reserve)."""
|
| 184 |
+
pack = load_pack(PACK)
|
| 185 |
+
tools = set(pack.base.get("tools", []))
|
| 186 |
+
expected = {
|
| 187 |
+
"observe", "build", "place_building", "move_units",
|
| 188 |
+
"attack_unit", "attack_move", "stop",
|
| 189 |
+
}
|
| 190 |
+
assert tools == expected, f"tools mismatch: got {sorted(tools)}"
|
| 191 |
+
|
| 192 |
+
|
| 193 |
+
# ── predicate-level (no engine) ─────────────────────────────────────
|
| 194 |
+
|
| 195 |
+
|
| 196 |
+
def _ctx(*, units=(), tick=1000, kills=0, lost=0, own_buildings=()):
|
| 197 |
+
import types
|
| 198 |
+
|
| 199 |
+
sig = types.SimpleNamespace(
|
| 200 |
+
game_tick=tick,
|
| 201 |
+
units_killed=kills,
|
| 202 |
+
units_lost=lost,
|
| 203 |
+
cash=0,
|
| 204 |
+
resources=0,
|
| 205 |
+
own_buildings=list(own_buildings),
|
| 206 |
+
own_building_types={str(t).lower() for (t, _, _) in own_buildings},
|
| 207 |
+
enemies_seen_ids=set(),
|
| 208 |
+
enemy_buildings_seen_ids=set(),
|
| 209 |
+
)
|
| 210 |
+
return WinContext(
|
| 211 |
+
signals=sig,
|
| 212 |
+
render_state={"units_summary": list(units)},
|
| 213 |
+
)
|
| 214 |
+
|
| 215 |
+
|
| 216 |
+
def test_predicates_enforce_capability():
|
| 217 |
+
"""Win requires (≥4 3tnks AND ≥5 kills AND fact alive) AND in-time;
|
| 218 |
+
fail fires on timeout OR all-units-dead OR fact destroyed."""
|
| 219 |
+
c = compile_level(load_pack(PACK), "medium")
|
| 220 |
+
four_tanks = [{"cell_x": 22, "cell_y": 18 + 2 * i, "type": "3tnk"} for i in range(4)]
|
| 221 |
+
fact = [("fact", 8, 18)]
|
| 222 |
+
|
| 223 |
+
# Intended: 4 3tnks, 5 kills, fact alive, in time → WIN
|
| 224 |
+
assert evaluate(
|
| 225 |
+
c.win_condition,
|
| 226 |
+
_ctx(units=four_tanks, tick=2000, kills=5, own_buildings=fact),
|
| 227 |
+
)
|
| 228 |
+
# Only 3 3tnks (didn't rebuild after a loss) → not a win
|
| 229 |
+
assert not evaluate(
|
| 230 |
+
c.win_condition,
|
| 231 |
+
_ctx(units=four_tanks[:3], tick=2000, kills=5, own_buildings=fact),
|
| 232 |
+
)
|
| 233 |
+
# Only 4 kills (didn't clear the garrison) → not a win
|
| 234 |
+
assert not evaluate(
|
| 235 |
+
c.win_condition,
|
| 236 |
+
_ctx(units=four_tanks, tick=2000, kills=4, own_buildings=fact),
|
| 237 |
+
)
|
| 238 |
+
# All units dead → real fail (capability collapses)
|
| 239 |
+
assert evaluate(
|
| 240 |
+
c.fail_condition,
|
| 241 |
+
_ctx(units=[], tick=2000, kills=5, own_buildings=fact),
|
| 242 |
+
)
|
| 243 |
+
# Timeout (tick past after_ticks): bar unmet → fail
|
| 244 |
+
assert evaluate(
|
| 245 |
+
c.fail_condition,
|
| 246 |
+
_ctx(units=four_tanks, tick=5402, kills=0, own_buildings=fact),
|
| 247 |
+
)
|
| 248 |
+
# Construction yard destroyed → fail
|
| 249 |
+
assert evaluate(
|
| 250 |
+
c.fail_condition,
|
| 251 |
+
_ctx(units=four_tanks, tick=2000, kills=5, own_buildings=[]),
|
| 252 |
+
)
|
| 253 |
+
|
| 254 |
+
|
| 255 |
+
# ── engine-driven policies (the no-cheat bar) ───────────────────────
|
| 256 |
+
|
| 257 |
+
|
| 258 |
+
@pytest.mark.parametrize("level", ["easy", "medium", "hard"])
|
| 259 |
+
@pytest.mark.parametrize("seed", [1, 2, 3, 4])
|
| 260 |
+
def test_stall_loses_every_tier_and_seed(level, seed):
|
| 261 |
+
"""No orders → no kills → kill bar unmet → timeout LOSS via
|
| 262 |
+
after_ticks. The strike force kills 1-4 agent tanks during the
|
| 263 |
+
grind, but the agent neither rebuilds nor advances on the
|
| 264 |
+
garrison."""
|
| 265 |
+
_, r = _run(level, _stall, seed=seed)
|
| 266 |
+
assert r.outcome == "loss", (
|
| 267 |
+
f"{level}/seed{seed}: stall must LOSE (no kills, no rebuild); "
|
| 268 |
+
f"got {r.outcome} kills={r.signals.units_killed} "
|
| 269 |
+
f"losses={r.signals.units_lost} turns={r.turns}"
|
| 270 |
+
)
|
| 271 |
+
|
| 272 |
+
|
| 273 |
+
@pytest.mark.parametrize("level", ["easy", "medium", "hard"])
|
| 274 |
+
@pytest.mark.parametrize("seed", [1, 2, 3, 4])
|
| 275 |
+
def test_no_rebuild_loses(level, seed):
|
| 276 |
+
"""Assault-without-rebuild: the strike kills ≥1 3tnk; the surviving
|
| 277 |
+
force clears the garrison so kills ≥ 5, but the type-count clause
|
| 278 |
+
busts because the dead tank was never replaced → LOSS even with a
|
| 279 |
+
cleared kill bar. This is the canonical 'forgot to replace the
|
| 280 |
+
casualty' failure mode the scenario is designed to catch."""
|
| 281 |
+
_, r = _run(level, _no_rebuild, seed=seed)
|
| 282 |
+
assert r.outcome == "loss", (
|
| 283 |
+
f"{level}/seed{seed}: no-rebuild must LOSE (type-count busted "
|
| 284 |
+
f"by unreplaced casualty); got {r.outcome} "
|
| 285 |
+
f"kills={r.signals.units_killed} losses={r.signals.units_lost}"
|
| 286 |
+
)
|
| 287 |
+
|
| 288 |
+
|
| 289 |
+
@pytest.mark.parametrize("level", ["easy", "medium", "hard"])
|
| 290 |
+
@pytest.mark.parametrize("seed", [1, 2, 3, 4])
|
| 291 |
+
def test_intended_rebuild_and_attack_wins(level, seed):
|
| 292 |
+
"""The intended capability — build replacements whenever the
|
| 293 |
+
establishment drops below 4 AND attack_move the live force east —
|
| 294 |
+
WINS every tier and every hard seed well inside the tick budget."""
|
| 295 |
+
_, r = _run(level, _intended, seed=seed)
|
| 296 |
+
assert r.outcome == "win", (
|
| 297 |
+
f"{level}/seed{seed}: intended rebuild-and-attack should WIN; "
|
| 298 |
+
f"got {r.outcome} kills={r.signals.units_killed} "
|
| 299 |
+
f"losses={r.signals.units_lost} turns={r.turns}"
|
| 300 |
+
)
|
| 301 |
+
|
| 302 |
+
|
| 303 |
+
def test_outcomes_are_deterministic_per_seed():
|
| 304 |
+
"""Same seed, same policy → identical outcome."""
|
| 305 |
+
c = compile_level(load_pack(PACK), "medium")
|
| 306 |
+
a = run_level(c, _intended, seed=2)
|
| 307 |
+
b = run_level(c, _intended, seed=2)
|
| 308 |
+
assert (a.outcome, a.turns, a.signals.units_killed, a.signals.units_lost) == (
|
| 309 |
+
b.outcome, b.turns, b.signals.units_killed, b.signals.units_lost
|
| 310 |
+
)
|