OpenRA-Bench / openra_bench /human_labeling.py
yxc20098's picture
Land PR #15 surgical fixes (Windows safety + human-play hints)
a04d20a
Raw
History Blame Contribute Delete
38 kB
"""Phase 2 โ€” the human-labeling machine.
Lets a human play the exact scenarios the LLM plays, for a direct
human-vs-LLM comparison on one leaderboard. Design constraints
(user-confirmed):
* the human gets the **same observation the LLM gets** (the rendered
minimap + the text briefing) โ€” the UI just displays what
`render_state` already carries;
* the cadence is **turn-based, matching the LLM** โ€” one `act()` call is
one human decision turn;
* the human interacts by **clicking the minimap**, not typing world
coordinates โ€” the UI runs `minimap_click_to_cell` to recover the
world cell, then assembles `HumanAction`s.
Everything here is headless-testable; a browser/terminal UI is a thin
shell over these pieces:
1. `minimap_click_to_cell` / `cell_to_minimap_rect` โ€” the pure
pixelโ‡„cell transforms (renderer-agnostic: parameterised by the
rendered image size).
2. `own_units_at_cell` / `enemy_at_cell` โ€” click-to-selection and
click-to-target resolution against `render_state`.
3. `HumanAction` + `human_actions_to_commands` โ€” a human's per-turn
point-and-click gestures, translated into engine `Command`s by
delegating to the **same** `agent._to_commands` the LLM path uses,
so human and model emit byte-identical commands.
4. `HumanController` โ€” a Controller (Phase 1 contract) whose `act()`
pulls the turn's `HumanAction`s from an injected input source and
records a playback-compatible transcript. The episode then runs
through `run_level` identically to a model run, so the trace, the
playback, and the leaderboard entry are directly comparable.
"""
from __future__ import annotations
from dataclasses import dataclass, field
from typing import Any, Callable, Sequence
from .controller import BaseController, EpisodeContext
# An input source yields the human's gestures for one decision turn.
# Either a callable `(render_state) -> [HumanAction]` (the real UI: a
# blocking browser/terminal round-trip) or a pre-scripted list of turns
# (tests, or replay of a recorded session).
TurnActions = "list[HumanAction]"
InputSource = "Callable[[dict], list[HumanAction]] | Sequence[list[HumanAction]]"
# โ”€โ”€ Scenario hints for human play โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
_REGION_PREDICATES = {
"reach_region",
"units_in_region_gte",
"units_of_type_in_region_gte",
"all_units_in_region",
"building_in_region",
"enemy_key_buildings_destroyed_in_region",
}
def _condition_node(cond: Any) -> dict:
if cond is None:
return {}
if isinstance(cond, dict):
return cond
return dict(getattr(cond, "__pydantic_extra__", None) or {})
def _objective_regions_from_condition(cond: Any) -> list[dict]:
"""Extract authored objective regions from a win-condition tree."""
out: list[dict] = []
def walk(node: Any) -> None:
data = _condition_node(node)
if not data:
return
for child in data.get("all_of") or []:
walk(child)
for child in data.get("any_of") or []:
walk(child)
if "not" in data:
walk(data["not"])
then = data.get("then")
if isinstance(then, dict):
for child in then.get("clauses") or []:
walk(child)
seq = data.get("waypoint_sequence")
if isinstance(seq, dict):
default_r = seq.get("radius", 6)
for i, point in enumerate(seq.get("points") or [], start=1):
if not isinstance(point, dict):
continue
if "x" in point and "y" in point:
out.append({
"x": int(point["x"]),
"y": int(point["y"]),
"radius": float(point.get("radius", default_r)),
"label": str(point.get("label") or f"W{i}"),
})
for key in _REGION_PREDICATES:
v = data.get(key)
if not isinstance(v, dict) or "x" not in v or "y" not in v:
continue
label = v.get("label")
if not label:
if key == "units_in_region_gte":
label = f">={int(v.get('n', 1))} units"
elif key == "units_of_type_in_region_gte":
label = f">={int(v.get('n', 1))} {v.get('type')}"
elif key == "building_in_region":
label = str(v.get("type") or "building")
else:
label = key.replace("_", " ")
out.append({
"x": int(v["x"]),
"y": int(v["y"]),
"radius": float(v.get("radius", 3)),
"label": str(label),
})
walk(cond)
seen = set()
unique = []
for r in out:
key = (r["x"], r["y"], r["radius"], r["label"])
if key in seen:
continue
seen.add(key)
unique.append(r)
return unique
def _initial_type_by_id(scenario: Any, render_state: dict) -> dict[str, str]:
"""Infer own unit types from authored initial placements.
Rust currently omits own-unit actor types in some observations, but
it preserves deterministic actor ids and initial cells. This maps the
initial visible units back to the scenario placements so the human UI
can distinguish 1tnk/2tnk/etc. after the units move.
"""
expected: dict[tuple[int, int], list[str]] = {}
for actor in getattr(scenario, "actors", []) or []:
if str(getattr(actor, "owner", "")).lower() != "agent":
continue
pos = getattr(actor, "position", None)
if not pos or len(pos) < 2:
continue
cell = (int(pos[0]), int(pos[1]))
atype = str(getattr(actor, "type", "") or "").lower()
count = int(getattr(actor, "count", 1) or 1)
expected.setdefault(cell, []).extend([atype] * count)
observed: dict[tuple[int, int], list[str]] = {}
for unit in render_state.get("units_summary") or []:
if not isinstance(unit, dict) or unit.get("id") is None:
continue
cell = (int(unit.get("cell_x", 0)), int(unit.get("cell_y", 0)))
observed.setdefault(cell, []).append(str(unit["id"]))
out: dict[str, str] = {}
for cell, types in expected.items():
ids = sorted(observed.get(cell, []))
for uid, atype in zip(ids, types):
if atype:
out[uid] = atype
return out
# โ”€โ”€ Pixel โ‡„ cell transforms โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
def minimap_click_to_cell(
px: float,
py: float,
img_w: int,
img_h: int,
map_cols: int,
map_rows: int,
) -> tuple[int, int]:
"""Map a click at pixel ``(px, py)`` on a rendered minimap of size
``img_w ร— img_h`` back to a world cell on a ``map_cols ร— map_rows``
grid.
Renderer-agnostic: pass the *actual* rendered image dimensions, so
this works for either minimap renderer (`minimap.render_png_b64`
at CELL=6, or the vendored `prompt_v2.minimap_b64`). The result is
clamped to the playable grid โ€” an out-of-bounds click resolves to
the nearest edge cell rather than raising."""
if img_w <= 0 or img_h <= 0 or map_cols <= 0 or map_rows <= 0:
raise ValueError("image and map dimensions must be positive")
cx = int(px * map_cols / img_w)
cy = int(py * map_rows / img_h)
cx = min(map_cols - 1, max(0, cx))
cy = min(map_rows - 1, max(0, cy))
return cx, cy
def cell_to_minimap_rect(
cx: int,
cy: int,
img_w: int,
img_h: int,
map_cols: int,
map_rows: int,
) -> tuple[int, int, int, int]:
"""Inverse of `minimap_click_to_cell`: the pixel rectangle
``(left, top, width, height)`` a world cell occupies on the
rendered minimap. The UI uses it to draw selection highlights."""
if img_w <= 0 or img_h <= 0 or map_cols <= 0 or map_rows <= 0:
raise ValueError("image and map dimensions must be positive")
left = int(cx * img_w / map_cols)
top = int(cy * img_h / map_rows)
right = int((cx + 1) * img_w / map_cols)
bottom = int((cy + 1) * img_h / map_rows)
return left, top, max(1, right - left), max(1, bottom - top)
# โ”€โ”€ Click โ†’ selection / target resolution โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
def _cells_near(cx: int, cy: int, radius: int):
for yy in range(cy - radius, cy + radius + 1):
for xx in range(cx - radius, cx + radius + 1):
yield xx, yy
def own_units_at_cell(
render_state: dict, cx: int, cy: int, radius: int = 1
) -> list[str]:
"""Ids of the agent's own units within `radius` cells of (cx, cy) โ€”
click-to-select. Buildings are excluded (a click on a building is a
building selection; see `own_buildings_at_cell`)."""
near = set(_cells_near(cx, cy, radius))
out: list[str] = []
for u in render_state.get("units_summary", []) or []:
if not isinstance(u, dict):
continue
if (int(u.get("cell_x", -99)), int(u.get("cell_y", -99))) in near:
uid = u.get("id")
if uid is not None:
out.append(str(uid))
return out
def own_buildings_at_cell(
render_state: dict, cx: int, cy: int, radius: int = 1
) -> list[str]:
"""Ids of the agent's own buildings within `radius` cells of
(cx, cy) โ€” click-to-select for repair / sell / power_down."""
near = set(_cells_near(cx, cy, radius))
out: list[str] = []
for b in render_state.get("own_buildings", []) or []:
if not isinstance(b, dict):
continue
if (int(b.get("cell_x", -99)), int(b.get("cell_y", -99))) in near:
bid = b.get("id")
if bid is not None:
out.append(str(bid))
return out
def enemy_at_cell(
render_state: dict, cx: int, cy: int, radius: int = 1
) -> str | None:
"""Id of the nearest visible enemy actor within `radius` cells of
(cx, cy), or None โ€” click-to-target for `attack`. Picks the closest
so a click between two enemies is unambiguous."""
best: tuple[int, str] | None = None
for e in render_state.get("enemy_summary", []) or []:
if not isinstance(e, dict):
continue
ex, ey = int(e.get("cell_x", -99)), int(e.get("cell_y", -99))
d2 = (ex - cx) ** 2 + (ey - cy) ** 2
if d2 <= radius * radius * 2:
eid = e.get("id")
if eid is not None and (best is None or d2 < best[0]):
best = (d2, str(eid))
return best[1] if best else None
# โ”€โ”€ Human action โ†’ engine Command โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
# The gesture vocabulary. Each gesture maps onto exactly one LLM tool
# name so the human path reuses `agent._to_commands` verbatim. The
# `move` gesture is the only rename โ€” the engine tool is `move_units`.
_TARGETED_MOVE = {
"move": "move_units",
"attack_move": "attack_move",
"harvest": "harvest",
"set_rally_point": "set_rally_point",
}
_UNIT_ONLY = {
"stop", "deploy", "sell", "repair", "power_down",
"set_primary", "unload", "patrol",
}
@dataclass
class HumanAction:
"""One point-and-click gesture from the human, already resolved to
world-cell coordinates (the UI ran `minimap_click_to_cell` first).
`mode` is the gesture; the other fields are filled per gesture:
* ``move`` / ``attack_move`` / ``harvest`` / ``set_rally_point`` โ€”
`units` + `target` cell.
* ``attack`` โ€” `units` + either `target_id` (an enemy actor) or a
`target` cell (falls back to `attack_move`).
* ``guard`` โ€” `units` + `target_id` (ally to escort).
* ``stop`` / ``deploy`` / ``sell`` / ``repair`` / ``power_down`` /
``set_primary`` / ``unload`` / ``patrol`` โ€” `units` only.
* ``set_stance`` โ€” `units` + `stance` (0โ€“3).
* ``build`` / ``cancel_production`` โ€” `unit_type`.
* ``place_building`` โ€” `unit_type` + `target` cell.
* ``observe`` / ``surrender`` โ€” no payload (pass-turn / concede).
"""
mode: str
units: list[str] = field(default_factory=list)
target: tuple[int, int] | None = None
target_id: str | None = None
unit_type: str = ""
stance: int = 0
note: str = "" # optional free-text rationale (playback only)
raw: dict = field(default_factory=dict) # original click payload
def to_tool_call(self) -> dict | None:
"""Normalise into the ``{name, arguments}`` tool-call dict that
`agent._to_commands` consumes. Returns None for a gesture that
cannot form a valid command (dropped silently, like a malformed
LLM tool call)."""
m = self.mode
if m == "observe":
return {"name": "observe", "arguments": {}}
if m == "surrender":
return {"name": "surrender", "arguments": {}}
if m == "attack":
if self.target_id is not None:
return {
"name": "attack_unit",
"arguments": {
"unit_ids": list(self.units),
"target_id": str(self.target_id),
},
}
# No enemy under the click โ†’ close on the cell instead.
if self.target is not None and self.units:
return {
"name": "attack_move",
"arguments": {
"unit_ids": list(self.units),
"target_x": int(self.target[0]),
"target_y": int(self.target[1]),
},
}
return None
if m == "guard":
if not self.units or self.target_id is None:
return None
return {
"name": "guard",
"arguments": {
"unit_ids": list(self.units),
"target_id": str(self.target_id),
},
}
if m in _TARGETED_MOVE:
if not self.units or self.target is None:
return None
return {
"name": _TARGETED_MOVE[m],
"arguments": {
"unit_ids": list(self.units),
"target_x": int(self.target[0]),
"target_y": int(self.target[1]),
},
}
if m == "set_stance":
if not self.units:
return None
return {
"name": "set_stance",
"arguments": {
"unit_ids": list(self.units),
"stance": int(self.stance),
},
}
if m in _UNIT_ONLY:
if not self.units:
return None
return {"name": m, "arguments": {"unit_ids": list(self.units)}}
if m in ("build", "cancel_production"):
if not self.unit_type:
return None
return {"name": m, "arguments": {"item": str(self.unit_type)}}
if m == "place_building":
if not self.unit_type or self.target is None:
return None
return {
"name": "place_building",
"arguments": {
"item": str(self.unit_type),
"target_x": int(self.target[0]),
"target_y": int(self.target[1]),
},
}
return None # unknown gesture โ€” dropped
def describe(self) -> str:
"""One-line human-readable summary for the playback transcript."""
if self.mode in ("observe", "surrender"):
return self.mode
bits = [self.mode]
if self.units:
bits.append(f"units={','.join(self.units)}")
if self.target_id is not None:
bits.append(f"target={self.target_id}")
if self.target is not None:
bits.append(f"@{self.target[0]},{self.target[1]}")
if self.unit_type:
bits.append(f"item={self.unit_type}")
if self.mode == "set_stance":
bits.append(f"stance={self.stance}")
return " ".join(bits)
def human_actions_to_commands(
actions: Sequence[HumanAction], Command: Any
) -> list:
"""Translate a turn's `HumanAction`s into engine `Command`s by
delegating to `agent._to_commands` โ€” the exact translator the LLM
path uses โ€” so a human and a model emit byte-identical commands."""
from .agent import _to_commands
calls = [tc for a in actions if (tc := a.to_tool_call()) is not None]
return _to_commands(calls, Command)
# โ”€โ”€ Input sources โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
class ScriptedInputSource:
"""A deterministic, pre-recorded input source โ€” one entry per turn.
Drives `HumanController` headlessly in tests, and replays a saved
human session. When the script is exhausted every further turn
yields a single `observe` (pass-turn)."""
def __init__(self, turns: Sequence[Sequence[HumanAction]]):
self._turns = [list(t) for t in turns]
self._i = 0
def __call__(self, render_state: dict) -> list[HumanAction]:
if self._i >= len(self._turns):
return [HumanAction(mode="observe")]
turn = self._turns[self._i]
self._i += 1
return list(turn)
# โ”€โ”€ The Controller โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
class HumanController(BaseController):
"""A Controller (Phase 1 contract) driven by a human.
`act()` pulls the turn's `HumanAction`s from `input_source` and
translates them into engine `Command`s. It also records a
playback-compatible transcript into `self.history` (the same
`{role, content, tool_calls}` shape `ModelAgent` writes), so a human
run renders in the existing battle viewer beside model runs and the
leaderboard entry is directly comparable.
`input_source` is either a callable ``(render_state) -> [HumanAction]``
โ€” the real UI's blocking browser/terminal round-trip โ€” or a
sequence of per-turn action lists (a `ScriptedInputSource`, a plain
list, or a saved replay)."""
def __init__(
self,
input_source: "InputSource",
name: str = "human",
):
super().__init__(name=name)
if callable(input_source):
self._source = input_source
else:
self._source = ScriptedInputSource(input_source)
self.stats = {"turns": 0, "tool_calls": 0, "empty_replies": 0}
self._ctx: EpisodeContext | None = None
def reset(self, ctx: EpisodeContext) -> None:
"""Per-episode hook: stamp context and seed the transcript with
a system message naming the scenario and objective."""
self._ctx = ctx
self.history = [
{
"role": "system",
"content": (
f"Human-labeling session โ€” scenario "
f"{ctx.pack_id}:{ctx.level} (seed {ctx.seed}). "
f"Objective: {ctx.objective or '(see briefing)'}"
),
}
]
self.stats = {"turns": 0, "tool_calls": 0, "empty_replies": 0}
@staticmethod
def _briefing(render_state: dict) -> str:
"""The SAME text briefing the LLM is shown (vendored
`prompt_v2.briefing`, with a defensive fallback)."""
try:
from .prompt_v2 import briefing as _v2_brief
return _v2_brief(render_state)
except Exception: # noqa: BLE001 โ€” never break a turn
return (
f"tick={render_state.get('game_tick', 0)} "
f"explored={render_state.get('explored_percent', 0.0):.1f}%"
)
def act(self, observation: dict, Command: Any) -> list:
self.stats["turns"] += 1
# The human sees exactly the LLM's observation.
self.history.append(
{"role": "user", "content": self._briefing(observation)}
)
actions = list(self._source(observation) or [])
calls = [
tc for a in actions if (tc := a.to_tool_call()) is not None
]
cmds = human_actions_to_commands(actions, Command)
self.stats["tool_calls"] += len(cmds)
if not cmds:
self.stats["empty_replies"] += 1
cmds = [Command.observe()]
# Playback-compatible assistant turn: a human-readable summary
# plus the structured tool_calls, mirroring ModelAgent.agent_fn.
notes = "; ".join(a.note for a in actions if a.note)
self.history.append(
{
"role": "assistant",
"content": "; ".join(a.describe() for a in actions)
or "observe",
"reasoning": notes,
"tool_calls": [
{
"id": f"h{i}",
"type": "function",
"function": {
"name": c["name"],
"arguments": c["arguments"],
},
}
for i, c in enumerate(calls)
],
}
)
for i in range(len(calls)):
self.history.append(
{"role": "tool", "tool_call_id": f"h{i}", "content": "ok"}
)
return cmds
# โ”€โ”€ Interactive (GUI-driven) session โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
class InteractiveSession:
"""A turn-steppable scenario session for GUI-driven human play.
`run_level` / `run_human_session` run the whole episode in one
synchronous call โ€” fine when the policy is a function, impossible
when the policy is a human at a browser. `InteractiveSession`
inverts the loop: the UI holds control and calls `submit_turn()`
once per decision turn, exactly mirroring `run_level`'s per-turn
body (command translation, forbidden-tool accounting, win/fail
evaluation), so a human's run is scored by the identical rules as
an LLM's.
Lifecycle:
s = InteractiveSession.from_pack("defense-rush-survive", "easy")
s.render_state() # what the human sees
s.submit_turn([HumanAction(...), ...]) # advance one turn
... # repeat until s.done
s.close()
"""
def __init__(
self,
compiled: Any,
seed: int = 1,
record: bool = True,
playback_root: Any = None,
player: str = "Human",
):
from openra_rl_training.training.rust_env_pool import RustEnvPool
from .eval_core import _scenario_to_tmp_yaml
from .rust_adapter import RustObsAdapter
if not compiled.map_supported:
raise RuntimeError(
f"{compiled.pack_id}: base map not Rust-loadable"
)
self.compiled = compiled
self.seed = seed
self._tmp_path = _scenario_to_tmp_yaml(compiled)
self._pool = RustEnvPool(size=1, scenario_path=self._tmp_path)
self._env = self._pool.acquire()
self._adapter = RustObsAdapter()
self._adapter.observe(self._env.reset(seed=seed))
self._adapter.type_by_id.update(
_initial_type_by_id(compiled.scenario, self._adapter.render_state())
)
self._objective_regions = (
_objective_regions_from_condition(compiled.win_condition)
if compiled.objective_coords == "exact" else []
)
self._forbidden = {
str(t).lower() for t in (compiled.forbidden_tools or [])
}
self.turn = 0
self.outcome = "draw"
self.done = False
self._closed = False
self.save_path: str | None = None
# Standard-playback recording: a human run produces the IDENTICAL
# artifact a model run does โ€” a Playback dir (turns.jsonl,
# per-turn minimap PNGs, messages.json, manifest.json) under the
# same PLAYBACK_ROOT โ€” so the Battle Viewer and leaderboard treat
# human and LLM runs apples-to-apples (no separate human format).
self._player = player
self._playback = None
self._finalized = False
self._history: list[dict] = []
self._issued = 0
self._pb_terrain = None
self._pb_explored: set = set()
if record:
self._setup_playback(playback_root)
# A live engine session is a resource handle, not a value. Gradio's
# `gr.State` deep-copies whatever it holds โ€” and the RustEnvPool
# carries an unpicklable `_thread.lock`, so a naive deepcopy raises
# and the Play tab's Start handler silently fails. Copying a session
# is meaningless anyway; return the same instance.
def __deepcopy__(self, memo):
return self
def __copy__(self):
return self
@classmethod
def from_pack(
cls,
pack_id: str,
level: str = "easy",
seed: int = 1,
record: bool = True,
playback_root: Any = None,
player: str = "Human",
fog_mode: str = "vision",
) -> "InteractiveSession":
"""Compile a pack by id and open a session on it. `fog_mode`
selects the observation modality โ€” `vision-clear` (or any
`-clear` mode) reveals the whole map (engine `reveal_map`), the
no-fog condition of the human-study fog-penalty pair."""
from .scenarios import load_pack
from .scenarios.loader import PACKS_DIR, compile_level
compiled = compile_level(
load_pack(PACKS_DIR / f"{pack_id}.yaml"), level
)
compiled.fog_mode = fog_mode
return cls(
compiled, seed=seed, record=record,
playback_root=playback_root, player=player,
)
@property
def Command(self) -> Any:
"""The engine Command factory."""
return self._env.Command
@property
def max_turns(self) -> int:
return self.compiled.max_turns
@property
def objective(self) -> str:
"""The scenario objective brief โ€” the SAME text an LLM agent is
given as its goal. What the human must accomplish to WIN."""
return (self.compiled.scenario.description or "").strip()
def render_state(self) -> dict:
"""The current observation โ€” the SAME render_state an LLM agent
is shown for this scenario."""
rs = self._adapter.render_state()
if self._objective_regions:
rs["objective_regions"] = list(self._objective_regions)
return rs
def status(self) -> dict:
"""Turn / outcome / done summary for the UI."""
return {
"turn": self.turn,
"max_turns": self.max_turns,
"outcome": self.outcome,
"done": self.done,
"tick": self._adapter.signals.game_tick,
"save_path": self.save_path,
}
# โ”€โ”€ Standard-playback recording โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
def _setup_playback(self, playback_root: Any) -> None:
"""Open a Playback dir โ€” the same one model runs use."""
try:
import os
from datetime import datetime, timezone
from pathlib import Path
from .playback import Playback
root = Path(
playback_root
or os.environ.get(
"OPENRA_BENCH_PLAYBACK_ROOT",
Path(__file__).resolve().parents[1] / "playback",
)
)
run_id = "human-" + datetime.now(timezone.utc).strftime(
"%Y%m%dT%H%M%SZ"
)
safe = "".join(
c if (c.isalnum() or c in "-_") else "-"
for c in self._player
)
pb = Playback(
root / f"{run_id}__{safe}",
cell=f"{self.compiled.pack_id}:{self.compiled.level}",
seed=self.seed,
)
pb.run_id, pb.model = run_id, self._player
self._playback = pb
self._history = [{
"role": "system",
"content": (
f"Human-labeling session โ€” {self.compiled.pack_id}:"
f"{self.compiled.level} seed {self.seed}. Objective: "
f"{self.objective or '(see briefing)'}"
),
}]
except Exception: # noqa: BLE001 โ€” recording never breaks play
self._playback = None
def _pb_frame(self, rs: dict) -> "str | None":
"""Per-turn minimap PNG (b64) via the SAME renderer model
playback uses, so saved human frames match model frames."""
try:
from .minimap import terrain_png_for
if self._pb_terrain is None:
self._pb_terrain = terrain_png_for(
self.compiled.scenario.base_map
)
png = None
try:
from .prompt_v2 import minimap_b64 as _v2_mm
png = _v2_mm(
rs, self._pb_terrain, self._pb_explored,
constant_colors=self.compiled.level in ("easy", "medium"),
)
except Exception: # noqa: BLE001
pass
if png is None:
from .minimap import render_b64
png = render_b64(rs, self._pb_terrain)
return png
except Exception: # noqa: BLE001
return None
def _record_turn_playback(self, actions, calls, cmds, ctx) -> None:
"""Append one turn to the Playback + the message transcript."""
if self._playback is None:
return
from .goal_tracker import turn_goal
rs = self._adapter.render_state()
self._issued += len(cmds)
self._playback.record_turn(
self.turn, rs, cmds, self._adapter.signals,
self._pb_frame(rs),
goal=turn_goal(self.compiled.win_condition, ctx),
)
# Transcript turn โ€” same {role, content, tool_calls} shape a
# ModelAgent writes, so messages.json is uniform across human
# and LLM runs.
self._history.append(
{"role": "user", "content": HumanController._briefing(rs)}
)
self._history.append({
"role": "assistant",
"content": "; ".join(a.describe() for a in actions) or "observe",
"reasoning": "; ".join(a.note for a in actions if a.note),
"tool_calls": [
{
"id": f"h{i}", "type": "function",
"function": {
"name": c["name"], "arguments": c["arguments"],
},
}
for i, c in enumerate(calls)
],
})
for i in range(len(calls)):
self._history.append(
{"role": "tool", "tool_call_id": f"h{i}", "content": "ok"}
)
def _finalize_playback(self, ctx) -> None:
"""Write messages.json + manifest.json โ€” the same manifest shape
`run_level` writes for a model run, tagged as a human run."""
if self._playback is None or self._finalized:
return
self._finalized = True
from .goal_tracker import turn_goal
final_goal = turn_goal(self.compiled.win_condition, ctx)
sig = self._adapter.signals
try:
self._playback.write_messages(self._history)
self._playback.finalize({
"scenario": f"{self.compiled.pack_id}:{self.compiled.level}",
"pack_id": self.compiled.pack_id,
"level": self.compiled.level,
"capability": self.compiled.meta.capability,
"run_id": self._playback.run_id,
"model": self._playback.model,
"agent_type": "Human",
"seed": self.seed,
"outcome": self.outcome,
"turns": self.turn,
"max_turns": self.max_turns,
"actions_issued": self._issued,
"actions_warned": 0,
"agent_stats": {"turns": self.turn},
"objective_progress": final_goal.get(
"objective_progress", 0.0
),
"reward_vector": final_goal.get("reward_vector", {}),
"signals": {
"economy_value": sig.cash + sig.resources,
"explored_percent": round(sig.explored_percent, 2),
"units_killed": sig.units_killed,
"units_lost": sig.units_lost,
},
})
self.save_path = str(self._playback.dir)
except Exception: # noqa: BLE001
pass
def submit_turn(self, actions: "list[HumanAction]") -> dict:
"""Advance one decision turn with the human's gestures. Mirrors
`run_level`'s per-turn body. Returns the updated `status()`."""
if self.done:
return self.status()
from .scenarios.win_conditions import WinContext, evaluate
Command = self._env.Command
calls = [
tc for a in (actions or []) if (tc := a.to_tool_call())
is not None
]
cmds = human_actions_to_commands(actions or [], Command)
# Forbidden-tool accounting โ€” identical rule to run_level.
for tc in calls:
tn = str(tc.get("name", "")).lower()
self._adapter.signals.tools_called[tn] = (
self._adapter.signals.tools_called.get(tn, 0) + 1
)
if tn in self._forbidden:
self._adapter.signals.tool_violations += 1
conceded = any(a.mode == "surrender" for a in (actions or []))
if not cmds:
cmds = [Command.observe()]
obs, _r, engine_done, _info = self._env.step(cmds)
self._adapter.observe(obs, done=engine_done)
self.turn += 1
ctx = WinContext(
signals=self._adapter.signals,
render_state=self._adapter.render_state(),
)
if evaluate(self.compiled.win_condition, ctx):
self.outcome = "win"
elif evaluate(self.compiled.fail_condition, ctx):
self.outcome = "loss"
if conceded:
self.outcome = "loss"
if (
self.outcome != "draw"
or engine_done
or self.turn >= self.max_turns
):
self.done = True
# Standard-playback recording โ€” log this turn, finalize on done.
self._record_turn_playback(actions or [], calls, cmds, ctx)
if self.done:
self._finalize_playback(ctx)
return self.status()
def close(self) -> None:
"""Release the engine env. Idempotent."""
if self._closed:
return
self._closed = True
# An abandoned run (closed before game-over) is still finalized
# so its Playback dir carries a manifest and stays viewable.
if (
self._playback is not None
and not self._finalized
and self.turn > 0
):
try:
from .scenarios.win_conditions import WinContext
self._finalize_playback(
WinContext(
signals=self._adapter.signals,
render_state=self._adapter.render_state(),
)
)
except Exception: # noqa: BLE001
pass
elif self._playback is not None and self.turn == 0:
# Never-played recorded session โ€” drop the empty dir rather
# than littering the playback root.
import shutil
try:
self._playback._turns_fh.close()
except Exception: # noqa: BLE001
pass
shutil.rmtree(self._playback.dir, ignore_errors=True)
try:
self._pool.release(self._env)
self._pool.shutdown()
finally:
from pathlib import Path
Path(self._tmp_path).unlink(missing_ok=True)
# โ”€โ”€ Session harness โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
def run_human_session(
pack_id: str,
level: str = "easy",
seed: int = 1,
input_source: "InputSource" = (),
playback: Any = None,
name: str = "human",
):
"""Run a full human-labeling session and return the scored
`EpisodeResult`.
Compiles the named pack/level and drives a `HumanController` through
`run_level` โ€” the IDENTICAL scoring path a model run takes โ€” so the
result, the per-turn playback, and the leaderboard entry are
directly comparable to an LLM's run on the same scenario.
`input_source` is a callable ``(render_state) -> [HumanAction]`` (the
real UI's blocking browser/terminal round-trip) or a pre-recorded
sequence of per-turn `HumanAction` lists (a replay)."""
from .eval_core import run_level
from .scenarios import load_pack
from .scenarios.loader import PACKS_DIR, compile_level
compiled = compile_level(
load_pack(PACKS_DIR / f"{pack_id}.yaml"), level
)
controller = HumanController(input_source, name=name)
return run_level(compiled, controller, seed=seed, playback=playback)