yxc20098 commited on
Commit
e3e79cb
·
1 Parent(s): 8a0b07d

mapgen: obstacles, bridges-arena, chokepoint-arena + per-scenario authoring

Browse files

Extend 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 CHANGED
@@ -40,18 +40,14 @@ def generator(name: str):
40
  return _reg
41
 
42
 
43
- @generator("arena")
44
- def _arena(spec: dict):
45
- """Open rectangle with a water cordon (generalizes rush-hour-arena
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
- grid = [
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
- bounds = (c, c, w - 2 * c, h - 2 * c)
64
- # Four playable-corner spawn references.
65
- spawns = [
 
 
 
 
 
 
 
 
 
 
 
 
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
scripts/author_perscenario_maps.py ADDED
@@ -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())
tests/test_mapgen.py CHANGED
@@ -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