yxc20098 commited on
Commit
96c15d6
·
1 Parent(s): 39fba02

Vendor training prompt/briefing/minimap v2 (byte-identical)

Browse files

So the bench format is identical-by-construction to the training
rollouts (no re-implementation drift):
- _vendor/system_v2.txt <= openra_rl_training/prompts/system_v2.txt
- _vendor/briefing_v2.py <= openra_rl_training/prompts/briefing_v2.py
- _vendor/minimap_v2.py <= scripts/_minimap_v2.py (the bitmap 'bit
type' renderer: native 1px/cell, NEAREST upscale, 3-tier fog, grid)
- _vendor/README.md provenance; tests/test_vendor_drift.py byte-
compares against upstream when the training checkout is present
Slice 1 of the prompt-v2 re-alignment (wiring follows).

openra_bench/_vendor/README.md ADDED
@@ -0,0 +1,15 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Vendored from OpenRA-RL-Training (byte-identical)
2
+
3
+ So the bench prompt/briefing/minimap format is **identical by
4
+ construction** to the training rollouts (no drift, no re-implementation):
5
+
6
+ | vendored file | upstream source |
7
+ |--------------------|---------------------------------------------------|
8
+ | `system_v2.txt` | `openra_rl_training/prompts/system_v2.txt` |
9
+ | `briefing_v2.py` | `openra_rl_training/prompts/briefing_v2.py` |
10
+ | `minimap_v2.py` | `scripts/_minimap_v2.py` |
11
+
12
+ Do **not** hand-edit. `tests/test_vendor_drift.py` byte-compares these
13
+ against the upstream originals when the training checkout is present
14
+ (skips otherwise) so divergence is caught. To resync: re-copy and
15
+ re-run the suite.
openra_bench/_vendor/__init__.py ADDED
File without changes
openra_bench/_vendor/briefing_v2.py ADDED
@@ -0,0 +1,149 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Briefing formatter v2: one unit per line, prose movement, no arrow.
2
+
3
+ Drop-in replacement for openra_env.agent.format_state_briefing — same input
4
+ state dict, different output layout. Designed to be conservative w.r.t.
5
+ information content (same fields, same numbers) and aggressive w.r.t.
6
+ readability and natural-language alignment.
7
+
8
+ Differences vs v1:
9
+ - Per-unit per-line (always), instead of `Nx<type>[id@(x,y),id@(x,y)]`.
10
+ - Movement rendered as ", moving to (X,Y)" instead of "→(X,Y)".
11
+ - Type column space-padded to 5 chars so IDs/positions align across rows.
12
+ - Idle list preserved at end.
13
+ - Buildings, enemies, production lines unchanged from v1 to limit the
14
+ perturbation to the units block (the noisiest part).
15
+ """
16
+ from __future__ import annotations
17
+
18
+ from collections import defaultdict
19
+
20
+
21
+ _TYPE_PAD = 5 # widest common type code is 4 chars (medi, 2tnk, ...) + 1 space
22
+
23
+
24
+ def format_state_briefing_v2(state: dict) -> str:
25
+ if not isinstance(state, dict) or "tick" not in state:
26
+ return ""
27
+
28
+ eco = state.get("economy", {})
29
+ tick = state["tick"]
30
+ cash = eco.get("cash", 0)
31
+ ore = eco.get("ore", 0)
32
+ funds = cash + ore
33
+
34
+ parts = [
35
+ f"--- TURN BRIEFING (tick {tick}, ~{tick // 25}s game time) ---",
36
+ f"Funds: ${funds} (cash=${cash} + ore=${ore}) | "
37
+ f"Power: {state.get('power_balance', 0):+d} | "
38
+ f"Harvesters: {eco.get('harvester_count', 0)} | "
39
+ f"Explored: {state.get('explored_percent', 0)}%",
40
+ ]
41
+
42
+ minimap = state.get("minimap", "")
43
+ if minimap:
44
+ parts.append(minimap)
45
+
46
+ buildings = state.get("buildings_summary", [])
47
+ if buildings:
48
+ base_x = sum(b["cell_x"] for b in buildings) // len(buildings)
49
+ base_y = sum(b["cell_y"] for b in buildings) // len(buildings)
50
+ parts.append(f"Base center: ({base_x},{base_y})")
51
+
52
+ units = state.get("units_summary", [])
53
+ if units:
54
+ idle_ids = []
55
+ # Sort by (type, id) so the per-line list groups by type visually.
56
+ sorted_units = sorted(units, key=lambda u: (u.get("type", ""), u.get("id", 0)))
57
+ unit_lines = []
58
+ for u in sorted_units:
59
+ uid = u.get("id", "?")
60
+ utype = (u.get("type", "?") + " " * _TYPE_PAD)[:_TYPE_PAD]
61
+ x, y = u.get("cell_x", "?"), u.get("cell_y", "?")
62
+ line = f" {uid} {utype} @({x},{y})"
63
+ if u.get("target_x") is not None:
64
+ line += f", moving to ({u['target_x']},{u['target_y']})"
65
+ elif not u.get("idle"):
66
+ act = u.get("activity", "")
67
+ if act and act not in ("Idle", "Unknown", "Wait"):
68
+ line += f", {act.lower()}"
69
+ unit_lines.append(line)
70
+ if u.get("idle") and u.get("can_attack"):
71
+ idle_ids.append(uid)
72
+ parts.append(f"Units ({len(units)}):")
73
+ parts.extend(unit_lines)
74
+ if idle_ids:
75
+ parts.append(f"Idle: {', '.join(str(i) for i in idle_ids)}")
76
+ else:
77
+ parts.append(f"Units: {state.get('own_units', '?')}")
78
+
79
+ # Buildings, enemies, production: identical to v1 to constrain the diff.
80
+ _BLDG_CATEGORY = {
81
+ "tent": "infantry", "barr": "infantry", "weap": "vehicle",
82
+ "hpad": "aircraft", "afld": "aircraft", "syrd": "ship", "spen": "ship",
83
+ "gun": "defense", "ftur": "defense", "tsla": "defense",
84
+ "sam": "defense", "agun": "defense", "pbox": "defense", "hbox": "defense",
85
+ }
86
+ if buildings:
87
+ bldg_parts = []
88
+ for b in buildings:
89
+ cat = _BLDG_CATEGORY.get(b["type"], "")
90
+ cat_str = f"[{cat}]" if cat else ""
91
+ bldg_parts.append(
92
+ f"{b['type']}({b['id']})@({b['cell_x']},{b['cell_y']}){cat_str}"
93
+ )
94
+ parts.append(f"Buildings: {' '.join(bldg_parts)}")
95
+ else:
96
+ parts.append(
97
+ f"Buildings: {state.get('own_buildings', '?')} "
98
+ f"({', '.join(state.get('building_types', []))})"
99
+ )
100
+
101
+ enemies = state.get("enemy_summary", [])
102
+ enemy_bldgs = state.get("enemy_buildings_summary", [])
103
+ if enemies or enemy_bldgs:
104
+ enemy_parts = []
105
+ if enemies:
106
+ eby_type = defaultdict(list)
107
+ for e in enemies:
108
+ eby_type[e["type"]].append(e)
109
+ for etype, es in eby_type.items():
110
+ entries = ",".join(f"{e['id']}@({e['cell_x']},{e['cell_y']})" for e in es)
111
+ enemy_parts.append(f"{len(es)}x{etype}[{entries}]")
112
+ if enemy_bldgs:
113
+ ebby_type = defaultdict(list)
114
+ for b in enemy_bldgs:
115
+ ebby_type[b["type"]].append(b)
116
+ for btype, bs in ebby_type.items():
117
+ entries = ",".join(f"{b['id']}@({b['cell_x']},{b['cell_y']})" for b in bs)
118
+ enemy_parts.append(f"{len(bs)}x{btype}[{entries}]")
119
+ all_enemy_pos = (
120
+ [(e["cell_x"], e["cell_y"]) for e in enemies]
121
+ + [(b["cell_x"], b["cell_y"]) for b in enemy_bldgs]
122
+ )
123
+ avg_x = sum(p[0] for p in all_enemy_pos) // len(all_enemy_pos)
124
+ avg_y = sum(p[1] for p in all_enemy_pos) // len(all_enemy_pos)
125
+ parts.append(f"Enemies: {' '.join(enemy_parts)} center ({avg_x},{avg_y})")
126
+ else:
127
+ n_enemy = state.get("visible_enemy_units", 0)
128
+ parts.append(
129
+ f"Enemies: {'none visible' if n_enemy == 0 else f'{n_enemy} visible'}"
130
+ )
131
+
132
+ prod = state.get("production_items", [])
133
+ if prod:
134
+ active = [p for p in prod if "@100%" not in p]
135
+ ready = [p.split("@")[0] for p in prod if "@100%" in p]
136
+ parts_prod = []
137
+ if active:
138
+ parts_prod.append(", ".join(active))
139
+ if ready:
140
+ parts_prod.append(f"READY TO PLACE: {', '.join(ready)}")
141
+ parts.append(f"Production: {' | '.join(parts_prod)}")
142
+ else:
143
+ parts.append("Production: IDLE")
144
+
145
+ available = state.get("available_production", [])
146
+ if available:
147
+ parts.append(f"Can build: {', '.join(available)}")
148
+
149
+ return "\n".join(parts)
openra_bench/_vendor/minimap_v2.py ADDED
@@ -0,0 +1,535 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Clean minimap renderer for the Rust env probes.
2
+
3
+ Replaces the production renderer with something the user can actually read:
4
+
5
+ - Terrain: native-resolution bitmap (1 px per cell), upscaled with NEAREST so
6
+ cell boundaries stay crisp.
7
+ - Three-tier fog: BRIGHT (currently visible), DIM (previously explored, no
8
+ current vision), DARK (never seen).
9
+ - Unit markers drawn at cell centers with clear contrast.
10
+ - Grid lines + axis labels matching the cell coordinate system.
11
+
12
+ API:
13
+ render(obs, scenario_terrain_png_bytes, map_width, map_height,
14
+ bounds=(bx, by, bw, bh), explored_history, output_pixels_per_cell=8,
15
+ sight_radius=8) -> bytes (PNG)
16
+
17
+ `explored_history` is mutated in place to accumulate seen cells.
18
+ """
19
+ from __future__ import annotations
20
+
21
+ import io
22
+ import math
23
+ from typing import Any
24
+
25
+ import numpy as np
26
+ from PIL import Image, ImageDraw, ImageFont
27
+
28
+
29
+ # Brightness multipliers
30
+ BRIGHT = 1.00 # currently visible
31
+ DIM = 0.45 # previously explored
32
+ DARK = 0.10 # never seen
33
+
34
+ # Output styling
35
+ GRID_COLOR = (255, 255, 255, 90)
36
+ PLAYABLE_BORDER_COLOR = (255, 145, 0, 220)
37
+ LABEL_COLOR = (255, 200, 60, 255)
38
+ BG_COLOR = (12, 12, 18, 255)
39
+ OUTLINE_COLOR = (255, 255, 255, 230)
40
+
41
+ # Per-unit-type styling. shape ∈ {"circle", "square", "triangle", "diamond", "pentagon", "x"}.
42
+ # Color is the fill (RGBA). Own units use cyan family; enemies use red family.
43
+ UNIT_STYLE_OWN: dict[str, tuple[str, tuple[int, int, int, int]]] = {
44
+ "2tnk": ("square", (40, 200, 230, 255)), # medium tank — solid square cyan
45
+ "1tnk": ("square", (90, 230, 255, 255)), # light tank — paler square
46
+ "jeep": ("triangle", (180, 240, 255, 255)), # scout — triangle
47
+ "dog": ("x", (255, 230, 100, 255)), # dog — yellow X (anti-spy)
48
+ "e1": ("circle", (90, 200, 255, 255)), # rifle infantry — small circle
49
+ "apc": ("pentagon", (0, 160, 200, 255)), # APC — pentagon, deeper cyan
50
+ # fallback
51
+ "?": ("circle", (0, 220, 255, 255)),
52
+ }
53
+
54
+ UNIT_STYLE_ENEMY: dict[str, tuple[str, tuple[int, int, int, int]]] = {
55
+ "2tnk": ("square", (255, 60, 60, 255)),
56
+ "1tnk": ("square", (255, 110, 110, 255)),
57
+ "jeep": ("triangle", (255, 160, 160, 255)),
58
+ "dog": ("x", (255, 180, 60, 255)),
59
+ "e1": ("circle", (255, 80, 80, 255)),
60
+ "apc": ("pentagon", (200, 30, 30, 255)),
61
+ "?": ("circle", (255, 60, 60, 255)),
62
+ }
63
+
64
+ # Static enemy buildings get a distinct shape (filled square outline) and
65
+ # warmer red so the model can tell defensive turrets / production
66
+ # structures apart from mobile enemy units at a glance.
67
+ BUILDING_STYLE_ENEMY: dict[str, tuple[str, tuple[int, int, int, int]]] = {
68
+ # defenses (shoot back)
69
+ "gun": ("filled_square", (255, 90, 0, 255)), # turret — orange-red
70
+ "tsla": ("filled_square", (200, 100, 255, 255)), # tesla — purple
71
+ "pbox": ("filled_square", (255, 90, 0, 255)),
72
+ "hbox": ("filled_square", (255, 90, 0, 255)),
73
+ "ftur": ("filled_square", (255, 90, 0, 255)),
74
+ "agun": ("filled_square", (255, 130, 0, 255)),
75
+ "sam": ("filled_square", (255, 130, 0, 255)),
76
+ # production / key targets (must-be-destroyed)
77
+ "fact": ("filled_square", (255, 220, 60, 255)), # construction yard — yellow
78
+ "proc": ("filled_square", (255, 220, 60, 255)), # refinery — yellow
79
+ # scenery
80
+ "powr": ("filled_square", (160, 160, 160, 255)),
81
+ "barr": ("filled_square", (160, 160, 160, 255)),
82
+ "?": ("filled_square", (255, 100, 100, 255)),
83
+ }
84
+
85
+
86
+ def _load_font(size: int) -> ImageFont.FreeTypeFont | ImageFont.ImageFont:
87
+ """Try a few common fonts; fall back to PIL default."""
88
+ for path in (
89
+ "/System/Library/Fonts/Helvetica.ttc",
90
+ "/System/Library/Fonts/Supplemental/Arial.ttf",
91
+ "/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf",
92
+ ):
93
+ try:
94
+ return ImageFont.truetype(path, size)
95
+ except (OSError, IOError):
96
+ continue
97
+ return ImageFont.load_default()
98
+
99
+
100
+ def _compute_visible_mask(
101
+ own_units: list[tuple[int, int]],
102
+ map_width: int,
103
+ map_height: int,
104
+ sight_radius: int,
105
+ ) -> np.ndarray:
106
+ """Boolean mask (h, w): True where cells are within sight radius of any own unit."""
107
+ mask = np.zeros((map_height, map_width), dtype=bool)
108
+ r2 = sight_radius * sight_radius
109
+ for cx, cy in own_units:
110
+ x_lo = max(0, cx - sight_radius)
111
+ x_hi = min(map_width, cx + sight_radius + 1)
112
+ y_lo = max(0, cy - sight_radius)
113
+ y_hi = min(map_height, cy + sight_radius + 1)
114
+ for y in range(y_lo, y_hi):
115
+ for x in range(x_lo, x_hi):
116
+ if (x - cx) ** 2 + (y - cy) ** 2 <= r2:
117
+ mask[y, x] = True
118
+ return mask
119
+
120
+
121
+ def _draw_unit_marker(
122
+ draw: ImageDraw.ImageDraw,
123
+ cx_px: int,
124
+ cy_px: int,
125
+ radius: int,
126
+ shape: str,
127
+ fill: tuple[int, int, int, int],
128
+ ) -> None:
129
+ """Draw a single unit marker of the given shape centered at (cx_px, cy_px)."""
130
+ r = radius
131
+ if shape == "square":
132
+ draw.rectangle(
133
+ [(cx_px - r, cy_px - r), (cx_px + r, cy_px + r)],
134
+ fill=fill, outline=OUTLINE_COLOR, width=1,
135
+ )
136
+ elif shape == "filled_square":
137
+ # Buildings: bigger filled square with thick black outline so it
138
+ # reads as a structure (not a unit) at all zoom levels.
139
+ big_r = r + 2
140
+ draw.rectangle(
141
+ [(cx_px - big_r, cy_px - big_r), (cx_px + big_r, cy_px + big_r)],
142
+ fill=fill, outline=(0, 0, 0, 255), width=2,
143
+ )
144
+ elif shape == "triangle":
145
+ draw.polygon(
146
+ [(cx_px, cy_px - r), (cx_px - r, cy_px + r), (cx_px + r, cy_px + r)],
147
+ fill=fill, outline=OUTLINE_COLOR,
148
+ )
149
+ elif shape == "diamond":
150
+ draw.polygon(
151
+ [(cx_px, cy_px - r), (cx_px + r, cy_px),
152
+ (cx_px, cy_px + r), (cx_px - r, cy_px)],
153
+ fill=fill, outline=OUTLINE_COLOR,
154
+ )
155
+ elif shape == "pentagon":
156
+ import math as _m
157
+ pts = []
158
+ for i in range(5):
159
+ theta = _m.pi / 2 + i * 2 * _m.pi / 5
160
+ pts.append((cx_px + r * _m.cos(theta), cy_px - r * _m.sin(theta)))
161
+ draw.polygon(pts, fill=fill, outline=OUTLINE_COLOR)
162
+ elif shape == "x":
163
+ draw.line([(cx_px - r, cy_px - r), (cx_px + r, cy_px + r)], fill=fill, width=2)
164
+ draw.line([(cx_px + r, cy_px - r), (cx_px - r, cy_px + r)], fill=fill, width=2)
165
+ else: # circle (default)
166
+ draw.ellipse(
167
+ [(cx_px - r, cy_px - r), (cx_px + r, cy_px + r)],
168
+ fill=fill, outline=OUTLINE_COLOR, width=1,
169
+ )
170
+
171
+
172
+ def render(
173
+ *,
174
+ obs: dict[str, Any],
175
+ terrain_png_bytes: bytes,
176
+ map_width: int,
177
+ map_height: int,
178
+ bounds: tuple[int, int, int, int],
179
+ explored_history: set[tuple[int, int]],
180
+ sight_radius: int = 8,
181
+ pixels_per_cell: int = 8,
182
+ own_unit_types: dict[str, str] | None = None,
183
+ enemy_unit_types: dict[str, str] | None = None,
184
+ draw_grid: bool = True,
185
+ draw_axis_labels: bool = True,
186
+ draw_playable_border: bool = True,
187
+ draw_legend: bool = True,
188
+ crop_to_playable: bool = True,
189
+ ) -> bytes:
190
+ """Render a fog-of-war minimap and return PNG bytes.
191
+
192
+ Args:
193
+ obs: observation dict from openra_train env (unit_positions, enemy_positions).
194
+ terrain_png_bytes: native-resolution terrain bitmap (e.g. 128x40 RGB).
195
+ map_width, map_height: cell dimensions (e.g. 128, 40).
196
+ bounds: (bx, by, bw, bh) playable area in cells.
197
+ explored_history: set of (x, y) cell coords ever seen; mutated in place.
198
+ sight_radius: cells within this radius of any own unit count as currently visible.
199
+ pixels_per_cell: scale-up factor for output.
200
+ draw_grid: if False, suppress the every-20-cells grid lines (use
201
+ for transfer-learning training where the model should not
202
+ rely on the grid as a coordinate-counting cue).
203
+ draw_axis_labels: if False, suppress the 0/20/40/.../127 numeric
204
+ labels on the x and y axes (same rationale).
205
+ draw_playable_border: if False, suppress the orange-dashed
206
+ playable-area rectangle.
207
+ draw_legend: if False, suppress the bottom legend strip.
208
+ """
209
+ # ---- Load terrain bitmap and ensure size matches map cells ----
210
+ terrain_img = Image.open(io.BytesIO(terrain_png_bytes)).convert("RGB")
211
+ if terrain_img.size != (map_width, map_height):
212
+ terrain_img = terrain_img.resize((map_width, map_height), Image.NEAREST)
213
+ terrain = np.asarray(terrain_img, dtype=np.float32) / 255.0 # (h, w, 3) in [0,1]
214
+
215
+ # ---- Build own/enemy lists in (x, y) cell coords ----
216
+ unit_positions = obs.get("unit_positions") or {}
217
+ if isinstance(unit_positions, list):
218
+ unit_positions = {p[0]: p[1] for p in unit_positions}
219
+ own_cells: list[tuple[int, int]] = []
220
+ own_dots: list[tuple[int, int, str]] = []
221
+ for uid_str, pos in unit_positions.items():
222
+ cx = int(pos.get("cell_x") if isinstance(pos, dict) else pos[0])
223
+ cy = int(pos.get("cell_y") if isinstance(pos, dict) else pos[1])
224
+ own_cells.append((cx, cy))
225
+ own_dots.append((cx, cy, uid_str))
226
+
227
+ enemy_dots: list[tuple[int, int, str]] = []
228
+ for ep in obs.get("enemy_positions") or []:
229
+ ex = int(ep.get("cell_x", 0))
230
+ ey = int(ep.get("cell_y", 0))
231
+ enemy_dots.append((ex, ey, str(ep.get("id", "?"))))
232
+ # Enemy buildings: same world-space, drawn with a distinct marker so
233
+ # the model (and humans browsing the viewer) can tell at a glance
234
+ # which dots are mobile units vs static structures (defenses + key
235
+ # targets). Strategy scenarios place ONLY buildings on the enemy
236
+ # side, so without this layer the minimap shows nothing where the
237
+ # threat actually is.
238
+ enemy_building_dots: list[tuple[int, int, str]] = []
239
+ for eb in obs.get("enemy_buildings_summary") or []:
240
+ ex = int(eb.get("cell_x", 0))
241
+ ey = int(eb.get("cell_y", 0))
242
+ enemy_building_dots.append((ex, ey, str(eb.get("id", "?"))))
243
+
244
+ # ---- Compute visible + explored masks at cell resolution ----
245
+ visible = _compute_visible_mask(own_cells, map_width, map_height, sight_radius)
246
+ # Prefer the engine's per-tick `explored_cells` (ground truth — captures
247
+ # cells that units transited *between* briefings, which a snapshot-based
248
+ # vision-mask reveal misses and produces "disconnected green blobs").
249
+ # Fall back to the legacy briefing-snapshot history if the engine field
250
+ # is absent (older envs).
251
+ engine_explored = obs.get("explored_cells")
252
+ if engine_explored:
253
+ for cell in engine_explored:
254
+ if isinstance(cell, (list, tuple)) and len(cell) == 2:
255
+ explored_history.add((int(cell[0]), int(cell[1])))
256
+ else:
257
+ for y in range(map_height):
258
+ for x in range(map_width):
259
+ if visible[y, x]:
260
+ explored_history.add((x, y))
261
+ # Build explored mask from history
262
+ explored = np.zeros((map_height, map_width), dtype=bool)
263
+ for (x, y) in explored_history:
264
+ if 0 <= x < map_width and 0 <= y < map_height:
265
+ explored[y, x] = True
266
+
267
+ # ---- Apply 3-tier brightness ----
268
+ brightness = np.full((map_height, map_width), DARK, dtype=np.float32)
269
+ brightness[explored] = DIM
270
+ brightness[visible] = BRIGHT
271
+ composite = terrain * brightness[..., np.newaxis]
272
+ composite_uint8 = (np.clip(composite, 0.0, 1.0) * 255).astype(np.uint8)
273
+
274
+ # ---- Crop to playable bounds if requested ----
275
+ bx, by, bw, bh = bounds
276
+ if crop_to_playable:
277
+ # Clip bounds to valid range first.
278
+ cx0 = max(0, min(bx, map_width))
279
+ cy0 = max(0, min(by, map_height))
280
+ cx1 = max(cx0, min(bx + bw, map_width))
281
+ cy1 = max(cy0, min(by + bh, map_height))
282
+ composite_uint8 = composite_uint8[cy0:cy1, cx0:cx1, :]
283
+ disp_w = cx1 - cx0
284
+ disp_h = cy1 - cy0
285
+ x_origin, y_origin = cx0, cy0 # world cell at left/top edge of crop
286
+ else:
287
+ disp_w, disp_h = map_width, map_height
288
+ x_origin, y_origin = 0, 0
289
+
290
+ # ---- Upscale (NEAREST keeps cell boundaries crisp) ----
291
+ cell_img = Image.fromarray(composite_uint8, mode="RGB")
292
+ out_w = disp_w * pixels_per_cell
293
+ out_h = disp_h * pixels_per_cell
294
+ cell_img = cell_img.resize((out_w, out_h), Image.NEAREST)
295
+
296
+ # ---- Add a margin for axis labels ----
297
+ margin_x = 36
298
+ margin_y = 22
299
+ # Legend height is decided once we know how many types appear in
300
+ # this frame (own + enemy units + enemy buildings). Each row is 16
301
+ # pixels; the swatch+terrain header is one row; unit / building
302
+ # rows wrap at ~7 entries each. Worst case 4 rows.
303
+ legend_h = 16 * 5 # set after counting, reserved upper bound
304
+ canvas_w = out_w + margin_x + 8
305
+ canvas_h = out_h + margin_y + legend_h + 8
306
+ canvas = Image.new("RGBA", (canvas_w, canvas_h), BG_COLOR)
307
+ canvas.paste(cell_img, (margin_x, margin_y))
308
+
309
+ draw = ImageDraw.Draw(canvas, "RGBA")
310
+ label_font = _load_font(11)
311
+ legend_font = _load_font(11)
312
+
313
+ # Helper: world cell x/y → pixel x/y in the output canvas.
314
+ def _wx(cx: int) -> int:
315
+ return margin_x + (cx - x_origin) * pixels_per_cell
316
+
317
+ def _wy(cy: int) -> int:
318
+ return margin_y + (cy - y_origin) * pixels_per_cell
319
+
320
+ # x/y world-cell ranges currently displayed.
321
+ disp_x_lo, disp_x_hi = x_origin, x_origin + disp_w
322
+ disp_y_lo, disp_y_hi = y_origin, y_origin + disp_h
323
+
324
+ # ---- Grid lines every 20 cells (x), 10 cells (y), in world coords ----
325
+ if draw_grid:
326
+ # Find first world x divisible by 20 within the displayed range.
327
+ first_x = ((disp_x_lo + 19) // 20) * 20
328
+ for x in range(first_x, disp_x_hi + 1, 20):
329
+ px = _wx(x)
330
+ draw.line([(px, margin_y), (px, margin_y + out_h)], fill=GRID_COLOR, width=1)
331
+ first_y = ((disp_y_lo + 9) // 10) * 10
332
+ for y in range(first_y, disp_y_hi + 1, 10):
333
+ py = _wy(y)
334
+ draw.line([(margin_x, py), (margin_x + out_w, py)], fill=GRID_COLOR, width=1)
335
+
336
+ # ---- Playable bounds rectangle (orange dashed) ----
337
+ # Skip when the entire visible region IS the playable area (crop_to_playable).
338
+ if draw_playable_border and not crop_to_playable:
339
+ pb_x0 = _wx(bx)
340
+ pb_y0 = _wy(by)
341
+ pb_x1 = _wx(bx + bw)
342
+ pb_y1 = _wy(by + bh)
343
+ # Dashed manually
344
+ dash_len = 6
345
+ gap = 4
346
+ def _dashed_line(p0, p1):
347
+ x0, y0 = p0
348
+ x1, y1 = p1
349
+ dx, dy = x1 - x0, y1 - y0
350
+ length = math.hypot(dx, dy)
351
+ if length == 0:
352
+ return
353
+ ux, uy = dx / length, dy / length
354
+ d = 0.0
355
+ while d < length:
356
+ d2 = min(d + dash_len, length)
357
+ draw.line(
358
+ [(x0 + ux * d, y0 + uy * d), (x0 + ux * d2, y0 + uy * d2)],
359
+ fill=PLAYABLE_BORDER_COLOR,
360
+ width=2,
361
+ )
362
+ d = d2 + gap
363
+ _dashed_line((pb_x0, pb_y0), (pb_x1, pb_y0))
364
+ _dashed_line((pb_x1, pb_y0), (pb_x1, pb_y1))
365
+ _dashed_line((pb_x1, pb_y1), (pb_x0, pb_y1))
366
+ _dashed_line((pb_x0, pb_y1), (pb_x0, pb_y0))
367
+
368
+ # ---- Axis labels (world coords across the displayed range) ----
369
+ if draw_axis_labels:
370
+ # x-axis: every 20 cells starting from a multiple of 20 within
371
+ # [disp_x_lo, disp_x_hi-1]. Skip if it would land within ~3 cells
372
+ # of the far-right label (avoids "80" + "81" overlap).
373
+ first_x = ((disp_x_lo + 19) // 20) * 20
374
+ last_x = disp_x_hi - 1
375
+ for x in range(first_x, disp_x_hi, 20):
376
+ if abs(x - last_x) < 3:
377
+ continue
378
+ draw.text((_wx(x) - 6, 4), str(x), fill=LABEL_COLOR, font=label_font)
379
+ draw.text((_wx(last_x) - 8, 4), str(last_x),
380
+ fill=LABEL_COLOR, font=label_font)
381
+
382
+ first_y = ((disp_y_lo + 9) // 10) * 10
383
+ last_y = disp_y_hi - 1
384
+ for y in range(first_y, disp_y_hi, 10):
385
+ if abs(y - last_y) < 3:
386
+ continue
387
+ draw.text((4, _wy(y) - 6), str(y), fill=LABEL_COLOR, font=label_font)
388
+ draw.text((4, _wy(last_y) - 6), str(last_y),
389
+ fill=LABEL_COLOR, font=label_font)
390
+
391
+ # ---- Unit markers ----
392
+ # When N units share a cell, jitter each marker on a small ring around
393
+ # the cell center so every unit is individually visible. Smaller marker
394
+ # radius when stacked so the cluster fits within ~1.5 cells.
395
+ own_unit_types = own_unit_types or {}
396
+ enemy_unit_types = enemy_unit_types or {}
397
+
398
+ def _cell_offsets(n: int, ring_radius: float) -> list[tuple[float, float]]:
399
+ """Return N (dx, dy) offsets distributed at the cell center.
400
+ n=1 → centered. n=2-6 → single ring. n>6 → ring + center.
401
+ """
402
+ if n <= 1:
403
+ return [(0.0, 0.0)]
404
+ if n <= 6:
405
+ return [
406
+ (ring_radius * math.cos(2 * math.pi * i / n - math.pi / 2),
407
+ ring_radius * math.sin(2 * math.pi * i / n - math.pi / 2))
408
+ for i in range(n)
409
+ ]
410
+ # >6: place 1 in center + remaining on outer ring
411
+ offsets = [(0.0, 0.0)]
412
+ outer = n - 1
413
+ for i in range(outer):
414
+ offsets.append((
415
+ ring_radius * math.cos(2 * math.pi * i / outer - math.pi / 2),
416
+ ring_radius * math.sin(2 * math.pi * i / outer - math.pi / 2),
417
+ ))
418
+ return offsets
419
+
420
+ # Group by cell to compute jitter offsets
421
+ def _draw_group(dots, type_map, style_map):
422
+ by_cell: dict[tuple[int, int], list[tuple[int, int, str]]] = {}
423
+ for d in dots:
424
+ by_cell.setdefault((d[0], d[1]), []).append(d)
425
+ for (cx, cy), members in by_cell.items():
426
+ n = len(members)
427
+ base_r = max(2, pixels_per_cell // 2 - max(0, n - 1)) # shrink with stack
428
+ ring = pixels_per_cell * (0.55 if n <= 6 else 0.75)
429
+ offsets = _cell_offsets(n, ring)
430
+ cx_px_center = _wx(cx) + pixels_per_cell // 2
431
+ cy_px_center = _wy(cy) + pixels_per_cell // 2
432
+ for (mem, off) in zip(members, offsets):
433
+ _, _, uid = mem
434
+ utype = type_map.get(uid, "?")
435
+ shape, fill = style_map.get(utype, style_map["?"])
436
+ px = int(cx_px_center + off[0])
437
+ py = int(cy_px_center + off[1])
438
+ _draw_unit_marker(draw, px, py, base_r, shape, fill)
439
+
440
+ # Static structures first (largest), then mobile enemies, then own
441
+ # units on top. Building type is taken from enemy_buildings_summary
442
+ # entries (kept by the engine in a separate `type` field).
443
+ enemy_building_types = {
444
+ str(eb.get("id", "?")): eb.get("type", "?")
445
+ for eb in (obs.get("enemy_buildings_summary") or [])
446
+ }
447
+ _draw_group(enemy_building_dots, enemy_building_types, BUILDING_STYLE_ENEMY)
448
+ _draw_group(enemy_dots, enemy_unit_types, UNIT_STYLE_ENEMY)
449
+ _draw_group(own_dots, own_unit_types, UNIT_STYLE_OWN)
450
+
451
+ # ---- Legend strip ----
452
+ if draw_legend:
453
+ lx = margin_x
454
+ ly = margin_y + out_h + 6
455
+ draw.text((lx, ly), "BRIGHT visible | DIM explored | DARK unexplored",
456
+ fill=(220, 220, 220, 230), font=legend_font)
457
+
458
+ # Terrain swatches — sample one grass-typical cell from the
459
+ # interior of the playable rect and one water-typical cell from
460
+ # the impassable border. After hue-rotation theming (applied to
461
+ # the whole canvas), these swatches still match the in-image
462
+ # terrain colors because they're drawn pre-theming with the same
463
+ # source pixels.
464
+ terrain_x = lx + 290
465
+ bx_, by_, bw_, bh_ = bounds
466
+ # Grass: center of playable area (typical interior cell)
467
+ grass_cx, grass_cy = bx_ + bw_ // 2, by_ + bh_ // 2
468
+ grass_color = tuple(int(c) for c in terrain[grass_cy, grass_cx] * 255) + (255,)
469
+ # Water: a corner just inside the impassable border (rush-hour
470
+ # has water at y=0..2 and y=37..39)
471
+ water_cy = max(0, by_ - 1)
472
+ water_cx = bx_ + bw_ // 2
473
+ water_color = tuple(int(c) for c in terrain[water_cy, water_cx] * 255) + (255,)
474
+ # Draw small color swatches with labels
475
+ sw_w, sw_h = 12, 10
476
+ for label, color in [("grass", grass_color), ("water", water_color)]:
477
+ draw.rectangle(
478
+ [(terrain_x, ly + 1), (terrain_x + sw_w, ly + sw_h + 1)],
479
+ fill=color, outline=OUTLINE_COLOR,
480
+ )
481
+ draw.text((terrain_x + sw_w + 4, ly), label,
482
+ fill=(220, 220, 220, 230), font=legend_font)
483
+ terrain_x += sw_w + 38
484
+
485
+ # ── Dynamic legend ──
486
+ # Walk the actual dots present in this frame and emit one entry
487
+ # per distinct type, using its real marker shape + colour. This
488
+ # makes the legend exhaustive: every coloured square / dot on
489
+ # the minimap can be decoded right here. Three rows are stacked:
490
+ # row 1: terrain swatches + visibility key (already drawn)
491
+ # row 2: agent units (cyan family)
492
+ # row 3: enemy units (red family) + enemy buildings (filled
493
+ # squares of various colours)
494
+ #
495
+ # Wraps at ~7 entries per row and grows downward; the canvas
496
+ # height was reserved generously above.
497
+
498
+ def _emit_row(items, y_offset, prefix=None):
499
+ x = margin_x
500
+ if prefix:
501
+ draw.text((x, y_offset), prefix, fill=(180, 180, 180, 230), font=legend_font)
502
+ x += 32
503
+ for label, shape, fill in items:
504
+ _draw_unit_marker(draw, x, y_offset + 7, 5, shape, fill)
505
+ draw.text((x + 8, y_offset), label,
506
+ fill=(220, 220, 220, 230), font=legend_font)
507
+ x += 50 # wider spacing for short type codes
508
+ if x + 50 > canvas_w:
509
+ return # silently truncate (very rare with ≤8 types/row)
510
+
511
+ own_types_in_frame = sorted({own_unit_types.get(uid, "?")
512
+ for _, _, uid in own_dots} - {"?"})
513
+ enemy_unit_types_in_frame = sorted({enemy_unit_types.get(uid, "?")
514
+ for _, _, uid in enemy_dots} - {"?"})
515
+ enemy_bld_types_in_frame = sorted({enemy_building_types.get(uid, "?")
516
+ for _, _, uid in enemy_building_dots} - {"?"})
517
+
518
+ if own_types_in_frame:
519
+ row_items = [(t, *UNIT_STYLE_OWN.get(t, UNIT_STYLE_OWN["?"]))
520
+ for t in own_types_in_frame]
521
+ _emit_row(row_items, ly + 16, prefix="own:")
522
+
523
+ # Enemies + buildings on the same line if they fit; else wrap.
524
+ enemy_row_items = []
525
+ for t in enemy_unit_types_in_frame:
526
+ enemy_row_items.append((t, *UNIT_STYLE_ENEMY.get(t, UNIT_STYLE_ENEMY["?"])))
527
+ for t in enemy_bld_types_in_frame:
528
+ enemy_row_items.append((t, *BUILDING_STYLE_ENEMY.get(t, BUILDING_STYLE_ENEMY["?"])))
529
+ if enemy_row_items:
530
+ _emit_row(enemy_row_items, ly + 32, prefix="enemy:")
531
+
532
+ # ---- Encode PNG ----
533
+ buf = io.BytesIO()
534
+ canvas.convert("RGB").save(buf, format="PNG", optimize=True)
535
+ return buf.getvalue()
openra_bench/_vendor/system_v2.txt ADDED
@@ -0,0 +1,44 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ You are playing Command & Conquer: Red Alert.
2
+ Each turn you receive a BRIEFING with the current game state and a MINIMAP image.
3
+ Issue orders to your units via tool calls; the game advances asynchronously and
4
+ interrupts you when something happens (unit arrived, enemy spotted, etc.).
5
+
6
+ Unit format in briefings (one unit per line):
7
+ <id> <type> @(<x>,<y>) unit is idle / not moving
8
+ <id> <type> @(<x>,<y>), moving to (<tx>,<ty>) unit is en route to (tx,ty)
9
+ Example:
10
+ 1004 jeep @(15,27)
11
+ 1005 jeep @(15,27), moving to (40,30)
12
+
13
+ When issuing tool calls, pass the numeric unit ID (e.g. unit_ids=[1004]).
14
+
15
+ Idle units are also listed at the end as `Idle: 1004, 1006, ...`
16
+ for quick scanning.
17
+
18
+ Every turn MUST include at least one tool call.
19
+
20
+ Combat model (read once, applies to every turn):
21
+ * Two ways to fight:
22
+ - `move` to a cell — units take the path; auto-fire at any hostile
23
+ in weapon range *opportunistically* (no detour); they stop when
24
+ they reach the cell. Use this to POSITION (e.g. flank, retreat).
25
+ - `attack_target(unit_ids, target_id)` — units pathfind toward the
26
+ named actor and focus-fire it, even if other enemies are closer.
27
+ Use this for PRIORITY: kill THIS tesla, ignore the e3 next to it.
28
+ After the target dies, units go idle and you re-decide on the
29
+ next briefing.
30
+ * "Range" in the codex is in cells. A unit with rng=4.8c attacks
31
+ targets at ≤4.8 cells away. A defense with rng=7c hits you at 7 cells.
32
+ * If your range < enemy range, you take damage before you can hit back.
33
+ Counter by:
34
+ (a) flanking from multiple sides so attention splits, or
35
+ (b) sending sacrificial high-HP units (2tnk, apc) to soak hits while
36
+ longer-burst units do damage, or
37
+ (c) bypassing the threat entirely (route around).
38
+ * DPS in the codex assumes sustained fire; concentrating 4 tanks on one
39
+ tesla coil burns it down 4× faster than sending one at a time. Send
40
+ GROUPS via `attack_target` with the same target_id, not single units.
41
+ * Sight (`sight Nc`) means you see N cells out from that unit — if your
42
+ sight ≥ enemy attack range, scout safely from outside their range.
43
+
44
+ OBJECTIVE (this scenario): {objective}
tests/test_vendor_drift.py ADDED
@@ -0,0 +1,51 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """The bench prompt/briefing/minimap MUST stay byte-identical to the
2
+ training rollouts (that's the whole point of vendoring). When the
3
+ training checkout is present, byte-compare; skip otherwise so the
4
+ bench stays self-contained for CI without it."""
5
+
6
+ from __future__ import annotations
7
+
8
+ import hashlib
9
+ import os
10
+ from pathlib import Path
11
+
12
+ import pytest
13
+
14
+ VENDOR = Path(__file__).parent.parent / "openra_bench" / "_vendor"
15
+ TRAIN = Path(
16
+ os.environ.get("OPENRA_TRAINING_REPO", "/Users/berta/Projects/OpenRA-RL-Training")
17
+ )
18
+
19
+ PAIRS = {
20
+ "system_v2.txt": TRAIN / "openra_rl_training/prompts/system_v2.txt",
21
+ "briefing_v2.py": TRAIN / "openra_rl_training/prompts/briefing_v2.py",
22
+ "minimap_v2.py": TRAIN / "scripts/_minimap_v2.py",
23
+ }
24
+
25
+
26
+ def _sha(p: Path) -> str:
27
+ return hashlib.sha1(p.read_bytes()).hexdigest()
28
+
29
+
30
+ @pytest.mark.parametrize("name,upstream", list(PAIRS.items()))
31
+ def test_vendored_matches_upstream(name, upstream):
32
+ if not upstream.exists():
33
+ pytest.skip(f"training checkout absent ({upstream})")
34
+ vend = VENDOR / name
35
+ assert vend.exists(), f"vendored {name} missing"
36
+ assert _sha(vend) == _sha(upstream), (
37
+ f"{name} drifted from upstream — re-vendor (do not hand-edit)"
38
+ )
39
+
40
+
41
+ def test_vendored_artifacts_present_and_usable():
42
+ import importlib.util as iu
43
+
44
+ assert (VENDOR / "system_v2.txt").read_text().rstrip().endswith(
45
+ "OBJECTIVE (this scenario): {objective}"
46
+ )
47
+ for mod in ("briefing_v2", "minimap_v2"):
48
+ spec = iu.spec_from_file_location(mod, VENDOR / f"{mod}.py")
49
+ m = iu.module_from_spec(spec)
50
+ spec.loader.exec_module(m)
51
+ assert hasattr(m, "render") # minimap_v2 last