yxc20098 commited on
Commit
db26966
·
1 Parent(s): 3ad2a28

fix(scenario): combat-kite-jeep-vs-tank — recalibrate after engine movement fixes

Browse files

The OpenRA-Rust movement fixes (moving units fire and take fire en
route; attack_unit on out-of-sight targets paths normally) regressed
this pack on every tier: the easy 1tnk-light-tank variant went fully
degenerate (every policy, stall included, killed the weak light tank
for free) and the close-range trade shifted so a static stand-and-
shoot now trades exactly one raider for the kill — beating the kite
cycle under the old own_units_gte:2 survival bar.

Recalibration:
- easy: replace the 1tnk light tank with the same 3tnk Soviet heavy
used on medium/hard, staged closer (x=70) so the kite-band is
widest; raiders stage off-axis (y=10) for the reactive window.
- survival bar own_units_gte:3 on EVERY tier (was :1 easy, :2
medium/hard): kiting kills the heavy losing zero raiders, every
non-kite policy loses >=1, so 'lose no raider' is the load-bearing
teeth.
- the easy->medium->hard axis is now kite-band width: easy heavy at
x=70 (band ~5-7), medium at x=80 (band ~5-6), hard at x=80 + the
two seed-flipped spawn corridors (only the ~5-cell trigger
generalises).

Bar verified every level x seeds 1-4: stall / stand-and-shoot / brute
attack_move all LOSE; intended kite cycle WINS. The stand-and-shoot
test is now a strict LOSS on all tiers (was an xfail on the pre-fix
engine).

openra_bench/scenarios/packs/combat-kite-jeep-vs-tank.yaml CHANGED
@@ -97,73 +97,88 @@ base:
97
 
98
  levels:
99
  # ── EASY ────────────────────────────────────────────────────────────
100
- # Bare kite skill: 3 medium-tank raiders vs 1 LIGHT enemy tank
101
- # (1tnk light/allies-style armour). Easier trade: even a slightly
102
- # clumsy kite cycle leaves ≥1 raider alive (the survival bar is ≥1,
103
- # not ≥2 as on medium/hard). Stall LOSES on the clock + hunt-bot
104
- # tank wipes the idle stack; stand-and-shoot still LOSES because
105
- # the bot closes and the tank cannon out-trades raider weapons
106
- # head-on even on the lighter tank.
 
 
 
 
 
 
 
 
 
107
  easy:
108
  description: >
109
- Three fast medium-tank raiders (2tnk) at the centre-west (y=20)
110
- must kill ONE enemy light tank (1tnk) that is actively HUNTING
111
- your raiders from centre-east (x≈80, y=20). The tank can
112
- out-trade raider weapons at close range; the only winning play
113
- is the KITE cycle when the tank closes into lethal range,
114
- MOVE your raiders a few cells AWAY along the lane, then
115
- attack_unit the tank from outside its close-range window;
116
- repeat. Keep at least ONE raider alive. Stalling (observe only)
117
- LOSES (kill bar unmet; the hunt-bot tank also reaches your
118
- stack and one-shots it). Standing still and trading shots
119
- LOSES (the tank cannon out-trades raider weapons at close
120
- range). Brute attack_move east LOSES (same no disengage).
121
- Before tick 4500.
 
122
  overrides:
123
  actors:
124
- # RAIDERS — 3 medium tanks spread across three cells at
125
- # centre-west to avoid stacking on a single tile (which pins
126
- # them in retreat).
127
- - {type: 2tnk, owner: agent, position: [28, 19], stance: 1}
128
- - {type: 2tnk, owner: agent, position: [30, 20], stance: 1}
129
- - {type: 2tnk, owner: agent, position: [28, 21], stance: 1}
130
- # THE HEAVY (easier variant: 1tnk light tank).
131
- - {type: 1tnk, owner: enemy, position: [80, 20], stance: 2}
 
 
132
  # Persistent far-east enemy fact — prevents engine auto-done
133
  # on enemy unit wipe collapsing the run to DRAW.
134
  - {type: fact, owner: enemy, position: [124, 20]}
135
  win_condition:
136
  all_of:
137
  - {units_killed_gte: 1}
138
- - {own_units_gte: 1}
139
  - {within_ticks: 4500}
140
  fail_condition:
141
  any_of:
142
  - {after_ticks: 4501}
143
- - {not: {own_units_gte: 1}}
144
  max_turns: 51
145
 
146
  # ── MEDIUM ──────────────────────────────────────────────────────────
147
- # +1 controlled variable vs easy: the heavy unit becomes 3tnk (Soviet
148
- # heavy, longer cannon range, higher damage), AND the survival bar
149
- # tightens to ≥2 raiders. A sloppy stand-and-shoot drops the raider
150
- # stack to ≤1 survivor in trade (verified by smoke run); the kite
151
- # cycle keeps the heavy at the edge of fire range, preserving the
152
- # survival bar.
 
 
153
  medium:
154
  description: >
