yxc20098 commited on
Commit
f8f4a41
·
1 Parent(s): f46590b

fix(scenario): build-repair-priority-under-fire — recalibrate after engine movement fixes

Browse files

The engine move-fire fixes made pre-placed stance:3 defenders resolve a
clustered fire-fight in one ~90-tick window with the buildings never
scratched: stall WON every level/seed (all bands cleared by tick 108,
buildings at 100%, kill bar instantly met). The old slow damage race no
longer exists.

Redesign: drop the agent combat units and the kill bar entirely; the
triage is now a pure `repair`-target decision against an unopposed
grenadier band. proc (wood) dies unrepaired and is saved by autorepair;
pbox is pre-damaged (health: 30) Heavy-armoured decoy that survives on
its own; weap rides out a bridged-proc-pinned band on easy but needs
its own repair on medium/hard. Win now requires SURVIVING a danger
window (after_ticks floor + within_ticks ceiling) so it cannot latch on
turn 1. stall / repair-pbox lose every level+seed; repair-proc wins
easy, loses medium/hard; intended repair-proc+weap and repair-all win
every level+seed.

openra_bench/scenarios/packs/build-repair-priority-under-fire.yaml CHANGED
@@ -1,48 +1,56 @@
1
  # build-repair-priority-under-fire — criticality-weighted repair triage:
2
  # three damaged buildings under fire, repair the high-value-and-lethally-
3
- # threatened one FIRST.
4
  #
5
  # REASONING capability — Wave-10 "disaster-recovery triage / criticality-
6
  # weighted maintenance" pack. Three of the agent's structures are under
7
  # simultaneous attrition from three separate grenadier bands, and the
8
- # agent's hold-the-line job is a pure TRIAGE DECISION: which building to
9
- # put the `repair` organ on FIRST. The three buildings present a
10
  # deliberately mis-leading damage picture:
11
  #
12
- # - proc (refinery) — HIGH value, on a LETHAL trajectory. Six
13
- # grenadiers chew the wood-armoured refinery down and KILL it inside
14
- # ~2 turns if it is not repaired. proc is required by the win
15
- # predicate; losing it is an instant fail.
16
- # - pbox (pillbox) — LOW value, the DECOY. Five grenadiers chip the
17
- # pillbox down to ~25% HP fast, so a naive "repair the most-damaged
18
- # building" heuristic targets the pbox but the pbox is NOT in the
19
- # win predicate and, once its band is cleared by the local
20
- # defenders, it STABILISES at ~25% and survives untouched. Repairing
21
- # it is wasted effort.
22
- # - weap (war factory) MEDIUM. On easy a light band leaves the
23
- # high-HP war factory standing on its own; on medium/hard the band
24
- # is heavy enough that weap ALSO needs repair, deepening the triage
25
- # from "pick the 1 critical building" to "pick the 2 critical
26
- # buildings, skip the low-value decoy".
 
 
 
 
27
  #
28
  # The intended capability is criticality-weighted triage: rank by
29
  # (value x lethal-trajectory), not by raw damage percentage. The pbox
30
- # LOOKS the most damaged; the proc IS the one that dies. Repair the
31
- # proc (easy) / the proc AND the weap (medium, hard) and skip the pbox.
 
32
  #
33
  # Bar:
34
  # - stall (only observe): LOSS — the proc band is never bridged; the
35
- # refinery is chewed to 0 inside ~2 turns; fail clause `not
36
  # building_count_gte:proc` fires.
37
  # - repair-pbox-first (toggle repair on the most-damaged-LOOKING
38
  # building, the pbox): LOSS — the pbox is saved but the pbox is not
39
- # in the win predicate; the proc gets no repair and dies; same fail
40
- # clause fires.
41
- # - INTENDED triage (repair proc first easy; proc + weap medium /
42
- # hard): WIN the autorepair heal rate (1% max-HP/tick) out-paces
43
- # the grenade chip on the bridged building(s); the pre-placed
44
- # hold-the-line defenders clear the bands; the kill bar latches and
45
- # proc (+ weap) are still standing inside the clock.
 
 
 
46
  #
47
  # Anchor: disaster-recovery triage / criticality-weighted maintenance —
48
  # the SRE incident-response playbook ("triage by criticality x blast
@@ -54,39 +62,46 @@
54
  # ENGINE FACTS (CLAUDE.md):
55
  # - `repair` toggles autorepair on a building ID; it heals ~1% of the
56
  # building's max HP per tick and deducts a small proportional cash
57
- # cost (world.rs:3046-3094). Repair auto-stops when the building is
58
- # full or the owner runs out of cash. Buildings surface in the
59
- # agent's `own_buildings` observation, each with a list-index `id`
60
- # the agent targets directly.
61
- # - `building_count_gte:{type: proc, n: 1}` enforces proc-survival
62
- # atomically every tick re-counts the live building actors (the
63
- # accumulating-set footgun is on `has_building`, not on
64
- # building_count_gte).
65
- # - Reachable tick approx 93 + 90*(max_turns-1). Every within_ticks /
66
- # after_ticks value below sits inside reachable for its tier, so the
67
- # deadline always bites (no draw degeneracy). A "repair proc only,
68
- # let the weap die" play on medium/hard loses the war factory, which
69
- # trips the `not building_count_gte:{weap}` fail clause immediately —
70
- # a clean LOSS the moment the building falls, no draw.
71
- # - Grenadier (e2) carries the Grenade weapon: 6000 damage, Versus
72
- # Wood 100% / Heavy 25%, 60-tick reload. proc and weap are
73
- # Wood-armoured (full chip); pbox is Heavy-armoured (quarter chip),
74
- # so the SAME band size bleeds proc/weap much faster than pbox.
75
- # - Enemy bands are stance:3 (AttackAnything) with NO bot_type they
76
- # stay localised on their own building cluster instead of charging a
77
- # shared centroid, so the three-way damage picture is stable and the
78
- # triage is a clean read.
79
- # - The agent's hold-the-line defenders are stance:3 (AttackAnything)
80
- # so they auto-advance into and clear their local band without an
81
- # explicit order this pack tests the REPAIR-TARGET decision, not
82
- # combat micro. The kill bar is reachable purely from the auto-clears.
 
 
 
 
 
 
 
 
 
 
 
 
83
  # - The unarmed high-HP enemy `fact` far east prevents the engine
84
  # auto-`done` on enemy-elimination before the win/clock is evaluated.
