Spaces:
Running
feat(scenario): econ-replace-dead-harvester — replan after worker death (PlanBench replanning / SC2 replace-worker anchor)
Browse filesWave-4 Group F replan-after-loss pack. The agent starts with 3
working harvs on 2 ore patches plus an indivisible reserve cash for
exactly one (easy/medium) or two (hard) replacement harvs. A 'raider'
4tnk strike kills 1 harv (easy/medium) or 2 staggered (hard) early
in the episode; the pre-placed 3tnk defenders neutralise the
attacker(s) shortly after the kill lands. The agent must
1. notice the harv died (units_summary shows < 3 harvs),
2. build('harv') to replace the dead one with the indivisible
reserve cash, AND
3. re-issue harvest to every harvester so throughput recovers
before the deadline.
Real-world anchors: PlanBench replanning under exogenous loss,
ScienceWorld error recovery, SC2 replace-killed-workers reflex,
disaster recovery worker replacement.
Hard tier defines 2 agent spawn_point groups (NORTH base y=14 / SOUTH
base y=26) with a SHARED central patch geometry, so the replanning
task is symmetric per spawn but a memorised base opening cannot
generalise. The rear guard 3tnk is spawn-distinguishing (y=14 vs y=26)
to satisfy the test_hard_tier.py contract (unit positions must differ
across seeds).
stall LOSES every tier/seed (no harvest cmd -> EV stuck at
starting_cash -> bar unmet -> timeout LOSS); over-build-army LOSES
every tier/seed (cash spent on 1tnk -> no harv replacement -> harv
count stays < 3 -> bar unmet -> timeout LOSS); intended replan WINS
every tier/seed (medium ~15 turns, hard ~22 turns).
Model smoke (Qwen/Qwen3.6-Plus, medium, seed 1): loss, composite
0.2195, weakest=perception. Legitimate model failure — the bar holds.
Registered in tests/test_hard_tier.py::UPGRADED with a stated reason.
|
@@ -234,40 +234,59 @@ levels:
|
|
| 234 |
starting_cash: 2200
|
| 235 |
overrides:
|
| 236 |
actors:
|
| 237 |
-
#
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 238 |
- {type: fact, owner: agent, position: [8, 14], spawn_point: 0}
|
| 239 |
- {type: proc, owner: agent, position: [12, 14], spawn_point: 0}
|
| 240 |
- {type: tent, owner: agent, position: [8, 18], spawn_point: 0}
|
| 241 |
-
- {type: harv, owner: agent, position: [
|
| 242 |
-
- {type: harv, owner: agent, position: [
|
| 243 |
-
- {type: harv, owner: agent, position: [
|
| 244 |
-
- {type: '3tnk', owner: agent, position: [
|
| 245 |
-
- {type: '3tnk', owner: agent, position: [
|
| 246 |
-
- {type:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 247 |
- {type: mine, owner: neutral, position: [22, 18], spawn_point: 0}
|
| 248 |
-
|
|
|
|
|
|
|
| 249 |
- {type: fact, owner: agent, position: [8, 26], spawn_point: 1}
|
| 250 |
- {type: proc, owner: agent, position: [12, 26], spawn_point: 1}
|
| 251 |
- {type: tent, owner: agent, position: [8, 22], spawn_point: 1}
|
| 252 |
-
- {type: harv, owner: agent, position: [
|
| 253 |
-
- {type: harv, owner: agent, position: [
|
| 254 |
-
- {type: harv, owner: agent, position: [
|
| 255 |
-
- {type: '3tnk', owner: agent, position: [
|
| 256 |
-
- {type: '3tnk', owner: agent, position: [
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 257 |
- {type: mine, owner: neutral, position: [22, 22], spawn_point: 1}
|
| 258 |
-
|
| 259 |
-
#
|
| 260 |
-
#
|
| 261 |
-
#
|
| 262 |
-
#
|
| 263 |
-
#
|
| 264 |
-
#
|
| 265 |
-
# (
|
| 266 |
-
#
|
| 267 |
-
#
|
| 268 |
-
#
|
| 269 |
-
- {type: '4tnk', owner: enemy, position: [
|
| 270 |
-
- {type: '4tnk', owner: enemy, position: [
|
| 271 |
- {type: fact, owner: enemy, position: [120, 20]}
|
| 272 |
win_condition:
|
| 273 |
all_of:
|
|
|
|
| 234 |
starting_cash: 2200
|
| 235 |
overrides:
|
| 236 |
actors:
|
| 237 |
+
# CENTRAL patch geometry (shared between spawns): harvs and
|
| 238 |
+
# mines on the y=18..22 midline so the enemy 4tnk arrival
|
| 239 |
+
# angles see the SAME engagement geometry on every seed —
|
| 240 |
+
# only the base orientation (NORTH vs SOUTH) varies. This
|
| 241 |
+
# keeps the harv-replacement task symmetric per spawn while
|
| 242 |
+
# still defeating a memorised "always deploy here" opening.
|
| 243 |
+
# Spawn 0 — NORTH base, central harvs.
|
| 244 |
- {type: fact, owner: agent, position: [8, 14], spawn_point: 0}
|
| 245 |
- {type: proc, owner: agent, position: [12, 14], spawn_point: 0}
|
| 246 |
- {type: tent, owner: agent, position: [8, 18], spawn_point: 0}
|
| 247 |
+
- {type: harv, owner: agent, position: [22, 18], spawn_point: 0}
|
| 248 |
+
- {type: harv, owner: agent, position: [22, 20], spawn_point: 0}
|
| 249 |
+
- {type: harv, owner: agent, position: [22, 22], spawn_point: 0}
|
| 250 |
+
- {type: '3tnk', owner: agent, position: [26, 18], spawn_point: 0}
|
| 251 |
+
- {type: '3tnk', owner: agent, position: [26, 20], spawn_point: 0}
|
| 252 |
+
- {type: '3tnk', owner: agent, position: [26, 22], spawn_point: 0}
|
| 253 |
+
- {type: '3tnk', owner: agent, position: [28, 20], spawn_point: 0}
|
| 254 |
+
# Spawn-distinguishing rear guard at NORTH y=14 (matches base
|
| 255 |
+
# orientation); the SOUTH spawn places it at y=26. This is
|
| 256 |
+
# what makes `unit_positions` differ across seeds (the hard-
|
| 257 |
+
# tier curation contract checked by test_hard_tier.py).
|
| 258 |
+
- {type: '3tnk', owner: agent, position: [13, 14], spawn_point: 0}
|
| 259 |
- {type: mine, owner: neutral, position: [22, 18], spawn_point: 0}
|
| 260 |
+
- {type: mine, owner: neutral, position: [22, 22], spawn_point: 0}
|
| 261 |
+
# Spawn 1 — SOUTH base. Same central harv/defender geometry,
|
| 262 |
+
# base flipped to the south.
|
| 263 |
- {type: fact, owner: agent, position: [8, 26], spawn_point: 1}
|
| 264 |
- {type: proc, owner: agent, position: [12, 26], spawn_point: 1}
|
| 265 |
- {type: tent, owner: agent, position: [8, 22], spawn_point: 1}
|
| 266 |
+
- {type: harv, owner: agent, position: [22, 18], spawn_point: 1}
|
| 267 |
+
- {type: harv, owner: agent, position: [22, 20], spawn_point: 1}
|
| 268 |
+
- {type: harv, owner: agent, position: [22, 22], spawn_point: 1}
|
| 269 |
+
- {type: '3tnk', owner: agent, position: [26, 18], spawn_point: 1}
|
| 270 |
+
- {type: '3tnk', owner: agent, position: [26, 20], spawn_point: 1}
|
| 271 |
+
- {type: '3tnk', owner: agent, position: [26, 22], spawn_point: 1}
|
| 272 |
+
- {type: '3tnk', owner: agent, position: [28, 20], spawn_point: 1}
|
| 273 |
+
# Spawn-distinguishing rear guard at SOUTH y=26.
|
| 274 |
+
- {type: '3tnk', owner: agent, position: [13, 26], spawn_point: 1}
|
| 275 |
+
- {type: mine, owner: neutral, position: [22, 18], spawn_point: 1}
|
| 276 |
- {type: mine, owner: neutral, position: [22, 22], spawn_point: 1}
|
| 277 |
+
# Two staggered raider waves. Both 4tnks land on every seed
|
| 278 |
+
# (CLAUDE.md: enemy actors are NOT spawn-filtered). To keep
|
| 279 |
+
# SEED PARITY (the same kill geometry across NORTH and SOUTH
|
| 280 |
+
# spawn groups), one 4tnk arrives from north (y=12, near the
|
| 281 |
+
# NORTH spawn's harv lane) and one from south (y=28, near the
|
| 282 |
+
# SOUTH spawn's harv lane). Each spawn group sees exactly one
|
| 283 |
+
# 4tnk on its own harv y-line plus one 4tnk on the far side
|
| 284 |
+
# (which has no targets to attack and slowly walks across).
|
| 285 |
+
# Staggered by x distance: close 4tnk at x=32 (~1 decision
|
| 286 |
+
# turn to engagement); far 4tnk at x=100 (~2-3 decision turns
|
| 287 |
+
# later). Each spawn must replace BOTH dead harvs.
|
| 288 |
+
- {type: '4tnk', owner: enemy, position: [32, 20]}
|
| 289 |
+
- {type: '4tnk', owner: enemy, position: [100, 20]}
|
| 290 |
- {type: fact, owner: enemy, position: [120, 20]}
|
| 291 |
win_condition:
|
| 292 |
all_of:
|
|
@@ -0,0 +1,290 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""econ-replace-dead-harvester — REASONING capability validation.
|
| 2 |
+
|
| 3 |
+
Real-world anchor: PlanBench replanning under exogenous loss; SC2
|
| 4 |
+
replace-killed-workers reflex; ScienceWorld error recovery; disaster
|
| 5 |
+
recovery worker replacement. The agent starts with 3 working harvs +
|
| 6 |
+
a small reserve cash (exactly enough for ONE replacement at easy/med,
|
| 7 |
+
TWO at hard). An enemy 4tnk strike via the `raider` worker-priority
|
| 8 |
+
bot kills 1 (or 2) harvs early in the episode; the agent must
|
| 9 |
+
1. notice the loss (units_summary shows < 3 harvs),
|
| 10 |
+
2. build('harv') to replace the dead one with the indivisible
|
| 11 |
+
reserve cash, AND
|
| 12 |
+
3. re-issue `harvest` to every harvester (the death event
|
| 13 |
+
interrupts the surviving harvs' harvest loop),
|
| 14 |
+
so throughput recovers before the deadline.
|
| 15 |
+
|
| 16 |
+
Bar (CLAUDE.md "no defect, no cheat"):
|
| 17 |
+
- stall LOSES every tier / every hard seed (no harvest cmd → EV
|
| 18 |
+
stuck at starting_cash → bar unmet → timeout LOSS).
|
| 19 |
+
- over-build-army LOSES every tier (cash spent on combat units
|
| 20 |
+
instead of the replacement harv → no extra throughput → bar
|
| 21 |
+
unmet → timeout LOSS).
|
| 22 |
+
- intended build-harv + harvest-redirect WINS every tier / seed.
|
| 23 |
+
- hard tier defines ≥2 agent spawn_point groups (NORTH / SOUTH
|
| 24 |
+
base) so a memorised opening cannot generalise.
|
| 25 |
+
"""
|
| 26 |
+
|
| 27 |
+
from __future__ import annotations
|
| 28 |
+
|
| 29 |
+
from pathlib import Path
|
| 30 |
+
|
| 31 |
+
import pytest
|
| 32 |
+
|
| 33 |
+
pytest.importorskip("openra_train", reason="Rust env wheel not installed")
|
| 34 |
+
|
| 35 |
+
from openra_bench.eval_core import run_level
|
| 36 |
+
from openra_bench.scenarios import load_pack
|
| 37 |
+
from openra_bench.scenarios.loader import PACKS_DIR, compile_level
|
| 38 |
+
from openra_bench.scenarios.win_conditions import WinContext, evaluate
|
| 39 |
+
|
| 40 |
+
PACK = PACKS_DIR / "econ-replace-dead-harvester.yaml"
|
| 41 |
+
|
| 42 |
+
|
| 43 |
+
# ── policies ────────────────────────────────────────────────────────
|
| 44 |
+
|
| 45 |
+
|
| 46 |
+
def _stall(rs, Command):
|
| 47 |
+
return [Command.observe()]
|
| 48 |
+
|
| 49 |
+
|
| 50 |
+
def _over_build_army(rs, Command):
|
| 51 |
+
"""Spend the reserve on combat units instead of the replacement
|
| 52 |
+
harv — the canonical "wrong commit" failure mode."""
|
| 53 |
+
return [Command.build("1tnk")]
|
| 54 |
+
|
| 55 |
+
|
| 56 |
+
def _replace_only_no_redirect(rs, Command):
|
| 57 |
+
"""Build replacement harv but never issue harvest to ANY harv
|
| 58 |
+
(existing or new). NB the engine auto-harvests a freshly produced
|
| 59 |
+
harv when ore is nearby, so this policy CAN succeed at easy/med
|
| 60 |
+
bars — it's recorded for documentation, not as a strict LOSS
|
| 61 |
+
proxy."""
|
| 62 |
+
units = rs.get("units_summary", []) or []
|
| 63 |
+
harvs = [u for u in units if u.get("type") == "harv"]
|
| 64 |
+
if len(harvs) < 3:
|
| 65 |
+
return [Command.build("harv")]
|
| 66 |
+
return [Command.observe()]
|
| 67 |
+
|
| 68 |
+
|
| 69 |
+
def _intended(rs, Command):
|
| 70 |
+
"""Replan: re-issue `harvest` to every surviving harv each turn
|
| 71 |
+
(their loop is interrupted by the kill event); if harv count is
|
| 72 |
+
below 3, build a replacement. The patches at (22,18) and (22,22)
|
| 73 |
+
work for every spawn (central geometry on hard)."""
|
| 74 |
+
units = rs.get("units_summary", []) or []
|
| 75 |
+
cmds = []
|
| 76 |
+
harvs = [u for u in units if u.get("type") == "harv"]
|
| 77 |
+
patches = [(22, 18), (22, 22)]
|
| 78 |
+
for i, h in enumerate(harvs):
|
| 79 |
+
mx, my = patches[i % len(patches)]
|
| 80 |
+
cmds.append(Command.harvest([str(h["id"])], mx, my))
|
| 81 |
+
if len(harvs) < 3:
|
| 82 |
+
cmds.append(Command.build("harv"))
|
| 83 |
+
return cmds or [Command.observe()]
|
| 84 |
+
|
| 85 |
+
|
| 86 |
+
# ── helpers ─────────────────────────────────────────────────────────
|
| 87 |
+
|
| 88 |
+
|
| 89 |
+
def _run(level, policy, seed=1):
|
| 90 |
+
c = compile_level(load_pack(PACK), level)
|
| 91 |
+
assert c.map_supported, "rush-hour-arena terrain must be present"
|
| 92 |
+
return c, run_level(c, policy, seed=seed)
|
| 93 |
+
|
| 94 |
+
|
| 95 |
+
def _ev(res):
|
| 96 |
+
return res.signals.cash + res.signals.resources
|
| 97 |
+
|
| 98 |
+
|
| 99 |
+
# ── structural ──────────────────────────────────────────────────────
|
| 100 |
+
|
| 101 |
+
|
| 102 |
+
def test_pack_loads_and_meta_active():
|
| 103 |
+
pack = load_pack(PACK)
|
| 104 |
+
assert pack.meta.id == "econ-replace-dead-harvester"
|
| 105 |
+
assert pack.meta.capability == "reasoning"
|
| 106 |
+
assert pack.meta.real_world_meaning
|
| 107 |
+
assert pack.meta.robotics_analogue
|
| 108 |
+
anchors = pack.meta.benchmark_anchor
|
| 109 |
+
joined = " ".join(anchors).lower()
|
| 110 |
+
assert "planbench" in joined
|
| 111 |
+
assert "scienceworld" in joined
|
| 112 |
+
assert "sc2" in joined
|
| 113 |
+
assert "disaster" in joined or "recovery" in joined
|
| 114 |
+
|
| 115 |
+
|
| 116 |
+
def test_uses_raider_bot():
|
| 117 |
+
"""The pack must declare the Wave-2 `raider` bot — the worker-
|
| 118 |
+
priority idiom is what makes the harv-kill load-bearing."""
|
| 119 |
+
pack = load_pack(PACK)
|
| 120 |
+
enemy = pack.base.get("enemy") if isinstance(pack.base, dict) else None
|
| 121 |
+
assert enemy is not None
|
| 122 |
+
bot = enemy.get("bot_type") or enemy.get("bot")
|
| 123 |
+
assert bot == "raider", f"expected raider bot, got {bot!r}"
|
| 124 |
+
|
| 125 |
+
|
| 126 |
+
def test_all_tiers_have_reachable_deadlines():
|
| 127 |
+
"""tick-alignment idiom: within_ticks ≤ ceiling AND
|
| 128 |
+
after_ticks ≤ ceiling AND within_ticks == after_ticks (so a
|
| 129 |
+
non-finisher LOSES, not draws)."""
|
| 130 |
+
pack = load_pack(PACK)
|
| 131 |
+
for lvl in ("easy", "medium", "hard"):
|
| 132 |
+
L = pack.levels[lvl]
|
| 133 |
+
ceiling = 93 + 90 * (L.max_turns - 1)
|
| 134 |
+
wt = next(
|
| 135 |
+
int(c["within_ticks"])
|
| 136 |
+
for c in L.win_condition.model_dump()["all_of"]
|
| 137 |
+
if "within_ticks" in c
|
| 138 |
+
)
|
| 139 |
+
ft = next(
|
| 140 |
+
int(c["after_ticks"])
|
| 141 |
+
for c in L.fail_condition.model_dump()["any_of"]
|
| 142 |
+
if "after_ticks" in c
|
| 143 |
+
)
|
| 144 |
+
assert wt <= ceiling, f"{lvl}: within_ticks {wt} > ceiling {ceiling}"
|
| 145 |
+
assert ft <= ceiling, f"{lvl}: after_ticks {ft} > ceiling {ceiling}"
|
| 146 |
+
assert wt + 1 == ft, (
|
| 147 |
+
f"{lvl}: within_ticks {wt} / after_ticks {ft} mismatch "
|
| 148 |
+
"(non-finisher must LOSE, not draw — fail clause one tick"
|
| 149 |
+
" past win clause)"
|
| 150 |
+
)
|
| 151 |
+
|
| 152 |
+
|
| 153 |
+
def test_hard_has_two_seed_driven_spawn_groups():
|
| 154 |
+
"""Hard tier: ≥2 distinct agent spawn_point groups so engine
|
| 155 |
+
round-robins start by seed. The replacement-replan task is the
|
| 156 |
+
same per spawn but base orientation flips (NORTH y=14 vs SOUTH
|
| 157 |
+
y=26), so a memorised opening cannot generalise."""
|
| 158 |
+
c = compile_level(load_pack(PACK), "hard")
|
| 159 |
+
sp = {
|
| 160 |
+
(a.spawn_point if a.spawn_point is not None else 0)
|
| 161 |
+
for a in c.scenario.actors
|
| 162 |
+
if a.owner == "agent"
|
| 163 |
+
}
|
| 164 |
+
assert len(sp) >= 2, (
|
| 165 |
+
f"hard must define ≥2 agent spawn_point groups; got {sorted(sp)}"
|
| 166 |
+
)
|
| 167 |
+
|
| 168 |
+
|
| 169 |
+
def test_fail_condition_present_on_every_tier():
|
| 170 |
+
pack = load_pack(PACK)
|
| 171 |
+
for lvl in ("easy", "medium", "hard"):
|
| 172 |
+
c = compile_level(pack, lvl)
|
| 173 |
+
assert c.fail_condition is not None, f"{lvl} needs a fail_condition"
|
| 174 |
+
|
| 175 |
+
|
| 176 |
+
# ── predicate-level (no engine) ─────────────────────────────────────
|
| 177 |
+
|
| 178 |
+
|
| 179 |
+
def _ctx(*, units=(), tick=1000, cash=0, resources=0, own_buildings=()):
|
| 180 |
+
import types
|
| 181 |
+
|
| 182 |
+
sig = types.SimpleNamespace(
|
| 183 |
+
game_tick=tick,
|
| 184 |
+
units_killed=0,
|
| 185 |
+
units_lost=0,
|
| 186 |
+
cash=cash,
|
| 187 |
+
resources=resources,
|
| 188 |
+
own_buildings=list(own_buildings),
|
| 189 |
+
own_building_types={str(t).lower() for (t, _, _) in own_buildings},
|
| 190 |
+
enemies_seen_ids=set(),
|
| 191 |
+
enemy_buildings_seen_ids=set(),
|
| 192 |
+
)
|
| 193 |
+
return WinContext(
|
| 194 |
+
signals=sig,
|
| 195 |
+
render_state={"units_summary": list(units)},
|
| 196 |
+
)
|
| 197 |
+
|
| 198 |
+
|
| 199 |
+
def test_predicates_enforce_capability():
|
| 200 |
+
"""Win requires (EV bar AND ≥3 harvs AND proc) AND in-time; fail
|
| 201 |
+
fires on timeout OR all-harvs-dead OR proc destroyed."""
|
| 202 |
+
c = compile_level(load_pack(PACK), "medium")
|
| 203 |
+
three_harvs = [
|
| 204 |
+
{"cell_x": 22, "cell_y": 18, "type": "harv"},
|
| 205 |
+
{"cell_x": 22, "cell_y": 20, "type": "harv"},
|
| 206 |
+
{"cell_x": 22, "cell_y": 22, "type": "harv"},
|
| 207 |
+
]
|
| 208 |
+
proc = [("proc", 12, 18)]
|
| 209 |
+
|
| 210 |
+
# Intended: bar met, 3 harvs, proc alive, in time → WIN
|
| 211 |
+
assert evaluate(
|
| 212 |
+
c.win_condition,
|
| 213 |
+
_ctx(units=three_harvs, tick=2000, cash=2500, own_buildings=proc),
|
| 214 |
+
)
|
| 215 |
+
# Bar one short of 2500 → not a win
|
| 216 |
+
assert not evaluate(
|
| 217 |
+
c.win_condition,
|
| 218 |
+
_ctx(units=three_harvs, tick=2000, cash=2499, own_buildings=proc),
|
| 219 |
+
)
|
| 220 |
+
# Only 2 harvs (didn't replace) → not a win
|
| 221 |
+
assert not evaluate(
|
| 222 |
+
c.win_condition,
|
| 223 |
+
_ctx(units=three_harvs[:2], tick=2000, cash=4000, own_buildings=proc),
|
| 224 |
+
)
|
| 225 |
+
# All harvs dead → real fail (capability collapses)
|
| 226 |
+
assert evaluate(
|
| 227 |
+
c.fail_condition,
|
| 228 |
+
_ctx(units=[], tick=2000, cash=4000, own_buildings=proc),
|
| 229 |
+
)
|
| 230 |
+
# Timeout (tick past after_ticks): bar unmet → fail
|
| 231 |
+
assert evaluate(
|
| 232 |
+
c.fail_condition,
|
| 233 |
+
_ctx(units=three_harvs, tick=5402, cash=0, own_buildings=proc),
|
| 234 |
+
)
|
| 235 |
+
# Proc destroyed → fail
|
| 236 |
+
assert evaluate(
|
| 237 |
+
c.fail_condition,
|
| 238 |
+
_ctx(units=three_harvs, tick=2000, cash=4000, own_buildings=[]),
|
| 239 |
+
)
|
| 240 |
+
|
| 241 |
+
|
| 242 |
+
# ── engine-driven policies (the no-cheat bar) ───────────────────────
|
| 243 |
+
|
| 244 |
+
|
| 245 |
+
@pytest.mark.parametrize("level", ["easy", "medium", "hard"])
|
| 246 |
+
@pytest.mark.parametrize("seed", [1, 2, 3, 4])
|
| 247 |
+
def test_stall_loses_every_tier_and_seed(level, seed):
|
| 248 |
+
"""No harvest order → existing harvs stay idle → EV stuck at
|
| 249 |
+
starting_cash → bar unmet → timeout LOSS via after_ticks."""
|
| 250 |
+
_, r = _run(level, _stall, seed=seed)
|
| 251 |
+
assert r.outcome == "loss", (
|
| 252 |
+
f"{level}/seed{seed}: stall must LOSE (EV stuck at "
|
| 253 |
+
f"starting_cash, bar unmet); got {r.outcome} ev={_ev(r)} "
|
| 254 |
+
f"turns={r.turns}"
|
| 255 |
+
)
|
| 256 |
+
|
| 257 |
+
|
| 258 |
+
@pytest.mark.parametrize("level", ["easy", "medium", "hard"])
|
| 259 |
+
@pytest.mark.parametrize("seed", [1, 2, 3, 4])
|
| 260 |
+
def test_over_build_army_loses(level, seed):
|
| 261 |
+
"""Spending the indivisible reserve on combat units (1tnk) leaves
|
| 262 |
+
no cash for the harv replacement; harv count stays below 3 (the
|
| 263 |
+
win clause's hard constraint) and throughput never recovers →
|
| 264 |
+
timeout LOSS."""
|
| 265 |
+
_, r = _run(level, _over_build_army, seed=seed)
|
| 266 |
+
assert r.outcome == "loss", (
|
| 267 |
+
f"{level}/seed{seed}: over-build-army must LOSE; got "
|
| 268 |
+
f"{r.outcome} ev={_ev(r)} turns={r.turns}"
|
| 269 |
+
)
|
| 270 |
+
|
| 271 |
+
|
| 272 |
+
@pytest.mark.parametrize("level", ["easy", "medium", "hard"])
|
| 273 |
+
@pytest.mark.parametrize("seed", [1, 2, 3, 4])
|
| 274 |
+
def test_intended_replan_wins(level, seed):
|
| 275 |
+
"""The intended capability — replace the dead harv AND re-issue
|
| 276 |
+
`harvest` to every survivor — WINS every tier and every hard seed
|
| 277 |
+
well inside the tick budget."""
|
| 278 |
+
_, r = _run(level, _intended, seed=seed)
|
| 279 |
+
assert r.outcome == "win", (
|
| 280 |
+
f"{level}/seed{seed}: intended replan should WIN; got "
|
| 281 |
+
f"{r.outcome} ev={_ev(r)} turns={r.turns} losses={r.signals.units_lost}"
|
| 282 |
+
)
|
| 283 |
+
|
| 284 |
+
|
| 285 |
+
def test_outcomes_are_deterministic_per_seed():
|
| 286 |
+
"""Same seed, same policy → identical outcome and EV."""
|
| 287 |
+
c = compile_level(load_pack(PACK), "medium")
|
| 288 |
+
a = run_level(c, _intended, seed=2)
|
| 289 |
+
b = run_level(c, _intended, seed=2)
|
| 290 |
+
assert (a.outcome, a.turns, _ev(a)) == (b.outcome, b.turns, _ev(b))
|