155
- Three medium-tank raiders (2tnk) at the centre-west (y=20) must
156
- kill ONE enemy HEAVY tank (3tnk) that is actively HUNTING your
157
- raiders from centre-east (x≈80, y=20). The heavy tank's cannon
158
- out-trades raider weapons at close range and can one-shot a
159
- raider; standing still loses raiders faster than the tank's HP
160
- runs out. The only winning play is the KITE cycle — when the
161
- tank closes within ~6 cells, MOVE your raiders ~5 cells AWAY
162
- along the lane, then attack_unit the tank from outside its
163
- close-range window; repeat. Keep at least TWO raiders alive
164
- (a sloppy stand-and-shoot routinely trades the whole stack;
165
- the kite cycle preserves the survival bar). Stall, stand-and-
166
- shoot, and brute attack_move all LOSE. Before tick 4500.
 
167
  overrides:
168
  actors:
169
  # Raiders stage off the tank's centroid latitude (y=10
@@ -173,19 +188,19 @@ levels:
173
  - {type: 2tnk, owner: agent, position: [28, 9], stance: 1}
174
  - {type: 2tnk, owner: agent, position: [30, 10], stance: 1}
175
  - {type: 2tnk, owner: agent, position: [28, 11], stance: 1}
176
- # Heavy tank on the mid-latitude hunts the y=10 raider
177
- # centroid.
178
  - {type: 3tnk, owner: enemy, position: [80, 20], stance: 2}
179
  - {type: fact, owner: enemy, position: [124, 20]}
180
  win_condition:
181
  all_of:
182
  - {units_killed_gte: 1}
183
- - {own_units_gte: 2}
184
  - {within_ticks: 4500}
185
  fail_condition:
186
  any_of:
187
  - {after_ticks: 4501}
188
- - {not: {own_units_gte: 2}}
189
  max_turns: 51
190
 
191
  # ── HARD ────────────────────────────────────────────────────────────
@@ -198,6 +213,11 @@ levels:
198
  # opening cannot generalise. The heavy tank is centred mid-map
199
  # on the mid-latitude (y=20) between the two corridors so both
200
  # spawns face a symmetric pursuit geometry from the hunt bot.
 
 
 
 
 
201
  hard:
202
  description: >
203
  Three medium-tank raiders (2tnk) stage at one of two
@@ -207,11 +227,11 @@ levels:
207
  actively HUNTING your raiders — whichever corridor you start
208
  in, the tank closes on you. The cannon out-trades raider
209
  weapons at close range; the only winning play is the KITE
210
- cycle — when the tank closes within ~6 cells, MOVE your
211
- raiders ~5 cells AWAY along your lane, then attack_unit the
212
- tank from outside its close-range window; repeat. Keep at
213
- least TWO raiders alive AND finish before tick 3600. Stall,
214
- stand-and-shoot, and brute attack_move all LOSE.
215
  overrides:
216
  actors:
217
  # spawn_point 0 — NORTH corridor (y=10)
@@ -229,10 +249,10 @@ levels:
229
  win_condition:
230
  all_of:
231
  - {units_killed_gte: 1}
232
- - {own_units_gte: 2}
233
  - {within_ticks: 3600}
234
  fail_condition:
235
  any_of:
236
  - {after_ticks: 3601}
237
- - {not: {own_units_gte: 2}}
238
  max_turns: 41
 
97
 
98
  levels:
99
  # ── EASY ────────────────────────────────────────────────────────────
100
+ # Bare kite skill. Recalibrated 2026-05-20 after the OpenRA-Rust
101
+ # movement fixes (moving units fire AND take fire en route; attack_
102
+ # unit on out-of-sight targets paths normally). Those fixes made the
103
+ # old 1tnk-light-tank variant fully degenerate every policy
104
+ # (including pure stall) killed the weak light tank for free — so the
105
+ # heavy is now the same 3tnk Soviet heavy used on medium/hard. The
106
+ # easy AXIS is the kite-band WIDTH: the heavy stages CLOSER (x≈70 vs
107
+ # x≈80 on medium) so it has less closing distance, the hunt-bot
108
+ # centroid chase lags more, and the kite cycle has a forgiving
109
+ # reactive window — the intended kite wins across a trigger band of
110
+ # ~5..7 cells. On medium the band narrows to ~5..6; on hard (with the
111
+ # seed-flipped spawn) only the ~5-cell trigger generalises.
112
+ # The bar: kiting kills the heavy WITHOUT losing a raider; standing
113
+ # still trades exactly one raider for the kill, so own_units_gte:3
114
+ # (lose ZERO raiders) is the load-bearing teeth — stall / stand /
115
+ # brute all LOSE, only the kite cycle keeps all three raiders.
116
  easy:
117
  description: >