85
- # Because the loss/draw boundary must never depend on a race between
86
- # the stance:3 defenders hunting that far `fact` and the deadline,
87
- # the fail predicate ALSO trips on `not building_count_gte:{weap}` —
88
- # the moment a required building falls the run is a clean immediate
89
- # LOSS, never a draw.
90
  # - hard: agent base + enemy bands BOTH declare `spawn_point` (per-owner
91
  # filter, oramap.rs::expand_scenario_actors) so each seed instantiates
92
  # exactly one base latitude with its matching bands — a memorised
@@ -99,16 +114,16 @@ meta:
99
  real_world_meaning: >
100
  Three of your structures are under simultaneous attrition and you
101
  have one repair organ to commit. The damage picture is deliberately
102
- misleading: the pillbox (pbox) is chipped to ~25% HP and LOOKS the
103
- most damaged, but it is low-value and, once its attackers are
104
- cleared, it survives on its own. The refinery (proc) looks less
105
- damaged at first but is on a LETHAL trajectory six grenadiers kill
106
- it within ~2 turns unless you toggle `repair` on it immediately. The
107
- intended decision is criticality-weighted triage: rank by value x
108
- lethal-trajectory, not by raw damage percent. Repair the proc first
109
- (and on the harder tiers the war factory too); skipping the
110
- refinery to "fix the worst-looking building" loses it, and stalling
111
- loses it.
112
  robotics_analogue: >
113
  SRE / disaster-recovery incident triage: several services are
114
  degraded at once and the operator has one repair lever. The correct
@@ -130,9 +145,9 @@ starting_cash: 8000
130
  base:
131
  agent:
132
  faction: allies
133
- # Stance-only enemy (no bot_type) — each grenadier band stays on its
134
- # own building cluster rather than charging a shared centroid, so the
135
- # three-way damage picture stays a clean, stable read.
136
  enemy:
137
  faction: soviet
138
  tools:
@@ -140,8 +155,8 @@ base:
140
  # The load-bearing tool: toggles autorepair on a building.
141
  - repair
142
  planning: true
143
- # Re-decide the instant the attackers come into sight or a defender
144
- # falls — repair triage is reactive, decided turn 1, not on a fixed
145
  # tick schedule.
146
  interrupts:
147
  enemy_unit_spotted: true
@@ -151,48 +166,50 @@ base:
151
  actors: []
152
 
153
  levels:
154
- # ── EASY ── the bare triage skill. Three buildings, three bands:
155
- # proc — 6 grenadiers / 4 defenders DIES ~turn 2 unrepaired;
156
- # autorepair out-paces the chip and it survives.
157
- # weap 3 grenadiers / 3 defenders → high-HP war factory rides
158
- # out the band on its own HP (no repair needed).
159
- # pbox 5 grenadiers / 5 defenders chipped to ~25% fast (the
160
- # most-damaged-LOOKING building) then stabilises; low value,
161
- # NOT in the win predicate.
162
- # Intended play: repair the proc. within_ticks 2400 (~turn 27);
163
- # max_turns 30 → reachable tick 2703, so after_ticks 2401 bites.
 
 
 
 
164
  easy:
165
  description: >
166
- Three of your structures are under attrition from three separate
167
- grenadier bands: the refinery (proc), the war factory (weap), and
168
- the pillbox (pbox). The pillbox is chipped down fast and looks the
169
- most damaged — but it is low value and its defenders will save it.
170
- The refinery is on a lethal trajectory: it dies within about two
171
- turns unless you toggle REPAIR on it now. Win when the refinery
172
- and the war factory are both still standing AND you have killed at
173
- least 12 attackers, before tick 2400. If the refinery falls, or
174
- the clock runs out with the kill bar unmet, you lose. Stalling and
 
 
175
  repairing the most-damaged-looking building (the pillbox) both
176
  lose the refinery.
177
  overrides:
178
  actors:
179
- # proc — HIGH value, LETHAL trajectory. 6 grenadiers kill the
180
- # wood-armoured refinery in ~2 turns; 4 hold-the-line defenders
181
- # clear the band in ~4. Repair must bridge the gap.
182
  - {type: proc, owner: agent, position: [20, 8]}
183
- - {type: e3, owner: agent, position: [26, 9], stance: 3, count: 4}
184
- - {type: e2, owner: enemy, position: [20, 4], stance: 3, count: 6}
185
- # weap MEDIUM. Light 3-grenadier band; the high-HP war
186
- # factory rides it out on its own HP — no repair needed on easy.
187
  - {type: weap, owner: agent, position: [20, 20]}
188
- - {type: e3, owner: agent, position: [26, 21], stance: 3, count: 3}
189
- - {type: e2, owner: enemy, position: [20, 16], stance: 3, count: 3}
190
- # pbox LOW value DECOY. 5 grenadiers chip the pillbox to ~25%
191
- # fast (looks worst); 5 defenders clear the band; pbox then
192
- # stabilises and survives. Not in the win predicate.
193
- - {type: pbox, owner: agent, position: [20, 32]}
194
- - {type: e3, owner: agent, position: [26, 33], stance: 3, count: 5}
195
- - {type: e2, owner: enemy, position: [20, 36], stance: 3, count: 5}
196
  # Base context — service depot + construction yard tucked safely
197
  # in the rear, away from every band.
198
  - {type: fix, owner: agent, position: [44, 20]}
@@ -204,50 +221,48 @@ levels:
204
  all_of:
205
  - building_count_gte: {type: proc, n: 1}
206
  - building_count_gte: {type: weap, n: 1}
207
- - units_killed_gte: 12
208
  - within_ticks: 2400
209
  fail_condition:
210
  any_of:
211
  - after_ticks: 2401
212
  - not: {building_count_gte: {type: proc, n: 1}}
213
  - not: {building_count_gte: {type: weap, n: 1}}
214
- max_turns: 30
215
 
216
- # ── MEDIUM ── +1 controlled variable: the war factory's band is now
217
- # heavy enough (7 grenadiers) that the weap ALSO dies unrepaired. The
218
- # triage deepens from "repair the 1 critical building" to "repair the
219
- # 2 critical buildings (proc + weap), skip the low-value decoy
220
- # (pbox)". Tighter clock: within_ticks 2000 (~turn 22), max_turns 26
221
- # reachable tick 2343, so after_ticks 2001 bites. A "repair proc
222
- # only" play keeps the proc but loses the weap → the win never
223
- # latches (weap_count_gte fails) the after_ticks fail catches it as
224
- # a LOSS, not a draw.
 
225
  medium:
226
  description: >
