Spaces:
Running
feat(scenario): rob-cash-depletion-recovery — recover from sudden cash drain (financial turnaround / SC2 anchor)
Browse filesWave-6 Group J robustness seed. Frames a mid-episode cash-crisis
recovery: agent starts with a normal operating economy (fact + powr +
proc + 1 harv + 2 mines + a small 3tnk defender squad) and an
indivisible 1400 reserve (= exactly one proc rebuild). A scripted
`hunt` 4tnk strike destroys the refinery mid-episode; the agent must
`build('proc') + place_building` and re-issue `harvest` to restart
income before the EV bar 2500 / deadline 6300 bites.
Tiers — one new controlled variable per level:
• easy — LIGHT strike (1× 4tnk + 2 own 3tnk). Defenders intercept
the lone 4tnk; proc SURVIVES. Discriminator: stall = 0
income → bar unmet → LOSS.
• medium — PROC DESTROYED mid-game (2× 4tnk + 3 own 3tnk; proc dies
~tick 453). Agent must rebuild proc with the indivisible
reserve and resume harvest. No-rebuild fails the
`building_count_gte:proc:1` clause permanently.
• hard — PROC + TENT BOTH DESTROYED (4× 4tnk + tent placed in the
attack lane; tent dies tick 183, proc tick 273). Same
1400 reserve = one rebuild; the tent rebuild is OPTIONAL
(not in the win predicate). Tests prioritising the
income-restoring proc rebuild over the defensive-tech
tent rebuild.
Hard registered NOT_APPLICABLE in test_hard_tier.py because enemy
actors don't honour spawn_point (CLAUDE.md oramap.rs footgun) so
foreign-latitude `hunt` 4tnks drift to the active spawn and over-
pressure the tuned defender ring, destroying the fact and collapsing
the recovery test into a fact-defence test. Spawn variation would
compete with the recovery-discipline signal for attribution; the
within-tier discriminator (strike severity → rebuild decision
discipline) stays clean instead.
Real-world anchors: financial cash-crisis recovery / SC2 cash-drain
rebuild / DR after critical-infra loss / bankruptcy turnaround under
deadline.
Tests: 52 scripted no-cheat assertions cover stall / no-rebuild /
build-army / intended-rebuild × 3 levels × 4 seeds + structural and
determinism checks.
|
@@ -0,0 +1,423 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Validate: python -m openra_bench.scenarios.validate packs/rob-cash-depletion-recovery.yaml
|
| 2 |
+
# See ../CONTRIBUTING.md for the full win-condition grammar.
|
| 3 |
+
#
|
| 4 |
+
# ROB-CASH-DEPLETION-RECOVERY (Group J — robustness / mid-episode
|
| 5 |
+
# financial-crisis recovery after a scripted enemy strike destroys the
|
| 6 |
+
# refinery and halts income).
|
| 7 |
+
#
|
| 8 |
+
# Real-world anchor: financial cash-crisis recovery after a sudden
|
| 9 |
+
# emergency outflow / SC2 mid-game economic-rebuild after a worker-
|
| 10 |
+
# kill or refinery snipe / disaster-recovery after a critical-
|
| 11 |
+
# infrastructure loss / bankruptcy turnaround under a deadline.
|
| 12 |
+
#
|
| 13 |
+
# Pack frame: the agent inherits a NORMAL operating economy (fact +
|
| 14 |
+
# proc + 1 harv + 2 mines + a small 3tnk defender squad), with
|
| 15 |
+
# starting_cash = 1400 — exactly enough to rebuild ONE refinery (proc
|
| 16 |
+
# cost 1400). A scripted enemy `hunt` strike (2-3× 4tnk) charges the
|
| 17 |
+
# refinery early in the episode (medium: proc destroyed ~tick 543;
|
| 18 |
+
# hard: proc + tent both destroyed by ~tick 453). The agent must:
|
| 19 |
+
# 1. notice the proc died (no income, harv has no place to refine),
|
| 20 |
+
# 2. `build('proc')` and `place_building` it adjacent to the fact
|
| 21 |
+
# with the indivisible reserve cash,
|
| 22 |
+
# 3. re-issue `harvest` to the surviving harv (the death event
|
| 23 |
+
# interrupts the harvest loop), so income resumes and the EV
|
| 24 |
+
# bar (2500) is met before the deadline.
|
| 25 |
+
#
|
| 26 |
+
# Difficulty axis (one new controlled variable per tier):
|
| 27 |
+
# • easy — LIGHT strike (1× 4tnk + 2 own defenders). The defenders
|
| 28 |
+
# intercept the lone 4tnk before the proc dies; the agent
|
| 29 |
+
# never has to rebuild. The bare skill is "commit to
|
| 30 |
+
# harvest, don't stall, EV bar gets met". `has_building:
|
| 31 |
+
# proc` is satisfied trivially by the surviving original.
|
| 32 |
+
# • medium — PROC DESTROYED mid-game (2× 4tnk + 1 own defender;
|
| 33 |
+
# proc dies ~tick 543 ≈ "~tick 600" target). Agent must
|
| 34 |
+
# rebuild proc with the 1400 reserve AND redirect harv
|
| 35 |
+
# back to the patch. Stalling, ignoring the death, or
|
| 36 |
+
# spending the reserve on army units all LOSE.
|
| 37 |
+
# • hard — PROC + TENT BOTH DESTROYED (3× 4tnk + tent placed in
|
| 38 |
+
# the attack lane at (13,18); proc dies ~tick 453, tent
|
| 39 |
+
# dies ~tick 273-453). Agent must rebuild proc (1400);
|
| 40 |
+
# losing the tent removes the infantry production queue
|
| 41 |
+
# (an army-rebuild detour is even more unaffordable).
|
| 42 |
+
# + 2 seed-driven spawn_point groups (NORTH y=14 / SOUTH
|
| 43 |
+
# y=26) so the strike geometry flips per seed.
|
| 44 |
+
#
|
| 45 |
+
# Why this pack is different from `econ-recover-from-zero-cash`:
|
| 46 |
+
# • `econ-recover-from-zero-cash` starts from a MINIMUM VIABLE KIT
|
| 47 |
+
# with $0 cash and no aggression — the test is the SCALING
|
| 48 |
+
# recovery (harvest first, save 2000, build weap, build a 2nd /
|
| 49 |
+
# 3rd harv). The recovery is INTERNAL (no enemy event).
|
| 50 |
+
# • `rob-cash-depletion-recovery` starts from a WORKING ECONOMY
|
| 51 |
+
# with $1400 cash and ENEMY AGGRESSION that destroys the refinery
|
| 52 |
+
# mid-episode. The test is the EXOGENOUS-EVENT recovery (notice
|
| 53 |
+
# the strike, replace the dead refinery, restart income). The
|
| 54 |
+
# agent never needs to scale past 1 harv; the test is rebuilding
|
| 55 |
+
# the income channel that was destroyed, not extending it.
|
| 56 |
+
#
|
| 57 |
+
# Why this pack is different from `econ-replace-dead-harvester`:
|
| 58 |
+
# • `econ-replace-dead-harvester` kills a HARVESTER (a unit) with a
|
| 59 |
+
# raider 4tnk that targets workers specifically; agent replaces
|
| 60 |
+
# the dead worker with `build('harv')`.
|
| 61 |
+
# • `rob-cash-depletion-recovery` kills a REFINERY (a building) with
|
| 62 |
+
# a `hunt` 4tnk that targets the nearest combat actor (defenders
|
| 63 |
+
# and proc are MustBeDestroyed and engaged); the agent rebuilds
|
| 64 |
+
# the dead BUILDING with `build('proc') + place_building`. The
|
| 65 |
+
# recovery is a building-production task, not a unit-production
|
| 66 |
+
# task — one chain step deeper (build + place + adjacency).
|
| 67 |
+
#
|
| 68 |
+
# Why this pack is different from `mid-economy-under-fire`:
|
| 69 |
+
# • `mid-economy-under-fire` is an ongoing-attack defense: the
|
| 70 |
+
# raider drives at the harvesters and the agent must keep the
|
| 71 |
+
# harvest loop running THROUGH the attack. Throughput is
|
| 72 |
+
# sustained; no rebuild is needed.
|
| 73 |
+
# • `rob-cash-depletion-recovery` is a one-shot strike followed by
|
| 74 |
+
# a rebuild: the strike LANDS (the proc dies), throughput STOPS,
|
| 75 |
+
# and the agent must rebuild the destroyed infrastructure to
|
| 76 |
+
# restart income. The decision is replan-after-loss, not
|
| 77 |
+
# defend-and-sustain.
|
| 78 |
+
#
|
| 79 |
+
# ENGINE FACTS verified (CLAUDE.md + /tmp/probe_*.py 2026-05-19/20
|
| 80 |
+
# against the installed openra_train wheel):
|
| 81 |
+
# 1. `hunt` ScriptedBehavior (openra-sim/src/scripted_bot.rs lines
|
| 82 |
+
# 151-166 / 242-253) collects FOES including Building actors and
|
| 83 |
+
# each unit attacks the nearest. With 4tnk attackers placed in
|
| 84 |
+
# melee range of the proc, the proc becomes the closest enemy
|
| 85 |
+
# and is engaged on turn 1.
|
| 86 |
+
# 2. Proc strike timing (probe-measured, all attackers at x=14
|
| 87 |
+
# adjacent to proc at x=12):
|
| 88 |
+
# - easy: 1× 4tnk + 1 own 3tnk @ x=18 → defender kills 4tnk
|
| 89 |
+
# first; proc SURVIVES the full episode (verified to tick 6303).
|
| 90 |
+
# Discriminator: stall yields 0 income; intended harvest WINS.
|
| 91 |
+
# - medium: 2× 4tnk + 3 own 3tnk @ x=18 → proc dies tick 453
|
| 92 |
+
# (≈ "~tick 600" target); 4tnks then die to the defender ring
|
| 93 |
+
# so fact and powr SURVIVE → rebuild is feasible.
|
| 94 |
+
# - hard: 4× 4tnk + tent at (13,*) + 5 own 3tnk → tent dies tick
|
| 95 |
+
# 183, proc tick 273; 4tnks die to the defender ring shortly
|
| 96 |
+
# after; fact and powr SURVIVE to ~tick 6300 (probe H3).
|
| 97 |
+
# The defender-vs-attacker ratio (medium: 90 vs 80 dps; hard:
|
| 98 |
+
# 150 vs 160 dps) is tuned so the attackers BARELY land the
|
| 99 |
+
# building kill and then die — preventing the 4tnks from chain-
|
| 100 |
+
# killing fact after the proc falls (which would convert the
|
| 101 |
+
# run to a LOSS via `not building_count_gte:fact` regardless of
|
| 102 |
+
# the rebuild).
|
| 103 |
+
# 3. Proc cost = 1400 (verified econ-startup-from-scratch.yaml line
|
| 104 |
+
# 135). starting_cash = 1400 funds exactly ONE proc rebuild; any
|
| 105 |
+
# pre-spend (powr 300 / tent 500 / e1 100) starves the rebuild.
|
| 106 |
+
# 4. After proc dies, the surviving harv's harvest loop is
|
| 107 |
+
# interrupted (the engine cannot deliver ore without a refinery).
|
| 108 |
+
# Once the rebuild completes and the new proc lands, the agent
|
| 109 |
+
# must re-issue `harvest(harv_id, mine_x, mine_y)` for the harv
|
| 110 |
+
# to deliver into the NEW proc — the harvest auto-cycle does not
|
| 111 |
+
# auto-resume across a destroy-rebuild gap.
|
| 112 |
+
# 5. Tick alignment (CLAUDE.md): max_turns 70 → ceiling 93 + 90·69 =
|
| 113 |
+
# 6303 > 6300 ✓. within_ticks 6300; fail after_ticks 6301
|
| 114 |
+
# (deadline bites as a reachable LOSS, not a draw).
|
| 115 |
+
# 6. Persistent unarmed enemy `fact` marker far east at (120,20)
|
| 116 |
+
# prevents engine auto-`done` via the all-enemies-eliminated
|
| 117 |
+
# path (CLAUDE.md auto-done footgun) — once the strike force is
|
| 118 |
+
# killed, the marker keeps the episode running so the EV bar
|
| 119 |
+
# can be reached and the deadline can bite as a LOSS.
|
| 120 |
+
# 7. Hard tier is registered NOT_APPLICABLE in test_hard_tier.py
|
| 121 |
+
# (the UPGRADED spawn-variation contract). Reason: the
|
| 122 |
+
# controlled variable for hard is the STRIKE SEVERITY (proc +
|
| 123 |
+
# tent destroyed vs medium's proc only). The enemy-bot footgun
|
| 124 |
+
# (CLAUDE.md: enemy actors don't honour spawn_point) makes a
|
| 125 |
+
# clean per-spawn enemy partition geometrically infeasible —
|
| 126 |
+
# foreign-latitude 4tnks always place and drift to the active
|
| 127 |
+
# base, over-pressuring the tuned defender ring and destroying
|
| 128 |
+
# the fact (converting the recovery test into a fact-defence
|
| 129 |
+
# test). Spawn variation here would compete with the recovery-
|
| 130 |
+
# discipline signal for attribution.
|
| 131 |
+
|
| 132 |
+
meta:
|
| 133 |
+
id: rob-cash-depletion-recovery
|
| 134 |
+
title: 'Recover From Cash Depletion — Rebuild Refinery After Mid-Episode Strike'
|
| 135 |
+
capability: reasoning
|
| 136 |
+
real_world_meaning: >
|
| 137 |
+
Financial recovery after a sudden cash drain caused by an
|
| 138 |
+
emergency outflow that destroys the operation's income channel.
|
| 139 |
+
The agent inherits a normal operating economy with just enough
|
| 140 |
+
reserve cash for ONE refinery rebuild; an exogenous strike
|
| 141 |
+
destroys the refinery mid-episode; income halts. The agent must
|
| 142 |
+
notice the loss, commit the indivisible reserve to rebuilding the
|
| 143 |
+
refinery, and redirect the harvester back to the patch — fast
|
| 144 |
+
enough that the revenue bar is still cleared before the deadline.
|
| 145 |
+
Stalling, ignoring the destruction, or spending the reserve on
|
| 146 |
+
army units all miss the bar.
|
| 147 |
+
robotics_analogue: >
|
| 148 |
+
Post-disaster operations restart after the critical processing
|
| 149 |
+
node is destroyed by an external event mid-mission. The
|
| 150 |
+
autonomous operator has one collector still alive, no working
|
| 151 |
+
capital beyond a single processing-node rebuild allowance, and a
|
| 152 |
+
deadline. Recovery requires identifying the lost asset,
|
| 153 |
+
committing the reserve to rebuilding it (not to defenders or
|
| 154 |
+
expansion), and re-tasking the collector back to the source once
|
| 155 |
+
the new node is operational.
|
| 156 |
+
benchmark_anchor:
|
| 157 |
+
- "financial cash-crisis recovery"
|
| 158 |
+
- "SC2 cash-drain rebuild"
|
| 159 |
+
- "DR after critical-infra loss"
|
| 160 |
+
- "bankruptcy turnaround under deadline"
|
| 161 |
+
author: openra-bench-wave-6
|
| 162 |
+
|
| 163 |
+
base_map: rush-hour-arena
|
| 164 |
+
# Indivisible rebuild reserve: 1400 = exactly one proc. Any pre-spend
|
| 165 |
+
# starves the rebuild → the income channel never reopens → EV bar
|
| 166 |
+
# never reached → LOSS.
|
| 167 |
+
starting_cash: 1400
|
| 168 |
+
|
| 169 |
+
base:
|
| 170 |
+
agent: {faction: allies}
|
| 171 |
+
# `hunt` worker bot: each enemy unit attacks the NEAREST agent
|
| 172 |
+
# actor (including buildings). With 4tnk attackers placed close to
|
| 173 |
+
# the proc and only 1-2 own 3tnk defenders staged further from the
|
| 174 |
+
# ingress lane than the proc itself, the closest foe is the proc
|
| 175 |
+
# and the 4tnk commits to destroying it (then retargets to the
|
| 176 |
+
# next nearest building — tent on hard).
|
| 177 |
+
enemy: {faction: soviet, cash: 0, bot_type: hunt}
|
| 178 |
+
# Spec-required toolset (Wave-6 spec): all 8 tools.
|
| 179 |
+
tools: [observe, build, place_building, harvest, move_units, attack_unit, attack_move, stop]
|
| 180 |
+
# Default `spawn_mcvs: false` — we declare actors ourselves per
|
| 181 |
+
# level so the engine doesn't add a phantom MCV.
|
| 182 |
+
spawn_mcvs: false
|
| 183 |
+
planning: true
|
| 184 |
+
termination: {max_ticks: 8000}
|
| 185 |
+
actors: []
|
| 186 |
+
|
| 187 |
+
levels:
|
| 188 |
+
# ── EASY ─────────────────────────────────────────────────────────
|
| 189 |
+
# The bare skill: a LIGHT strike (1× 4tnk) that the 2 pre-placed
|
| 190 |
+
# 3tnk defenders intercept before the proc falls. The agent never
|
| 191 |
+
# has to rebuild; the discriminator is "stall LOSES (no harvest
|
| 192 |
+
# income → EV bar unmet → reachable timeout LOSS), any committed
|
| 193 |
+
# harvest WINS". `building_count_gte:proc:1` is satisfied trivially
|
| 194 |
+
# by the surviving original. max_turns 70 → ceiling 93 + 90·69 =
|
| 195 |
+
# 6303 > 6300 ✓.
|
| 196 |
+
easy:
|
| 197 |
+
description: >
|
| 198 |
+
You start with a Construction Yard ('fact'), a Power Plant
|
| 199 |
+
('powr'), an Ore Refinery ('proc'), one Ore Truck ('harv'), two
|
| 200 |
+
ore patches near the refinery, two Light Tank ('3tnk')
|
| 201 |
+
defenders, and $1400 cash. A lone enemy heavy tank is staged
|
| 202 |
+
adjacent to the refinery; your defenders will intercept it
|
| 203 |
+
before it can destroy the refinery (the strike is light enough
|
| 204 |
+
that proc survives intact). To collect ore, first `move_units`
|
| 205 |
+
the harv onto a patch cell ((22,18) or (22,20)) then issue
|
| 206 |
+
`harvest` with that cell as the target (the auto-cycle
|
| 207 |
+
continues on its own). Win = economy value (cash + stored ore)
|
| 208 |
+
≥ 2500 AND you still own a 'proc' AND a 'fact', before tick
|
| 209 |
+
6300. Stalling (only `observe`) earns nothing and loses on the
|
| 210 |
+
clock — your starting $1400 alone does NOT clear the 2500 bar
|
| 211 |
+
without harvest income.
|
| 212 |
+
starting_cash: 1400
|
| 213 |
+
overrides:
|
| 214 |
+
actors:
|
| 215 |
+
- {type: fact, owner: agent, position: [8, 18]}
|
| 216 |
+
# Powr placed WEST of the fact (far from the strike lane) so
|
| 217 |
+
# it survives the attack and provides the `proc` rebuild
|
| 218 |
+
# prerequisite (gamerules.rs line 343 — proc.prerequisites =
|
| 219 |
+
# ["powr"]).
|
| 220 |
+
- {type: powr, owner: agent, position: [5, 18]}
|
| 221 |
+
- {type: proc, owner: agent, position: [12, 18]}
|
| 222 |
+
# Harvester staged WEST of the proc (x=10, behind the proc
|
| 223 |
+
# relative to the attacker) so the closest agent foe to the
|
| 224 |
+
# 4tnk is the defender, then the proc — the harv is shielded
|
| 225 |
+
# and survives the strike intact.
|
| 226 |
+
- {type: harv, owner: agent, position: [10, 18]}
|
| 227 |
+
# TWO 3tnk defenders ringed east of the proc — combined
|
| 228 |
+
# 60 dps vs the lone 4tnk's 40 dps; the 4tnk dies before it
|
| 229 |
+
# can land lethal damage on the proc (probe-verified: proc
|
| 230 |
+
# SURVIVES the full episode at this attacker:defender ratio).
|
| 231 |
+
- {type: '3tnk', owner: agent, position: [18, 17]}
|
| 232 |
+
- {type: '3tnk', owner: agent, position: [18, 19]}
|
| 233 |
+
# Two ore patches east of the proc — sustained harvest target.
|
| 234 |
+
- {type: mine, owner: neutral, position: [22, 18]}
|
| 235 |
+
- {type: mine, owner: neutral, position: [22, 20]}
|
| 236 |
+
# LIGHT strike: one enemy 4tnk staged adjacent to the
|
| 237 |
+
# refinery at (15,18). The closest agent foes are the
|
| 238 |
+
# defender ring at (18,17)/(18,19) — the 4tnk engages them
|
| 239 |
+
# first and dies before it can finish the proc. The agent
|
| 240 |
+
# never has to rebuild on easy.
|
| 241 |
+
- {type: '4tnk', owner: enemy, position: [15, 18]}
|
| 242 |
+
# Persistent far enemy marker — LOSS-not-DRAW guarantee
|
| 243 |
+
# (keeps the engine from auto-`done` when the strike force
|
| 244 |
+
# falls before the EV bar / deadline is evaluated).
|
| 245 |
+
- {type: fact, owner: enemy, position: [120, 20]}
|
| 246 |
+
win_condition:
|
| 247 |
+
all_of:
|
| 248 |
+
- economy_value_gte: 2500
|
| 249 |
+
- building_count_gte: {type: proc, n: 1}
|
| 250 |
+
- building_count_gte: {type: fact, n: 1}
|
| 251 |
+
- within_ticks: 6300
|
| 252 |
+
fail_condition:
|
| 253 |
+
any_of:
|
| 254 |
+
- after_ticks: 6301
|
| 255 |
+
- not: {building_count_gte: {type: fact, n: 1}}
|
| 256 |
+
max_turns: 70
|
| 257 |
+
|
| 258 |
+
# ── MEDIUM ───────────────────────────────────────────────────────
|
| 259 |
+
# +1 controlled variable on top of easy: TWO 4tnk attackers vs
|
| 260 |
+
# THREE defenders. The defenders kill the strike force AFTER it
|
| 261 |
+
# destroys the proc (probe-measured: proc dies tick 453); the
|
| 262 |
+
# 4tnks then die before they can reach the fact, so the rebuild
|
| 263 |
+
# is viable. The agent MUST rebuild the proc with the 1400 reserve
|
| 264 |
+
# and re-issue harvest. Stall (no rebuild) → `building_count_gte:
|
| 265 |
+
# proc:1` clause fails → LOSS. Spending reserve on army (powr 300
|
| 266 |
+
# / tent 500 / e1) → cash drops below 1400 → proc cannot be queued
|
| 267 |
+
# before the deadline → LOSS.
|
| 268 |
+
medium:
|
| 269 |
+
description: >
|
| 270 |
+
Same base as easy (fact, powr, proc, 1 harv, 2 mines, $1400
|
| 271 |
+
reserve) but the enemy strike is now TWO heavy tanks adjacent
|
| 272 |
+
to the refinery; even with three light-tank defenders, the
|
| 273 |
+
refinery is destroyed early in the episode (around tick 450).
|
| 274 |
+
The harvester survives but the harvest loop stops because the
|
| 275 |
+
refinery is gone. To recover: queue another Refinery (`build`
|
| 276 |
+
"proc", cost 1400), place it adjacent to the fact, then re-
|
| 277 |
+
issue `harvest` to the surviving harv (the destruction event
|
| 278 |
+
interrupted the harvest loop). Win = economy_value ≥ 2500 AND
|
| 279 |
+
you own a 'proc' AND a 'fact', before tick 6300. Stalling,
|
| 280 |
+
ignoring the rebuild, or spending the 1400 reserve on army
|
| 281 |
+
units (powr/tent/e1) — which starves the proc rebuild — all
|
| 282 |
+
miss the bar.
|
| 283 |
+
starting_cash: 1400
|
| 284 |
+
overrides:
|
| 285 |
+
actors:
|
| 286 |
+
- {type: fact, owner: agent, position: [8, 18]}
|
| 287 |
+
# Powr placed WEST of the fact — survives the strike, provides
|
| 288 |
+
# the `proc` rebuild prerequisite (gamerules.rs line 343).
|
| 289 |
+
- {type: powr, owner: agent, position: [5, 18]}
|
| 290 |
+
- {type: proc, owner: agent, position: [12, 18]}
|
| 291 |
+
# Harv staged WEST of the proc (x=10) so it is shielded by
|
| 292 |
+
# the proc and the defender ring; the 4tnks engage the
|
| 293 |
+
# defenders/proc first and the harv survives the strike.
|
| 294 |
+
- {type: harv, owner: agent, position: [10, 18]}
|
| 295 |
+
# THREE 3tnk defenders — enough to kill the strike force
|
| 296 |
+
# AFTER it lands the proc kill. The DPS race is tuned so the
|
| 297 |
+
# 4tnks reach the proc, destroy it (probe: proc dies tick
|
| 298 |
+
# 453 ≈ turn 6 ≈ "~tick 600" target), and then die to the
|
| 299 |
+
# defenders' concentrated 90 dps — fact and powr survive
|
| 300 |
+
# so the rebuild can fund and the EV bar can be reached.
|
| 301 |
+
- {type: '3tnk', owner: agent, position: [18, 17]}
|
| 302 |
+
- {type: '3tnk', owner: agent, position: [18, 18]}
|
| 303 |
+
- {type: '3tnk', owner: agent, position: [18, 19]}
|
| 304 |
+
- {type: mine, owner: neutral, position: [22, 18]}
|
| 305 |
+
- {type: mine, owner: neutral, position: [22, 20]}
|
| 306 |
+
# MEDIUM strike: two 4tnks staged adjacent to the refinery
|
| 307 |
+
# at (14,17) and (14,19) — in melee range of the proc at
|
| 308 |
+
# x=12. The 4tnks (combined 80 dps) destroy the proc before
|
| 309 |
+
# the three defenders (combined 90 dps) can wipe them; the
|
| 310 |
+
# surviving 4tnk count then falls to zero after the proc
|
| 311 |
+
# kill so the fact remains intact (probe-verified).
|
| 312 |
+
- {type: '4tnk', owner: enemy, position: [14, 17]}
|
| 313 |
+
- {type: '4tnk', owner: enemy, position: [14, 19]}
|
| 314 |
+
- {type: fact, owner: enemy, position: [120, 20]}
|
| 315 |
+
win_condition:
|
| 316 |
+
all_of:
|
| 317 |
+
- economy_value_gte: 2500
|
| 318 |
+
- building_count_gte: {type: proc, n: 1}
|
| 319 |
+
- building_count_gte: {type: fact, n: 1}
|
| 320 |
+
- within_ticks: 6300
|
| 321 |
+
fail_condition:
|
| 322 |
+
any_of:
|
| 323 |
+
- after_ticks: 6301
|
| 324 |
+
- not: {building_count_gte: {type: fact, n: 1}}
|
| 325 |
+
max_turns: 70
|
| 326 |
+
|
| 327 |
+
# ── HARD ─────────────────────────────────────────────────────────
|
| 328 |
+
# +2 controlled variables vs medium:
|
| 329 |
+
# 1. FOUR 4tnk attackers (vs medium's two) + a tent placed
|
| 330 |
+
# adjacent to the proc — both proc AND tent are destroyed
|
| 331 |
+
# early in the episode (probe-measured: tent dies tick 183,
|
| 332 |
+
# proc tick 273). The lost tent removes the infantry
|
| 333 |
+
# production queue, so an army-rebuild detour (powr → tent →
|
| 334 |
+
# e1) is even more unaffordable in build time.
|
| 335 |
+
# 2. SAME indivisible $1400 reserve but DOUBLE the structural
|
| 336 |
+
# burden (proc + tent rebuild). The agent has exactly enough
|
| 337 |
+
# for the proc (the prereq for the harvester income channel);
|
| 338 |
+
# the tent rebuild can wait or be skipped entirely (it isn't
|
| 339 |
+
# part of the win predicate). The reasoning test is:
|
| 340 |
+
# prioritise the income-restoring proc rebuild, NOT the
|
| 341 |
+
# defensive-tech tent rebuild, even though more buildings
|
| 342 |
+
# are gone.
|
| 343 |
+
#
|
| 344 |
+
# NOTE on the spawn-variation contract: this pack is hard-tier
|
| 345 |
+
# NOT_APPLICABLE in tests/test_hard_tier.py — the recovery axis
|
| 346 |
+
# (strike severity → number of destroyed buildings → rebuild
|
| 347 |
+
# decision discipline) is the controlled variable, and the
|
| 348 |
+
# enemy-bot footgun (CLAUDE.md: enemy actors don't honour
|
| 349 |
+
# spawn_point) makes a clean per-spawn enemy partition
|
| 350 |
+
# geometrically impossible (foreign-latitude 4tnks drift to the
|
| 351 |
+
# active base and over-pressure the defender ring beyond the
|
| 352 |
+
# tuned proc-but-not-fact kill window). Spawn variation here
|
| 353 |
+
# would compete with the recovery-discipline signal for
|
| 354 |
+
# attribution, so the within-tier discriminator stays clean.
|
| 355 |
+
hard:
|
| 356 |
+
description: >
|
| 357 |
+
A harder version of the rebuild: FOUR enemy heavy tanks
|
| 358 |
+
attack the refinery AND a barracks ('tent') is placed in the
|
| 359 |
+
attack lane and is also destroyed early in the episode (tent
|
| 360 |
+
down by around tick 180, refinery by around tick 270). Your
|
| 361 |
+
reserve is still $1400 — exactly one refinery. To recover:
|
| 362 |
+
queue another Refinery (`build` "proc"), place it adjacent to
|
| 363 |
+
the fact, and re-issue `harvest` to the surviving harv. The
|
| 364 |
+
tent rebuild is OPTIONAL (it isn't part of the win predicate);
|
| 365 |
+
prioritise the income-restoring proc rebuild even though more
|
| 366 |
+
buildings are gone. Win = economy_value ≥ 2500 AND you own a
|
| 367 |
+
'proc' AND a 'fact', before tick 6300. Stalling, ignoring the
|
| 368 |
+
rebuild, attempting to rebuild the tent first (no cash left
|
| 369 |
+
for the proc), or spending the reserve on army units all miss
|
| 370 |
+
the bar.
|
| 371 |
+
starting_cash: 1400
|
| 372 |
+
overrides:
|
| 373 |
+
actors:
|
| 374 |
+
- {type: fact, owner: agent, position: [8, 18]}
|
| 375 |
+
# Powr WEST of the fact — survives the strike, provides the
|
| 376 |
+
# `proc` rebuild prerequisite (gamerules.rs line 343).
|
| 377 |
+
- {type: powr, owner: agent, position: [5, 18]}
|
| 378 |
+
- {type: proc, owner: agent, position: [12, 18]}
|
| 379 |
+
# Harv WEST of the proc (x=10) — shielded by proc/defender
|
| 380 |
+
# ring, survives the strike intact for the rebuild-and-resume.
|
| 381 |
+
- {type: harv, owner: agent, position: [10, 18]}
|
| 382 |
+
# Tent placed adjacent to the proc (the attack lane) — the
|
| 383 |
+
# 4tnks engage tent and proc first; both fall by ~tick 270.
|
| 384 |
+
- {type: tent, owner: agent, position: [13, 18]}
|
| 385 |
+
# FIVE 3tnk defenders ringed east of the proc — concentrated
|
| 386 |
+
# 150 dps. After the 4tnks land the proc+tent kill, the
|
| 387 |
+
# defenders wipe them so fact and powr remain intact (probe-
|
| 388 |
+
# verified H3: 4×4tnk vs 5×3tnk → tent dies tick 183, proc
|
| 389 |
+
# tick 273, fact survives full 70 turns).
|
| 390 |
+
- {type: '3tnk', owner: agent, position: [18, 16]}
|
| 391 |
+
- {type: '3tnk', owner: agent, position: [18, 17]}
|
| 392 |
+
- {type: '3tnk', owner: agent, position: [18, 18]}
|
| 393 |
+
- {type: '3tnk', owner: agent, position: [18, 19]}
|
| 394 |
+
- {type: '3tnk', owner: agent, position: [18, 20]}
|
| 395 |
+
# Two ore patches east of the proc — sustained harvest target.
|
| 396 |
+
- {type: mine, owner: neutral, position: [22, 18]}
|
| 397 |
+
- {type: mine, owner: neutral, position: [22, 20]}
|
| 398 |
+
# HARD strike: FOUR 4tnks staged adjacent to the refinery at
|
| 399 |
+
# (14,15..18) — in melee range of the proc at x=12 and tent
|
| 400 |
+
# at x=13. The 4tnks (combined 160 dps) destroy tent first
|
| 401 |
+
# (lower HP) then proc; the 5 defenders (150 dps) wipe them
|
| 402 |
+
# shortly after, so fact and powr SURVIVE intact (probe-
|
| 403 |
+
# verified H3 + extended trace to tick 6303). The agent's
|
| 404 |
+
# rebuild path: build('proc') (1400) + place adjacent to
|
| 405 |
+
# fact + re-issue harvest. Tent rebuild is OPTIONAL (not
|
| 406 |
+
# part of the win clause).
|
| 407 |
+
- {type: '4tnk', owner: enemy, position: [14, 15]}
|
| 408 |
+
- {type: '4tnk', owner: enemy, position: [14, 17]}
|
| 409 |
+
- {type: '4tnk', owner: enemy, position: [14, 19]}
|
| 410 |
+
- {type: '4tnk', owner: enemy, position: [14, 21]}
|
| 411 |
+
# Persistent far enemy marker — LOSS-not-DRAW guarantee.
|
| 412 |
+
- {type: fact, owner: enemy, position: [120, 20]}
|
| 413 |
+
win_condition:
|
| 414 |
+
all_of:
|
| 415 |
+
- economy_value_gte: 2500
|
| 416 |
+
- building_count_gte: {type: proc, n: 1}
|
| 417 |
+
- building_count_gte: {type: fact, n: 1}
|
| 418 |
+
- within_ticks: 6300
|
| 419 |
+
fail_condition:
|
| 420 |
+
any_of:
|
| 421 |
+
- after_ticks: 6301
|
| 422 |
+
- not: {building_count_gte: {type: fact, n: 1}}
|
| 423 |
+
max_turns: 70
|
|
@@ -468,6 +468,24 @@ NOT_APPLICABLE = {
|
|
| 468 |
"variable (route geometry) that competes with that signal for "
|
| 469 |
"attribution. Hard tightens the patrol intensity (a second arc) "
|
| 470 |
"and the attrition cap (units_lost_lte 1 → 0) instead.",
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 471 |
}
|
| 472 |
|
| 473 |
# No-adversary maps: spawn variation applies but a force-loss
|
|
|
|
| 468 |
"variable (route geometry) that competes with that signal for "
|
| 469 |
"attribution. Hard tightens the patrol intensity (a second arc) "
|
| 470 |
"and the attrition cap (units_lost_lte 1 → 0) instead.",
|
| 471 |
+
# Wave-6 Group J robustness seed. Hard's controlled variable is
|
| 472 |
+
# the strike SEVERITY (one more 4tnk + tent added to the rubble
|
| 473 |
+
# pile). The enemy-bot footgun (CLAUDE.md: enemy actors don't
|
| 474 |
+
# honour spawn_point) makes a clean per-spawn enemy partition
|
| 475 |
+
# geometrically infeasible — foreign-latitude `hunt` 4tnks drift
|
| 476 |
+
# to the active base and over-pressure the defender ring beyond
|
| 477 |
+
# the tuned proc-but-not-fact kill window, converting clean LOSS
|
| 478 |
+
# discrimination into noisy across-seed instability. Spawn
|
| 479 |
+
# variation would compete with the recovery-discipline signal
|
| 480 |
+
# for attribution.
|
| 481 |
+
"rob-cash-depletion-recovery": "hard's controlled variable is "
|
| 482 |
+
"strike severity (proc + tent destroyed vs medium's proc only). "
|
| 483 |
+
"Enemy `hunt` actors don't honour spawn_point (CLAUDE.md "
|
| 484 |
+
"oramap.rs footgun) so foreign-latitude 4tnks drift to the "
|
| 485 |
+
"active spawn and over-pressure the tuned defender ring, "
|
| 486 |
+
"destroying the fact and converting the recovery test into a "
|
| 487 |
+
"fact-defence test. Spawn variation would compete with the "
|
| 488 |
+
"recovery-discipline signal for attribution.",
|
| 489 |
}
|
| 490 |
|
| 491 |
# No-adversary maps: spawn variation applies but a force-loss
|
|
@@ -0,0 +1,424 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""No-cheat + solvency proof for `rob-cash-depletion-recovery` (Group J
|
| 2 |
+
robustness — mid-episode recovery after a scripted enemy strike
|
| 3 |
+
destroys the refinery and halts income).
|
| 4 |
+
|
| 5 |
+
The pack frames a CASH-CRISIS RECOVERY decision: the agent inherits a
|
| 6 |
+
normal operating economy (fact + proc + 1 harv + 2 mines + a small
|
| 7 |
+
defender squad) with $1400 cash — exactly enough to rebuild ONE
|
| 8 |
+
refinery (proc cost 1400). An enemy `hunt` 4tnk strike destroys the
|
| 9 |
+
proc mid-episode (medium ≈ tick 540; hard ≈ tick 450; on easy the
|
| 10 |
+
defenders intercept the lone 4tnk and the proc survives). The agent
|
| 11 |
+
must `build('proc') + place_building` and re-issue `harvest`.
|
| 12 |
+
|
| 13 |
+
For every level + every hard seed (1-4):
|
| 14 |
+
* INTENDED chain (notice the destruction, build proc, place, re-
|
| 15 |
+
issue harvest) WINS;
|
| 16 |
+
* STALL (only `observe`) LOSES every tier — no harvest income, EV
|
| 17 |
+
stays at the starting $1400, bar (2500) unmet, clock bites;
|
| 18 |
+
* NO-REBUILD (harvest with the starting harv but never `build`
|
| 19 |
+
proc) is the EASY FLOOR (wins easy where the proc survives) but
|
| 20 |
+
LOSES medium/hard (the structural `has_building:proc` clause
|
| 21 |
+
fails after the proc is destroyed);
|
| 22 |
+
* BUILD-ARMY (spend the 1400 reserve on powr → tent → e1 instead
|
| 23 |
+
of replacing the proc) LOSES medium/hard — the reserve is
|
| 24 |
+
starved, the proc rebuild never completes, the bar is unmet.
|
| 25 |
+
"""
|
| 26 |
+
|
| 27 |
+
from __future__ import annotations
|
| 28 |
+
|
| 29 |
+
import pytest
|
| 30 |
+
|
| 31 |
+
pytest.importorskip("openra_train", reason="Rust env wheel not installed")
|
| 32 |
+
pytest.importorskip("openra_rl_training", reason="Rust env wheel not installed")
|
| 33 |
+
from openra_bench.eval_core import run_level
|
| 34 |
+
from openra_bench.scenarios import load_pack
|
| 35 |
+
from openra_bench.scenarios.loader import PACKS_DIR, compile_level
|
| 36 |
+
|
| 37 |
+
PACK = PACKS_DIR / "rob-cash-depletion-recovery.yaml"
|
| 38 |
+
LEVELS = ("easy", "medium", "hard")
|
| 39 |
+
SEEDS = (1, 2, 3, 4)
|
| 40 |
+
|
| 41 |
+
|
| 42 |
+
# ───────────────────────── helpers ────────────────────────────────────────
|
| 43 |
+
|
| 44 |
+
|
| 45 |
+
def _own_fact(bldgs):
|
| 46 |
+
"""Return the AGENT-owned fact (skip the far enemy marker at x=120)."""
|
| 47 |
+
for b in bldgs:
|
| 48 |
+
if b.get("type") == "fact" and b.get("cell_x", 0) < 100:
|
| 49 |
+
return b
|
| 50 |
+
return None
|
| 51 |
+
|
| 52 |
+
|
| 53 |
+
def _patches_for_fact(fy: int):
|
| 54 |
+
"""easy/medium: one fact at y=18 → patches at (22,18)/(22,20).
|
| 55 |
+
hard: NORTH y=14 → (22,14)/(22,16); SOUTH y=26 → (22,26)/(22,28)."""
|
| 56 |
+
if abs(fy - 18) <= 2:
|
| 57 |
+
return [(22, 18), (22, 20)]
|
| 58 |
+
if fy < 18:
|
| 59 |
+
return [(22, 14), (22, 16)]
|
| 60 |
+
return [(22, 26), (22, 28)]
|
| 61 |
+
|
| 62 |
+
|
| 63 |
+
def _ev(res):
|
| 64 |
+
return res.signals.cash + res.signals.resources
|
| 65 |
+
|
| 66 |
+
|
| 67 |
+
def _current_building_types(res):
|
| 68 |
+
"""`signals.own_building_types` is accumulative (grows across the
|
| 69 |
+
episode); for "currently alive" checks use `signals.own_buildings`
|
| 70 |
+
(overwritten each observation)."""
|
| 71 |
+
return {t for (t, _, _) in res.signals.own_buildings}
|
| 72 |
+
|
| 73 |
+
|
| 74 |
+
# ───────────────────────── scripted policies ──────────────────────────────
|
| 75 |
+
|
| 76 |
+
|
| 77 |
+
def _stall(rs, Command):
|
| 78 |
+
"""Idle: never harvests, never rebuilds. EV stays at starting
|
| 79 |
+
$1400, bar (2500) unmet → reachable timeout LOSS every tier."""
|
| 80 |
+
return [Command.observe()]
|
| 81 |
+
|
| 82 |
+
|
| 83 |
+
def _no_rebuild_factory():
|
| 84 |
+
"""Run the starting harv but never `build` proc. WINS easy (the
|
| 85 |
+
proc survives there — the defenders intercept the lone 4tnk) and
|
| 86 |
+
LOSES medium/hard (after the proc is destroyed, the
|
| 87 |
+
`has_building:proc` clause fails permanently)."""
|
| 88 |
+
|
| 89 |
+
state = {"moved": set()}
|
| 90 |
+
|
| 91 |
+
def policy(rs, Command):
|
| 92 |
+
units = rs.get("units_summary", []) or []
|
| 93 |
+
bldgs = rs.get("own_buildings", []) or []
|
| 94 |
+
fact_b = _own_fact(bldgs)
|
| 95 |
+
if fact_b is None:
|
| 96 |
+
return [Command.observe()]
|
| 97 |
+
patches = _patches_for_fact(fact_b["cell_y"])
|
| 98 |
+
harvs = [
|
| 99 |
+
(u["id"], u.get("cell_x"), u.get("cell_y"))
|
| 100 |
+
for u in units
|
| 101 |
+
if str(u.get("type", "")).lower() == "harv"
|
| 102 |
+
]
|
| 103 |
+
cmds = []
|
| 104 |
+
for i, (uid, cx, cy) in enumerate(harvs):
|
| 105 |
+
uid_s = str(uid)
|
| 106 |
+
px, py = patches[i % len(patches)]
|
| 107 |
+
if uid_s not in state["moved"]:
|
| 108 |
+
cmds.append(Command.move_units([uid_s], target_x=px, target_y=py))
|
| 109 |
+
if abs(cx - px) <= 3 and abs(cy - py) <= 3:
|
| 110 |
+
state["moved"].add(uid_s)
|
| 111 |
+
else:
|
| 112 |
+
cmds.append(Command.harvest([uid_s], px, py))
|
| 113 |
+
return cmds if cmds else [Command.observe()]
|
| 114 |
+
|
| 115 |
+
return policy
|
| 116 |
+
|
| 117 |
+
|
| 118 |
+
def _build_army_factory():
|
| 119 |
+
"""Spend the reserve on powr → tent → e1 instead of rebuilding
|
| 120 |
+
the proc. The 1400 cash is drained on the wrong assets; the proc
|
| 121 |
+
rebuild never funds → `has_building:proc` fails permanently on
|
| 122 |
+
medium/hard → LOSS."""
|
| 123 |
+
|
| 124 |
+
state = {"moved": set()}
|
| 125 |
+
|
| 126 |
+
def policy(rs, Command):
|
| 127 |
+
units = rs.get("units_summary", []) or []
|
| 128 |
+
bldgs = rs.get("own_buildings", []) or []
|
| 129 |
+
own_types = {b["type"] for b in bldgs}
|
| 130 |
+
prod = rs.get("production", []) or []
|
| 131 |
+
cash = rs.get("cash", 0)
|
| 132 |
+
fact_b = _own_fact(bldgs)
|
| 133 |
+
if fact_b is None:
|
| 134 |
+
return [Command.observe()]
|
| 135 |
+
fx, fy = fact_b["cell_x"], fact_b["cell_y"]
|
| 136 |
+
patches = _patches_for_fact(fy)
|
| 137 |
+
harvs = [
|
| 138 |
+
(u["id"], u.get("cell_x"), u.get("cell_y"))
|
| 139 |
+
for u in units
|
| 140 |
+
if str(u.get("type", "")).lower() == "harv"
|
| 141 |
+
]
|
| 142 |
+
cmds = []
|
| 143 |
+
for i, (uid, cx, cy) in enumerate(harvs):
|
| 144 |
+
uid_s = str(uid)
|
| 145 |
+
px, py = patches[i % len(patches)]
|
| 146 |
+
if uid_s not in state["moved"]:
|
| 147 |
+
cmds.append(Command.move_units([uid_s], target_x=px, target_y=py))
|
| 148 |
+
if abs(cx - px) <= 3 and abs(cy - py) <= 3:
|
| 149 |
+
state["moved"].add(uid_s)
|
| 150 |
+
else:
|
| 151 |
+
cmds.append(Command.harvest([uid_s], px, py))
|
| 152 |
+
if "powr" not in own_types:
|
| 153 |
+
if cash >= 300 and "powr" not in prod:
|
| 154 |
+
cmds.append(Command.build("powr"))
|
| 155 |
+
cmds.append(Command.place_building("powr", fx + 2, fy - 3))
|
| 156 |
+
elif "tent" not in own_types:
|
| 157 |
+
if cash >= 500 and "tent" not in prod:
|
| 158 |
+
cmds.append(Command.build("tent"))
|
| 159 |
+
cmds.append(Command.place_building("tent", fx + 2, fy + 3))
|
| 160 |
+
elif cash >= 100 and "e1" not in prod:
|
| 161 |
+
cmds.append(Command.build("e1"))
|
| 162 |
+
return cmds if cmds else [Command.observe()]
|
| 163 |
+
|
| 164 |
+
return policy
|
| 165 |
+
|
| 166 |
+
|
| 167 |
+
def _intended_rebuild_factory():
|
| 168 |
+
"""Intended recovery chain: harvest with the starting harv → if
|
| 169 |
+
proc disappears, `build('proc')` + place adjacent to the fact →
|
| 170 |
+
once the new proc lands, re-issue `harvest` for the surviving
|
| 171 |
+
harv. WINS every tier × every seed (on easy the proc never
|
| 172 |
+
falls; on medium/hard the rebuild fires)."""
|
| 173 |
+
|
| 174 |
+
state = {"moved": set(), "placed_proc": False}
|
| 175 |
+
|
| 176 |
+
def policy(rs, Command):
|
| 177 |
+
units = rs.get("units_summary", []) or []
|
| 178 |
+
bldgs = rs.get("own_buildings", []) or []
|
| 179 |
+
own_types = [b["type"] for b in bldgs]
|
| 180 |
+
prod = rs.get("production", []) or []
|
| 181 |
+
cash = rs.get("cash", 0)
|
| 182 |
+
fact_b = _own_fact(bldgs)
|
| 183 |
+
if fact_b is None:
|
| 184 |
+
return [Command.observe()]
|
| 185 |
+
fx, fy = fact_b["cell_x"], fact_b["cell_y"]
|
| 186 |
+
patches = _patches_for_fact(fy)
|
| 187 |
+
n_proc = sum(1 for t in own_types if t == "proc")
|
| 188 |
+
|
| 189 |
+
harvs = [
|
| 190 |
+
(u["id"], u.get("cell_x"), u.get("cell_y"))
|
| 191 |
+
for u in units
|
| 192 |
+
if str(u.get("type", "")).lower() == "harv"
|
| 193 |
+
]
|
| 194 |
+
|
| 195 |
+
cmds = []
|
| 196 |
+
# If proc is gone, queue a rebuild and spam place_building.
|
| 197 |
+
if n_proc < 1:
|
| 198 |
+
if cash >= 1400 and "proc" not in prod:
|
| 199 |
+
cmds.append(Command.build("proc"))
|
| 200 |
+
# Place at the original proc spot (fx+4, fy) — adjacent to
|
| 201 |
+
# the fact, in-bounds on the rush-hour map.
|
| 202 |
+
cmds.append(Command.place_building("proc", fx + 4, fy))
|
| 203 |
+
|
| 204 |
+
# Always nudge each harv onto a patch and harvest. Re-issue
|
| 205 |
+
# the harvest order EVERY turn so that when the new proc
|
| 206 |
+
# lands, the auto-cycle resumes (it does not auto-resume
|
| 207 |
+
# across a destroy-rebuild gap).
|
| 208 |
+
for i, (uid, cx, cy) in enumerate(harvs):
|
| 209 |
+
uid_s = str(uid)
|
| 210 |
+
px, py = patches[i % len(patches)]
|
| 211 |
+
if uid_s not in state["moved"]:
|
| 212 |
+
cmds.append(Command.move_units([uid_s], target_x=px, target_y=py))
|
| 213 |
+
if abs(cx - px) <= 3 and abs(cy - py) <= 3:
|
| 214 |
+
state["moved"].add(uid_s)
|
| 215 |
+
cmds.append(Command.harvest([uid_s], px, py))
|
| 216 |
+
return cmds if cmds else [Command.observe()]
|
| 217 |
+
|
| 218 |
+
return policy
|
| 219 |
+
|
| 220 |
+
|
| 221 |
+
def _run(level, policy_or_factory, seed=1):
|
| 222 |
+
c = compile_level(load_pack(PACK), level)
|
| 223 |
+
assert c.map_supported, "rush-hour-arena must compile"
|
| 224 |
+
pol = (
|
| 225 |
+
policy_or_factory()
|
| 226 |
+
if callable(policy_or_factory)
|
| 227 |
+
and policy_or_factory.__name__.endswith("factory")
|
| 228 |
+
else policy_or_factory
|
| 229 |
+
)
|
| 230 |
+
return c, run_level(c, pol, seed=seed)
|
| 231 |
+
|
| 232 |
+
|
| 233 |
+
# ───────────────────────── structural ─────────────────────────────────────
|
| 234 |
+
|
| 235 |
+
|
| 236 |
+
def test_pack_loads_with_three_levels_and_required_tools():
|
| 237 |
+
pack = load_pack(PACK)
|
| 238 |
+
assert pack.meta.id == "rob-cash-depletion-recovery"
|
| 239 |
+
assert pack.meta.capability == "reasoning"
|
| 240 |
+
assert pack.meta.status == "active"
|
| 241 |
+
assert set(pack.levels) == set(LEVELS)
|
| 242 |
+
c = compile_level(pack, "easy")
|
| 243 |
+
tools = set(c.scenario.tools or [])
|
| 244 |
+
# Spec-required tools (Wave-6 spec): all 8.
|
| 245 |
+
for t in (
|
| 246 |
+
"observe",
|
| 247 |
+
"build",
|
| 248 |
+
"place_building",
|
| 249 |
+
"harvest",
|
| 250 |
+
"move_units",
|
| 251 |
+
"attack_unit",
|
| 252 |
+
"attack_move",
|
| 253 |
+
"stop",
|
| 254 |
+
):
|
| 255 |
+
assert t in tools, f"missing tool {t} in {tools}"
|
| 256 |
+
|
| 257 |
+
|
| 258 |
+
def test_starting_cash_is_one_proc_on_every_level():
|
| 259 |
+
"""Reserve must fund EXACTLY one refinery rebuild — any other
|
| 260 |
+
value breaks the indivisible-rebuild test."""
|
| 261 |
+
pack = load_pack(PACK)
|
| 262 |
+
for L in LEVELS:
|
| 263 |
+
c = compile_level(pack, L)
|
| 264 |
+
assert c.starting_cash == 1400, (
|
| 265 |
+
f"{L}: starting_cash must equal proc cost 1400 (got {c.starting_cash})"
|
| 266 |
+
)
|
| 267 |
+
|
| 268 |
+
|
| 269 |
+
def test_benchmark_anchor_lists_financial_and_recovery():
|
| 270 |
+
pack = load_pack(PACK)
|
| 271 |
+
anchors = pack.meta.benchmark_anchor
|
| 272 |
+
assert anchors, "benchmark_anchor must be non-empty"
|
| 273 |
+
blob = " | ".join(anchors).lower()
|
| 274 |
+
assert "sc2" in blob or "cash" in blob, anchors
|
| 275 |
+
assert "recovery" in blob or "turnaround" in blob or "rebuild" in blob, anchors
|
| 276 |
+
|
| 277 |
+
|
| 278 |
+
@pytest.mark.parametrize("level", LEVELS)
|
| 279 |
+
def test_every_level_has_a_reachable_timeout_fail(level):
|
| 280 |
+
"""Non-win must be a real LOSS, never a DRAW: after_ticks in
|
| 281 |
+
fail_condition must be reachable within max_turns. within_ticks
|
| 282 |
+
+ 1 == after_ticks idiom."""
|
| 283 |
+
c = compile_level(load_pack(PACK), level)
|
| 284 |
+
assert c.fail_condition is not None
|
| 285 |
+
after_ticks = int(c.fail_condition.model_dump()["any_of"][0]["after_ticks"])
|
| 286 |
+
reachable = 93 + 90 * (c.max_turns - 1)
|
| 287 |
+
assert after_ticks <= reachable, (
|
| 288 |
+
f"{level}: fail after_ticks {after_ticks} unreachable within "
|
| 289 |
+
f"{c.max_turns} turns (max tick {reachable}) → draw degeneracy"
|
| 290 |
+
)
|
| 291 |
+
within_clauses = c.win_condition.model_dump().get("all_of", [])
|
| 292 |
+
wt = next(int(x["within_ticks"]) for x in within_clauses if "within_ticks" in x)
|
| 293 |
+
assert after_ticks == wt + 1, (
|
| 294 |
+
f"{level}: after_ticks {after_ticks} must equal within_ticks+1 ({wt + 1})"
|
| 295 |
+
)
|
| 296 |
+
|
| 297 |
+
|
| 298 |
+
def test_hard_strike_destroys_proc_and_tent_per_spec():
|
| 299 |
+
"""Hard tier strike-severity contract (the controlled variable
|
| 300 |
+
vs medium): the scripted enemy strike must destroy BOTH proc
|
| 301 |
+
AND tent — not just one — so the rebuild decision discipline
|
| 302 |
+
(income-restoring proc rebuild prioritised over defensive-tech
|
| 303 |
+
tent rebuild) is exercised. Verified via the stall policy
|
| 304 |
+
(the strike outcome is deterministic regardless of agent
|
| 305 |
+
actions; stall lets the strike land cleanly)."""
|
| 306 |
+
c, r = _run("hard", _stall, seed=1)
|
| 307 |
+
# The proc and tent are pre-placed at start (additive set
|
| 308 |
+
# confirms ever-existed); the per-frame list confirms both are
|
| 309 |
+
# GONE by episode end (= scripted strike landed).
|
| 310 |
+
assert "proc" in r.signals.own_building_types, (
|
| 311 |
+
"proc must have been pre-placed (additive set)"
|
| 312 |
+
)
|
| 313 |
+
assert "tent" in r.signals.own_building_types, (
|
| 314 |
+
"tent must have been pre-placed (additive set)"
|
| 315 |
+
)
|
| 316 |
+
cur = _current_building_types(r)
|
| 317 |
+
assert "proc" not in cur, (
|
| 318 |
+
f"hard strike must destroy the proc; currently-alive types={cur}"
|
| 319 |
+
)
|
| 320 |
+
assert "tent" not in cur, (
|
| 321 |
+
f"hard strike must destroy the tent; currently-alive types={cur}"
|
| 322 |
+
)
|
| 323 |
+
|
| 324 |
+
|
| 325 |
+
# ───────────────────────── intended WIN bar ───────────────────────────────
|
| 326 |
+
|
| 327 |
+
|
| 328 |
+
@pytest.mark.parametrize("level", LEVELS)
|
| 329 |
+
@pytest.mark.parametrize("seed", SEEDS)
|
| 330 |
+
def test_intended_rebuild_wins(level, seed):
|
| 331 |
+
"""Intended recovery (harvest with starting harv → on proc death,
|
| 332 |
+
build('proc') + place + re-harvest) WINS every tier × every seed."""
|
| 333 |
+
c, r = _run(level, _intended_rebuild_factory, seed=seed)
|
| 334 |
+
assert r.outcome == "win", (
|
| 335 |
+
f"{level} seed{seed}: intended rebuild should WIN, got {r.outcome}; "
|
| 336 |
+
f"ev={_ev(r)}, cash={r.signals.cash}, "
|
| 337 |
+
f"types={r.signals.own_building_types}, tick={r.signals.game_tick}"
|
| 338 |
+
)
|
| 339 |
+
|
| 340 |
+
|
| 341 |
+
# ───────────────────────── no-cheat: lazy plays LOSE ──────────────────────
|
| 342 |
+
|
| 343 |
+
|
| 344 |
+
@pytest.mark.parametrize("level", LEVELS)
|
| 345 |
+
@pytest.mark.parametrize("seed", SEEDS)
|
| 346 |
+
def test_stall_loses(level, seed):
|
| 347 |
+
"""Stall (only `observe`) must LOSE every tier × every seed — no
|
| 348 |
+
harvest income, EV stays at $1400, bar 2500 unmet, clock bites."""
|
| 349 |
+
c, r = _run(level, _stall, seed=seed)
|
| 350 |
+
assert r.outcome == "loss", (
|
| 351 |
+
f"{level} seed{seed} stall must LOSE; got {r.outcome} "
|
| 352 |
+
f"(ev={_ev(r)}, tick={r.signals.game_tick})"
|
| 353 |
+
)
|
| 354 |
+
|
| 355 |
+
|
| 356 |
+
@pytest.mark.parametrize("seed", SEEDS)
|
| 357 |
+
def test_no_rebuild_is_easy_floor(seed):
|
| 358 |
+
"""No-rebuild (harvest with starting harv only) is the EASY
|
| 359 |
+
FLOOR — the lone 4tnk is killed by the defender ring, the proc
|
| 360 |
+
survives, harvest income clears the 2500 bar comfortably."""
|
| 361 |
+
c, r = _run("easy", _no_rebuild_factory, seed=seed)
|
| 362 |
+
assert r.outcome == "win", (
|
| 363 |
+
f"easy seed{seed} no-rebuild should be the FLOOR and WIN; "
|
| 364 |
+
f"got {r.outcome} (ev={_ev(r)}, "
|
| 365 |
+
f"types={r.signals.own_building_types})"
|
| 366 |
+
)
|
| 367 |
+
|
| 368 |
+
|
| 369 |
+
@pytest.mark.parametrize("level", ("medium", "hard"))
|
| 370 |
+
@pytest.mark.parametrize("seed", SEEDS)
|
| 371 |
+
def test_no_rebuild_loses_medium_and_hard(level, seed):
|
| 372 |
+
"""No-rebuild LOSES medium/hard — the strike destroys the proc;
|
| 373 |
+
the `has_building:proc` clause fails permanently regardless of
|
| 374 |
+
cash/EV. This is the discriminator that the recovery chain
|
| 375 |
+
(`build('proc') + place_building`) is actually exercised."""
|
| 376 |
+
c, r = _run(level, _no_rebuild_factory, seed=seed)
|
| 377 |
+
assert r.outcome == "loss", (
|
| 378 |
+
f"{level} seed{seed} no-rebuild must LOSE; got {r.outcome} "
|
| 379 |
+
f"(ev={_ev(r)}, types={r.signals.own_building_types})"
|
| 380 |
+
)
|
| 381 |
+
# The proc must have actually been destroyed for the
|
| 382 |
+
# discrimination to be real (not an EV-bar miss). Note:
|
| 383 |
+
# `own_building_types` is accumulative; use `own_buildings`
|
| 384 |
+
# (current per-frame list) for the "alive now" check.
|
| 385 |
+
cur = _current_building_types(r)
|
| 386 |
+
assert "proc" not in cur, (
|
| 387 |
+
f"{level}: no-rebuild should observe proc destroyed; "
|
| 388 |
+
f"currently-alive types={cur}"
|
| 389 |
+
)
|
| 390 |
+
|
| 391 |
+
|
| 392 |
+
@pytest.mark.parametrize("level", ("medium", "hard"))
|
| 393 |
+
@pytest.mark.parametrize("seed", SEEDS)
|
| 394 |
+
def test_build_army_loses_medium_and_hard(level, seed):
|
| 395 |
+
"""Build-army (powr → tent → e1) drains the reserve on the wrong
|
| 396 |
+
assets; the proc rebuild never funds → `has_building:proc` clause
|
| 397 |
+
fails permanently → LOSS."""
|
| 398 |
+
c, r = _run(level, _build_army_factory, seed=seed)
|
| 399 |
+
assert r.outcome == "loss", (
|
| 400 |
+
f"{level} seed{seed} build-army must LOSE; got {r.outcome} "
|
| 401 |
+
f"(ev={_ev(r)}, types={r.signals.own_building_types})"
|
| 402 |
+
)
|
| 403 |
+
# Wrong-asset commit confirmed via the accumulative set (any
|
| 404 |
+
# type ever built shows here); proc CURRENTLY alive checked via
|
| 405 |
+
# the per-frame list.
|
| 406 |
+
cur = _current_building_types(r)
|
| 407 |
+
assert "proc" not in cur, (
|
| 408 |
+
f"{level}: build-army should NEVER re-fund the proc; "
|
| 409 |
+
f"currently-alive types={cur}"
|
| 410 |
+
)
|
| 411 |
+
|
| 412 |
+
|
| 413 |
+
# ───────────────────────── determinism ────────────────────────────────────
|
| 414 |
+
|
| 415 |
+
|
| 416 |
+
def test_outcomes_are_deterministic_per_seed():
|
| 417 |
+
"""Same seed, same pack, same policy → identical outcome."""
|
| 418 |
+
c = compile_level(load_pack(PACK), "medium")
|
| 419 |
+
a = run_level(c, _intended_rebuild_factory(), seed=2)
|
| 420 |
+
b = run_level(c, _intended_rebuild_factory(), seed=2)
|
| 421 |
+
assert (a.outcome, a.turns, _ev(a)) == (b.outcome, b.turns, _ev(b)), (
|
| 422 |
+
f"determinism: {(a.outcome, a.turns, _ev(a))} vs "
|
| 423 |
+
f"{(b.outcome, b.turns, _ev(b))}"
|
| 424 |
+
)
|