118
+ Three fast medium-tank raiders (2tnk) stage off-axis at the
119
+ centre-west (y≈10) and must kill ONE enemy HEAVY tank (3tnk)
120
+ that is actively HUNTING your raiders from centre-east
121
+ (x≈70, y=20). The heavy tank's cannon out-trades raider weapons
122
+ at close range and can one-shot a raider; the only winning play
123
+ is the KITE cycle when the tank closes into ~5 cells, MOVE
124
+ your raiders away along the lane, then attack_unit the tank from
125
+ outside its close-range window; repeat. Keep ALL THREE raiders
126
+ alive (kiting kills the heavy losing none; standing still trades
127
+ one raider for the kill). Stalling (observe only) LOSES — the
128
+ hunt-bot heavy closes and grinds a raider down. Standing still
129
+ and trading shots LOSES — the cannon out-trades raider weapons
130
+ and one raider falls. Brute attack_move east LOSES (same — no
131
+ disengage). Before tick 4500.
132
  overrides:
133
  actors:
134
+ # RAIDERS — 3 medium tanks spread across three cells off the
135
+ # heavy's centroid latitude (y≈10, not the heavy's y=20) so the
136
+ # hunt-bot must close DIAGONALLY — the y-axis lag gives the
137
+ # kite cycle its reactive window. Spread avoids retreat-pinning.
138
+ - {type: 2tnk, owner: agent, position: [28, 9], stance: 1}
139
+ - {type: 2tnk, owner: agent, position: [30, 10], stance: 1}
140
+ - {type: 2tnk, owner: agent, position: [28, 11], stance: 1}
141
+ # THE HEAVY 3tnk Soviet heavy, staged CLOSER (x=70) than on
142
+ # medium/hard so the kite band is widest (the easy axis).
143
+ - {type: 3tnk, owner: enemy, position: [70, 20], stance: 2}
144
  # Persistent far-east enemy fact — prevents engine auto-done
145
  # on enemy unit wipe collapsing the run to DRAW.
146
  - {type: fact, owner: enemy, position: [124, 20]}
147
  win_condition:
148
  all_of:
149
  - {units_killed_gte: 1}
150
+ - {own_units_gte: 3}
151
  - {within_ticks: 4500}
152
  fail_condition:
153
  any_of:
154
  - {after_ticks: 4501}
155
+ - {not: {own_units_gte: 3}}
156
  max_turns: 51
157
 
158
  # ── MEDIUM ──────────────────────────────────────────────────────────
159
+ # +1 controlled variable vs easy: the heavy stages FURTHER east
160
+ # (x≈80 vs x≈70 on easy), so it has more closing distance and the
161
+ # kite-band narrows from ~5..7 cells down to ~5..6 a sloppier
162
+ # proximity threshold no longer wins. The 3tnk heavy and the
163
+ # own_units_gte:3 force-preservation bar are the same as easy: kiting
164
+ # kills the heavy losing ZERO raiders, standing still trades exactly
165
+ # one raider for the kill, so the survival bar is the load-bearing
166
+ # teeth. Stall, stand-and-shoot, and brute attack_move all LOSE.
167
  medium:
168
  description: >
169
+ Three medium-tank raiders (2tnk) stage off-axis at the
170
+ centre-west (y≈10) and must kill ONE enemy HEAVY tank (3tnk)
171
+ that is actively HUNTING your raiders from centre-east
172
+ (x≈80, y=20). The heavy tank's cannon out-trades raider weapons
173
+ at close range and can one-shot a raider; standing still loses a
174
+ raider before the tank's HP runs out. The only winning play is
175
+ the KITE cycle — when the tank closes within ~5 cells, MOVE your
176
+ raiders away along the lane, then attack_unit the tank from
177
+ outside its close-range window; repeat. Keep ALL THREE raiders
178
+ alive a stand-and-shoot trades one raider for the kill and
179
+ busts the survival bar; only the kite cycle preserves the whole
180
+ stack. Stall, stand-and-shoot, and brute attack_move all LOSE.
181
+ Before tick 4500.
182
  overrides:
183
  actors:
184
  # Raiders stage off the tank's centroid latitude (y=10
 
188
  - {type: 2tnk, owner: agent, position: [28, 9], stance: 1}
189
  - {type: 2tnk, owner: agent, position: [30, 10], stance: 1}
190
  - {type: 2tnk, owner: agent, position: [28, 11], stance: 1}
191
+ # Heavy tank on the mid-latitude, staged further east than on
192
+ # easy — hunts the y=10 raider centroid.
193
  - {type: 3tnk, owner: enemy, position: [80, 20], stance: 2}
194
  - {type: fact, owner: enemy, position: [124, 20]}
195
  win_condition:
196
  all_of:
197
  - {units_killed_gte: 1}
198
+ - {own_units_gte: 3}
199
  - {within_ticks: 4500}
200
  fail_condition:
201
  any_of:
202
  - {after_ticks: 4501}
203
+ - {not: {own_units_gte: 3}}
204
  max_turns: 51
205
 
206
  # ── HARD ────────────────────────────────────────────────────────────
 
213
  # opening cannot generalise. The heavy tank is centred mid-map
214
  # on the mid-latitude (y=20) between the two corridors so both
215
  # spawns face a symmetric pursuit geometry from the hunt bot.