227
- Three structures are under attrition from three grenadier bands:
228
- the refinery (proc), the war factory (weap), and the pillbox
229
- (pbox). The pillbox is chipped down fast and looks the most
230
- damaged — but it is low value and survives on its own. The
231
- refinery AND the war factory are both on lethal trajectories: each
232
- dies within a few turns unless you toggle REPAIR on it. Win when
233
- the refinery and the war factory are both still standing AND you
234
- have killed at least 15 attackers, before tick 2000. If the
235
- refinery falls, or the clock runs out with the win unmet, you
236
- lose. Stalling, repairing only the pillbox, and repairing only one
237
- of the two critical buildings all lose.
238
  overrides:
239
  actors:
240
  - {type: proc, owner: agent, position: [20, 8]}
241
- - {type: e3, owner: agent, position: [26, 9], stance: 3, count: 4}
242
- - {type: e2, owner: enemy, position: [20, 4], stance: 3, count: 6}
243
- # weap band heavier (7 grenadiers) the war factory now dies
244
- # unrepaired; repair is mandatory on it too.
245
  - {type: weap, owner: agent, position: [20, 20]}
246
- - {type: e3, owner: agent, position: [26, 21], stance: 3, count: 3}
247
- - {type: e2, owner: enemy, position: [20, 16], stance: 3, count: 7}
248
- - {type: pbox, owner: agent, position: [20, 32]}
249
- - {type: e3, owner: agent, position: [26, 33], stance: 3, count: 5}
250
- - {type: e2, owner: enemy, position: [20, 36], stance: 3, count: 5}
251
  - {type: fix, owner: agent, position: [44, 20]}
252
  - {type: fact, owner: agent, position: [48, 20]}
253
  - {type: fact, owner: enemy, position: [120, 20]}
@@ -255,70 +270,64 @@ levels:
255
  all_of:
256
  - building_count_gte: {type: proc, n: 1}
257
  - building_count_gte: {type: weap, n: 1}
258
- - units_killed_gte: 15
259
- - within_ticks: 2000
260
  fail_condition:
261
  any_of:
262
- - after_ticks: 2001
263
  - not: {building_count_gte: {type: proc, n: 1}}
264
  - not: {building_count_gte: {type: weap, n: 1}}
265
- max_turns: 26
266
 
267
  # ── HARD ── +1 controlled variable: TWO spawn_point groups round-
268
- # robin the AGENT base latitude (NORTH y=8..32 cluster / SOUTH
269
- # y=8..32 mirror) per seed, so a single memorised opening cannot
270
- # generalise. Agent base AND the matching enemy bands BOTH declare
271
- # `spawn_point` — the per-owner filter (oramap.rs::expand_scenario_
272
- # actors) instantiates exactly one self-consistent base + bands per
273
- # seed; the persistent every-seed enemy auto-`done` fact is
274
- # duplicated across both groups. Same composition and clock as
275
- # medium (proc + weap both lethal; pbox decoy).
276
  hard:
277
  description: >
278
  Three structures — the refinery (proc), the war factory (weap),
279
  and the pillbox (pbox) — are under attrition from three grenadier
280
- bands. Your base stages from a seed-chosen latitude, so a single
281
- memorised opening cannot generalise. The pillbox is chipped down
282
- fast and looks the most damaged but is low value and survives on
283
- its own; the refinery and the war factory are both on lethal
284
- trajectories and each needs REPAIR. Win when the refinery and the
285
- war factory are both still standing AND you have killed at least
286
- 15 attackers, before tick 2000. If the refinery falls, or the
287
  clock runs out with the win unmet, you lose. Stalling, repairing
288
  only the pillbox, and repairing only one critical building all
289
  lose.
290
  overrides:
291
  actors:
292
- # NORTH base group (spawn_point 0). Full base + bands duplicated
293
  # under each spawn_point: ANY agent actor with a spawn_point
294
  # filters OUT every ungrouped agent actor, and the matching
295
  # enemy bands declare the same spawn_point so each seed gets a
296
  # self-consistent instantiation.
297
  - {type: proc, owner: agent, position: [20, 8], spawn_point: 0}
298
- - {type: e3, owner: agent, position: [26, 9], stance: 3, count: 4, spawn_point: 0}
299
- - {type: e2, owner: enemy, position: [20, 4], stance: 3, count: 6, spawn_point: 0}
300
  - {type: weap, owner: agent, position: [20, 20], spawn_point: 0}
301
- - {type: e3, owner: agent, position: [26, 21], stance: 3, count: 3, spawn_point: 0}
302
- - {type: e2, owner: enemy, position: [20, 16], stance: 3, count: 7, spawn_point: 0}
303
- - {type: pbox, owner: agent, position: [20, 32], spawn_point: 0}
304
- - {type: e3, owner: agent, position: [26, 33], stance: 3, count: 5, spawn_point: 0}
305
- - {type: e2, owner: enemy, position: [20, 36], stance: 3, count: 5, spawn_point: 0}
306
  - {type: fix, owner: agent, position: [44, 20], spawn_point: 0}
307
  - {type: fact, owner: agent, position: [48, 20], spawn_point: 0}
308
  - {type: fact, owner: enemy, position: [120, 20], spawn_point: 0}
309
- # SOUTH base group (spawn_point 1) — the base mirrored east-
310
  # ward into the x=70.. column with the same y-geometry, so each
311
  # seed faces a structurally identical triage from a different
312
  # start. Bands and the auto-`done` fact mirror with it.
313
  - {type: proc, owner: agent, position: [70, 8], spawn_point: 1}
314
- - {type: e3, owner: agent, position: [76, 9], stance: 3, count: 4, spawn_point: 1}
315
- - {type: e2, owner: enemy, position: [70, 4], stance: 3, count: 6, spawn_point: 1}
316
  - {type: weap, owner: agent, position: [70, 20], spawn_point: 1}
317
- - {type: e3, owner: agent, position: [76, 21], stance: 3, count: 3, spawn_point: 1}
318
- - {type: e2, owner: enemy, position: [70, 16], stance: 3, count: 7, spawn_point: 1}
319
- - {type: pbox, owner: agent, position: [70, 32], spawn_point: 1}
320
- - {type: e3, owner: agent, position: [76, 33], stance: 3, count: 5, spawn_point: 1}
321
- - {type: e2, owner: enemy, position: [70, 36], stance: 3, count: 5, spawn_point: 1}
322
  - {type: fix, owner: agent, position: [94, 20], spawn_point: 1}
323
  - {type: fact, owner: agent, position: [98, 20], spawn_point: 1}
