yxc20098 commited on
Commit
3050e92
·
1 Parent(s): 4134a12

feat(scenario): combat-suicide-charge-mission — sacrifice force to destroy objective (forlorn hope / military sacrifice anchor)

Browse files

Wave-8 REASONING pack. 6× 2tnk + 4× e3 staged at the west must raze a
high-value enemy construction yard at the far-east objective region
(110,20), heavily defended by a leashed guard picket (e3 rocket
infantry + 4tnk heavy tank). The win is buildings-only:

enemy_key_buildings_destroyed_in_region:{x:110,y:20,radius:6,types:[fact]}
AND within_ticks: T

There is NO own_units_gte clause and NO force-wipe fail clause; the
only fail path is after_ticks T+1. Survival is NOT required — the
intended sacrifice play accepts total force loss as the cost of
mission success.

The discriminator: a stall (only observe) and a preserve-the-force
policy (only the lead tank probes, the rest hold; damaged tanks
retreat) both LOSE on every level and every hard seed (insufficient
damage on the fact; the clock expires). Only the all-in commit that
drives the WHOLE force at the objective lands enough hits to raze
the fact before the deadline. Verified on the live Rust engine:
ALL_IN wins easy/medium/hard × seeds {1..4}, STALL and PRESERVE lose
on the same matrix.

Engine knobs:
- termination.agent_units_killed=False prevents the engine's
ConquestVictoryConditions auto-done from collapsing the run into
a draw the moment the last attacker dies (with the fact already
razed in vision).
- A SENTINEL enemy fact at (122,4) keeps the episode alive past
the objective fact's destruction so within_ticks evaluates on
the terminal frame (the CLAUDE.md auto-done footgun on
MustBeDestroyed enemy buildings).

