yxc20098 commited on
Commit
e6d690e
ยท
1 Parent(s): 18612b1

Strategy packs: faithful 'destroy key economic buildings' objective

Browse files

Per the training design (scout-dilemma/gauntlet/twobody): win = destroy
the enemy's MustBeDestroyed economic core (fact construction yard +
proc refinery), NOT discover-a-building and NOT wipe the deliberately
strong enemy โ€” brute force bleeds the force and loses.

- rust_adapter: enemy_buildings_destroyed{,_types} โ€” a seen enemy
building now absent WHILE an agent unit is on its cell โ‡’ killed
(proximity to a current unit is the reliable vision test; cumulative
explored_cells can't distinguish a kill from a fogged retreat)
- win_conditions: enemy_key_buildings_destroyed {types:[...]} (all of
a named set) + enemy_buildings_destroyed_gte (total); goal_tracker
current-fn for the latter
- strategy-dilemma/gauntlet/twobody: win = destroy fact+proc within
ticks; medium/hard add attrition caps; real fail_condition (force
wiped by the strong enemy) โ€” kills the old gameable
buildings_discovered_gte:1 and the loss==draw degeneracy
- tests: test_strategy_objective.py (6) incl proximity/fog edge cases,
non-gameability, live runs of all 3. 311 passed, 1 skipped
- test_hard_tier NOT_APPLICABLE reasons refreshed (win redesigned;
spawn still N/A by design intent)

openra_bench/goal_tracker.py CHANGED
@@ -29,6 +29,9 @@ _CURRENT: dict[str, Any] = {
29
  "enemies_discovered_gte": lambda c: len(c.signals.enemies_seen_ids),
30
  "buildings_discovered_gte": lambda c: len(c.signals.enemy_buildings_seen_ids),
31
  "units_killed_gte": lambda c: c.signals.units_killed,
 
 
 
32
  "units_lost_lte": lambda c: c.signals.units_lost,
33
  "within_ticks": lambda c: c.signals.game_tick,
34
  "after_ticks": lambda c: c.signals.game_tick,
 
29
  "enemies_discovered_gte": lambda c: len(c.signals.enemies_seen_ids),
30
  "buildings_discovered_gte": lambda c: len(c.signals.enemy_buildings_seen_ids),
31
  "units_killed_gte": lambda c: c.signals.units_killed,
32
+ "enemy_buildings_destroyed_gte": lambda c: getattr(
33
+ c.signals, "enemy_buildings_destroyed", 0
34
+ ),
35
  "units_lost_lte": lambda c: c.signals.units_lost,
36
  "within_ticks": lambda c: c.signals.game_tick,
37
  "after_ticks": lambda c: c.signals.game_tick,
openra_bench/rust_adapter.py CHANGED
@@ -83,6 +83,12 @@ class EpisodeSignals:
83
  explored_delta: float = 0.0
84
  enemies_seen_ids: set[str] = field(default_factory=set)
85
  enemy_buildings_seen_ids: set[str] = field(default_factory=set)
 
 
 
 
 
 
86
  new_enemies_this_step: int = 0
87
  new_buildings_this_step: int = 0
88
  game_tick: int = 0
@@ -143,6 +149,10 @@ class RustObsAdapter:
143
  self._prev_own_ids: set[str] = set()
144
  self._raw: dict[str, Any] = {}
145
  self._first_own_count: int | None = None
 
 
 
 
146
 
147
  # -- ingestion --------------------------------------------------------
148
  def observe(self, obs: dict[str, Any], done: bool = False) -> None:
@@ -173,10 +183,40 @@ class RustObsAdapter:
173
  s.new_enemies_this_step = len(s.enemies_seen_ids) - before_e
174
 
175
  before_b = len(s.enemy_buildings_seen_ids)
 
176
  for b in self._raw.get("enemy_buildings_summary", []) or []:
177
  if isinstance(b, dict) and b.get("id") is not None:
178
- s.enemy_buildings_seen_ids.add(str(b["id"]))
 
 
 
 
 
 
179
  s.new_buildings_this_step = len(s.enemy_buildings_seen_ids) - before_b
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
180
 
181
  econ = self._raw.get("economy") or {}
182
  if isinstance(econ, dict):
 
83
  explored_delta: float = 0.0
84
  enemies_seen_ids: set[str] = field(default_factory=set)
85
  enemy_buildings_seen_ids: set[str] = field(default_factory=set)
86
+ # Enemy buildings confirmed destroyed: a building seen earlier that
87
+ # is now absent while we still have vision of its cell (so it's
88
+ # killed, not fogged). Total + per-type โ€” the faithful signal for
89
+ # "eliminate the enemy's key economic structures" objectives.
90
+ enemy_buildings_destroyed: int = 0
91
+ enemy_buildings_destroyed_types: dict = field(default_factory=dict)
92
  new_enemies_this_step: int = 0
93
  new_buildings_this_step: int = 0
94
  game_tick: int = 0
 
149
  self._prev_own_ids: set[str] = set()
150
  self._raw: dict[str, Any] = {}
151
  self._first_own_count: int | None = None
152
+ # id -> (type, (cell_x, cell_y)) last time the building was seen,
153
+ # for destruction detection (absent + cell explored โ‡’ killed).
154
+ self._seen_buildings: dict[str, tuple[str, tuple[int, int]]] = {}
155
+ self._destroyed_bldg_ids: set[str] = set()
156
 
157
  # -- ingestion --------------------------------------------------------
158
  def observe(self, obs: dict[str, Any], done: bool = False) -> None:
 
183
  s.new_enemies_this_step = len(s.enemies_seen_ids) - before_e
184
 
185
  before_b = len(s.enemy_buildings_seen_ids)
186
+ visible_b: set[str] = set()
187
  for b in self._raw.get("enemy_buildings_summary", []) or []:
188
  if isinstance(b, dict) and b.get("id") is not None:
189
+ bid = str(b["id"])
190
+ s.enemy_buildings_seen_ids.add(bid)
191
+ visible_b.add(bid)
192
+ self._seen_buildings[bid] = (
193
+ str(b.get("type", "")).lower(),
194
+ (int(b.get("cell_x", 0)), int(b.get("cell_y", 0))),
195
+ )
196
  s.new_buildings_this_step = len(s.enemy_buildings_seen_ids) - before_b
197
+ # Destruction: a previously-seen enemy building now absent while
198
+ # an agent unit is right on top of its last cell โ‡’ it was
199
+ # killed (not merely fogged after a retreat). Proximity to a
200
+ # *current* unit is the reliable "we have vision here" test โ€”
201
+ # `explored_cells` is cumulative and can't distinguish the two.
202
+ _VIS = 6 # cells; ~unit sight radius
203
+ agent_cells = [
204
+ (int(p.get("cell_x", 0)), int(p.get("cell_y", 0)))
205
+ for p in (own.values() if isinstance(own, dict) else [])
206
+ if isinstance(p, dict)
207
+ ]
208
+ for bid, (btype, (bx, by)) in self._seen_buildings.items():
209
+ if bid in visible_b or bid in self._destroyed_bldg_ids:
210
+ continue
211
+ if any(
212
+ max(abs(ux - bx), abs(uy - by)) <= _VIS
213
+ for ux, uy in agent_cells
214
+ ):
215
+ self._destroyed_bldg_ids.add(bid)
216
+ s.enemy_buildings_destroyed_types[btype] = (
217
+ s.enemy_buildings_destroyed_types.get(btype, 0) + 1
218
+ )
219
+ s.enemy_buildings_destroyed = len(self._destroyed_bldg_ids)
220
 
221
  econ = self._raw.get("economy") or {}
222
  if isinstance(econ, dict):
openra_bench/scenarios/packs/strategy-dilemma.yaml CHANGED
@@ -253,21 +253,39 @@ base:
253
  enemy_units_killed: true
254
  levels:
255
  easy:
256
- description: "Strategy: Risk Dilemma \u2014 easy difficulty (ported)."
 
 
 
 
257
  win_condition:
258
  all_of:
259
- - buildings_discovered_gte: 1
260
  - within_ticks: 16000
 
 
 
261
  medium:
262
- description: "Strategy: Risk Dilemma \u2014 medium difficulty (ported)."
 
 
263
  win_condition:
264
  all_of:
265
- - buildings_discovered_gte: 1
266
  - within_ticks: 12000
 
 
 
 
267
  hard:
268
- description: "Strategy: Risk Dilemma \u2014 hard difficulty (ported)."
 
 
269
  win_condition:
270
  all_of:
271
- - buildings_discovered_gte: 1
272
  - within_ticks: 9000
273
- - units_lost_lte: 2
 
 
 
 
253
  enemy_units_killed: true
254
  levels:
255
  easy:
256
+ description: >
257
+ Risk Dilemma \u2014 destroy the enemy's key economic buildings
258
+ (construction yard + refinery). The enemy is deliberately strong:
259
+ brute-forcing every defender bleeds the force and loses. Take the
260
+ safer route, reach the base, eliminate fact+proc.
261
  win_condition:
262
  all_of:
263
+ - enemy_key_buildings_destroyed: {types: [fact, proc]}
264
  - within_ticks: 16000
265
+ fail_condition:
266
+ not: {own_units_gte: 1}
267
+ max_turns: 100
268
  medium:
269
+ description: >
270
+ Same objective (fact+proc), tighter clock and an attrition cap \u2014
271
+ a costly brawl no longer counts as a win even if you reach base.
272
  win_condition:
273
  all_of:
274
+ - enemy_key_buildings_destroyed: {types: [fact, proc]}
275
  - within_ticks: 12000
276
+ - units_lost_lte: 8
277
+ fail_condition:
278
+ not: {own_units_gte: 1}
279
+ max_turns: 90
280
  hard:
281
+ description: >
282
+ fact+proc destroyed, tight clock, strict attrition: only a clean
283
+ raid that avoids the strong defenses wins; brute force loses.
284
  win_condition:
285
  all_of:
286
+ - enemy_key_buildings_destroyed: {types: [fact, proc]}
287
  - within_ticks: 9000
288
+ - units_lost_lte: 4
289
+ fail_condition:
290
+ not: {own_units_gte: 1}
291
+ max_turns: 80
openra_bench/scenarios/packs/strategy-gauntlet.yaml CHANGED
@@ -416,21 +416,39 @@ base:
416
  enemy_units_killed: true
417
  levels:
418
  easy:
419
- description: "Strategy: The Gauntlet \u2014 easy difficulty (ported)."
 
 
 
 
420
  win_condition:
421
  all_of:
422
- - buildings_discovered_gte: 1
423
  - within_ticks: 16000
 
 
 
424
  medium:
425
- description: "Strategy: The Gauntlet \u2014 medium difficulty (ported)."
 
 
426
  win_condition:
427
  all_of:
428
- - buildings_discovered_gte: 1
429
  - within_ticks: 12000
 
 
 
 
430
  hard:
431
- description: "Strategy: The Gauntlet \u2014 hard difficulty (ported)."
 
 
432
  win_condition:
433
  all_of:
434
- - buildings_discovered_gte: 1
435
  - within_ticks: 9000
436
- - units_lost_lte: 2
 
 
 
 
416
  enemy_units_killed: true
417
  levels:
418
  easy:
419
+ description: >
420
+ The Gauntlet \u2014 run the defended corridor and destroy the enemy's
421
+ key economic buildings (construction yard + refinery). The
422
+ corridor is lethal to a brute-force push; sequence and time the
423
+ run so the force survives to kill fact+proc.
424
  win_condition:
425
  all_of:
426
+ - enemy_key_buildings_destroyed: {types: [fact, proc]}
427
  - within_ticks: 16000
428
+ fail_condition:
429
+ not: {own_units_gte: 1}
430
+ max_turns: 100
431
  medium:
432
+ description: >
433
+ Same objective (fact+proc), tighter clock + attrition cap \u2014 a
434
+ costly run no longer counts as a win.
435
  win_condition:
436
  all_of:
437
+ - enemy_key_buildings_destroyed: {types: [fact, proc]}
438
  - within_ticks: 12000
439
+ - units_lost_lte: 8
440
+ fail_condition:
441
+ not: {own_units_gte: 1}
442
+ max_turns: 90
443
  hard:
444
+ description: >
445
+ fact+proc destroyed, tight clock, strict attrition: only a
446
+ well-timed run that survives the gauntlet wins.
447
  win_condition:
448
  all_of:
449
+ - enemy_key_buildings_destroyed: {types: [fact, proc]}
450
  - within_ticks: 9000
451
+ - units_lost_lte: 4
452
+ fail_condition:
453
+ not: {own_units_gte: 1}
454
+ max_turns: 80
openra_bench/scenarios/packs/strategy-twobody.yaml CHANGED
@@ -335,21 +335,39 @@ base:
335
  enemy_units_killed: true
336
  levels:
337
  easy:
338
- description: "Strategy: Two-Body Coordination \u2014 easy difficulty (ported)."
 
 
 
 
339
  win_condition:
340
  all_of:
341
- - buildings_discovered_gte: 1
342
  - within_ticks: 16000
 
 
 
343
  medium:
344
- description: "Strategy: Two-Body Coordination \u2014 medium difficulty (ported)."
 
 
345
  win_condition:
346
  all_of:
347
- - buildings_discovered_gte: 1
348
  - within_ticks: 12000
 
 
 
 
349
  hard:
350
- description: "Strategy: Two-Body Coordination \u2014 hard difficulty (ported)."
 
 
351
  win_condition:
352
  all_of:
353
- - buildings_discovered_gte: 1
354
  - within_ticks: 9000
355
- - units_lost_lte: 2
 
 
 
 
335
  enemy_units_killed: true
336
  levels:
337
  easy:
338
+ description: >
339
+ Two-Body Coordination \u2014 drive the two separated squads in
340
+ parallel, converge through the weak gap, and destroy the enemy's
341
+ key economic buildings (construction yard + refinery). Serial
342
+ play or a brute-force push through the strong center loses.
343
  win_condition:
344
  all_of:
345
+ - enemy_key_buildings_destroyed: {types: [fact, proc]}
346
  - within_ticks: 16000
347
+ fail_condition:
348
+ not: {own_units_gte: 1}
349
+ max_turns: 100
350
  medium:
351
+ description: >
352
+ Same objective (fact+proc), tighter clock + attrition cap \u2014 only
353
+ coordinated parallel control reaches base cheaply enough.
354
  win_condition:
355
  all_of:
356
+ - enemy_key_buildings_destroyed: {types: [fact, proc]}
357
  - within_ticks: 12000
358
+ - units_lost_lte: 8
359
+ fail_condition:
360
+ not: {own_units_gte: 1}
361
+ max_turns: 90
362
  hard:
363
+ description: >
364
+ fact+proc destroyed, tight clock, strict attrition: both squads
365
+ must converge and raid cleanly; brute force loses.
366
  win_condition:
367
  all_of:
368
+ - enemy_key_buildings_destroyed: {types: [fact, proc]}
369
  - within_ticks: 9000
370
+ - units_lost_lte: 4
371
+ fail_condition:
372
+ not: {own_units_gte: 1}
373
+ max_turns: 80
openra_bench/scenarios/win_conditions.py CHANGED
@@ -51,6 +51,20 @@ _PREDICATES: dict[str, Callable[[WinContext, Any], bool]] = {
51
  "buildings_discovered_gte": lambda c, v: len(c.signals.enemy_buildings_seen_ids)
52
  >= int(v),
53
  "units_killed_gte": lambda c, v: c.signals.units_killed >= int(v),
 
 
 
 
 
 
 
 
 
 
 
 
 
 
54
  "units_lost_lte": lambda c, v: c.signals.units_lost <= int(v),
55
  "within_ticks": lambda c, v: c.signals.game_tick <= int(v),
56
  "after_ticks": lambda c, v: c.signals.game_tick >= int(v),
 
51
  "buildings_discovered_gte": lambda c, v: len(c.signals.enemy_buildings_seen_ids)
52
  >= int(v),
53
  "units_killed_gte": lambda c, v: c.signals.units_killed >= int(v),
54
+ # Eliminate the enemy's key economic structures (the training
55
+ # strategy design: destroy fact+proc, NOT brute-force the whole
56
+ # strong enemy). Total count, or all of a named type set.
57
+ "enemy_buildings_destroyed_gte": lambda c, v: getattr(
58
+ c.signals, "enemy_buildings_destroyed", 0
59
+ )
60
+ >= int(v),
61
+ "enemy_key_buildings_destroyed": lambda c, v: all(
62
+ getattr(c.signals, "enemy_buildings_destroyed_types", {}).get(
63
+ str(t).lower(), 0
64
+ )
65
+ >= 1
66
+ for t in (v["types"] if isinstance(v, dict) else v)
67
+ ),
68
  "units_lost_lte": lambda c, v: c.signals.units_lost <= int(v),
69
  "within_ticks": lambda c, v: c.signals.game_tick <= int(v),
70
  "after_ticks": lambda c, v: c.signals.game_tick >= int(v),
tests/test_hard_tier.py CHANGED
@@ -51,11 +51,13 @@ NOT_APPLICABLE = {
51
  "strict-production-bom": "non-spatial: exact bill-of-materials spec",
52
  "reasoning-risk-route": "rigor 5/5 from one tuned safe seam โ€” varying "
53
  "the start would break the single-solution tuning / seed parity",
54
- "strategy-dilemma": "needs win-predicate redesign (gameable "
55
- "buildings_discovered_gte:1), not spawn variation โ€” separate rigor item",
56
- "strategy-gauntlet": "dual-entry design + gameable win โ€” separate item",
57
- "strategy-twobody": "two simultaneously-controlled groups IS the task; "
58
- "spawn-alternatives would break intent โ€” separate item",
 
 
59
  }
60
 
61
  # No-adversary maps: spawn variation applies but a force-loss
 
51
  "strict-production-bom": "non-spatial: exact bill-of-materials spec",
52
  "reasoning-risk-route": "rigor 5/5 from one tuned safe seam โ€” varying "
53
  "the start would break the single-solution tuning / seed parity",
54
+ "strategy-dilemma": "win redesigned to destroy fact+proc (faithful "
55
+ "to training); spawn deferred โ€” route-choice puzzle is the decision",
56
+ "strategy-gauntlet": "win redesigned to fact+proc; single defended "
57
+ "corridor โ€” spawn variation would not add a distinct decision",
58
+ "strategy-twobody": "win redesigned to fact+proc; two "
59
+ "simultaneously-controlled groups IS the task โ€” spawn-alternatives "
60
+ "would break intent",
61
  }
62
 
63
  # No-adversary maps: spawn variation applies but a force-loss
tests/test_strategy_objective.py ADDED
@@ -0,0 +1,127 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Strategy packs: faithful "destroy the enemy's key economic
2
+ buildings" objective (training design โ€” fact+proc are MustBeDestroyed;
3
+ the enemy is deliberately strong so brute force loses).
4
+
5
+ Covers the new adapter destruction signal, the two predicates, the
6
+ non-gameability (discovering a building no longer wins), and that all
7
+ three packs compile + run on the live engine.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ from pathlib import Path
13
+
14
+ import pytest
15
+
16
+ from openra_bench.rust_adapter import RustObsAdapter
17
+ from openra_bench.scenarios import load_pack
18
+ from openra_bench.scenarios.loader import compile_level
19
+ from openra_bench.scenarios.win_conditions import WinContext, evaluate
20
+
21
+ PACKS = Path(__file__).parent.parent / "openra_bench" / "scenarios" / "packs"
22
+ STRAT = ["strategy-dilemma", "strategy-gauntlet", "strategy-twobody"]
23
+
24
+
25
+ # โ”€โ”€ adapter: destruction detection (absent + cell explored โ‡’ killed) โ”€โ”€
26
+
27
+
28
+ def test_adapter_counts_destroyed_only_when_unit_is_present():
29
+ a = RustObsAdapter()
30
+ # t1: scout sees fact+proc from afar (unit at 5,5) โ€” nothing killed.
31
+ a.observe({
32
+ "unit_positions": {"1": {"cell_x": 5, "cell_y": 5}},
33
+ "enemy_buildings_summary": [
34
+ {"id": 900, "type": "fact", "cell_x": 80, "cell_y": 35},
35
+ {"id": 901, "type": "proc", "cell_x": 80, "cell_y": 32},
36
+ ],
37
+ })
38
+ assert a.signals.enemy_buildings_destroyed == 0
39
+ # t2: a unit is on the proc (79,33) and proc is gone โ‡’ killed;
40
+ # fact still standing.
41
+ a.observe({
42
+ "unit_positions": {"1": {"cell_x": 79, "cell_y": 33}},
43
+ "enemy_buildings_summary": [
44
+ {"id": 900, "type": "fact", "cell_x": 80, "cell_y": 35},
45
+ ],
46
+ })
47
+ assert a.signals.enemy_buildings_destroyed == 1
48
+ assert a.signals.enemy_buildings_destroyed_types == {"proc": 1}
49
+ # t3: fact also absent but the force RETREATED far away โ‡’ that's
50
+ # fog, not a kill โ€” must NOT be counted.
51
+ a.observe({
52
+ "unit_positions": {"1": {"cell_x": 5, "cell_y": 5}},
53
+ "enemy_buildings_summary": [],
54
+ })
55
+ assert a.signals.enemy_buildings_destroyed == 1 # unchanged (fog)
56
+ # t4: a unit is back on the fact cell and it's gone โ‡’ now a kill.
57
+ a.observe({
58
+ "unit_positions": {"1": {"cell_x": 79, "cell_y": 36}},
59
+ "enemy_buildings_summary": [],
60
+ })
61
+ assert a.signals.enemy_buildings_destroyed == 2
62
+ assert a.signals.enemy_buildings_destroyed_types == {"proc": 1, "fact": 1}
63
+
64
+
65
+ # โ”€โ”€ predicates โ”€โ”€
66
+
67
+
68
+ class _Sig:
69
+ def __init__(self, destroyed_types=None, seen=0):
70
+ self.enemy_buildings_destroyed_types = destroyed_types or {}
71
+ self.enemy_buildings_destroyed = sum(
72
+ (destroyed_types or {}).values()
73
+ )
74
+ self.enemy_buildings_seen_ids = set(range(seen))
75
+ self.game_tick = 100
76
+
77
+
78
+ def _ctx(sig):
79
+ return WinContext(signals=sig, render_state={})
80
+
81
+
82
+ def test_enemy_key_buildings_predicate_requires_all_types():
83
+ assert evaluate(
84
+ {"enemy_key_buildings_destroyed": {"types": ["fact", "proc"]}},
85
+ _ctx(_Sig({"fact": 1, "proc": 1})),
86
+ )
87
+ # only one of the two โ‡’ not satisfied
88
+ assert not evaluate(
89
+ {"enemy_key_buildings_destroyed": {"types": ["fact", "proc"]}},
90
+ _ctx(_Sig({"fact": 1})),
91
+ )
92
+ assert evaluate(
93
+ {"enemy_buildings_destroyed_gte": 2},
94
+ _ctx(_Sig({"fact": 1, "proc": 1})),
95
+ )
96
+
97
+
98
+ def test_objective_is_not_gameable_by_mere_discovery():
99
+ c = compile_level(load_pack(PACKS / "strategy-dilemma.yaml"), "easy")
100
+ # Saw the whole enemy base but destroyed nothing โ†’ NOT a win
101
+ # (the old buildings_discovered_gte:1 bug would have passed here).
102
+ seen_only = _Sig(destroyed_types={}, seen=5)
103
+ assert evaluate(c.win_condition, _ctx(seen_only)) is False
104
+ # fact+proc down, in time โ†’ win
105
+ won = _Sig({"fact": 1, "proc": 1})
106
+ won.game_tick = 1000
107
+ assert evaluate(c.win_condition, _ctx(won)) is True
108
+
109
+
110
+ @pytest.mark.parametrize("pid", STRAT)
111
+ def test_strategy_pack_compiles_runs_and_has_faithful_objective(pid):
112
+ pack = load_pack(PACKS / f"{pid}.yaml")
113
+ for lvl in ("easy", "medium", "hard"):
114
+ c = compile_level(pack, lvl)
115
+ assert c.map_supported
116
+ node = dict(c.win_condition.__pydantic_extra__ or {})
117
+ clauses = node.get("all_of", [])
118
+ assert any("enemy_key_buildings_destroyed" in cl for cl in clauses), (
119
+ f"{pid}:{lvl} win must require destroying fact+proc"
120
+ )
121
+ assert c.fail_condition is not None # loss reachable (brute force)
122
+ pytest.importorskip("openra_train")
123
+ from openra_bench.eval_core import run_level
124
+
125
+ res = run_level(compile_level(pack, "easy"),
126
+ lambda rs, C: [C.observe()], seed=1)
127
+ assert res.outcome in {"win", "draw", "loss"} and res.turns >= 1