324
  - {type: fact, owner: enemy, position: [120, 20], spawn_point: 1}
@@ -326,11 +335,11 @@ levels:
326
  all_of:
327
  - building_count_gte: {type: proc, n: 1}
328
  - building_count_gte: {type: weap, n: 1}
329
- - units_killed_gte: 15
330
- - within_ticks: 2000
331
  fail_condition:
332
  any_of:
333
- - after_ticks: 2001
334
  - not: {building_count_gte: {type: proc, n: 1}}
335
  - not: {building_count_gte: {type: weap, n: 1}}
336
- max_turns: 26
 
1
  # build-repair-priority-under-fire — criticality-weighted repair triage:
2
  # three damaged buildings under fire, repair the high-value-and-lethally-
3
+ # threatened one(s) FIRST.
4
  #
5
  # REASONING capability — Wave-10 "disaster-recovery triage / criticality-
6
  # weighted maintenance" pack. Three of the agent's structures are under
7
  # simultaneous attrition from three separate grenadier bands, and the
8
+ # agent's hold-the-line job is a pure TRIAGE DECISION: which building(s)
9
+ # to put the `repair` organ on. The three buildings present a
10
  # deliberately mis-leading damage picture:
11
  #
12
+ # - proc (refinery) — HIGH value, on a LETHAL trajectory. A
13
+ # grenadier band chews the wood-armoured refinery down and KILLS it
14
+ # unrepaired; toggling `repair` on it makes the ~1%/tick autorepair
15
+ # out-pace the grenade chip so it rides out the band. proc is
16
+ # required by the win predicate; losing it is an instant fail.
17
+ # - pbox (pillbox) — LOW value, the DECOY. It is pre-placed already
18
+ # damaged (health: 30 chipped to ~30% HP), so a naive "repair the
19
+ # most-damaged-LOOKING building" heuristic targets the pbox. But the
20
+ # pbox is Heavy-armoured: the grenade Versus-Heavy multiplier is so
21
+ # low the band barely scratches it, and it is NOT in the win
22
+ # predicate it survives untouched at ~30% on its own. Repairing it
23
+ # is wasted effort that leaves the proc to die.
24
+ # - weap (war factory) MEDIUM. On easy NO band sits on the war
25
+ # factory directly: as long as the proc is bridged its band stays
26
+ # pinned and the war factory is never touched, so repairing the proc
27
+ # alone saves both. On medium / hard a light band ALSO chews the
28
+ # wood-armoured war factory, so it too must be repaired — deepening
29
+ # the triage from "pick the 1 critical building" to "pick the 2
30
+ # critical buildings, skip the low-value decoy".
31
  #
32
  # The intended capability is criticality-weighted triage: rank by
33
  # (value x lethal-trajectory), not by raw damage percentage. The pbox
34
+ # LOOKS the most damaged; the proc (+ weap) IS the one that dies.
35
+ # Repair the proc (easy) / the proc AND the weap (medium, hard) and
36
+ # skip the pbox.
37
  #
38
  # Bar:
39
  # - stall (only observe): LOSS — the proc band is never bridged; the
40
+ # refinery is chewed to 0 inside ~3 turns; fail clause `not
41
  # building_count_gte:proc` fires.
42
  # - repair-pbox-first (toggle repair on the most-damaged-LOOKING
43
  # building, the pbox): LOSS — the pbox is saved but the pbox is not
44
+ # in the win predicate and survives on its own anyway; the proc gets
45
+ # no repair and dies; same fail clause fires.
46
+ # - repair-proc-only: WIN on easy (no band sits on the war factory,
47
+ # so a bridged proc keeps its band pinned and the war factory is
48
+ # never threatened) but LOSS on medium / hard (a light band ALSO
49
+ # chews the war factory; `not building_count_gte:weap` fires).
50
+ # - INTENDED triage (repair proc easy; proc + weap — medium /
51
+ # hard): WIN — the autorepair heal rate out-paces the grenade chip
52
+ # on the bridged building(s); proc (+ weap) are still standing when
53
+ # the survival window opens and the win latches.
54
  #
55
  # Anchor: disaster-recovery triage / criticality-weighted maintenance —
56
  # the SRE incident-response playbook ("triage by criticality x blast
 
62
  # ENGINE FACTS (CLAUDE.md):
63
  # - `repair` toggles autorepair on a building ID; it heals ~1% of the
64
  # building's max HP per tick and deducts a small proportional cash
65
+ # cost. Repair auto-stops when the building is full or the owner runs
66
+ # out of cash. Buildings surface in the agent's `own_buildings`
67
+ # observation, each with a real engine `id` the agent targets
68
+ # directly.
69
+ # - `health:` on a pre-placed actor IS honoured (oramap.rs parser
70
+ # carries the HP percentage through to the spawned actor's Health
71
+ # trait). The pbox is pre-placed at `health: 30` so it LOOKS the
72
+ # worst-damaged building from turn 1 — the decoy bait.
73
+ # - Grenadier (e2) carries the Grenade weapon: Versus Wood ~full,
74
+ # Versus Heavy ~quarter. proc and weap are Wood-armoured (full chip
75
+ # lethal unrepaired); pbox is Heavy-armoured (quarter chip the
76
+ # band barely scratches it, so a pre-damaged pbox survives on its own
77
+ # at ~30%).
78
+ # - Enemy bands are stance:3 (AttackAnything) with NO bot_type — each
79
+ # band engages the building cluster on its own latitude. After the
80
+ # move-fire engine fix a stance:3 attacker whose target building has
81
+ # been destroyed advances on the next structure: this is what
82
+ # couples the proc band to the war factory on easy — bridge the proc
83
+ # and its band stays pinned; let the proc die and its band migrates
84
+ # south and finishes the war factory too.
85
+ # - This pack has NO agent combat units and NO kill bar: the engine
86
+ # movement fixes made pre-placed defenders resolve a clustered
87
+ # fire-fight in a single ~90-tick window with the buildings never
88
+ # scratched, collapsing the damage race. The triage is now a pure
89
+ # `repair`-target decision against an unopposed band repair is the
90
+ # ONLY lever and is genuinely load-bearing.
91
+ # - The win uses an `after_ticks` floor (the building must SURVIVE the
92
+ # danger window, not merely be alive on turn 1) AND a `within_ticks`
93
+ # ceiling. This win is building-SURVIVAL, not ConquestVictory — there
94
+ # is no enemy MustBeDestroyed objective — so `after_ticks` in the win
95
+ # clause is safe (no auto-`done` race). The unrepaired buildings die
96
+ # well before the `after_ticks` floor; the correctly-repaired ones
97
+ # ride the band out past it. Reachable tick approx 93 + 90*(max_turns
98
+ # -1); every after_ticks / within_ticks below sits inside reachable
99
+ # for its tier, so the deadline always bites (no draw degeneracy).
100
+ # - The fail predicate trips the moment a required building falls
101
+ # (`not building_count_gte:{proc}` / `{weap}`) — a clean immediate
102
+ # LOSS, never a draw, well before the survival window opens.
103
  # - The unarmed high-HP enemy `fact` far east prevents the engine
