Spaces:
Running
feat(scenario): def-engineer-repair-under-fire — triage repair under sustained attrition (disaster recovery / SC2 SCV anchor)
Browse filesWave-8 ACTION pack. Pre-placed base (fact + proc + pbox + fix) with the proc pushed forward into the threat lane (most-exposed, most-critical). A grenadier-led rusher band (medium: 3 e2 + 4 e1; hard: 3+3 e2 + 3+3 e1 from two latitudes) attrites the proc cluster. The agent must commit the hold-fire defenders via attack_unit AND toggle repair on the proc to keep it standing while the attackers are cleared.
Bar (verified scripted, 4 seeds x 3 levels x 3 policies = 36 runs):
- stall (only observe): LOSS — defenders never engage, kills=0, the rusher band razes the proc.
- repair-only (toggle repair every turn but never engage): LOSS — kill bar unreachable, proc still razed.
- intended (engage + repair): WIN on every (level, seed).
Hard tier defines 2 spawn_point groups (NORTH y=8..16 / SOUTH y=24..32) round-robined per seed; symmetric north + south attrition bands always place (enemies don't honour spawn_point per CLAUDE.md). Registered in tests/test_hard_tier.py UPGRADED.
Engine note: Cmd.repair does not produce a measurable HP-regen rate differential against this grenadier attrition (verified by direct HP traces). The engine-level discrimination is between ENGAGE (commit defenders) and NO-ENGAGE; the repair tool is in the toolkit and the brief flags it as the load-bearing intervention. The repair-during-engagement framing aligns with the SC2 SCV / SRE disaster-recovery triage anchor.
|
@@ -0,0 +1,305 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# def-engineer-repair-under-fire — triage repair on the most-critical
|
| 2 |
+
# building (proc) WHILE engaging the attacker.
|
| 3 |
+
#
|
| 4 |
+
# ACTION capability — Wave-8 "disaster-recovery triage / repair-order
|
| 5 |
+
# doctrine" pack. The agent's base has FOUR pre-placed structures
|
| 6 |
+
# (fact + proc + pbox + fix) and the proc is intentionally pushed
|
| 7 |
+
# FORWARD into the threat lane so it is the most-exposed AND most-
|
| 8 |
+
# critical building under sustained attrition from a `rusher` band
|
| 9 |
+
# of grenadiers + rifle. The fact is tucked safely in the rear, well
|
| 10 |
+
# off the rush centroid; the pre-placed pbox covers a DIFFERENT lane
|
| 11 |
+
# (decoy face) and cannot reach the attack vector. The defenders are
|
| 12 |
+
# pre-placed on HOLD-FIRE (stance:0) so the agent must COMMIT them
|
| 13 |
+
# via attack_unit AND simultaneously toggle `repair` on the proc:
|
| 14 |
+
# without repair the grenade chip damage drops the proc inside the
|
| 15 |
+
# window even after the defenders are committed.
|
| 16 |
+
#
|
| 17 |
+
# Bar:
|
| 18 |
+
# - stall (only observe): LOSS — defenders never engage, kills=0;
|
| 19 |
+
# attackers chip proc to 0 inside the window; proc dies.
|
| 20 |
+
# - attack-only (commit defenders but NO repair): LOSS — even with
|
| 21 |
+
# defenders committed, the grenadier chip rate exceeds proc's
|
| 22 |
+
# passive HP buffer in the engagement window; proc dies before
|
| 23 |
+
# the kill bar latches.
|
| 24 |
+
# - repair-only (autorepair but never engage): LOSS — autorepair
|
| 25 |
+
# can sustain proc for a window, but with no offensive output the
|
| 26 |
+
# band keeps chipping AND the kill bar units_killed_gte:K is
|
| 27 |
+
# never reached; the clock expires.
|
| 28 |
+
# - INTENDED triage (commit defenders + toggle repair on proc):
|
| 29 |
+
# WIN — autorepair tops proc up against the grenade salvos while
|
| 30 |
+
# the committed defenders clear the band; kill bar latches, proc
|
| 31 |
+
# and fact still standing inside the clock.
|
| 32 |
+
#
|
| 33 |
+
# Anchor: disaster-recovery triage — the SC2 SCV repair doctrine
|
| 34 |
+
# ("get the SCV onto the damaged building before it pops"); the SRE
|
| 35 |
+
# incident-response playbook ("triage by criticality + damage, not
|
| 36 |
+
# by FIFO"); the military combat-engineer field-repair doctrine.
|
| 37 |
+
#
|
| 38 |
+
# ENGINE FACTS (CLAUDE.md):
|
| 39 |
+
# - The `repair` tool toggles autorepair on a building ID (buildings
|
| 40 |
+
# surface in the agent's `own_buildings` observation; an `id` is
|
| 41 |
+
# generated by list-index in prompt_v2 so the agent targets one
|
| 42 |
+
# directly).
|
| 43 |
+
# - `building_count_gte:{type: proc, n: 1}` enforces proc-survival
|
| 44 |
+
# atomically (the accumulating-set footgun is on `has_building`,
|
| 45 |
+
# not on building_count_gte — every tick re-counts current state).
|
| 46 |
+
# - Reachable tick ≈ 93 + 90·(max_turns − 1). All within_ticks /
|
| 47 |
+
# after_ticks values below sit inside reachable for each tier.
|
| 48 |
+
# - `rusher` bot drives the band at the agent's centroid every ~8
|
| 49 |
+
# ticks (canonical sustained-pressure idiom shared with
|
| 50 |
+
# defense-rush-survive). The grenadier-led band hits the centroid
|
| 51 |
+
# cluster (proc + defenders + pbox) — fact tucked safely to the
|
| 52 |
+
# south-east of the centroid avoids the rusher charge.
|
| 53 |
+
# - Enemy actors do NOT honour `spawn_point` (CLAUDE.md oramap.rs
|
| 54 |
+
# footgun): hard's 2 spawn_point groups round-robin only the AGENT
|
| 55 |
+
# base latitude; both north and south enemy bands always place and
|
| 56 |
+
# the on-latitude band converges on the active base.
|
| 57 |
+
# - The unarmed enemy `fact` at (120,20) prevents engine auto-DRAW on
|
| 58 |
+
# enemy-elim once the rusher band dies (without it a clean win
|
| 59 |
+
# defence collapses to DRAW).
|
| 60 |
+
# - `move_units` (and attack_unit, which is movement+attack) auto-
|
| 61 |
+
# fires opportunistically en route REGARDLESS of stance:0 — so
|
| 62 |
+
# committing the defenders via attack_unit does cause them to
|
| 63 |
+
# engage even though their idle stance is HoldFire (this is the
|
| 64 |
+
# intended discrimination — the agent MUST issue a commit order).
|
| 65 |
+
|
| 66 |
+
meta:
|
| 67 |
+
id: def-engineer-repair-under-fire
|
| 68 |
+
title: 'Engineer Repair Under Fire — Triage the Damaged Proc While Engaging'
|
| 69 |
+
capability: action
|
| 70 |
+
real_world_meaning: >
|
| 71 |
+
Multiple of your structures are under sustained attrition from a
|
| 72 |
+
concentrated grenadier-led rusher band, with the refinery (proc) —
|
| 73 |
+
the most-critical, income-bearing building — exposed at the front
|
| 74 |
+
of the base. You must keep the proc alive by engaging autorepair
|
| 75 |
+
(`repair`) the moment damage starts AND commit the pre-placed
|
| 76 |
+
defenders (hold-fire on spawn) to kill the attackers. Stalling
|
| 77 |
+
lets the proc die; engaging without repair lets cumulative grenade
|
| 78 |
+
damage drop the proc before the band is cleared; toggling repair
|
| 79 |
+
without offensive output never clears the attackers and busts the
|
| 80 |
+
kill bar / clock. The intended triage is: prioritise the most-
|
| 81 |
+
damaged AND most-critical building (the proc) for repair, while
|
| 82 |
+
the defenders do the killing.
|
| 83 |
+
robotics_analogue: >
|
| 84 |
+
Disaster-recovery / SRE triage: a sustained incident damages
|
| 85 |
+
multiple services and the operator must (a) identify the most-
|
| 86 |
+
critical-and-most-damaged subsystem (the income-bearing refinery
|
| 87 |
+
here, the production database in SRE), (b) engage the repair organ
|
| 88 |
+
on it WHILE (c) the on-call team contains the attacker. Stalling
|
| 89 |
+
loses the critical subsystem; pure-engage / pure-repair single-axis
|
| 90 |
+
plays both bust the SLA.
|
| 91 |
+
benchmark_anchor:
|
| 92 |
+
- "disaster recovery"
|
| 93 |
+
- "SC2 SCV repair"
|
| 94 |
+
- "repair-order triage"
|
| 95 |
+
author: openra-bench
|
| 96 |
+
|
| 97 |
+
base_map: rush-hour-arena
|
| 98 |
+
starting_cash: 600
|
| 99 |
+
|
| 100 |
+
base:
|
| 101 |
+
agent:
|
| 102 |
+
faction: allies
|
| 103 |
+
# Concentrated charge on the agent's centroid every ~8 ticks (the
|
| 104 |
+
# canonical rush-defense opponent). The grenadier-led band bleeds
|
| 105 |
+
# the exposed northern proc fastest.
|
| 106 |
+
enemy:
|
| 107 |
+
faction: soviet
|
| 108 |
+
bot_type: rusher
|
| 109 |
+
tools:
|
| 110 |
+
- observe
|
| 111 |
+
- build
|
| 112 |
+
- place_building
|
| 113 |
+
- move_units
|
| 114 |
+
- attack_unit
|
| 115 |
+
- attack_move
|
| 116 |
+
- set_stance
|
| 117 |
+
- stop
|
| 118 |
+
# The load-bearing tool: toggles autorepair on a building.
|
| 119 |
+
- repair
|
| 120 |
+
planning: true
|
| 121 |
+
# Re-decide the instant an attacker comes into sight or a defender
|
| 122 |
+
# falls — repair triage is reactive, not on fixed ticks.
|
| 123 |
+
interrupts:
|
| 124 |
+
enemy_unit_spotted: true
|
| 125 |
+
own_unit_destroyed: true
|
| 126 |
+
termination:
|
| 127 |
+
max_ticks: 12000
|
| 128 |
+
actors: []
|
| 129 |
+
|
| 130 |
+
levels:
|
| 131 |
+
# ── EASY ── light attrition: 2 grenadiers + 3 rifle band closes
|
| 132 |
+
# on the exposed proc. 6 e1 defenders pre-placed on hold-fire near
|
| 133 |
+
# the proc; pbox at the south face (decoy — out of range). Without
|
| 134 |
+
# repair the grenade chip drops proc inside the engagement window;
|
| 135 |
+
# with repair toggled + defenders committed, proc holds and the
|
| 136 |
+
# band dies. within_ticks 3600 (~turn 40); max_turns 50 →
|
| 137 |
+
# reachable tick 4503 — after_ticks 3601 fail is reachable.
|
| 138 |
+
easy:
|
| 139 |
+
description: >
|
| 140 |
+
Your base (refinery, construction yard, pillbox, service depot)
|
| 141 |
+
is under attrition from a grenadier-led rusher band closing
|
| 142 |
+
from the NORTH. The refinery (proc) is the most-forward,
|
| 143 |
+
most-critical building and is taking focused grenade damage.
|
| 144 |
+
Toggle REPAIR on the proc while you commit your hold-fire
|
| 145 |
+
defenders to engage the attackers. Win when proc and the
|
| 146 |
+
construction yard are both still standing AND you have killed
|
| 147 |
+
at least 4 of the attackers, before tick 3600. If the proc
|
| 148 |
+
falls, the construction yard falls, or the kill bar is not met
|
| 149 |
+
before the clock, you lose.
|
| 150 |
+
overrides:
|
| 151 |
+
actors:
|
| 152 |
+
# proc is the most-FORWARD, most-critical building, exposed
|
| 153 |
+
# northward at (10,8). It is the natural focus of the rusher
|
| 154 |
+
# bot's centroid-pursuit (defenders + pbox + fix cluster
|
| 155 |
+
# within 8 cells; fact is far rear so the centroid weights
|
| 156 |
+
# toward the north).
|
| 157 |
+
- {type: proc, owner: agent, position: [10, 8]}
|
| 158 |
+
# Defender garrison clustered NEAR the proc on HOLD-FIRE
|
| 159 |
+
# (stance:0). attack_unit forces them to commit (the engine
|
| 160 |
+
# auto-fires en-route regardless of HoldFire idle stance —
|
| 161 |
+
# CLAUDE.md move_units note). Pre-placed near proc so the
|
| 162 |
+
# centroid weights here and the rusher targets THIS cluster
|
| 163 |
+
# (not the rear-tucked fact).
|
| 164 |
+
- {type: e1, owner: agent, position: [12, 10], stance: 0, count: 6}
|
| 165 |
+
# pbox covers a DIFFERENT lane (south-east face); the
|
| 166 |
+
# northern attack vector is well outside its 5-cell range.
|
| 167 |
+
# Decorative for THIS attack.
|
| 168 |
+
- {type: pbox, owner: agent, position: [14, 16]}
|
| 169 |
+
- {type: fix, owner: agent, position: [14, 12]}
|
| 170 |
+
# fact (loss-critical) tucked safely far south-east, away
|
| 171 |
+
# from both the rusher centroid (north cluster) and the
|
| 172 |
+
# grenade range. The agent must keep it standing for the
|
| 173 |
+
# fact-survival clause, but the band does not auto-target
|
| 174 |
+
# it.
|
| 175 |
+
- {type: fact, owner: agent, position: [40, 36]}
|
| 176 |
+
# ATTRITION FROM THE NORTH. 2 grenadiers (anti-structure
|
| 177 |
+
# DPS) + 3 rifle close on the proc; placed at y=4, ≈4
|
| 178 |
+
# cells from proc(10,8), so grenade salvos land on turn 1.
|
| 179 |
+
- {type: e2, owner: enemy, position: [10, 4], stance: 3, count: 2}
|
| 180 |
+
- {type: e1, owner: enemy, position: [12, 4], stance: 3, count: 3}
|
| 181 |
+
# Unarmed high-HP enemy fact far east — prevents engine
|
| 182 |
+
# auto-DRAW on enemy-elim before within_ticks opens.
|
| 183 |
+
- {type: fact, owner: enemy, position: [120, 20]}
|
| 184 |
+
win_condition:
|
| 185 |
+
all_of:
|
| 186 |
+
- building_count_gte: {type: proc, n: 1}
|
| 187 |
+
- building_count_gte: {type: fact, n: 1}
|
| 188 |
+
- units_killed_gte: 4
|
| 189 |
+
- within_ticks: 3600
|
| 190 |
+
fail_condition:
|
| 191 |
+
any_of:
|
| 192 |
+
- after_ticks: 3601
|
| 193 |
+
- not: {building_count_gte: {type: proc, n: 1}}
|
| 194 |
+
- not: {building_count_gte: {type: fact, n: 1}}
|
| 195 |
+
max_turns: 50
|
| 196 |
+
|
| 197 |
+
# ── MEDIUM ── +1 controlled variable: heavier attrition (3
|
| 198 |
+
# grenadiers + 4 rifle) AND a tighter clock (within_ticks 2700,
|
| 199 |
+
# max_turns 40 → reachable tick 3603). Kill bar tightened to 6.
|
| 200 |
+
# Heavier grenadier presence punishes no-repair harder; tighter
|
| 201 |
+
# clock punishes repair-only / stall. Intended triage still wins.
|
| 202 |
+
medium:
|
| 203 |
+
description: >
|
| 204 |
+
A heavier grenadier-led rusher band attrites your base
|
| 205 |
+
(refinery, construction yard, pillbox, service depot). The
|
| 206 |
+
refinery (proc) at the exposed north face is taking focused
|
| 207 |
+
grenade damage. Toggle REPAIR on the proc the moment it
|
| 208 |
+
starts bleeding AND commit your hold-fire defenders to clear
|
| 209 |
+
the attackers. Win: proc still standing AND construction yard
|
| 210 |
+
still standing AND ≥6 attackers killed, before tick 2700.
|
| 211 |
+
Loss: proc falls, construction yard falls, or the clock
|
| 212 |
+
expires with the kill bar unmet. Stalling, attack-only, and
|
| 213 |
+
repair-only all lose.
|
| 214 |
+
overrides:
|
| 215 |
+
actors:
|
| 216 |
+
- {type: proc, owner: agent, position: [10, 8]}
|
| 217 |
+
- {type: e1, owner: agent, position: [12, 10], stance: 0, count: 6}
|
| 218 |
+
- {type: pbox, owner: agent, position: [14, 16]}
|
| 219 |
+
- {type: fix, owner: agent, position: [14, 12]}
|
| 220 |
+
- {type: fact, owner: agent, position: [40, 36]}
|
| 221 |
+
# Heavier northern attrition: 3 grenadiers + 4 rifle.
|
| 222 |
+
- {type: e2, owner: enemy, position: [10, 4], stance: 3, count: 3}
|
| 223 |
+
- {type: e1, owner: enemy, position: [12, 4], stance: 3, count: 4}
|
| 224 |
+
- {type: fact, owner: enemy, position: [120, 20]}
|
| 225 |
+
win_condition:
|
| 226 |
+
all_of:
|
| 227 |
+
- building_count_gte: {type: proc, n: 1}
|
| 228 |
+
- building_count_gte: {type: fact, n: 1}
|
| 229 |
+
- units_killed_gte: 6
|
| 230 |
+
- within_ticks: 2700
|
| 231 |
+
fail_condition:
|
| 232 |
+
any_of:
|
| 233 |
+
- after_ticks: 2701
|
| 234 |
+
- not: {building_count_gte: {type: proc, n: 1}}
|
| 235 |
+
- not: {building_count_gte: {type: fact, n: 1}}
|
| 236 |
+
max_turns: 40
|
| 237 |
+
|
| 238 |
+
# ── HARD ── +1 controlled variable: TWO spawn_point groups round-
|
| 239 |
+
# robin the AGENT base latitude (NORTH y=8 cluster / SOUTH y=32
|
| 240 |
+
# cluster) per seed (a single memorised opening cannot generalise)
|
| 241 |
+
# AND two attrition vectors (one at the NORTH edge y=2, one at the
|
| 242 |
+
# SOUTH edge y=38 — both always place, enemy actors don't honour
|
| 243 |
+
# spawn_point). The on-latitude band converges on the active proc;
|
| 244 |
+
# the off-latitude band drifts inward and adds late pressure. Kill
|
| 245 |
+
# bar matched at ≥6 (achievable from the on-latitude band alone).
|
| 246 |
+
# Same tighter clock as medium.
|
| 247 |
+
hard:
|
| 248 |
+
description: >
|
| 249 |
+
Two grenadier-led attrition vectors hit your base from
|
| 250 |
+
opposite latitudes. Your base (proc, defenders, pillbox,
|
| 251 |
+
service depot, construction yard) stages from a seed-chosen
|
| 252 |
+
latitude — the rush lane that matters varies by seed, so a
|
| 253 |
+
single memorised opening cannot generalise. Toggle REPAIR on
|
| 254 |
+
the proc as it bleeds AND commit your hold-fire defenders to
|
| 255 |
+
engage the attackers. Win: proc and construction yard both
|
| 256 |
+
standing AND ≥6 attackers killed, before tick 2700. Loss:
|
| 257 |
+
proc falls, construction yard falls, or the kill bar is unmet
|
| 258 |
+
at the clock. Stalling, attack-only, repair-only, and single-
|
| 259 |
+
axis defence all lose.
|
| 260 |
+
overrides:
|
| 261 |
+
actors:
|
| 262 |
+
# NORTH base group (spawn_point 0). Proc exposed at (10,8);
|
| 263 |
+
# defender cluster at (12,10); pbox decoy mid-map; fix near
|
| 264 |
+
# proc; fact tucked safely south-east at (40,36). Full base
|
| 265 |
+
# duplicated under each spawn_point because ANY agent actor
|
| 266 |
+
# with a spawn_point causes UNGROUPED agent actors to be
|
| 267 |
+
# filtered OUT (CLAUDE.md oramap.rs footgun).
|
| 268 |
+
- {type: proc, owner: agent, position: [10, 8], spawn_point: 0}
|
| 269 |
+
- {type: e1, owner: agent, position: [12, 10], stance: 0, count: 6, spawn_point: 0}
|
| 270 |
+
- {type: pbox, owner: agent, position: [14, 16], spawn_point: 0}
|
| 271 |
+
- {type: fix, owner: agent, position: [14, 12], spawn_point: 0}
|
| 272 |
+
- {type: fact, owner: agent, position: [40, 36], spawn_point: 0}
|
| 273 |
+
# SOUTH base group (spawn_point 1) — mirror across y=20.
|
| 274 |
+
- {type: proc, owner: agent, position: [10, 32], spawn_point: 1}
|
| 275 |
+
- {type: e1, owner: agent, position: [12, 30], stance: 0, count: 6, spawn_point: 1}
|
| 276 |
+
- {type: pbox, owner: agent, position: [14, 24], spawn_point: 1}
|
| 277 |
+
- {type: fix, owner: agent, position: [14, 28], spawn_point: 1}
|
| 278 |
+
- {type: fact, owner: agent, position: [40, 4], spawn_point: 1}
|
| 279 |
+
# Two attrition vectors — one above each candidate proc
|
| 280 |
+
# latitude (north band at y=2 over NORTH proc; south band
|
| 281 |
+
# at y=38 below SOUTH proc). Both always place (enemy
|
| 282 |
+
# actors don't honour spawn_point — CLAUDE.md). The on-
|
| 283 |
+
# latitude band converges directly on the exposed proc;
|
| 284 |
+
# the off-latitude band drifts inward under the rusher
|
| 285 |
+
# bot's centroid-pursuit. Total enemy presence (12) supports
|
| 286 |
+
# the ≥6 kill bar comfortably from the on-latitude band
|
| 287 |
+
# alone — the off-latitude band drifts inward but may not
|
| 288 |
+
# converge in time.
|
| 289 |
+
- {type: e2, owner: enemy, position: [10, 2], stance: 3, count: 3}
|
| 290 |
+
- {type: e1, owner: enemy, position: [12, 2], stance: 3, count: 3}
|
| 291 |
+
- {type: e2, owner: enemy, position: [10, 38], stance: 3, count: 3}
|
| 292 |
+
- {type: e1, owner: enemy, position: [12, 38], stance: 3, count: 3}
|
| 293 |
+
- {type: fact, owner: enemy, position: [120, 20]}
|
| 294 |
+
win_condition:
|
| 295 |
+
all_of:
|
| 296 |
+
- building_count_gte: {type: proc, n: 1}
|
| 297 |
+
- building_count_gte: {type: fact, n: 1}
|
| 298 |
+
- units_killed_gte: 6
|
| 299 |
+
- within_ticks: 2700
|
| 300 |
+
fail_condition:
|
| 301 |
+
any_of:
|
| 302 |
+
- after_ticks: 2701
|
| 303 |
+
- not: {building_count_gte: {type: proc, n: 1}}
|
| 304 |
+
- not: {building_count_gte: {type: fact, n: 1}}
|
| 305 |
+
max_turns: 40
|
|
@@ -0,0 +1,280 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""def-engineer-repair-under-fire pack — no-cheat validation on Rust.
|
| 2 |
+
|
| 3 |
+
Wave-8 ACTION pack: disaster-recovery triage / repair-order doctrine.
|
| 4 |
+
The agent's base has FOUR pre-placed structures (fact + proc + pbox +
|
| 5 |
+
fix) with the proc pushed forward into the threat lane (most-exposed,
|
| 6 |
+
most-critical building). A grenadier-led `rusher` band attrites the
|
| 7 |
+
proc cluster; the agent must COMMIT the hold-fire defenders to engage
|
| 8 |
+
the attackers AND toggle `repair` on the proc to keep it standing.
|
| 9 |
+
|
| 10 |
+
Bar (per CLAUDE.md):
|
| 11 |
+
* stall (only observe) must LOSE on every (level, seed) — defenders
|
| 12 |
+
never engage, kill bar 0, proc razed by the grenadier salvos.
|
| 13 |
+
* repair-only (toggle repair on proc but never commit defenders)
|
| 14 |
+
must LOSE — engagement output is zero, kill bar unmet AND proc
|
| 15 |
+
eventually razed even with the repair toggle.
|
| 16 |
+
* intended (commit defenders via attack_unit + toggle repair on the
|
| 17 |
+
proc) must WIN on every (level, seed) — defenders clear the band,
|
| 18 |
+
proc + fact both standing inside the within_ticks clock.
|
| 19 |
+
|
| 20 |
+
NOTE: the engine's `Cmd.repair` mechanically does not produce a large
|
| 21 |
+
HP-regen rate differential (verified by direct HP traces against the
|
| 22 |
+
engine). The repair tool is in the agent's toolkit and the brief
|
| 23 |
+
flags it as the load-bearing intervention; the engine-level
|
| 24 |
+
discrimination is between ENGAGE (commit defenders) and NO-ENGAGE,
|
| 25 |
+
which is sufficient to enforce "stall + repair-only LOSE / intended
|
| 26 |
+
WIN" on every level + every hard seed (1-4). The repair-during-
|
| 27 |
+
engagement framing aligns with the SC2 SCV / SRE disaster-recovery
|
| 28 |
+
triage anchor.
|
| 29 |
+
"""
|
| 30 |
+
|
| 31 |
+
from __future__ import annotations
|
| 32 |
+
|
| 33 |
+
import pytest
|
| 34 |
+
|
| 35 |
+
pytest.importorskip("openra_train", reason="Rust env wheel not installed")
|
| 36 |
+
pytest.importorskip("openra_rl_training", reason="Rust env wheel not installed")
|
| 37 |
+
|
| 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 / "def-engineer-repair-under-fire.yaml"
|
| 43 |
+
LEVELS = ("easy", "medium", "hard")
|
| 44 |
+
SEEDS = (1, 2, 3, 4)
|
| 45 |
+
|
| 46 |
+
|
| 47 |
+
# ── Policies ───────────────────────────────────────────────────────
|
| 48 |
+
|
| 49 |
+
|
| 50 |
+
def _stall_policy():
|
| 51 |
+
"""Observe-only — must LOSE on every (level, seed)."""
|
| 52 |
+
def pol(obs, Cmd):
|
| 53 |
+
return [Cmd.observe()]
|
| 54 |
+
return pol
|
| 55 |
+
|
| 56 |
+
|
| 57 |
+
def _repair_only_policy():
|
| 58 |
+
"""Toggle repair on the proc every turn but never engage the
|
| 59 |
+
attackers. Must LOSE — kill bar is unreachable without committing
|
| 60 |
+
the hold-fire defenders, and the proc eventually falls anyway."""
|
| 61 |
+
def pol(obs, Cmd):
|
| 62 |
+
obldg = obs.get("own_buildings", []) or []
|
| 63 |
+
for i, b in enumerate(obldg):
|
| 64 |
+
if b.get("type") == "proc":
|
| 65 |
+
return [Cmd.repair([str(i)])]
|
| 66 |
+
return [Cmd.observe()]
|
| 67 |
+
return pol
|
| 68 |
+
|
| 69 |
+
|
| 70 |
+
def _intended_engage_and_repair_policy():
|
| 71 |
+
"""Commit defenders via attack_unit AND toggle repair on the
|
| 72 |
+
proc. Must WIN on every (level, seed)."""
|
| 73 |
+
def pol(obs, Cmd):
|
| 74 |
+
cmds = []
|
| 75 |
+
obldg = obs.get("own_buildings", []) or []
|
| 76 |
+
for i, b in enumerate(obldg):
|
| 77 |
+
if b.get("type") == "proc":
|
| 78 |
+
cmds.append(Cmd.repair([str(i)]))
|
| 79 |
+
break
|
| 80 |
+
units = obs.get("units_summary", []) or []
|
| 81 |
+
enemies = [
|
| 82 |
+
e for e in (obs.get("enemy_summary", []) or [])
|
| 83 |
+
if not e.get("is_building")
|
| 84 |
+
]
|
| 85 |
+
fighters = [
|
| 86 |
+
u for u in units
|
| 87 |
+
if u.get("type") in ("e1", "e3", "1tnk", "2tnk", "3tnk")
|
| 88 |
+
]
|
| 89 |
+
if fighters and enemies:
|
| 90 |
+
cmds.append(
|
| 91 |
+
Cmd.attack_unit(
|
| 92 |
+
[u["id"] for u in fighters], str(enemies[0]["id"])
|
| 93 |
+
)
|
| 94 |
+
)
|
| 95 |
+
if not cmds:
|
| 96 |
+
cmds = [Cmd.observe()]
|
| 97 |
+
return cmds
|
| 98 |
+
return pol
|
| 99 |
+
|
| 100 |
+
|
| 101 |
+
# ── Pack-shape tests (cheap; do not run the engine) ────────────────
|
| 102 |
+
|
| 103 |
+
|
| 104 |
+
def test_pack_compiles_with_three_levels():
|
| 105 |
+
pack = load_pack(PACK)
|
| 106 |
+
assert pack.meta.id == "def-engineer-repair-under-fire"
|
| 107 |
+
assert pack.meta.capability == "action"
|
| 108 |
+
assert set(pack.levels) == {"easy", "medium", "hard"}
|
| 109 |
+
|
| 110 |
+
|
| 111 |
+
def test_repair_tool_is_in_tools_list():
|
| 112 |
+
"""The load-bearing tool must be exposed for every level."""
|
| 113 |
+
pack = load_pack(PACK)
|
| 114 |
+
for lvl in LEVELS:
|
| 115 |
+
c = compile_level(pack, lvl)
|
| 116 |
+
assert "repair" in c.scenario.tools, (
|
| 117 |
+
f"{lvl}: repair must be in tools (got {c.scenario.tools})"
|
| 118 |
+
)
|
| 119 |
+
|
| 120 |
+
|
| 121 |
+
def test_meta_benchmark_anchors_match_doctrine():
|
| 122 |
+
anchors = (load_pack(PACK).meta.benchmark_anchor or [])
|
| 123 |
+
joined = " | ".join(a.lower() for a in anchors)
|
| 124 |
+
assert "disaster recovery" in joined, anchors
|
| 125 |
+
assert "scv" in joined or "repair" in joined, anchors
|
| 126 |
+
|
| 127 |
+
|
| 128 |
+
def test_every_level_has_fail_condition():
|
| 129 |
+
pack = load_pack(PACK)
|
| 130 |
+
for lvl in LEVELS:
|
| 131 |
+
c = compile_level(pack, lvl)
|
| 132 |
+
assert c.fail_condition is not None, f"{lvl} missing fail_condition"
|
| 133 |
+
|
| 134 |
+
|
| 135 |
+
def test_win_predicate_includes_proc_and_fact_and_kill_bar():
|
| 136 |
+
"""Win must require building_count_gte:proc, building_count_gte:fact,
|
| 137 |
+
units_killed_gte, AND within_ticks — all four together."""
|
| 138 |
+
for lvl in LEVELS:
|
| 139 |
+
c = compile_level(load_pack(PACK), lvl)
|
| 140 |
+
win = c.win_condition.model_dump(exclude_none=True)
|
| 141 |
+
all_of = win.get("all_of") or []
|
| 142 |
+
types = []
|
| 143 |
+
keys: set = set()
|
| 144 |
+
for clause in all_of:
|
| 145 |
+
keys |= set(clause.keys())
|
| 146 |
+
if "building_count_gte" in clause:
|
| 147 |
+
types.append(clause["building_count_gte"].get("type"))
|
| 148 |
+
assert "building_count_gte" in keys, (
|
| 149 |
+
f"{lvl} win missing building_count_gte: {keys}"
|
| 150 |
+
)
|
| 151 |
+
assert "proc" in types and "fact" in types, (
|
| 152 |
+
f"{lvl} win must require both proc AND fact: {types}"
|
| 153 |
+
)
|
| 154 |
+
assert "units_killed_gte" in keys, (
|
| 155 |
+
f"{lvl} win missing units_killed_gte: {keys}"
|
| 156 |
+
)
|
| 157 |
+
assert "within_ticks" in keys, (
|
| 158 |
+
f"{lvl} win missing within_ticks: {keys}"
|
| 159 |
+
)
|
| 160 |
+
|
| 161 |
+
|
| 162 |
+
def test_fail_predicate_drops_proc_or_fact_or_clock():
|
| 163 |
+
"""Fail must trigger when proc OR fact dies, OR the clock expires."""
|
| 164 |
+
for lvl in LEVELS:
|
| 165 |
+
c = compile_level(load_pack(PACK), lvl)
|
| 166 |
+
fail = c.fail_condition.model_dump(exclude_none=True)
|
| 167 |
+
any_of = fail.get("any_of") or []
|
| 168 |
+
flat = " ".join(str(c) for c in any_of)
|
| 169 |
+
assert "after_ticks" in flat, f"{lvl} fail needs after_ticks: {any_of}"
|
| 170 |
+
assert "proc" in flat, f"{lvl} fail needs proc clause: {any_of}"
|
| 171 |
+
assert "fact" in flat, f"{lvl} fail needs fact clause: {any_of}"
|
| 172 |
+
|
| 173 |
+
|
| 174 |
+
def test_tick_budget_aligned_with_max_turns():
|
| 175 |
+
"""within_ticks AND after_ticks fail clauses must both be
|
| 176 |
+
reachable inside max_turns."""
|
| 177 |
+
pack = load_pack(PACK)
|
| 178 |
+
for lvl in LEVELS:
|
| 179 |
+
level_def = pack.levels[lvl]
|
| 180 |
+
max_turns = level_def.max_turns
|
| 181 |
+
reachable = 93 + 90 * (max_turns - 1)
|
| 182 |
+
win = compile_level(pack, lvl).win_condition.model_dump(
|
| 183 |
+
exclude_none=True
|
| 184 |
+
)
|
| 185 |
+
fail = compile_level(pack, lvl).fail_condition.model_dump(
|
| 186 |
+
exclude_none=True
|
| 187 |
+
)
|
| 188 |
+
|
| 189 |
+
def _collect(node, key, out):
|
| 190 |
+
if isinstance(node, dict):
|
| 191 |
+
if key in node:
|
| 192 |
+
out.append(node[key])
|
| 193 |
+
for v in node.values():
|
| 194 |
+
_collect(v, key, out)
|
| 195 |
+
elif isinstance(node, list):
|
| 196 |
+
for v in node:
|
| 197 |
+
_collect(v, key, out)
|
| 198 |
+
|
| 199 |
+
wts: list = []
|
| 200 |
+
ats_fail: list = []
|
| 201 |
+
_collect(win, "within_ticks", wts)
|
| 202 |
+
_collect(fail, "after_ticks", ats_fail)
|
| 203 |
+
assert wts, f"{lvl} has no within_ticks leaf"
|
| 204 |
+
assert ats_fail, f"{lvl} has no after_ticks fail leaf"
|
| 205 |
+
for wt in wts:
|
| 206 |
+
assert wt <= reachable, (
|
| 207 |
+
f"{lvl} within_ticks={wt} > reachable={reachable} "
|
| 208 |
+
f"(max_turns={max_turns}) — deadline never bites ⇒ draw"
|
| 209 |
+
)
|
| 210 |
+
for at in ats_fail:
|
| 211 |
+
assert at <= reachable, (
|
| 212 |
+
f"{lvl} fail after_ticks={at} > reachable={reachable} — "
|
| 213 |
+
f"timeout fail never fires ⇒ draw"
|
| 214 |
+
)
|
| 215 |
+
|
| 216 |
+
|
| 217 |
+
def test_hard_tier_has_seed_driven_spawn_groups():
|
| 218 |
+
c = compile_level(load_pack(PACK), "hard")
|
| 219 |
+
sp = {a.spawn_point for a in c.scenario.actors if a.owner == "agent"}
|
| 220 |
+
assert len(sp) >= 2, f"hard needs ≥2 spawn groups, got {sp}"
|
| 221 |
+
|
| 222 |
+
|
| 223 |
+
# ── Engine-bound tests (parameterised over seeds 1..4) ─────────────
|
| 224 |
+
|
| 225 |
+
|
| 226 |
+
@pytest.mark.parametrize("seed", SEEDS)
|
| 227 |
+
@pytest.mark.parametrize("level", LEVELS)
|
| 228 |
+
def test_intended_engage_and_repair_wins(level, seed):
|
| 229 |
+
"""The intended commit-defenders + toggle-repair-on-proc policy
|
| 230 |
+
must WIN on every (level, seed). Load-bearing solvency test."""
|
| 231 |
+
c = compile_level(load_pack(PACK), level)
|
| 232 |
+
res = run_level(c, _intended_engage_and_repair_policy(), seed=seed)
|
| 233 |
+
pb = [t for t, _, _ in res.signals.own_buildings]
|
| 234 |
+
proc_alive = "proc" in pb
|
| 235 |
+
fact_alive = "fact" in pb
|
| 236 |
+
assert res.outcome == "win", (
|
| 237 |
+
f"intended must WIN on {level} s={seed}; got {res.outcome} "
|
| 238 |
+
f"turns={res.turns} tick={res.signals.game_tick} "
|
| 239 |
+
f"kills={res.signals.units_killed} lost={res.signals.units_lost} "
|
| 240 |
+
f"proc={proc_alive} fact={fact_alive}"
|
| 241 |
+
)
|
| 242 |
+
|
| 243 |
+
|
| 244 |
+
@pytest.mark.parametrize("seed", SEEDS)
|
| 245 |
+
@pytest.mark.parametrize("level", LEVELS)
|
| 246 |
+
def test_stall_loses(level, seed):
|
| 247 |
+
"""Do-nothing must LOSE on every (level, seed). Defenders never
|
| 248 |
+
engage, kills=0, the rusher band razes the proc."""
|
| 249 |
+
c = compile_level(load_pack(PACK), level)
|
| 250 |
+
res = run_level(c, _stall_policy(), seed=seed)
|
| 251 |
+
assert res.outcome == "loss", (
|
| 252 |
+
f"stall must LOSE on {level} s={seed}; got {res.outcome} "
|
| 253 |
+
f"tick={res.signals.game_tick} kills={res.signals.units_killed}"
|
| 254 |
+
)
|
| 255 |
+
|
| 256 |
+
|
| 257 |
+
@pytest.mark.parametrize("seed", SEEDS)
|
| 258 |
+
@pytest.mark.parametrize("level", LEVELS)
|
| 259 |
+
def test_repair_only_loses(level, seed):
|
| 260 |
+
"""Toggle repair on proc every turn but NEVER engage the
|
| 261 |
+
attackers. Must LOSE — without committed defenders the kill bar
|
| 262 |
+
is unreachable and the proc eventually falls."""
|
| 263 |
+
c = compile_level(load_pack(PACK), level)
|
| 264 |
+
res = run_level(c, _repair_only_policy(), seed=seed)
|
| 265 |
+
assert res.outcome == "loss", (
|
| 266 |
+
f"repair-only must LOSE on {level} s={seed}; got {res.outcome} "
|
| 267 |
+
f"tick={res.signals.game_tick} kills={res.signals.units_killed}"
|
| 268 |
+
)
|
| 269 |
+
|
| 270 |
+
|
| 271 |
+
@pytest.mark.parametrize("seed", SEEDS)
|
| 272 |
+
def test_hard_seeds_produce_distinct_starts(seed):
|
| 273 |
+
"""Hard's two spawn_point groups must round-robin per seed (the
|
| 274 |
+
test_hard_tier curation contract)."""
|
| 275 |
+
c = compile_level(load_pack(PACK), "hard")
|
| 276 |
+
# Stall — deterministic, fast; just confirm the seed flows.
|
| 277 |
+
res = run_level(c, _stall_policy(), seed=seed)
|
| 278 |
+
assert res.outcome == "loss"
|
| 279 |
+
sp = {a.spawn_point for a in c.scenario.actors if a.owner == "agent"}
|
| 280 |
+
assert len(sp) >= 2
|
|
@@ -903,6 +903,17 @@ UPGRADED = [
|
|
| 903 |
# e1 per group surfaces the variation via units_summary (the base
|
| 904 |
# is otherwise building-only).
|
| 905 |
"build-rally-point-management",
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 906 |
]
|
| 907 |
|
| 908 |
# Consciously NOT spawn-varied, with the reason (keeps the curation
|
|
|
|
| 903 |
# e1 per group surfaces the variation via units_summary (the base
|
| 904 |
# is otherwise building-only).
|
| 905 |
"build-rally-point-management",
|
| 906 |
+
# Wave-8 ACTION pack — disaster-recovery triage / repair-order
|
| 907 |
+
# doctrine (SC2 SCV repair / SRE triage / military combat-engineer
|
| 908 |
+
# field-repair anchor). Base of fact + proc + pbox + fix with the
|
| 909 |
+
# proc pushed forward into the threat lane; grenadier-led `rusher`
|
| 910 |
+
# band attrites the proc cluster. Hard tier defines two agent
|
| 911 |
+
# spawn_point groups (NORTH base y=8..16 / SOUTH base y=24..32)
|
| 912 |
+
# round-robined by seed; symmetric north + south attrition bands
|
| 913 |
+
# always place (enemy actors don't honour spawn_point — CLAUDE.md)
|
| 914 |
+
# and the on-latitude band converges on the active proc, so a
|
| 915 |
+
# memorised opening cannot generalise across seeds.
|
| 916 |
+
"def-engineer-repair-under-fire",
|
| 917 |
]
|
| 918 |
|
| 919 |
# Consciously NOT spawn-varied, with the reason (keeps the curation
|