yxc20098 commited on
Commit
680e8db
·
1 Parent(s): 9063f2b

Persist human Play-tab runs in the standard Playback format

Browse files

Addresses PR #13's concern — saving human-play data for research — the
apples-to-apples way: a human run now produces the IDENTICAL artifact a
model run does, instead of a separate parallel format.

InteractiveSession (the Play tab's turn-stepped session) now emits a
standard Playback dir under the same PLAYBACK_ROOT model runs use:
turns.jsonl per-turn commands / signals / goal
minimap_turnNN.png per-turn frame (same prompt_v2 renderer as models)
messages.json per-turn transcript ({role,content,tool_calls})
manifest.json same shape run_level writes, tagged model=Human /
agent_type=Human
So the Battle Viewer (incl. its compare mode) and the leaderboard
treat human and LLM runs uniformly — human-vs-LLM comparison for free,
no separate schema to reconcile.

- record=True by default; record=False / playback_root= for tests.
- Abandoned sessions still finalize (viewable); never-played sessions
drop their empty dir.
- status() surfaces save_path; the Play tab shows it on game-over.

NOT taken from PR #13: the separate data/human_runs/ JSON format, the
per-session auto-append to results.csv, the data/results.csv reset, the
schema.py stub fallback, and the openra_rl_training shim stubs —
superseded by the proper vendoring already on main.

tests/test_human_labeling.py: +1 test asserting the standard Playback
layout + Human-tagged manifest; 25 green.

app.py CHANGED
@@ -1167,6 +1167,11 @@ def _play_status_md(sess) -> str:
1167
  line = f"**Turn {st['turn']}/{st['max_turns']}** · tick {st['tick']}"
1168
  if st["done"]:
1169
  line += f" · **{st['outcome'].upper()}** — game over"
 
 
 
 
 
1170
  return line
1171
 
1172
 
 
1167
  line = f"**Turn {st['turn']}/{st['max_turns']}** · tick {st['tick']}"
1168
  if st["done"]:
1169
  line += f" · **{st['outcome'].upper()}** — game over"
1170
+ if st.get("save_path"):
1171
+ line += (
1172
+ f"\n\n_Run saved (standard playback format): "
1173
+ f"`{st['save_path']}`_"
1174
+ )
1175
  return line
1176
 
1177
 
openra_bench/human_labeling.py CHANGED
@@ -468,7 +468,14 @@ class InteractiveSession:
468
  s.close()
469
  """
470
 
471
- def __init__(self, compiled: Any, seed: int = 1):
 
 
 
 
 
 
 
472
  from openra_rl_training.training.rust_env_pool import RustEnvPool
473
 
474
  from .eval_core import _scenario_to_tmp_yaml
@@ -492,6 +499,21 @@ class InteractiveSession:
492
  self.outcome = "draw"
493
  self.done = False
494
  self._closed = False
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
495
 
496
  # A live engine session is a resource handle, not a value. Gradio's
497
  # `gr.State` deep-copies whatever it holds — and the RustEnvPool
@@ -506,7 +528,13 @@ class InteractiveSession:
506
 
507
  @classmethod
508
  def from_pack(
509
- cls, pack_id: str, level: str = "easy", seed: int = 1
 
 
 
 
 
 
510
  ) -> "InteractiveSession":
511
  """Compile a pack by id and open a session on it."""
512
  from .scenarios import load_pack
@@ -515,7 +543,10 @@ class InteractiveSession:
515
  compiled = compile_level(
516
  load_pack(PACKS_DIR / f"{pack_id}.yaml"), level
517
  )
518
- return cls(compiled, seed=seed)
 
 
 
519
 
520
  @property
521
  def Command(self) -> Any:
@@ -545,8 +576,155 @@ class InteractiveSession:
545
  "outcome": self.outcome,
546
  "done": self.done,
547
  "tick": self._adapter.signals.game_tick,
 
548
  }
549
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
550
  def submit_turn(self, actions: "list[HumanAction]") -> dict:
551
  """Advance one decision turn with the human's gestures. Mirrors
552
  `run_level`'s per-turn body. Returns the updated `status()`."""
@@ -592,6 +770,10 @@ class InteractiveSession:
592
  or self.turn >= self.max_turns
593
  ):
594
  self.done = True
 
 
 
 
595
  return self.status()
596
 
597
  def close(self) -> None:
@@ -599,6 +781,34 @@ class InteractiveSession:
599
  if self._closed:
600
  return
601
  self._closed = True
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
602
  try:
603
  self._pool.release(self._env)
604
  self._pool.shutdown()
 