104
  # auto-`done` on enemy-elimination before the win/clock is evaluated.
 
 
 
 
 
105
  # - hard: agent base + enemy bands BOTH declare `spawn_point` (per-owner
106
  # filter, oramap.rs::expand_scenario_actors) so each seed instantiates
107
  # exactly one base latitude with its matching bands — a memorised
 
114
  real_world_meaning: >
115
  Three of your structures are under simultaneous attrition and you
116
  have one repair organ to commit. The damage picture is deliberately
117
+ misleading: the pillbox (pbox) is pre-damaged to ~30% HP and LOOKS
118
+ the most damaged, but it is low-value, heavily armoured, and the
119
+ grenadiers barely scratch it — it survives on its own. The refinery
120
+ (proc) starts intact but is on a LETHAL trajectory: its grenadier
121
+ band kills it within a few turns unless you toggle `repair` on it
122
+ immediately. The intended decision is criticality-weighted triage:
123
+ rank by value x lethal-trajectory, not by raw damage percent. Repair
124
+ the proc first (and on the harder tiers the war factory too);
125
+ skipping the refinery to "fix the worst-looking building" loses it,
126
+ and stalling loses it.
127
  robotics_analogue: >
128
  SRE / disaster-recovery incident triage: several services are
129
  degraded at once and the operator has one repair lever. The correct
 
145
  base:
146
  agent:
147
  faction: allies
148
+ # Stance-only enemy (no bot_type) — each grenadier band engages the
149
+ # building cluster on its own latitude rather than charging a shared
150
+ # centroid, so the three-way damage picture stays a clean, stable read.
151
  enemy:
152
  faction: soviet
153
  tools:
 
155
  # The load-bearing tool: toggles autorepair on a building.
156
  - repair
157
  planning: true
158
+ # Re-decide the instant the attackers come into sight or a building
159
+ # is lost — repair triage is reactive, decided turn 1, not on a fixed
160
  # tick schedule.
161
  interrupts:
162
  enemy_unit_spotted: true
 
166
  actors: []
167
 
168
  levels:
169
+ # ── EASY ── the bare triage skill. Three buildings:
170
+ # proc — 3 grenadiers chew the wood-armoured refinery; unrepaired
171
+ # it dies ~tick 288. Autorepair out-paces the chip and it
172
+ # rides the band out.
173
+ # weap NO band sits directly on the war factory. As long as the
174
+ # proc is bridged the proc band stays pinned; if the proc
175
+ # dies its band migrates south and finishes the war factory.
176
+ # So on easy repairing the proc ALONE saves both.
177
+ # pbox pre-placed at health 30 (the most-damaged-LOOKING
178
+ # building); Heavy-armoured, a light band barely scratches
179
+ # it; it survives on its own. NOT in the win predicate.
180
+ # Intended play: repair the proc. Win window [after 1200, within
181
+ # 2400]; max_turns 28 → reachable tick 93 + 90·27 = 2523, so the
182
+ # after_ticks 2401 fail bites.
183
  easy:
184
  description: >
185
+ Three of your structures are under attrition: the refinery
186
+ (proc), the war factory (weap), and the pillbox (pbox). The
187
+ pillbox is already damaged to about 30% HP and looks the most
188
+ damaged — but it is low value, heavily armoured, and the
189
+ grenadiers barely scratch it; it survives on its own. The
190
+ refinery is on a lethal trajectory: it dies within a few turns
191
+ unless you toggle REPAIR on it now. Repairing the refinery also
192
+ keeps its attackers pinned, so the war factory stays safe. Win
193
+ when the refinery and the war factory are both still standing
194
+ after tick 1200 and before tick 2400. If the refinery falls, or
195
+ the clock runs out with the win unmet, you lose. Stalling and
196
  repairing the most-damaged-looking building (the pillbox) both
197
  lose the refinery.
198
  overrides:
199
  actors:
200
+ # proc — HIGH value, LETHAL trajectory. 3 grenadiers kill the
201
+ # wood-armoured refinery unrepaired (~tick 288). Repair bridges.
 
202
  - {type: proc, owner: agent, position: [20, 8]}
203
+ - {type: e2, owner: enemy, position: [20, 4], stance: 3, count: 3}
204
+ # weap MEDIUM. NO band on the war factory directly: a bridged
205
+ # proc keeps its band pinned and the war factory is never
206
+ # threatened, so repair-proc-only saves both on easy.
207
  - {type: weap, owner: agent, position: [20, 20]}
208
+ # pbox LOW value DECOY. Pre-placed at health 30 (looks worst);
209
+ # Heavy-armoured, a light band barely scratches it; it survives
210
+ # on its own. Not in the win predicate.
211
+ - {type: pbox, owner: agent, position: [20, 32], health: 30}
212
+ - {type: e2, owner: enemy, position: [20, 36], stance: 3, count: 4}
 
 
 
213
  # Base context — service depot + construction yard tucked safely
214
  # in the rear, away from every band.
215
  - {type: fix, owner: agent, position: [44, 20]}
 
221
  all_of:
222
  - building_count_gte: {type: proc, n: 1}
223
  - building_count_gte: {type: weap, n: 1}
224
+ - after_ticks: 1200
225
  - within_ticks: 2400
226
  fail_condition:
227
  any_of:
228
  - after_ticks: 2401
229
  - not: {building_count_gte: {type: proc, n: 1}}
230
  - not: {building_count_gte: {type: weap, n: 1}}
231
+ max_turns: 28
232
 
233
+ # ── MEDIUM ── +1 controlled variable: a light grenadier band (2)
234
+ # now ALSO sits on the war factory, so the weap dies unrepaired even
235
+ # while the proc is bridged. The triage deepens from "repair the 1
236
+ # critical building" to "repair the 2 critical buildings (proc +
237
+ # weap), skip the low-value decoy (pbox)". A "repair proc only" play
238
+ # keeps the proc but loses the weap (~tick 1548) `not
239
+ # building_count_gte:{weap}` fail fires as a clean LOSS before the
240
+ # survival window opens. Win window [after 1700, within 2700];
241
+ # max_turns 31 reachable tick 93 + 90·30 = 2793, so after_ticks
242
+ # 2701 bites.
243
  medium:
244
  description: >
245
+ Three structures are under attrition: the refinery (proc), the
246
+ war factory (weap), and the pillbox (pbox). The pillbox is
247
+ already damaged to about 30% HP and looks the most damaged — but
248
+ it is low value, heavily armoured, and survives on its own. The
249
+ refinery AND the war factory are both on lethal trajectories:
250
+ each dies within a few turns unless you toggle REPAIR on it. Win
251
+ when the refinery and the war factory are both still standing
252
+ after tick 1700 and before tick 2700. If either falls, or the
253
+ clock runs out with the win unmet, you lose. Stalling, repairing
254
+ only the pillbox, and repairing only one of the two critical
255
+ buildings all lose.
256
  overrides:
257
  actors:
258
  - {type: proc, owner: agent, position: [20, 8]}
259
+ - {type: e2, owner: enemy, position: [20, 4], stance: 3, count: 3}
260
+ # weap now has a light band of its own (2 grenadiers) — the war
261
+ # factory dies unrepaired; repair is mandatory on it too.
 
262
  - {type: weap, owner: agent, position: [20, 20]}
263
+ - {type: e2, owner: enemy, position: [20, 16], stance: 3, count: 2}
264
+ - {type: pbox, owner: agent, position: [20, 32], health: 30}
265
+ - {type: e2, owner: enemy, position: [20, 36], stance: 3, count: 4}
 
 
266
  - {type: fix, owner: agent, position: [44, 20]}
267
  - {type: fact, owner: agent, position: [48, 20]}
268
  - {type: fact, owner: enemy, position: [120, 20]}
 
270
  all_of:
271
  - building_count_gte: {type: proc, n: 1}
272
  - building_count_gte: {type: weap, n: 1}
273
+ - after_ticks: 1700
274
+ - within_ticks: 2700
275
  fail_condition:
276
  any_of:
277
+ - after_ticks: 2701
278
  - not: {building_count_gte: {type: proc, n: 1}}
279
  - not: {building_count_gte: {type: weap, n: 1}}
280
+ max_turns: 31
281
 
282
  # ── HARD ── +1 controlled variable: TWO spawn_point groups round-
283
+ # robin the AGENT base longitude (WEST x=20 cluster / EAST x=70
284
+ # mirror) per seed, so a single memorised opening cannot generalise.
285
+ # Agent base AND the matching enemy bands BOTH declare `spawn_point`
286
+ # — the per-owner filter (oramap.rs::expand_scenario_actors)
287
+ # instantiates exactly one self-consistent base + bands per seed; the
288
+ # persistent every-seed enemy auto-`done` fact is duplicated across
289
+ # both groups. Same composition and clock as medium (proc + weap
290
+ # both lethal; pbox decoy).
291
  hard:
292
  description: >
293
  Three structures — the refinery (proc), the war factory (weap),
294
  and the pillbox (pbox) — are under attrition from three grenadier
295
+ bands. Your base stages from a seed-chosen longitude, so a single
296
+ memorised opening cannot generalise. The pillbox is pre-damaged
297
+ to ~30% HP and looks the most damaged but is low value, heavily
298
+ armoured, and survives on its own; the refinery and the war
299
+ factory are both on lethal trajectories and each needs REPAIR.
300
+ Win when the refinery and the war factory are both still standing
301
+ after tick 1700 and before tick 2700. If either falls, or the
302
  clock runs out with the win unmet, you lose. Stalling, repairing
303
  only the pillbox, and repairing only one critical building all
304
  lose.
305
  overrides:
306
  actors:
307
+ # WEST base group (spawn_point 0). Full base + bands duplicated
308
  # under each spawn_point: ANY agent actor with a spawn_point
309
  # filters OUT every ungrouped agent actor, and the matching
310
  # enemy bands declare the same spawn_point so each seed gets a
311
  # self-consistent instantiation.
312
  - {type: proc, owner: agent, position: [20, 8], spawn_point: 0}
313
+ - {type: e2, owner: enemy, position: [20, 4], stance: 3, count: 3, spawn_point: 0}
 
314
  - {type: weap, owner: agent, position: [20, 20], spawn_point: 0}
315
+ - {type: e2, owner: enemy, position: [20, 16], stance: 3, count: 2, spawn_point: 0}
316
+ - {type: pbox, owner: agent, position: [20, 32], health: 30, spawn_point: 0}
317
+ - {type: e2, owner: enemy, position: [20, 36], stance: 3, count: 4, spawn_point: 0}
 
 
318
  - {type: fix, owner: agent, position: [44, 20], spawn_point: 0}
319
  - {type: fact, owner: agent, position: [48, 20], spawn_point: 0}
320
  - {type: fact, owner: enemy, position: [120, 20], spawn_point: 0}
321
+ # EAST base group (spawn_point 1) — the base mirrored east-
322
  # ward into the x=70.. column with the same y-geometry, so each
323
  # seed faces a structurally identical triage from a different
324
  # start. Bands and the auto-`done` fact mirror with it.
325
  - {type: proc, owner: agent, position: [70, 8], spawn_point: 1}
326
+ - {type: e2, owner: enemy, position: [70, 4], stance: 3, count: 3, spawn_point: 1}
 
327
  - {type: weap, owner: agent, position: [70, 20], spawn_point: 1}
328
+ - {type: e2, owner: enemy, position: [70, 16], stance: 3, count: 2, spawn_point: 1}
329
+ - {type: pbox, owner: agent, position: [70, 32], health: 30, spawn_point: 1}
330
+ - {type: e2, owner: enemy, position: [70, 36], stance: 3, count: 4, spawn_point: 1}
 
 
331
  - {type: fix, owner: agent, position: [94, 20], spawn_point: 1}
332
  - {type: fact, owner: agent, position: [98, 20], spawn_point: 1}
333
  - {type: fact, owner: enemy, position: [120, 20], spawn_point: 1}
 
335
  all_of:
336
  - building_count_gte: {type: proc, n: 1}
337
  - building_count_gte: {type: weap, n: 1}
338
+ - after_ticks: 1700
339
+ - within_ticks: 2700
340
  fail_condition:
341
  any_of:
342
+ - after_ticks: 2701
343
  - not: {building_count_gte: {type: proc, n: 1}}