Hard tier defines two agent spawn_point groups (NORTH y=10 / SOUTH
y=30) round-robined by seed; the defender picket and objective fact
place every seed (enemy actors don't honour spawn_point — CLAUDE.md)
so the task is symmetric per spawn but the approach vector flips
and a memorised 'drive east on y=20' opening cannot generalise.
Registered in tests/test_hard_tier.py::UPGRADED.

Anchors: military sacrifice doctrine / forlorn hope (West & East
canonical), SC2 expendable strike package, MicroRTS / SC2LE
attack-the-base under deadline with attrition-OK.

openra_bench/scenarios/packs/combat-suicide-charge-mission.yaml ADDED
@@ -0,0 +1,196 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ meta:
2
+ id: combat-suicide-charge-mission
3
+ title: 'Suicide Charge — Sacrifice the Force to Raze a High-Value Objective'
4
+ capability: reasoning
5
+ real_world_meaning: >
6
+ Forlorn hope / military sacrifice doctrine. A high-value enemy
7
+ construction yard (`fact`) sits at the far east of the map,
8
+ heavily defended; the agent's small force at the west cannot
9
+ survive the engagement. The OBJECTIVE is the building's
10
+ destruction — survival is NOT required. A "preserve the force"
11
+ policy that engages carefully and tries to save units loses on
12
+ the clock (the picket grinds attackers down and they never
13
+ arrive); only an all-in commit that drives the WHOLE force
14
+ straight at the objective and trades every attacker for damage
15
+ on the fact actually razes it in time. This is delayed-terminal
16
+ credit assignment with an explicit cost-vs-objective trade-off:
17
+ every unit lost looks locally negative, but the only successful
18
+ plan accepts the loss of all of them.
19
+ robotics_analogue: >
20
+ Expendable strike package / single-use intervention: a fleet of
21
+ cheap drones must destroy a critical adversary asset under a
22
+ hard deadline; loiter or stand-off attacks miss the window, so
23
+ the planner must commit every platform on a one-way attack
24
+ trajectory, accepting total platform loss as the operating cost
25
+ of mission success.
26
+ benchmark_anchor:
27
+ - "military sacrifice doctrine / forlorn hope (West & East canonical)"
28
+ - "SC2 expendable strike package (all-in cost-objective trade)"
29
+ - "MicroRTS / SC2LE attack-the-base under deadline with attrition-OK"
30
+ author: openra-bench
31
+
32
+ base_map: rush-hour-arena
33
+
34
+ # The discriminator: the enemy fact at (110,20) is guarded by an
35
+ # anti-armor picket (e3 rocket infantry + 4tnk heavy tank). A
36
+ # careful "preserve the force" approach (skirmish, retreat, regroup,
37
+ # pick off targets) cannot land enough damage on the fact before the
38
+ # deadline — the e3 cluster outranges and out-damages the 2tnk column
39
+ # in a stand-off, and any retreat resets the clock. The intended
40
+ # play commits ALL six tanks plus the four e3 escorts straight east,
41
+ # absorbing fire and trading every attacker for the seconds of
42
+ # face-time needed to raze the fact. Win is buildings-only; force
43
+ # wipe is NOT a fail.
44
+ base:
45
+ agent: {faction: allies, cash: 0}
46
+ enemy: {faction: soviet, cash: 0, bot_type: guard}
47
+ tools: [move_units, attack_unit, attack_move, stop]
48
+ planning: true
49
+ termination:
50
+ max_ticks: 12000
51
+ # CRITICAL for the sacrifice anchor: disable the engine's
52
+ # auto-`done` flag on full agent-force elimination. Without this
53
+ # the engine ends the run the moment the last attacker dies,
54
+ # even if the objective fact has already been razed and
55
+ # within_ticks is still satisfied — collapsing the win into a
56
+ # draw. Enemy-units flag is left off too; the SENTINEL fact
57
+ # already keeps MustBeDestroyed enemy buildings alive past the
58
+ # objective fact's destruction; e3/4tnk defenders don't matter
59
+ # for victory conditions.
60
+ agent_units_killed: false
61
+ enemy_units_killed: false
62
+ actors:
63
+ # ── Strike package (west) — 6× 2tnk + 4× e3 ──
64
+ # Stance 1 = ReturnFire so they push without burning ammo on
65
+ # patrol fire en route; the all-in commit relies on attack_move
66
+ # / attack_unit at the end of the lane. Spread vertically across
67
+ # y=16..24 so the column isn't focus-fired into a single column
68
+ # on approach.
69
+ - {type: 2tnk, owner: agent, position: [6, 16], stance: 1, count: 2}
70
+ - {type: 2tnk, owner: agent, position: [6, 20], stance: 1, count: 2}
71
+ - {type: 2tnk, owner: agent, position: [6, 24], stance: 1, count: 2}
72
+ - {type: e3, owner: agent, position: [8, 18], stance: 1, count: 2}
73
+ - {type: e3, owner: agent, position: [8, 22], stance: 1, count: 2}
74
+ # ── Defender — `guard` bot, leashed (lunge then snap back). e3
75
+ # rockets shred 2tnk armour at range 5; 4tnk heavy brawls close.
76
+ # Medium picket = 3 e3 (anti-armor wall) + 1 4tnk (HP sponge);
77
+ # spread vertically (y=16/20/24) to cover the west approach
78
+ # corridor — an all-in commit still cracks it but trades half
79
+ # the force in doing so. On EASY, lighter picket (see override).
80
+ - {type: e3, owner: enemy, position: [104, 16], stance: 2}
81
+ - {type: e3, owner: enemy, position: [104, 20], stance: 2}
82
+ - {type: e3, owner: enemy, position: [104, 24], stance: 2}
83
+ - {type: 4tnk, owner: enemy, position: [108, 20], stance: 2}
84
+ # Objective: the high-value enemy fact at far east (110, 20).
85
+ - {type: fact, owner: enemy, position: [110, 20]}
86
+ # Sentinel fact — far north corner, never engaged. Keeps the
87
+ # episode ALIVE past objective-fact destruction so within_ticks
88
+ # can evaluate on the terminal frame (the CLAUDE.md auto-done
89
+ # footgun on MustBeDestroyed enemy buildings).
90
+ - {type: fact, owner: enemy, position: [122, 4]}
91
+
92
+ levels:
93
+ # ── EASY ── the bare sacrifice skill: light defender (only 2�� e3
94
+ # + 1× 4tnk). A preserve-the-force plan still times out, but the
95
+ # all-in commit razes the fact comfortably.
96
+ easy:
97
+ description: >
98
+ A defended enemy construction yard sits at the far east
99
+ (110, 20). Your strike package (6 tanks + 4 rocket infantry)
100
+ at the west cannot survive the engagement intact. You DO
101
+ NOT need to keep your units alive — the objective is the
102
+ enemy fact's DESTRUCTION before the deadline. A careful,
103
+ preserve-the-force approach times out on the clock; commit
104
+ the WHOLE force straight east at the objective, accept the
105
+ losses, and raze the fact.
106
+ overrides:
107
+ actors:
108
+ - {type: 2tnk, owner: agent, position: [6, 16], stance: 1, count: 2}
109
+ - {type: 2tnk, owner: agent, position: [6, 20], stance: 1, count: 2}
110
+ - {type: 2tnk, owner: agent, position: [6, 24], stance: 1, count: 2}
111
+ - {type: e3, owner: agent, position: [8, 18], stance: 1, count: 2}
112
+ - {type: e3, owner: agent, position: [8, 22], stance: 1, count: 2}
113
+ - {type: e3, owner: enemy, position: [104, 18], stance: 2}
114
+ - {type: e3, owner: enemy, position: [104, 22], stance: 2}
115
+ - {type: 4tnk, owner: enemy, position: [108, 20], stance: 2}
116
+ - {type: fact, owner: enemy, position: [110, 20]}
117
+ - {type: fact, owner: enemy, position: [122, 4]}
118
+ win_condition:
119
+ all_of:
120
+ - enemy_key_buildings_destroyed_in_region:
121
+ {x: 110, y: 20, radius: 6, types: [fact]}
122
+ - {within_ticks: 5400}
123
+ fail_condition:
124
+ any_of:
125
+ - {after_ticks: 5401}
126
+ max_turns: 62
127
+
128
+ # ── MEDIUM ── +1 controlled variable: heavier defender (3× e3 +
129
+ # 1× 4tnk — the base composition). The preserve-the-force trap
130
+ # tightens: any standoff loses ground; the all-in commit still
131
+ # wins but trades nearly the whole package.
132
+ medium:
133
+ description: >
134
+ Heavier picket (3 rocket infantry spread y=16/20/24 + 1
135
+ heavy tank) on the enemy fact at (110, 20). Same task: raze
136
+ the fact before the deadline; survival is NOT required. The
137
+ careful, preserve-the-force approach reliably times out —
138
+ the e3 outrange your tanks in any standoff. Commit
139
+ everything, all-in, straight at the objective.
140
+ overrides: {}
141
+ win_condition:
142
+ all_of:
143
+ - enemy_key_buildings_destroyed_in_region:
144
+ {x: 110, y: 20, radius: 6, types: [fact]}
145
+ - {within_ticks: 6400}
146
+ fail_condition:
147
+ any_of:
148
+ - {after_ticks: 6401}
149
+ max_turns: 73
150
+
151
+ # ── HARD ── +1 controlled variable: seed-chosen staging latitude
152
+ # (two spawn_point groups, NORTH y=10 / SOUTH y=30) so the all-in
153
+ # vector flips per seed and can't be memorised; picket adds one
154
+ # more e3 (heavy on top of the heavy-2tnk pair). Tight deadline.
155
+ hard:
156
+ description: >
157
+ Strongest defender on the far-east enemy fact at (110, 20);
158
+ your strike package stages from a seed-chosen latitude
159
+ (NORTH or SOUTH) so the all-in vector varies per seed. Raze
160
+ the fact before the (tight) deadline. Survival is NOT
161
+ required — every unit may be spent. The careful preserve
162
+ plan and any partial commit both time out; only the
163
+ whole-force all-in commit lands enough damage on the fact.
164
+ overrides:
165
+ actors:
166
+ # spawn_point 0 — NORTH staging (y=8..12)
167
+ - {type: 2tnk, owner: agent, position: [6, 8], stance: 1, count: 2, spawn_point: 0}
168
+ - {type: 2tnk, owner: agent, position: [6, 10], stance: 1, count: 2, spawn_point: 0}
169
+ - {type: 2tnk, owner: agent, position: [6, 12], stance: 1, count: 2, spawn_point: 0}
170
+ - {type: e3, owner: agent, position: [8, 9], stance: 1, count: 2, spawn_point: 0}
171
+ - {type: e3, owner: agent, position: [8, 11], stance: 1, count: 2, spawn_point: 0}
172
+ # spawn_point 1 — SOUTH staging (y=28..32)
173
+ - {type: 2tnk, owner: agent, position: [6, 28], stance: 1, count: 2, spawn_point: 1}
174
+ - {type: 2tnk, owner: agent, position: [6, 30], stance: 1, count: 2, spawn_point: 1}
175
+ - {type: 2tnk, owner: agent, position: [6, 32], stance: 1, count: 2, spawn_point: 1}
176
+ - {type: e3, owner: agent, position: [8, 29], stance: 1, count: 2, spawn_point: 1}
177
+ - {type: e3, owner: agent, position: [8, 31], stance: 1, count: 2, spawn_point: 1}
178
+ # Defender — symmetric picket on the objective fact (enemy
179
+ # actors don't honour spawn_point — see CLAUDE.md; the same
180
+ # picket meets either spawn). Hard adds one e3 vs medium.
181
+ - {type: e3, owner: enemy, position: [104, 16], stance: 2}
182
+ - {type: e3, owner: enemy, position: [104, 20], stance: 2}
183
+ - {type: e3, owner: enemy, position: [104, 24], stance: 2}
184
+ - {type: e3, owner: enemy, position: [106, 20], stance: 2}
185
+ - {type: 4tnk, owner: enemy, position: [108, 20], stance: 2}
186
+ - {type: fact, owner: enemy, position: [110, 20]}
187
+ - {type: fact, owner: enemy, position: [122, 4]}
188
+ win_condition:
189
+ all_of:
190
+ - enemy_key_buildings_destroyed_in_region:
191
+ {x: 110, y: 20, radius: 6, types: [fact]}
192
+ - {within_ticks: 7200}
193
+ fail_condition:
194
+ any_of:
195
+ - {after_ticks: 7201}
196
+ max_turns: 82
tests/test_combat_suicide_charge_mission.py ADDED
@@ -0,0 +1,434 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """combat-suicide-charge-mission — sacrifice the force to destroy a
2
+ high-value enemy objective (forlorn hope / military sacrifice doctrine).
3
+
4
+ The bar: an all-in commit that drives the WHOLE force straight at the
5
+ enemy fact (110, 20), accepting total force loss, WINS on every level
6
+ and every hard seed (1..4). STALL (only observe) and PRESERVE-FORCE
7
+ (only the lead tank probes, the rest hold) both LOSE on every level —
8
+ non-win is a real reachable timeout LOSS via the `after_ticks` fail
9
+ clause (no own_units_gte:1 clause; total force loss is NOT a fail,
10
+ this is the whole point of the sacrifice anchor).
11
+
12
+ Validation is scripted (no model / network).
13
+ """
14
+ from __future__ import annotations
15
+
16
+ from pathlib import Path
17
+
18
+ import pytest
19
+
20
+ pytest.importorskip("openra_rl_training", reason="Rust env wheel not installed")
21
+ from openra_bench.scenarios import load_pack
22
+ from openra_bench.scenarios.loader import compile_level
23
+ from openra_bench.scenarios.win_conditions import WinContext, evaluate
24
+
25
+ PACKS = Path(__file__).parent.parent / "openra_bench" / "scenarios" / "packs"
26
+ PACK_PATH = PACKS / "combat-suicide-charge-mission.yaml"
27
+
28
+
29
+ # ── unit-level predicate / metadata checks (no engine) ──────────────
30
+
31
+
32
+ def test_pack_compiles_and_meta_fields_populated():
33
+ pack = load_pack(PACK_PATH)
34
+ assert pack.meta.id == "combat-suicide-charge-mission"
35
+ assert pack.meta.capability == "reasoning"
36
+ assert pack.meta.real_world_meaning, "real_world_meaning required"
37
+ assert pack.meta.robotics_analogue, "robotics_analogue required"
38
+ anchors = pack.meta.benchmark_anchor
39
+ assert isinstance(anchors, list) and len(anchors) >= 3, (
40
+ f"benchmark_anchor must list the sacrifice anchors, got {anchors!r}"
41
+ )
42
+ joined = " ".join(anchors).lower()
43
+ for needle in ("sacrifice", "forlorn", "sc2"):
44
+ assert needle in joined, f"missing anchor keyword: {needle}"
45
+ for lvl in ("easy", "medium", "hard"):
46
+ c = compile_level(pack, lvl)
47
+ assert c.map_supported
48
+ assert c.win_condition is not None
49
+ assert c.fail_condition is not None
50
+
51
+
52
+ def _ctx(*, units=(), tick=1000, lost=0, fact_destroyed=False,
53
+ fact_xy=(110, 20)):
54
+ """Synthesize a WinContext for predicate-level checks.
55
+
56
+ `fact_destroyed=True` simulates the objective fact at `fact_xy`
57
+ having been destroyed (the engine's `enemy_buildings_destroyed_
58
+ records` carries the type + cell coords).
59
+ """
60
+ import types
61
+
62
+ types_count = {"fact": 1} if fact_destroyed else {}
63
+ sig = types.SimpleNamespace(
64
+ game_tick=tick,
65
+ units_killed=0,
66
+ units_lost=lost,
67
+ cash=0,
68
+ resources=0,
69
+ own_buildings=[],
70
+ own_building_types=set(),
71
+ enemies_seen_ids=set(),
72
+ enemy_buildings_seen_ids=set(),
73
+ enemy_buildings_destroyed=1 if fact_destroyed else 0,
74
+ enemy_buildings_destroyed_types=types_count,
75
+ enemy_buildings_destroyed_records=(
76
+ [("fact", int(fact_xy[0]), int(fact_xy[1]))]
77
+ if fact_destroyed
78
+ else []
79
+ ),
80
+ )
81
+ return WinContext(
82
+ signals=sig,
83
+ render_state={"units_summary": list(units)},
84
+ )
85
+
86
+
87
+ def _alive(n):
88
+ return [
89
+ {"cell_x": 10, "cell_y": 20, "type": "2tnk", "id": str(1000 + i)}
90
+ for i in range(n)
91
+ ]
92
+
93
+
94
+ def test_predicates_all_levels():
95
+ """Win iff (objective fact razed) AND (within deadline).
96
+ Force-wipe with the fact razed is STILL a WIN (the whole point
97
+ of the sacrifice anchor — survival is NOT required).
98
+ Fact intact + clock expired is a real fail."""
99
+ for lvl, within in (("easy", 5400), ("medium", 6400), ("hard", 7200)):
100
+ c = compile_level(load_pack(PACK_PATH), lvl)
101
+ # Intended: fact destroyed in budget — WIN even with zero units left
102
+ assert evaluate(
103
+ c.win_condition,
104
+ _ctx(units=[], tick=within - 100, lost=10, fact_destroyed=True),
105
+ ), f"{lvl}: force-wipe-but-fact-razed must WIN"
106
+ # Fact destroyed AND units alive — also a WIN (lower-loss path)
107
+ assert evaluate(
108
+ c.win_condition,
109
+ _ctx(units=_alive(3), tick=within - 100, lost=7,
110
+ fact_destroyed=True),
111
+ ), f"{lvl}: fact razed in budget must WIN regardless of survivors"
112
+ # Fact NOT destroyed — never a win even with all units alive
113
+ assert not evaluate(
114
+ c.win_condition,
115
+ _ctx(units=_alive(10), tick=within - 100, lost=0,
116
+ fact_destroyed=False),
117
+ ), f"{lvl}: preserve-the-force without razing the fact must not win"
118
+ # Past deadline — not a win even if fact razed late
119
+ assert not evaluate(
120
+ c.win_condition,
121
+ _ctx(units=_alive(5), tick=within + 1, lost=5,
122
+ fact_destroyed=True),
123
+ ), f"{lvl}: late raze must not win"
124
+ # Timeout with fact intact → fail
125
+ assert evaluate(
126
+ c.fail_condition,
127
+ _ctx(units=_alive(5), tick=within + 1, lost=5,
128
+ fact_destroyed=False),
129
+ ), f"{lvl}: deadline expiry with fact intact must FAIL"
130
+ # Force-wipe with fact intact — NOT an automatic fail
131
+ # (only the deadline can fail; the model must rely on the
132
+ # clock, not unit count). This is what makes "preserve" a
133
+ # strict losing strategy: it can't run-down-the-clock OR
134
+ # avoid death to win, only RAZE the fact.
135
+ assert not evaluate(
136
+ c.fail_condition,
137
+ _ctx(units=[], tick=within - 100, lost=10,
138
+ fact_destroyed=False),
139
+ ), (
140
+ f"{lvl}: pre-deadline force-wipe must NOT be a fail "
141
+ "(sacrifice anchor — survival is not required, only "
142
+ "the objective)"
143
+ )
144
+
145
+
146
+ def test_predicate_region_scoped_to_objective():
147
+ """The destroyed `fact` must be at (110, 20) — a fact destroyed
148
+ elsewhere (e.g. the far-corner SENTINEL fact at (122, 4)) does
149
+ NOT satisfy the in-region predicate."""
150
+ for lvl in ("easy", "medium", "hard"):
151
+ c = compile_level(load_pack(PACK_PATH), lvl)
152
+ # Sentinel fact at (122, 4) razed (would only happen if the
153
+ # agent burned ticks on the wrong target) — predicate must
154
+ # NOT trigger.
155
+ import types
156
+ sig = types.SimpleNamespace(
157
+ game_tick=100,
158
+ units_killed=0,
159
+ units_lost=0,
160
+ cash=0,
161
+ resources=0,
162
+ own_buildings=[],
163
+ own_building_types=set(),
164
+ enemies_seen_ids=set(),
165
+ enemy_buildings_seen_ids=set(),
166
+ enemy_buildings_destroyed=1,
167
+ enemy_buildings_destroyed_types={"fact": 1},
168
+ enemy_buildings_destroyed_records=[("fact", 122, 4)],
169
+ )
170
+ ctx = WinContext(signals=sig, render_state={"units_summary": []})
171
+ assert not evaluate(c.win_condition, ctx), (
172
+ f"{lvl}: razing the sentinel fact at (122,4) must NOT "
173
+ "satisfy the in-region predicate at (110,20)"
174
+ )
175
+
176
+
177
+ def test_timeout_reachable_inside_max_turns():
178
+ """No draw degeneracy: after_ticks must be reachable in max_turns
179
+ (~90 ticks/turn; max tick ≈ 93 + 90·(max_turns-1))."""
180
+ pack = load_pack(PACK_PATH)
181
+ expected = {"easy": 5401, "medium": 6401, "hard": 7201}
182
+ for lvl, after in expected.items():
183
+ c = compile_level(pack, lvl)
184
+ max_tick = 93 + 90 * (c.max_turns - 1)
185
+ assert after <= max_tick, (
186
+ f"{lvl}: after_ticks {after} > max reachable tick {max_tick} "
187
+ f"(max_turns={c.max_turns}); deadline never bites"
188
+ )
189
+
190
+
191
+ def test_hard_has_two_spawn_point_groups():
192
+ """Hard-tier curation: ≥2 distinct agent spawn_point groups so the
193
+ seed round-robins the staging latitude (NORTH y=10 / SOUTH y=30).
194
+ Engine-roundtrip is asserted by tests/test_hard_tier.py once the
195
+ pack is added to UPGRADED."""
196
+ c = compile_level(load_pack(PACK_PATH), "hard")
197
+ groups = {
198
+ a.spawn_point
199
+ for a in c.scenario.actors
200
+ if a.owner == "agent" and a.spawn_point is not None
201
+ }
202
+ assert groups == {0, 1}, f"hard needs 2 spawn_point groups, got {groups}"
203
+
204
+
205
+ def test_objective_fact_and_sentinel_present():
206
+ """Two enemy `fact` per level: one OBJECTIVE fact at (110, 20)
207
+ and one SENTINEL fact far away (~(122, 4)). The sentinel keeps
208
+ the episode alive past objective-fact destruction so the
209
+ within_ticks predicate evaluates on the terminal frame (engine
210
+ auto-done footgun on MustBeDestroyed enemy buildings —
211
+ CLAUDE.md)."""
212
+ pack = load_pack(PACK_PATH)
213
+ for lvl in ("easy", "medium", "hard"):
214
+ c = compile_level(pack, lvl)
215
+ facts = [
216
+ (a.position[0], a.position[1])
217
+ for a in c.scenario.actors
218
+ if a.owner == "enemy" and a.type == "fact"
219
+ ]
220
+ assert len(facts) == 2, (
221
+ f"{lvl}: must have 2 enemy facts (objective + sentinel), "
222
+ f"got {len(facts)} at {facts}"
223
+ )
224
+ objective = [(x, y) for (x, y) in facts
225
+ if (x - 110) ** 2 + (y - 20) ** 2 <= 36]
226
+ sentinel = [(x, y) for (x, y) in facts
227
+ if (x - 110) ** 2 + (y - 20) ** 2 > 36]
228
+ assert len(objective) == 1 and len(sentinel) == 1, (
229
+ f"{lvl}: expected 1 objective fact at ~(110,20) + 1 "
230
+ f"sentinel far away, got obj={objective} sent={sentinel}"
231
+ )
232
+
233
+
234
+ def test_actors_inside_playable_bounds():
235
+ """rush-hour-arena playable bounds are (2..126, 2..38). Every
236
+ authored actor must be inside or the engine panics."""
237
+ pack = load_pack(PACK_PATH)
238
+ for lvl in ("easy", "medium", "hard"):
239
+ c = compile_level(pack, lvl)
240
+ for a in c.scenario.actors:
241
+ x, y = a.position
242
+ assert 2 <= x <= 126 and 2 <= y <= 38, (
243
+ f"{lvl}: actor {a.type} owner={a.owner} at {a.position} "
244
+ f"is outside rush-hour-arena playable bounds"
245
+ )
246
+
247
+
248
+ def test_no_force_wipe_fail_clause():
249
+ """Critical sacrifice-anchor invariant: the fail clause MUST NOT
250
+ contain `not own_units_gte:1` (force-wipe). If it does, the
251
+ intended commit-all strategy fails the moment the last attacker
252
+ dies — even if the fact has already been razed — which inverts
253
+ the anchor. The only fail path is the deadline expiring."""
254
+ import yaml
255
+ raw = yaml.safe_load(PACK_PATH.read_text())
256
+ for lvl in ("easy", "medium", "hard"):
257
+ fail = raw["levels"][lvl].get("fail_condition") or {}
258
+
259
+ def _has_force_wipe(node) -> bool:
260
+ if isinstance(node, dict):
261
+ if "not" in node:
262
+ inner = node["not"]
263
+ if isinstance(inner, dict) and "own_units_gte" in inner:
264
+ return True
265
+ if "own_units_lte" in node and int(node["own_units_lte"]) == 0:
266
+ return True
267
+ return any(_has_force_wipe(v) for v in node.values())
268
+ if isinstance(node, list):
269
+ return any(_has_force_wipe(v) for v in node)
270
+ return False
271
+
272
+ assert not _has_force_wipe(fail), (
273
+ f"{lvl}: fail_condition must NOT include a force-wipe clause "
274
+ "(sacrifice anchor — survival is NOT required); only the "
275
+ f"deadline may fail. got {fail!r}"
276
+ )
277
+
278
+
279
+ def test_termination_keeps_episode_alive_past_force_wipe():
280
+ """Engine `agent_units_killed: false` is required: without it
281
+ the engine auto-`done`s the run the moment the last attacker
282
+ dies (the rush-hour-arena map carries the default Conquest
283
+ rules), collapsing the win-by-objective into a draw — even if
284
+ the objective fact has already been razed and within_ticks is
285
+ still satisfied. This invariant must hold per level (overrides
286
+ must NOT silently flip it back to True)."""
287
+ pack = load_pack(PACK_PATH)
288
+ for lvl in ("easy", "medium", "hard"):
289
+ c = compile_level(pack, lvl)
290
+ assert c.scenario.termination.agent_units_killed is False, (
291
+ f"{lvl}: termination.agent_units_killed must be False "
292
+ "(sacrifice anchor — engine must not auto-done on "
293
+ "force wipe before the objective evaluates)"
294
+ )
295
+
296
+
297
+ def test_enemy_picket_anti_armor():
298
+ """The defender must be lethal to a careful approach (e3 rocket
299
+ infantry anti-tank Dragon, range 5, damage 5000 vs Heavy; +
300
+ 4tnk heavy mammoth tank). Without these, a "preserve the force"
301
+ plan trivially wins — destroying the discriminator."""
302
+ pack = load_pack(PACK_PATH)
303
+ for lvl in ("easy", "medium", "hard"):
304
+ c = compile_level(pack, lvl)
305
+ enemy_types = [a.type for a in c.scenario.actors if a.owner == "enemy"]
306
+ assert "e3" in enemy_types, (
307
+ f"{lvl}: defender must include e3 rocket infantry (anti-armor)"
308
+ )
309
+ assert "4tnk" in enemy_types, (
310
+ f"{lvl}: defender must include 4tnk heavy tank (close brawl)"
311
+ )
312
+ assert enemy_types.count("fact") == 2, (
313
+ f"{lvl}: exactly 2 enemy fact (objective + sentinel)"
314
+ )
315
+
316
+
317
+ # ── engine-driven scripted policies ─────────────────────────────────
318
+
319
+
320
+ def _stall(rs, Command):
321
+ """Pure observe — fact never takes damage; clock runs out → LOSS."""
322
+ return [Command.observe()]
323
+
324
+
325
+ def _all_in(rs, Command):
326
+ """Commit-all: every agent unit drives straight at the objective
327
+ fact at (110, 20). When any enemy comes into vision the whole
328
+ force attack_unit's the closest threat, switching to the
329
+ objective fact the moment it appears in vision. Tanks + rocket
330
+ infantry absorb fire and trade themselves for damage on the
331
+ fact. The intended sacrifice play."""
332
+ units = rs.get("units_summary", []) or []
333
+ if not units:
334
+ return [Command.observe()]
335
+ ids = [str(u["id"]) for u in units]
336
+ enemies = rs.get("enemy_summary", []) or []
337
+ if enemies:
338
+ # Prioritise the objective fact if in vision (cell_x < 115
339
+ # filters out the far-corner sentinel fact at (122,4) which
340
+ # sits past the objective).
341
+ for e in enemies:
342
+ t = (e.get("type") or "").lower()
343
+ if t == "fact" and int(e.get("cell_x", 200)) < 115:
344
+ return [Command.attack_unit(ids, str(e["id"]))]
345
+ # Otherwise smash the nearest defender first.
346
+ tx = sum(int(u["cell_x"]) for u in units) / len(units)
347
+ ty = sum(int(u["cell_y"]) for u in units) / len(units)
348
+ enemies.sort(
349
+ key=lambda e: (int(e.get("cell_x", 0)) - tx) ** 2
350
+ + (int(e.get("cell_y", 0)) - ty) ** 2
351
+ )
352
+ return [Command.attack_unit(ids, str(enemies[0]["id"]))]
353
+ return [Command.attack_move(ids, 110, 20)]
354
+
355
+
356
+ def _preserve_force(rs, Command):
357
+ """Preserve plan: only the LEAD tank pushes east while the rest
358
+ hold the west staging — and any tank below 50% HP retreats back
359
+ to the west edge. Saves units but dribbles damage onto the fact
360
+ (the lead tank gets shredded long before the fact falls); clock
361
+ runs out → LOSS. Discriminates careful-skirmish from the
362
+ all-in commit."""
363
+ units = rs.get("units_summary", []) or []
364
+ if not units:
365
+ return [Command.observe()]
366
+ cmds = []
367
+ # Find the eastmost unit — that's the "lead probe"; everyone
368
+ # else holds position.
369
+ sorted_units = sorted(units, key=lambda u: -int(u.get("cell_x", 0)))
370
+ lead = sorted_units[0]
371
+ holders = sorted_units[1:]
372
+ # Lead pushes east — but if damaged, retreats to safety.
373
+ lead_hp = int(lead.get("health", lead.get("hp", 100)) or 100)
374
+ if lead_hp < 50:
375
+ cmds.append(Command.move_units([str(lead["id"])], 6, int(lead["cell_y"])))
376
+ else:
377
+ cmds.append(Command.attack_move([str(lead["id"])], 100, 20))
378
+ # Holders explicitly stop and stay west.
379
+ for h in holders:
380
+ cmds.append(Command.stop([str(h["id"])]))
381
+ return cmds
382
+
383
+
384
+ @pytest.mark.parametrize("level", ["easy", "medium", "hard"])
385
+ @pytest.mark.parametrize("seed", [1, 2, 3, 4])
386
+ def test_all_in_commit_wins(level, seed):
387
+ """Intended sacrifice play: commit ALL units straight at the
388
+ objective. Must WIN on every level and every hard seed."""
389
+ pytest.importorskip("openra_train")
390
+ from openra_bench.eval_core import run_level
391
+
392
+ c = compile_level(load_pack(PACK_PATH), level)
393
+ r = run_level(c, _all_in, seed=seed)
394
+ assert r.outcome == "win", (
395
+ f"{level} seed={seed}: all-in commit should WIN, got {r.outcome} "
396
+ f"after {r.turns} turns "
397
+ f"(lost={r.signals.units_lost}, "
398
+ f"bldgs={r.signals.enemy_buildings_destroyed_types})"
399
+ )
400
+
401
+
402
+ @pytest.mark.parametrize("level", ["easy", "medium", "hard"])
403
+ @pytest.mark.parametrize("seed", [1, 2, 3, 4])
404
+ def test_stall_loses(level, seed):
405
+ """Pure observe: fact never takes damage; clock runs out → LOSS."""
406
+ pytest.importorskip("openra_train")
407
+ from openra_bench.eval_core import run_level
408
+
409
+ c = compile_level(load_pack(PACK_PATH), level)
410
+ r = run_level(c, _stall, seed=seed)
411
+ assert r.outcome == "loss", (
412
+ f"{level} seed={seed}: stall must be a real timeout LOSS "
413
+ f"(fact never takes damage), got {r.outcome}"
414
+ )
415
+
416
+
417
+ @pytest.mark.parametrize("level", ["easy", "medium", "hard"])
418
+ @pytest.mark.parametrize("seed", [1, 2, 3, 4])
419
+ def test_preserve_force_loses(level, seed):
420
+ """Preserve-the-force / careful-skirmish: only a lead tank probes
421
+ east while the rest hold and any damaged tank retreats. Damage
422
+ on the fact dribbles; clock expires → LOSS. Discriminates
423
+ careful from the intended all-in commit."""
424
+ pytest.importorskip("openra_train")
425
+ from openra_bench.eval_core import run_level
426
+
427
+ c = compile_level(load_pack(PACK_PATH), level)
428
+ r = run_level(c, _preserve_force, seed=seed)
429
+ assert r.outcome == "loss", (
430
+ f"{level} seed={seed}: preserve-force must LOSE "
431
+ f"(insufficient damage on the fact), got {r.outcome} "
432
+ f"(lost={r.signals.units_lost}, "
433
+ f"bldgs={r.signals.enemy_buildings_destroyed_types})"
434
+ )
tests/test_hard_tier.py CHANGED
@@ -846,6 +846,19 @@ UPGRADED = [
846
  # east) and a memorised "drive east on y=20" plan mis-targets
847
  # from both spawns.
848
  "tp-rush-objective-very-fast",
 
 
 
 
 
 
 
 
 
 
 
 
 
849
  ]
850
 
851
  # Consciously NOT spawn-varied, with the reason (keeps the curation
 
846
  # east) and a memorised "drive east on y=20" plan mis-targets
847
  # from both spawns.
848
  "tp-rush-objective-very-fast",
849
+ # Wave-8 REASONING sacrifice pack — forlorn hope / military
850
+ # sacrifice doctrine / SC2 expendable strike package anchor.
851
+ # 6× 2tnk + 4× e3 staged at the west must raze an enemy `fact`
852
+ # at the far-east objective region (110,20) inside a tight
853
+ # clock; survival is NOT required (no own_units_gte clause).
854
+ # Hard defines TWO agent spawn_point groups (NORTH staging y≈10
855
+ # vs SOUTH staging y≈30) round-robined by seed; the defender
856
+ # picket (4 e3 + 1 4tnk) and objective fact at (110,20) place
857
+ # every seed (enemy actors don't honour spawn_point — CLAUDE.md)
858
+ # so the all-in-commit task is symmetric per spawn but the
859
+ # approach vector flips and a memorised "drive east on y=20"
860
+ # opening cannot generalise across seeds.
861
+ "combat-suicide-charge-mission",
862
  ]
863
 
864
  # Consciously NOT spawn-varied, with the reason (keeps the curation