Spaces:
Running
Running
feat(scenario): build-tech-skip-decision — skip the unneeded tech tier (PlanBench step-pruning / YAGNI anchor)
Browse files
openra_bench/scenarios/packs/build-tech-skip-decision.yaml
ADDED
|
@@ -0,0 +1,278 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# build-tech-skip-decision — skip an unnecessary tech tier (YAGNI)
|
| 2 |
+
#
|
| 3 |
+
# REASONING capability — PlanBench unnecessary-step PRUNING. The
|
| 4 |
+
# objective only needs BASIC infantry (e1, $100, trainable from turn 1
|
| 5 |
+
# off the pre-placed tent). Climbing the full tech chain to a war
|
| 6 |
+
# factory and medium tanks (powr → proc → weap + fix → 2tnk) is a
|
| 7 |
+
# whole tech tier the objective never asks for: it wastes cash AND —
|
| 8 |
+
# critically — the clock. The agent must recognise the goal does not
|
| 9 |
+
# require high tech and skip STRAIGHT to the cheap e1-swarm path.
|
| 10 |
+
#
|
| 11 |
+
# Real-world anchors:
|
| 12 |
+
# • PlanBench unnecessary-step pruning — find the plan that omits
|
| 13 |
+
# every action the goal does not require, not merely SOME plan.
|
| 14 |
+
# • Lean process / muda elimination — do not add a production step
|
| 15 |
+
# the deliverable never consumes.
|
| 16 |
+
# • YAGNI ("You Aren't Gonna Need It") — do not build capability the
|
| 17 |
+
# requirement does not call for; it costs time and budget now.
|
| 18 |
+
#
|
| 19 |
+
# THE DECISION (the whole test):
|
| 20 |
+
# The agent starts with a Construction Yard (fact) + an Allied
|
| 21 |
+
# barracks (tent) already standing — so `e1` rifle infantry are
|
| 22 |
+
# buildable from turn 1 with NO prior tech step. A light enemy
|
| 23 |
+
# garrison of rifle infantry is incoming from the east (stance 3,
|
| 24 |
+
# AttackAnything — it advances on the agent base). Killing K of them
|
| 25 |
+
# is the objective. An e1 swarm (cost $100 each) clears a light
|
| 26 |
+
# infantry garrison comfortably when massed at the base and left to
|
| 27 |
+
# auto-fire as the garrison closes; the agent need only keep
|
| 28 |
+
# training e1 and rallying them at the base front.
|
| 29 |
+
#
|
| 30 |
+
# The TECH-FULL-CHAIN play instead spends the budget and the clock
|
| 31 |
+
# building powr → proc → weap (+ fix) to reach medium tanks. That is
|
| 32 |
+
# a whole tech tier the garrison never required: by the deadline the
|
| 33 |
+
# tech play has only powr+proc standing and ZERO army — no tank has
|
| 34 |
+
# fielded — so the kill bar is unmet ⇒ LOSS on the clock. Stalling
|
| 35 |
+
# never kills anything ⇒ LOSS on the clock.
|
| 36 |
+
#
|
| 37 |
+
# Engine facts used (RA ruleset / prompt_v2 codex / CLAUDE.md):
|
| 38 |
+
# • e1 rifle infantry: cost $100, trainable from a barracks/tent.
|
| 39 |
+
# The tent is PRE-PLACED, so e1 is buildable on turn 1 with no
|
| 40 |
+
# prerequisite build — the cheap path is immediately actionable.
|
| 41 |
+
# • Full tech chain to medium tanks: powr ($300) → proc ($1400,
|
| 42 |
+
# prereq anypower) → weap ($2000, prereq proc); 2tnk ($850,
|
| 43 |
+
# prereq weap + fix). Three structures + a service depot before a
|
| 44 |
+
# single tank — many turns of placement + queue time; measured,
|
| 45 |
+
# the tech play has only reached powr+proc by the deadline.
|
| 46 |
+
# • A `stance:3` (AttackAnything) enemy advances toward the nearest
|
| 47 |
+
# visible foe (CLAUDE.md stance semantics) — so the garrison
|
| 48 |
+
# closes on the agent base on its own; the intended play just
|
| 49 |
+
# masses e1 at the base front and lets them auto-fire as
|
| 50 |
+
# defenders (concentrated fire beats the strung-out attacker).
|
| 51 |
+
# • `place_building` has no adjacency requirement (CLAUDE.md): the
|
| 52 |
+
# tech play's LOSS is from time cost, not placement difficulty.
|
| 53 |
+
# • Tick alignment: the engine advances ~90 ticks per decision turn,
|
| 54 |
+
# so within_ticks ≤ 93 + 90·(max_turns − 1). fail_condition uses
|
| 55 |
+
# after_ticks = within_ticks + 1 so a non-finisher LOSES one tick
|
| 56 |
+
# past the window — a real reachable timeout, never a DRAW.
|
| 57 |
+
# • A persistent unarmed enemy `fact` marker far east keeps the
|
| 58 |
+
# episode alive past the last garrison death so a non-winner
|
| 59 |
+
# reaches the deadline as a real LOSS (CLAUDE.md auto-done
|
| 60 |
+
# footgun).
|
| 61 |
+
# • `building_count_gte` (counts the live own_buildings list) is
|
| 62 |
+
# used for the fact-alive clause instead of `has_building`
|
| 63 |
+
# (CLAUDE.md own-elimination footgun).
|
| 64 |
+
# • Hard tier: the `spawn_point` filter applies ONLY to agent
|
| 65 |
+
# actors, and ANY agent actor with spawn_point filters out agent
|
| 66 |
+
# actors WITHOUT one (CLAUDE.md oramap.rs footgun). So the whole
|
| 67 |
+
# agent base (fact + tent) is duplicated across both spawn_point
|
| 68 |
+
# groups; the garrison + far enemy marker carry no spawn_point
|
| 69 |
+
# and always place.
|
| 70 |
+
#
|
| 71 |
+
# DISCRIMINATIONS (CLAUDE.md bar — no defect, no cheat, no draw):
|
| 72 |
+
# • stall (only observe): LOSS — 0 kills, the after_ticks deadline
|
| 73 |
+
# bites as a real reachable timeout.
|
| 74 |
+
# • tech-full-chain (build powr → proc → weap → fix, then 2tnk):
|
| 75 |
+
# LOSS — the unneeded tech tier burns the clock; by the deadline
|
| 76 |
+
# only powr+proc stand and no tank has fielded ⇒ kill bar unmet.
|
| 77 |
+
# • intended skip-to-e1-swarm (train e1 from the pre-placed tent
|
| 78 |
+
# from turn 1, rally them at the base front, let them auto-fire
|
| 79 |
+
# on the closing garrison): WIN — the kill bar is met AND the
|
| 80 |
+
# fact survives, well inside the deadline.
|
| 81 |
+
#
|
| 82 |
+
# Validate (no model / no network):
|
| 83 |
+
# python3 -m pytest tests/test_build_tech_skip_decision.py -q
|
| 84 |
+
|
| 85 |
+
meta:
|
| 86 |
+
id: build-tech-skip-decision
|
| 87 |
+
title: 'Skip the Unneeded Tech Tier — Clear a Light Garrison with a Basic e1 Swarm'
|
| 88 |
+
capability: reasoning
|
| 89 |
+
real_world_meaning: >
|
| 90 |
+
The objective only requires basic units. The agent starts with a
|
| 91 |
+
Construction Yard and an Allied barracks already standing, so cheap
|
| 92 |
+
rifle infantry (e1) are trainable from turn 1 with no prior tech
|
| 93 |
+
step, and a light enemy garrison — also basic infantry — is
|
| 94 |
+
incoming. An e1 swarm clears a light infantry garrison comfortably.
|
| 95 |
+
The trap is to climb the full tech chain (power plant → refinery →
|
| 96 |
+
war factory → service depot → medium tanks) to bring a higher tech
|
| 97 |
+
tier online that the objective never asked for: that whole tier
|
| 98 |
+
costs budget and, critically, clock — by the deadline no tank has
|
| 99 |
+
fielded at all. The pack tests whether the model recognises that
|
| 100 |
+
the goal does not require high tech and skips straight to the
|
| 101 |
+
cheap path.
|
| 102 |
+
robotics_analogue: >
|
| 103 |
+
Right-sizing the plan to the task. A delivery only needs a basic
|
| 104 |
+
pick-and-place cell that is already commissioned; provisioning a
|
| 105 |
+
full high-precision assembly line (extra power, feedstock, an
|
| 106 |
+
assembly station, a calibration depot) before starting work is a
|
| 107 |
+
whole capability tier the job never required — it burns budget and
|
| 108 |
+
blows the cycle-time deadline. The competent planner prunes every
|
| 109 |
+
step the goal does not consume and executes the minimal plan with
|
| 110 |
+
the capability already on hand.
|
| 111 |
+
benchmark_anchor:
|
| 112 |
+
- "PlanBench unnecessary-step pruning"
|
| 113 |
+
- "lean process"
|
| 114 |
+
- "YAGNI"
|
| 115 |
+
author: openra-bench
|
| 116 |
+
|
| 117 |
+
base_map: rush-hour-arena
|
| 118 |
+
# Cash covers a generous e1 swarm ($100 each) many times over AND, on
|
| 119 |
+
# its own, a full tech chain (powr 300 + proc 1400 + weap 2000 + fix +
|
| 120 |
+
# a 2tnk) — so the budget is NOT the binding constraint. The CLOCK is:
|
| 121 |
+
# the tech tier simply cannot come online in time. Cash is kept ample
|
| 122 |
+
# so the tech play's LOSS attributes cleanly to the wasted tier
|
| 123 |
+
# (time), not to running out of money.
|
| 124 |
+
starting_cash: 6000
|
| 125 |
+
|
| 126 |
+
base:
|
| 127 |
+
agent: {faction: allies}
|
| 128 |
+
# No scripted bot — the garrison is a fixed group of stance:3
|
| 129 |
+
# (AttackAnything) rifle infantry that advances on the agent base
|
| 130 |
+
# on its own. The intended e1 swarm fights it as a base defender.
|
| 131 |
+
enemy: {faction: soviet, bot_type: '', cash: 0}
|
| 132 |
+
tools: [observe, build, place_building, move_units, attack_move, stop]
|
| 133 |
+
spawn_mcvs: false
|
| 134 |
+
planning: true
|
| 135 |
+
# Re-decide the instant a garrison unit is spotted or one of our own
|
| 136 |
+
# is destroyed — the skip-to-cheap-path call is made from the live
|
| 137 |
+
# threat reading, not on fixed cadence.
|
| 138 |
+
interrupts:
|
| 139 |
+
enemy_unit_spotted: true
|
| 140 |
+
own_unit_destroyed: true
|
| 141 |
+
termination: {max_ticks: 12000}
|
| 142 |
+
actors: []
|
| 143 |
+
|
| 144 |
+
levels:
|
| 145 |
+
# ── EASY ─────────────────────────────────────────────────────────
|
| 146 |
+
# Bare step-pruning skill. Pre-placed fact + tent ⇒ e1 buildable
|
| 147 |
+
# turn 1. A light garrison of 5 rifle infantry (stance 3) advances
|
| 148 |
+
# from x=52. Training e1 and rallying them at the base front clears
|
| 149 |
+
# the 4-kill bar by ~tick 633 — a wide buffer under tick 1800. The
|
| 150 |
+
# tech-full-chain play has only powr+proc standing by the deadline
|
| 151 |
+
# and zero army ⇒ LOSS. max_turns 25 → reachable tick 93 + 90·24 =
|
| 152 |
+
# 2253 > 1801 ✓ (the deadline bites as a real LOSS).
|
| 153 |
+
easy:
|
| 154 |
+
description: >
|
| 155 |
+
Your base already has a Construction Yard (fact) and an Allied
|
| 156 |
+
barracks (tent) — so rifle infantry (e1, $100 each) are
|
| 157 |
+
buildable from turn 1 with NO prior tech step. A light enemy
|
| 158 |
+
garrison of 5 rifle infantry is advancing from the east. The
|
| 159 |
+
objective needs only basic units: train an e1 swarm and rally it
|
| 160 |
+
at your base front to meet the garrison. Do NOT climb the tech
|
| 161 |
+
chain to a war factory and tanks — that is a whole tech tier the
|
| 162 |
+
objective never asked for and it overruns the clock. Win when
|
| 163 |
+
≥4 enemy units are killed AND your fact still stands, before
|
| 164 |
+
tick 1800. Stalling or teching to tanks both LOSE on the clock.
|
| 165 |
+
starting_cash: 6000
|
| 166 |
+
overrides:
|
| 167 |
+
actors:
|
| 168 |
+
- {type: fact, owner: agent, position: [10, 20]}
|
| 169 |
+
- {type: tent, owner: agent, position: [13, 20]}
|
| 170 |
+
# Light garrison — 5× e1, stance 3 (AttackAnything ⇒ advances
|
| 171 |
+
# on the agent base). An e1 swarm massed at the base clears
|
| 172 |
+
# them; the tech play never fields a tank in time.
|
| 173 |
+
- {type: e1, owner: enemy, position: [52, 20], stance: 3, count: 5}
|
| 174 |
+
# Far unarmed enemy fact marker — anti-DRAW. Keeps the
|
| 175 |
+
# episode alive past the last garrison death so a
|
| 176 |
+
# non-winner reaches the deadline as a real LOSS.
|
| 177 |
+
- {type: fact, owner: enemy, position: [120, 20]}
|
| 178 |
+
win_condition:
|
| 179 |
+
all_of:
|
| 180 |
+
- {units_killed_gte: 4}
|
| 181 |
+
- {building_count_gte: {type: fact, n: 1}}
|
| 182 |
+
- {within_ticks: 1800}
|
| 183 |
+
fail_condition:
|
| 184 |
+
any_of:
|
| 185 |
+
- {after_ticks: 1801}
|
| 186 |
+
- {not: {building_count_gte: {type: fact, n: 1}}}
|
| 187 |
+
max_turns: 25
|
| 188 |
+
|
| 189 |
+
# ── MEDIUM ───────────────────────────────────────────────────────
|
| 190 |
+
# +1 controlled variable: a LARGER garrison (7× e1) AND a TIGHTER
|
| 191 |
+
# deadline (tick 1600). The e1-swarm play still wins comfortably
|
| 192 |
+
# (~tick 633); the tech-full-chain play loses by an even wider
|
| 193 |
+
# margin. Same base. max_turns 23 → reachable tick 93 + 90·22 =
|
| 194 |
+
# 2073 > 1601 ✓.
|
| 195 |
+
medium:
|
| 196 |
+
description: >
|
| 197 |
+
Same base (fact + tent pre-placed, e1 buildable from turn 1) but
|
| 198 |
+
a larger enemy garrison (7 rifle infantry) and a tighter
|
| 199 |
+
deadline. The objective still needs only basic units: train an
|
| 200 |
+
e1 swarm and rally it at your base front. Climbing the tech
|
| 201 |
+
chain to a war factory and tanks is an unnecessary tier that
|
| 202 |
+
overruns the clock. Win when ≥6 enemy units are killed AND your
|
| 203 |
+
fact still stands, before tick 1600. Stalling or teching to
|
| 204 |
+
tanks both LOSE on the clock.
|
| 205 |
+
starting_cash: 6000
|
| 206 |
+
overrides:
|
| 207 |
+
actors:
|
| 208 |
+
- {type: fact, owner: agent, position: [10, 20]}
|
| 209 |
+
- {type: tent, owner: agent, position: [13, 20]}
|
| 210 |
+
- {type: e1, owner: enemy, position: [52, 20], stance: 3, count: 7}
|
| 211 |
+
- {type: fact, owner: enemy, position: [120, 20]}
|
| 212 |
+
win_condition:
|
| 213 |
+
all_of:
|
| 214 |
+
- {units_killed_gte: 6}
|
| 215 |
+
- {building_count_gte: {type: fact, n: 1}}
|
| 216 |
+
- {within_ticks: 1600}
|
| 217 |
+
fail_condition:
|
| 218 |
+
any_of:
|
| 219 |
+
- {after_ticks: 1601}
|
| 220 |
+
- {not: {building_count_gte: {type: fact, n: 1}}}
|
| 221 |
+
max_turns: 23
|
| 222 |
+
|
| 223 |
+
# ── HARD ─────────────────────────────────────────────────────────
|
| 224 |
+
# +1 controlled variable on top of medium: TWO agent spawn_point
|
| 225 |
+
# groups (NORTH base y=14 / SOUTH base y=26) round-robined by seed.
|
| 226 |
+
# The whole base (fact + tent) is duplicated at both latitudes so
|
| 227 |
+
# the e1 rally geometry must be read from the actual base position —
|
| 228 |
+
# a memorised opening cannot generalise. The garrison is duplicated
|
| 229 |
+
# at BOTH latitudes (enemy actors don't honour spawn_point —
|
| 230 |
+
# CLAUDE.md) so whichever spawn is chosen faces an equivalent
|
| 231 |
+
# garrison; the garrison at the agent's latitude advances on the
|
| 232 |
+
# base, the other angles toward the nearest foe. Same 6-kill bar
|
| 233 |
+
# and same tight tick-1600 deadline as medium. max_turns 23 →
|
| 234 |
+
# reachable tick 2073 > 1601 ✓.
|
| 235 |
+
hard:
|
| 236 |
+
description: >
|
| 237 |
+
Same step-pruning call as medium (clear a 7-rifle garrison with
|
| 238 |
+
a basic e1 swarm before tick 1600; skip the unnecessary war-
|
| 239 |
+
factory tech tier) but your base may begin in the NORTH band
|
| 240 |
+
(y≈14) or the SOUTH band (y≈26) depending on the seed. Read
|
| 241 |
+
your barracks' actual position from the observation, train the
|
| 242 |
+
e1 swarm and rally it at your base front. Win when ≥6 enemy
|
| 243 |
+
units are killed AND your fact still stands, before tick 1600.
|
| 244 |
+
Stalling or teching to tanks both LOSE on the clock.
|
| 245 |
+
starting_cash: 6000
|
| 246 |
+
overrides:
|
| 247 |
+
actors:
|
| 248 |
+
# ── AGENT spawn 0 — NORTH base (y=14) ─────────────────────
|
| 249 |
+
- {type: fact, owner: agent, position: [10, 14], spawn_point: 0}
|
| 250 |
+
- {type: tent, owner: agent, position: [13, 14], spawn_point: 0}
|
| 251 |
+
# Inert spawn-witness e1 (HoldFire, stance 0): required for
|
| 252 |
+
# the hard-tier units_summary spawn-variation check (the
|
| 253 |
+
# base is otherwise building-only). Placed base-adjacent;
|
| 254 |
+
# stance 0 means it never opens fire — it does not change
|
| 255 |
+
# the skip-vs-tech decision, only surfaces per-spawn data.
|
| 256 |
+
- {type: e1, owner: agent, position: [11, 14], spawn_point: 0, stance: 0}
|
| 257 |
+
# ── AGENT spawn 1 — SOUTH base (y=26) ─────────────────────
|
| 258 |
+
- {type: fact, owner: agent, position: [10, 26], spawn_point: 1}
|
| 259 |
+
- {type: tent, owner: agent, position: [13, 26], spawn_point: 1}
|
| 260 |
+
- {type: e1, owner: agent, position: [11, 26], spawn_point: 1, stance: 0}
|
| 261 |
+
# Garrison duplicated at both latitudes — enemy actors don't
|
| 262 |
+
# honour spawn_point, so both place every seed; the garrison
|
| 263 |
+
# at the agent's latitude advances on the base.
|
| 264 |
+
- {type: e1, owner: enemy, position: [52, 14], stance: 3, count: 7}
|
| 265 |
+
- {type: e1, owner: enemy, position: [52, 26], stance: 3, count: 7}
|
| 266 |
+
# Far unarmed enemy fact marker — anti-DRAW (no spawn_point
|
| 267 |
+
# ⇒ always places).
|
| 268 |
+
- {type: fact, owner: enemy, position: [120, 20]}
|
| 269 |
+
win_condition:
|
| 270 |
+
all_of:
|
| 271 |
+
- {units_killed_gte: 6}
|
| 272 |
+
- {building_count_gte: {type: fact, n: 1}}
|
| 273 |
+
- {within_ticks: 1600}
|
| 274 |
+
fail_condition:
|
| 275 |
+
any_of:
|
| 276 |
+
- {after_ticks: 1601}
|
| 277 |
+
- {not: {building_count_gte: {type: fact, n: 1}}}
|
| 278 |
+
max_turns: 23
|
tests/test_build_tech_skip_decision.py
ADDED
|
@@ -0,0 +1,365 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""build-tech-skip-decision — REASONING capability validation.
|
| 2 |
+
|
| 3 |
+
Skip an unnecessary tech tier (PlanBench unnecessary-step pruning /
|
| 4 |
+
lean process / YAGNI). The objective only needs BASIC infantry: the
|
| 5 |
+
agent starts with a pre-placed Construction Yard (fact) + Allied
|
| 6 |
+
barracks (tent), so rifle infantry (e1, $100) are trainable from turn
|
| 7 |
+
1 with no prior tech step. A light enemy garrison of rifle infantry
|
| 8 |
+
(stance 3, AttackAnything) advances on the base. Training e1 and
|
| 9 |
+
rallying them at the base front clears the kill bar comfortably.
|
| 10 |
+
|
| 11 |
+
The trap is to climb the full tech chain to medium tanks
|
| 12 |
+
(powr -> proc -> weap + fix -> 2tnk) — a whole tech tier the
|
| 13 |
+
objective never asked for. It burns the clock: by the deadline the
|
| 14 |
+
tech play has only powr+proc standing and ZERO army.
|
| 15 |
+
|
| 16 |
+
Bar (CLAUDE.md "no defect, no cheat, no draw"):
|
| 17 |
+
|
| 18 |
+
* stall (observe-only) LOSES every tier / every hard seed —
|
| 19 |
+
0 kills, the `after_ticks` deadline bites.
|
| 20 |
+
* tech-full-chain (build powr -> proc -> weap -> fix, then 2tnk)
|
| 21 |
+
LOSES every tier / every hard seed — the unneeded tier burns the
|
| 22 |
+
clock; no tank fields before the deadline, kill bar unmet.
|
| 23 |
+
* intended skip-to-e1 (train e1 from the pre-placed tent from turn
|
| 24 |
+
1, rally them at the base front, let them auto-fire on the
|
| 25 |
+
closing garrison) WINS every tier / every hard seed — the kill
|
| 26 |
+
bar is met AND the fact survives, well inside the deadline.
|
| 27 |
+
* hard tier defines >=2 agent spawn_point groups (NORTH base y=14
|
| 28 |
+
/ SOUTH base y=26) round-robined by seed; the garrison is
|
| 29 |
+
duplicated at both latitudes (enemy actors don't honour
|
| 30 |
+
spawn_point — CLAUDE.md), so a memorised base-latitude opening
|
| 31 |
+
cannot generalise.
|
| 32 |
+
"""
|
| 33 |
+
|
| 34 |
+
from __future__ import annotations
|
| 35 |
+
|
| 36 |
+
from pathlib import Path
|
| 37 |
+
|
| 38 |
+
import pytest
|
| 39 |
+
|
| 40 |
+
pytest.importorskip("openra_train", reason="Rust env wheel not installed")
|
| 41 |
+
pytest.importorskip("openra_rl_training", reason="Rust env wheel not installed")
|
| 42 |
+
|
| 43 |
+
from openra_bench.eval_core import run_level
|
| 44 |
+
from openra_bench.scenarios import load_pack
|
| 45 |
+
from openra_bench.scenarios.loader import PACKS_DIR, compile_level
|
| 46 |
+
from openra_bench.scenarios.win_conditions import WinContext, evaluate
|
| 47 |
+
|
| 48 |
+
PACK = PACKS_DIR / "build-tech-skip-decision.yaml"
|
| 49 |
+
LEVELS = ("easy", "medium", "hard")
|
| 50 |
+
SEEDS = (1, 2, 3, 4)
|
| 51 |
+
|
| 52 |
+
|
| 53 |
+
# ── scripted policies ───────────────────────────────────────────────
|
| 54 |
+
|
| 55 |
+
|
| 56 |
+
def _stall(rs, C):
|
| 57 |
+
"""Observe-only — no production. 0 kills, the after_ticks
|
| 58 |
+
deadline bites -> LOSS."""
|
| 59 |
+
return [C.observe()]
|
| 60 |
+
|
| 61 |
+
|
| 62 |
+
def _tech_full_chain(rs, C):
|
| 63 |
+
"""The wrong call: climb the full tech chain (powr -> proc ->
|
| 64 |
+
weap -> fix -> 2tnk) instead of training basic infantry. The
|
| 65 |
+
unneeded tier burns the clock — no tank fields before the
|
| 66 |
+
deadline -> the kill bar is unmet -> LOSS."""
|
| 67 |
+
ob = rs.get("own_buildings") or []
|
| 68 |
+
bt = {b.get("type") for b in ob}
|
| 69 |
+
fy = 20
|
| 70 |
+
for b in ob:
|
| 71 |
+
if b.get("type") == "fact":
|
| 72 |
+
fy = int(b["cell_y"])
|
| 73 |
+
prod = [p.get("item") for p in (rs.get("production") or []) if isinstance(p, dict)]
|
| 74 |
+
cmds = []
|
| 75 |
+
if "powr" not in bt:
|
| 76 |
+
if "powr" not in prod:
|
| 77 |
+
cmds.append(C.build("powr"))
|
| 78 |
+
cmds.append(C.place_building("powr", 17, fy))
|
| 79 |
+
elif "proc" not in bt:
|
| 80 |
+
if "proc" not in prod:
|
| 81 |
+
cmds.append(C.build("proc"))
|
| 82 |
+
cmds.append(C.place_building("proc", 20, fy))
|
| 83 |
+
elif "weap" not in bt:
|
| 84 |
+
if "weap" not in prod:
|
| 85 |
+
cmds.append(C.build("weap"))
|
| 86 |
+
cmds.append(C.place_building("weap", 23, fy))
|
| 87 |
+
elif "fix" not in bt:
|
| 88 |
+
if "fix" not in prod:
|
| 89 |
+
cmds.append(C.build("fix"))
|
| 90 |
+
cmds.append(C.place_building("fix", 23, fy + 4))
|
| 91 |
+
else:
|
| 92 |
+
cmds.append(C.build("2tnk"))
|
| 93 |
+
return cmds or [C.observe()]
|
| 94 |
+
|
| 95 |
+
|
| 96 |
+
def _skip_to_e1(rs, C):
|
| 97 |
+
"""The intended capability — skip the tech tier. Train e1 from
|
| 98 |
+
the pre-placed tent from turn 1 and rally them at the base front;
|
| 99 |
+
they auto-fire on the closing garrison and clear the kill bar."""
|
| 100 |
+
units = rs.get("units_summary") or []
|
| 101 |
+
ob = rs.get("own_buildings") or []
|
| 102 |
+
fy = 20
|
| 103 |
+
for b in ob:
|
| 104 |
+
if b.get("type") == "tent":
|
| 105 |
+
fy = int(b["cell_y"])
|
| 106 |
+
ids = [u.get("id") for u in units if u.get("type") == "e1"]
|
| 107 |
+
cmds = [C.build("e1")]
|
| 108 |
+
if ids:
|
| 109 |
+
cmds.append(C.move_units(ids, 18, fy))
|
| 110 |
+
return cmds
|
| 111 |
+
|
| 112 |
+
|
| 113 |
+
# ── structural tests ────────────────────────────────────────────────
|
| 114 |
+
|
| 115 |
+
|
| 116 |
+
def test_pack_loads_and_meta_active():
|
| 117 |
+
pack = load_pack(PACK)
|
| 118 |
+
assert pack.meta.id == "build-tech-skip-decision"
|
| 119 |
+
assert pack.meta.capability == "reasoning"
|
| 120 |
+
assert pack.meta.real_world_meaning
|
| 121 |
+
assert pack.meta.robotics_analogue
|
| 122 |
+
anchors = " ".join(pack.meta.benchmark_anchor).lower()
|
| 123 |
+
assert "planbench" in anchors, anchors
|
| 124 |
+
assert "pruning" in anchors, anchors
|
| 125 |
+
assert "yagni" in anchors, anchors
|
| 126 |
+
assert "lean process" in anchors, anchors
|
| 127 |
+
|
| 128 |
+
|
| 129 |
+
def test_tools_include_build_and_combat_surface():
|
| 130 |
+
"""Pack must expose [observe, build, place_building, move_units,
|
| 131 |
+
attack_move, stop] — the build-and-engage interaction surface."""
|
| 132 |
+
pack = load_pack(PACK)
|
| 133 |
+
tools = set(pack.base.get("tools", []) if isinstance(pack.base, dict) else [])
|
| 134 |
+
for required in ("observe", "build", "place_building", "move_units",
|
| 135 |
+
"attack_move", "stop"):
|
| 136 |
+
assert required in tools, f"missing tool: {required!r}"
|
| 137 |
+
|
| 138 |
+
|
| 139 |
+
def test_preplaced_base_is_fact_plus_tent_no_higher_tech():
|
| 140 |
+
"""Every level pre-places fact + tent for the agent — the cheap
|
| 141 |
+
e1 path must be actionable on turn 1 (tent present) with NO
|
| 142 |
+
higher-tech building (powr / proc / weap / fix) handed to the
|
| 143 |
+
agent: building the tech tier is what the agent must AVOID. The
|
| 144 |
+
hard tier additionally carries an inert HoldFire e1 spawn-witness
|
| 145 |
+
per spawn group (units_summary spawn-variation contract)."""
|
| 146 |
+
pack = load_pack(PACK)
|
| 147 |
+
higher_tech = {"powr", "proc", "weap", "fix", "dome", "apwr"}
|
| 148 |
+
for lvl in LEVELS:
|
| 149 |
+
c = compile_level(pack, lvl)
|
| 150 |
+
agent_types = {a.type for a in c.scenario.actors if a.owner == "agent"}
|
| 151 |
+
assert "fact" in agent_types, f"{lvl}: agent base missing fact"
|
| 152 |
+
assert "tent" in agent_types, f"{lvl}: agent base missing tent"
|
| 153 |
+
assert not (agent_types & higher_tech), (
|
| 154 |
+
f"{lvl}: agent must NOT start with a higher-tech building; "
|
| 155 |
+
f"got {sorted(agent_types & higher_tech)}"
|
| 156 |
+
)
|
| 157 |
+
# only fact / tent / (hard) the inert e1 witness
|
| 158 |
+
assert agent_types <= {"fact", "tent", "e1"}, (
|
| 159 |
+
f"{lvl}: unexpected agent actor types {sorted(agent_types)}"
|
| 160 |
+
)
|
| 161 |
+
|
| 162 |
+
|
| 163 |
+
def test_garrison_is_light_infantry_stance3():
|
| 164 |
+
"""The enemy garrison is basic rifle infantry (e1) at stance 3
|
| 165 |
+
(AttackAnything — advances on the agent base). No tanks / no
|
| 166 |
+
higher-tech enemy units."""
|
| 167 |
+
pack = load_pack(PACK)
|
| 168 |
+
for lvl in LEVELS:
|
| 169 |
+
c = compile_level(pack, lvl)
|
| 170 |
+
garrison = [
|
| 171 |
+
a for a in c.scenario.actors
|
| 172 |
+
if a.owner == "enemy" and a.type == "e1"
|
| 173 |
+
]
|
| 174 |
+
assert garrison, f"{lvl}: must have an e1 garrison"
|
| 175 |
+
for a in garrison:
|
| 176 |
+
assert a.stance == 3, (
|
| 177 |
+
f"{lvl}: garrison e1 must be stance 3 (AttackAnything); "
|
| 178 |
+
f"got {a.stance}"
|
| 179 |
+
)
|
| 180 |
+
# only e1 + the far fact marker on the enemy side
|
| 181 |
+
enemy_types = {a.type for a in c.scenario.actors if a.owner == "enemy"}
|
| 182 |
+
assert enemy_types == {"e1", "fact"}, (
|
| 183 |
+
f"{lvl}: enemy actors must be {{e1, fact}}; got {sorted(enemy_types)}"
|
| 184 |
+
)
|
| 185 |
+
|
| 186 |
+
|
| 187 |
+
def test_far_enemy_fact_marker_present():
|
| 188 |
+
"""A persistent unarmed enemy fact marker far east keeps the
|
| 189 |
+
episode alive past the last garrison death (CLAUDE.md auto-done
|
| 190 |
+
footgun) so a non-winner reaches the deadline as a real LOSS."""
|
| 191 |
+
pack = load_pack(PACK)
|
| 192 |
+
for lvl in LEVELS:
|
| 193 |
+
c = compile_level(pack, lvl)
|
| 194 |
+
far = [
|
| 195 |
+
a for a in c.scenario.actors
|
| 196 |
+
if a.owner == "enemy" and a.type == "fact" and a.position[0] >= 100
|
| 197 |
+
]
|
| 198 |
+
assert far, f"{lvl}: missing far enemy fact marker (anti-DRAW)"
|
| 199 |
+
|
| 200 |
+
|
| 201 |
+
def test_every_level_has_reachable_timeout_fail():
|
| 202 |
+
"""`after_ticks` fail must bite WITHIN max_turns (so stall / tech
|
| 203 |
+
are a real reachable LOSS, not a draw). within_ticks + 1 ==
|
| 204 |
+
after_ticks so a non-finisher LOSES one tick past the window."""
|
| 205 |
+
pack = load_pack(PACK)
|
| 206 |
+
for lvl in LEVELS:
|
| 207 |
+
L = pack.levels[lvl]
|
| 208 |
+
ceiling = 93 + 90 * (L.max_turns - 1)
|
| 209 |
+
wt = next(
|
| 210 |
+
int(c["within_ticks"])
|
| 211 |
+
for c in L.win_condition.model_dump()["all_of"]
|
| 212 |
+
if "within_ticks" in c
|
| 213 |
+
)
|
| 214 |
+
ft = next(
|
| 215 |
+
int(c["after_ticks"])
|
| 216 |
+
for c in L.fail_condition.model_dump()["any_of"]
|
| 217 |
+
if "after_ticks" in c
|
| 218 |
+
)
|
| 219 |
+
assert wt < ceiling, f"{lvl}: within_ticks {wt} >= ceiling {ceiling}"
|
| 220 |
+
assert ft <= ceiling, f"{lvl}: after_ticks {ft} > ceiling {ceiling}"
|
| 221 |
+
assert wt + 1 == ft, (
|
| 222 |
+
f"{lvl}: within_ticks {wt} / after_ticks {ft} mismatch "
|
| 223 |
+
"(boundary non-finisher must LOSE, not draw)"
|
| 224 |
+
)
|
| 225 |
+
|
| 226 |
+
|
| 227 |
+
def test_every_level_has_a_fail_condition():
|
| 228 |
+
pack = load_pack(PACK)
|
| 229 |
+
for lvl in LEVELS:
|
| 230 |
+
c = compile_level(pack, lvl)
|
| 231 |
+
assert c.fail_condition is not None, f"{lvl} needs a fail_condition"
|
| 232 |
+
|
| 233 |
+
|
| 234 |
+
def test_hard_has_two_seed_driven_spawn_groups():
|
| 235 |
+
c = compile_level(load_pack(PACK), "hard")
|
| 236 |
+
sp = {
|
| 237 |
+
(a.spawn_point if a.spawn_point is not None else 0)
|
| 238 |
+
for a in c.scenario.actors
|
| 239 |
+
if a.owner == "agent"
|
| 240 |
+
}
|
| 241 |
+
assert sp == {0, 1}, f"hard must define exactly {{0, 1}}; got {sorted(sp)}"
|
| 242 |
+
|
| 243 |
+
|
| 244 |
+
def test_in_bounds_actors_on_every_level():
|
| 245 |
+
"""rush-hour-arena playable bounds ~ x:2..126, y:2..38."""
|
| 246 |
+
pack = load_pack(PACK)
|
| 247 |
+
for lvl in LEVELS:
|
| 248 |
+
c = compile_level(pack, lvl)
|
| 249 |
+
for a in c.scenario.actors:
|
| 250 |
+
x, y = a.position
|
| 251 |
+
assert 2 <= x <= 126 and 2 <= y <= 38, (
|
| 252 |
+
f"{lvl}: actor {a.type} at ({x},{y}) out of bounds"
|
| 253 |
+
)
|
| 254 |
+
|
| 255 |
+
|
| 256 |
+
# ── predicate-level (no engine) ─────────────────────────────────────
|
| 257 |
+
|
| 258 |
+
|
| 259 |
+
def _ctx(*, tick=0, kills=0, own_buildings=()):
|
| 260 |
+
import types
|
| 261 |
+
|
| 262 |
+
sig = types.SimpleNamespace(
|
| 263 |
+
game_tick=tick,
|
| 264 |
+
units_killed=kills,
|
| 265 |
+
units_lost=0,
|
| 266 |
+
cash=0,
|
| 267 |
+
resources=0,
|
| 268 |
+
own_buildings=list(own_buildings),
|
| 269 |
+
own_building_types={str(t).lower() for (t, _, _) in own_buildings},
|
| 270 |
+
enemies_seen_ids=set(),
|
| 271 |
+
enemy_buildings_seen_ids=set(),
|
| 272 |
+
)
|
| 273 |
+
return WinContext(signals=sig, render_state={"units_summary": []})
|
| 274 |
+
|
| 275 |
+
|
| 276 |
+
def test_predicates_enforce_capability():
|
| 277 |
+
"""Win requires (kill bar AND fact alive AND in time); fail fires
|
| 278 |
+
on timeout OR fact destroyed."""
|
| 279 |
+
c = compile_level(load_pack(PACK), "easy")
|
| 280 |
+
base_b = [("fact", 10, 20), ("tent", 13, 20)]
|
| 281 |
+
|
| 282 |
+
# Intended: 4 kills, fact alive, in time -> WIN
|
| 283 |
+
assert evaluate(c.win_condition, _ctx(tick=900, kills=4, own_buildings=base_b))
|
| 284 |
+
# 3 kills (under bar) -> not win
|
| 285 |
+
assert not evaluate(
|
| 286 |
+
c.win_condition, _ctx(tick=900, kills=3, own_buildings=base_b)
|
| 287 |
+
)
|
| 288 |
+
# 4 kills but past within_ticks -> not win
|
| 289 |
+
assert not evaluate(
|
| 290 |
+
c.win_condition, _ctx(tick=1801, kills=4, own_buildings=base_b)
|
| 291 |
+
)
|
| 292 |
+
# 4 kills but fact destroyed -> not win
|
| 293 |
+
assert not evaluate(
|
| 294 |
+
c.win_condition, _ctx(tick=900, kills=4, own_buildings=base_b[1:])
|
| 295 |
+
)
|
| 296 |
+
# Past after_ticks deadline -> fail
|
| 297 |
+
assert evaluate(
|
| 298 |
+
c.fail_condition, _ctx(tick=1900, kills=0, own_buildings=base_b)
|
| 299 |
+
)
|
| 300 |
+
# Fact destroyed -> fail
|
| 301 |
+
assert evaluate(
|
| 302 |
+
c.fail_condition, _ctx(tick=900, kills=4, own_buildings=base_b[1:])
|
| 303 |
+
)
|
| 304 |
+
# Within deadline, fact alive -> not fail
|
| 305 |
+
assert not evaluate(
|
| 306 |
+
c.fail_condition, _ctx(tick=900, kills=0, own_buildings=base_b)
|
| 307 |
+
)
|
| 308 |
+
|
| 309 |
+
|
| 310 |
+
# ── engine-driven: every lazy / wrong policy LOSES, intended WINS ───
|
| 311 |
+
|
| 312 |
+
|
| 313 |
+
@pytest.mark.parametrize("level", LEVELS)
|
| 314 |
+
@pytest.mark.parametrize("seed", SEEDS)
|
| 315 |
+
def test_stall_loses_every_tier_and_seed(level, seed):
|
| 316 |
+
"""Observe-only -> 0 kills + the after_ticks deadline bites ->
|
| 317 |
+
a real reachable LOSS, not a draw."""
|
| 318 |
+
c = compile_level(load_pack(PACK), level)
|
| 319 |
+
r = run_level(c, _stall, seed=seed)
|
| 320 |
+
assert r.outcome == "loss", (
|
| 321 |
+
f"{level}/seed{seed}: stall must LOSE; got {r.outcome} "
|
| 322 |
+
f"tick={r.signals.game_tick} kills={r.signals.units_killed}"
|
| 323 |
+
)
|
| 324 |
+
|
| 325 |
+
|
| 326 |
+
@pytest.mark.parametrize("level", LEVELS)
|
| 327 |
+
@pytest.mark.parametrize("seed", SEEDS)
|
| 328 |
+
def test_tech_full_chain_loses_every_tier_and_seed(level, seed):
|
| 329 |
+
"""Climbing the full tech chain (powr -> proc -> weap -> fix ->
|
| 330 |
+
2tnk) burns the clock on a tier the objective never required -> no
|
| 331 |
+
tank fields before the deadline -> kill bar unmet -> LOSS."""
|
| 332 |
+
c = compile_level(load_pack(PACK), level)
|
| 333 |
+
r = run_level(c, _tech_full_chain, seed=seed)
|
| 334 |
+
assert r.outcome == "loss", (
|
| 335 |
+
f"{level}/seed{seed}: tech-full-chain must LOSE (clock); "
|
| 336 |
+
f"got {r.outcome} tick={r.signals.game_tick} "
|
| 337 |
+
f"kills={r.signals.units_killed}"
|
| 338 |
+
)
|
| 339 |
+
|
| 340 |
+
|
| 341 |
+
@pytest.mark.parametrize("level", LEVELS)
|
| 342 |
+
@pytest.mark.parametrize("seed", SEEDS)
|
| 343 |
+
def test_skip_to_e1_wins_every_tier_and_seed(level, seed):
|
| 344 |
+
"""The intended capability — skip the unneeded tech tier, train
|
| 345 |
+
e1 from the pre-placed tent and rally them at the base front.
|
| 346 |
+
Wins every tier and every hard seed, well inside the deadline."""
|
| 347 |
+
c = compile_level(load_pack(PACK), level)
|
| 348 |
+
r = run_level(c, _skip_to_e1, seed=seed)
|
| 349 |
+
assert r.outcome == "win", (
|
| 350 |
+
f"{level}/seed{seed}: skip-to-e1 must WIN; got {r.outcome} "
|
| 351 |
+
f"tick={r.signals.game_tick} kills={r.signals.units_killed}"
|
| 352 |
+
)
|
| 353 |
+
|
| 354 |
+
|
| 355 |
+
# ── determinism ─────────────────────────────────────────────────────
|
| 356 |
+
|
| 357 |
+
|
| 358 |
+
def test_skip_to_e1_run_is_deterministic_per_seed():
|
| 359 |
+
"""Same seed, same policy -> identical outcome / kills / turns."""
|
| 360 |
+
c = compile_level(load_pack(PACK), "medium")
|
| 361 |
+
a = run_level(c, _skip_to_e1, seed=3)
|
| 362 |
+
b = run_level(c, _skip_to_e1, seed=3)
|
| 363 |
+
assert (a.outcome, a.turns, a.signals.units_killed) == (
|
| 364 |
+
b.outcome, b.turns, b.signals.units_killed
|
| 365 |
+
)
|