Spaces:
Running
Running
feat(scenario): def-position-revealed-direction — scout-then-fortify adaptive defense (PlanBench replanning anchor)
Browse files
openra_bench/scenarios/packs/def-position-revealed-direction.yaml
ADDED
|
@@ -0,0 +1,457 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# def-position-revealed-direction.yaml
|
| 2 |
+
#
|
| 3 |
+
# REASONING capability — INTEL-DRIVEN ADAPTIVE DEFENSE. Unlike its
|
| 4 |
+
# sibling `def-position-expected-direction` (where the threat axis is
|
| 5 |
+
# STATED in the brief and the agent merely commits a budget to the
|
| 6 |
+
# named lane), this pack hides the axis: the agent must FIRST SCOUT to
|
| 7 |
+
# discover where the enemy's forward outpost actually is, THEN fortify
|
| 8 |
+
# THAT lane with pillboxes, THEN engage and kill the incoming rush.
|
| 9 |
+
# Pre-fortifying any one direction on faith LOSES on seeds where the
|
| 10 |
+
# enemy comes from the opposite side; only the scout-then-fortify
|
| 11 |
+
# discipline generalises.
|
| 12 |
+
#
|
| 13 |
+
# Win idiom (the load-bearing chain):
|
| 14 |
+
# then:
|
| 15 |
+
# - {buildings_discovered_gte: 1} # FIRST — scout finds the
|
| 16 |
+
# # enemy outpost on the
|
| 17 |
+
# # approach axis (outposts are
|
| 18 |
+
# # OUTSIDE pbox/base vision; a
|
| 19 |
+
# # scout MUST move on medium/
|
| 20 |
+
# # hard).
|
| 21 |
+
# - {building_count_gte: {type: pbox, n: 3}} # THEN — commit 3
|
| 22 |
+
# # pillboxes (defensive
|
| 23 |
+
# # posture).
|
| 24 |
+
# - {units_killed_gte: K} # THEN — actually engage and
|
| 25 |
+
# # break the incoming rush.
|
| 26 |
+
# AND building_count_gte:{type: fact, n: 1} AND within_ticks: T.
|
| 27 |
+
# Fail: after_ticks: T+1 OR not building_count_gte:{type: fact, n: 1}
|
| 28 |
+
# OR not own_units_gte: 1.
|
| 29 |
+
# (We use `building_count_gte` rather than `has_building` because the
|
| 30 |
+
# latter checks `own_building_types`, which ACCUMULATES across the
|
| 31 |
+
# episode — once `fact` has been owned, `has_building:fact` stays true
|
| 32 |
+
# even after the fact is razed, so it is structurally useless as a
|
| 33 |
+
# "still standing" fail clause. `building_count_gte` reads the live
|
| 34 |
+
# `own_buildings` list each frame and bites correctly when the fact
|
| 35 |
+
# is destroyed.)
|
| 36 |
+
#
|
| 37 |
+
# Pre-fortify-N-without-scout fails: clause 1 of the then-chain never
|
| 38 |
+
# latches because the pbox vision radius (~6) cannot reach the enemy
|
| 39 |
+
# outpost (placed 10+ cells away from the base on the approach axis).
|
| 40 |
+
# Pre-fortify-N-with-scout (memorised NORTH on hard) fails on the
|
| 41 |
+
# SOUTH-spawn seeds: the defenses sit silent on the wrong axis, the
|
| 42 |
+
# south-band rushers walk straight to the south-spawn fact,
|
| 43 |
+
# units_killed never reaches K, and the fact is razed → double LOSS
|
| 44 |
+
# path (chain clause 3 + the fact-loss fail clause). Stall LOSES on
|
| 45 |
+
# the deadline (or earlier on the fact-loss fail clause when the rush
|
| 46 |
+
# closes the home fact).
|
| 47 |
+
#
|
| 48 |
+
# Real-world / benchmark anchors:
|
| 49 |
+
# - PlanBench replanning: model must update its plan based on
|
| 50 |
+
# observation (which axis is the actual threat) before committing.
|
| 51 |
+
# - Military reactive defense / intel-driven facility hardening:
|
| 52 |
+
# reconnaissance precedes fortification; pre-committing without
|
| 53 |
+
# intel is the classical "Maginot Line" failure mode (defenses on
|
| 54 |
+
# the wrong axis are dead weight).
|
| 55 |
+
# - Intel-driven defense: convert reconnaissance into committed
|
| 56 |
+
# defensive posture; a generalist policy MUST drive the scout
|
| 57 |
+
# loop, not memorise a fixed axis.
|
| 58 |
+
#
|
| 59 |
+
# Distinct from siblings:
|
| 60 |
+
# * def-position-expected-direction — intel is GIVEN; the decision
|
| 61 |
+
# is purely WHICH axis given the textual cue. THIS pack hides the
|
| 62 |
+
# axis; the decision is whether to SCOUT before committing.
|
| 63 |
+
# * def-pre-position-mobile-reserve — direction unknown; mobile
|
| 64 |
+
# reserve commits on detection (no static defenses). THIS pack
|
| 65 |
+
# requires STATIC fortification AFTER scouting.
|
| 66 |
+
# * def-surprise-flank-react — intel is FALSE (announced N, actual
|
| 67 |
+
# S); the trap is trusting the brief. THIS pack has NO false
|
| 68 |
+
# intel; the brief states "you don't know — scout to find out".
|
| 69 |
+
# * lh-scout-react-counter — scout→react→counter on a FIXED enemy
|
| 70 |
+
# posture; the chain is the same idiom but the capability is
|
| 71 |
+
# offensive (raze the construction yard) rather than defensive
|
| 72 |
+
# (fortify the lane).
|
| 73 |
+
#
|
| 74 |
+
# ENGINE FACTS (per CLAUDE.md / SCENARIO_QUALITY.md):
|
| 75 |
+
# - `pbox` costs 600cr; needs tent (which needs powr). Pre-place
|
| 76 |
+
# fact + proc + powr + tent so the pbox queue is hot from turn 1.
|
| 77 |
+
# Starting cash 2400 = 3 × 600 + ~600 buffer.
|
| 78 |
+
# - Enemy actors with NO spawn_point ALWAYS place regardless of the
|
| 79 |
+
# agent's spawn group (CLAUDE.md). So on hard we declare BOTH a
|
| 80 |
+
# NORTH band and a SOUTH band; the seed-driven agent spawn picks
|
| 81 |
+
# which band is the "near" (immediate) one. Each band has its
|
| 82 |
+
# own outpost building so scouting either side reveals exactly
|
| 83 |
+
# one outpost.
|
| 84 |
+
# - `then:` is the happened-before composite: clauses latch IN
|
| 85 |
+
# ORDER, so a pre-fortify-no-scout play that places 3 pbox first
|
| 86 |
+
# and then idles cannot satisfy clause 1 (scout) after the fact
|
| 87 |
+
# UNLESS a scout actually drives close enough to see the
|
| 88 |
+
# outpost — which is the load-bearing capability.
|
| 89 |
+
# - `building_count_gte:{pbox, 3}` (NOT building_in_region) — the
|
| 90 |
+
# scout latch already enforces axis awareness via clause 1; we
|
| 91 |
+
# don't double-encode the region. Placement is purely about the
|
| 92 |
+
# agent committing to defense after intel; lane choice is enforced
|
| 93 |
+
# by the units_killed clause (3 pbox on the wrong axis sit silent,
|
| 94 |
+
# the rushers walk past, kills don't accrue, fact dies).
|
| 95 |
+
# - Inert enemy `fact` marker far east — anti-DRAW per CLAUDE.md;
|
| 96 |
+
# without a persistent enemy actor the engine auto-`done`s on
|
| 97 |
+
# enemy-elim before the win/fail evaluator runs.
|
| 98 |
+
# - `enemy_unit_spotted` interrupt — re-decide the instant a rusher
|
| 99 |
+
# unit is spotted (the actual axis becomes confirmed by the rush
|
| 100 |
+
# itself, in case the scout missed the outpost).
|
| 101 |
+
# - `enemy_building_spotted` interrupt — re-decide the instant the
|
| 102 |
+
# outpost is discovered (the FIRST clause latches).
|
| 103 |
+
# - Tick budget: max_turns 66 → reachable tick 93 + 90·65 = 5943 ≥
|
| 104 |
+
# 5402 (deadline 5400 + 1 = 5401), so a non-finisher is a real
|
| 105 |
+
# reachable timeout LOSS, never a draw.
|
| 106 |
+
#
|
| 107 |
+
# Validate (no model / no network):
|
| 108 |
+
# python3 -m openra_bench.scenarios.validate \
|
| 109 |
+
# openra_bench/scenarios/packs/def-position-revealed-direction.yaml
|
| 110 |
+
|
| 111 |
+
meta:
|
| 112 |
+
id: def-position-revealed-direction
|
| 113 |
+
title: 'Scout the Axis, Then Fortify — Intel-Driven Adaptive Defense'
|
| 114 |
+
capability: reasoning
|
| 115 |
+
real_world_meaning: >
|
| 116 |
+
Adaptive defense under uncertainty: the threat axis is NOT known
|
| 117 |
+
a priori; the agent must drive its scouts to discover the
|
| 118 |
+
enemy's forward outpost (where the build-up actually is), then
|
| 119 |
+
commit pillboxes on THAT lane, then engage. Pre-committing to a
|
| 120 |
+
single direction without scouting is the classical Maginot-Line
|
| 121 |
+
failure mode — defenses on the wrong axis are dead weight while
|
| 122 |
+
the unguarded lane is overrun. The capability under test is the
|
| 123 |
+
intel-action loop: observation FIRST, commitment SECOND.
|
| 124 |
+
robotics_analogue: >
|
| 125 |
+
Intel-driven facility hardening / military reactive defense: a
|
| 126 |
+
perimeter operator does not know which gate will be breached;
|
| 127 |
+
they must dispatch reconnaissance, confirm the threat axis from
|
| 128 |
+
sensor returns, and then deploy barriers / responders on the
|
| 129 |
+
confirmed lane. Skipping the reconnaissance step and pre-staging
|
| 130 |
+
on a guessed lane is correct half the time and catastrophically
|
| 131 |
+
wrong the other half. The robust doctrine is scout-then-fortify,
|
| 132 |
+
not commit-on-faith.
|
| 133 |
+
benchmark_anchor:
|
| 134 |
+
- 'PlanBench replanning'
|
| 135 |
+
- 'military reactive defense'
|
| 136 |
+
- 'intel-driven defense'
|
| 137 |
+
author: openra-bench
|
| 138 |
+
|
| 139 |
+
# rush-hour-arena (128×40, playable x∈[2..126], y∈[2..38]). Agent
|
| 140 |
+
# base at (20, 20) on easy/medium. The fact + proc + powr + tent let
|
| 141 |
+
# the pbox queue fire from turn 1. Two jeep scouts at the base — the
|
| 142 |
+
# agent must drive them to confirm the enemy outpost's lane before
|
| 143 |
+
# committing pillboxes (medium/hard; on easy the jeeps are pre-staged
|
| 144 |
+
# forward and passively see the outpost from turn 1).
|
| 145 |
+
base_map: rush-hour-arena
|
| 146 |
+
|
| 147 |
+
# Pack-wide budget — funds the intended 3-pbox triple (3 × 600 =
|
| 148 |
+
# 1800cr) with a small buffer (~600cr) for replacement defenders or
|
| 149 |
+
# an extra pbox if the agent wants. Spamming pbox on the wrong axis
|
| 150 |
+
# burns the same budget without satisfying the kill clause (wrong-axis
|
| 151 |
+
# pbox sit silent, the rushers walk past).
|
| 152 |
+
starting_cash: 2400
|
| 153 |
+
|
| 154 |
+
base:
|
| 155 |
+
agent:
|
| 156 |
+
faction: allies
|
| 157 |
+
# Concentrated charge at the agent centroid every ~8 ticks — the
|
| 158 |
+
# canonical rush-defense opponent. The bot's SPAWN POSITION is the
|
| 159 |
+
# hidden information the model must discover by scouting.
|
| 160 |
+
enemy:
|
| 161 |
+
faction: soviet
|
| 162 |
+
bot_type: rusher
|
| 163 |
+
tools:
|
| 164 |
+
- observe
|
| 165 |
+
- build
|
| 166 |
+
- place_building
|
| 167 |
+
- move_units
|
| 168 |
+
- attack_unit
|
| 169 |
+
- attack_move
|
| 170 |
+
- stop
|
| 171 |
+
planning: true
|
| 172 |
+
# Re-decide the instant the scout discovers an enemy outpost
|
| 173 |
+
# (the first latch in the then-chain) or a rusher unit is spotted
|
| 174 |
+
# (confirms the actual axis) or a defender is lost (defense
|
| 175 |
+
# commitment is reactive).
|
| 176 |
+
interrupts:
|
| 177 |
+
enemy_building_spotted: true
|
| 178 |
+
enemy_unit_spotted: true
|
| 179 |
+
own_unit_destroyed: true
|
| 180 |
+
termination:
|
| 181 |
+
max_ticks: 12000
|
| 182 |
+
actors:
|
| 183 |
+
# Base-pack defaults; each level overrides with the spawn-specific
|
| 184 |
+
# layout. (Anti-empty-actors sanity for the loader.)
|
| 185 |
+
- {type: fact, owner: agent, position: [20, 20]}
|
| 186 |
+
|
| 187 |
+
levels:
|
| 188 |
+
# ── EASY ─────────────────────────────────────────────────────────
|
| 189 |
+
# The bare scout-then-fortify chain. Axis is NORTH; the brief
|
| 190 |
+
# explicitly tells the model the chain (scout → fortify → engage)
|
| 191 |
+
# and the threat axis. The two jeeps are pre-positioned FORWARD
|
| 192 |
+
# (y=8) so a single passive observe on turn 1 already has line-of-
|
| 193 |
+
# sight to the enemy outpost (a `tent` at (20, 2), within jeep
|
| 194 |
+
# vision range of ~7). The first then-clause latches immediately;
|
| 195 |
+
# the agent only needs to build 3 pbox and engage the incoming
|
| 196 |
+
# light rush (3 e1) — a 3-kill bar matched by the screen + pbox.
|
| 197 |
+
# max_turns 66 → reachable tick 5943 ≥ 5401.
|
| 198 |
+
easy:
|
| 199 |
+
description: >
|
| 200 |
+
A light enemy rush is incoming from the NORTH (your scouts can
|
| 201 |
+
already see the enemy outpost up the lane). The discipline
|
| 202 |
+
under test is the scout-then-fortify chain: confirm the threat
|
| 203 |
+
with intel, then commit defenses, then engage. You start with
|
| 204 |
+
a construction yard, refinery, power plant, and barracks at
|
| 205 |
+
(20, 20), four rifle defenders, and TWO scout jeeps pre-
|
| 206 |
+
positioned NORTH at (20, 8)/(22, 8) — already in line of
|
| 207 |
+
sight of the enemy outpost. Build at least THREE pillboxes
|
| 208 |
+
(pbox), then destroy ≥3 of the incoming rifle infantry. Keep
|
| 209 |
+
the construction yard standing and ≥1 of your own units
|
| 210 |
+
alive. Win before tick 5400. Stalling loses on the clock;
|
| 211 |
+
pure-build-no-fight loses on the kill bar; pure-fight-no-
|
| 212 |
+
build loses when the rush overruns the defenders.
|
| 213 |
+
overrides:
|
| 214 |
+
actors:
|
| 215 |
+
# Agent base — fact + proc + powr + tent so the pbox queue
|
| 216 |
+
# (needs powr + tent) is hot from turn 1. The tent is also
|
| 217 |
+
# the source of the four pre-placed rifle defenders.
|
| 218 |
+
- {type: fact, owner: agent, position: [20, 20]}
|
| 219 |
+
- {type: proc, owner: agent, position: [16, 20]}
|
| 220 |
+
- {type: powr, owner: agent, position: [20, 24]}
|
| 221 |
+
- {type: tent, owner: agent, position: [16, 24]}
|
| 222 |
+
# Two scout jeeps pre-positioned FORWARD-NORTH at y=8 —
|
| 223 |
+
# already within vision range of the enemy outpost at (20, 2)
|
| 224 |
+
# (distance 6, inside jeep sight ~7). A single observe on
|
| 225 |
+
# turn 1 latches `buildings_discovered_gte:1` — easy teaches
|
| 226 |
+
# the chain without requiring scout MOVEMENT.
|
| 227 |
+
- {type: jeep, owner: agent, position: [20, 8], stance: 1}
|
| 228 |
+
- {type: jeep, owner: agent, position: [22, 8], stance: 1}
|
| 229 |
+
# Four starter defenders SOUTH of the base (away from the
|
| 230 |
+
# NORTH lane mouth) — they survive a light push so
|
| 231 |
+
# own_units_gte:1 holds; positioning them away from the lane
|
| 232 |
+
# avoids auto-fire chasing the rush past the pbox line.
|
| 233 |
+
- {type: e1, owner: agent, position: [22, 24], stance: 2}
|
| 234 |
+
- {type: e1, owner: agent, position: [24, 24], stance: 2}
|
| 235 |
+
- {type: e1, owner: agent, position: [22, 26], stance: 2}
|
| 236 |
+
- {type: e1, owner: agent, position: [24, 26], stance: 2}
|
| 237 |
+
# Enemy forward outpost (NORTH): a `tent` at (20, 2) —
|
| 238 |
+
# placed at the playable edge so it is OUT of canonical pbox
|
| 239 |
+
# vision (a pbox at y=14 is 12 cells from (20,2), well past
|
| 240 |
+
# any pbox sight range). The forward jeeps at y=8 sit 6
|
| 241 |
+
# cells away and see it from turn 1 (jeep sight ~7).
|
| 242 |
+
- {type: tent, owner: enemy, position: [20, 2]}
|
| 243 |
+
# Light NORTH rusher push — 3 e1 at (20, 4). The rusher bot
|
| 244 |
+
# charges the agent centroid (~y=22 with the southern
|
| 245 |
+
# defenders), walking south through (20, 14) where the pbox
|
| 246 |
+
# line intercepts.
|
| 247 |
+
- {type: e1, owner: enemy, position: [20, 4], stance: 3, count: 3}
|
| 248 |
+
# Inert enemy `fact` marker far east — anti-DRAW per
|
| 249 |
+
# CLAUDE.md; without a persistent enemy actor the engine
|
| 250 |
+
# auto-`done`s on enemy-elim before the win/fail evaluator
|
| 251 |
+
# runs.
|
| 252 |
+
- {type: fact, owner: enemy, position: [120, 20]}
|
| 253 |
+
win_condition:
|
| 254 |
+
all_of:
|
| 255 |
+
- then:
|
| 256 |
+
id: scout-fortify-engage-easy
|
| 257 |
+
clauses:
|
| 258 |
+
- {buildings_discovered_gte: 1}
|
| 259 |
+
- {building_count_gte: {type: pbox, n: 3}}
|
| 260 |
+
- {units_killed_gte: 3}
|
| 261 |
+
- building_count_gte: {type: fact, n: 1}
|
| 262 |
+
- own_units_gte: 1
|
| 263 |
+
- within_ticks: 5400
|
| 264 |
+
fail_condition:
|
| 265 |
+
any_of:
|
| 266 |
+
- after_ticks: 5401
|
| 267 |
+
- not: {building_count_gte: {type: fact, n: 1}}
|
| 268 |
+
- not: {own_units_gte: 1}
|
| 269 |
+
max_turns: 66
|
| 270 |
+
|
| 271 |
+
# ── MEDIUM ───────────────────────────────────────────────────────
|
| 272 |
+
# +1 controlled variable: the AXIS IS FOGGED. Jeeps now start AT
|
| 273 |
+
# the base (20, 20), NOT forward — the outpost at (20, 2) is 18
|
| 274 |
+
# cells away, well outside passive vision (~7). A no-scout play
|
| 275 |
+
# cannot latch the first then-clause. The intended play is
|
| 276 |
+
# `move_units([jeep1, jeep2], 20, 8)` to enter outpost sight, THEN
|
| 277 |
+
# build 3 pbox NORTH, THEN engage.
|
| 278 |
+
# A pair of forward delayer e1 at y=10 absorbs the first wave so
|
| 279 |
+
# the pbox triple has time to come online (without them, even an
|
| 280 |
+
# intended-scout play loses because the rush razes the fact before
|
| 281 |
+
# 3 pbox finish building).
|
| 282 |
+
# max_turns 66 → reachable tick 5943 ≥ 5401.
|
| 283 |
+
medium:
|
| 284 |
+
description: >
|
| 285 |
+
A rush is incoming, but the threat axis is NOT given — you
|
| 286 |
+
must SCOUT to find it. You start with a construction yard,
|
| 287 |
+
refinery, power plant, and barracks at (20, 20), four rifle
|
| 288 |
+
defenders, and TWO scout jeeps at the base. The enemy has a
|
| 289 |
+
forward outpost on the approach axis; drive a jeep north (or
|
| 290 |
+
whichever lane your observation suggests) to register at least
|
| 291 |
+
ONE enemy building, THEN build at least THREE pillboxes (pbox),
|
| 292 |
+
THEN destroy ≥3 of the incoming rifle infantry. Keep the
|
| 293 |
+
construction yard standing and ≥1 of your own units alive.
|
| 294 |
+
Win before tick 5400. Skipping the scout phase fails the
|
| 295 |
+
then-chain at clause 1; pre-fortifying without scouting wastes
|
| 296 |
+
the budget on the wrong-axis intuition; stalling loses on the
|
| 297 |
+
clock.
|
| 298 |
+
overrides:
|
| 299 |
+
actors:
|
| 300 |
+
- {type: fact, owner: agent, position: [20, 20]}
|
| 301 |
+
- {type: proc, owner: agent, position: [16, 20]}
|
| 302 |
+
- {type: powr, owner: agent, position: [20, 24]}
|
| 303 |
+
- {type: tent, owner: agent, position: [16, 24]}
|
| 304 |
+
# Jeeps AT the base — must drive to scout. Distance from
|
| 305 |
+
# (24, 20) to outpost (20, 2) is √(16+324)≈18, far out of
|
| 306 |
+
# jeep vision (~7).
|
| 307 |
+
- {type: jeep, owner: agent, position: [24, 20], stance: 1}
|
| 308 |
+
- {type: jeep, owner: agent, position: [26, 20], stance: 1}
|
| 309 |
+
- {type: e1, owner: agent, position: [22, 24], stance: 2}
|
| 310 |
+
- {type: e1, owner: agent, position: [24, 24], stance: 2}
|
| 311 |
+
- {type: e1, owner: agent, position: [22, 26], stance: 2}
|
| 312 |
+
- {type: e1, owner: agent, position: [24, 26], stance: 2}
|
| 313 |
+
# Forward delayer pair NORTH of the base — stationed ahead of
|
| 314 |
+
# the pbox line so they soak the first wave while the pbox
|
| 315 |
+
# triple comes online (the rush from y=4 reaches y=14 before
|
| 316 |
+
# the 1st pbox is up; without absorbers, the wave punches
|
| 317 |
+
# through and razes the fact before clause 3 can latch).
|
| 318 |
+
- {type: e1, owner: agent, position: [20, 10], stance: 2}
|
| 319 |
+
- {type: e1, owner: agent, position: [22, 10], stance: 2}
|
| 320 |
+
# Enemy outpost (NORTH) at the playable edge (y=2) — well
|
| 321 |
+
# outside pbox vision (pbox at y=14 is 12 cells away). Scout
|
| 322 |
+
# MUST drive a jeep north to enter outpost sight range.
|
| 323 |
+
# Placing pbox WITHOUT scouting cannot retroactively satisfy
|
| 324 |
+
# clause 1 of the then-chain.
|
| 325 |
+
- {type: tent, owner: enemy, position: [20, 2]}
|
| 326 |
+
# NORTH rush — 3 e1 (matched to easy because the medium +1
|
| 327 |
+
# controlled variable is the SCOUT requirement (outpost no
|
| 328 |
+
# longer visible from starting jeep position), NOT raw
|
| 329 |
+
# defensive load — a heavier push would force the medium to
|
| 330 |
+
# discriminate on a SECOND axis (absorption) and break the
|
| 331 |
+
# clean tier attribution).
|
| 332 |
+
- {type: e1, owner: enemy, position: [20, 4], stance: 3, count: 3}
|
| 333 |
+
- {type: fact, owner: enemy, position: [120, 20]}
|
| 334 |
+
win_condition:
|
| 335 |
+
all_of:
|
| 336 |
+
- then:
|
| 337 |
+
id: scout-fortify-engage-medium
|
| 338 |
+
clauses:
|
| 339 |
+
- {buildings_discovered_gte: 1}
|
| 340 |
+
- {building_count_gte: {type: pbox, n: 3}}
|
| 341 |
+
- {units_killed_gte: 3}
|
| 342 |
+
- building_count_gte: {type: fact, n: 1}
|
| 343 |
+
- own_units_gte: 1
|
| 344 |
+
- within_ticks: 5400
|
| 345 |
+
fail_condition:
|
| 346 |
+
any_of:
|
| 347 |
+
- after_ticks: 5401
|
| 348 |
+
- not: {building_count_gte: {type: fact, n: 1}}
|
| 349 |
+
- not: {own_units_gte: 1}
|
| 350 |
+
max_turns: 66
|
| 351 |
+
|
| 352 |
+
# ── HARD ─────────────────────────────────────────────────────────
|
| 353 |
+
# +1 controlled variable: TWO agent spawn_point groups round-robin
|
| 354 |
+
# the AGENT BASE LATITUDE per seed (NORTH-staged base centred at
|
| 355 |
+
# (20, 16) vs SOUTH-staged base centred at (20, 24)). Enemy actors
|
| 356 |
+
# don't honour spawn_point (CLAUDE.md) so BOTH a north outpost
|
| 357 |
+
# (20, 4) and a south outpost (20, 36) always place; the MATCHING-
|
| 358 |
+
# side band is the immediate threat on each spawn (the rusher bot
|
| 359 |
+
# charges the agent centroid, which sits near each base's
|
| 360 |
+
# latitude, so the closer band has the shorter walk). The capability
|
| 361 |
+
# test is the same scout-then-fortify chain — but the agent must
|
| 362 |
+
# FIRST identify its OWN base latitude from observation (the
|
| 363 |
+
# threat is on the SIDE FACING the base's own latitude) and THEN
|
| 364 |
+
# commit defenses on that side. A memorised "always defend NORTH"
|
| 365 |
+
# opening cannot generalise: on the SOUTH spawn the north scout
|
| 366 |
+
# never reaches the south outpost (clause 1 never latches), the
|
| 367 |
+
# south-band rushers walk straight past the wrong-axis pbox line,
|
| 368 |
+
# and the south-spawn fact dies → double LOSS path.
|
| 369 |
+
# The rush is 4 e1 + 1 e3 per band (the e3 is the discriminator —
|
| 370 |
+
# rocket splash + AT damage means the base defenders alone cannot
|
| 371 |
+
# hold the matching lane, so the on-axis pbox triple is load-
|
| 372 |
+
# bearing; the agent base is staged farther from each rush band
|
| 373 |
+
# (14 cells instead of 10) to give the intended play time to land
|
| 374 |
+
# the 3 pbox before the rush arrives).
|
| 375 |
+
# max_turns 66 → reachable tick 5943 ≥ 5401.
|
| 376 |
+
hard:
|
| 377 |
+
description: >
|
| 378 |
+
A rush is incoming, but the threat axis is NOT given AND your
|
| 379 |
+
base could be in the NORTH half (fact at y=16) OR the SOUTH
|
| 380 |
+
half (fact at y=24) — chosen by seed. Identify your base's
|
| 381 |
+
latitude from observation; scout the lane FACING your base
|
| 382 |
+
(if your base is north of centre, the threat approaches from
|
| 383 |
+
the north edge; if south of centre, from the south edge), then
|
| 384 |
+
build at least THREE pillboxes (pbox) on that lane (between
|
| 385 |
+
your base and the threat — roughly y≈10 if your base is at
|
| 386 |
+
y=16, y≈30 if your base is at y=24), THEN destroy ≥3 of the
|
| 387 |
+
incoming rifle infantry. Keep the construction yard standing
|
| 388 |
+
and ≥1 of your own units alive. Win before tick 5400. A
|
| 389 |
+
memorised "always defend NORTH" opening loses on the SOUTH-
|
| 390 |
+
base seeds (rushers walk past your wrong-axis pbox line and
|
| 391 |
+
raze the fact); a no-scout commit fails the chain at clause 1.
|
| 392 |
+
overrides:
|
| 393 |
+
actors:
|
| 394 |
+
# ── NORTH spawn (spawn_point 0) — base centred at (20, 16).
|
| 395 |
+
# Threat: from the north band at y=2 (north outpost at y=4).
|
| 396 |
+
# Base-to-rush distance = 14 cells, gives the intended scout-
|
| 397 |
+
# then-fortify play time to land 3 pbox at y≈10 (between base
|
| 398 |
+
# at y=16 and band at y=2) before the rush arrives. Correct
|
| 399 |
+
# defense lane: pbox at y≈10. The defender ring + the 4
|
| 400 |
+
# starter e1 sit SOUTH of fact (y=18..20, away from the lane
|
| 401 |
+
# mouth) so they survive the first wave and satisfy
|
| 402 |
+
# own_units_gte:1.
|
| 403 |
+
- {type: fact, owner: agent, position: [20, 16], spawn_point: 0}
|
| 404 |
+
- {type: proc, owner: agent, position: [16, 16], spawn_point: 0}
|
| 405 |
+
- {type: powr, owner: agent, position: [20, 20], spawn_point: 0}
|
| 406 |
+
- {type: tent, owner: agent, position: [16, 20], spawn_point: 0}
|
| 407 |
+
- {type: jeep, owner: agent, position: [24, 16], stance: 1, spawn_point: 0}
|
| 408 |
+
- {type: jeep, owner: agent, position: [26, 16], stance: 1, spawn_point: 0}
|
| 409 |
+
- {type: e1, owner: agent, position: [22, 20], stance: 2, spawn_point: 0}
|
| 410 |
+
- {type: e1, owner: agent, position: [24, 20], stance: 2, spawn_point: 0}
|
| 411 |
+
- {type: e1, owner: agent, position: [22, 18], stance: 2, spawn_point: 0}
|
| 412 |
+
- {type: e1, owner: agent, position: [24, 18], stance: 2, spawn_point: 0}
|
| 413 |
+
# ── SOUTH spawn (spawn_point 1) — base centred at (20, 24).
|
| 414 |
+
# Mirror of the NORTH spawn (fact 8 cells south of map mid),
|
| 415 |
+
# threat from the south band at y=38 (south outpost at y=36).
|
| 416 |
+
# Correct defense lane: pbox at y≈30.
|
| 417 |
+
- {type: fact, owner: agent, position: [20, 24], spawn_point: 1}
|
| 418 |
+
- {type: proc, owner: agent, position: [16, 24], spawn_point: 1}
|
| 419 |
+
- {type: powr, owner: agent, position: [20, 20], spawn_point: 1}
|
| 420 |
+
- {type: tent, owner: agent, position: [16, 20], spawn_point: 1}
|
| 421 |
+
- {type: jeep, owner: agent, position: [24, 24], stance: 1, spawn_point: 1}
|
| 422 |
+
- {type: jeep, owner: agent, position: [26, 24], stance: 1, spawn_point: 1}
|
| 423 |
+
- {type: e1, owner: agent, position: [22, 20], stance: 2, spawn_point: 1}
|
| 424 |
+
- {type: e1, owner: agent, position: [24, 20], stance: 2, spawn_point: 1}
|
| 425 |
+
- {type: e1, owner: agent, position: [22, 22], stance: 2, spawn_point: 1}
|
| 426 |
+
- {type: e1, owner: agent, position: [24, 22], stance: 2, spawn_point: 1}
|
| 427 |
+
# Enemy outposts and rush bands — enemy actors don't honour
|
| 428 |
+
# spawn_point (CLAUDE.md), so BOTH north and south bands
|
| 429 |
+
# always place. Each spawn faces the MATCHING side as the
|
| 430 |
+
# near (immediate) threat — the opposite-side rush has the
|
| 431 |
+
# full map width to walk and is well outside the 5400-tick
|
| 432 |
+
# budget for arrival.
|
| 433 |
+
- {type: tent, owner: enemy, position: [20, 4]}
|
| 434 |
+
- {type: e1, owner: enemy, position: [20, 2], stance: 3, count: 4}
|
| 435 |
+
- {type: e3, owner: enemy, position: [22, 2], stance: 3, count: 1}
|
| 436 |
+
- {type: tent, owner: enemy, position: [20, 36]}
|
| 437 |
+
- {type: e1, owner: enemy, position: [20, 38], stance: 3, count: 4}
|
| 438 |
+
- {type: e3, owner: enemy, position: [22, 38], stance: 3, count: 1}
|
| 439 |
+
# Inert enemy `fact` marker far east — anti-DRAW.
|
| 440 |
+
- {type: fact, owner: enemy, position: [120, 20]}
|
| 441 |
+
win_condition:
|
| 442 |
+
all_of:
|
| 443 |
+
- then:
|
| 444 |
+
id: scout-fortify-engage-hard
|
| 445 |
+
clauses:
|
| 446 |
+
- {buildings_discovered_gte: 1}
|
| 447 |
+
- {building_count_gte: {type: pbox, n: 3}}
|
| 448 |
+
- {units_killed_gte: 3}
|
| 449 |
+
- building_count_gte: {type: fact, n: 1}
|
| 450 |
+
- own_units_gte: 1
|
| 451 |
+
- within_ticks: 5400
|
| 452 |
+
fail_condition:
|
| 453 |
+
any_of:
|
| 454 |
+
- after_ticks: 5401
|
| 455 |
+
- not: {building_count_gte: {type: fact, n: 1}}
|
| 456 |
+
- not: {own_units_gte: 1}
|
| 457 |
+
max_turns: 66
|
tests/test_def_position_revealed_direction.py
ADDED
|
@@ -0,0 +1,475 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""def-position-revealed-direction scenario pack — full no-cheat
|
| 2 |
+
validation on Rust.
|
| 3 |
+
|
| 4 |
+
REASONING capability — INTEL-DRIVEN ADAPTIVE DEFENSE. The threat axis
|
| 5 |
+
is HIDDEN from the brief (sibling `def-position-expected-direction`
|
| 6 |
+
DECLARES the axis; this pack does not). The agent must FIRST drive its
|
| 7 |
+
scout jeeps to register the enemy's forward outpost on the actual
|
| 8 |
+
approach lane, THEN commit a 3-pbox triple between its base and that
|
| 9 |
+
outpost, THEN engage and break the incoming rush. The win predicate is
|
| 10 |
+
the Wave-2 `then:` happened-before composite:
|
| 11 |
+
|
| 12 |
+
then:
|
| 13 |
+
- {buildings_discovered_gte: 1} # SCOUT first (axis intel)
|
| 14 |
+
- {building_count_gte: {type: pbox, n: 3}} # FORTIFY second
|
| 15 |
+
- {units_killed_gte: 3} # ENGAGE third
|
| 16 |
+
|
| 17 |
+
paired with `building_count_gte:{fact, 1}` (the home fact still stands)
|
| 18 |
+
and `within_ticks: 5400` (a reachable deadline given max_turns=66, which
|
| 19 |
+
caps ticks at 93+90·65=5943). Fail is `after_ticks: 5401` OR
|
| 20 |
+
`not building_count_gte:{fact,1}` OR `not own_units_gte:1` — a non-win
|
| 21 |
+
is always a real reachable LOSS, never a draw.
|
| 22 |
+
|
| 23 |
+
`building_count_gte:{fact,1}` is used in place of the more common
|
| 24 |
+
`has_building: fact` because the latter checks
|
| 25 |
+
`own_building_types` which ACCUMULATES across the episode (once
|
| 26 |
+
the agent ever owned a fact the set still contains "fact" after
|
| 27 |
+
the fact is razed). The `building_count_gte` predicate reads the
|
| 28 |
+
live `own_buildings` list each frame and correctly bites when
|
| 29 |
+
the fact is destroyed — without this swap the stall policy on
|
| 30 |
+
the hard NORTH spawn ended in DRAW (engine auto-`done` on player
|
| 31 |
+
elimination before the deadline) rather than LOSS.
|
| 32 |
+
|
| 33 |
+
These tests prove deterministically (no model / no network) that:
|
| 34 |
+
|
| 35 |
+
* the intended scout-then-fortify-then-engage policy WINS every level
|
| 36 |
+
and every hard seed (1..4) — including BOTH the NORTH-base and the
|
| 37 |
+
SOUTH-base spawn groups on hard;
|
| 38 |
+
* stall (observe-only) LOSES every level and every seed (real LOSS,
|
| 39 |
+
never draw);
|
| 40 |
+
* no-scout-pre-fortify-NORTH (build 3 pbox at y=14 immediately and
|
| 41 |
+
never move the jeeps) WINS only on EASY (where the jeeps start at
|
| 42 |
+
y=8 already in sight of the outpost) and LOSES on every MEDIUM and
|
| 43 |
+
HARD seed (clause 1 of the then-chain never latches);
|
| 44 |
+
* scout-then-fortify-WRONG-AXIS (memorised "always defend NORTH") LOSES
|
| 45 |
+
on every hard SOUTH-spawn seed — the south rush walks past the
|
| 46 |
+
wrong-axis pbox line and razes the SOUTH-spawn fact, AND the
|
| 47 |
+
buildings-discovered clause cannot latch on the SOUTH-spawn jeep
|
| 48 |
+
because the north outpost is out of sight;
|
| 49 |
+
* the hard tier defines ≥2 spawn_point groups (NORTH base y=16 /
|
| 50 |
+
SOUTH base y=24) so the base latitude rotates by seed (no single-
|
| 51 |
+
cell memorised opening can generalise);
|
| 52 |
+
* every level's `after_ticks` is reachable inside `max_turns` so a
|
| 53 |
+
non-finisher is a real timeout LOSS, never a draw.
|
| 54 |
+
"""
|
| 55 |
+
|
| 56 |
+
from __future__ import annotations
|
| 57 |
+
|
| 58 |
+
import pytest
|
| 59 |
+
|
| 60 |
+
pytest.importorskip("openra_train", reason="Rust env wheel not installed")
|
| 61 |
+
pytest.importorskip("openra_rl_training", reason="Rust env wheel not installed")
|
| 62 |
+
|
| 63 |
+
from openra_bench.eval_core import run_level
|
| 64 |
+
from openra_bench.scenarios import load_pack
|
| 65 |
+
from openra_bench.scenarios.loader import PACKS_DIR, compile_level
|
| 66 |
+
|
| 67 |
+
PACK = PACKS_DIR / "def-position-revealed-direction.yaml"
|
| 68 |
+
LEVELS = ("easy", "medium", "hard")
|
| 69 |
+
SEEDS = (1, 2, 3, 4)
|
| 70 |
+
|
| 71 |
+
|
| 72 |
+
# ── scripted policies ────────────────────────────────────────────────
|
| 73 |
+
|
| 74 |
+
|
| 75 |
+
def stall(rs, C):
|
| 76 |
+
"""Observe-only. Never scouts, never builds, never engages.
|
| 77 |
+
Reachable timeout LOSS (or fact-loss LOSS, which is the same
|
| 78 |
+
fail-clause family)."""
|
| 79 |
+
return [C.observe()]
|
| 80 |
+
|
| 81 |
+
|
| 82 |
+
def make_intended():
|
| 83 |
+
"""The intended capability play: identify own base latitude
|
| 84 |
+
(NORTH-of-mid → threat-from-NORTH; SOUTH-of-mid → threat-from-
|
| 85 |
+
SOUTH; mid → easy preset NORTH lane), DRIVE the jeeps to the
|
| 86 |
+
matching outpost band, BUILD 3 pbox between base and that band,
|
| 87 |
+
and let the on-axis pbox + base defenders rack up ≥3 kills on
|
| 88 |
+
the incoming rush."""
|
| 89 |
+
|
| 90 |
+
state = {"scout_dispatched": False, "queued": 0}
|
| 91 |
+
|
| 92 |
+
def policy(rs, C):
|
| 93 |
+
units = rs.get("units_summary") or []
|
| 94 |
+
own_b = rs.get("own_buildings") or []
|
| 95 |
+
fact = next(
|
| 96 |
+
(b for b in own_b if b.get("type") == "fact"), None,
|
| 97 |
+
)
|
| 98 |
+
if fact is None:
|
| 99 |
+
return [C.observe()]
|
| 100 |
+
fx, fy = int(fact["cell_x"]), int(fact["cell_y"])
|
| 101 |
+
# Pick the threat side from base latitude. Hard: y=16 ⇒
|
| 102 |
+
# north lane (scout to y=8, pbox at y=10); y=24 ⇒ south
|
| 103 |
+
# lane (scout to y=32, pbox at y=30). Easy/medium: y=20 ⇒
|
| 104 |
+
# north preset.
|
| 105 |
+
if fy < 20:
|
| 106 |
+
scout_y = max(2, fy - 8)
|
| 107 |
+
pbox_y = max(4, fy - 6)
|
| 108 |
+
elif fy > 20:
|
| 109 |
+
scout_y = min(38, fy + 8)
|
| 110 |
+
pbox_y = min(34, fy + 6)
|
| 111 |
+
else:
|
| 112 |
+
scout_y = 8
|
| 113 |
+
pbox_y = 14
|
| 114 |
+
|
| 115 |
+
types = [b.get("type") for b in own_b]
|
| 116 |
+
pbox_count = sum(1 for t in types if t == "pbox")
|
| 117 |
+
|
| 118 |
+
cmds = []
|
| 119 |
+
jeeps = [u for u in units if u.get("type") == "jeep"]
|
| 120 |
+
if jeeps and not state["scout_dispatched"]:
|
| 121 |
+
cmds.append(
|
| 122 |
+
C.move_units([j["id"] for j in jeeps], fx, scout_y),
|
| 123 |
+
)
|
| 124 |
+
state["scout_dispatched"] = True
|
| 125 |
+
# Queue the 3 pbox up front; one StartProduction per turn
|
| 126 |
+
# (the Defense queue is one-at-a-time, so queueing more
|
| 127 |
+
# than 3 wastes only the StartProduction order).
|
| 128 |
+
if state["queued"] < 3:
|
| 129 |
+
cmds.append(C.build("pbox"))
|
| 130 |
+
state["queued"] += 1
|
| 131 |
+
# Always emit the NEXT slot's place_building — the engine
|
| 132 |
+
# buffers blocked PLACEs until production finishes, then
|
| 133 |
+
# places at the requested cell.
|
| 134 |
+
if pbox_count < 3:
|
| 135 |
+
dx = -2 + 2 * pbox_count
|
| 136 |
+
cmds.append(C.place_building("pbox", fx + dx, pbox_y))
|
| 137 |
+
if not cmds:
|
| 138 |
+
cmds.append(C.observe())
|
| 139 |
+
return cmds
|
| 140 |
+
|
| 141 |
+
return policy
|
| 142 |
+
|
| 143 |
+
|
| 144 |
+
def make_no_scout_north_pbox():
|
| 145 |
+
"""Pre-fortify NORTH-lane WITHOUT ever moving the jeeps. On EASY
|
| 146 |
+
this WINS (the jeeps are pre-staged at y=8, passively in sight of
|
| 147 |
+
the outpost at y=2 — clause 1 latches at turn 1 even without a
|
| 148 |
+
move command). On MEDIUM and HARD it LOSES every seed — the
|
| 149 |
+
jeeps sit at the base, outside outpost vision, so the then-chain
|
| 150 |
+
NEVER reaches clause 1, and the deadline expires."""
|
| 151 |
+
|
| 152 |
+
state = {"queued": 0}
|
| 153 |
+
|
| 154 |
+
def policy(rs, C):
|
| 155 |
+
own_b = rs.get("own_buildings") or []
|
| 156 |
+
fact = next(
|
| 157 |
+
(b for b in own_b if b.get("type") == "fact"), None,
|
| 158 |
+
)
|
| 159 |
+
if fact is None:
|
| 160 |
+
return [C.observe()]
|
| 161 |
+
fx = int(fact["cell_x"])
|
| 162 |
+
# Memorised: always build pbox at y=14 (the easy/medium
|
| 163 |
+
# canonical NORTH lane). NEVER moves the jeeps.
|
| 164 |
+
types = [b.get("type") for b in own_b]
|
| 165 |
+
pbox_count = sum(1 for t in types if t == "pbox")
|
| 166 |
+
cmds = []
|
| 167 |
+
if state["queued"] < 3:
|
| 168 |
+
cmds.append(C.build("pbox"))
|
| 169 |
+
state["queued"] += 1
|
| 170 |
+
if pbox_count < 3:
|
| 171 |
+
dx = -2 + 2 * pbox_count
|
| 172 |
+
cmds.append(C.place_building("pbox", fx + dx, 14))
|
| 173 |
+
if not cmds:
|
| 174 |
+
cmds.append(C.observe())
|
| 175 |
+
return cmds
|
| 176 |
+
|
| 177 |
+
return policy
|
| 178 |
+
|
| 179 |
+
|
| 180 |
+
def make_scout_then_fortify_always_north():
|
| 181 |
+
"""Drives the scout NORTH and builds pbox on the NORTH lane —
|
| 182 |
+
the 'memorised: always defend NORTH' policy. On HARD SOUTH-spawn
|
| 183 |
+
seeds the north scout never reaches the south outpost (the
|
| 184 |
+
matching threat), so clause 1 stays unlatched AND the south rush
|
| 185 |
+
razes the SOUTH-spawn fact (the y=18 pbox line on the SOUTH
|
| 186 |
+
spawn is on the wrong axis). LOSS on every hard SOUTH-spawn
|
| 187 |
+
seed; WIN on every hard NORTH-spawn seed (where it accidentally
|
| 188 |
+
happens to be the correct lane)."""
|
| 189 |
+
|
| 190 |
+
state = {"scout_dispatched": False, "queued": 0}
|
| 191 |
+
|
| 192 |
+
def policy(rs, C):
|
| 193 |
+
units = rs.get("units_summary") or []
|
| 194 |
+
own_b = rs.get("own_buildings") or []
|
| 195 |
+
fact = next(
|
| 196 |
+
(b for b in own_b if b.get("type") == "fact"), None,
|
| 197 |
+
)
|
| 198 |
+
if fact is None:
|
| 199 |
+
return [C.observe()]
|
| 200 |
+
fx, fy = int(fact["cell_x"]), int(fact["cell_y"])
|
| 201 |
+
# ALWAYS scout NORTH (whatever the base latitude); ALWAYS
|
| 202 |
+
# build pbox on the NORTH side of base.
|
| 203 |
+
scout_y = max(2, fy - 8)
|
| 204 |
+
pbox_y = max(4, fy - 6)
|
| 205 |
+
|
| 206 |
+
types = [b.get("type") for b in own_b]
|
| 207 |
+
pbox_count = sum(1 for t in types if t == "pbox")
|
| 208 |
+
cmds = []
|
| 209 |
+
jeeps = [u for u in units if u.get("type") == "jeep"]
|
| 210 |
+
if jeeps and not state["scout_dispatched"]:
|
| 211 |
+
cmds.append(
|
| 212 |
+
C.move_units([j["id"] for j in jeeps], fx, scout_y),
|
| 213 |
+
)
|
| 214 |
+
state["scout_dispatched"] = True
|
| 215 |
+
if state["queued"] < 3:
|
| 216 |
+
cmds.append(C.build("pbox"))
|
| 217 |
+
state["queued"] += 1
|
| 218 |
+
if pbox_count < 3:
|
| 219 |
+
dx = -2 + 2 * pbox_count
|
| 220 |
+
cmds.append(C.place_building("pbox", fx + dx, pbox_y))
|
| 221 |
+
if not cmds:
|
| 222 |
+
cmds.append(C.observe())
|
| 223 |
+
return cmds
|
| 224 |
+
|
| 225 |
+
return policy
|
| 226 |
+
|
| 227 |
+
|
| 228 |
+
# ── scenario-shape invariants ────────────────────────────────────────
|
| 229 |
+
|
| 230 |
+
|
| 231 |
+
def test_pack_compiles_with_three_levels_and_rusher_bot():
|
| 232 |
+
pack = load_pack(PACK)
|
| 233 |
+
assert pack.meta.id == "def-position-revealed-direction"
|
| 234 |
+
assert pack.meta.capability == "reasoning"
|
| 235 |
+
assert set(pack.levels) == {"easy", "medium", "hard"}
|
| 236 |
+
# Required-by-spec benchmark anchors (per the seed taxonomy).
|
| 237 |
+
anchors = pack.meta.benchmark_anchor or []
|
| 238 |
+
assert any("PlanBench" in a for a in anchors), anchors
|
| 239 |
+
assert any("military" in a.lower() for a in anchors), anchors
|
| 240 |
+
assert any("intel" in a.lower() for a in anchors), anchors
|
| 241 |
+
# Rusher bot wired through to the engine for every level.
|
| 242 |
+
for lvl in LEVELS:
|
| 243 |
+
c = compile_level(pack, lvl)
|
| 244 |
+
assert c.map_supported
|
| 245 |
+
enemy = c.scenario.enemy
|
| 246 |
+
bot = (
|
| 247 |
+
getattr(enemy, "bot_type", None)
|
| 248 |
+
or getattr(enemy, "bot", None)
|
| 249 |
+
)
|
| 250 |
+
assert str(bot).lower() == "rusher", (lvl, bot)
|
| 251 |
+
|
| 252 |
+
|
| 253 |
+
@pytest.mark.parametrize("level", LEVELS)
|
| 254 |
+
def test_every_level_has_a_reachable_timeout_fail(level):
|
| 255 |
+
"""Non-win must be a real LOSS: the `after_ticks` fail clause must
|
| 256 |
+
be strictly below the tick reachable at max_turns (≤90 ticks per
|
| 257 |
+
step in interrupt mode)."""
|
| 258 |
+
c = compile_level(load_pack(PACK), level)
|
| 259 |
+
assert c.fail_condition is not None
|
| 260 |
+
fc = c.fail_condition.model_dump(exclude_none=True)
|
| 261 |
+
deadline = None
|
| 262 |
+
for clause in fc.get("any_of", []) or []:
|
| 263 |
+
if "after_ticks" in clause:
|
| 264 |
+
deadline = int(clause["after_ticks"])
|
| 265 |
+
assert deadline is not None, f"{level}: no after_ticks fail clause"
|
| 266 |
+
reachable = 93 + 90 * (c.max_turns - 1)
|
| 267 |
+
assert deadline < reachable, (
|
| 268 |
+
f"{level}: deadline {deadline} unreachable within "
|
| 269 |
+
f"{c.max_turns} turns (max tick {reachable}) → draw degeneracy"
|
| 270 |
+
)
|
| 271 |
+
|
| 272 |
+
|
| 273 |
+
@pytest.mark.parametrize("level", LEVELS)
|
| 274 |
+
def test_every_level_uses_then_chain_with_scout_fortify_engage(level):
|
| 275 |
+
"""The 3-phase scout→fortify→engage chain MUST be wired through
|
| 276 |
+
as a Wave-2 `then:` happened-before composite (the whole point of
|
| 277 |
+
the pack — ordered intel-driven defense, not a degenerate
|
| 278 |
+
permutation-tolerant `all_of`)."""
|
| 279 |
+
c = compile_level(load_pack(PACK), level)
|
| 280 |
+
win = c.win_condition.model_dump(exclude_none=True)
|
| 281 |
+
ao = win.get("all_of") or []
|
| 282 |
+
then_branches = [b for b in ao if "then" in b]
|
| 283 |
+
assert len(then_branches) == 1, (
|
| 284 |
+
f"{level}: expected exactly one then-chain in win; got {win}"
|
| 285 |
+
)
|
| 286 |
+
clauses = then_branches[0]["then"]["clauses"]
|
| 287 |
+
assert len(clauses) == 3, (
|
| 288 |
+
f"{level}: then-chain must be 3 clauses (scout, fortify, "
|
| 289 |
+
f"engage); got {len(clauses)}: {clauses}"
|
| 290 |
+
)
|
| 291 |
+
# Clause 1: scout latch — ≥1 enemy building discovered (the
|
| 292 |
+
# forward outpost the agent's jeep MUST reach by movement on
|
| 293 |
+
# medium/hard; passively visible on easy).
|
| 294 |
+
assert clauses[0].get("buildings_discovered_gte") == 1, clauses[0]
|
| 295 |
+
# Clause 2: fortify — exactly 3 pbox (not a token pbox).
|
| 296 |
+
bcg = clauses[1].get("building_count_gte") or {}
|
| 297 |
+
assert bcg.get("type") == "pbox" and int(bcg.get("n", 0)) == 3, (
|
| 298 |
+
clauses[1]
|
| 299 |
+
)
|
| 300 |
+
# Clause 3: engage — at least 3 kills.
|
| 301 |
+
assert clauses[2].get("units_killed_gte") == 3, clauses[2]
|
| 302 |
+
|
| 303 |
+
|
| 304 |
+
@pytest.mark.parametrize("level", LEVELS)
|
| 305 |
+
def test_win_predicate_uses_building_count_gte_for_fact_persistence(
|
| 306 |
+
level,
|
| 307 |
+
):
|
| 308 |
+
"""The fact-persistence clause MUST be `building_count_gte:
|
| 309 |
+
{type:fact, n:1}` (live count) and NOT `has_building:fact`
|
| 310 |
+
(accumulating set). See module docstring for the footgun."""
|
| 311 |
+
c = compile_level(load_pack(PACK), level)
|
| 312 |
+
win = c.win_condition.model_dump(exclude_none=True)
|
| 313 |
+
flat = str(win)
|
| 314 |
+
assert "has_building" not in flat, (
|
| 315 |
+
f"{level}: win must avoid the accumulating `has_building` "
|
| 316 |
+
f"footgun; use `building_count_gte:{{type:fact,n:1}}` "
|
| 317 |
+
f"instead. win={win}"
|
| 318 |
+
)
|
| 319 |
+
# And the live count clause IS present:
|
| 320 |
+
ao = win.get("all_of") or []
|
| 321 |
+
found = False
|
| 322 |
+
for clause in ao:
|
| 323 |
+
bcg = (
|
| 324 |
+
clause.get("building_count_gte")
|
| 325 |
+
if isinstance(clause, dict) else None
|
| 326 |
+
)
|
| 327 |
+
if isinstance(bcg, dict) and bcg.get("type") == "fact":
|
| 328 |
+
assert int(bcg.get("n", 0)) >= 1
|
| 329 |
+
found = True
|
| 330 |
+
assert found, f"{level}: missing fact-alive clause; got {win}"
|
| 331 |
+
|
| 332 |
+
|
| 333 |
+
def test_hard_has_two_spawn_point_groups():
|
| 334 |
+
"""Hard-tier contract: ≥2 distinct seed-driven spawn_point groups
|
| 335 |
+
so the threat axis rotates by seed (anti-memorisation). Both
|
| 336 |
+
bands of enemy outposts always place (CLAUDE.md: enemy actors
|
| 337 |
+
don't honour spawn_point) so the SAME scout-then-fortify
|
| 338 |
+
discipline is tested from a flipped base latitude per seed."""
|
| 339 |
+
c = compile_level(load_pack(PACK), "hard")
|
| 340 |
+
groups = {
|
| 341 |
+
a.spawn_point for a in c.scenario.actors
|
| 342 |
+
if a.owner == "agent" and a.spawn_point is not None
|
| 343 |
+
}
|
| 344 |
+
assert groups == {0, 1}, groups
|
| 345 |
+
# In-bounds check (rush-hour-arena playable y ≈ 2..38).
|
| 346 |
+
for a in c.scenario.actors:
|
| 347 |
+
x, y = a.position
|
| 348 |
+
assert 2 <= x <= 126 and 2 <= y <= 38, (a.type, a.position)
|
| 349 |
+
|
| 350 |
+
|
| 351 |
+
def test_tools_list_matches_spec():
|
| 352 |
+
pack = load_pack(PACK)
|
| 353 |
+
expected = {
|
| 354 |
+
"observe", "build", "place_building", "move_units",
|
| 355 |
+
"attack_unit", "attack_move", "stop",
|
| 356 |
+
}
|
| 357 |
+
tools = (
|
| 358 |
+
pack.base.get("tools")
|
| 359 |
+
if isinstance(pack.base, dict)
|
| 360 |
+
else pack.base.tools
|
| 361 |
+
)
|
| 362 |
+
assert set(tools) == expected, tools
|
| 363 |
+
|
| 364 |
+
|
| 365 |
+
# ── solvency: intended WINS every level + every hard seed ────────────
|
| 366 |
+
|
| 367 |
+
|
| 368 |
+
@pytest.mark.parametrize("seed", SEEDS)
|
| 369 |
+
@pytest.mark.parametrize("level", LEVELS)
|
| 370 |
+
def test_intended_scout_fortify_engage_wins_every_level_and_seed(
|
| 371 |
+
level, seed,
|
| 372 |
+
):
|
| 373 |
+
"""The intended capability play (identify base latitude → scout
|
| 374 |
+
matching outpost → fortify matching lane → engage) MUST WIN on
|
| 375 |
+
every (level, seed). This is the load-bearing solvency test."""
|
| 376 |
+
c = compile_level(load_pack(PACK), level)
|
| 377 |
+
r = run_level(c, make_intended(), seed=seed)
|
| 378 |
+
assert r.outcome == "win", (
|
| 379 |
+
f"{level} seed{seed}: intended scout-fortify-engage must WIN; "
|
| 380 |
+
f"got {r.outcome} (tick={r.signals.game_tick}, "
|
| 381 |
+
f"kills={r.signals.units_killed}, "
|
| 382 |
+
f"then_progress={getattr(r.signals, 'then_progress', {})}, "
|
| 383 |
+
f"buildings_seen="
|
| 384 |
+
f"{len(r.signals.enemy_buildings_seen_ids)})"
|
| 385 |
+
)
|
| 386 |
+
|
| 387 |
+
|
| 388 |
+
# ── no-cheat: every wrong / lazy policy LOSES (real LOSS, not draw) ──
|
| 389 |
+
|
| 390 |
+
|
| 391 |
+
@pytest.mark.parametrize("seed", SEEDS)
|
| 392 |
+
@pytest.mark.parametrize("level", LEVELS)
|
| 393 |
+
def test_stall_loses_every_level_and_seed(level, seed):
|
| 394 |
+
"""Stall (observe-only) MUST LOSE on every (level, seed). The
|
| 395 |
+
fail clause family (after_ticks: 5401 OR
|
| 396 |
+
not building_count_gte:{fact,1} OR not own_units_gte:1) bites
|
| 397 |
+
deterministically — never a draw."""
|
| 398 |
+
c = compile_level(load_pack(PACK), level)
|
| 399 |
+
r = run_level(c, stall, seed=seed)
|
| 400 |
+
assert r.outcome == "loss", (
|
| 401 |
+
f"{level} seed{seed}: stall must LOSE (real fail, not a "
|
| 402 |
+
f"draw); got {r.outcome} (tick={r.signals.game_tick})"
|
| 403 |
+
)
|
| 404 |
+
|
| 405 |
+
|
| 406 |
+
@pytest.mark.parametrize("seed", SEEDS)
|
| 407 |
+
def test_no_scout_pre_fortify_loses_on_medium(seed):
|
| 408 |
+
"""Build 3 pbox at the canonical NORTH lane without ever moving
|
| 409 |
+
the jeeps. The jeeps sit at the base, outside outpost vision —
|
| 410 |
+
so clause 1 (`buildings_discovered_gte:1`) NEVER latches and the
|
| 411 |
+
deadline expires. (On EASY the jeeps START forward at y=8 and
|
| 412 |
+
passively see the outpost from turn 1, so this play wins easy —
|
| 413 |
+
that's by design, easy explicitly teaches the chain without
|
| 414 |
+
requiring scout movement.)"""
|
| 415 |
+
c = compile_level(load_pack(PACK), "medium")
|
| 416 |
+
r = run_level(c, make_no_scout_north_pbox(), seed=seed)
|
| 417 |
+
assert r.outcome == "loss", (
|
| 418 |
+
f"medium s={seed}: no-scout pre-fortify must LOSE; got "
|
| 419 |
+
f"{r.outcome} (tick={r.signals.game_tick}, "
|
| 420 |
+
f"then_progress={getattr(r.signals, 'then_progress', {})})"
|
| 421 |
+
)
|
| 422 |
+
|
| 423 |
+
|
| 424 |
+
@pytest.mark.parametrize("seed", SEEDS)
|
| 425 |
+
def test_no_scout_pre_fortify_loses_on_hard(seed):
|
| 426 |
+
"""Same no-scout pre-fortify play on hard. Loses for two
|
| 427 |
+
independent reasons depending on spawn: (i) on NORTH spawn the
|
| 428 |
+
jeeps at y=16 can't see the y=4 outpost without moving — clause
|
| 429 |
+
1 never latches; (ii) on SOUTH spawn the pbox at y=14 is on the
|
| 430 |
+
WRONG axis (south rush comes from y=38), the south rush razes
|
| 431 |
+
the south-spawn fact AND clause 1 never latches."""
|
| 432 |
+
c = compile_level(load_pack(PACK), "hard")
|
| 433 |
+
r = run_level(c, make_no_scout_north_pbox(), seed=seed)
|
| 434 |
+
assert r.outcome == "loss", (
|
| 435 |
+
f"hard s={seed}: no-scout pre-fortify must LOSE; got "
|
| 436 |
+
f"{r.outcome} (tick={r.signals.game_tick}, "
|
| 437 |
+
f"then_progress={getattr(r.signals, 'then_progress', {})})"
|
| 438 |
+
)
|
| 439 |
+
|
| 440 |
+
|
| 441 |
+
def test_scout_then_always_north_fortify_loses_on_hard_south_spawn():
|
| 442 |
+
"""The memorised 'always scout/fortify NORTH' policy LOSES on
|
| 443 |
+
every hard SOUTH-spawn seed (s=1, s=3 in the engine's spawn_idx
|
| 444 |
+
round-robin): the north scout doesn't reach the south outpost
|
| 445 |
+
(clause 1 never latches) AND the south-spawn fact at y=24 is
|
| 446 |
+
razed by the y=38 south rush which the NORTH-side pbox line at
|
| 447 |
+
y=18 can't intercept. Demonstrates the spawn-variation
|
| 448 |
+
discrimination — a fixed-axis opening cannot generalise across
|
| 449 |
+
seeds."""
|
| 450 |
+
c = compile_level(load_pack(PACK), "hard")
|
| 451 |
+
# Verified empirically: SOUTH spawn corresponds to seeds 1 & 3
|
| 452 |
+
# in the engine's spawn_idx round-robin; NORTH to 2 & 4.
|
| 453 |
+
south_spawn_seeds = (1, 3)
|
| 454 |
+
for seed in south_spawn_seeds:
|
| 455 |
+
r = run_level(
|
| 456 |
+
c, make_scout_then_fortify_always_north(), seed=seed,
|
| 457 |
+
)
|
| 458 |
+
assert r.outcome == "loss", (
|
| 459 |
+
f"hard SOUTH-spawn s={seed}: scout-and-fortify-always-"
|
| 460 |
+
f"NORTH must LOSE; got {r.outcome} "
|
| 461 |
+
f"(tick={r.signals.game_tick}, "
|
| 462 |
+
f"then_progress={getattr(r.signals, 'then_progress', {})})"
|
| 463 |
+
)
|
| 464 |
+
|
| 465 |
+
|
| 466 |
+
# ── determinism ──────────────────────────────────────────────────────
|
| 467 |
+
|
| 468 |
+
|
| 469 |
+
def test_intended_run_is_deterministic_on_easy():
|
| 470 |
+
c = compile_level(load_pack(PACK), "easy")
|
| 471 |
+
a = run_level(c, make_intended(), seed=3)
|
| 472 |
+
b = run_level(c, make_intended(), seed=3)
|
| 473 |
+
assert (a.outcome, a.turns) == (b.outcome, b.turns), (
|
| 474 |
+
"same seed must be deterministic"
|
| 475 |
+
)
|