468
  s.close()
469
  """
470
 
471
+ def __init__(
472
+ self,
473
+ compiled: Any,
474
+ seed: int = 1,
475
+ record: bool = True,
476
+ playback_root: Any = None,
477
+ player: str = "Human",
478
+ ):
479
  from openra_rl_training.training.rust_env_pool import RustEnvPool
480
 
481
  from .eval_core import _scenario_to_tmp_yaml
 
499
  self.outcome = "draw"
500
  self.done = False
501
  self._closed = False
502
+ self.save_path: str | None = None
503
+ # Standard-playback recording: a human run produces the IDENTICAL
504
+ # artifact a model run does — a Playback dir (turns.jsonl,
505
+ # per-turn minimap PNGs, messages.json, manifest.json) under the
506
+ # same PLAYBACK_ROOT — so the Battle Viewer and leaderboard treat
507
+ # human and LLM runs apples-to-apples (no separate human format).
508
+ self._player = player
509
+ self._playback = None
510
+ self._finalized = False
511
+ self._history: list[dict] = []
512
+ self._issued = 0
513
+ self._pb_terrain = None
514
+ self._pb_explored: set = set()
515
+ if record:
516
+ self._setup_playback(playback_root)
517
 
518
  # A live engine session is a resource handle, not a value. Gradio's
519
  # `gr.State` deep-copies whatever it holds — and the RustEnvPool
 
528
 
529
  @classmethod
530
  def from_pack(
531
+ cls,
532
+ pack_id: str,
533
+ level: str = "easy",
534
+ seed: int = 1,
535
+ record: bool = True,
536
+ playback_root: Any = None,
537
+ player: str = "Human",
538
  ) -> "InteractiveSession":
539
  """Compile a pack by id and open a session on it."""
540
  from .scenarios import load_pack
 
543
  compiled = compile_level(
544
  load_pack(PACKS_DIR / f"{pack_id}.yaml"), level
545
  )
546
+ return cls(
547
+ compiled, seed=seed, record=record,
548
+ playback_root=playback_root, player=player,
549
+ )
550
 
551
  @property
552
  def Command(self) -> Any:
 
576
  "outcome": self.outcome,
577
  "done": self.done,
578
  "tick": self._adapter.signals.game_tick,
579
+ "save_path": self.save_path,
580
  }
581
 
582
+ # ── Standard-playback recording ──────────────────────────────────
583
+ def _setup_playback(self, playback_root: Any) -> None:
584
+ """Open a Playback dir — the same one model runs use."""
585
+ try:
586
+ import os
587
+ from datetime import datetime, timezone
588
+ from pathlib import Path
589
+
590
+ from .playback import Playback
591
+
592
+ root = Path(
593
+ playback_root
594
+ or os.environ.get(
595
+ "OPENRA_BENCH_PLAYBACK_ROOT",
596
+ Path(__file__).resolve().parents[1] / "playback",
597
+ )
598
+ )
599
+ run_id = "human-" + datetime.now(timezone.utc).strftime(
600
+ "%Y%m%dT%H%M%SZ"
601
+ )
602
+ safe = "".join(
603
+ c if (c.isalnum() or c in "-_") else "-"
604
+ for c in self._player
605
+ )
606
+ pb = Playback(
607
+ root / f"{run_id}__{safe}",
608
+ cell=f"{self.compiled.pack_id}:{self.compiled.level}",
609
+ seed=self.seed,
610
+ )
611
+ pb.run_id, pb.model = run_id, self._player
612
+ self._playback = pb
613
+ self._history = [{
614
+ "role": "system",
615
+ "content": (
616
+ f"Human-labeling session — {self.compiled.pack_id}:"
617
+ f"{self.compiled.level} seed {self.seed}. Objective: "
618
+ f"{self.objective or '(see briefing)'}"
619
+ ),
620
+ }]
621
+ except Exception: # noqa: BLE001 — recording never breaks play
622
+ self._playback = None
623
+
624
+ def _pb_frame(self, rs: dict) -> "str | None":
625
+ """Per-turn minimap PNG (b64) via the SAME renderer model
626
+ playback uses, so saved human frames match model frames."""
627
+ try:
628
+ from .minimap import terrain_png_for
629
+
630
+ if self._pb_terrain is None:
631
+ self._pb_terrain = terrain_png_for(
632
+ self.compiled.scenario.base_map
633
+ )
634
+ from .prompt_v2 import minimap_b64 as _v2_mm
635
+
636
+ png = _v2_mm(
637
+ rs, self._pb_terrain, self._pb_explored,
638
+ constant_colors=self.compiled.level in ("easy", "medium"),
639
+ )
640
+ if png is None:
641
+ from .agent import _render_minimap_b64
642
+
643
+ png = _render_minimap_b64(rs, self._pb_terrain)
644
+ return png
645
+ except Exception: # noqa: BLE001
646
+ return None
647
+
648
+ def _record_turn_playback(self, actions, calls, cmds, ctx) -> None:
649
+ """Append one turn to the Playback + the message transcript."""
650
+ if self._playback is None:
651
+ return
652
+ from .goal_tracker import turn_goal
653
+
654
+ rs = self._adapter.render_state()
655
+ self._issued += len(cmds)
656
+ self._playback.record_turn(
657
+ self.turn, rs, cmds, self._adapter.signals,
658
+ self._pb_frame(rs),
659
+ goal=turn_goal(self.compiled.win_condition, ctx),
660
+ )
661
+ # Transcript turn — same {role, content, tool_calls} shape a
662
+ # ModelAgent writes, so messages.json is uniform across human
663
+ # and LLM runs.
664
+ self._history.append(
665
+ {"role": "user", "content": HumanController._briefing(rs)}
666
+ )
667
+ self._history.append({
668
+ "role": "assistant",
669
+ "content": "; ".join(a.describe() for a in actions) or "observe",
670
+ "reasoning": "; ".join(a.note for a in actions if a.note),
671
+ "tool_calls": [
672
+ {
673
+ "id": f"h{i}", "type": "function",
674
+ "function": {
675
+ "name": c["name"], "arguments": c["arguments"],
676
+ },
677
+ }
678
+ for i, c in enumerate(calls)
679
+ ],
680
+ })
681
+ for i in range(len(calls)):
682
+ self._history.append(
683
+ {"role": "tool", "tool_call_id": f"h{i}", "content": "ok"}
684
+ )
685
+
686
+ def _finalize_playback(self, ctx) -> None:
687
+ """Write messages.json + manifest.json — the same manifest shape
688
+ `run_level` writes for a model run, tagged as a human run."""
689
+ if self._playback is None or self._finalized:
690
+ return
691
+ self._finalized = True
692
+ from .goal_tracker import turn_goal
693
+
694
+ final_goal = turn_goal(self.compiled.win_condition, ctx)
695
+ sig = self._adapter.signals
696
+ try:
697
+ self._playback.write_messages(self._history)
698
+ self._playback.finalize({
699
+ "scenario": f"{self.compiled.pack_id}:{self.compiled.level}",
700
+ "pack_id": self.compiled.pack_id,
701
+ "level": self.compiled.level,
702
+ "capability": self.compiled.meta.capability,
703
+ "run_id": self._playback.run_id,
704
+ "model": self._playback.model,
705
+ "agent_type": "Human",
706
+ "seed": self.seed,
707
+ "outcome": self.outcome,
708
+ "turns": self.turn,
709
+ "max_turns": self.max_turns,
710
+ "actions_issued": self._issued,
711
+ "actions_warned": 0,
712
+ "agent_stats": {"turns": self.turn},
713
+ "objective_progress": final_goal.get(
714
+ "objective_progress", 0.0
715
+ ),
716
+ "reward_vector": final_goal.get("reward_vector", {}),
717
+ "signals": {
718
+ "economy_value": sig.cash + sig.resources,
719
+ "explored_percent": round(sig.explored_percent, 2),
720
+ "units_killed": sig.units_killed,
721
+ "units_lost": sig.units_lost,
722
+ },
723
+ })
724
+ self.save_path = str(self._playback.dir)
725
+ except Exception: # noqa: BLE001
726
+ pass
727
+
728
  def submit_turn(self, actions: "list[HumanAction]") -> dict:
729
  """Advance one decision turn with the human's gestures. Mirrors
