Spaces:
Running
mapgen: obstacles, bridges-arena, chokepoint-arena + per-scenario authoring
Browse filesExtend the YAML-referenceable map generator with three new tools so
each scenario pack can have a map tailored to what it measures
instead of all sharing rush-hour-arena.
* `arena` generator: new `obstacles: [{x,y,w,h}, ...]` parameter
paints WATER rectangles inside the playable area (cover, channels,
forced-flank obstacles).
* `bridges-arena` generator: a horizontal or vertical water channel
with N explicit bridge gaps — canonical chokepoint-defense map.
Honours all `arena` knobs plus axis / channel_y or _x /
channel_width / bridges (list of {pos, width}).
* `chokepoint-arena` generator: two open lobes linked by one narrow
corridor through a thick wall — forces the attacker through a
single cell.
* Refactor: extract `_blank_arena()`, `_stamp_rect()`, and
`_default_spawns()` helpers shared across generators.
3 new tests cover obstacle painting, bridge gaps, and corridor-only
routing (9 total now; 6 existing pass unchanged).
`scripts/author_perscenario_maps.py` declares per-pack map specs
for the 12 engine-feature debug packs — each scenario gets a
tailored .oramap (bridges for def-bridge-chokepoint, chokepoint for
spec-thief-steal-cash, small focused arena for spec-tanya-c4-strike,
etc). `--dry-run` materialises the maps without touching YAML
(committing only the infrastructure for now).
Validation found 7/12 packs whose existing actor coords collide
with the proposed map geometry: ~5 econ/spec packs have actors at
x=120 designed for rush-hour's 128 width that fall outside smaller
custom maps, def-bridge-chokepoint has enemy infantry on the vertical
water channel, and spec-spy-infiltrate has a pre-existing bug placing
enemy buildings at y=80-100 (out of bounds for any 40-high map —
silently ignored in rush-hour-arena too). Next wave will either
upsize the maps to fit existing coords, or rewrite per-pack actor
positions to fit tailored maps.
- openra_bench/mapgen.py +153 -11
- scripts/author_perscenario_maps.py +233 -0
- tests/test_mapgen.py +71 -0
|
@@ -40,18 +40,14 @@ def generator(name: str):
|
|
| 40 |
return _reg
|
| 41 |
|
| 42 |
|
| 43 |
-
|
| 44 |
-
|
| 45 |
-
"""
|
| 46 |
-
and scout-arena). Params: width, height, cordon."""
|
| 47 |
-
w = int(spec.get("width", 128))
|
| 48 |
-
h = int(spec.get("height", 40))
|
| 49 |
-
c = int(spec.get("cordon", spec.get("bounds", 2)))
|
| 50 |
if not (16 <= w <= 512 and 16 <= h <= 512):
|
| 51 |
raise ValueError(f"arena size out of range: {w}x{h}")
|
| 52 |
if not (0 <= c < min(w, h) // 2):
|
| 53 |
raise ValueError(f"arena cordon out of range: {c}")
|
| 54 |
-
|
| 55 |
[
|
| 56 |
WATER
|
| 57 |
if (x < c or x >= w - c or y < c or y >= h - c)
|
|
@@ -60,15 +56,161 @@ def _arena(spec: dict):
|
|
| 60 |
]
|
| 61 |
for y in range(h)
|
| 62 |
]
|
| 63 |
-
|
| 64 |
-
|
| 65 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 66 |
(c + 4, c + 4), (w - c - 5, c + 4),
|
| 67 |
(c + 4, h - c - 5), (w - c - 5, h - c - 5),
|
| 68 |
]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 69 |
return grid, (w, h), bounds, spawns, spec.get("title", f"Arena {w}x{h}")
|
| 70 |
|
| 71 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 72 |
@generator("naval-arena")
|
| 73 |
def _naval_arena(spec: dict):
|
| 74 |
"""Naval-arena: an open rectangle (like `arena`) with a documented
|
|
|
|
| 40 |
return _reg
|
| 41 |
|
| 42 |
|
| 43 |
+
def _blank_arena(w: int, h: int, c: int) -> list[list[int]]:
|
| 44 |
+
"""Open arena with a water cordon (the base canvas every generator
|
| 45 |
+
builds on)."""
|
|
|
|
|
|
|
|
|
|
|
|
|
| 46 |
if not (16 <= w <= 512 and 16 <= h <= 512):
|
| 47 |
raise ValueError(f"arena size out of range: {w}x{h}")
|
| 48 |
if not (0 <= c < min(w, h) // 2):
|
| 49 |
raise ValueError(f"arena cordon out of range: {c}")
|
| 50 |
+
return [
|
| 51 |
[
|
| 52 |
WATER
|
| 53 |
if (x < c or x >= w - c or y < c or y >= h - c)
|
|
|
|
| 56 |
]
|
| 57 |
for y in range(h)
|
| 58 |
]
|
| 59 |
+
|
| 60 |
+
|
| 61 |
+
def _stamp_rect(grid: list[list[int]], rect: dict, tile: int) -> None:
|
| 62 |
+
"""Paint a rectangle of `tile` onto `grid`. `rect` is {x, y, w, h}
|
| 63 |
+
in playable-area coords (the cordon is honoured)."""
|
| 64 |
+
h = len(grid); w = len(grid[0])
|
| 65 |
+
rx = int(rect["x"]); ry = int(rect["y"])
|
| 66 |
+
rw = int(rect.get("w", 1)); rh = int(rect.get("h", 1))
|
| 67 |
+
for y in range(max(0, ry), min(h, ry + rh)):
|
| 68 |
+
for x in range(max(0, rx), min(w, rx + rw)):
|
| 69 |
+
grid[y][x] = tile
|
| 70 |
+
|
| 71 |
+
|
| 72 |
+
def _default_spawns(w: int, h: int, c: int) -> list[tuple[int, int]]:
|
| 73 |
+
return [
|
| 74 |
(c + 4, c + 4), (w - c - 5, c + 4),
|
| 75 |
(c + 4, h - c - 5), (w - c - 5, h - c - 5),
|
| 76 |
]
|
| 77 |
+
|
| 78 |
+
|
| 79 |
+
@generator("arena")
|
| 80 |
+
def _arena(spec: dict):
|
| 81 |
+
"""Open rectangle with a water cordon + optional interior obstacles.
|
| 82 |
+
|
| 83 |
+
Params:
|
| 84 |
+
width, height (16..512), cordon (default 2).
|
| 85 |
+
obstacles: list of {x, y, w, h} rectangles painted as WATER.
|
| 86 |
+
Each rectangle is in absolute map coords; the cordon is
|
| 87 |
+
the impassable frame, obstacles paint additional water
|
| 88 |
+
INSIDE the playable area (forces flanking, cover, etc).
|
| 89 |
+
spawns: optional list of [x, y] overriding the 4 corner defaults.
|
| 90 |
+
title: optional human-readable map title.
|
| 91 |
+
"""
|
| 92 |
+
w = int(spec.get("width", 128))
|
| 93 |
+
h = int(spec.get("height", 40))
|
| 94 |
+
c = int(spec.get("cordon", spec.get("bounds", 2)))
|
| 95 |
+
grid = _blank_arena(w, h, c)
|
| 96 |
+
for rect in (spec.get("obstacles") or []):
|
| 97 |
+
_stamp_rect(grid, rect, WATER)
|
| 98 |
+
spawns = [tuple(s) for s in spec["spawns"]] if spec.get("spawns") \
|
| 99 |
+
else _default_spawns(w, h, c)
|
| 100 |
+
bounds = (c, c, w - 2 * c, h - 2 * c)
|
| 101 |
return grid, (w, h), bounds, spawns, spec.get("title", f"Arena {w}x{h}")
|
| 102 |
|
| 103 |
|
| 104 |
+
@generator("bridges-arena")
|
| 105 |
+
def _bridges_arena(spec: dict):
|
| 106 |
+
"""Open arena split by a horizontal or vertical water channel with
|
| 107 |
+
N bridge gaps — the canonical 'chokepoint defense' map.
|
| 108 |
+
|
| 109 |
+
Params:
|
| 110 |
+
width, height, cordon as in `arena`.
|
| 111 |
+
axis: 'horizontal' (default) splits along y; 'vertical' splits
|
| 112 |
+
along x.
|
| 113 |
+
channel_y / channel_x: the cell index of the water row/column
|
| 114 |
+
(defaults to the map midpoint).
|
| 115 |
+
channel_width: thickness of the water band (default 2 cells).
|
| 116 |
+
bridges: list of {pos, width} declaring the cell position of
|
| 117 |
+
each crossing (along the axis perpendicular to the channel)
|
| 118 |
+
and how many cells wide the gap is. Default: 3 evenly-spaced
|
| 119 |
+
2-cell bridges.
|
| 120 |
+
obstacles: optional extra WATER rectangles, as in `arena`.
|
| 121 |
+
spawns / title: as in `arena`.
|
| 122 |
+
"""
|
| 123 |
+
w = int(spec.get("width", 128))
|
| 124 |
+
h = int(spec.get("height", 40))
|
| 125 |
+
c = int(spec.get("cordon", 2))
|
| 126 |
+
grid = _blank_arena(w, h, c)
|
| 127 |
+
axis = spec.get("axis", "horizontal")
|
| 128 |
+
cw = int(spec.get("channel_width", 2))
|
| 129 |
+
bridges = spec.get("bridges")
|
| 130 |
+
if axis == "horizontal":
|
| 131 |
+
cy = int(spec.get("channel_y", h // 2))
|
| 132 |
+
if bridges is None:
|
| 133 |
+
# 3 evenly-spaced 2-cell bridges
|
| 134 |
+
third = (w - 2 * c) // 4
|
| 135 |
+
bridges = [
|
| 136 |
+
{"pos": c + third, "width": 2},
|
| 137 |
+
{"pos": c + 2 * third, "width": 2},
|
| 138 |
+
{"pos": c + 3 * third, "width": 2},
|
| 139 |
+
]
|
| 140 |
+
for dy in range(cy, min(h, cy + cw)):
|
| 141 |
+
for x in range(c, w - c):
|
| 142 |
+
grid[dy][x] = WATER
|
| 143 |
+
for br in bridges:
|
| 144 |
+
bx = int(br["pos"]); bw = int(br.get("width", 2))
|
| 145 |
+
for dy in range(cy, min(h, cy + cw)):
|
| 146 |
+
for x in range(max(c, bx), min(w - c, bx + bw)):
|
| 147 |
+
grid[dy][x] = CLEAR
|
| 148 |
+
elif axis == "vertical":
|
| 149 |
+
cx = int(spec.get("channel_x", w // 2))
|
| 150 |
+
if bridges is None:
|
| 151 |
+
third = (h - 2 * c) // 4
|
| 152 |
+
bridges = [
|
| 153 |
+
{"pos": c + third, "width": 2},
|
| 154 |
+
{"pos": c + 2 * third, "width": 2},
|
| 155 |
+
{"pos": c + 3 * third, "width": 2},
|
| 156 |
+
]
|
| 157 |
+
for dx in range(cx, min(w, cx + cw)):
|
| 158 |
+
for y in range(c, h - c):
|
| 159 |
+
grid[y][dx] = WATER
|
| 160 |
+
for br in bridges:
|
| 161 |
+
by = int(br["pos"]); bh = int(br.get("width", 2))
|
| 162 |
+
for dx in range(cx, min(w, cx + cw)):
|
| 163 |
+
for y in range(max(c, by), min(h - c, by + bh)):
|
| 164 |
+
grid[y][dx] = CLEAR
|
| 165 |
+
else:
|
| 166 |
+
raise ValueError(f"bridges-arena axis must be horizontal/vertical, got {axis!r}")
|
| 167 |
+
for rect in (spec.get("obstacles") or []):
|
| 168 |
+
_stamp_rect(grid, rect, WATER)
|
| 169 |
+
spawns = [tuple(s) for s in spec["spawns"]] if spec.get("spawns") \
|
| 170 |
+
else _default_spawns(w, h, c)
|
| 171 |
+
bounds = (c, c, w - 2 * c, h - 2 * c)
|
| 172 |
+
return grid, (w, h), bounds, spawns, spec.get("title", f"Bridges {w}x{h}")
|
| 173 |
+
|
| 174 |
+
|
| 175 |
+
@generator("chokepoint-arena")
|
| 176 |
+
def _chokepoint_arena(spec: dict):
|
| 177 |
+
"""Two open lobes linked by a single narrow corridor — the canonical
|
| 178 |
+
'force the attacker through one cell' map. Defender bias: small army
|
| 179 |
+
holding the pinch beats a larger army that can't deploy width.
|
| 180 |
+
|
| 181 |
+
Params:
|
| 182 |
+
width, height, cordon as in `arena`.
|
| 183 |
+
pinch_x: x of the corridor wall (default w//2).
|
| 184 |
+
pinch_width: thickness of the wall on each side of the corridor
|
| 185 |
+
(default 6 cells of WATER above + below the corridor).
|
| 186 |
+
corridor_width: open height of the corridor gap (default 4).
|
| 187 |
+
corridor_y: y of the corridor centre (default h//2).
|
| 188 |
+
obstacles / spawns / title: as in `arena`.
|
| 189 |
+
"""
|
| 190 |
+
w = int(spec.get("width", 96))
|
| 191 |
+
h = int(spec.get("height", 40))
|
| 192 |
+
c = int(spec.get("cordon", 2))
|
| 193 |
+
grid = _blank_arena(w, h, c)
|
| 194 |
+
px = int(spec.get("pinch_x", w // 2))
|
| 195 |
+
pw = int(spec.get("pinch_width", 6)) # wall thickness vertical
|
| 196 |
+
cw = int(spec.get("corridor_width", 4)) # corridor height
|
| 197 |
+
cy = int(spec.get("corridor_y", h // 2))
|
| 198 |
+
# Wall above corridor
|
| 199 |
+
for y in range(c, cy - cw // 2):
|
| 200 |
+
for x in range(max(c, px - pw // 2), min(w - c, px + pw // 2 + 1)):
|
| 201 |
+
grid[y][x] = WATER
|
| 202 |
+
# Wall below corridor
|
| 203 |
+
for y in range(cy + (cw + 1) // 2, h - c):
|
| 204 |
+
for x in range(max(c, px - pw // 2), min(w - c, px + pw // 2 + 1)):
|
| 205 |
+
grid[y][x] = WATER
|
| 206 |
+
for rect in (spec.get("obstacles") or []):
|
| 207 |
+
_stamp_rect(grid, rect, WATER)
|
| 208 |
+
spawns = [tuple(s) for s in spec["spawns"]] if spec.get("spawns") \
|
| 209 |
+
else [(c + 4, h // 2), (w - c - 5, h // 2)]
|
| 210 |
+
bounds = (c, c, w - 2 * c, h - 2 * c)
|
| 211 |
+
return grid, (w, h), bounds, spawns, spec.get("title", f"Chokepoint {w}x{h}")
|
| 212 |
+
|
| 213 |
+
|
| 214 |
@generator("naval-arena")
|
| 215 |
def _naval_arena(spec: dict):
|
| 216 |
"""Naval-arena: an open rectangle (like `arena`) with a documented
|
|
@@ -0,0 +1,233 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env python3
|
| 2 |
+
"""Author per-scenario maps for each scenario pack.
|
| 3 |
+
|
| 4 |
+
The bench currently shares one 128×40 `rush-hour-arena.oramap` across
|
| 5 |
+
194 of 210 packs. Each scenario should have a map TAILORED to what it
|
| 6 |
+
measures — small focused arenas for short-execution packs (Tanya C4),
|
| 7 |
+
chokepoint maps for defensive packs, multi-zone maps for expansion /
|
| 8 |
+
multi-base packs, bridges for crossing-defense packs.
|
| 9 |
+
|
| 10 |
+
This script declares a per-pack map spec (one dict per pack id) and:
|
| 11 |
+
|
| 12 |
+
--dry-run : materialize the .oramaps + print a summary;
|
| 13 |
+
do NOT edit any pack YAMLs.
|
| 14 |
+
--apply : in addition, rewrite each pack's `base_map:`
|
| 15 |
+
line to point at the new per-scenario .oramap.
|
| 16 |
+
--validate : run a stall-policy smoke through each pack to
|
| 17 |
+
confirm the new map loads cleanly (no panic) and
|
| 18 |
+
the scenario still terminates.
|
| 19 |
+
|
| 20 |
+
Authoring rule of thumb (from CLAUDE.md):
|
| 21 |
+
- small open arena → short-execution packs (Tanya C4, engineer
|
| 22 |
+
capture easy, spy infiltrate easy).
|
| 23 |
+
- medium with corridor / chokepoint → packs that need a forced
|
| 24 |
+
pinch (def-bridge-chokepoint, chokepoint family).
|
| 25 |
+
- large open arena → search / perception packs (spec-nuke-strike,
|
| 26 |
+
scout-far-frontier).
|
| 27 |
+
- bridges-arena → packs whose briefing references bridges
|
| 28 |
+
(def-bridge-chokepoint).
|
| 29 |
+
- naval-arena → packs with water + ships (combat-naval-shore-strike).
|
| 30 |
+
|
| 31 |
+
The 12-pack debug grid is the first wave; the remaining ~198 packs
|
| 32 |
+
follow in subsequent waves once these are validated.
|
| 33 |
+
"""
|
| 34 |
+
from __future__ import annotations
|
| 35 |
+
|
| 36 |
+
import argparse
|
| 37 |
+
import sys
|
| 38 |
+
from pathlib import Path
|
| 39 |
+
|
| 40 |
+
HERE = Path(__file__).resolve().parent
|
| 41 |
+
REPO = HERE.parent
|
| 42 |
+
if str(REPO) not in sys.path:
|
| 43 |
+
sys.path.insert(0, str(REPO))
|
| 44 |
+
|
| 45 |
+
from openra_bench.mapgen import materialize # noqa: E402
|
| 46 |
+
|
| 47 |
+
# ── per-pack map specs ────────────────────────────────────────────────
|
| 48 |
+
# Each spec is the dict you'd put under `base_map:` in the pack YAML.
|
| 49 |
+
# Generators understand: arena, bridges-arena, chokepoint-arena,
|
| 50 |
+
# naval-arena. `name` slugs become the .oramap filename.
|
| 51 |
+
PER_PACK_MAPS: dict[str, dict] = {
|
| 52 |
+
# ── special-ability family ────────────────────────────────────────
|
| 53 |
+
# Short execution; small focused arena keeps the run on the
|
| 54 |
+
# action, not the navigation.
|
| 55 |
+
"spec-tanya-c4-strike": {
|
| 56 |
+
"generator": "arena",
|
| 57 |
+
"name": "spec-tanya-c4-strike-arena",
|
| 58 |
+
"title": "Tanya Strike — close-range execution",
|
| 59 |
+
"width": 80, "height": 32, "cordon": 2,
|
| 60 |
+
},
|
| 61 |
+
# Engineer is fragile; one obstacle band creates a north/south
|
| 62 |
+
# choice that tests path planning under attrition.
|
| 63 |
+
"spec-engineer-capture": {
|
| 64 |
+
"generator": "arena",
|
| 65 |
+
"name": "spec-engineer-capture-arena",
|
| 66 |
+
"title": "Engineer Capture — escort path",
|
| 67 |
+
"width": 96, "height": 36, "cordon": 2,
|
| 68 |
+
"obstacles": [{"x": 44, "y": 14, "w": 8, "h": 8}],
|
| 69 |
+
},
|
| 70 |
+
# Spy infiltrates fogged; medium arena with TWO cover clusters
|
| 71 |
+
# offers a stealth-route choice.
|
| 72 |
+
"spec-spy-infiltrate": {
|
| 73 |
+
"generator": "arena",
|
| 74 |
+
"name": "spec-spy-infiltrate-arena",
|
| 75 |
+
"title": "Spy Infiltrate — fogged approach",
|
| 76 |
+
"width": 96, "height": 36, "cordon": 2,
|
| 77 |
+
"obstacles": [
|
| 78 |
+
{"x": 30, "y": 8, "w": 6, "h": 4},
|
| 79 |
+
{"x": 30, "y": 24, "w": 6, "h": 4},
|
| 80 |
+
{"x": 56, "y": 16, "w": 6, "h": 4},
|
| 81 |
+
],
|
| 82 |
+
},
|
| 83 |
+
# Thief drains cash from proc/silo. Single corridor approach
|
| 84 |
+
# tests whether the model finds the only path.
|
| 85 |
+
"spec-thief-steal-cash": {
|
| 86 |
+
"generator": "chokepoint-arena",
|
| 87 |
+
"name": "spec-thief-steal-cash-arena",
|
| 88 |
+
"title": "Thief — single corridor approach",
|
| 89 |
+
"width": 80, "height": 32, "cordon": 2,
|
| 90 |
+
"pinch_x": 40, "pinch_width": 8, "corridor_width": 4,
|
| 91 |
+
"corridor_y": 16,
|
| 92 |
+
},
|
| 93 |
+
# Nuke targets a CLUSTER; the test is spatial commit (find +
|
| 94 |
+
# aim). Large open arena gives plausible alternative cluster
|
| 95 |
+
# positions.
|
| 96 |
+
"spec-nuke-strike": {
|
| 97 |
+
"generator": "arena",
|
| 98 |
+
"name": "spec-nuke-strike-arena",
|
| 99 |
+
"title": "Nuke Strike — large arena, find the cluster",
|
| 100 |
+
"width": 144, "height": 48, "cordon": 3,
|
| 101 |
+
},
|
| 102 |
+
|
| 103 |
+
# ── economy family ────────────────────────────────────────────────
|
| 104 |
+
# Pure macro: small open arena, no obstacles, no enemies.
|
| 105 |
+
"econ-mine-and-grow": {
|
| 106 |
+
"generator": "arena",
|
| 107 |
+
"name": "econ-mine-and-grow-arena",
|
| 108 |
+
"title": "Mine and Grow — pure macro",
|
| 109 |
+
"width": 72, "height": 32, "cordon": 2,
|
| 110 |
+
},
|
| 111 |
+
# Contested patch in the centre; obstacle band between the two
|
| 112 |
+
# bases forces an approach commitment.
|
| 113 |
+
"econ-contested-expansion": {
|
| 114 |
+
"generator": "arena",
|
| 115 |
+
"name": "econ-contested-expansion-arena",
|
| 116 |
+
"title": "Contested Expansion — single channel",
|
| 117 |
+
"width": 112, "height": 36, "cordon": 2,
|
| 118 |
+
"obstacles": [
|
| 119 |
+
{"x": 50, "y": 6, "w": 8, "h": 8},
|
| 120 |
+
{"x": 50, "y": 22, "w": 8, "h": 8},
|
| 121 |
+
],
|
| 122 |
+
},
|
| 123 |
+
# Multi-patch decision; medium arena with room for 3+ patches.
|
| 124 |
+
"econ-multi-patch-allocation": {
|
| 125 |
+
"generator": "arena",
|
| 126 |
+
"name": "econ-multi-patch-allocation-arena",
|
| 127 |
+
"title": "Multi-Patch Allocation",
|
| 128 |
+
"width": 128, "height": 40, "cordon": 2,
|
| 129 |
+
},
|
| 130 |
+
# Second-base race; medium arena with clear best vs second-best
|
| 131 |
+
# placement zones.
|
| 132 |
+
"econ-second-base-race": {
|
| 133 |
+
"generator": "arena",
|
| 134 |
+
"name": "econ-second-base-race-arena",
|
| 135 |
+
"title": "Second Base Race",
|
| 136 |
+
"width": 112, "height": 36, "cordon": 2,
|
| 137 |
+
},
|
| 138 |
+
# Defend harvester from a raider on a single lane.
|
| 139 |
+
"econ-harvester-defense-raid": {
|
| 140 |
+
"generator": "arena",
|
| 141 |
+
"name": "econ-harvester-defense-raid-arena",
|
| 142 |
+
"title": "Harvester Defense — single raid lane",
|
| 143 |
+
"width": 96, "height": 36, "cordon": 2,
|
| 144 |
+
"obstacles": [
|
| 145 |
+
{"x": 48, "y": 6, "w": 6, "h": 10},
|
| 146 |
+
{"x": 48, "y": 22, "w": 6, "h": 10},
|
| 147 |
+
],
|
| 148 |
+
},
|
| 149 |
+
|
| 150 |
+
# ── defense / combat family ───────────────────────────────────────
|
| 151 |
+
# Bridges! Real water channel with 3 crossings — the briefing
|
| 152 |
+
# finally matches the terrain.
|
| 153 |
+
"def-bridge-chokepoint": {
|
| 154 |
+
"generator": "bridges-arena",
|
| 155 |
+
"name": "def-bridge-chokepoint-arena",
|
| 156 |
+
"title": "Bridge Chokepoint — 3 crossings",
|
| 157 |
+
"width": 128, "height": 40, "cordon": 2,
|
| 158 |
+
"axis": "vertical",
|
| 159 |
+
"channel_x": 60, "channel_width": 3,
|
| 160 |
+
"bridges": [
|
| 161 |
+
{"pos": 8, "width": 3},
|
| 162 |
+
{"pos": 18, "width": 3},
|
| 163 |
+
{"pos": 28, "width": 3},
|
| 164 |
+
],
|
| 165 |
+
},
|
| 166 |
+
# Naval shore strike already uses naval-arena; keep but namespace
|
| 167 |
+
# to the pack so future tuning is per-scenario.
|
| 168 |
+
"combat-naval-shore-strike": {
|
| 169 |
+
"generator": "naval-arena",
|
| 170 |
+
"name": "combat-naval-shore-strike-arena",
|
| 171 |
+
"title": "Naval Shore Strike",
|
| 172 |
+
"width": 96, "height": 40, "cordon": 2,
|
| 173 |
+
},
|
| 174 |
+
}
|
| 175 |
+
|
| 176 |
+
|
| 177 |
+
def main() -> int:
|
| 178 |
+
ap = argparse.ArgumentParser(description=__doc__,
|
| 179 |
+
formatter_class=argparse.RawDescriptionHelpFormatter)
|
| 180 |
+
ap.add_argument("--dry-run", action="store_true",
|
| 181 |
+
help="materialize maps and print summary; no YAML edits")
|
| 182 |
+
ap.add_argument("--apply", action="store_true",
|
| 183 |
+
help="rewrite each pack's base_map to the per-scenario id")
|
| 184 |
+
ap.add_argument("--packs", default="all",
|
| 185 |
+
help="comma-separated pack ids or 'all'")
|
| 186 |
+
args = ap.parse_args()
|
| 187 |
+
|
| 188 |
+
target = (set(args.packs.split(",")) if args.packs != "all"
|
| 189 |
+
else set(PER_PACK_MAPS))
|
| 190 |
+
|
| 191 |
+
materialized: list[tuple[str, str, Path]] = []
|
| 192 |
+
for pid, spec in sorted(PER_PACK_MAPS.items()):
|
| 193 |
+
if pid not in target:
|
| 194 |
+
continue
|
| 195 |
+
mid = materialize(spec)
|
| 196 |
+
out = REPO / "data" / "maps" / f"{mid}.oramap"
|
| 197 |
+
materialized.append((pid, mid, out))
|
| 198 |
+
print(f" {pid:34s} → {mid:42s} ({out.stat().st_size} bytes)")
|
| 199 |
+
print(f"\nmaterialized {len(materialized)}/{len(target)} pack maps")
|
| 200 |
+
|
| 201 |
+
if args.apply:
|
| 202 |
+
from openra_bench.scenarios.loader import PACKS_DIR
|
| 203 |
+
edited = 0
|
| 204 |
+
for pid, mid, _ in materialized:
|
| 205 |
+
yaml_path = PACKS_DIR / f"{pid}.yaml"
|
| 206 |
+
if not yaml_path.exists():
|
| 207 |
+
print(f" ⚠ pack file missing: {yaml_path}")
|
| 208 |
+
continue
|
| 209 |
+
raw = yaml_path.read_text()
|
| 210 |
+
# Replace the first `base_map: <something>` line with the
|
| 211 |
+
# new logical id. We use a regex anchored to start-of-line
|
| 212 |
+
# so we don't touch any documentation that mentions
|
| 213 |
+
# "base_map:" inside a comment.
|
| 214 |
+
import re
|
| 215 |
+
new_raw, n = re.subn(
|
| 216 |
+
r"^base_map: .*$",
|
| 217 |
+
f"base_map: {mid}",
|
| 218 |
+
raw,
|
| 219 |
+
count=1,
|
| 220 |
+
flags=re.MULTILINE,
|
| 221 |
+
)
|
| 222 |
+
if n == 0:
|
| 223 |
+
print(f" ⚠ no base_map: line in {pid}")
|
| 224 |
+
continue
|
| 225 |
+
if new_raw != raw:
|
| 226 |
+
yaml_path.write_text(new_raw)
|
| 227 |
+
edited += 1
|
| 228 |
+
print(f"\napplied to {edited} pack YAMLs")
|
| 229 |
+
return 0
|
| 230 |
+
|
| 231 |
+
|
| 232 |
+
if __name__ == "__main__":
|
| 233 |
+
sys.exit(main())
|
|
@@ -67,3 +67,74 @@ def test_pack_with_generator_spec_compiles():
|
|
| 67 |
hard = compile_level(p, "hard")
|
| 68 |
assert hard.scenario.base_map == "scout-arena"
|
| 69 |
assert hard.map_supported
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 67 |
hard = compile_level(p, "hard")
|
| 68 |
assert hard.scenario.base_map == "scout-arena"
|
| 69 |
assert hard.map_supported
|
| 70 |
+
|
| 71 |
+
|
| 72 |
+
def test_arena_obstacles_paint_interior_water():
|
| 73 |
+
"""Obstacles are WATER rectangles inside the playable area —
|
| 74 |
+
distinguishable from the cordon by their position."""
|
| 75 |
+
s = {"generator": "arena", "name": "test-obs-paint",
|
| 76 |
+
"width": 40, "height": 20, "cordon": 2,
|
| 77 |
+
"obstacles": [{"x": 10, "y": 8, "w": 4, "h": 2}]}
|
| 78 |
+
mid = materialize(s)
|
| 79 |
+
p = resolve_map_path(mid)
|
| 80 |
+
# Parse map.bin and confirm the (10,8)..(13,9) block is water but
|
| 81 |
+
# surrounding playable cells are clear.
|
| 82 |
+
blob = zipfile.ZipFile(p).read("map.bin")
|
| 83 |
+
w = int.from_bytes(blob[1:3], "little")
|
| 84 |
+
h = int.from_bytes(blob[3:5], "little")
|
| 85 |
+
tiles_off = int.from_bytes(blob[5:9], "little")
|
| 86 |
+
def cell(x, y):
|
| 87 |
+
idx = tiles_off + 3 * (x * h + y)
|
| 88 |
+
return int.from_bytes(blob[idx:idx + 2], "little")
|
| 89 |
+
assert (w, h) == (40, 20)
|
| 90 |
+
assert cell(10, 8) == 1 and cell(13, 9) == 1, "obstacle painted as water"
|
| 91 |
+
assert cell(20, 10) == 255, "interior outside obstacle stays clear"
|
| 92 |
+
assert cell(0, 0) == 1, "cordon corner still water"
|
| 93 |
+
|
| 94 |
+
|
| 95 |
+
def test_bridges_arena_channel_with_gaps():
|
| 96 |
+
"""Horizontal water band with explicit bridge gaps."""
|
| 97 |
+
s = {"generator": "bridges-arena", "name": "test-br-h",
|
| 98 |
+
"width": 60, "height": 30, "cordon": 2,
|
| 99 |
+
"channel_y": 14, "channel_width": 2,
|
| 100 |
+
"bridges": [{"pos": 20, "width": 3}]}
|
| 101 |
+
mid = materialize(s)
|
| 102 |
+
p = resolve_map_path(mid)
|
| 103 |
+
blob = zipfile.ZipFile(p).read("map.bin")
|
| 104 |
+
w = int.from_bytes(blob[1:3], "little")
|
| 105 |
+
h = int.from_bytes(blob[3:5], "little")
|
| 106 |
+
tiles_off = int.from_bytes(blob[5:9], "little")
|
| 107 |
+
def cell(x, y):
|
| 108 |
+
idx = tiles_off + 3 * (x * h + y)
|
| 109 |
+
return int.from_bytes(blob[idx:idx + 2], "little")
|
| 110 |
+
# Channel row 14 is water on the non-bridge cells
|
| 111 |
+
assert cell(40, 14) == 1, "off-bridge channel cell is water"
|
| 112 |
+
# Bridge gap (20..22) is clear
|
| 113 |
+
assert cell(20, 14) == 255 and cell(22, 14) == 255
|
| 114 |
+
# Row 13 (above channel) is open
|
| 115 |
+
assert cell(40, 13) == 255
|
| 116 |
+
|
| 117 |
+
|
| 118 |
+
def test_chokepoint_arena_corridor_is_only_route():
|
| 119 |
+
"""One narrow corridor across the wall; everywhere else along the
|
| 120 |
+
wall x-band is water."""
|
| 121 |
+
s = {"generator": "chokepoint-arena", "name": "test-chk",
|
| 122 |
+
"width": 60, "height": 30, "cordon": 2,
|
| 123 |
+
"pinch_x": 30, "pinch_width": 6, "corridor_width": 4,
|
| 124 |
+
"corridor_y": 14}
|
| 125 |
+
mid = materialize(s)
|
| 126 |
+
p = resolve_map_path(mid)
|
| 127 |
+
blob = zipfile.ZipFile(p).read("map.bin")
|
| 128 |
+
h = int.from_bytes(blob[3:5], "little")
|
| 129 |
+
tiles_off = int.from_bytes(blob[5:9], "little")
|
| 130 |
+
def cell(x, y):
|
| 131 |
+
idx = tiles_off + 3 * (x * h + y)
|
| 132 |
+
return int.from_bytes(blob[idx:idx + 2], "little")
|
| 133 |
+
# Corridor (y=12..15) clear at the pinch column
|
| 134 |
+
assert cell(30, 12) == 255 and cell(30, 15) == 255
|
| 135 |
+
# Above corridor (y=8) at the pinch is water
|
| 136 |
+
assert cell(30, 8) == 1
|
| 137 |
+
# Below corridor (y=22) at the pinch is water
|
| 138 |
+
assert cell(30, 22) == 1
|
| 139 |
+
# Open areas left/right of the wall still clear
|
| 140 |
+
assert cell(10, 14) == 255 and cell(50, 14) == 255
|