216
+ # With the seed-flipped spawn the kite-band narrows further: only a
217
+ # ~5-cell trigger generalises across BOTH corridors. The
218
+ # own_units_gte:3 force-preservation bar is the same as easy/medium
219
+ # — kiting kills the heavy losing zero raiders, every other policy
220
+ # loses at least one.
221
  hard:
222
  description: >
223
  Three medium-tank raiders (2tnk) stage at one of two
 
227
  actively HUNTING your raiders — whichever corridor you start
228
  in, the tank closes on you. The cannon out-trades raider
229
  weapons at close range; the only winning play is the KITE
230
+ cycle — when the tank closes within ~5 cells, MOVE your
231
+ raiders away along your lane, then attack_unit the tank from
232
+ outside its close-range window; repeat. Keep ALL THREE raiders
233
+ alive AND finish before tick 3600. Stall, stand-and-shoot, and
234
+ brute attack_move all LOSE.
235
  overrides:
236
  actors:
237
  # spawn_point 0 — NORTH corridor (y=10)
 
249
  win_condition:
250
  all_of:
251
  - {units_killed_gte: 1}
252
+ - {own_units_gte: 3}
253
  - {within_ticks: 3600}
254
  fail_condition:
255
  any_of:
256
  - {after_ticks: 3601}
257
+ - {not: {own_units_gte: 3}}
258
  max_turns: 41
tests/test_combat_kite_jeep_vs_tank.py CHANGED
@@ -1,26 +1,35 @@
1
  """combat-kite-jeep-vs-tank — kite a slow heavy unit with fast raiders.
2
 
3
- Bar: the intended kite cycle (move-AWAY when the heavy is close, then
4
- attack_unit at range; repeat) is the load-bearing decision. The four
5
- script-policy proxies exercise the predicate teeth and the failure-
6
- mode bar:
7
-
8
- stall (observe only) LOSS
9
- stand-and-shoot → LOSS (cannon out-trades raider
10
- weapons; survival bar fails)
11
- • brute attack_move east → LOSS (no disengage; tank closes
12
- and out-trades)
13
- intended kite cycle → WIN (in principle, see notes)
14
-
15
- Engine note (verified 2026-05-19): with the `hunt` bot the heavy tank
16
- closes ~50 cells per 90-tick decision interval. A purely reactive
17
- kite cycle (act after observing the tank within range) lags one
18
- decision behind the tank's approach and currently cannot consistently
19
- preserve raiders to the survival bar on every seed. The PREDICATE-
20
- level discrimination is strict and correct; the engine-driven
21
- intended-kite WIN test is parametrized over seeds with a `xfail`
22
- note documenting this engine limitation, while the other three
23
- engine-driven LOSS tests are strict.
 
 
 
 
 
 
 
 
 
24
  """
25
 
26
  from __future__ import annotations
@@ -65,14 +74,18 @@ def _ctx(units_xy=(), tick=1000, killed=0, lost=0):
65
 
66
  def test_predicates_easy():
67
  c = compile_level(load_pack(PACK_PATH), "easy")
68
- raiders3 = [(28, 19), (30, 20), (28, 21)]
69
 
70
- # Intended: 1 kill (tank), ≥1 raider alive, in time → WIN
71
  assert evaluate(c.win_condition, _ctx(raiders3, tick=3000, killed=1, lost=0))
72
- # 2 losses (1 raider remaining) still wins on easy (cap is ≥1)
73
- assert evaluate(c.win_condition, _ctx(raiders3[:1], tick=3000, killed=1, lost=2))
74
- # All 3 lost → fail (own_units_gte:1 fails)
75
- assert evaluate(c.fail_condition, _ctx([], tick=3000, killed=1, lost=3))
 
 
 
 
76
  # Past deadline → real loss, reachable within max_turns
77
  assert evaluate(c.fail_condition, _ctx(raiders3, tick=4502, killed=0, lost=0))
78
  assert 4501 <= 93 + 90 * (c.max_turns - 1), (
@@ -80,21 +93,19 @@ def test_predicates_easy():
80
  )
81
 
82
 
83
- def test_predicates_medium_two_raider_survival_bar():
84
  c = compile_level(load_pack(PACK_PATH), "medium")
85
- raiders3 = [(28, 19), (30, 20), (28, 21)]
86
  raiders2 = raiders3[:2]
87
- raiders1 = raiders3[:1]
88
 
89
- # Intended: 1 kill, ≥2 raiders alive → WIN
90
  assert evaluate(c.win_condition, _ctx(raiders3, tick=3000, killed=1, lost=0))
91
- assert evaluate(c.win_condition, _ctx(raiders2, tick=3000, killed=1, lost=1))
92
- # 1 raider remaining predicate fails (need ≥2)
93
- assert not evaluate(c.win_condition, _ctx(raiders1, tick=3000, killed=1, lost=2))
94
  # 0 kills → predicate fails
95
  assert not evaluate(c.win_condition, _ctx(raiders3, tick=3000, killed=0, lost=0))
