Spaces:
Running
Running
Vendor training prompt/briefing/minimap v2 (byte-identical)
Browse filesSo 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 +15 -0
- openra_bench/_vendor/__init__.py +0 -0
- openra_bench/_vendor/briefing_v2.py +149 -0
- openra_bench/_vendor/minimap_v2.py +535 -0
- openra_bench/_vendor/system_v2.txt +44 -0
- tests/test_vendor_drift.py +51 -0
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
|