730
  `run_level`'s per-turn body. Returns the updated `status()`."""
 
770
  or self.turn >= self.max_turns
771
  ):
772
  self.done = True
773
+ # Standard-playback recording — log this turn, finalize on done.
774
+ self._record_turn_playback(actions or [], calls, cmds, ctx)
775
+ if self.done:
776
+ self._finalize_playback(ctx)
777
  return self.status()
778
 
779
  def close(self) -> None:
 
781
  if self._closed:
782
  return
783
  self._closed = True
784
+ # An abandoned run (closed before game-over) is still finalized
785
+ # so its Playback dir carries a manifest and stays viewable.
786
+ if (
787
+ self._playback is not None
788
+ and not self._finalized
789
+ and self.turn > 0
790
+ ):
791
+ try:
792
+ from .scenarios.win_conditions import WinContext
793
+
794
+ self._finalize_playback(
795
+ WinContext(
796
+ signals=self._adapter.signals,
797
+ render_state=self._adapter.render_state(),
798
+ )
799
+ )
800
+ except Exception: # noqa: BLE001
801
+ pass
802
+ elif self._playback is not None and self.turn == 0:
803
+ # Never-played recorded session — drop the empty dir rather
804
+ # than littering the playback root.
805
+ import shutil
806
+
807
+ try:
808
+ self._playback._turns_fh.close()
809
+ except Exception: # noqa: BLE001
810
+ pass
811
+ shutil.rmtree(self._playback.dir, ignore_errors=True)
812
  try:
813
  self._pool.release(self._env)
814
  self._pool.shutdown()
tests/test_human_labeling.py CHANGED
@@ -339,7 +339,7 @@ def test_interactive_session_steps_turn_by_turn():
339
  compiled = _smallest_easy_pack()
340
  assert compiled is not None
341
 
342
- sess = InteractiveSession(compiled, seed=1)
343
  try:
344
  # The observation is a real render_state — what the LLM sees.
345
  rs = sess.render_state()
@@ -372,7 +372,9 @@ def test_interactive_session_from_pack():
372
  from openra_bench.human_labeling import InteractiveSession
373
 
374
  compiled = _smallest_easy_pack()
375
- sess = InteractiveSession.from_pack(compiled.pack_id, "easy", seed=2)
 
 
376
  try:
377
  assert sess.seed == 2
378
  assert sess.Command is not None
@@ -387,3 +389,51 @@ def test_interactive_session_from_pack():
387
  assert sess.turn == 1
388
  finally:
389
  sess.close()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
339
  compiled = _smallest_easy_pack()
340
  assert compiled is not None
341
 
342
+ sess = InteractiveSession(compiled, seed=1, record=False)
343
  try:
344
  # The observation is a real render_state — what the LLM sees.
345
  rs = sess.render_state()
 
372
  from openra_bench.human_labeling import InteractiveSession
373
 
374
  compiled = _smallest_easy_pack()
375
+ sess = InteractiveSession.from_pack(
376
+ compiled.pack_id, "easy", seed=2, record=False
377
+ )
378
  try:
379
  assert sess.seed == 2
380
  assert sess.Command is not None
 
389
  assert sess.turn == 1
390
  finally:
391
  sess.close()
392
+
393
+
394
+ def test_interactive_session_emits_standard_playback(tmp_path):
395
+ """A recorded human session produces the SAME Playback artifact a
396
+ model run does — turns.jsonl + per-turn minimap PNGs + messages.json
397
+ + a manifest — so the Battle Viewer / leaderboard treat human and
398
+ LLM runs apples-to-apples."""
399
+ import json
400
+ from pathlib import Path
401
+
402
+ from openra_bench.human_labeling import InteractiveSession
403
+
404
+ compiled = _smallest_easy_pack()
405
+ assert compiled is not None
406
+
407
+ sess = InteractiveSession(
408
+ compiled, seed=1, record=True, playback_root=tmp_path,
409
+ player="Human",
410
+ )
411
+ try:
412
+ guard = compiled.max_turns + 5
413
+ steps = 0
414
+ while not sess.done and steps < guard:
415
+ sess.submit_turn([HumanAction(mode="observe")])
416
+ steps += 1
417
+ assert sess.done
418
+ save = sess.status()["save_path"]
419
+ assert save, "a finished recorded session must report save_path"
420
+ run_dir = Path(save)
421
+ # Standard Playback layout — identical to a model run's.
422
+ assert (run_dir / "turns.jsonl").is_file()
423
+ assert (run_dir / "messages.json").is_file()
424
+ manifest = json.loads((run_dir / "manifest.json").read_text())
425
+ assert manifest["model"] == "Human"
426
+ assert manifest["agent_type"] == "Human"
427
+ assert manifest["pack_id"] == compiled.pack_id
428
+ assert manifest["outcome"] in ("win", "loss", "draw")
429
+ assert manifest["turns"] == sess.turn
430
+ # Per-turn minimap PNG frames were written.
431
+ assert list(run_dir.glob("minimap_turn*.png"))
432
+ # turns.jsonl has one record per turn.
433
+ lines = [
434
+ ln for ln in (run_dir / "turns.jsonl").read_text().splitlines()
435
+ if ln.strip()
436
+ ]
437
+ assert len(lines) == sess.turn
438
+ finally:
439
+ sess.close()