yxc20098 commited on
Commit
7cc5e9b
·
1 Parent(s): 957ab86

feat(scenario): build-sequence-tech-cheapest — cost-minimal tech path (PlanBench cost-optimal anchor)

Browse files
openra_bench/scenarios/packs/build-sequence-tech-cheapest.yaml ADDED
@@ -0,0 +1,252 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # build-sequence-tech-cheapest.yaml
2
+ #
3
+ # REASONING capability — Wave-11 build-order optimization (cost-MINIMAL
4
+ # planning). Sibling of `build-sequence-tech-fastest` (which optimizes
5
+ # TIME). Here the binding constraint is MONEY, not the clock: the agent
6
+ # must reach `weap` (war factory) by spending the LEAST cash, on the
7
+ # ONLY affordable prerequisite chain: powr → proc → weap. Starting cash
8
+ # is tuned to EXACTLY the cost of that minimal path with near-zero
9
+ # slack. Any extra structure (a barracks/tent, a pillbox, an infantry
10
+ # unit) bought before weap exhausts the budget — weap can never be
11
+ # funded, the `then:` chain never completes, the episode times out as a
12
+ # real LOSS.
13
+ #
14
+ # Engine-verified tech tree + costs (measured against the live wheel,
15
+ # see tests/test_build_sequence_tech_cheapest.py — cash drains
16
+ # incrementally as a queue progresses):
17
+ # - POWR cost 300, Prerequisites: <none> (provides anypower)
18
+ # - PROC cost 1400, Prerequisites: anypower (needs powr)
19
+ # - WEAP cost 2000, Prerequisites: proc (needs proc)
20
+ # Total credits on the minimal path = 3700.
21
+ # - TENT cost 500 (allied barracks — NOT on weap's prereq chain)
22
+ # - PBOX cost 600 (pillbox defense — NOT on weap's prereq chain)
23
+ # - E1 cost 100 (rifle infantry — needs tent first anyway)
24
+ #
25
+ # Why money — not the clock — is the teeth: there is NO ore on the map
26
+ # and NO harvester income. The starting cash is the ENTIRE budget. The
27
+ # tick deadline T is deliberately GENEROUS (the optimal chain finishes
28
+ # with many turns of slack); a policy does NOT lose by being slow, it
29
+ # loses by being WASTEFUL. A wasteful play spends part of the fixed
30
+ # budget on a non-load-bearing structure, then cannot fund weap —
31
+ # cash hits 0 mid-queue, weap never completes, and because income
32
+ # never flows it can NEVER recover no matter how much time remains.
33
+ # This is the cost-OPTIMAL (vs the sibling's time-OPTIMAL) axis.
34
+ #
35
+ # Budget tuning (starting_cash 3750 — minimal path 3700 + 50 slack):
36
+ # - minimal powr(300)+proc(1400)+weap(2000) = 3700 ⇒ 50 left ⇒ WIN
37
+ # - +tent 300+500+1400+2000 = 4200 > 3750 ⇒ weap unfunded ⇒ LOSS
38
+ # - +pbox 300+600+1400+2000 = 4300 > 3750 ⇒ weap unfunded ⇒ LOSS
39
+ # - +tent+e1 300+500+100+1400+2000 = 4300 > 3750 ⇒ LOSS
40
+ # The 50-credit slack is non-actionable (nothing costs ≤50 except an
41
+ # extra e1 at 100, still unaffordable) — it only absorbs rounding in
42
+ # the incremental queue drain so the minimal path never stalls itself.
43
+ #
44
+ # Bar (CLAUDE.md — no defect, no cheat, no draw):
45
+ # - stall (observe-only) ⇒ LOSS on every level + seed
46
+ # - wasteful-spend (tent/pbox first) ⇒ LOSS on every level + seed
47
+ # (cash exhausted, weap never built — real timeout LOSS)
48
+ # - intended powr→proc→weap path ⇒ WIN on every level + seed
49
+ # Real LOSS not DRAW: fail_condition `after_ticks: T+1` is reachable
50
+ # inside max_turns (engine ~90 ticks/turn ⇒ tick ≤ 93+90·(N-1)); the
51
+ # `not building_count_gte:fact:1` clause also bites if the construction
52
+ # yard is somehow lost. The pre-placed enemy `fact` far east is an
53
+ # unarmed MustBeDestroyed landmark that keeps the episode alive so a
54
+ # stall really times out (no premature engine auto-done).
55
+ #
56
+ # Real-world anchor:
57
+ # - PlanBench cost-optimal planning (find the minimum-COST plan that
58
+ # achieves the goal under a hard budget, not just A plan)
59
+ # - BOM cost minimization — build only what the next stage strictly
60
+ # requires; every non-load-bearing line item on the bill of
61
+ # materials is wasted capital
62
+ # - Budget-constrained planning — a fixed, non-replenishing budget
63
+ # funds exactly the critical path and nothing else
64
+ #
65
+ # Validate (no model / no network):
66
+ # cd /Users/berta/Projects/OpenRA-Bench && \
67
+ # python3 -m pytest tests/test_build_sequence_tech_cheapest.py -q
68
+
69
+ meta:
70
+ id: build-sequence-tech-cheapest
71
+ title: 'Cheapest War Factory — Cost-Minimal powr → proc → weap Build Order'
72
+ capability: reasoning
73
+ real_world_meaning: >
74
+ Cost-minimal build-order planning under a fixed, non-replenishing
75
+ budget: the agent must reach the war factory (`weap`) by spending
76
+ the LEAST cash on the ONLY affordable prerequisite chain
77
+ (powr → proc → weap). There is no ore and no income — the starting
78
+ cash is the entire budget, tuned to exactly the cost of the minimal
79
+ path. Any detour through a non-load-bearing structure (a barracks,
80
+ a pillbox, an early infantry unit) bloats the bill of materials and
81
+ exhausts the budget; the war factory can then never be funded.
82
+ Tests that the model can plan the minimum-COST prerequisite chain —
83
+ not merely SOME plan that arrives — under a budget that only the
84
+ cost-minimal plan can afford. Sibling of build-sequence-tech-fastest
85
+ (the time-optimal axis); here money, not the clock, is the
86
+ constraint.
87
+ robotics_analogue: >
88
+ Capital-minimal commissioning of an autonomous manufacturing cell
89
+ under a fixed procurement budget: the cell must bring a target
90
+ machine online by buying only the strictly required upstream
91
+ stations (power → feedstock → assembly). The budget is fixed and
92
+ non-replenishing; procuring a non-required station (an extra
93
+ quality bench, a spare buffer) spends capital that the assembly
94
+ station then cannot be funded with. Only the minimum-cost
95
+ precedence chain stays within budget.
96
+ benchmark_anchor:
97
+ - "PlanBench cost-optimal"
98
+ - "BOM cost minimization"
99
+ - "budget-constrained planning"
100
+ author: openra-bench
101
+
102
+ base_map: rush-hour-arena
103
+
104
+ base:
105
+ agent:
106
+ faction: allies
107
+ enemy:
108
+ faction: soviet
109
+ bot_type: ''
110
+ tools:
111
+ - observe
112
+ - build
113
+ - place_building
114
+ planning: true
115
+ termination:
116
+ max_ticks: 40000
117
+
118
+ levels:
119
+ # ── EASY ─────────────────────────────────────────────────────────
120
+ # Bare cost-minimal skill. Generous T = 3200 ticks (max_turns 40 →
121
+ # reachable 3603). The minimal chain powr→proc→weap finishes well
122
+ # inside T (time is NOT the teeth). The teeth are the budget: at
123
+ # starting_cash 3750 the minimal path (3700) just fits; a wasteful
124
+ # tent/pbox detour overruns the budget so weap is never funded ⇒
125
+ # the `then:` chain never completes ⇒ after_ticks LOSS. Stall never
126
+ # builds anything ⇒ LOSS on the same clause.
127
+ easy:
128
+ description: >
129
+ Build a war factory (weap) by spending the LEAST cash, on the
130
+ ONLY affordable prerequisite chain: powr → proc → weap. Your
131
+ starting cash ($3750) is a fixed budget — there is no ore and no
132
+ income, so it is all the money you will ever have. The minimal
133
+ chain costs exactly $3700 (powr $300 + proc $1400 + weap $2000).
134
+ Any extra structure bought before weap (a barracks/tent $500, a
135
+ pillbox $600, an infantry unit) exhausts the budget and weap can
136
+ never be funded — you LOSE on the clock. The `then:` chain
137
+ enforces the exact order; placing weap before proc cannot satisfy
138
+ it (and the engine refuses too: weap's prerequisite is proc).
139
+ starting_cash: 3750
140
+ overrides:
141
+ actors:
142
+ # Agent base seed — ONE construction yard. Nothing else
143
+ # pre-placed (no power, no refinery). The minimal chain MUST
144
+ # be executed by the agent. NO ore patches — the starting
145
+ # cash is the entire, non-replenishing budget.
146
+ - {type: fact, owner: agent, position: [10, 18]}
147
+ # Far-east enemy `fact` landmark — MustBeDestroyed, unarmed
148
+ # neutral company. Keeps the episode alive so a stall really
149
+ # times out (not engine auto-done from a stray sentry kill).
150
+ - {type: fact, owner: enemy, position: [115, 30]}
151
+ win_condition:
152
+ all_of:
153
+ - then:
154
+ id: bo-easy
155
+ clauses:
156
+ - {has_building: powr}
157
+ - {has_building: proc}
158
+ - {has_building: weap}
159
+ - {within_ticks: 3200}
160
+ fail_condition:
161
+ any_of:
162
+ - {after_ticks: 3201}
163
+ - {not: {building_count_gte: {type: fact, n: 1}}}
164
+ max_turns: 40
165
+
166
+ # ── MEDIUM ───────────────────────────────────────────────────────
167
+ # +1 controlled variable: TIGHTER budget. starting_cash 3720 (only
168
+ # 20 credits of slack over the 3700 minimal path). The minimal
169
+ # chain still fits; ANY wasteful spend overruns even harder. Same
170
+ # generous clock T = 3200 — money, not time, remains the teeth.
171
+ medium:
172
+ description: >
173
+ Build a war factory (weap) on the cost-minimal prerequisite
174
+ chain: powr → proc → weap. Tighter budget — your starting cash
175
+ ($3720) barely covers the minimal path ($3700: powr $300 +
176
+ proc $1400 + weap $2000). There is no ore and no income. Any
177
+ extra structure (tent / pbox / an infantry unit) bought before
178
+ weap exhausts the budget and weap can never be funded. The
179
+ `then:` chain enforces the exact order; weap before proc cannot
180
+ satisfy it.
181
+ starting_cash: 3720
182
+ overrides:
183
+ actors:
184
+ - {type: fact, owner: agent, position: [10, 18]}
185
+ - {type: fact, owner: enemy, position: [115, 30]}
186
+ win_condition:
187
+ all_of:
188
+ - then:
189
+ id: bo-medium
190
+ clauses:
191
+ - {has_building: powr}
192
+ - {has_building: proc}
193
+ - {has_building: weap}
194
+ - {within_ticks: 3200}
195
+ fail_condition:
196
+ any_of:
197
+ - {after_ticks: 3201}
198
+ - {not: {building_count_gte: {type: fact, n: 1}}}
199
+ max_turns: 40
200
+
201
+ # ── HARD ─────────────────────────────────────────────────────────
202
+ # +1 controlled variable: ≥2 spawn_point groups (NORTH y=14 vs
203
+ # SOUTH y=26 base). Same cost-minimal chain, same tight budget
204
+ # (3720). The seed-varied spawn means a memorised "place powr at
205
+ # (14,22)" opening cannot generalise — the agent must compute
206
+ # placement relative to its actual fact each seed. No ore at either
207
+ # latitude (the budget is fixed regardless of spawn). Enemy actors
208
+ # do NOT honour spawn_point (CLAUDE.md), so the lone enemy `fact`
209
+ # landmark always places.
210
+ hard:
211
+ description: >
212
+ Build a war factory (weap) on the cost-minimal prerequisite
213
+ chain: powr → proc → weap, from a seed-chosen base (NORTH or
214
+ SOUTH). Tight budget — starting cash ($3720) barely covers the
215
+ minimal path ($3700). There is no ore and no income. Any extra
216
+ structure (tent / pbox / an infantry unit) bought before weap
217
+ exhausts the budget and weap can never be funded. Placement
218
+ that memorises one spawn's geometry cannot generalise; compute
219
+ placement relative to your actual fact each run.
220
+ starting_cash: 3720
221
+ overrides:
222
+ actors:
223
+ # NORTH spawn (spawn_point 0): fact at y=14.
224
+ - {type: fact, owner: agent, position: [10, 14], spawn_point: 0}
225
+ # An inert rifleman per spawn group (passive: stance 2 =
226
+ # Defend; no move_units / attack_unit exposed so the unit
227
+ # cannot act — the tool surface is build-only). Establishes a
228
+ # seed-varying AGENT UNIT in `units_summary` so the hard-tier
229
+ # spawn-variation contract (tests/test_hard_tier.py, which
230
+ # inspects units not buildings) is satisfied with real
231
+ # per-spawn data. Note: the e1 is pre-placed (free), it does
232
+ # NOT spend the budget.
233
+ - {type: e1, owner: agent, position: [12, 14], spawn_point: 0, stance: 2}
234
+ # SOUTH spawn (spawn_point 1): fact at y=26.
235
+ - {type: fact, owner: agent, position: [10, 26], spawn_point: 1}
236
+ - {type: e1, owner: agent, position: [12, 26], spawn_point: 1, stance: 2}
237
+ # Far-east enemy fact landmark — keeps the episode alive.
238
+ - {type: fact, owner: enemy, position: [115, 30]}
239
+ win_condition:
240
+ all_of:
241
+ - then:
242
+ id: bo-hard
243
+ clauses:
244
+ - {has_building: powr}
245
+ - {has_building: proc}
246
+ - {has_building: weap}
247
+ - {within_ticks: 3200}
248
+ fail_condition:
249
+ any_of:
250
+ - {after_ticks: 3201}
251
+ - {not: {building_count_gte: {type: fact, n: 1}}}
252
+ max_turns: 40
tests/test_build_sequence_tech_cheapest.py ADDED
@@ -0,0 +1,383 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """build-sequence-tech-cheapest pack — full no-cheat validation on Rust.
2
+
3
+ Wave-11 REASONING — cost-MINIMAL build-order planning. Sibling of
4
+ build-sequence-tech-fastest (the time-optimal axis); here the binding
5
+ constraint is MONEY. The agent must reach the war factory (`weap`) on
6
+ the ONLY affordable prerequisite chain:
7
+
8
+ powr → proc → weap
9
+
10
+ There is NO ore on the map and NO harvester income — the starting cash
11
+ is the entire, non-replenishing budget, tuned to exactly the cost of
12
+ the minimal path (powr $300 + proc $1400 + weap $2000 = $3700). Any
13
+ detour (build a barracks/tent or a pillbox first) bloats the bill of
14
+ materials, exhausts the fixed budget, and weap can then never be
15
+ funded — the `then:` chain never completes and the episode times out.
16
+ The clock budget is GENEROUS: a policy loses by being WASTEFUL, not
17
+ by being slow.
18
+
19
+ Bar (CLAUDE.md): the intended cost-minimal policy WINS on every
20
+ (level, seed); stall and the wasteful-spend policies LOSE on every
21
+ (level, seed). Real LOSS not DRAW — `fail after_ticks:T+1` inside
22
+ max_turns is the bite.
23
+
24
+ Scenario shape:
25
+ - rush-hour-arena, allies vs soviet (bot disabled).
26
+ - easy: budget $3750, T=3200, max_turns=40 — 50-credit slack.
27
+ - medium: budget $3720, T=3200, max_turns=40 — 20-credit slack.
28
+ - hard: budget $3720, T=3200, max_turns=40 — same tight budget
29
+ + ≥2 spawn_point groups (NORTH y=14 / SOUTH y=26 base,
30
+ round-robined by seed).
31
+
32
+ Measured (seed 1, scripted policies):
33
+ intended powr→proc→weap completes ≈ tick 2613 (well under T=3200)
34
+ tent-first wasteful: cash hits $0 ≈ tick 2703, weap stuck in queue
35
+ forever (no income ⇒ no recovery) ⇒ after_ticks LOSS at T+1.
36
+ """
37
+
38
+ from __future__ import annotations
39
+
40
+ import pytest
41
+
42
+ pytest.importorskip("openra_train", reason="Rust env wheel not installed")
43
+ pytest.importorskip("openra_rl_training", reason="Rust env wheel not installed")
44
+
45
+ from openra_bench.eval_core import run_level
46
+ from openra_bench.scenarios import load_pack
47
+ from openra_bench.scenarios.loader import PACKS_DIR, compile_level
48
+
49
+ PACK = PACKS_DIR / "build-sequence-tech-cheapest.yaml"
50
+ LEVELS = ("easy", "medium", "hard")
51
+ SEEDS = (1, 2, 3, 4)
52
+
53
+
54
+ # ── Policies ──────────────────────────────────────────────────────
55
+
56
+
57
+ def _stall_policy():
58
+ """Do nothing — must LOSE on the clock on every level/seed."""
59
+ def pol(obs, Cmd):
60
+ return [Cmd.observe()]
61
+ return pol
62
+
63
+
64
+ def _intended_policy():
65
+ """Cost-minimal play: build powr → proc → weap, each placed
66
+ relative to the agent's actual fact (so the policy generalises
67
+ across the hard-tier spawn variation). This is the policy the
68
+ pack is solvable by — must WIN on every (level, seed)."""
69
+ milestone = {"powr": False, "proc": False, "weap": False}
70
+
71
+ def pol(obs, Cmd):
72
+ ob = obs.get("own_buildings", []) or []
73
+ own_b = {b["type"] for b in ob}
74
+ prod = obs.get("production", []) or []
75
+ for b in ("powr", "proc", "weap"):
76
+ if b in own_b:
77
+ milestone[b] = True
78
+ cmds = []
79
+ base = [b for b in ob if b["type"] == "fact"]
80
+ if not milestone["powr"]:
81
+ if "powr" not in prod:
82
+ cmds.append(Cmd.build("powr"))
83
+ if base:
84
+ cmds.append(Cmd.place_building(
85
+ "powr", base[0]["cell_x"] + 4, base[0]["cell_y"]
86
+ ))
87
+ elif not milestone["proc"]:
88
+ if "proc" not in prod:
89
+ cmds.append(Cmd.build("proc"))
90
+ if base:
91
+ cmds.append(Cmd.place_building(
92
+ "proc", base[0]["cell_x"] + 6, base[0]["cell_y"] + 3
93
+ ))
94
+ elif not milestone["weap"]:
95
+ if "weap" not in prod:
96
+ cmds.append(Cmd.build("weap"))
97
+ if base:
98
+ cmds.append(Cmd.place_building(
99
+ "weap", base[0]["cell_x"] + 8, base[0]["cell_y"]
100
+ ))
101
+ if not cmds:
102
+ cmds.append(Cmd.observe())
103
+ return cmds
104
+ return pol
105
+
106
+
107
+ def _wasteful_policy(extra: str):
108
+ """Cost-non-minimal play: powr → <extra> → proc → weap, where
109
+ <extra> ('tent' $500 or 'pbox' $600) is NOT on weap's prerequisite
110
+ chain. The detour bloats the bill of materials past the fixed
111
+ budget, so weap can never be funded — cash hits $0 mid-queue and,
112
+ with no ore/income, never recovers. Must LOSE on every
113
+ (level, seed). The capability measured is COST-MINIMAL planning;
114
+ a 'some plan that arrives' policy must not win."""
115
+ milestone = {"powr": False, extra: False, "proc": False, "weap": False}
116
+
117
+ def pol(obs, Cmd):
118
+ ob = obs.get("own_buildings", []) or []
119
+ own_b = {b["type"] for b in ob}
120
+ prod = obs.get("production", []) or []
121
+ for b in ("powr", extra, "proc", "weap"):
122
+ if b in own_b:
123
+ milestone[b] = True
124
+ cmds = []
125
+ base = [b for b in ob if b["type"] == "fact"]
126
+ if not milestone["powr"]:
127
+ if "powr" not in prod:
128
+ cmds.append(Cmd.build("powr"))
129
+ if base:
130
+ cmds.append(Cmd.place_building(
131
+ "powr", base[0]["cell_x"] + 4, base[0]["cell_y"]
132
+ ))
133
+ elif not milestone[extra]:
134
+ if extra not in prod:
135
+ cmds.append(Cmd.build(extra))
136
+ if base:
137
+ cmds.append(Cmd.place_building(
138
+ extra, base[0]["cell_x"] + 4, base[0]["cell_y"] + 3
139
+ ))
140
+ elif not milestone["proc"]:
141
+ if "proc" not in prod:
142
+ cmds.append(Cmd.build("proc"))
143
+ if base:
144
+ cmds.append(Cmd.place_building(
145
+ "proc", base[0]["cell_x"] + 6, base[0]["cell_y"] + 3
146
+ ))
147
+ elif not milestone["weap"]:
148
+ if "weap" not in prod:
149
+ cmds.append(Cmd.build("weap"))
150
+ if base:
151
+ cmds.append(Cmd.place_building(
152
+ "weap", base[0]["cell_x"] + 8, base[0]["cell_y"]
153
+ ))
154
+ if not cmds:
155
+ cmds.append(Cmd.observe())
156
+ return cmds
157
+ return pol
158
+
159
+
160
+ # ── Pack-shape tests (cheap; do not run the engine) ──────────────
161
+
162
+
163
+ def test_pack_compiles_with_three_levels():
164
+ pack = load_pack(PACK)
165
+ assert pack.meta.id == "build-sequence-tech-cheapest"
166
+ assert pack.meta.capability == "reasoning"
167
+ assert set(pack.levels) == {"easy", "medium", "hard"}
168
+
169
+
170
+ def test_meta_benchmark_anchor_set():
171
+ """Required by the seed taxonomy: PlanBench cost-optimal +
172
+ BOM cost minimization + budget-constrained planning."""
173
+ pack = load_pack(PACK)
174
+ anchors = pack.meta.benchmark_anchor or []
175
+ assert any("PlanBench" in a for a in anchors), anchors
176
+ assert any("BOM" in a for a in anchors), anchors
177
+ assert any("budget" in a for a in anchors), anchors
178
+
179
+
180
+ def test_budget_is_near_minimal_path_cost():
181
+ """The whole pack hinges on starting_cash being tuned to the
182
+ minimal-path cost (powr 300 + proc 1400 + weap 2000 = 3700) with
183
+ near-zero slack — enough to fund the minimal chain, never enough
184
+ to also afford a non-load-bearing structure."""
185
+ pack = load_pack(PACK)
186
+ minimal = 3700
187
+ for lvl in LEVELS:
188
+ cash = pack.levels[lvl].starting_cash
189
+ assert minimal <= cash <= minimal + 100, (
190
+ f"{lvl} starting_cash={cash} not near-minimal (3700 + ≤100 "
191
+ f"slack); a wasteful detour must overrun the budget"
192
+ )
193
+
194
+
195
+ def test_no_ore_patches_placed():
196
+ """The budget must be the entire, non-replenishing money supply:
197
+ no `mine` actors ⇒ no harvester income ⇒ a wasteful spend can
198
+ never be recovered no matter how generous the clock is."""
199
+ for lvl in LEVELS:
200
+ c = compile_level(load_pack(PACK), lvl)
201
+ mines = [a for a in c.scenario.actors if a.type == "mine"]
202
+ assert not mines, f"{lvl} has ore patches {mines} — income would "\
203
+ "let a wasteful policy recover; budget must be fixed"
204
+
205
+
206
+ def test_hard_tier_has_seed_driven_spawn_groups():
207
+ """Hard must define ≥2 agent spawn_point groups so seed varies
208
+ the start base (tests/test_hard_tier.py::UPGRADED contract)."""
209
+ c = compile_level(load_pack(PACK), "hard")
210
+ sp = {a.spawn_point for a in c.scenario.actors if a.owner == "agent"}
211
+ assert len(sp) >= 2, f"hard needs ≥2 spawn groups, got {sp}"
212
+
213
+
214
+ def test_every_level_has_fail_condition():
215
+ """No silent draws — every level must be able to emit a LOSS."""
216
+ pack = load_pack(PACK)
217
+ for lvl in LEVELS:
218
+ c = compile_level(pack, lvl)
219
+ assert c.fail_condition is not None, f"{lvl} missing fail_condition"
220
+
221
+
222
+ def test_then_composite_used_in_win():
223
+ """Confirms the 3-step build-order chain is wired through to the
224
+ compiled win condition — the load-bearing teeth of this pack."""
225
+ for lvl in LEVELS:
226
+ c = compile_level(load_pack(PACK), lvl)
227
+ win = c.win_condition.model_dump(exclude_none=True)
228
+ inner = win.get("all_of") or []
229
+ assert any("then" in cl for cl in inner), (
230
+ f"{lvl} win missing then-chain: {win}"
231
+ )
232
+ for cl in inner:
233
+ if "then" in cl:
234
+ clauses = (cl["then"] or {}).get("clauses") or []
235
+ assert len(clauses) == 3, (
236
+ f"{lvl} then-chain must be powr→proc→weap (3 clauses); "
237
+ f"got {clauses}"
238
+ )
239
+ # And in the exact engine-enforced prereq order.
240
+ assert clauses[0].get("has_building") == "powr"
241
+ assert clauses[1].get("has_building") == "proc"
242
+ assert clauses[2].get("has_building") == "weap"
243
+
244
+
245
+ def test_tick_budget_aligned_with_max_turns():
246
+ """within_ticks must be reachable inside max_turns. Engine
247
+ advances ~90 ticks/turn → reachable max = 93 + 90·(N-1)."""
248
+ pack = load_pack(PACK)
249
+ for lvl in LEVELS:
250
+ level_def = pack.levels[lvl]
251
+ max_turns = level_def.max_turns
252
+ reachable = 93 + 90 * (max_turns - 1)
253
+ win = compile_level(pack, lvl).win_condition.model_dump(exclude_none=True)
254
+
255
+ def _collect(node, key, out):
256
+ if isinstance(node, dict):
257
+ if key in node:
258
+ out.append(node[key])
259
+ for v in node.values():
260
+ _collect(v, key, out)
261
+ elif isinstance(node, list):
262
+ for v in node:
263
+ _collect(v, key, out)
264
+ wts = []
265
+ _collect(win, "within_ticks", wts)
266
+ assert wts, f"{lvl} has no within_ticks leaf (no clock teeth)"
267
+ for wt in wts:
268
+ assert wt <= reachable, (
269
+ f"{lvl} within_ticks={wt} > reachable={reachable} "
270
+ f"(max_turns={max_turns}) — deadline never bites ⇒ draw"
271
+ )
272
+
273
+
274
+ # ── Engine-bound tests (parameterised over seeds 1..4) ────────────
275
+
276
+
277
+ @pytest.mark.parametrize("seed", SEEDS)
278
+ @pytest.mark.parametrize("level", LEVELS)
279
+ def test_intended_cost_minimal_policy_wins(level, seed):
280
+ """The intended cost-minimal play (powr → proc → weap) must WIN
281
+ on every (level, seed). This is the load-bearing test that the
282
+ pack is solvable inside the budget by the advertised capability."""
283
+ c = compile_level(load_pack(PACK), level)
284
+ res = run_level(c, _intended_policy(), seed=seed)
285
+ tp = getattr(res.signals, "then_progress", {}) or {}
286
+ assert res.outcome == "win", (
287
+ f"intended cost-minimal must WIN on {level} s={seed}; "
288
+ f"got {res.outcome} (tick={res.signals.game_tick}, "
289
+ f"then_progress={tp}, "
290
+ f"own_buildings={res.signals.own_building_types})"
291
+ )
292
+
293
+
294
+ @pytest.mark.parametrize("seed", SEEDS)
295
+ @pytest.mark.parametrize("level", LEVELS)
296
+ def test_stall_loses(level, seed):
297
+ """A do-nothing policy must LOSE on every (level, seed). The
298
+ fail_condition's after_ticks clause bites at the budget; never
299
+ a draw."""
300
+ c = compile_level(load_pack(PACK), level)
301
+ res = run_level(c, _stall_policy(), seed=seed)
302
+ assert res.outcome == "loss", (
303
+ f"stall must LOSE on {level} s={seed}; got {res.outcome} "
304
+ f"(tick={res.signals.game_tick})"
305
+ )
306
+
307
+
308
+ @pytest.mark.parametrize("seed", SEEDS)
309
+ @pytest.mark.parametrize("level", LEVELS)
310
+ @pytest.mark.parametrize("extra", ("tent", "pbox"))
311
+ def test_wasteful_spend_loses(level, seed, extra):
312
+ """The cost-non-minimal wasteful play (powr → <extra> → proc →
313
+ weap) must LOSE on every (level, seed). The <extra> detour
314
+ ('tent' $500 / 'pbox' $600) bloats the bill of materials past the
315
+ fixed budget; weap can never be funded (cash hits $0 mid-queue,
316
+ no income ⇒ no recovery) and the `then:` chain never completes.
317
+ The capability measured is COST-MINIMAL planning."""
318
+ c = compile_level(load_pack(PACK), level)
319
+ res = run_level(c, _wasteful_policy(extra), seed=seed)
320
+ tp = getattr(res.signals, "then_progress", {}) or {}
321
+ assert res.outcome == "loss", (
322
+ f"wasteful {extra}-first must LOSE on {level} s={seed}; got "
323
+ f"{res.outcome} (tick={res.signals.game_tick}, "
324
+ f"then_progress={tp}, own_buildings={res.signals.own_building_types})"
325
+ )
326
+ # weap must NOT have been built — the budget could not fund it.
327
+ assert "weap" not in (res.signals.own_building_types or []), (
328
+ f"wasteful {extra}-first built weap on {level} s={seed} — the "
329
+ f"budget trap leaked (own_buildings={res.signals.own_building_types})"
330
+ )
331
+
332
+
333
+ @pytest.mark.parametrize("seed", SEEDS)
334
+ def test_hard_seeds_produce_distinct_starts(seed):
335
+ """Hard's two spawn_point groups must actually round-robin —
336
+ different seeds must place the agent fact at a different (x,y).
337
+ Smoke-tests the spawn-variation contract that
338
+ tests/test_hard_tier.py also enforces."""
339
+ c = compile_level(load_pack(PACK), "hard")
340
+ captured = {"first_obs": None}
341
+
342
+ def probe(obs, Cmd):
343
+ if captured["first_obs"] is None:
344
+ captured["first_obs"] = list(obs.get("own_buildings", []) or [])
345
+ return [Cmd.observe()]
346
+
347
+ res = run_level(c, probe, seed=seed)
348
+ assert res.outcome == "loss" # stall must lose
349
+ facts = [
350
+ (b["cell_x"], b["cell_y"])
351
+ for b in (captured["first_obs"] or [])
352
+ if b["type"] == "fact"
353
+ ]
354
+ assert facts, f"no fact observed at turn 0 for seed={seed}"
355
+
356
+
357
+ def test_hard_spawns_round_robin_across_seeds():
358
+ """Two seeds (1 and 2) must place the agent's fact at DIFFERENT
359
+ cells — proves the spawn_point round-robin is active, not
360
+ degenerate."""
361
+ c = compile_level(load_pack(PACK), "hard")
362
+
363
+ def probe():
364
+ captured = {}
365
+ def pol(obs, Cmd):
366
+ if "fact_pos" not in captured:
367
+ bs = obs.get("own_buildings", []) or []
368
+ facts = [(b["cell_x"], b["cell_y"]) for b in bs if b["type"] == "fact"]
369
+ if facts:
370
+ captured["fact_pos"] = facts[0]
371
+ return [Cmd.observe()]
372
+ pol.captured = captured
373
+ return pol
374
+
375
+ p1 = probe(); run_level(c, p1, seed=1)
376
+ p2 = probe(); run_level(c, p2, seed=2)
377
+ pos1 = p1.captured.get("fact_pos")
378
+ pos2 = p2.captured.get("fact_pos")
379
+ assert pos1 and pos2, f"missing fact obs: s1={pos1} s2={pos2}"
380
+ assert pos1 != pos2, (
381
+ f"hard spawn round-robin is degenerate: seed 1 and 2 both "
382
+ f"started at {pos1}"
383
+ )