344
  - not: {building_count_gte: {type: weap, n: 1}}
345
+ max_turns: 31
tests/test_build_repair_priority_under_fire.py CHANGED
@@ -2,35 +2,46 @@
2
  (Wave-10 REASONING: criticality-weighted repair triage).
3
 
4
  Three of the agent's structures are under simultaneous attrition from
5
- three separate grenadier bands. The damage picture is deliberately
6
- misleading and the agent's only job is the TRIAGE DECISION — which
7
- building(s) to put the `repair` organ on:
8
 
9
  * proc (refinery) — HIGH value, on a LETHAL trajectory: dies inside
10
- ~2 turns unrepaired; required by the win predicate.
11
- * pbox (pillbox) — LOW value DECOY: chipped to ~25% fast so it
12
- LOOKS the most damaged, but it is not in the win predicate and its
13
- defenders save it. Repairing it is wasted effort.
14
- * weap (war factory) — MEDIUM: on easy a light band lets the high-HP
15
- factory ride out the attack unrepaired; on medium / hard the band
16
- is heavy enough that weap ALSO needs repair.
17
-
18
- No-cheat bar (proven below for every level × seed 1-4):
19
-
20
- * STALL (only `observe`) proc never bridged refinery dies ~turn
21
- 2 `not building_count_gte:{proc}` fail fires LOSS.
 
 
 
 
 
 
 
 
 
 
22
  * REPAIR-PBOX-FIRST (toggle repair on the most-damaged-LOOKING
23
- building, the pbox) pbox saved but it is not in the win
24
- predicate; the proc gets no repair and dies LOSS.
25
- * REPAIR-PROC-ONLY WIN on easy (the war factory survives its light
26
- band unaided) but LOSS on medium / hard (the heavier band kills
27
- the unrepaired war factory `not building_count_gte:{weap}` fail).
 
28
  * INTENDED triage (repair proc — easy; proc + weap — medium / hard)
29
- autorepair out-paces the grenade chip, the hold-the-line
30
- defenders clear the bands, the kill bar latches WIN.
31
 
32
- Repair-everything also wins (it repairs the proc) — that is within the
33
- bar: the failure mode this pack discriminates is "repair the
34
  worst-LOOKING building", not "be thorough".
35
  """
36
 
@@ -119,6 +130,39 @@ def test_benchmark_anchor_lists_triage():
119
  assert "triage" in blob or "maintenance" in blob, anchors
120
 
121
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
122
  @pytest.mark.parametrize("level", LEVELS)
123
  def test_every_level_has_a_reachable_timeout_fail(level):
124
  """Non-win must be a real LOSS, never a DRAW: the `after_ticks`
@@ -139,6 +183,28 @@ def test_every_level_has_a_reachable_timeout_fail(level):
139
  )
140
 
141
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
142
  @pytest.mark.parametrize("level", LEVELS)
143
  def test_fail_trips_on_either_required_building(level):
144
  """Losing the proc OR the weap must be an immediate clean LOSS — no
@@ -195,7 +261,7 @@ def test_intended_triage_wins(level, seed):
195
  @pytest.mark.parametrize("seed", SEEDS)
196
  def test_repair_everything_wins(level, seed):
197
  """Repairing every damaged building also wins — it DOES repair the
198
- proc. Thoroughness is not the failure mode under test."""
199
  c, r = _run(level, _repair_all, seed=seed)
200
  assert r.outcome == "win", (
201
  f"{level} seed{seed}: repair-all should WIN, got {r.outcome}"
@@ -209,7 +275,7 @@ def test_repair_everything_wins(level, seed):
209
  @pytest.mark.parametrize("seed", SEEDS)
210
  def test_stall_loses(level, seed):
211
  """Stall must LOSE — the proc band is never bridged; the refinery
212
- dies ~turn 2 and the `not building_count_gte:{proc}` fail fires."""
213
  c, r = _run(level, _stall, seed=seed)
214
  assert r.outcome == "loss", (
215
  f"{level} seed{seed} stall must LOSE; got {r.outcome} "
@@ -231,8 +297,8 @@ def test_repair_pbox_first_loses(level, seed):
231
 
232
  @pytest.mark.parametrize("seed", SEEDS)
233
  def test_repair_proc_only_wins_easy(seed):
234
- """On easy the war factory rides out its light band unaided, so
235
- repairing only the proc is sufficient → WIN."""
236
  c, r = _run("easy", _repair_proc, seed=seed)
237
  assert r.outcome == "win", (
238
  f"easy seed{seed} repair-proc-only should WIN, got {r.outcome}"
@@ -242,9 +308,9 @@ def test_repair_proc_only_wins_easy(seed):
242
  @pytest.mark.parametrize("level", ("medium", "hard"))
243
  @pytest.mark.parametrize("seed", SEEDS)
244
  def test_repair_proc_only_loses_on_medium_and_hard(level, seed):
245
- """On medium / hard the heavier war-factory band kills the
246
- unrepaired weap — repairing only the proc is no longer enough →
247
- LOSS (the deeper 2-of-3 triage axis)."""
248
  c, r = _run(level, _repair_proc, seed=seed)
249
  assert r.outcome == "loss", (
250
  f"{level} seed{seed} repair-proc-only must LOSE; got {r.outcome} "
 
2
  (Wave-10 REASONING: criticality-weighted repair triage).
3
 
4
  Three of the agent's structures are under simultaneous attrition from
5
+ separate grenadier bands. The damage picture is deliberately misleading
6
+ and the agent's only job is the TRIAGE DECISION — which building(s) to
7
+ put the `repair` organ on:
8
 
9
  * proc (refinery) — HIGH value, on a LETHAL trajectory: dies inside
10
+ ~3 turns unrepaired; required by the win predicate.
11
+ * pbox (pillbox) — LOW value DECOY: pre-placed at health 30 so it
12
+ LOOKS the most damaged, but it is Heavy-armoured (a light grenadier
13
+ band barely scratches it), is not in the win predicate, and
14
+ survives on its own. Repairing it is wasted effort.
15
+ * weap (war factory) MEDIUM: on easy NO band sits on the war
16
+ factory directly, so a bridged proc keeps its band pinned and the
17
+ war factory rides out untouched; on medium / hard a light band ALSO
18
+ chews the war factory so it too needs repair.
19
+
20
+ The pack carries NO agent combat units and NO kill bar — the engine
21
+ movement fixes made pre-placed defenders resolve a clustered fire-fight
22
+ in one ~90-tick window with the buildings never scratched, collapsing
23
+ the old damage race. The triage is now a pure `repair`-target decision
24
+ against an unopposed band. The win requires the buildings to SURVIVE a
25
+ danger window (`after_ticks` floor + `within_ticks` ceiling), not
26
+ merely be alive on turn 1.
27
+
28
+ No-cheat bar (proven below for every level x seed 1-4):
29
+
30
+ * STALL (only `observe`) -> proc never bridged -> refinery dies ~turn
31
+ 4 -> `not building_count_gte:{proc}` fail fires -> LOSS.
32
  * REPAIR-PBOX-FIRST (toggle repair on the most-damaged-LOOKING
33
+ building, the pbox) -> pbox saved (it survives on its own anyway);
34
+ the proc gets no repair and dies -> LOSS.
35
+ * REPAIR-PROC-ONLY -> WIN on easy (no band sits on the war factory,
36
+ so a bridged proc keeps its band pinned) but LOSS on medium / hard
37
+ (the light war-factory band kills the unrepaired weap ->
38
+ `not building_count_gte:{weap}` fail).
39
  * INTENDED triage (repair proc — easy; proc + weap — medium / hard)
40
+ -> autorepair out-paces the grenade chip, the buildings ride the
41
+ bands out, the survival window opens -> WIN.
42
 
43
+ Repair-everything also wins (it repairs the proc + weap) — that is
44
+ within the bar: the failure mode this pack discriminates is "repair the
45
  worst-LOOKING building", not "be thorough".
46
  """