96
- # 1 raider remaining → fail clause fires
97
- assert evaluate(c.fail_condition, _ctx(raiders1, tick=3000, killed=1, lost=2))
98
  # Past deadline → real loss, reachable
99
  assert evaluate(c.fail_condition, _ctx(raiders3, tick=4502, killed=0, lost=0))
100
  assert 4501 <= 93 + 90 * (c.max_turns - 1)
@@ -104,11 +115,11 @@ def test_predicates_hard_tighter_deadline_and_survival_bar():
104
  c = compile_level(load_pack(PACK_PATH), "hard")
105
  raiders3 = [(28, 9), (30, 10), (28, 11)]
106
 
107
- # Intended: 1 kill, ≥2 alive, in time → WIN
108
  assert evaluate(c.win_condition, _ctx(raiders3, tick=3000, killed=1, lost=0))
109
- # 1 raider remaining → predicate fails (need ≥2)
110
  assert not evaluate(
111
- c.win_condition, _ctx(raiders3[:1], tick=3000, killed=1, lost=2)
112
  )
113
  # Past the tighter deadline → real loss, reachable
114
  assert evaluate(c.fail_condition, _ctx(raiders3, tick=3602, killed=0, lost=0))
@@ -146,6 +157,26 @@ def test_pack_compiles_and_meta_fields_populated():
146
  assert c.win_condition is not None and c.fail_condition is not None
147
 
148
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
149
  def test_timeout_loss_is_reachable_on_every_level():
150
  """No draw degeneracy: the after_ticks deadline fits inside
151
  max_turns on every level (∼90 ticks/turn ⇒ 93 + 90·(max_turns-1))."""
@@ -164,10 +195,12 @@ def test_timeout_loss_is_reachable_on_every_level():
164
  # KITE_TRIGGER cells of a raider, move that raider RETREAT_DIST cells
165
  # AWAY from the tank along the lane; otherwise attack_unit the
166
  # nearest tank. The cycle is purely reactive — no memory, derived
167
- # each turn from geometry.
 
 
168
 
169
- KITE_TRIGGER = 6
170
- RETREAT_DIST = 8
171
 
172
 
173
  def _tanks(enemies):
@@ -179,15 +212,15 @@ def _tanks(enemies):
179
 
180
 
181
  def _stall_policy(rs, Command):
182
- """Stall: only observe. Kill bar never met → fail on the clock
183
- (medium/hard) or after the hunt-bot tank wipes the idle stack."""
184
  return [Command.observe()]
185
 
186
 
187
  def _stand_and_shoot_policy(rs, Command):
188
- """Stand at staging, attack_unit the tank when visible. The
189
- heavy's cannon out-trades the raider stack head-on; survival bar
190
- (own_units_gte:2 on medium/hard) fails."""
191
  units = rs.get("units_summary", []) or []
192
  enemies = rs.get("enemy_summary", []) or []
193
  tanks = _tanks(enemies)
@@ -210,8 +243,8 @@ def _stand_and_shoot_policy(rs, Command):
210
 
211
  def _brute_attack_move_policy(rs, Command):
212
  """Brute: one attack_move order eastward. No disengage; the
213
- column meets the hunt-bot heavy and dies in the same close-range
214
- trade as stand-and-shoot."""
215
  units = rs.get("units_summary", []) or []
216
  if not units:
217
  return [Command.observe()]
@@ -266,16 +299,16 @@ def _intended_kite_policy(rs, Command):
266
  return cmds
267
 
268
 
269
- @pytest.mark.parametrize("level", ["medium", "hard"])
270
  def test_stall_policy_loses(level):
271
- """Stall must LOSE on medium and hard (kill bar unmet OR hunt-bot
272
- tank wipes the idle stack)."""
 
273
  pytest.importorskip("openra_train")
274
  from openra_bench.eval_core import run_level
275
 
276
  c = compile_level(load_pack(PACK_PATH), level)
277
- seeds = (1, 2, 3, 4) if level == "hard" else (1,)
278
- for s in seeds:
279
  res = run_level(c, _stall_policy, seed=s)
280
  assert res.outcome == "loss", (
281
  f"{level} seed={s}: stall must LOSE; got {res.outcome} "
@@ -283,17 +316,16 @@ def test_stall_policy_loses(level):
283
  )
284
 
285
 
286
- @pytest.mark.parametrize("level", ["medium", "hard"])
287
  def test_brute_attack_move_loses(level):
288
  """Brute attack_move east must LOSE — no disengage; the hunt-bot
289
- heavy closes to point-blank and out-trades the column before the
290
- survival bar is met."""
291
  pytest.importorskip("openra_train")
292
  from openra_bench.eval_core import run_level
293
 
294
  c = compile_level(load_pack(PACK_PATH), level)
295
- seeds = (1, 2, 3, 4) if level == "hard" else (1,)
296
- for s in seeds:
297
  res = run_level(c, _brute_attack_move_policy, seed=s)