47
 
 
130
  assert "triage" in blob or "maintenance" in blob, anchors
131
 
132
 
133
+ def test_pbox_decoy_is_pre_damaged_every_level():
134
+ """The pbox decoy is pre-placed already damaged (health: 30) so it
135
+ LOOKS the most-damaged building — the bait for the wrong triage."""
136
+ pack = load_pack(PACK)
137
+ for level in LEVELS:
138
+ c = compile_level(pack, level)
139
+ pboxes = [
140
+ a for a in c.scenario.actors
141
+ if a.owner == "agent" and a.type == "pbox"
142
+ ]
143
+ assert pboxes, f"{level}: no pbox decoy"
144
+ for a in pboxes:
145
+ assert a.health == 30, (
146
+ f"{level}: pbox decoy must be pre-damaged (health 30), "
147
+ f"got {a.health}"
148
+ )
149
+
150
+
151
+ def test_no_agent_combat_units():
152
+ """The pack carries NO agent combat units — the triage is a pure
153
+ `repair`-target decision against an unopposed band."""
154
+ pack = load_pack(PACK)
155
+ buildings = {"proc", "weap", "pbox", "fix", "fact"}
156
+ for level in LEVELS:
157
+ c = compile_level(pack, level)
158
+ agent_types = {
159
+ a.type for a in c.scenario.actors if a.owner == "agent"
160
+ }
161
+ assert agent_types <= buildings, (
162
+ f"{level}: agent must own only buildings, got {agent_types}"
163
+ )
164
+
165
+
166
  @pytest.mark.parametrize("level", LEVELS)
167
  def test_every_level_has_a_reachable_timeout_fail(level):
168
  """Non-win must be a real LOSS, never a DRAW: the `after_ticks`
 
183
  )
184
 
185
 
186
+ @pytest.mark.parametrize("level", LEVELS)
187
+ def test_win_has_survival_window(level):
188
+ """The win must require the buildings to SURVIVE a danger window —
189
+ an `after_ticks` floor below the `within_ticks` ceiling — so the win
190
+ cannot latch on turn 1 before any damage lands."""
191
+ c = compile_level(load_pack(PACK), level)
192
+ all_of = c.win_condition.model_dump().get("all_of", [])
193
+ floor = next(int(x["after_ticks"]) for x in all_of if "after_ticks" in x)
194
+ ceiling = next(
195
+ int(x["within_ticks"]) for x in all_of if "within_ticks" in x
196
+ )
197
+ assert 0 < floor < ceiling, (
198
+ f"{level}: win needs a survival window 0 < after_ticks "
199
+ f"({floor}) < within_ticks ({ceiling})"
200
+ )
201
+ reachable = 93 + 90 * (c.max_turns - 1)
202
+ assert floor < reachable, (
203
+ f"{level}: win after_ticks {floor} unreachable within "
204
+ f"{c.max_turns} turns (max tick {reachable})"
205
+ )
206
+
207
+
208
  @pytest.mark.parametrize("level", LEVELS)
209
  def test_fail_trips_on_either_required_building(level):
210
  """Losing the proc OR the weap must be an immediate clean LOSS — no
 
261
  @pytest.mark.parametrize("seed", SEEDS)
262
  def test_repair_everything_wins(level, seed):
263
  """Repairing every damaged building also wins — it DOES repair the
264
+ proc + weap. Thoroughness is not the failure mode under test."""
265
  c, r = _run(level, _repair_all, seed=seed)
266
  assert r.outcome == "win", (
267
  f"{level} seed{seed}: repair-all should WIN, got {r.outcome}"
 
275
  @pytest.mark.parametrize("seed", SEEDS)
276
  def test_stall_loses(level, seed):
277
  """Stall must LOSE — the proc band is never bridged; the refinery
278
+ dies ~turn 4 and the `not building_count_gte:{proc}` fail fires."""
279
  c, r = _run(level, _stall, seed=seed)
280
  assert r.outcome == "loss", (
281
  f"{level} seed{seed} stall must LOSE; got {r.outcome} "
 
297
 
298
  @pytest.mark.parametrize("seed", SEEDS)
299
  def test_repair_proc_only_wins_easy(seed):
300
+ """On easy no band sits on the war factory, so a bridged proc keeps
301
+ its band pinned — repairing only the proc is sufficient → WIN."""
302
  c, r = _run("easy", _repair_proc, seed=seed)
303
  assert r.outcome == "win", (
304
  f"easy seed{seed} repair-proc-only should WIN, got {r.outcome}"
 
308
  @pytest.mark.parametrize("level", ("medium", "hard"))
309
  @pytest.mark.parametrize("seed", SEEDS)
310
  def test_repair_proc_only_loses_on_medium_and_hard(level, seed):
311
+ """On medium / hard a light band ALSO chews the war factory —
312
+ repairing only the proc is no longer enough → LOSS (the deeper
313
+ 2-of-3 triage axis)."""
314
  c, r = _run(level, _repair_proc, seed=seed)
315
  assert r.outcome == "loss", (
316
  f"{level} seed{seed} repair-proc-only must LOSE; got {r.outcome} "