298
  assert res.outcome == "loss", (
299
  f"{level} seed={s}: brute attack_move must LOSE; got "
@@ -302,57 +334,38 @@ def test_brute_attack_move_loses(level):
302
  )
303
 
304
 
305
- @pytest.mark.xfail(
306
- reason=(
307
- "Engine note (verified 2026-05-19): a tightly-focused stand-and-"
308
- "shoot (attack_unit on the heavy, hold position at the off-axis "
309
- "staging) trades exactly 1 raider for the kill on current engine "
310
- "numbers — clears the own_units_gte:2 bar with 2 survivors. The "
311
- "KITE policy is strictly STRONGER on the rigor axis (preserves "
312
- "more raider HP and more flexible against multi-tank variants) "
313
- "but both pass the bar as currently stated. Bar-tightening to "
314
- "own_units_gte:3 is engine-blocked: the kite cycle cannot "
315
- "preserve all 3 raiders under hunt-bot close-range geometry. "
316
- "Tracked for a follow-up engine pass that makes heavy close-range "
317
- "volleys AoE the raider stack."
318
- ),
319
- strict=False,
320
- )
321
- @pytest.mark.parametrize("level", ["medium", "hard"])
322
  def test_stand_and_shoot_loses(level):
323
- """Stand-and-shoot should LOSE on medium and hard — the heavy tank's
324
- cannon out-trades raider weapons head-on. Marked xfail: see
325
- decorator note (current engine numbers allow a 2-survivor stand)."""
 
 
326
  pytest.importorskip("openra_train")
327
  from openra_bench.eval_core import run_level
328
 
329
  c = compile_level(load_pack(PACK_PATH), level)
330
- seeds = (1, 2, 3, 4) if level == "hard" else (1,)
331
- for s in seeds:
332
  res = run_level(c, _stand_and_shoot_policy, seed=s)
333
  assert res.outcome == "loss", (
334
- f"{level} seed={s}: stand-and-shoot expected LOSS, got "
335
  f"{res.outcome} killed={res.signals.units_killed} "
336
  f"lost={res.signals.units_lost}"
337
  )
338
 
339
 
340
- @pytest.mark.parametrize("level", ["medium", "hard"])
341
  def test_intended_kite_wins(level):
342
  """Intended kite cycle (reactive move-away + attack_unit) — the
343
  spec's load-bearing decision: each turn, if the heavy is within
344
- KITE_TRIGGER cells, retreat along the lane; otherwise attack_unit
345
- the nearest tank. Verified WINNING on every hard seed (1..4) and
346
- medium seed=1 with the off-axis raider staging (raiders on the
347
- y=10 corridor, heavy on y=20) — the y-axis lag in the hunt-bot
348
- centroid chase gives the kite cycle a reactive window.
349
- """
350
  pytest.importorskip("openra_train")
351
  from openra_bench.eval_core import run_level
352
 
353
  c = compile_level(load_pack(PACK_PATH), level)
354
- seeds = (1, 2, 3, 4) if level == "hard" else (1,)
355
- for s in seeds:
356
  res = run_level(c, _intended_kite_policy, seed=s)
357
  assert res.outcome == "win", (
358
  f"{level} seed={s}: intended kite should WIN, got {res.outcome} "
 
1
  """combat-kite-jeep-vs-tank — kite a slow heavy unit with fast raiders.
2
 
3
+ Bar (recalibrated 2026-05-20 after the OpenRA-Rust engine movement
4
+ fixes moving units now fire AND take fire en route, and attack_unit
5
+ on an out-of-sight target paths normally instead of teleporting).
6
+ Those fixes (a) made the old 1tnk-light-tank easy variant fully
7
+ degenerate — every policy, pure stall included, killed the weak light
8
+ tank for free — and (b) shifted the close-range trade so a static
9
+ attack_unit stand now trades exactly ONE raider for the kill. The
10
+ recalibration:
11
+
12
+ easy now uses the same 3tnk Soviet heavy as medium/hard, staged
13
+ CLOSER (x≈70) so the kite-band is widest (the easy difficulty
14
+ axis is band width, not a weaker enemy);
15
+ the survival bar is own_units_gte:3 on EVERY tier kiting kills
16
+ the heavy losing ZERO raiders, every non-kite policy loses ≥1, so
17
+ "lose no raider" is the load-bearing teeth.
18
+
19
+ The four script-policy proxies, every level, seeds 1-4:
20
+
21
+ stall (observe only) → LOSS the hunt-bot heavy closes and
22
+ grinds a raider down (killed 0, lost 1).
23
+ • stand-and-shoot → LOSS attack_unit the heavy without
24
+ disengaging; the cannon out-trades raider weapons at close range
25
+ and one raider falls (killed 0-1, lost 1).
26
+ • brute attack_move east → LOSS — no disengage; the column
27
+ meets the heavy point-blank and loses a raider (killed 0, lost
28
+ 1-2).
29
+ • intended kite cycle → WIN — when the heavy is within ~5
30
+ cells move the raiders away along the lane, else attack_unit it;
31
+ repeat. Kills the heavy keeping all three raiders (killed 1,
32
+ lost 0).
33
  """
34
 
35
  from __future__ import annotations
 
74
 
75
  def test_predicates_easy():
76
  c = compile_level(load_pack(PACK_PATH), "easy")
77
+ raiders3 = [(28, 9), (30, 10), (28, 11)]
78
 
79
+ # Intended: 1 kill (the heavy), all 3 raiders alive, in time → WIN
80
  assert evaluate(c.win_condition, _ctx(raiders3, tick=3000, killed=1, lost=0))
81
+ # 1 loss (only 2 raiders) win fails (the bar is own_units_gte:3)
82
+ assert not evaluate(
83
+ c.win_condition, _ctx(raiders3[:2], tick=3000, killed=1, lost=1)
84
+ )
85
+ # 0 kills → win fails
86
+ assert not evaluate(c.win_condition, _ctx(raiders3, tick=3000, killed=0, lost=0))
87
+ # 1 raider lost → fail clause fires (own_units_gte:3 busts)
88
+ assert evaluate(c.fail_condition, _ctx(raiders3[:2], tick=3000, killed=1, lost=1))
89
  # Past deadline → real loss, reachable within max_turns
90
  assert evaluate(c.fail_condition, _ctx(raiders3, tick=4502, killed=0, lost=0))
91
  assert 4501 <= 93 + 90 * (c.max_turns - 1), (
 
93
  )
94
 
95
 
96
+ def test_predicates_medium_force_preservation_bar():
97
  c = compile_level(load_pack(PACK_PATH), "medium")
98
+ raiders3 = [(28, 9), (30, 10), (28, 11)]
99
  raiders2 = raiders3[:2]
 
100
 
101
+ # Intended: 1 kill, all 3 raiders alive → WIN
102
  assert evaluate(c.win_condition, _ctx(raiders3, tick=3000, killed=1, lost=0))
103
+ # 1 raider lost predicate fails (need ≥3)
104
+ assert not evaluate(c.win_condition, _ctx(raiders2, tick=3000, killed=1, lost=1))
 
105
  # 0 kills → predicate fails
106
  assert not evaluate(c.win_condition, _ctx(raiders3, tick=3000, killed=0, lost=0))
107
+ # 1 raider lost → fail clause fires
108
+ assert evaluate(c.fail_condition, _ctx(raiders2, tick=3000, killed=1, lost=1))
109
  # Past deadline → real loss, reachable
110
  assert evaluate(c.fail_condition, _ctx(raiders3, tick=4502, killed=0, lost=0))
111
  assert 4501 <= 93 + 90 * (c.max_turns - 1)
 
115
  c = compile_level(load_pack(PACK_PATH), "hard")
116
  raiders3 = [(28, 9), (30, 10), (28, 11)]
117
 
118
+ # Intended: 1 kill, all 3 alive, in time → WIN
119
  assert evaluate(c.win_condition, _ctx(raiders3, tick=3000, killed=1, lost=0))
120
+ # 1 raider lost → predicate fails (need ≥3)
121
  assert not evaluate(
122
+ c.win_condition, _ctx(raiders3[:2], tick=3000, killed=1, lost=1)
123
  )
124
  # Past the tighter deadline → real loss, reachable
125
  assert evaluate(c.fail_condition, _ctx(raiders3, tick=3602, killed=0, lost=0))
 
157
  assert c.win_condition is not None and c.fail_condition is not None
158
 
159
 
160
+ def test_force_preservation_bar_is_lose_zero_on_every_tier():
161
+ """The recalibrated bar is own_units_gte:3 on every level — a
162
+ policy that loses even ONE of the three raiders fails the win and
163
+ trips the fail clause. This is what makes the kite cycle (lose
164
+ zero) load-bearing against a stand-and-shoot (lose one)."""
165
+ pack = load_pack(PACK_PATH)
166
+ raiders3 = [(28, 9), (30, 10), (28, 11)]
167
+ for lvl in ("easy", "medium", "hard"):
168
+ c = compile_level(pack, lvl)
169
+ # all 3 alive + a kill → WIN
170
+ assert evaluate(c.win_condition, _ctx(raiders3, tick=2000, killed=1))
171
+ # exactly one raider lost → NOT a win, and fail clause fires
172
+ assert not evaluate(
173
+ c.win_condition, _ctx(raiders3[:2], tick=2000, killed=1, lost=1)
174
+ )
175
+ assert evaluate(
176
+ c.fail_condition, _ctx(raiders3[:2], tick=2000, killed=1, lost=1)
177
+ )
178
+
179
+
180
  def test_timeout_loss_is_reachable_on_every_level():
181
  """No draw degeneracy: the after_ticks deadline fits inside
182
  max_turns on every level (∼90 ticks/turn ⇒ 93 + 90·(max_turns-1))."""
 
195
  # KITE_TRIGGER cells of a raider, move that raider RETREAT_DIST cells
196
  # AWAY from the tank along the lane; otherwise attack_unit the
197
  # nearest tank. The cycle is purely reactive — no memory, derived
198
+ # each turn from geometry. KITE_TRIGGER=5 is the proximity threshold
199
+ # that generalises across all tiers and both hard spawn corridors
200
+ # (the recalibrated kite band).
201
 
202
+ KITE_TRIGGER = 5
203
+ RETREAT_DIST = 12
204
 
205
 
206
  def _tanks(enemies):
 
212
 
213
 
214
  def _stall_policy(rs, Command):
215
+ """Stall: only observe. The hunt-bot heavy closes on the idle
216
+ raider stack and grinds a raider down own_units_gte:3 busts."""
217
  return [Command.observe()]
218
 
219
 
220
  def _stand_and_shoot_policy(rs, Command):
221
+ """Stand at staging, attack_unit the heavy when visible — never
222
+ disengage. The cannon out-trades raider weapons head-on; one
223
+ raider falls and the own_units_gte:3 survival bar fails."""
224
  units = rs.get("units_summary", []) or []
225
  enemies = rs.get("enemy_summary", []) or []
226
  tanks = _tanks(enemies)
 
243
 
244
  def _brute_attack_move_policy(rs, Command):
245
  """Brute: one attack_move order eastward. No disengage; the
246
+ column meets the hunt-bot heavy and loses a raider in the same
247
+ close-range trade as stand-and-shoot."""
248
  units = rs.get("units_summary", []) or []
249
  if not units:
250
  return [Command.observe()]
 
299
  return cmds
300
 
301
 
302
+ @pytest.mark.parametrize("level", ["easy", "medium", "hard"])
303
  def test_stall_policy_loses(level):
304
+ """Stall must LOSE on every level/seed the hunt-bot heavy closes
305
+ on the idle raider stack and grinds a raider down (own_units_gte:3
306
+ busts)."""
307
  pytest.importorskip("openra_train")
308
  from openra_bench.eval_core import run_level
309
 
310
  c = compile_level(load_pack(PACK_PATH), level)
311
+ for s in (1, 2, 3, 4):
 
312
  res = run_level(c, _stall_policy, seed=s)
313
  assert res.outcome == "loss", (
314
  f"{level} seed={s}: stall must LOSE; got {res.outcome} "
 
316
  )
317
 
318
 
319
+ @pytest.mark.parametrize("level", ["easy", "medium", "hard"])
320
  def test_brute_attack_move_loses(level):
321
  """Brute attack_move east must LOSE — no disengage; the hunt-bot
322
+ heavy closes to point-blank and out-trades the column, costing a
323
+ raider before the survival bar is met."""
324
  pytest.importorskip("openra_train")
325
  from openra_bench.eval_core import run_level
326
 
327
  c = compile_level(load_pack(PACK_PATH), level)
328
+ for s in (1, 2, 3, 4):
 
329
  res = run_level(c, _brute_attack_move_policy, seed=s)
330
  assert res.outcome == "loss", (
331
  f"{level} seed={s}: brute attack_move must LOSE; got "
 
334
  )
335
 
336
 
337
+ @pytest.mark.parametrize("level", ["easy", "medium", "hard"])
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
338
  def test_stand_and_shoot_loses(level):
339
+ """Stand-and-shoot must LOSE on every level/seed — the heavy tank's
340
+ cannon out-trades raider weapons head-on. With the recalibrated
341
+ own_units_gte:3 bar a stand trades exactly one raider for the kill
342
+ and busts the survival cap (was an xfail on the pre-fix engine,
343
+ now a strict LOSS)."""
344
  pytest.importorskip("openra_train")
345
  from openra_bench.eval_core import run_level
346
 
347
  c = compile_level(load_pack(PACK_PATH), level)
348
+ for s in (1, 2, 3, 4):
 
349
  res = run_level(c, _stand_and_shoot_policy, seed=s)
350
  assert res.outcome == "loss", (
351
+ f"{level} seed={s}: stand-and-shoot must LOSE; got "
352
  f"{res.outcome} killed={res.signals.units_killed} "
353
  f"lost={res.signals.units_lost}"
354
  )
355
 
356
 
357
+ @pytest.mark.parametrize("level", ["easy", "medium", "hard"])
358
  def test_intended_kite_wins(level):
359
  """Intended kite cycle (reactive move-away + attack_unit) — the
360
  spec's load-bearing decision: each turn, if the heavy is within
361
+ KITE_TRIGGER (~5) cells, retreat along the lane; otherwise
362
+ attack_unit the nearest tank. Verified WINNING on every level and
363
+ every seed (1..4) kills the heavy keeping all three raiders."""
 
 
 
364
  pytest.importorskip("openra_train")
365
  from openra_bench.eval_core import run_level
366
 
367
  c = compile_level(load_pack(PACK_PATH), level)
368
+ for s in (1, 2, 3, 4):
 
369
  res = run_level(c, _intended_kite_policy, seed=s)
370
  assert res.outcome == "win", (
371
  f"{level} seed={s}: intended kite should WIN, got {res.outcome} "